From 97ca1f277dc8b6973d6fa67add5593a9c395ce60 Mon Sep 17 00:00:00 2001 From: Sebastien Douheret Date: Mon, 25 Sep 2017 14:15:16 +0200 Subject: [PATCH] Added webapp Dashboard + logic to interact with server. Signed-off-by: Sebastien Douheret --- .gitignore | 6 +- .vscode/launch.json | 4 +- .vscode/settings.json | 24 +- Makefile | 71 ++-- agent-config.json.in | 10 +- glide.yaml | 2 +- lib/agent/agent.go | 132 +++++- lib/agent/apiv1-browse.go | 28 ++ lib/agent/apiv1-config.go | 108 +++++ lib/agent/apiv1-events.go | 73 ++++ lib/agent/apiv1-exec.go | 99 +++++ lib/agent/apiv1-projects.go | 72 ++++ lib/agent/apiv1-version.go | 45 ++ lib/agent/apiv1.go | 129 ++++++ lib/agent/events.go | 131 ++++++ lib/agent/project-interface.go | 47 ++ lib/agent/project-pathmap.go | 79 ++++ lib/agent/project-st.go | 93 ++++ lib/agent/projects.go | 254 +++++++++++ lib/{session => agent}/session.go | 25 +- lib/agent/webserver.go | 246 +++++++++++ lib/agent/xdsserver.go | 472 +++++++++++++++++++++ lib/apiv1/apiv1.go | 36 -- lib/apiv1/config.go | 45 -- lib/apiv1/version.go | 24 -- lib/syncthing/st.go | 95 ++++- lib/syncthing/stEvent.go | 265 ++++++++++++ lib/syncthing/stfolder.go | 103 ++++- lib/webserver/server.go | 226 ---------- lib/xdsconfig/config.go | 78 +++- lib/xdsconfig/configfile.go | 112 +++++ lib/xdsconfig/fileconfig.go | 111 ----- main.go | 67 +-- webapp/README.md | 45 ++ webapp/assets/favicon.ico | Bin 0 -> 26463 bytes webapp/assets/images/iot-bzh-logo-small.png | Bin 0 -> 14449 bytes webapp/assets/images/iot-graphx.jpg | Bin 0 -> 138350 bytes webapp/bs-config.json | 9 + webapp/gulp.conf.js | 34 ++ webapp/gulpfile.js | 123 ++++++ webapp/package.json | 63 +++ webapp/src/app/alert/alert.component.ts | 30 ++ webapp/src/app/app.component.css | 31 ++ webapp/src/app/app.component.html | 30 ++ webapp/src/app/app.component.ts | 37 ++ webapp/src/app/app.module.ts | 93 ++++ webapp/src/app/app.routing.ts | 19 + webapp/src/app/config/config.component.css | 35 ++ webapp/src/app/config/config.component.html | 101 +++++ webapp/src/app/config/config.component.ts | 108 +++++ .../src/app/config/downloadXdsAgent.component.ts | 47 ++ webapp/src/app/devel/build/build.component.css | 54 +++ webapp/src/app/devel/build/build.component.html | 115 +++++ webapp/src/app/devel/build/build.component.ts | 223 ++++++++++ webapp/src/app/devel/devel.component.css | 19 + webapp/src/app/devel/devel.component.html | 40 ++ webapp/src/app/devel/devel.component.ts | 35 ++ webapp/src/app/home/home.component.ts | 81 ++++ webapp/src/app/main.ts | 6 + .../src/app/projects/projectAddModal.component.css | 24 ++ .../app/projects/projectAddModal.component.html | 54 +++ .../src/app/projects/projectAddModal.component.ts | 147 +++++++ webapp/src/app/projects/projectCard.component.ts | 91 ++++ .../projects/projectsListAccordion.component.ts | 39 ++ webapp/src/app/sdks/sdkAddModal.component.html | 23 + webapp/src/app/sdks/sdkAddModal.component.ts | 24 ++ webapp/src/app/sdks/sdkCard.component.ts | 55 +++ webapp/src/app/sdks/sdkSelectDropdown.component.ts | 48 +++ webapp/src/app/sdks/sdksListAccordion.component.ts | 26 ++ webapp/src/app/services/alert.service.ts | 66 +++ webapp/src/app/services/config.service.ts | 178 ++++++++ webapp/src/app/services/project.service.ts | 199 +++++++++ webapp/src/app/services/sdk.service.ts | 54 +++ webapp/src/app/services/syncthing.service.ts | 352 +++++++++++++++ webapp/src/app/services/utils.service.ts | 33 ++ webapp/src/app/services/xdsagent.service.ts | 401 +++++++++++++++++ webapp/src/index.html | 50 +++ webapp/src/systemjs.config.js | 69 +++ webapp/tsconfig.json | 18 + webapp/tslint.json | 55 +++ webapp/tslint.prod.json | 56 +++ webapp/typings.json | 11 + 82 files changed, 6167 insertions(+), 596 deletions(-) create mode 100644 lib/agent/apiv1-browse.go create mode 100644 lib/agent/apiv1-config.go create mode 100644 lib/agent/apiv1-events.go create mode 100644 lib/agent/apiv1-exec.go create mode 100644 lib/agent/apiv1-projects.go create mode 100644 lib/agent/apiv1-version.go create mode 100644 lib/agent/apiv1.go create mode 100644 lib/agent/events.go create mode 100644 lib/agent/project-interface.go create mode 100644 lib/agent/project-pathmap.go create mode 100644 lib/agent/project-st.go create mode 100644 lib/agent/projects.go rename lib/{session => agent}/session.go (89%) create mode 100644 lib/agent/webserver.go create mode 100644 lib/agent/xdsserver.go delete mode 100644 lib/apiv1/apiv1.go delete mode 100644 lib/apiv1/config.go delete mode 100644 lib/apiv1/version.go create mode 100644 lib/syncthing/stEvent.go delete mode 100644 lib/webserver/server.go create mode 100644 lib/xdsconfig/configfile.go delete mode 100644 lib/xdsconfig/fileconfig.go create mode 100644 webapp/README.md create mode 100644 webapp/assets/favicon.ico create mode 100644 webapp/assets/images/iot-bzh-logo-small.png create mode 100644 webapp/assets/images/iot-graphx.jpg create mode 100644 webapp/bs-config.json create mode 100644 webapp/gulp.conf.js create mode 100644 webapp/gulpfile.js create mode 100644 webapp/package.json create mode 100644 webapp/src/app/alert/alert.component.ts create mode 100644 webapp/src/app/app.component.css create mode 100644 webapp/src/app/app.component.html create mode 100644 webapp/src/app/app.component.ts create mode 100644 webapp/src/app/app.module.ts create mode 100644 webapp/src/app/app.routing.ts create mode 100644 webapp/src/app/config/config.component.css create mode 100644 webapp/src/app/config/config.component.html create mode 100644 webapp/src/app/config/config.component.ts create mode 100644 webapp/src/app/config/downloadXdsAgent.component.ts create mode 100644 webapp/src/app/devel/build/build.component.css create mode 100644 webapp/src/app/devel/build/build.component.html create mode 100644 webapp/src/app/devel/build/build.component.ts create mode 100644 webapp/src/app/devel/devel.component.css create mode 100644 webapp/src/app/devel/devel.component.html create mode 100644 webapp/src/app/devel/devel.component.ts create mode 100644 webapp/src/app/home/home.component.ts create mode 100644 webapp/src/app/main.ts create mode 100644 webapp/src/app/projects/projectAddModal.component.css create mode 100644 webapp/src/app/projects/projectAddModal.component.html create mode 100644 webapp/src/app/projects/projectAddModal.component.ts create mode 100644 webapp/src/app/projects/projectCard.component.ts create mode 100644 webapp/src/app/projects/projectsListAccordion.component.ts create mode 100644 webapp/src/app/sdks/sdkAddModal.component.html create mode 100644 webapp/src/app/sdks/sdkAddModal.component.ts create mode 100644 webapp/src/app/sdks/sdkCard.component.ts create mode 100644 webapp/src/app/sdks/sdkSelectDropdown.component.ts create mode 100644 webapp/src/app/sdks/sdksListAccordion.component.ts create mode 100644 webapp/src/app/services/alert.service.ts create mode 100644 webapp/src/app/services/config.service.ts create mode 100644 webapp/src/app/services/project.service.ts create mode 100644 webapp/src/app/services/sdk.service.ts create mode 100644 webapp/src/app/services/syncthing.service.ts create mode 100644 webapp/src/app/services/utils.service.ts create mode 100644 webapp/src/app/services/xdsagent.service.ts create mode 100644 webapp/src/index.html create mode 100644 webapp/src/systemjs.config.js create mode 100644 webapp/tsconfig.json create mode 100644 webapp/tslint.json create mode 100644 webapp/tslint.prod.json create mode 100644 webapp/typings.json diff --git a/.gitignore b/.gitignore index 6ecdd8f..2d75cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -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..d4a4e1e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,8 +15,8 @@ "DEBUG_MODE": "1", "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..c82504e 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", @@ -28,10 +39,17 @@ "WSID", "sess", "IXDS", + "golib", "xdsconfig", "xdsserver", + "xdsagent", + "nbsp", "Inot", "inotify", - "cmdi" + "cmdi", + "sdkid", + "Flds", + "prjs", + "iosk" ] -} \ No newline at end of file +} diff --git a/Makefile b/Makefile index fa17d57..cb0af7b 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,6 @@ SYNCTHING_VERSION = 0.14.28 SYNCTHING_INOTIFY_VERSION = 0.8.6 - # Retrieve git tag/commit to set sub-version string ifeq ($(origin SUB_VERSION), undefined) SUB_VERSION := $(shell git describe --exact-match --tags 2>/dev/null | sed 's/^v//') @@ -24,12 +23,12 @@ ifeq ($(origin SUB_VERSION), undefined) endif endif -# for backward compatibility -DESTDIR := $(INSTALL_DIR) - -# Configurable variables for installation (default /usr/local/...) +# Configurable variables for installation (default /opt/AGL/...) ifeq ($(origin DESTDIR), undefined) - DESTDIR := /usr/local/bin + DESTDIR := /opt/AGL/xds/agent +endif +ifeq ($(origin DESTDIR_WWW), undefined) + DESTDIR_WWW := $(DESTDIR)/www endif HOST_GOOS=$(shell go env GOOS) @@ -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 "$(GORELEASE) -X main.AppVersion=$(VERSION) -X main.AppSubVersion=$(SUB_VERSION)" -gcflags "$(GO_GCFLAGS)" . -package: clean tools/syncthing vendor build - @mkdir -p $(PACKAGE_DIR)/xds-agent - @cp agent-config.json.in $(PACKAGE_DIR)/xds-agent/agent-config.json - @cp -a $(LOCAL_BINDIR)/* $(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,19 +100,50 @@ 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: all mkdir -p $(DESTDIR) && cp $(LOCAL_BINDIR)/* $(DESTDIR) + mkdir -p $(DESTDIR_WWW) && cp -a webapp/dist/* $(DESTDIR_WWW) + +package: clean tools/syncthing vendor build + @mkdir -p $(PACKAGE_DIR)/xds-agent + @cp agent-config.json.in $(PACKAGE_DIR)/xds-agent/agent-config.json + @cp -a $(LOCAL_BINDIR)/* $(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 || { \ @@ -144,7 +161,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/agent-config.json.in b/agent-config.json.in index 0ef6d63..e8599cb 100644 --- a/agent-config.json.in +++ b/agent-config.json.in @@ -1,9 +1,15 @@ { + "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:8384", "gui-apikey": "1234abcezam" } -} \ No newline at end of file +} diff --git a/glide.yaml b/glide.yaml index af9ece2..033c303 100644 --- a/glide.yaml +++ b/glide.yaml @@ -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 + version: master - 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..29b0622 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -2,18 +2,22 @@ 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 @@ -22,8 +26,14 @@ type Context struct { SThg *st.SyncThing SThgCmd *exec.Cmd SThgInotCmd *exec.Cmd - WWWServer *webserver.ServerService - Exit chan os.Signal + + webServer *WebServer + xdsServers map[string]*XdsServer + sessions *Sessions + events *Events + projects *Projects + + Exit chan os.Signal } // NewAgent Create a new instance of Agent @@ -48,6 +58,10 @@ func NewAgent(cliCtx *cli.Context) *Context { ProgName: cliCtx.App.Name, Log: log, Exit: make(chan os.Signal, 1), + + webServer: nil, + xdsServers: make(map[string]*XdsServer), + events: nil, } // register handler on SIGTERM / exit @@ -57,6 +71,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 +190,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..83ec7aa --- /dev/null +++ b/lib/agent/apiv1-exec.go @@ -0,0 +1,99 @@ +package agent + +import ( + "encoding/json" + "io/ioutil" + "net/http" + + "github.com/gin-gonic/gin" + common "github.com/iotbzh/xds-common/golib" +) + +// Only define useful fields +type ExecArgs struct { + ID string `json:"id" binding:"required"` +} + +// 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(url string, c *gin.Context) { + data, err := c.GetRawData() + if err != nil { + common.APIError(c, err.Error()) + } + + // First get Project ID to retrieve Server ID and send command to right server + id := c.Param("id") + if id == "" { + 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 + } + 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 + for _, evName := range []string{ + "exec:input", + "exec:output", + "exec:exit", + "exec:inferior-input", + "exec:inferior-output", + } { + evN := evName + svr.EventOn(evN, func(evData interface{}) { + (*sock).Emit(evN, evData) + }) + } + + // Forward back command to right server + response, err := svr.HTTPPostBody(url, string(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..c2387c1 --- /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.HTTPGet("/version", &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..24efc5a --- /dev/null +++ b/lib/agent/events.go @@ -0,0 +1,131 @@ +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 { + // SEB cbs []EventsCB + 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") + } + + 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..031e1d9 --- /dev/null +++ b/lib/agent/project-interface.go @@ -0,0 +1,47 @@ +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" +) + +type EventCBData map[string]interface{} +type EventCB func(cfg *ProjectConfig, data *EventCBData) + +// 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 + SetProject(prj ProjectConfig) *ProjectConfig // Set project configuration + GetServer() *XdsServer // Get XdsServer that holds this project + GetFullPath(dir string) string // Get project full path + 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..1de8e11 --- /dev/null +++ b/lib/agent/project-pathmap.go @@ -0,0 +1,79 @@ +package agent + +import ( + "path/filepath" +) + +// IPROJECT interface implementation for native/path mapping projects + +// PathMap . +type PathMap struct { + *Context + server *XdsServer + folder *FolderConfig +} + +// NewProjectPathMap Create a new instance of PathMap +func NewProjectPathMap(ctx *Context, svr *XdsServer) *PathMap { + p := PathMap{ + Context: ctx, + server: svr, + folder: &FolderConfig{}, + } + return &p +} + +// Add a new project +func (p *PathMap) Add(cfg ProjectConfig) (*ProjectConfig, error) { + var err error + + // SEB TODO: check local/server directory access + + err = p.server.FolderAdd(p.server.ProjectToFolder(cfg), p.folder) + if err != nil { + return nil, err + } + + 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 +} + +// SetProject Set project config +func (p *PathMap) SetProject(prj ProjectConfig) *ProjectConfig { + p.folder = p.server.ProjectToFolder(prj) + return p.GetProject() +} + +// GetServer Get the XdsServer that holds this project +func (p *PathMap) GetServer() *XdsServer { + return p.server +} + +// GetFullPath returns the full path of a directory (from server POV) +func (p *PathMap) GetFullPath(dir string) string { + if &dir == nil { + return p.folder.DataPathMap.ServerPath + } + return filepath.Join(p.folder.DataPathMap.ServerPath, dir) +} + +// 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..28a287c --- /dev/null +++ b/lib/agent/project-st.go @@ -0,0 +1,93 @@ +package agent + +import "github.com/iotbzh/xds-agent/lib/syncthing" + +// SEB TODO + +// IPROJECT interface implementation for syncthing projects + +// STProject . +type STProject struct { + *Context + server *XdsServer + folder *FolderConfig +} + +// NewProjectST Create a new instance of STProject +func NewProjectST(ctx *Context, svr *XdsServer) *STProject { + p := STProject{ + Context: ctx, + server: svr, + folder: &FolderConfig{}, + } + return &p +} + +// Add a new project +func (p *STProject) Add(cfg ProjectConfig) (*ProjectConfig, error) { + var err error + + err = p.server.FolderAdd(p.server.ProjectToFolder(cfg), p.folder) + if err != nil { + return nil, err + } + svrPrj := p.GetProject() + + // Declare project into local Syncthing + p.SThg.FolderChange(st.FolderChangeArg{ + ID: cfg.ID, + Label: cfg.Label, + RelativePath: cfg.ClientPath, + SyncThingID: p.server.ServerConfig.Builder.SyncThingID, + }) + + return svrPrj, nil +} + +// Delete a project +func (p *STProject) Delete() error { + return p.server.FolderDelete(p.folder.ID) +} + +// 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 +} + +// SetProject Set project config +func (p *STProject) SetProject(prj ProjectConfig) *ProjectConfig { + // SEB TODO + p.folder = p.server.ProjectToFolder(prj) + return p.GetProject() +} + +// GetServer Get the XdsServer that holds this project +func (p *STProject) GetServer() *XdsServer { + // SEB TODO + return p.server +} + +// GetFullPath returns the full path of a directory (from server POV) +func (p *STProject) GetFullPath(dir string) string { + /* SEB + if &dir == nil { + return p.folder.DataSTProject.ServerPath + } + return filepath.Join(p.folder.DataSTProject.ServerPath, dir) + */ + return "SEB TODO" +} + +// Sync Force project files synchronization +func (p *STProject) Sync() error { + // SEB TODO + return nil +} + +// IsInSync Check if project files are in-sync +func (p *STProject) IsInSync() (bool, error) { + // SEB TODO + return false, nil +} diff --git a/lib/agent/projects.go b/lib/agent/projects.go new file mode 100644 index 0000000..39c120f --- /dev/null +++ b/lib/agent/projects.go @@ -0,0 +1,254 @@ +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 + //SEB registerCB []RegisteredCB +} + +/* SEB +type RegisteredCB struct { + cb *EventCB + data *EventCBData +} +*/ + +// 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), + //registerCB: []RegisteredCB{}, + } +} + +// 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 := []FolderConfig{} + if err := svr.HTTPGet("/folders", &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("Server %s, %d projects detected", svr.ID[:8], len(xFlds)) + for _, prj := range xFlds { + newP := svr.FolderToProject(prj) + if /*nPrj*/ _, err := p.createUpdate(newP, false, true); err != nil { + errMsg += "Error while creating project id " + prj.ID + ": " + err.Error() + "\n " + continue + } + + /* FIXME emit EVTProjectChange event ? + if err := p.events.Emit(EVTProjectChange, *nPrj); err != nil { + p.Log.Warningf("Cannot notify project change: %v", err) + } + */ + } + + } + + 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 { + /*SEB fld = f.SThg.NewFolderST(f.Conf)*/ + 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 folder: %v\n", err) + return newPrj, err + } + } else { + // Just update project config + newPrj = fld.SetProject(newF) + } + + // 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 similarity index 89% rename from lib/session/session.go rename to lib/agent/session.go index b56f9ff..e50abe1 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 } @@ -202,22 +199,22 @@ func (s *Sessions) monitorSessMap() { 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) + 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) + //SEB DEBUG 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..ead06d1 --- /dev/null +++ b/lib/agent/webserver.go @@ -0,0 +1,246 @@ +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() + } +} + +/* SEB +func (s *WebServer) isValidAPIKey(key string) bool { + return (key == s.Config.FileConf.XDSAPIKey && key != "") +} +*/ + +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..014415f --- /dev/null +++ b/lib/agent/xdsserver.go @@ -0,0 +1,472 @@ +package agent + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + "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/zhouhui8915/go-socket.io-client" +) + +// Server . +type XdsServer struct { + *Context + ID string + BaseURL string + APIURL string + PartialURL string + ConnRetry int + Connected bool + Disabled bool + ServerConfig *xdsServerConfig + + // callbacks + CBOnError func(error) + CBOnDisconnect func(error) + + // 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"` +} + +// FolderType XdsServer folder type +type FolderType string + +const ( + XdsTypePathMap = "PathMap" + XdsTypeCloudSync = "CloudSync" + XdsTypeCifsSmb = "CIFS" +) + +// FolderConfig XdsServer folder config +type FolderConfig struct { + ID string `json:"id"` + Label string `json:"label"` + ClientPath string `json:"path"` + Type FolderType `json:"type"` + Status string `json:"status"` + IsInSync bool `json:"isInSync"` + DefaultSdk string `json:"defaultSdk"` + // Specific data depending on which Type is used + DataPathMap PathMapConfig `json:"dataPathMap,omitempty"` + DataCloudSync CloudSyncConfig `json:"dataCloudSync,omitempty"` +} + +// PathMapConfig Path mapping specific data +type PathMapConfig struct { + ServerPath string `json:"serverPath"` +} + +// CloudSyncConfig CloudSync (AKA Syncthing) specific data +type CloudSyncConfig struct { + SyncThingID string `json:"syncThingID"` +} + +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, + + 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 SEB: 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 +} + +// FolderAdd Send POST request to add a folder +func (xs *XdsServer) FolderAdd(prj *FolderConfig, res interface{}) error { + response, err := xs.HTTPPost("/folder", prj) + if err != nil { + return err + } + if response.StatusCode != 200 { + return fmt.Errorf("FolderAdd error status=%s", response.Status) + } + // Result is a FolderConfig 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) +} + +// 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.HTTPPostBody(url, string(body)) +} + +// HTTPPostBody . +func (xs *XdsServer) HTTPPostBody(url string, body string) (*http.Response, error) { + return xs.client.HTTPPostWithRes(url, body) +} + +// 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.HTTPPostBody(url, string(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)) + }) +} + +// EventOn Register a callback on events reception +func (xs *XdsServer) EventOn(message string, f interface{}) (err error) { + if xs.ioSock == nil { + return fmt.Errorf("Io.Socket not initialized") + } + // FIXME SEB: support chain / multiple listeners + /* sockEvents map[string][]*caller + xs.sockEventsLock.Lock() + xs.sockEvents[message] = append(xs.sockEvents[message], f) + xs.sockEventsLock.Unlock() + xs.ioSock.On(message, func(ev) { + + }) + */ + return xs.ioSock.On(message, f) +} + +// ProjectToFolder +func (xs *XdsServer) ProjectToFolder(pPrj ProjectConfig) *FolderConfig { + stID := "" + if pPrj.Type == XdsTypeCloudSync { + stID, _ = xs.SThg.IDGet() + } + fPrj := FolderConfig{ + ID: pPrj.ID, + Label: pPrj.Label, + ClientPath: pPrj.ClientPath, + Type: FolderType(pPrj.Type), + Status: pPrj.Status, + IsInSync: pPrj.IsInSync, + DefaultSdk: pPrj.DefaultSdk, + DataPathMap: PathMapConfig{ + ServerPath: pPrj.ServerPath, + }, + DataCloudSync: CloudSyncConfig{ + SyncThingID: stID, + }, + } + return &fPrj +} + +// FolderToProject +func (xs *XdsServer) FolderToProject(fPrj FolderConfig) ProjectConfig { + pPrj := ProjectConfig{ + ID: fPrj.ID, + ServerID: xs.ID, + Label: fPrj.Label, + ClientPath: fPrj.ClientPath, + ServerPath: fPrj.DataPathMap.ServerPath, + Type: ProjectType(fPrj.Type), + Status: fPrj.Status, + IsInSync: fPrj.IsInSync, + 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 +} + +// 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 660738d..bc3b101 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 + // SEB TO TEST s.Events = s.NewEventListener() + return &s } @@ -182,6 +226,8 @@ func (s *SyncThing) Start() (*exec.Cmd, error) { "STNOUPGRADE=1", } + /* SEB 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 +257,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("(.*)") @@ -294,11 +340,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 +362,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 + //SEB TODO err = s.Events.Start() + + return err } // IDGet returns the Syncthing ID of Syncthing instance running locally @@ -347,3 +405,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..9ca8b78 --- /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_DEBUG_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..a5312eb 100644 --- a/lib/syncthing/stfolder.go +++ b/lib/syncthing/stfolder.go @@ -1,10 +1,12 @@ 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" ) @@ -18,23 +20,23 @@ type FolderChangeArg struct { } // 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 } // 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 + s.log.Errorf("not a valid device id (err %v)", err) + return "", err } - newDevice := config.DeviceConfiguration{ + newDevice := stconfig.DeviceConfiguration{ DeviceID: devID, Name: f.SyncThingID, Addresses: []string{"dynamic"}, @@ -60,18 +62,33 @@ func (s *SyncThing) FolderChange(f FolderChangeArg) error { id = f.SyncThingID[0:15] + "_" + label } - folder := config.FolderConfiguration{ - ID: id, - Label: label, - RawPath: filepath.Join(f.ShareRootDir, f.RelativePath), + // Resolve local path + pathCli, err := common.ResolveEnvVar(f.RelativePath) + if err != nil { + pathCli = f.RelativePath + } + // SEB still need ShareRootDir ? a sup + // pathCli := filepath.Join(f.ShareRootDir, f.RelativePath) + + folder := stconfig.FolderConfiguration{ + ID: id, + Label: label, + RawPath: pathCli, + AutoNormalize: true, } - folder.Devices = append(folder.Devices, config.FolderDeviceConfiguration{ + /* TODO - add it ? + if s.conf.FileConf.SThgConf.RescanIntervalS > 0 { + folder.RescanIntervalS = s.conf.FileConf.SThgConf.RescanIntervalS + } + */ + + 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 @@ -89,7 +106,7 @@ func (s *SyncThing) FolderChange(f FolderChangeArg) error { s.log.Errorln(err) } - return nil + return id, nil } // FolderDelete is called to delete a folder config @@ -114,3 +131,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..9cff862 100644 --- a/lib/xdsconfig/config.go +++ b/lib/xdsconfig/config.go @@ -2,6 +2,8 @@ package xdsconfig import ( "fmt" + "io" + "path/filepath" "os" @@ -12,14 +14,20 @@ import ( // Config parameters (json format) of /config command type Config struct { - Version string `json:"version"` - APIVersion string `json:"apiVersion"` - VersionGitTag string `json:"gitTag"` + Version string + APIVersion string + VersionGitTag string + Options Options + FileConf FileConfig + Log *logrus.Logger + LogVerboseOut io.Writer +} - // Private / un-exported fields - HTTPPort string `json:"-"` - FileConf *FileConfig `json:"-"` - Log *logrus.Logger `json:"-"` +// Options set at the command line +type Options struct { + ConfigFile string + LogLevel string + LogFile string } // Config default values @@ -32,39 +40,75 @@ const ( func Init(ctx *cli.Context, log *logrus.Logger) (*Config, error) { var err error + defaultWebAppDir := "${EXEPATH}/www" + defaultSTHomeDir := "${HOME}/.xds/agent/syncthing-config" + // Define default configuration c := Config{ 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", + // SEB XDSAPIKey: "1234abcezam", + ServersConf: []XDSServerConf{ + XDSServerConf{ + URL: "http://localhost:8000", + ConnRetry: 10, + }, + }, SThgConf: &SyncThingConf{ - Home: "${HOME}/.xds/agent/syncthing-config", + Home: defaultSTHomeDir, }, }, Log: log, } // 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..a47038b --- /dev/null +++ b/lib/xdsconfig/configfile.go @@ -0,0 +1,112 @@ +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"` + // SEB A SUP ? 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/ /agent-config.json file +// 4/ /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") + + searchIn = append(searchIn, path.Join(common.GetExePath(), "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 efe94bf..0000000 --- a/lib/xdsconfig/fileconfig.go +++ /dev/null @@ -1,111 +0,0 @@ -package xdsconfig - -import ( - "encoding/json" - "os" - "os/user" - "path" - "path/filepath" - - 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/ /agent-config.json file -// 4/ /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/agent-config.json") - - exePath := os.Args[0] - ee, _ := os.Executable() - exeAbsPath, err := filepath.Abs(ee) - if err == nil { - exePath, err = filepath.EvalSymlinks(exeAbsPath) - if err == nil { - exePath = filepath.Dir(ee) - } else { - exePath = filepath.Dir(exeAbsPath) - } - } - searchIn = append(searchIn, path.Join(exePath, "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 -} diff --git a/main.go b/main.go index 32083bb..b0ec7ca 100644 --- a/main.go +++ b/main.go @@ -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/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 new file mode 100644 index 0000000000000000000000000000000000000000..6bf5138aefee75ef550856546442f011220f5aee GIT binary patch literal 26463 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

