changeset 131:59e8facc27cb

Add experimental "multicharts" feature Reviewed-by: almac Review-thread: http://icedtea.classpath.org/pipermail/thermostat/2017-July/024046.html
author Andrew Azores <aazores@redhat.com>
date Tue, 18 Jul 2017 10:14:56 -0400
parents 342dd7281bf1
children 349cb0c9bf64
files src/app/app.module.js src/app/components/jvm-info/jvm-memory/jvm-memory.controller.js src/app/components/jvm-info/jvm-memory/jvm-memory.controller.spec.js src/app/components/jvm-info/jvm-memory/jvm-memory.html src/app/components/jvm-info/jvm-memory/jvm-memory.module.js src/app/components/jvm-list/jvm-list.controller.js src/app/components/landing/landing.html src/app/components/multichart/chart.controller.js src/app/components/multichart/chart.controller.spec.js src/app/components/multichart/multichart.controller.js src/app/components/multichart/multichart.controller.spec.js src/app/components/multichart/multichart.html src/app/components/multichart/multichart.module.js src/app/components/multichart/multichart.routing.js src/app/components/multichart/multichart.routing.spec.js src/app/components/system-info/system-cpu.controller.js src/app/components/system-info/system-cpu.controller.spec.js src/app/components/system-info/system-info.html src/app/components/system-info/system-info.module.js src/app/components/system-info/system-memory.controller.js src/app/components/system-info/system-memory.controller.spec.js src/app/shared/directives/directives.module.js src/app/shared/directives/dismissible-error-message/dismissible-error-message.directive.js src/app/shared/directives/dismissible-error-message/dismissible-error-message.directive.spec.js src/app/shared/directives/dismissible-error-message/dismissible-error-message.html src/app/shared/directives/dismissible-error-message/dismissible-error-message.module.js src/app/shared/directives/multichart-add/multichart-add.controller.js src/app/shared/directives/multichart-add/multichart-add.controller.spec.js src/app/shared/directives/multichart-add/multichart-add.directive.js src/app/shared/directives/multichart-add/multichart-add.directive.spec.js src/app/shared/directives/multichart-add/multichart-add.html src/app/shared/directives/multichart-add/multichart-add.module.js src/app/shared/services/multichart.service.js src/app/shared/services/multichart.service.spec.js src/app/shared/services/sanitize.service.js src/app/shared/services/sanitize.service.spec.js webpack.config.js
diffstat 37 files changed, 2201 insertions(+), 23 deletions(-) [+]
line wrap: on
line diff
--- a/src/app/app.module.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/app.module.js	Tue Jul 18 10:14:56 2017 -0400
@@ -28,12 +28,14 @@
 import 'angular-patternfly';
 import '@uirouter/angularjs';
 import 'oclazyload';
+import 'bootstrap';
 import 'bootstrap-switch';
 
 import configModule from 'shared/config/config.module.js';
 import {default as authModule, config as authModBootstrap} from './components/auth/auth.module.js';
 import filters from 'shared/filters/filters.module.js';
 import services from 'shared/services/services.module.js';
+import directives from 'shared/directives/directives.module.js';
 import appRouting from './app.routing.js';
 import authInterceptorFactory from './auth-interceptor.factory.js';
 import AppController from './app.controller.js';
@@ -54,6 +56,7 @@
     // non-core modules
     services,
     filters,
+    directives,
     appRouting,
     authInterceptorFactory,
     AppController
--- a/src/app/components/jvm-info/jvm-memory/jvm-memory.controller.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/jvm-info/jvm-memory/jvm-memory.controller.js	Tue Jul 18 10:14:56 2017 -0400
@@ -32,7 +32,7 @@
 
 class JvmMemoryController {
   constructor (jvmId, $scope, $interval, jvmMemoryService, metricToBigIntFilter,
-    bigIntToStringFilter, stringToNumberFilter, scaleBytesService) {
+    bigIntToStringFilter, stringToNumberFilter, scaleBytesService, sanitizeService) {
     'ngInject';
 
     this.jvmId = jvmId;
@@ -44,6 +44,7 @@
     this.bigIntToString = bigIntToStringFilter;
     this.stringToNumber = stringToNumberFilter;
     this.scaleBytes = scaleBytesService;
+    this.scope.sanitize = sanitizeService.sanitize;
 
     this.scope.refreshRate = '2000';
 
@@ -82,6 +83,14 @@
     }
   }
 
+  multichartFn () {
+    return new Promise(resolve =>
+      this.jvmMemoryService.getJvmMemory(this.scope.systemId).then(resp =>
+        resolve(this.convertMemStat(resp.data.response[0].metaspaceUsed))
+      )
+    );
+  }
+
   update () {
     this.jvmMemoryService.getJvmMemory(this.jvmId).then(resp => {
       let data = resp.data.response[0];
--- a/src/app/components/jvm-info/jvm-memory/jvm-memory.controller.spec.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/jvm-info/jvm-memory/jvm-memory.controller.spec.js	Tue Jul 18 10:14:56 2017 -0400
@@ -243,4 +243,31 @@
     });
   });
 
+  describe('multichartFn', () => {
+    it('should return a promise', () => {
+      let res = ctrl.multichartFn();
+      res.should.be.a.Promise();
+    });
+
+    it('should resolve jvm-memory stat', done => {
+      promise.then.should.be.calledOnce();
+      let res = ctrl.multichartFn();
+      res.then(v => {
+        v.should.equal(9001);
+        done();
+      });
+      promise.then.should.be.calledTwice();
+      let prom = promise.then.secondCall.args[0];
+      prom({
+        data: {
+          response: [
+            {
+              metaspaceUsed: { $numberLong: '9001' }
+            }
+          ]
+        }
+      });
+    });
+  });
+
 });
--- a/src/app/components/jvm-info/jvm-memory/jvm-memory.html	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/jvm-info/jvm-memory/jvm-memory.html	Tue Jul 18 10:14:56 2017 -0400
@@ -39,6 +39,7 @@
           <div class="card-pf card-pf-view">
             <div class="card-pf-heading">
               <label class="card-pf-title">{{generation.name}} ({{generation.collector}})</label>
+              <mc-add class="pull-right" svc-name="{{ctrl.jvmId}}-{{sanitize(generation.name)}}-metaspace" get-fn="ctrl.multichartFn()"></mc-add>
             </div>
             <div ng-repeat="space in generation.spaces">
               <div class="card-pf-body text-center">
--- a/src/app/components/jvm-info/jvm-memory/jvm-memory.module.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/jvm-info/jvm-memory/jvm-memory.module.js	Tue Jul 18 10:14:56 2017 -0400
@@ -27,10 +27,12 @@
 
 import JvmMemoryController from './jvm-memory.controller.js';
 import service from './jvm-memory.service.js';
+import directives from 'shared/directives/directives.module.js';
 
 export default angular
   .module('jvmMemory', [
     JvmMemoryController,
-    service
+    service,
+    directives
   ])
   .name;
--- a/src/app/components/jvm-list/jvm-list.controller.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/jvm-list/jvm-list.controller.js	Tue Jul 18 10:14:56 2017 -0400
@@ -27,7 +27,7 @@
 
 import filters from 'shared/filters/filters.module.js';
 import service from './jvm-list.service.js';
