diff options
author | Sebastien Douheret <sebastien.douheret@iot.bzh> | 2018-02-23 18:52:49 +0100 |
---|---|---|
committer | Sebastien Douheret <sebastien.douheret@iot.bzh> | 2018-04-05 01:29:05 +0200 |
commit | 069de98bdd926cb25954aad94fe23be0272a7b5e (patch) | |
tree | cb77c241cc0a257d574187e333e106b811ad4f3d | |
parent | 6aa5e4ccb5adadcc0cb802c44c1e88a35a20a925 (diff) |
Added target and terminal support in Dashboard
Signed-off-by: Sebastien Douheret <sebastien.douheret@iot.bzh>
27 files changed, 1757 insertions, 59 deletions
diff --git a/lib/agent/apiv1-targets.go b/lib/agent/apiv1-targets.go index 5a7862a..cc08822 100644 --- a/lib/agent/apiv1-targets.go +++ b/lib/agent/apiv1-targets.go @@ -18,6 +18,7 @@ package agent import ( + "encoding/json" "fmt" "net/http" @@ -52,6 +53,110 @@ func (s *APIService) targetsPassthroughInit(svr *XdsServer) error { return nil } +// targetsEventsForwardInit Register events forwarder for targets +func (s *APIService) targetsEventsForwardInit(svr *XdsServer) error { + + if !svr.Connected { + return fmt.Errorf("Cannot register events: XDS Server %v not connected", svr.ID) + } + + // Forward Target events from XDS-server to client + if _, err := svr.EventOn(xsapiv1.EVTTargetAdd, xaapiv1.EVTTargetAdd, s._targetsEventCB); err != nil { + s.Log.Errorf("XDS Server EventOn '%s' failed: %v", xsapiv1.EVTTargetAdd, err) + return err + } + if _, err := svr.EventOn(xsapiv1.EVTTargetRemove, xaapiv1.EVTTargetRemove, s._targetsEventCB); err != nil { + s.Log.Errorf("XDS Server EventOn '%s' failed: %v", xsapiv1.EVTTargetRemove, err) + return err + } + if _, err := svr.EventOn(xsapiv1.EVTTargetStateChange, xaapiv1.EVTTargetStateChange, s._targetsEventCB); err != nil { + s.Log.Errorf("XDS Server EventOn '%s' failed: %v", xsapiv1.EVTTargetStateChange, err) + return err + } + + return nil +} + +func (s *APIService) _targetsEventCB(privD interface{}, data interface{}) error { + evt := xsapiv1.EventMsg{} + d, err := json.Marshal(data) + if err != nil { + s.Log.Errorf("Cannot marshal XDS Server Target event err=%v, data=%v", err, data) + return err + } + if err = json.Unmarshal(d, &evt); err != nil { + s.Log.Errorf("Cannot unmarshal XDS Server Target event err=%v, d=%v", err, string(d)) + return err + } + + // assume that xsapiv1.TargetConfig == xaapiv1.TargetConfig + target, err := evt.DecodeTargetEvent() + if err != nil { + s.Log.Errorf("Cannot decode XDS Server Target event: err=%v, data=%v", err, data) + return err + } + + evtName := privD.(string) + + if err := s.events.Emit(evtName, target, ""); err != nil { + s.Log.Warningf("Cannot notify %s (from server): %v", evtName, err) + return err + } + return nil +} + +// terminalsEventsForwardInit Register events forwarder for terminals +func (s *APIService) terminalsEventsForwardInit(svr *XdsServer) error { + + if !svr.Connected { + return fmt.Errorf("Cannot register events: XDS Server %v not connected", svr.ID) + } + + // Forward Terminal events from XDS-server to client + if _, err := svr.EventOn(xsapiv1.EVTTargetTerminalAdd, xaapiv1.EVTTargetTerminalAdd, s._terminalsEventCB); err != nil { + s.Log.Errorf("XDS Server EventOn '%s' failed: %v", xsapiv1.EVTTargetTerminalAdd, err) + return err + } + if _, err := svr.EventOn(xsapiv1.EVTTargetTerminalRemove, xaapiv1.EVTTargetTerminalRemove, s._terminalsEventCB); err != nil { + s.Log.Errorf("XDS Server EventOn '%s' failed: %v", xsapiv1.EVTTargetTerminalRemove, err) + return err + } + if _, err := svr.EventOn(xsapiv1.EVTTargetTerminalStateChange, xaapiv1.EVTTargetTerminalStateChange, s._terminalsEventCB); err != nil { + s.Log.Errorf("XDS Server EventOn '%s' failed: %v", xsapiv1.EVTTargetTerminalStateChange, err) + return err + } + + return nil +} + +func (s *APIService) _terminalsEventCB(privD interface{}, data interface{}) error { + evt := xsapiv1.EventMsg{} + d, err := json.Marshal(data) + if err != nil { + s.Log.Errorf("Cannot marshal XDS Server Target event err=%v, data=%v", err, data) + return err + } + if err = json.Unmarshal(d, &evt); err != nil { + s.Log.Errorf("Cannot unmarshal XDS Server Target event err=%v, d=%v", err, string(d)) + return err + } + + // assume that xsapiv1.TargetConfig == xaapiv1.TargetConfig + target, err := evt.DecodeTerminalEvent() + if err != nil { + s.Log.Errorf("Cannot decode XDS Server Target event: err=%v, data=%v", err, data) + return err + } + + evtName := privD.(string) + + if err := s.events.Emit(evtName, target, ""); err != nil { + s.Log.Warningf("Cannot notify %s (from server): %v", evtName, err) + return err + } + return nil +} + // GetServerFromTargetID Retrieve XDS Server definition from a target ID func (s *APIService) GetServerFromTargetID(targetID, termID string) (*XdsServer, string, error) { diff --git a/lib/agent/apiv1.go b/lib/agent/apiv1.go index 730e7c0..97165b3 100644 --- a/lib/agent/apiv1.go +++ b/lib/agent/apiv1.go @@ -137,6 +137,12 @@ func (s *APIService) AddXdsServer(cfg xdsconfig.XDSServerConf) (*XdsServer, erro if err := s.sdksEventsForwardInit(server); err != nil { s.Log.Errorf("XDS Server %v - sdk events forwarding error: %v", server.ID, err) } + if err := s.targetsEventsForwardInit(server); err != nil { + s.Log.Errorf("XDS Server %v - target events forwarding error: %v", server.ID, err) + } + if err := s.terminalsEventsForwardInit(server); err != nil { + s.Log.Errorf("XDS Server %v - terminal events forwarding error: %v", server.ID, err) + } // Load projects if err := s.projects.Init(server); err != nil { diff --git a/lib/agent/projects.go b/lib/agent/projects.go index ff28f96..0bd5315 100644 --- a/lib/agent/projects.go +++ b/lib/agent/projects.go @@ -26,10 +26,10 @@ import ( "time" st "gerrit.automotivelinux.org/gerrit/src/xds/xds-agent/lib/syncthing" + "gerrit.automotivelinux.org/gerrit/src/xds/xds-server.git/lib/xsapiv1" "gerrit.automotivelinux.org/gerrit/src/xds/xds-agent/lib/xaapiv1" common "gerrit.automotivelinux.org/gerrit/src/xds/xds-common.git/golib" - "gerrit.automotivelinux.org/gerrit/src/xds/xds-server.git/lib/xsapiv1" "github.com/franciscocpg/reflectme" "github.com/syncthing/syncthing/lib/sync" ) diff --git a/lib/xaapiv1/events.go b/lib/xaapiv1/events.go index 6520057..3a47e49 100644 --- a/lib/xaapiv1/events.go +++ b/lib/xaapiv1/events.go @@ -40,15 +40,25 @@ const ( EventTypePrefix = "event:" // following by event type // Supported Events type - EVTAll = EventTypePrefix + "all" - EVTServerConfig = EventTypePrefix + "server-config" // type EventMsg with Data type xaapiv1.ServerCfg - EVTProjectAdd = EventTypePrefix + "project-add" // type EventMsg with Data type xaapiv1.ProjectConfig - EVTProjectDelete = EventTypePrefix + "project-delete" // type EventMsg with Data type xaapiv1.ProjectConfig - EVTProjectChange = EventTypePrefix + "project-state-change" // type EventMsg with Data type xaapiv1.ProjectConfig - EVTSDKAdd = EventTypePrefix + "sdk-add" // type EventMsg with Data type xaapiv1.SDK - EVTSDKRemove = EventTypePrefix + "sdk-remove" // type EventMsg with Data type xaapiv1.SDK - EVTSDKManagement = EventTypePrefix + "sdk-management" // type EventMsg with Data type xaapiv1.SDKManagementMsg - EVTSDKStateChange = EventTypePrefix + "sdk-state-change" // type EventMsg with Data type xaapiv1.SDK + EVTAll = EventTypePrefix + "all" + + EVTServerConfig = EventTypePrefix + "server-config" // type EventMsg with Data type xaapiv1.ServerCfg + EVTProjectAdd = EventTypePrefix + "project-add" // type EventMsg with Data type xaapiv1.ProjectConfig + EVTProjectDelete = EventTypePrefix + "project-delete" // type EventMsg with Data type xaapiv1.ProjectConfig + EVTProjectChange = EventTypePrefix + "project-state-change" // type EventMsg with Data type xaapiv1.ProjectConfig + + EVTSDKAdd = EventTypePrefix + "sdk-add" // type EventMsg with Data type xaapiv1.SDK + EVTSDKRemove = EventTypePrefix + "sdk-remove" // type EventMsg with Data type xaapiv1.SDK + EVTSDKManagement = EventTypePrefix + "sdk-management" // type EventMsg with Data type xaapiv1.SDKManagementMsg + EVTSDKStateChange = EventTypePrefix + "sdk-state-change" // type EventMsg with Data type xaapiv1.SDK + + EVTTargetAdd = EventTypePrefix + "target-add" // type EventMsg with Data type xaapiv1.TargetConfig + EVTTargetRemove = EventTypePrefix + "target-remove" // type EventMsg with Data type xaapiv1.TargetConfig + EVTTargetStateChange = EventTypePrefix + "target-state-change" // type EventMsg with Data type xaapiv1.TargetConfig + + EVTTargetTerminalAdd = EventTypePrefix + "target-terminal-add" // type EventMsg with Data type xaapiv1.TerminalConfig + EVTTargetTerminalRemove = EventTypePrefix + "target-terminal-remove" // type EventMsg with Data type xaapiv1.TerminalConfig + EVTTargetTerminalStateChange = EventTypePrefix + "target-terminal-state-change" // type EventMsg with Data type xaapiv1.TerminalConfig ) // EVTAllList List of all supported events @@ -61,6 +71,12 @@ var EVTAllList = []string{ EVTSDKRemove, EVTSDKManagement, EVTSDKStateChange, + EVTTargetAdd, + EVTTargetRemove, + EVTTargetStateChange, + EVTTargetTerminalAdd, + EVTTargetTerminalRemove, + EVTTargetTerminalStateChange, } // EventMsg Event message send over Websocket, data format depend to Type (see DecodeXXX function) @@ -132,3 +148,37 @@ func (e *EventMsg) DecodeSDKEvent() (SDK, error) { } return s, err } + +// DecodeTargetEvent Helper to decode Data field type TargetConfig +func (e *EventMsg) DecodeTargetEvent() (TargetConfig, error) { + var err error + p := TargetConfig{} + switch e.Type { + case EVTTargetAdd, EVTTargetRemove, EVTTargetStateChange: + d := []byte{} + d, err = json.Marshal(e.Data) + if err == nil { + err = json.Unmarshal(d, &p) + } + default: + err = fmt.Errorf("Invalid type") + } + return p, err +} + +// DecodeTerminalEvent Helper to decode Data field type TerminalConfig +func (e *EventMsg) DecodeTerminalEvent() (TerminalConfig, error) { + var err error + p := TerminalConfig{} + switch e.Type { + case EVTTargetTerminalAdd, EVTTargetTerminalRemove, EVTTargetTerminalStateChange: + d := []byte{} + d, err = json.Marshal(e.Data) + if err == nil { + err = json.Unmarshal(d, &p) + } + default: + err = fmt.Errorf("Invalid type") + } + return p, err +} diff --git a/webapp/.angular-cli.json b/webapp/.angular-cli.json index ade79a2..9aa0157 100644 --- a/webapp/.angular-cli.json +++ b/webapp/.angular-cli.json @@ -27,6 +27,7 @@ "../node_modules/font-awesome-animation/dist/font-awesome-animation.min.css", "../node_modules/nebular-icons/scss/nebular-icons.scss", "../node_modules/pace-js/templates/pace-theme-flash.tmpl.css", + "../node_modules/xterm/dist/xterm.css", "./app/@theme/styles/styles.scss" ], "scripts": [ diff --git a/webapp/package.json b/webapp/package.json index a96586c..8176cf2 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -72,6 +72,7 @@ "tether": "1.4.0", "typeface-exo": "0.0.22", "web-animations-js": "2.2.5", + "xterm": "^3.0.0", "zone.js": "0.8.18" }, "devDependencies": { diff --git a/webapp/src/app/@core-xds/services/@core-xds-services.module.ts b/webapp/src/app/@core-xds/services/@core-xds-services.module.ts index 7c380eb..a3a67c5 100644 --- a/webapp/src/app/@core-xds/services/@core-xds-services.module.ts +++ b/webapp/src/app/@core-xds/services/@core-xds-services.module.ts @@ -23,6 +23,7 @@ import { AlertService } from './alert.service'; import { ConfigService } from './config.service'; import { ProjectService } from './project.service'; import { SdkService } from './sdk.service'; +import { TargetService } from './target.service'; import { UserService } from './users.service'; import { XDSConfigService } from './xds-config.service'; import { XDSAgentService } from './xdsagent.service'; @@ -32,6 +33,7 @@ const SERVICES = [ ConfigService, ProjectService, SdkService, + TargetService, UserService, XDSConfigService, XDSAgentService, diff --git a/webapp/src/app/@core-xds/services/target.service.ts b/webapp/src/app/@core-xds/services/target.service.ts new file mode 100644 index 0000000..9c995ea --- /dev/null +++ b/webapp/src/app/@core-xds/services/target.service.ts @@ -0,0 +1,285 @@ +/** +* @license +* Copyright (C) 2018 "IoT.bzh" +* Author Sebastien Douheret <sebastien@iot.bzh> +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { Injectable, SecurityContext, isDevMode } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { Subject } from 'rxjs/Subject'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + +import { XDSAgentService, IXDSTargetConfig, IXDSTargetTerminal } from '../services/xdsagent.service'; + +/* FIXME: syntax only compatible with TS>2.4.0 +export enum TargetTypeEnum { + UNSET = '', + STANDARD: 'standard', +} +*/ +export type TargetTypeEnum = '' | 'standard'; +export const TargetType = { + UNSET: <TargetTypeEnum>'', + STANDARD: <TargetTypeEnum>'standard', +}; + +export const TargetTypes = [ + { value: TargetType.STANDARD, display: 'Standard' }, +]; + +export const TargetStatus = { + ErrorConfig: 'ErrorConfig', + Disable: 'Disable', + Enable: 'Enable', +}; + +export type TerminalTypeEnum = '' | 'ssh'; +export const TerminalType = { + UNSET: <TerminalTypeEnum>'', + SSH: <TerminalTypeEnum>'ssh', +}; + +export interface ITarget extends IXDSTargetConfig { + isUsable?: boolean; +} + +export interface ITerminal extends IXDSTargetTerminal { + targetID?: string; +} + +export interface ITerminalOutput { + termID: string; + timestamp: string; + stdout: string; + stderr: string; +} + +export interface ITerminalExit { + termID: string; + timestamp: string; + code: number; + error: string; +} + +@Injectable() +export class TargetService { + public targets$: Observable<ITarget[]>; + public curTarget$: Observable<ITarget>; + public terminalOutput$ = <Subject<ITerminalOutput>>new Subject(); + public terminalExit$ = <Subject<ITerminalExit>>new Subject(); + + private _tgtsList: ITarget[] = []; + private tgtsSubject = <BehaviorSubject<ITarget[]>>new BehaviorSubject(this._tgtsList); + private _current: ITarget; + private curTgtSubject = <BehaviorSubject<ITarget>>new BehaviorSubject(this._current); + private curServerID; + private termSocket: SocketIOClient.Socket; + + constructor(private xdsSvr: XDSAgentService) { + this._current = null; + this.targets$ = this.tgtsSubject.asObservable(); + this.curTarget$ = this.curTgtSubject.asObservable(); + + this.xdsSvr.XdsConfig$.subscribe(cfg => { + if (!cfg || cfg.servers.length < 1) { + return; + } + + // FIXME support multiple server + this.curServerID = cfg.servers[0].id; + + // Load initial targets list + this.xdsSvr.getTargets(this.curServerID).subscribe((targets) => { + this._tgtsList = []; + targets.forEach(p => { + this._addTarget(p, true); + }); + + // TODO: get previous val from xds-config service / cookie + if (this._tgtsList.length > 0) { + this._current = this._tgtsList[0]; + this.curTgtSubject.next(this._current); + } + + this.tgtsSubject.next(this._tgtsList); + }); + }); + + // Add listener on targets creation, deletion and change events + this.xdsSvr.onTargetAdd().subscribe(tgt => this._addTarget(tgt)); + this.xdsSvr.onTargetDelete().subscribe(tgt => this._delTarget(tgt)); + this.xdsSvr.onTargetChange().subscribe(tgt => this._updateTarget(tgt)); + + // Register events to forward terminal Output and Exit + this.xdsSvr.onSocketConnect().subscribe(socket => { + this.termSocket = socket; + + // Handle terminal output + socket.on('term:output', data => { + const termOut = <ITerminalOutput>{ + termID: data.termID, + timestamp: data.timestamp, + stdout: atob(data.stdout), + stderr: atob(data.stderr), + }; + this.terminalOutput$.next(termOut); + }); + + // Handle terminal exit event + socket.on('term:exit', data => { + this.terminalExit$.next(Object.assign({}, <ITerminalExit>data)); + }); + + }); + } + + setCurrent(p: ITarget): ITarget | undefined { + if (!p) { + this._current = null; + return undefined; + } + return this.setCurrentById(p.id); + } + + setCurrentById(id: string): ITarget | undefined { + const p = this._tgtsList.find(item => item.id === id); + if (p) { + this._current = p; + this.curTgtSubject.next(this._current); + } + return this._current; + } + + getCurrent(): ITarget { + return this._current; + } + + getTargetById(id: string): ITarget | undefined { + const t = this._tgtsList.find(item => item.id === id); + return t; + } + + add(tgt: ITarget): Observable<ITarget> { + return this.xdsSvr.addTarget(this.curServerID, tgt); + } + + delete(tgt: ITarget): Observable<ITarget> { + const idx = this._getTargetIdx(tgt.id); + const delTgt = tgt; + if (idx === -1) { + throw new Error('Invalid target id (id=' + tgt.id + ')'); + } + return this.xdsSvr.deleteTarget(this.curServerID, tgt.id) + .map(res => delTgt); + } + + setSettings(tgt: ITarget): Observable<ITarget> { + return this.xdsSvr.updateTarget(this.curServerID, tgt); + } + + terminalOpen(tgtID: string, termID: string, cfg?: IXDSTargetTerminal): Observable<IXDSTargetTerminal> { + if (termID === '' || termID === undefined) { + // create a new terminal when no termID is set + if (cfg === undefined) { + cfg = <IXDSTargetTerminal>{ + name: 'ssh to ' + this.getTargetById(tgtID).name, + type: TerminalType.SSH, + }; + } + return this.xdsSvr.createTerminalTarget(this.curServerID, tgtID, cfg) + .flatMap(res => { + return this.xdsSvr.openTerminalTarget(this.curServerID, tgtID, res.id); + }); + } else { + return this.xdsSvr.openTerminalTarget(this.curServerID, tgtID, termID); + } + } + + terminalClose(tgtID, termID: string): Observable<IXDSTargetTerminal> { + return this.xdsSvr.closeTerminalTarget(this.curServerID, tgtID, termID); + } + + terminalWrite(data: string) { + if (this.termSocket) { + this.termSocket.emit('term:input', btoa(data)); + } + } + + terminalResize(tgtID, termID: string, cols, rows: number): Observable<IXDSTargetTerminal> { + return this.xdsSvr.resizeTerminalTarget(this.curServerID, tgtID, termID, cols, rows); + } + + /*** Private functions ***/ + + private _isUsableTarget(p) { + return p && (p.status === TargetStatus.Enable); + } + + private _getTargetIdx(id: string): number { + return this._tgtsList.findIndex((item) => item.id === id); + } + + private _addTarget(tgt: ITarget, noNext?: boolean): ITarget { + + tgt.isUsable = this._isUsableTarget(tgt); + + // add new target + this._tgtsList.push(tgt); + + // sort target array + this._tgtsList.sort((a, b) => { + if (a.name < b.name) { + return -1; + } + if (a.name > b.name) { + return 1; + } + return 0; + }); + + if (!noNext) { + this.tgtsSubject.next(this._tgtsList); + } + + return tgt; + } + + private _delTarget(tgt: ITarget) { + const idx = this._tgtsList.findIndex(item => item.id === tgt.id); + if (idx === -1) { + if (isDevMode) { + /* tslint:disable:no-console */ + console.log('Warning: Try to delete target unknown id: tgt=', tgt); + } + return; + } + const delId = this._tgtsList[idx].id; + this._tgtsList.splice(idx, 1); + if (delId === this._current.id) { + this.setCurrent(this._tgtsList[0]); + } + this.tgtsSubject.next(this._tgtsList); + } + + private _updateTarget(tgt: ITarget) { + const i = this._getTargetIdx(tgt.id); + if (i >= 0) { + this._tgtsList[i].status = tgt.status; + this._tgtsList[i].isUsable = this._isUsableTarget(tgt); + this.tgtsSubject.next(this._tgtsList); + } + } + +} diff --git a/webapp/src/app/@core-xds/services/xdsagent.service.ts b/webapp/src/app/@core-xds/services/xdsagent.service.ts index 033185b..adbee98 100644 --- a/webapp/src/app/@core-xds/services/xdsagent.service.ts +++ b/webapp/src/app/@core-xds/services/xdsagent.service.ts @@ -27,6 +27,7 @@ import * as io from 'socket.io-client'; import { AlertService } from './alert.service'; import { ISdk, ISdkManagementMsg } from './sdk.service'; import { ProjectType, ProjectTypeEnum } from './project.service'; +import { TargetType, TargetTypeEnum } from './target.service'; // Import RxJs required methods import 'rxjs/add/operator/map'; @@ -65,6 +66,25 @@ export interface IXDSProjectConfig { clientData?: string; } +/** Targets **/ +export interface IXDSTargetConfig { + id?: string; + name: string; + type: TargetTypeEnum; + ip: string; + status?: string; + terms?: IXDSTargetTerminal[]; +} + +export interface IXDSTargetTerminal { + id?: string; + type: string; + name: string; + status?: string; + cols?: number; + rows?: number; +} + export interface IXDSVer { id: string; version: string; @@ -124,11 +144,15 @@ export interface IAgentStatus { @Injectable() export class XDSAgentService { + public Socket: SocketIOClient.Socket; public XdsConfig$: Observable<IXDSConfig>; public Status$: Observable<IAgentStatus>; public CmdOutput$ = <Subject<ICmdOutput>>new Subject(); public CmdExit$ = <Subject<ICmdExit>>new Subject(); + protected sockConnect$ = new Subject<SocketIOClient.Socket>(); + protected sockDisconnect$ = new Subject<SocketIOClient.Socket>(); + protected projectAdd$ = new Subject<IXDSProjectConfig>(); protected projectDel$ = new Subject<IXDSProjectConfig>(); protected projectChange$ = new Subject<IXDSProjectConfig>(); @@ -138,6 +162,15 @@ export class XDSAgentService { protected sdkChange$ = new Subject<ISdk>(); protected sdkManagement$ = new Subject<ISdkManagementMsg>(); + protected targetAdd$ = new Subject<IXDSTargetConfig>(); + protected targetDel$ = new Subject<IXDSTargetConfig>(); + protected targetChange$ = new Subject<IXDSTargetConfig>(); + + protected targetTerminalAdd$ = new Subject<IXDSTargetTerminal>(); + protected targetTerminalDel$ = new Subject<IXDSTargetTerminal>(); + protected targetTerminalChange$ = new Subject<IXDSTargetTerminal>(); + + private _socket: SocketIOClient.Socket; private baseUrl: string; private wsUrl: string; private httpSessionID: string; @@ -147,9 +180,9 @@ export class XDSAgentService { private configSubject = <BehaviorSubject<IXDSConfig>>new BehaviorSubject(this._config); private statusSubject = <BehaviorSubject<IAgentStatus>>new BehaviorSubject(this._status); - private socket: SocketIOClient.Socket; - constructor( @Inject(DOCUMENT) private document: Document, + + constructor(@Inject(DOCUMENT) private document: Document, private http: HttpClient, private alert: AlertService) { this.XdsConfig$ = this.configSubject.asObservable(); @@ -161,22 +194,22 @@ export class XDSAgentService { // Retrieve Session ID / token this.http.get(this.baseUrl + '/version', { observe: 'response' }) .subscribe( - resp => { - this.httpSessionID = resp.headers.get('xds-agent-sid'); - - const re = originUrl.match(/http[s]?:\/\/([^\/]*)[\/]?/); - if (re === null || re.length < 2) { - console.error('ERROR: cannot determine Websocket url'); - } else { - this.wsUrl = 'ws://' + re[1]; - this._handleIoSocket(); - this._RegisterEvents(); - } - }, - err => { - /* tslint:disable:no-console */ - console.error('ERROR while retrieving session id:', err); - }); + resp => { + this.httpSessionID = resp.headers.get('xds-agent-sid'); + + const re = originUrl.match(/http[s]?:\/\/([^\/]*)[\/]?/); + if (re === null || re.length < 2) { + console.error('ERROR: cannot determine Websocket url'); + } else { + this.wsUrl = 'ws://' + re[1]; + this._handleIoSocket(); + this._RegisterEvents(); + } + }, + err => { + /* tslint:disable:no-console */ + console.error('ERROR while retrieving session id:', err); + }); } private _NotifyXdsAgentState(sts: boolean) { @@ -201,45 +234,39 @@ export class XDSAgentService { } private _handleIoSocket() { - this.socket = io(this.wsUrl, { transports: ['websocket'] }); + this.Socket = this._socket = io(this.wsUrl, { transports: ['websocket'] }); - this.socket.on('connect_error', (res) => { + this._socket.on('connect_error', (res) => { this._NotifyXdsAgentState(false); console.error('XDS Agent WebSocket Connection error !'); }); - this.socket.on('connect', (res) => { + this._socket.on('connect', (res) => { this._NotifyXdsAgentState(true); + this.sockConnect$.next(this._socket); }); - this.socket.on('disconnection', (res) => { + this._socket.on('disconnection', (res) => { this._NotifyXdsAgentState(false); this.alert.error('WS disconnection: ' + res); + this.sockDisconnect$.next(this._socket); }); - this.socket.on('error', (err) => { + this._socket.on('error', (err) => { console.error('WS error:', err); }); // XDS Events decoding - 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)); - }); - - this.socket.on('exec:output', data => { + this._socket.on('exec:output', data => { this.CmdOutput$.next(Object.assign({}, <ICmdOutput>data)); }); - this.socket.on('exec:exit', data => { + this._socket.on('exec:exit', data => { this.CmdExit$.next(Object.assign({}, <ICmdExit>data)); }); - this.socket.on('event:server-config', ev => { + this._socket.on('event:server-config', ev => { if (ev && ev.data) { const cfg: IXDServerCfg = ev.data; const idx = this._config.servers.findIndex(el => el.id === cfg.id); @@ -253,7 +280,7 @@ export class XDSAgentService { /*** Project events ****/ - this.socket.on('event:project-add', (ev) => { + this._socket.on('event:project-add', (ev) => { if (ev && ev.data && ev.data.id) { this.projectAdd$.next(Object.assign({}, ev.data)); if (ev.sessionID !== '' && ev.sessionID !== this.httpSessionID && ev.data.label) { @@ -265,7 +292,7 @@ export class XDSAgentService { } }); - this.socket.on('event:project-delete', (ev) => { + this._socket.on('event:project-delete', (ev) => { if (ev && ev.data && ev.data.id) { this.projectDel$.next(Object.assign({}, ev.data)); if (ev.sessionID !== '' && ev.sessionID !== this.httpSessionID && ev.data.label) { @@ -276,17 +303,17 @@ export class XDSAgentService { } }); - this.socket.on('event:project-state-change', ev => { + this._socket.on('event:project-state-change', ev => { if (ev && ev.data) { this.projectChange$.next(Object.assign({}, ev.data)); } else if (isDevMode) { - console.log('Warning: received event:project-state-change with unkn220own data: ev=', ev); + console.log('Warning: received event:project-state-change with unknown data: ev=', ev); } }); /*** SDK Events ***/ - this.socket.on('event:sdk-add', (ev) => { + this._socket.on('event:sdk-add', (ev) => { if (ev && ev.data && ev.data.id) { const evt = <ISdk>ev.data; this.sdkAdd$.next(Object.assign({}, evt)); @@ -299,7 +326,7 @@ export class XDSAgentService { } }); - this.socket.on('event:sdk-remove', (ev) => { + this._socket.on('event:sdk-remove', (ev) => { if (ev && ev.data && ev.data.id) { const evt = <ISdk>ev.data; this.sdkRemove$.next(Object.assign({}, evt)); @@ -312,7 +339,7 @@ export class XDSAgentService { } }); - this.socket.on('event:sdk-state-change', (ev) => { + this._socket.on('event:sdk-state-change', (ev) => { if (ev && ev.data && ev.data.id) { const evt = <ISdk>ev.data; this.sdkChange$.next(Object.assign({}, evt)); @@ -322,8 +349,7 @@ export class XDSAgentService { } }); - - this.socket.on('event:sdk-management', (ev) => { + this._socket.on('event:sdk-management', (ev) => { if (ev && ev.data && ev.data.sdk) { const evt = <ISdkManagementMsg>ev.data; this.sdkManagement$.next(Object.assign({}, evt)); @@ -337,11 +363,86 @@ export class XDSAgentService { } }); + /*** Target events ****/ + + this._socket.on('event:target-add', (ev) => { + if (ev && ev.data && ev.data.id) { + this.targetAdd$.next(Object.assign({}, ev.data)); + if (ev.sessionID !== '' && ev.sessionID !== this.httpSessionID && ev.data.label) { + this.alert.info('Target "' + ev.data.label + '" has been added by another tool.'); + } + } else if (isDevMode) { + /* tslint:disable:no-console */ + console.log('Warning: received event:target-add with unknown data: ev=', ev); + } + }); + + this._socket.on('event:target-remove', (ev) => { + if (ev && ev.data && ev.data.id) { + this.targetDel$.next(Object.assign({}, ev.data)); + if (ev.sessionID !== '' && ev.sessionID !== this.httpSessionID && ev.data.label) { + this.alert.info('Target "' + ev.data.label + '" has been deleted by another tool.'); + } + } else if (isDevMode) { + console.log('Warning: received event:target-remove with unknown data: ev=', ev); + } + }); + + this._socket.on('event:target-state-change', ev => { + if (ev && ev.data) { + this.targetChange$.next(Object.assign({}, ev.data)); + } else if (isDevMode) { + console.log('Warning: received event:target-state-change with unknown data: ev=', ev); + } + }); + + /*** Target Terminal events ****/ + + this._socket.on('event:target-terminal-add', (ev) => { + if (ev && ev.data && ev.data.id) { + this.targetTerminalAdd$.next(Object.assign({}, ev.data)); + if (ev.sessionID !== '' && ev.sessionID !== this.httpSessionID && ev.data.label) { + this.alert.info('Target terminal "' + ev.data.label + '" has been added by another tool.'); + } + } else if (isDevMode) { + /* tslint:disable:no-console */ + console.log('Warning: received event:target-terminal-add with unknown data: ev=', ev); + } + }); + + this._socket.on('event:target-terminal-delete', (ev) => { + if (ev && ev.data && ev.data.id) { + this.targetTerminalDel$.next(Object.assign({}, ev.data)); + if (ev.sessionID !== '' && ev.sessionID !== this.httpSessionID && ev.data.label) { + this.alert.info('Target terminal "' + ev.data.label + '" has been deleted by another tool.'); + } + } else if (isDevMode) { + console.log('Warning: received event:target-terminal-delete with unknown data: ev=', ev); + } + }); + + this._socket.on('event:target-terminal-state-change', ev => { + if (ev && ev.data) { + this.targetTerminalChange$.next(Object.assign({}, ev.data)); + } else if (isDevMode) { + console.log('Warning: received event:target-terminal-state-change with unknown data: ev=', ev); + } + }); + } /** ** Events registration ***/ + + onSocketConnect(): Observable<any> { + return this.sockConnect$.asObservable(); + } + + onSocketDisconnect(): Observable<any> { + return this.sockDisconnect$.asObservable(); + } + onProjectAdd(): Observable<IXDSProjectConfig> { return this.projectAdd$.asObservable(); } @@ -370,6 +471,30 @@ export class XDSAgentService { return this.sdkManagement$.asObservable(); } + onTargetAdd(): Observable<IXDSTargetConfig> { + return this.targetAdd$.asObservable(); + } + + onTargetDelete(): Observable<IXDSTargetConfig> { + return this.targetDel$.asObservable(); + } + + onTargetChange(): Observable<IXDSTargetConfig> { + return this.targetChange$.asObservable(); + } + + onTargetTerminalAdd(): Observable<IXDSTargetTerminal> { + return this.targetTerminalAdd$.asObservable(); + } + + onTargetTerminalDelete(): Observable<IXDSTargetTerminal> { + return this.targetTerminalDel$.asObservable(); + } + + onTargetTerminalChange(): Observable<IXDSTargetTerminal> { + return this.targetTerminalChange$.asObservable(); + } + /** ** Misc / Version ***/ @@ -485,6 +610,61 @@ export class XDSAgentService { }); } + + /*** + ** Targets + ***/ + getTargets(serverID: string): Observable<IXDSTargetConfig[]> { + return this._get(this._getServerUrl(serverID) + '/targets'); + } + + addTarget(serverID: string, cfg: IXDSTargetConfig): Observable<IXDSTargetConfig> { + return this._post(this._getServerUrl(serverID) + '/targets', cfg); + } + + deleteTarget(serverID: string, id: string): Observable<IXDSTargetConfig> { + return this._delete(this._getServerUrl(serverID) + '/targets/' + id); + } + + updateTarget(serverID: string, cfg: IXDSTargetConfig): Observable<IXDSTargetConfig> { + return this._put(this._getServerUrl(serverID) + '/targets/' + cfg.id, cfg); + } + + /*** + ** Terminals + ***/ + getTerminalsTarget(serverID, targetID: string): Observable<IXDSTargetTerminal[]> { + return this._get(this._getServerUrl(serverID) + '/targets/' + targetID + '/terminals'); + } + + getTerminalTarget(serverID, targetID, termID: string): Observable<IXDSTargetTerminal> { + return this._get(this._getServerUrl(serverID) + '/targets/' + targetID + '/terminals/' + termID); + } + + createTerminalTarget(serverID, targetID: string, cfg: IXDSTargetTerminal): Observable<IXDSTargetTerminal> { + return this._post(this._getServerUrl(serverID) + '/targets/' + targetID + '/terminals', cfg); + } + + updateTerminalTarget(serverID, targetID: string, cfg: IXDSTargetTerminal): Observable<IXDSTargetTerminal> { + if (cfg && (cfg.id !== '' || cfg.id !== undefined)) { + return this._put(this._getServerUrl(serverID) + '/targets/' + targetID + '/terminals/' + cfg.id, cfg); + } + return Observable.throw('Undefined terminal id'); + } + + openTerminalTarget(serverID, targetID, termID: string): Observable<IXDSTargetTerminal> { + return this._post(this._getServerUrl(serverID) + '/targets/' + targetID + '/terminals/' + termID + '/open', {}); + } + + closeTerminalTarget(serverID, targetID, termID: string): Observable<IXDSTargetTerminal> { + return this._post(this._getServerUrl(serverID) + '/targets/' + targetID + '/terminals/' + termID + '/close', {}); + } + + resizeTerminalTarget(serverID, targetID, termID: string, cols, rows: number): Observable<IXDSTargetTerminal> { + return this._post(this._getServerUrl(serverID) + '/targets/' + targetID + '/terminals/' + termID + '/resize', + { cols: cols, rows: rows }); + } + /** ** Private functions ***/ @@ -493,10 +673,10 @@ export class XDSAgentService { // Register to all existing events this._post('/events/register', { 'name': 'event:all' }) .subscribe( - res => { }, - error => { - this.alert.error('ERROR while registering to all events: ' + error); - }, + res => { }, + error => { + this.alert.error('ERROR while registering to all events: ' + error); + }, ); } diff --git a/webapp/src/app/pages/pages-menu.ts b/webapp/src/app/pages/pages-menu.ts index 1e4839d..86884bc 100644 --- a/webapp/src/app/pages/pages-menu.ts +++ b/webapp/src/app/pages/pages-menu.ts @@ -66,9 +66,18 @@ export const MENU_ITEMS: NbMenuItem[] = [ */ }, { - title: 'Boards', + title: 'Targets', icon: 'fa fa-microchip', + link: '/pages/targets', children: [ + { + title: 'List', + link: '/pages/targets/list', + }, + { + title: 'Terminal', + link: '/pages/targets/term', + }, ], }, { diff --git a/webapp/src/app/pages/pages-routing.module.ts b/webapp/src/app/pages/pages-routing.module.ts index 7eeccd0..655dea2 100644 --- a/webapp/src/app/pages/pages-routing.module.ts +++ b/webapp/src/app/pages/pages-routing.module.ts @@ -24,6 +24,8 @@ import { DashboardComponent } from './dashboard/dashboard.component'; import { ProjectsComponent } from './projects/projects.component'; import { SdksComponent } from './sdks/sdks.component'; import { SdkManagementComponent } from './sdks/sdk-management/sdk-management.component'; +import { TargetsComponent } from './targets/targets.component'; +import { TerminalsComponent } from './targets/terminals/terminals.component'; import { BuildComponent } from './build/build.component'; const routes: Routes = [{ @@ -45,6 +47,12 @@ const routes: Routes = [{ path: 'build', component: BuildComponent, }, { + path: 'targets/list', + component: TargetsComponent, + }, { + path: 'targets/term', + component: TerminalsComponent, + }, { path: 'config', loadChildren: './config/config.module#ConfigModule', }, diff --git a/webapp/src/app/pages/pages.module.ts b/webapp/src/app/pages/pages.module.ts index 42a9a84..55fe61a 100644 --- a/webapp/src/app/pages/pages.module.ts +++ b/webapp/src/app/pages/pages.module.ts @@ -26,6 +26,7 @@ import { DashboardModule } from './dashboard/dashboard.module'; import { BuildModule } from './build/build.module'; import { ProjectsModule } from './projects/projects.module'; import { SdksModule } from './sdks/sdks.module'; +import { TargetsModule } from './targets/targets.module'; import { PagesRoutingModule } from './pages-routing.module'; import { NotificationsComponent } from './notifications/notifications.component'; import { ThemeModule } from '../@theme/theme.module'; @@ -46,6 +47,7 @@ const PAGES_COMPONENTS = [ ProjectsModule, SdksModule, ToasterModule, + TargetsModule, ], declarations: [ ...PAGES_COMPONENTS, diff --git a/webapp/src/app/pages/projects/projects.module.ts b/webapp/src/app/pages/projects/projects.module.ts index 7c4b0a8..54255f8 100644 --- a/webapp/src/app/pages/projects/projects.module.ts +++ b/webapp/src/app/pages/projects/projects.module.ts @@ -23,7 +23,6 @@ import { ProjectsComponent } from './projects.component'; import { ProjectCardComponent, ProjectReadableTypePipe } from './project-card/project-card.component'; import { ProjectAddModalComponent } from './project-add-modal/project-add-modal.component'; - @NgModule({ imports: [ ThemeModule, diff --git a/webapp/src/app/pages/targets/settings/target-select-dropdown.component.ts b/webapp/src/app/pages/targets/settings/target-select-dropdown.component.ts new file mode 100644 index 0000000..c124054 --- /dev/null +++ b/webapp/src/app/pages/targets/settings/target-select-dropdown.component.ts @@ -0,0 +1,57 @@ +/** +* @license +* Copyright (C) 2018 "IoT.bzh" +* Author Sebastien Douheret <sebastien@iot.bzh> +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { Component, OnInit, Input } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; + +import { ITarget, TargetService } from '../../../@core-xds/services/target.service'; + +@Component({ + selector: 'xds-target-select-dropdown', + template: ` + <div class="form-group row"> + <label class="col-sm-3 form-control-label" style="margin-top: auto; margin-bottom: auto;">Target</label> + <div class="col-sm-9"> + <select class="form-control" style="min-width: 10rem;" [(ngModel)]="curTgt" (click)="select()"> + <option *ngFor="let tgt of targets$ | async" [ngValue]="tgt">{{ tgt.name }}</option> + </select> + </div> + </div> + `, +}) +export class TargetSelectDropdownComponent implements OnInit { + + targets$: Observable<ITarget[]>; + curTgt: ITarget; + + constructor(private targetSvr: TargetService) { } + + ngOnInit() { + this.curTgt = this.targetSvr.getCurrent(); + this.targets$ = this.targetSvr.targets$; + this.targetSvr.curTarget$.subscribe(p => this.curTgt = p); + } + + select() { + if (this.curTgt) { + this.targetSvr.setCurrentById(this.curTgt.id); + } + } +} + + diff --git a/webapp/src/app/pages/targets/target-add-modal/target-add-modal.component.html b/webapp/src/app/pages/targets/target-add-modal/target-add-modal.component.html new file mode 100644 index 0000000..84424b4 --- /dev/null +++ b/webapp/src/app/pages/targets/target-add-modal/target-add-modal.component.html @@ -0,0 +1,46 @@ +<div class="modal-header"> + <span>Add a new target</span> + <button class="close" aria-label="Close" (click)="closeModal()"> + <span aria-hidden="true">×</span> + </button> +</div> + +<div class="modal-body row"> + <div class="col-12"> + <form [formGroup]="addTargetForm" (ngSubmit)="onSubmit()"> + + <div class="form-group row"> + <label for="sharing-type" class="col-sm-3 col-form-label">Target Type</label> + <div class="col-sm-9"> + <select id="select-sharing-type" class="form-control" formControlName="type"> + <option *ngFor="let t of targetTypes" [value]="t.value">{{t.display}} + </option> + </select> + </div> + </div> + + <div class="form-group row"> + <label for="select-ip" class="col-sm-3 col-form-ip">IP or Name</label> + <div class="col-sm-9"> + <input type="text" id="inputLabel" class="form-control" formControlName="ip" (keyup)="onKeyLabel($event)"> + </div> + </div> + + <div class="form-group row"> + <label for="select-name" class="col-sm-3 col-form-name">Name</label> + <div class="col-sm-9"> + <input type="text" id="inputLabel" class="form-control" formControlName="name" (keyup)="onKeyLabel($event)"> + </div> + </div> + + </form> + </div> +</div> +<div class="modal-footer form-group"> + <div class="col-12"> + <div class="offset-sm-4 col-sm-6"> + <button class="btn btn-md btn-secondary" (click)="cancelAction=true; closeModal()"> Cancel </button> + <button class="btn btn-md btn-primary" (click)="onSubmit()" [disabled]="!addTargetForm.valid">Add Folder</button> + </div> + </div> +</div> diff --git a/webapp/src/app/pages/targets/target-add-modal/target-add-modal.component.ts b/webapp/src/app/pages/targets/target-add-modal/target-add-modal.component.ts new file mode 100644 index 0000000..fdcb048 --- /dev/null +++ b/webapp/src/app/pages/targets/target-add-modal/target-add-modal.component.ts @@ -0,0 +1,174 @@ +/** +* @license +* Copyright (C) 2018 "IoT.bzh" +* Author Sebastien Douheret <sebastien@iot.bzh> +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { Component, ViewEncapsulation, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { FormControl, FormGroup, Validators, ValidationErrors, FormBuilder, ValidatorFn, AbstractControl } from '@angular/forms'; + +// Import RxJs required methods +import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/filter'; +import 'rxjs/add/operator/debounceTime'; + +import { AlertService, IAlert } from '../../../@core-xds/services/alert.service'; +import { TargetService, ITarget, TargetType, TargetTypes } from '../../../@core-xds/services/target.service'; +import { XDSConfigService } from '../../../@core-xds/services/xds-config.service'; + + +@Component({ + selector: 'xds-target-add-modal', + templateUrl: 'target-add-modal.component.html', + encapsulation: ViewEncapsulation.None, + styles: [` + .modal-xxl .modal-lg { + width: 90%; + max-width:1200px; + } + `], +}) +export class TargetAddModalComponent implements OnInit { + // @Input('server-id') serverID: string; + private serverID: string; + + cancelAction = false; + userEditedName = false; + targetTypes = Object.assign([], TargetTypes); + + addTargetForm: FormGroup; + typeCtrl: FormControl; + ipCtrl: FormControl; + + constructor( + private alert: AlertService, + private targetSvr: TargetService, + private XdsConfigSvr: XDSConfigService, + private fb: FormBuilder, + private activeModal: NgbActiveModal, + ) { + // Define types (first one is special/placeholder) + this.targetTypes.unshift({ value: TargetType.UNSET, display: '--Select a type--' }); + + // Select first type item (Standard) by default + this.typeCtrl = new FormControl(this.targetTypes[1].value, this.validatorTgtType.bind(this)); + this.ipCtrl = new FormControl('', this.validatorIP.bind(this)); + + this.addTargetForm = fb.group({ + type: this.typeCtrl, + ip: this.ipCtrl, + name: ['', Validators.nullValidator], + }); + } + + ngOnInit() { + // Update server ID + this.serverID = this.XdsConfigSvr.getCurServer().id; + this.XdsConfigSvr.onCurServer().subscribe(svr => this.serverID = svr.id); + + // Auto create target name + this.ipCtrl.valueChanges + .debounceTime(100) + .filter(n => n) + .map(n => { + if (this._isIPstart(n)) { + return 'Target_' + n; + } +// SEB PB + return n; + }) + .subscribe(value => { + if (value && !this.userEditedName) { + this.addTargetForm.patchValue({ name: value }); + } + }); + } + + closeModal() { + this.activeModal.close(); + } + + onKeyLabel(event: any) { + this.userEditedName = (this.addTargetForm.value.label !== ''); + } + + onChangeLocalTarget(e) { + } + + onSubmit() { + if (this.cancelAction) { + return; + } + + const formVal = this.addTargetForm.value; + + this.targetSvr.add({ + name: formVal['name'], + ip: formVal['ip'], + type: formVal['type'], + }).subscribe( + tgt => { + this.alert.info('Target ' + tgt.name + ' successfully created.'); + this.closeModal(); + + // Reset Value for the next creation + this.addTargetForm.reset(); + const selectedType = this.targetTypes[0].value; + this.addTargetForm.patchValue({ type: selectedType }); + + }, + err => { + this.alert.error(err, 60); + this.closeModal(); + }, + ); + } + + private validatorTgtType(g: FormGroup): ValidationErrors | null { + return (g.value !== TargetType.UNSET) ? null : { validatorTgtType: { valid: false } }; + } + + private validatorIP(g: FormGroup): ValidationErrors | null { + const noValid = <ValidationErrors>{ validatorProjPath: { valid: false } }; + + if (g.value === '') { + return noValid; + } + + if (this._isIPstart(g.value) && !this._isIPv4(g.value)) { + return noValid; + } + + // Else accept any text / hostname + return null; + } + + private _isIPstart(str) { + return /^(\d+)\./.test(str); + } + + private _isIPv4(str) { + const ipv4Maybe = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/ + if (!ipv4Maybe.test(str)) { + return false; + } + const parts = str.split('.').sort(function (a, b) { + return a - b; + }); + return (parts[3] <= 255); + } +} diff --git a/webapp/src/app/pages/targets/target-card/target-card.component.html b/webapp/src/app/pages/targets/target-card/target-card.component.html new file mode 100644 index 0000000..7c921b1 --- /dev/null +++ b/webapp/src/app/pages/targets/target-card/target-card.component.html @@ -0,0 +1,62 @@ +<nb-card class="xds-targets"> + <nb-card-header> + + <div class="row"> + <div class="col-12 col-md-8"> + {{ target.name }} + </div> + <div class="col-6 col-md-4 text-right" role="group"> + <button class="btn btn-outline-danger btn-tn btn-xds" (click)="delete(target)"> + <span class="fa fa-trash fa-size-x2"></span> + </button> + </div> + </div> + </nb-card-header> + + <nb-card-body> + <table class="table table-striped"> + <tbody> + <tr> + <th> + <span class="fa fa-fw fa-id-badge"></span> + <span>Target ID</span> + </th> + <td>{{ target.id }}</td> + </tr> + <tr> + <th> + <span class="fa fa-fw fa-exchange"></span> + <span>Type</span> + </th> + <td>{{ target.type | readableType }}</td> + </tr> + <tr> + <th> + <span class="fa fa-fw fa-folder-open-o"></span> + <span>IP</span> + </th> + <td>{{ target.ip }}</td> + </tr> + <tr> + <th> + <span class="fa fa-fw fa-flag"></span> + <span>Status</span> + </th> + <td>{{ target.status }}</td> + </tr> + <tr> + <th> + <span class="fa fa-fw fa-folder-open-o"></span> + <span>Terminals</span> + </th> + <td>{{ target.terms.length }}</td> + </tr> + + </tbody> + </table> + </nb-card-body> + + <nb-card-footer> + <!-- <pre>{{target | json}}</pre> --> + </nb-card-footer> +</nb-card> diff --git a/webapp/src/app/pages/targets/target-card/target-card.component.scss b/webapp/src/app/pages/targets/target-card/target-card.component.scss new file mode 100644 index 0000000..6ac8d11 --- /dev/null +++ b/webapp/src/app/pages/targets/target-card/target-card.component.scss @@ -0,0 +1,41 @@ +@import '~@nebular/theme/styles/global/bootstrap/buttons'; + +.xds-project-card .icon { + padding: 0.75rem 0; + font-size: 1.75rem; +} + +nb-card-body { + padding: 0; +} + +nb-card-footer { + text-align: right; +} + +.fa-size-x2 { + font-size: 20px; +} + +th span { + font-weight: 100; +} + +th label { + font-weight: 100; + margin-bottom: 0; +} + +.btn-outline-danger.btn-xds { + color: #ff4c6a; + &:focus { + color: white; + } +} + +.btn-outline-info.btn-xds { + color: #4ca6ff; + &:focus { + color: white; + } +} diff --git a/webapp/src/app/pages/targets/target-card/target-card.component.ts b/webapp/src/app/pages/targets/target-card/target-card.component.ts new file mode 100644 index 0000000..6d43260 --- /dev/null +++ b/webapp/src/app/pages/targets/target-card/target-card.component.ts @@ -0,0 +1,87 @@ +/** +* @license +* Copyright (C) 2018 "IoT.bzh" +* Author Sebastien Douheret <sebastien@iot.bzh> +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { Component, Input, Pipe, PipeTransform } from '@angular/core'; +import { TargetService, ITarget, TargetType, TargetTypeEnum, TargetTypes } from '../../../@core-xds/services/target.service'; +import { AlertService } from '../../../@core-xds/services/alert.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ConfirmModalComponent, EType } from '../../confirm/confirm-modal/confirm-modal.component'; +import { find } from 'rxjs/operator/find'; +import { findIndex } from 'rxjs/operator/findIndex'; + +@Component({ + selector: 'xds-target-card', + styleUrls: ['./target-card.component.scss'], + templateUrl: './target-card.component.html', +}) +export class TargetCardComponent { + + // FIXME workaround of https://github.com/angular/angular-cli/issues/2034 + // should be removed with angular 5 + // @Input() target: ITarget; + @Input() target = <ITarget>null; + + constructor( + private alert: AlertService, + private targetSvr: TargetService, + private modalService: NgbModal, + ) { + } + + delete(tgt: ITarget) { + + const modal = this.modalService.open(ConfirmModalComponent, { + size: 'lg', + backdrop: 'static', + container: 'nb-layout', + }); + modal.componentInstance.title = 'Confirm SDK deletion'; + modal.componentInstance.type = EType.YesNo; + modal.componentInstance.question = ` + Do you <b>permanently delete '` + tgt.name + `'</b> target ? + <br><br> + <i><small>(Target ID: ` + tgt.id + ` )</small></i>`; + + modal.result + .then(res => { + if (res === 'yes') { + this.targetSvr.delete(tgt).subscribe( + r => { }, + err => this.alert.error('ERROR delete: ' + err), + ); + } + }); + + } + +} + +// Make Target type human readable +@Pipe({ + name: 'readableType', +}) + +export class TargetReadableTypePipe implements PipeTransform { + transform(type: TargetTypeEnum): string { + const tt = TargetTypes.find(el => type === el.value); + if (tt) { + return tt.display; + } + return String(type); + } +} diff --git a/webapp/src/app/pages/targets/targets.component.html b/webapp/src/app/pages/targets/targets.component.html new file mode 100644 index 0000000..a4fd894 --- /dev/null +++ b/webapp/src/app/pages/targets/targets.component.html @@ -0,0 +1,26 @@ +<div class="row"> + <div class="col-12"> + <nb-card-body> + <div class="col-9"> + <nb-actions size="medium"> + <nb-action> + <button (click)="add()"> + <i class="nb-plus"></i> + <span>Add Target</span> + </button> + </nb-action> + </nb-actions> + </div> + <div class="col-3 right"> + <nb-actions size="medium"> + <nb-action> + <nb-search type="rotate-layout"></nb-search> + </nb-action> + </nb-actions> + </div> + </nb-card-body> + </div> + <div class="col-md-6 col-lg-6" *ngFor="let tgt of (targets$ | async)"> + <xds-target-card [target]="tgt"></xds-target-card> + </div> +</div> diff --git a/webapp/src/app/pages/targets/targets.component.scss b/webapp/src/app/pages/targets/targets.component.scss new file mode 100644 index 0000000..93ed0db --- /dev/null +++ b/webapp/src/app/pages/targets/targets.component.scss @@ -0,0 +1,84 @@ +@import '~xterm/dist/xterm.css'; +@import '../../@theme/styles/themes'; +@import '~@nebular/theme/components/card/card.component.theme'; +@import '~bootstrap/scss/mixins/breakpoints'; +@import '~@nebular/theme/styles/global/bootstrap/breakpoints'; +@include nb-install-component() { + nb-card-body { + display: flex; + align-items: center; + } + .action-groups-header { + flex-basis: 20%; + color: nb-theme(card-header-fg-heading); + font-family: nb-theme(card-header-font-family); + font-size: nb-theme(card-header-font-size); + font-weight: nb-theme(card-header-font-weight); + } + .nb-actions { + flex-basis: 80%; + } + .right { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + order: 1; + flex-direction: row-reverse; + } + nb-actions > nb-action { + padding: 0; + } + nb-action { + i { + color: nb-theme(color-fg); + font-size: 2.5rem; + margin-right: 1rem; + } + span { + font-family: nb-theme(font-secondary); + font-weight: nb-theme(font-weight-bold); + color: nb-theme(color-fg-heading); + text-transform: uppercase; + } + button { + margin: 0 auto; + padding: 0; + cursor: pointer; + border: none; + background: none; + display: flex; + align-items: center; + &:focus { + box-shadow: none; + outline: none; + } + } + } + @include media-breakpoint-down(md) { + nb-actions nb-action { + padding: 0 0.75rem; + } + } + @include media-breakpoint-down(sm) { + nb-card-body { + padding: 1rem; + } + nb-action { + font-size: 0.75rem; + i { + font-size: 2rem; + margin-right: 0.5rem; + } + } + } + @include media-breakpoint-down(is) { + nb-action i { + font-size: 1.75rem; + margin: 0; + } + span { + display: none; + } + } +} diff --git a/webapp/src/app/pages/targets/targets.component.ts b/webapp/src/app/pages/targets/targets.component.ts new file mode 100644 index 0000000..95abdea --- /dev/null +++ b/webapp/src/app/pages/targets/targets.component.ts @@ -0,0 +1,60 @@ +/** +* @license +* Copyright (C) 2017-2018 "IoT.bzh" +* Author Sebastien Douheret <sebastien@iot.bzh> +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { Subject } from 'rxjs/Subject'; + +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TargetAddModalComponent } from './target-add-modal/target-add-modal.component'; + +import { TargetService, ITarget } from '../../@core-xds/services/target.service'; +import { AlertService } from '../../@core-xds/services/alert.service'; + +@Component({ + selector: 'xds-targets', + styleUrls: ['./targets.component.scss'], + templateUrl: './targets.component.html', +}) +export class TargetsComponent implements OnInit { + + public targets$: Observable<ITarget[]>; + + protected curTargetID: string; + + constructor( + private modalService: NgbModal, + private targetSvr: TargetService, + private alert: AlertService, + ) { + this.curTargetID = ''; + } + + ngOnInit() { + this.targets$ = this.targetSvr.targets$; + } + + add() { + const activeModal = this.modalService.open(TargetAddModalComponent, { + size: 'lg', + windowClass: 'modal-xxl', + container: 'nb-layout', + }); + } + +} diff --git a/webapp/src/app/pages/targets/targets.module.ts b/webapp/src/app/pages/targets/targets.module.ts new file mode 100644 index 0000000..8589bcd --- /dev/null +++ b/webapp/src/app/pages/targets/targets.module.ts @@ -0,0 +1,47 @@ +/** +* @license +* Copyright (C) 2017-2018 "IoT.bzh" +* Author Sebastien Douheret <sebastien@iot.bzh> +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { NgModule } from '@angular/core'; +import { ThemeModule } from '../../@theme/theme.module'; + +import { TargetsComponent } from './targets.component'; +import { TerminalsComponent } from './terminals/terminals.component'; +import { TerminalComponent } from './terminals/terminal.component'; +import { TargetCardComponent, TargetReadableTypePipe } from './target-card/target-card.component'; +import { TargetAddModalComponent } from './target-add-modal/target-add-modal.component'; +import { TargetSelectDropdownComponent } from './settings/target-select-dropdown.component'; + + +@NgModule({ + imports: [ + ThemeModule, + ], + declarations: [ + TargetsComponent, + TerminalsComponent, + TerminalComponent, + TargetCardComponent, + TargetAddModalComponent, + TargetReadableTypePipe, + TargetSelectDropdownComponent, + ], + entryComponents: [ + TargetAddModalComponent, + ], +}) +export class TargetsModule { } diff --git a/webapp/src/app/pages/targets/terminals/terminal.component.ts b/webapp/src/app/pages/targets/terminals/terminal.component.ts new file mode 100644 index 0000000..0478a08 --- /dev/null +++ b/webapp/src/app/pages/targets/terminals/terminal.component.ts @@ -0,0 +1,135 @@ +/** +* @license +* Copyright (C) 2018 "IoT.bzh" +* Author Sebastien Douheret <sebastien@iot.bzh> +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { Component, ElementRef, ViewChild, Input, Output, HostListener, EventEmitter, AfterViewInit } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; + +import { Terminal } from 'xterm'; +import * as fit from 'xterm/lib/addons/fit/fit'; + +export interface ITerminalFont { + fontFamily: string; + fontSize: string; + lineHeight: number; + charWidth: number; + charHeight: number; +} + + +@Component({ + selector: 'xds-terminal', + styles: [], + template: ` + <div #terminalContainer></div> + `, +}) +export class TerminalComponent implements AfterViewInit { + + private _xterm: Terminal; + private _initDone: boolean; + + @ViewChild('terminalContainer') termContainer: ElementRef; + + @Output() stdin = new EventEmitter<any>(); + @Output() resize = new EventEmitter<{ cols: number, rows: number }>(); + + + constructor() { + this._initDone = false; + Terminal.applyAddon(fit); + + this._xterm = new Terminal({ + cursorBlink: true, + // useStyle: true, + scrollback: 1000, + rows: 24, + cols: 80, + }); + } + + // getting the nativeElement only possible after view init + ngAfterViewInit() { + + // this now finds the #terminal element + this._xterm.open(this.termContainer.nativeElement); + + // the number of rows will determine the size of the terminal screen + (<any>this._xterm).fit(); + + // Bind input key + this._xterm.on('data', (data) => { + // console.log(data.charCodeAt(0)); + this.stdin.emit(this._sanitizeInput(data)); + return false; + }); + + this._initDone = true; + } + + @Input('stdout') + set writeData(data) { + if (this._initDone && data !== undefined) { + this._xterm.write(data); + } + } + + @Input('disable') + set disable(value: boolean) { + if (!this._initDone) { + return; + } + + this._xterm.setOption('disableStdin', value); + + if (value) { + this._xterm.blur(); + } else { + this._xterm.focus(); + } + this._resize(); + } + + @HostListener('window:resize', ['$event']) + onWindowResize(event) { + this._resize(); + } + + /*** Private functions ***/ + + private _sanitizeInput(d) { + // TODO sanitize ? + return d; + } + + private _resize() { + const geom = fit.proposeGeometry(this._xterm); + + // console.log('DEBUG cols ' + String(geom.cols) + ' rows ' + String(geom.rows)); + + if (geom.cols < 0 || geom.cols > 2000 || geom.rows < 0 || geom.rows > 2000) { + return; + } + + // Update xterm size + this._xterm.resize(geom.cols, geom.rows); + + // Send resize event to update remote terminal + this.resize.emit({ cols: geom.cols, rows: geom.rows }); + } + +} diff --git a/webapp/src/app/pages/targets/terminals/terminals.component.html b/webapp/src/app/pages/targets/terminals/terminals.component.html new file mode 100644 index 0000000..8b78963 --- /dev/null +++ b/webapp/src/app/pages/targets/terminals/terminals.component.html @@ -0,0 +1,32 @@ +<div class="row"> + <div class="col-12"> + <nb-card-body> + <nb-actions size="medium"> + <nb-action class="col-sm-6"> + <xds-target-select-dropdown></xds-target-select-dropdown> + </nb-action> + <nb-action class="col-sm-3" [disabled]="curTarget==null"> + <button (click)="openTerm()"> + <i class="nb-layout-default"></i> + <span>Open Terminal</span> + </button> + </nb-action> + <nb-action class="col-sm-3" [disabled]="curTarget==null"> + <button (click)="closeTerm()"> + <i class="nb-close-circled"></i> + <span>Close Terminal</span> + </button> + </nb-action> + </nb-actions> + </nb-card-body> + </div> + + <div class="col-12" *ngIf="!xTermDisable; else elseBlock"> + <pre>Connected to {{curTarget?.name}}</pre> + </div> + <ng-template #elseBlock><pre> </pre></ng-template> + + <div class="col-12"> + <xds-terminal [(stdout)]="xTermStdout" (stdin)="onXTermData($event)" (resize)="onResize($event)" [disable]="xTermDisable"></xds-terminal> + </div> +</div> diff --git a/webapp/src/app/pages/targets/terminals/terminals.component.scss b/webapp/src/app/pages/targets/terminals/terminals.component.scss new file mode 100644 index 0000000..3f12c78 --- /dev/null +++ b/webapp/src/app/pages/targets/terminals/terminals.component.scss @@ -0,0 +1,84 @@ +@import '~xterm/dist/xterm.css'; +@import '../../../@theme/styles/themes'; +@import '~@nebular/theme/components/card/card.component.theme'; +@import '~bootstrap/scss/mixins/breakpoints'; +@import '~@nebular/theme/styles/global/bootstrap/breakpoints'; +@include nb-install-component() { + nb-card-body { + display: flex; + align-items: center; + } + .action-groups-header { + flex-basis: 20%; + color: nb-theme(card-header-fg-heading); + font-family: nb-theme(card-header-font-family); + font-size: nb-theme(card-header-font-size); + font-weight: nb-theme(card-header-font-weight); + } + .nb-actions { + flex-basis: 80%; + } + .right { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + order: 1; + flex-direction: row-reverse; + } + nb-actions > nb-action { + padding: 0; + } + nb-action { + i { + color: nb-theme(color-fg); + font-size: 2.5rem; + margin-right: 1rem; + } + span { + font-family: nb-theme(font-secondary); + font-weight: nb-theme(font-weight-bold); + color: nb-theme(color-fg-heading); + text-transform: uppercase; + } + button { + margin: 0 auto; + padding: 0; + cursor: pointer; + border: none; + background: none; + display: flex; + align-items: center; + &:focus { + box-shadow: none; + outline: none; + } + } + } + @include media-breakpoint-down(md) { + nb-actions nb-action { + padding: 0 0.75rem; + } + } + @include media-breakpoint-down(sm) { + nb-card-body { + padding: 1rem; + } + nb-action { + font-size: 0.75rem; + i { + font-size: 2rem; + margin-right: 0.5rem; + } + } + } + @include media-breakpoint-down(is) { + nb-action i { + font-size: 1.75rem; + margin: 0; + } + span { + display: none; + } + } +} diff --git a/webapp/src/app/pages/targets/terminals/terminals.component.ts b/webapp/src/app/pages/targets/terminals/terminals.component.ts new file mode 100644 index 0000000..306c759 --- /dev/null +++ b/webapp/src/app/pages/targets/terminals/terminals.component.ts @@ -0,0 +1,115 @@ +/** +* @license +* Copyright (C) 2017-2018 "IoT.bzh" +* Author Sebastien Douheret <sebastien@iot.bzh> +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { Subject } from 'rxjs/Subject'; +import { Subscription } from 'rxjs/Subscription'; + +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import { TargetService, TargetType, ITarget, ITerminal, TerminalType, ITerminalOutput } from '../../../@core-xds/services/target.service'; +import { AlertService } from '../../../@core-xds/services/alert.service'; + +@Component({ + selector: 'xds-terminals', + styleUrls: ['./terminals.component.scss'], + templateUrl: './terminals.component.html', +}) +export class TerminalsComponent implements OnInit { + + public curTarget: ITarget; + public xTermStdout: string; + public xTermDisable: boolean; + + protected curTermID: string; + + private termOut$: Subject<ITerminalOutput>; + private termSubs: Subscription; + + constructor( + private modalService: NgbModal, + private targetSvr: TargetService, + private alert: AlertService, + ) { + this.xTermStdout = ''; + this.xTermDisable = true; + this.curTarget = null; + this.curTermID = ''; + } + + ngOnInit() { + this.targetSvr.curTarget$.subscribe(p => this.curTarget = p); + } + + openTerm() { + if (this.curTarget == null || this.curTarget.id === '') { + return; + } + + // FIXME: don't always use 1st terminal + if (this.curTarget.terms.length > 0) { + this.curTermID = this.curTarget.terms[0].id; + } + + this.targetSvr.terminalOpen(this.curTarget.id, this.curTermID) + .subscribe( + res => { + this.termOut$ = this.targetSvr.terminalOutput$; + + this.termSubs = this.termOut$.subscribe(termOut => { + this.xTermStdout = termOut.stdout + termOut.stderr; + }); + + this.xTermDisable = false; + }, + err => { + this.alert.error(err); + }, + ); + } + + closeTerm() { + if (this.curTarget == null || this.curTarget.id === '' || this.curTermID === '') { + return; + } + this.targetSvr.terminalClose(this.curTarget.id, this.curTermID) + .subscribe(res => { + this.curTermID = ''; + this.xTermStdout = '\r\n*** Terminal closed ***\n\n\r'; + if (this.termSubs !== undefined) { + this.termSubs.unsubscribe(); + this.termOut$ = undefined; + } + this.xTermDisable = true; + }); + } + + onXTermData(data: string) { + if (this.termOut$ !== undefined && !this.termOut$.closed) { + this.targetSvr.terminalWrite(data); + } + } + + onResize(sz) { + if (this.termOut$ !== undefined && !this.termOut$.closed) { + this.targetSvr.terminalResize(this.curTarget.id, this.curTermID, sz.cols, sz.rows).subscribe(); + } + } + +} |