cu1M 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 E1B0vi82|tP literal 0 HcmV?d00001 diff --git a/webapp/assets/images/iot-bzh-logo-small.png b/webapp/assets/images/iot-bzh-logo-small.png new file mode 100644 index 0000000000000000000000000000000000000000..2c3b2aef11e9b4fe5fd7f22340fab71ff6fa86e4 GIT binary patch literal 14449 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 literal 0 HcmV?d00001 diff --git a/webapp/assets/images/iot-graphx.jpg b/webapp/assets/images/iot-graphx.jpg new file mode 100644 index 0000000000000000000000000000000000000000..74c640a7430afbf7978f5c1a7a85b2f8e664b1ae GIT binary patch literal 138350 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 literal 0 HcmV?d00001 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: ` +

+ ` +}) + +export class AlertComponent { + + alerts$: Observable; + + 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 @@ + + + + +
+ +
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 @@ +
+
+

+ Global Configuration +
+ + + +
+

+
+
+
+
+ + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ +
+ +
+
+
+
+
+ +
+
+

+ Cross SDKs +
+ + + +
+

+
+
+
+ +
+
+
+ +
+
+

+ Projects +
+ + + +
+

+
+
+
+ +
+
+
+ + + + + + + + +
+
Config: {{config$ | async | json}}
+
+
Projects: {{projects$ | async | json}} 
+
diff --git a/webapp/src/app/config/config.component.ts b/webapp/src/app/config/config.component.ts new file mode 100644 index 0000000..101596f --- /dev/null +++ b/webapp/src/app/config/config.component.ts @@ -0,0 +1,108 @@ +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; + projects$: Observable; + sdks$: Observable; + agentStatus$: Observable; + + 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); + this.configSvr.loadProjects(); + } + +} 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: ` + + + `, + 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 @@ +
+
+

