From ec7051e1da665206f594c7616ad381bfeaea333a Mon Sep 17 00:00:00 2001 From: Sebastien Douheret Date: Thu, 11 May 2017 19:42:00 +0200 Subject: [PATCH] Initial main commit. Signed-off-by: Sebastien Douheret --- .gitignore | 10 + .vscode/launch.json | 37 +++ .vscode/settings.json | 22 ++ Makefile | 118 +++++++ README.md | 115 +++++++ config.json.in | 8 + glide.yaml | 19 ++ lib/apiv1/apiv1.go | 49 +++ lib/apiv1/config.go | 45 +++ lib/apiv1/exec.go | 154 ++++++++++ lib/apiv1/folders.go | 77 +++++ lib/apiv1/make.go | 151 +++++++++ lib/apiv1/version.go | 24 ++ lib/common/error.go | 13 + lib/common/execPipeWs.go | 148 +++++++++ lib/common/httpclient.go | 221 +++++++++++++ lib/session/session.go | 227 ++++++++++++++ lib/syncthing/st.go | 76 +++++ lib/syncthing/stfolder.go | 116 +++++++ lib/xdsconfig/builderconfig.go | 50 +++ lib/xdsconfig/config.go | 231 ++++++++++++++ lib/xdsconfig/fileconfig.go | 133 ++++++++ lib/xdsconfig/folderconfig.go | 79 +++++ lib/xdsconfig/foldersconfig.go | 47 +++ lib/xdsserver/server.go | 189 ++++++++++++ main.go | 87 ++++++ webapp/README.md | 45 +++ webapp/assets/favicon.ico | Bin 0 -> 26463 bytes webapp/assets/images/iot-graphx.jpg | Bin 0 -> 113746 bytes webapp/bs-config.json | 9 + webapp/gulp.conf.js | 34 ++ webapp/gulpfile.js | 123 ++++++++ webapp/package.json | 62 ++++ webapp/src/app/alert/alert.component.ts | 30 ++ webapp/src/app/app.component.css | 17 + webapp/src/app/app.component.html | 21 ++ webapp/src/app/app.component.ts | 34 ++ webapp/src/app/app.module.ts | 69 +++++ webapp/src/app/app.routing.ts | 19 ++ webapp/src/app/build/build.component.css | 10 + webapp/src/app/build/build.component.html | 50 +++ webapp/src/app/build/build.component.ts | 120 ++++++++ webapp/src/app/common/alert.service.ts | 64 ++++ webapp/src/app/common/config.service.ts | 276 +++++++++++++++++ webapp/src/app/common/syncthing.service.ts | 342 +++++++++++++++++++++ webapp/src/app/common/xdsserver.service.ts | 216 +++++++++++++ webapp/src/app/config/config.component.css | 26 ++ webapp/src/app/config/config.component.html | 73 +++++ webapp/src/app/config/config.component.ts | 123 ++++++++ webapp/src/app/home/home.component.ts | 62 ++++ webapp/src/app/main.ts | 6 + webapp/src/app/projects/projectCard.component.ts | 63 ++++ .../projects/projectsListAccordion.component.ts | 26 ++ webapp/src/index.html | 49 +++ webapp/src/systemjs.config.js | 55 ++++ webapp/tsconfig.json | 17 + webapp/tslint.json | 55 ++++ webapp/tslint.prod.json | 56 ++++ webapp/typings.json | 11 + 59 files changed, 4609 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 Makefile create mode 100644 README.md create mode 100644 config.json.in create mode 100644 glide.yaml create mode 100644 lib/apiv1/apiv1.go create mode 100644 lib/apiv1/config.go create mode 100644 lib/apiv1/exec.go create mode 100644 lib/apiv1/folders.go create mode 100644 lib/apiv1/make.go create mode 100644 lib/apiv1/version.go create mode 100644 lib/common/error.go create mode 100644 lib/common/execPipeWs.go create mode 100644 lib/common/httpclient.go create mode 100644 lib/session/session.go create mode 100644 lib/syncthing/st.go create mode 100644 lib/syncthing/stfolder.go create mode 100644 lib/xdsconfig/builderconfig.go create mode 100644 lib/xdsconfig/config.go create mode 100644 lib/xdsconfig/fileconfig.go create mode 100644 lib/xdsconfig/folderconfig.go create mode 100644 lib/xdsconfig/foldersconfig.go create mode 100644 lib/xdsserver/server.go create mode 100644 main.go create mode 100644 webapp/README.md create mode 100644 webapp/assets/favicon.ico create mode 100644 webapp/assets/images/iot-graphx.jpg create mode 100644 webapp/bs-config.json create mode 100644 webapp/gulp.conf.js create mode 100644 webapp/gulpfile.js create mode 100644 webapp/package.json create mode 100644 webapp/src/app/alert/alert.component.ts create mode 100644 webapp/src/app/app.component.css create mode 100644 webapp/src/app/app.component.html create mode 100644 webapp/src/app/app.component.ts create mode 100644 webapp/src/app/app.module.ts create mode 100644 webapp/src/app/app.routing.ts create mode 100644 webapp/src/app/build/build.component.css create mode 100644 webapp/src/app/build/build.component.html create mode 100644 webapp/src/app/build/build.component.ts create mode 100644 webapp/src/app/common/alert.service.ts create mode 100644 webapp/src/app/common/config.service.ts create mode 100644 webapp/src/app/common/syncthing.service.ts create mode 100644 webapp/src/app/common/xdsserver.service.ts create mode 100644 webapp/src/app/config/config.component.css create mode 100644 webapp/src/app/config/config.component.html create mode 100644 webapp/src/app/config/config.component.ts create mode 100644 webapp/src/app/home/home.component.ts create mode 100644 webapp/src/app/main.ts create mode 100644 webapp/src/app/projects/projectCard.component.ts create mode 100644 webapp/src/app/projects/projectsListAccordion.component.ts create mode 100644 webapp/src/index.html create mode 100644 webapp/src/systemjs.config.js create mode 100644 webapp/tsconfig.json create mode 100644 webapp/tslint.json create mode 100644 webapp/tslint.prod.json create mode 100644 webapp/typings.json diff --git a/.gitignore b/.gitignore 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. `/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/ /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 0000000000000000000000000000000000000000..6bf5138aefee75ef550856546442f011220f5aee GIT binary patch literal 26463 zcmb?igDxiRbbSeT$cgN6OBHi8H{oVKX zH+;Z_d4}hinS1s=d#}CLIsgCxzyJG!08D_I4FFJpkHfTIsNmz!;D8VD)l?OA{`>EL z7ZwKi$Jq6^H2~NUsVP2t?KQpE=I{8PW3f6}xdu_3h}fn<#oUk?5M_*JI(DV5oI)hefNmYORB zpmRALjWj;8Tp|MBf`NhqPWD(Fw%EOv>9+Rwl}XNn3l@qP^VCYb`1Rr%?qL!EMEtl6 zLwaE4VI#kKc3Gy(Wsv+C2Ka0lRb~b{-r&{01vEezQUMi*nZZy#fRWo%B!7g zY7=n4`%cIi3l)t%{9}rgb0hU4fNV1vFuBl9b5_L_DoSUpJp;1bdhHBsp zEN||FPyK&yM6x-+j&d8kdUb&A=u7V0mHRMG|3L(1{L_>AnJxP7xX@|9@R=N|oCt6` zDwhBW9Ssq{H<201!Ga{IV`{mX(JjiP+RM;2B)C3`XHu& zV@Z1G2T?WeAqrFgBKzU+OGIPX${Zn@XW>=q&_Q0I4O=1W&j`SQeV76`1E8a3{0W2E z5Jibg0dd(Zm&!M5Q?IB+JY2;+%5FOD4?4x4j3I6N0TY~7eIdm~Fe2zXSR1K`6~09& zm3!6q*v7B6NE&FvCPjcb#moV~4gyP9Y_tTPwn~7nW>wreifNG)ZDG@yPPdCAo6-8d z4~2K>5m2p8@bx(ijFCB9>@s0rb)}`v$&z+wzlbu?kC(VULQV|O$IjdqYUmQjJXkT7 z$+X>CP#IP5{f75Y;UqAg-pUStM!1;G@mYx#3MP!;SXjtH=T*u)M#J8#h2JrWzK@9O z(K2Ts1!#-)LHkl|E%g%c^r5^z3*U0{lc^Kw$?@Z2|Ce7pZ_L=_ zR=6Ho>9M5)&Ye1Z9^%JfOO=Yd48Tn36U`eP^)Cui*>+PZD^nv~k@$B%?^;*yjVS|- zqFSsJ{<{}>fGG+VXLCjAq_y~|$?3|nrTJ@UBYGwrO~10;{nG|rZRU@LtWd)ByVU%l zoUzHa#@LYa-GhgD@o?UtE25U4QAh=V-)-@0|EGfFbyGiGx*MNn%I}Ipg|OSBiX=uu zNnnGT;Au;(XlJcxj%&8*N-xufoqTZo-=I0pzK*AP3#*|#S$R7Q{+LZ z9+K*lv;GJ~ZHMaP^R3Y)su{0re>D)fzf124ZeEypm=o-;!opz=GA{^3XNH6TCOL~4 zNk_5>U#goM1eL4c1-;^F7vR|OFJ{jCdAysu+}iWppV?ZL|GePOBhItGA6{D8nVBSi{PD=mdz@IgY6U+rp{DbAo&g2kptCb~;3K zt92*5CyLIWk4dq&xbd>Sq;}L6Mz^|L)A(q(y%~_!v@t zl19a9z7a`LI=f#Ny-$~T4a~A%evAz!q1v)?mw=`Ud%NIMqj=JE`Ij!T++bn zVxF%~_{WAY%1B>k@x|HbYB*dn2eX>;@4ep>5xUtx3urv4NX?)u45m5Y^7cPVi-=1f zKjT|>U}gF9$aS&VWEbK53vhk>9YzOrZof==dgM6x;IJTD&$ViGL9}@QdH;At#5sa{ zgJdeq^uL_DjQ)|4zG1vpQ_-hIczD(9M&@|#M)m^ehzgt+w4jetKNL{vv!j7~1ukHs z$qPVl8`S>00qm>xP&a(j8rA{Ab42w^v%e~k>sQ|eyK`MC`J?G?-g#OcWT}KO>?e(n zPz%d4-ujd`(Wg}io%CVf>d+}qI4VI;IDnDBfjH)H#pHAsXRkv{F|7WxcR+QUm#M$n zr{G7zcDFrEJ#7m!6Ax*m)BnQx*O0SVx60Pdy0BTS&8t3V;e(k{j$J(wp)%kLW7E_^ zhEaU~d!B;l2@+s35t*~e-D$=*Zx20vJdUB)7$KL@@%q})KygH9Z|UEs19}?2&7KiO z6?4PlM9AS;{Q0S0K;u-=u#dm8k?-3Ry0S~Ze76k+m>)CHOhmcY%@V}Obi33@^DrHF z6U5hYkpTGPSxqPR)rv-IcOqOoZj%@*zuneRzRC$GsvXim7FYM%ST0l8=UAj~K(pOx zE1<<_0-o3fsy*Q1QQg3t{Wc;}WdqKQ2B5J|@rQjOHN8iBdGQiphWjtzHZDx3>2T;8 z;~cI*O^0xR0w?fH7tK37iVc_%8hTH(k04fMf{MO zdJWU!?6^tc4UY82gX|v}A2fENaNTVD{xZXa4>!1KqD3+{7E&ND6mm@qHh?fyncc)!`~^N3JW0}V0$ zHyxgLTu|8L?~vwsCyH;dHhEZ_PV=QQT=L&vK*(`ysMFl%?R2|Ti^%!&19Fb~ZDGQbCwK4up8M%)z2 znGAzr8YZCMVlQ4E_)0tsj$jsBwKrj#8F`~JB&Q^fLWT}@9tK}(6z**N+kfPuK9?eD zGIn?_i|cK;z@ss6?Z#(90d-(_{A5zfdlTCK22zg_WPv%#kz8%i5QowqBk_NhxDv0U zPwRY901_bAkj3Wx<=g0TLX&||e>7C2tDlRWI)jwd`M+icFr!~HTqbGd9nH;NN7v7A z72TBZV2Bc{3gqx}T>VO3ub^?rP)+-W636;2CcGV}U+J}gbvhd)zH3MSwR4N%vIbe1@Nn{@xoiC&*uz-uENTL2@3g(bH3v zvH7?IGSz~TY+DT+^fez(tqR;Xi*Td8wCow^e0Qy&d8m$1t?P6H8O4H=iV_Q##O9P{hqImoG#tVgKwMSzx zE?e>*b}w4Rb%_N=nZIZ&-wo?sfI@&pKx&BKP(2A{K_11!O)U;9QB*P``STFHhPe6a zqz86@3`j#3-9pNkj*V8t{W|Vm);Et8o#v+yU`F3};&Y+`qQsE()#A>?H$hc7^Bf2rmy@$+E?wq=}U~zdoO)pAi@c|9qPj2 zJnMI|nx-_zo`krm@dlOclDYk2VvgVjfMWfN7s&fZIZ$U@E*f~2Lg~5F!H(~tGr^^j z^3R>lc-dwP-Oc#s_{7{wSoNcSJ`)YrR-2vH{V(>$&_YZOUl7jP4ZgLa-%QK;`Gn&0 z$l0Kc(vQ{5A@VM$zTGBf7NXWYXySUpxGVII7TNjf{slpLlB)Z|VlA%eLuDtqsHQlS z^<~|UtD${c?EOn5>X&xU_eiy8#jmqGR7}Kwy6AqoB3bYo@RgD?4yAz%L#pVb_{BHr zq53~r1Ss#0=Xo9+CN2`NHLhupX;znp4C;h3b@dxfjBMp^c`6K+m^#nkCU-i-(c#6&TH5G$o!pEOT#N zio9AUN$hD*YXNhd3F@{KDCT_MUwZ>59(kml-rFdhy6t~@h*D6AB0VxS^6yF>i!1iZ z=w7Wuw~--P#dydOdxBmy@zzmc$d5#zkD)>xmOcGUfBPDtx9Ay!gclLH6uEx#wXCMP z#Dq4M(k&$G{8S+%xL=ft{$OfHb6e0-UAhmJGTBWEg`Gb43l=wB-)!v~;fDG-jzrv} z1IUf=zIiBpCckY^lm&!G@;0z|u7mjd&H>A?Z|_kW7rb?Y)fe%PQASb_KcMB&*k!Su zUzbs%&RCVuN)VTfLp6VKO%d^RCE6YZc_nViRXjC!r3jEnDPbURC?b3z$`6k}DyK!U| z0!5E)Vey}M_H<9(kd1OnL4ga*NTp8Mkk8`}OadH@q$vEg%fAKjyAm?z^^bIU+XQ89 zCcNNq@M;WFjiRvU;hHO~uGHN%jL%B&vD6MHH-kxY%N;>wi@)b%T0vwN+W`tqD(us= zdXaq!qX|KCQre$Vy1bshP$SR%{){{TK2Y~K_t!0pz@JuL3~O_U z4~}VghACF&I&P79XCcqkJpqJAhM6GJOO*NA*d3veDMWV42F?N#>I$+~Z=VP!_Ok;K z+@aPOAvAX>3F<+`9;Kb_?3Y|CQ_*c;sQzIb;$rWUmoYT|YoEgR>iEGJe!n+Hlc9RH z&r~w>gQqXRm_9ti?{e~xKXNq)acu_HBSXvm9-4!^fpD0&V5gFq&L^Jhwl_-~S_YiW zb4aj3T=Gl&7X$rEOqD0X%pYg_?!}PsxE1cv=M^j9Sd5?>inMmcGK(*kiQlCwJ}Qhx zE1ItSx`IR06I9I=Pyduo?LtLxqCm87R`{A0JqF!%c@^@V z_!e+Jz(9u1g$cPIjCqMZC6e*+s(VvSd}$NbTfSYo6q2FFwG`R$j{e!}H3JuWH)j=# z!L#*qR-z9Ctoh2{Ha}!U3?9H6#3|&wv60o^L|$XF`O~0|UHzfa|(H%`dJ=KHB|3@I5Ds*MY3~8qH8}rTv7M@ z#T?@&gUs(csZ9OGUQeJbtnir1X6#uAOdLq1@?LcYm(97f0BJY$WhY;To9nIg&W0iE zYCpmPeUhjP%E=0`tPEdGuzwH>EaL8ha~axI_^*zbD*yEt>`%Wt^En-mAmdGY%N9O% zHSTD^=F?Y{x{3p>8TR}We#sLQ)cA`jtJy)Oocj$OZl=bf?BTOP`^NlftPkq7P2$n` zo@ZT=k&8O3vIxeOYL?nrwDr`; zZHUWhj8RHboXj4Ps_?2;#u2I(xQ;rvrwZfiPg^?Sg=BdlEzm)%z$p))7*`v5>+7g9 zclypM4MZe_$Ye#H;KTCw%6)V_t;dFBh3{9J!f8xDT0N83cAPcbFfuAcbC&80j3ftV z^yOZELI_K&$IticvQgTtjii5YUHT&~X(6V?@%J6;RBcD2S)@Ix z=^!BTdg_tiI0hr8V9mM@4YRB>q!Nb}z2$9d3@U*=Ka?NVMG|FFX4DAE@&s+Gn-tA_mv`N6s5 zaxlump!pt!Tr%EKgZb!HXYBcD!-Hgue$C_3y|U+f$+B> z2hEGKh!*L&iGG}6w7Y@{9}dFEU*CIzt>Ahj4V6tI+?rUZP#D(v9MaF_7@&Ks%EyH_ zAx??zNs3H%^>^1(fAR4ZWh=(ec<(LzEj`{p!17THW%|Zq*f9Z5R%X6Sw)$c1S0+f~ z3x!Yr<0#2Ee4hdWBzl>6?hn>%g^DVVa-m4;fMyeQ2dVXk{ubXXOszkLIa#SIJrJ*7 z+UlQX$tswDa$P{m1(~4v0_4naAAKJIpp^jUjNY`q^)IcJJZmIlxpInfJTmV@?0}$I zAV$yPrcde@9f?~A$Okaf!nAGqkklnbRj?jE|dY=hUK<@xVM>>P)! zkb4FojlC_J_W3>!74VM-i5Z=e%MQDD%vf?GLKa86n?2E&NfwPG3TXq;-D3Fyl+evl0?W-y4XB*bodL@k~VB(LjjG zDT7|acFD#?4RPkp=kRUan)jbuqC9sGxgfBv{3Uva*CD+~J8-Yp)XXp`lJ>9y2qf)m zSyrx6gM@CmF()J1fd!=FANnE%ve~>F9GB3^lz)b^a04!f9}L47sJ>Lo2KelH?q!9!g>4PF zwupDhCct(R@1!v|g+n-mDD4{Tp%M3*y5z2I4)RG@oW?@I3>;XNvf1 zH1I$+Q#nU0{PsxvR=>1`-xzYtY*DgB@b6%6l}tiY?c|Fu<-)r85-%y3ym>nX(nL>X z<99VQgXcHP!|j&PI$z%Jo@(h*+!l=2=j8O3|NPn7SkQTp=j8IWWsM{@L69V$8rz*( z~- z=gms*CtK^wrB_+a-nX;uZWi7sG45^1W_9~=x7vJkVhVVNdHIdp`CuY`M&=jw@M5GW zy25e$bq7MxA;}6tEU{w6#on4LOD!RF$Ipf(N~Ovgm*(A|#uEun`e8lt0>BF0{cX~G z_vsKJY&~GSkA-NH2nGGUk!_mvJ_Hc22&*9RKW$wNWMtfY2$(o^b7>JzFeTiqJ$MH! zQg9oK%|Ejke;)yGE*q`j~&+zJ1Y># z2H;!uax+IQlK9zZtYcAaWk!Cof)cN*u^T9lE4D^T2_LaaLp_s+QcCy+Gk(N2`nAj0Vu;EFBpGu}8Ce&oq4Nb&)s`Xqm_hdZ?E~th{nW zWUbPIxSXa06CJ#wpI>>|W*>2Vn;?~w5jgTZaNeg017Hwbb;NyB{qu3j-FD_|$uo*u z$xQwhBtwupeQR-K*OEu+9yuL@8Gr$8(EPV(|CKZaSQP!z6RRvchfB;{-n z`(G-l0$nN$)Xha(cd}+|w-T6tY2X)ZJqNz{2X+YV83W%&mXhL ze7qvbV8GVz0FEE3j_>$q*5pF~3hlZ!-+#|NPsEagr5H6I$NpJiZKk0PWzcng9M+}U z>^4&HJgBSk=kj$`{1^LT!XC|ude{OEOH1W&=FkLr!lHd!wlXVaAjwd8j!03YFiD@@ z<;Q3y>{Sx8y*4+pjR&JOw5XQF(KSU-Z{aBzUTR^?1-ZFPJnM<#auNc$xKBu5i@hCD zG4!hD&PTcjB#HS1Ov&|7eFkFHym1hl<%t!#Sov`U7>K@A{BC-7$>OZEP65}O|HmR| zKpUD?Bapt!5M27ztXh>5FI4W}tuY0tdC4V(0^=<*W|A+vcrn%nDxD_FcuMXZi(h+l zvzGSWF7$p9FMV#w_i{wYuqZ~$8iPE7yY!fVQxMvhkM{dVNLOLlgHkc!dn?4xJj*EL}w~LyQV)awKZ} z)hlf}@F_Vi{2vY%zeQ@AIy!2YaeR9=fejwGHuM+t{T8^ijcKq^xj?C@5V(dp~OsN#>(1AB6Q?t(HCOVe!V{KTOB~S z2wOH@_1hMnFW-_PsK9*$bugMs**P$|>5z3QoIJz-Ms=OVju)`_cYNhmpZJBCm2+++?@{_-@LHk>Pek!GENUG;t5Q>Qp}HPJDcp zLM!c7F1HlpjQxcLS&31!&5$X^63!inXZ`r2`r%)PKG`N-PX3#c zZeu5a)0uh>hxP7*>EB*@DBu<8)1MqDqPaw8TwYd1jeYj4_I_nT4B`B0Xtf1_C_M;# zl{lyEuI$=UoP=cLP&BK0Ut{ZILI$x-4RkXg0SJ-wfLj>nQzIm^N187W{kC0UvVg6&5Dom~4uOqqZ|O69Yq;`UZ$v0BGBOGT`>B*L zPUYnsuAyW8+1mB;gQ8=e3%TYdr0$Mj{ZSx0DJAFr4`LKH4iXOt40gI1U#gBhLk!g8 zomeSDQx_(0-ySs5J{;UM=&W6YeVkYkGV#$BVQ;GyMZ5kAkz;vA!F1iH4%jKD^{OOh z$Kj+b=oH%sA%@6 zjYy#QS;+_N#SQ*O#@GP|Kp^(6a**ANM)CnOq$h&>I-Eb@1utdsr)Yp+aN_kG^KAW= zk<(!)`GZpXQ>2gwhM{Ut-k+MzF=}XyEu==NS%s;zpr!vYB~#({Hf+G?K`~*`{{}2S z2uI(xnHhl@lI};r=F>47JBL4`tA5#z+zv;ngWQXyCF2E1(IWxq(c zU@@7;1?S0e8K-(adbiZ9=bri{IZR(!sYMuuMv}gcg|Mxk2#}zy`O(n~S?6GzyMs=MBGSWj)t|(_BKB3OkoaCEZ-kF;Q$S zd)BY#sgS*K8ZQXj(Nmr&tp|*4=NUb`vstO1&_L|>`=`)*%tB=R`Rm!W@gPKi& zsYIJA%=oNs>|wKG_JgX7P|2&XPw|r28cxbBaFe*D2k*^?!uU_9X41{98hMTTsn*Kx zLP`H#c^MpfQ)8!>y}=g$YsZ5Vj4wIrlBg4d;?{dTf1AYXe!(-uEZ2g>`{II50qzl# zx|iM*z_;a}NeV`OI+~S(O0?vLoMe~kT$d{Cp$cdZWE=eFhy%&!RZO6c5^me3J6QQ% zIU@UvY@S;;M4(b7(-@Y?QNAP)&bq~JDjJ`7Gwwd-c{`WaYqWi$iVDZtt>83$O~U!w z;Bf*F${GR;GYL#62fVwhjG}v24~e;Ir+khZP!#AkxNugEY1*giMT8oFvmDweuGmQy zc9u!>2D~f{c2!^{j_q~lO|`dUk)jYBCp0ATcgAj#0<&{13%^M-P68$7hF#!8HeD!F zVkT+QfSzfE!j6@IX+JZA6Gs~y2U8yo#H+^RVH05|pqkgDidjo;fP&_j7TGlKgcsbU z1(EW&0q{4%fpHG}XdyX@!V1JIHtn{7sz_<`!@>#C#sh_C@L>LLFhyK>f#-BwYzDSl zL`ao9*v2*|^21>FK~3dZ*37JzTfbtE8hVHX* zv}P=O?o28Y&f0lOd7!-Mc3zX@b&)awfA}~vqpQFp5M3mG&tt3SY-<4*4%b>X%Xj^*iB&`)j{GIl;Y%!mIfrN=UKl|r9 zk$Ayj$Ua5z=u9AGo*b7q_Z%-{(~q^biDwHqC8SJGSRvoP!W3sNQXla?D0}sNMtp

