Initial main commit.
authorSebastien Douheret <sebastien.douheret@iot.bzh>
Thu, 11 May 2017 17:42:00 +0000 (19:42 +0200)
committerSebastien Douheret <sebastien.douheret@iot.bzh>
Thu, 11 May 2017 17:42:22 +0000 (19:42 +0200)
Signed-off-by: Sebastien Douheret <sebastien.douheret@iot.bzh>
59 files changed:
.gitignore [new file with mode: 0644]
.vscode/launch.json [new file with mode: 0644]
.vscode/settings.json [new file with mode: 0644]
Makefile [new file with mode: 0644]
README.md [new file with mode: 0644]
config.json.in [new file with mode: 0644]
glide.yaml [new file with mode: 0644]
lib/apiv1/apiv1.go [new file with mode: 0644]
lib/apiv1/config.go [new file with mode: 0644]
lib/apiv1/exec.go [new file with mode: 0644]
lib/apiv1/folders.go [new file with mode: 0644]
lib/apiv1/make.go [new file with mode: 0644]
lib/apiv1/version.go [new file with mode: 0644]
lib/common/error.go [new file with mode: 0644]
lib/common/execPipeWs.go [new file with mode: 0644]
lib/common/httpclient.go [new file with mode: 0644]
lib/session/session.go [new file with mode: 0644]
lib/syncthing/st.go [new file with mode: 0644]
lib/syncthing/stfolder.go [new file with mode: 0644]
lib/xdsconfig/builderconfig.go [new file with mode: 0644]
lib/xdsconfig/config.go [new file with mode: 0644]
lib/xdsconfig/fileconfig.go [new file with mode: 0644]
lib/xdsconfig/folderconfig.go [new file with mode: 0644]
lib/xdsconfig/foldersconfig.go [new file with mode: 0644]
lib/xdsserver/server.go [new file with mode: 0644]
main.go [new file with mode: 0644]
webapp/README.md [new file with mode: 0644]
webapp/assets/favicon.ico [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/build/build.component.css [new file with mode: 0644]
webapp/src/app/build/build.component.html [new file with mode: 0644]
webapp/src/app/build/build.component.ts [new file with mode: 0644]
webapp/src/app/common/alert.service.ts [new file with mode: 0644]
webapp/src/app/common/config.service.ts [new file with mode: 0644]
webapp/src/app/common/syncthing.service.ts [new file with mode: 0644]
webapp/src/app/common/xdsserver.service.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/home/home.component.ts [new file with mode: 0644]
webapp/src/app/main.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/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]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..660e248
--- /dev/null
@@ -0,0 +1,10 @@
+bin
+tools
+**/glide.lock
+**/vendor
+
+debug
+cmd/*/debug
+
+webapp/dist
+webapp/node_modules
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644 (file)
index 0000000..8bdde69
--- /dev/null
@@ -0,0 +1,37 @@
+{
+    "version": "0.2.0",
+    "configurations": [{
+            "name": "XDS-Server local",
+            "type": "go",
+            "request": "launch",
+            "mode": "debug",
+            "remotePath": "",
+            "port": 2345,
+            "host": "127.0.0.1",
+            "program": "${workspaceRoot}",
+            "env": {
+                "GOPATH": "${workspaceRoot}/../../../..:${env:GOPATH}",
+                "ROOT_DIR": "${workspaceRoot}/../../../.."
+            },
+            "args": ["-log", "debug", "-c", "config.json.in"],
+            "showLog": false
+        },
+        {
+            "name": "XDS-Server IN DOCKER",
+            "type": "go",
+            "request": "launch",
+            "mode": "debug",
+            "port": 22000,
+            "host": "172.17.0.2",
+            "remotePath": "/xds/src/github.com/iotbzh/xds-server/bin/xds-server",
+            "program": "${workspaceRoot}",
+            "env": {
+                "GOPATH": "${workspaceRoot}/../../../..:${env:GOPATH}",
+                "ROOT_DIR": "${workspaceRoot}/../../../.."
+            },
+            "args": [],
+            "showLog": true
+        }
+
+    ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644 (file)
index 0000000..a90ab0d
--- /dev/null
@@ -0,0 +1,22 @@
+// Place your settings in this file to overwrite default and user settings.
+{
+    // Configure glob patterns for excluding files and folders.
+    "files.exclude": {
+        ".tmp": true,
+        ".git": true,
+        "glide.lock": true,
+        "vendor": true,
+        "debug": true,
+        "bin": true,
+        "tools": true,
+        "webapp/dist": true,
+        "webapp/node_modules": true
+    },
+
+    // Words to add to dictionary for a workspace.
+    "cSpell.words": [
+        "apiv", "gonic", "devel", "csrffound", "Syncthing", "STID",
+        "ISTCONFIG", "socketio", "ldflags", "SThg", "Intf", "dismissible",
+        "rpath", "WSID", "sess", "IXDS", "xdsconfig", "xdsserver"
+    ]
+}
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..5977854
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,118 @@
+# Makefile used to build XDS daemon Web Server
+
+# Retrieve git tag/commit to set sub-version string
+ifeq ($(origin VERSION), undefined)
+       VERSION := $(shell git describe --tags --always | sed 's/^v//')
+       ifeq ($(VERSION), )
+               VERSION=unknown-dev
+       endif
+endif
+
+
+HOST_GOOS=$(shell go env GOOS)
+HOST_GOARCH=$(shell go env GOARCH)
+REPOPATH=github.com/iotbzh/xds-server
+
+mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST)))
+ROOT_SRCDIR := $(patsubst %/,%,$(dir $(mkfile_path)))
+ROOT_GOPRJ := $(abspath $(ROOT_SRCDIR)/../../../..)
+
+export GOPATH := $(shell go env GOPATH):$(ROOT_GOPRJ)
+export PATH := $(PATH):$(ROOT_SRCDIR)/tools
+
+VERBOSE_1 := -v
+VERBOSE_2 := -v -x
+
+#WHAT := xds-make
+
+all: build webapp
+
+#build: build/xds build/cmds
+build: build/xds
+
+xds: build/xds
+
+build/xds: vendor
+       @echo "### Build XDS server (version $(VERSION))";
+       @cd $(ROOT_SRCDIR); $(BUILD_ENV_FLAGS) go build $(VERBOSE_$(V)) -i -o bin/xds-server -ldflags "-X main.AppVersionGitTag=$(VERSION)" .
+
+#build/cmds: vendor
+#      @for target in $(WHAT); do \
+#              echo "### Build $$target"; \
+#              $(BUILD_ENV_FLAGS) go build $(VERBOSE_$(V)) -i -o bin/$$target -ldflags "-X main.AppVersionGitTag=$(VERSION)" ./cmd/$$target; \
+#      done
+
+test: tools/glide
+       go test --race $(shell ./tools/glide novendor)
+
+vet: tools/glide
+       go vet $(shell ./tools/glide novendor)
+
+fmt: tools/glide
+       go fmt $(shell ./tools/glide novendor)
+
+run: build/xds
+       ./bin/xds-server --log info -c config.json.in
+
+debug: build/xds webapp/debug
+       ./bin/xds-server --log debug -c config.json.in
+
+clean:
+       rm -rf ./bin/* debug cmd/*/debug $(ROOT_GOPRJ)/pkg/*/$(REPOPATH)
+
+distclean: clean
+       rm -rf bin tools glide.lock vendor cmd/*/vendor webapp/{node_modules,dist}
+
+run3:
+       goreman start
+
+webapp: webapp/install
+       (cd webapp && gulp build)
+
+webapp/debug:
+       (cd webapp && gulp watch &)
+
+webapp/install:
+       (cd webapp && npm install)
+
+
+# FIXME - package webapp
+release: releasetar
+       goxc -d ./release -tasks-=go-vet,go-test -os="linux darwin" -pv=$(VERSION)  -arch="386 amd64 arm arm64" -build-ldflags="-X main.AppVersionGitTag=$(VERSION)" -resources-include="README.md,Documentation,LICENSE,contrib" -main-dirs-exclude="vendor"
+
+releasetar:
+       mkdir -p release/$(VERSION)
+       glide install --strip-vcs --strip-vendor --update-vendored --delete
+       glide-vc --only-code --no-tests --keep="**/*.json.in"
+       git ls-files > /tmp/xds-server-build
+       find vendor >> /tmp/xds-server-build
+       find webapp/ -path webapp/node_modules -prune -o -print >> /tmp/xds-server-build
+       tar -cvf release/$(VERSION)/xds-server_$(VERSION)_src.tar -T /tmp/xds-server-build --transform 's,^,xds-server_$(VERSION)/,'
+       rm /tmp/xds-server-build
+       gzip release/$(VERSION)/xds-server_$(VERSION)_src.tar
+
+
+vendor: tools/glide glide.yaml
+       ./tools/glide install --strip-vendor
+
+tools/glide:
+       @echo "Downloading glide"
+       mkdir -p tools
+       curl --silent -L https://glide.sh/get | GOBIN=./tools  sh
+
+goenv:
+       @go env
+
+help:
+       @echo "Main supported rules:"
+       @echo "  build               (default)"
+       @echo "  build/xds"
+       @echo "  build/cmds"
+       @echo "  release"
+       @echo "  clean"
+       @echo "  distclean"
+       @echo ""
+       @echo "Influential make variables:"
+       @echo "  V                 - Build verbosity {0,1,2}."
+       @echo "  BUILD_ENV_FLAGS   - Environment added to 'go build'."
+#      @echo "  WHAT              - Command to build. (e.g. WHAT=xds-make)"
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..1fb42df
--- /dev/null
+++ b/README.md
@@ -0,0 +1,115 @@
+# XDS - X(cross) Development System
+
+XDS-server is a web server that allows user to remotely cross build applications.
+
+The first goal is to provide a multi-platform cross development tool with
+near-zero installation.
+The second goals is to keep application sources locally (on user's machine) to
+make it compatible with existing IT policies (e.g. corporate backup or SCM).
+
+This powerful webserver (written in [Go](https://golang.org)) exposes a REST
+interface over HTTP and also provides a Web dashboard to configure projects and execute only _(for now)_ basics commands.
+
+XDS-server also uses [Syncthing](https://syncthing.net/) tool to synchronize
+projects files from user machine to build server machine.
+
+> **NOTE**: For now, only Syncthing sharing method is supported to synchronize
+projects files.
+
+> **SEE ALSO**: [xds-make](https://github.com/iotbzh/xds-make), a wrapper on `make`
+command that allows you to build your application from command-line through
+xds-server.
+
+
+## How to build
+
+### Dependencies
+
+- Install and setup [Go](https://golang.org/doc/install) version 1.7 or
+higher to compile this tool.
+- Install [npm](https://www.npmjs.com/) : `sudo apt install npm`
+- Install [gulp](http://gulpjs.com/) : `sudo npm install -g gulp`
+
+
+### Building
+
+Clone this repo into your `$GOPATH/src/github.com/iotbzh` and use delivered Makefile:
+```bash
+ mkdir -p $GOPATH/src/github.com/iotbzh
+ cd $GOPATH/src/github.com/iotbzh
+ git clone https://github.com/iotbzh/xds-server.git
+ cd xds-server
+ make all
+```
+
+## How to run
+
+## Configuration
+
+xds-server configuration is driven by a JSON config file (`config.json`).
+
+Here is the logic to determine which `config.json` file will be used:
+1. from command line option: `--config myConfig.json`
+2. `$HOME/.xds/config.json` file
+3. `<xds-server executable dir>/config.json` file
+
+Supported fields in configuration file are:
+```json
+{
+    "webAppDir": "location of client dashboard (default: webapp/dist)",
+    "shareRootDir": "root directory where projects will be copied",
+    "syncthing": {
+        "home": "syncthing home directory (usually .../syncthing-config)",
+        "gui-address": "syncthing gui url (default http://localhost:8384)"
+    }
+}
+```
+
+>**NOTE:** environment variables are supported by using `${MY_VAR}` syntax.
+
+## Start-up
+
+```bash
+./bin/xds-server -c config.json
+```
+
+**TODO**: add notes about Syncthing setup and startup
+
+
+## Debugging
+
+### XDS server architecture
+
+The server part is written in *Go* and web app / dashboard (client part) in
+*Angular2*.
+
+```
+|
++-- bin/                where xds-server binary file will be built
+|
++-- config.json.in      example of config.json file
+|
++-- glide.yaml          Go package dependency file
+|
++-- lib/                sources of server part (Go)
+|
++-- main.go             main entry point of of Web server (Go)
+|
++-- Makefile            makefile including
+|
++-- README.md           this readme
+|
++-- tools/              temporary directory to hold development tools (like glide)
+|
++-- vendor/             temporary directory to hold Go dependencies packages
+|
++-- webapp/             source client dashboard (Angular2 app)
+```
+
+VSCode launcher settings can be found into `.vscode/launch.json`.
+
+
+## TODO:
+- replace makefile by build.go to make Windows build support easier
+- add more tests
+- add more documentation
diff --git a/config.json.in b/config.json.in
new file mode 100644 (file)
index 0000000..a4dcf33
--- /dev/null
@@ -0,0 +1,8 @@
+{
+    "webAppDir": "webapp/dist",
+    "shareRootDir": "${ROOT_DIR}/tmp/builder_dev_host/share",
+    "syncthing": {
+        "home": "${ROOT_DIR}/tmp/local_dev/syncthing-config",
+        "gui-address": "http://localhost:8384"
+    }
+}
\ No newline at end of file
diff --git a/glide.yaml b/glide.yaml
new file mode 100644 (file)
index 0000000..b182ebc
--- /dev/null
@@ -0,0 +1,19 @@
+package: github.com/iotbzh/xds-server
+license: Apache-2
+owners:
+- name: Sebastien Douheret
+  email: sebastien@iot.bzh
+import:
+- package: github.com/gin-gonic/gin
+  version: ^1.1.4
+- package: github.com/gin-contrib/static
+- package: github.com/syncthing/syncthing
+  version: ^0.14.27-rc.2
+- package: github.com/codegangsta/cli
+  version: ^1.19.1
+- package: github.com/Sirupsen/logrus
+  version: ^0.11.5
+- package: github.com/googollee/go-socket.io
+- package: github.com/zhouhui8915/go-socket.io-client
+- package: github.com/satori/go.uuid
+  version: ^1.1.0
diff --git a/lib/apiv1/apiv1.go b/lib/apiv1/apiv1.go
new file mode 100644 (file)
index 0000000..56c7503
--- /dev/null
@@ -0,0 +1,49 @@
+package apiv1
+
+import (
+       "github.com/Sirupsen/logrus"
+       "github.com/gin-gonic/gin"
+
+       "github.com/iotbzh/xds-server/lib/session"
+       "github.com/iotbzh/xds-server/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, cfg xdsconfig.Config, r *gin.Engine) *APIService {
+       s := &APIService{
+               router:    r,
+               sessions:  sess,
+               apiRouter: r.Group("/api/v1"),
+               cfg:       cfg,
+               log:       cfg.Log,
+       }
+
+       s.apiRouter.GET("/version", s.getVersion)
+
+       s.apiRouter.GET("/config", s.getConfig)
+       s.apiRouter.POST("/config", s.setConfig)
+
+       s.apiRouter.GET("/folders", s.getFolders)
+       s.apiRouter.GET("/folder/:id", s.getFolder)
+       s.apiRouter.POST("/folder", s.addFolder)
+       s.apiRouter.DELETE("/folder/:id", s.delFolder)
+
+       s.apiRouter.POST("/make", s.buildMake)
+       s.apiRouter.POST("/make/:id", s.buildMake)
+
+       /* TODO: to be tested and then enabled
+       s.apiRouter.POST("/exec", s.execCmd)
+       s.apiRouter.POST("/exec/:id", s.execCmd)
+       */
+
+       return s
+}
diff --git a/lib/apiv1/config.go b/lib/apiv1/config.go
new file mode 100644 (file)
index 0000000..a2817a0
--- /dev/null
@@ -0,0 +1,45 @@
+package apiv1
+
+import (
+       "net/http"
+       "sync"
+
+       "github.com/gin-gonic/gin"
+       "github.com/iotbzh/xds-server/lib/common"
+       "github.com/iotbzh/xds-server/lib/xdsconfig"
+)
+
+var confMut sync.Mutex
+
+// GetConfig returns server configuration
+func (s *APIService) getConfig(c *gin.Context) {
+       confMut.Lock()
+       defer confMut.Unlock()
+
+       c.JSON(http.StatusOK, s.cfg)
+}
+
+// SetConfig sets server 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/exec.go b/lib/apiv1/exec.go
new file mode 100644 (file)
index 0000000..f7beea6
--- /dev/null
@@ -0,0 +1,154 @@
+package apiv1
+
+import (
+       "net/http"
+       "strconv"
+       "strings"
+       "time"
+
+       "github.com/gin-gonic/gin"
+       "github.com/iotbzh/xds-server/lib/common"
+)
+
+// ExecArgs JSON parameters of /exec command
+type ExecArgs struct {
+       ID         string   `json:"id"`
+       RPath      string   `json:"rpath"` // relative path into project
+       Cmd        string   `json:"cmd" binding:"required"`
+       Args       []string `json:"args"`
+       CmdTimeout int      `json:"timeout"` // command completion timeout in Second
+}
+
+// ExecOutMsg Message send on each output (stdout+stderr) of executed command
+type ExecOutMsg struct {
+       CmdID     string `json:"cmdID"`
+       Timestamp string `json:timestamp`
+       Stdout    string `json:"stdout"`
+       Stderr    string `json:"stderr"`
+}
+
+// ExecExitMsg Message send when executed command exited
+type ExecExitMsg struct {
+       CmdID     string `json:"cmdID"`
+       Timestamp string `json:timestamp`
+       Code      int    `json:"code"`
+       Error     error  `json:"error"`
+}
+
+// Event name send in WS
+const ExecOutEvent = "exec:output"
+const ExecExitEvent = "exec:exit"
+
+var execCommandID = 1
+
+// ExecCmd executes remotely a command
+func (s *APIService) execCmd(c *gin.Context) {
+       var args ExecArgs
+       if c.BindJSON(&args) != nil {
+               common.APIError(c, "Invalid arguments")
+               return
+       }
+
+       // TODO: add permission
+
+       // Retrieve session info
+       sess := s.sessions.Get(c)
+       if sess == nil {
+               common.APIError(c, "Unknown sessions")
+               return
+       }
+       sop := sess.IOSocket
+       if sop == nil {
+               common.APIError(c, "Websocket not established")
+               return
+       }
+
+       // Allow to pass id in url (/exec/:id) or as JSON argument
+       id := c.Param("id")
+       if id == "" {
+               id = args.ID
+       }
+       if id == "" {
+               common.APIError(c, "Invalid id")
+               return
+       }
+
+       prj := s.cfg.GetFolderFromID(id)
+       if prj == nil {
+               common.APIError(c, "Unknown id")
+               return
+       }
+
+       execTmo := args.CmdTimeout
+       if execTmo == 0 {
+               // TODO get default timeout from config.json file
+               execTmo = 24 * 60 * 60 // 1 day
+       }
+
+       // Define callback for output
+       var oCB common.EmitOutputCB
+       oCB = func(sid string, id int, stdout, stderr string) {
+               // IO socket can be nil when disconnected
+               so := s.sessions.IOSocketGet(sid)
+               if so == nil {
+                       s.log.Infof("%s not emitted: WS closed - sid: %s - msg id:%d", ExecOutEvent, sid, id)
+                       return
+               }
+               s.log.Debugf("%s emitted - WS sid %s - id:%d", ExecOutEvent, sid, id)
+
+               // FIXME replace by .BroadcastTo a room
+               err := (*so).Emit(ExecOutEvent, ExecOutMsg{
+                       CmdID:     strconv.Itoa(id),
+                       Timestamp: time.Now().String(),
+                       Stdout:    stdout,
+                       Stderr:    stderr,
+               })
+               if err != nil {
+                       s.log.Errorf("WS Emit : %v", err)
+               }
+       }
+
+       // Define callback for output
+       eCB := func(sid string, id int, code int, err error) {
+               s.log.Debugf("Command [Cmd ID %d] exited: code %d, error: %v", id, code, err)
+
+               // IO socket can be nil when disconnected
+               so := s.sessions.IOSocketGet(sid)
+               if so == nil {
+                       s.log.Infof("%s not emitted - WS closed (id:%d", ExecExitEvent, id)
+                       return
+               }
+
+               // FIXME replace by .BroadcastTo a room
+               e := (*so).Emit(ExecExitEvent, ExecExitMsg{
+                       CmdID:     strconv.Itoa(id),
+                       Timestamp: time.Now().String(),
+                       Code:      code,
+                       Error:     err,
+               })
+               if e != nil {
+                       s.log.Errorf("WS Emit : %v", e)
+               }
+       }
+
+       cmdID := execCommandID
+       execCommandID++
+
+       cmd := "cd " + prj.GetFullPath(args.RPath) + " && " + args.Cmd
+       if len(args.Args) > 0 {
+               cmd += " " + strings.Join(args.Args, " ")
+       }
+
+       s.log.Debugf("Execute [Cmd ID %d]: %v %v", cmdID, cmd)
+       err := common.ExecPipeWs(cmd, sop, sess.ID, cmdID, execTmo, s.log, oCB, eCB)
+       if err != nil {
+               common.APIError(c, err.Error())
+               return
+       }
+
+       c.JSON(http.StatusOK,
+               gin.H{
+                       "status": "OK",
+                       "cmdID":  cmdID,
+               })
+}
diff --git a/lib/apiv1/folders.go b/lib/apiv1/folders.go
new file mode 100644 (file)
index 0000000..b1864a2
--- /dev/null
@@ -0,0 +1,77 @@
+package apiv1
+
+import (
+       "net/http"
+       "strconv"
+
+       "github.com/gin-gonic/gin"
+       "github.com/iotbzh/xds-server/lib/common"
+       "github.com/iotbzh/xds-server/lib/xdsconfig"
+)
+
+// getFolders returns all folders configuration
+func (s *APIService) getFolders(c *gin.Context) {
+       confMut.Lock()
+       defer confMut.Unlock()
+
+       c.JSON(http.StatusOK, s.cfg.Folders)
+}
+
+// getFolder returns a specific folder configuration
+func (s *APIService) getFolder(c *gin.Context) {
+       id, err := strconv.Atoi(c.Param("id"))
+       if err != nil || id < 0 || id > len(s.cfg.Folders) {
+               common.APIError(c, "Invalid id")
+               return
+       }
+
+       confMut.Lock()
+       defer confMut.Unlock()
+
+       c.JSON(http.StatusOK, s.cfg.Folders[id])
+}
+
+// addFolder adds a new folder to server config
+func (s *APIService) addFolder(c *gin.Context) {
+       var cfgArg xdsconfig.FolderConfig
+       if c.BindJSON(&cfgArg) != nil {
+               common.APIError(c, "Invalid arguments")
+               return
+       }
+
+       confMut.Lock()
+       defer confMut.Unlock()
+
+       s.log.Debugln("Add folder config: ", cfgArg)
+
+       newFld, err := s.cfg.UpdateFolder(cfgArg)
+       if err != nil {
+               common.APIError(c, err.Error())
+               return
+       }
+
+       c.JSON(http.StatusOK, newFld)
+}
+
+// delFolder deletes folder from server config
+func (s *APIService) delFolder(c *gin.Context) {
+       id := c.Param("id")
+       if id == "" {
+               common.APIError(c, "Invalid id")
+               return
+       }
+
+       confMut.Lock()
+       defer confMut.Unlock()
+
+       s.log.Debugln("Delete folder id ", id)
+
+       var delEntry xdsconfig.FolderConfig
+       var err error
+       if delEntry, err = s.cfg.DeleteFolder(id); err != nil {
+               common.APIError(c, err.Error())
+               return
+       }
+       c.JSON(http.StatusOK, delEntry)
+
+}
diff --git a/lib/apiv1/make.go b/lib/apiv1/make.go
new file mode 100644 (file)
index 0000000..eac6210
--- /dev/null
@@ -0,0 +1,151 @@
+package apiv1
+
+import (
+       "net/http"
+
+       "time"
+
+       "strconv"
+
+       "github.com/gin-gonic/gin"
+       "github.com/iotbzh/xds-server/lib/common"
+)
+
+// MakeArgs is the parameters (json format) of /make command
+type MakeArgs struct {
+       ID         string `json:"id"`
+       RPath      string `json:"rpath"` // relative path into project
+       Args       string `json:"args"`
+       CmdTimeout int    `json:"timeout"` // command completion timeout in Second
+}
+
+// MakeOutMsg Message send on each output (stdout+stderr) of make command
+type MakeOutMsg struct {
+       CmdID     string `json:"cmdID"`
+       Timestamp string `json:timestamp`
+       Stdout    string `json:"stdout"`
+       Stderr    string `json:"stderr"`
+}
+
+// MakeExitMsg Message send on make command exit
+type MakeExitMsg struct {
+       CmdID     string `json:"cmdID"`
+       Timestamp string `json:timestamp`
+       Code      int    `json:"code"`
+       Error     error  `json:"error"`
+}
+
+// Event name send in WS
+const MakeOutEvent = "make:output"
+const MakeExitEvent = "make:exit"
+
+var makeCommandID = 1
+
+func (s *APIService) buildMake(c *gin.Context) {
+       var args MakeArgs
+
+       if c.BindJSON(&args) != nil {
+               common.APIError(c, "Invalid arguments")
+               return
+       }
+
+       sess := s.sessions.Get(c)
+       if sess == nil {
+               common.APIError(c, "Unknown sessions")
+               return
+       }
+       sop := sess.IOSocket
+       if sop == nil {
+               common.APIError(c, "Websocket not established")
+               return
+       }
+
+       // Allow to pass id in url (/make/:id) or as JSON argument
+       id := c.Param("id")
+       if id == "" {
+               id = args.ID
+       }
+       if id == "" {
+               common.APIError(c, "Invalid id")
+               return
+       }
+
+       prj := s.cfg.GetFolderFromID(id)
+       if prj == nil {
+               common.APIError(c, "Unknown id")
+               return
+       }
+
+       execTmo := args.CmdTimeout
+       if execTmo == 0 {
+               // TODO get default timeout from config.json file
+               execTmo = 24 * 60 * 60 // 1 day
+       }
+
+       cmd := "cd " + prj.GetFullPath(args.RPath) + " && make"
+       if args.Args != "" {
+               cmd += " " + args.Args
+       }
+
+       // Define callback for output
+       var oCB common.EmitOutputCB
+       oCB = func(sid string, id int, stdout, stderr string) {
+               // IO socket can be nil when disconnected
+               so := s.sessions.IOSocketGet(sid)
+               if so == nil {
+                       s.log.Infof("%s not emitted: WS closed - sid: %s - msg id:%d", MakeOutEvent, sid, id)
+                       return
+               }
+               s.log.Debugf("%s emitted - WS sid %s - id:%d", MakeOutEvent, sid, id)
+
+               // FIXME replace by .BroadcastTo a room
+               err := (*so).Emit(MakeOutEvent, MakeOutMsg{
+                       CmdID:     strconv.Itoa(id),
+                       Timestamp: time.Now().String(),
+                       Stdout:    stdout,
+                       Stderr:    stderr,
+               })
+               if err != nil {
+                       s.log.Errorf("WS Emit : %v", err)
+               }
+       }
+
+       // Define callback for output
+       eCB := func(sid string, id int, code int, err error) {
+               s.log.Debugf("Command [Cmd ID %d] exited: code %d, error: %v", id, code, err)
+
+               // IO socket can be nil when disconnected
+               so := s.sessions.IOSocketGet(sid)
+               if so == nil {
+                       s.log.Infof("%s not emitted - WS closed (id:%d", MakeExitEvent, id)
+                       return
+               }
+
+               // FIXME replace by .BroadcastTo a room
+               e := (*so).Emit(MakeExitEvent, MakeExitMsg{
+                       CmdID:     strconv.Itoa(id),
+                       Timestamp: time.Now().String(),
+                       Code:      code,
+                       Error:     err,
+               })
+               if e != nil {
+                       s.log.Errorf("WS Emit : %v", e)
+               }
+       }
+
+       cmdID := makeCommandID
+       makeCommandID++
+
+       s.log.Debugf("Execute [Cmd ID %d]: %v", cmdID, cmd)
+       err := common.ExecPipeWs(cmd, sop, sess.ID, cmdID, execTmo, s.log, oCB, eCB)
+       if err != nil {
+               common.APIError(c, err.Error())
+               return
+       }
+
+       c.JSON(http.StatusOK,
+               gin.H{
+                       "status": "OK",
+                       "cmdID":  cmdID,
+               })
+}
diff --git a/lib/apiv1/version.go b/lib/apiv1/version.go
new file mode 100644 (file)
index 0000000..e022441
--- /dev/null
@@ -0,0 +1,24 @@
+package apiv1
+
+import (
+       "net/http"
+
+       "github.com/gin-gonic/gin"
+)
+
+type version struct {
+       Version       string `json:"version"`
+       APIVersion    string `json:"apiVersion"`
+       VersionGitTag string `json:"gitTag"`
+}
+
+// getInfo : return various information about server
+func (s *APIService) getVersion(c *gin.Context) {
+       response := version{
+               Version:       s.cfg.Version,
+               APIVersion:    s.cfg.APIVersion,
+               VersionGitTag: s.cfg.VersionGitTag,
+       }
+
+       c.JSON(http.StatusOK, response)
+}
diff --git a/lib/common/error.go b/lib/common/error.go
new file mode 100644 (file)
index 0000000..d03c176
--- /dev/null
@@ -0,0 +1,13 @@
+package common
+
+import (
+       "github.com/gin-gonic/gin"
+)
+
+// APIError returns an uniform json formatted error
+func APIError(c *gin.Context, err string) {
+       c.JSON(500, gin.H{
+               "status": "error",
+               "error":  err,
+       })
+}
diff --git a/lib/common/execPipeWs.go b/lib/common/execPipeWs.go
new file mode 100644 (file)
index 0000000..3b63cdc
--- /dev/null
@@ -0,0 +1,148 @@
+package common
+
+import (
+       "bufio"
+       "fmt"
+       "io"
+       "os"
+       "time"
+
+       "syscall"
+
+       "github.com/Sirupsen/logrus"
+       "github.com/googollee/go-socket.io"
+)
+
+// EmitOutputCB is the function callback used to emit data
+type EmitOutputCB func(sid string, cmdID int, stdout, stderr string)
+
+// EmitExitCB is the function callback used to emit exit proc code
+type EmitExitCB func(sid string, cmdID int, code int, err error)
+
+// Inspired by :
+// https://github.com/gorilla/websocket/blob/master/examples/command/main.go
+
+// ExecPipeWs executes a command and redirect stdout/stderr into a WebSocket
+func ExecPipeWs(cmd string, so *socketio.Socket, sid string, cmdID int,
+       cmdExecTimeout int, log *logrus.Logger, eoCB EmitOutputCB, eeCB EmitExitCB) error {
+
+       outr, outw, err := os.Pipe()
+       if err != nil {
+               return fmt.Errorf("Pipe stdout error: " + err.Error())
+       }
+
+       // XXX - do we need to pipe stdin one day ?
+       inr, inw, err := os.Pipe()
+       if err != nil {
+               outr.Close()
+               outw.Close()
+               return fmt.Errorf("Pipe stdin error: " + err.Error())
+       }
+
+       bashArgs := []string{"/bin/bash", "-c", cmd}
+       proc, err := os.StartProcess("/bin/bash", bashArgs, &os.ProcAttr{
+               Files: []*os.File{inr, outw, outw},
+       })
+       if err != nil {
+               outr.Close()
+               outw.Close()
+               inr.Close()
+               inw.Close()
+               return fmt.Errorf("Process start error: " + err.Error())
+       }
+
+       go func() {
+               defer outr.Close()
+               defer outw.Close()
+               defer inr.Close()
+               defer inw.Close()
+
+               stdoutDone := make(chan struct{})
+               go cmdPumpStdout(so, outr, stdoutDone, sid, cmdID, log, eoCB)
+
+               // Blocking function that poll input or wait for end of process
+               cmdPumpStdin(so, inw, proc, sid, cmdID, cmdExecTimeout, log, eeCB)
+
+               // Some commands will exit when stdin is closed.
+               inw.Close()
+
+               defer outr.Close()
+
+               if status, err := proc.Wait(); err == nil {
+                       // Other commands need a bonk on the head.
+                       if !status.Exited() {
+                               if err := proc.Signal(os.Interrupt); err != nil {
+                                       log.Errorln("Proc interrupt:", err)
+                               }
+
+                               select {
+                               case <-stdoutDone:
+                               case <-time.After(time.Second):
+                                       // A bigger bonk on the head.
+                                       if err := proc.Signal(os.Kill); err != nil {
+                                               log.Errorln("Proc term:", err)
+                                       }
+                                       <-stdoutDone
+                               }
+                       }
+               }
+       }()
+
+       return nil
+}
+
+func cmdPumpStdin(so *socketio.Socket, w io.Writer, proc *os.Process,
+       sid string, cmdID int, tmo int, log *logrus.Logger, exitFuncCB EmitExitCB) {
+       /* XXX - code to add to support stdin through WS
+       for {
+               _, message, err := so. ?? ReadMessage()
+               if err != nil {
+                       break
+               }
+               message = append(message, '\n')
+               if _, err := w.Write(message); err != nil {
+                       break
+               }
+       }
+       */
+
+       // Monitor process exit
+       type DoneChan struct {
+               status int
+               err    error
+       }
+       done := make(chan DoneChan, 1)
+       go func() {
+               status := 0
+               sts, err := proc.Wait()
+               if !sts.Success() {
+                       s := sts.Sys().(syscall.WaitStatus)
+                       status = s.ExitStatus()
+               }
+               done <- DoneChan{status, err}
+       }()
+
+       // Wait cmd complete
+       select {
+       case dC := <-done:
+               exitFuncCB(sid, cmdID, dC.status, dC.err)
+       case <-time.After(time.Duration(tmo) * time.Second):
+               exitFuncCB(sid, cmdID, -99,
+                       fmt.Errorf("Exit Timeout for command ID %v", cmdID))
+       }
+}
+
+func cmdPumpStdout(so *socketio.Socket, r io.Reader, done chan struct{},
+       sid string, cmdID int, log *logrus.Logger, emitFuncCB EmitOutputCB) {
+       defer func() {
+       }()
+
+       sc := bufio.NewScanner(r)
+       for sc.Scan() {
+               emitFuncCB(sid, cmdID, string(sc.Bytes()), "")
+       }
+       if sc.Err() != nil {
+               log.Errorln("scan:", sc.Err())
+       }
+       close(done)
+}
diff --git a/lib/common/httpclient.go b/lib/common/httpclient.go
new file mode 100644 (file)
index 0000000..40d7bc2
--- /dev/null
@@ -0,0 +1,221 @@
+package common
+
+import (
+       "bytes"
+       "crypto/tls"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io/ioutil"
+       "net/http"
+       "strings"
+)
+
+type HTTPClient struct {
+       httpClient http.Client
+       endpoint   string
+       apikey     string
+       username   string
+       password   string
+       id         string
+       csrf       string
+       conf       HTTPClientConfig
+}
+
+type HTTPClientConfig struct {
+       URLPrefix           string
+       HeaderAPIKeyName    string
+       Apikey              string
+       HeaderClientKeyName string
+       CsrfDisable         bool
+}
+
+// Inspired by syncthing/cmd/cli
+
+const insecure = false
+
+// HTTPNewClient creates a new HTTP client to deal with Syncthing
+func HTTPNewClient(baseURL string, cfg HTTPClientConfig) (*HTTPClient, error) {
+
+       // Create w new Http client
+       httpClient := http.Client{
+               Transport: &http.Transport{
+                       TLSClientConfig: &tls.Config{
+                               InsecureSkipVerify: insecure,
+                       },
+               },
+       }
+       client := HTTPClient{
+               httpClient: httpClient,
+               endpoint:   baseURL,
+               apikey:     cfg.Apikey,
+               conf:       cfg,
+               /* TODO - add user + pwd support
+               username:   c.GlobalString("username"),
+               password:   c.GlobalString("password"),
+               */
+       }
+
+       if client.apikey == "" {
+               if err := client.getCidAndCsrf(); err != nil {
+                       return nil, err
+               }
+       }
+       return &client, nil
+}
+
+// Send request to retrieve Client id and/or CSRF token
+func (c *HTTPClient) getCidAndCsrf() error {
+       request, err := http.NewRequest("GET", c.endpoint, nil)
+       if err != nil {
+               return err
+       }
+       if _, err := c.handleRequest(request); err != nil {
+               return err
+       }
+       if c.id == "" {
+               return errors.New("Failed to get device ID")
+       }
+       if !c.conf.CsrfDisable && c.csrf == "" {
+               return errors.New("Failed to get CSRF token")
+       }
+       return nil
+}
+
+// GetClientID returns the id
+func (c *HTTPClient) GetClientID() string {
+       return c.id
+}
+
+// formatURL Build full url by concatenating all parts
+func (c *HTTPClient) formatURL(endURL string) string {
+       url := c.endpoint
+       if !strings.HasSuffix(url, "/") {
+               url += "/"
+       }
+       url += strings.TrimLeft(c.conf.URLPrefix, "/")
+       if !strings.HasSuffix(url, "/") {
+               url += "/"
+       }
+       return url + strings.TrimLeft(endURL, "/")
+}
+
+// HTTPGet Send a Get request to client and return an error object
+func (c *HTTPClient) HTTPGet(url string, data *[]byte) error {
+       _, err := c.HTTPGetWithRes(url, data)
+       return err
+}
+
+// HTTPGetWithRes Send a Get request to client and return both response and error
+func (c *HTTPClient) HTTPGetWithRes(url string, data *[]byte) (*http.Response, error) {
+       request, err := http.NewRequest("GET", c.formatURL(url), nil)
+       if err != nil {
+               return nil, err
+       }
+       res, err := c.handleRequest(request)
+       if err != nil {
+               return res, err
+       }
+       if res.StatusCode != 200 {
+               return res, errors.New(res.Status)
+       }
+
+       *data = c.responseToBArray(res)
+
+       return res, nil
+}
+
+// HTTPPost Send a POST request to client and return an error object
+func (c *HTTPClient) HTTPPost(url string, body string) error {
+       _, err := c.HTTPPostWithRes(url, body)
+       return err
+}
+
+// HTTPPostWithRes Send a POST request to client and return both response and error
+func (c *HTTPClient) HTTPPostWithRes(url string, body string) (*http.Response, error) {
+       request, err := http.NewRequest("POST", c.formatURL(url), bytes.NewBufferString(body))
+       if err != nil {
+               return nil, err
+       }
+       res, err := c.handleRequest(request)
+       if err != nil {
+               return res, err
+       }
+       if res.StatusCode != 200 {
+               return res, errors.New(res.Status)
+       }
+       return res, nil
+}
+
+func (c *HTTPClient) responseToBArray(response *http.Response) []byte {
+       defer response.Body.Close()
+       bytes, err := ioutil.ReadAll(response.Body)
+       if err != nil {
+               // TODO improved error reporting
+               fmt.Println("ERROR: " + err.Error())
+       }
+       return bytes
+}
+
+func (c *HTTPClient) handleRequest(request *http.Request) (*http.Response, error) {
+       if c.conf.HeaderAPIKeyName != "" && c.apikey != "" {
+               request.Header.Set(c.conf.HeaderAPIKeyName, c.apikey)
+       }
+       if c.conf.HeaderClientKeyName != "" && c.id != "" {
+               request.Header.Set(c.conf.HeaderClientKeyName, c.id)
+       }
+       if c.username != "" || c.password != "" {
+               request.SetBasicAuth(c.username, c.password)
+       }
+       if c.csrf != "" {
+               request.Header.Set("X-CSRF-Token-"+c.id[:5], c.csrf)
+       }
+
+       response, err := c.httpClient.Do(request)
+       if err != nil {
+               return nil, err
+       }
+
+       // Detect client ID change
+       cid := response.Header.Get(c.conf.HeaderClientKeyName)
+       if cid != "" && c.id != cid {
+               c.id = cid
+       }
+
+       // Detect CSR token change
+       for _, item := range response.Cookies() {
+               if item.Name == "CSRF-Token-"+c.id[:5] {
+                       c.csrf = item.Value
+                       goto csrffound
+               }
+       }
+       // OK CSRF found
+csrffound:
+
+       if response.StatusCode == 404 {
+               return nil, errors.New("Invalid endpoint or API call")
+       } else if response.StatusCode == 401 {
+               return nil, errors.New("Invalid username or password")
+       } else if response.StatusCode == 403 {
+               if c.apikey == "" {
+                       // Request a new Csrf for next requests
+                       c.getCidAndCsrf()
+                       return nil, errors.New("Invalid CSRF token")
+               }
+               return nil, errors.New("Invalid API key")
+       } else if response.StatusCode != 200 {
+               data := make(map[string]interface{})
+               // Try to decode error field of APIError struct
+               json.Unmarshal(c.responseToBArray(response), &data)
+               if err, found := data["error"]; found {
+                       return nil, fmt.Errorf(err.(string))
+               } else {
+                       body := strings.TrimSpace(string(c.responseToBArray(response)))
+                       if body != "" {
+                               return nil, fmt.Errorf(body)
+                       }
+               }
+               return nil, errors.New("Unknown HTTP status returned: " + response.Status)
+       }
+       return response, nil
+}
diff --git a/lib/session/session.go b/lib/session/session.go
new file mode 100644 (file)
index 0000000..35dfdc6
--- /dev/null
@@ -0,0 +1,227 @@
+package session
+
+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"
+       "github.com/syncthing/syncthing/lib/sync"
+)
+
+const sessionCookieName = "xds-sid"
+const sessionHeaderName = "XDS-SID"
+
+const sessionMonitorTime = 10 // Time (in seconds) to schedule monitoring session tasks
+
+const initSessionMaxAge = 10 // Initial session max age in seconds
+const maxSessions = 100000   // Maximum number of sessions in sessMap map
+
+const secureCookie = false // TODO: see https://github.com/astaxie/beego/blob/master/session/session.go#L218
+
+// ClientSession contains the info of a user/client session
+type ClientSession struct {
+       ID       string
+       WSID     string // only one WebSocket per client/session
+       MaxAge   int64
+       IOSocket *socketio.Socket
+
+       // private
+       expireAt time.Time
+       useCount int64
+}
+
+// Sessions holds client sessions
+type Sessions struct {
+       router       *gin.Engine
+       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 {
+       ckMaxAge, err := strconv.ParseInt(cookieMaxAge, 10, 0)
+       if err != nil {
+               ckMaxAge = 0
+       }
+       s := Sessions{
+               router:       router,
+               cookieMaxAge: ckMaxAge,
+               sessMap:      make(map[string]ClientSession),
+               mutex:        sync.NewMutex(),
+               log:          log,
+               stop:         make(chan struct{}),
+       }
+       s.router.Use(s.Middleware())
+
+       // Start monitoring of sessions Map (use to manage expiration and cleanup)
+       go s.monitorSessMap()
+
+       return &s
+}
+
+// Stop sessions management
+func (s *Sessions) Stop() {
+       close(s.stop)
+}
+
+// Middleware is used to managed session
+func (s *Sessions) Middleware() gin.HandlerFunc {
+       return func(c *gin.Context) {
+               // FIXME Add CSRF management
+
+               // Get session
+               sess := s.Get(c)
+               if sess == nil {
+                       // Allocate a new session key and put in cookie
+                       sess = s.newSession("")
+               } else {
+                       s.refresh(sess.ID)
+               }
+
+               // Set session in cookie and in header
+               // Do not set Domain to localhost (http://stackoverflow.com/questions/1134290/cookies-on-localhost-with-explicit-domain)
+               c.SetCookie(sessionCookieName, sess.ID, int(sess.MaxAge), "/", "",
+                       secureCookie, false)
+               c.Header(sessionHeaderName, sess.ID)
+
+               // Save session id in gin metadata
+               c.Set(sessionCookieName, sess.ID)
+
+               c.Next()
+       }
+}
+
+// Get returns the client session for a specific ID
+func (s *Sessions) Get(c *gin.Context) *ClientSession {
+       var sid string
+
+       // First get from gin metadata
+       v, exist := c.Get(sessionCookieName)
+       if v != nil {
+               sid = v.(string)
+       }
+
+       // Then look in cookie
+       if !exist || sid == "" {
+               sid, _ = c.Cookie(sessionCookieName)
+       }
+
+       // Then look in Header
+       if sid == "" {
+               sid = c.Request.Header.Get(sessionCookieName)
+       }
+       if sid != "" {
+               s.mutex.Lock()
+               defer s.mutex.Unlock()
+               if key, ok := s.sessMap[sid]; ok {
+                       // TODO: return a copy ???
+                       return &key
+               }
+       }
+       return nil
+}
+
+// IOSocketGet Get socketio definition from sid
+func (s *Sessions) IOSocketGet(sid string) *socketio.Socket {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+       sess, ok := s.sessMap[sid]
+       if ok {
+               return sess.IOSocket
+       }
+       return nil
+}
+
+// UpdateIOSocket updates the IO Socket definition for of a session
+func (s *Sessions) UpdateIOSocket(sid string, so *socketio.Socket) error {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+       if _, ok := s.sessMap[sid]; ok {
+               sess := s.sessMap[sid]
+               if so == nil {
+                       // Could be the case when socketio is closed/disconnected
+                       sess.WSID = ""
+               } else {
+                       sess.WSID = (*so).Id()
+               }
+               sess.IOSocket = so
+               s.sessMap[sid] = sess
+       }
+       return nil
+}
+
+// nesSession Allocate a new client session
+func (s *Sessions) newSession(prefix string) *ClientSession {
+       uuid := prefix + uuid.NewV4().String()
+       id := base64.URLEncoding.EncodeToString([]byte(uuid))
+       se := ClientSession{
+               ID:       id,
+               WSID:     "",
+               MaxAge:   initSessionMaxAge,
+               IOSocket: nil,
+               expireAt: time.Now().Add(time.Duration(initSessionMaxAge) * time.Second),
+               useCount: 0,
+       }
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       s.sessMap[se.ID] = se
+
+       s.log.Debugf("NEW session (%d): %s", len(s.sessMap), id)
+       return &se
+}
+
+// refresh Move this session ID to the head of the list
+func (s *Sessions) refresh(sid string) {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       sess := s.sessMap[sid]
+       sess.useCount++
+       if sess.MaxAge < s.cookieMaxAge && sess.useCount > 1 {
+               sess.MaxAge = s.cookieMaxAge
+               sess.expireAt = time.Now().Add(time.Duration(sess.MaxAge) * time.Second)
+       }
+
+       // TODO - Add flood detection (like limit_req of nginx)
+       // (delayed request when to much requests in a short period of time)
+
+       s.sessMap[sid] = sess
+}
+
+func (s *Sessions) monitorSessMap() {
+       const dbgFullTrace = false // for debugging
+
+       for {
+               select {
+               case <-s.stop:
+                       s.log.Debugln("Stop monitorSessMap")
+                       return
+               case <-time.After(sessionMonitorTime * time.Second):
+                       s.log.Debugf("Sessions Map size: %d", len(s.sessMap))
+                       if dbgFullTrace {
+                               s.log.Debugf("Sessions Map : %v", s.sessMap)
+                       }
+
+                       if len(s.sessMap) > maxSessions {
+                               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)
+                                       delete(s.sessMap, ss.ID)
+                               }
+                       }
+                       s.mutex.Unlock()
+               }
+       }
+}
diff --git a/lib/syncthing/st.go b/lib/syncthing/st.go
new file mode 100644 (file)
index 0000000..7d07b70
--- /dev/null
@@ -0,0 +1,76 @@
+package st
+
+import (
+       "encoding/json"
+
+       "strings"
+
+       "fmt"
+
+       "github.com/Sirupsen/logrus"
+       "github.com/iotbzh/xds-server/lib/common"
+       "github.com/syncthing/syncthing/lib/config"
+)
+
+// SyncThing .
+type SyncThing struct {
+       BaseURL string
+       client  *common.HTTPClient
+       log     *logrus.Logger
+}
+
+// NewSyncThing creates a new instance of Syncthing
+func NewSyncThing(url string, apikey string, log *logrus.Logger) *SyncThing {
+       cl, err := common.HTTPNewClient(url,
+               common.HTTPClientConfig{
+                       URLPrefix:           "/rest",
+                       HeaderClientKeyName: "X-Syncthing-ID",
+               })
+       if err != nil {
+               msg := ": " + err.Error()
+               if strings.Contains(err.Error(), "connection refused") {
+                       msg = fmt.Sprintf("(url: %s)", url)
+               }
+               log.Debugf("ERROR: cannot connect to Syncthing %s", msg)
+               return nil
+       }
+
+       s := SyncThing{
+               BaseURL: url,
+               client:  cl,
+               log:     log,
+       }
+
+       return &s
+}
+
+// IDGet returns the Syncthing ID of Syncthing instance running locally
+func (s *SyncThing) IDGet() (string, error) {
+       var data []byte
+       if err := s.client.HTTPGet("system/status", &data); err != nil {
+               return "", err
+       }
+       status := make(map[string]interface{})
+       json.Unmarshal(data, &status)
+       return status["myID"].(string), nil
+}
+
+// ConfigGet returns the current Syncthing configuration
+func (s *SyncThing) ConfigGet() (config.Configuration, error) {
+       var data []byte
+       config := config.Configuration{}
+       if err := s.client.HTTPGet("system/config", &data); err != nil {
+               return config, err
+       }
+       err := json.Unmarshal(data, &config)
+       return config, err
+}
+
+// ConfigSet set Syncthing configuration
+func (s *SyncThing) ConfigSet(cfg config.Configuration) error {
+       body, err := json.Marshal(cfg)
+       if err != nil {
+               return err
+       }
+       return s.client.HTTPPost("system/config", string(body))
+}
diff --git a/lib/syncthing/stfolder.go b/lib/syncthing/stfolder.go
new file mode 100644 (file)
index 0000000..d79e579
--- /dev/null
@@ -0,0 +1,116 @@
+package st
+
+import (
+       "path/filepath"
+       "strings"
+
+       "github.com/syncthing/syncthing/lib/config"
+       "github.com/syncthing/syncthing/lib/protocol"
+)
+
+// FIXME remove and use an interface on xdsconfig.FolderConfig
+type FolderChangeArg struct {
+       ID           string
+       Label        string
+       RelativePath string
+       SyncThingID  string
+       ShareRootDir string
+}
+
+// FolderChange is called when configuration has changed
+func (s *SyncThing) FolderChange(f FolderChangeArg) error {
+
+       // Get current config
+       stCfg, err := s.ConfigGet()
+       if err != nil {
+               s.log.Errorln(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
+       }
+
+       newDevice := config.DeviceConfiguration{
+               DeviceID:  devID,
+               Name:      f.SyncThingID,
+               Addresses: []string{"dynamic"},
+       }
+
+       var found = false
+       for _, device := range stCfg.Devices {
+               if device.DeviceID == devID {
+                       found = true
+                       break
+               }
+       }
+       if !found {
+               stCfg.Devices = append(stCfg.Devices, newDevice)
+       }
+
+       // Add or update Folder settings
+       var label, id string
+       if label = f.Label; label == "" {
+               label = strings.Split(id, "/")[0]
+       }
+       if id = f.ID; id == "" {
+               id = f.SyncThingID[0:15] + "_" + label
+       }
+
+       folder := config.FolderConfiguration{
+               ID:      id,
+               Label:   label,
+               RawPath: filepath.Join(f.ShareRootDir, f.RelativePath),
+       }
+
+       folder.Devices = append(folder.Devices, config.FolderDeviceConfiguration{
+               DeviceID: newDevice.DeviceID,
+       })
+
+       found = false
+       var fld config.FolderConfiguration
+       for _, fld = range stCfg.Folders {
+               if folder.ID == fld.ID {
+                       fld = folder
+                       found = true
+                       break
+               }
+       }
+       if !found {
+               stCfg.Folders = append(stCfg.Folders, folder)
+               fld = stCfg.Folders[0]
+       }
+
+       err = s.ConfigSet(stCfg)
+       if err != nil {
+               s.log.Errorln(err)
+       }
+
+       return nil
+}
+
+// FolderDelete is called to delete a folder config
+func (s *SyncThing) FolderDelete(id string) error {
+       // Get current config
+       stCfg, err := s.ConfigGet()
+       if err != nil {
+               s.log.Errorln(err)
+               return err
+       }
+
+       for i, fld := range stCfg.Folders {
+               if id == fld.ID {
+                       stCfg.Folders = append(stCfg.Folders[:i], stCfg.Folders[i+1:]...)
+                       err = s.ConfigSet(stCfg)
+                       if err != nil {
+                               s.log.Errorln(err)
+                               return err
+                       }
+               }
+       }
+
+       return nil
+}
diff --git a/lib/xdsconfig/builderconfig.go b/lib/xdsconfig/builderconfig.go
new file mode 100644 (file)
index 0000000..c64fe9c
--- /dev/null
@@ -0,0 +1,50 @@
+package xdsconfig
+
+import (
+       "errors"
+       "net"
+)
+
+// BuilderConfig represents the builder container configuration
+type BuilderConfig struct {
+       IP          string `json:"ip"`
+       Port        string `json:"port"`
+       SyncThingID string `json:"syncThingID"`
+}
+
+// NewBuilderConfig creates a new BuilderConfig instance
+func NewBuilderConfig(stID string) (BuilderConfig, error) {
+       // Do we really need it ? may be not accessible from client side
+       ip, err := getLocalIP()
+       if err != nil {
+               return BuilderConfig{}, err
+       }
+
+       b := BuilderConfig{
+               IP:          ip, // TODO currently not used
+               Port:        "", // TODO currently not used
+               SyncThingID: stID,
+       }
+       return b, nil
+}
+
+// Copy makes a real copy of BuilderConfig
+func (c *BuilderConfig) Copy(n BuilderConfig) {
+       // TODO
+}
+
+func getLocalIP() (string, error) {
+       addrs, err := net.InterfaceAddrs()
+       if err != nil {
+               return "", err
+       }
+       for _, address := range addrs {
+               // check the address type and if it is not a loopback the display it
+               if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
+                       if ipnet.IP.To4() != nil {
+                               return ipnet.IP.String(), nil
+                       }
+               }
+       }
+       return "", errors.New("Cannot determined local IP")
+}
diff --git a/lib/xdsconfig/config.go b/lib/xdsconfig/config.go
new file mode 100644 (file)
index 0000000..df98439
--- /dev/null
@@ -0,0 +1,231 @@
+package xdsconfig
+
+import (
+       "fmt"
+       "strings"
+
+       "os"
+
+       "time"
+
+       "github.com/Sirupsen/logrus"
+       "github.com/codegangsta/cli"
+       "github.com/iotbzh/xds-server/lib/syncthing"
+)
+
+// Config parameters (json format) of /config command
+type Config struct {
+       Version       string        `json:"version"`
+       APIVersion    string        `json:"apiVersion"`
+       VersionGitTag string        `json:"gitTag"`
+       Builder       BuilderConfig `json:"builder"`
+       Folders       FoldersConfig `json:"folders"`
+
+       // Private / un-exported fields
+       progName     string
+       fileConf     FileConfig
+       WebAppDir    string         `json:"-"`
+       HTTPPort     string         `json:"-"`
+       ShareRootDir string         `json:"-"`
+       Log          *logrus.Logger `json:"-"`
+       SThg         *st.SyncThing  `json:"-"`
+}
+
+// Config default values
+const (
+       DefaultAPIVersion = "1"
+       DefaultPort       = "8000"
+       DefaultShareDir   = "/mnt/share"
+       DefaultLogLevel   = "error"
+)
+
+// Init loads the configuration on start-up
+func Init(ctx *cli.Context) (Config, error) {
+       var err error
+
+       // Set logger level and formatter
+       log := ctx.App.Metadata["logger"].(*logrus.Logger)
+
+       logLevel := ctx.GlobalString("log")
+       if logLevel == "" {
+               logLevel = DefaultLogLevel
+       }
+       if log.Level, err = logrus.ParseLevel(logLevel); err != nil {
+               fmt.Printf("Invalid log level : \"%v\"\n", logLevel)
+               os.Exit(1)
+       }
+       log.Formatter = &logrus.TextFormatter{}
+
+       // Define default configuration
+       c := Config{
+               Version:       ctx.App.Metadata["version"].(string),
+               APIVersion:    DefaultAPIVersion,
+               VersionGitTag: ctx.App.Metadata["git-tag"].(string),
+               Builder:       BuilderConfig{},
+               Folders:       FoldersConfig{},
+
+               progName:     ctx.App.Name,
+               WebAppDir:    "webapp/dist",
+               HTTPPort:     DefaultPort,
+               ShareRootDir: DefaultShareDir,
+               Log:          log,
+               SThg:         nil,
+       }
+
+       // config file settings overwrite default config
+       err = updateConfigFromFile(&c, ctx.GlobalString("config"))
+       if err != nil {
+               return Config{}, err
+       }
+
+       // Update location of shared dir if needed
+       if !dirExists(c.ShareRootDir) {
+               if err := os.MkdirAll(c.ShareRootDir, 0770); err != nil {
+                       c.Log.Fatalf("No valid shared directory found (err=%v)", err)
+               }
+       }
+       c.Log.Infoln("Share root directory: ", c.ShareRootDir)
+
+       // FIXME - add a builder interface and support other builder type (eg. native)
+       builderType := "syncthing"
+
+       switch builderType {
+       case "syncthing":
+               // Syncthing settings only configurable from config.json file
+               stGuiAddr := c.fileConf.SThgConf.GuiAddress
+               stGuiApikey := c.fileConf.SThgConf.GuiAPIKey
+               if stGuiAddr == "" {
+                       stGuiAddr = "http://localhost:8384"
+               }
+               if stGuiAddr[0:7] != "http://" {
+                       stGuiAddr = "http://" + stGuiAddr
+               }
+
+               // Retry if connection fail
+               retry := 5
+               for retry > 0 {
+                       c.SThg = st.NewSyncThing(stGuiAddr, stGuiApikey, c.Log)
+                       if c.SThg != nil {
+                               break
+                       }
+                       c.Log.Warningf("Establishing connection to Syncthing (retry %d/5)", retry)
+                       time.Sleep(time.Second)
+                       retry--
+               }
+               if c.SThg == nil {
+                       c.Log.Fatalf("ERROR: cannot connect to Syncthing (url: %s)", stGuiAddr)
+               }
+
+               // Retrieve Syncthing config
+               id, err := c.SThg.IDGet()
+               if err != nil {
+                       return Config{}, err
+               }
+
+               if c.Builder, err = NewBuilderConfig(id); err != nil {
+                       c.Log.Fatalln(err)
+               }
+
+               // Retrieve initial Syncthing config
+               stCfg, err := c.SThg.ConfigGet()
+               if err != nil {
+                       return Config{}, err
+               }
+               for _, stFld := range stCfg.Folders {
+                       relativePath := strings.TrimPrefix(stFld.RawPath, c.ShareRootDir)
+                       if relativePath == "" {
+                               relativePath = stFld.RawPath
+                       }
+                       newFld := NewFolderConfig(stFld.ID, stFld.Label, c.ShareRootDir, strings.Trim(relativePath, "/"))
+                       c.Folders = c.Folders.Update(FoldersConfig{newFld})
+               }
+
+       default:
+               log.Fatalln("Unsupported builder type")
+       }
+
+       return c, nil
+}
+
+// GetFolderFromID retrieves the Folder config from id
+func (c *Config) GetFolderFromID(id string) *FolderConfig {
+       if idx := c.Folders.GetIdx(id); idx != -1 {
+               return &c.Folders[idx]
+       }
+       return nil
+}
+
+// UpdateAll updates all the current configuration
+func (c *Config) UpdateAll(newCfg Config) error {
+       return fmt.Errorf("Not Supported")
+       /*
+               if err := VerifyConfig(newCfg); err != nil {
+                       return err
+               }
+
+               // TODO: c.Builder = c.Builder.Update(newCfg.Builder)
+               c.Folders = c.Folders.Update(newCfg.Folders)
+
+               // SEB A SUP model.NotifyListeners(c, NotifyFoldersChange, FolderConfig{})
+               // FIXME To be tested & improved error handling
+               for _, f := range c.Folders {
+                       if err := c.SThg.FolderChange(st.FolderChangeArg{
+                               ID:           f.ID,
+                               Label:        f.Label,
+                               RelativePath: f.RelativePath,
+                               SyncThingID:  f.SyncThingID,
+                               ShareRootDir: c.ShareRootDir,
+                       }); err != nil {
+                               return err
+                       }
+               }
+
+               return nil
+       */
+}
+
+// UpdateFolder updates a specific folder into the current configuration
+func (c *Config) UpdateFolder(newFolder FolderConfig) (FolderConfig, error) {
+       if err := FolderVerify(newFolder); err != nil {
+               return FolderConfig{}, err
+       }
+
+       c.Folders = c.Folders.Update(FoldersConfig{newFolder})
+
+       // SEB A SUP model.NotifyListeners(c, NotifyFolderAdd, newFolder)
+       err := c.SThg.FolderChange(st.FolderChangeArg{
+               ID:           newFolder.ID,
+               Label:        newFolder.Label,
+               RelativePath: newFolder.RelativePath,
+               SyncThingID:  newFolder.SyncThingID,
+               ShareRootDir: c.ShareRootDir,
+       })
+
+       newFolder.BuilderSThgID = c.Builder.SyncThingID // FIXME - should be removed after local ST config rework
+       newFolder.Status = FolderStatusEnable
+
+       return newFolder, err
+}
+
+// DeleteFolder deletes a specific folder
+func (c *Config) DeleteFolder(id string) (FolderConfig, error) {
+       var fld FolderConfig
+       var err error
+
+       //SEB A SUP model.NotifyListeners(c, NotifyFolderDelete, fld)
+       if err = c.SThg.FolderDelete(id); err != nil {
+               return fld, err
+       }
+
+       c.Folders, fld, err = c.Folders.Delete(id)
+
+       return fld, err
+}
+
+func dirExists(path string) bool {
+       _, err := os.Stat(path)
+       if os.IsNotExist(err) {
+               return false
+       }
+       return true
+}
diff --git a/lib/xdsconfig/fileconfig.go b/lib/xdsconfig/fileconfig.go
new file mode 100644 (file)
index 0000000..262d023
--- /dev/null
@@ -0,0 +1,133 @@
+package xdsconfig
+
+import (
+       "encoding/json"
+       "os"
+       "os/user"
+       "path"
+       "path/filepath"
+       "regexp"
+       "strings"
+)
+
+type SyncThingConf struct {
+       Home       string `json:"home"`
+       GuiAddress string `json:"gui-address"`
+       GuiAPIKey  string `json:"gui-apikey"`
+}
+
+type FileConfig struct {
+       WebAppDir    string        `json:"webAppDir"`
+       ShareRootDir string        `json:"shareRootDir"`
+       HTTPPort     string        `json:"httpPort"`
+       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/config.json file
+//  3/ <xds-server executable dir>/config.json file
+
+func updateConfigFromFile(c *Config, confFile string) 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", "config.json"))
+       }
+       cwd, err := os.Getwd()
+       if err == nil {
+               searchIn = append(searchIn, path.Join(cwd, "config.json"))
+       }
+       exePath, err := filepath.Abs(filepath.Dir(os.Args[0]))
+       if err == nil {
+               searchIn = append(searchIn, path.Join(exePath, "config.json"))
+       }
+
+       var cFile *string
+       for _, p := range searchIn {
+               if _, err := os.Stat(p); err == nil {
+                       cFile = &p
+                       break
+               }
+       }
+       if cFile == nil {
+               // No config file found
+               return nil
+       }
+
+       // 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()
+       fCfg := FileConfig{}
+       if err := json.NewDecoder(fd).Decode(&fCfg); err != nil {
+               return err
+       }
+       c.fileConf = fCfg
+
+       // Support environment variables (IOW ${MY_ENV_VAR} syntax) in config.json
+       // TODO: better to use reflect package to iterate on fields and be more generic
+       fCfg.WebAppDir = path.Clean(resolveEnvVar(fCfg.WebAppDir))
+       fCfg.ShareRootDir = path.Clean(resolveEnvVar(fCfg.ShareRootDir))
+       fCfg.SThgConf.Home = path.Clean(resolveEnvVar(fCfg.SThgConf.Home))
+
+       // Config file settings overwrite default config
+
+       if fCfg.WebAppDir != "" {
+               c.WebAppDir = strings.Trim(fCfg.WebAppDir, " ")
+       }
+       // Is it a full path ?
+       if !strings.HasPrefix(c.WebAppDir, "/") && exePath != "" {
+               // Check first from current directory
+               for _, rootD := range []string{cwd, exePath} {
+                       ff := path.Join(rootD, c.WebAppDir, "index.html")
+                       if exists(ff) {
+                               c.WebAppDir = path.Join(rootD, c.WebAppDir)
+                               break
+                       }
+               }
+       }
+
+       if fCfg.ShareRootDir != "" {
+               c.ShareRootDir = fCfg.ShareRootDir
+       }
+
+       if fCfg.HTTPPort != "" {
+               c.HTTPPort = fCfg.HTTPPort
+       }
+
+       return nil
+}
+
+// resolveEnvVar Resolved environment variable regarding the syntax ${MYVAR}
+func resolveEnvVar(s string) string {
+       re := regexp.MustCompile("\\${(.*)}")
+       vars := re.FindAllStringSubmatch(s, -1)
+       res := s
+       for _, v := range vars {
+               val := os.Getenv(v[1])
+               if val != "" {
+                       rer := regexp.MustCompile("\\${" + v[1] + "}")
+                       res = rer.ReplaceAllString(res, val)
+               }
+       }
+       return res
+}
+
+// exists returns whether the given file or directory exists or not
+func exists(path string) bool {
+       _, err := os.Stat(path)
+       if err == nil {
+               return true
+       }
+       if os.IsNotExist(err) {
+               return false
+       }
+       return true
+}
diff --git a/lib/xdsconfig/folderconfig.go b/lib/xdsconfig/folderconfig.go
new file mode 100644 (file)
index 0000000..e8bff4f
--- /dev/null
@@ -0,0 +1,79 @@
+package xdsconfig
+
+import (
+       "fmt"
+       "log"
+       "path/filepath"
+)
+
+// FolderType constances
+const (
+       FolderTypeDocker           = 0
+       FolderTypeWindowsSubsystem = 1
+       FolderTypeCloudSync        = 2
+
+       FolderStatusErrorConfig = "ErrorConfig"
+       FolderStatusDisable     = "Disable"
+       FolderStatusEnable      = "Enable"
+)
+
+// FolderType is the type of sharing folder
+type FolderType int
+
+// FolderConfig is the config for one folder
+type FolderConfig struct {
+       ID            string     `json:"id" binding:"required"`
+       Label         string     `json:"label"`
+       RelativePath  string     `json:"path"`
+       Type          FolderType `json:"type"`
+       SyncThingID   string     `json:"syncThingID"`
+       BuilderSThgID string     `json:"builderSThgID"`
+       Status        string     `json:"status"`
+
+       // Private fields
+       rootPath string
+}
+
+// NewFolderConfig creates a new folder object
+func NewFolderConfig(id, label, rootDir, path string) FolderConfig {
+       return FolderConfig{
+               ID:           id,
+               Label:        label,
+               RelativePath: path,
+               Type:         FolderTypeCloudSync,
+               SyncThingID:  "",
+               Status:       FolderStatusDisable,
+               rootPath:     rootDir,
+       }
+}
+
+// GetFullPath returns the full path
+func (c *FolderConfig) GetFullPath(dir string) string {
+       if &dir == nil {
+               dir = ""
+       }
+       if filepath.IsAbs(dir) {
+               return filepath.Join(c.rootPath, dir)
+       }
+       return filepath.Join(c.rootPath, c.RelativePath, dir)
+}
+
+// FolderVerify is called to verify that a configuration is valid
+func FolderVerify(fCfg FolderConfig) error {
+       var err error
+
+       if fCfg.Type != FolderTypeCloudSync {
+               err = fmt.Errorf("Unsupported folder type")
+       }
+
+       if fCfg.SyncThingID == "" {
+               err = fmt.Errorf("device id not set (SyncThingID field)")
+       }
+
+       if err != nil {
+               fCfg.Status = FolderStatusErrorConfig
+               log.Printf("ERROR FolderVerify: %v\n", err)
+       }
+
+       return err
+}
diff --git a/lib/xdsconfig/foldersconfig.go b/lib/xdsconfig/foldersconfig.go
new file mode 100644 (file)
index 0000000..4ad16df
--- /dev/null
@@ -0,0 +1,47 @@
+package xdsconfig
+
+import (
+       "fmt"
+)
+
+// FoldersConfig contains all the folder configurations
+type FoldersConfig []FolderConfig
+
+// GetIdx returns the index of the folder matching id in FoldersConfig array
+func (c FoldersConfig) GetIdx(id string) int {
+       for i := range c {
+               if id == c[i].ID {
+                       return i
+               }
+       }
+       return -1
+}
+
+// Update is used to fully update or add a new FolderConfig
+func (c FoldersConfig) Update(newCfg FoldersConfig) FoldersConfig {
+       for i := range newCfg {
+               found := false
+               for j := range c {
+                       if newCfg[i].ID == c[j].ID {
+                               c[j] = newCfg[i]
+                               found = true
+                               break
+                       }
+               }
+               if !found {
+                       c = append(c, newCfg[i])
+               }
+       }
+       return c
+}
+
+// Delete is used to delete a folder matching id in FoldersConfig array
+func (c FoldersConfig) Delete(id string) (FoldersConfig, FolderConfig, error) {
+       if idx := c.GetIdx(id); idx != -1 {
+               f := c[idx]
+               c = append(c[:idx], c[idx+1:]...)
+               return c, f, nil
+       }
+
+       return c, FolderConfig{}, fmt.Errorf("invalid id")
+}
diff --git a/lib/xdsserver/server.go b/lib/xdsserver/server.go
new file mode 100644 (file)
index 0000000..90d0f38
--- /dev/null
@@ -0,0 +1,189 @@
+package xdsserver
+
+import (
+       "net/http"
+       "os"
+
+       "path"
+
+       "github.com/Sirupsen/logrus"
+       "github.com/gin-contrib/static"
+       "github.com/gin-gonic/gin"
+       "github.com/googollee/go-socket.io"
+       "github.com/iotbzh/xds-server/lib/apiv1"
+       "github.com/iotbzh/xds-server/lib/session"
+       "github.com/iotbzh/xds-server/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"
+
+// NewServer creates an instance of ServerService
+func NewServer(cfg xdsconfig.Config) *ServerService {
+
+       // Setup logging for gin router
+       if cfg.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:       cfg,
+               log:       cfg.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.middlewareXDSDetails())
+       s.router.Use(s.middlewareCORS())
+
+       // Sessions manager
+       s.sessions = session.NewClientSessions(s.router, s.log, cookieMaxAge)
+
+       // Create REST API
+       s.api = apiv1.New(s.sessions, s.cfg, 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)
+       */
+
+       // Web Application (serve on / )
+       idxFile := path.Join(s.cfg.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.cfg.WebAppDir)
+       s.router.Use(static.Serve("/", static.LocalFile(s.cfg.WebAppDir, true)))
+       s.webApp = s.router.Group("/", s.serveIndexFile)
+       {
+               s.webApp.GET("/")
+       }
+
+       // Serve in the background
+       serveError := make(chan error, 1)
+       go func() {
+               serveError <- http.ListenAndServe(":"+s.cfg.HTTPPort, s.router)
+       }()
+
+       // 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)
+}
+
+// serveIndexFile provides initial file (eg. index.html) of webapp
+func (s *ServerService) serveIndexFile(c *gin.Context) {
+       c.HTML(200, indexFilename, gin.H{})
+}
+
+// Add details in Header
+func (s *ServerService) middlewareXDSDetails() gin.HandlerFunc {
+       return func(c *gin.Context) {
+               c.Header("XDS-Version", s.cfg.Version)
+               c.Header("XDS-API-Version", s.cfg.APIVersion)
+               c.Next()
+       }
+}
+
+// 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")
+                       c.Header("Access-Control-Allow-Methods", "POST, DELETE, GET, PUT")
+                       c.Header("Content-Type", "application/json")
+                       c.Header("Access-Control-Max-Age", cookieMaxAge)
+                       c.AbortWithStatus(204)
+                       return
+               }
+
+               c.Next()
+       }
+}
+
+// socketHandler is the handler for the "main" websocket connection
+func (s *ServerService) socketHandler(c *gin.Context) {
+
+       // Retrieve user session
+       sess := s.sessions.Get(c)
+       if sess == nil {
+               c.JSON(500, gin.H{"error": "Cannot retrieve session"})
+               return
+       }
+
+       s.sIOServer.On("connection", func(so socketio.Socket) {
+               s.log.Debugf("WS Connected (SID=%v)", so.Id())
+               s.sessions.UpdateIOSocket(sess.ID, &so)
+
+               so.On("disconnection", func() {
+                       s.log.Debugf("WS disconnected (SID=%v)", so.Id())
+                       s.sessions.UpdateIOSocket(sess.ID, nil)
+               })
+       })
+
+       s.sIOServer.On("error", func(so socketio.Socket, err error) {
+               s.log.Errorf("WS SID=%v Error : %v", so.Id(), err.Error())
+       })
+
+       s.sIOServer.ServeHTTP(c.Writer, c.Request)
+}
diff --git a/main.go b/main.go
new file mode 100644 (file)
index 0000000..6561785
--- /dev/null
+++ b/main.go
@@ -0,0 +1,87 @@
+// TODO add Doc
+//
+package main
+
+import (
+       "log"
+       "os"
+
+       "github.com/Sirupsen/logrus"
+       "github.com/codegangsta/cli"
+       "github.com/iotbzh/xds-server/lib/xdsconfig"
+       "github.com/iotbzh/xds-server/lib/xdsserver"
+)
+
+const (
+       appName        = "xds-server"
+       appDescription = "X(cross) Development System Server is a web server that allows to remotely cross build applications."
+       appVersion     = "0.0.1"
+       appCopyright   = "Apache-2.0"
+       appUsage       = "X(cross) Development System Server"
+)
+
+var appAuthors = []cli.Author{
+       cli.Author{Name: "Sebastien Douheret", Email: "sebastien@iot.bzh"},
+}
+
+// AppVersionGitTag is the git tag id added to version string
+// Should be set by compilation -ldflags "-X main.AppVersionGitTag=xxx"
+var AppVersionGitTag = "unknown-dev"
+
+// Web server main routine
+func webServer(ctx *cli.Context) error {
+
+       // Init config
+       cfg, err := xdsconfig.Init(ctx)
+       if err != nil {
+               return cli.NewExitError(err, 2)
+       }
+
+       // Create and start Web Server
+       svr := xdsserver.NewServer(cfg)
+       if err = svr.Serve(); err != nil {
+               log.Println(err)
+               return cli.NewExitError(err, 3)
+       }
+
+       return cli.NewExitError("Program exited ", 4)
+}
+
+// main
+func main() {
+
+       // Create a new instance of the logger
+       log := logrus.New()
+
+       // Create a new App instance
+       app := cli.NewApp()
+       app.Name = appName
+       app.Description = appDescription
+       app.Usage = appUsage
+       app.Version = appVersion + " (" + AppVersionGitTag + ")"
+       app.Authors = appAuthors
+       app.Copyright = appCopyright
+       app.Metadata = make(map[string]interface{})
+       app.Metadata["version"] = appVersion
+       app.Metadata["git-tag"] = AppVersionGitTag
+       app.Metadata["logger"] = log
+
+       app.Flags = []cli.Flag{
+               cli.StringFlag{
+                       Name:   "config, c",
+                       Usage:  "JSON config file to use\n\t",
+                       EnvVar: "APP_CONFIG",
+               },
+               cli.StringFlag{
+                       Name:   "log, l",
+                       Value:  "error",
+                       Usage:  "logging level (supported levels: panic, fatal, error, warn, info, debug)\n\t",
+                       EnvVar: "LOG_LEVEL",
+               },
+       }
+
+       // only one action: Web Server
+       app.Action = webServer
+
+       app.Run(os.Args)
+}
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-graphx.jpg b/webapp/assets/images/iot-graphx.jpg
new file mode 100644 (file)
index 0000000..6a2c428
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..0de52f9
--- /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/**/*.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-server'
+    }
+}
\ No newline at end of file
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..ecc6a78
--- /dev/null
@@ -0,0 +1,62 @@
+{
+  "name": "xds-server",
+  "version": "1.0.0",
+  "description": "XDS (Cross Development System) Server",
+  "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/xds-server"
+  },
+  "author": "Sebastien Douheret [IoT.bzh]",
+  "license": "Apache-2.0",
+  "bugs": {
+    "url": "https://github.com/xds-server/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",
+    "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..e9d7629
--- /dev/null
@@ -0,0 +1,30 @@
+import { Component } from '@angular/core';
+import { Observable } from 'rxjs';
+
+import {AlertService, IAlert} from '../common/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)">
+                <span [innerHtml]="alert.msg"></span>
+            </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..0ec4936
--- /dev/null
@@ -0,0 +1,17 @@
+.navbar-inverse  {
+    background-color: #330066;
+}
+
+.navbar-brand {
+    background: #330066;
+    color: white;
+    font-size: x-large;
+}
+
+.navbar-nav ul li a {
+    color: #fff;
+}
+
+.menu-text {
+    color: #fff;
+}
diff --git a/webapp/src/app/app.component.html b/webapp/src/app/app.component.html
new file mode 100644 (file)
index 0000000..ab792be
--- /dev/null
@@ -0,0 +1,21 @@
+<nav class="navbar navbar-fixed-top navbar-inverse">
+    <div class="container-fluid">
+        <div class="navbar-header">
+            <a class="navbar-brand" href="#">Cross Development System Dashboard</a>
+        </div>
+
+        <div class="navbar-collapse collapse menu2">
+            <ul class="nav navbar-nav navbar-right">
+                <li><a routerLink="/build"><i class="fa fa-2x fa-play-circle" title="Open build page"></i></a></li>
+                <li><a routerLink="/config"><i class="fa fa-2x fa-cog" title="Open configuration page"></i></a></li>
+                <li><a routerLink="/home"><i class="fa fa-2x fa-home" title="Back to home page"></i></a></li>
+            </ul>
+        </div>
+    </div>
+</nav>
+
+<app-alert id="alert"></app-alert>
+
+<div style="margin:10px;">
+    <router-outlet></router-outlet>
+</div>
\ No newline at end of file
diff --git a/webapp/src/app/app.component.ts b/webapp/src/app/app.component.ts
new file mode 100644 (file)
index 0000000..d0f9c6e
--- /dev/null
@@ -0,0 +1,34 @@
+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 {
+    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..5c33e43
--- /dev/null
@@ -0,0 +1,69 @@
+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 { 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 { ProjectCardComponent } from "./projects/projectCard.component";
+import { ProjectReadableTypePipe } from "./projects/projectCard.component";
+import { ProjectsListAccordionComponent } from "./projects/projectsListAccordion.component";
+import { HomeComponent } from "./home/home.component";
+import { BuildComponent } from "./build/build.component";
+import { XDSServerService } from "./common/xdsserver.service";
+import { SyncthingService } from "./common/syncthing.service";
+import { ConfigService } from "./common/config.service";
+import { AlertService } from './common/alert.service';
+
+
+
+@NgModule({
+    imports: [
+        BrowserModule,
+        HttpModule,
+        FormsModule,
+        ReactiveFormsModule,
+        Routing,
+        CookieModule.forRoot(),
+        AlertModule.forRoot(),
+        ModalModule.forRoot(),
+        AccordionModule.forRoot(),
+        CarouselModule.forRoot(),
+        BsDropdownModule.forRoot(),
+    ],
+    declarations: [
+        AppComponent,
+        AlertComponent,
+        HomeComponent,
+        BuildComponent,
+        ConfigComponent,
+        ProjectCardComponent,
+        ProjectReadableTypePipe,
+        ProjectsListAccordionComponent,
+    ],
+    providers: [
+        AppRoutingProviders,
+        {
+            provide: Window,
+            useValue: window
+        },
+        XDSServerService,
+        ConfigService,
+        SyncthingService,
+        AlertService
+    ],
+    bootstrap: [AppComponent]
+})
+export class AppModule {
+}
\ No newline at end of file
diff --git a/webapp/src/app/app.routing.ts b/webapp/src/app/app.routing.ts
new file mode 100644 (file)
index 0000000..747727c
--- /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 {BuildComponent} from "./build/build.component";
+
+
+const appRoutes: Routes = [
+    {path: '', redirectTo: 'home', pathMatch: 'full'},
+
+    {path: 'config', component: ConfigComponent, data: {title: 'Config'}},
+    {path: 'home', component: HomeComponent, data: {title: 'Home'}},
+    {path: 'build', component: BuildComponent, data: {title: 'Build'}}
+];
+
+export const AppRoutingProviders: any[] = [];
+export const Routing: ModuleWithProviders = RouterModule.forRoot(appRoutes, {
+    useHash: true
+});
diff --git a/webapp/src/app/build/build.component.css b/webapp/src/app/build/build.component.css
new file mode 100644 (file)
index 0000000..5bfc898
--- /dev/null
@@ -0,0 +1,10 @@
+.vcenter {
+    display: inline-block;
+    vertical-align: middle;
+}
+
+.blocks .btn-primary {
+    margin-left: 5px;
+    margin-right: 5px;
+    border-radius: 4px !important;
+}
\ No newline at end of file
diff --git a/webapp/src/app/build/build.component.html b/webapp/src/app/build/build.component.html
new file mode 100644 (file)
index 0000000..d2a8da6
--- /dev/null
@@ -0,0 +1,50 @@
+<form [formGroup]="buildForm">
+    <div class="row">
+        <div class="col-xs-6">
+            <label>Project </label>
+            <div class="btn-group" dropdown *ngIf="curProject">
+                <button dropdownToggle type="button" class="btn btn-primary dropdown-toggle" style="width: 14em;">
+                    {{curProject.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 (config$ | async)?.projects" (click)="curProject=prj">
+                        {{prj.label}}</a>
+                    </li>
+                </ul>
+            </div>
+        </div>
+        <div class="col-xs-6" style="padding-right: 3em;">
+            <div class="btn-group blocks pull-right">
+                <button class="btn btn-primary " (click)="make() " [disabled]="!confValid ">Build</button>
+                <button class="btn btn-primary " (click)="make('clean') " [disabled]="!confValid ">Clean</button>
+            </div>
+        </div>
+    </div>
+    &nbsp;
+    <div class="row ">
+        <div class="col-xs-8 pull-left ">
+            <label>Sub-directory</label>
+            <input type="text" style="width:70%;" formControlName="subpath">
+        </div>
+    </div>
+</form>
+
+<div style="margin-left: 2em; margin-right: 2em; ">
+    <div class="row ">
+        <div class="col-xs-12 ">
+            <button class="btn btn-link pull-right " (click)="reset() "><span class="fa fa-eraser " style="font-size:20px; "></span></button>
+        </div>
+    </div>
+
+    <div class="row ">
+        <div class="col-xs-12 text-center ">
+            <textarea rows="30 " style="width:100%; overflow-y: scroll; " #scrollOutput>{{ cmdOutput }}</textarea>
+        </div>
+    </div>
+
+    <div class="row ">
+        <div class="col-xs-12 ">
+            {{ cmdInfo }}
+        </div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/webapp/src/app/build/build.component.ts b/webapp/src/app/build/build.component.ts
new file mode 100644 (file)
index 0000000..e1076c5
--- /dev/null
@@ -0,0 +1,120 @@
+import { Component, AfterViewChecked, ElementRef, ViewChild, OnInit } from '@angular/core';
+import { Observable } from 'rxjs';
+import { FormControl, FormGroup, Validators, FormBuilder } from '@angular/forms';
+
+import 'rxjs/add/operator/scan';
+import 'rxjs/add/operator/startWith';
+
+import { XDSServerService, ICmdOutput } from "../common/xdsserver.service";
+import { ConfigService, IConfig, IProject } from "../common/config.service";
+import { AlertService, IAlert } from "../common/alert.service";
+
+@Component({
+    selector: 'build',
+    moduleId: module.id,
+    templateUrl: './build.component.html',
+    styleUrls: ['./build.component.css']
+})
+
+export class BuildComponent implements OnInit, AfterViewChecked {
+    @ViewChild('scrollOutput') private scrollContainer: ElementRef;
+
+    config$: Observable<IConfig>;
+
+    buildForm: FormGroup;
+    subpathCtrl = new FormControl("", Validators.required);
+
+    public cmdOutput: string;
+    public confValid: boolean;
+    public curProject: IProject;
+    public cmdInfo: string;
+
+    private startTime: Map<string, number> = new Map<string, number>();
+
+    // I initialize the app component.
+    constructor(private configSvr: ConfigService, private sdkSvr: XDSServerService,
+        private fb: FormBuilder, private alertSvr: AlertService
+    ) {
+        this.cmdOutput = "";
+        this.confValid = false;
+        this.cmdInfo = "";      // TODO: to be remove (only for debug)
+        this.buildForm = fb.group({ subpath: this.subpathCtrl });
+    }
+
+    ngOnInit() {
+        this.config$ = this.configSvr.conf;
+        this.config$.subscribe((cfg) => {
+            this.curProject = cfg.projects[0];
+
+            this.confValid = (cfg.projects.length && this.curProject.id != null);
+        });
+
+        // Command output data tunneling
+        this.sdkSvr.CmdOutput$.subscribe(data => {
+            this.cmdOutput += data.stdout + "\n";
+        });
+
+        // Command exit
+        this.sdkSvr.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();
+    }
+
+    ngAfterViewChecked() {
+        this._scrollToBottom();
+    }
+
+    reset() {
+        this.cmdOutput = '';
+    }
+
+    make(args: string) {
+        let prjID = this.curProject.id;
+
+        this.cmdOutput += this._outputHeader();
+
+        let t0 = performance.now();
+        this.cmdInfo = 'Start build of ' + prjID + ' at ' + t0;
+
+        this.sdkSvr.make(prjID, this.buildForm.value.subpath, args)
+            .subscribe(res => {
+                this.startTime.set(String(res.cmdID), t0);
+            },
+            err => {
+                this.cmdInfo = 'Last command duration: ' + this._computeTime(t0);
+                this.alertSvr.add({ type: "danger", msg: '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";
+    }
+}
\ No newline at end of file
diff --git a/webapp/src/app/common/alert.service.ts b/webapp/src/app/common/alert.service.ts
new file mode 100644 (file)
index 0000000..710046f
--- /dev/null
@@ -0,0 +1,64 @@
+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) {
+        this.add({ type: "danger", msg: msg, dismissible: true });
+    }
+
+    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: "warning", 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/common/config.service.ts b/webapp/src/app/common/config.service.ts
new file mode 100644 (file)
index 0000000..67ee14c
--- /dev/null
@@ -0,0 +1,276 @@
+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 { XDSServerService, IXDSConfigProject } from "../common/xdsserver.service";
+import { SyncthingService, ISyncThingProject, ISyncThingStatus } from "../common/syncthing.service";
+import { AlertService, IAlert } from "../common/alert.service";
+
+export enum ProjectType {
+    NATIVE = 1,
+    SYNCTHING = 2
+}
+
+export interface INativeProject {
+    // TODO
+}
+
+export interface IProject {
+    id?: string;
+    label: string;
+    path: string;
+    type: ProjectType;
+    remotePrjDef?: INativeProject | ISyncThingProject;
+    localPrjDef?: any;
+    isExpanded?: boolean;
+    visible?: boolean;
+}
+
+export interface ILocalSTConfig {
+    ID: string;
+    URL: string;
+    retry: number;
+    tilde: string;
+}
+
+export interface IConfig {
+    xdsServerURL: string;
+    projectsRootDir: string;
+    projects: IProject[];
+    localSThg: ILocalSTConfig;
+}
+
+@Injectable()
+export class ConfigService {
+
+    public conf: Observable<IConfig>;
+
+    private confSubject: BehaviorSubject<IConfig>;
+    private confStore: IConfig;
+    private stConnectObs = null;
+
+    constructor(private _window: Window,
+        private cookie: CookieService,
+        private sdkSvr: XDSServerService,
+        private stSvr: SyncthingService,
+        private alert: AlertService,
+    ) {
+        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 = {
+                xdsServerURL: this._window.location.origin + '/api/v1',
+                projectsRootDir: "",
+                projects: [],
+                localSThg: {
+                    ID: null,
+                    URL: "http://localhost:8384",
+                    retry: 10,    // 10 seconds
+                    tilde: "",
+                }
+            };
+        }
+    }
+
+    // Save config into cookie
+    save() {
+        // Notify subscribers
+        this.confSubject.next(Object.assign({}, this.confStore));
+
+        // Don't save projects in cookies (too big!)
+        let cfg = this.confStore;
+        delete(cfg.projects);
+        this.cookie.putObject("xds-config", cfg);
+    }
+
+    loadProjects() {
+        // Remove previous subscriber if existing
+        if (this.stConnectObs) {
+            try {
+                this.stConnectObs.unsubscribe();
+            } catch (err) { }
+            this.stConnectObs = null;
+        }
+
+        // First 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.sdkSvr.getProjects().subscribe(remotePrj => {
+                this.stSvr.getProjects().subscribe(localPrj => {
+                    remotePrj.forEach(rPrj => {
+                        let lPrj = localPrj.filter(item => item.id === rPrj.id);
+                        if (lPrj.length > 0) {
+                            let pp: IProject = {
+                                id: rPrj.id,
+                                label: rPrj.label,
+                                path: rPrj.path,
+                                type: ProjectType.SYNCTHING,    // FIXME support other types
+                                remotePrjDef: Object.assign({}, rPrj),
+                                localPrjDef: Object.assign({}, lPrj[0]),
+                            };
+                            this.confStore.projects.push(pp);
+                        }
+                    });
+                    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 => this.alert.error(error));
+    }
+
+    set syncToolURL(url: string) {
+        this.confStore.localSThg.URL = url;
+        this.save();
+    }
+
+    set syncToolRetry(r: number) {
+        this.confStore.localSThg.retry = r;
+        this.save();
+    }
+
+    set projectsRootDir(p: string) {
+        if (p.charAt(0) === '~') {
+            p = this.confStore.localSThg.tilde + p.substring(1);
+        }
+        this.confStore.projectsRootDir = p;
+        this.save();
+    }
+
+    getLabelRootName(): string {
+        let id = this.confStore.localSThg.ID;
+        if (!id || id === "") {
+            return null;
+        }
+        return id.slice(0, 15);
+    }
+
+    addProject(prj: IProject) {
+        // Substitute tilde with to user home path
+        prj.path = prj.path.trim();
+        if (prj.path.charAt(0) === '~') {
+            prj.path = this.confStore.localSThg.tilde + prj.path.substring(1);
+
+            // Must be a full path (on Linux or Windows)
+        } else if (!((prj.path.charAt(0) === '/') ||
+            (prj.path.charAt(1) === ':' && (prj.path.charAt(2) === '\\' || prj.path.charAt(2) === '/')))) {
+            prj.path = this.confStore.projectsRootDir + '/' + prj.path;
+        }
+
+        if (prj.id == null) {
+            // FIXME - must be done on server side
+            let prefix = this.getLabelRootName() || new Date().toISOString();
+            let splath = prj.path.split('/');
+            prj.id = prefix + "_" + splath[splath.length - 1];
+        }
+
+        if (this._getProjectIdx(prj.id) !== -1) {
+            this.alert.warning("Project already exist (id=" + prj.id + ")", true);
+            return;
+        }
+
+        // TODO - support others project types
+        if (prj.type !== ProjectType.SYNCTHING) {
+            this.alert.error('Project type not supported yet (type: ' + prj.type + ')');
+            return;
+        }
+
+        let sdkPrj: IXDSConfigProject = {
+            id: prj.id,
+            label: prj.label,
+            path: prj.path,
+            hostSyncThingID: this.confStore.localSThg.ID,
+        };
+
+        // Send config to XDS server
+        let newPrj = prj;
+        this.sdkSvr.addProject(sdkPrj)
+            .subscribe(resStRemotePrj => {
+                newPrj.remotePrjDef = resStRemotePrj;
+
+                // FIXME REWORK local ST config
+                //  move logic to server side tunneling-back by WS
+
+                // Now setup local config
+                let stLocPrj: ISyncThingProject = {
+                    id: sdkPrj.id,
+                    label: sdkPrj.label,
+                    path: sdkPrj.path,
+                    remoteSyncThingID: resStRemotePrj.builderSThgID
+                };
+
+                // Set local Syncthing config
+                this.stSvr.addProject(stLocPrj)
+                    .subscribe(resStLocalPrj => {
+                        newPrj.localPrjDef = resStLocalPrj;
+
+                        // FIXME: maybe reduce subject to only .project
+                        //this.confSubject.next(Object.assign({}, this.confStore).project);
+                        this.confStore.projects.push(Object.assign({}, newPrj));
+                        this.confSubject.next(Object.assign({}, this.confStore));
+                    },
+                    err => {
+                        this.alert.error("Configuration local ERROR: " + err);
+                    });
+            },
+            err => {
+                this.alert.error("Configuration remote ERROR: " + err);
+            });
+    }
+
+    deleteProject(prj: IProject) {
+        let idx = this._getProjectIdx(prj.id);
+        if (idx === -1) {
+            throw new Error("Invalid project id (id=" + prj.id + ")");
+        }
+        this.sdkSvr.deleteProject(prj.id)
+            .subscribe(res => {
+                this.stSvr.deleteProject(prj.id)
+                    .subscribe(res => {
+                        this.confStore.projects.splice(idx, 1);
+                    }, err => {
+                        this.alert.error("Delete local ERROR: " + err);
+                    });
+            }, err => {
+                this.alert.error("Delete remote ERROR: " + err);
+            });
+    }
+
+    private _getProjectIdx(id: string): number {
+        return this.confStore.projects.findIndex((item) => item.id === id);
+    }
+
+}
\ No newline at end of file
diff --git a/webapp/src/app/common/syncthing.service.ts b/webapp/src/app/common/syncthing.service.ts
new file mode 100644 (file)
index 0000000..c8b0193
--- /dev/null
@@ -0,0 +1,342 @@
+import { Injectable } from '@angular/core';
+import { Http, Headers, RequestOptionsArgs, Response } from '@angular/http';
+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;
+    remoteSyncThingID: string;
+    label?: string;
+}
+
+export interface ISyncThingStatus {
+    ID: string;
+    baseURL: string;
+    connected: boolean;
+    tilde: string;
+    rawStatus: any;
+}
+
+// Private interfaces of Syncthing
+const ISTCONFIG_VERSION = 19;
+
+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";
+
+
+@Injectable()
+export class SyncthingService {
+
+    public Status$: Observable<ISyncThingStatus>;
+
+    private baseRestUrl: string;
+    private apikey: string;
+    private localSTID: string;
+    private stCurVersion: number;
+    private _status: ISyncThingStatus = {
+        ID: null,
+        baseURL: "",
+        connected: false,
+        tilde: "",
+        rawStatus: null,
+    };
+    private statusSubject = <BehaviorSubject<ISyncThingStatus>>new BehaviorSubject(this._status);
+
+    constructor(private http: Http, private _window: Window) {
+        this._status.baseURL = 'http://localhost:' + DEFAULT_GUI_PORT;
+        this.baseRestUrl = this._status.baseURL + '/rest';
+        this.apikey = DEFAULT_GUI_API_KEY;
+        this.stCurVersion = -1;
+
+        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;
+        return this.getStatus(retry);
+    }
+
+    getID(retry?: number): Observable<string> {
+        if (this._status.ID != null) {
+            return Observable.of(this._status.ID);
+        }
+        return this.getStatus(retry).map(sts => sts.ID);
+    }
+
+    getStatus(retry?: number): Observable<ISyncThingStatus> {
+
+        if (retry == null) {
+            retry = 3600;   // 1 hour
+        }
+        return this._get('/system/status')
+            .map((status) => {
+                this._status.ID = status["myID"];
+                this._status.tilde = status["tilde"];
+                this._status.connected = true;
+                console.debug('ST local ID', this._status.ID);
+
+                this._status.rawStatus = status;
+
+                return this._status;
+            })
+            .retryWhen((attempts) => {
+                let count = 0;
+                return attempts.flatMap(error => {
+                    if (++count >= retry) {
+                        return this._handleError(error);
+                    } else {
+                        return Observable.timer(count * 1000);
+                    }
+                });
+            });
+    }
+
+    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.remoteSyncThingID;
+
+                // 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 folder: ISTFolderConfiguration = {
+                    id: prj.id,
+                    label: label,
+                    path: prj.path,
+                    devices: [{ deviceID: newDevID, introducedBy: "" }],
+                    autoNormalize: true,
+                };
+
+                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> {
+        return this.http.get(this.baseRestUrl + '/system/version', this._attachAuthHeaders())
+            .map((r) => this._status.connected = true)
+            .repeatWhen
+            .catch((err) => {
+                this._status.connected = false;
+                throw new Error("Syncthing local daemon not responding (url=" + this._status.baseURL + ")");
+            });
+    }
+
+    private _getAPIVersion(): Observable<number> {
+        if (this.stCurVersion !== -1) {
+            return Observable.of(this.stCurVersion);
+        }
+
+        return this._checkAlive()
+            .flatMap(() => 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._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._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/common/xdsserver.service.ts b/webapp/src/app/common/xdsserver.service.ts
new file mode 100644 (file)
index 0000000..fd2e32a
--- /dev/null
@@ -0,0 +1,216 @@
+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 RxJs required methods
+import 'rxjs/add/operator/map';
+import 'rxjs/add/operator/catch';
+import 'rxjs/add/observable/throw';
+import 'rxjs/add/operator/mergeMap';
+
+
+export interface IXDSConfigProject {
+    id: string;
+    path: string;
+    hostSyncThingID: string;
+    label?: string;
+}
+
+interface IXDSBuilderConfig {
+    ip: string;
+    port: string;
+    syncThingID: string;
+}
+
+interface IXDSFolderConfig {
+    id: string;
+    label: string;
+    path: string;
+    type: number;
+    syncThingID: string;
+    builderSThgID?: string;
+    status?: string;
+}
+
+interface IXDSConfig {
+    version: number;
+    builder: IXDSBuilderConfig;
+    folders: IXDSFolderConfig[];
+}
+
+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 IServerStatus {
+    WS_connected: boolean;
+
+}
+
+const FOLDER_TYPE_CLOUDSYNC = 2;
+
+@Injectable()
+export class XDSServerService {
+
+    public CmdOutput$ = <Subject<ICmdOutput>>new Subject();
+    public CmdExit$ = <Subject<ICmdExit>>new Subject();
+    public Status$: Observable<IServerStatus>;
+
+    private baseUrl: string;
+    private wsUrl: string;
+    private _status = { WS_connected: false };
+    private statusSubject = <BehaviorSubject<IServerStatus>>new BehaviorSubject(this._status);
+
+
+    private socket: SocketIOClient.Socket;
+
+    constructor(private http: Http, private _window: Window, private alert: AlertService) {
+
+        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();
+        }
+    }
+
+    private _WSState(sts: boolean) {
+        this._status.WS_connected = sts;
+        this.statusSubject.next(Object.assign({}, this._status));
+    }
+
+    private _handleIoSocket() {
+        this.socket = io(this.wsUrl, { transports: ['websocket'] });
+
+        this.socket.on('connect_error', (res) => {
+            this._WSState(false);
+            console.error('WS Connect_error ', res);
+        });
+
+        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));
+        });
+
+    }
+
+    getProjects(): Observable<IXDSFolderConfig[]> {
+        return this._get('/folders');
+    }
+
+    addProject(cfg: IXDSConfigProject): Observable<IXDSFolderConfig> {
+        let folder: IXDSFolderConfig = {
+            id: cfg.id || null,
+            label: cfg.label || "",
+            path: cfg.path,
+            type: FOLDER_TYPE_CLOUDSYNC,
+            syncThingID: cfg.hostSyncThingID
+        };
+        return this._post('/folder', folder);
+    }
+
+    deleteProject(id: string): Observable<IXDSFolderConfig> {
+        return this._delete('/folder/' + id);
+    }
+
+    exec(cmd: string, args?: string[], options?: any): Observable<any> {
+        return this._post('/exec',
+            {
+                cmd: cmd,
+                args: args || []
+            });
+    }
+
+    make(prjID: string, dir: string, args: string): Observable<any> {
+        return this._post('/make', { id: prjID, rpath: dir, args: args });
+    }
+
+
+    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 (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.json().error || 'Server error';
+        }
+        return Observable.throw(e);
+    }
+}
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..f480857
--- /dev/null
@@ -0,0 +1,26 @@
+.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;
+}
\ No newline at end of file
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..45b0e14
--- /dev/null
@@ -0,0 +1,73 @@
+<div class="panel panel-default">
+    <div class="panel-heading clearfix">
+        <h2 class="panel-title pull-left">Global Configuration</h2>
+        <div class="pull-right">
+            <span class="fa fa-fw fa-exchange fa-size-x2" [style.color]="((severStatus$ | async)?.WS_connected)?'green':'red'"></span>
+        </div>
+    </div>
+    <div class="panel-body">
+        <div class="row">
+            <div class="col-xs-12">
+                <table class="table table-condensed">
+                    <tbody>
+                        <tr [ngClass]="{'info': (localSTStatus$ | async)?.connected, 'danger': !(localSTStatus$ | async)?.connected}">
+                            <th><label>Local Sync-tool URL</label></th>
+                            <td> <input type="text" [(ngModel)]="syncToolUrl"></td>
+                            <td>
+                                <button class="btn btn-link" (click)="syncToolRestartConn()"><span class="fa fa-refresh fa-size-x2"></span></button>
+                            </td>
+                        </tr>
+                        <tr class="info">
+                            <th><label>Local Sync-tool connection retry</label></th>
+                            <td> <input type="text" [(ngModel)]="syncToolRetry" (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">Projects Configuration</h2>
+    </div>
+    <div class="panel-body">
+        <form [formGroup]="addProjectForm" (ngSubmit)="onSubmit()">
+            <div class="row ">
+                <div class="col-xs-2">
+                    <button class="btn btn-primary" type="submit" [disabled]="!addProjectForm.valid"><i class="fa fa-plus"></i>&nbsp;New Folder</button>
+                </div>
+
+                <div class="col-xs-6">
+                    <label>Folder Path </label>
+                    <input type="text" style="width:70%;" formControlName="path" placeholder="myProject">
+                </div>
+                <div class="col-xs-4">
+                    <label>Label </label>
+                    <input type="text" formControlName="label" (keyup)="onKeyLabel($event)">
+                </div>
+            </div>
+        </form>
+
+        <div class="row col-xs-12">
+            <projects-list-accordion [projects]="(config$ | async).projects"></projects-list-accordion>
+        </div>
+    </div>
+</div>
+
+
+<!-- only for debug -->
+<div *ngIf="false" class="row">
+    {{config$ | async | json}}
+</div>
\ No newline at end of file
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..681c296
--- /dev/null
@@ -0,0 +1,123 @@
+import { Component, OnInit } from "@angular/core";
+import { Observable } from 'rxjs/Observable';
+import { FormControl, FormGroup, Validators, FormBuilder } from '@angular/forms';
+
+// Import RxJs required methods
+import 'rxjs/add/operator/map';
+import 'rxjs/add/operator/filter';
+import 'rxjs/add/operator/debounceTime';
+
+import { ConfigService, IConfig, IProject, ProjectType } from "../common/config.service";
+import { XDSServerService, IServerStatus } from "../common/xdsserver.service";
+import { SyncthingService, ISyncThingStatus } from "../common/syncthing.service";
+import { AlertService } from "../common/alert.service";
+
+@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 {
+
+    config$: Observable<IConfig>;
+    severStatus$: Observable<IServerStatus>;
+    localSTStatus$: Observable<ISyncThingStatus>;
+
+    curProj: number;
+    userEditedLabel: boolean = false;
+
+    // TODO replace by reactive FormControl + add validation
+    syncToolUrl: string;
+    syncToolRetry: string;
+    projectsRootDir: string;
+    showApplyBtn = {    // Used to show/hide Apply buttons
+        "retry": false,
+        "rootDir": false,
+    };
+
+    addProjectForm: FormGroup;
+    pathCtrl = new FormControl("", Validators.required);
+
+
+    constructor(
+        private configSvr: ConfigService,
+        private sdkSvr: XDSServerService,
+        private stSvr: SyncthingService,
+        private alert: AlertService,
+        private fb: FormBuilder
+    ) {
+        // FIXME implement multi project support
+        this.curProj = 0;
+        this.addProjectForm = fb.group({
+            path: this.pathCtrl,
+            label: ["", Validators.nullValidator],
+        });
+    }
+
+    ngOnInit() {
+        this.config$ = this.configSvr.conf;
+        this.severStatus$ = this.sdkSvr.Status$;
+        this.localSTStatus$ = this.stSvr.Status$;
+
+        // Bind syncToolUrl to baseURL
+        this.config$.subscribe(cfg => {
+            this.syncToolUrl = cfg.localSThg.URL;
+            this.syncToolRetry = String(cfg.localSThg.retry);
+            this.projectsRootDir = cfg.projectsRootDir;
+        });
+
+        // Auto create label name
+        this.pathCtrl.valueChanges
+            .debounceTime(100)
+            .filter(n => n)
+            .map(n => "Project_" + n.split('/')[0])
+            .subscribe(value => {
+                if (value && !this.userEditedLabel) {
+                    this.addProjectForm.patchValue({ label: value });
+                }
+            });
+    }
+
+    onKeyLabel(event: any) {
+        this.userEditedLabel = (this.addProjectForm.value.label !== "");
+    }
+
+    submitGlobConf(field: string) {
+        switch (field) {
+            case "retry":
+                let re = new RegExp('^[0-9]+$');
+                let rr = parseInt(this.syncToolRetry, 10);
+                if (re.test(this.syncToolRetry) && rr >= 0) {
+                    this.configSvr.syncToolRetry = 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;
+    }
+
+    syncToolRestartConn() {
+        this.configSvr.syncToolURL = this.syncToolUrl;
+        this.configSvr.loadProjects();
+    }
+
+    onSubmit() {
+        let formVal = this.addProjectForm.value;
+
+        this.configSvr.addProject({
+            label: formVal['label'],
+            path: formVal['path'],
+            type: ProjectType.SYNCTHING,
+        });
+    }
+
+}
\ No newline at end of file
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..1df277f
--- /dev/null
@@ -0,0 +1,62 @@
+import { Component, OnInit } from '@angular/core';
+
+export interface ISlide {
+    img?: string;
+    imgAlt?: string;
+    hText?: string;
+    pText?: string;
+    btn?: string;
+    btnHref?: string;
+}
+
+@Component({
+    selector: 'home',
+    moduleId: module.id,
+    template: `
+        <style>
+            .wide img {
+                width: 98%;
+            }
+            h1, h2, h3, h4, p {
+                color: #330066;
+            }
+
+        </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" *ngIf="sl.hText">
+                        <h2>{{ sl.hText }}</h2>
+                        <p>{{ sl.pText }}</p>
+                    </div>
+                </slide>
+            </carousel>
+        </div>
+    `
+})
+
+export class HomeComponent {
+
+    public carInterval: number = 2000;
+
+    // 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 !",
+            pText: "X(cross) Development System allows developers to easily cross-compile applications.",
+        },
+        {
+            //img: 'assets/images/beige.jpg',
+            //imgAlt: "beige image",
+            img: 'assets/images/iot-graphx.jpg',
+            imgAlt: "iot graphx image",
+            hText: "Create, Build, Deploy, Enjoy !",
+            pText: "TODO...",
+        }
+    ];
+
+    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/projectCard.component.ts b/webapp/src/app/projects/projectCard.component.ts
new file mode 100644 (file)
index 0000000..010b476
--- /dev/null
@@ -0,0 +1,63 @@
+import { Component, Input, Pipe, PipeTransform } from '@angular/core';
+import { ConfigService, IProject, ProjectType } from "../common/config.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-folder-open-o"></span>&nbsp;<span>Folder path</span></th>
+                <td>{{ project.path}}</td>
+            </tr>
+            <tr>
+                <th><span class="fa fa-fw fa-exchange"></span>&nbsp;<span>Synchronization type</span></th>
+                <td>{{ project.type | readableType }}</td>
+            </tr>
+
+            </tbody>
+        </table >
+    `,
+    styleUrls: ['./app/config/config.component.css']
+})
+
+export class ProjectCardComponent {
+
+    @Input() project: IProject;
+
+    constructor(private configSvr: ConfigService) {
+    }
+
+
+    delete(prj: IProject) {
+        this.configSvr.deleteProject(prj);
+    }
+
+}
+
+// Remove APPS. prefix if translate has failed
+@Pipe({
+    name: 'readableType'
+})
+
+export class ProjectReadableTypePipe implements PipeTransform {
+  transform(type: ProjectType): string {
+    switch (+type) {
+        case ProjectType.NATIVE:    return "Native";
+        case ProjectType.SYNCTHING: return "Cloud (Syncthing)";
+        default:                    return String(type);
+    }
+  }
+}
\ No newline at end of file
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..bea3f0f
--- /dev/null
@@ -0,0 +1,26 @@
+import { Component, Input } from "@angular/core";
+
+import { IProject } from "../common/config.service";
+
+@Component({
+    selector: 'projects-list-accordion',
+    template: `
+        <accordion>
+            <accordion-group #group *ngFor="let prj of projects">
+                <div accordion-heading>
+                    {{ prj.label }}
+                    <i class="pull-right float-xs-right fa"
+                    [ngClass]="{'fa-chevron-down': group.isOpen, 'fa-chevron-right': !group.isOpen}"></i>
+                </div>
+                <project-card [project]="prj"></project-card>
+            </accordion-group>
+        </accordion>
+    `
+})
+export class ProjectsListAccordionComponent {
+
+    @Input() projects: IProject[];
+
+}
+
+
diff --git a/webapp/src/index.html b/webapp/src/index.html
new file mode 100644 (file)
index 0000000..33e5efd
--- /dev/null
@@ -0,0 +1,49 @@
+<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%);">
+            Loading...
+            <i class="fa fa-spinner fa-spin fa-fw"></i>
+        </div>
+    </app>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/webapp/src/systemjs.config.js b/webapp/src/systemjs.config.js
new file mode 100644 (file)
index 0000000..e6139b0
--- /dev/null
@@ -0,0 +1,55 @@
+(function (global) {
+    System.config({
+        paths: {
+            // paths serve as alias
+            'npm:': 'lib/'
+        },
+        // 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/dropdown': 'npm:ngx-bootstrap/bundles/ngx-bootstrap.umd.min.js',
+            // other libraries
+            'rxjs': 'npm:rxjs',
+            '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: 'js'
+            },
+            "socket.io-client": {
+                defaultExtension: 'js'
+            },
+            'ngx-bootstrap': {
+                format: 'cjs',
+                main: 'bundles/ng2-bootstrap.umd.js',
+                defaultExtension: 'js'
+            },
+            'moment': {
+                main: 'moment.js',
+                defaultExtension: 'js'
+            }
+        }
+    });
+})(this);
\ No newline at end of file
diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json
new file mode 100644 (file)
index 0000000..4c37259
--- /dev/null
@@ -0,0 +1,17 @@
+{
+  "compilerOptions": {
+    "outDir": "dist/app",
+    "target": "es5",
+    "module": "commonjs",
+    "moduleResolution": "node",
+    "sourceMap": true,
+    "emitDecoratorMetadata": true,
+    "experimentalDecorators": true,
+    "removeComments": false,
+    "noImplicitAny": false
+  },
+  "exclude": [
+    "gulpfile.ts",
+    "node_modules"
+  ]
+}
\ No newline at end of file
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"
+  }
+}