-import dismissibleErrorMessage from 'shared/directives/dismissible-error-message/dismissible-error-message.directive.js';
+import directives from 'shared/directives/directives.module.js';
 
 class JvmListController {
   constructor (jvmListService, $scope, $location, $timeout, $anchorScroll) {
@@ -99,9 +99,9 @@
 
 export default angular
   .module('jvmList.controller', [
-    'directives.dismissible-error-message',
     'patternfly',
     filters,
+    directives,
     service
   ])
   .controller('JvmListController', JvmListController)
--- a/src/app/components/landing/landing.html	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/landing/landing.html	Tue Jul 18 10:14:56 2017 -0400
@@ -13,22 +13,28 @@
 
 <div class="container-fluid container-cards-pf">
   <div class="row row-cards-pf">
-    <div class="col-md-4">
-      <div class="card-pf card-pf-utilization">
+    <div class="col-xs-12 col-md-4">
+      <div class="card-pf">
+
         <div class="card-pf-heading">
-          <h2 class="card-pf-title">
-            Some Metric
-          </h2>
-        </div>
-        <div class="card-pf-body">
-          <div class="row">
-            <div class="col-xs-12 col-sm-4 col-md-4">
-              <h3 class="card-pf-subtitle">
-                <a ui-sref="jvmList">JVM Listing</a>
-              </h3>
-            </div>
+          <div class="card-pf-title">
+            Navigation
           </div>
         </div>
+
+        <div class="card-pf-body">
+          <div class="card-pf-subtitle">
+            <ul>
+              <li>
+                <a ui-sref="jvmList">JVM Listing</a>
+              </li>
+              <li>
+                <a ui-sref="multichart">Multicharts</a>
+              </li>
+            </ul>
+          </div>
+        </div>
+
       </div>
     </div>
   </div><!-- /row -->
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/chart.controller.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,159 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import 'c3';
+import services from 'shared/services/services.module.js';
+import filters from 'shared/filters/filters.module.js';
+
+class MultiChartController {
+  constructor (multichartService, $scope, $interval, dateFilter, DATE_FORMAT) {
+    this.svc = multichartService;
+    this.scope = $scope;
+    this.interval = $interval;
+    this.dateFilter = dateFilter;
+    this.dateFormat = DATE_FORMAT;
+    this.chart = $scope.$parent.chart;
+
+    this.initializeChartData();
+
+    this.scope.$on('$destroy', () => this.stop());
+
+    this.scope.refreshRate = '2000';
+    this.scope.dataAgeLimit = '60000';
+
+    this.scope.$watch('refreshRate', (cur, prev) => this.setRefreshRate(cur));
+    this.scope.$watch('dataAgeLimit', () => this.trimData());
+
+    this.refresh = $interval(() => this.update(), parseInt(this.scope.refreshRate));
+    this.update();
+  }
+
+  update () {
+    this.svc.getData(this.chart).then(data => {
+      let keys = Object.keys(data);
+      if (keys.length === 0) {
+        return;
+      }
+
+      this.chartData.xData.push(Date.now());
+      keys.forEach(prop => {
+        if (this.chartData.hasOwnProperty(prop)) {
+          this.chartData[prop].push(data[prop][1]);
+        } else {
+          this.chartData[prop] = data[prop];
+        }
+      });
+      this.chartConfig.data.axes = this.svc.getAxesForChart(this.chart);
+
+      this.trimData();
+    }, angular.noop);
+  }
+
+  stop () {
+    if (angular.isDefined(this.refresh)) {
+      this.interval.cancel(this.refresh);
+      delete this.refresh;
+    }
+  }
+
+  trimData () {
+    let now = Date.now();
+    let oldestLimit = now - parseInt(this.scope.dataAgeLimit);
+
+    while (true) {
+      if (this.chartData.xData.length <= 2) {
+        break;
+      }
+      let oldest = this.chartData.xData[1];
+      if (oldest < oldestLimit) {
+        Object.keys(this.chartData).forEach(key => {
+          this.chartData[key].splice(1, 1);
+        });
+      } else {
+        break;
+      }
+    }
+  }
+
+  initializeChartData () {
+    let self = this;
+    this.chartConfig = {
+      chartId: 'chart-' + this.chart,
+      axis: {
+        x: {
+          label: 'timestamp',
+          type: 'timeseries',
+          localtime: false,
+          tick: {
+            format: timestamp => this.dateFilter(timestamp, this.dateFormat.time.medium),
+            count: 5
+          }
+        },
+        y: {
+          tick: {
+            format: d => d
+          }
+        },
+        y2: {
+          get show () {
+            return self.svc.countServicesForChart(self.chart) > 1;
+          }
+        }
+      },
+      tooltip: {
+        format: {
+          title: x => 'Time: ' + x,
+          value: y => y
+        }
+      }
+    };
+    this.chartData = {
+      xData: ['timestamp']
+    };
+  }
+
+  removeChart () {
+    this.svc.removeChart(this.chart);
+  }
+
+  setRefreshRate (val) {
+    this.stop();
+    if (val > 0) {
+      this.refresh = this.interval(() => this.update(), val);
+    }
+  }
+}
+
+export default angular
+  .module('multichartChartController', [
+    'patternfly',
+    'patternfly.charts',
+    services,
+    filters
+  ])
+  .controller('MultiChartChartController', MultiChartController)
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/chart.controller.spec.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,338 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import controllerModule from './chart.controller.js';
+
+describe('multichart chartController', () => {
+
+  let ctrl, svc, scope, interval, dateFilter;
+  beforeEach(() => {
+    angular.mock.module(controllerModule);
+    angular.mock.inject($controller => {
+      'ngInject';
+
+      let getDataPromise = sinon.spy();
+      let getData = sinon.stub().returns({
+        then: getDataPromise
+      });
+      svc = {
+        getData: getData,
+        getDataPromise: getDataPromise,
+        getAxesForChart: sinon.stub().returns(['y']),
+        countServicesForChart: sinon.stub().returns(2),
+        removeChart: sinon.spy()
+      };
+      scope = {
+        $parent: {
+          chart: 'foo-chart'
+        },
+        $on: sinon.spy(),
+        $watch: sinon.spy()
+      };
+      interval = sinon.stub().returns('interval-sentinel');
+      interval.cancel = sinon.spy();
+      dateFilter = sinon.stub().returns('dateFilter-sentinel');
+
+      ctrl = $controller('MultiChartChartController', {
+        multichartService: svc,
+        $scope: scope,
+        $interval: interval,
+        dateFilter: dateFilter,
+        DATE_FORMAT: { time: { medium: 'medium-time' } }
+      });
+      ctrl.chartConfig.data = {};
+    });
+  });
+
+  it('should exist', () => {
+    should.exist(ctrl);
+  });
+
+  it('should stop on destroy', () => {
+    scope.$on.should.be.calledWith('$destroy');
+    scope.$on.callCount.should.equal(1);
+    let fn = scope.$on.args[0][1];
+    fn.should.be.a.Function();
+
+    interval.cancel.should.not.be.called();
+    fn();
+    interval.cancel.should.be.calledOnce();
+    interval.cancel.should.be.calledWith('interval-sentinel');
+  });
+
+  it('should do nothing if stopped when already stopped', () => {
+    interval.cancel.should.not.be.called();
+    ctrl.stop();
+    interval.cancel.should.be.calledOnce();
+    ctrl.stop();
+    interval.cancel.should.be.calledOnce();
+  });
+
+  it('should initialize refreshRate', () => {
+    scope.should.have.ownProperty('refreshRate');
+    scope.refreshRate.should.equal('2000');
+  });
+
+  it('should initialize dataAgeLimit', () => {
+    scope.should.have.ownProperty('dataAgeLimit');
+    scope.dataAgeLimit.should.equal('60000');
+  });
+
+  it('should begin updating at refreshRate period', () => {
+    svc.getData.should.be.calledOnce();
+    interval.should.be.calledOnce();
+    interval.should.be.calledWithMatch(sinon.match.func, sinon.match(2000));
+    let fn = interval.args[0][0];
+    fn();
+    svc.getData.should.be.calledTwice();
+  });
+
+  it('should watch refreshRate changes', () => {
+    interval.cancel.should.not.be.called();
+    scope.$watch.should.be.calledWithMatch(sinon.match('refreshRate'), sinon.match.func);
+    let fn = scope.$watch.withArgs('refreshRate').args[0][1];
+    fn.should.be.a.Function();
+    interval.returns('foo-sentinel');
+    fn(5);
+    interval.cancel.should.be.calledOnce();
+    ctrl.refresh.should.equal('foo-sentinel');
+    interval.should.be.calledWithMatch(sinon.match.func, sinon.match(5));
+  });
+
+  it('should watch dataAgeLimit changes', () => {
+    let spy = sinon.spy(ctrl, 'trimData');
+    scope.$watch.should.be.calledWithMatch(sinon.match('dataAgeLimit'), sinon.match.func);
+    let fn = scope.$watch.withArgs('dataAgeLimit').args[0][1];
+    fn.should.be.a.Function();
+    fn();
+    spy.should.be.calledOnce();
+    spy.restore();
+  });
+
+  describe('update', () => {
+    let fn;
+    beforeEach(() => {
+      fn = svc.getDataPromise.args[0][0];
+      fn.should.be.a.Function();
+    });
+
+    it('should pass chart ID', () => {
+      ctrl.update();
+      svc.getData.should.be.calledWith(scope.$parent.chart);
+    });
+
+    it('should do nothing if data is empty', () => {
+      ctrl.chartData.xData.length.should.equal(1);
+      fn({});
+      ctrl.chartData.xData.length.should.equal(1);
+    });
+
+    it('should append new data', () => {
+      ctrl.chartData.xData.length.should.equal(1);
+      ctrl.should.not.have.ownProperty('yData');
+      fn({
+        yData: ['someMetric', 100]
+      });
+      ctrl.chartData.xData.length.should.equal(2);
+      ctrl.chartData.should.have.ownProperty('yData');
+      ctrl.chartData.yData.length.should.equal(2);
+    });
+
+    it('should append data for existing metric', () => {
+      ctrl.chartData.xData.length.should.equal(1);
+      ctrl.should.not.have.ownProperty('yData');
+      fn({
+        yData: ['someMetric', 100]
+      });
+      ctrl.chartData.xData.length.should.equal(2);
+      ctrl.chartData.should.have.ownProperty('yData');
+      ctrl.chartData.yData.length.should.equal(2);
+      fn({
+        yData: ['someMetric', 200]
+      });
+      ctrl.chartData.xData.length.should.equal(3);
+      ctrl.chartData.yData.length.should.equal(3);
+    });
+  });
+
+  describe('trimData', () => {
+    let dateNowStub;
+    beforeEach(() => {
+      dateNowStub = sinon.stub(Date, 'now');
+    });
+
+    afterEach(() => {
+      Date.now.restore();
+    });
+
+    it('should do nothing if no samples', () => {
+      ctrl.trimData();
+      ctrl.chartData.should.eql({
+        xData: ['timestamp']
+      });
+    });
+
+    it('should not trim data if only one sample', () => {
+      scope.dataAgeLimit = 10;
+      let updateFn = svc.getDataPromise.args[0][0];
+
+      dateNowStub.returns(100);
+      updateFn({
+        yData: ['foo', 500],
+        yData1: ['bar', 5000]
+      });
+
+      dateNowStub.returns(200);
+      ctrl.trimData();
+
+      ctrl.chartData.should.eql({
+        xData: ['timestamp', 100],
+        yData: ['foo', 500],
+        yData1: ['bar', 5000]
+      });
+    });
+
+    it('should trim old data', () => {
+      scope.dataAgeLimit = 200;
+      let updateFn = svc.getDataPromise.args[0][0];
+
+      dateNowStub.returns(100);
+      updateFn({
+        yData: ['foo', 500],
+        yData1: ['bar', 5000]
+      });
+
+      dateNowStub.returns(200);
+      updateFn({
+        yData: ['foo', 600],
+        yData1: ['bar', 6000]
+      });
+
+      dateNowStub.returns(300);
+      updateFn({
+        yData: ['foo', 700],
+        yData1: ['bar', 7000]
+      });
+
+      dateNowStub.returns(350);
+
+      ctrl.chartData.should.eql({
+        xData: ['timestamp', 100, 200, 300],
+        yData: ['foo', 500, 600, 700],
+        yData1: ['bar', 5000, 6000, 7000]
+      });
+
+      ctrl.trimData();
+
+      ctrl.chartData.should.eql({
+        xData: ['timestamp', 200, 300],
+        yData: ['foo', 600, 700],
+        yData1: ['bar', 6000, 7000]
+      });
+    });
+  });
+
+  describe('initializeChartData', () => {
+    it('should set chartId', () => {
+      ctrl.chartConfig.chartId.should.equal('chart-foo-chart');
+    });
+
+    it('should format x-axis ticks using dateFilter', () => {
+      dateFilter.should.not.be.called();
+      let fmtFn = ctrl.chartConfig.axis.x.tick.format;
+      fmtFn(100).should.equal('dateFilter-sentinel');
+      dateFilter.should.be.calledOnce();
+      dateFilter.should.be.calledWith(100, 'medium-time');
+    });
+
+    it('should format y-axis ticks with identity function', () => {
+      let fmtFn = ctrl.chartConfig.axis.y.tick.format;
+      fmtFn(1).should.equal(1);
+      fmtFn('foo').should.equal('foo');
+    });
+
+    it('should show y2 axis if suggested by service', () => {
+      svc.countServicesForChart.should.not.be.called();
+      let res = ctrl.chartConfig.axis.y2.show;
+      res.should.equal(true);
+      svc.countServicesForChart.should.be.calledOnce();
+      svc.countServicesForChart.should.be.calledWith('foo-chart');
+    });
+
+    it('should format tooltips', () => {
+      let titleFmt = ctrl.chartConfig.tooltip.format.title;
+      let valueFmt = ctrl.chartConfig.tooltip.format.value;
+
+      titleFmt('foo').should.equal('Time: foo');
+      titleFmt(100).should.equal('Time: 100');
+
+      valueFmt('foo').should.equal('foo');
+      valueFmt(100).should.equal(100);
+    });
+  });
+
+  describe('removeChart', () => {
+    it('should delegate to service', () => {
+      svc.removeChart.should.not.be.called();
+      ctrl.removeChart();
+      svc.removeChart.should.be.calledOnce();
+      svc.removeChart.should.be.calledWith('foo-chart');
+    });
+  });
+
+  describe('setRefreshRate', () => {
+    it('should stop previous refreshes', () => {
+      interval.cancel.should.not.be.called();
+      ctrl.setRefreshRate(5);
+      interval.cancel.should.be.calledOnce();
+    });
+
+    it('should set the new update interval', () => {
+      interval.should.be.calledOnce();
+      ctrl.setRefreshRate(5);
+      interval.should.be.calledTwice();
+      interval.secondCall.should.be.calledWith(sinon.match.func, 5);
+    });
+
+    it('should perform updates at each interval', () => {
+      ctrl.setRefreshRate(5);
+      svc.getData.should.be.calledOnce();
+      let fn = interval.secondCall.args[0];
+      fn();
+      svc.getData.should.be.calledTwice();
+    });
+
+    it('should cancel if given non-positive value', () => {
+      interval.cancel.should.not.be.called();
+      svc.getData.should.be.calledOnce();
+      ctrl.setRefreshRate(-1);
+      interval.cancel.should.be.calledOnce();
+      svc.getData.should.be.calledOnce();
+    });
+  });
+
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/multichart.controller.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,69 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import services from 'shared/services/services.module.js';
+import directives from 'shared/directives/directives.module.js';
+
+class MultiChartController {
+  constructor (multichartService) {
+    this.svc = multichartService;
+    this.showErr = false;
+  }
+
+  createChart (chartName) {
+    if (!chartName) {
+      return false;
+    }
+    chartName = chartName.trim();
+    if (!this.isValid(chartName)) {
+      this.showErr = true;
+      return;
+    }
+    this.showErr = false;
+    this.svc.addChart(chartName);
+  }
+
+  isValid (chartName) {
+    if (!chartName) {
+      return false;
+    }
+    return chartName.search(/^[\w-]+$/) > -1;
+  }
+
+  get chartNames () {
+    return this.svc.chartNames;
+  }
+}
+
+export default angular
+  .module('multichartController', [
+    'patternfly',
+    services,
+    directives
+  ])
+  .controller('MultichartController', MultiChartController)
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/multichart.controller.spec.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,137 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import controllerModule from './multichart.controller.js';
+
+describe('MultiChartController', () => {
+
+  let svc, ctrl;
+  beforeEach(() => {
+    angular.mock.module(controllerModule);
+    angular.mock.inject($controller => {
+      'ngInject';
+      svc = {
+        addChart: sinon.spy(),
+        chartNames: ['foo', 'bar']
+      };
+      ctrl = $controller('MultichartController', {
+        multichartService: svc
+      });
+    });
+  });
+
+  it('should exist', () => {
+    should(ctrl).not.be.undefined();
+  });
+
+  it('should initialize showErr to false', () => {
+    ctrl.showErr.should.be.false();
+  });
+
+  it('should return chartNames from service', () => {
+    ctrl.chartNames.should.deepEqual(['foo', 'bar']);
+  });
+
+  describe('createChart', () => {
+    it('should do nothing if chartName is undefined', () => {
+      svc.addChart.should.not.be.called();
+      ctrl.createChart();
+      svc.addChart.should.not.be.called();
+      ctrl.showErr.should.be.false();
+    });
+
+    it('should call to service on success', () => {
+      svc.addChart.should.not.be.called();
+      ctrl.createChart('foo');
+      svc.addChart.should.be.calledOnce();
+      svc.addChart.should.be.calledWith('foo');
+    });
+
+    it('should trim spaces from chart names', () => {
+      ctrl.createChart(' foo ');
+      svc.addChart.should.be.calledWith('foo');
+    });
+
+    it('should set showErr to false on success', () => {
+      ctrl.createChart('foo');
+      ctrl.showErr.should.be.false();
+    });
+
+    it('should set showErr to true on error', () => {
+      ctrl.createChart('<script>alert();</script>');
+      ctrl.showErr.should.be.true();
+      svc.addChart.should.not.be.called();
+    });
+  });
+
+  describe('isValid', () => {
+    it('should reject undefined', () => {
+      ctrl.isValid(undefined).should.be.false();
+    });
+
+    it('should reject null', () => {
+      ctrl.isValid(null).should.be.false();
+    });
+
+    it('should reject empty string', () => {
+      ctrl.isValid('').should.be.false();
+    });
+
+    it('should reject HTML-formatted string', () => {
+      ctrl.isValid('<b>chart</b>').should.be.false();
+    });
+
+    it('should reject HTML script tag', () => {
+      ctrl.isValid('<script>alert("foo");</script>').should.be.false();
+    });
+
+    it('should reject string with spaces', () => {
+      ctrl.isValid('foo ').should.be.false();
+    });
+
+    it('should accept plain single word', () => {
+      ctrl.isValid('foo').should.be.true();
+    });
+
+    it('should accept words separated by hyphens', () => {
+      ctrl.isValid('foo-system').should.be.true();
+    });
+
+    it('should accept a name comprised of hyphens and underscores', () => {
+      ctrl.isValid('-_--_-').should.be.true();
+    });
+
+    it('should accept words separated by underscores', () => {
+      ctrl.isValid('foo_system');
+    });
+
+    it('should accept numbers', () => {
+      ctrl.isValid('1').should.be.true();
+    });
+  });
+
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/multichart.html	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,80 @@
+<div class="container-fluid container-cards-pf">
+  <ol class="breadcrumb" style="margin-bottom: 0px;">
+    <li><a ui-sref="landing">Thermostat</a></li>
+    <li><a ui-serf="multichart">Multichart</a></li>
+  </ol>
+
+  <dismissible-error-message ng-show="ctrl.showErr" err-title="'Invalid chart name.'"
+                                                    err-message="'Chart names may contain alphanumeric characters, underscores, and hyphens.'"/>
+
+  <div class="row">
+    <div class="col-xs-12 col-md-4">
+      <div class="input-group">
+        <span class="input-group-addon">Name</span>
+        <input type="text" class="form-control" placeholder="Enter new chart name" ng-model="newChartName"/>
+        <button class="input-group-button" ng-click="ctrl.createChart(newChartName)">Create Chart</button>
+      </div>
+    </div>
+  </div>
+
+  <div class="row row-cards-pf">
+    <div class="container-fluid container-cards-pf">
+
+      <div ng-repeat="chart in ctrl.chartNames">
+        <div class="col-xs-12 col-md-10">
+          <div ng-controller="MultiChartChartController as chartCtrl" class="card-pf card-pf-view">
+            <div class="card-pf-heading">
+              <label class="card-pf-title">{{chart}}</label>
+              <button class="btn btn-default pull-right" ng-click="chartCtrl.removeChart(chart)"><span class="pficon pficon-close"></span></button>
+            </div>
+
+            <!-- Metric Controls: Refresh Rate -->
+            <div class="row" style="margin-top:2vh">
+              <div class="col-xs-12 col-md-3">
+                <label for="refreshCombo" class="label label-info">Refresh Rate</label>
+                <select name="refreshCombo" class="combobox form-control" ng-model="refreshRate">
+                  <option value="-1">Disabled</option>
+                  <option value="1000">1 Second</option>
+                  <option value="2000" selected>2 Seconds (Default)</option>
+                  <option value="5000">5 Seconds</option>
+                  <option value="10000">10 Seconds</option>
+                  <option value="30000">30 Seconds</option>
+                  <option value="60000">1 Minute</option>
+                  <option value="300000">5 Minutes</option>
+                  <option value="600000">10 Minutes</option>
+                </select>
+              </div>
+              <!-- Metric Controls: Max Data Age -->
+              <div class="col-xs-12 col-md-3">
+                <label for="dataAgeCombo" class="label label-info">Max Data Age</label>
+                <select name="dataAgeCombo" class="combobox form-control" ng-model="dataAgeLimit">
+                  <option value="10000">10 Seconds</option>
+                  <option value="30000">30 Seconds</option>
+                  <option value="60000" selected>1 Minute (Default)</option>
+                  <option value="300000">5 Minutes</option>
+                  <option value="600000">10 Minutes</option>
+                  <option value="900000">15 Minutes</option>
+                  <option value="1800000">30 Minutes</option>
+                  <option value="3600000">1 Hour</option>
+                </select>
+              </div>
+            </div>
+
+            <div class="card-pf-body">
+              <div pf-line-chart id="chart-{{chart}}" config="chartCtrl.chartConfig"
+                                                      chart-data="chartCtrl.chartData"
+                                                      show-x-axis="true"
+                                                      show-y-axis="true"
+                                                      ></div>
+
+
+            </div>
+
+          </div>
+        </div>
+      </div>
+
+    </div>
+  </div>
+
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/multichart.module.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,36 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import controller from './multichart.controller.js';
+import chartController from './chart.controller.js';
+
+export default angular
+  .module('multichartModule', [
+    controller,
+    chartController
+  ])
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/multichart.routing.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,64 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+function config ($stateProvider) {
+  'ngInject';
+
+  $stateProvider.state('multichart', {
+    url: '/multichart',
+    templateProvider: $q => {
+      'ngInject';
+      return $q(resolve =>
+        require.ensure([], () => resolve(require('./multichart.html'))
+        )
+      );
+    },
+    controller: 'MultichartController as ctrl',
+    resolve: {
+      loadMultichart: ($q, $ocLazyLoad) => {
+        'ngInject';
+        return $q(resolve => {
+          require.ensure(['./multichart.module.js'], () => {
+            let module = require('./multichart.module.js');
+            $ocLazyLoad.load({ name: module.default });
+            resolve(module);
+          });
+        });
+      }
+    }
+  });
+}
+
+export { config };
+
+export default angular
+  .module('multichartRouter', [
+    'ui.router',
+    'oc.lazyLoad'
+  ])
+  .config(config)
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/multichart.routing.spec.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,92 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+describe('MultiChartRouting', () => {
+
+  let module = require('./multichart.routing.js');
+
+  let stateProvider, args, q, ocLazyLoad;
+  beforeEach(() => {
+    stateProvider = {
+      state: sinon.spy()
+    };
+    module.config(stateProvider);
+    args = stateProvider.state.args[0];
+    q = sinon.spy();
+    ocLazyLoad = {
+      load: sinon.spy()
+    };
+  });
+
+  describe('stateProvider', () => {
+    it('should call $stateProvider.state', () => {
+      stateProvider.state.should.be.calledOnce();
+    });
+
+    it('should define a \'multichart\' state', () => {
+      args[0].should.equal('multichart');
+    });
+
+    it('should map to /multichart', () => {
+      args[1].url.should.equal('/multichart');
+    });
+
+    it('template provider should return multichart.html', done => {
+      let providerFn = args[1].templateProvider[1];
+      providerFn.should.be.a.Function();
+      providerFn(q);
+      q.should.be.calledOnce();
+
+      let deferred = q.args[0][0];
+      deferred.should.be.a.Function();
+
+      let resolve = sinon.stub().callsFake(val => {
+        val.should.equal(require('./multichart.html'));
+        done();
+      });
+      deferred(resolve);
+    });
+
+    it('resolve should load multichart module', done => {
+      let resolveFn = args[1].resolve.loadMultichart[2];
+      resolveFn.should.be.a.Function();
+      resolveFn(q, ocLazyLoad);
+      q.should.be.calledOnce();
+
+      let deferred = q.args[0][0];
+      deferred.should.be.a.Function();
+
+      let resolve = sinon.stub().callsFake(val => {
+        ocLazyLoad.load.should.be.calledWith({ name: require('./multichart.module.js').default});
+        val.should.equal(require('./multichart.module.js'));
+        done();
+      });
+      deferred(resolve);
+    });
+  });
+
+});
--- a/src/app/components/system-info/system-cpu.controller.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/system-info/system-cpu.controller.js	Tue Jul 18 10:14:56 2017 -0400
@@ -65,6 +65,14 @@
       };
     });
   }
+
+  multichartFn () {
+    return new Promise(resolve =>
+      this.svc.getCpuInfo(this.scope.systemId).then(resp =>
+        resolve(_.floor(_.mean(resp.data.response[0].perProcessorUsage)))
+      )
+    );
+  }
 }
 
 export default angular
--- a/src/app/components/system-info/system-cpu.controller.spec.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/system-info/system-cpu.controller.spec.js	Tue Jul 18 10:14:56 2017 -0400
@@ -140,4 +140,31 @@
     });
   });
 
