GUI2-Cluster View

Change-Id: I812439fae68d18756c707c1021ce6e070ae6afc3
diff --git a/web/gui2/src/main/webapp/app/fw/layer/panel.service.ts b/web/gui2/src/main/webapp/app/fw/layer/panel.service.ts
new file mode 100644
index 0000000..9854c15
--- /dev/null
+++ b/web/gui2/src/main/webapp/app/fw/layer/panel.service.ts
@@ -0,0 +1,223 @@
+/*
+ *  Copyright 2018-present Open Networking Foundation
+ *
+ *  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} from '@angular/core';
+import {FnService} from '../util/fn.service';
+import {LogService} from '../../log.service';
+import {ThemeService} from '../util/theme.service';
+import {WebSocketService} from '../remote/websocket.service';
+import * as d3 from 'd3';
+
+let fs;
+
+const defaultSettings = {
+    edge: 'right',
+    width: 200,
+    margin: 20,
+    hideMargin: 20,
+    xtnTime: 750,
+    fade: true,
+};
+
+let panels,
+    panelLayer;
+
+function init() {
+    panelLayer = d3.select('div#floatpanels');
+    panelLayer.text('');
+    panels = {};
+}
+
+// helpers for panel
+function noop() {
+}
+
+function margin(p: any) {
+    return p.settings.margin;
+}
+
+function hideMargin(p: any) {
+    return p.settings.hideMargin;
+}
+
+function noPx(p: any, what: any) {
+    return Number(p.el.style(what).replace(/px$/, ''));
+}
+
+function widthVal(p: any) {
+    return noPx(p, 'width');
+}
+
+function heightVal(p: any) {
+    return noPx(p, 'height');
+}
+
+function pxShow(p: any) {
+    return margin(p) + 'px';
+}
+
+function pxHide(p: any) {
+    return (-hideMargin(p) - widthVal(p) - (noPx(p, 'padding') * 2)) + 'px';
+}
+
+function makePanel(id: any, settings: any) {
+    const p = {
+            id: id,
+            settings: settings,
+            on: false,
+            el: null,
+        },
+        api = {
+            show: showPanel,
+            hide: hidePanel,
+            toggle: togglePanel,
+            empty: emptyPanel,
+            append: appendPanel,
+            width: panelWidth,
+            height: panelHeight,
+            bbox: panelBBox,
+            isVisible: panelIsVisible,
+            classed: classed,
+            el: panelEl,
+        };
+
+    p.el = panelLayer.append('div')
+        .attr('id', id)
+        .attr('class', 'floatpanel')
+        .style('opacity', 0);
+
+    // has to be called after el is set
+    p.el.style(p.settings.edge, pxHide(p));
+    panelWidth(p.settings.width);
+    if (p.settings.height) {
+        panelHeight(p.settings.height);
+    }
+
+    panels[id] = p;
+
+    function showPanel(cb: any) {
+        const endCb = fs.isF(cb) || noop;
+        p.on = true;
+        p.el.transition().duration(p.settings.xtnTime)
+            .style(p.settings.edge, pxShow(p))
+            .style('opacity', 1);
+    }
+
+    function hidePanel(cb: any) {
+        const endCb = fs.isF(cb) || noop,
+            endOpacity = p.settings.fade ? 0 : 1;
+        p.on = false;
+        p.el.transition().duration(p.settings.xtnTime)
+            .style(p.settings.edge, pxHide(p))
+            .style('opacity', endOpacity);
+    }
+
+    function togglePanel(cb: any) {
+        if (p.on) {
+            hidePanel(cb);
+        } else {
+            showPanel(cb);
+        }
+        return p.on;
+    }
+
+    function emptyPanel() {
+        return p.el.text('');
+    }
+
+    function appendPanel(what: any) {
+        return p.el.append(what);
+    }
+
+    function panelWidth(w: any) {
+        if (w === undefined) {
+            return widthVal(p);
+        }
+        p.el.style('width', w + 'px');
+    }
+
+    function panelHeight(h: any) {
+        if (h === undefined) {
+            return heightVal(p);
+        }
+        p.el.style('height', h + 'px');
+    }
+
+    function panelBBox() {
+        return p.el.node().getBoundingClientRect();
+    }
+
+    function panelIsVisible() {
+        return p.on;
+    }
+
+    function classed(cls: any, bool: any) {
+        return p.el.classed(cls, bool);
+    }
+
+    function panelEl() {
+        return p.el;
+    }
+
+    return api;
+}
+
+function removePanel(id: any) {
+    panelLayer.select('#' + id).remove();
+    delete panels[id];
+}
+
+@Injectable({
+    providedIn: 'root',
+})
+
+export class PanelService {
+    constructor(private funcs: FnService,
+                private log: LogService,
+                private ts: ThemeService,
+                private wss: WebSocketService) {
+        fs = this.funcs;
+        init();
+    }
+
+    createPanel(id: any, opts: any) {
+        const settings = Object.assign({}, defaultSettings, opts);
+        if (!id) {
+            this.log.warn('createPanel: no ID given');
+            return null;
+        }
+        if (panels[id]) {
+            this.log.warn('Panel with ID "' + id + '" already exists');
+            return null;
+        }
+        if (fs.debugOn('widget')) {
+            this.log.debug('creating panel:', id, settings);
+        }
+        return makePanel(id, settings);
+    }
+
+    destroyPanel(id: any) {
+        if (panels[id]) {
+            if (fs.debugOn('widget')) {
+                this.log.debug('destroying panel:', id);
+            }
+            removePanel(id);
+        } else {
+            if (fs.debugOn('widget')) {
+                this.log.debug('no panel to destroy:', id);
+            }
+        }
+    }
+}
diff --git a/web/gui2/src/main/webapp/app/fw/nav/nav/nav.component.html b/web/gui2/src/main/webapp/app/fw/nav/nav/nav.component.html
index bd4d6af..505dc46 100644
--- a/web/gui2/src/main/webapp/app/fw/nav/nav/nav.component.html
+++ b/web/gui2/src/main/webapp/app/fw/nav/nav/nav.component.html
@@ -22,6 +22,9 @@
     <a id="settings" (click)="ns.hideNav()" routerLink="/settings" routerLinkActive="active">
         <onos-icon iconId="nav_settings"></onos-icon> Settings</a>
 
+    <a id="cluster" (click)="ns.hideNav()" routerLink="/cluster" routerLinkActive="active">
+        <onos-icon iconId="nav_cluster"></onos-icon> Cluster Nodes</a>
+
     <a id="processor" (click)="ns.hideNav()" routerLink="/processor" routerLinkActive="active">
         <onos-icon iconId="nav_processors"></onos-icon> Packet Processors</a>
 
@@ -46,4 +49,4 @@
         <onos-icon iconId="nav_tunnels"></onos-icon> Tunnels</a>
 
     <div id="other" class="nav-hdr">{{ lionFn('cat_other') }}</div>
-</nav>
\ No newline at end of file
+</nav>
diff --git a/web/gui2/src/main/webapp/app/fw/nav/nav/nav.component.spec.ts b/web/gui2/src/main/webapp/app/fw/nav/nav/nav.component.spec.ts
index 9435b43..1ba29a0 100644
--- a/web/gui2/src/main/webapp/app/fw/nav/nav/nav.component.spec.ts
+++ b/web/gui2/src/main/webapp/app/fw/nav/nav/nav.component.spec.ts
@@ -130,6 +130,13 @@
         expect(div.textContent).toEqual(' Applications');
     });
 
+    it('should have an cluster view link inside a nav#nav', () => {
+        const appDe: DebugElement = fixture.debugElement;
+        const divDe = appDe.query(By.css('nav#nav a#cluster'));
+        const div: HTMLElement = divDe.nativeElement;
+        expect(div.textContent).toEqual(' Cluster Nodes');
+    });
+
     it('should have an processor view link inside a nav#nav', () => {
         const appDe: DebugElement = fixture.debugElement;
         const divDe = appDe.query(By.css('nav#nav a#processor'));
diff --git a/web/gui2/src/main/webapp/app/onos-routing.module.ts b/web/gui2/src/main/webapp/app/onos-routing.module.ts
index 8be8549..327f17e 100644
--- a/web/gui2/src/main/webapp/app/onos-routing.module.ts
+++ b/web/gui2/src/main/webapp/app/onos-routing.module.ts
@@ -38,6 +38,10 @@
         loadChildren: 'app/view/partition/partition.module#PartitionModule'
     },
     {
+        path: 'cluster',
+        loadChildren: 'app/view/cluster/cluster.module#ClusterModule'
+    },
+    {
         path: 'device',
         loadChildren: 'app/view/device/device.module#DeviceModule'
     },
diff --git a/web/gui2/src/main/webapp/app/onos.component.css b/web/gui2/src/main/webapp/app/onos.component.css
index 6b7cd6c..7e5af04 100644
--- a/web/gui2/src/main/webapp/app/onos.component.css
+++ b/web/gui2/src/main/webapp/app/onos.component.css
@@ -28,3 +28,10 @@
     -webkit-margin-before: 0;
     -webkit-margin-after: 0;
 }
+
+#floatpanels {
+    z-index: 0;
+    font-size: 10pt;
+    width:821px;
+    top: 145px;
+}
\ No newline at end of file
diff --git a/web/gui2/src/main/webapp/app/onos.component.html b/web/gui2/src/main/webapp/app/onos.component.html
index b71bab2..2012a17 100644
--- a/web/gui2/src/main/webapp/app/onos.component.html
+++ b/web/gui2/src/main/webapp/app/onos.component.html
@@ -19,6 +19,8 @@
     <onos-veil #veil></onos-veil>
     <div>{{ wss.setVeilDelegate(veil) }}</div>
     <router-outlet></router-outlet>
+    <div id="floatpanels"></div>
+
 </div>
 
 
diff --git a/web/gui2/src/main/webapp/app/view/apps/apps/apps.component.html b/web/gui2/src/main/webapp/app/view/apps/apps/apps.component.html
index 37826fa..9aa9584 100644
--- a/web/gui2/src/main/webapp/app/view/apps/apps/apps.component.html
+++ b/web/gui2/src/main/webapp/app/view/apps/apps/apps.component.html
@@ -104,7 +104,7 @@
             <table>
                 <tr *ngIf="tableData.length === 0" class="no-data">
                     <td colspan="5">
-                        {{annots.no_rows_msg}}
+                        {{annots.noRowsMsg}}
                     </td>
                 </tr>
                 <!-- See https://angular.io/guide/pipes#appendix-no-filterpipe-or-orderbypipe
diff --git a/web/gui2/src/main/webapp/app/view/cluster/cluster-details.directive.ts b/web/gui2/src/main/webapp/app/view/cluster/cluster-details.directive.ts
new file mode 100644
index 0000000..9aa2eb7
--- /dev/null
+++ b/web/gui2/src/main/webapp/app/view/cluster/cluster-details.directive.ts
@@ -0,0 +1,314 @@
+/*
+ * Copyright 2015-present Open Networking Foundation
+ *
+ * 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 {Directive, ElementRef, EventEmitter, Inject, Input, OnChanges, OnDestroy, OnInit, Output} from '@angular/core';
+import {FnService} from '../../fw/util/fn.service';
+import {LogService} from '../../log.service';
+import {MastService} from '../../fw/mast/mast.service';
+import {DetailsPanelBaseImpl} from '../../fw/widget/detailspanel.base';
+import {LoadingService} from '../../fw/layer/loading.service';
+import {IconService} from '../../fw/svg/icon.service';
+import {LionService} from '../../fw/util/lion.service';
+import {WebSocketService} from '../../fw/remote/websocket.service';
+import * as d3 from 'd3';
+import {PanelService} from '../../fw/layer/panel.service';
+import {HostListener} from '@angular/core';
+
+// internal state
+let detailsPanel,
+    pStartY,
+    pHeight,
+    top,
+    topTable,
+    bottom,
+    iconDiv,
+    wSize;
+
+
+// constants
+const topPdg = 28,
+    ctnrPdg = 24,
+    scrollSize = 17,
+    portsTblPdg = 100,
+    pName = 'details-panel',
+    propOrder = [
+        'id', 'ip',
+    ],
+    deviceCols = [
+        'id', 'type', 'chassisid', 'mfr',
+        'hw', 'sw', 'protocol', 'serial',
+    ];
+
+function addProp(tbody, label, value) {
+    const tr = tbody.append('tr');
+
+    function addCell(cls, txt) {
+        tr.append('td').attr('class', cls).text(txt);
+    }
+
+    addCell('label', label + ' :');
+    addCell('value', value);
+}
+
+function addDeviceRow(tbody, device) {
+    const tr = tbody.append('tr');
+
+    deviceCols.forEach(function (col) {
+        tr.append('td').text(device[col]);
+    });
+}
+
+@Directive({
+    selector: '[onosClusterDetails]',
+})
+
+export class ClusterDetailsDirective extends DetailsPanelBaseImpl implements OnInit, OnDestroy, OnChanges {
+    @Input() id: string;
+    @Output() closeEvent = new EventEmitter<string>();
+
+    lionFn; // Function
+
+    constructor(protected fs: FnService,
+                protected ls: LoadingService,
+                protected is: IconService,
+                protected lion: LionService,
+                protected wss: WebSocketService,
+                protected log: LogService,
+                protected mast: MastService,
+                protected ps: PanelService,
+                protected el: ElementRef,
+                @Inject('Window') private w: Window) {
+        super(fs, ls, log, wss, 'cluster');
+
+        if (this.lion.ubercache.length === 0) {
+            this.lionFn = this.dummyLion;
+            this.lion.loadCbs.set('clusterdetails', () => this.doLion());
+        } else {
+            this.doLion();
+        }
+        this.log.debug('ClusterDetailsDirective constructed');
+    }
+
+    ngOnInit() {
+        this.init();
+        this.initPanel();
+        this.log.debug('Cluster Details Component initialized');
+    }
+
+    /**
+     * Stop listening to clusterDetailsResponse on WebSocket
+     */
+    ngOnDestroy() {
+        this.lion.loadCbs.delete('clusterdetails');
+        this.destroy();
+        this.ps.destroyPanel(pName);
+        this.log.debug('Cluster Details Component destroyed');
+    }
+
+    @HostListener('window:resize', ['event'])
+    onResize(event: any) {
+        this.heightCalc();
+        this.populateDetails(this.detailsData);
+        return {
+            h: this.w.innerHeight,
+            w: this.w.innerWidth
+        };
+    }
+
+    @HostListener('document:click', ['$event'])
+    onClick(event) {
+        if (event.path !== undefined) {
+            for (let i = 0; i < event.path.length; i++) {
+                if (event.path[i].className === 'close-btn') {
+                    this.close();
+                    break;
+                }
+            }
+        } else if (event.target.href === undefined) {
+            if (event.target.parentNode.className === 'close-btn') {
+                this.close();
+            }
+        } else if (event.target.href.baseVal === '#xClose') {
+            this.close();
+        }
+    }
+
+    /**
+     * Details Panel Data Request on row selection changes
+     * Should be called whenever id changes
+     * If id is empty, no request is made
+     */
+    ngOnChanges() {
+        if (this.id === '') {
+            if (detailsPanel) {
+                detailsPanel.hide();
+            }
+            return '';
+        } else {
+            const query = {
+                'id': this.id
+            };
+            this.requestDetailsPanelData(query);
+            this.heightCalc();
+
+            /*
+             * Details data takes around 2ms to come up on web-socket
+             * putting a timeout interval of 5ms
+             */
+            setTimeout(() => {
+                this.populateDetails(this.detailsData);
+                detailsPanel.show();
+            }, 500);
+
+
+        }
+    }
+
+    doLion() {
+        this.lionFn = this.lion.bundle('core.view.Cluster');
+    }
+
+    heightCalc() {
+        pStartY = this.fs.noPxStyle(d3.select('.tabular-header'), 'height')
+            + this.mast.mastHeight + topPdg;
+        wSize = this.fs.windowSize(this.fs.noPxStyle(d3.select('.tabular-header'), 'height'));
+        pHeight = wSize.height;
+    }
+
+    createDetailsPane() {
+        detailsPanel = this.ps.createPanel(pName, {
+            width: wSize.width,
+            margin: 0,
+            hideMargin: 0,
+        });
+        detailsPanel.el().style('top', pStartY + 'px');
+        detailsPanel.el().style('position', 'absolute');
+        this.hidePanel = function () {
+            detailsPanel.hide();
+        };
+        detailsPanel.hide();
+    }
+
+    initPanel() {
+        this.heightCalc();
+        this.createDetailsPane();
+    }
+
+    populateDetails(details) {
+        this.setUpPanel();
+        this.populateTop(details);
+        this.populateBottom(details.devices);
+        detailsPanel.height(pHeight);
+    }
+
+    setUpPanel() {
+        let container, closeBtn;
+        detailsPanel.empty();
+
+        container = detailsPanel.append('div').classed('container', true);
+
+        top = container.append('div').classed('top', true);
+        closeBtn = top.append('div').classed('close-btn', true);
+        this.addCloseBtn(closeBtn);
+        iconDiv = top.append('div').classed('dev-icon', true);
+        top.append('h2');
+        topTable = top.append('div').classed('top-content', true)
+            .append('table');
+        top.append('hr');
+
+        bottom = container.append('div').classed('bottom', true);
+        bottom.append('h2').classed('devices-title', true).text('Devices');
+        bottom.append('table');
+    }
+
+    addCloseBtn(div) {
+        this.is.loadEmbeddedIcon(div, 'close', 20);
+        div.on('click', this.closePanel);
+    }
+
+    closePanel(): boolean {
+        if (detailsPanel.isVisible()) {
+            detailsPanel.hide();
+            return true;
+        }
+        return false;
+    }
+
+    populateTop(details) {
+        const propLabels = this.getLionProps();
+
+        this.is.loadEmbeddedIcon(iconDiv, 'node', 40);
+        top.select('h2').text(details.id);
+
+        const tbody = topTable.append('tbody');
+
+        propOrder.forEach(function (prop, i) {
+            addProp(tbody, propLabels[i], details[prop]);
+        });
+    }
+
+    getLionDeviceCols() {
+        return [
+            this.lionFn('uri'),
+            this.lionFn('type'),
+            this.lionFn('chassis_id'),
+            this.lionFn('vendor'),
+            this.lionFn('hw_version'),
+            this.lionFn('sw_version'),
+            this.lionFn('protocol'),
+            this.lionFn('serial_number'),
+        ];
+    }
+
+    populateBottom(devices) {
+        const table = bottom.select('table'),
+            theader = table.append('thead').append('tr'),
+            tbody = table.append('tbody');
+
+        let tbWidth, tbHeight;
+
+        this.getLionDeviceCols().forEach(function (col) {
+            theader.append('th').text(col);
+        });
+        if (devices !== undefined) {
+            devices.forEach(function (device) {
+                addDeviceRow(tbody, device);
+            });
+        }
+
+        tbWidth = this.fs.noPxStyle(tbody, 'width') + scrollSize;
+        tbHeight = pHeight
+            - (this.fs.noPxStyle(detailsPanel.el()
+                    .select('.top'), 'height')
+                + this.fs.noPxStyle(detailsPanel.el()
+                    .select('.devices-title'), 'height')
+                + portsTblPdg);
+
+        table.style('zIndex', '0');
+        table.style('height', tbHeight + 'px');
+        table.style('width', tbWidth + 'px');
+        table.style('overflow', 'auto');
+        table.style('display', 'block');
+
+        detailsPanel.width(tbWidth + ctnrPdg);
+    }
+
+    getLionProps() {
+        return [
+            this.lionFn('node_id'),
+            this.lionFn('ip_address'),
+        ];
+    }
+}
diff --git a/web/gui2/src/main/webapp/app/view/cluster/cluster-routing.module.ts b/web/gui2/src/main/webapp/app/view/cluster/cluster-routing.module.ts
new file mode 100644
index 0000000..1dd26b5
--- /dev/null
+++ b/web/gui2/src/main/webapp/app/view/cluster/cluster-routing.module.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2018-present Open Networking Foundation
+ *
+ * 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 { Routes, RouterModule } from '@angular/router';
+import {ClusterComponent} from './cluster/cluster.component';
+
+const clusterRoutes: Routes = [
+    {
+        path: '',
+        component: ClusterComponent
+    }
+];
+
+/**
+ * ONOS GUI -- Cluster Tabular View Feature Routing Module - allows it to be lazy loaded
+ *
+ * See https://angular.io/guide/lazy-loading-ngmodules
+ */
+@NgModule({
+    imports: [RouterModule.forChild(clusterRoutes)],
+    exports: [RouterModule]
+})
+export class ClusterRoutingModule { }
diff --git a/web/gui2/src/main/webapp/app/view/cluster/cluster.module.ts b/web/gui2/src/main/webapp/app/view/cluster/cluster.module.ts
new file mode 100644
index 0000000..d5da0bf
--- /dev/null
+++ b/web/gui2/src/main/webapp/app/view/cluster/cluster.module.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2018-present Open Networking Foundation
+ *
+ * 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 {CommonModule} from '@angular/common';
+import {SvgModule} from '../../fw/svg/svg.module';
+import {WidgetModule} from '../../fw/widget/widget.module';
+import {ClusterComponent} from './cluster/cluster.component';
+import {ClusterRoutingModule} from './cluster-routing.module';
+import { ClusterDetailsDirective } from './cluster-details.directive';
+
+@NgModule({
+    imports: [
+        CommonModule,
+        SvgModule,
+        ClusterRoutingModule,
+        WidgetModule
+    ],
+    declarations: [ClusterComponent, ClusterDetailsDirective]
+})
+export class ClusterModule {
+}
diff --git a/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.css b/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.css
new file mode 100644
index 0000000..61e4135
--- /dev/null
+++ b/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.css
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2018-present Open Networking Foundation
+ *
+ * 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.
+ */
+
+/*
+ ONOS GUI -- Cluster View (layout) -- CSS file
+ */
+#ov-cluster h2 {
+    display: inline-block;
+}
+
+#ov-cluster div.ctrl-btns {
+    width: 45px;
+}
+
+#ov-cluster .tabular-header {
+    text-align: left;
+}
+#ov-cluster div.summary-list .table-header td {
+    font-weight: bold;
+    font-variant: small-caps;
+    text-transform: uppercase;
+    font-size: 10pt;
+    padding-top: 8px;
+    padding-bottom: 8px;
+    letter-spacing: 0.02em;
+    cursor: pointer;
+    background-color: #e5e5e6;
+    color: #3c3a3a;
+}
+
+#ov-cluster div.summary-list .table-body {
+    overflow:scroll;
+}
+
+#ov-cluster th, td {
+    text-align: left;
+    padding:  8px;
+}
+
+
+
+
diff --git a/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.html b/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.html
new file mode 100644
index 0000000..1bd61ec
--- /dev/null
+++ b/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.html
@@ -0,0 +1,72 @@
+<!--
+~ Copyright 2018-present Open Networking Foundation
+~
+~ 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.
+-->
+<div id="ov-cluster">
+    <div class="tabular-header">
+        <h2>
+            {{lionFn('title_cluster_nodes')}}
+            ({{tableData.length}} {{lionFn('total')}})
+        </h2>
+        <div class="ctrl-btns">
+            <div class="refresh" (click)="toggleRefresh()">
+                <onos-icon classes="{{ autoRefresh?'active refresh':'refresh' }}"
+                           iconId="refresh" iconSize="42" toolTip="{{ autoRefreshTip }}"></onos-icon>
+            </div>
+        </div>
+    </div>
+
+    <div class="summary-list" class="summary-list" onosTableResize>
+        <div class="table-header">
+            <table>
+                <tr>
+                    <td colId="_iconid_state" style="width:84px" class="table-icon">
+                        {{lionFn('active')}}
+                    </td>
+                    <td colId="_iconid_started" style="width:90px" class="table-icon">
+                        {{lionFn('started')}}
+                    </td>
+                    <td colId="id"> {{lionFn('node_id')}}</td>
+                    <td colId="ip"> {{lionFn('ip_address')}}</td>
+                    <td colId="tcp"> {{lionFn('tcp_port')}}</td>
+                    <td colId="updated"> {{lionFn('last_updated')}}</td>
+                </tr>
+            </table>
+        </div>
+
+        <div class="table-body">
+            <table>
+                <tr *ngIf="tableData.length === 0" class="no-data">
+                    <td colspan="9">{{ annots.noRowsMsg }}</td>
+                </tr>
+
+                <tr *ngFor="let node of tableData" (click)="selectCallback($event, node)"
+                    onosClusterDetails id="{{ selId }}" (closeEvent)="deselectRow($event)"
+                    [ngClass]="{selected: node.id === selId, 'data-change': isChanged(node.id)}">
+                    <td class="table-icon" style="width:84px">
+                        <onos-icon classes="{{ node._iconid_state}}" iconId={{node._iconid_state}}></onos-icon>
+                    </td>
+                    <td class="table-icon" style="width:90px">
+                        <onos-icon classes="{{node._iconid_started}}"
+                                   iconId="{{node._iconid_started}}"></onos-icon>
+                    </td>
+                    <td>{{node.id}}</td>
+                    <td>{{node.ip}}</td>
+                    <td>{{node.tcp}}</td>
+                    <td>{{node.updated}}</td>
+                </tr>
+            </table>
+        </div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.spec.ts b/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.spec.ts
new file mode 100644
index 0000000..f0b4908
--- /dev/null
+++ b/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.spec.ts
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2018-present Open Networking Foundation
+ *
+ * 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 {async, ComponentFixture, TestBed} from '@angular/core/testing';
+
+import {ClusterComponent} from './cluster.component';
+import {FnService} from '../../../fw/util/fn.service';
+import {LogService} from '../../../log.service';
+import {ActivatedRoute, Params} from '@angular/router';
+import {of} from 'rxjs/index';
+import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
+import {FormsModule} from '@angular/forms';
+import {RouterTestingModule} from '@angular/router/testing';
+import {IconComponent} from '../../../fw/svg/icon/icon.component';
+import {IconService} from '../../../fw/svg/icon.service';
+import {GlyphService} from '../../../fw/svg/glyph.service';
+import {KeyService} from '../../../fw/util/key.service';
+import {LoadingService} from '../../../fw/layer/loading.service';
+import {MastService} from '../../../fw/mast/mast.service';
+import {NavService} from '../../../fw/nav/nav.service';
+import {WebSocketService} from '../../../fw/remote/websocket.service';
+import {ThemeService} from '../../../fw/util/theme.service';
+import {DebugElement} from '@angular/core';
+import {By} from '@angular/platform-browser';
+
+class MockActivatedRoute extends ActivatedRoute {
+    constructor(params: Params) {
+        super();
+        this.queryParams = of(params);
+    }
+}
+
+class MockIconService {
+    loadIconDef() {
+    }
+}
+
+class MockGlyphService {
+}
+
+class MockKeyService {
+}
+
+class MockLoadingService {
+    startAnim() {
+    }
+
+    stop() {
+    }
+}
+
+class MockNavService {
+}
+
+class MockMastService {
+}
+
+class MockThemeService {
+}
+
+class MockWebSocketService {
+    createWebSocket() {
+    }
+
+    isConnected() {
+        return false;
+    }
+
+    unbindHandlers() {
+    }
+
+    bindHandlers() {
+    }
+}
+
+/**
+ * ONOS GUI -- Cluster View Module - Unit Tests
+ */
+
+describe('ClusterComponent', () => {
+    let fs: FnService;
+    let ar: MockActivatedRoute;
+    let windowMock: Window;
+    let logServiceSpy: jasmine.SpyObj<LogService>;
+    let component: ClusterComponent;
+    let fixture: ComponentFixture<ClusterComponent>;
+
+    beforeEach(async(() => {
+        const logSpy = jasmine.createSpyObj('LogService', ['info', 'debug', 'warn', 'error']);
+        ar = new MockActivatedRoute({'debug': 'txrx'});
+        windowMock = <any>{
+            location: <any>{
+                hostname: 'foo',
+                host: 'foo',
+                port: '80',
+                protocol: 'http',
+                search: {debug: 'true'},
+                href: 'ws://foo:123/onos/ui/websock/path',
+                absUrl: 'ws://foo:123/onos/ui/websock/path'
+            }
+        };
+        fs = new FnService(ar, logSpy, windowMock);
+
+        TestBed.configureTestingModule({
+            imports: [BrowserAnimationsModule, FormsModule, RouterTestingModule],
+            declarations: [ClusterComponent, IconComponent],
+            providers: [
+                {provide: FnService, useValue: fs},
+                {provide: IconService, useClass: MockIconService},
+                {provide: GlyphService, useClass: MockGlyphService},
+                {provide: KeyService, useClass: MockKeyService},
+                {provide: LoadingService, useClass: MockLoadingService},
+                {provide: MastService, useClass: MockMastService},
+                {provide: NavService, useClass: MockNavService},
+                {provide: LogService, useValue: logSpy},
+                {provide: ThemeService, useClass: MockThemeService},
+                {provide: WebSocketService, useClass: MockWebSocketService},
+                {provide: 'Window', useValue: windowMock},
+            ]
+        }).compileComponents();
+        logServiceSpy = TestBed.get(LogService);
+    }));
+
+    beforeEach(() => {
+        fixture = TestBed.createComponent(ClusterComponent);
+        component = fixture.componentInstance;
+        fixture.detectChanges();
+    });
+
+    it('should create', () => {
+        expect(component).toBeTruthy();
+    });
+
+    it('should have a div.tabular-header inside a div#ov-cluster', () => {
+        const appDe: DebugElement = fixture.debugElement;
+        const divDe = appDe.query(By.css('div#ov-cluster div.tabular-header'));
+        expect(divDe).toBeTruthy();
+    });
+
+    it('should have a refresh button inside the div.tabular-header', () => {
+        const appDe: DebugElement = fixture.debugElement;
+        const divDe = appDe.query(By.css('div#ov-cluster div.tabular-header div.ctrl-btns div.refresh'));
+        expect(divDe).toBeTruthy();
+    });
+
+    it('should have a div.summary-list inside a div#ov-cluster', () => {
+        const appDe: DebugElement = fixture.debugElement;
+        const divDe = appDe.query(By.css('div#ov-cluster div.summary-list'));
+        expect(divDe).toBeTruthy();
+    });
+
+    it('should have a div.table-body ', () => {
+        const appDe: DebugElement = fixture.debugElement;
+        const divDe = appDe.query(By.css('div#ov-cluster div.summary-list div.table-body'));
+        expect(divDe).toBeTruthy();
+    });
+});
diff --git a/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.ts b/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.ts
new file mode 100644
index 0000000..1181a17
--- /dev/null
+++ b/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.component.ts
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2018-present Open Networking Foundation
+ *
+ * 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, OnDestroy, OnInit} from '@angular/core';
+import {SortDir, TableBaseImpl, TableResponse} from '../../../fw/widget/table.base';
+import {FnService} from '../../../fw/util/fn.service';
+import {LoadingService} from '../../../fw/layer/loading.service';
+import {LogService} from '../../../log.service';
+import {WebSocketService} from '../../../fw/remote/websocket.service';
+import {LionService} from '../../../fw/util/lion.service';
+
+/**
+ * Model of the response from WebSocket
+ */
+interface ClusterTableResponse extends TableResponse {
+    clusters: Cluster[];
+}
+
+/**
+ * Model of the cluster returned from the WebSocket
+ */
+interface Cluster {
+    _iconid_state: string;
+    _iconid_started: string;
+    active: string;
+    started: string;
+    nodeId: string;
+    ipAddress: string;
+    tcpPort: string;
+    lastUpdated: string;
+}
+
+/**
+ * ONOS GUI -- Cluster View Component
+ */
+@Component({
+  selector: 'onos-cluster',
+  templateUrl: './cluster.component.html',
+  styleUrls: ['./cluster.component.css', './cluster.theme.css', '../../../fw/widget/table.css', '../../../fw/widget/table.theme.css']
+})
+
+export class ClusterComponent extends TableBaseImpl implements OnInit, OnDestroy {
+
+    lionFn; // Function
+
+    constructor(
+        protected fs: FnService,
+        protected ls: LoadingService,
+        protected log: LogService,
+        protected lion: LionService,
+        protected wss: WebSocketService,
+    ) {
+        super(fs, ls, log, wss, 'cluster');
+        this.responseCallback = this.clusterResponseCb;
+
+        this.sortParams = {
+            firstCol: 'id',
+            firstDir: SortDir.desc,
+            secondCol: 'ip',
+            secondDir: SortDir.asc,
+        };
+
+        if (this.lion.ubercache.length === 0) {
+            this.lionFn = this.dummyLion;
+            this.lion.loadCbs.set('cluster', () => this.doLion());
+        } else {
+            this.doLion();
+        }
+    }
+
+    ngOnInit() {
+        this.init();
+        this.log.debug('ClusterComponent initialized');
+    }
+
+    ngOnDestroy() {
+        this.destroy();
+        this.log.debug('ClusterComponent destroyed');
+    }
+
+    clusterResponseCb(data: ClusterTableResponse) {
+        this.log.debug('Cluster response received for ', data.clusters.length, 'cluster');
+    }
+
+    doLion() {
+        this.lionFn = this.lion.bundle('core.view.Cluster');
+
+    }
+}
diff --git a/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.theme.css b/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.theme.css
new file mode 100644
index 0000000..d15f27c5
--- /dev/null
+++ b/web/gui2/src/main/webapp/app/view/cluster/cluster/cluster.theme.css
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2018-present Open Networking Foundation
+ *
+ * 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.
+ */
+
+/*
+ ONOS GUI -- Cluster View (theme) -- CSS file
+ */
+
+.light #cluster-details-panel .bottom th {
+    background-color: #e5e5e6;
+}
+
+.light #cluster-details-panel .bottom tr:nth-child(odd) {
+    background-color: #fbfbfb;
+}
+.light #cluster-details-panel .bottom tr:nth-child(even) {
+    background-color: #f4f4f4;
+}
+
diff --git a/web/gui2/src/main/webapp/app/view/tunnel/tunnel.module.spec.ts b/web/gui2/src/main/webapp/app/view/tunnel/tunnel.module.spec.ts
deleted file mode 100644
index 694f645..0000000
--- a/web/gui2/src/main/webapp/app/view/tunnel/tunnel.module.spec.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Copyright 2018-present Open Networking Foundation
- *
- * 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 { TunnelModule } from './tunnel.module';
-
-describe('TunnelModule', () => {
-  let tunnelModule: TunnelModule;
-
-  beforeEach(() => {
-    tunnelModule = new TunnelModule();
-  });
-
-  it('should create an instance', () => {
-    expect(tunnelModule).toBeTruthy();
-  });
-});
diff --git a/web/gui2/src/main/webapp/onos.theme.css b/web/gui2/src/main/webapp/onos.theme.css
index a2ac0aa..8a09e16 100644
--- a/web/gui2/src/main/webapp/onos.theme.css
+++ b/web/gui2/src/main/webapp/onos.theme.css
@@ -85,4 +85,99 @@
 .dark select {
     color: #dddddd;
     background-color: #222222;
+}
+
+.floatpanel {
+    background-color: white;
+    color: #3c3a3a;
+    border: 1px solid #c7c7c0;
+}
+
+#details-panel .floatpanel a {
+    font-weight: bold;
+}
+
+#details-panel .container {
+    padding: 8px 12px;
+
+}
+
+#details-panel .container .bottom {
+    /*overflow-y: scroll;*/
+}
+
+#details-panel .close-btn {
+    position: absolute;
+    right:5px;
+    width: 20px;
+    height: 20px;
+    top: 5px;
+    cursor: pointer;
+}
+
+#details-panel .dev-icon {
+    display: inline-block;
+    padding: 0 6px 0 0;
+    vertical-align: middle;
+}
+
+#details-panel h2 {
+    display: inline-block;
+    margin: 8px 0;
+    font-weight: bold;
+    font-size: 16pt;
+}
+
+#details-panel h2 input {
+    font-size: 0.90em;
+}
+
+#details-panel .actionBtns div {
+    padding: 12px 6px;
+}
+
+#details-panel hr {
+    margin: 2px auto;
+}
+
+#details-panel td.label {
+    font-weight: bold;
+    text-align: right;
+    padding-right: 6px;
+}
+
+#details-panel .bottom table {
+    border-spacing: 0;
+    table-layout:fixed;
+    /*overflow: auto;*/
+    display: block;
+}
+
+#details-panel .bottom th {
+    letter-spacing: 0.02em;
+}
+
+#details-panel .bottom th,
+#details-panel .bottom td {
+    padding: 6px 12px;
+    text-align: center;
+}
+
+#details-panel .bottom thead tr {
+    background-color: #e5e5e6;
+}
+
+svg.embeddedIcon g.icon .glyph {
+    fill: #0071bd;
+    stroke: none;
+    fill-rule: evenodd;
+}
+
+div.close-btn svg.embeddedIcon g.icon .glyph {
+    fill: #333333;
+}
+
+svg.embeddedIcon g.icon rect {
+    stroke: none;
+    fill: none;
 }
\ No newline at end of file