diff options
Diffstat (limited to 'webapp/src/app')
20 files changed, 1647 insertions, 0 deletions
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[]; + +} + + |