+  describe('multichartFn', () => {
+    it('should return a promise', () => {
+      let res = controller.multichartFn();
+      res.should.be.a.Promise();
+    });
+
+    it('should resolve system-cpu stat', done => {
+      service.cpuPromise.should.be.calledOnce();
+      let res = controller.multichartFn();
+      res.then(v => {
+        v.should.equal(90);
+        done();
+      });
+      service.cpuPromise.should.be.calledTwice();
+      let prom = service.cpuPromise.secondCall.args[0];
+      prom({
+        data: {
+          response: [
+            {
+              perProcessorUsage: [90]
+            }
+          ]
+        }
+      });
+    });
+  });
+
 });
--- a/src/app/components/system-info/system-info.html	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/system-info/system-info.html	Tue Jul 18 10:14:56 2017 -0400
@@ -50,6 +50,7 @@
           <div class="card-pf card-pf-view">
             <div class="card-pf-heading">
               <label class="card-pf-title">CPU Usage</label>
+              <mc-add class="pull-right" svc-name="{{systemId}}-cpu" get-fn="ctrl.multichartFn()"></mc-add>
             </div>
             <div class="card-pf-body">
               <div pf-donut-pct-chart id="cpuChart" config="ctrl.config" data="ctrl.data"></div>
@@ -62,6 +63,7 @@
             <div class="card-pf card-pf-view">
               <div class="card-pf-heading">
                 <label class="card-pf-title">Memory Usage</label>
