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