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();
- });
-});