Added native Bazel build to GUI2. Reduced a lot of the unused Angular CLI structures

Reviewers should look at the changes in WORKSPACE, BUILD, BUILD.bazel, README.md files
This is only possible now as rules_nodejs went to 1.0.0 on December 20
gui2 has now been made the entry point (rather than gui2-fw-lib)
No tests or linting are functional yet for Typescript
Each NgModule now has its own BUILD.bazel file with ng_module
gui2-fw-lib is all one module and has been refactored to simplify the directory structure
gui2-topo-lib is also all one module - its directory structure has had 3 layers removed
The big bash script in web/gui2/BUILD has been removed - all is done through ng_module rules
in web/gui2/src/main/webapp/BUILD.bazel and web/gui2/src/main/webapp/app/BUILD.bazel

Change-Id: Ifcfcc23a87be39fe6d6c8324046cc8ebadb90551
diff --git a/web/gui2-fw-lib/lib/util/fn.service.spec.ts b/web/gui2-fw-lib/lib/util/fn.service.spec.ts
new file mode 100644
index 0000000..e9b7c2a
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/fn.service.spec.ts
@@ -0,0 +1,493 @@
+/*
+ * 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 { TestBed, inject } from '@angular/core/testing';
+
+import { LogService } from '../log.service';
+import { ConsoleLoggerService } from '../consolelogger.service';
+import { FnService } from './fn.service';
+import { ActivatedRoute, Params } from '@angular/router';
+import { of } from 'rxjs';
+import * as d3 from 'd3';
+
+class MockActivatedRoute extends ActivatedRoute {
+    constructor(params: Params) {
+        super();
+        this.queryParams = of(params);
+    }
+}
+
+/**
+ * ONOS GUI -- Util -- General Purpose Functions - Unit Tests
+ */
+describe('FnService', () => {
+    let ar: ActivatedRoute;
+    let fs: FnService;
+    let mockWindow: Window;
+    let logServiceSpy: jasmine.SpyObj<LogService>;
+
+    const someFunction = () => {};
+    const someArray = [1, 2, 3];
+    const someObject = { foo: 'bar'};
+    const someNumber = 42;
+    const someString = 'xyyzy';
+    const someDate = new Date();
+    const stringArray = ['foo', 'bar'];
+
+    beforeEach(() => {
+        const logSpy = jasmine.createSpyObj('LogService', ['debug', 'warn']);
+        ar = new MockActivatedRoute({'debug': 'TestService'});
+        mockWindow = <any>{
+            innerWidth: 400,
+            innerHeight: 200,
+            navigator: {
+                userAgent: 'defaultUA'
+            }
+        };
+
+
+        TestBed.configureTestingModule({
+            providers: [FnService,
+                { provide: LogService, useValue: logSpy },
+                { provide: ActivatedRoute, useValue: ar },
+                { provide: 'Window', useFactory: (() => mockWindow ) }
+            ]
+        });
+
+        fs = TestBed.get(FnService);
+        logServiceSpy = TestBed.get(LogService);
+    });
+
+    it('should be created', () => {
+        expect(fs).toBeTruthy();
+    });
+
+    // === Tests for isF()
+    it('isF(): null for undefined', () => {
+        expect(fs.isF(undefined)).toBeNull();
+    });
+
+    it('isF(): null for null', () => {
+        expect(fs.isF(null)).toBeNull();
+    });
+    it('isF(): the reference for function', () => {
+        expect(fs.isF(someFunction)).toBe(someFunction);
+    });
+    it('isF(): null for string', () => {
+        expect(fs.isF(someString)).toBeNull();
+    });
+    it('isF(): null for number', () => {
+        expect(fs.isF(someNumber)).toBeNull();
+    });
+    it('isF(): null for Date', () => {
+        expect(fs.isF(someDate)).toBeNull();
+    });
+    it('isF(): null for array', () => {
+        expect(fs.isF(someArray)).toBeNull();
+    });
+    it('isF(): null for object', () => {
+        expect(fs.isF(someObject)).toBeNull();
+    });
+
+    // === Tests for isA()
+    it('isA(): null for undefined', () => {
+        expect(fs.isA(undefined)).toBeNull();
+    });
+    it('isA(): null for null', () => {
+        expect(fs.isA(null)).toBeNull();
+    });
+    it('isA(): null for function', () => {
+        expect(fs.isA(someFunction)).toBeNull();
+    });
+    it('isA(): null for string', () => {
+        expect(fs.isA(someString)).toBeNull();
+    });
+    it('isA(): null for number', () => {
+        expect(fs.isA(someNumber)).toBeNull();
+    });
+    it('isA(): null for Date', () => {
+        expect(fs.isA(someDate)).toBeNull();
+    });
+    it('isA(): the reference for array', () => {
+        expect(fs.isA(someArray)).toBe(someArray);
+    });
+    it('isA(): null for object', () => {
+        expect(fs.isA(someObject)).toBeNull();
+    });
+
+    // === Tests for isS()
+    it('isS(): null for undefined', () => {
+        expect(fs.isS(undefined)).toBeNull();
+    });
+    it('isS(): null for null', () => {
+        expect(fs.isS(null)).toBeNull();
+    });
+    it('isS(): null for function', () => {
+        expect(fs.isS(someFunction)).toBeNull();
+    });
+    it('isS(): the reference for string', () => {
+        expect(fs.isS(someString)).toBe(someString);
+    });
+    it('isS(): null for number', () => {
+        expect(fs.isS(someNumber)).toBeNull();
+    });
+    it('isS(): null for Date', () => {
+        expect(fs.isS(someDate)).toBeNull();
+    });
+    it('isS(): null for array', () => {
+        expect(fs.isS(someArray)).toBeNull();
+    });
+    it('isS(): null for object', () => {
+        expect(fs.isS(someObject)).toBeNull();
+    });
+
+    // === Tests for isO()
+    it('isO(): null for undefined', () => {
+        expect(fs.isO(undefined)).toBeNull();
+    });
+    it('isO(): null for null', () => {
+        expect(fs.isO(null)).toBeNull();
+    });
+    it('isO(): null for function', () => {
+        expect(fs.isO(someFunction)).toBeNull();
+    });
+    it('isO(): null for string', () => {
+        expect(fs.isO(someString)).toBeNull();
+    });
+    it('isO(): null for number', () => {
+        expect(fs.isO(someNumber)).toBeNull();
+    });
+    it('isO(): null for Date', () => {
+        expect(fs.isO(someDate)).toBeNull();
+    });
+    it('isO(): null for array', () => {
+        expect(fs.isO(someArray)).toBeNull();
+    });
+    it('isO(): the reference for object', () => {
+        expect(fs.isO(someObject)).toBe(someObject);
+    });
+
+
+    // === Tests for contains()
+    it('contains(): false for non-array', () => {
+        expect(fs.contains(null, 1)).toBeFalsy();
+    });
+    it('contains(): true for contained item', () => {
+        expect(fs.contains(someArray, 1)).toBeTruthy();
+        expect(fs.contains(stringArray, 'bar')).toBeTruthy();
+    });
+    it('contains(): false for non-contained item', () => {
+        expect(fs.contains(someArray, 109)).toBeFalsy();
+        expect(fs.contains(stringArray, 'zonko')).toBeFalsy();
+    });
+
+    // === Tests for areFunctions()
+    it('areFunctions(): true for empty-array', () => {
+        expect(fs.areFunctions({}, [])).toBeTruthy();
+    });
+    it('areFunctions(): true for some api', () => {
+        expect(fs.areFunctions({
+            a: () => {},
+            b: () => {}
+        }, ['b', 'a'])).toBeTruthy();
+    });
+    it('areFunctions(): false for some other api', () => {
+        expect(fs.areFunctions({
+            a: () => {},
+            b: 'not-a-function'
+        }, ['b', 'a'])).toBeFalsy();
+    });
+    it('areFunctions(): extraneous stuff NOT ignored', () => {
+        expect(fs.areFunctions({
+            a: () => {},
+            b: () => {},
+            c: 1,
+            d: 'foo'
+        }, ['a', 'b'])).toBeFalsy();
+    });
+    it('areFunctions(): extraneous stuff ignored (alternate fn)', () => {
+        expect(fs.areFunctionsNonStrict({
+            a: () => {},
+            b: () => {},
+            c: 1,
+            d: 'foo'
+        }, ['a', 'b'])).toBeTruthy();
+    });
+
+    // == use the now-tested areFunctions() on our own api:
+    it('should define api functions', () => {
+        expect(fs.areFunctions(fs, [
+            'isF', 'isA', 'isS', 'isO', 'contains',
+            'areFunctions', 'areFunctionsNonStrict', 'windowSize',
+            'isMobile', 'isChrome', 'isChromeHeadless', 'isSafari',
+            'isFirefox', 'parseDebugFlags',
+            'debugOn', 'debug', 'find', 'inArray', 'removeFromArray',
+            'isEmptyObject', 'cap', 'noPx', 'noPxStyle', 'endsWith',
+            'inEvilList', 'analyze', 'sanitize', 'sameObjProps', 'containsObj',
+            'addToTrie', 'removeFromTrie', 'trieLookup'
+//            'find', 'inArray', 'removeFromArray', 'isEmptyObject', 'sameObjProps', 'containsObj', 'cap',
+//            'eecode', 'noPx', 'noPxStyle', 'endsWith', 'addToTrie', 'removeFromTrie', 'trieLookup',
+//            'classNames', 'extend', 'sanitize'
+        ])).toBeTruthy();
+    });
+
+
+    // === Tests for windowSize()
+    it('windowSize(): adjust height', () => {
+        const dim = fs.windowSize(50);
+        expect(dim.width).toEqual(400);
+        expect(dim.height).toEqual(150);
+    });
+
+    it('windowSize(): adjust width', () => {
+        const dim = fs.windowSize(0, 50);
+        expect(dim.width).toEqual(350);
+        expect(dim.height).toEqual(200);
+    });
+
+    it('windowSize(): adjust width and height', () => {
+        const dim = fs.windowSize(101, 201);
+        expect(dim.width).toEqual(199);
+        expect(dim.height).toEqual(99);
+    });
+
+    // === Tests for isMobile()
+    const uaMap = {
+        chrome: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) ' +
+                'AppleWebKit/537.36 (KHTML, like Gecko) ' +
+                'Chrome/41.0.2272.89 Safari/537.36',
+
+        iPad: 'Mozilla/5.0 (iPad; CPU OS 7_0 like Mac OS X) ' +
+                'AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 ' +
+                'Mobile/11A465 Safari/9537.53',
+
+        iPhone: 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X) ' +
+                'AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 ' +
+                'Mobile/11A465 Safari/9537.53'
+    };
+
+    function setUa(key) {
+        const str = uaMap[key];
+        expect(str).toBeTruthy();
+        (<any>mockWindow.navigator).userAgent = str;
+    }
+
+    it('isMobile(): should be false for Chrome on Mac OS X', () => {
+        setUa('chrome');
+        expect(fs.isMobile()).toBe(false);
+    });
+    it('isMobile(): should be true for Safari on iPad', () => {
+        setUa('iPad');
+        expect(fs.isMobile()).toBe(true);
+    });
+    it('isMobile(): should be true for Safari on iPhone', () => {
+        setUa('iPhone');
+        expect(fs.isMobile()).toBe(true);
+    });
+
+    // === Tests for find()
+    const dataset = [
+        { id: 'foo', name: 'Furby'},
+        { id: 'bar', name: 'Barbi'},
+        { id: 'baz', name: 'Basil'},
+        { id: 'goo', name: 'Gabby'},
+        { id: 'zoo', name: 'Zevvv'}
+    ];
+
+    it('should not find ooo', () => {
+        expect(fs.find('ooo', dataset)).toEqual(-1);
+    });
+    it('should find foo', () => {
+        expect(fs.find('foo', dataset)).toEqual(0);
+    });
+    it('should find zoo', () => {
+        expect(fs.find('zoo', dataset)).toEqual(4);
+    });
+
+    it('should not find Simon', () => {
+        expect(fs.find('Simon', dataset, 'name')).toEqual(-1);
+    });
+    it('should find Furby', () => {
+        expect(fs.find('Furby', dataset, 'name')).toEqual(0);
+    });
+    it('should find Zevvv', () => {
+        expect(fs.find('Zevvv', dataset, 'name')).toEqual(4);
+    });
+
+
+    // === Tests for inArray()
+    const objRef = { x: 1, y: 2 };
+    const array = [1, 3.14, 'hey', objRef, 'there', true];
+    const array2 = ['b', 'a', 'd', 'a', 's', 's'];
+
+    it('should not find HOO', () => {
+        expect(fs.inArray('HOO', array)).toEqual(-1);
+    });
+    it('should find 1', () => {
+        expect(fs.inArray(1, array)).toEqual(0);
+    });
+    it('should find pi', () => {
+        expect(fs.inArray(3.14, array)).toEqual(1);
+    });
+    it('should find hey', () => {
+        expect(fs.inArray('hey', array)).toEqual(2);
+    });
+    it('should find the object', () => {
+        expect(fs.inArray(objRef, array)).toEqual(3);
+    });
+    it('should find there', () => {
+        expect(fs.inArray('there', array)).toEqual(4);
+    });
+    it('should find true', () => {
+        expect(fs.inArray(true, array)).toEqual(5);
+    });
+
+    it('should find the first occurrence A', () => {
+        expect(fs.inArray('a', array2)).toEqual(1);
+    });
+    it('should find the first occurrence S', () => {
+        expect(fs.inArray('s', array2)).toEqual(4);
+    });
+    it('should not find X', () => {
+        expect(fs.inArray('x', array2)).toEqual(-1);
+    });
+
+    // === Tests for removeFromArray()
+    it('should keep the array the same, for non-match', () => {
+        const array1 = [1, 2, 3];
+        expect(fs.removeFromArray(4, array1)).toBe(false);
+        expect(array1).toEqual([1, 2, 3]);
+    });
+    it('should remove a value', () => {
+        const array1a = [1, 2, 3];
+        expect(fs.removeFromArray(2, array1a)).toBe(true);
+        expect(array1a).toEqual([1, 3]);
+    });
+    it('should remove the first occurrence', () => {
+        const array1b = ['x', 'y', 'z', 'z', 'y'];
+        expect(fs.removeFromArray('y', array1b)).toBe(true);
+        expect(array1b).toEqual(['x', 'z', 'z', 'y']);
+        expect(fs.removeFromArray('x', array1b)).toBe(true);
+        expect(array1b).toEqual(['z', 'z', 'y']);
+    });
+
+    // === Tests for isEmptyObject()
+    it('should return true if an object is empty', () => {
+        expect(fs.isEmptyObject({})).toBe(true);
+    });
+    it('should return false if an object is not empty', () => {
+        expect(fs.isEmptyObject({foo: 'bar'})).toBe(false);
+    });
+
+    // === Tests for cap()
+    it('should ignore non-alpha', () => {
+        expect(fs.cap('123')).toEqual('123');
+    });
+    it('should capitalize first char', () => {
+        expect(fs.cap('Foo')).toEqual('Foo');
+        expect(fs.cap('foo')).toEqual('Foo');
+        expect(fs.cap('foo bar')).toEqual('Foo bar');
+        expect(fs.cap('FOO BAR')).toEqual('Foo bar');
+        expect(fs.cap('foo Bar')).toEqual('Foo bar');
+    });
+
+    // === Tests for noPx()
+    it('should return the value without px suffix', () => {
+        expect(fs.noPx('10px')).toBe(10);
+        expect(fs.noPx('500px')).toBe(500);
+        expect(fs.noPx('-80px')).toBe(-80);
+    });
+
+    // === Tests for noPxStyle()
+    it('should give a style\'s property without px suffix', () => {
+        const d3Elem = d3.select('body')
+            .append('div')
+            .attr('id', 'fooElem')
+            .style('width', '500px')
+            .style('height', '200px')
+            .style('font-size', '12px');
+        expect(fs.noPxStyle(d3Elem, 'width')).toBe(500);
+        expect(fs.noPxStyle(d3Elem, 'height')).toBe(200);
+        expect(fs.noPxStyle(d3Elem, 'font-size')).toBe(12);
+        d3.select('#fooElem').remove();
+    });
+
+    // === Tests for endsWith()
+    it('should return true if string ends with foo', () => {
+        expect(fs.endsWith('barfoo', 'foo')).toBe(true);
+    });
+
+    it('should return false if string doesnt end with foo', () => {
+        expect(fs.endsWith('barfood', 'foo')).toBe(false);
+    });
+
+    // === Tests for sanitize()
+    it('should return foo', () => {
+        expect(fs.sanitize('foo')).toEqual('foo');
+    });
+    it('should retain < b > tags', () => {
+        const str = 'foo <b>bar</b> baz';
+        expect(fs.sanitize(str)).toEqual(str);
+    });
+    it('should retain < i > tags', () => {
+        const str = 'foo <i>bar</i> baz';
+        expect(fs.sanitize(str)).toEqual(str);
+    });
+    it('should retain < p > tags', () => {
+        const str = 'foo <p>bar</p> baz';
+        expect(fs.sanitize(str)).toEqual(str);
+    });
+    it('should retain < em > tags', () => {
+        const str = 'foo <em>bar</em> baz';
+        expect(fs.sanitize(str)).toEqual(str);
+    });
+    it('should retain < strong > tags', () => {
+        const str = 'foo <strong>bar</strong> baz';
+        expect(fs.sanitize(str)).toEqual(str);
+    });
+
+    it('should reject < a > tags', () => {
+        expect(fs.sanitize('test <a href="hah">something</a> this'))
+            .toEqual('test something this');
+    });
+
+    it('should log a warning for < script > tags', () => {
+        expect(fs.sanitize('<script>alert("foo");</script>'))
+            .toEqual('');
+        expect(logServiceSpy.warn).toHaveBeenCalledWith(
+            'Unsanitary HTML input -- <script> detected!'
+        );
+    });
+    it('should log a warning for < style > tags', () => {
+        expect(fs.sanitize('<style> h1 {color:red;} </style>'))
+            .toEqual('');
+        expect(logServiceSpy.warn).toHaveBeenCalledWith(
+            'Unsanitary HTML input -- <style> detected!'
+        );
+    });
+
+    it('should log a warning for < iframe > tags', () => {
+        expect(fs.sanitize('Foo<iframe><body><h1>fake</h1></body></iframe>Bar'))
+            .toEqual('FooBar');
+        expect(logServiceSpy.warn).toHaveBeenCalledWith(
+            'Unsanitary HTML input -- <iframe> detected!'
+        );
+    });
+
+    it('should completely strip < script >, remove < a >, retain < i >', () => {
+        expect(fs.sanitize('Hey <i>this</i> is <script>alert("foo");</script> <a href="meh">cool</a>'))
+            .toEqual('Hey <i>this</i> is  cool');
+    });
+});
diff --git a/web/gui2-fw-lib/lib/util/fn.service.ts b/web/gui2-fw-lib/lib/util/fn.service.ts
new file mode 100644
index 0000000..6694182
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/fn.service.ts
@@ -0,0 +1,501 @@
+/*
+ * 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 {Inject, Injectable} from '@angular/core';
+import {ActivatedRoute} from '@angular/router';
+import {LogService} from '../log.service';
+import {Trie, TrieOp} from './trie';
+
+// Angular>=2 workaround for missing definition
+declare const InstallTrigger: any;
+
+const matcher = /<\/?([a-zA-Z0-9]+)*(.*?)\/?>/igm;
+const whitelist: string[] = ['b', 'i', 'p', 'em', 'strong', 'br'];
+const evillist: string[] = ['script', 'style', 'iframe'];
+
+/**
+ * Used with the Window size function;
+ **/
+export interface WindowSize {
+    width: number;
+    height: number;
+}
+
+/**
+ * For the sanitize() and analyze() functions
+ */
+export interface Match {
+    full: string;
+    name: string;
+}
+
+/**
+ * ONOS GUI -- Util -- General Purpose Functions
+ */
+@Injectable({
+  providedIn: 'root',
+})
+export class FnService {
+    // internal state
+    private debugFlags = new Map<string, boolean>([
+//        [ "LoadingService", true ]
+    ]);
+
+    constructor(
+        private route: ActivatedRoute,
+        private log: LogService,
+        // TODO: Change the any type to Window when https://github.com/angular/angular/issues/15640 is fixed.
+        @Inject('Window') private w: any
+    ) {
+        this.route.queryParams.subscribe(params => {
+            const debugparam: string = params['debug'];
+//            log.debug('Param:', debugparam);
+            this.parseDebugFlags(debugparam);
+        });
+//        this.log.debug('FnService constructed');
+    }
+
+    /**
+     * Test if an argument is a function
+     *
+     * Note: the need for this would go away if all functions
+     * were strongly typed
+     */
+    isF(f: any): any {
+        return typeof f === 'function' ? f : null;
+    }
+
+    /**
+     * Test if an argument is an array
+     *
+     * Note: the need for this would go away if all arrays
+     * were strongly typed
+     */
+    isA(a: any): any {
+    // NOTE: Array.isArray() is part of EMCAScript 5.1
+        return Array.isArray(a) ? a : null;
+    }
+
+    /**
+     * Test if an argument is a string
+     *
+     * Note: the need for this would go away if all strings
+     * were strongly typed
+     */
+    isS(s: any): string {
+        return typeof s === 'string' ? s : null;
+    }
+
+    /**
+     * Test if an argument is an object
+     *
+     * Note: the need for this would go away if all objects
+     * were strongly typed
+     */
+    isO(o: any): Object {
+        return (o && typeof o === 'object' && o.constructor === Object) ? o : null;
+    }
+
+    /**
+     * Test that an array contains an object
+     */
+    contains(a: any[], x: any): boolean {
+        return this.isA(a) && a.indexOf(x) > -1;
+    }
+
+    /**
+     * Returns width and height of window inner dimensions.
+     * offH, offW : offset width/height are subtracted, if present
+     */
+    windowSize(offH: number = 0, offW: number = 0): WindowSize {
+        return {
+            height: this.w.innerHeight - offH,
+            width: this.w.innerWidth - offW
+        };
+    }
+
+    /**
+     * Returns true if all names in the array are defined as functions
+     * on the given api object; false otherwise.
+     * Also returns false if there are properties on the api that are NOT
+     * listed in the array of names.
+     *
+     * This gets extra complicated when the api Object is an
+     * Angular service - while the functions can be retrieved
+     * by an indexed get, the ownProperties does not show the
+     * functions of the class. We have to dive in to the prototypes
+     * properties to get these - and even then we have to filter
+     * out the constructor and any member variables
+     */
+    // areFunctions(api: Object, fnNames: string[]): boolean {
+    //     const fnLookup: Map<string, boolean> = new Map();
+    //     let extraFound: boolean = false;
+    //
+    //     if (!this.isA(fnNames)) {
+    //         return false;
+    //     }
+    //
+    //     const n: number = fnNames.length;
+    //     let i: number;
+    //     let name: string;
+    //
+    //     for (i = 0; i < n; i++) {
+    //         name = fnNames[i];
+    //         if (!this.isF(api[name])) {
+    //             return false;
+    //         }
+    //         fnLookup.set(name, true);
+    //     }
+    //
+    //     // check for properties on the API that are not listed in the array,
+    //     const keys = Object.getOwnPropertyNames(api);
+    //     if (keys.length === 0) {
+    //         return true;
+    //     }
+    //     // If the api is a class it will have a name,
+    //     //  else it will just be called 'Object'
+    //     const apiObjectName: string = api.constructor.name;
+    //     if (apiObjectName === 'Object') {
+    //         Object.keys(api).forEach((key) => {
+    //             if (!fnLookup.get(key)) {
+    //                 extraFound = true;
+    //             }
+    //         });
+    //     } else { // It is a class, so its functions will be in the child (prototype)
+    //         const pObj: Object = Object.getPrototypeOf(api);
+    //         for ( const key in Object.getOwnPropertyDescriptors(pObj) ) {
+    //             if (key === 'constructor') { // Filter out constructor
+    //                 continue;
+    //             }
+    //             const value = Object.getOwnPropertyDescriptor(pObj, key);
+    //             // Only compare functions. Look for any not given in the map
+    //             if (this.isF(value.value) && !fnLookup.get(key)) {
+    //                 extraFound = true;
+    //             }
+    //         }
+    //     }
+    //     return !extraFound;
+    // }
+
+    /**
+     * Returns true if all names in the array are defined as functions
+     * on the given api object; false otherwise. This is a non-strict version
+     * that does not care about other properties on the api.
+     */
+    areFunctionsNonStrict(api, fnNames): boolean {
+        if (!this.isA(fnNames)) {
+            return false;
+        }
+        const n = fnNames.length;
+        let i;
+        let name;
+
+        for (i = 0; i < n; i++) {
+            name = fnNames[i];
+            if (!this.isF(api[name])) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Returns true if current browser determined to be a mobile device
+     */
+    isMobile() {
+        const ua = this.w.navigator.userAgent;
+        const patt = /iPhone|iPod|iPad|Silk|Android|BlackBerry|Opera Mini|IEMobile/;
+        return patt.test(ua);
+    }
+
+    /**
+     * Returns true if the current browser determined to be Chrome
+     */
+    isChrome() {
+        const isChromium = (this.w as any).chrome;
+        const vendorName = this.w.navigator.vendor;
+
+        const isOpera = this.w.navigator.userAgent.indexOf('OPR') > -1;
+        return (isChromium !== null &&
+        isChromium !== undefined &&
+        vendorName === 'Google Inc.' &&
+        isOpera === false);
+    }
+
+    isChromeHeadless() {
+        const vendorName = this.w.navigator.vendor;
+        const headlessChrome = this.w.navigator.userAgent.indexOf('HeadlessChrome') > -1;
+
+        return (vendorName === 'Google Inc.' && headlessChrome === true);
+    }
+
+    /**
+     * Returns true if the current browser determined to be Safari
+     */
+    isSafari() {
+        return (this.w.navigator.userAgent.indexOf('Safari') !== -1 &&
+        this.w.navigator.userAgent.indexOf('Chrome') === -1);
+    }
+
+    /**
+     * Returns true if the current browser determined to be Firefox
+     */
+    isFirefox() {
+        return typeof InstallTrigger !== 'undefined';
+    }
+
+    /**
+     * search through an array of objects, looking for the one with the
+     * tagged property matching the given key. tag defaults to 'id'.
+     * returns the index of the matching object, or -1 for no match.
+     */
+    find(key: string, array: Object[], tag: string = 'id'): number {
+        let idx: number;
+        const n: number = array.length;
+
+        for (idx = 0 ; idx < n; idx++) {
+            const d: Object = array[idx];
+            if (d[tag] === key) {
+                return idx;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * search through array to find (the first occurrence of) item,
+     * returning its index if found; otherwise returning -1.
+     */
+    inArray(item: any, array: any[]): number {
+        if (this.isA(array)) {
+            for (let i = 0; i < array.length; i++) {
+                if (array[i] === item) {
+                    return i;
+                }
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * remove (the first occurrence of) the specified item from the given
+     * array, if any. Return true if the removal was made; false otherwise.
+     */
+    removeFromArray(item: any, array: any[]): boolean {
+        const i: number = this.inArray(item, array);
+        if (i >= 0) {
+            array.splice(i, 1);
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * return true if the object is empty, return false otherwise
+     */
+    isEmptyObject(obj: Object): boolean {
+        for (const key in obj) {
+            if (true) { return false; }
+        }
+        return true;
+    }
+
+    /**
+     * returns true if the two objects have all the same properties
+     */
+    sameObjProps(obj1: Object, obj2: Object): boolean {
+        for (const key in obj1) {
+            if (obj1.hasOwnProperty(key)) {
+                if (!(obj1[key] === obj2[key])) {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * returns true if the array contains the object
+     * does NOT use strict object reference equality,
+     * instead checks each property individually for equality
+     */
+    containsObj(arr: any[], obj: Object): boolean {
+        const len = arr.length;
+        for (let i = 0; i < len; i++) {
+            if (this.sameObjProps(arr[i], obj)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Return the given string with the first character capitalized.
+     */
+    cap(s: string): string {
+        return s ? s[0].toUpperCase() + s.slice(1).toLowerCase() : s;
+    }
+
+    /**
+     * return the parameter without a px suffix
+     */
+    noPx(num: string): number {
+        return Number(num.replace(/px$/, ''));
+    }
+
+    /**
+     * return an element's given style property without px suffix
+     */
+    noPxStyle(elem: any, prop: string): number {
+        return Number(elem.style(prop).replace(/px$/, ''));
+    }
+
+    /**
+     * Return true if a str ends with suffix
+     */
+    endsWith(str: string, suffix: string) {
+        return str.indexOf(suffix, str.length - suffix.length) !== -1;
+    }
+
+    /**
+     * output debug message to console, if debug tag set...
+     * e.g. fs.debug('mytag', arg1, arg2, ...)
+     */
+    debug(tag, ...args) {
+        if (this.debugFlags.get(tag)) {
+//            this.log.debug(tag, args.join());
+        }
+    }
+
+    private parseDebugFlags(dbgstr: string): void {
+        const bits = dbgstr ? dbgstr.split(',') : [];
+        bits.forEach((key) => {
+            this.debugFlags.set(key, true);
+        });
+//        this.log.debug('Debug flags:', dbgstr);
+    }
+
+    /**
+      * Return true if the given debug flag was specified in the query params
+      */
+    debugOn(tag: string): boolean {
+        return this.debugFlags.get(tag);
+    }
+
+
+
+    // -----------------------------------------------------------------
+    // The next section deals with sanitizing external strings destined
+    // to be loaded via a .html() function call.
+    //
+    // See definition of matcher, evillist and whitelist at the top of this file
+
+    /*
+     * Returns true if the tag is in the evil list, (and is not an end-tag)
+     */
+    inEvilList(tag: any): boolean {
+        return (evillist.indexOf(tag.name) !== -1 && tag.full.indexOf('/') === -1);
+    }
+
+    /*
+     * Returns an array of Matches of matcher in html
+     */
+    analyze(html: string): Match[] {
+        const matches: Match[] = [];
+        let match;
+
+        // extract all tags
+        while ((match = matcher.exec(html)) !== null) {
+            matches.push({
+                full: match[0],
+                name: match[1],
+                // NOTE: ignoring attributes {match[2].split(' ')} for now
+            });
+        }
+
+        return matches;
+    }
+
+    /*
+     * Returns a cleaned version of html
+     */
+    sanitize(html: string): string {
+        const matches: Match[] = this.analyze(html);
+
+        // completely obliterate evil tags and their contents...
+        evillist.forEach((tag) => {
+            const re = new RegExp('<' + tag + '(.*?)>(.*?[\r\n])*?(.*?)(.*?[\r\n])*?<\/' + tag + '>', 'gim');
+            html = html.replace(re, '');
+        });
+
+        // filter out all but white-listed tags and end-tags
+        matches.forEach((tag) => {
+            if (whitelist.indexOf(tag.name) === -1) {
+                html = html.replace(tag.full, '');
+                if (this.inEvilList(tag)) {
+                    this.log.warn('Unsanitary HTML input -- ' +
+                        tag.full + ' detected!');
+                }
+            }
+        });
+
+        // TODO: consider encoding HTML entities, e.g. '&' -> '&amp;'
+
+        return html;
+    }
+
+    /**
+     * add word to trie (word will be converted to uppercase)
+     * data associated with the word
+     * returns 'added' or 'updated'
+     */
+    addToTrie(trie, word, data) {
+        return new Trie(TrieOp.PLUS, trie, word, data);
+    }
+
+    /**
+     * remove word from trie (word will be converted to uppercase)
+     * returns 'removed' or 'absent'
+     */
+    removeFromTrie(trie, word) {
+        return new Trie(TrieOp.MINUS, trie, word);
+    }
+
+    /**
+     * lookup word (converted to uppercase) in trie
+     * returns:
+     *    undefined if the word is not in the trie
+     *    -1 for a partial match (word is a prefix to an existing word)
+     *    data for the word for an exact match
+     */
+    trieLookup(trie, word) {
+        const s = word.toUpperCase().split('');
+        let p = trie;
+        let n;
+
+        while (s.length) {
+            n = s.shift();
+            p = p[n];
+            if (!p) {
+                return undefined;
+            }
+        }
+        if (p._data) {
+            return p._data;
+        }
+        return -1;
+    }
+
+}
diff --git a/web/gui2-fw-lib/lib/util/keys.service.spec.ts b/web/gui2-fw-lib/lib/util/keys.service.spec.ts
new file mode 100644
index 0000000..6f2f69a
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/keys.service.spec.ts
@@ -0,0 +1,315 @@
+/*
+ * 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 { TestBed, inject } from '@angular/core/testing';
+import {ActivatedRoute, Params} from '@angular/router';
+
+import { KeysService, KeysToken } from './keys.service';
+import { FnService } from './fn.service';
+import { LogService } from '../log.service';
+import { NavService } from '../nav/nav.service';
+
+import {of} from 'rxjs';
+import * as d3 from 'd3';
+
+class MockActivatedRoute extends ActivatedRoute {
+    constructor(params: Params) {
+        super();
+        this.queryParams = of(params);
+    }
+}
+
+class MockNavService {}
+
+/*
+ ONOS GUI -- Key Handler Service - Unit Tests
+ */
+describe('KeysService', () => {
+    let ar: ActivatedRoute;
+    let fs: FnService;
+    let ks: KeysService;
+    let mockWindow: Window;
+    let logServiceSpy: jasmine.SpyObj<LogService>;
+
+    const qhs: any = {};
+    let d3Elem: any;
+    let elem: any;
+    let last: any;
+
+    beforeEach(() => {
+        const logSpy = jasmine.createSpyObj('LogService', ['debug', 'warn', 'info']);
+        ar = new MockActivatedRoute({'debug': 'TestService'});
+        mockWindow = <any>{
+            innerWidth: 400,
+            innerHeight: 200,
+            navigator: {
+                userAgent: 'defaultUA'
+            },
+            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, mockWindow);
+
+        d3Elem = d3.select('body').append('p').attr('id', 'ptest');
+        elem = d3Elem.node();
+        last = {
+            view: null,
+            key: null,
+            code: null,
+            ev: null
+        };
+
+        TestBed.configureTestingModule({
+            providers: [KeysService,
+                { provide: FnService, useValue: fs},
+                { provide: LogService, useValue: logSpy },
+                { provide: ActivatedRoute, useValue: ar },
+                { provide: NavService, useClass: MockNavService},
+                { provide: 'Window', useFactory: (() => mockWindow ) }
+            ]
+        });
+        ks = TestBed.get(KeysService);
+        ks.installOn(d3Elem);
+        logServiceSpy = TestBed.get(LogService);
+    });
+
+    afterEach(() => {
+        d3.select('#ptest').remove();
+    });
+
+    it('should be created', () => {
+        expect(ks).toBeTruthy();
+    });
+
+    it('should define api functions', () => {
+        expect(fs.areFunctions(ks, [
+            'installOn', 'keyBindings', 'unbindKeys', 'dialogKeys',
+            'addSeq', 'remSeq', 'gestureNotes', 'enableKeys', 'enableGlobalKeys',
+            'checkNotGlobal', 'getKeyBindings',
+            'matchSeq', 'whatKey', 'textFieldInput', 'keyIn', 'qhlion', 'qhlionShowHide',
+            'qhlionHintEsc', 'qhlionHintT', 'setupGlobalKeys', 'quickHelp',
+            'escapeKey', 'toggleTheme', 'filterMaskedKeys', 'unexParam',
+            'setKeyBindings', 'bindDialogKeys', 'unbindDialogKeys'
+        ])).toBeTruthy();
+    });
+
+    function jsKeyDown(element, code: string, keyName: string) {
+        const ev = new KeyboardEvent('keydown',
+            { code: code, key: keyName });
+
+        // Chromium Hack
+        // if (navigator.userAgent.toLowerCase().indexOf('chrome') > -1) {
+        //     Object.defineProperty(ev, 'keyCode', {
+        //         get: () => { return this.keyCodeVal; }
+        //     });
+        //     Object.defineProperty(ev, 'which', {
+        //         get: () => { return this.keyCodeVal; }
+        //     });
+        // }
+
+        if (ev.code !== code.toString()) {
+            console.warn('keyCode mismatch ' + ev.code +
+                '(' + ev.toString() + ') -> ' + code);
+        }
+        element.dispatchEvent(ev);
+    }
+
+    // === Key binding related tests
+    it('should start with default key bindings', () => {
+        const state = ks.getKeyBindings();
+        const gk = state.globalKeys;
+        const mk = state.maskedKeys;
+        const vk = state.viewKeys;
+        const vf = state.viewFunction;
+
+        expect(gk.length).toEqual(4);
+        ['backSlash', 'slash', 'esc', 'T'].forEach((k) => {
+            expect(fs.contains(gk, k)).toBeTruthy();
+        });
+
+        expect(mk.length).toEqual(3);
+        ['backSlash', 'slash', 'T'].forEach((k) => {
+            expect(fs.contains(mk, k)).toBeTruthy();
+        });
+
+        expect(vk.length).toEqual(0);
+        expect(vf).toBeFalsy();
+    });
+
+    function bindTestKeys(withDescs?) {
+        const keys = ['A', '1', 'F5', 'equals'];
+        const kb = {};
+
+        function cb(view, key, code, ev) {
+            last.view = view;
+            last.key = key;
+            last.code = code;
+            last.ev = ev;
+        }
+
+        function bind(k) {
+            return withDescs ?
+                [(view, key, code, ev) => {cb(view, key, code, ev); }, 'desc for key ' + k] :
+                (view, key, code, ev) => {cb(view, key, code, ev); };
+        }
+
+        keys.forEach((k) => {
+            kb[k] = bind(k);
+        });
+
+        ks.keyBindings(kb);
+    }
+
+    function verifyCall(key, code) {
+        // TODO: update expectation, when view tokens are implemented
+        expect(last.view).toEqual(KeysToken.KEYEV);
+        last.view = null;
+
+        expect(last.key).toEqual(key);
+        last.key = null;
+
+        expect(last.code).toEqual(code);
+        last.code = null;
+
+        expect(last.ev).toBeTruthy();
+        last.ev = null;
+    }
+
+    function verifyNoCall() {
+        expect(last.view).toBeNull();
+        expect(last.key).toBeNull();
+        expect(last.code).toBeNull();
+        expect(last.ev).toBeNull();
+    }
+
+    function verifyTestKeys() {
+        jsKeyDown(elem, '65', 'A'); // 'A'
+        verifyCall('A', '65');
+        jsKeyDown(elem, '66', 'B'); // 'B'
+        verifyNoCall();
+
+        jsKeyDown(elem, '49', '1'); // '1'
+        verifyCall('1', '49');
+        jsKeyDown(elem, '50', '2'); // '2'
+        verifyNoCall();
+
+        jsKeyDown(elem, '116', 'F5'); // 'F5'
+        verifyCall('F5', '116');
+        jsKeyDown(elem, '117', 'F6'); // 'F6'
+        verifyNoCall();
+
+        jsKeyDown(elem, '187', '='); // 'equals'
+        verifyCall('equals', '187');
+        jsKeyDown(elem, '189', '-'); // 'dash'
+        verifyNoCall();
+
+        const vk = ks.getKeyBindings().viewKeys;
+
+        expect(vk.length).toEqual(4);
+        ['A', '1', 'F5', 'equals'].forEach((k) => {
+            expect(fs.contains(vk, k)).toBeTruthy();
+        });
+
+        expect(ks.getKeyBindings().viewFunction).toBeFalsy();
+    }
+
+    it('should allow specific key bindings', () => {
+        bindTestKeys();
+        verifyTestKeys();
+    });
+
+    it('should allow specific key bindings with descriptions', () => {
+        bindTestKeys(true);
+        verifyTestKeys();
+    });
+
+    it('should warn about masked keys', () => {
+        const k = {
+            'space': (token, key, code, ev) => cb(token, key, code, ev),
+            'T': (token, key, code, ev) => cb(token, key, code, ev)
+        };
+        let count = 0;
+
+        function cb(token, key, code, ev) {
+            count++;
+            // console.debug('count = ' + count, token, key, code);
+        }
+
+        ks.keyBindings(k);
+
+        expect(logServiceSpy.warn).toHaveBeenCalledWith('setKeyBindings()\n: Key "T" is reserved');
+
+        // the 'T' key should NOT invoke our callback
+        expect(count).toEqual(0);
+        jsKeyDown(elem, '84', 'T'); // 'T'
+        expect(count).toEqual(0);
+
+        // but the 'space' key SHOULD invoke our callback
+        jsKeyDown(elem, '32', ' '); // 'space'
+        expect(count).toEqual(1);
+    });
+
+    it('should block keys when disabled', () => {
+        let cbCount = 0;
+
+        function cb() { cbCount++; }
+
+        function pressA() { jsKeyDown(elem, '65', 'A'); }  // 65 == 'A' keycode
+
+        ks.keyBindings({ A: () => cb() });
+
+        expect(cbCount).toBe(0);
+
+        pressA();
+        expect(cbCount).toBe(1);
+
+        ks.enableKeys(false);
+        pressA();
+        expect(cbCount).toBe(1);
+
+        ks.enableKeys(true);
+        pressA();
+        expect(cbCount).toBe(2);
+    });
+
+    // === Gesture notes related tests
+    it('should start with no notes', () => {
+        expect(ks.gestureNotes()).toEqual([]);
+    });
+
+    it('should allow us to add nodes', () => {
+        const notes = [
+            ['one', 'something about one'],
+            ['two', 'description of two']
+        ];
+        ks.gestureNotes(notes);
+
+        expect(ks.gestureNotes()).toEqual(notes);
+    });
+
+    it('should ignore non-arrays', () => {
+        ks.gestureNotes({foo: 4});
+        expect(ks.gestureNotes()).toEqual([]);
+    });
+
+    // Consider adding test to ensure array contains 2-tuples of strings
+});
diff --git a/web/gui2-fw-lib/lib/util/keys.service.ts b/web/gui2-fw-lib/lib/util/keys.service.ts
new file mode 100644
index 0000000..d4adb01
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/keys.service.ts
@@ -0,0 +1,405 @@
+/*
+ * 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 * as d3 from 'd3';
+import { LogService } from '../log.service';
+import { FnService } from '../util/fn.service';
+import { LionService } from './lion.service';
+import { NavService } from '../nav/nav.service';
+
+export interface KeyHandler {
+    globalKeys: Object;
+    maskedKeys: Object;
+    dialogKeys: Object;
+    viewKeys: any;
+    viewFn: any;
+    viewGestures: string[][];
+}
+
+export enum KeysToken {
+    KEYEV = 'keyev'
+}
+
+/**
+ * ONOS GUI -- Keys Service Module.
+ */
+@Injectable({
+    providedIn: 'root',
+})
+export class KeysService {
+    enabled: boolean = true;
+    globalEnabled: boolean = true;
+    keyHandler: KeyHandler = <KeyHandler>{
+        globalKeys: {},
+        maskedKeys: {},
+        dialogKeys: {},
+        viewKeys: {},
+        viewFn: null,
+        viewGestures: [],
+    };
+
+    seq: any = {};
+    matching: boolean = false;
+    matched: string = '';
+    lookup: any;
+    textFieldDoesNotBlock: any = {
+        enter: 1,
+        esc: 1,
+    };
+    quickHelpShown: boolean = false;
+
+    constructor(
+        protected log: LogService,
+        protected fs: FnService,
+        protected ls: LionService,
+        protected ns: NavService
+    ) {
+        this.log.debug('KeyService constructed');
+    }
+
+    installOn(elem) {
+        this.log.debug('Installing keys handler');
+        elem.on('keydown', () => { this.keyIn(); });
+        this.setupGlobalKeys();
+    }
+
+    keyBindings(x) {
+        if (x === undefined) {
+            return this.getKeyBindings();
+        } else {
+            this.setKeyBindings(x);
+        }
+    }
+
+    unbindKeys() {
+        this.keyHandler.viewKeys = {};
+        this.keyHandler.viewFn = null;
+        this.keyHandler.viewGestures = [];
+    }
+
+    dialogKeys(x) {
+        if (x === undefined) {
+            this.unbindDialogKeys();
+        } else {
+            this.bindDialogKeys(x);
+        }
+    }
+
+    addSeq(word, data) {
+        this.fs.addToTrie(this.seq, word, data);
+    }
+
+    remSeq(word) {
+        this.fs.removeFromTrie(this.seq, word);
+    }
+
+    gestureNotes(g?) {
+        if (g === undefined) {
+            return this.keyHandler.viewGestures;
+        } else {
+            this.keyHandler.viewGestures = this.fs.isA(g) || [];
+        }
+    }
+
+    enableKeys(b) {
+        this.enabled = b;
+    }
+
+    enableGlobalKeys(b) {
+        this.globalEnabled = b;
+    }
+
+    checkNotGlobal(o) {
+        const oops = [];
+        if (this.fs.isO(o)) {
+            o.forEach((val, key) => {
+                if (this.keyHandler.globalKeys[key]) {
+                    oops.push(key);
+                }
+            });
+            if (oops.length) {
+                this.log.warn('Ignoring reserved global key(s):', oops.join(','));
+                oops.forEach((key) => {
+                    delete o[key];
+                });
+            }
+        }
+    }
+
+    protected matchSeq(key) {
+        if (!this.matching && key === 'shift-shift') {
+            this.matching = true;
+            return true;
+        }
+        if (this.matching) {
+            this.matched += key;
+            this.lookup = this.fs.trieLookup(this.seq, this.matched);
+            if (this.lookup === -1) {
+                return true;
+            }
+            this.matching = false;
+            this.matched = '';
+            if (!this.lookup) {
+                return;
+            }
+            // ee.cluck(lookup);
+            return true;
+        }
+    }
+
+    protected whatKey(code: number): string {
+        switch (code) {
+            case 8: return 'delete';
+            case 9: return 'tab';
+            case 13: return 'enter';
+            case 16: return 'shift';
+            case 27: return 'esc';
+            case 32: return 'space';
+            case 37: return 'leftArrow';
+            case 38: return 'upArrow';
+            case 39: return 'rightArrow';
+            case 40: return 'downArrow';
+            case 186: return 'semicolon';
+            case 187: return 'equals';
+            case 188: return 'comma';
+            case 189: return 'dash';
+            case 190: return 'dot';
+            case 191: return 'slash';
+            case 192: return 'backQuote';
+            case 219: return 'openBracket';
+            case 220: return 'backSlash';
+            case 221: return 'closeBracket';
+            case 222: return 'quote';
+            default:
+                if ((code >= 48 && code <= 57) ||
+                    (code >= 65 && code <= 90)) {
+                    return String.fromCharCode(code);
+                } else if (code >= 112 && code <= 123) {
+                    return 'F' + (code - 111);
+                }
+                return null;
+        }
+    }
+
+    protected textFieldInput() {
+        const t = d3.event.target.tagName.toLowerCase();
+        return t === 'input' || t === 'textarea';
+    }
+
+    protected keyIn() {
+        const event = d3.event;
+        // d3.events can set the keyCode, but unit tests based on KeyboardEvent
+        // cannot set keyCode since the attribute has been deprecated
+        const code = event.keyCode ? event.keyCode : event.code;
+        const codeNum: number = parseInt(code, 10);
+        let key = this.whatKey(codeNum);
+        this.log.debug('Key detected', event, key, event.code, event.keyCode);
+        const textBlockable = !this.textFieldDoesNotBlock[key];
+        const modifiers = [];
+
+        if (event.metaKey) {
+            modifiers.push('cmd');
+        }
+        if (event.altKey) {
+            modifiers.push('alt');
+        }
+        if (event.shiftKey) {
+            modifiers.push('shift');
+        }
+
+        if (!key) {
+            return;
+        }
+
+        modifiers.push(key);
+        key = modifiers.join('-');
+
+        if (textBlockable && this.textFieldInput()) {
+            return;
+        }
+
+        const kh: KeyHandler = this.keyHandler;
+        const gk = kh.globalKeys[key];
+        const gcb = this.fs.isF(gk) || (this.fs.isA(gk) && this.fs.isF(gk[0]));
+        const dk = kh.dialogKeys[key];
+        const dcb = this.fs.isF(dk);
+        const vk = kh.viewKeys[key];
+        const kl = this.fs.isF(kh.viewKeys._keyListener);
+        const vcb = this.fs.isF(vk) || (this.fs.isA(vk) && this.fs.isF(vk[0])) || this.fs.isF(kh.viewFn);
+        const token: KeysToken = KeysToken.KEYEV; // indicate this was a key-pressed event
+
+        event.stopPropagation();
+
+        if (this.enabled) {
+            if (this.matchSeq(key)) {
+                return;
+            }
+
+            // global callback?
+            if (gcb && gcb(token, key, code, event)) {
+                // if the event was 'handled', we are done
+                return;
+            }
+            // dialog callback?
+            if (dcb) {
+                dcb(token, key, code, event);
+                // assume dialog handled the event
+                return;
+            }
+            // otherwise, let the view callback have a shot
+            if (vcb) {
+                this.log.debug('Letting view callback have a shot', vcb, token, key, code, event );
+                vcb(token, key, code, event);
+            }
+            if (kl) {
+                kl(key);
+            }
+        }
+    }
+
+    // functions to obtain localized strings deferred from the setup of the
+    //  global key data structures.
+    protected qhlion() {
+        return this.ls.bundle('core.fw.QuickHelp');
+    }
+    protected qhlionShowHide() {
+        return this.qhlion()('qh_hint_show_hide_qh');
+    }
+
+    protected qhlionHintEsc() {
+        return this.qhlion()('qh_hint_esc');
+    }
+
+    protected qhlionHintT() {
+        return this.qhlion()('qh_hint_t');
+    }
+
+    protected setupGlobalKeys() {
+        (<any>Object).assign(this.keyHandler, {
+            globalKeys: {
+                backSlash: [(view, key, code, ev) => this.quickHelp(view, key, code, ev), this.qhlionShowHide],
+                slash: [(view, key, code, ev) => this.quickHelp(view, key, code, ev), this.qhlionShowHide],
+                esc: [(view, key, code, ev) => this.escapeKey(view, key, code, ev), this.qhlionHintEsc],
+                T: [(view, key, code, ev) => this.toggleTheme(view, key, code, ev), this.qhlionHintT],
+            },
+            globalFormat: ['backSlash', 'slash', 'esc', 'T'],
+
+            // Masked keys are global key handlers that always return true.
+            // That is, the view will never see the event for that key.
+            maskedKeys: {
+                slash: 1,
+                backSlash: 1,
+                T: 1,
+            },
+        });
+    }
+
+    protected quickHelp(view, key, code, ev) {
+        if (!this.globalEnabled) {
+            return false;
+        }
+        this.quickHelpShown = !this.quickHelpShown;
+        return true;
+    }
+
+    // returns true if we 'consumed' the ESC keypress, false otherwise
+    protected escapeKey(view, key, code, ev) {
+        this.quickHelpShown = false;
+        return this.ns.hideNav();
+    }
+
+    protected toggleTheme(view, key, code, ev) {
+        if (!this.globalEnabled) {
+            return false;
+        }
+        // ts.toggleTheme();
+        return true;
+    }
+
+    protected filterMaskedKeys(map: any, caller: any, remove: boolean): any[] {
+        const masked = [];
+        const msgs = [];
+
+        d3.map(map).keys().forEach((key) => {
+            if (this.keyHandler.maskedKeys[key]) {
+                masked.push(key);
+                msgs.push(caller, ': Key "' + key + '" is reserved');
+            }
+        });
+
+        if (msgs.length) {
+            this.log.warn(msgs.join('\n'));
+        }
+
+        if (remove) {
+            masked.forEach((k) => {
+                delete map[k];
+            });
+        }
+        return masked;
+    }
+
+    protected unexParam(fname, x) {
+        this.log.warn(fname, ': unexpected parameter-- ', x);
+    }
+
+    protected setKeyBindings(keyArg) {
+        const fname = 'setKeyBindings()';
+        const kFunc = this.fs.isF(keyArg);
+        const kMap = this.fs.isO(keyArg);
+
+        if (kFunc) {
+            // set general key handler callback
+            this.keyHandler.viewFn = kFunc;
+        } else if (kMap) {
+            this.filterMaskedKeys(kMap, fname, true);
+            this.keyHandler.viewKeys = kMap;
+        } else {
+            this.unexParam(fname, keyArg);
+        }
+    }
+
+    getKeyBindings() {
+        const gkeys = d3.map(this.keyHandler.globalKeys).keys();
+        const masked = d3.map(this.keyHandler.maskedKeys).keys();
+        const vkeys = d3.map(this.keyHandler.viewKeys).keys();
+        const vfn = !!this.fs.isF(this.keyHandler.viewFn);
+
+        return {
+            globalKeys: gkeys,
+            maskedKeys: masked,
+            viewKeys: vkeys,
+            viewFunction: vfn,
+        };
+    }
+
+    protected bindDialogKeys(map) {
+        const fname = 'bindDialogKeys()';
+        const kMap = this.fs.isO(map);
+
+        if (kMap) {
+            this.filterMaskedKeys(map, fname, true);
+            this.keyHandler.dialogKeys = kMap;
+        } else {
+            this.unexParam(fname, map);
+        }
+    }
+
+    protected unbindDialogKeys() {
+        this.keyHandler.dialogKeys = {};
+    }
+
+}
diff --git a/web/gui2-fw-lib/lib/util/lion.service.spec.ts b/web/gui2-fw-lib/lib/util/lion.service.spec.ts
new file mode 100644
index 0000000..b0c252c
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/lion.service.spec.ts
@@ -0,0 +1,83 @@
+/*
+ * 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 { TestBed, inject } from '@angular/core/testing';
+import { of } from 'rxjs';
+
+import { LogService } from '../log.service';
+import { ConsoleLoggerService } from '../consolelogger.service';
+import { ActivatedRoute, Params } from '@angular/router';
+import { FnService } from '../util/fn.service';
+import { GlyphService } from '../svg/glyph.service';
+import { LionService } from './lion.service';
+import { UrlFnService } from '../remote/urlfn.service';
+import { WSock } from '../remote/wsock.service';
+import { WebSocketService, WsOptions } from '../remote/websocket.service';
+
+class MockActivatedRoute extends ActivatedRoute {
+    constructor(params: Params) {
+        super();
+        this.queryParams = of(params);
+    }
+}
+
+class MockWSock {}
+
+class MockGlyphService {}
+
+class MockUrlFnService {}
+
+/**
+ * ONOS GUI -- Lion -- Localization Utilities - Unit Tests
+ */
+describe('LionService', () => {
+    let log: LogService;
+    let fs: FnService;
+    let ar: MockActivatedRoute;
+    let windowMock: Window;
+
+    beforeEach(() => {
+        log = new ConsoleLoggerService();
+        ar = new MockActivatedRoute({'debug': 'TestService'});
+        windowMock = <any>{
+            location: <any> {
+                hostname: '',
+                host: '',
+                port: '',
+                protocol: '',
+                search: { debug: 'true'},
+                href: ''
+            }
+        };
+        fs = new FnService(ar, log, windowMock);
+
+        TestBed.configureTestingModule({
+            providers: [LionService,
+                { provide: FnService, useValue: fs },
+                { provide: GlyphService, useClass: MockGlyphService },
+                { provide: LogService, useValue: log },
+                { provide: UrlFnService, useClass: MockUrlFnService },
+                { provide: WSock, useClass: MockWSock },
+                { provide: WebSocketService, useClass: WebSocketService },
+                { provide: 'Window', useFactory: (() => windowMock ) },
+            ]
+        });
+    });
+
+    it('should be created', inject([LionService], (service: LionService) => {
+        expect(service).toBeTruthy();
+    }));
+});
diff --git a/web/gui2-fw-lib/lib/util/lion.service.ts b/web/gui2-fw-lib/lib/util/lion.service.ts
new file mode 100644
index 0000000..288d54d
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/lion.service.ts
@@ -0,0 +1,90 @@
+/*
+ * 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 { LogService } from '../log.service';
+import { WebSocketService } from '../remote/websocket.service';
+
+/**
+ * A definition of Lion data
+ */
+export interface Lion {
+    locale: any;
+    lion: any;
+}
+
+/**
+ * ONOS GUI -- Lion -- Localization Utilities
+ */
+@Injectable({
+  providedIn: 'root',
+})
+export class LionService {
+
+    ubercache: any[] = [];
+    loadCbs = new Map<string, () => void>([]); // A map of functions
+
+    /**
+     * Handler for uberlion event from WSS
+     */
+    uberlion(data: Lion) {
+        this.ubercache = data.lion;
+
+        this.log.info('LION service: Locale... [' + data.locale + ']');
+        this.log.info('LION service: Bundles installed...');
+
+        for (const p in this.ubercache) {
+            if (this.ubercache[p]) {
+                this.log.info('            :=> ', p);
+            }
+        }
+        // If any component had registered a callback, call it now
+        // that LION is loaded
+        for (const cbname of this.loadCbs.keys()) {
+            this.log.debug('Updating', cbname, 'with LION');
+            this.loadCbs.get(cbname)();
+        }
+
+        this.log.debug('LION service: uber-lion bundle received:', data);
+    }
+
+    constructor(
+        private log: LogService,
+        private wss: WebSocketService
+    ) {
+        this.wss.bindHandlers(new Map<string, (data) => void>([
+            ['uberlion', (data) => this.uberlion(data) ]
+        ]));
+        this.log.debug('LionService constructed');
+    }
+
+    /**
+     * Returns a lion bundle (function) for the given bundle ID (string)
+     * returns a function that takes a string and returns a string
+     */
+    bundle(bundleId: string): (string) => string {
+        let bundleObj = this.ubercache[bundleId];
+
+        if (!bundleObj) {
+            this.log.warn('No lion bundle registered:', bundleId);
+            bundleObj = {};
+        }
+
+        return (key) =>  {
+            return bundleObj[key] || '?' + key + '?';
+        };
+    }
+}
diff --git a/web/gui2-fw-lib/lib/util/name-input/name-input.component.css b/web/gui2-fw-lib/lib/util/name-input/name-input.component.css
new file mode 100644
index 0000000..c8acf1b
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/name-input/name-input.component.css
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2019-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 -- Name Input Component (layout) -- CSS file
+ */
+#name-input-dialog {
+    top: 140px;
+    padding: 12px;
+}
+
+#name-input-dialog h3 {
+    display: inline-block;
+    font-weight: bold;
+    font-size: 18pt;
+}
+
+#name-input-dialog p {
+    font-size: 12pt;
+}
+
+#name-input-dialog p.strong {
+    font-weight: bold;
+    padding: 8px;
+    text-align: center;
+}
diff --git a/web/gui2-fw-lib/lib/util/name-input/name-input.component.html b/web/gui2-fw-lib/lib/util/name-input/name-input.component.html
new file mode 100644
index 0000000..e505780
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/name-input/name-input.component.html
@@ -0,0 +1,22 @@
+<!--
+~ Copyright 2019-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="name-input-dialog" class="floatpanel dialog" [@niDlgState]="title!==''">
+    <h3>{{ title }}</h3><br>
+    <input #newName type="text" pattern="[a-zA-Z0-9\-:_]{3}" [placeholder]="placeholder" name="nameinput" [minLength]="minLen" [maxLength]="maxLen" width="40" required>
+    <p *ngIf="warning" class="warning strong">{{ warning }}</p>
+    <div tabindex="10" class="dialog-button" (click)="choice(true, newName.value)">OK</div>
+    <div tabindex="11" class="dialog-button" (click)="choice(false, '')">Cancel</div>
+</div>
diff --git a/web/gui2-fw-lib/lib/util/name-input/name-input.component.spec.ts b/web/gui2-fw-lib/lib/util/name-input/name-input.component.spec.ts
new file mode 100644
index 0000000..63ec2cb
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/name-input/name-input.component.spec.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2019-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 {NameInputComponent} from './name-input.component';
+import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
+
+describe('NameInputComponent', () => {
+    let component: NameInputComponent;
+    let fixture: ComponentFixture<NameInputComponent>;
+
+    beforeEach(async(() => {
+        TestBed.configureTestingModule({
+            imports: [BrowserAnimationsModule],
+            declarations: [NameInputComponent]
+        })
+            .compileComponents();
+    }));
+
+    beforeEach(() => {
+        fixture = TestBed.createComponent(NameInputComponent);
+        component = fixture.componentInstance;
+        fixture.detectChanges();
+    });
+
+    it('should create', () => {
+        expect(component).toBeTruthy();
+    });
+});
diff --git a/web/gui2-fw-lib/lib/util/name-input/name-input.component.ts b/web/gui2-fw-lib/lib/util/name-input/name-input.component.ts
new file mode 100644
index 0000000..9d7116e
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/name-input/name-input.component.ts
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2019-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, EventEmitter, Input, OnInit, Output} from '@angular/core';
+import {animate, state, style, transition, trigger} from '@angular/animations';
+
+export interface NameInputResult {
+    chosen: boolean;
+    name: string;
+}
+
+@Component({
+    selector: 'onos-name-input',
+    templateUrl: './name-input.component.html',
+    styleUrls: [
+        './name-input.component.css',
+        './name-input.theme.css',
+        '../../layer/dialog.css',
+        '../../layer/dialog.theme.css',
+        '../../widget/panel.css',
+        '../../widget/panel-theme.css'
+    ],
+    animations: [
+        trigger('niDlgState', [
+            state('true', style({
+                transform: 'translateX(-100%)',
+                opacity: '100'
+            })),
+            state('false', style({
+                transform: 'translateX(0%)',
+                opacity: '0'
+            })),
+            transition('0 => 1', animate('100ms ease-in')),
+            transition('1 => 0', animate('100ms ease-out'))
+        ])
+    ]
+})
+export class NameInputComponent implements OnInit {
+    @Input() warning: string;
+    @Input() title: string = '';
+    @Input() pattern;
+    @Input() minLen = 4;
+    @Input() maxLen = 40;
+    @Input() placeholder = 'name';
+    @Output() chosen: EventEmitter<NameInputResult> = new EventEmitter();
+
+    constructor() {
+    }
+
+    ngOnInit() {
+    }
+
+    /**
+     * When OK or Cancel is pressed, send an event to parent with choice
+     */
+    choice(chosen: boolean, newName: string): void {
+        if (chosen && (newName === undefined || newName === '')) {
+            return;
+        }
+        this.chosen.emit(<NameInputResult>{
+            chosen: chosen,
+            name: newName
+        });
+    }
+}
diff --git a/web/gui2-fw-lib/lib/util/name-input/name-input.theme.css b/web/gui2-fw-lib/lib/util/name-input/name-input.theme.css
new file mode 100644
index 0000000..bb88fb2
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/name-input/name-input.theme.css
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2019-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 -- name-input-dialog Component (theme) -- CSS file
+ */
+/* temporarily removed .light */
+#name-input-dialog p.strong {
+    color: white;
+    background-color: #ce5b58;
+}
+
+#name-input-dialog.floatpanel.dialog {
+    background-color: #ffffff;
+}
+
+#name-input-dialog p.strong {
+    color: white;
+    background-color: #ce5b58;
+}
diff --git a/web/gui2-fw-lib/lib/util/prefs.service.spec.ts b/web/gui2-fw-lib/lib/util/prefs.service.spec.ts
new file mode 100644
index 0000000..13892af
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/prefs.service.spec.ts
@@ -0,0 +1,68 @@
+/*
+ * 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 { TestBed, inject } from '@angular/core/testing';
+
+import { LogService } from '../log.service';
+import { ConsoleLoggerService } from '../consolelogger.service';
+import { PrefsService } from '../util/prefs.service';
+import { FnService } from '../util/fn.service';
+import { WebSocketService } from '../remote/websocket.service';
+
+class MockFnService {}
+
+class MockWebSocketService {
+    createWebSocket() {}
+    isConnected() { return false; }
+    unbindHandlers() {}
+    bindHandlers() {}
+}
+
+/**
+ * ONOS GUI -- Util -- User Preference Service - Unit Tests
+ */
+describe('PrefsService', () => {
+    let log: LogService;
+    let windowMock: Window;
+
+    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'
+        }
+    };
+
+    beforeEach(() => {
+        log = new ConsoleLoggerService();
+
+        TestBed.configureTestingModule({
+            providers: [PrefsService,
+                { provide: LogService, useValue: log },
+                { provide: FnService, useClass: MockFnService },
+                { provide: 'Window', useFactory: (() => windowMock ) },
+                { provide: WebSocketService, useClass: MockWebSocketService },
+            ]
+        });
+    });
+
+    it('should be created', inject([PrefsService], (service: PrefsService) => {
+        expect(service).toBeTruthy();
+    }));
+});
diff --git a/web/gui2-fw-lib/lib/util/prefs.service.ts b/web/gui2-fw-lib/lib/util/prefs.service.ts
new file mode 100644
index 0000000..ef55180
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/prefs.service.ts
@@ -0,0 +1,130 @@
+/*
+ * 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 {Inject, Injectable} from '@angular/core';
+import { FnService } from './fn.service';
+import { LogService } from '../log.service';
+import { WebSocketService } from '../remote/websocket.service';
+
+const UPDATE_PREFS: string = 'updatePrefs';
+const UPDATE_PREFS_REQ: string = 'updatePrefReq';
+
+
+/**
+ * ONOS GUI -- Util -- User Preference Service
+ */
+@Injectable({
+    providedIn: 'root',
+})
+export class PrefsService {
+    protected handlers: string[] = [];
+    cache: Object;
+    listeners: ((data) => void)[] = [];
+    constructor(
+        protected fs: FnService,
+        protected log: LogService,
+        protected wss: WebSocketService,
+        @Inject('Window') private window: any
+    ) {
+        this.wss.bindHandlers(new Map<string, (data) => void>([
+            [UPDATE_PREFS, (data) => this.updatePrefs(data)]
+        ]));
+        this.handlers.push(UPDATE_PREFS);
+
+        // When index.html is fetched it is served up by MainIndexResource.java
+        // which fetches userPrefs in to the global scope.
+        // After that updates are done through WebSocket
+        this.cache = (<any>Object).assign({}, this.window['userPrefs']);
+
+        this.log.debug('PrefsService constructed');
+    }
+
+    setPrefs(name: string, obj: Object) {
+        // keep a cached copy of the object and send an update to server
+        this.cache[name] = obj;
+        this.wss.sendEvent(UPDATE_PREFS_REQ, { key: name, value: obj });
+    }
+    updatePrefs(data: any) {
+        this.cache = data;
+        this.listeners.forEach((lsnr) => lsnr(data) );
+    }
+
+    asNumbers(obj: any, keys?: any, not?: any) {
+        if (!obj) {
+            return null;
+        }
+
+        const skip = {};
+        if (not) {
+            keys.forEach(k => {
+                skip[k] = 1;
+            }
+            );
+        }
+
+        if (!keys || not) {
+            // do them all
+            Array.from(obj).forEach((v, k) => {
+                if (!not || !skip[k]) {
+                    obj[k] = Number(obj[k]);
+                }
+            });
+        } else {
+            // do the explicitly named keys
+            keys.forEach(k => {
+                obj[k] = Number(obj[k]);
+            });
+        }
+        return obj;
+    }
+
+    getPrefs(name: string, defaults: Object, qparams?: string) {
+        const obj = (<any>Object).assign({}, defaults || {}, this.cache[name] || {});
+
+        // if query params are specified, they override...
+        if (this.fs.isO(qparams)) {
+            obj.forEach(k => {
+                if (qparams.hasOwnProperty(k)) {
+                    obj[k] = qparams[k];
+                }
+            });
+        }
+        return obj;
+    }
+
+    // merge preferences:
+    // The assumption here is that obj is a sparse object, and that the
+    //  defined keys should overwrite the corresponding values, but any
+    //  existing keys that are NOT explicitly defined here should be left
+    //  alone (not deleted).
+    mergePrefs(name: string, obj: any): void {
+        const merged = this.cache[name] || {};
+        this.setPrefs(name, (<any>Object).assign(merged, obj));
+    }
+
+    /**
+     * Add a listener function
+     * This will get called back when an 'updatePrefs' message is received on WSS
+     * @param listener a function that can accept one param - data
+     */
+    addListener(listener: (data) => void): void {
+        this.listeners.push(listener);
+    }
+
+    removeListener(listener: (data) => void) {
+        this.listeners = this.listeners.filter((obj) => obj !== listener);
+    }
+
+}
diff --git a/web/gui2-fw-lib/lib/util/theme.service.spec.ts b/web/gui2-fw-lib/lib/util/theme.service.spec.ts
new file mode 100644
index 0000000..16d38e3
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/theme.service.spec.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 { TestBed, inject } from '@angular/core/testing';
+
+import { LogService } from '../log.service';
+import { ConsoleLoggerService } from '../consolelogger.service';
+import { ThemeService } from './theme.service';
+
+/**
+ * ONOS GUI -- Util -- Theme Service - Unit Tests
+ */
+describe('ThemeService', () => {
+    let log: LogService;
+
+    beforeEach(() => {
+        log = new ConsoleLoggerService();
+
+        TestBed.configureTestingModule({
+            providers: [ThemeService,
+                { provide: LogService, useValue: log },
+            ]
+        });
+    });
+
+    it('should be created', inject([ThemeService], (service: ThemeService) => {
+        expect(service).toBeTruthy();
+    }));
+});
diff --git a/web/gui2-fw-lib/lib/util/theme.service.ts b/web/gui2-fw-lib/lib/util/theme.service.ts
new file mode 100644
index 0000000..993d9f3
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/theme.service.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 { LogService } from '../log.service';
+
+
+/**
+ * ONOS GUI -- Util -- Theme Service
+ */
+@Injectable({
+  providedIn: 'root',
+})
+export class ThemeService {
+    themes: string[] = ['light', 'dark'];
+    thidx = 0;
+
+    constructor(
+        private log: LogService
+    ) {
+        this.log.debug('ThemeService constructed');
+    }
+
+    getTheme(): string {
+        return this.themes[this.thidx];
+    }
+
+    themeStr(): string {
+        return this.themes.join(' ');
+    }
+
+}
diff --git a/web/gui2-fw-lib/lib/util/trie.ts b/web/gui2-fw-lib/lib/util/trie.ts
new file mode 100644
index 0000000..5e08061
--- /dev/null
+++ b/web/gui2-fw-lib/lib/util/trie.ts
@@ -0,0 +1,125 @@
+/*
+ * 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.
+ */
+
+export interface TrieC {
+    p: any;
+    s: string[];
+}
+
+export interface TrieT {
+    k: any;
+    p: any;
+    q: any;
+}
+
+export enum TrieRemoved {
+    REMOVED = 'removed',
+    ABSENT = 'absent'
+}
+
+export enum TrieInsert {
+    ADDED = 'added',
+    UPDATED = 'updated'
+}
+
+/**
+ * Combine TrieRemoved and TrieInsert in to a union type
+ */
+export type TrieActions = TrieRemoved | TrieInsert;
+
+export enum TrieOp {
+    PLUS = '+',
+    MINUS = '-'
+}
+
+
+export class Trie {
+    p: any;
+    w: string;
+    s: string[];
+    c: TrieC;
+    t: TrieT[];
+    x: number;
+    f1: (TrieC) => TrieC;
+    f2: () => TrieActions;
+    data: any;
+
+
+    constructor(
+        op: TrieOp,
+        trie: any,
+        word: string,
+        data?: any
+    ) {
+        this.p = trie;
+        this.w = word.toUpperCase();
+        this.s = this.w.split('');
+        this.c = { p: this.p, s: this.s },
+        this.t = [];
+        this.x = 0;
+        this.f1 = op === TrieOp.PLUS ? this.add : this.probe;
+        this.f2 = op === TrieOp.PLUS ? this.insert : this.remove;
+        this.data = data;
+        while (this.c.s.length) {
+            this.c = this.f1(this.c);
+        }
+    }
+
+    add(cAdded: TrieC): TrieC {
+        const q = cAdded.s.shift();
+        let np = cAdded.p[q];
+
+        if (!np) {
+            cAdded.p[q] = {};
+            np = cAdded.p[q];
+            this.x = 1;
+        }
+        return { p: np, s: cAdded.s };
+    }
+
+    probe(cProbed: TrieC): TrieC {
+        const q = cProbed.s.shift();
+        const k: number = Object.keys(cProbed.p).length;
+        const np = cProbed.p[q];
+
+        this.t.push({ q: q, k: k, p: cProbed.p });
+        if (!np) {
+            this.t = [];
+            return { p: [], s: [] };
+        }
+        return { p: np, s: cProbed.s };
+    }
+
+    insert(): TrieInsert {
+        this.c.p._data = this.data;
+        return this.x ? TrieInsert.ADDED : TrieInsert.UPDATED;
+    }
+
+    remove(): TrieRemoved {
+        if (this.t.length) {
+            this.t = this.t.reverse();
+            while (this.t.length) {
+                const d = this.t.shift();
+                delete d.p[d.q];
+                if (d.k > 1) {
+                    this.t = [];
+                }
+            }
+            return TrieRemoved.REMOVED;
+        }
+        return TrieRemoved.ABSENT;
+    }
+}