Added target and terminal support in Dashboard
authorSebastien Douheret <sebastien.douheret@iot.bzh>
Fri, 23 Feb 2018 17:52:49 +0000 (18:52 +0100)
committerSebastien Douheret <sebastien.douheret@iot.bzh>
Wed, 4 Apr 2018 23:29:05 +0000 (01:29 +0200)
Signed-off-by: Sebastien Douheret <sebastien.douheret@iot.bzh>
27 files changed:
lib/agent/apiv1-targets.go
lib/agent/apiv1.go
lib/agent/projects.go
lib/xaapiv1/events.go
webapp/.angular-cli.json
webapp/package.json
webapp/src/app/@core-xds/services/@core-xds-services.module.ts
webapp/src/app/@core-xds/services/target.service.ts [new file with mode: 0644]
webapp/src/app/@core-xds/services/xdsagent.service.ts
webapp/src/app/pages/pages-menu.ts
webapp/src/app/pages/pages-routing.module.ts
webapp/src/app/pages/pages.module.ts
webapp/src/app/pages/projects/projects.module.ts
webapp/src/app/pages/targets/settings/target-select-dropdown.component.ts [new file with mode: 0644]
webapp/src/app/pages/targets/target-add-modal/target-add-modal.component.html [new file with mode: 0644]
webapp/src/app/pages/targets/target-add-modal/target-add-modal.component.ts [new file with mode: 0644]
webapp/src/app/pages/targets/target-card/target-card.component.html [new file with mode: 0644]
webapp/src/app/pages/targets/target-card/target-card.component.scss [new file with mode: 0644]
webapp/src/app/pages/targets/target-card/target-card.component.ts [new file with mode: 0644]
webapp/src/app/pages/targets/targets.component.html [new file with mode: 0644]
webapp/src/app/pages/targets/targets.component.scss [new file with mode: 0644]
webapp/src/app/pages/targets/targets.component.ts [new file with mode: 0644]
webapp/src/app/pages/targets/targets.module.ts [new file with mode: 0644]
webapp/src/app/pages/targets/terminals/terminal.component.ts [new file with mode: 0644]
webapp/src/app/pages/targets/terminals/terminals.component.html [new file with mode: 0644]
webapp/src/app/pages/targets/terminals/terminals.component.scss [new file with mode: 0644]
webapp/src/app/pages/targets/terminals/terminals.component.ts [new file with mode: 0644]

index 5a7862a..cc08822 100644 (file)
@@ -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) {
 
index 730e7c0..97165b3 100644 (file)
@@ -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 {
index ff28f96..0bd5315 100644 (file)
@@ -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"
 )
index 6520057..3a47e49 100644 (file)
@@ -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
+}
index ade79a2..9aa0157 100644 (file)
@@ -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": [
index a96586c..8176cf2 100644 (file)
@@ -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": {
index 7c380eb..a3a67c5 100644 (file)
@@ -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 (file)
index 0000000..9c995ea
--- /dev/null
@@ -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);
+    }
+  }
+
+}
index 033185b..adbee98 100644 (file)
@@ -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);
+        },
     );
   }
 
index 1e4839d..86884bc 100644 (file)
@@ -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',
+      },
     ],
   },
   {
index 7eeccd0..655dea2 100644 (file)
@@ -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 = [{
@@ -44,6 +46,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',
index 42a9a84..55fe61a 100644 (file)
@@ -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,
index 7c4b0a8..54255f8 100644 (file)
@@ -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 (file)
index 0000000..c124054
--- /dev/null
@@ -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 (file)
index 0000000..84424b4
--- /dev/null
@@ -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 (file)
index 0000000..fdcb048
--- /dev/null
@@ -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 (file)
index 0000000..7c921b1
--- /dev/null
@@ -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 (file)
index 0000000..6ac8d11
--- /dev/null
@@ -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 (file)
index 0000000..6d43260
--- /dev/null
@@ -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 (file)
index 0000000..a4fd894
--- /dev/null
@@ -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 (file)
index 0000000..93ed0db
--- /dev/null
@@ -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 (file)
index 0000000..95abdea
--- /dev/null
@@ -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 (file)
index 0000000..8589bcd
--- /dev/null
@@ -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 (file)
index 0000000..0478a08
--- /dev/null
@@ -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 (file)
index 0000000..8b78963
--- /dev/null
@@ -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 (file)
index 0000000..3f12c78
--- /dev/null
@@ -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 (file)
index 0000000..306c759
--- /dev/null
@@ -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();
+    }
+  }
+
+}