summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSebastien Douheret <sebastien.douheret@iot.bzh>2018-02-23 18:52:49 +0100
committerSebastien Douheret <sebastien.douheret@iot.bzh>2018-04-05 01:29:05 +0200
commit069de98bdd926cb25954aad94fe23be0272a7b5e (patch)
treecb77c241cc0a257d574187e333e106b811ad4f3d
parent6aa5e4ccb5adadcc0cb802c44c1e88a35a20a925 (diff)
Added target and terminal support in Dashboard
Signed-off-by: Sebastien Douheret <sebastien.douheret@iot.bzh>
-rw-r--r--lib/agent/apiv1-targets.go105
-rw-r--r--lib/agent/apiv1.go6
-rw-r--r--lib/agent/projects.go2
-rw-r--r--lib/xaapiv1/events.go68
-rw-r--r--webapp/.angular-cli.json1
-rw-r--r--webapp/package.json1
-rw-r--r--webapp/src/app/@core-xds/services/@core-xds-services.module.ts2
-rw-r--r--webapp/src/app/@core-xds/services/target.service.ts285
-rw-r--r--webapp/src/app/@core-xds/services/xdsagent.service.ts274
-rw-r--r--webapp/src/app/pages/pages-menu.ts11
-rw-r--r--webapp/src/app/pages/pages-routing.module.ts8
-rw-r--r--webapp/src/app/pages/pages.module.ts2
-rw-r--r--webapp/src/app/pages/projects/projects.module.ts1
-rw-r--r--webapp/src/app/pages/targets/settings/target-select-dropdown.component.ts57
-rw-r--r--webapp/src/app/pages/targets/target-add-modal/target-add-modal.component.html46
-rw-r--r--webapp/src/app/pages/targets/target-add-modal/target-add-modal.component.ts174
-rw-r--r--webapp/src/app/pages/targets/target-card/target-card.component.html62
-rw-r--r--webapp/src/app/pages/targets/target-card/target-card.component.scss41
-rw-r--r--webapp/src/app/pages/targets/target-card/target-card.component.ts87
-rw-r--r--webapp/src/app/pages/targets/targets.component.html26
-rw-r--r--webapp/src/app/pages/targets/targets.component.scss84
-rw-r--r--webapp/src/app/pages/targets/targets.component.ts60
-rw-r--r--webapp/src/app/pages/targets/targets.module.ts47
-rw-r--r--webapp/src/app/pages/targets/terminals/terminal.component.ts135
-rw-r--r--webapp/src/app/pages/targets/terminals/terminals.component.html32
-rw-r--r--webapp/src/app/pages/targets/terminals/terminals.component.scss84
-rw-r--r--webapp/src/app/pages/targets/terminals/terminals.component.ts115
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">&times;</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>&nbsp;
+ <span>Target ID</span>
+ </th>
+ <td>{{ target.id }}</td>
+ </tr>
+ <tr>
+ <th>
+ <span class="fa fa-fw fa-exchange"></span>&nbsp;
+ <span>Type</span>
+ </th>
+ <td>{{ target.type | readableType }}</td>
+ </tr>
+ <tr>
+ <th>
+ <span class="fa fa-fw fa-folder-open-o"></span>&nbsp;
+ <span>IP</span>
+ </th>
+ <td>{{ target.ip }}</td>
+ </tr>
+ <tr>
+ <th>
+ <span class="fa fa-fw fa-flag"></span>&nbsp;
+ <span>Status</span>
+ </th>
+ <td>{{ target.status }}</td>
+ </tr>
+ <tr>
+ <th>
+ <span class="fa fa-fw fa-folder-open-o"></span>&nbsp;
+ <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();
+ }
+ }
+
+}