+                <mc-add class="pull-right" svc-name="{{systemId}}-memory" get-fn="ctrl.multichartFn()"></mc-add>
               </div>
               <div class="card-pf-body">
                 <div pf-donut-pct-chart id="systemMemoryDonutChart" config="ctrl.donutConfig" data="ctrl.donutData"></div>
--- a/src/app/components/system-info/system-info.module.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/system-info/system-info.module.js	Tue Jul 18 10:14:56 2017 -0400
@@ -29,12 +29,14 @@
 import SystemCpuController from './system-cpu.controller.js';
 import SystemMemoryController from './system-memory.controller.js';
 import service from './system-info.service.js';
+import directives from 'shared/directives/directives.module.js';
 
 export default angular
   .module('systemInfo', [
     SystemInfocontroller,
     SystemCpuController,
     SystemMemoryController,
-    service
+    service,
+    directives
   ])
   .name;
--- a/src/app/components/system-info/system-memory.controller.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/system-info/system-memory.controller.js	Tue Jul 18 10:14:56 2017 -0400
@@ -184,6 +184,20 @@
       }
     }
   }
+
+  multichartFn () {
+    return new Promise(resolve =>
+      this.svc.getMemoryInfo(this.scope.systemId).then(resp => {
+        let data = resp.data.response[0];
+        let free = data.free;
+        let total = data.total;
+        let used = total - free;
+        let usage = Math.round(used / total * 100);
+        resolve(usage);
+      })
+    );
+  }
+
 }
 
 export default angular
