diff options
82 files changed, 6059 insertions, 610 deletions
@@ -4,9 +4,11 @@ package **/glide.lock **/vendor *.zip - debug +webapp/dist +webapp/node_modules +**/npm*.log # private (prefixed by 2 underscores) directories or files __*/ -__*
\ No newline at end of file +__* diff --git a/.vscode/launch.json b/.vscode/launch.json index ed892d6..69944df 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,11 +12,12 @@ "env": { "GOPATH": "${workspaceRoot}/../../../..:${env:GOPATH}", "WORKSPACE_ROOT": "${workspaceRoot}", - "DEBUG_MODE": "1", + "XDS_DEBUG_MODE": "1", + "XDS_LOG_SILLY": "0", "ROOT_DIR": "${workspaceRoot}/../../../.." }, - "args": ["-log", "debug", "-c", "config.json.in"], + "args": ["-log", "debug", "-c", "__agent-config_local_dev.json"], "showLog": false } ] -}
\ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 1bc5381..eceb734 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,8 +8,19 @@ "vendor": true, "debug": true, "bin": true, - "tools": true + "tools": true, + "webapp/dist": true, + "webapp/node_modules": true }, + // Specify paths/files to ignore. (Supports Globs) + "cSpell.ignorePaths": [ + "**/node_modules/**", + "**/vscode-extension/**", + "**/.git/**", + "**/vendor/**", + ".vscode", + "typings" + ], // Words to add to dictionary for a workspace. "cSpell.words": [ "apiv", @@ -22,16 +33,27 @@ "socketio", "ldflags", "SThg", + "stconfig", "Intf", "dismissible", "rpath", "WSID", "sess", "IXDS", + "golib", + "xdsapi", "xdsconfig", "xdsserver", + "xdsagent", + "nbsp", "Inot", "inotify", - "cmdi" + "cmdi", + "sdkid", + "Flds", + "prjs", + "iosk", + "CIFS", + "IPROJECT" ] -}
\ No newline at end of file +} @@ -4,9 +4,8 @@ VERSION := 0.2.0 # Syncthing version to install -SYNCTHING_VERSION = 0.14.28 -SYNCTHING_INOTIFY_VERSION = 0.8.6 - +SYNCTHING_VERSION = 0.14.38 +SYNCTHING_INOTIFY_VERSION = 0.8.7 # Retrieve git tag/commit to set sub-version string @@ -24,13 +23,13 @@ ifeq ($(origin SUB_VERSION), undefined) endif endif -# for backward compatibility -DESTDIR := $(INSTALL_DIR) - -# Configurable variables for installation (default /opt/AGL/xds/agent) +# Configurable variables for installation (default /opt/AGL/...) ifeq ($(origin DESTDIR), undefined) DESTDIR := /opt/AGL/xds/agent endif +ifeq ($(origin DESTDIR_WWW), undefined) + DESTDIR_WWW := $(DESTDIR)/www +endif HOST_GOOS=$(shell go env GOOS) HOST_GOARCH=$(shell go env GOARCH) @@ -75,28 +74,15 @@ else endif -all: tools/syncthing vendor build +all: tools/syncthing build -build: tools/syncthing/copytobin +.PHONY: build +build: vendor xds webapp + +xds: scripts tools/syncthing/copytobin @echo "### Build XDS agent (version $(VERSION), subversion $(SUB_VERSION)) - $(BUILD_MODE)"; @cd $(ROOT_SRCDIR); $(BUILD_ENV_FLAGS) go build $(VERBOSE_$(V)) -i -o $(LOCAL_BINDIR)/xds-agent$(EXT) -ldflags "$(GO_LDFLAGS) -X main.AppVersion=$(VERSION) -X main.AppSubVersion=$(SUB_VERSION)" -gcflags "$(GO_GCFLAGS)" . -package: clean tools/syncthing vendor build - @mkdir -p $(PACKAGE_DIR)/xds-agent $(PACKAGE_DIR)/scripts - @cp -a $(LOCAL_BINDIR)/* $(PACKAGE_DIR)/xds-agent - @cp -r $(ROOT_SRCDIR)/conf.d $(ROOT_SRCDIR)/scripts $(PACKAGE_DIR)/xds-agent - cd $(PACKAGE_DIR) && zip -r $(ROOT_SRCDIR)/$(PACKAGE_ZIPFILE) ./xds-agent - -.PHONY: package-all -package-all: - @echo "# Build linux amd64..." - GOOS=linux GOARCH=amd64 RELEASE=1 make -f $(ROOT_SRCDIR)/Makefile package - @echo "# Build windows amd64..." - GOOS=windows GOARCH=amd64 RELEASE=1 make -f $(ROOT_SRCDIR)/Makefile package - @echo "# Build darwin amd64..." - GOOS=darwin GOARCH=amd64 RELEASE=1 make -f $(ROOT_SRCDIR)/Makefile package - make -f $(ROOT_SRCDIR)/Makefile clean - test: tools/glide go test --race $(shell $(LOCAL_TOOLSDIR)/glide novendor) @@ -114,25 +100,55 @@ debug: build/xds tools/syncthing/copytobin .PHONY: clean clean: - rm -rf $(LOCAL_BINDIR)/* debug $(ROOT_GOPRJ)/pkg/*/$(REPOPATH) $(PACKAGE_DIR) + rm -rf $(LOCAL_BINDIR)/* $(ROOT_SRCDIR)/debug $(ROOT_GOPRJ)/pkg/*/$(REPOPATH) $(PACKAGE_DIR) .PHONY: distclean distclean: clean - rm -rf $(LOCAL_BINDIR) tools glide.lock vendor $(ROOT_SRCDIR)/*.zip + cd $(ROOT_SRCDIR) && rm -rf $(LOCAL_BINDIR) ./tools ./glide.lock ./vendor ./*.zip ./webapp/node_modules ./webapp/dist + +webapp: webapp/install + (cd webapp && gulp build) + +webapp/debug: + (cd webapp && gulp watch &) + +webapp/install: + (cd webapp && npm install) + @if [ -d ${DESTDIR}/usr/local/etc ]; then rm -rf ${DESTDIR}/usr; fi .PHONY: install install: @test -e $(LOCAL_BINDIR)/xds-agent$(EXT) || { echo "Please execute first: make all\n"; exit 1; } - @test -e $(LOCAL_BINDIR)/syncthing$(EXT) -a -e $(LOCAL_BINDIR)/syncthing-inotify$(EXT) || { echo "Please execute first: make all\n"; exit 1; } - export DESTDIR=$(DESTDIR) && $(ROOT_SRCDIR)/scripts/install.sh + @test -e $(LOCAL_BINDIR)/syncthing$(EXT) -a -e $(LOCAL_BINDIR)/syncthing-inotify$(EXT) || { echo "Please execute first: make all\n"; exit 1; } + export DESTDIR=$(DESTDIR) && export DESTDIR_WWW=$(DESTDIR_WWW) && $(ROOT_SRCDIR)/scripts/install.sh .PHONY: uninstall uninstall: - export DESTDIR=$(DESTDIR) && $(ROOT_SRCDIR)/scripts/install.sh uninstall + export DESTDIR=$(DESTDIR) && export DESTDIR_WWW=$(DESTDIR_WWW) && $(ROOT_SRCDIR)/scripts/install.sh uninstall + +package: clean tools/syncthing vendor build + @mkdir -p $(PACKAGE_DIR)/xds-agent $(PACKAGE_DIR)/scripts + @cp -a $(LOCAL_BINDIR)/* $(PACKAGE_DIR)/xds-agent + @cp -r $(ROOT_SRCDIR)/conf.d $(ROOT_SRCDIR)/scripts $(PACKAGE_DIR)/xds-agent + cd $(PACKAGE_DIR) && zip -r $(ROOT_SRCDIR)/$(PACKAGE_ZIPFILE) ./xds-agent + +.PHONY: package-all +package-all: + @echo "# Build linux amd64..." + GOOS=linux GOARCH=amd64 RELEASE=1 make -f $(ROOT_SRCDIR)/Makefile package + @echo "# Build windows amd64..." + GOOS=windows GOARCH=amd64 RELEASE=1 make -f $(ROOT_SRCDIR)/Makefile package + @echo "# Build darwin amd64..." + GOOS=darwin GOARCH=amd64 RELEASE=1 make -f $(ROOT_SRCDIR)/Makefile package + make -f $(ROOT_SRCDIR)/Makefile clean vendor: tools/glide glide.yaml $(LOCAL_TOOLSDIR)/glide install --strip-vendor +vendor/debug: vendor + (cd vendor/github.com/iotbzh && \ + rm -rf xds-common && ln -s ../../../../xds-common ) + .PHONY: tools/glide tools/glide: @test -f $(LOCAL_TOOLSDIR)/glide || { \ @@ -150,7 +166,7 @@ tools/syncthing: SYNCTHING_INOTIFY_VERSION=$(SYNCTHING_INOTIFY_VERSION) \ ./scripts/get-syncthing.sh; } -.PHONY: +.PHONY: tools/syncthing/copytobin tools/syncthing/copytobin: @test -e $(LOCAL_TOOLSDIR)/syncthing$(EXT) -a -e $(LOCAL_TOOLSDIR)/syncthing-inotify$(EXT) || { echo "Please execute first: make tools/syncthing\n"; exit 1; } @mkdir -p $(LOCAL_BINDIR) diff --git a/conf.d/etc/xds-agent/config.json b/conf.d/etc/xds-agent/config.json index 35d702d..7c7d7cb 100644 --- a/conf.d/etc/xds-agent/config.json +++ b/conf.d/etc/xds-agent/config.json @@ -1,6 +1,12 @@ { + "httpPort": "8000", + "webAppDir": "./www", "logsDir": "${HOME}/.xds/agent/logs", - "xds-apikey": "1234abcezam", + "xdsServers": [ + { + "url": "http://localhost:8810" + } + ], "syncthing": { "home": "${HOME}/.xds/agent/syncthing-config", "gui-address": "http://localhost:8386", @@ -8,7 +8,7 @@ import: version: ^1.1.4 - package: github.com/gin-contrib/static - package: github.com/syncthing/syncthing - version: =0.14.28 + version: =0.14.38 subpackages: - lib/sync - lib/config @@ -17,8 +17,8 @@ import: version: ^1.19.1 - package: github.com/Sirupsen/logrus version: ^0.11.5 -- package: github.com/googollee/go-socket.io -- package: github.com/zhouhui8915/go-socket.io-client +- package: github.com/sebd71/go-socket.io-client + version: 46defcb47f - package: github.com/satori/go.uuid version: ^1.1.0 - package: github.com/iotbzh/xds-common diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 74872f7..3bdd89f 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -2,28 +2,39 @@ package agent import ( "fmt" + "log" "os" "os/exec" "os/signal" + "path/filepath" "syscall" + "time" "github.com/Sirupsen/logrus" "github.com/codegangsta/cli" "github.com/iotbzh/xds-agent/lib/syncthing" "github.com/iotbzh/xds-agent/lib/xdsconfig" - "github.com/iotbzh/xds-agent/lib/webserver" ) +const cookieMaxAge = "3600" + // Context holds the Agent context structure type Context struct { - ProgName string - Config *xdsconfig.Config - Log *logrus.Logger - SThg *st.SyncThing - SThgCmd *exec.Cmd - SThgInotCmd *exec.Cmd - WWWServer *webserver.ServerService - Exit chan os.Signal + ProgName string + Config *xdsconfig.Config + Log *logrus.Logger + LogLevelSilly bool + SThg *st.SyncThing + SThgCmd *exec.Cmd + SThgInotCmd *exec.Cmd + + webServer *WebServer + xdsServers map[string]*XdsServer + sessions *Sessions + events *Events + projects *Projects + + Exit chan os.Signal } // NewAgent Create a new instance of Agent @@ -43,11 +54,18 @@ func NewAgent(cliCtx *cli.Context) *Context { } log.Formatter = &logrus.TextFormatter{} + sillyVal, sillyLog := os.LookupEnv("XDS_LOG_SILLY") + // Define default configuration ctx := Context{ - ProgName: cliCtx.App.Name, - Log: log, - Exit: make(chan os.Signal, 1), + ProgName: cliCtx.App.Name, + Log: log, + LogLevelSilly: (sillyLog && sillyVal == "1"), + Exit: make(chan os.Signal, 1), + + webServer: nil, + xdsServers: make(map[string]*XdsServer), + events: nil, } // register handler on SIGTERM / exit @@ -57,6 +75,114 @@ func NewAgent(cliCtx *cli.Context) *Context { return &ctx } +// Run Main function called to run agent +func (ctx *Context) Run() (int, error) { + var err error + + // Logs redirected into a file when logfile option or logsDir config is set + ctx.Config.LogVerboseOut = os.Stderr + if ctx.Config.FileConf.LogsDir != "" { + if ctx.Config.Options.LogFile != "stdout" { + logFile := ctx.Config.Options.LogFile + + fdL, err := os.OpenFile(logFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666) + if err != nil { + msgErr := fmt.Errorf("Cannot create log file %s", logFile) + return int(syscall.EPERM), msgErr + } + ctx.Log.Out = fdL + + ctx._logPrint("Logging file: %s\n", logFile) + } + + logFileHTTPReq := filepath.Join(ctx.Config.FileConf.LogsDir, "xds-agent-verbose.log") + fdLH, err := os.OpenFile(logFileHTTPReq, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666) + if err != nil { + msgErr := fmt.Errorf("Cannot create log file %s", logFileHTTPReq) + return int(syscall.EPERM), msgErr + } + ctx.Config.LogVerboseOut = fdLH + + ctx._logPrint("Logging file for HTTP requests: %s\n", logFileHTTPReq) + } + + // Create syncthing instance when section "syncthing" is present in config.json + if ctx.Config.FileConf.SThgConf != nil { + ctx.SThg = st.NewSyncThing(ctx.Config, ctx.Log) + } + + // Start local instance of Syncthing and Syncthing-notify + if ctx.SThg != nil { + ctx.Log.Infof("Starting Syncthing...") + ctx.SThgCmd, err = ctx.SThg.Start() + if err != nil { + return 2, err + } + fmt.Printf("Syncthing started (PID %d)\n", ctx.SThgCmd.Process.Pid) + + ctx.Log.Infof("Starting Syncthing-inotify...") + ctx.SThgInotCmd, err = ctx.SThg.StartInotify() + if err != nil { + return 2, err + } + fmt.Printf("Syncthing-inotify started (PID %d)\n", ctx.SThgInotCmd.Process.Pid) + + // Establish connection with local Syncthing (retry if connection fail) + time.Sleep(3 * time.Second) + maxRetry := 30 + retry := maxRetry + for retry > 0 { + if err := ctx.SThg.Connect(); err == nil { + break + } + ctx.Log.Infof("Establishing connection to Syncthing (retry %d/%d)", retry, maxRetry) + time.Sleep(time.Second) + retry-- + } + if err != nil || retry == 0 { + return 2, err + } + + // Retrieve Syncthing config + id, err := ctx.SThg.IDGet() + if err != nil { + return 2, err + } + ctx.Log.Infof("Local Syncthing ID: %s", id) + + } else { + ctx.Log.Infof("Cloud Sync / Syncthing not supported") + } + + // Create Web Server + ctx.webServer = NewWebServer(ctx) + + // Sessions manager + ctx.sessions = NewClientSessions(ctx, cookieMaxAge) + + // Create events management + ctx.events = NewEvents(ctx) + + // Create projects management + ctx.projects = NewProjects(ctx, ctx.SThg) + + // Run Web Server until exit requested (blocking call) + if err = ctx.webServer.Serve(); err != nil { + log.Println(err) + return 3, err + } + + return 4, fmt.Errorf("Program exited") +} + +// Helper function to log message on both stdout and logger +func (ctx *Context) _logPrint(format string, args ...interface{}) { + fmt.Printf(format, args...) + if ctx.Log.Out != os.Stdout { + ctx.Log.Infof(format, args...) + } +} + // Handle exit and properly stop/close all stuff func handlerSigTerm(ctx *Context) { <-ctx.Exit @@ -68,9 +194,9 @@ func handlerSigTerm(ctx *Context) { ctx.SThg.Stop() ctx.SThg.StopInotify() } - if ctx.WWWServer != nil { + if ctx.webServer != nil { ctx.Log.Infof("Stoping Web server...") - ctx.WWWServer.Stop() + ctx.webServer.Stop() } os.Exit(1) } diff --git a/lib/agent/apiv1-browse.go b/lib/agent/apiv1-browse.go new file mode 100644 index 0000000..1701a2e --- /dev/null +++ b/lib/agent/apiv1-browse.go @@ -0,0 +1,28 @@ +package agent + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type directory struct { + Name string `json:"name"` + Fullpath string `json:"fullpath"` +} + +type apiDirectory struct { + Dir []directory `json:"dir"` +} + +// browseFS used to browse local file system +func (s *APIService) browseFS(c *gin.Context) { + + response := apiDirectory{ + Dir: []directory{ + directory{Name: "TODO SEB"}, + }, + } + + c.JSON(http.StatusOK, response) +} diff --git a/lib/agent/apiv1-config.go b/lib/agent/apiv1-config.go new file mode 100644 index 0000000..31d8de6 --- /dev/null +++ b/lib/agent/apiv1-config.go @@ -0,0 +1,108 @@ +package agent + +import ( + "net/http" + "sync" + + "github.com/gin-gonic/gin" + "github.com/iotbzh/xds-agent/lib/xdsconfig" + common "github.com/iotbzh/xds-common/golib" +) + +var confMut sync.Mutex + +// APIConfig parameters (json format) of /config command +type APIConfig struct { + Servers []ServerCfg `json:"servers"` + + // Not exposed outside in JSON + Version string `json:"-"` + APIVersion string `json:"-"` + VersionGitTag string `json:"-"` +} + +// ServerCfg . +type ServerCfg struct { + ID string `json:"id"` + URL string `json:"url"` + APIURL string `json:"apiUrl"` + PartialURL string `json:"partialUrl"` + ConnRetry int `json:"connRetry"` + Connected bool `json:"connected"` + Disabled bool `json:"disabled"` +} + +// GetConfig returns the configuration +func (s *APIService) getConfig(c *gin.Context) { + confMut.Lock() + defer confMut.Unlock() + + cfg := s._getConfig() + + c.JSON(http.StatusOK, cfg) +} + +// SetConfig sets configuration +func (s *APIService) setConfig(c *gin.Context) { + var cfgArg APIConfig + if c.BindJSON(&cfgArg) != nil { + common.APIError(c, "Invalid arguments") + return + } + + confMut.Lock() + defer confMut.Unlock() + + s.Log.Debugln("SET config: ", cfgArg) + + // First delete/disable XDS Server that are no longer listed + for _, svr := range s.xdsServers { + found := false + for _, svrArg := range cfgArg.Servers { + if svr.ID == svrArg.ID { + found = true + break + } + } + if !found { + s.DelXdsServer(svr.ID) + } + } + + // Add new XDS Server + for _, svr := range cfgArg.Servers { + cfg := xdsconfig.XDSServerConf{ + ID: svr.ID, + URL: svr.URL, + ConnRetry: svr.ConnRetry, + } + if _, err := s.AddXdsServer(cfg); err != nil { + common.APIError(c, err.Error()) + return + } + } + + c.JSON(http.StatusOK, s._getConfig()) +} + +func (s *APIService) _getConfig() APIConfig { + cfg := APIConfig{ + Version: s.Config.Version, + APIVersion: s.Config.APIVersion, + VersionGitTag: s.Config.VersionGitTag, + Servers: []ServerCfg{}, + } + + for _, svr := range s.xdsServers { + cfg.Servers = append(cfg.Servers, ServerCfg{ + ID: svr.ID, + URL: svr.BaseURL, + APIURL: svr.APIURL, + PartialURL: svr.PartialURL, + ConnRetry: svr.ConnRetry, + Connected: svr.Connected, + Disabled: svr.Disabled, + }) + } + return cfg +} diff --git a/lib/agent/apiv1-events.go b/lib/agent/apiv1-events.go new file mode 100644 index 0000000..8aad18a --- /dev/null +++ b/lib/agent/apiv1-events.go @@ -0,0 +1,73 @@ +package agent + +import ( + "net/http" + + "github.com/gin-gonic/gin" + common "github.com/iotbzh/xds-common/golib" +) + +// EventRegisterArgs is the parameters (json format) of /events/register command +type EventRegisterArgs struct { + Name string `json:"name"` + ProjectID string `json:"filterProjectID"` +} + +// EventUnRegisterArgs is the parameters (json format) of /events/unregister command +type EventUnRegisterArgs struct { + Name string `json:"name"` + ID int `json:"id"` +} + +// eventsList Registering for events that will be send over a WS +func (s *APIService) eventsList(c *gin.Context) { + c.JSON(http.StatusOK, s.events.GetList()) +} + +// eventsRegister Registering for events that will be send over a WS +func (s *APIService) eventsRegister(c *gin.Context) { + var args EventRegisterArgs + + if c.BindJSON(&args) != nil || args.Name == "" { + common.APIError(c, "Invalid arguments") + return + } + + sess := s.webServer.sessions.Get(c) + if sess == nil { + common.APIError(c, "Unknown sessions") + return + } + + // Register to all or to a specific events + if err := s.events.Register(args.Name, sess.ID); err != nil { + common.APIError(c, err.Error()) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "OK"}) +} + +// eventsRegister Registering for events that will be send over a WS +func (s *APIService) eventsUnRegister(c *gin.Context) { + var args EventUnRegisterArgs + + if c.BindJSON(&args) != nil || args.Name == "" { + common.APIError(c, "Invalid arguments") + return + } + + sess := s.webServer.sessions.Get(c) + if sess == nil { + common.APIError(c, "Unknown sessions") + return + } + + // Register to all or to a specific events + if err := s.events.UnRegister(args.Name, sess.ID); err != nil { + common.APIError(c, err.Error()) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "OK"}) +} diff --git a/lib/agent/apiv1-exec.go b/lib/agent/apiv1-exec.go new file mode 100644 index 0000000..37070f7 --- /dev/null +++ b/lib/agent/apiv1-exec.go @@ -0,0 +1,131 @@ +package agent + +import ( + "encoding/json" + "io/ioutil" + "net/http" + + "github.com/gin-gonic/gin" + common "github.com/iotbzh/xds-common/golib" + uuid "github.com/satori/go.uuid" +) + +// ExecArgs Only define used fields +type ExecArgs struct { + ID string `json:"id" binding:"required"` + CmdID string `json:"cmdID"` // command unique ID +} + +var execCmdID = 1 + +// ExecCmd executes remotely a command +func (s *APIService) execCmd(c *gin.Context) { + s._execRequest("/exec", c) +} + +// execSignalCmd executes remotely a command +func (s *APIService) execSignalCmd(c *gin.Context) { + s._execRequest("/signal", c) +} + +func (s *APIService) _execRequest(cmd string, c *gin.Context) { + data, err := c.GetRawData() + if err != nil { + common.APIError(c, err.Error()) + } + + args := ExecArgs{} + // XXX - we cannot use c.BindJSON, so directly unmarshall it + // (see https://github.com/gin-gonic/gin/issues/1078) + if err := json.Unmarshal(data, &args); err != nil { + common.APIError(c, "Invalid arguments") + return + } + + // First get Project ID to retrieve Server ID and send command to right server + id := c.Param("id") + if id == "" { + id = args.ID + } + + prj := s.projects.Get(id) + if prj == nil { + common.APIError(c, "Unknown id") + return + } + + svr := (*prj).GetServer() + if svr == nil { + common.APIError(c, "Cannot identify XDS Server") + return + } + + // Retrieve session info + sess := s.sessions.Get(c) + if sess == nil { + common.APIError(c, "Unknown sessions") + return + } + sock := sess.IOSocket + if sock == nil { + common.APIError(c, "Websocket not established") + return + } + + // Forward XDSServer WS events to client WS + // TODO removed static event name list and get it from XDSServer + evtList := []string{ + "exec:input", + "exec:output", + "exec:inferior-input", + "exec:inferior-output", + } + so := *sock + fwdFuncID := []uuid.UUID{} + for _, evName := range evtList { + evN := evName + fwdFunc := func(evData interface{}) { + // Forward event to Client/Dashboard + so.Emit(evN, evData) + } + id, err := svr.EventOn(evN, fwdFunc) + if err != nil { + common.APIError(c, err.Error()) + return + } + fwdFuncID = append(fwdFuncID, id) + } + + // Handle Exit event separately to cleanup registered listener + var exitFuncID uuid.UUID + exitFunc := func(evData interface{}) { + so.Emit("exec:exit", evData) + + // cleanup listener + for i, evName := range evtList { + svr.EventOff(evName, fwdFuncID[i]) + } + svr.EventOff("exec:exit", exitFuncID) + } + exitFuncID, err = svr.EventOn("exec:exit", exitFunc) + if err != nil { + common.APIError(c, err.Error()) + return + } + + // Forward back command to right server + response, err := svr.SendCommand(cmd, data) + if err != nil { + common.APIError(c, err.Error()) + return + } + + // Decode response + body, err := ioutil.ReadAll(response.Body) + if err != nil { + common.APIError(c, "Cannot read response body") + return + } + c.JSON(http.StatusOK, string(body)) + +} diff --git a/lib/agent/apiv1-projects.go b/lib/agent/apiv1-projects.go new file mode 100644 index 0000000..d4b5e74 --- /dev/null +++ b/lib/agent/apiv1-projects.go @@ -0,0 +1,72 @@ +package agent + +import ( + "net/http" + + "github.com/gin-gonic/gin" + common "github.com/iotbzh/xds-common/golib" +) + +// getProjects returns all projects configuration +func (s *APIService) getProjects(c *gin.Context) { + c.JSON(http.StatusOK, s.projects.GetProjectArr()) +} + +// getProject returns a specific project configuration +func (s *APIService) getProject(c *gin.Context) { + prj := s.projects.Get(c.Param("id")) + if prj == nil { + common.APIError(c, "Invalid id") + return + } + + c.JSON(http.StatusOK, (*prj).GetProject()) +} + +// addProject adds a new project to server config +func (s *APIService) addProject(c *gin.Context) { + var cfgArg ProjectConfig + if c.BindJSON(&cfgArg) != nil { + common.APIError(c, "Invalid arguments") + return + } + + s.Log.Debugln("Add project config: ", cfgArg) + + newFld, err := s.projects.Add(cfgArg) + if err != nil { + common.APIError(c, err.Error()) + return + } + + c.JSON(http.StatusOK, newFld) +} + +// syncProject force synchronization of project files +func (s *APIService) syncProject(c *gin.Context) { + id := c.Param("id") + + s.Log.Debugln("Sync project id: ", id) + + err := s.projects.ForceSync(id) + if err != nil { + common.APIError(c, err.Error()) + return + } + + c.JSON(http.StatusOK, "") +} + +// delProject deletes project from server config +func (s *APIService) delProject(c *gin.Context) { + id := c.Param("id") + + s.Log.Debugln("Delete project id ", id) + + delEntry, err := s.projects.Delete(id) + if err != nil { + common.APIError(c, err.Error()) + return + } + c.JSON(http.StatusOK, delEntry) +} diff --git a/lib/agent/apiv1-version.go b/lib/agent/apiv1-version.go new file mode 100644 index 0000000..6b4923f --- /dev/null +++ b/lib/agent/apiv1-version.go @@ -0,0 +1,45 @@ +package agent + +import ( + "net/http" + + "github.com/gin-gonic/gin" + common "github.com/iotbzh/xds-common/golib" +) + +type version struct { + ID string `json:"id"` + Version string `json:"version"` + APIVersion string `json:"apiVersion"` + VersionGitTag string `json:"gitTag"` +} + +type apiVersion struct { + Client version `json:"client"` + Server []version `json:"servers"` +} + +// getInfo : return various information about server +func (s *APIService) getVersion(c *gin.Context) { + response := apiVersion{ + Client: version{ + ID: "", + Version: s.Config.Version, + APIVersion: s.Config.APIVersion, + VersionGitTag: s.Config.VersionGitTag, + }, + } + + svrVer := []version{} + for _, svr := range s.xdsServers { + res := version{} + if err := svr.GetVersion(&res); err != nil { + common.APIError(c, "Cannot retrieve version of XDS server ID %s : %v", svr.ID, err.Error()) + return + } + svrVer = append(svrVer, res) + } + response.Server = svrVer + + c.JSON(http.StatusOK, response) +} diff --git a/lib/agent/apiv1.go b/lib/agent/apiv1.go new file mode 100644 index 0000000..77b05ba --- /dev/null +++ b/lib/agent/apiv1.go @@ -0,0 +1,129 @@ +package agent + +import ( + "fmt" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/iotbzh/xds-agent/lib/xdsconfig" +) + +const apiBaseUrl = "/api/v1" + +// APIService . +type APIService struct { + *Context + apiRouter *gin.RouterGroup + serverIndex int +} + +// NewAPIV1 creates a new instance of API service +func NewAPIV1(ctx *Context) *APIService { + s := &APIService{ + Context: ctx, + apiRouter: ctx.webServer.router.Group(apiBaseUrl), + serverIndex: 0, + } + + s.apiRouter.GET("/version", s.getVersion) + + s.apiRouter.GET("/config", s.getConfig) + s.apiRouter.POST("/config", s.setConfig) + + s.apiRouter.GET("/browse", s.browseFS) + + s.apiRouter.GET("/projects", s.getProjects) + s.apiRouter.GET("/project/:id", s.getProject) + s.apiRouter.POST("/project", s.addProject) + s.apiRouter.POST("/project/sync/:id", s.syncProject) + s.apiRouter.DELETE("/project/:id", s.delProject) + + s.apiRouter.POST("/exec", s.execCmd) + s.apiRouter.POST("/exec/:id", s.execCmd) + s.apiRouter.POST("/signal", s.execSignalCmd) + + s.apiRouter.GET("/events", s.eventsList) + s.apiRouter.POST("/events/register", s.eventsRegister) + s.apiRouter.POST("/events/unregister", s.eventsUnRegister) + + return s +} + +// Stop Used to stop/close created services +func (s *APIService) Stop() { + for _, svr := range s.xdsServers { + svr.Close() + } +} + +// AddXdsServer Add a new XDS Server to the list of a server +func (s *APIService) AddXdsServer(cfg xdsconfig.XDSServerConf) (*XdsServer, error) { + var svr *XdsServer + var exist, tempoID bool + tempoID = false + + // First check if not already exist and update it + if svr, exist = s.xdsServers[cfg.ID]; exist { + + // Update: Found, so just update some settings + svr.ConnRetry = cfg.ConnRetry + + tempoID = svr.IsTempoID() + if svr.Connected && !svr.Disabled && svr.BaseURL == cfg.URL && tempoID { + return svr, nil + } + + // URL differ or not connected, so need to reconnect + svr.BaseURL = cfg.URL + + } else { + + // Create a new server object + if cfg.APIBaseURL == "" { + cfg.APIBaseURL = apiBaseUrl + } + if cfg.APIPartialURL == "" { + cfg.APIPartialURL = "/server/" + strconv.Itoa(s.serverIndex) + s.serverIndex = s.serverIndex + 1 + } + + // Create a new XDS Server + svr = NewXdsServer(s.Context, cfg) + + svr.SetLoggerOutput(s.Config.LogVerboseOut) + + // Passthrough routes (handle by XDS Server) + grp := s.apiRouter.Group(svr.PartialURL) + svr.SetAPIRouterGroup(grp) + svr.PassthroughGet("/sdks") + svr.PassthroughGet("/sdk/:id") + } + + // Established connection + err := svr.Connect() + + // Delete temporary ID with it has been replaced by right Server ID + if tempoID && !svr.IsTempoID() { + delete(s.xdsServers, cfg.ID) + } + + // Add to map + s.xdsServers[svr.ID] = svr + + // Load projects + if err == nil && svr.Connected { + err = s.projects.Init(svr) + } + + return svr, err +} + +// DelXdsServer Delete an XDS Server from the list of a server +func (s *APIService) DelXdsServer(id string) error { + if _, exist := s.xdsServers[id]; !exist { + return fmt.Errorf("Unknown Server ID %s", id) + } + // Don't really delete, just disable it + s.xdsServers[id].Close() + return nil +} diff --git a/lib/agent/events.go b/lib/agent/events.go new file mode 100644 index 0000000..e66f758 --- /dev/null +++ b/lib/agent/events.go @@ -0,0 +1,132 @@ +package agent + +import ( + "fmt" + "time" +) + +// Events constants +const ( + // EventTypePrefix Used as event prefix + EventTypePrefix = "event:" // following by event type + + // Supported Events type + EVTAll = "all" + EVTServerConfig = "server-config" // data type ServerCfg + EVTProjectAdd = "project-add" // data type ProjectConfig + EVTProjectDelete = "project-delete" // data type ProjectConfig + EVTProjectChange = "project-state-change" // data type ProjectConfig +) + +var _EVTAllList = []string{ + EVTServerConfig, + EVTProjectAdd, + EVTProjectDelete, + EVTProjectChange, +} + +// EventMsg Message send +type EventMsg struct { + Time string `json:"time"` + Type string `json:"type"` + Data interface{} `json:"data"` +} + +type EventDef struct { + sids map[string]int +} + +type Events struct { + *Context + eventsMap map[string]*EventDef +} + +// NewEvents creates an instance of Events +func NewEvents(ctx *Context) *Events { + evMap := make(map[string]*EventDef) + for _, ev := range _EVTAllList { + evMap[ev] = &EventDef{ + sids: make(map[string]int), + } + } + return &Events{ + Context: ctx, + eventsMap: evMap, + } +} + +// GetList returns the list of all supported events +func (e *Events) GetList() []string { + return _EVTAllList +} + +// Register Used by a client/session to register to a specific (or all) event(s) +func (e *Events) Register(evName, sessionID string) error { + evs := _EVTAllList + if evName != EVTAll { + if _, ok := e.eventsMap[evName]; !ok { + return fmt.Errorf("Unsupported event type name") + } + evs = []string{evName} + } + for _, ev := range evs { + e.eventsMap[ev].sids[sessionID]++ + } + return nil +} + +// UnRegister Used by a client/session to unregister event(s) +func (e *Events) UnRegister(evName, sessionID string) error { + evs := _EVTAllList + if evName != EVTAll { + if _, ok := e.eventsMap[evName]; !ok { + return fmt.Errorf("Unsupported event type name") + } + evs = []string{evName} + } + for _, ev := range evs { + if _, exist := e.eventsMap[ev].sids[sessionID]; exist { + delete(e.eventsMap[ev].sids, sessionID) + break + } + } + return nil +} + +// Emit Used to manually emit an event +func (e *Events) Emit(evName string, data interface{}) error { + var firstErr error + + if _, ok := e.eventsMap[evName]; !ok { + return fmt.Errorf("Unsupported event type") + } + + if e.LogLevelSilly { + e.Log.Debugf("Emit Event %s: %v", evName, data) + } + + firstErr = nil + evm := e.eventsMap[evName] + for sid := range evm.sids { + so := e.webServer.sessions.IOSocketGet(sid) + if so == nil { + if firstErr == nil { + firstErr = fmt.Errorf("IOSocketGet return nil") + } + continue + } + msg := EventMsg{ + Time: time.Now().String(), + Type: evName, + Data: data, + } + if err := (*so).Emit(EventTypePrefix+evName, msg); err != nil { + e.Log.Errorf("WS Emit %v error : %v", EventTypePrefix+evName, err) + if firstErr == nil { + firstErr = err + } + } + } + + return firstErr +} diff --git a/lib/agent/project-interface.go b/lib/agent/project-interface.go new file mode 100644 index 0000000..0a4a17e --- /dev/null +++ b/lib/agent/project-interface.go @@ -0,0 +1,43 @@ +package agent + +// ProjectType definition +type ProjectType string + +const ( + TypePathMap = "PathMap" + TypeCloudSync = "CloudSync" + TypeCifsSmb = "CIFS" +) + +// Project Status definition +const ( + StatusErrorConfig = "ErrorConfig" + StatusDisable = "Disable" + StatusEnable = "Enable" + StatusPause = "Pause" + StatusSyncing = "Syncing" +) + +// IPROJECT Project interface +type IPROJECT interface { + Add(cfg ProjectConfig) (*ProjectConfig, error) // Add a new project + Delete() error // Delete a project + GetProject() *ProjectConfig // Get project public configuration + UpdateProject(prj ProjectConfig) (*ProjectConfig, error) // Update project configuration + GetServer() *XdsServer // Get XdsServer that holds this project + Sync() error // Force project files synchronization + IsInSync() (bool, error) // Check if project files are in-sync +} + +// ProjectConfig is the config for one project +type ProjectConfig struct { + ID string `json:"id"` + ServerID string `json:"serverId"` + Label string `json:"label"` + ClientPath string `json:"clientPath"` + ServerPath string `json:"serverPath"` + Type ProjectType `json:"type"` + Status string `json:"status"` + IsInSync bool `json:"isInSync"` + DefaultSdk string `json:"defaultSdk"` +} diff --git a/lib/agent/project-pathmap.go b/lib/agent/project-pathmap.go new file mode 100644 index 0000000..aacbd1f --- /dev/null +++ b/lib/agent/project-pathmap.go @@ -0,0 +1,123 @@ +package agent + +import ( + "fmt" + "io/ioutil" + "os" + "strings" + + common "github.com/iotbzh/xds-common/golib" +) + +// IPROJECT interface implementation for native/path mapping projects + +// PathMap . +type PathMap struct { + *Context + server *XdsServer + folder *XdsFolderConfig +} + +// NewProjectPathMap Create a new instance of PathMap +func NewProjectPathMap(ctx *Context, svr *XdsServer) *PathMap { + p := PathMap{ + Context: ctx, + server: svr, + folder: &XdsFolderConfig{}, + } + return &p +} + +// Add a new project +func (p *PathMap) Add(cfg ProjectConfig) (*ProjectConfig, error) { + var err error + var file *os.File + errMsg := "ClientPath sanity check error (%d): %v" + + // Sanity check to verify that we have RW permission and path-mapping is correct + dir := cfg.ClientPath + if !common.Exists(dir) { + // try to create if not existing + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("Cannot create ClientPath directory: %s", dir) + } + } + if !common.Exists(dir) { + return nil, fmt.Errorf("ClientPath directory is not accessible: %s", dir) + } + if file, err = ioutil.TempFile(dir, ".xds_pathmap_check"); err != nil { + return nil, fmt.Errorf(errMsg, 1, err) + } + // Write a specific message that will be check by server during folder add + msg := "Pathmap checked message written by xds-agent ID: " + p.Config.AgentUID + "\n" + if n, err := file.WriteString(msg); n != len(msg) || err != nil { + return nil, fmt.Errorf(errMsg, 2, err) + } + defer func() { + if file != nil { + os.Remove(file.Name()) + file.Close() + } + }() + + // Convert to Xds folder + fld := p.server.ProjectToFolder(cfg) + fld.DataPathMap.CheckFile = file.Name() + fld.DataPathMap.CheckContent = msg + + // Send request to create folder on XDS server side + err = p.server.FolderAdd(fld, p.folder) + if err != nil { + return nil, fmt.Errorf("Folders mapping verification failure.\n%v", err) + } + + // 2nd part of sanity checker + // check specific message added by XDS Server during folder add processing + content, err := ioutil.ReadFile(file.Name()) + if err != nil { + return nil, fmt.Errorf(errMsg, 3, err) + } + if !strings.Contains(string(content), + "Pathmap checked message written by xds-server ID") { + return nil, fmt.Errorf(errMsg, 4, "file content differ") + } + + return p.GetProject(), nil +} + +// Delete a project +func (p *PathMap) Delete() error { + return p.server.FolderDelete(p.folder.ID) +} + +// GetProject Get public part of project config +func (p *PathMap) GetProject() *ProjectConfig { + prj := p.server.FolderToProject(*p.folder) + prj.ServerID = p.server.ID + return &prj +} + +// UpdateProject Set project config +func (p *PathMap) UpdateProject(prj ProjectConfig) (*ProjectConfig, error) { + p.folder = p.server.ProjectToFolder(prj) + np := p.GetProject() + if err := p.events.Emit(EVTProjectChange, np); err != nil { + return np, err + } + return np, nil +} + +// GetServer Get the XdsServer that holds this project +func (p *PathMap) GetServer() *XdsServer { + return p.server +} + +// Sync Force project files synchronization +func (p *PathMap) Sync() error { + return nil +} + +// IsInSync Check if project files are in-sync +func (p *PathMap) IsInSync() (bool, error) { + return true, nil +} diff --git a/lib/agent/project-st.go b/lib/agent/project-st.go new file mode 100644 index 0000000..c0d2550 --- /dev/null +++ b/lib/agent/project-st.go @@ -0,0 +1,186 @@ +package agent + +import ( + st "github.com/iotbzh/xds-agent/lib/syncthing" +) + +// IPROJECT interface implementation for syncthing projects + +// STProject . +type STProject struct { + *Context + server *XdsServer + folder *XdsFolderConfig + eventIDs []int +} + +// NewProjectST Create a new instance of STProject +func NewProjectST(ctx *Context, svr *XdsServer) *STProject { + p := STProject{ + Context: ctx, + server: svr, + folder: &XdsFolderConfig{}, + } + return &p +} + +// Add a new project +func (p *STProject) Add(cfg ProjectConfig) (*ProjectConfig, error) { + var err error + + // Add project/folder into XDS Server + err = p.server.FolderAdd(p.server.ProjectToFolder(cfg), p.folder) + if err != nil { + return nil, err + } + svrPrj := p.GetProject() + + // Declare project into local Syncthing + id, err := p.SThg.FolderChange(st.FolderChangeArg{ + ID: svrPrj.ID, + Label: svrPrj.Label, + RelativePath: cfg.ClientPath, + SyncThingID: p.server.ServerConfig.Builder.SyncThingID, + }) + if err != nil { + return nil, err + } + + locPrj, err := p.SThg.FolderConfigGet(id) + if err != nil { + svrPrj.Status = StatusErrorConfig + return nil, err + } + if svrPrj.ID != locPrj.ID { + p.Log.Errorf("Project ID in XDSServer and local ST differ: %s != %s", svrPrj.ID, locPrj.ID) + } + + // Use Update function to setup remains fields + return p.UpdateProject(*svrPrj) +} + +// Delete a project +func (p *STProject) Delete() error { + errSvr := p.server.FolderDelete(p.folder.ID) + errLoc := p.SThg.FolderDelete(p.folder.ID) + if errSvr != nil { + return errSvr + } + return errLoc +} + +// GetProject Get public part of project config +func (p *STProject) GetProject() *ProjectConfig { + prj := p.server.FolderToProject(*p.folder) + prj.ServerID = p.server.ID + return &prj +} + +// UpdateProject Update project config +func (p *STProject) UpdateProject(prj ProjectConfig) (*ProjectConfig, error) { + // Update folder + p.folder = p.server.ProjectToFolder(prj) + svrPrj := p.GetProject() + + // Register events to update folder status + // Register to XDS Server events + p.server.EventOn("event:FolderStateChanged", p._cbServerFolderChanged) + if err := p.server.EventRegister("FolderStateChanged", svrPrj.ID); err != nil { + p.Log.Warningf("XDS Server EventRegister failed: %v", err) + return svrPrj, err + } + + // Register to Local Syncthing events + for _, evName := range []string{st.EventStateChanged, st.EventFolderPaused} { + evID, err := p.SThg.Events.Register(evName, p._cbLocalSTEvents, svrPrj.ID, nil) + if err != nil { + return nil, err + } + p.eventIDs = append(p.eventIDs, evID) + } + + return svrPrj, nil +} + +// GetServer Get the XdsServer that holds this project +func (p *STProject) GetServer() *XdsServer { + return p.server +} + +// Sync Force project files synchronization +func (p *STProject) Sync() error { + if err := p.server.FolderSync(p.folder.ID); err != nil { + return err + } + return p.SThg.FolderScan(p.folder.ID, "") +} + +// IsInSync Check if project files are in-sync +func (p *STProject) IsInSync() (bool, error) { + // Should be up-to-date by callbacks (see below) + return p.folder.IsInSync, nil +} + +/** +** Private functions +***/ + +// callback use to update (XDS Server) folder IsInSync status + +func (p *STProject) _cbServerFolderChanged(data interface{}) { + evt := data.(XdsEventFolderChange) + + // Only process event that concerns this project/folder ID + if p.folder.ID != evt.Folder.ID { + return + } + + if evt.Folder.IsInSync != p.folder.DataCloudSync.STSvrIsInSync || + evt.Folder.Status != p.folder.DataCloudSync.STSvrStatus { + + p.folder.DataCloudSync.STSvrIsInSync = evt.Folder.IsInSync + p.folder.DataCloudSync.STSvrStatus = evt.Folder.Status + + if err := p.events.Emit(EVTProjectChange, p.server.FolderToProject(*p.folder)); err != nil { + p.Log.Warningf("Cannot notify project change: %v", err) + } + } +} + +// callback use to update IsInSync status +func (p *STProject) _cbLocalSTEvents(ev st.Event, data *st.EventsCBData) { + + inSync := p.folder.DataCloudSync.STLocIsInSync + sts := p.folder.DataCloudSync.STLocStatus + prevSync := inSync + prevStatus := sts + + switch ev.Type { + + case st.EventStateChanged: + to := ev.Data["to"] + switch to { + case "scanning", "syncing": + sts = StatusSyncing + case "idle": + sts = StatusEnable + } + inSync = (to == "idle") + + case st.EventFolderPaused: + if sts == StatusEnable { + sts = StatusPause + } + inSync = false + } + + if prevSync != inSync || prevStatus != sts { + + p.folder.DataCloudSync.STLocIsInSync = inSync + p.folder.DataCloudSync.STLocStatus = sts + + if err := p.events.Emit(EVTProjectChange, p.server.FolderToProject(*p.folder)); err != nil { + p.Log.Warningf("Cannot notify project change: %v", err) + } + } +} diff --git a/lib/agent/projects.go b/lib/agent/projects.go new file mode 100644 index 0000000..5e20395 --- /dev/null +++ b/lib/agent/projects.go @@ -0,0 +1,241 @@ +package agent + +import ( + "fmt" + "log" + "time" + + "github.com/iotbzh/xds-agent/lib/syncthing" + "github.com/syncthing/syncthing/lib/sync" +) + +// Projects Represent a an XDS Projects +type Projects struct { + *Context + SThg *st.SyncThing + projects map[string]*IPROJECT +} + +// Mutex to make add/delete atomic +var pjMutex = sync.NewMutex() + +// NewProjects Create a new instance of Project Model +func NewProjects(ctx *Context, st *st.SyncThing) *Projects { + return &Projects{ + Context: ctx, + SThg: st, + projects: make(map[string]*IPROJECT), + } +} + +// Init Load Projects configuration +func (p *Projects) Init(server *XdsServer) error { + svrList := make(map[string]*XdsServer) + // If server not set, load for all servers + if server == nil { + svrList = p.xdsServers + } else { + svrList[server.ID] = server + } + errMsg := "" + for _, svr := range svrList { + if svr.Disabled { + continue + } + xFlds := []XdsFolderConfig{} + if err := svr.GetFolders(&xFlds); err != nil { + errMsg += fmt.Sprintf("Cannot retrieve folders config of XDS server ID %s : %v \n", svr.ID, err.Error()) + continue + } + p.Log.Debugf("Connected to XDS Server %s, %d projects detected", svr.ID, len(xFlds)) + for _, prj := range xFlds { + newP := svr.FolderToProject(prj) + if _, err := p.createUpdate(newP, false, true); err != nil { + errMsg += "Error while creating project id " + prj.ID + ": " + err.Error() + "\n " + continue + } + } + } + + p.Log.Infof("Number of loaded Projects: %d", len(p.projects)) + + if errMsg != "" { + return fmt.Errorf(errMsg) + } + return nil +} + +// Get returns the folder config or nil if not existing +func (p *Projects) Get(id string) *IPROJECT { + if id == "" { + return nil + } + fc, exist := p.projects[id] + if !exist { + return nil + } + return fc +} + +// GetProjectArr returns the config of all folders as an array +func (p *Projects) GetProjectArr() []ProjectConfig { + pjMutex.Lock() + defer pjMutex.Unlock() + + return p.GetProjectArrUnsafe() +} + +// GetProjectArrUnsafe Same as GetProjectArr without mutex protection +func (p *Projects) GetProjectArrUnsafe() []ProjectConfig { + conf := []ProjectConfig{} + for _, v := range p.projects { + prj := (*v).GetProject() + conf = append(conf, *prj) + } + return conf +} + +// Add adds a new folder +func (p *Projects) Add(newF ProjectConfig) (*ProjectConfig, error) { + prj, err := p.createUpdate(newF, true, false) + if err != nil { + return prj, err + } + + // Notify client with event + if err := p.events.Emit(EVTProjectAdd, *prj); err != nil { + p.Log.Warningf("Cannot notify project deletion: %v", err) + } + + return prj, err +} + +// CreateUpdate creates or update a folder +func (p *Projects) createUpdate(newF ProjectConfig, create bool, initial bool) (*ProjectConfig, error) { + var err error + + pjMutex.Lock() + defer pjMutex.Unlock() + + // Sanity check + if _, exist := p.projects[newF.ID]; create && exist { + return nil, fmt.Errorf("ID already exists") + } + if newF.ClientPath == "" { + return nil, fmt.Errorf("ClientPath must be set") + } + if newF.ServerID == "" { + return nil, fmt.Errorf("Server ID must be set") + } + var svr *XdsServer + var exist bool + if svr, exist = p.xdsServers[newF.ServerID]; !exist { + return nil, fmt.Errorf("Unknown Server ID %s", newF.ServerID) + } + + // Check type supported + b, exist := svr.ServerConfig.SupportedSharing[string(newF.Type)] + if !exist || !b { + return nil, fmt.Errorf("Server doesn't support project type %s", newF.Type) + } + + // Create a new folder object + var fld IPROJECT + switch newF.Type { + // SYNCTHING + case TypeCloudSync: + if p.SThg != nil { + fld = NewProjectST(p.Context, svr) + } else { + return nil, fmt.Errorf("Cloud Sync project not supported") + } + + // PATH MAP + case TypePathMap: + fld = NewProjectPathMap(p.Context, svr) + default: + return nil, fmt.Errorf("Unsupported folder type") + } + + var newPrj *ProjectConfig + if create { + // Add project on server + if newPrj, err = fld.Add(newF); err != nil { + newF.Status = StatusErrorConfig + log.Printf("ERROR Adding project: %v\n", err) + return newPrj, err + } + } else { + // Just update project config + if newPrj, err = fld.UpdateProject(newF); err != nil { + newF.Status = StatusErrorConfig + log.Printf("ERROR Updating project: %v\n", err) + return newPrj, err + } + } + + // Sanity check + if newPrj.ID == "" { + log.Printf("ERROR project ID empty: %v", newF) + return newPrj, fmt.Errorf("Project ID empty") + } + + // Add to folders list + p.projects[newPrj.ID] = &fld + + // Force sync after creation + // (need to defer to be sure that WS events will arrive after HTTP creation reply) + go func() { + time.Sleep(time.Millisecond * 500) + fld.Sync() + }() + + return newPrj, nil +} + +// Delete deletes a specific folder +func (p *Projects) Delete(id string) (ProjectConfig, error) { + var err error + + pjMutex.Lock() + defer pjMutex.Unlock() + + fld := ProjectConfig{} + fc, exist := p.projects[id] + if !exist { + return fld, fmt.Errorf("unknown id") + } + + prj := (*fc).GetProject() + + if err = (*fc).Delete(); err != nil { + return *prj, err + } + + delete(p.projects, id) + + // Notify client with event + if err := p.events.Emit(EVTProjectDelete, *prj); err != nil { + p.Log.Warningf("Cannot notify project deletion: %v", err) + } + + return *prj, err +} + +// ForceSync Force the synchronization of a folder +func (p *Projects) ForceSync(id string) error { + fc := p.Get(id) + if fc == nil { + return fmt.Errorf("Unknown id") + } + return (*fc).Sync() +} + +// IsProjectInSync Returns true when folder is in sync +func (p *Projects) IsProjectInSync(id string) (bool, error) { + fc := p.Get(id) + if fc == nil { + return false, fmt.Errorf("Unknown id") + } + return (*fc).IsInSync() +} diff --git a/lib/session/session.go b/lib/agent/session.go index b56f9ff..06789d5 100644 --- a/lib/session/session.go +++ b/lib/agent/session.go @@ -1,11 +1,10 @@ -package session +package agent import ( "encoding/base64" "strconv" "time" - "github.com/Sirupsen/logrus" "github.com/gin-gonic/gin" "github.com/googollee/go-socket.io" uuid "github.com/satori/go.uuid" @@ -36,29 +35,27 @@ type ClientSession struct { // Sessions holds client sessions type Sessions struct { - router *gin.Engine + *Context cookieMaxAge int64 sessMap map[string]ClientSession mutex sync.Mutex - log *logrus.Logger stop chan struct{} // signals intentional stop } // NewClientSessions . -func NewClientSessions(router *gin.Engine, log *logrus.Logger, cookieMaxAge string) *Sessions { +func NewClientSessions(ctx *Context, cookieMaxAge string) *Sessions { ckMaxAge, err := strconv.ParseInt(cookieMaxAge, 10, 0) if err != nil { ckMaxAge = 0 } s := Sessions{ - router: router, + Context: ctx, cookieMaxAge: ckMaxAge, sessMap: make(map[string]ClientSession), mutex: sync.NewMutex(), - log: log, stop: make(chan struct{}), } - s.router.Use(s.Middleware()) + s.webServer.router.Use(s.Middleware()) // Start monitoring of sessions Map (use to manage expiration and cleanup) go s.monitorSessMap() @@ -174,7 +171,7 @@ func (s *Sessions) newSession(prefix string) *ClientSession { s.sessMap[se.ID] = se - s.log.Debugf("NEW session (%d): %s", len(s.sessMap), id) + s.Log.Debugf("NEW session (%d): %s", len(s.sessMap), id) return &se } @@ -197,27 +194,27 @@ func (s *Sessions) refresh(sid string) { } func (s *Sessions) monitorSessMap() { - const dbgFullTrace = false // for debugging - for { select { case <-s.stop: - s.log.Debugln("Stop monitorSessMap") + s.Log.Debugln("Stop monitorSessMap") return case <-time.After(sessionMonitorTime * time.Second): - if dbgFullTrace { - s.log.Debugf("Sessions Map size: %d", len(s.sessMap)) - s.log.Debugf("Sessions Map : %v", s.sessMap) + if s.LogLevelSilly { + s.Log.Debugf("Sessions Map size: %d", len(s.sessMap)) + s.Log.Debugf("Sessions Map : %v", s.sessMap) } if len(s.sessMap) > maxSessions { - s.log.Errorln("TOO MUCH sessions, cleanup old ones !") + s.Log.Errorln("TOO MUCH sessions, cleanup old ones !") } s.mutex.Lock() for _, ss := range s.sessMap { if ss.expireAt.Sub(time.Now()) < 0 { - s.log.Debugf("Delete expired session id: %s", ss.ID) + if s.LogLevelSilly { + s.Log.Debugf("Delete expired session id: %s", ss.ID) + } delete(s.sessMap, ss.ID) } } diff --git a/lib/agent/webserver.go b/lib/agent/webserver.go new file mode 100644 index 0000000..4b2e024 --- /dev/null +++ b/lib/agent/webserver.go @@ -0,0 +1,244 @@ +package agent + +import ( + "fmt" + "log" + "net/http" + "os" + "path" + + "github.com/Sirupsen/logrus" + "github.com/gin-contrib/static" + "github.com/gin-gonic/gin" + "github.com/googollee/go-socket.io" +) + +// WebServer . +type WebServer struct { + *Context + router *gin.Engine + api *APIService + sIOServer *socketio.Server + webApp *gin.RouterGroup + stop chan struct{} // signals intentional stop +} + +const indexFilename = "index.html" + +// NewWebServer creates an instance of WebServer +func NewWebServer(ctx *Context) *WebServer { + + // Setup logging for gin router + if ctx.Log.Level == logrus.DebugLevel { + gin.SetMode(gin.DebugMode) + } else { + gin.SetMode(gin.ReleaseMode) + } + + // Redirect gin logs into another logger (LogVerboseOut may be stderr or a file) + gin.DefaultWriter = ctx.Config.LogVerboseOut + gin.DefaultErrorWriter = ctx.Config.LogVerboseOut + log.SetOutput(ctx.Config.LogVerboseOut) + + // Creates gin router + r := gin.New() + + svr := &WebServer{ + Context: ctx, + router: r, + api: nil, + sIOServer: nil, + webApp: nil, + stop: make(chan struct{}), + } + + return svr +} + +// Serve starts a new instance of the Web Server +func (s *WebServer) Serve() error { + var err error + + // Setup middlewares + s.router.Use(gin.Logger()) + s.router.Use(gin.Recovery()) + s.router.Use(s.middlewareCORS()) + s.router.Use(s.middlewareXDSDetails()) + s.router.Use(s.middlewareCSRF()) + + // Create REST API + s.api = NewAPIV1(s.Context) + + // Create connections to XDS Servers + // XXX - not sure there is no side effect to do it in background ! + go func() { + for _, svrCfg := range s.Config.FileConf.ServersConf { + if svr, err := s.api.AddXdsServer(svrCfg); err != nil { + // Just log error, don't consider as critical + s.Log.Infof("Cannot connect to XDS Server url=%s: %v", svr.BaseURL, err.Error()) + } + } + }() + + // Websocket routes + s.sIOServer, err = socketio.NewServer(nil) + if err != nil { + s.Log.Fatalln(err) + } + + s.router.GET("/socket.io/", s.socketHandler) + s.router.POST("/socket.io/", s.socketHandler) + /* TODO: do we want to support ws://... ? + s.router.Handle("WS", "/socket.io/", s.socketHandler) + s.router.Handle("WSS", "/socket.io/", s.socketHandler) + */ + + // Web Application (serve on / ) + idxFile := path.Join(s.Config.FileConf.WebAppDir, indexFilename) + if _, err := os.Stat(idxFile); err != nil { + s.Log.Fatalln("Web app directory not found, check/use webAppDir setting in config file: ", idxFile) + } + s.Log.Infof("Serve WEB app dir: %s", s.Config.FileConf.WebAppDir) + s.router.Use(static.Serve("/", static.LocalFile(s.Config.FileConf.WebAppDir, true))) + s.webApp = s.router.Group("/", s.serveIndexFile) + { + s.webApp.GET("/") + } + + // Serve in the background + serveError := make(chan error, 1) + go func() { + fmt.Printf("Web Server running on localhost:%s ...\n", s.Config.FileConf.HTTPPort) + serveError <- http.ListenAndServe(":"+s.Config.FileConf.HTTPPort, s.router) + }() + + fmt.Printf("XDS agent running...\n") + + // Wait for stop, restart or error signals + select { + case <-s.stop: + // Shutting down permanently + s.sessions.Stop() + s.Log.Infoln("shutting down (stop)") + case err = <-serveError: + // Error due to listen/serve failure + s.Log.Errorln(err) + } + + return nil +} + +// Stop web server +func (s *WebServer) Stop() { + s.api.Stop() + close(s.stop) +} + +// serveIndexFile provides initial file (eg. index.html) of webapp +func (s *WebServer) serveIndexFile(c *gin.Context) { + c.HTML(200, indexFilename, gin.H{}) +} + +// Add details in Header +func (s *WebServer) middlewareXDSDetails() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("XDS-Agent-Version", s.Config.Version) + c.Header("XDS-API-Version", s.Config.APIVersion) + c.Next() + } +} + +func (s *WebServer) isValidAPIKey(key string) bool { + return (s.Config.FileConf.XDSAPIKey != "" && key == s.Config.FileConf.XDSAPIKey) +} + +func (s *WebServer) middlewareCSRF() gin.HandlerFunc { + return func(c *gin.Context) { + // XXX - not used for now + c.Next() + return + /* + // Allow requests carrying a valid API key + if s.isValidAPIKey(c.Request.Header.Get("X-API-Key")) { + // Set the access-control-allow-origin header for CORS requests + // since a valid API key has been provided + c.Header("Access-Control-Allow-Origin", "*") + c.Next() + return + } + + // Allow io.socket request + if strings.HasPrefix(c.Request.URL.Path, "/socket.io") { + c.Next() + return + } + + // FIXME Add really CSRF support + + // Allow requests for anything not under the protected path prefix, + // and set a CSRF cookie if there isn't already a valid one. + //if !strings.HasPrefix(c.Request.URL.Path, prefix) { + // cookie, err := c.Cookie("CSRF-Token-" + unique) + // if err != nil || !validCsrfToken(cookie.Value) { + // s.Log.Debugln("new CSRF cookie in response to request for", c.Request.URL) + // c.SetCookie("CSRF-Token-"+unique, newCsrfToken(), 600, "/", "", false, false) + // } + // c.Next() + // return + //} + + // Verify the CSRF token + //token := c.Request.Header.Get("X-CSRF-Token-" + unique) + //if !validCsrfToken(token) { + // c.AbortWithError(403, "CSRF Error") + // return + //} + + //c.Next() + + c.AbortWithError(403, fmt.Errorf("Not valid API key")) + */ + } +} + +// CORS middleware +func (s *WebServer) middlewareCORS() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Method == "OPTIONS" { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Headers", "Content-Type, X-API-Key") + c.Header("Access-Control-Allow-Methods", "GET, POST, DELETE") + c.Header("Access-Control-Max-Age", cookieMaxAge) + c.AbortWithStatus(204) + return + } + c.Next() + } +} + +// socketHandler is the handler for the "main" websocket connection +func (s *WebServer) socketHandler(c *gin.Context) { + + // Retrieve user session + sess := s.sessions.Get(c) + if sess == nil { + c.JSON(500, gin.H{"error": "Cannot retrieve session"}) + return + } + + s.sIOServer.On("connection", func(so socketio.Socket) { + s.Log.Debugf("WS Connected (SID=%v)", so.Id()) + s.sessions.UpdateIOSocket(sess.ID, &so) + + so.On("disconnection", func() { + s.Log.Debugf("WS disconnected (SID=%v)", so.Id()) + s.sessions.UpdateIOSocket(sess.ID, nil) + }) + }) + + s.sIOServer.On("error", func(so socketio.Socket, err error) { + s.Log.Errorf("WS SID=%v Error : %v", so.Id(), err.Error()) + }) + + s.sIOServer.ServeHTTP(c.Writer, c.Request) +} diff --git a/lib/agent/xdsserver.go b/lib/agent/xdsserver.go new file mode 100644 index 0000000..b76908c --- /dev/null +++ b/lib/agent/xdsserver.go @@ -0,0 +1,605 @@ +package agent + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/iotbzh/xds-agent/lib/xdsconfig" + common "github.com/iotbzh/xds-common/golib" + uuid "github.com/satori/go.uuid" + sio_client "github.com/sebd71/go-socket.io-client" +) + +// XdsServer . +type XdsServer struct { + *Context + ID string + BaseURL string + APIURL string + PartialURL string + ConnRetry int + Connected bool + Disabled bool + ServerConfig *XdsServerConfig + + // Events management + CBOnError func(error) + CBOnDisconnect func(error) + sockEvents map[string][]*caller + sockEventsLock *sync.Mutex + + // Private fields + client *common.HTTPClient + ioSock *sio_client.Client + logOut io.Writer + apiRouter *gin.RouterGroup +} + +// XdsServerConfig Data return by GET /config +type XdsServerConfig struct { + ID string `json:"id"` + Version string `json:"version"` + APIVersion string `json:"apiVersion"` + VersionGitTag string `json:"gitTag"` + SupportedSharing map[string]bool `json:"supportedSharing"` + Builder XdsBuilderConfig `json:"builder"` +} + +// XdsBuilderConfig represents the builder container configuration +type XdsBuilderConfig struct { + IP string `json:"ip"` + Port string `json:"port"` + SyncThingID string `json:"syncThingID"` +} + +// XdsFolderType XdsServer folder type +type XdsFolderType string + +const ( + XdsTypePathMap = "PathMap" + XdsTypeCloudSync = "CloudSync" + XdsTypeCifsSmb = "CIFS" +) + +// XdsFolderConfig XdsServer folder config +type XdsFolderConfig struct { + ID string `json:"id"` + Label string `json:"label"` + ClientPath string `json:"path"` + Type XdsFolderType `json:"type"` + Status string `json:"status"` + IsInSync bool `json:"isInSync"` + DefaultSdk string `json:"defaultSdk"` + // Specific data depending on which Type is used + DataPathMap XdsPathMapConfig `json:"dataPathMap,omitempty"` + DataCloudSync XdsCloudSyncConfig `json:"dataCloudSync,omitempty"` +} + +// XdsPathMapConfig Path mapping specific data +type XdsPathMapConfig struct { + ServerPath string `json:"serverPath"` + CheckFile string `json:"checkFile"` + CheckContent string `json:"checkContent"` +} + +// XdsCloudSyncConfig CloudSync (AKA Syncthing) specific data +type XdsCloudSyncConfig struct { + SyncThingID string `json:"syncThingID"` + STSvrStatus string `json:"-"` + STSvrIsInSync bool `json:"-"` + STLocStatus string `json:"-"` + STLocIsInSync bool `json:"-"` +} + +// XdsEventRegisterArgs arguments used to register to XDS server events +type XdsEventRegisterArgs struct { + Name string `json:"name"` + ProjectID string `json:"filterProjectID"` +} + +// XdsEventFolderChange Folder change event structure +type XdsEventFolderChange struct { + Time string `json:"time"` + Type string `json:"type"` + Folder XdsFolderConfig `json:"folder"` +} + +// caller Used to chain event listeners +type caller struct { + id uuid.UUID + EventName string + Func func(interface{}) +} + +const _IDTempoPrefix = "tempo-" + +// NewXdsServer creates an instance of XdsServer +func NewXdsServer(ctx *Context, conf xdsconfig.XDSServerConf) *XdsServer { + return &XdsServer{ + Context: ctx, + ID: _IDTempoPrefix + uuid.NewV1().String(), + BaseURL: conf.URL, + APIURL: conf.APIBaseURL + conf.APIPartialURL, + PartialURL: conf.APIPartialURL, + ConnRetry: conf.ConnRetry, + Connected: false, + Disabled: false, + + sockEvents: make(map[string][]*caller), + sockEventsLock: &sync.Mutex{}, + logOut: ctx.Log.Out, + } +} + +// Close Free and close XDS Server connection +func (xs *XdsServer) Close() error { + xs.Connected = false + xs.Disabled = true + xs.ioSock = nil + xs._NotifyState() + return nil +} + +// Connect Establish HTTP connection with XDS Server +func (xs *XdsServer) Connect() error { + var err error + var retry int + + xs.Disabled = false + xs.Connected = false + + err = nil + for retry = xs.ConnRetry; retry > 0; retry-- { + if err = xs._CreateConnectHTTP(); err == nil { + break + } + if retry == xs.ConnRetry { + // Notify only on the first conn error + // doing that avoid 2 notifs (conn false; conn true) on startup + xs._NotifyState() + } + xs.Log.Infof("Establishing connection to XDS Server (retry %d/%d)", retry, xs.ConnRetry) + time.Sleep(time.Second) + } + if retry == 0 { + // FIXME: re-use _reconnect to wait longer in background + return fmt.Errorf("Connection to XDS Server failure") + } + if err != nil { + return err + } + + // Check HTTP connection and establish WS connection + err = xs._connect(false) + + return err +} + +// IsTempoID returns true when server as a temporary id +func (xs *XdsServer) IsTempoID() bool { + return strings.HasPrefix(xs.ID, _IDTempoPrefix) +} + +// SetLoggerOutput Set logger ou +func (xs *XdsServer) SetLoggerOutput(out io.Writer) { + xs.logOut = out +} + +// SendCommand Send a command to XDS Server +func (xs *XdsServer) SendCommand(cmd string, body []byte) (*http.Response, error) { + url := cmd + if !strings.HasPrefix("/", cmd) { + url = "/" + cmd + } + return xs.client.HTTPPostWithRes(url, string(body)) +} + +// GetVersion Send Get request to retrieve XDS Server version +func (xs *XdsServer) GetVersion(res interface{}) error { + return xs._HTTPGet("/version", &res) +} + +// GetFolders Send GET request to get current folder configuration +func (xs *XdsServer) GetFolders(folders *[]XdsFolderConfig) error { + return xs._HTTPGet("/folders", folders) +} + +// FolderAdd Send POST request to add a folder +func (xs *XdsServer) FolderAdd(fld *XdsFolderConfig, res interface{}) error { + response, err := xs._HTTPPost("/folder", fld) + if err != nil { + return err + } + if response.StatusCode != 200 { + return fmt.Errorf("FolderAdd error status=%s", response.Status) + } + // Result is a XdsFolderConfig that is equivalent to ProjectConfig + err = json.Unmarshal(xs.client.ResponseToBArray(response), res) + + return err +} + +// FolderDelete Send DELETE request to delete a folder +func (xs *XdsServer) FolderDelete(id string) error { + return xs.client.HTTPDelete("/folder/" + id) +} + +// FolderSync Send POST request to force synchronization of a folder +func (xs *XdsServer) FolderSync(id string) error { + return xs.client.HTTPPost("/folder/sync/"+id, "") +} + +// SetAPIRouterGroup . +func (xs *XdsServer) SetAPIRouterGroup(r *gin.RouterGroup) { + xs.apiRouter = r +} + +// PassthroughGet Used to declare a route that sends directly a GET request to XDS Server +func (xs *XdsServer) PassthroughGet(url string) { + if xs.apiRouter == nil { + xs.Log.Errorf("apiRouter not set !") + return + } + + xs.apiRouter.GET(url, func(c *gin.Context) { + var data interface{} + if err := xs._HTTPGet(url, &data); err != nil { + if strings.Contains(err.Error(), "connection refused") { + xs.Connected = false + xs._NotifyState() + } + common.APIError(c, err.Error()) + return + } + + c.JSON(http.StatusOK, data) + }) +} + +// PassthroughPost Used to declare a route that sends directly a POST request to XDS Server +func (xs *XdsServer) PassthroughPost(url string) { + if xs.apiRouter == nil { + xs.Log.Errorf("apiRouter not set !") + return + } + + xs.apiRouter.POST(url, func(c *gin.Context) { + bodyReq := []byte{} + n, err := c.Request.Body.Read(bodyReq) + if err != nil { + common.APIError(c, err.Error()) + return + } + + response, err := xs._HTTPPost(url, bodyReq[:n]) + if err != nil { + common.APIError(c, err.Error()) + return + } + bodyRes, err := ioutil.ReadAll(response.Body) + if err != nil { + common.APIError(c, "Cannot read response body") + return + } + c.JSON(http.StatusOK, string(bodyRes)) + }) +} + +// EventRegister Post a request to register to an XdsServer event +func (xs *XdsServer) EventRegister(evName string, id string) error { + var err error + _, err = xs._HTTPPost("/events/register", XdsEventRegisterArgs{ + Name: evName, + ProjectID: id, + }) + return err +} + +// EventOn Register a callback on events reception +func (xs *XdsServer) EventOn(evName string, f func(interface{})) (uuid.UUID, error) { + if xs.ioSock == nil { + return uuid.Nil, fmt.Errorf("Io.Socket not initialized") + } + + xs.sockEventsLock.Lock() + defer xs.sockEventsLock.Unlock() + + if _, exist := xs.sockEvents[evName]; !exist { + // Register listener only the first time + evn := evName + + // FIXME: use generic type: data interface{} instead of data XdsEventFolderChange + var err error + if evName == "event:FolderStateChanged" { + err = xs.ioSock.On(evn, func(data XdsEventFolderChange) error { + xs.sockEventsLock.Lock() + defer xs.sockEventsLock.Unlock() + for _, c := range xs.sockEvents[evn] { + c.Func(data) + } + return nil + }) + } else { + err = xs.ioSock.On(evn, f) + } + if err != nil { + return uuid.Nil, err + } + } + + c := &caller{ + id: uuid.NewV1(), + EventName: evName, + Func: f, + } + + xs.sockEvents[evName] = append(xs.sockEvents[evName], c) + return c.id, nil +} + +// EventOff Un-register a (or all) callbacks associated to an event +func (xs *XdsServer) EventOff(evName string, id uuid.UUID) error { + xs.sockEventsLock.Lock() + defer xs.sockEventsLock.Unlock() + if _, exist := xs.sockEvents[evName]; exist { + if id == uuid.Nil { + // Un-register all + xs.sockEvents[evName] = []*caller{} + } else { + // Un-register only the specified callback + for i, ff := range xs.sockEvents[evName] { + if uuid.Equal(ff.id, id) { + xs.sockEvents[evName] = append(xs.sockEvents[evName][:i], xs.sockEvents[evName][i+1:]...) + break + } + } + } + } + return nil +} + +// ProjectToFolder Convert Project structure to Folder structure +func (xs *XdsServer) ProjectToFolder(pPrj ProjectConfig) *XdsFolderConfig { + stID := "" + if pPrj.Type == XdsTypeCloudSync { + stID, _ = xs.SThg.IDGet() + } + fPrj := XdsFolderConfig{ + ID: pPrj.ID, + Label: pPrj.Label, + ClientPath: pPrj.ClientPath, + Type: XdsFolderType(pPrj.Type), + Status: pPrj.Status, + IsInSync: pPrj.IsInSync, + DefaultSdk: pPrj.DefaultSdk, + DataPathMap: XdsPathMapConfig{ + ServerPath: pPrj.ServerPath, + }, + DataCloudSync: XdsCloudSyncConfig{ + SyncThingID: stID, + STLocIsInSync: pPrj.IsInSync, + STLocStatus: pPrj.Status, + STSvrIsInSync: pPrj.IsInSync, + STSvrStatus: pPrj.Status, + }, + } + + return &fPrj +} + +// FolderToProject Convert Folder structure to Project structure +func (xs *XdsServer) FolderToProject(fPrj XdsFolderConfig) ProjectConfig { + inSync := fPrj.IsInSync + sts := fPrj.Status + + if fPrj.Type == XdsTypeCloudSync { + inSync = fPrj.DataCloudSync.STSvrIsInSync && fPrj.DataCloudSync.STLocIsInSync + + sts = fPrj.DataCloudSync.STSvrStatus + switch fPrj.DataCloudSync.STLocStatus { + case StatusErrorConfig, StatusDisable, StatusPause: + sts = fPrj.DataCloudSync.STLocStatus + break + case StatusSyncing: + if sts != StatusErrorConfig && sts != StatusDisable && sts != StatusPause { + sts = StatusSyncing + } + break + case StatusEnable: + // keep STSvrStatus + break + } + } + + pPrj := ProjectConfig{ + ID: fPrj.ID, + ServerID: xs.ID, + Label: fPrj.Label, + ClientPath: fPrj.ClientPath, + ServerPath: fPrj.DataPathMap.ServerPath, + Type: ProjectType(fPrj.Type), + Status: sts, + IsInSync: inSync, + DefaultSdk: fPrj.DefaultSdk, + } + return pPrj +} + +/*** +** Private functions +***/ + +// Create HTTP client +func (xs *XdsServer) _CreateConnectHTTP() error { + var err error + xs.client, err = common.HTTPNewClient(xs.BaseURL, + common.HTTPClientConfig{ + URLPrefix: "/api/v1", + HeaderClientKeyName: "Xds-Sid", + CsrfDisable: true, + LogOut: xs.logOut, + LogPrefix: "XDSSERVER: ", + LogLevel: common.HTTPLogLevelWarning, + }) + + xs.client.SetLogLevel(xs.Log.Level.String()) + + if err != nil { + msg := ": " + err.Error() + if strings.Contains(err.Error(), "connection refused") { + msg = fmt.Sprintf("(url: %s)", xs.BaseURL) + } + return fmt.Errorf("ERROR: cannot connect to XDS Server %s", msg) + } + if xs.client == nil { + return fmt.Errorf("ERROR: cannot connect to XDS Server (null client)") + } + + return nil +} + +// _HTTPGet . +func (xs *XdsServer) _HTTPGet(url string, data interface{}) error { + var dd []byte + if err := xs.client.HTTPGet(url, &dd); err != nil { + return err + } + return json.Unmarshal(dd, &data) +} + +// _HTTPPost . +func (xs *XdsServer) _HTTPPost(url string, data interface{}) (*http.Response, error) { + body, err := json.Marshal(data) + if err != nil { + return nil, err + } + return xs.client.HTTPPostWithRes(url, string(body)) +} + +// Re-established connection +func (xs *XdsServer) _reconnect() error { + err := xs._connect(true) + if err == nil { + // Reload projects list for this server + err = xs.projects.Init(xs) + } + return err +} + +// Established HTTP and WS connection and retrieve XDSServer config +func (xs *XdsServer) _connect(reConn bool) error { + + xdsCfg := XdsServerConfig{} + if err := xs._HTTPGet("/config", &xdsCfg); err != nil { + xs.Connected = false + if !reConn { + xs._NotifyState() + } + return err + } + + if reConn && xs.ID != xdsCfg.ID { + xs.Log.Warningf("Reconnected to server but ID differs: old=%s, new=%s", xs.ID, xdsCfg.ID) + } + + // Update local XDS config + xs.ID = xdsCfg.ID + xs.ServerConfig = &xdsCfg + + // Establish WS connection and register listen + if err := xs._SocketConnect(); err != nil { + xs.Connected = false + xs._NotifyState() + return err + } + + xs.Connected = true + xs._NotifyState() + return nil +} + +// Create WebSocket (io.socket) connection +func (xs *XdsServer) _SocketConnect() error { + + xs.Log.Infof("Connecting IO.socket for server %s (url %s)", xs.ID, xs.BaseURL) + + opts := &sio_client.Options{ + Transport: "websocket", + Header: make(map[string][]string), + } + opts.Header["XDS-SID"] = []string{xs.client.GetClientID()} + + iosk, err := sio_client.NewClient(xs.BaseURL, opts) + if err != nil { + return fmt.Errorf("IO.socket connection error for server %s: %v", xs.ID, err) + } + xs.ioSock = iosk + + // Register some listeners + + iosk.On("error", func(err error) { + xs.Log.Infof("IO.socket Error server %s; err: %v", xs.ID, err) + if xs.CBOnError != nil { + xs.CBOnError(err) + } + }) + + iosk.On("disconnection", func(err error) { + xs.Log.Infof("IO.socket disconnection server %s", xs.ID) + if xs.CBOnDisconnect != nil { + xs.CBOnDisconnect(err) + } + xs.Connected = false + xs._NotifyState() + + // Try to reconnect during 15min (or at least while not disabled) + go func() { + count := 0 + waitTime := 1 + for !xs.Disabled && !xs.Connected { + count++ + if count%60 == 0 { + waitTime *= 5 + } + if waitTime > 15*60 { + xs.Log.Infof("Stop reconnection to server url=%s id=%s !", xs.BaseURL, xs.ID) + return + } + time.Sleep(time.Second * time.Duration(waitTime)) + xs.Log.Infof("Try to reconnect to server %s (%d)", xs.BaseURL, count) + + xs._reconnect() + } + }() + }) + + // XXX - There is no connection event generated so, just consider that + // we are connected when NewClient return successfully + /* iosk.On("connection", func() { ... }) */ + xs.Log.Infof("IO.socket connected server url=%s id=%s", xs.BaseURL, xs.ID) + + return nil +} + +// Send event to notify changes +func (xs *XdsServer) _NotifyState() { + + evSts := ServerCfg{ + ID: xs.ID, + URL: xs.BaseURL, + APIURL: xs.APIURL, + PartialURL: xs.PartialURL, + ConnRetry: xs.ConnRetry, + Connected: xs.Connected, + } + if err := xs.events.Emit(EVTServerConfig, evSts); err != nil { + xs.Log.Warningf("Cannot notify XdsServer state change: %v", err) + } +} diff --git a/lib/apiv1/apiv1.go b/lib/apiv1/apiv1.go deleted file mode 100644 index 734929b..0000000 --- a/lib/apiv1/apiv1.go +++ /dev/null @@ -1,36 +0,0 @@ -package apiv1 - -import ( - "github.com/Sirupsen/logrus" - "github.com/gin-gonic/gin" - - "github.com/iotbzh/xds-agent/lib/session" - "github.com/iotbzh/xds-agent/lib/xdsconfig" -) - -// APIService . -type APIService struct { - router *gin.Engine - apiRouter *gin.RouterGroup - sessions *session.Sessions - cfg *xdsconfig.Config - log *logrus.Logger -} - -// New creates a new instance of API service -func New(sess *session.Sessions, conf *xdsconfig.Config, log *logrus.Logger, r *gin.Engine) *APIService { - s := &APIService{ - router: r, - sessions: sess, - apiRouter: r.Group("/api/v1"), - cfg: conf, - log: log, - } - - s.apiRouter.GET("/version", s.getVersion) - - s.apiRouter.GET("/config", s.getConfig) - s.apiRouter.POST("/config", s.setConfig) - - return s -} diff --git a/lib/apiv1/config.go b/lib/apiv1/config.go deleted file mode 100644 index 47155ed..0000000 --- a/lib/apiv1/config.go +++ /dev/null @@ -1,45 +0,0 @@ -package apiv1 - -import ( - "net/http" - "sync" - - "github.com/gin-gonic/gin" - "github.com/iotbzh/xds-agent/lib/xdsconfig" - common "github.com/iotbzh/xds-common/golib" -) - -var confMut sync.Mutex - -// GetConfig returns the configuration -func (s *APIService) getConfig(c *gin.Context) { - confMut.Lock() - defer confMut.Unlock() - - c.JSON(http.StatusOK, s.cfg) -} - -// SetConfig sets configuration -func (s *APIService) setConfig(c *gin.Context) { - // FIXME - must be tested - c.JSON(http.StatusNotImplemented, "Not implemented") - - var cfgArg xdsconfig.Config - - if c.BindJSON(&cfgArg) != nil { - common.APIError(c, "Invalid arguments") - return - } - - confMut.Lock() - defer confMut.Unlock() - - s.log.Debugln("SET config: ", cfgArg) - - if err := s.cfg.UpdateAll(cfgArg); err != nil { - common.APIError(c, err.Error()) - return - } - - c.JSON(http.StatusOK, s.cfg) -} diff --git a/lib/apiv1/version.go b/lib/apiv1/version.go deleted file mode 100644 index e022441..0000000 --- a/lib/apiv1/version.go +++ /dev/null @@ -1,24 +0,0 @@ -package apiv1 - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -type version struct { - Version string `json:"version"` - APIVersion string `json:"apiVersion"` - VersionGitTag string `json:"gitTag"` -} - -// getInfo : return various information about server -func (s *APIService) getVersion(c *gin.Context) { - response := version{ - Version: s.cfg.Version, - APIVersion: s.cfg.APIVersion, - VersionGitTag: s.cfg.VersionGitTag, - } - - c.JSON(http.StatusOK, response) -} diff --git a/lib/syncthing/st.go b/lib/syncthing/st.go index 6199a8f..031a2ac 100644 --- a/lib/syncthing/st.go +++ b/lib/syncthing/st.go @@ -24,11 +24,14 @@ import ( // SyncThing . type SyncThing struct { - BaseURL string - APIKey string - Home string - STCmd *exec.Cmd - STICmd *exec.Cmd + BaseURL string + APIKey string + Home string + STCmd *exec.Cmd + STICmd *exec.Cmd + MyID string + Connected bool + Events *Events // Private fields binDir string @@ -37,6 +40,7 @@ type SyncThing struct { exitSTIChan chan ExitChan client *common.HTTPClient log *logrus.Logger + conf *xdsconfig.Config } // ExitChan Channel used for process exit @@ -45,6 +49,42 @@ type ExitChan struct { err error } +// ConfigInSync Check whether if Syncthing configuration is in sync +type configInSync struct { + ConfigInSync bool `json:"configInSync"` +} + +// FolderStatus Information about the current status of a folder. +type FolderStatus struct { + GlobalFiles int `json:"globalFiles"` + GlobalDirectories int `json:"globalDirectories"` + GlobalSymlinks int `json:"globalSymlinks"` + GlobalDeleted int `json:"globalDeleted"` + GlobalBytes int64 `json:"globalBytes"` + + LocalFiles int `json:"localFiles"` + LocalDirectories int `json:"localDirectories"` + LocalSymlinks int `json:"localSymlinks"` + LocalDeleted int `json:"localDeleted"` + LocalBytes int64 `json:"localBytes"` + + NeedFiles int `json:"needFiles"` + NeedDirectories int `json:"needDirectories"` + NeedSymlinks int `json:"needSymlinks"` + NeedDeletes int `json:"needDeletes"` + NeedBytes int64 `json:"needBytes"` + + InSyncFiles int `json:"inSyncFiles"` + InSyncBytes int64 `json:"inSyncBytes"` + + State string `json:"state"` + StateChanged time.Time `json:"stateChanged"` + + Sequence int64 `json:"sequence"` + + IgnorePatterns bool `json:"ignorePatterns"` +} + // NewSyncThing creates a new instance of Syncthing func NewSyncThing(conf *xdsconfig.Config, log *logrus.Logger) *SyncThing { var url, apiKey, home, binDir string @@ -75,8 +115,12 @@ func NewSyncThing(conf *xdsconfig.Config, log *logrus.Logger) *SyncThing { binDir: binDir, logsDir: conf.FileConf.LogsDir, log: log, + conf: conf, } + // Create Events monitoring + s.Events = s.NewEventListener() + return &s } @@ -86,8 +130,9 @@ func (s *SyncThing) startProc(exeName string, args []string, env []string, eChan var exePath string // Kill existing process (useful for debug ;-) ) - if os.Getenv("DEBUG_MODE") != "" { - exec.Command("bash", "-c", "pkill -9 "+exeName).Output() + if _, dbg := os.LookupEnv("XDS_DEBUG_MODE"); dbg { + fmt.Printf("\n!!! DEBUG_MODE set: KILL existing %s process(es) !!!\n", exeName) + exec.Command("bash", "-c", "ps -ax |grep "+exeName+" |grep "+s.BaseURL+" |cut -d' ' -f 1|xargs -I{} kill -9 {}").Output() } // When not set (or set to '.') set bin to path of xds-agent executable @@ -182,6 +227,8 @@ func (s *SyncThing) Start() (*exec.Cmd, error) { "STNOUPGRADE=1", } + /* FIXME - STILL NEEDED ?, if not SUP code + // XXX - temporary hack because -gui-apikey seems to correctly handle by // syncthing the early first time stConfigFile := filepath.Join(s.Home, "config.xml") @@ -211,12 +258,12 @@ func (s *SyncThing) Start() (*exec.Cmd, error) { return nil, fmt.Errorf("Cannot write Syncthing config file to set apikey") } } - + */ s.STCmd, err = s.startProc("syncthing", args, env, &s.exitSTChan) // Use autogenerated apikey if not set by config.json - if s.APIKey == "" { - if fd, err := os.Open(stConfigFile); err == nil { + if err == nil && s.APIKey == "" { + if fd, err := os.Open(filepath.Join(s.Home, "config.xml")); err == nil { defer fd.Close() if b, err := ioutil.ReadAll(fd); err == nil { re := regexp.MustCompile("<apikey>(.*)</apikey>") @@ -294,11 +341,17 @@ func (s *SyncThing) StopInotify() { // Connect Establish HTTP connection with Syncthing func (s *SyncThing) Connect() error { var err error + s.Connected = false s.client, err = common.HTTPNewClient(s.BaseURL, common.HTTPClientConfig{ URLPrefix: "/rest", HeaderClientKeyName: "X-Syncthing-ID", + LogOut: s.conf.LogVerboseOut, + LogPrefix: "SYNCTHING: ", + LogLevel: common.HTTPLogLevelWarning, }) + s.client.SetLogLevel(s.log.Level.String()) + if err != nil { msg := ": " + err.Error() if strings.Contains(err.Error(), "connection refused") { @@ -310,11 +363,17 @@ func (s *SyncThing) Connect() error { return fmt.Errorf("ERROR: cannot connect to Syncthing (null client)") } - s.client.SetLogLevel(s.log.Level.String()) - s.client.LoggerPrefix = "SYNCTHING: " - s.client.LoggerOut = s.log.Out + s.MyID, err = s.IDGet() + if err != nil { + return fmt.Errorf("ERROR: cannot retrieve ID") + } + + s.Connected = true - return nil + // Start events monitoring + err = s.Events.Start() + + return err } // IDGet returns the Syncthing ID of Syncthing instance running locally @@ -347,3 +406,16 @@ func (s *SyncThing) ConfigSet(cfg config.Configuration) error { } return s.client.HTTPPost("system/config", string(body)) } + +// IsConfigInSync Returns true if configuration is in sync +func (s *SyncThing) IsConfigInSync() (bool, error) { + var data []byte + var d configInSync + if err := s.client.HTTPGet("system/config/insync", &data); err != nil { + return false, err + } + if err := json.Unmarshal(data, &d); err != nil { + return false, err + } + return d.ConfigInSync, nil +} diff --git a/lib/syncthing/stEvent.go b/lib/syncthing/stEvent.go new file mode 100644 index 0000000..0017555 --- /dev/null +++ b/lib/syncthing/stEvent.go @@ -0,0 +1,265 @@ +package st + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/Sirupsen/logrus" +) + +// Events . +type Events struct { + MonitorTime time.Duration + Debug bool + + stop chan bool + st *SyncThing + log *logrus.Logger + cbArr map[string][]cbMap +} + +type Event struct { + Type string `json:"type"` + Time time.Time `json:"time"` + Data map[string]string `json:"data"` +} + +type EventsCBData map[string]interface{} +type EventsCB func(ev Event, cbData *EventsCBData) + +const ( + EventFolderCompletion string = "FolderCompletion" + EventFolderSummary string = "FolderSummary" + EventFolderPaused string = "FolderPaused" + EventFolderResumed string = "FolderResumed" + EventFolderErrors string = "FolderErrors" + EventStateChanged string = "StateChanged" +) + +var EventsAll string = EventFolderCompletion + "|" + + EventFolderSummary + "|" + + EventFolderPaused + "|" + + EventFolderResumed + "|" + + EventFolderErrors + "|" + + EventStateChanged + +type STEvent struct { + // Per-subscription sequential event ID. Named "id" for backwards compatibility with the REST API + SubscriptionID int `json:"id"` + // Global ID of the event across all subscriptions + GlobalID int `json:"globalID"` + Time time.Time `json:"time"` + Type string `json:"type"` + Data map[string]interface{} `json:"data"` +} + +type cbMap struct { + id int + cb EventsCB + filterID string + data *EventsCBData +} + +// NewEventListener Create a new instance of Event listener +func (s *SyncThing) NewEventListener() *Events { + _, dbg := os.LookupEnv("XDS_LOG_SILLY_STEVENTS") // set to add more debug log + return &Events{ + MonitorTime: 100, // in Milliseconds + Debug: dbg, + stop: make(chan bool, 1), + st: s, + log: s.log, + cbArr: make(map[string][]cbMap), + } +} + +// Start starts event monitoring loop +func (e *Events) Start() error { + go e.monitorLoop() + return nil +} + +// Stop stops event monitoring loop +func (e *Events) Stop() { + e.stop <- true +} + +// Register Add a listener on an event +func (e *Events) Register(evName string, cb EventsCB, filterID string, data *EventsCBData) (int, error) { + if evName == "" || !strings.Contains(EventsAll, evName) { + return -1, fmt.Errorf("Unknown event name") + } + if data == nil { + data = &EventsCBData{} + } + + cbList := []cbMap{} + if _, ok := e.cbArr[evName]; ok { + cbList = e.cbArr[evName] + } + + id := len(cbList) + (*data)["id"] = strconv.Itoa(id) + + e.cbArr[evName] = append(cbList, cbMap{id: id, cb: cb, filterID: filterID, data: data}) + + return id, nil +} + +// UnRegister Remove a listener event +func (e *Events) UnRegister(evName string, id int) error { + cbKey, ok := e.cbArr[evName] + if !ok { + return fmt.Errorf("No event registered to such name") + } + + // FIXME - NOT TESTED + if id >= len(cbKey) { + return fmt.Errorf("Invalid id") + } else if id == len(cbKey) { + e.cbArr[evName] = cbKey[:id-1] + } else { + e.cbArr[evName] = cbKey[id : id+1] + } + + return nil +} + +// GetEvents returns the Syncthing events +func (e *Events) getEvents(since int) ([]STEvent, error) { + var data []byte + ev := []STEvent{} + url := "events" + if since != -1 { + url += "?since=" + strconv.Itoa(since) + } + if err := e.st.client.HTTPGet(url, &data); err != nil { + return ev, err + } + err := json.Unmarshal(data, &ev) + return ev, err +} + +// Loop to monitor Syncthing events +func (e *Events) monitorLoop() { + e.log.Infof("Event monitoring running...") + since := 0 + cntErrConn := 0 + cntErrRetry := 1 + for { + select { + case <-e.stop: + e.log.Infof("Event monitoring exited") + return + + case <-time.After(e.MonitorTime * time.Millisecond): + + if !e.st.Connected { + cntErrConn++ + time.Sleep(time.Second) + if cntErrConn > cntErrRetry { + e.log.Error("ST Event monitor: ST connection down") + cntErrConn = 0 + cntErrRetry *= 2 + if _, err := e.getEvents(since); err == nil { + e.st.Connected = true + cntErrRetry = 1 + // XXX - should we reset since value ? + goto readEvent + } + } + continue + } + + readEvent: + stEvArr, err := e.getEvents(since) + if err != nil { + e.log.Errorf("Syncthing Get Events: %v", err) + e.st.Connected = false + continue + } + + // Process events + for _, stEv := range stEvArr { + since = stEv.SubscriptionID + if e.Debug { + e.log.Warnf("ST EVENT: %d %s\n %v", stEv.GlobalID, stEv.Type, stEv) + } + + cbKey, ok := e.cbArr[stEv.Type] + if !ok { + continue + } + + evData := Event{ + Type: stEv.Type, + Time: stEv.Time, + } + + // Decode Events + // FIXME: re-define data struct for each events + // instead of map of string and use JSON marshing/unmarshing + fID := "" + evData.Data = make(map[string]string) + switch stEv.Type { + + case EventFolderCompletion: + fID = convString(stEv.Data["folder"]) + evData.Data["completion"] = convFloat64(stEv.Data["completion"]) + + case EventFolderSummary: + fID = convString(stEv.Data["folder"]) + evData.Data["needBytes"] = convInt64(stEv.Data["needBytes"]) + evData.Data["state"] = convString(stEv.Data["state"]) + + case EventFolderPaused, EventFolderResumed: + fID = convString(stEv.Data["id"]) + evData.Data["label"] = convString(stEv.Data["label"]) + + case EventFolderErrors: + fID = convString(stEv.Data["folder"]) + // TODO decode array evData.Data["errors"] = convString(stEv.Data["errors"]) + + case EventStateChanged: + fID = convString(stEv.Data["folder"]) + evData.Data["from"] = convString(stEv.Data["from"]) + evData.Data["to"] = convString(stEv.Data["to"]) + + default: + e.log.Warnf("Unsupported event type") + } + + if fID != "" { + evData.Data["id"] = fID + } + + // Call all registered callbacks + for _, c := range cbKey { + if e.Debug { + e.log.Warnf("EVENT CB fID=%s, filterID=%s", fID, c.filterID) + } + // Call when filterID is not set or when it matches + if c.filterID == "" || (fID != "" && fID == c.filterID) { + c.cb(evData, c.data) + } + } + } + } + } +} + +func convString(d interface{}) string { + return d.(string) +} + +func convFloat64(d interface{}) string { + return strconv.FormatFloat(d.(float64), 'f', -1, 64) +} + +func convInt64(d interface{}) string { + return strconv.FormatInt(d.(int64), 10) +} diff --git a/lib/syncthing/stfolder.go b/lib/syncthing/stfolder.go index d79e579..196e3c7 100644 --- a/lib/syncthing/stfolder.go +++ b/lib/syncthing/stfolder.go @@ -1,42 +1,82 @@ package st import ( - "path/filepath" + "encoding/json" + "fmt" "strings" - "github.com/syncthing/syncthing/lib/config" + common "github.com/iotbzh/xds-common/golib" + stconfig "github.com/syncthing/syncthing/lib/config" "github.com/syncthing/syncthing/lib/protocol" ) -// FIXME remove and use an interface on xdsconfig.FolderConfig +// FolderChangeArg argument structure used by FolderChange type FolderChangeArg struct { ID string Label string RelativePath string SyncThingID string - ShareRootDir string } +// FolderLoadFromStConfig Load/Retrieve folder config from syncthing database +/* +func (s *SyncThing) FolderLoadFromStConfig(f *[]XdsFolderConfig) error { + + defaultSdk := "" // cannot know which was the default sdk + + stCfg, err := s.ConfigGet() + if err != nil { + return err + } + if len(stCfg.Devices) < 1 { + return fmt.Errorf("Cannot load syncthing config: no device defined") + } + devID := stCfg.Devices[0].DeviceID.String() + if devID == s.MyID { + if len(stCfg.Devices) < 2 { + return fmt.Errorf("Cannot load syncthing config: no valid device found") + } + devID = stCfg.Devices[1].DeviceID.String() + } + + for _, stFld := range stCfg.Folders { + *f = append(*f, XdsFolderConfig{ + ID: stFld.ID, + Label: stFld.Label, + ClientPath: strings.TrimRight(stFld.Path, "/"), + Type: XdsTypeCloudSync, + Status: StatusDisable, + DefaultSdk: defaultSdk, + RootPath: "", + DataCloudSync: XdsCloudSyncConfig{SyncThingID: devID}, + }) + } + + return nil +} +*/ + // FolderChange is called when configuration has changed -func (s *SyncThing) FolderChange(f FolderChangeArg) error { +func (s *SyncThing) FolderChange(f FolderChangeArg) (string, error) { // Get current config stCfg, err := s.ConfigGet() if err != nil { s.log.Errorln(err) - return err + return "", err } + stClientID := f.SyncThingID // Add new Device if needed var devID protocol.DeviceID - if err := devID.UnmarshalText([]byte(f.SyncThingID)); err != nil { - s.log.Errorf("not a valid device id (err %v)\n", err) - return err + if err := devID.UnmarshalText([]byte(stClientID)); err != nil { + s.log.Errorf("not a valid device id (err %v)", err) + return "", err } - newDevice := config.DeviceConfiguration{ + newDevice := stconfig.DeviceConfiguration{ DeviceID: devID, - Name: f.SyncThingID, + Name: stClientID, Addresses: []string{"dynamic"}, } @@ -57,21 +97,34 @@ func (s *SyncThing) FolderChange(f FolderChangeArg) error { label = strings.Split(id, "/")[0] } if id = f.ID; id == "" { - id = f.SyncThingID[0:15] + "_" + label + id = stClientID[0:15] + "_" + label + } + + // Resolve local path + pathCli, err := common.ResolveEnvVar(f.RelativePath) + if err != nil { + pathCli = f.RelativePath + } + + folder := stconfig.FolderConfiguration{ + ID: id, + Label: label, + Path: pathCli, + AutoNormalize: true, } - folder := config.FolderConfiguration{ - ID: id, - Label: label, - RawPath: filepath.Join(f.ShareRootDir, f.RelativePath), + /* TODO - add it ? + if s.conf.FileConf.SThgConf.RescanIntervalS > 0 { + folder.RescanIntervalS = s.conf.FileConf.SThgConf.RescanIntervalS } + */ - folder.Devices = append(folder.Devices, config.FolderDeviceConfiguration{ + folder.Devices = append(folder.Devices, stconfig.FolderDeviceConfiguration{ DeviceID: newDevice.DeviceID, }) found = false - var fld config.FolderConfiguration + var fld stconfig.FolderConfiguration for _, fld = range stCfg.Folders { if folder.ID == fld.ID { fld = folder @@ -85,11 +138,8 @@ func (s *SyncThing) FolderChange(f FolderChangeArg) error { } err = s.ConfigSet(stCfg) - if err != nil { - s.log.Errorln(err) - } - return nil + return id, err } // FolderDelete is called to delete a folder config @@ -114,3 +164,61 @@ func (s *SyncThing) FolderDelete(id string) error { return nil } + +// FolderConfigGet Returns the configuration of a specific folder +func (s *SyncThing) FolderConfigGet(folderID string) (stconfig.FolderConfiguration, error) { + fc := stconfig.FolderConfiguration{} + if folderID == "" { + return fc, fmt.Errorf("folderID not set") + } + cfg, err := s.ConfigGet() + if err != nil { + return fc, err + } + for _, f := range cfg.Folders { + if f.ID == folderID { + fc = f + return fc, nil + } + } + return fc, fmt.Errorf("id not found") +} + +// FolderStatus Returns all information about the current +func (s *SyncThing) FolderStatus(folderID string) (*FolderStatus, error) { + var data []byte + var res FolderStatus + if folderID == "" { + return nil, fmt.Errorf("folderID not set") + } + if err := s.client.HTTPGet("db/status?folder="+folderID, &data); err != nil { + return nil, err + } + if err := json.Unmarshal(data, &res); err != nil { + return nil, err + } + return &res, nil +} + +// IsFolderInSync Returns true when folder is in sync +func (s *SyncThing) IsFolderInSync(folderID string) (bool, error) { + sts, err := s.FolderStatus(folderID) + if err != nil { + return false, err + } + return sts.NeedBytes == 0 && sts.State == "idle", nil +} + +// FolderScan Request immediate folder scan. +// Scan all folders if folderID param is empty +func (s *SyncThing) FolderScan(folderID string, subpath string) error { + url := "db/scan" + if folderID != "" { + url += "?folder=" + folderID + + if subpath != "" { + url += "&sub=" + subpath + } + } + return s.client.HTTPPost(url, "") +} diff --git a/lib/webserver/server.go b/lib/webserver/server.go deleted file mode 100644 index b835a65..0000000 --- a/lib/webserver/server.go +++ /dev/null @@ -1,226 +0,0 @@ -package webserver - -import ( - "fmt" - "net/http" - "strings" - - "github.com/Sirupsen/logrus" - "github.com/gin-gonic/gin" - "github.com/googollee/go-socket.io" - "github.com/iotbzh/xds-agent/lib/apiv1" - "github.com/iotbzh/xds-agent/lib/session" - "github.com/iotbzh/xds-agent/lib/xdsconfig" -) - -// ServerService . -type ServerService struct { - router *gin.Engine - api *apiv1.APIService - sIOServer *socketio.Server - webApp *gin.RouterGroup - cfg *xdsconfig.Config - sessions *session.Sessions - log *logrus.Logger - stop chan struct{} // signals intentional stop -} - -const indexFilename = "index.html" -const cookieMaxAge = "3600" - -// New creates an instance of ServerService -func New(conf *xdsconfig.Config, log *logrus.Logger) *ServerService { - - // Setup logging for gin router - if log.Level == logrus.DebugLevel { - gin.SetMode(gin.DebugMode) - } else { - gin.SetMode(gin.ReleaseMode) - } - - // TODO - // - try to bind gin DefaultWriter & DefaultErrorWriter to logrus logger - // - try to fix pb about isTerminal=false when out is in VSC Debug Console - //gin.DefaultWriter = ?? - //gin.DefaultErrorWriter = ?? - - // Creates gin router - r := gin.New() - - svr := &ServerService{ - router: r, - api: nil, - sIOServer: nil, - webApp: nil, - cfg: conf, - log: log, - sessions: nil, - stop: make(chan struct{}), - } - - return svr -} - -// Serve starts a new instance of the Web Server -func (s *ServerService) Serve() error { - var err error - - // Setup middlewares - s.router.Use(gin.Logger()) - s.router.Use(gin.Recovery()) - s.router.Use(s.middlewareCORS()) - s.router.Use(s.middlewareXDSDetails()) - s.router.Use(s.middlewareCSRF()) - - // Sessions manager - s.sessions = session.NewClientSessions(s.router, s.log, cookieMaxAge) - - s.router.GET("", s.slashHandler) - - // Create REST API - s.api = apiv1.New(s.sessions, s.cfg, s.log, s.router) - - // Websocket routes - s.sIOServer, err = socketio.NewServer(nil) - if err != nil { - s.log.Fatalln(err) - } - - s.router.GET("/socket.io/", s.socketHandler) - s.router.POST("/socket.io/", s.socketHandler) - /* TODO: do we want to support ws://... ? - s.router.Handle("WS", "/socket.io/", s.socketHandler) - s.router.Handle("WSS", "/socket.io/", s.socketHandler) - */ - - // Serve in the background - serveError := make(chan error, 1) - go func() { - fmt.Printf("Web Server running on localhost:%s ...\n", s.cfg.HTTPPort) - serveError <- http.ListenAndServe(":"+s.cfg.HTTPPort, s.router) - }() - - fmt.Printf("XDS agent running...\n") - - // Wait for stop, restart or error signals - select { - case <-s.stop: - // Shutting down permanently - s.sessions.Stop() - s.log.Infoln("shutting down (stop)") - case err = <-serveError: - // Error due to listen/serve failure - s.log.Errorln(err) - } - - return nil -} - -// Stop web server -func (s *ServerService) Stop() { - close(s.stop) -} - -// serveSlash provides response to GET "/" -func (s *ServerService) slashHandler(c *gin.Context) { - c.String(200, "Hello from XDS agent!") -} - -// Add details in Header -func (s *ServerService) middlewareXDSDetails() gin.HandlerFunc { - return func(c *gin.Context) { - c.Header("XDS-Agent-Version", s.cfg.Version) - c.Header("XDS-API-Version", s.cfg.APIVersion) - c.Next() - } -} - -func (s *ServerService) isValidAPIKey(key string) bool { - return (key == s.cfg.FileConf.XDSAPIKey && key != "") -} - -func (s *ServerService) middlewareCSRF() gin.HandlerFunc { - return func(c *gin.Context) { - // Allow requests carrying a valid API key - if s.isValidAPIKey(c.Request.Header.Get("X-API-Key")) { - // Set the access-control-allow-origin header for CORS requests - // since a valid API key has been provided - c.Header("Access-Control-Allow-Origin", "*") - c.Next() - return - } - - // Allow io.socket request - if strings.HasPrefix(c.Request.URL.Path, "/socket.io") { - c.Next() - return - } - - /* FIXME Add really CSRF support - - // Allow requests for anything not under the protected path prefix, - // and set a CSRF cookie if there isn't already a valid one. - if !strings.HasPrefix(c.Request.URL.Path, prefix) { - cookie, err := c.Cookie("CSRF-Token-" + unique) - if err != nil || !validCsrfToken(cookie.Value) { - s.log.Debugln("new CSRF cookie in response to request for", c.Request.URL) - c.SetCookie("CSRF-Token-"+unique, newCsrfToken(), 600, "/", "", false, false) - } - c.Next() - return - } - - // Verify the CSRF token - token := c.Request.Header.Get("X-CSRF-Token-" + unique) - if !validCsrfToken(token) { - c.AbortWithError(403, "CSRF Error") - return - } - - c.Next() - */ - c.AbortWithError(403, fmt.Errorf("Not valid API key")) - } -} - -// CORS middleware -func (s *ServerService) middlewareCORS() gin.HandlerFunc { - return func(c *gin.Context) { - if c.Request.Method == "OPTIONS" { - c.Header("Access-Control-Allow-Origin", "*") - c.Header("Access-Control-Allow-Headers", "Content-Type, X-API-Key") - c.Header("Access-Control-Allow-Methods", "GET, POST, DELETE") - c.Header("Access-Control-Max-Age", cookieMaxAge) - c.AbortWithStatus(204) - return - } - c.Next() - } -} - -// socketHandler is the handler for the "main" websocket connection -func (s *ServerService) socketHandler(c *gin.Context) { - - // Retrieve user session - sess := s.sessions.Get(c) - if sess == nil { - c.JSON(500, gin.H{"error": "Cannot retrieve session"}) - return - } - - s.sIOServer.On("connection", func(so socketio.Socket) { - s.log.Debugf("WS Connected (SID=%v)", so.Id()) - s.sessions.UpdateIOSocket(sess.ID, &so) - - so.On("disconnection", func() { - s.log.Debugf("WS disconnected (SID=%v)", so.Id()) - s.sessions.UpdateIOSocket(sess.ID, nil) - }) - }) - - s.sIOServer.On("error", func(so socketio.Socket, err error) { - s.log.Errorf("WS SID=%v Error : %v", so.Id(), err.Error()) - }) - - s.sIOServer.ServeHTTP(c.Writer, c.Request) -} diff --git a/lib/xdsconfig/config.go b/lib/xdsconfig/config.go index 854d383..9e44070 100644 --- a/lib/xdsconfig/config.go +++ b/lib/xdsconfig/config.go @@ -2,24 +2,34 @@ package xdsconfig import ( "fmt" + "io" + "path/filepath" "os" "github.com/Sirupsen/logrus" "github.com/codegangsta/cli" common "github.com/iotbzh/xds-common/golib" + uuid "github.com/satori/go.uuid" ) // Config parameters (json format) of /config command type Config struct { - Version string `json:"version"` - APIVersion string `json:"apiVersion"` - VersionGitTag string `json:"gitTag"` - - // Private / un-exported fields - HTTPPort string `json:"-"` - FileConf *FileConfig `json:"-"` - Log *logrus.Logger `json:"-"` + AgentUID string + Version string + APIVersion string + VersionGitTag string + Options Options + FileConf FileConfig + Log *logrus.Logger + LogVerboseOut io.Writer +} + +// Options set at the command line +type Options struct { + ConfigFile string + LogLevel string + LogFile string } // Config default values @@ -32,39 +42,81 @@ const ( func Init(ctx *cli.Context, log *logrus.Logger) (*Config, error) { var err error + defaultWebAppDir := "${EXEPATH}/www" + defaultSTHomeDir := "${HOME}/.xds/agent/syncthing-config" + + // TODO: allocate uuid only the first time and save+reuse it later + uuid := uuid.NewV1().String() + // Define default configuration c := Config{ + AgentUID: uuid, Version: ctx.App.Metadata["version"].(string), APIVersion: DefaultAPIVersion, VersionGitTag: ctx.App.Metadata["git-tag"].(string), - HTTPPort: "8010", - FileConf: &FileConfig{ - LogsDir: "/tmp/logs", + Options: Options{ + ConfigFile: ctx.GlobalString("config"), + LogLevel: ctx.GlobalString("log"), + LogFile: ctx.GlobalString("logfile"), + }, + + FileConf: FileConfig{ + HTTPPort: "8800", + WebAppDir: defaultWebAppDir, + LogsDir: "/tmp/logs", + ServersConf: []XDSServerConf{ + XDSServerConf{ + URL: "http://localhost:8000", + ConnRetry: 10, + }, + }, SThgConf: &SyncThingConf{ - Home: "${HOME}/.xds/agent/syncthing-config", + Home: defaultSTHomeDir, }, }, Log: log, } + c.Log.Infoln("Agent UUID: ", uuid) + // config file settings overwrite default config - c.FileConf, err = updateConfigFromFile(&c, ctx.GlobalString("config")) + err = readGlobalConfig(&c, c.Options.ConfigFile) if err != nil { return nil, err } + // Handle where Logs are redirected: + // default 'stdout' (logfile option default value) + // else use file (or filepath) set by --logfile option + // that may be overwritten by LogsDir field of config file + logF := c.Options.LogFile + logD := c.FileConf.LogsDir + if logF != "stdout" { + if logD != "" { + lf := filepath.Base(logF) + if lf == "" || lf == "." { + lf = "xds-agent.log" + } + logF = filepath.Join(logD, lf) + } else { + logD = filepath.Dir(logF) + } + } + if logD == "" || logD == "." { + logD = "/tmp/xds/logs" + } + c.Options.LogFile = logF + c.FileConf.LogsDir = logD + if c.FileConf.LogsDir != "" && !common.Exists(c.FileConf.LogsDir) { if err := os.MkdirAll(c.FileConf.LogsDir, 0770); err != nil { return nil, fmt.Errorf("Cannot create logs dir: %v", err) } } + + c.Log.Infoln("Logs file: ", c.Options.LogFile) c.Log.Infoln("Logs directory: ", c.FileConf.LogsDir) return &c, nil } - -// UpdateAll Update the current configuration -func (c *Config) UpdateAll(newCfg Config) error { - return fmt.Errorf("Not Supported") -} diff --git a/lib/xdsconfig/configfile.go b/lib/xdsconfig/configfile.go new file mode 100644 index 0000000..e3737f4 --- /dev/null +++ b/lib/xdsconfig/configfile.go @@ -0,0 +1,109 @@ +package xdsconfig + +import ( + "encoding/json" + "os" + "path" + + common "github.com/iotbzh/xds-common/golib" +) + +type SyncThingConf struct { + BinDir string `json:"binDir"` + Home string `json:"home"` + GuiAddress string `json:"gui-address"` + GuiAPIKey string `json:"gui-apikey"` +} + +type XDSServerConf struct { + URL string `json:"url"` + ConnRetry int `json:"connRetry"` + + // private/not exported fields + ID string `json:"-"` + APIBaseURL string `json:"-"` + APIPartialURL string `json:"-"` +} + +type FileConfig struct { + HTTPPort string `json:"httpPort"` + WebAppDir string `json:"webAppDir"` + LogsDir string `json:"logsDir"` + XDSAPIKey string `json:"xds-apikey"` + ServersConf []XDSServerConf `json:"xdsServers"` + SThgConf *SyncThingConf `json:"syncthing"` +} + +// readGlobalConfig reads configuration from a config file. +// Order to determine which config file is used: +// 1/ from command line option: "--config myConfig.json" +// 2/ $HOME/.xds/agent/agent-config.json file +// 3/ /etc/xds-agent/config.json file + +func readGlobalConfig(c *Config, confFile string) error { + + searchIn := make([]string, 0, 3) + if confFile != "" { + searchIn = append(searchIn, confFile) + } + if homeDir := common.GetUserHome(); homeDir != "" { + searchIn = append(searchIn, path.Join(homeDir, ".xds", "agent", "agent-config.json")) + } + + searchIn = append(searchIn, "/etc/xds-agent/agent-config.json") + + var cFile *string + for _, p := range searchIn { + if _, err := os.Stat(p); err == nil { + cFile = &p + break + } + } + if cFile == nil { + c.Log.Infof("No config file found") + return nil + } + + c.Log.Infof("Use config file: %s", *cFile) + + // TODO move on viper package to support comments in JSON and also + // bind with flags (command line options) + // see https://github.com/spf13/viper#working-with-flags + + fd, _ := os.Open(*cFile) + defer fd.Close() + + // Decode config file content and save it in a first variable + fCfg := FileConfig{} + if err := json.NewDecoder(fd).Decode(&fCfg); err != nil { + return err + } + + // Decode config file content and overwrite default settings + fd.Seek(0, 0) + json.NewDecoder(fd).Decode(&c.FileConf) + + // Disable Syncthing support when there is no syncthing field in config + if fCfg.SThgConf == nil { + c.FileConf.SThgConf = nil + } + + // Support environment variables (IOW ${MY_ENV_VAR} syntax) in agent-config.json + vars := []*string{ + &c.FileConf.LogsDir, + &c.FileConf.WebAppDir, + } + if c.FileConf.SThgConf != nil { + vars = append(vars, &c.FileConf.SThgConf.Home, + &c.FileConf.SThgConf.BinDir) + } + for _, field := range vars { + var err error + *field, err = common.ResolveEnvVar(*field) + if err != nil { + return err + } + } + + return nil +} diff --git a/lib/xdsconfig/fileconfig.go b/lib/xdsconfig/fileconfig.go deleted file mode 100644 index d936bbe..0000000 --- a/lib/xdsconfig/fileconfig.go +++ /dev/null @@ -1,96 +0,0 @@ -package xdsconfig - -import ( - "encoding/json" - "os" - "os/user" - "path" - - common "github.com/iotbzh/xds-common/golib" -) - -type SyncThingConf struct { - BinDir string `json:"binDir"` - Home string `json:"home"` - GuiAddress string `json:"gui-address"` - GuiAPIKey string `json:"gui-apikey"` -} - -type FileConfig struct { - HTTPPort string `json:"httpPort"` - LogsDir string `json:"logsDir"` - XDSAPIKey string `json:"xds-apikey"` - SThgConf *SyncThingConf `json:"syncthing"` -} - -// getConfigFromFile reads configuration from a config file. -// Order to determine which config file is used: -// 1/ from command line option: "--config myConfig.json" -// 2/ $HOME/.xds/agent/agent-config.json file -// 3/ /etc/xds-agent/config.json file - -func updateConfigFromFile(c *Config, confFile string) (*FileConfig, error) { - - searchIn := make([]string, 0, 3) - if confFile != "" { - searchIn = append(searchIn, confFile) - } - if usr, err := user.Current(); err == nil { - searchIn = append(searchIn, path.Join(usr.HomeDir, ".xds", "agent", "agent-config.json")) - } - - searchIn = append(searchIn, "/etc/xds-agent/config.json") - - var cFile *string - for _, p := range searchIn { - if _, err := os.Stat(p); err == nil { - cFile = &p - break - } - } - // Use default settings - fCfg := *c.FileConf - - // Read config file when existing - if cFile != nil { - c.Log.Infof("Use config file: %s", *cFile) - - // TODO move on viper package to support comments in JSON and also - // bind with flags (command line options) - // see https://github.com/spf13/viper#working-with-flags - - fd, _ := os.Open(*cFile) - defer fd.Close() - if err := json.NewDecoder(fd).Decode(&fCfg); err != nil { - return nil, err - } - } - - // Support environment variables (IOW ${MY_ENV_VAR} syntax) in agent-config.json - vars := []*string{ - &fCfg.LogsDir, - } - if fCfg.SThgConf != nil { - vars = append(vars, &fCfg.SThgConf.Home, &fCfg.SThgConf.BinDir) - } - for _, field := range vars { - var err error - *field, err = common.ResolveEnvVar(*field) - if err != nil { - return nil, err - } - } - - // Config file settings overwrite default config - if fCfg.HTTPPort != "" { - c.HTTPPort = fCfg.HTTPPort - } - - // Set default apikey - // FIXME - rework with dynamic key - if fCfg.XDSAPIKey == "" { - fCfg.XDSAPIKey = "1234abcezam" - } - - return &fCfg, nil -} @@ -3,16 +3,11 @@ package main import ( - "fmt" - "log" "os" - "time" "github.com/Sirupsen/logrus" "github.com/codegangsta/cli" "github.com/iotbzh/xds-agent/lib/agent" - "github.com/iotbzh/xds-agent/lib/syncthing" - "github.com/iotbzh/xds-agent/lib/webserver" "github.com/iotbzh/xds-agent/lib/xdsconfig" ) @@ -39,62 +34,18 @@ func xdsAgent(cliCtx *cli.Context) error { var err error // Create Agent context - ctx := agent.NewAgent(cliCtx) + ctxAgent := agent.NewAgent(cliCtx) // Load config - ctx.Config, err = xdsconfig.Init(cliCtx, ctx.Log) + ctxAgent.Config, err = xdsconfig.Init(cliCtx, ctxAgent.Log) if err != nil { return cli.NewExitError(err, 2) } - // Start local instance of Syncthing and Syncthing-notify - ctx.SThg = st.NewSyncThing(ctx.Config, ctx.Log) + // Run Agent (main loop) + errCode, err := ctxAgent.Run() - ctx.Log.Infof("Starting Syncthing...") - ctx.SThgCmd, err = ctx.SThg.Start() - if err != nil { - return cli.NewExitError(err, 2) - } - fmt.Printf("Syncthing started (PID %d)\n", ctx.SThgCmd.Process.Pid) - - ctx.Log.Infof("Starting Syncthing-inotify...") - ctx.SThgInotCmd, err = ctx.SThg.StartInotify() - if err != nil { - return cli.NewExitError(err, 2) - } - fmt.Printf("Syncthing-inotify started (PID %d)\n", ctx.SThgInotCmd.Process.Pid) - - // Establish connection with local Syncthing (retry if connection fail) - time.Sleep(3 * time.Second) - maxRetry := 30 - retry := maxRetry - for retry > 0 { - if err := ctx.SThg.Connect(); err == nil { - break - } - ctx.Log.Infof("Establishing connection to Syncthing (retry %d/%d)", retry, maxRetry) - time.Sleep(time.Second) - retry-- - } - if err != nil || retry == 0 { - return cli.NewExitError(err, 2) - } - - // Retrieve Syncthing config - id, err := ctx.SThg.IDGet() - if err != nil { - return cli.NewExitError(err, 2) - } - ctx.Log.Infof("Local Syncthing ID: %s", id) - - // Create and start Web Server - ctx.WWWServer = webserver.New(ctx.Config, ctx.Log) - if err = ctx.WWWServer.Serve(); err != nil { - log.Println(err) - return cli.NewExitError(err, 3) - } - - return cli.NewExitError("Program exited ", 4) + return cli.NewExitError(err, errCode) } // main @@ -126,7 +77,13 @@ func main() { Name: "log, l", Value: "error", Usage: "logging level (supported levels: panic, fatal, error, warn, info, debug)\n\t", - EnvVar: "LOG_LEVEL", + EnvVar: "XDS_LOGLEVEL", + }, + cli.StringFlag{ + Name: "logfile", + Value: "stdout", + Usage: "filename where logs will be redirected (default stdout)\n\t", + EnvVar: "XDS_LOGFILE", }, } diff --git a/scripts/install.sh b/scripts/install.sh index 6f93f2b..357c5e8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,11 +3,13 @@ # Install XDS agent as a user systemd service DESTDIR=${DESTDIR:-/opt/AGL/xds/agent} +DESTDIR_WWW=${DESTDIR_WWW:-${DESTDIR}/www} ROOT_SRCDIR=$(cd $(dirname "$0")/.. && pwd) install() { mkdir -p ${DESTDIR} && cp ${ROOT_SRCDIR}/bin/* ${DESTDIR} || exit 1 + mkdir -p ${DESTDIR_WWW} && cp -a ${ROOT_SRCDIR}/webapp/dist/* ${DESTDIR_WWW} || exit 1 cp ${ROOT_SRCDIR}/conf.d/etc/xds-agent /etc/ || exit 1 cp ${ROOT_SRCDIR}/conf.d/etc/default/xds-agent /etc/default/ || exit 1 diff --git a/webapp/README.md b/webapp/README.md new file mode 100644 index 0000000..acee846 --- /dev/null +++ b/webapp/README.md @@ -0,0 +1,45 @@ +XDS Dashboard +============= + +This is the web application dashboard for Cross Development System. + +## 1. Prerequisites + +*nodejs* must be installed on your system and the below global node packages must be installed: + +> sudo npm install -g gulp-cli + +## 2. Installing dependencies + +Install dependencies by running the following command: + +> npm install + +`node_modules` and `typings` directories will be created during the install. + +## 3. Building the project + +Build the project by running the following command: + +> npm run clean & npm run build + +`dist` directory will be created during the build + +## 4. Starting the application + +Start the application by running the following command: + +> npm start + +The application will be displayed in the browser. + + +## TODO + +- Upgrade to angular 2.4.9 or 2.4.10 AND rxjs 5.2.0 +- Complete README + package.json +- Add prod mode and use update gulpfile tslint: "./tslint/prod.json" +- Generate a bundle minified file, using systemjs-builder or find a better way + http://stackoverflow.com/questions/35280582/angular2-too-many-file-requests-on-load +- Add SASS support + http://foundation.zurb.com/sites/docs/sass.html
\ No newline at end of file diff --git a/webapp/assets/favicon.ico b/webapp/assets/favicon.ico Binary files differnew file mode 100644 index 0000000..6bf5138 --- /dev/null +++ b/webapp/assets/favicon.ico diff --git a/webapp/assets/images/iot-bzh-logo-small.png b/webapp/assets/images/iot-bzh-logo-small.png Binary files differnew file mode 100644 index 0000000..2c3b2ae --- /dev/null +++ b/webapp/assets/images/iot-bzh-logo-small.png diff --git a/webapp/assets/images/iot-graphx.jpg b/webapp/assets/images/iot-graphx.jpg Binary files differnew file mode 100644 index 0000000..74c640a --- /dev/null +++ b/webapp/assets/images/iot-graphx.jpg diff --git a/webapp/bs-config.json b/webapp/bs-config.json new file mode 100644 index 0000000..0041c6d --- /dev/null +++ b/webapp/bs-config.json @@ -0,0 +1,9 @@ +{ + "port": 8000, + "files": [ + "dist/**/*.{html,htm,css,js}" + ], + "server": { + "baseDir": "dist" + } +}
\ No newline at end of file diff --git a/webapp/gulp.conf.js b/webapp/gulp.conf.js new file mode 100644 index 0000000..2e8fa17 --- /dev/null +++ b/webapp/gulp.conf.js @@ -0,0 +1,34 @@ +"use strict"; + +module.exports = { + prodMode: process.env.PRODUCTION || false, + outDir: "dist", + paths: { + tsSources: ["src/**/*.ts"], + srcDir: "src", + assets: ["assets/**"], + node_modules_libs: [ + 'core-js/client/shim.min.js', + 'reflect-metadata/Reflect.js', + 'rxjs-system-bundle/*.min.js', + 'socket.io-client/dist/socket.io*.js', + 'systemjs/dist/system-polyfills.js', + 'systemjs/dist/system.src.js', + 'zone.js/dist/**', + '@angular/**/bundles/**', + 'ngx-cookie/bundles/**', + 'ngx-bootstrap/bundles/**', + 'bootstrap/dist/**', + 'moment/*.min.js', + 'font-awesome-animation/dist/font-awesome-animation.min.css', + 'font-awesome/css/font-awesome.min.css', + 'font-awesome/fonts/**' + ] + }, + deploy: { + target_ip: 'ip', + username: "user", + //port: 6666, + dir: '/tmp/xds-agent' + } +} diff --git a/webapp/gulpfile.js b/webapp/gulpfile.js new file mode 100644 index 0000000..0226380 --- /dev/null +++ b/webapp/gulpfile.js @@ -0,0 +1,123 @@ +"use strict"; +//FIXME in VSC/eslint or add to typings declare function require(v: string): any; + +// FIXME: Rework based on +// https://github.com/iotbzh/app-framework-templates/blob/master/templates/hybrid-html5/gulpfile.js +// AND +// https://github.com/antonybudianto/angular-starter +// and/or +// https://github.com/smmorneau/tour-of-heroes/blob/master/gulpfile.js + +const gulp = require("gulp"), + gulpif = require('gulp-if'), + del = require("del"), + sourcemaps = require('gulp-sourcemaps'), + tsc = require("gulp-typescript"), + tsProject = tsc.createProject("tsconfig.json"), + tslint = require('gulp-tslint'), + gulpSequence = require('gulp-sequence'), + rsync = require('gulp-rsync'), + conf = require('./gulp.conf'); + + +var tslintJsonFile = "./tslint.json" +if (conf.prodMode) { + tslintJsonFile = "./tslint.prod.json" +} + + +/** + * Remove output directory. + */ +gulp.task('clean', (cb) => { + return del([conf.outDir], cb); +}); + +/** + * Lint all custom TypeScript files. + */ +gulp.task('tslint', function() { + return gulp.src(conf.paths.tsSources) + .pipe(tslint({ + formatter: 'verbose', + configuration: tslintJsonFile + })) + .pipe(tslint.report()); +}); + +/** + * Compile TypeScript sources and create sourcemaps in build directory. + */ +gulp.task("compile", ["tslint"], function() { + var tsResult = gulp.src(conf.paths.tsSources) + .pipe(sourcemaps.init()) + .pipe(tsProject()); + return tsResult.js + .pipe(sourcemaps.write(".", { sourceRoot: '/src' })) + .pipe(gulp.dest(conf.outDir)); +}); + +/** + * Copy all resources that are not TypeScript files into build directory. + */ +gulp.task("resources", function() { + return gulp.src(["src/**/*", "!**/*.ts"]) + .pipe(gulp.dest(conf.outDir)); +}); + +/** + * Copy all assets into build directory. + */ +gulp.task("assets", function() { + return gulp.src(conf.paths.assets) + .pipe(gulp.dest(conf.outDir + "/assets")); +}); + +/** + * Copy all required libraries into build directory. + */ +gulp.task("libs", function() { + return gulp.src(conf.paths.node_modules_libs, + { cwd: "node_modules/**" }) /* Glob required here. */ + .pipe(gulp.dest(conf.outDir + "/lib")); +}); + +/** + * Watch for changes in TypeScript, HTML and CSS files. + */ +gulp.task('watch', function () { + gulp.watch([conf.paths.tsSources], ['compile']).on('change', function (e) { + console.log('TypeScript file ' + e.path + ' has been changed. Compiling.'); + }); + gulp.watch(["src/**/*.html", "src/**/*.css"], ['resources']).on('change', function (e) { + console.log('Resource file ' + e.path + ' has been changed. Updating.'); + }); +}); + +/** + * Build the project. + */ +gulp.task("build", ['compile', 'resources', 'libs', 'assets'], function() { + console.log("Building the project ..."); +}); + +/** + * Deploy the project on another machine/container + */ +gulp.task('rsync', function () { + return gulp.src(conf.outDir) + .pipe(rsync({ + root: conf.outDir, + username: conf.deploy.username, + hostname: conf.deploy.target_ip, + port: conf.deploy.port || null, + archive: true, + recursive: true, + compress: true, + progress: false, + incremental: true, + destination: conf.deploy.dir + })); +}); + +gulp.task('deploy', gulpSequence('build', 'rsync'));
\ No newline at end of file diff --git a/webapp/package.json b/webapp/package.json new file mode 100644 index 0000000..9c22f6b --- /dev/null +++ b/webapp/package.json @@ -0,0 +1,63 @@ +{ + "name": "xds-dashboard", + "version": "1.0.0", + "description": "X (cross) Development System dashboard", + "scripts": { + "clean": "gulp clean", + "compile": "gulp compile", + "build": "gulp build", + "start": "concurrently --kill-others \"gulp watch\" \"lite-server\"" + }, + "repository": { + "type": "git", + "url": "https://github.com/iotbzh/xds-agent" + }, + "author": "Sebastien Douheret [IoT.bzh]", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/iotbzh/xds-agent/issues" + }, + "dependencies": { + "@angular/common": "2.4.4", + "@angular/compiler": "2.4.4", + "@angular/core": "2.4.4", + "@angular/forms": "2.4.4", + "@angular/http": "2.4.4", + "@angular/platform-browser": "2.4.4", + "@angular/platform-browser-dynamic": "2.4.4", + "@angular/router": "3.4.4", + "@angular/upgrade": "2.4.4", + "@types/core-js": "0.9.35", + "@types/node": "7.0.5", + "@types/socket.io-client": "^1.4.29", + "bootstrap": "^3.3.7", + "core-js": "^2.4.1", + "font-awesome": "^4.7.0", + "font-awesome-animation": "0.0.10", + "ngx-bootstrap": "1.6.6", + "ngx-cookie": "^1.0.0", + "reflect-metadata": "^0.1.8", + "rxjs": "5.0.3", + "rxjs-system-bundle": "5.0.3", + "socket.io-client": "^1.7.3", + "socketio": "^1.0.0", + "systemjs": "0.20.0", + "zone.js": "^0.7.6" + }, + "devDependencies": { + "concurrently": "^3.1.0", + "del": "^2.2.0", + "gulp": "^3.9.1", + "gulp-if": "2.0.2", + "gulp-rsync": "0.0.7", + "gulp-sequence": "^0.4.6", + "gulp-sourcemaps": "^1.9.1", + "gulp-tslint": "^7.0.1", + "gulp-typescript": "^3.1.3", + "lite-server": "^2.2.2", + "ts-node": "^1.7.2", + "tslint": "^4.0.2", + "typescript": "^2.2.1", + "typings": "^2.0.0" + } +} diff --git a/webapp/src/app/alert/alert.component.ts b/webapp/src/app/alert/alert.component.ts new file mode 100644 index 0000000..672d7bf --- /dev/null +++ b/webapp/src/app/alert/alert.component.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { Observable } from 'rxjs'; + +import {AlertService, IAlert} from '../services/alert.service'; + +@Component({ + selector: 'app-alert', + template: ` + <div style="width:80%; margin-left:auto; margin-right:auto;" *ngFor="let alert of (alerts$ | async)"> + <alert *ngIf="alert.show" [type]="alert.type" [dismissible]="alert.dismissible" [dismissOnTimeout]="alert.dismissTimeout" + (onClose)="onClose(alert)"> + <div style="text-align:center;" [innerHtml]="alert.msg"></div> + </alert> + </div> + ` +}) + +export class AlertComponent { + + alerts$: Observable<IAlert[]>; + + constructor(private alertSvr: AlertService) { + this.alerts$ = this.alertSvr.alerts; + } + + onClose(al) { + this.alertSvr.del(al); + } + +} diff --git a/webapp/src/app/app.component.css b/webapp/src/app/app.component.css new file mode 100644 index 0000000..a47ad13 --- /dev/null +++ b/webapp/src/app/app.component.css @@ -0,0 +1,31 @@ +.navbar { + background-color: whitesmoke; +} + +.navbar-brand { + font-size: x-large; + font-variant: small-caps; + color: #5a28a1; +} + +a.navbar-brand { + margin-top: 5px; +} + + +.navbar-nav ul li a { + color: #fff; +} + +.menu-text { + color: #fff; +} + +#logo-iot { + padding: 0 2px; + height: 60px; +} + +li>a { + color:#5a28a1; +} diff --git a/webapp/src/app/app.component.html b/webapp/src/app/app.component.html new file mode 100644 index 0000000..a889b12 --- /dev/null +++ b/webapp/src/app/app.component.html @@ -0,0 +1,30 @@ +<nav class="navbar navbar-fixed-top"> + <!-- navbar-inverse"> --> + <div class="container-fluid"> + <div class="navbar-header"> + <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#myNavbar" + [attr.aria-expanded]="!isCollapsed" (click)="isCollapsed = !isCollapsed;" [ngClass]="{'collapsed': isCollapsed}"> + <span class="icon-bar"></span> + <span class="icon-bar"></span> + <span class="icon-bar"></span> + </button> + + <img class="navbar-brand" id="logo-iot" src="assets/images/iot-bzh-logo-small.png"> + <a class="navbar-brand" href="#">X(cross) Development System Dashboard</a> + </div> + + <div class="collapse navbar-collapse" [ngClass]="{'in': !isCollapsed}" id="myNavbar"> + <ul class="nav navbar-nav navbar-right"> + <li><a routerLink="/config"><i class="fa fa-2x fa-cog" title="Open configuration page" (click)="isCollapsed=true;"></i></a></li> + <li><a routerLink="/devel"><i class="fa fa-2x fa-play-circle" title="Open build page" (click)="isCollapsed=true;"></i></a></li> + <li><a routerLink="/home"><i class="fa fa-2x fa-home" title="Back to home page" (click)="isCollapsed=true;"></i></a></li> + </ul> + </div> + </div> +</nav> + +<app-alert id="alert"></app-alert> + +<div style="margin:10px;"> + <router-outlet></router-outlet> +</div> diff --git a/webapp/src/app/app.component.ts b/webapp/src/app/app.component.ts new file mode 100644 index 0000000..40cfb24 --- /dev/null +++ b/webapp/src/app/app.component.ts @@ -0,0 +1,37 @@ +import { Component, OnInit, OnDestroy } from "@angular/core"; +import { Router } from '@angular/router'; +//TODO import {TranslateService} from "ng2-translate"; + +@Component({ + selector: 'app', + templateUrl: './app/app.component.html', + styleUrls: ['./app/app.component.css'] +}) + +export class AppComponent implements OnInit, OnDestroy { + + isCollapsed: boolean = true; + + private defaultLanguage: string = 'en'; + + // I initialize the app component. + //TODO constructor(private translate: TranslateService) { + constructor(public router: Router) { + } + + ngOnInit() { + + /* TODO + this.translate.addLangs(["en", "fr"]); + this.translate.setDefaultLang(this.defaultLanguage); + + let browserLang = this.translate.getBrowserLang(); + this.translate.use(browserLang.match(/en|fr/) ? browserLang : this.defaultLanguage); + */ + } + + ngOnDestroy(): void { + } + + +} diff --git a/webapp/src/app/app.module.ts b/webapp/src/app/app.module.ts new file mode 100644 index 0000000..c3fd586 --- /dev/null +++ b/webapp/src/app/app.module.ts @@ -0,0 +1,93 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { HttpModule } from "@angular/http"; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { CookieModule } from 'ngx-cookie'; + +// Import bootstrap +import { AlertModule } from 'ngx-bootstrap/alert'; +import { ModalModule } from 'ngx-bootstrap/modal'; +import { AccordionModule } from 'ngx-bootstrap/accordion'; +import { CarouselModule } from 'ngx-bootstrap/carousel'; +import { PopoverModule } from 'ngx-bootstrap/popover'; +import { CollapseModule } from 'ngx-bootstrap/collapse'; +import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; + +// Import the application components and services. +import { Routing, AppRoutingProviders } from './app.routing'; +import { AppComponent } from "./app.component"; +import { AlertComponent } from './alert/alert.component'; +import { ConfigComponent } from "./config/config.component"; +import { DlXdsAgentComponent, CapitalizePipe } from "./config/downloadXdsAgent.component"; +import { ProjectCardComponent } from "./projects/projectCard.component"; +import { ProjectReadableTypePipe } from "./projects/projectCard.component"; +import { ProjectsListAccordionComponent } from "./projects/projectsListAccordion.component"; +import { ProjectAddModalComponent} from "./projects/projectAddModal.component"; +import { SdkCardComponent } from "./sdks/sdkCard.component"; +import { SdksListAccordionComponent } from "./sdks/sdksListAccordion.component"; +import { SdkSelectDropdownComponent } from "./sdks/sdkSelectDropdown.component"; +import { SdkAddModalComponent} from "./sdks/sdkAddModal.component"; + +import { HomeComponent } from "./home/home.component"; +import { DevelComponent } from "./devel/devel.component"; +import { BuildComponent } from "./devel/build/build.component"; +import { XDSAgentService } from "./services/xdsagent.service"; +import { ConfigService } from "./services/config.service"; +import { ProjectService } from "./services/project.service"; +import { AlertService } from './services/alert.service'; +import { UtilsService } from './services/utils.service'; +import { SdkService } from "./services/sdk.service"; + + + +@NgModule({ + imports: [ + BrowserModule, + HttpModule, + FormsModule, + ReactiveFormsModule, + Routing, + CookieModule.forRoot(), + AlertModule.forRoot(), + ModalModule.forRoot(), + AccordionModule.forRoot(), + CarouselModule.forRoot(), + PopoverModule.forRoot(), + CollapseModule.forRoot(), + BsDropdownModule.forRoot(), + ], + declarations: [ + AppComponent, + AlertComponent, + HomeComponent, + BuildComponent, + DevelComponent, + ConfigComponent, + DlXdsAgentComponent, + CapitalizePipe, + ProjectCardComponent, + ProjectReadableTypePipe, + ProjectsListAccordionComponent, + ProjectAddModalComponent, + SdkCardComponent, + SdksListAccordionComponent, + SdkSelectDropdownComponent, + SdkAddModalComponent, + ], + providers: [ + AppRoutingProviders, + { + provide: Window, + useValue: window + }, + XDSAgentService, + ConfigService, + ProjectService, + AlertService, + UtilsService, + SdkService, + ], + bootstrap: [AppComponent] +}) +export class AppModule { +} diff --git a/webapp/src/app/app.routing.ts b/webapp/src/app/app.routing.ts new file mode 100644 index 0000000..f0d808f --- /dev/null +++ b/webapp/src/app/app.routing.ts @@ -0,0 +1,19 @@ +import {Routes, RouterModule} from "@angular/router"; +import {ModuleWithProviders} from "@angular/core"; +import {ConfigComponent} from "./config/config.component"; +import {HomeComponent} from "./home/home.component"; +import {DevelComponent} from "./devel/devel.component"; + + +const appRoutes: Routes = [ + {path: '', redirectTo: 'home', pathMatch: 'full'}, + + {path: 'config', component: ConfigComponent, data: {title: 'Config'}}, + {path: 'home', component: HomeComponent, data: {title: 'Home'}}, + {path: 'devel', component: DevelComponent, data: {title: 'Build & Deploy'}} +]; + +export const AppRoutingProviders: any[] = []; +export const Routing: ModuleWithProviders = RouterModule.forRoot(appRoutes, { + useHash: true +}); diff --git a/webapp/src/app/config/config.component.css b/webapp/src/app/config/config.component.css new file mode 100644 index 0000000..6412f9a --- /dev/null +++ b/webapp/src/app/config/config.component.css @@ -0,0 +1,35 @@ +.fa-big { + font-size: 20px; + font-weight: bold; +} + +.fa-size-x2 { + font-size: 20px; +} + +h2 { + font-family: sans-serif; + font-variant: small-caps; + font-size: x-large; +} + +th span { + font-weight: 100; +} + +th label { + font-weight: 100; + margin-bottom: 0; +} + +tr.info>th { + vertical-align: middle; +} + +tr.info>td { + vertical-align: middle; +} + +.panel-heading { + background: aliceblue; +} diff --git a/webapp/src/app/config/config.component.html b/webapp/src/app/config/config.component.html new file mode 100644 index 0000000..4dbd238 --- /dev/null +++ b/webapp/src/app/config/config.component.html @@ -0,0 +1,101 @@ +<div class="panel panel-default"> + <div class="panel-heading"> + <h2 class="panel-title" (click)="gConfigIsCollapsed = !gConfigIsCollapsed"> + Global Configuration + <div class="pull-right"> + <span class="fa fa-fw fa-exchange fa-size-x2" [style.color]="((agentStatus$ | async)?.WS_connected)?'green':'red'"></span> + + <button class="btn btn-link" (click)="gConfigIsCollapsed = !gConfigIsCollapsed; $event.stopPropagation()"> + <span class="fa fa-big" [ngClass]="{'fa-angle-double-down': gConfigIsCollapsed, 'fa-angle-double-right': !gConfigIsCollapsed}"></span> + </button> + </div> + </h2> + </div> + <div class="panel-body" [collapse]="gConfigIsCollapsed && xdsServerConnected"> + <div class="row"> + <div class="col-xs-12"> + <table class="table table-condensed"> + <tbody> + <tr [ngClass]="{'info': xdsServerConnected, 'danger': !xdsServerConnected}"> + <th><label>XDS Server URL</label></th> + <td> <input type="text" [(ngModel)]="xdsServerUrl"></td> + <td style="white-space: nowrap"> + <div class="btn-group"> + <button class="btn btn-link" (click)="xdsAgentRestartConn()"><span class="fa fa-refresh fa-size-x2"></span></button> + <dl-xds-agent class="button"></dl-xds-agent> + </div> + </td> + </tr> + <tr class="info"> + <th><label>XDS Server connection retry</label></th> + <td> <input type="text" [(ngModel)]="xdsServerRetry" (ngModelChange)="showApplyBtn['retry'] = true"></td> + <td> + <button *ngIf="showApplyBtn['retry']" class="btn btn-primary btn-xs" (click)="submitGlobConf('retry')">APPLY</button> + </td> + </tr> + <tr class="info"> + <th><label>Local Projects root directory</label></th> + <td> <input type="text" [(ngModel)]="projectsRootDir" (ngModelChange)="showApplyBtn['rootDir'] = true"></td> + <td> + <button *ngIf="showApplyBtn['rootDir']" class="btn btn-primary btn-xs" (click)="submitGlobConf('rootDir')">APPLY</button> + </td> + </tr> + </tbody> + </table> + </div> + </div> + </div> +</div> + +<div class="panel panel-default"> + <div class="panel-heading"> + <h2 class="panel-title" (click)="sdksIsCollapsed = !sdksIsCollapsed"> + Cross SDKs + <div class="pull-right"> + <button class="btn btn-link" (click)="childSdkModal.show(); $event.stopPropagation()"><span class="fa fa-plus fa-size-x2"></span></button> + + <button class="btn btn-link" (click)="sdksIsCollapsed = !sdksIsCollapsed; $event.stopPropagation()"> + <span class="fa fa-big" [ngClass]="{'fa-angle-double-down': sdksIsCollapsed, 'fa-angle-double-right': !sdksIsCollapsed}"></span> + </button> + </div> + </h2> + </div> + <div class="panel-body" [collapse]="sdksIsCollapsed"> + <div class="row col-xs-12"> + <sdks-list-accordion [sdks]="(sdks$ | async)"></sdks-list-accordion> + </div> + </div> +</div> + +<div class="panel panel-default"> + <div class="panel-heading"> + <h2 class="panel-title" (click)="projectsIsCollapsed = !projectsIsCollapsed; $event.stopPropagation()"> + Projects + <div class="pull-right"> + <button class="btn btn-link" (click)="childProjectModal.show(); $event.stopPropagation()"><span class="fa fa-plus fa-size-x2"></span></button> + + <button class="btn btn-link" (click)="projectsIsCollapsed = !projectsIsCollapsed; $event.stopPropagation()"> + <span class="fa fa-big" [ngClass]="{'fa-angle-double-down': projectsIsCollapsed, 'fa-angle-double-right': !projectsIsCollapsed}"></span> + </button> + </div> + </h2> + </div> + <div class="panel-body" [collapse]="projectsIsCollapsed"> + <div class="row col-xs-12"> + <projects-list-accordion [projects]="(projects$ | async)"></projects-list-accordion> + </div> + </div> +</div> + +<!-- Modals --> +<project-add-modal #childProjectModal [title]="'Add a new project'" [server-id]=curServerID> +</project-add-modal> +<sdk-add-modal #childSdkModal [title]="'Add a new SDK'"> +</sdk-add-modal> + +<!-- only for debug --> +<div *ngIf="false" class="row"> + <pre>Config: {{config$ | async | json}}</pre> + <br> + <pre>Projects: {{projects$ | async | json}} </pre> +</div> diff --git a/webapp/src/app/config/config.component.ts b/webapp/src/app/config/config.component.ts new file mode 100644 index 0000000..6377844 --- /dev/null +++ b/webapp/src/app/config/config.component.ts @@ -0,0 +1,107 @@ +import { Component, ViewChild, OnInit } from "@angular/core"; +import { Observable } from 'rxjs/Observable'; +import { FormControl, FormGroup, Validators, FormBuilder } from '@angular/forms'; +import { CollapseModule } from 'ngx-bootstrap/collapse'; + +import { ConfigService, IConfig } from "../services/config.service"; +import { ProjectService, IProject } from "../services/project.service"; +import { XDSAgentService, IAgentStatus, IXDSConfig } from "../services/xdsagent.service"; +import { AlertService } from "../services/alert.service"; +import { ProjectAddModalComponent } from "../projects/projectAddModal.component"; +import { SdkService, ISdk } from "../services/sdk.service"; +import { SdkAddModalComponent } from "../sdks/sdkAddModal.component"; + +@Component({ + templateUrl: './app/config/config.component.html', + styleUrls: ['./app/config/config.component.css'] +}) + +// Inspired from https://embed.plnkr.co/jgDTXknPzAaqcg9XA9zq/ +// and from http://plnkr.co/edit/vCdjZM?p=preview + +export class ConfigComponent implements OnInit { + @ViewChild('childProjectModal') childProjectModal: ProjectAddModalComponent; + @ViewChild('childSdkModal') childSdkModal: SdkAddModalComponent; + + config$: Observable<IConfig>; + projects$: Observable<IProject[]>; + sdks$: Observable<ISdk[]>; + agentStatus$: Observable<IAgentStatus>; + + curProj: number; + curServer: number; + curServerID: string; + userEditedLabel: boolean = false; + + gConfigIsCollapsed: boolean = true; + sdksIsCollapsed: boolean = true; + projectsIsCollapsed: boolean = false; + + // TODO replace by reactive FormControl + add validation + xdsServerConnected: boolean = false; + xdsServerUrl: string; + xdsServerRetry: string; + projectsRootDir: string; // FIXME: should be remove when projectAddModal will always return full path + showApplyBtn = { // Used to show/hide Apply buttons + "retry": false, + "rootDir": false, + }; + + constructor( + private configSvr: ConfigService, + private projectSvr: ProjectService, + private xdsAgentSvr: XDSAgentService, + private sdkSvr: SdkService, + private alert: AlertService, + ) { + } + + ngOnInit() { + this.config$ = this.configSvr.Conf$; + this.projects$ = this.projectSvr.Projects$; + this.sdks$ = this.sdkSvr.Sdks$; + this.agentStatus$ = this.xdsAgentSvr.Status$; + + // FIXME support multiple servers + this.curServer = 0; + + // Bind xdsServerUrl to baseURL + this.xdsAgentSvr.XdsConfig$.subscribe(cfg => { + if (!cfg || cfg.servers.length < 1) { + return; + } + let svr = cfg.servers[this.curServer]; + this.curServerID = svr.id; + this.xdsServerConnected = svr.connected; + this.xdsServerUrl = svr.url; + this.xdsServerRetry = String(svr.connRetry); + this.projectsRootDir = ''; // SEB FIXME: add in go config? cfg.projectsRootDir; + }); + } + + submitGlobConf(field: string) { + switch (field) { + case "retry": + let re = new RegExp('^[0-9]+$'); + let rr = parseInt(this.xdsServerRetry, 10); + if (re.test(this.xdsServerRetry) && rr >= 0) { + this.xdsAgentSvr.setServerRetry(this.curServerID, rr); + } else { + this.alert.warning("Not a valid number", true); + } + break; + case "rootDir": + this.configSvr.projectsRootDir = this.projectsRootDir; + break; + default: + return; + } + this.showApplyBtn[field] = false; + } + + xdsAgentRestartConn() { + let url = this.xdsServerUrl; + this.xdsAgentSvr.setServerUrl(this.curServerID, url); + } + +} diff --git a/webapp/src/app/config/downloadXdsAgent.component.ts b/webapp/src/app/config/downloadXdsAgent.component.ts new file mode 100644 index 0000000..0b63e50 --- /dev/null +++ b/webapp/src/app/config/downloadXdsAgent.component.ts @@ -0,0 +1,47 @@ +import { Component, Input, Pipe, PipeTransform } from '@angular/core'; + +@Component({ + selector: 'dl-xds-agent', + template: ` + <template #popTemplate> + <h3>Install xds-agent:</h3> + <ul> + <li>On Linux machine <a href="{{url_OS_Linux}}" target="_blank"> + <span class="fa fa-external-link"></span></a></li> + + <li>On Windows machine <a href="{{url_OS_Other}}" target="_blank"><span class="fa fa-external-link"></span></a></li> + + <li>On MacOS machine <a href="{{url_OS_Other}}" target="_blank"><span class="fa fa-external-link"></span></a></li> + </ul> + <button type="button" class="btn btn-sm" (click)="pop.hide()"> Cancel </button> + </template> + <button type="button" class="btn btn-link fa fa-download fa-size-x2" + [popover]="popTemplate" + #pop="bs-popover" + placement="left"> + </button> + `, + styles: [` + .fa-size-x2 { + font-size: 20px; + } + `] +}) + +export class DlXdsAgentComponent { + + public url_OS_Linux = "https://en.opensuse.org/LinuxAutomotive#Installation_AGL_XDS"; + public url_OS_Other = "https://github.com/iotbzh/xds-agent#how-to-install-on-other-platform"; +} + +@Pipe({ + name: 'capitalize' +}) +export class CapitalizePipe implements PipeTransform { + transform(value: string): string { + if (value) { + return value.charAt(0).toUpperCase() + value.slice(1); + } + return value; + } +} diff --git a/webapp/src/app/devel/build/build.component.css b/webapp/src/app/devel/build/build.component.css new file mode 100644 index 0000000..695a89b --- /dev/null +++ b/webapp/src/app/devel/build/build.component.css @@ -0,0 +1,54 @@ +.vcenter { + display: inline-block; + vertical-align: middle; +} + +.blocks .btn-primary { + margin-left: 5px; + margin-right: 5px; + margin-top: 5px; + border-radius: 4px !important; +} + +.table-center { + width: 80%; + margin-left: auto; + margin-right: auto; +} + +.table-borderless>tbody>tr>td, +.table-borderless>tbody>tr>th, +.table-borderless>tfoot>tr>td, +.table-borderless>tfoot>tr>th, +.table-borderless>thead>tr>td, +.table-borderless>thead>tr>th { + border: none; +} + +.table-in-accordion>tbody>tr>th { + width: 30% +} + +.btn-large { + width: 10em; +} + +.fa-big { + font-size: 18px; + font-weight: bold; +} + +.textarea-scroll { + width: 100%; + overflow-y: scroll; +} + +h2 { + font-family: sans-serif; + font-variant: small-caps; + font-size: x-large; +} + +.panel-heading { + background: aliceblue; +} diff --git a/webapp/src/app/devel/build/build.component.html b/webapp/src/app/devel/build/build.component.html new file mode 100644 index 0000000..2bcd2c7 --- /dev/null +++ b/webapp/src/app/devel/build/build.component.html @@ -0,0 +1,115 @@ +<div class="panel panel-default"> + <div class="panel-heading"> + <h2 class="panel-title" (click)="buildIsCollapsed = !buildIsCollapsed"> + Build + <div class="pull-right"> + <button class="btn btn-link" (click)="buildIsCollapsed = !buildIsCollapsed; $event.stopPropagation()"> + <span class="fa fa-big" [ngClass]="{'fa-angle-double-down': buildIsCollapsed, 'fa-angle-double-right': !buildIsCollapsed}"></span> + </button> + </div> + </h2> + </div> + <div class="panel-body" [collapse]="buildIsCollapsed"> + <form [formGroup]="buildForm"> + <div class="col-xs-12"> + <table class="table table-borderless table-center"> + <tbody> + <tr> + <th>Cross SDK</th> + <td> + <!-- FIXME why not working ? + <sdk-select-dropdown [sdks]="(sdks$ | async)"></sdk-select-dropdown> + --> + <sdk-select-dropdown></sdk-select-dropdown> + </td> + </tr> + <tr> + <th>Project root path</th> + <td> <input type="text" disabled style="width:99%;" [value]="curProject && curProject.pathClient"></td> + </tr> + <tr> + <th>Sub-path</th> + <td> <input type="text" style="width:99%;" formControlName="subpath"> </td> + </tr> + <tr> + <td colspan="2"> + <accordion> + <accordion-group #group> + <div accordion-heading> + Advanced Settings + <i class="pull-right float-xs-right fa" [ngClass]="{'fa-chevron-down': group.isOpen, 'fa-chevron-right': !group.isOpen}"></i> + </div> + + <table class="table table-borderless table-in-accordion"> + <tbody> + <tr> + <th>Clean Command</th> + <td> <input type="text" style="width:99%;" formControlName="cmdClean"> </td> + </tr> + <tr> + <th>Pre-Build Command</th> + <td> <input type="text" style="width:99%;" formControlName="cmdPrebuild"> </td> + </tr> + <tr> + <th>Build Command</th> + <td> <input type="text" style="width:99%;" formControlName="cmdBuild"> </td> + </tr> + <tr> + <th>Populate Command</th> + <td> <input type="text" style="width:99%;" formControlName="cmdPopulate"> </td> + </tr> + <tr> + <th>Env variables</th> + <td> <input type="text" style="width:99%;" formControlName="envVars"> </td> + </tr> + <tr *ngIf="debugEnable"> + <th>Args variables</th> + <td> <input type="text" style="width:99%;" formControlName="cmdArgs"> </td> + </tr> + </tbody> + </table> + </accordion-group> + </accordion> + </td> + </tr> + </tbody> + </table> + </div> + <div class="row"> + <div class="col-xs-12 text-center"> + <div class="btn-group blocks"> + <button class="btn btn-primary btn-large" (click)="clean()" [disabled]="!curProject ">Clean</button> + <button class="btn btn-primary btn-large" (click)="preBuild()" [disabled]="!curProject">Pre-Build</button> + <button class="btn btn-primary btn-large" (click)="build()" [disabled]="!curProject">Build</button> + <button class="btn btn-primary btn-large" (click)="populate()" [disabled]="!curProject ">Populate</button> + <button *ngIf="debugEnable" class="btn btn-primary btn-large" (click)="execCmd()" [disabled]="!curProject ">Execute command</button> + <button *ngIf="debugEnable" class="btn btn-primary btn-large" (click)="make()" [disabled]="!curProject ">Make</button> + </div> + </div> + </div> + </form> + + <div style="margin-left: 2em; margin-right: 2em; "> + <div class="row "> + <div class="col-xs-10"> + <div class="row "> + <div class="col-xs-4"> + <label>Command Output</label> + </div> + <div class="col-xs-8" style="font-size:x-small; margin-top:5px;"> + {{ cmdInfo }} + </div> + </div> + </div> + <div class="col-xs-2"> + <button class="btn btn-link pull-right " (click)="reset() "><span class="fa fa-eraser fa-size-x2"></span></button> + </div> + </div> + <div class="row "> + <div class="col-xs-12 text-center "> + <textarea rows="20" class="textarea-scroll" #scrollOutput>{{ cmdOutput }}</textarea> + </div> + </div> + </div> + </div> +</div> diff --git a/webapp/src/app/devel/build/build.component.ts b/webapp/src/app/devel/build/build.component.ts new file mode 100644 index 0000000..87df4e1 --- /dev/null +++ b/webapp/src/app/devel/build/build.component.ts @@ -0,0 +1,223 @@ +import { Component, AfterViewChecked, ElementRef, ViewChild, OnInit, Input } from '@angular/core'; +import { Observable } from 'rxjs'; +import { FormControl, FormGroup, Validators, FormBuilder } from '@angular/forms'; +import { CookieService } from 'ngx-cookie'; + +import 'rxjs/add/operator/scan'; +import 'rxjs/add/operator/startWith'; + +import { XDSAgentService, ICmdOutput } from "../../services/xdsagent.service"; +import { ProjectService, IProject } from "../../services/project.service"; +import { AlertService, IAlert } from "../../services/alert.service"; +import { SdkService } from "../../services/sdk.service"; + +@Component({ + selector: 'panel-build', + moduleId: module.id, + templateUrl: './build.component.html', + styleUrls: ['./build.component.css'] +}) + +export class BuildComponent implements OnInit, AfterViewChecked { + @ViewChild('scrollOutput') private scrollContainer: ElementRef; + + @Input() curProject: IProject; + + public buildForm: FormGroup; + public subpathCtrl = new FormControl("", Validators.required); + public debugEnable: boolean = false; + public buildIsCollapsed: boolean = false; + public cmdOutput: string; + public cmdInfo: string; + + private startTime: Map<string, number> = new Map<string, number>(); + + constructor( + private xdsSvr: XDSAgentService, + private fb: FormBuilder, + private alertSvr: AlertService, + private sdkSvr: SdkService, + private cookie: CookieService, + ) { + this.cmdOutput = ""; + this.cmdInfo = ""; // TODO: to be remove (only for debug) + this.buildForm = fb.group({ + subpath: this.subpathCtrl, + cmdClean: ["", Validators.nullValidator], + cmdPrebuild: ["", Validators.nullValidator], + cmdBuild: ["", Validators.nullValidator], + cmdPopulate: ["", Validators.nullValidator], + cmdArgs: ["", Validators.nullValidator], + envVars: ["", Validators.nullValidator], + }); + } + + ngOnInit() { + // Set default settings + // TODO save & restore values from cookies + this.buildForm.patchValue({ + subpath: "", + cmdClean: "rm -rf build", + cmdPrebuild: "mkdir -p build && cd build && cmake ..", + cmdBuild: "cd build && make", + cmdPopulate: "cd build && make remote-target-populate", + cmdArgs: "", + envVars: "", + }); + + // Command output data tunneling + this.xdsSvr.CmdOutput$.subscribe(data => { + this.cmdOutput += data.stdout; + this.cmdOutput += data.stderr; + }); + + // Command exit + this.xdsSvr.CmdExit$.subscribe(exit => { + if (this.startTime.has(exit.cmdID)) { + this.cmdInfo = 'Last command duration: ' + this._computeTime(this.startTime.get(exit.cmdID)); + this.startTime.delete(exit.cmdID); + } + + if (exit && exit.code !== 0) { + this.cmdOutput += "--- Command exited with code " + exit.code + " ---\n\n"; + } + }); + + this._scrollToBottom(); + + // only use for debug + this.debugEnable = (this.cookie.get("debug_build") === "1"); + } + + ngAfterViewChecked() { + this._scrollToBottom(); + } + + reset() { + this.cmdOutput = ''; + } + + clean() { + this._exec( + this.buildForm.value.cmdClean, + this.buildForm.value.subpath, + [], + this.buildForm.value.envVars); + } + + preBuild() { + this._exec( + this.buildForm.value.cmdPrebuild, + this.buildForm.value.subpath, + [], + this.buildForm.value.envVars); + } + + build() { + this._exec( + this.buildForm.value.cmdBuild, + this.buildForm.value.subpath, + [], + this.buildForm.value.envVars + ); + } + + populate() { + this._exec( + this.buildForm.value.cmdPopulate, + this.buildForm.value.subpath, + [], // args + this.buildForm.value.envVars + ); + } + + execCmd() { + this._exec( + this.buildForm.value.cmdArgs, + this.buildForm.value.subpath, + [], + this.buildForm.value.envVars + ); + } + + private _exec(cmd: string, dir: string, args: string[], env: string) { + if (!this.curProject) { + this.alertSvr.warning('No active project', true); + } + + let prjID = this.curProject.id; + + this.cmdOutput += this._outputHeader(); + + let sdkid = this.sdkSvr.getCurrentId(); + + // Detect key=value in env string to build array of string + let envArr = []; + env.split(';').forEach(v => envArr.push(v.trim())); + + let t0 = performance.now(); + this.cmdInfo = 'Start build of ' + prjID + ' at ' + t0; + + this.xdsSvr.exec(prjID, dir, cmd, sdkid, args, envArr) + .subscribe(res => { + this.startTime.set(String(res.cmdID), t0); + }, + err => { + this.cmdInfo = 'Last command duration: ' + this._computeTime(t0); + this.alertSvr.error('ERROR: ' + err); + }); + } + + make(args: string) { + if (!this.curProject) { + this.alertSvr.warning('No active project', true); + } + + let prjID = this.curProject.id; + + this.cmdOutput += this._outputHeader(); + + let sdkid = this.sdkSvr.getCurrentId(); + + let argsArr = args ? args.split(' ') : this.buildForm.value.cmdArgs.split(' '); + + // Detect key=value in env string to build array of string + let envArr = []; + this.buildForm.value.envVars.split(';').forEach(v => envArr.push(v.trim())); + + let t0 = performance.now(); + this.cmdInfo = 'Start build of ' + prjID + ' at ' + t0; + + this.xdsSvr.make(prjID, this.buildForm.value.subpath, sdkid, argsArr, envArr) + .subscribe(res => { + this.startTime.set(String(res.cmdID), t0); + }, + err => { + this.cmdInfo = 'Last command duration: ' + this._computeTime(t0); + this.alertSvr.error('ERROR: ' + err); + }); + } + + private _scrollToBottom(): void { + try { + this.scrollContainer.nativeElement.scrollTop = this.scrollContainer.nativeElement.scrollHeight; + } catch (err) { } + } + + private _computeTime(t0: number, t1?: number): string { + let enlap = Math.round((t1 || performance.now()) - t0); + if (enlap < 1000.0) { + return enlap.toFixed(2) + ' ms'; + } else { + return (enlap / 1000.0).toFixed(3) + ' seconds'; + } + } + + private _outputHeader(): string { + return "--- " + new Date().toString() + " ---\n"; + } + + private _outputFooter(): string { + return "\n"; + } +} diff --git a/webapp/src/app/devel/devel.component.css b/webapp/src/app/devel/devel.component.css new file mode 100644 index 0000000..4b03dcb --- /dev/null +++ b/webapp/src/app/devel/devel.component.css @@ -0,0 +1,19 @@ +.table-center { + width: 60%; + margin-left: auto; + margin-right: auto; +} + +.table-borderless>tbody>tr>td, +.table-borderless>tbody>tr>th, +.table-borderless>tfoot>tr>td, +.table-borderless>tfoot>tr>th, +.table-borderless>thead>tr>td, +.table-borderless>thead>tr>th { + border: none; +} + +a.dropdown-item.disabled { + pointer-events:none; + opacity:0.4; +} diff --git a/webapp/src/app/devel/devel.component.html b/webapp/src/app/devel/devel.component.html new file mode 100644 index 0000000..cc62889 --- /dev/null +++ b/webapp/src/app/devel/devel.component.html @@ -0,0 +1,40 @@ +<div class="row"> + <div class="col-md-8"> + <table class="table table-borderless table-center"> + <tbody> + <tr> + <th style="border: none;">Project</th> + <td> + <div class="btn-group" dropdown *ngIf="curPrj"> + <button dropdownToggle type="button" class="btn btn-primary dropdown-toggle" style="width: 20em;"> + {{curPrj.label}} + <span class="caret" style="float: right; margin-top: 8px;"></span> + </button> + <ul *dropdownMenu class="dropdown-menu" role="menu"> + <li role="menuitem"><a class="dropdown-item" *ngFor="let prj of (Prjs$ | async)" [class.disabled]="!prj.isUsable" + (click)="curPrj=prj">{{prj.label}}</a> + </li> + + </ul> + </div> + <span *ngIf="!curPrj" style="color:red; font-style: italic;"> + No project detected, please create first a project using the configuration page. + </span> + </td> + </tr> + </tbody> + </table> + </div> +</div> + +<div class="row"> + <!--<div class="col-md-8">--> + <div class="col-md-12"> + <panel-build [curProject]=curPrj></panel-build> + </div> + <!-- TODO: disable for now + <div class="col-md-4"> + <panel-deploy [curProject]=curPrj></panel-deploy> + </div> + --> +</div> diff --git a/webapp/src/app/devel/devel.component.ts b/webapp/src/app/devel/devel.component.ts new file mode 100644 index 0000000..5c8b9f2 --- /dev/null +++ b/webapp/src/app/devel/devel.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { ProjectService, IProject } from "../services/project.service"; + +@Component({ + selector: 'devel', + moduleId: module.id, + templateUrl: './devel.component.html', + styleUrls: ['./devel.component.css'], +}) + +export class DevelComponent { + + curPrj: IProject; + Prjs$: Observable<IProject[]>; + + constructor(private projectSvr: ProjectService) { + } + + ngOnInit() { + this.Prjs$ = this.projectSvr.Projects$; + this.Prjs$.subscribe((prjs) => { + // Select project if no one is selected or no project exists + if (this.curPrj && "id" in this.curPrj) { + this.curPrj = prjs.find(p => p.id === this.curPrj.id) || prjs[0]; + } else if (this.curPrj == null) { + this.curPrj = prjs[0]; + } else { + this.curPrj = null; + } + }); + } +} diff --git a/webapp/src/app/home/home.component.ts b/webapp/src/app/home/home.component.ts new file mode 100644 index 0000000..0e3c995 --- /dev/null +++ b/webapp/src/app/home/home.component.ts @@ -0,0 +1,81 @@ +import { Component, OnInit } from '@angular/core'; + +export interface ISlide { + img?: string; + imgAlt?: string; + hText?: string; + hHtml?: string; + text?: string; + html?: string; + btn?: string; + btnHref?: string; +} + +@Component({ + selector: 'home', + moduleId: module.id, + template: ` + <style> + .wide img { + width: 98%; + } + .carousel-item { + max-height: 90%; + } + h1, h2, h3, h4, p { + color: #330066; + } + .html-inner { + color: #330066; + } + h1 { + font-size: 4em; + } + p { + font-size: 2.5em; + } + + </style> + + <div class="wide"> + <carousel [interval]="carInterval" [(activeSlide)]="activeSlideIndex"> + <slide *ngFor="let sl of slides; let index=index"> + <img [src]="sl.img" [alt]="sl.imgAlt"> + <div class="carousel-caption"> + <h1 *ngIf="sl.hText">{{ sl.hText }}</h1> + <h1 *ngIf="sl.hHtml" class="html-inner" [innerHtml]="sl.hHtml"></h1> + <p *ngIf="sl.text">{{ sl.text }}</p> + <div *ngIf="sl.html" class="html-inner" [innerHtml]="sl.html"></div> + </div> + </slide> + </carousel> + </div> + ` +}) + +export class HomeComponent { + + public carInterval: number = 4000; + + // FIXME SEB - Add more slides and info + public slides: ISlide[] = [ + { + img: 'assets/images/iot-graphx.jpg', + imgAlt: "iot graphx image", + hText: "Welcome to XDS Dashboard !", + text: "X(cross) Development System allows developers to easily cross-compile applications.", + }, + { + img: 'assets/images/iot-graphx.jpg', + imgAlt: "iot graphx image", + hText: "Create, Build, Deploy, Enjoy !", + }, + { + img: 'assets/images/iot-graphx.jpg', + imgAlt: "iot graphx image", + hHtml: '<p>To Start: click on <i class="fa fa-cog" style="color:#9d9d9d;"></i> icon and add new folder</p>', + } + ]; + + constructor() { } +}
\ No newline at end of file diff --git a/webapp/src/app/main.ts b/webapp/src/app/main.ts new file mode 100644 index 0000000..1f68ccc --- /dev/null +++ b/webapp/src/app/main.ts @@ -0,0 +1,6 @@ +import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; +import {AppModule} from './app.module'; + +const platform = platformBrowserDynamic(); + +platform.bootstrapModule(AppModule);
\ No newline at end of file diff --git a/webapp/src/app/projects/projectAddModal.component.css b/webapp/src/app/projects/projectAddModal.component.css new file mode 100644 index 0000000..77f73a5 --- /dev/null +++ b/webapp/src/app/projects/projectAddModal.component.css @@ -0,0 +1,24 @@ +.table-borderless>tbody>tr>td, +.table-borderless>tbody>tr>th, +.table-borderless>tfoot>tr>td, +.table-borderless>tfoot>tr>th, +.table-borderless>thead>tr>td, +.table-borderless>thead>tr>th { + border: none; +} + +tr>th { + vertical-align: middle; +} + +tr>td { + vertical-align: middle; +} + +th label { + margin-bottom: 0; +} + +td input { + width: 100%; +} diff --git a/webapp/src/app/projects/projectAddModal.component.html b/webapp/src/app/projects/projectAddModal.component.html new file mode 100644 index 0000000..dc84985 --- /dev/null +++ b/webapp/src/app/projects/projectAddModal.component.html @@ -0,0 +1,54 @@ +<div bsModal #childProjectModal="bs-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="mySmallModalLabel" + [config]="{backdrop: 'static'}" aria-hidden="true"> + <div class="modal-dialog modal-lg"> + <div class="modal-content"> + <div class="modal-header"> + <h4 class="modal-title pull-left">{{title}}</h4> + <button type="button" class="close pull-right" aria-label="Close" (click)="hide()"> + <span aria-hidden="true">×</span> + </button> + </div> + + <form [formGroup]="addProjectForm" (ngSubmit)="onSubmit()"> + <div class="modal-body"> + <div class="row "> + <div class="col-xs-12"> + <table class="table table-borderless"> + <tbody> + <tr> + <th><label>Sharing Type </label></th> + <td><select class="form-control" formControlName="type"> + <option *ngFor="let t of projectTypes" [value]="t.value">{{t.display}} + </option> + </select> + </td> + </tr> + <tr> + <th><label for="select-local-path">Local Path </label></th> + <td><input type="text" id="select-local-path" formControlName="pathCli" placeholder="/tmp/myProject" (change)="onChangeLocalProject($event)"></td> + </tr> + <tr> + <th><label for="select-server-path">Server Path </label></th> + <td><input type="text" id="select-server-path" formControlName="pathSvr"></td> + </tr> + <tr> + <th><label for="select-label">Label </label></th> + <td><input type="text" formControlName="label" id="select-label" (keyup)="onKeyLabel($event)"></td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + <div class="modal-footer"> + <div class="pull-left"> + <button class="btn btn-default" (click)="cancelAction=true; hide()"> Cancel </button> + </div> + <div class=""> + <button class="btn btn-primary" type="submit" [disabled]="!addProjectForm.valid">Add Folder</button> + </div> + </div> + </form> + </div> + </div> +</div> diff --git a/webapp/src/app/projects/projectAddModal.component.ts b/webapp/src/app/projects/projectAddModal.component.ts new file mode 100644 index 0000000..1352060 --- /dev/null +++ b/webapp/src/app/projects/projectAddModal.component.ts @@ -0,0 +1,147 @@ +import { Component, Input, ViewChild, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { ModalDirective } from 'ngx-bootstrap/modal'; +import { FormControl, FormGroup, Validators, FormBuilder, ValidatorFn, AbstractControl } from '@angular/forms'; + +// Import RxJs required methods +import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/filter'; +import 'rxjs/add/operator/debounceTime'; + +import { AlertService, IAlert } from "../services/alert.service"; +import { ProjectService, IProject, ProjectType, ProjectTypes } from "../services/project.service"; + + +@Component({ + selector: 'project-add-modal', + templateUrl: './app/projects/projectAddModal.component.html', + styleUrls: ['./app/projects/projectAddModal.component.css'] +}) +export class ProjectAddModalComponent { + @ViewChild('childProjectModal') public childProjectModal: ModalDirective; + @Input() title?: string; + @Input('server-id') serverID: string; + + cancelAction: boolean = false; + userEditedLabel: boolean = false; + projectTypes = ProjectTypes; + + addProjectForm: FormGroup; + typeCtrl: FormControl; + pathCliCtrl: FormControl; + pathSvrCtrl: FormControl; + + constructor( + private alert: AlertService, + private projectSvr: ProjectService, + private fb: FormBuilder + ) { + // Define types (first one is special/placeholder) + this.projectTypes.unshift({ value: ProjectType.UNSET, display: "--Select a type--" }); + + this.typeCtrl = new FormControl(this.projectTypes[0].value, Validators.pattern("[A-Za-z]+")); + this.pathCliCtrl = new FormControl("", Validators.required); + this.pathSvrCtrl = new FormControl({ value: "", disabled: true }, [Validators.required, Validators.minLength(1)]); + + this.addProjectForm = fb.group({ + type: this.typeCtrl, + pathCli: this.pathCliCtrl, + pathSvr: this.pathSvrCtrl, + label: ["", Validators.nullValidator], + }); + } + + ngOnInit() { + // Auto create label name + this.pathCliCtrl.valueChanges + .debounceTime(100) + .filter(n => n) + .map(n => { + let last = n.split('/'); + let nm = n; + if (last.length > 0) { + nm = last.pop(); + if (nm === "" && last.length > 0) { + nm = last.pop(); + } + } + return "Project_" + nm; + }) + .subscribe(value => { + if (value && !this.userEditedLabel) { + this.addProjectForm.patchValue({ label: value }); + } + }); + + // Handle disabling of Server path + this.typeCtrl.valueChanges + .debounceTime(500) + .subscribe(valType => { + let dis = (valType === String(ProjectType.SYNCTHING)); + this.pathSvrCtrl.reset({ value: "", disabled: dis }); + }); + } + + show() { + this.cancelAction = false; + this.userEditedLabel = false; + this.childProjectModal.show(); + } + + hide() { + this.childProjectModal.hide(); + } + + onKeyLabel(event: any) { + this.userEditedLabel = (this.addProjectForm.value.label !== ""); + } + + /* FIXME: change input to file type + <td><input type="file" id="select-local-path" webkitdirectory + formControlName="pathCli" placeholder="myProject" (change)="onChangeLocalProject($event)"></td> + + onChangeLocalProject(e) { + if e.target.files.length < 1 { + console.log('NO files'); + } + let dir = e.target.files[0].webkitRelativePath; + console.log("files: " + dir); + let u = URL.createObjectURL(e.target.files[0]); + } + */ + onChangeLocalProject(e) { + } + + onSubmit() { + if (this.cancelAction) { + return; + } + + let formVal = this.addProjectForm.value; + + let type = formVal['type'].value; + this.projectSvr.Add({ + serverId: this.serverID, + label: formVal['label'], + pathClient: formVal['pathCli'], + pathServer: formVal['pathSvr'], + type: formVal['type'], + // FIXME: allow to set defaultSdkID from New Project config panel + }) + .subscribe(prj => { + this.alert.info("Project " + prj.label + " successfully created."); + this.hide(); + + // Reset Value for the next creation + this.addProjectForm.reset(); + let selectedType = this.projectTypes[0].value; + this.addProjectForm.patchValue({ type: selectedType }); + + }, + err => { + this.alert.error(err, 60); + this.hide(); + }); + } + +} diff --git a/webapp/src/app/projects/projectCard.component.ts b/webapp/src/app/projects/projectCard.component.ts new file mode 100644 index 0000000..fdacba4 --- /dev/null +++ b/webapp/src/app/projects/projectCard.component.ts @@ -0,0 +1,91 @@ +import { Component, Input, Pipe, PipeTransform } from '@angular/core'; +import { ProjectService, IProject, ProjectType } from "../services/project.service"; +import { AlertService } from "../services/alert.service"; + +@Component({ + selector: 'project-card', + template: ` + <div class="row"> + <div class="col-xs-12"> + <div class="text-right" role="group"> + <button class="btn btn-link" (click)="delete(project)"> + <span class="fa fa-trash fa-size-x2"></span> + </button> + </div> + </div> + </div> + + <table class="table table-striped"> + <tbody> + <tr> + <th><span class="fa fa-fw fa-id-badge"></span> <span>Project ID</span></th> + <td>{{ project.id }}</td> + </tr> + <tr> + <th><span class="fa fa-fw fa-exchange"></span> <span>Sharing type</span></th> + <td>{{ project.type | readableType }}</td> + </tr> + <tr> + <th><span class="fa fa-fw fa-folder-open-o"></span> <span>Local path</span></th> + <td>{{ project.pathClient }}</td> + </tr> + <tr *ngIf="project.pathServer && project.pathServer != ''"> + <th><span class="fa fa-fw fa-folder-open-o"></span> <span>Server path</span></th> + <td>{{ project.pathServer }}</td> + </tr> + <tr> + <th><span class="fa fa-fw fa-flag"></span> <span>Status</span></th> + <td>{{ project.status }} - {{ project.isInSync ? "Up to Date" : "Out of Sync"}} + <button *ngIf="!project.isInSync" class="btn btn-link" (click)="sync(project)"> + <span class="fa fa-refresh fa-size-x2"></span> + </button> + </td> + </tr> + </tbody> + </table > + `, + styleUrls: ['./app/config/config.component.css'] +}) + +export class ProjectCardComponent { + + @Input() project: IProject; + + constructor( + private alert: AlertService, + private projectSvr: ProjectService + ) { + } + + delete(prj: IProject) { + this.projectSvr.Delete(prj) + .subscribe(res => { + }, err => { + this.alert.error("Delete ERROR: " + err); + }); + } + + sync(prj: IProject) { + this.projectSvr.Sync(prj) + .subscribe(res => { + }, err => { + this.alert.error("ERROR: " + err); + }); + } + +} + +// Remove APPS. prefix if translate has failed +@Pipe({ + name: 'readableType' +}) + +export class ProjectReadableTypePipe implements PipeTransform { + transform(type: ProjectType): string { + switch (type) { + case ProjectType.NATIVE_PATHMAP: return "Native (path mapping)"; + case ProjectType.SYNCTHING: return "Cloud (Syncthing)"; + default: return String(type); + } + } +} diff --git a/webapp/src/app/projects/projectsListAccordion.component.ts b/webapp/src/app/projects/projectsListAccordion.component.ts new file mode 100644 index 0000000..210be5c --- /dev/null +++ b/webapp/src/app/projects/projectsListAccordion.component.ts @@ -0,0 +1,39 @@ +import { Component, Input } from "@angular/core"; + +import { IProject } from "../services/project.service"; + +@Component({ + selector: 'projects-list-accordion', + template: ` + <style> + .fa.fa-exclamation-triangle { + margin-right: 2em; + color: red; + } + .fa.fa-refresh { + margin-right: 10px; + color: darkviolet; + } + </style> + <accordion> + <accordion-group #group *ngFor="let prj of projects"> + <div accordion-heading> + {{ prj.label }} + <div class="pull-right"> + <i *ngIf="prj.status == 'Syncing'" class="fa fa-refresh faa-spin animated"></i> + <i *ngIf="!prj.isInSync && prj.status != 'Syncing'" class="fa fa-exclamation-triangle"></i> + <i class="fa" [ngClass]="{'fa-chevron-down': group.isOpen, 'fa-chevron-right': !group.isOpen}"></i> + </div> + </div> + <project-card [project]="prj"></project-card> + </accordion-group> + </accordion> + ` +}) +export class ProjectsListAccordionComponent { + + @Input() projects: IProject[]; + +} + + diff --git a/webapp/src/app/sdks/sdkAddModal.component.html b/webapp/src/app/sdks/sdkAddModal.component.html new file mode 100644 index 0000000..2c07fca --- /dev/null +++ b/webapp/src/app/sdks/sdkAddModal.component.html @@ -0,0 +1,23 @@ +<div bsModal #sdkChildModal="bs-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="mySmallModalLabel" + aria-hidden="true"> + <div class="modal-dialog modal-lg"> + <div class="modal-content"> + <div class="modal-header"> + <h4 class="modal-title pull-left">{{title}}</h4> + <button type="button" class="close pull-right" aria-label="Close" (click)="hideChildModal()"> + <span aria-hidden="true">×</span> + </button> + </div> + <div class="modal-body"> + <ng-content select=".modal-body"> </ng-content> + <i>Not available for now.</i> + </div> + + <div class="modal-footer"> + <div class="pull-left"> + <button class="btn btn-default" (click)="hide()"> Cancel </button> + </div> + </div> + </div> + </div> +</div> diff --git a/webapp/src/app/sdks/sdkAddModal.component.ts b/webapp/src/app/sdks/sdkAddModal.component.ts new file mode 100644 index 0000000..b6c8eb2 --- /dev/null +++ b/webapp/src/app/sdks/sdkAddModal.component.ts @@ -0,0 +1,24 @@ +import { Component, Input, ViewChild } from '@angular/core'; +import { ModalDirective } from 'ngx-bootstrap/modal'; + +@Component({ + selector: 'sdk-add-modal', + templateUrl: './app/sdks/sdkAddModal.component.html', +}) +export class SdkAddModalComponent { + @ViewChild('sdkChildModal') public sdkChildModal: ModalDirective; + + @Input() title?: string; + + // TODO + constructor() { + } + + show() { + this.sdkChildModal.show(); + } + + hide() { + this.sdkChildModal.hide(); + } +} diff --git a/webapp/src/app/sdks/sdkCard.component.ts b/webapp/src/app/sdks/sdkCard.component.ts new file mode 100644 index 0000000..3256a0b --- /dev/null +++ b/webapp/src/app/sdks/sdkCard.component.ts @@ -0,0 +1,55 @@ +import { Component, Input } from '@angular/core'; +import { ISdk } from "../services/sdk.service"; + +@Component({ + selector: 'sdk-card', + template: ` + <div class="row"> + <div class="col-xs-12"> + <div class="text-right" role="group"> + <button disabled class="btn btn-link" (click)="delete(sdk)"><span class="fa fa-trash fa-size-x2"></span></button> + </div> + </div> + </div> + + <table class="table table-striped"> + <tbody> + <tr> + <th><span class="fa fa-fw fa-id-badge"></span> <span>SDK ID</span></th> + <td>{{ sdk.id }}</td> + </tr> + <tr> + <th><span class="fa fa-fw fa-user"></span> <span>Profile</span></th> + <td>{{ sdk.profile }}</td> + </tr> + <tr> + <th><span class="fa fa-fw fa-tasks"></span> <span>Architecture</span></th> + <td>{{ sdk.arch }}</td> + </tr> + <tr> + <th><span class="fa fa-fw fa-code-fork"></span> <span>Version</span></th> + <td>{{ sdk.version }}</td> + </tr> + <tr> + <th><span class="fa fa-fw fa-folder-open-o"></span> <span>Sdk path</span></th> + <td>{{ sdk.path}}</td> + </tr> + + </tbody> + </table > + `, + styleUrls: ['./app/config/config.component.css'] +}) + +export class SdkCardComponent { + + @Input() sdk: ISdk; + + constructor() { } + + + delete(sdk: ISdk) { + // Not supported for now + } + +} diff --git a/webapp/src/app/sdks/sdkSelectDropdown.component.ts b/webapp/src/app/sdks/sdkSelectDropdown.component.ts new file mode 100644 index 0000000..a2fe37a --- /dev/null +++ b/webapp/src/app/sdks/sdkSelectDropdown.component.ts @@ -0,0 +1,48 @@ +import { Component, Input } from "@angular/core"; + +import { ISdk, SdkService } from "../services/sdk.service"; + +@Component({ + selector: 'sdk-select-dropdown', + template: ` + <div class="btn-group" dropdown *ngIf="curSdk" > + <button dropdownToggle type="button" class="btn btn-primary dropdown-toggle" style="width: 20em;"> + {{curSdk.name}} <span class="caret" style="float: right; margin-top: 8px;"></span> + </button> + <ul *dropdownMenu class="dropdown-menu" role="menu"> + <li role="menuitem"><a class="dropdown-item" *ngFor="let sdk of sdks" (click)="select(sdk)"> + {{sdk.name}}</a> + </li> + </ul> + </div> + ` +}) +export class SdkSelectDropdownComponent { + + // FIXME investigate to understand why not working with sdks as input + // <sdk-select-dropdown [sdks]="(sdks$ | async)"></sdk-select-dropdown> + //@Input() sdks: ISdk[]; + sdks: ISdk[]; + + curSdk: ISdk; + + constructor(private sdkSvr: SdkService) { } + + ngOnInit() { + this.curSdk = this.sdkSvr.getCurrent(); + this.sdkSvr.Sdks$.subscribe((s) => { + if (s) { + this.sdks = s; + if (this.curSdk === null || s.indexOf(this.curSdk) === -1) { + this.sdkSvr.setCurrent(this.curSdk = s.length ? s[0] : null); + } + } + }); + } + + select(s) { + this.sdkSvr.setCurrent(this.curSdk = s); + } +} + + diff --git a/webapp/src/app/sdks/sdksListAccordion.component.ts b/webapp/src/app/sdks/sdksListAccordion.component.ts new file mode 100644 index 0000000..9d5f7e9 --- /dev/null +++ b/webapp/src/app/sdks/sdksListAccordion.component.ts @@ -0,0 +1,26 @@ +import { Component, Input } from "@angular/core"; + +import { ISdk } from "../services/sdk.service"; + +@Component({ + selector: 'sdks-list-accordion', + template: ` + <accordion> + <accordion-group #group *ngFor="let sdk of sdks"> + <div accordion-heading> + {{ sdk.name }} + <i class="pull-right float-xs-right fa" + [ngClass]="{'fa-chevron-down': group.isOpen, 'fa-chevron-right': !group.isOpen}"></i> + </div> + <sdk-card [sdk]="sdk"></sdk-card> + </accordion-group> + </accordion> + ` +}) +export class SdksListAccordionComponent { + + @Input() sdks: ISdk[]; + +} + + diff --git a/webapp/src/app/services/alert.service.ts b/webapp/src/app/services/alert.service.ts new file mode 100644 index 0000000..2978e84 --- /dev/null +++ b/webapp/src/app/services/alert.service.ts @@ -0,0 +1,67 @@ +import { Injectable, SecurityContext } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { Observable } from 'rxjs/Observable'; +import { Subject } from 'rxjs/Subject'; + + +export type AlertType = "danger" | "warning" | "info" | "success"; + +export interface IAlert { + type: AlertType; + msg: string; + show?: boolean; + dismissible?: boolean; + dismissTimeout?: number; // close alert after this time (in seconds) + id?: number; +} + +@Injectable() +export class AlertService { + public alerts: Observable<IAlert[]>; + + private _alerts: IAlert[]; + private alertsSubject = <Subject<IAlert[]>>new Subject(); + private uid = 0; + private defaultDissmissTmo = 5; // in seconds + + constructor(private sanitizer: DomSanitizer) { + this.alerts = this.alertsSubject.asObservable(); + this._alerts = []; + this.uid = 0; + } + + public error(msg: string, dismissTime?: number) { + this.add({ + type: "danger", msg: msg, dismissible: true, dismissTimeout: dismissTime + }); + } + + public warning(msg: string, dismissible?: boolean) { + this.add({ type: "warning", msg: msg, dismissible: true, dismissTimeout: (dismissible ? this.defaultDissmissTmo : 0) }); + } + + public info(msg: string) { + this.add({ type: "info", msg: msg, dismissible: true, dismissTimeout: this.defaultDissmissTmo }); + } + + public add(al: IAlert) { + let msg = String(al.msg).replace("\n", "<br>"); + this._alerts.push({ + show: true, + type: al.type, + msg: this.sanitizer.sanitize(SecurityContext.HTML, msg), + dismissible: al.dismissible || true, + dismissTimeout: (al.dismissTimeout * 1000) || 0, + id: this.uid, + }); + this.uid += 1; + this.alertsSubject.next(this._alerts); + } + + public del(al: IAlert) { + let idx = this._alerts.findIndex((a) => a.id === al.id); + if (idx > -1) { + this._alerts.splice(idx, 1); + } + } +} diff --git a/webapp/src/app/services/config.service.ts b/webapp/src/app/services/config.service.ts new file mode 100644 index 0000000..bbe2fb8 --- /dev/null +++ b/webapp/src/app/services/config.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { CookieService } from 'ngx-cookie'; +import { Observable } from 'rxjs/Observable'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + +import { AlertService, IAlert } from "../services/alert.service"; +import { UtilsService } from "../services/utils.service"; + +export interface IConfig { + projectsRootDir: string; +} + +@Injectable() +export class ConfigService { + + public Conf$: Observable<IConfig>; + + private confSubject: BehaviorSubject<IConfig>; + private confStore: IConfig; + + constructor(private _window: Window, + private cookie: CookieService, + private alert: AlertService, + private utils: UtilsService, + ) { + this.load(); + this.confSubject = <BehaviorSubject<IConfig>>new BehaviorSubject(this.confStore); + this.Conf$ = this.confSubject.asObservable(); + } + + // Load config + load() { + // Try to retrieve previous config from cookie + let cookConf = this.cookie.getObject("xds-config"); + if (cookConf != null) { + this.confStore = <IConfig>cookConf; + } else { + // Set default config + this.confStore = { + projectsRootDir: "", + //projects: [] + }; + } + } + + // Save config into cookie + save() { + // Notify subscribers + this.confSubject.next(Object.assign({}, this.confStore)); + + // Don't save projects in cookies (too big!) + let cfg = Object.assign({}, this.confStore); + this.cookie.putObject("xds-config", cfg); + } + + set projectsRootDir(p: string) { + this.confStore.projectsRootDir = p; + this.save(); + } +} diff --git a/webapp/src/app/services/project.service.ts b/webapp/src/app/services/project.service.ts new file mode 100644 index 0000000..53adc80 --- /dev/null +++ b/webapp/src/app/services/project.service.ts @@ -0,0 +1,199 @@ +import { Injectable, SecurityContext } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + +import { XDSAgentService, IXDSProjectConfig } from "../services/xdsagent.service"; + +export enum ProjectType { + UNSET = "", + NATIVE_PATHMAP = "PathMap", + SYNCTHING = "CloudSync" +} + +export var ProjectTypes = [ + { value: ProjectType.NATIVE_PATHMAP, display: "Path mapping" }, + { value: ProjectType.SYNCTHING, display: "Cloud Sync" } +]; + +export var ProjectStatus = { + ErrorConfig: "ErrorConfig", + Disable: "Disable", + Enable: "Enable", + Pause: "Pause", + Syncing: "Syncing" +}; + +export interface IProject { + id?: string; + serverId: string; + label: string; + pathClient: string; + pathServer?: string; + type: ProjectType; + status?: string; + isInSync?: boolean; + isUsable?: boolean; + serverPrjDef?: IXDSProjectConfig; + isExpanded?: boolean; + visible?: boolean; + defaultSdkID?: string; +} + +@Injectable() +export class ProjectService { + public Projects$: Observable<IProject[]>; + + private _prjsList: IProject[] = []; + private current: IProject; + private prjsSubject = <BehaviorSubject<IProject[]>>new BehaviorSubject(this._prjsList); + + constructor(private xdsSvr: XDSAgentService) { + this.current = null; + this.Projects$ = this.prjsSubject.asObservable(); + + this.xdsSvr.getProjects().subscribe((projects) => { + this._prjsList = []; + projects.forEach(p => { + this._addProject(p, true); + }); + this.prjsSubject.next(Object.assign([], this._prjsList)); + }); + + // Update Project data + this.xdsSvr.ProjectState$.subscribe(prj => { + let i = this._getProjectIdx(prj.id); + if (i >= 0) { + // XXX for now, only isInSync and status may change + this._prjsList[i].isInSync = prj.isInSync; + this._prjsList[i].status = prj.status; + this._prjsList[i].isUsable = this._isUsableProject(prj); + this.prjsSubject.next(Object.assign([], this._prjsList)); + } + }); + + // Add listener on create and delete project events + this.xdsSvr.addEventListener('event:project-add', (ev) => { + if (ev && ev.data && ev.data.id) { + this._addProject(ev.data); + } else { + console.log("Warning: received events with unknown data: ev=", ev); + } + }); + this.xdsSvr.addEventListener('event:project-delete', (ev) => { + if (ev && ev.data && ev.data.id) { + let idx = this._prjsList.findIndex(item => item.id === ev.data.id); + if (idx === -1) { + console.log("Warning: received events on unknown project id: ev=", ev); + return; + } + this._prjsList.splice(idx, 1); + this.prjsSubject.next(Object.assign([], this._prjsList)); + } else { + console.log("Warning: received events with unknown data: ev=", ev); + } + }); + + } + + public setCurrent(s: IProject) { + this.current = s; + } + + public getCurrent(): IProject { + return this.current; + } + + public getCurrentId(): string { + if (this.current && this.current.id) { + return this.current.id; + } + return ""; + } + + Add(prj: IProject): Observable<IProject> { + let xdsPrj: IXDSProjectConfig = { + id: "", + serverId: prj.serverId, + label: prj.label || "", + clientPath: prj.pathClient.trim(), + serverPath: prj.pathServer, + type: prj.type, + defaultSdkID: prj.defaultSdkID, + }; + // Send config to XDS server + return this.xdsSvr.addProject(xdsPrj) + .map(xdsPrj => this._convToIProject(xdsPrj)); + } + + Delete(prj: IProject): Observable<IProject> { + let idx = this._getProjectIdx(prj.id); + let delPrj = prj; + if (idx === -1) { + throw new Error("Invalid project id (id=" + prj.id + ")"); + } + return this.xdsSvr.deleteProject(prj.id) + .map(res => { return delPrj; }); + } + + Sync(prj: IProject): Observable<string> { + let idx = this._getProjectIdx(prj.id); + if (idx === -1) { + throw new Error("Invalid project id (id=" + prj.id + ")"); + } + return this.xdsSvr.syncProject(prj.id); + } + + private _isUsableProject(p) { + return p && p.isInSync && + (p.status === ProjectStatus.Enable) && + (p.status !== ProjectStatus.Syncing); + } + + private _getProjectIdx(id: string): number { + return this._prjsList.findIndex((item) => item.id === id); + } + + private _convToIProject(rPrj: IXDSProjectConfig): IProject { + // Convert XDSFolderConfig to IProject + let pp: IProject = { + id: rPrj.id, + serverId: rPrj.serverId, + label: rPrj.label, + pathClient: rPrj.clientPath, + pathServer: rPrj.serverPath, + type: rPrj.type, + status: rPrj.status, + isInSync: rPrj.isInSync, + isUsable: this._isUsableProject(rPrj), + defaultSdkID: rPrj.defaultSdkID, + serverPrjDef: Object.assign({}, rPrj), // do a copy + }; + return pp; + } + + private _addProject(rPrj: IXDSProjectConfig, noNext?: boolean): IProject { + + // Convert XDSFolderConfig to IProject + let pp = this._convToIProject(rPrj); + + // add new project + this._prjsList.push(pp); + + // sort project array + this._prjsList.sort((a, b) => { + if (a.label < b.label) { + return -1; + } + if (a.label > b.label) { + return 1; + } + return 0; + }); + + if (!noNext) { + this.prjsSubject.next(Object.assign([], this._prjsList)); + } + + return pp; + } +} diff --git a/webapp/src/app/services/sdk.service.ts b/webapp/src/app/services/sdk.service.ts new file mode 100644 index 0000000..6d8a5f6 --- /dev/null +++ b/webapp/src/app/services/sdk.service.ts @@ -0,0 +1,54 @@ +import { Injectable, SecurityContext } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + +import { XDSAgentService } from "../services/xdsagent.service"; + +export interface ISdk { + id: string; + profile: string; + version: string; + arch: number; + path: string; +} + +@Injectable() +export class SdkService { + public Sdks$: Observable<ISdk[]>; + + private _sdksList = []; + private current: ISdk; + private sdksSubject = <BehaviorSubject<ISdk[]>>new BehaviorSubject(this._sdksList); + + constructor(private xdsSvr: XDSAgentService) { + this.current = null; + this.Sdks$ = this.sdksSubject.asObservable(); + + this.xdsSvr.XdsConfig$.subscribe(cfg => { + if (!cfg || cfg.servers.length < 1) { + return; + } + // FIXME support multiple server + //cfg.servers.forEach(svr => { + this.xdsSvr.getSdks(cfg.servers[0].id).subscribe((s) => { + this._sdksList = s; + this.sdksSubject.next(s); + }); + }); + } + + public setCurrent(s: ISdk) { + this.current = s; + } + + public getCurrent(): ISdk { + return this.current; + } + + public getCurrentId(): string { + if (this.current && this.current.id) { + return this.current.id; + } + return ""; + } +} diff --git a/webapp/src/app/services/utils.service.ts b/webapp/src/app/services/utils.service.ts new file mode 100644 index 0000000..84b9ab6 --- /dev/null +++ b/webapp/src/app/services/utils.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; + +@Injectable() +export class UtilsService { + constructor() { } + + getOSName(lowerCase?: boolean): string { + var checkField = function (ff) { + if (ff.indexOf("Linux") !== -1) { + return "Linux"; + } else if (ff.indexOf("Win") !== -1) { + return "Windows"; + } else if (ff.indexOf("Mac") !== -1) { + return "MacOS"; + } else if (ff.indexOf("X11") !== -1) { + return "UNIX"; + } + return ""; + }; + + let OSName = checkField(navigator.platform); + if (OSName === "") { + OSName = checkField(navigator.appVersion); + } + if (OSName === "") { + OSName = "Unknown OS"; + } + if (lowerCase) { + return OSName.toLowerCase(); + } + return OSName; + } +}
\ No newline at end of file diff --git a/webapp/src/app/services/xdsagent.service.ts b/webapp/src/app/services/xdsagent.service.ts new file mode 100644 index 0000000..fd84ccb --- /dev/null +++ b/webapp/src/app/services/xdsagent.service.ts @@ -0,0 +1,401 @@ +import { Injectable } from '@angular/core'; +import { Http, Headers, RequestOptionsArgs, Response } from '@angular/http'; +import { Location } from '@angular/common'; +import { Observable } from 'rxjs/Observable'; +import { Subject } from 'rxjs/Subject'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; +import * as io from 'socket.io-client'; + +import { AlertService } from './alert.service'; +import { ISdk } from './sdk.service'; +import { ProjectType} from "./project.service"; + +// Import RxJs required methods +import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/catch'; +import 'rxjs/add/observable/throw'; +import 'rxjs/add/operator/mergeMap'; +import 'rxjs/add/observable/of'; +import 'rxjs/add/operator/retryWhen'; + + +export interface IXDSConfigProject { + id: string; + path: string; + clientSyncThingID: string; + type: string; + label?: string; + defaultSdkID?: string; +} + +interface IXDSBuilderConfig { + ip: string; + port: string; + syncThingID: string; +} + +export interface IXDSProjectConfig { + id: string; + serverId: string; + label: string; + clientPath: string; + serverPath?: string; + type: ProjectType; + status?: string; + isInSync?: boolean; + defaultSdkID: string; +} + +export interface IXDSVer { + id: string; + version: string; + apiVersion: string; + gitTag: string; +} + +export interface IXDSVersions { + client: IXDSVer; + servers: IXDSVer[]; +} + +export interface IXDServerCfg { + id: string; + url: string; + apiUrl: string; + partialUrl: string; + connRetry: number; + connected: boolean; +} + +export interface IXDSConfig { + servers: IXDServerCfg[]; +} + +export interface ISdkMessage { + wsID: string; + msgType: string; + data: any; +} + +export interface ICmdOutput { + cmdID: string; + timestamp: string; + stdout: string; + stderr: string; +} + +export interface ICmdExit { + cmdID: string; + timestamp: string; + code: number; + error: string; +} + +export interface IAgentStatus { + WS_connected: boolean; +} + + +@Injectable() +export class XDSAgentService { + + public XdsConfig$: Observable<IXDSConfig>; + public Status$: Observable<IAgentStatus>; + public ProjectState$ = <Subject<IXDSProjectConfig>>new Subject(); + public CmdOutput$ = <Subject<ICmdOutput>>new Subject(); + public CmdExit$ = <Subject<ICmdExit>>new Subject(); + + private baseUrl: string; + private wsUrl: string; + private _config = <IXDSConfig>{ servers: [] }; + private _status = { WS_connected: false }; + + private configSubject = <BehaviorSubject<IXDSConfig>>new BehaviorSubject(this._config); + private statusSubject = <BehaviorSubject<IAgentStatus>>new BehaviorSubject(this._status); + + private socket: SocketIOClient.Socket; + + constructor(private http: Http, private _window: Window, private alert: AlertService) { + + this.XdsConfig$ = this.configSubject.asObservable(); + this.Status$ = this.statusSubject.asObservable(); + + this.baseUrl = this._window.location.origin + '/api/v1'; + + let re = this._window.location.origin.match(/http[s]?:\/\/([^\/]*)[\/]?/); + if (re === null || re.length < 2) { + console.error('ERROR: cannot determine Websocket url'); + } else { + this.wsUrl = 'ws://' + re[1]; + this._handleIoSocket(); + this._RegisterEvents(); + } + } + + private _WSState(sts: boolean) { + this._status.WS_connected = sts; + this.statusSubject.next(Object.assign({}, this._status)); + + // Update XDS config including XDS Server list when connected + if (sts) { + this.getConfig().subscribe(c => { + this._config = c; + this.configSubject.next( + Object.assign({ servers: [] }, this._config) + ); + }); + } + } + + private _handleIoSocket() { + this.socket = io(this.wsUrl, { transports: ['websocket'] }); + + this.socket.on('connect_error', (res) => { + this._WSState(false); + console.error('XDS Agent WebSocket Connection error !'); + }); + + this.socket.on('connect', (res) => { + this._WSState(true); + }); + + this.socket.on('disconnection', (res) => { + this._WSState(false); + this.alert.error('WS disconnection: ' + res); + }); + + this.socket.on('error', (err) => { + console.error('WS error:', err); + }); + + this.socket.on('make:output', data => { + this.CmdOutput$.next(Object.assign({}, <ICmdOutput>data)); + }); + + this.socket.on('make:exit', data => { + this.CmdExit$.next(Object.assign({}, <ICmdExit>data)); + }); + + this.socket.on('exec:output', data => { + this.CmdOutput$.next(Object.assign({}, <ICmdOutput>data)); + }); + + this.socket.on('exec:exit', data => { + this.CmdExit$.next(Object.assign({}, <ICmdExit>data)); + }); + + // Events + // (project-add and project-delete events are managed by project.service) + this.socket.on('event:server-config', ev => { + if (ev && ev.data) { + let cfg: IXDServerCfg = ev.data; + let idx = this._config.servers.findIndex(el => el.id === cfg.id); + if (idx >= 0) { + this._config.servers[idx] = Object.assign({}, cfg); + } + this.configSubject.next(Object.assign({}, this._config)); + } + }); + + this.socket.on('event:project-state-change', ev => { + if (ev && ev.data) { + this.ProjectState$.next(Object.assign({}, ev.data)); + } + }); + + } + + /** + ** Events + ***/ + addEventListener(ev: string, fn: Function): SocketIOClient.Emitter { + return this.socket.addEventListener(ev, fn); + } + + /** + ** Misc / Version + ***/ + getVersion(): Observable<IXDSVersions> { + return this._get('/version'); + } + + /*** + ** Config + ***/ + getConfig(): Observable<IXDSConfig> { + return this._get('/config'); + } + + setConfig(cfg: IXDSConfig): Observable<IXDSConfig> { + return this._post('/config', cfg); + } + + setServerRetry(serverID: string, r: number) { + let svr = this._getServer(serverID); + if (!svr) { + return Observable.of([]); + } + + svr.connRetry = r; + this.setConfig(this._config).subscribe( + newCfg => { + this._config = newCfg; + this.configSubject.next(Object.assign({}, this._config)); + }, + err => { + this.alert.error(err); + } + ); + } + + setServerUrl(serverID: string, url: string) { + let svr = this._getServer(serverID); + if (!svr) { + return Observable.of([]); + } + svr.url = url; + this.setConfig(this._config).subscribe( + newCfg => { + this._config = newCfg; + this.configSubject.next(Object.assign({}, this._config)); + }, + err => { + this.alert.error(err); + } + ); + } + + /*** + ** SDKs + ***/ + getSdks(serverID: string): Observable<ISdk[]> { + let svr = this._getServer(serverID); + if (!svr || !svr.connected) { + return Observable.of([]); + } + + return this._get(svr.partialUrl + '/sdks'); + } + + /*** + ** Projects + ***/ + getProjects(): Observable<IXDSProjectConfig[]> { + return this._get('/projects'); + } + + addProject(cfg: IXDSProjectConfig): Observable<IXDSProjectConfig> { + return this._post('/project', cfg); + } + + deleteProject(id: string): Observable<IXDSProjectConfig> { + return this._delete('/project/' + id); + } + + syncProject(id: string): Observable<string> { + return this._post('/project/sync/' + id, {}); + } + + /*** + ** Exec + ***/ + exec(prjID: string, dir: string, cmd: string, sdkid?: string, args?: string[], env?: string[]): Observable<any> { + return this._post('/exec', + { + id: prjID, + rpath: dir, + cmd: cmd, + sdkID: sdkid || "", + args: args || [], + env: env || [], + }); + } + + make(prjID: string, dir: string, sdkid?: string, args?: string[], env?: string[]): Observable<any> { + // SEB TODO add serverID + return this._post('/make', + { + id: prjID, + rpath: dir, + sdkID: sdkid, + args: args || [], + env: env || [], + }); + } + + + /** + ** Private functions + ***/ + + private _RegisterEvents() { + // Register to all existing events + this._post('/events/register', { "name": "all" }) + .subscribe( + res => { }, + error => { + this.alert.error("ERROR while registering to all events: ", error); + } + ); + } + + private _getServer(ID: string): IXDServerCfg { + let svr = this._config.servers.filter(item => item.id === ID); + if (svr.length < 1) { + return null; + } + return svr[0]; + } + + private _attachAuthHeaders(options?: any) { + options = options || {}; + let headers = options.headers || new Headers(); + // headers.append('Authorization', 'Basic ' + btoa('username:password')); + headers.append('Accept', 'application/json'); + headers.append('Content-Type', 'application/json'); + // headers.append('Access-Control-Allow-Origin', '*'); + + options.headers = headers; + return options; + } + + private _get(url: string): Observable<any> { + return this.http.get(this.baseUrl + url, this._attachAuthHeaders()) + .map((res: Response) => res.json()) + .catch(this._decodeError); + } + private _post(url: string, body: any): Observable<any> { + return this.http.post(this.baseUrl + url, JSON.stringify(body), this._attachAuthHeaders()) + .map((res: Response) => res.json()) + .catch((error) => { + return this._decodeError(error); + }); + } + private _delete(url: string): Observable<any> { + return this.http.delete(this.baseUrl + url, this._attachAuthHeaders()) + .map((res: Response) => res.json()) + .catch(this._decodeError); + } + + private _decodeError(err: any) { + let e: string; + if (err instanceof Response) { + const body = err.json() || 'Agent error'; + e = body.error || JSON.stringify(body); + if (!e || e === "" || e === '{"isTrusted":true}') { + e = `${err.status} - ${err.statusText || 'Unknown error'}`; + } + } else if (typeof err === "object") { + if (err.statusText) { + e = err.statusText; + } else if (err.error) { + e = String(err.error); + } else { + e = JSON.stringify(err); + } + } else { + e = err.message ? err.message : err.toString(); + } + return Observable.throw(e); + } +} diff --git a/webapp/src/index.html b/webapp/src/index.html new file mode 100644 index 0000000..290b4be --- /dev/null +++ b/webapp/src/index.html @@ -0,0 +1,50 @@ +<html> + +<head> + <title> + XDS Dashboard + </title> + <meta name="viewport" content="width=device-width, initial-scale=1"> + + <link rel="icon" type="image/x-icon" href="assets/favicon.ico"> + + <!-- TODO cleanup + <link rel="stylesheet" href="lib/foundation-sites/dist/css/foundation.min.css"> + --> + <link <link href="lib/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet"> + + <link rel="stylesheet" href="lib/font-awesome/css/font-awesome.min.css"> + <link rel="stylesheet" href="lib/font-awesome-animation/dist/font-awesome-animation.min.css"> + + <!-- 1. Load libraries --> + <!-- Polyfill(s) for older browsers --> + <script src="lib/core-js/client/shim.min.js"></script> + + <script src="lib/zone.js/dist/zone.js"></script> + <script src="lib/reflect-metadata/Reflect.js"></script> + <script src="lib/systemjs/dist/system.src.js"></script> + + <!-- 2. Configure SystemJS --> + <script src="systemjs.config.js"></script> + <script> + System.import('app') + .then(null, console.error.bind(console)); + </script> + + <script src="https://code.jquery.com/jquery-2.1.1.min.js"></script> + +</head> + +<!-- 3. Display the application --> + +<body style="padding-top: 70px;"> <!-- padding needed due to fixed navbar --> + <app> + <div style="text-align:center; position:absolute; top:50%; width:100%; transform:translate(0,-50%);"> + <img id="logo-iot" src="assets/images/iot-bzh-logo-small.png"> + <br> Loading... + <i class="fa fa-spinner fa-spin fa-fw"></i> + </div> + </app> +</body> + +</html> diff --git a/webapp/src/systemjs.config.js b/webapp/src/systemjs.config.js new file mode 100644 index 0000000..15c52ba --- /dev/null +++ b/webapp/src/systemjs.config.js @@ -0,0 +1,69 @@ +(function (global) { + System.config({ + paths: { + // paths serve as alias + 'npm:': 'lib/' + }, + bundles: { + "npm:rxjs-system-bundle/Rx.system.min.js": [ + "rxjs", + "rxjs/*", + "rxjs/operator/*", + "rxjs/observable/*", + "rxjs/scheduler/*", + "rxjs/symbol/*", + "rxjs/add/operator/*", + "rxjs/add/observable/*", + "rxjs/util/*" + ] + }, + // map tells the System loader where to look for things + map: { + // our app is within the app folder + app: 'app', + // angular bundles + '@angular/core': 'npm:@angular/core/bundles/core.umd.js', + '@angular/common': 'npm:@angular/common/bundles/common.umd.js', + '@angular/compiler': 'npm:@angular/compiler/bundles/compiler.umd.js', + '@angular/platform-browser': 'npm:@angular/platform-browser/bundles/platform-browser.umd.js', + '@angular/platform-browser-dynamic': 'npm:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic.umd.js', + '@angular/http': 'npm:@angular/http/bundles/http.umd.js', + '@angular/router': 'npm:@angular/router/bundles/router.umd.js', + '@angular/forms': 'npm:@angular/forms/bundles/forms.umd.js', + 'ngx-cookie': 'npm:ngx-cookie/bundles/ngx-cookie.umd.js', + // ng2-bootstrap + 'moment': 'npm:moment', + 'ngx-bootstrap/alert': 'npm:ngx-bootstrap/bundles/ngx-bootstrap.umd.min.js', + 'ngx-bootstrap/modal': 'npm:ngx-bootstrap/bundles/ngx-bootstrap.umd.min.js', + 'ngx-bootstrap/accordion': 'npm:ngx-bootstrap/bundles/ngx-bootstrap.umd.min.js', + 'ngx-bootstrap/carousel': 'npm:ngx-bootstrap/bundles/ngx-bootstrap.umd.min.js', + 'ngx-bootstrap/popover': 'npm:ngx-bootstrap/bundles/ngx-bootstrap.umd.min.js', + 'ngx-bootstrap/dropdown': 'npm:ngx-bootstrap/bundles/ngx-bootstrap.umd.min.js', + 'ngx-bootstrap/collapse': 'npm:ngx-bootstrap/bundles/ngx-bootstrap.umd.min.js', + // other libraries + 'socket.io-client': 'npm:socket.io-client/dist/socket.io.min.js' + }, + // packages tells the System loader how to load when no filename and/or no extension + packages: { + 'app': { + main: './main.js', + defaultExtension: 'js' + }, + 'rxjs': { + defaultExtension: false + }, + 'socket.io-client': { + defaultExtension: 'js' + }, + 'ngx-bootstrap': { + format: 'cjs', + main: 'bundles/ng2-bootstrap.umd.js', + defaultExtension: 'js' + }, + 'moment': { + main: 'moment.js', + defaultExtension: 'js' + } + } + }); +})(this); diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json new file mode 100644 index 0000000..9bad681 --- /dev/null +++ b/webapp/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "outDir": "dist/app", + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "removeComments": false, + "noImplicitAny": false, + "noStrictGenericChecks": true // better to switch to RxJS 5.4.2 ; workaround https://stackoverflow.com/questions/44810195/how-do-i-get-around-this-subject-incorrectly-extends-observable-error-in-types + }, + "exclude": [ + "gulpfile.ts", + "node_modules" + ] +} diff --git a/webapp/tslint.json b/webapp/tslint.json new file mode 100644 index 0000000..15969a4 --- /dev/null +++ b/webapp/tslint.json @@ -0,0 +1,55 @@ +{ + "rules": { + "class-name": true, + "curly": true, + "eofline": false, + "forin": true, + "indent": [ + true, + 4 + ], + "label-position": true, + "max-line-length": [ + true, + 140 + ], + "no-arg": true, + "no-bitwise": true, + "no-console": [ + true, + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-variable": true, + "no-empty": false, + "no-eval": true, + "no-string-literal": false, + "no-trailing-whitespace": true, + "no-use-before-declare": true, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-whitespace" + ], + "radix": true, + "semicolon": true, + "triple-equals": [ + true, + "allow-null-check" + ], + "variable-name": false, + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator" + ] + } +} diff --git a/webapp/tslint.prod.json b/webapp/tslint.prod.json new file mode 100644 index 0000000..aa64c7f --- /dev/null +++ b/webapp/tslint.prod.json @@ -0,0 +1,56 @@ +{ + "rules": { + "class-name": true, + "curly": true, + "eofline": false, + "forin": true, + "indent": [ + true, + 4 + ], + "label-position": true, + "max-line-length": [ + true, + 140 + ], + "no-arg": true, + "no-bitwise": true, + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-variable": true, + "no-empty": false, + "no-eval": true, + "no-string-literal": false, + "no-trailing-whitespace": true, + "no-use-before-declare": true, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-whitespace" + ], + "radix": true, + "semicolon": true, + "triple-equals": [ + true, + "allow-null-check" + ], + "variable-name": false, + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator" + ] + } +} diff --git a/webapp/typings.json b/webapp/typings.json new file mode 100644 index 0000000..23c6a41 --- /dev/null +++ b/webapp/typings.json @@ -0,0 +1,11 @@ +{ + "dependencies": {}, + "devDependencies": {}, + "globalDependencies": { + "es6-shim": "registry:dt/es6-shim#0.31.2+20160317120654", + "socket.io-client": "registry:dt/socket.io-client#1.4.4+20160317120654" + }, + "globalDevDependencies": { + "jasmine": "registry:dt/jasmine#2.2.0+20160505161446" + } +} |