cu1M 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 E1B0vi82|tP literal 0 HcmV?d00001 diff --git a/webapp/assets/images/iot-graphx.jpg b/webapp/assets/images/iot-graphx.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6a2c4286c35ae3cb76117b6e7dd5a8cb52c6d99c GIT binary patch literal 113746 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 MMLyroiTS(oZ@4$y7ytkO literal 0 HcmV?d00001 diff --git a/webapp/bs-config.json b/webapp/bs-config.json new file mode 100644 index 0000000..0041c6d --- /dev/null +++ b/webapp/bs-config.json @@ -0,0 +1,9 @@ +{ + "port": 8000, + "files": [ + "dist/**/*.{html,htm,css,js}" + ], + "server": { + "baseDir": "dist" + } +} \ No newline at end of file diff --git a/webapp/gulp.conf.js b/webapp/gulp.conf.js new file mode 100644 index 0000000..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: ` +

+ + + +
+ ` +}) + +export class AlertComponent { + + alerts$: Observable; + + constructor(private alertSvr: AlertService) { + this.alerts$ = this.alertSvr.alerts; + } + + onClose(al) { + this.alertSvr.del(al); + } + +} diff --git a/webapp/src/app/app.component.css b/webapp/src/app/app.component.css new file mode 100644 index 0000000..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 @@ + + + + +
+ +
\ 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 @@ +
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+   +
+
+ + +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {{ cmdInfo }} +
+
+
\ 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; + + buildForm: FormGroup; + subpathCtrl = new FormControl("", Validators.required); + + public cmdOutput: string; + public confValid: boolean; + public curProject: IProject; + public cmdInfo: string; + + private startTime: Map = new Map(); + + // 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; + + private _alerts: IAlert[]; + private alertsSubject = >new Subject(); + private uid = 0; + private defaultDissmissTmo = 5; // in seconds + + constructor(private sanitizer: DomSanitizer) { + this.alerts = this.alertsSubject.asObservable(); + this._alerts = []; + this.uid = 0; + } + + public error(msg: string) { + 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; + + private confSubject: BehaviorSubject; + 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 = >new BehaviorSubject(this.confStore); + this.conf = this.confSubject.asObservable(); + + // force to load projects + this.loadProjects(); + } + + // Load config + load() { + // Try to retrieve previous config from cookie + let cookConf = this.cookie.getObject("xds-config"); + if (cookConf != null) { + this.confStore = cookConf; + } else { + // Set default config + this.confStore = { + 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; + + 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 = >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 { + 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 { + if (this._status.ID != null) { + return Observable.of(this._status.ID); + } + return this.getStatus(retry).map(sts => sts.ID); + } + + getStatus(retry?: number): Observable { + + 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 { + return this._getConfig() + .map((conf) => conf.folders); + } + + addProject(prj: ISyncThingProject): Observable { + 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 { + let delPrj: ISTFolderConfiguration; + return this._getConfig() + .flatMap((conf: ISTConfiguration) => { + let idx = conf.folders.findIndex(item => item.id === id); + if (idx === -1) { + throw new Error("Cannot delete project: not found"); + } + delPrj = Object.assign({}, conf.folders[idx]); + conf.folders.splice(idx, 1); + return this._setConfig(conf); + }) + .map(() => delPrj); + } + + /* + * --- Private functions --- + */ + private _getConfig(): Observable { + return this._get('/system/config'); + } + + private _setConfig(cfg: ISTConfiguration): Observable { + return this._post('/system/config', cfg); + } + + private _attachAuthHeaders(options?: any) { + options = options || {}; + let headers = options.headers || new Headers(); + // headers.append('Authorization', 'Basic ' + btoa('username:password')); + headers.append('Accept', 'application/json'); + headers.append('Content-Type', 'application/json'); + if (this.apikey !== "") { + headers.append('X-API-Key', this.apikey); + + } + options.headers = headers; + return options; + } + + private _checkAlive(): Observable { + 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 { + 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 { + return this._getAPIVersion().map(ver => { + if (ver !== ISTCONFIG_VERSION) { + throw new Error("Unsupported Syncthing version api (" + ver + + " != " + ISTCONFIG_VERSION + ") !"); + } + return ver; + }); + } + + private _get(url: string): Observable { + return this._checkAPIVersion() + .flatMap(() => this.http.get(this.baseRestUrl + url, this._attachAuthHeaders())) + .map((res: Response) => res.json()) + .catch(this._handleError); + } + + private _post(url: string, body: any): Observable { + return this._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$ = >new Subject(); + public CmdExit$ = >new Subject(); + public Status$: Observable; + + private baseUrl: string; + private wsUrl: string; + private _status = { WS_connected: false }; + private statusSubject = >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({}, data)); + }); + + this.socket.on('make:exit', data => { + this.CmdExit$.next(Object.assign({}, data)); + }); + + } + + getProjects(): Observable { + return this._get('/folders'); + } + + addProject(cfg: IXDSConfigProject): Observable { + 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 { + return this._delete('/folder/' + id); + } + + exec(cmd: string, args?: string[], options?: any): Observable { + return this._post('/exec', + { + cmd: cmd, + args: args || [] + }); + } + + make(prjID: string, dir: string, args: string): Observable { + 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 { + return this.http.get(this.baseUrl + url, this._attachAuthHeaders()) + .map((res: Response) => res.json()) + .catch(this._decodeError); + } + private _post(url: string, body: any): Observable { + return this.http.post(this.baseUrl + url, JSON.stringify(body), this._attachAuthHeaders()) + .map((res: Response) => res.json()) + .catch((error) => { + return this._decodeError(error); + }); + } + private _delete(url: string): Observable { + return this.http.delete(this.baseUrl + url, this._attachAuthHeaders()) + .map((res: Response) => res.json()) + .catch(this._decodeError); + } + + private _decodeError(err: any) { + let e: string; + if (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 @@ +
+
+

Global Configuration

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

Projects Configuration

+
+
+
+
+
+ +
+ +
+ + +
+
+ + +
+
+
+ +
+ +
+
+
+ + + +
+ {{config$ | async | json}} +
\ 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; + severStatus$: Observable; + localSTStatus$: Observable; + + 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: ` + +
+ + + + + + +
+ ` +}) + +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: ` +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + +
 Project ID{{ project.id }}
 Folder path{{ project.path}}
 Synchronization type{{ project.type | readableType }}
+ `, + 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: ` + + +
+ {{ prj.label }} + +
+ +
+
+ ` +}) +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 @@ + + + + + XDS Dashboard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Loading... + +
+
+ + + \ 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" + } +} -- 2.16.6