Added webapp Dashboard + logic to interact with server.
authorSebastien Douheret <sebastien.douheret@iot.bzh>
Mon, 25 Sep 2017 12:15:16 +0000 (14:15 +0200)
committerSebastien Douheret <sebastien.douheret@iot.bzh>
Fri, 6 Oct 2017 16:25:04 +0000 (18:25 +0200)
Signed-off-by: Sebastien Douheret <sebastien.douheret@iot.bzh>
82 files changed:
.gitignore
.vscode/launch.json
.vscode/settings.json
Makefile
agent-config.json.in
glide.yaml
lib/agent/agent.go
lib/agent/apiv1-browse.go [new file with mode: 0644]
lib/agent/apiv1-config.go [new file with mode: 0644]
lib/agent/apiv1-events.go [new file with mode: 0644]
lib/agent/apiv1-exec.go [new file with mode: 0644]
lib/agent/apiv1-projects.go [new file with mode: 0644]
lib/agent/apiv1-version.go [new file with mode: 0644]
lib/agent/apiv1.go [new file with mode: 0644]
lib/agent/events.go [new file with mode: 0644]
lib/agent/project-interface.go [new file with mode: 0644]
lib/agent/project-pathmap.go [new file with mode: 0644]
lib/agent/project-st.go [new file with mode: 0644]
lib/agent/projects.go [new file with mode: 0644]
lib/agent/session.go [moved from lib/session/session.go with 89% similarity]
lib/agent/webserver.go [new file with mode: 0644]
lib/agent/xdsserver.go [new file with mode: 0644]
lib/apiv1/apiv1.go [deleted file]
lib/apiv1/config.go [deleted file]
lib/apiv1/version.go [deleted file]
lib/syncthing/st.go
lib/syncthing/stEvent.go [new file with mode: 0644]
lib/syncthing/stfolder.go
lib/webserver/server.go [deleted file]
lib/xdsconfig/config.go
lib/xdsconfig/configfile.go [new file with mode: 0644]
lib/xdsconfig/fileconfig.go [deleted file]
main.go
webapp/README.md [new file with mode: 0644]
webapp/assets/favicon.ico [new file with mode: 0644]
webapp/assets/images/iot-bzh-logo-small.png [new file with mode: 0644]
webapp/assets/images/iot-graphx.jpg [new file with mode: 0644]
webapp/bs-config.json [new file with mode: 0644]
webapp/gulp.conf.js [new file with mode: 0644]
webapp/gulpfile.js [new file with mode: 0644]
webapp/package.json [new file with mode: 0644]
webapp/src/app/alert/alert.component.ts [new file with mode: 0644]
webapp/src/app/app.component.css [new file with mode: 0644]
webapp/src/app/app.component.html [new file with mode: 0644]
webapp/src/app/app.component.ts [new file with mode: 0644]
webapp/src/app/app.module.ts [new file with mode: 0644]
webapp/src/app/app.routing.ts [new file with mode: 0644]
webapp/src/app/config/config.component.css [new file with mode: 0644]
webapp/src/app/config/config.component.html [new file with mode: 0644]
webapp/src/app/config/config.component.ts [new file with mode: 0644]
webapp/src/app/config/downloadXdsAgent.component.ts [new file with mode: 0644]
webapp/src/app/devel/build/build.component.css [new file with mode: 0644]
webapp/src/app/devel/build/build.component.html [new file with mode: 0644]
webapp/src/app/devel/build/build.component.ts [new file with mode: 0644]
webapp/src/app/devel/devel.component.css [new file with mode: 0644]
webapp/src/app/devel/devel.component.html [new file with mode: 0644]
webapp/src/app/devel/devel.component.ts [new file with mode: 0644]
webapp/src/app/home/home.component.ts [new file with mode: 0644]
webapp/src/app/main.ts [new file with mode: 0644]
webapp/src/app/projects/projectAddModal.component.css [new file with mode: 0644]
webapp/src/app/projects/projectAddModal.component.html [new file with mode: 0644]
webapp/src/app/projects/projectAddModal.component.ts [new file with mode: 0644]
webapp/src/app/projects/projectCard.component.ts [new file with mode: 0644]
webapp/src/app/projects/projectsListAccordion.component.ts [new file with mode: 0644]
webapp/src/app/sdks/sdkAddModal.component.html [new file with mode: 0644]
webapp/src/app/sdks/sdkAddModal.component.ts [new file with mode: 0644]
webapp/src/app/sdks/sdkCard.component.ts [new file with mode: 0644]
webapp/src/app/sdks/sdkSelectDropdown.component.ts [new file with mode: 0644]
webapp/src/app/sdks/sdksListAccordion.component.ts [new file with mode: 0644]
webapp/src/app/services/alert.service.ts [new file with mode: 0644]
webapp/src/app/services/config.service.ts [new file with mode: 0644]
webapp/src/app/services/project.service.ts [new file with mode: 0644]
webapp/src/app/services/sdk.service.ts [new file with mode: 0644]
webapp/src/app/services/syncthing.service.ts [new file with mode: 0644]
webapp/src/app/services/utils.service.ts [new file with mode: 0644]
webapp/src/app/services/xdsagent.service.ts [new file with mode: 0644]
webapp/src/index.html [new file with mode: 0644]
webapp/src/systemjs.config.js [new file with mode: 0644]
webapp/tsconfig.json [new file with mode: 0644]
webapp/tslint.json [new file with mode: 0644]
webapp/tslint.prod.json [new file with mode: 0644]
webapp/typings.json [new file with mode: 0644]

index 6ecdd8f..2d75cb7 100644 (file)
@@ -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
+__*
index ed892d6..d4a4e1e 100644 (file)
@@ -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
+}
index 1bc5381..c82504e 100644 (file)
@@ -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",
     "WSID",
     "sess",
     "IXDS",
+    "golib",
     "xdsconfig",
     "xdsserver",
+    "xdsagent",
+    "nbsp",
     "Inot",
     "inotify",
-    "cmdi"
+    "cmdi",
+    "sdkid",
+    "Flds",
+    "prjs",
+    "iosk"
   ]