--- a/src/app/components/system-info/system-memory.controller.spec.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/components/system-info/system-memory.controller.spec.js	Tue Jul 18 10:14:56 2017 -0400
@@ -152,6 +152,37 @@
     controller.should.not.have.ownProperty('refresh');
   });
 
+  describe('multichartFn', () => {
+    it('should return a promise', () => {
+      let res = controller.multichartFn();
+      res.should.be.a.Promise();
+    });
+
+    [[50, 45, 10], [100, 20, 80], [500, 50, 90]].forEach(tup => {
+      it('should resolve system-memory stat (' + tup + ')', done => {
+        service.memoryPromise.then.should.be.calledOnce();
+        let res = controller.multichartFn();
+        res.then(v => {
+          v.should.equal(tup[2]);
+          done();
+        });
+        service.memoryPromise.then.should.be.calledTwice();
+        let prom = service.memoryPromise.then.secondCall.args[0];
+        prom({
+          data: {
+            response: [
+              {
+                total: tup[0],
+                free: tup[1]
+              }
+            ]
+          }
+        });
+      });
+    });
+
+  });
+
   it('should call update() on refresh', () => {
     scope.$watch.should.be.calledWith(sinon.match('refreshRate'), sinon.match.func);
     let refreshFn = scope.$watch.args[0][1];
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/directives/directives.module.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,39 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+let mods = [];
+let req = require.context('./', true, /\.module\.js/);
+req.keys().map(v => {
+  let name = req(v).default;
+  if (name) {
+    mods.push(name);
+  }
+});
+
+export default angular
+  .module('app.directives', mods)
+  .name;
--- a/src/app/shared/directives/dismissible-error-message/dismissible-error-message.directive.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/shared/directives/dismissible-error-message/dismissible-error-message.directive.js	Tue Jul 18 10:14:56 2017 -0400
@@ -39,5 +39,6 @@
 };
 
 export default angular
-    .module('directives.dismissible-error-message', [])
-    .directive('dismissibleErrorMessage', dismissibleErrorMessageFunc);
+    .module('dismissibleErrorMessage.directive', [])
+    .directive('dismissibleErrorMessage', dismissibleErrorMessageFunc)
+    .name;
--- a/src/app/shared/directives/dismissible-error-message/dismissible-error-message.directive.spec.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/shared/directives/dismissible-error-message/dismissible-error-message.directive.spec.js	Tue Jul 18 10:14:56 2017 -0400
@@ -25,11 +25,12 @@
  * exception statement from your version.
  */
 
+import directiveModule from './dismissible-error-message.directive.js';
 import {dismissibleErrorMessageFunc} from './dismissible-error-message.directive.js';
 
 describe('dismissibleErrorMessage Directive', () => {
   let compiledDirectiveElement;
-  beforeEach(angular.mock.module('directives.dismissible-error-message'));
+  beforeEach(angular.mock.module(directiveModule));
 
   let initDummyModule = () => {
     let compile, rootScope;
--- a/src/app/shared/directives/dismissible-error-message/dismissible-error-message.html	Tue Jul 18 09:37:28 2017 -0400
+++ b/src/app/shared/directives/dismissible-error-message/dismissible-error-message.html	Tue Jul 18 10:14:56 2017 -0400
@@ -4,6 +4,5 @@
   </button>
 
   <span class="pficon pficon-warning-triangle-o"></span>
-  <strong>{{errTitle}}</strong> 
-  {{errMessage}}
+  <strong>{{errTitle}}</strong> {{errMessage}}
 </div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/directives/dismissible-error-message/dismissible-error-message.module.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,32 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import directive from './dismissible-error-message.directive.js';
+
+export default angular
+  .module('dismissibleErrorMessage', [directive])
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/directives/multichart-add/multichart-add.controller.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,74 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import servicesModule from 'shared/services/services.module.js';
+
+class MultichartAddController {
+  constructor (multichartService, $scope, $timeout) {
+    'ngInject';
+    this.svc = multichartService;
+    this.scope = $scope;
+    this.scope.multicharts = multichartService.chartNames;
+
+    this.scope.$watch('multicharts', cur => {
+      $timeout(() => {
+        cur.forEach(chart => {
+          let el = angular.element('#' + chart + '-' + this.scope.svcName);
+          el.bootstrapSwitch();
+          el.on('switchChange.bootstrapSwitch', event => {
+            this.toggleChart(event.currentTarget.getAttribute('data-chart'));
+          });
+        });
+      });
+    });
+  }
+
+  toggleChart (chartName) {
+    if (this.isInChart(chartName)) {
+      this.removeFromChart(chartName);
+    } else {
+      this.addToChart(chartName);
+    }
+  }
+
+  removeFromChart (chartName) {
+    this.svc.removeService(chartName, this.scope.svcName);
+  }
+
+  addToChart (chartName) {
+    this.svc.addService(chartName, this.scope.svcName, this.scope.getFn);
+  }
+
+  isInChart (chartName) {
+    return this.svc.hasServiceForChart(chartName, this.scope.svcName);
+  }
+}
+
+export default angular
+  .module('multichartAddControllerModule', [servicesModule])
+  .controller('MultichartAddController', MultichartAddController)
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/directives/multichart-add/multichart-add.controller.spec.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,172 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import controllerModule from './multichart-add.controller.js';
+
+describe('MultichartAddController', () => {
+
+  let svc, scope, timeout, ctrl;
+
+  beforeEach(() => {
+    angular.mock.module(controllerModule);
+    angular.mock.inject($controller => {
+      'ngInject';
+
+      svc = {
+        chartNames: ['foo', 'bar'],
+        addService: sinon.spy(),
+        removeService: sinon.spy(),
+        hasServiceForChart: sinon.stub().returns(true)
+      };
+
+      scope = {
+        svcName: 'foo-svc',
+        getFn: sinon.spy(),
+        $watch: sinon.spy()
+      };
+
+      timeout = sinon.spy();
+
+      ctrl = $controller('MultichartAddController', {
+        multichartService: svc,
+        $scope: scope,
+        $timeout: timeout
+      });
+    });
+  });
+
+  it('should exist', () => {
+    should.exist(ctrl);
+  });
+
+  it('should assign multichart names from service', () => {
+    scope.multicharts.should.deepEqual(svc.chartNames);
+  });
+
+  describe('bootstrapSwitch', () => {
+    let mockElem;
+    beforeEach(() => {
+      mockElem = {
+        bootstrapSwitch: sinon.spy(),
+        on: sinon.spy()
+      };
+      sinon.stub(angular, 'element').returns(mockElem);
+    });
+
+    afterEach(() => {
+      angular.element.restore();
+    });
+
+    it('should watch on multicharts', () => {
+      scope.$watch.should.be.calledWith('multicharts', sinon.match.func);
+    });
+
+    it('should use $timeout to wait for $scope $digest to complete', () => {
+      timeout.should.not.be.called();
+      let fn = scope.$watch.firstCall.args[1];
+      fn();
+      timeout.should.be.calledOnce();
+      timeout.should.be.calledWith(sinon.match.func);
+    });
+
+    it('should set up bootstrapSwitch functionality for each switch', () => {
+      svc.removeService.should.not.be.called();
+      let fn = scope.$watch.firstCall.args[1];
+      fn(['foo']);
+      timeout.yield();
+
+      angular.element.should.be.calledOnce();
+      angular.element.should.be.calledWith('#foo-foo-svc');
+      mockElem.bootstrapSwitch.should.be.calledOnce();
+      mockElem.on.should.be.calledOnce();
+      mockElem.on.should.be.calledWith('switchChange.bootstrapSwitch', sinon.match.func);
+      let evtHandler = mockElem.on.firstCall.args[1];
+      evtHandler({
+        currentTarget: {
+          getAttribute: () => 'foo-chart'
+        }
+      });
+      svc.removeService.should.be.calledOnce();
+      svc.removeService.should.be.calledWith('foo-chart', 'foo-svc');
+    });
+  });
+
+  describe('isInChart', () => {
+    it('should delegate to service', () => {
+      svc.hasServiceForChart.should.not.be.called();
+      ctrl.isInChart('foo').should.be.true();
+      svc.hasServiceForChart.should.be.calledOnce();
+      svc.hasServiceForChart.should.be.calledWith('foo', scope.svcName);
+    });
+  });
+
+  describe('addToChart', () => {
+    it('should delegate to service', () => {
+      svc.addService.should.not.be.called();
+      ctrl.addToChart('foo');
+      svc.addService.should.be.calledOnce();
+      svc.addService.should.be.calledWith('foo', scope.svcName, scope.getFn);
+    });
+  });
+
+  describe('removeFromChart', () => {
+    it('should delegate to service', () => {
+      svc.removeService.should.not.be.called();
+      ctrl.removeFromChart('foo');
+      svc.removeService.should.be.calledOnce();
+      svc.removeService.should.be.calledWith('foo', scope.svcName);
+    });
+  });
+
+  describe('toggleChart', () => {
+    it('should remove if already added', () => {
+      svc.hasServiceForChart.should.not.be.called();
+      svc.addService.should.not.be.called();
+      svc.removeService.should.not.be.called();
+      ctrl.toggleChart('foo');
+      svc.hasServiceForChart.should.be.calledOnce();
+      svc.hasServiceForChart.should.be.calledWith('foo', scope.svcName);
+      svc.addService.should.not.be.called();
+      svc.removeService.should.be.calledOnce();
+      svc.removeService.should.be.calledWith('foo', scope.svcName);
+    });
+
+    it('should add if not present', () => {
+      svc.hasServiceForChart.returns(false);
+      svc.hasServiceForChart.should.not.be.called();
+      svc.addService.should.not.be.called();
+      svc.removeService.should.not.be.called();
+      ctrl.toggleChart('foo');
+      svc.hasServiceForChart.should.be.calledOnce();
+      svc.hasServiceForChart.should.be.calledWith('foo', scope.svcName);
+      svc.addService.should.be.calledOnce();
+      svc.addService.should.be.calledWith('foo', scope.svcName);
+      svc.removeService.should.not.be.called();
+    });
+  });
+
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/directives/multichart-add/multichart-add.directive.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,48 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import controller from './multichart-add.controller.js';
+
+function configFactory () {
+  return {
+    restrict: 'EA',
+    controller: 'MultichartAddController',
+    controllerAs: 'ctrl',
+    template: require('./multichart-add.html'),
+    scope: {
+      svcName: '@',
+      getFn: '&'
+    }
+  };
+}
+
+export { configFactory };
+
+export default angular
+  .module('multichartAddDirective', [controller])
+  .directive('mcAdd', configFactory)
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/directives/multichart-add/multichart-add.directive.spec.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,62 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import { configFactory } from './multichart-add.directive.js';
+
+describe('MultichartAddDirective', () => {
+  let cfg;
+  beforeEach(() => {
+    cfg = configFactory();
+  });
+
+  it('should be restricted to an element or attribute', () => {
+    cfg.restrict.should.have.length(2);
+    cfg.restrict.should.containEql('E');
+    cfg.restrict.should.containEql('A');
+  });
+
+  it('should expect a svcName string in scope', () => {
+    cfg.scope.should.have.ownProperty('svcName');
+    cfg.scope.svcName.should.equal('@');
+  });
+
+  it('should expect a getFn expression in scope', () => {
+    cfg.scope.should.have.ownProperty('getFn');
+    cfg.scope.getFn.should.equal('&');
+  });
+
+  it('should use correct template', () => {
+    cfg.template.should.equal(require('./multichart-add.html'));
+  });
+
+  it('should attach multichartAddController', () => {
+    cfg.should.have.ownProperty('controller');
+    cfg.controller.should.equal('MultichartAddController');
+    cfg.should.have.ownProperty('controllerAs');
+    cfg.controllerAs.should.equal('ctrl');
+  });
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/directives/multichart-add/multichart-add.html	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,24 @@
+<div ng-if="multicharts.length > 0">
+  <div class="dropdown dropdown-kebab-pf">
+    <button class="btn btn-link dropdown-toggle" type="button" id="kebab" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
+      <span class="fa fa-ellipsis-v"></span>
+    </button>
+    <ul class="dropdown-menu dropdown-menu-right" aria-labelledby="kebab">
+      <li>Multicharts</li>
+      <li role="separator" class="divider"></li>
+      <li ng-repeat="chart in multicharts">
+        <div class="text-right">
+          <div class="form-group">
+            <label for="switch-{{chart}}-{{svcName}}" class="label label-info pull-left">{{chart}}</label>
+            <input class="bootstrap-switch pull-right" id="{{chart}}-{{svcName}}" name="switch-{{chart}}-{{svcName}}" data-chart="{{chart}}"
+            data-size="mini"
+            type="checkbox"
+            ng-checked="ctrl.isInChart(chart)"
+            />
+          </div>
+        </div>
+      </li>
+      <li>
+    </ul>
+  </div>
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/directives/multichart-add/multichart-add.module.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,36 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import directive from './multichart-add.directive.js';
+import controller from './multichart-add.controller.js';
+
+export default angular
+  .module('multichartAddModule', [
+    directive,
+    controller
+  ])
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/services/multichart.service.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,155 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import servicesModule from 'shared/services/services.module.js';
+import _ from 'lodash';
+
+class MultiChartService {
+  constructor () {
+    this.charts = new Map();
+  }
+
+  get chartNames () {
+    let res = [];
+    for (let key of this.charts.keys()) {
+      res.push(key);
+    }
+    res.sort();
+    return res;
+  }
+
+  countServicesForChart (chartName) {
+    if (!this.charts.has(chartName)) {
+      return 0;
+    }
+    return this.charts.get(chartName).length;
+  }
+
+  getAxesForChart (chartName) {
+    //TODO: make this more intelligent once we start getting extended info
+    //back from gateway about what metrics are, the units and context.
+    //then we can group similar metrics onto the same axis.
+    if (!this.hasChart(chartName)) {
+      return {};
+    }
+    let charts = this.charts.get(chartName);
+    let ret = {};
+
+    for (let i = 0; i < this.countServicesForChart(chartName); i++) {
+      let axis;
+      if (i === 0) {
+        axis = 'y';
+      } else {
+        axis = 'y2';
+      }
+      ret[charts[i].svcName] = axis;
+    }
+    return ret;
+  }
+
+  addChart (chartName) {
+    if (this.charts.has(chartName)) {
+      return;
+    }
+    this.charts.set(chartName, new Array());
+  }
+
+  hasChart (chartName) {
+    return this.charts.has(chartName);
+  }
+
+  removeChart (chartName) {
+    if (!this.hasChart(chartName)) {
+      return;
+    }
+    this.charts.delete(chartName);
+  }
+
+  addService (chartName, svcName, getFn) {
+    if (!this.hasChart(chartName) || this.hasServiceForChart(chartName, svcName)) {
+      return;
+    }
+    let svcs = this.charts.get(chartName);
+    svcs.push({
+      svcName: svcName,
+      getFn: getFn
+    });
+  }
+
+  hasServiceForChart (chartName, svcName) {
+    if (!this.hasChart(chartName)) {
+      return false;
+    }
+    let svcs = this.charts.get(chartName);
+    for (let i = 0; i < svcs.length; i++) {
+      if (svcs[i].svcName === svcName) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  removeService (chartName, svcName) {
+    if (!this.hasChart(chartName)) {
+      return;
+    }
+    _.remove(this.charts.get(chartName), svc => svc.svcName === svcName);
+  }
+
+  getData (chartName) {
+    if (!this.charts.has(chartName)) {
+      return new Promise((resolve, reject) => reject(new Error('No such multichart ' + chartName)));
+    }
+
+    let svcs = this.charts.get(chartName);
+    let promises = [];
+    let res = {};
+    for (let i = 0; i < svcs.length; i++) {
+      let svc = svcs[i];
+      let key;
+      if (i === 0) {
+        key = 'yData';
+      } else {
+        key = 'yData' + i;
+      }
+      res[key] = [svc.svcName];
+      let promise = svc.getFn();
+      promises.push(promise);
+      promise.then(data => {
+        res[key].push(data);
+      });
+    }
+
+    return new Promise(resolve => {
+      Promise.all(promises).then(() => resolve(res));
+    });
+  }
+}
+
+angular
+  .module(servicesModule)
+  .service('multichartService', MultiChartService);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/services/multichart.service.spec.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,298 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import servicesModule from 'shared/services/services.module.js';
+
+describe('MultichartService', () => {
+
+  let svc;
+  beforeEach(() => {
+    angular.mock.module(servicesModule);
+    angular.mock.inject(multichartService => {
+      'ngInject';
+      svc = multichartService;
+    });
+  });
+
+  it('should exist', () => {
+    should.exist(svc);
+  });
+
+  it('should initialize an empty charts map', () => {
+    svc.charts.should.be.an.instanceof(Map);
+    svc.charts.should.eql(new Map());
+  });
+
+  describe('chartNames', () => {
+    it('should be initially an empty array', () => {
+      let res = svc.chartNames;
+      res.should.be.an.Array();
+      res.should.deepEqual([]);
+    });
+
+    it('should reflect added charts', () => {
+      svc.addChart('foo');
+      svc.addChart('bar');
+      svc.chartNames.should.deepEqual(['bar', 'foo']);
+    });
+
+    it('should reflect removed charts', () => {
+      svc.addChart('foo');
+      svc.addChart('bar');
+      svc.chartNames.should.deepEqual(['bar', 'foo']);
+      svc.removeChart('foo');
+      svc.chartNames.should.deepEqual(['bar']);
+    });
+
+    it('should not contain duplicates', () => {
+      svc.addChart('foo');
+      svc.addChart('foo');
+      svc.chartNames.should.deepEqual(['foo']);
+    });
+
+    it('should be sorted alphabetically', () => {
+      svc.addChart('b');
+      svc.addChart('a');
+      svc.addChart('d');
+      svc.addChart('c');
+      svc.chartNames.should.deepEqual(['a', 'b', 'c', 'd']);
+    });
+  });
+
+  describe('countServicesForChart', () => {
+    it('should return 0 for nonexistent chart', () => {
+      svc.countServicesForChart('foo').should.equal(0);
+    });
+
+    it('should return 0 for chart with no services', () => {
+      svc.addChart('foo');
+      svc.countServicesForChart('foo').should.equal(0);
+    });
+
+    it('should return the service count', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A', angular.noop);
+      svc.addService('foo', 'foo-svc-B', angular.noop);
+      svc.countServicesForChart('foo').should.equal(2);
+    });
+
+    it('should reflect removed services', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A', angular.noop);
+      svc.addService('foo', 'foo-svc-B', angular.noop);
+      svc.countServicesForChart('foo').should.equal(2);
+      svc.removeService('foo', 'foo-svc-B');
+      svc.countServicesForChart('foo').should.equal(1);
+    });
+
+    it('should not count duplicate services', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A', angular.noop);
+      svc.addService('foo', 'foo-svc-A', angular.noop);
+      svc.countServicesForChart('foo').should.equal(1);
+    });
+  });
+
+  describe('getAxesForChart', () => {
+    it('should return an empty object for nonexistent chart', () => {
+      svc.getAxesForChart('foo').should.deepEqual({});
+    });
+
+    it('should return a single-axis config for a chart with one service', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A', angular.noop);
+      svc.getAxesForChart('foo').should.deepEqual({'foo-svc-A': 'y'});
+    });
+
+    it('should return a two-axis config for a chart with two services', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A', angular.noop);
+      svc.addService('foo', 'foo-svc-B', angular.noop);
+      svc.getAxesForChart('foo').should.deepEqual(
+        {
+          'foo-svc-A': 'y',
+          'foo-svc-B': 'y2'
+        }
+      );
+    });
+
+    it('should return a two-axis config for a chart with three services', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A', angular.noop);
+      svc.addService('foo', 'foo-svc-B', angular.noop);
+      svc.addService('foo', 'foo-svc-C', angular.noop);
+      svc.getAxesForChart('foo').should.deepEqual(
+        {
+          'foo-svc-A': 'y',
+          'foo-svc-B': 'y2',
+          'foo-svc-C': 'y2'
+        }
+      );
+    });
+  });
+
+  describe('addChart', () => {
+    it('should add new charts', () => {
+      svc.hasChart('foo').should.be.false();
+      svc.addChart('foo');
+      svc.hasChart('foo').should.be.true();
+    });
+
+    it('should not add duplicates', () => {
+      svc.addChart('foo');
+      svc.addChart('foo');
+      let map = new Map();
+      map.set('foo', new Array());
+      svc.charts.should.eql(map);
+      svc.hasChart('foo').should.be.true();
+    });
+
+    it('should not clobber services for repeat additions', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A', angular.noop);
+      svc.addChart('foo');
+      let map = new Map();
+      map.set('foo', [{svcName: 'foo-svc-A', getFn: angular.noop}]);
+      svc.charts.should.eql(map);
+    });
+  });
+
+  describe('removeChart', () => {
+    it('should do nothing for nonexistent charts', () => {
+      svc.hasChart('foo').should.be.false();
+      svc.removeChart('foo');
+      svc.hasChart('foo').should.be.false();
+      svc.charts.should.deepEqual(new Map());
+    });
+
+    it('should remove added charts', () => {
+      svc.addChart('foo');
+      svc.hasChart('foo').should.be.true();
+      svc.removeChart('foo');
+      svc.hasChart('foo').should.be.false();
+    });
+  });
+
+  describe('addService', () => {
+    it('should do nothing for nonexistent chart', () => {
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.false();
+      svc.addService('foo', 'foo-svc-A');
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.false();
+      svc.countServicesForChart('foo').should.equal(0);
+    });
+
+    it('should add service to chart', () => {
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.false();
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A');
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.true();
+      svc.countServicesForChart('foo').should.equal(1);
+    });
+
+    it('should do nothing if service already added', () => {
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.false();
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A');
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.true();
+      svc.addService('foo', 'foo-svc-A');
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.true();
+      svc.countServicesForChart('foo').should.equal(1);
+    });
+  });
+
+  describe('hasServiceForChart', () => {
+    it('should return false for nonexistent chart', () => {
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.false();
+    });
+
+    it('should return false for nonexistent service', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-B');
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.false();
+    });
+
+    it('should return true for existing service', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A');
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.true();
+    });
+  });
+
+  describe('removeService', () => {
+    it('should do nothing for nonexistent chart', () => {
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.false();
+      svc.hasChart('foo').should.be.false();
+      svc.removeService('foo', 'foo-svc-A');
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.false();
+      svc.hasChart('foo').should.be.false();
+    });
+
+    it('should do nothing for nonexistent service', () => {
+      svc.addChart('foo');
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.false();
+      svc.hasChart('foo').should.be.true();
+      svc.removeService('foo', 'foo-svc-A');
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.false();
+      svc.hasChart('foo').should.be.true();
+    });
+
+    it('should remove services', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A', angular.noop);
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.true();
+      svc.removeService('foo', 'foo-svc-A');
+      svc.hasServiceForChart('foo', 'foo-svc-A').should.be.false();
+    });
+  });
+
+  describe('getData', () => {
+    it('should reject for nonexistent chart', () => {
+      return svc.getData('foo').should.be.rejectedWith(Error);
+    });
+
+    it('should resolve data from single service getter functions', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A', () => new Promise(resolve => resolve(500)));
+      return svc.getData('foo').should.be.fulfilledWith({ yData: ['foo-svc-A', 500] });
+    });
+
+    it('should resolve data from multiple service getter functions', () => {
+      svc.addChart('foo');
+      svc.addService('foo', 'foo-svc-A', () => new Promise(resolve => resolve(500)));
+      svc.addService('foo', 'foo-svc-B', () => new Promise(resolve => resolve(600)));
+      svc.addService('foo', 'foo-svc-C', () => new Promise(resolve => resolve(700)));
+      svc.addChart('bar');
+      svc.addService('bar', 'bar-svc-A', () => new Promise(resolve => resolve(500)));
+      return svc.getData('foo').should.be.fulfilledWith({
+        yData: ['foo-svc-A', 500],
+        yData1: ['foo-svc-B', 600],
+        yData2: ['foo-svc-C', 700]
+      });
+    });
+  });
+
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/services/sanitize.service.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,46 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import servicesModule from 'shared/services/services.module.js';
+
+class SanitizeService {
+  sanitize (str) {
+    if (!str) {
+      return;
+    }
+    if (typeof str !== 'string') {
+      return str;
+    }
+    let res = str;
+    res = res.replace(/\s+/g, '-');
+    return res;
+  }
+}
+
+angular
+  .module(servicesModule)
+  .service('sanitizeService', SanitizeService);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/shared/services/sanitize.service.spec.js	Tue Jul 18 10:14:56 2017 -0400
@@ -0,0 +1,83 @@
+/**
+ * Copyright 2012-2017 Red Hat, Inc.
+ *
+ * Thermostat is distributed under the GNU General Public License,
+ * version 2 or any later version (with a special exception described
+ * below, commonly known as the "Classpath Exception").
+ *
+ * A copy of GNU General Public License (GPL) is included in this
+ * distribution, in the file COPYING.
+ *
+ * Linking Thermostat code with other modules is making a combined work
+ * based on Thermostat.  Thus, the terms and conditions of the GPL
+ * cover the whole combination.
+ *
+ * As a special exception, the copyright holders of Thermostat give you
+ * permission to link this code with independent modules to produce an
+ * executable, regardless of the license terms of these independent
+ * modules, and to copy and distribute the resulting executable under
+ * terms of your choice, provided that you also meet, for each linked
+ * independent module, the terms and conditions of the license of that
+ * module.  An independent module is a module which is not derived from
+ * or based on Thermostat code.  If you modify Thermostat, you may
+ * extend this exception to your version of the software, but you are
+ * not obligated to do so.  If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+import servicesModule from 'shared/services/services.module.js';
+
+describe('SanitizeService', () => {
+
+  let svc;
+  beforeEach(() => {
+    angular.mock.module(servicesModule);
+    angular.mock.inject(sanitizeService => {
+      'ngInject';
+      svc = sanitizeService;
+    });
+  });
+
+  it('should exist', () => {
+    should.exist(svc);
+  });
+
+  describe('non-string inputs', () => {
+    it('should do nothing to undefined', () => {
+      should(svc.sanitize(undefined)).be.undefined();
+    });
+
+    it('should do nothing to null', () => {
+      should(svc.sanitize(null)).be.undefined();
+    });
+
+    it('should return the argument if number', () => {
+      svc.sanitize(5).should.equal(5);
+    });
+
+    it('should return the argument if object', () => {
+      svc.sanitize({}).should.eql({});
+    });
+
+    it('should return the argument if array', () => {
+      svc.sanitize([]).should.eql([]);
+    });
+  });
+
+  it('should do nothing to sane strings', () => {
+    svc.sanitize('foo').should.equal('foo');
+  });
+
+  it('should sanitize string with a space', () => {
+    svc.sanitize('foo bar').should.equal('foo-bar');
+  });
+
+  it('should sanitize string with multiple spaces', () => {
+    svc.sanitize('foo and bar').should.equal('foo-and-bar');
+  });
+
+  it('should sanitize string with long spaces', () => {
+    svc.sanitize('foo   bar').should.equal('foo-bar');
+  });
+
+});
--- a/webpack.config.js	Tue Jul 18 09:37:28 2017 -0400
+++ b/webpack.config.js	Tue Jul 18 10:14:56 2017 -0400
@@ -21,6 +21,7 @@
       'angular': 'angular-patternfly/node_modules/angular',
       'd3': 'angular-patternfly/node_modules/patternfly/node_modules/d3',
       'c3': 'angular-patternfly/node_modules/patternfly/node_modules/c3',
+      'bootstrap': 'angular-patternfly/node_modules/patternfly/node_modules/bootstrap/dist/js/bootstrap.js',
       'bootstrap-switch': 'angular-patternfly/node_modules/patternfly/node_modules/bootstrap-switch',
 
       'assets': path.resolve(__dirname, 'src', 'assets'),