diff options
Diffstat (limited to 'webapp')
33 files changed, 2163 insertions, 0 deletions
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" + } +} |