+ Build +
+ +
+

+
+
+
+
+ + + + + + + + + + + + + + + + + + +
Cross SDK + + +
Project root path
Sub-path
+ + +
+ Advanced Settings + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Clean Command
Pre-Build Command
Build Command
Populate Command
Env variables
Args variables
+
+
+
+
+
+
+
+ + + + + + +
+
+
+
+ +
+
+
+
+
+ +
+
+ {{ cmdInfo }} +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
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 = new Map(); + + 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 @@ +
+
+ + + + + + + +
Project +
+ + +
+ + No project detected, please create first a project using the configuration page. + +
+
+
+ +
+ +
+ +
+ +
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; + + 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: ` + + +
+ + + + + + +
+ ` +}) + +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: '

To Start: click on icon and add new folder

', + } + ]; + + 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 @@ + diff --git a/webapp/src/app/projects/projectAddModal.component.ts b/webapp/src/app/projects/projectAddModal.component.ts new file mode 100644 index 0000000..1584b5b --- /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 + + + 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("Configuration 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: ` +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
 Project ID{{ project.id }}
 Sharing type{{ project.type | readableType }}
 Local path{{ project.pathClient }}
 Server path{{ project.pathServer }}
 Status{{ project.status }} - {{ project.isInSync ? "Up to Date" : "Out of Sync"}} + +