-}
\ No newline at end of file
+}
index fa17d57..cb0af7b 100644 (file)
--- 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)
index 0ef6d63..e8599cb 100644 (file)
@@ -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
+}
index af9ece2..033c303 100644 (file)
@@ -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
index 74872f7..29b0622 100644 (file)
@@ -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 (file)
index 0000000..1701a2e
--- /dev/null
@@ -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 (file)
index 0000000..31d8de6
--- /dev/null
@@ -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 (file)
index 0000000..8aad18a
--- /dev/null
@@ -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 (file)
index 0000000..83ec7aa
--- /dev/null
@@ -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 (file)
index 0000000..d4b5e74
--- /dev/null
@@ -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 (file)
index 0000000..c2387c1
--- /dev/null
@@ -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 (file)
index 0000000..77b05ba
--- /dev/null
@@ -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 (file)
index 0000000..24efc5a
--- /dev/null
@@ -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 (file)
index 0000000..031e1d9
--- /dev/null
@@ -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 (file)
index 0000000..1de8e11
--- /dev/null
@@ -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 (file)
index 0000000..28a287c
--- /dev/null
@@ -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 (file)
index 0000000..39c120f
--- /dev/null
@@ -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()
+}
similarity index 89%
rename from lib/session/session.go
rename to lib/agent/session.go
index b56f9ff..e50abe1 100644 (file)
@@ -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 (file)
index 0000000..ead06d1
--- /dev/null
@@ -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 (file)
index 0000000..014415f
--- /dev/null
@@ -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 (file)
index 734929b..0000000
+++ /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 (file)
index 47155ed..0000000
+++ /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 (file)
index e022441..0000000
+++ /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)
-}
index 660738d..bc3b101 100644 (file)
@@ -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("<apikey>(.*)</apikey>")
@@ -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 (file)
index 0000000..9ca8b78
--- /dev/null
@@ -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)
+}
index d79e579..a5312eb 100644 (file)
@@ -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 (file)
index b835a65..0000000
+++ /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)
-}
index 854d383..9cff862 100644 (file)
@@ -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 (file)
index 0000000..a47038b
--- /dev/null
@@ -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/ <current_dir>/agent-config.json file
+//  4/ <executable dir>/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 (file)
index efe94bf..0000000
+++ /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/ <current_dir>/agent-config.json file
-//  4/ <executable dir>/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 (file)
--- 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 (file)
index 0000000..acee846
--- /dev/null
@@ -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 (file)
index 0000000..6bf5138
Binary files /dev/null and b/webapp/assets/favicon.ico differ
diff --git a/webapp/assets/images/iot-bzh-logo-small.png b/webapp/assets/images/iot-bzh-logo-small.png
new file mode 100644 (file)
index 0000000..2c3b2ae
Binary files /dev/null and b/webapp/assets/images/iot-bzh-logo-small.png differ
diff --git a/webapp/assets/images/iot-graphx.jpg b/webapp/assets/images/iot-graphx.jpg
new file mode 100644 (file)
index 0000000..74c640a
Binary files /dev/null and b/webapp/assets/images/iot-graphx.jpg differ
diff --git a/webapp/bs-config.json b/webapp/bs-config.json
new file mode 100644 (file)
index 0000000..0041c6d
--- /dev/null
@@ -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 (file)
index 0000000..2e8fa17
--- /dev/null
@@ -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 (file)
index 0000000..0226380
--- /dev/null
@@ -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 (file)
index 0000000..9c22f6b
--- /dev/null
@@ -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 (file)
index 0000000..672d7bf
--- /dev/null
@@ -0,0 +1,30 @@
+import { Component } from '@angular/core';
+import { Observable } from 'rxjs';
+
+import {AlertService, IAlert} from '../services/alert.service';
+
+@Component({
+    selector: 'app-alert',
+    template: `
+        <div style="width:80%; margin-left:auto; margin-right:auto;" *ngFor="let alert of (alerts$ | async)">
+            <alert *ngIf="alert.show" [type]="alert.type" [dismissible]="alert.dismissible" [dismissOnTimeout]="alert.dismissTimeout"
+            (onClose)="onClose(alert)">
+                <div style="text-align:center;" [innerHtml]="alert.msg"></div>
+            </alert>
+        </div>
+    `
+})
+
+export class AlertComponent {
+
+    alerts$: Observable<IAlert[]>;
+
+    constructor(private alertSvr: AlertService) {
+        this.alerts$ = this.alertSvr.alerts;
+    }
+
+    onClose(al) {
+        this.alertSvr.del(al);
+    }
+
+}
diff --git a/webapp/src/app/app.component.css b/webapp/src/app/app.component.css
new file mode 100644 (file)
index 0000000..a47ad13
--- /dev/null
@@ -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 (file)
index 0000000..a889b12
--- /dev/null
@@ -0,0 +1,30 @@
+<nav class="navbar navbar-fixed-top">
+    <!-- navbar-inverse"> -->
+    <div class="container-fluid">
+        <div class="navbar-header">
+            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#myNavbar"
+          [attr.aria-expanded]="!isCollapsed" (click)="isCollapsed = !isCollapsed;" [ngClass]="{'collapsed': isCollapsed}">
+                <span class="icon-bar"></span>
+                <span class="icon-bar"></span>
+                <span class="icon-bar"></span>
+            </button>
+
+            <img class="navbar-brand" id="logo-iot" src="assets/images/iot-bzh-logo-small.png">
+            <a class="navbar-brand" href="#">X(cross) Development System Dashboard</a>
+        </div>
+
+        <div class="collapse navbar-collapse" [ngClass]="{'in': !isCollapsed}" id="myNavbar">
+            <ul class="nav navbar-nav navbar-right">
+                <li><a routerLink="/config"><i class="fa fa-2x fa-cog" title="Open configuration page" (click)="isCollapsed=true;"></i></a></li>
+                <li><a routerLink="/devel"><i class="fa fa-2x fa-play-circle" title="Open build page" (click)="isCollapsed=true;"></i></a></li>
+                <li><a routerLink="/home"><i class="fa fa-2x fa-home" title="Back to home page" (click)="isCollapsed=true;"></i></a></li>
+            </ul>
+        </div>
+    </div>
+</nav>
+
+<app-alert id="alert"></app-alert>
+
+<div style="margin:10px;">
+    <router-outlet></router-outlet>
+</div>
diff --git a/webapp/src/app/app.component.ts b/webapp/src/app/app.component.ts
new file mode 100644 (file)
index 0000000..40cfb24
--- /dev/null
@@ -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 (file)
index 0000000..c3fd586
--- /dev/null
@@ -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 (file)
index 0000000..f0d808f
--- /dev/null
@@ -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 (file)
index 0000000..6412f9a
--- /dev/null
@@ -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 (file)
index 0000000..4dbd238
--- /dev/null
@@ -0,0 +1,101 @@
+<div class="panel panel-default">
+    <div class="panel-heading">
+        <h2 class="panel-title" (click)="gConfigIsCollapsed = !gConfigIsCollapsed">
+            Global Configuration
+            <div class="pull-right">
+                <span class="fa fa-fw fa-exchange fa-size-x2" [style.color]="((agentStatus$ | async)?.WS_connected)?'green':'red'"></span>
+
+                <button class="btn btn-link" (click)="gConfigIsCollapsed = !gConfigIsCollapsed; $event.stopPropagation()">
+                    <span class="fa fa-big" [ngClass]="{'fa-angle-double-down': gConfigIsCollapsed, 'fa-angle-double-right': !gConfigIsCollapsed}"></span>
+                </button>
+            </div>
+        </h2>
+    </div>
+    <div class="panel-body" [collapse]="gConfigIsCollapsed && xdsServerConnected">
+        <div class="row">
+            <div class="col-xs-12">
+                <table class="table table-condensed">
+                    <tbody>
+                        <tr [ngClass]="{'info': xdsServerConnected, 'danger': !xdsServerConnected}">
+                            <th><label>XDS Server URL</label></th>
+                            <td> <input type="text" [(ngModel)]="xdsServerUrl"></td>
+                            <td style="white-space: nowrap">
+                                <div class="btn-group">
+                                    <button class="btn btn-link" (click)="xdsAgentRestartConn()"><span class="fa fa-refresh fa-size-x2"></span></button>
+                                    <dl-xds-agent class="button"></dl-xds-agent>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr class="info">
+                            <th><label>XDS Server connection retry</label></th>
+                            <td> <input type="text" [(ngModel)]="xdsServerRetry" (ngModelChange)="showApplyBtn['retry'] = true"></td>
+                            <td>
+                                <button *ngIf="showApplyBtn['retry']" class="btn btn-primary btn-xs" (click)="submitGlobConf('retry')">APPLY</button>
+                            </td>
+                        </tr>
+                        <tr class="info">
+                            <th><label>Local Projects root directory</label></th>
+                            <td> <input type="text" [(ngModel)]="projectsRootDir" (ngModelChange)="showApplyBtn['rootDir'] = true"></td>
+                            <td>
+                                <button *ngIf="showApplyBtn['rootDir']" class="btn btn-primary btn-xs" (click)="submitGlobConf('rootDir')">APPLY</button>
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="panel panel-default">
+    <div class="panel-heading">
+        <h2 class="panel-title" (click)="sdksIsCollapsed = !sdksIsCollapsed">
+            Cross SDKs
+            <div class="pull-right">
+                <button class="btn btn-link" (click)="childSdkModal.show(); $event.stopPropagation()"><span class="fa fa-plus fa-size-x2"></span></button>
+
+                <button class="btn btn-link" (click)="sdksIsCollapsed = !sdksIsCollapsed; $event.stopPropagation()">
+                    <span class="fa fa-big" [ngClass]="{'fa-angle-double-down': sdksIsCollapsed, 'fa-angle-double-right': !sdksIsCollapsed}"></span>
+                </button>
+            </div>
+        </h2>
+    </div>
+    <div class="panel-body" [collapse]="sdksIsCollapsed">
+        <div class="row col-xs-12">
+            <sdks-list-accordion [sdks]="(sdks$ | async)"></sdks-list-accordion>
+        </div>
+    </div>
+</div>
+
+<div class="panel panel-default">
+    <div class="panel-heading">
+        <h2 class="panel-title" (click)="projectsIsCollapsed = !projectsIsCollapsed; $event.stopPropagation()">
+            Projects
+            <div class="pull-right">
+                <button class="btn btn-link" (click)="childProjectModal.show(); $event.stopPropagation()"><span class="fa fa-plus fa-size-x2"></span></button>
+
+                <button class="btn btn-link" (click)="projectsIsCollapsed = !projectsIsCollapsed; $event.stopPropagation()">
+                       <span class="fa fa-big" [ngClass]="{'fa-angle-double-down': projectsIsCollapsed, 'fa-angle-double-right': !projectsIsCollapsed}"></span>
+                </button>
+            </div>
+        </h2>
+    </div>
+    <div class="panel-body" [collapse]="projectsIsCollapsed">
+        <div class="row col-xs-12">
+            <projects-list-accordion [projects]="(projects$ | async)"></projects-list-accordion>
+        </div>
+    </div>
+</div>
+
+<!-- Modals -->
+<project-add-modal #childProjectModal [title]="'Add a new project'" [server-id]=curServerID>
+</project-add-modal>
+<sdk-add-modal  #childSdkModal [title]="'Add a new SDK'">
+</sdk-add-modal>
+
+<!-- only for debug -->
+<div *ngIf="false" class="row">
+    <pre>Config: {{config$ | async | json}}</pre>
+    <br>
+    <pre>Projects: {{projects$ | async | json}} </pre>
+</div>
diff --git a/webapp/src/app/config/config.component.ts b/webapp/src/app/config/config.component.ts
new file mode 100644 (file)
index 0000000..101596f
--- /dev/null
@@ -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<IConfig>;
+    projects$: Observable<IProject[]>;
+    sdks$: Observable<ISdk[]>;
+    agentStatus$: Observable<IAgentStatus>;
+
+    curProj: number;
+    curServer: number;
+    curServerID: string;
+    userEditedLabel: boolean = false;
+
+    gConfigIsCollapsed: boolean = true;
+    sdksIsCollapsed: boolean = true;
+    projectsIsCollapsed: boolean = false;
+
+    // TODO replace by reactive FormControl + add validation
+    xdsServerConnected: boolean = false;
+    xdsServerUrl: string;
+    xdsServerRetry: string;
+    projectsRootDir: string;    // FIXME: should be remove when projectAddModal will always return full path
+    showApplyBtn = {    // Used to show/hide Apply buttons
+        "retry": false,
+        "rootDir": false,
+    };
+
+    constructor(
+        private configSvr: ConfigService,
+        private projectSvr: ProjectService,
+        private xdsAgentSvr: XDSAgentService,
+        private sdkSvr: SdkService,
+        private alert: AlertService,
+    ) {
+    }
+
+    ngOnInit() {
+        this.config$ = this.configSvr.Conf$;
+        this.projects$ = this.projectSvr.Projects$;
+        this.sdks$ = this.sdkSvr.Sdks$;
+        this.agentStatus$ = this.xdsAgentSvr.Status$;
+
+        // FIXME support multiple servers
+        this.curServer = 0;
+
+        // Bind xdsServerUrl to baseURL
+        this.xdsAgentSvr.XdsConfig$.subscribe(cfg => {
+            if (!cfg || cfg.servers.length < 1) {
+                return;
+            }
+            let svr = cfg.servers[this.curServer];
+            this.curServerID = svr.id;
+            this.xdsServerConnected = svr.connected;
+            this.xdsServerUrl = svr.url;
+            this.xdsServerRetry = String(svr.connRetry);
+            this.projectsRootDir = ''; // SEB FIXME: add in go config? cfg.projectsRootDir;
+        });
+    }
+
+    submitGlobConf(field: string) {
+        switch (field) {
+            case "retry":
+                let re = new RegExp('^[0-9]+$');
+                let rr = parseInt(this.xdsServerRetry, 10);
+                if (re.test(this.xdsServerRetry) && rr >= 0) {
+                    this.xdsAgentSvr.setServerRetry(this.curServerID, rr);
+                } else {
+                    this.alert.warning("Not a valid number", true);
+                }
+                break;
+            case "rootDir":
+                this.configSvr.projectsRootDir = this.projectsRootDir;
+                break;
+            default:
+                return;
+        }
+        this.showApplyBtn[field] = false;
+    }
+
+    xdsAgentRestartConn() {
+        let url = this.xdsServerUrl;
+        this.xdsAgentSvr.setServerUrl(this.curServerID, url);
+        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 (file)
index 0000000..0b63e50
--- /dev/null
@@ -0,0 +1,47 @@
+import { Component, Input, Pipe, PipeTransform } from '@angular/core';
+
+@Component({
+    selector: 'dl-xds-agent',
+    template: `
+        <template #popTemplate>
+            <h3>Install xds-agent:</h3>
+            <ul>
+                <li>On Linux machine <a href="{{url_OS_Linux}}" target="_blank">
+                <span class="fa fa-external-link"></span></a></li>
+
+                <li>On Windows machine <a href="{{url_OS_Other}}" target="_blank"><span class="fa fa-external-link"></span></a></li>
+
+                <li>On MacOS machine <a href="{{url_OS_Other}}" target="_blank"><span class="fa fa-external-link"></span></a></li>
+            </ul>
+            <button type="button" class="btn btn-sm" (click)="pop.hide()"> Cancel </button>
+        </template>
+        <button type="button" class="btn btn-link fa fa-download fa-size-x2"
+            [popover]="popTemplate"
+            #pop="bs-popover"
+            placement="left">
+        </button>
+        `,
+    styles: [`
+        .fa-size-x2 {
+            font-size: 20px;
+        }
+    `]
+})
+
+export class DlXdsAgentComponent {
+
+    public url_OS_Linux = "https://en.opensuse.org/LinuxAutomotive#Installation_AGL_XDS";
+    public url_OS_Other = "https://github.com/iotbzh/xds-agent#how-to-install-on-other-platform";
+}
+
+@Pipe({
+    name: 'capitalize'
+})
+export class CapitalizePipe implements PipeTransform {
+    transform(value: string): string {
+        if (value) {
+            return value.charAt(0).toUpperCase() + value.slice(1);
+        }
+        return value;
+    }
+}
diff --git a/webapp/src/app/devel/build/build.component.css b/webapp/src/app/devel/build/build.component.css
new file mode 100644 (file)
index 0000000..695a89b
--- /dev/null
@@ -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 (file)
index 0000000..2bcd2c7
--- /dev/null
@@ -0,0 +1,115 @@
+<div class="panel panel-default">
+    <div class="panel-heading">
+        <h2 class="panel-title" (click)="buildIsCollapsed = !buildIsCollapsed">
+            Build
+            <div class="pull-right">
+                <button class="btn btn-link" (click)="buildIsCollapsed = !buildIsCollapsed; $event.stopPropagation()">
+                    <span class="fa fa-big" [ngClass]="{'fa-angle-double-down': buildIsCollapsed, 'fa-angle-double-right': !buildIsCollapsed}"></span>
+                </button>
+            </div>
+        </h2>
+    </div>
+    <div class="panel-body" [collapse]="buildIsCollapsed">
+        <form [formGroup]="buildForm">
+            <div class="col-xs-12">
+                <table class="table table-borderless table-center">
+                    <tbody>
+                        <tr>
+                            <th>Cross SDK</th>
+                            <td>
+                                <!-- FIXME why not working ?
+                        <sdk-select-dropdown [sdks]="(sdks$ | async)"></sdk-select-dropdown>
+                        -->
+                                <sdk-select-dropdown></sdk-select-dropdown>
+                            </td>
+                        </tr>
+                        <tr>
+                            <th>Project root path</th>
+                            <td> <input type="text" disabled style="width:99%;" [value]="curProject && curProject.pathClient"></td>
+                        </tr>
+                        <tr>
+                            <th>Sub-path</th>
+                            <td> <input type="text" style="width:99%;" formControlName="subpath"> </td>
+                        </tr>
+                        <tr>
+                            <td colspan="2">
+                                <accordion>
+                                    <accordion-group #group>
+                                        <div accordion-heading>
+                                            Advanced Settings
+                                            <i class="pull-right float-xs-right fa" [ngClass]="{'fa-chevron-down': group.isOpen, 'fa-chevron-right': !group.isOpen}"></i>
+                                        </div>
+
+                                        <table class="table table-borderless table-in-accordion">
+                                            <tbody>
+                                                <tr>
+                                                    <th>Clean Command</th>
+                                                    <td> <input type="text" style="width:99%;" formControlName="cmdClean"> </td>
+                                                </tr>
+                                                <tr>
+                                                    <th>Pre-Build Command</th>
+                                                    <td> <input type="text" style="width:99%;" formControlName="cmdPrebuild">                                                        </td>
+                                                </tr>
+                                                <tr>
+                                                    <th>Build Command</th>
+                                                    <td> <input type="text" style="width:99%;" formControlName="cmdBuild"> </td>
+                                                </tr>
+                                                <tr>
+                                                    <th>Populate Command</th>
+                                                    <td> <input type="text" style="width:99%;" formControlName="cmdPopulate">                                                        </td>
+                                                </tr>
+                                                <tr>
+                                                    <th>Env variables</th>
+                                                    <td> <input type="text" style="width:99%;" formControlName="envVars"> </td>
+                                                </tr>
+                                                <tr *ngIf="debugEnable">
+                                                    <th>Args variables</th>
+                                                    <td> <input type="text" style="width:99%;" formControlName="cmdArgs"> </td>
+                                                </tr>
+                                            </tbody>
+                                        </table>
+                                    </accordion-group>
+                                </accordion>
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="row">
+                <div class="col-xs-12 text-center">
+                    <div class="btn-group blocks">
+                        <button class="btn btn-primary btn-large" (click)="clean()" [disabled]="!curProject ">Clean</button>
+                        <button class="btn btn-primary btn-large" (click)="preBuild()" [disabled]="!curProject">Pre-Build</button>
+                        <button class="btn btn-primary btn-large" (click)="build()" [disabled]="!curProject">Build</button>
+                        <button class="btn btn-primary btn-large" (click)="populate()" [disabled]="!curProject ">Populate</button>
+                        <button *ngIf="debugEnable" class="btn btn-primary btn-large" (click)="execCmd()" [disabled]="!curProject ">Execute command</button>
+                        <button *ngIf="debugEnable" class="btn btn-primary btn-large" (click)="make()" [disabled]="!curProject ">Make</button>
+                    </div>
+                </div>
+            </div>
+        </form>
+
+        <div style="margin-left: 2em; margin-right: 2em; ">
+            <div class="row ">
+                <div class="col-xs-10">
+                    <div class="row ">
+                        <div class="col-xs-4">
+                            <label>Command Output</label>
+                        </div>
+                        <div class="col-xs-8" style="font-size:x-small; margin-top:5px;">
+                            {{ cmdInfo }}
+                        </div>
+                    </div>
+                </div>
+                <div class="col-xs-2">
+                    <button class="btn btn-link pull-right " (click)="reset() "><span class="fa fa-eraser fa-size-x2"></span></button>
+                </div>
+            </div>
+            <div class="row ">
+                <div class="col-xs-12 text-center ">
+                    <textarea rows="20" class="textarea-scroll" #scrollOutput>{{ cmdOutput }}</textarea>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
diff --git a/webapp/src/app/devel/build/build.component.ts b/webapp/src/app/devel/build/build.component.ts
new file mode 100644 (file)
index 0000000..87df4e1
--- /dev/null
@@ -0,0 +1,223 @@
+import { Component, AfterViewChecked, ElementRef, ViewChild, OnInit, Input } from '@angular/core';
+import { Observable } from 'rxjs';
+import { FormControl, FormGroup, Validators, FormBuilder } from '@angular/forms';
+import { CookieService } from 'ngx-cookie';
+
+import 'rxjs/add/operator/scan';
+import 'rxjs/add/operator/startWith';
+
+import { XDSAgentService, ICmdOutput } from "../../services/xdsagent.service";
+import { ProjectService, IProject } from "../../services/project.service";
+import { AlertService, IAlert } from "../../services/alert.service";
+import { SdkService } from "../../services/sdk.service";
+
+@Component({
+    selector: 'panel-build',
+    moduleId: module.id,
+    templateUrl: './build.component.html',
+    styleUrls: ['./build.component.css']
+})
+
+export class BuildComponent implements OnInit, AfterViewChecked {
+    @ViewChild('scrollOutput') private scrollContainer: ElementRef;
+
+    @Input() curProject: IProject;
+
+    public buildForm: FormGroup;
+    public subpathCtrl = new FormControl("", Validators.required);
+    public debugEnable: boolean = false;
+    public buildIsCollapsed: boolean = false;
+    public cmdOutput: string;
+    public cmdInfo: string;
+
+    private startTime: Map<string, number> = new Map<string, number>();
+
+    constructor(
+        private xdsSvr: XDSAgentService,
+        private fb: FormBuilder,
+        private alertSvr: AlertService,
+        private sdkSvr: SdkService,
+        private cookie: CookieService,
+    ) {
+        this.cmdOutput = "";
+        this.cmdInfo = "";      // TODO: to be remove (only for debug)
+        this.buildForm = fb.group({
+            subpath: this.subpathCtrl,
+            cmdClean: ["", Validators.nullValidator],
+            cmdPrebuild: ["", Validators.nullValidator],
+            cmdBuild: ["", Validators.nullValidator],
+            cmdPopulate: ["", Validators.nullValidator],
+            cmdArgs: ["", Validators.nullValidator],
+            envVars: ["", Validators.nullValidator],
+        });
+    }
+
+    ngOnInit() {
+        // Set default settings
+        // TODO save & restore values from cookies
+        this.buildForm.patchValue({
+            subpath: "",
+            cmdClean: "rm -rf build",
+            cmdPrebuild: "mkdir -p build && cd build && cmake ..",
+            cmdBuild: "cd build && make",
+            cmdPopulate: "cd build && make remote-target-populate",
+            cmdArgs: "",
+            envVars: "",
+        });
+
+        // Command output data tunneling
+        this.xdsSvr.CmdOutput$.subscribe(data => {
+            this.cmdOutput += data.stdout;
+            this.cmdOutput += data.stderr;
+        });
+
+        // Command exit
+        this.xdsSvr.CmdExit$.subscribe(exit => {
+            if (this.startTime.has(exit.cmdID)) {
+                this.cmdInfo = 'Last command duration: ' + this._computeTime(this.startTime.get(exit.cmdID));
+                this.startTime.delete(exit.cmdID);
+            }
+
+            if (exit && exit.code !== 0) {
+                this.cmdOutput += "--- Command exited with code " + exit.code + " ---\n\n";
+            }
+        });
+
+        this._scrollToBottom();
+
+        // only use for debug
+        this.debugEnable = (this.cookie.get("debug_build") === "1");
+    }
+
+    ngAfterViewChecked() {
+        this._scrollToBottom();
+    }
+
+    reset() {
+        this.cmdOutput = '';
+    }
+
+    clean() {
+        this._exec(
+            this.buildForm.value.cmdClean,
+            this.buildForm.value.subpath,
+            [],
+            this.buildForm.value.envVars);
+    }
+
+    preBuild() {
+        this._exec(
+            this.buildForm.value.cmdPrebuild,
+            this.buildForm.value.subpath,
+            [],
+            this.buildForm.value.envVars);
+    }
+
+    build() {
+        this._exec(
+            this.buildForm.value.cmdBuild,
+            this.buildForm.value.subpath,
+            [],
+            this.buildForm.value.envVars
+        );
+    }
+
+    populate() {
+        this._exec(
+            this.buildForm.value.cmdPopulate,
+            this.buildForm.value.subpath,
+            [], // args
+            this.buildForm.value.envVars
+        );
+    }
+
+    execCmd() {
+        this._exec(
+            this.buildForm.value.cmdArgs,
+            this.buildForm.value.subpath,
+            [],
+            this.buildForm.value.envVars
+        );
+    }
+
+    private _exec(cmd: string, dir: string, args: string[], env: string) {
+        if (!this.curProject) {
+            this.alertSvr.warning('No active project', true);
+        }
+
+        let prjID = this.curProject.id;
+
+        this.cmdOutput += this._outputHeader();
+
+        let sdkid = this.sdkSvr.getCurrentId();
+
+        // Detect key=value in env string to build array of string
+        let envArr = [];
+        env.split(';').forEach(v => envArr.push(v.trim()));
+
+        let t0 = performance.now();
+        this.cmdInfo = 'Start build of ' + prjID + ' at ' + t0;
+
+        this.xdsSvr.exec(prjID, dir, cmd, sdkid, args, envArr)
+            .subscribe(res => {
+                this.startTime.set(String(res.cmdID), t0);
+            },
+            err => {
+                this.cmdInfo = 'Last command duration: ' + this._computeTime(t0);
+                this.alertSvr.error('ERROR: ' + err);
+            });
+    }
+
+    make(args: string) {
+        if (!this.curProject) {
+            this.alertSvr.warning('No active project', true);
+        }
+
+        let prjID = this.curProject.id;
+
+        this.cmdOutput += this._outputHeader();
+
+        let sdkid = this.sdkSvr.getCurrentId();
+
+        let argsArr = args ? args.split(' ') : this.buildForm.value.cmdArgs.split(' ');
+
+        // Detect key=value in env string to build array of string
+        let envArr = [];
+        this.buildForm.value.envVars.split(';').forEach(v => envArr.push(v.trim()));
+
+        let t0 = performance.now();
+        this.cmdInfo = 'Start build of ' + prjID + ' at ' + t0;
+
+        this.xdsSvr.make(prjID, this.buildForm.value.subpath, sdkid, argsArr, envArr)
+            .subscribe(res => {
+                this.startTime.set(String(res.cmdID), t0);
+            },
+            err => {
+                this.cmdInfo = 'Last command duration: ' + this._computeTime(t0);
+                this.alertSvr.error('ERROR: ' + err);
+            });
+    }
+
+    private _scrollToBottom(): void {
+        try {
+            this.scrollContainer.nativeElement.scrollTop = this.scrollContainer.nativeElement.scrollHeight;
+        } catch (err) { }
+    }
+
+    private _computeTime(t0: number, t1?: number): string {
+        let enlap = Math.round((t1 || performance.now()) - t0);
+        if (enlap < 1000.0) {
+            return enlap.toFixed(2) + ' ms';
+        } else {
+            return (enlap / 1000.0).toFixed(3) + ' seconds';
+        }
+    }
+
+    private _outputHeader(): string {
+        return "--- " + new Date().toString() + " ---\n";
+    }
+
+    private _outputFooter(): string {
+        return "\n";
+    }
+}
diff --git a/webapp/src/app/devel/devel.component.css b/webapp/src/app/devel/devel.component.css
new file mode 100644 (file)
index 0000000..4b03dcb
--- /dev/null
@@ -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 (file)
index 0000000..cc62889
--- /dev/null
@@ -0,0 +1,40 @@
+<div class="row">
+    <div class="col-md-8">
+        <table class="table table-borderless table-center">
+            <tbody>
+                <tr>
+                    <th style="border: none;">Project</th>
+                    <td>
+                        <div class="btn-group" dropdown *ngIf="curPrj">
+                            <button dropdownToggle type="button" class="btn btn-primary dropdown-toggle" style="width: 20em;">
+                                {{curPrj.label}}
+                                <span class="caret" style="float: right; margin-top: 8px;"></span>
+                            </button>
+                            <ul *dropdownMenu class="dropdown-menu" role="menu">
+                                <li role="menuitem"><a class="dropdown-item" *ngFor="let prj of (Prjs$ | async)" [class.disabled]="!prj.isUsable"
+                                        (click)="curPrj=prj">{{prj.label}}</a>
+                                </li>
+
+                            </ul>
+                        </div>
+                        <span *ngIf="!curPrj" style="color:red; font-style: italic;">
+                            No project detected, please create first a project using the configuration page.
+                        </span>
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+    </div>
+</div>
+
+<div class="row">
+    <!--<div class="col-md-8">-->
+    <div class="col-md-12">
+        <panel-build [curProject]=curPrj></panel-build>
+    </div>
+    <!-- TODO: disable for now
+    <div class="col-md-4">
+        <panel-deploy [curProject]=curPrj></panel-deploy>
+    </div>
+    -->
+</div>
diff --git a/webapp/src/app/devel/devel.component.ts b/webapp/src/app/devel/devel.component.ts
new file mode 100644 (file)
index 0000000..5c8b9f2
--- /dev/null
@@ -0,0 +1,35 @@
+import { Component } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+import { ProjectService, IProject } from "../services/project.service";
+
+@Component({
+    selector: 'devel',
+    moduleId: module.id,
+    templateUrl: './devel.component.html',
+    styleUrls: ['./devel.component.css'],
+})
+
+export class DevelComponent {
+
+    curPrj: IProject;
+    Prjs$: Observable<IProject[]>;
+
+    constructor(private projectSvr: ProjectService) {
+    }
+
+    ngOnInit() {
+        this.Prjs$ = this.projectSvr.Projects$;
+        this.Prjs$.subscribe((prjs) => {
+            // Select project if no one is selected or no project exists
+            if (this.curPrj && "id" in this.curPrj) {
+                this.curPrj = prjs.find(p => p.id === this.curPrj.id) || prjs[0];
+            } else if (this.curPrj == null) {
+                this.curPrj = prjs[0];
+            } else {
+                this.curPrj = null;
+            }
+        });
+    }
+}
diff --git a/webapp/src/app/home/home.component.ts b/webapp/src/app/home/home.component.ts
new file mode 100644 (file)
index 0000000..0e3c995
--- /dev/null
@@ -0,0 +1,81 @@
+import { Component, OnInit } from '@angular/core';
+
+export interface ISlide {
+    img?: string;
+    imgAlt?: string;
+    hText?: string;
+    hHtml?: string;
+    text?: string;
+    html?: string;
+    btn?: string;
+    btnHref?: string;
+}
+
+@Component({
+    selector: 'home',
+    moduleId: module.id,
+    template: `
+        <style>
+            .wide img {
+                width: 98%;
+            }
+            .carousel-item {
+                max-height: 90%;
+            }
+            h1, h2, h3, h4, p {
+                color: #330066;
+            }
+            .html-inner {
+                color: #330066;
+            }
+            h1 {
+                font-size: 4em;
+            }
+            p {
+                font-size: 2.5em;
+            }
+
+        </style>
+
+        <div class="wide">
+            <carousel [interval]="carInterval" [(activeSlide)]="activeSlideIndex">
+                <slide *ngFor="let sl of slides; let index=index">
+                    <img [src]="sl.img" [alt]="sl.imgAlt">
+                    <div class="carousel-caption">
+                        <h1 *ngIf="sl.hText">{{ sl.hText }}</h1>
+                        <h1 *ngIf="sl.hHtml" class="html-inner" [innerHtml]="sl.hHtml"></h1>
+                        <p   *ngIf="sl.text">{{ sl.text }}</p>
+                        <div *ngIf="sl.html" class="html-inner" [innerHtml]="sl.html"></div>
+                    </div>
+                </slide>
+            </carousel>
+        </div>
+    `
+})
+
+export class HomeComponent {
+
+    public carInterval: number = 4000;
+
+    // FIXME SEB - Add more slides and info
+    public slides: ISlide[] = [
+        {
+            img: 'assets/images/iot-graphx.jpg',
+            imgAlt: "iot graphx image",
+            hText: "Welcome to XDS Dashboard !",
+            text: "X(cross) Development System allows developers to easily cross-compile applications.",
+        },
+        {
+            img: 'assets/images/iot-graphx.jpg',
+            imgAlt: "iot graphx image",
+            hText: "Create, Build, Deploy, Enjoy !",
+        },
+        {
+            img: 'assets/images/iot-graphx.jpg',
+            imgAlt: "iot graphx image",
+            hHtml: '<p>To Start: click on <i class="fa fa-cog" style="color:#9d9d9d;"></i> icon and add new folder</p>',
+        }
+    ];
+
+    constructor() { }
+}
\ No newline at end of file
diff --git a/webapp/src/app/main.ts b/webapp/src/app/main.ts
new file mode 100644 (file)
index 0000000..1f68ccc
--- /dev/null
@@ -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 (file)
index 0000000..77f73a5
--- /dev/null
@@ -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 (file)
index 0000000..dc84985
--- /dev/null
@@ -0,0 +1,54 @@
+<div bsModal #childProjectModal="bs-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="mySmallModalLabel"
+    [config]="{backdrop: 'static'}" aria-hidden="true">
+    <div class="modal-dialog modal-lg">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h4 class="modal-title pull-left">{{title}}</h4>
+                <button type="button" class="close pull-right" aria-label="Close" (click)="hide()">
+                        <span aria-hidden="true">&times;</span>
+                    </button>
+            </div>
+
+            <form [formGroup]="addProjectForm" (ngSubmit)="onSubmit()">
+                <div class="modal-body">
+                    <div class="row ">
+                        <div class="col-xs-12">
+                            <table class="table table-borderless">
+                                <tbody>
+                                    <tr>
+                                        <th><label>Sharing Type </label></th>
+                                        <td><select class="form-control" formControlName="type">
+                                            <option *ngFor="let t of projectTypes" [value]="t.value">{{t.display}}
+                                            </option>
+                                        </select>
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <th><label for="select-local-path">Local Path </label></th>
+                                        <td><input type="text" id="select-local-path" formControlName="pathCli" placeholder="/tmp/myProject" (change)="onChangeLocalProject($event)"></td>
+                                    </tr>
+                                    <tr>
+                                        <th><label for="select-server-path">Server Path </label></th>
+                                        <td><input type="text" id="select-server-path" formControlName="pathSvr"></td>
+                                    </tr>
+                                    <tr>
+                                        <th><label for="select-label">Label </label></th>
+                                        <td><input type="text" formControlName="label" id="select-label" (keyup)="onKeyLabel($event)"></td>
+                                    </tr>
+                                </tbody>
+                            </table>
+                        </div>
+                    </div>
+                </div>
+                <div class="modal-footer">
+                    <div class="pull-left">
+                        <button class="btn btn-default" (click)="cancelAction=true; hide()"> Cancel </button>
+                    </div>
+                    <div class="">
+                        <button class="btn btn-primary" type="submit" [disabled]="!addProjectForm.valid">Add Folder</button>
+                    </div>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
diff --git a/webapp/src/app/projects/projectAddModal.component.ts b/webapp/src/app/projects/projectAddModal.component.ts
new file mode 100644 (file)
index 0000000..1584b5b
--- /dev/null
@@ -0,0 +1,147 @@
+import { Component, Input, ViewChild, OnInit } from '@angular/core';
+import { Observable } from 'rxjs/Observable';
+import { ModalDirective } from 'ngx-bootstrap/modal';
+import { FormControl, FormGroup, Validators, FormBuilder, ValidatorFn, AbstractControl } from '@angular/forms';
+
+// Import RxJs required methods
+import 'rxjs/add/operator/map';
+import 'rxjs/add/operator/filter';
+import 'rxjs/add/operator/debounceTime';
+
+import { AlertService, IAlert } from "../services/alert.service";
+import { ProjectService, IProject, ProjectType, ProjectTypes } from "../services/project.service";
+
+
+@Component({
+    selector: 'project-add-modal',
+    templateUrl: './app/projects/projectAddModal.component.html',
+    styleUrls: ['./app/projects/projectAddModal.component.css']
+})
+export class ProjectAddModalComponent {
+    @ViewChild('childProjectModal') public childProjectModal: ModalDirective;
+    @Input() title?: string;
+    @Input('server-id') serverID: string;
+
+    cancelAction: boolean = false;
+    userEditedLabel: boolean = false;
+    projectTypes = ProjectTypes;
+
+    addProjectForm: FormGroup;
+    typeCtrl: FormControl;
+    pathCliCtrl: FormControl;
+    pathSvrCtrl: FormControl;
+
+    constructor(
+        private alert: AlertService,
+        private projectSvr: ProjectService,
+        private fb: FormBuilder
+    ) {
+        // Define types (first one is special/placeholder)
+        this.projectTypes.unshift({ value: ProjectType.UNSET, display: "--Select a type--" });
+
+        this.typeCtrl = new FormControl(this.projectTypes[0].value, Validators.pattern("[A-Za-z]+"));
+        this.pathCliCtrl = new FormControl("", Validators.required);
+        this.pathSvrCtrl = new FormControl({ value: "", disabled: true }, [Validators.required, Validators.minLength(1)]);
+
+        this.addProjectForm = fb.group({
+            type: this.typeCtrl,
+            pathCli: this.pathCliCtrl,
+            pathSvr: this.pathSvrCtrl,
+            label: ["", Validators.nullValidator],
+        });
+    }
+
+    ngOnInit() {
+        // Auto create label name
+        this.pathCliCtrl.valueChanges
+            .debounceTime(100)
+            .filter(n => n)
+            .map(n => {
+                let last = n.split('/');
+                let nm = n;
+                if (last.length > 0) {
+                    nm = last.pop();
+                    if (nm === "" && last.length > 0) {
+                        nm = last.pop();
+                    }
+                }
+                return "Project_" + nm;
+            })
+            .subscribe(value => {
+                if (value && !this.userEditedLabel) {
+                    this.addProjectForm.patchValue({ label: value });
+                }
+            });
+
+        // Handle disabling of Server path
+        this.typeCtrl.valueChanges
+            .debounceTime(500)
+            .subscribe(valType => {
+                let dis = (valType === String(ProjectType.SYNCTHING));
+                this.pathSvrCtrl.reset({ value: "", disabled: dis });
+            });
+    }
+
+    show() {
+        this.cancelAction = false;
+        this.userEditedLabel = false;
+        this.childProjectModal.show();
+    }
+
+    hide() {
+        this.childProjectModal.hide();
+    }
+
+    onKeyLabel(event: any) {
+        this.userEditedLabel = (this.addProjectForm.value.label !== "");
+    }
+
+    /* FIXME: change input to file type
+     <td><input type="file" id="select-local-path" webkitdirectory
+     formControlName="pathCli" placeholder="myProject" (change)="onChangeLocalProject($event)"></td>
+
+    onChangeLocalProject(e) {
+        if e.target.files.length < 1 {
+            console.log('NO files');
+        }
+        let dir = e.target.files[0].webkitRelativePath;
+        console.log("files: " + dir);
+        let u = URL.createObjectURL(e.target.files[0]);
+    }
+    */
+    onChangeLocalProject(e) {
+    }
+
+    onSubmit() {
+        if (this.cancelAction) {
+            return;
+        }
+
+        let formVal = this.addProjectForm.value;
+
+        let type = formVal['type'].value;
+        this.projectSvr.Add({
+            serverId: this.serverID,
+            label: formVal['label'],
+            pathClient: formVal['pathCli'],
+            pathServer: formVal['pathSvr'],
+            type: formVal['type'],
+            // FIXME: allow to set defaultSdkID from New Project config panel
+        })
+            .subscribe(prj => {
+                this.alert.info("Project " + prj.label + " successfully created.");
+                this.hide();
+
+                // Reset Value for the next creation
+                this.addProjectForm.reset();
+                let selectedType = this.projectTypes[0].value;
+                this.addProjectForm.patchValue({ type: selectedType });
+
+            },
+            err => {
+                this.alert.error("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 (file)
index 0000000..fdacba4
--- /dev/null
@@ -0,0 +1,91 @@
+import { Component, Input, Pipe, PipeTransform } from '@angular/core';
+import { ProjectService, IProject, ProjectType } from "../services/project.service";
+import { AlertService } from "../services/alert.service";
+
+@Component({
+    selector: 'project-card',
+    template: `
+        <div class="row">
+            <div class="col-xs-12">
+                <div class="text-right" role="group">
+                    <button class="btn btn-link" (click)="delete(project)">
+                        <span class="fa fa-trash fa-size-x2"></span>
+                    </button>
+                </div>
+            </div>
+        </div>
+
+        <table class="table table-striped">
+            <tbody>
+            <tr>
+                <th><span class="fa fa-fw fa-id-badge"></span>&nbsp;<span>Project ID</span></th>
+                <td>{{ project.id }}</td>
+            </tr>
+            <tr>
+                <th><span class="fa fa-fw fa-exchange"></span>&nbsp;<span>Sharing type</span></th>
+                <td>{{ project.type | readableType }}</td>
+            </tr>
+            <tr>
+                <th><span class="fa fa-fw fa-folder-open-o"></span>&nbsp;<span>Local path</span></th>
+                <td>{{ project.pathClient }}</td>
+            </tr>
+            <tr *ngIf="project.pathServer && project.pathServer != ''">
+                <th><span class="fa fa-fw fa-folder-open-o"></span>&nbsp;<span>Server path</span></th>
+                <td>{{ project.pathServer }}</td>
+            </tr>
+            <tr>
+                <th><span class="fa fa-fw fa-flag"></span>&nbsp;<span>Status</span></th>
+                <td>{{ project.status }} - {{ project.isInSync ? "Up to Date" : "Out of Sync"}}
+                    <button *ngIf="!project.isInSync" class="btn btn-link" (click)="sync(project)">
+                        <span class="fa fa-refresh fa-size-x2"></span>
+                    </button>
+                </td>
+            </tr>
+            </tbody>
+        </table >
+    `,
+    styleUrls: ['./app/config/config.component.css']
+})
+
+export class ProjectCardComponent {
+
+    @Input() project: IProject;
+
+    constructor(
+        private alert: AlertService,
+        private projectSvr: ProjectService
+    ) {
+    }
+
+    delete(prj: IProject) {
+        this.projectSvr.Delete(prj)
+            .subscribe(res => {
+            }, err => {
+                this.alert.error("Delete ERROR: " + err);
+            });
+    }
+
+    sync(prj: IProject) {
+        this.projectSvr.Sync(prj)
+            .subscribe(res => {
+            }, err => {
+                this.alert.error("ERROR: " + err);
+            });
+    }
+
+}
+
+// Remove APPS. prefix if translate has failed
+@Pipe({
+    name: 'readableType'
+})
+
+export class ProjectReadableTypePipe implements PipeTransform {
+    transform(type: ProjectType): string {
+        switch (type) {
+            case ProjectType.NATIVE_PATHMAP: return "Native (path mapping)";
+            case ProjectType.SYNCTHING: return "Cloud (Syncthing)";
+            default: return String(type);
+        }
+    }
+}
diff --git a/webapp/src/app/projects/projectsListAccordion.component.ts b/webapp/src/app/projects/projectsListAccordion.component.ts
new file mode 100644 (file)
index 0000000..210be5c
--- /dev/null
@@ -0,0 +1,39 @@
+import { Component, Input } from "@angular/core";
+
+import { IProject } from "../services/project.service";
+
+@Component({
+    selector: 'projects-list-accordion',
+    template: `
+        <style>
+            .fa.fa-exclamation-triangle {
+                margin-right: 2em;
+                color: red;
+            }
+            .fa.fa-refresh {
+                margin-right: 10px;
+                color: darkviolet;
+            }
+        </style>
+        <accordion>
+            <accordion-group #group *ngFor="let prj of projects">
+                <div accordion-heading>
+                    {{ prj.label }}
+                    <div class="pull-right">
+                        <i *ngIf="prj.status == 'Syncing'" class="fa fa-refresh faa-spin animated"></i>
+                        <i *ngIf="!prj.isInSync && prj.status != 'Syncing'" class="fa fa-exclamation-triangle"></i>
+                        <i class="fa" [ngClass]="{'fa-chevron-down': group.isOpen, 'fa-chevron-right': !group.isOpen}"></i>
+                    </div>
+                </div>
+                <project-card [project]="prj"></project-card>
+            </accordion-group>
+        </accordion>
+    `
+})
+export class ProjectsListAccordionComponent {
+
+    @Input() projects: IProject[];
+
+}
+
+
diff --git a/webapp/src/app/sdks/sdkAddModal.component.html b/webapp/src/app/sdks/sdkAddModal.component.html
new file mode 100644 (file)
index 0000000..2c07fca
--- /dev/null
@@ -0,0 +1,23 @@
+<div bsModal #sdkChildModal="bs-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="mySmallModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog modal-lg">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h4 class="modal-title pull-left">{{title}}</h4>
+                <button type="button" class="close pull-right" aria-label="Close" (click)="hideChildModal()">
+                <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">
+                <ng-content select=".modal-body"> </ng-content>
+                <i>Not available for now.</i>
+            </div>
+
+            <div class="modal-footer">
+                <div class="pull-left">
+                    <button class="btn btn-default" (click)="hide()"> Cancel </button>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
diff --git a/webapp/src/app/sdks/sdkAddModal.component.ts b/webapp/src/app/sdks/sdkAddModal.component.ts
new file mode 100644 (file)
index 0000000..b6c8eb2
--- /dev/null
@@ -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 (file)
index 0000000..3256a0b
--- /dev/null
@@ -0,0 +1,55 @@
+import { Component, Input } from '@angular/core';
+import { ISdk } from "../services/sdk.service";
+
+@Component({
+    selector: 'sdk-card',
+    template: `
+        <div class="row">
+            <div class="col-xs-12">
+                <div class="text-right" role="group">
+                    <button disabled class="btn btn-link" (click)="delete(sdk)"><span class="fa fa-trash fa-size-x2"></span></button>
+                </div>
+            </div>
+        </div>
+
+        <table class="table table-striped">
+            <tbody>
+            <tr>
+                <th><span class="fa fa-fw fa-id-badge"></span>&nbsp;<span>SDK ID</span></th>
+                <td>{{ sdk.id }}</td>
+            </tr>
+            <tr>
+                <th><span class="fa fa-fw fa-user"></span>&nbsp;<span>Profile</span></th>
+                <td>{{ sdk.profile }}</td>
+            </tr>
+            <tr>
+                <th><span class="fa fa-fw fa-tasks"></span>&nbsp;<span>Architecture</span></th>
+                <td>{{ sdk.arch }}</td>
+            </tr>
+            <tr>
+                <th><span class="fa fa-fw fa-code-fork"></span>&nbsp;<span>Version</span></th>
+                <td>{{ sdk.version }}</td>
+            </tr>
+            <tr>
+                <th><span class="fa fa-fw fa-folder-open-o"></span>&nbsp;<span>Sdk path</span></th>
+                <td>{{ sdk.path}}</td>
+            </tr>
+
+            </tbody>
+        </table >
+    `,
+    styleUrls: ['./app/config/config.component.css']
+})
+
+export class SdkCardComponent {
+
+    @Input() sdk: ISdk;
+
+    constructor() { }
+
+
+    delete(sdk: ISdk) {
+        // Not supported for now
+    }
+
+}
diff --git a/webapp/src/app/sdks/sdkSelectDropdown.component.ts b/webapp/src/app/sdks/sdkSelectDropdown.component.ts
new file mode 100644 (file)
index 0000000..a2fe37a
--- /dev/null
@@ -0,0 +1,48 @@
+import { Component, Input } from "@angular/core";
+
+import { ISdk, SdkService } from "../services/sdk.service";
+
+@Component({
+    selector: 'sdk-select-dropdown',
+    template: `
+        <div class="btn-group" dropdown *ngIf="curSdk" >
+            <button dropdownToggle type="button" class="btn btn-primary dropdown-toggle" style="width: 20em;">
+                {{curSdk.name}} <span class="caret" style="float: right; margin-top: 8px;"></span>
+            </button>
+            <ul *dropdownMenu class="dropdown-menu" role="menu">
+                <li role="menuitem"><a class="dropdown-item" *ngFor="let sdk of sdks" (click)="select(sdk)">
+                    {{sdk.name}}</a>
+                </li>
+            </ul>
+        </div>
+    `
+})
+export class SdkSelectDropdownComponent {
+
+    // FIXME investigate to understand why not working with sdks as input
+    // <sdk-select-dropdown [sdks]="(sdks$ | async)"></sdk-select-dropdown>
+    //@Input() sdks: ISdk[];
+    sdks: ISdk[];
+
+    curSdk: ISdk;
+
+    constructor(private sdkSvr: SdkService) { }
+
+    ngOnInit() {
+        this.curSdk = this.sdkSvr.getCurrent();
+        this.sdkSvr.Sdks$.subscribe((s) => {
+            if (s) {
+                this.sdks = s;
+                if (this.curSdk === null || s.indexOf(this.curSdk) === -1) {
+                    this.sdkSvr.setCurrent(this.curSdk = s.length ? s[0] : null);
+                }
+            }
+        });
+    }
+
+    select(s) {
+        this.sdkSvr.setCurrent(this.curSdk = s);
+    }
+}
+
+
diff --git a/webapp/src/app/sdks/sdksListAccordion.component.ts b/webapp/src/app/sdks/sdksListAccordion.component.ts
new file mode 100644 (file)
index 0000000..9d5f7e9
--- /dev/null
@@ -0,0 +1,26 @@
+import { Component, Input } from "@angular/core";
+
+import { ISdk } from "../services/sdk.service";
+
+@Component({
+    selector: 'sdks-list-accordion',
+    template: `
+        <accordion>
+            <accordion-group #group *ngFor="let sdk of sdks">
+                <div accordion-heading>
+                    {{ sdk.name }}
+                    <i class="pull-right float-xs-right fa"
+                    [ngClass]="{'fa-chevron-down': group.isOpen, 'fa-chevron-right': !group.isOpen}"></i>
+                </div>
+                <sdk-card [sdk]="sdk"></sdk-card>
+            </accordion-group>
+        </accordion>
+    `
+})
+export class SdksListAccordionComponent {
+
+    @Input() sdks: ISdk[];
+
+}
+
+
diff --git a/webapp/src/app/services/alert.service.ts b/webapp/src/app/services/alert.service.ts
new file mode 100644 (file)
index 0000000..c3cae7a
--- /dev/null
@@ -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<IAlert[]>;
+
+    private _alerts: IAlert[];
+    private alertsSubject = <Subject<IAlert[]>>new Subject();
+    private uid = 0;
+    private defaultDissmissTmo = 5; // in seconds
+
+    constructor(private sanitizer: DomSanitizer) {
+        this.alerts = this.alertsSubject.asObservable();
+        this._alerts = [];
+        this.uid = 0;
+    }
+
+    public error(msg: string, dismissTime?: number) {
+        this.add({
+            type: "danger", msg: msg, dismissible: true, dismissTimeout: dismissTime
+        });
+    }
+
+    public warning(msg: string, dismissible?: boolean) {
+        this.add({ type: "warning", msg: msg, dismissible: true, dismissTimeout: (dismissible ? this.defaultDissmissTmo : 0) });
+    }
+
+    public info(msg: string) {
+        this.add({ type: "info", msg: msg, dismissible: true, dismissTimeout: this.defaultDissmissTmo });
+    }
+
+    public add(al: IAlert) {
+        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 (file)
index 0000000..090df7b
--- /dev/null
@@ -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<IConfig>;
+
+    private confSubject: BehaviorSubject<IConfig>;
+    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 = <BehaviorSubject<IConfig>>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 = <IConfig>cookConf;
+        } else {
+            // Set default config
+            this.confStore = {
+                projectsRootDir: "",
+                //projects: []
+            };
+        }
+    }
+
+    // Save config into cookie
+    save() {
+        // Notify subscribers
+        this.confSubject.next(Object.assign({}, this.confStore));
+
+        // Don't save projects in cookies (too big!)
+        let cfg = Object.assign({}, this.confStore);
+        this.cookie.putObject("xds-config", cfg);
+    }
+
+    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 = `<span><strong>` + error + `<br></strong>
+                    You may need to install and execute XDS-Agent: <br>
+                        On Linux machine <a href="` + url_OS_Linux + `" target="_blank"><span
+                            class="fa fa-external-link"></span></a>
+                        <br>
+                        On Windows machine <a href="` + url_OS_Other + `" target="_blank"><span
+                            class="fa fa-external-link"></span></a>
+                        <br>
+                        On MacOS machine <a href="` + url_OS_Other + `" target="_blank"><span
+                            class="fa fa-external-link"></span></a>
+                    `;
+                    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 = "<span><strong>" + error + "<br></strong>";
+                msg += "Please check that local XDS-Agent is running.<br>";
+                msg += "</span>";
+                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 (file)
index 0000000..53adc80
--- /dev/null
@@ -0,0 +1,199 @@
+import { Injectable, SecurityContext } from '@angular/core';
+import { Observable } from 'rxjs/Observable';
+import { BehaviorSubject } from 'rxjs/BehaviorSubject';
+
+import { XDSAgentService, IXDSProjectConfig } from "../services/xdsagent.service";
+
+export enum ProjectType {
+    UNSET = "",
+    NATIVE_PATHMAP = "PathMap",
+    SYNCTHING = "CloudSync"
+}
+
+export var ProjectTypes = [
+    { value: ProjectType.NATIVE_PATHMAP, display: "Path mapping" },
+    { value: ProjectType.SYNCTHING, display: "Cloud Sync" }
+];
+
+export var ProjectStatus = {
+    ErrorConfig: "ErrorConfig",
+    Disable: "Disable",
+    Enable: "Enable",
+    Pause: "Pause",
+    Syncing: "Syncing"
+};
+
+export interface IProject {
+    id?: string;
+    serverId: string;
+    label: string;
+    pathClient: string;
+    pathServer?: string;
+    type: ProjectType;
+    status?: string;
+    isInSync?: boolean;
+    isUsable?: boolean;
+    serverPrjDef?: IXDSProjectConfig;
+    isExpanded?: boolean;
+    visible?: boolean;
+    defaultSdkID?: string;
+}
+
+@Injectable()
+export class ProjectService {
+    public Projects$: Observable<IProject[]>;
+
+    private _prjsList: IProject[] = [];
+    private current: IProject;
+    private prjsSubject = <BehaviorSubject<IProject[]>>new BehaviorSubject(this._prjsList);
+
+    constructor(private xdsSvr: XDSAgentService) {
+        this.current = null;
+        this.Projects$ = this.prjsSubject.asObservable();
+
+        this.xdsSvr.getProjects().subscribe((projects) => {
+            this._prjsList = [];
+            projects.forEach(p => {
+                this._addProject(p, true);
+            });
+            this.prjsSubject.next(Object.assign([], this._prjsList));
+        });
+
+        // Update Project data
+        this.xdsSvr.ProjectState$.subscribe(prj => {
+            let i = this._getProjectIdx(prj.id);
+            if (i >= 0) {
+                // XXX for now, only isInSync and status may change
+                this._prjsList[i].isInSync = prj.isInSync;
+                this._prjsList[i].status = prj.status;
+                this._prjsList[i].isUsable = this._isUsableProject(prj);
+                this.prjsSubject.next(Object.assign([], this._prjsList));
+            }
+        });
+
+        // Add listener on create and delete project events
+        this.xdsSvr.addEventListener('event:project-add', (ev) => {
+            if (ev && ev.data && ev.data.id) {
+                this._addProject(ev.data);
+            } else {
+                console.log("Warning: received events with unknown data: ev=", ev);
+            }
+        });
+        this.xdsSvr.addEventListener('event:project-delete', (ev) => {
+            if (ev && ev.data && ev.data.id) {
+                let idx = this._prjsList.findIndex(item => item.id === ev.data.id);
+                if (idx === -1) {
+                    console.log("Warning: received events on unknown project id: ev=", ev);
+                    return;
+                }
+                this._prjsList.splice(idx, 1);
+                this.prjsSubject.next(Object.assign([], this._prjsList));
+            } else {
+                console.log("Warning: received events with unknown data: ev=", ev);
+            }
+        });
+
+    }
+
+    public setCurrent(s: IProject) {
+        this.current = s;
+    }
+
+    public getCurrent(): IProject {
+        return this.current;
+    }
+
+    public getCurrentId(): string {
+        if (this.current && this.current.id) {
+            return this.current.id;
+        }
+        return "";
+    }
+
+    Add(prj: IProject): Observable<IProject> {
+        let xdsPrj: IXDSProjectConfig = {
+            id: "",
+            serverId: prj.serverId,
+            label: prj.label || "",
+            clientPath: prj.pathClient.trim(),
+            serverPath: prj.pathServer,
+            type: prj.type,
+            defaultSdkID: prj.defaultSdkID,
+        };
+        // Send config to XDS server
+        return this.xdsSvr.addProject(xdsPrj)
+            .map(xdsPrj => this._convToIProject(xdsPrj));
+    }
+
+    Delete(prj: IProject): Observable<IProject> {
+        let idx = this._getProjectIdx(prj.id);
+        let delPrj = prj;
+        if (idx === -1) {
+            throw new Error("Invalid project id (id=" + prj.id + ")");
+        }
+        return this.xdsSvr.deleteProject(prj.id)
+            .map(res => { return delPrj; });
+    }
+
+    Sync(prj: IProject): Observable<string> {
+        let idx = this._getProjectIdx(prj.id);
+        if (idx === -1) {
+            throw new Error("Invalid project id (id=" + prj.id + ")");
+        }
+        return this.xdsSvr.syncProject(prj.id);
+    }
+
+    private _isUsableProject(p) {
+        return p && p.isInSync &&
+            (p.status === ProjectStatus.Enable) &&
+            (p.status !== ProjectStatus.Syncing);
+    }
+
+    private _getProjectIdx(id: string): number {
+        return this._prjsList.findIndex((item) => item.id === id);
+    }
+
+    private _convToIProject(rPrj: IXDSProjectConfig): IProject {
+        // Convert XDSFolderConfig to IProject
+        let pp: IProject = {
+            id: rPrj.id,
+            serverId: rPrj.serverId,
+            label: rPrj.label,
+            pathClient: rPrj.clientPath,
+            pathServer: rPrj.serverPath,
+            type: rPrj.type,
+            status: rPrj.status,
+            isInSync: rPrj.isInSync,
+            isUsable: this._isUsableProject(rPrj),
+            defaultSdkID: rPrj.defaultSdkID,
+            serverPrjDef: Object.assign({}, rPrj),  // do a copy
+        };
+        return pp;
+    }
+
+    private _addProject(rPrj: IXDSProjectConfig, noNext?: boolean): IProject {
+
+        // Convert XDSFolderConfig to IProject
+        let pp = this._convToIProject(rPrj);
+
+        // add new project
+        this._prjsList.push(pp);
+
+        // sort project array
+        this._prjsList.sort((a, b) => {
+            if (a.label < b.label) {
+                return -1;
+            }
+            if (a.label > b.label) {
+                return 1;
+            }
+            return 0;
+        });
+
+        if (!noNext) {
+            this.prjsSubject.next(Object.assign([], this._prjsList));
+        }
+
+        return pp;
+    }
+}
diff --git a/webapp/src/app/services/sdk.service.ts b/webapp/src/app/services/sdk.service.ts
new file mode 100644 (file)
index 0000000..6d8a5f6
--- /dev/null
@@ -0,0 +1,54 @@
+import { Injectable, SecurityContext } from '@angular/core';
+import { Observable } from 'rxjs/Observable';
+import { BehaviorSubject } from 'rxjs/BehaviorSubject';
+
+import { XDSAgentService } from "../services/xdsagent.service";
+
+export interface ISdk {
+    id: string;
+    profile: string;
+    version: string;
+    arch: number;
+    path: string;
+}
+
+@Injectable()
+export class SdkService {
+    public Sdks$: Observable<ISdk[]>;
+
+    private _sdksList = [];
+    private current: ISdk;
+    private sdksSubject = <BehaviorSubject<ISdk[]>>new BehaviorSubject(this._sdksList);
+
+    constructor(private xdsSvr: XDSAgentService) {
+        this.current = null;
+        this.Sdks$ = this.sdksSubject.asObservable();
+
+        this.xdsSvr.XdsConfig$.subscribe(cfg => {
+            if (!cfg || cfg.servers.length < 1) {
+                return;
+            }
+            // FIXME support multiple server
+            //cfg.servers.forEach(svr => {
+            this.xdsSvr.getSdks(cfg.servers[0].id).subscribe((s) => {
+                this._sdksList = s;
+                this.sdksSubject.next(s);
+            });
+        });
+    }
+
+    public setCurrent(s: ISdk) {
+        this.current = s;
+    }
+
+    public getCurrent(): ISdk {
+        return this.current;
+    }
+
+    public getCurrentId(): string {
+        if (this.current && this.current.id) {
+            return this.current.id;
+        }
+        return "";
+    }
+}
diff --git a/webapp/src/app/services/syncthing.service.ts b/webapp/src/app/services/syncthing.service.ts
new file mode 100644 (file)
index 0000000..1561cbf
--- /dev/null
@@ -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<ISyncThingStatus>;
+
+    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 = <BehaviorSubject<ISyncThingStatus>>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<ISyncThingStatus> {
+        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<string> {
+        if (this._status.ID != null) {
+            return Observable.of(this._status.ID);
+        }
+        return this.getStatus().map(sts => sts.ID);
+    }
+
+    getStatus(): Observable<ISyncThingStatus> {
+        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<ISTFolderConfiguration[]> {
+        return this._getConfig()
+            .map((conf) => conf.folders);
+    }
+
+    addProject(prj: ISyncThingProject): Observable<ISTFolderConfiguration> {
+        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<ISTFolderConfiguration> {
+        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<ISTConfiguration> {
+        return this._get('/system/config');
+    }
+
+    private _setConfig(cfg: ISTConfiguration): Observable<any> {
+        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<boolean> {
+        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<number> {
+        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<number> {
+        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<any> {
+        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<any> {
+        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 (file)
index 0000000..84b9ab6
--- /dev/null
@@ -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 (file)
index 0000000..e570399
--- /dev/null
@@ -0,0 +1,401 @@
+import { Injectable } from '@angular/core';
+import { Http, Headers, RequestOptionsArgs, Response } from '@angular/http';
+import { Location } from '@angular/common';
+import { Observable } from 'rxjs/Observable';
+import { Subject } from 'rxjs/Subject';
+import { BehaviorSubject } from 'rxjs/BehaviorSubject';
+import * as io from 'socket.io-client';
+
+import { AlertService } from './alert.service';
+import { ISdk } from './sdk.service';
+import { ProjectType} from "./project.service";
+
+// Import RxJs required methods
+import 'rxjs/add/operator/map';
+import 'rxjs/add/operator/catch';
+import 'rxjs/add/observable/throw';
+import 'rxjs/add/operator/mergeMap';
+import 'rxjs/add/observable/of';
+import 'rxjs/add/operator/retryWhen';
+
+
+export interface IXDSConfigProject {
+    id: string;
+    path: string;
+    clientSyncThingID: string;
+    type: string;
+    label?: string;
+    defaultSdkID?: string;
+}
+
+interface IXDSBuilderConfig {
+    ip: string;
+    port: string;
+    syncThingID: string;
+}
+
+export interface IXDSProjectConfig {
+    id: string;
+    serverId: string;
+    label: string;
+    clientPath: string;
+    serverPath?: string;
+    type: ProjectType;
+    status?: string;
+    isInSync?: boolean;
+    defaultSdkID: string;
+}
+
+export interface IXDSVer {
+    id: string;
+    version: string;
+    apiVersion: string;
+    gitTag: string;
+}
+
+export interface IXDSVersions {
+    client: IXDSVer;
+    servers: IXDSVer[];
+}
+
+export interface IXDServerCfg {
+    id: string;
+    url: string;
+    apiUrl: string;
+    partialUrl: string;
+    connRetry: number;
+    connected: boolean;
+}
+
+export interface IXDSConfig {
+    servers: IXDServerCfg[];
+}
+
+export interface ISdkMessage {
+    wsID: string;
+    msgType: string;
+    data: any;
+}
+
+export interface ICmdOutput {
+    cmdID: string;
+    timestamp: string;
+    stdout: string;
+    stderr: string;
+}
+
+export interface ICmdExit {
+    cmdID: string;
+    timestamp: string;
+    code: number;
+    error: string;
+}
+
+export interface IAgentStatus {
+    WS_connected: boolean;
+}
+
+
+@Injectable()
+export class XDSAgentService {
+
+    public XdsConfig$: Observable<IXDSConfig>;
+    public Status$: Observable<IAgentStatus>;
+    public ProjectState$ = <Subject<IXDSProjectConfig>>new Subject();
+    public CmdOutput$ = <Subject<ICmdOutput>>new Subject();
+    public CmdExit$ = <Subject<ICmdExit>>new Subject();
+
+    private baseUrl: string;
+    private wsUrl: string;
+    private _config = <IXDSConfig>{ servers: [] };
+    private _status = { WS_connected: false };
+
+    private configSubject = <BehaviorSubject<IXDSConfig>>new BehaviorSubject(this._config);
+    private statusSubject = <BehaviorSubject<IAgentStatus>>new BehaviorSubject(this._status);
+
+    private socket: SocketIOClient.Socket;
+
+    constructor(private http: Http, private _window: Window, private alert: AlertService) {
+
+        this.XdsConfig$ = this.configSubject.asObservable();
+        this.Status$ = this.statusSubject.asObservable();
+
+        this.baseUrl = this._window.location.origin + '/api/v1';
+
+        let re = this._window.location.origin.match(/http[s]?:\/\/([^\/]*)[\/]?/);
+        if (re === null || re.length < 2) {
+            console.error('ERROR: cannot determine Websocket url');
+        } else {
+            this.wsUrl = 'ws://' + re[1];
+            this._handleIoSocket();
+            this._RegisterEvents();
+        }
+    }
+
+    private _WSState(sts: boolean) {
+        this._status.WS_connected = sts;
+        this.statusSubject.next(Object.assign({}, this._status));
+
+        // Update XDS config including XDS Server list when connected
+        if (sts) {
+            this.getConfig().subscribe(c => {
+                this._config = c;
+                this.configSubject.next(
+                    Object.assign({ servers: [] }, this._config)
+                );
+            });
+        }
+    }
+
+    private _handleIoSocket() {
+        this.socket = io(this.wsUrl, { transports: ['websocket'] });
+
+        this.socket.on('connect_error', (res) => {
+            this._WSState(false);
+            console.error('XDS Agent WebSocket Connection error !');
+        });
+
+        this.socket.on('connect', (res) => {
+            this._WSState(true);
+        });
+
+        this.socket.on('disconnection', (res) => {
+            this._WSState(false);
+            this.alert.error('WS disconnection: ' + res);
+        });
+
+        this.socket.on('error', (err) => {
+            console.error('WS error:', err);
+        });
+
+        this.socket.on('make:output', data => {
+            this.CmdOutput$.next(Object.assign({}, <ICmdOutput>data));
+        });
+
+        this.socket.on('make:exit', data => {
+            this.CmdExit$.next(Object.assign({}, <ICmdExit>data));
+        });
+
+        this.socket.on('exec:output', data => {
+            this.CmdOutput$.next(Object.assign({}, <ICmdOutput>data));
+        });
+
+        this.socket.on('exec:exit', data => {
+            this.CmdExit$.next(Object.assign({}, <ICmdExit>data));
+        });
+
+        // Events
+        // (project-add and project-delete events are managed by project.service)
+        this.socket.on('event:server-config', ev => {
+            if (ev && ev.data) {
+                let cfg: IXDServerCfg = ev.data;
+                let idx = this._config.servers.findIndex(el => el.id === cfg.id);
+                if (idx >= 0) {
+                    this._config.servers[idx] = Object.assign({}, cfg);
+                }
+                this.configSubject.next(Object.assign({}, this._config));
+            }
+        });
+
+        this.socket.on('event:project-state-change', ev => {
+            if (ev && ev.data) {
+                this.ProjectState$.next(Object.assign({}, ev.data));
+            }
+        });
+
+    }
+
+    /**
+    ** Events
+    ***/
+    addEventListener(ev: string, fn: Function): SocketIOClient.Emitter {
+        return this.socket.addEventListener(ev, fn);
+    }
+
+    /**
+    ** Misc / Version
+    ***/
+    getVersion(): Observable<IXDSVersions> {
+        return this._get('/version');
+    }
+
+    /***
+    ** Config
+    ***/
+    getConfig(): Observable<IXDSConfig> {
+        return this._get('/config');
+    }
+
+    setConfig(cfg: IXDSConfig): Observable<IXDSConfig> {
+        return this._post('/config', cfg);
+    }
+
+    setServerRetry(serverID: string, r: number) {
+        let svr = this._getServer(serverID);
+        if (!svr) {
+            return Observable.of([]);
+        }
+
+        svr.connRetry = r;
+        this.setConfig(this._config).subscribe(
+            newCfg => {
+                this._config = newCfg;
+                this.configSubject.next(Object.assign({}, this._config));
+            },
+            err => {
+                this.alert.error(err);
+            }
+        );
+    }
+
+    setServerUrl(serverID: string, url: string) {
+        let svr = this._getServer(serverID);
+        if (!svr) {
+            return Observable.of([]);
+        }
+        svr.url = url;
+        this.setConfig(this._config).subscribe(
+            newCfg => {
+                this._config = newCfg;
+                this.configSubject.next(Object.assign({}, this._config));
+            },
+            err => {
+                this.alert.error(err);
+            }
+        );
+    }
+
+    /***
+    ** SDKs
+    ***/
+    getSdks(serverID: string): Observable<ISdk[]> {
+        let svr = this._getServer(serverID);
+        if (!svr || !svr.connected) {
+            return Observable.of([]);
+        }
+
+        return this._get(svr.partialUrl + '/sdks');
+    }
+
+    /***
+    ** Projects
+    ***/
+    getProjects(): Observable<IXDSProjectConfig[]> {
+        return this._get('/projects');
+    }
+
+    addProject(cfg: IXDSProjectConfig): Observable<IXDSProjectConfig> {
+        return this._post('/project', cfg);
+    }
+
+    deleteProject(id: string): Observable<IXDSProjectConfig> {
+        return this._delete('/project/' + id);
+    }
+
+    syncProject(id: string): Observable<string> {
+        return this._post('/project/sync/' + id, {});
+    }
+
+    /***
+    ** Exec
+    ***/
+    exec(prjID: string, dir: string, cmd: string, sdkid?: string, args?: string[], env?: string[]): Observable<any> {
+        return this._post('/exec',
+            {
+                id: prjID,
+                rpath: dir,
+                cmd: cmd,
+                sdkid: sdkid || "",
+                args: args || [],
+                env: env || [],
+            });
+    }
+
+    make(prjID: string, dir: string, sdkid?: string, args?: string[], env?: string[]): Observable<any> {
+        // SEB TODO add serverID
+        return this._post('/make',
+            {
+                id: prjID,
+                rpath: dir,
+                sdkid: sdkid,
+                args: args || [],
+                env: env || [],
+            });
+    }
+
+
+    /**
+    ** Private functions
+    ***/
+
+    private _RegisterEvents() {
+        // Register to all existing events
+        this._post('/events/register', { "name": "all" })
+            .subscribe(
+            res => { },
+            error => {
+                this.alert.error("ERROR while registering to all events: ", error);
+            }
+            );
+    }
+
+    private _getServer(ID: string): IXDServerCfg {
+        let svr = this._config.servers.filter(item => item.id === ID);
+        if (svr.length < 1) {
+            return null;
+        }
+        return svr[0];
+    }
+
+    private _attachAuthHeaders(options?: any) {
+        options = options || {};
+        let headers = options.headers || new Headers();
+        // headers.append('Authorization', 'Basic ' + btoa('username:password'));
+        headers.append('Accept', 'application/json');
+        headers.append('Content-Type', 'application/json');
+        // headers.append('Access-Control-Allow-Origin', '*');
+
+        options.headers = headers;
+        return options;
+    }
+
+    private _get(url: string): Observable<any> {
+        return this.http.get(this.baseUrl + url, this._attachAuthHeaders())
+            .map((res: Response) => res.json())
+            .catch(this._decodeError);
+    }
+    private _post(url: string, body: any): Observable<any> {
+        return this.http.post(this.baseUrl + url, JSON.stringify(body), this._attachAuthHeaders())
+            .map((res: Response) => res.json())
+            .catch((error) => {
+                return this._decodeError(error);
+            });
+    }
+    private _delete(url: string): Observable<any> {
+        return this.http.delete(this.baseUrl + url, this._attachAuthHeaders())
+            .map((res: Response) => res.json())
+            .catch(this._decodeError);
+    }
+
+    private _decodeError(err: any) {
+        let e: string;
+        if (err instanceof Response) {
+            const body = err.json() || 'Agent error';
+            e = body.error || JSON.stringify(body);
+            if (!e || e === "") {
+                e = `${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 (file)
index 0000000..290b4be
--- /dev/null
@@ -0,0 +1,50 @@
+<html>
+
+<head>
+    <title>
+        XDS Dashboard
+    </title>
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+    <link rel="icon" type="image/x-icon" href="assets/favicon.ico">
+
+    <!-- TODO cleanup
+    <link rel="stylesheet" href="lib/foundation-sites/dist/css/foundation.min.css">
+    -->
+    <link <link href="lib/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
+
+    <link rel="stylesheet" href="lib/font-awesome/css/font-awesome.min.css">
+    <link rel="stylesheet" href="lib/font-awesome-animation/dist/font-awesome-animation.min.css">
+
+    <!-- 1. Load libraries -->
+    <!-- Polyfill(s) for older browsers -->
+    <script src="lib/core-js/client/shim.min.js"></script>
+
+    <script src="lib/zone.js/dist/zone.js"></script>
+    <script src="lib/reflect-metadata/Reflect.js"></script>
+    <script src="lib/systemjs/dist/system.src.js"></script>
+
+    <!-- 2. Configure SystemJS -->
+    <script src="systemjs.config.js"></script>
+    <script>
+        System.import('app')
+            .then(null, console.error.bind(console));
+    </script>
+
+    <script src="https://code.jquery.com/jquery-2.1.1.min.js"></script>
+
+</head>
+
+<!-- 3. Display the application -->
+
+<body style="padding-top: 70px;">   <!-- padding needed due to fixed navbar -->
+    <app>
+        <div style="text-align:center; position:absolute; top:50%; width:100%; transform:translate(0,-50%);">
+            <img id="logo-iot" src="assets/images/iot-bzh-logo-small.png">
+            <br> Loading...
+            <i class="fa fa-spinner fa-spin fa-fw"></i>
+        </div>
+    </app>
+</body>
+
+</html>
diff --git a/webapp/src/systemjs.config.js b/webapp/src/systemjs.config.js
new file mode 100644 (file)
index 0000000..15c52ba
--- /dev/null
@@ -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 (file)
index 0000000..9bad681
--- /dev/null
@@ -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 (file)
index 0000000..15969a4
--- /dev/null
@@ -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 (file)
index 0000000..aa64c7f
--- /dev/null
@@ -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 (file)
index 0000000..23c6a41
--- /dev/null
@@ -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"
+  }
+}