summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore10
-rw-r--r--.vscode/launch.json37
-rw-r--r--.vscode/settings.json22
-rw-r--r--Makefile118
-rw-r--r--README.md115
-rw-r--r--config.json.in8
-rw-r--r--glide.yaml19
-rw-r--r--lib/apiv1/apiv1.go49
-rw-r--r--lib/apiv1/config.go45
-rw-r--r--lib/apiv1/exec.go154
-rw-r--r--lib/apiv1/folders.go77
-rw-r--r--lib/apiv1/make.go151
-rw-r--r--lib/apiv1/version.go24
-rw-r--r--lib/common/error.go13
-rw-r--r--lib/common/execPipeWs.go148
-rw-r--r--lib/common/httpclient.go221
-rw-r--r--lib/session/session.go227
-rw-r--r--lib/syncthing/st.go76
-rw-r--r--lib/syncthing/stfolder.go116
-rw-r--r--lib/xdsconfig/builderconfig.go50
-rw-r--r--lib/xdsconfig/config.go231
-rw-r--r--lib/xdsconfig/fileconfig.go133
-rw-r--r--lib/xdsconfig/folderconfig.go79
-rw-r--r--lib/xdsconfig/foldersconfig.go47
-rw-r--r--lib/xdsserver/server.go189
-rw-r--r--main.go87
-rw-r--r--webapp/README.md45
-rw-r--r--webapp/assets/favicon.icobin0 -> 26463 bytes
-rw-r--r--webapp/assets/images/iot-graphx.jpgbin0 -> 113746 bytes
-rw-r--r--webapp/bs-config.json9
-rw-r--r--webapp/gulp.conf.js34
-rw-r--r--webapp/gulpfile.js123
-rw-r--r--webapp/package.json62
-rw-r--r--webapp/src/app/alert/alert.component.ts30
-rw-r--r--webapp/src/app/app.component.css17
-rw-r--r--webapp/src/app/app.component.html21
-rw-r--r--webapp/src/app/app.component.ts34
-rw-r--r--webapp/src/app/app.module.ts69
-rw-r--r--webapp/src/app/app.routing.ts19
-rw-r--r--webapp/src/app/build/build.component.css10
-rw-r--r--webapp/src/app/build/build.component.html50
-rw-r--r--webapp/src/app/build/build.component.ts120
-rw-r--r--webapp/src/app/common/alert.service.ts64
-rw-r--r--webapp/src/app/common/config.service.ts276
-rw-r--r--webapp/src/app/common/syncthing.service.ts342
-rw-r--r--webapp/src/app/common/xdsserver.service.ts216
-rw-r--r--webapp/src/app/config/config.component.css26
-rw-r--r--webapp/src/app/config/config.component.html73
-rw-r--r--webapp/src/app/config/config.component.ts123
-rw-r--r--webapp/src/app/home/home.component.ts62
-rw-r--r--webapp/src/app/main.ts6
-rw-r--r--webapp/src/app/projects/projectCard.component.ts63
-rw-r--r--webapp/src/app/projects/projectsListAccordion.component.ts26
-rw-r--r--webapp/src/index.html49
-rw-r--r--webapp/src/systemjs.config.js55
-rw-r--r--webapp/tsconfig.json17
-rw-r--r--webapp/tslint.json55
-rw-r--r--webapp/tslint.prod.json56
-rw-r--r--webapp/typings.json11
59 files changed, 4609 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..660e248
--- /dev/null
+++ b/.gitignore
@@ -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
index 0000000..8bdde69
--- /dev/null
+++ b/.vscode/launch.json
@@ -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
index 0000000..a90ab0d
--- /dev/null
+++ b/.vscode/settings.json
@@ -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
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
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
index 0000000..a4dcf33
--- /dev/null
+++ b/config.json.in
@@ -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
index 0000000..b182ebc
--- /dev/null
+++ b/glide.yaml
@@ -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
index 0000000..56c7503
--- /dev/null
+++ b/lib/apiv1/apiv1.go
@@ -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
index 0000000..a2817a0
--- /dev/null
+++ b/lib/apiv1/config.go
@@ -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
index 0000000..f7beea6
--- /dev/null
+++ b/lib/apiv1/exec.go
@@ -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
index 0000000..b1864a2
--- /dev/null
+++ b/lib/apiv1/folders.go
@@ -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
index 0000000..eac6210
--- /dev/null
+++ b/lib/apiv1/make.go
@@ -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
index 0000000..e022441
--- /dev/null
+++ b/lib/apiv1/version.go
@@ -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
index 0000000..d03c176
--- /dev/null
+++ b/lib/common/error.go
@@ -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
index 0000000..3b63cdc
--- /dev/null
+++ b/lib/common/execPipeWs.go
@@ -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
index 0000000..40d7bc2
--- /dev/null
+++ b/lib/common/httpclient.go
@@ -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
index 0000000..35dfdc6
--- /dev/null
+++ b/lib/session/session.go
@@ -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
index 0000000..7d07b70
--- /dev/null
+++ b/lib/syncthing/st.go
@@ -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
index 0000000..d79e579
--- /dev/null
+++ b/lib/syncthing/stfolder.go
@@ -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
index 0000000..c64fe9c
--- /dev/null
+++ b/lib/xdsconfig/builderconfig.go
@@ -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
index 0000000..df98439
--- /dev/null
+++ b/lib/xdsconfig/config.go
@@ -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
index 0000000..262d023
--- /dev/null
+++ b/lib/xdsconfig/fileconfig.go
@@ -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
index 0000000..e8bff4f
--- /dev/null
+++ b/lib/xdsconfig/folderconfig.go
@@ -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
index 0000000..4ad16df
--- /dev/null
+++ b/lib/xdsconfig/foldersconfig.go
@@ -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
index 0000000..90d0f38
--- /dev/null
+++ b/lib/xdsserver/server.go
@@ -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
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
index 0000000..acee846
--- /dev/null
+++ b/webapp/README.md
@@ -0,0 +1,45 @@
+XDS Dashboard
+=============
+
+This is the web application dashboard for Cross Development System.
+
+## 1. Prerequisites
+
+*nodejs* must be installed on your system and the below global node packages must be installed:
+
+> sudo npm install -g gulp-cli
+
+## 2. Installing dependencies
+
+Install dependencies by running the following command:
+
+> npm install
+
+`node_modules` and `typings` directories will be created during the install.
+
+## 3. Building the project
+
+Build the project by running the following command:
+
+> npm run clean & npm run build
+
+`dist` directory will be created during the build
+
+## 4. Starting the application
+
+Start the application by running the following command:
+
+> npm start
+
+The application will be displayed in the browser.
+
+
+## TODO
+
+- Upgrade to angular 2.4.9 or 2.4.10 AND rxjs 5.2.0
+- Complete README + package.json
+- Add prod mode and use update gulpfile tslint: "./tslint/prod.json"
+- Generate a bundle minified file, using systemjs-builder or find a better way
+ http://stackoverflow.com/questions/35280582/angular2-too-many-file-requests-on-load
+- Add SASS support
+ http://foundation.zurb.com/sites/docs/sass.html \ No newline at end of file
diff --git a/webapp/assets/favicon.ico b/webapp/assets/favicon.ico
new file mode 100644
index 0000000..6bf5138
--- /dev/null
+++ b/webapp/assets/favicon.ico
Binary files differ
diff --git a/webapp/assets/images/iot-graphx.jpg b/webapp/assets/images/iot-graphx.jpg
new file mode 100644
index 0000000..6a2c428
--- /dev/null
+++ b/webapp/assets/images/iot-graphx.jpg
Binary files differ
diff --git a/webapp/bs-config.json b/webapp/bs-config.json
new file mode 100644
index 0000000..0041c6d
--- /dev/null
+++ b/webapp/bs-config.json
@@ -0,0 +1,9 @@
+{
+ "port": 8000,
+ "files": [
+ "dist/**/*.{html,htm,css,js}"
+ ],
+ "server": {
+ "baseDir": "dist"
+ }
+} \ No newline at end of file
diff --git a/webapp/gulp.conf.js b/webapp/gulp.conf.js
new file mode 100644
index 0000000..0de52f9
--- /dev/null
+++ b/webapp/gulp.conf.js
@@ -0,0 +1,34 @@
+"use strict";
+
+module.exports = {
+ prodMode: process.env.PRODUCTION || false,
+ outDir: "dist",
+ paths: {
+ tsSources: ["src/**/*.ts"],
+ srcDir: "src",
+ assets: ["assets/**"],
+ node_modules_libs: [
+ 'core-js/client/shim.min.js',
+ 'reflect-metadata/Reflect.js',
+ 'rxjs/**/*.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
index 0000000..0226380
--- /dev/null
+++ b/webapp/gulpfile.js
@@ -0,0 +1,123 @@
+"use strict";
+//FIXME in VSC/eslint or add to typings declare function require(v: string): any;
+
+// FIXME: Rework based on
+// https://github.com/iotbzh/app-framework-templates/blob/master/templates/hybrid-html5/gulpfile.js
+// AND
+// https://github.com/antonybudianto/angular-starter
+// and/or
+// https://github.com/smmorneau/tour-of-heroes/blob/master/gulpfile.js
+
+const gulp = require("gulp"),
+ gulpif = require('gulp-if'),
+ del = require("del"),
+ sourcemaps = require('gulp-sourcemaps'),
+ tsc = require("gulp-typescript"),
+ tsProject = tsc.createProject("tsconfig.json"),
+ tslint = require('gulp-tslint'),
+ gulpSequence = require('gulp-sequence'),
+ rsync = require('gulp-rsync'),
+ conf = require('./gulp.conf');
+
+
+var tslintJsonFile = "./tslint.json"
+if (conf.prodMode) {
+ tslintJsonFile = "./tslint.prod.json"
+}
+
+
+/**
+ * Remove output directory.
+ */
+gulp.task('clean', (cb) => {
+ return del([conf.outDir], cb);
+});
+
+/**
+ * Lint all custom TypeScript files.
+ */
+gulp.task('tslint', function() {
+ return gulp.src(conf.paths.tsSources)
+ .pipe(tslint({
+ formatter: 'verbose',
+ configuration: tslintJsonFile
+ }))
+ .pipe(tslint.report());
+});
+
+/**
+ * Compile TypeScript sources and create sourcemaps in build directory.
+ */
+gulp.task("compile", ["tslint"], function() {
+ var tsResult = gulp.src(conf.paths.tsSources)
+ .pipe(sourcemaps.init())
+ .pipe(tsProject());
+ return tsResult.js
+ .pipe(sourcemaps.write(".", { sourceRoot: '/src' }))
+ .pipe(gulp.dest(conf.outDir));
+});
+
+/**
+ * Copy all resources that are not TypeScript files into build directory.
+ */
+gulp.task("resources", function() {
+ return gulp.src(["src/**/*", "!**/*.ts"])
+ .pipe(gulp.dest(conf.outDir));
+});
+
+/**
+ * Copy all assets into build directory.
+ */
+gulp.task("assets", function() {
+ return gulp.src(conf.paths.assets)
+ .pipe(gulp.dest(conf.outDir + "/assets"));
+});
+
+/**
+ * Copy all required libraries into build directory.
+ */
+gulp.task("libs", function() {
+ return gulp.src(conf.paths.node_modules_libs,
+ { cwd: "node_modules/**" }) /* Glob required here. */
+ .pipe(gulp.dest(conf.outDir + "/lib"));
+});
+
+/**
+ * Watch for changes in TypeScript, HTML and CSS files.
+ */
+gulp.task('watch', function () {
+ gulp.watch([conf.paths.tsSources], ['compile']).on('change', function (e) {
+ console.log('TypeScript file ' + e.path + ' has been changed. Compiling.');
+ });
+ gulp.watch(["src/**/*.html", "src/**/*.css"], ['resources']).on('change', function (e) {
+ console.log('Resource file ' + e.path + ' has been changed. Updating.');
+ });
+});
+
+/**
+ * Build the project.
+ */
+gulp.task("build", ['compile', 'resources', 'libs', 'assets'], function() {
+ console.log("Building the project ...");
+});
+
+/**
+ * Deploy the project on another machine/container
+ */
+gulp.task('rsync', function () {
+ return gulp.src(conf.outDir)
+ .pipe(rsync({
+ root: conf.outDir,
+ username: conf.deploy.username,
+ hostname: conf.deploy.target_ip,
+ port: conf.deploy.port || null,
+ archive: true,
+ recursive: true,
+ compress: true,
+ progress: false,
+ incremental: true,
+ destination: conf.deploy.dir
+ }));
+});
+
+gulp.task('deploy', gulpSequence('build', 'rsync')); \ No newline at end of file
diff --git a/webapp/package.json b/webapp/package.json
new file mode 100644
index 0000000..ecc6a78
--- /dev/null
+++ b/webapp/package.json
@@ -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
index 0000000..e9d7629
--- /dev/null
+++ b/webapp/src/app/alert/alert.component.ts
@@ -0,0 +1,30 @@
+import { Component } from '@angular/core';
+import { Observable } from 'rxjs';
+
+import {AlertService, IAlert} from '../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
index 0000000..0ec4936
--- /dev/null
+++ b/webapp/src/app/app.component.css
@@ -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
index 0000000..ab792be
--- /dev/null
+++ b/webapp/src/app/app.component.html
@@ -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
index 0000000..d0f9c6e
--- /dev/null
+++ b/webapp/src/app/app.component.ts
@@ -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
index 0000000..5c33e43
--- /dev/null
+++ b/webapp/src/app/app.module.ts
@@ -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
index 0000000..747727c
--- /dev/null
+++ b/webapp/src/app/app.routing.ts
@@ -0,0 +1,19 @@
+import {Routes, RouterModule} from "@angular/router";
+import {ModuleWithProviders} from "@angular/core";
+import {ConfigComponent} from "./config/config.component";
+import {HomeComponent} from "./home/home.component";
+import {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
index 0000000..5bfc898
--- /dev/null
+++ b/webapp/src/app/build/build.component.css
@@ -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
index 0000000..d2a8da6
--- /dev/null
+++ b/webapp/src/app/build/build.component.html
@@ -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
index 0000000..e1076c5
--- /dev/null
+++ b/webapp/src/app/build/build.component.ts
@@ -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
index 0000000..710046f
--- /dev/null
+++ b/webapp/src/app/common/alert.service.ts
@@ -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
index 0000000..67ee14c
--- /dev/null
+++ b/webapp/src/app/common/config.service.ts
@@ -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
index 0000000..c8b0193
--- /dev/null
+++ b/webapp/src/app/common/syncthing.service.ts
@@ -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
index 0000000..fd2e32a
--- /dev/null
+++ b/webapp/src/app/common/xdsserver.service.ts
@@ -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
index 0000000..f480857
--- /dev/null
+++ b/webapp/src/app/config/config.component.css
@@ -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
index 0000000..45b0e14
--- /dev/null
+++ b/webapp/src/app/config/config.component.html
@@ -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
index 0000000..681c296
--- /dev/null
+++ b/webapp/src/app/config/config.component.ts
@@ -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
index 0000000..1df277f
--- /dev/null
+++ b/webapp/src/app/home/home.component.ts
@@ -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
index 0000000..1f68ccc
--- /dev/null
+++ b/webapp/src/app/main.ts
@@ -0,0 +1,6 @@
+import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
+import {AppModule} from './app.module';
+
+const platform = platformBrowserDynamic();
+
+platform.bootstrapModule(AppModule); \ No newline at end of file
diff --git a/webapp/src/app/projects/projectCard.component.ts b/webapp/src/app/projects/projectCard.component.ts
new file mode 100644
index 0000000..010b476
--- /dev/null
+++ b/webapp/src/app/projects/projectCard.component.ts
@@ -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
index 0000000..bea3f0f
--- /dev/null
+++ b/webapp/src/app/projects/projectsListAccordion.component.ts
@@ -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
index 0000000..33e5efd
--- /dev/null
+++ b/webapp/src/index.html
@@ -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
index 0000000..e6139b0
--- /dev/null
+++ b/webapp/src/systemjs.config.js
@@ -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
index 0000000..4c37259
--- /dev/null
+++ b/webapp/tsconfig.json
@@ -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
index 0000000..15969a4
--- /dev/null
+++ b/webapp/tslint.json
@@ -0,0 +1,55 @@
+{
+ "rules": {
+ "class-name": true,
+ "curly": true,
+ "eofline": false,
+ "forin": true,
+ "indent": [
+ true,
+ 4
+ ],
+ "label-position": true,
+ "max-line-length": [
+ true,
+ 140
+ ],
+ "no-arg": true,
+ "no-bitwise": true,
+ "no-console": [
+ true,
+ "info",
+ "time",
+ "timeEnd",
+ "trace"
+ ],
+ "no-construct": true,
+ "no-debugger": true,
+ "no-duplicate-variable": true,
+ "no-empty": false,
+ "no-eval": true,
+ "no-string-literal": false,
+ "no-trailing-whitespace": true,
+ "no-use-before-declare": true,
+ "one-line": [
+ true,
+ "check-open-brace",
+ "check-catch",
+ "check-else",
+ "check-whitespace"
+ ],
+ "radix": true,
+ "semicolon": true,
+ "triple-equals": [
+ true,
+ "allow-null-check"
+ ],
+ "variable-name": false,
+ "whitespace": [
+ true,
+ "check-branch",
+ "check-decl",
+ "check-operator",
+ "check-separator"
+ ]
+ }
+}
diff --git a/webapp/tslint.prod.json b/webapp/tslint.prod.json
new file mode 100644
index 0000000..aa64c7f
--- /dev/null
+++ b/webapp/tslint.prod.json
@@ -0,0 +1,56 @@
+{
+ "rules": {
+ "class-name": true,
+ "curly": true,
+ "eofline": false,
+ "forin": true,
+ "indent": [
+ true,
+ 4
+ ],
+ "label-position": true,
+ "max-line-length": [
+ true,
+ 140
+ ],
+ "no-arg": true,
+ "no-bitwise": true,
+ "no-console": [
+ true,
+ "debug",
+ "info",
+ "time",
+ "timeEnd",
+ "trace"
+ ],
+ "no-construct": true,
+ "no-debugger": true,
+ "no-duplicate-variable": true,
+ "no-empty": false,
+ "no-eval": true,
+ "no-string-literal": false,
+ "no-trailing-whitespace": true,
+ "no-use-before-declare": true,
+ "one-line": [
+ true,
+ "check-open-brace",
+ "check-catch",
+ "check-else",
+ "check-whitespace"
+ ],
+ "radix": true,
+ "semicolon": true,
+ "triple-equals": [
+ true,
+ "allow-null-check"
+ ],
+ "variable-name": false,
+ "whitespace": [
+ true,
+ "check-branch",
+ "check-decl",
+ "check-operator",
+ "check-separator"
+ ]
+ }
+}
diff --git a/webapp/typings.json b/webapp/typings.json
new file mode 100644
index 0000000..23c6a41
--- /dev/null
+++ b/webapp/typings.json
@@ -0,0 +1,11 @@
+{
+ "dependencies": {},
+ "devDependencies": {},
+ "globalDependencies": {
+ "es6-shim": "registry:dt/es6-shim#0.31.2+20160317120654",
+ "socket.io-client": "registry:dt/socket.io-client#1.4.4+20160317120654"
+ },
+ "globalDevDependencies": {
+ "jasmine": "registry:dt/jasmine#2.2.0+20160505161446"
+ }
+}