+ `, + 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: ` + + + +
+ {{ prj.label }} +
+ + + +
+
+ +
+
+ ` +}) +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 @@ + 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: ` +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
 SDK ID{{ sdk.id }}
 Profile{{ sdk.profile }}
 Architecture{{ sdk.arch }}
 Version{{ sdk.version }}
 Sdk path{{ sdk.path}}
+ `, + 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: ` +
+ + +
+ ` +}) +export class SdkSelectDropdownComponent { + + // FIXME investigate to understand why not working with sdks as input + // + //@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: ` + + +
+ {{ sdk.name }} + +
+ +
+
+ ` +}) +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..c3cae7a --- /dev/null +++ b/webapp/src/app/services/alert.service.ts @@ -0,0 +1,66 @@ +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; + + private _alerts: IAlert[]; + private alertsSubject = >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) { + this._alerts.push({ + show: true, + type: al.type, + msg: this.sanitizer.sanitize(SecurityContext.HTML, al.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..090df7b --- /dev/null +++ b/webapp/src/app/services/config.service.ts @@ -0,0 +1,178 @@ +import { Injectable, OnInit } from '@angular/core'; +import { Http, Headers, RequestOptionsArgs, Response } from '@angular/http'; +import { Location } from '@angular/common'; +import { CookieService } from 'ngx-cookie'; +import { Observable } from 'rxjs/Observable'; +import { Subscriber } from 'rxjs/Subscriber'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + +// 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 { XDSAgentService, IXDSProjectConfig } from "../services/xdsagent.service"; +import { AlertService, IAlert } from "../services/alert.service"; +import { UtilsService } from "../services/utils.service"; + +export interface IConfig { + projectsRootDir: string; + //SEB projects: IProject[]; +} + +@Injectable() +export class ConfigService { + + public Conf$: Observable; + + private confSubject: BehaviorSubject; + private confStore: IConfig; + // SEB cleanup private AgentConnectObs = null; + // SEB cleanup private stConnectObs = null; + + constructor(private _window: Window, + private cookie: CookieService, + private xdsAgentSvr: XDSAgentService, + private alert: AlertService, + private utils: UtilsService, + ) { + this.load(); + this.confSubject = >new BehaviorSubject(this.confStore); + this.Conf$ = this.confSubject.asObservable(); + + // force to load projects + this.loadProjects(); + } + + // Load config + load() { + // Try to retrieve previous config from cookie + let cookConf = this.cookie.getObject("xds-config"); + if (cookConf != null) { + this.confStore = 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); + } + + loadProjects() { + /* SEB + // Setup connection with local XDS agent + if (this.AgentConnectObs) { + try { + this.AgentConnectObs.unsubscribe(); + } catch (err) { } + this.AgentConnectObs = null; + } + + let cfg = this.confStore.xdsAgent; + this.AgentConnectObs = this.xdsAgentSvr.connect(cfg.retry, cfg.URL) + .subscribe((sts) => { + //console.log("Agent sts", sts); + // FIXME: load projects from local XDS Agent and + // not directly from local syncthing + this._loadProjectFromLocalST(); + + }, error => { + if (error.indexOf("XDS local Agent not responding") !== -1) { + let url_OS_Linux = "https://en.opensuse.org/LinuxAutomotive#Installation_AGL_XDS"; + let url_OS_Other = "https://github.com/iotbzh/xds-agent#how-to-install-on-other-platform"; + let msg = `` + error + `
+ You may need to install and execute XDS-Agent:
+ On Linux machine +
+ On Windows machine +
+ On MacOS machine + `; + this.alert.error(msg); + } else { + this.alert.error(error); + } + }); + */ + } + + /* SEB + private _loadProjectFromLocalST() { + // Remove previous subscriber if existing + if (this.stConnectObs) { + try { + this.stConnectObs.unsubscribe(); + } catch (err) { } + this.stConnectObs = null; + } + + // FIXME: move this code and all logic about syncthing inside XDS Agent + // Setup connection with local SyncThing + let retry = this.confStore.localSThg.retry; + let url = this.confStore.localSThg.URL; + this.stConnectObs = this.stSvr.connect(retry, url).subscribe((sts) => { + this.confStore.localSThg.ID = sts.ID; + this.confStore.localSThg.tilde = sts.tilde; + if (this.confStore.projectsRootDir === "") { + this.confStore.projectsRootDir = sts.tilde; + } + + // Rebuild projects definition from local and remote syncthing + this.confStore.projects = []; + + this.xdsServerSvr.getProjects().subscribe(remotePrj => { + this.stSvr.getProjects().subscribe(localPrj => { + remotePrj.forEach(rPrj => { + let lPrj = localPrj.filter(item => item.id === rPrj.id); + if (lPrj.length > 0 || rPrj.type === ProjectType.NATIVE_PATHMAP) { + this._addProject(rPrj, true); + } + }); + this.confSubject.next(Object.assign({}, this.confStore)); + }), error => this.alert.error('Could not load initial state of local projects.'); + }), error => this.alert.error('Could not load initial state of remote projects.'); + + }, error => { + if (error.indexOf("Syncthing local daemon not responding") !== -1) { + let msg = "" + error + "
"; + msg += "Please check that local XDS-Agent is running.
"; + msg += "
"; + this.alert.error(msg); + } else { + this.alert.error(error); + } + }); + } + + set syncToolURL(url: string) { + this.confStore.localSThg.URL = url; + this.save(); + } + */ + + set projectsRootDir(p: string) { + /* SEB + if (p.charAt(0) === '~') { + p = this.confStore.localSThg.tilde + p.substring(1); + } + */ + 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; + + private _prjsList: IProject[] = []; + private current: IProject; + private prjsSubject = >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 { + 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 { + 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 { + 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; + + private _sdksList = []; + private current: ISdk; + private sdksSubject = >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/syncthing.service.ts b/webapp/src/app/services/syncthing.service.ts new file mode 100644 index 0000000..1561cbf --- /dev/null +++ b/webapp/src/app/services/syncthing.service.ts @@ -0,0 +1,352 @@ +import { Injectable } from '@angular/core'; +/* +import { Http, Headers, RequestOptionsArgs, Response } from '@angular/http'; +import { CookieService } from 'ngx-cookie'; +import { Location } from '@angular/common'; +import { Observable } from 'rxjs/Observable'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + +// Import RxJs required methods +import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/catch'; +import 'rxjs/add/observable/throw'; +import 'rxjs/add/observable/of'; +import 'rxjs/add/observable/timer'; +import 'rxjs/add/operator/retryWhen'; + +export interface ISyncThingProject { + id: string; + path: string; + serverSyncThingID: string; + label?: string; +} + +export interface ISyncThingStatus { + ID: string; + baseURL: string; + connected: boolean; + connectionRetry: number; + tilde: string; + rawStatus: any; +} + +// Private interfaces of Syncthing +const ISTCONFIG_VERSION = 20; + +interface ISTFolderDeviceConfiguration { + deviceID: string; + introducedBy: string; +} +interface ISTFolderConfiguration { + id: string; + label: string; + path: string; + type?: number; + devices?: ISTFolderDeviceConfiguration[]; + rescanIntervalS?: number; + ignorePerms?: boolean; + autoNormalize?: boolean; + minDiskFreePct?: number; + versioning?: { type: string; params: string[] }; + copiers?: number; + pullers?: number; + hashers?: number; + order?: number; + ignoreDelete?: boolean; + scanProgressIntervalS?: number; + pullerSleepS?: number; + pullerPauseS?: number; + maxConflicts?: number; + disableSparseFiles?: boolean; + disableTempIndexes?: boolean; + fsync?: boolean; + paused?: boolean; +} + +interface ISTDeviceConfiguration { + deviceID: string; + name?: string; + address?: string[]; + compression?: string; + certName?: string; + introducer?: boolean; + skipIntroductionRemovals?: boolean; + introducedBy?: string; + paused?: boolean; + allowedNetwork?: string[]; +} + +interface ISTGuiConfiguration { + enabled: boolean; + address: string; + user?: string; + password?: string; + useTLS: boolean; + apiKey?: string; + insecureAdminAccess?: boolean; + theme: string; + debugging: boolean; + insecureSkipHostcheck?: boolean; +} + +interface ISTOptionsConfiguration { + listenAddresses: string[]; + globalAnnounceServer: string[]; + // To be completed ... +} + +interface ISTConfiguration { + version: number; + folders: ISTFolderConfiguration[]; + devices: ISTDeviceConfiguration[]; + gui: ISTGuiConfiguration; + options: ISTOptionsConfiguration; + ignoredDevices: string[]; +} + +// Default settings +const DEFAULT_GUI_PORT = 8384; +const DEFAULT_GUI_API_KEY = "1234abcezam"; +const DEFAULT_RESCAN_INTERV = 0; // 0: use syncthing-inotify to detect changes + +*/ + +@Injectable() +export class SyncthingService { + + /* SEB A SUP + public Status$: Observable; + + private baseRestUrl: string; + private apikey: string; + private localSTID: string; + private stCurVersion: number; + private connectionMaxRetry: number; + private _status: ISyncThingStatus = { + ID: null, + baseURL: "", + connected: false, + connectionRetry: 0, + tilde: "", + rawStatus: null, + }; + private statusSubject = >new BehaviorSubject(this._status); + + constructor(private http: Http, private _window: Window, private cookie: CookieService) { + this._status.baseURL = 'http://localhost:' + DEFAULT_GUI_PORT; + this.baseRestUrl = this._status.baseURL + '/rest'; + this.apikey = DEFAULT_GUI_API_KEY; + this.stCurVersion = -1; + this.connectionMaxRetry = 10; // 10 seconds + + this.Status$ = this.statusSubject.asObservable(); + } + + connect(retry: number, url?: string): Observable { + if (url) { + this._status.baseURL = url; + this.baseRestUrl = this._status.baseURL + '/rest'; + } + this._status.connected = false; + this._status.ID = null; + this._status.connectionRetry = 0; + this.connectionMaxRetry = retry || 3600; // 1 hour + return this.getStatus(); + } + + getID(): Observable { + if (this._status.ID != null) { + return Observable.of(this._status.ID); + } + return this.getStatus().map(sts => sts.ID); + } + + getStatus(): Observable { + return this._get('/system/status') + .map((status) => { + this._status.ID = status["myID"]; + this._status.tilde = status["tilde"]; + console.debug('ST local ID', this._status.ID); + + this._status.rawStatus = status; + + return this._status; + }); + } + + getProjects(): Observable { + return this._getConfig() + .map((conf) => conf.folders); + } + + addProject(prj: ISyncThingProject): Observable { + return this.getID() + .flatMap(() => this._getConfig()) + .flatMap((stCfg) => { + let newDevID = prj.serverSyncThingID; + + // Add new Device if needed + let dev = stCfg.devices.filter(item => item.deviceID === newDevID); + if (dev.length <= 0) { + stCfg.devices.push( + { + deviceID: newDevID, + name: "Builder_" + newDevID.slice(0, 15), + address: ["dynamic"], + } + ); + } + + // Add or update Folder settings + let label = prj.label || ""; + let scanInterval = parseInt(this.cookie.get("st-rescanInterval"), 10) || DEFAULT_RESCAN_INTERV; + let folder: ISTFolderConfiguration = { + id: prj.id, + label: label, + path: prj.path, + devices: [{ deviceID: newDevID, introducedBy: "" }], + autoNormalize: true, + rescanIntervalS: scanInterval, + }; + + let idx = stCfg.folders.findIndex(item => item.id === prj.id); + if (idx === -1) { + stCfg.folders.push(folder); + } else { + let newFld = Object.assign({}, stCfg.folders[idx], folder); + stCfg.folders[idx] = newFld; + } + + // Set new config + return this._setConfig(stCfg); + }) + .flatMap(() => this._getConfig()) + .map((newConf) => { + let idx = newConf.folders.findIndex(item => item.id === prj.id); + return newConf.folders[idx]; + }); + } + + deleteProject(id: string): Observable { + let delPrj: ISTFolderConfiguration; + return this._getConfig() + .flatMap((conf: ISTConfiguration) => { + let idx = conf.folders.findIndex(item => item.id === id); + if (idx === -1) { + throw new Error("Cannot delete project: not found"); + } + delPrj = Object.assign({}, conf.folders[idx]); + conf.folders.splice(idx, 1); + return this._setConfig(conf); + }) + .map(() => delPrj); + } + + // + // --- Private functions --- + // + private _getConfig(): Observable { + return this._get('/system/config'); + } + + private _setConfig(cfg: ISTConfiguration): Observable { + return this._post('/system/config', cfg); + } + + 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'); + if (this.apikey !== "") { + headers.append('X-API-Key', this.apikey); + + } + options.headers = headers; + return options; + } + + private _checkAlive(): Observable { + if (this._status.connected) { + return Observable.of(true); + } + + return this.http.get(this.baseRestUrl + '/system/version', this._attachAuthHeaders()) + .map((r) => this._status.connected = true) + .retryWhen((attempts) => { + this._status.connectionRetry = 0; + return attempts.flatMap(error => { + this._status.connected = false; + if (++this._status.connectionRetry >= this.connectionMaxRetry) { + return Observable.throw("Syncthing local daemon not responding (url=" + this._status.baseURL + ")"); + } else { + return Observable.timer(1000); + } + }); + }); + } + + private _getAPIVersion(): Observable { + if (this.stCurVersion !== -1) { + return Observable.of(this.stCurVersion); + } + + return this.http.get(this.baseRestUrl + '/system/config', this._attachAuthHeaders()) + .map((res: Response) => { + let conf: ISTConfiguration = res.json(); + this.stCurVersion = (conf && conf.version) || -1; + return this.stCurVersion; + }) + .catch(this._handleError); + } + + private _checkAPIVersion(): Observable { + return this._getAPIVersion().map(ver => { + if (ver !== ISTCONFIG_VERSION) { + throw new Error("Unsupported Syncthing version api (" + ver + + " != " + ISTCONFIG_VERSION + ") !"); + } + return ver; + }); + } + + private _get(url: string): Observable { + return this._checkAlive() + .flatMap(() => this._checkAPIVersion()) + .flatMap(() => this.http.get(this.baseRestUrl + url, this._attachAuthHeaders())) + .map((res: Response) => res.json()) + .catch(this._handleError); + } + + private _post(url: string, body: any): Observable { + return this._checkAlive() + .flatMap(() => this._checkAPIVersion()) + .flatMap(() => this.http.post(this.baseRestUrl + url, JSON.stringify(body), this._attachAuthHeaders())) + .map((res: Response) => { + if (res && res.status && res.status === 200) { + return res; + } + throw new Error(res.toString()); + + }) + .catch(this._handleError); + } + + private _handleError(error: Response | any) { + // In a real world app, you might use a remote logging infrastructure + let errMsg: string; + if (this._status) { + this._status.connected = false; + } + if (error instanceof Response) { + const body = error.json() || 'Server error'; + const err = body.error || JSON.stringify(body); + errMsg = `${error.status} - ${error.statusText || ''} ${err}`; + } else { + errMsg = error.message ? error.message : error.toString(); + } + return Observable.throw(errMsg); + } + */ +} 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..e570399 --- /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; + public Status$: Observable; + public ProjectState$ = >new Subject(); + public CmdOutput$ = >new Subject(); + public CmdExit$ = >new Subject(); + + private baseUrl: string; + private wsUrl: string; + private _config = { servers: [] }; + private _status = { WS_connected: false }; + + private configSubject = >new BehaviorSubject(this._config); + private statusSubject = >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({}, data)); + }); + + this.socket.on('make:exit', data => { + this.CmdExit$.next(Object.assign({}, data)); + }); + + this.socket.on('exec:output', data => { + this.CmdOutput$.next(Object.assign({}, data)); + }); + + this.socket.on('exec:exit', data => { + this.CmdExit$.next(Object.assign({}, 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 { + return this._get('/version'); + } + + /*** + ** Config + ***/ + getConfig(): Observable { + return this._get('/config'); + } + + setConfig(cfg: IXDSConfig): Observable { + 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 { + let svr = this._getServer(serverID); + if (!svr || !svr.connected) { + return Observable.of([]); + } + + return this._get(svr.partialUrl + '/sdks'); + } + + /*** + ** Projects + ***/ + getProjects(): Observable { + return this._get('/projects'); + } + + addProject(cfg: IXDSProjectConfig): Observable { + return this._post('/project', cfg); + } + + deleteProject(id: string): Observable { + return this._delete('/project/' + id); + } + + syncProject(id: string): Observable { + return this._post('/project/sync/' + id, {}); + } + + /*** + ** Exec + ***/ + exec(prjID: string, dir: string, cmd: string, sdkid?: string, args?: string[], env?: string[]): Observable { + 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 { + // 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 { + return this.http.get(this.baseUrl + url, this._attachAuthHeaders()) + .map((res: Response) => res.json()) + .catch(this._decodeError); + } + private _post(url: string, body: any): Observable { + 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 { + 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 = `${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 @@ + + + + + XDS Dashboard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
Loading... + +
+
+ + + 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" + } +} -- 2.16.6