Implemented WebSockets for GUI2

Change-Id: I4776ce392b1e8e94ebee938cf7df22791a1e0b8f
diff --git a/web/gui2/src/main/webapp/app/fw/remote/websocket.service.ts b/web/gui2/src/main/webapp/app/fw/remote/websocket.service.ts
index 0691c72..e470ec1 100644
--- a/web/gui2/src/main/webapp/app/fw/remote/websocket.service.ts
+++ b/web/gui2/src/main/webapp/app/fw/remote/websocket.service.ts
@@ -18,37 +18,111 @@
 import { GlyphService } from '../svg/glyph.service';
 import { LogService } from '../../log.service';
 import { UrlFnService } from './urlfn.service';
+import { VeilComponent } from '../layer/veil/veil.component';
 import { WSock } from './wsock.service';
 
 /**
  * Event Type structure for the WebSocketService
  */
-interface EventType {
+export interface EventType {
     event: string;
-    payload: any;
+    payload: Object;
+}
+
+export interface Callback {
+    id: number;
+    error: string;
+    cb(host: string, url: string): void;
+}
+
+interface ClusterNode {
+    id: string;
+    ip: string;
+    m_uiAttached: boolean;
+}
+
+interface Bootstrap {
+    user: string;
+    clusterNodes: ClusterNode[];
+    glyphs: any[]; // TODO: narrow this down to a known type
+}
+
+interface ErrorData {
+    message: string;
+}
+
+export interface WsOptions {
+    wsport: number;
 }
 
 /**
  * ONOS GUI -- Remote -- Web Socket Service
+ *
+ * To see debug messages add ?debug=txrx to the URL
  */
-@Injectable()
+@Injectable({
+  providedIn: 'root',
+})
 export class WebSocketService {
     // internal state
-    private webSockOpts; // web socket options
-    private ws = null; // web socket reference
-    private wsUp = false; // web socket is good to go
-    private handlers = {}; // event handler bindings
+    private webSockOpts: WsOptions; // web socket options
+    private ws: WebSocket = null; // web socket reference
+    private wsUp: boolean = false; // web socket is good to go
+
+    // A map of event handler bindings - names and functions (that accept data and return void)
+    private handlers = new Map<string, (data: any) => void>([]);
     private pendingEvents: EventType[] = []; // events TX'd while socket not up
     private host: string; // web socket host
     private url; // web socket URL
-    private clusterNodes = []; // ONOS instances data for failover
+    private clusterNodes: ClusterNode[] = []; // ONOS instances data for failover
     private clusterIndex = -1; // the instance to which we are connected
     private glyphs = [];
-    private connectRetries = 0; // limit our attempts at reconnecting
-    private openListeners = {}; // registered listeners for websocket open()
-    private nextListenerId = 1; // internal ID for open listeners
-    private loggedInUser = null; // name of logged-in user
+    private connectRetries: number = 0; // limit our attempts at reconnecting
 
+    // A map of registered Callbacks for websocket open()
+    private openListeners = new Map<number, Callback>([]);
+    private nextListenerId: number = 1; // internal ID for open listeners
+    private loggedInUser = null; // name of logged-in user
+    private lcd: any; // The loading component delegate
+    private vcd: any; // The veil component delegate
+
+    /**
+     * built-in handler for the 'boostrap' event
+     */
+    private bootstrap(data: Bootstrap) {
+        this.log.debug('bootstrap data', data);
+        this.loggedInUser = data.user;
+
+        this.clusterNodes = data.clusterNodes;
+        this.clusterNodes.forEach((d, i) => {
+            if (d.m_uiAttached) {
+                this.clusterIndex = i;
+                this.log.info('Connected to cluster node ' + d.ip);
+                // TODO: add connect info to masthead somewhere
+            }
+        });
+        this.glyphs = data.glyphs;
+        const glyphsMap = new Map<string, string>([]);
+        this.glyphs.forEach((d, i) => {
+            glyphsMap.set('_' + d.id, d.viewbox);
+            glyphsMap.set(d.id, d.path);
+            this.gs.registerGlyphs(glyphsMap);
+        });
+    }
+
+    private error(data: ErrorData) {
+        const m: string = data.message || 'error from server';
+        this.log.error(m, data);
+
+        // Unrecoverable error - throw up the veil...
+        if (this.vcd) {
+            this.vcd.show([
+                'Oops!',
+                'Server reports error...',
+                m,
+            ]);
+        }
+    }
 
     constructor(
         private fs: FnService,
@@ -59,90 +133,319 @@
         private window: Window
     ) {
         this.log.debug(window.location.hostname);
+
+        // Bind the boot strap event by default
+        this.bindHandlers(new Map<string, (data) => void>([
+            ['bootstrap', (data) => this.bootstrap(data)],
+            ['error', (data) => this.error(data)]
+        ]));
+
         this.log.debug('WebSocketService constructed');
     }
 
+
+    // ==========================
+    // === Web socket callbacks
+
+    /**
+     * Called when WebSocket has just opened
+     *
+     * Lift the Veil if it is displayed
+     * If there are any events pending, send them
+     * Mark the WSS as up and inform any listeners for this open event
+     */
+    handleOpen(): void {
+        this.log.info('Web socket open - ', this.url);
+        // Hide the veil
+        if (this.vcd) {
+            this.vcd.hide();
+        }
+
+        if (this.fs.debugOn('txrx')) {
+            this.log.debug('Sending ' + this.pendingEvents.length + ' pending event(s)...');
+        }
+        this.pendingEvents.forEach((ev) => {
+            this.send(ev);
+        });
+        this.pendingEvents = [];
+
+        this.connectRetries = 0;
+        this.wsUp = true;
+        this.informListeners(this.host, this.url);
+    }
+
+    /**
+     * Function called when WebSocket send a message
+     */
+    handleMessage(msgEvent: MessageEvent): void {
+        let ev: EventType;
+        let h;
+        try {
+            ev = JSON.parse(msgEvent.data);
+        } catch (e) {
+            this.log.error('Message.data is not valid JSON', msgEvent.data, e);
+            return null;
+        }
+        if (this.fs.debugOn('txrx')) {
+            this.log.debug(' << *Rx* ', ev.event, ev.payload);
+        }
+        h = this.handlers.get(ev.event);
+        if (h) {
+            try {
+                h(ev.payload);
+            } catch (e) {
+                this.log.error('Problem handling event:', ev, e);
+                return null;
+            }
+        } else {
+            this.log.warn('Unhandled event:', ev);
+        }
+    }
+
+    /**
+     * Called by the WebSocket if it is closed from the server end
+     *
+     * If the loading component is shown, call stop() on it
+     * Try to find another node in the cluster to connect to
+     * If this is not possible then show the Veil Component
+     */
+    handleClose(): void {
+        this.log.warn('Web socket closed');
+        if (this.lcd) {
+            this.lcd.stop();
+        }
+        this.wsUp = false;
+        let gsucc;
+
+        if (gsucc = this.findGuiSuccessor()) {
+            this.url = this.createWebSocket(this.webSockOpts, gsucc);
+        } else {
+            // If no controllers left to contact, show the Veil...
+            if (this.vcd) {
+                this.vcd.show([
+                    'Oops!',  // TODO: Localize this
+                    'Web-socket connection to server closed...',
+                    'Try refreshing the page.',
+                ]);
+            }
+        }
+    }
+
+    // ==============================
+    // === Private Helper Functions
+
+    /**
+     * Find the next node in the ONOS cluster.
+     *
+     * This is used if the WebSocket connection closes because a
+     * node in the cluster ges down - fail over should be automatic
+     */
+    findGuiSuccessor(): string {
+        const ncn = this.clusterNodes.length;
+        let ip: string;
+        let node;
+
+        while (this.connectRetries < ncn && !ip) {
+            this.connectRetries++;
+            this.clusterIndex = (this.clusterIndex + 1) % ncn;
+            node = this.clusterNodes[this.clusterIndex];
+            ip = node && node.ip;
+        }
+
+        return ip;
+    }
+
+    /**
+     * When the WebSocket is opened, inform any listeners that registered
+     * for that event
+     */
+    informListeners(host: string, url: string): void {
+        for (const [key, cb] of this.openListeners.entries()) {
+            cb.cb(host, url);
+        }
+    }
+
+    send(ev: EventType): void {
+        if (this.fs.debugOn('txrx')) {
+            this.log.debug(' *Tx* >> ', ev.event, ev.payload);
+        }
+        this.ws.send(JSON.stringify(ev));
+    }
+
+    /**
+     * Check if there are no WSS event handlers left
+     */
+    noHandlersWarn(handlers: Map<string, Object>, caller: string): boolean {
+        if (!handlers || handlers.size === 0) {
+            this.log.warn('WSS.' + caller + '(): no event handlers');
+            return true;
+        }
+        return false;
+    }
+
     /* ===================
      * === API Functions
-     *
+     */
+
+    /**
      * Required for unit tests to set to known state
      */
-    resetState() {
+    resetState(): void {
         this.webSockOpts = undefined;
         this.ws = null;
         this.wsUp = false;
         this.host = undefined;
         this.url = undefined;
         this.pendingEvents = [];
-        this.handlers = {};
+        this.handlers.clear();
         this.clusterNodes = [];
         this.clusterIndex = -1;
         this.glyphs = [];
         this.connectRetries = 0;
-        this.openListeners = {};
+        this.openListeners.clear();
         this.nextListenerId = 1;
 
     }
 
-    /* Currently supported opts:
+    /*
+     * Currently supported opts:
      *  wsport: web socket port (other than default 8181)
      *  host:   if defined, is the host address to use
      */
-    createWebSocket(opts, _host_: string = '') {
-        const wsport = (opts && opts.wsport) || null;
-
+    createWebSocket(opts?: WsOptions, host?: string) {
         this.webSockOpts = opts; // preserved for future calls
+        this.host = host === undefined ? this.window.location.host : host;
+        this.url = this.ufs.wsUrl('core', opts === undefined ? '' : opts['wsport'].toString(), host);
 
-//        this.host = _host_ || this.host();
-        const url = this.ufs.wsUrl('core', wsport, _host_);
-
-        this.log.debug('Attempting to open websocket to: ' + url);
-        this.ws = this.wsock.newWebSocket(url);
+        this.log.debug('Attempting to open websocket to: ' + this.url);
+        this.ws = this.wsock.newWebSocket(this.url);
         if (this.ws) {
-            this.ws.onopen = this.handleOpen();
-            this.ws.onmessage = this.handleMessage('???');
-            this.ws.onclose = this.handleClose();
+            // fat arrow => syntax means that the 'this' context passed will
+            // be of WebSocketService, not the WebSocket
+            this.ws.onopen = (() => this.handleOpen());
+            this.ws.onmessage = ((msgEvent) => this.handleMessage(msgEvent));
+            this.ws.onclose = (() => this.handleClose());
 
-//            sendEvent('authentication', { token: onosAuth });
-            this.sendEvent('authentication token', '');
+            this.sendEvent('authentication token', { token: 'testAuth' });
         }
         // Note: Wsock logs an error if the new WebSocket call fails
-        return url;
+        return this.url;
     }
 
-    handleOpen() {
-        this.log.debug('WebSocketService: handleOpen() not yet implemented');
+
+    /**
+     * Binds the message handlers to their message type (event type) as
+     *  specified in the given map. Note that keys are the event IDs; values
+     *  are either:
+     *     * the event handler function, or
+     *     * an API object which has an event handler for the key
+     */
+    bindHandlers(handlerMap: Map<string, (data) => void>): void {
+        const dups: string[] = [];
+
+        if (this.noHandlersWarn(handlerMap, 'bindHandlers')) {
+            return null;
+        }
+        for (const [eventId, api] of handlerMap) {
+            this.log.debug('Adding handler for ', eventId);
+            const fn = this.fs.isF(api) || this.fs.isF(api[eventId]);
+            if (!fn) {
+                this.log.warn(eventId + ' handler not a function');
+                return;
+            }
+
+            if (this.handlers.get(eventId)) {
+                dups.push(eventId);
+            } else {
+                this.handlers.set(eventId, fn);
+            }
+        }
+        if (dups.length) {
+            this.log.warn('duplicate bindings ignored:', dups);
+        }
     }
 
-    handleMessage(msgEvent: any) {
-        this.log.debug('WebSocketService: handleMessage() not yet implemented');
+    /**
+     * Unbinds the specified message handlers.
+     *   Expected that the same map will be used, but we only care about keys
+     */
+    unbindHandlers(handlerMap: Map<string, (data) => void>): void {
+        if (this.noHandlersWarn(handlerMap, 'unbindHandlers')) {
+            return null;
+        }
+
+        for (const [eventId, api] of handlerMap) {
+            this.handlers.delete(eventId);
+        }
     }
 
-    handleClose() {
-        this.log.debug('WebSocketService: handleClose() not yet implemented');
+    /**
+     * Add a listener function for listening for WebSocket opening.
+     * The function must give a host and url and return void
+     */
+    addOpenListener(callback: (host: string, url: string) => void ): Callback {
+        const id: number = this.nextListenerId++;
+        const cb = this.fs.isF(callback);
+        const o: Callback = <Callback>{ id: id, cb: cb };
+
+        if (cb) {
+            this.openListeners.set(id, o);
+        } else {
+            this.log.error('WSS.addOpenListener(): callback not a function');
+            o.error = 'No callback defined';
+        }
+        return o;
     }
 
-    /* Formulates an event message and sends it via the web-socket.
+    /**
+     * Remove a listener of WebSocket opening
+     */
+    removeOpenListener(lsnr: Callback): void {
+        const id = this.fs.isO(lsnr) && lsnr.id;
+        let o;
+
+        if (!id) {
+            this.log.warn('WSS.removeOpenListener(): invalid listener', lsnr);
+            return null;
+        }
+        o = this.openListeners[id];
+
+        if (o) {
+            this.openListeners.delete(id);
+        }
+    }
+
+    /**
+     * Formulates an event message and sends it via the web-socket.
      * If the websocket is not up yet, we store it in a pending list.
      */
-    sendEvent(evType, payload) {
+    sendEvent(evType: string, payload: Object ): void {
         const ev = <EventType> {
             event: evType,
             payload: payload
         };
 
         if (this.wsUp) {
-            this._send(ev);
+            this.send(ev);
         } else {
             this.pendingEvents.push(ev);
         }
     }
 
-    _send(ev: EventType) {
-        if (this.fs.debugOn('txrx')) {
-            this.log.debug(' *Tx* >> ', ev.event, ev.payload);
-        }
-        this.ws.send(JSON.stringify(ev));
+    /**
+     * Binds the veil service as a delegate.
+     */
+    setVeilDelegate(vd: VeilComponent): void {
+        this.vcd = vd;
+    }
+
+    /**
+     * Binds the loading service as a delegate
+     */
+    setLoadingDelegate(ld: any): void {
+        // TODO - Investigate changing Loading Service to LoadingComponent
+        this.log.debug('Loading delegate set', ld);
+        this.lcd = ld;
     }
 
 }