changeset 176:6c3ce29d353f

Convert multicharts module to component Chart component split from multicharts view component Reviewed-by: jkang Review-thread: http://icedtea.classpath.org/pipermail/thermostat/2017-August/024755.html
author Andrew Azores <aazores@redhat.com>
date Wed, 30 Aug 2017 14:21:56 -0400
parents e10e61e34baf
children 97e433c06ff8
files src/app/components/multichart/chart.controller.js src/app/components/multichart/chart.controller.spec.js src/app/components/multichart/chart/chart.component.js src/app/components/multichart/chart/chart.controller.js src/app/components/multichart/chart/chart.controller.spec.js src/app/components/multichart/chart/chart.html src/app/components/multichart/chart/en.locale.yaml src/app/components/multichart/en.locale.yaml src/app/components/multichart/multichart.component.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
diffstat 15 files changed, 742 insertions(+), 734 deletions(-) [+]
line wrap: on
line diff
--- a/src/app/components/multichart/chart.controller.js	Thu Aug 31 08:40:56 2017 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,184 +0,0 @@
-/**
- * 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, $translate) {
-    this.svc = multichartService;
-    this.scope = $scope;
-    this.interval = $interval;
-    this.dateFilter = dateFilter;
-    this.dateFormat = DATE_FORMAT;
-    this.translate = $translate;
-    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 () {
-    if (!angular.isDefined(this.chartData)) {
-      return;
-    }
-    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 () {
-    this.translate([
-      'multicharts.chart.X_AXIS_LABEL',
-      'multicharts.chart.X_AXIS_DATA_TYPE'
-    ]).then(translations => {
-      let self = this;
-      this.chartConfig = {
-        chartId: 'chart-' + this.chart,
-        axis: {
-          x: {
-            label: translations['multicharts.chart.X_AXIS_LABEL'],
-            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 => x,
-            value: y => y
-          }
-        }
-      };
-      this.chartData = {
-        xData: [translations['multicharts.chart.X_AXIS_DATA_TYPE']]
-      };
-    });
-  }
-
-  removeChart () {
-    this.svc.removeChart(this.chart);
-  }
-
-  setRefreshRate (val) {
-    this.stop();
-    if (val > 0) {
-      this.refresh = this.interval(() => this.update(), val);
-    }
-  }
-
-  rename (to) {
-    if (!to) {
-      return;
-    }
-    to = to.trim();
-    if (!this.isValid(to)) {
-      return;
-    }
-    this.svc.rename(this.chart, to);
-  }
-
-  isValid (chartName) {
-    // TODO: this needs to accept letters outside of the English alphabet
-    return chartName.search(/^[\w-]+$/) > -1;
-  }
-}
-
-export default angular
-  .module('multichartChartController', [
-    'patternfly',
-    'patternfly.charts',
-    services,
-    filters
-  ])
-  .controller('MultiChartChartController', MultiChartController)
-  .name;
--- a/src/app/components/multichart/chart.controller.spec.js	Thu Aug 31 08:40:56 2017 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,373 +0,0 @@
-/**
- * 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, translate;
-  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(),
-        rename: 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');
-      translate = sinon.stub().returns({
-        then: sinon.stub().yields({
-          'multicharts.chart.X_AXIS_LABEL': 'timestamp',
-          'multicharts.chart.X_AXIS_DATA_TYPE': 'timestamp'
-        })
-      });
-
-      ctrl = $controller('MultiChartChartController', {
-        multichartService: svc,
-        $scope: scope,
-        $interval: interval,
-        dateFilter: dateFilter,
-        DATE_FORMAT: { time: { medium: 'medium-time' } },
-        $translate: translate
-      });
-      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 chartData undefined', () => {
-      delete ctrl.chartData;
-      ctrl.trimData();
-      should(ctrl.chartData).be.undefined();
-    });
-
-    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('foo');
-      titleFmt(100).should.equal(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();
-    });
-  });
-
-  describe('rename', () => {
-    it('should delegate to service', () => {
-      svc.rename.should.not.be.called();
-      ctrl.rename('newname');
-      svc.rename.should.be.calledOnce();
-      svc.rename.should.be.calledWith('foo-chart', 'newname');
-    });
-
-    it('should do nothing if chart name is empty', () => {
-      svc.rename.should.not.be.called();
-      ctrl.rename('');
-      svc.rename.should.not.be.called();
-    });
-
-    it('should do nothing if chart name is invalid', () => {
-      svc.rename.should.not.be.called();
-      ctrl.rename('with space');
-      svc.rename.should.not.be.called();
-    });
-  });
-
-});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/chart/chart.component.js	Wed Aug 30 14:21: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.
+ */
+
+import controller from './chart.controller.js';
+
+export default angular
+  .module('multichartChartComponent', [controller])
+  .component('multichartChart', {
+    bindings: {
+      chart: '<'
+    },
+    controller: 'MultichartChartController',
+    template: require('./chart.html')
+  })
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/chart/chart.controller.js	Wed Aug 30 14:21:56 2017 -0400
@@ -0,0 +1,193 @@
+/**
+ * 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 MultichartChartController {
+  constructor (multichartService, $scope, $interval, dateFilter, DATE_FORMAT, $translate) {
+    this.svc = multichartService;
+    this.interval = $interval;
+    this.dateFilter = dateFilter;
+    this.dateFormat = DATE_FORMAT;
+    this.translate = $translate;
+    this.chart = $scope.$parent.chart;
+
+    this.initializeChartData();
+
+    $scope.$on('$destroy', () => this.stop());
+
+    this._refreshRate = 2000;
+    this._dataAgeLimit = 60000;
+
+    this.refresh = $interval(() => this.update(), this._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 () {
+    if (!angular.isDefined(this.chartData)) {
+      return;
+    }
+    let now = Date.now();
+    let oldestLimit = now - this._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 () {
+    this.translate([
+      'multicharts.chart.X_AXIS_LABEL',
+      'multicharts.chart.X_AXIS_DATA_TYPE'
+    ]).then(translations => {
+      let self = this;
+      this.chartConfig = {
+        chartId: 'chart-' + this.chart,
+        axis: {
+          x: {
+            label: translations['multicharts.chart.X_AXIS_LABEL'],
+            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 => x,
+            value: y => y
+          }
+        }
+      };
+      this.chartData = {
+        xData: [translations['multicharts.chart.X_AXIS_DATA_TYPE']]
+      };
+    });
+  }
+
+  removeChart () {
+    this.svc.removeChart(this.chart);
+  }
+
+  get refreshRate () {
+    return this._refreshRate.toString();
+  }
+
+  set refreshRate (val) {
+    this.stop();
+    if (val > 0) {
+      this.refresh = this.interval(() => this.update(), val);
+    }
+  }
+
+  get dataAgeLimit () {
+    return this._dataAgeLimit.toString();
+  }
+
+  set dataAgeLimit (val) {
+    this._dataAgeLimit = parseInt(val);
+    this.trimData();
+  }
+
+  rename (to) {
+    if (!to) {
+      return;
+    }
+    to = to.trim();
+    if (!this.isValid(to)) {
+      return;
+    }
+    this.svc.rename(this.chart, to);
+  }
+
+  isValid (chartName) {
+    // TODO: this needs to accept letters outside of the English alphabet
+    return chartName.search(/^[\w-]+$/) > -1;
+  }
+}
+
+export default angular
+  .module('multichartChartController', [
+    'patternfly',
+    'patternfly.charts',
+    services,
+    filters
+  ])
+  .controller('MultichartChartController', MultichartChartController)
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/chart/chart.controller.spec.js	Wed Aug 30 14:21:56 2017 -0400
@@ -0,0 +1,349 @@
+/**
+ * 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, translate;
+  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(),
+        rename: 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');
+      translate = sinon.stub().returns({
+        then: sinon.stub().yields({
+          'multicharts.chart.X_AXIS_LABEL': 'timestamp',
+          'multicharts.chart.X_AXIS_DATA_TYPE': 'timestamp'
+        })
+      });
+
+      ctrl = $controller('MultichartChartController', {
+        multichartService: svc,
+        $scope: scope,
+        $interval: interval,
+        dateFilter: dateFilter,
+        DATE_FORMAT: { time: { medium: 'medium-time' } },
+        $translate: translate
+      });
+      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', () => {
+    ctrl.refreshRate.should.equal('2000');
+  });
+
+  it('should initialize dataAgeLimit', () => {
+    ctrl.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();
+  });
+
+  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 chartData undefined', () => {
+      delete ctrl.chartData;
+      ctrl.trimData();
+      should(ctrl.chartData).be.undefined();
+    });
+
+    it('should do nothing if no samples', () => {
+      ctrl.trimData();
+      ctrl.chartData.should.eql({
+        xData: ['timestamp']
+      });
+    });
+
+    it('should not trim data if only one sample', () => {
+      ctrl.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', () => {
+      ctrl.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('foo');
+      titleFmt(100).should.equal(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('#set refreshRate ()', () => {
+    it('should stop previous refreshes', () => {
+      interval.cancel.should.not.be.called();
+      ctrl.refreshRate = 5;
+      interval.cancel.should.be.calledOnce();
+    });
+
+    it('should set the new update interval', () => {
+      interval.should.be.calledOnce();
+      ctrl.refreshRate = 5;
+      interval.should.be.calledTwice();
+      interval.secondCall.should.be.calledWith(sinon.match.func, 5);
+    });
+
+    it('should perform updates at each interval', () => {
+      ctrl.refreshRate = 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.refreshRate = -1;
+      interval.cancel.should.be.calledOnce();
+      svc.getData.should.be.calledOnce();
+    });
+  });
+
+  describe('rename', () => {
+    it('should delegate to service', () => {
+      svc.rename.should.not.be.called();
+      ctrl.rename('newname');
+      svc.rename.should.be.calledOnce();
+      svc.rename.should.be.calledWith('foo-chart', 'newname');
+    });
+
+    it('should do nothing if chart name is empty', () => {
+      svc.rename.should.not.be.called();
+      ctrl.rename('');
+      svc.rename.should.not.be.called();
+    });
+
+    it('should do nothing if chart name is invalid', () => {
+      svc.rename.should.not.be.called();
+      ctrl.rename('with space');
+      svc.rename.should.not.be.called();
+    });
+  });
+
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/chart/chart.html	Wed Aug 30 14:21:56 2017 -0400
@@ -0,0 +1,69 @@
+<div class="card-pf card-pf-view">
+  <div class="card-pf-heading">
+    <label class="card-pf-title">{{$ctrl.chart}}</label>
+  </div>
+
+  <div class="row" style="margin-top:2vh">
+
+    <!-- Metric Controls: Refresh Rate -->
+    <div class="col-xs-12 col-md-3">
+      <label for="refreshCombo" class="label label-info" translate>multicharts.chart.refresh.LABEL</label>
+      <select name="refreshCombo" class="combobox form-control" ng-model="$ctrl.refreshRate">
+        <option value="-1" translate>multicharts.chart.refresh.DISABLED</option>
+        <option value="1000" translate="multicharts.chart.refresh.SECONDS" translate-values="{ SECONDS: 1 }" translate-interpolation="messageformat"></option>
+        <option value="2000" selected translate="multicharts.chart.refresh.SECONDS" translate-values="{ SECONDS: 2, DEFAULT: true }" translate-interpolation="messageformat"></option>
+        <option value="5000" translate="multicharts.chart.refresh.SECONDS" translate-values="{ SECONDS: 5 }" translate-interpolation="messageformat"></option>
+        <option value="10000" translate="multicharts.chart.refresh.SECONDS" translate-values="{ SECONDS: 10 }" translate-interpolation="messageformat"></option>
+        <option value="30000" translate="multicharts.chart.refresh.SECONDS" translate-values="{ SECONDS: 30 }" translate-interpolation="messageformat"></option>
+        <option value="60000" translate="multicharts.chart.refresh.MINUTES" translate-values="{ MINUTES: 1 }" translate-interpolation="messageformat"></option>
+        <option value="300000" translate="multicharts.chart.refresh.MINUTES" translate-values="{ MINUTES: 5 }" translate-interpolation="messageformat"></option>
+        <option value="600000" translate="multicharts.chart.refresh.MINUTES" translate-values="{ MINUTES: 10 }" translate-interpolation="messageformat"></option>
+      </select>
+    </div>
+
+    <!-- Metric Controls: Max Data Age -->
+    <div class="col-xs-12 col-md-3">
+      <label for="dataAgeCombo" class="label label-info" translate>multicharts.chart.refresh.LABEL</label>
+      <select name="dataAgeCombo" class="combobox form-control" ng-model="$ctrl.dataAgeLimit">
+        <option value="10000" translate="multicharts.chart.dataAge.SECONDS" translate-values="{ SECONDS: 10 }" translate-interpolation="messageformat"></option>
+        <option value="30000" translate="multicharts.chart.dataAge.SECONDS" translate-values="{ SECONDS: 30 }" translate-interpolation="messageformat"></option>
+        <option value="60000" selected translate="multicharts.chart.dataAge.MINUTES" translate-values="{ MINUTES: 1, DEFAULT: true}" translate-interpolation="messageformat"></option>
+        <option value="300000" translate="multicharts.chart.dataAge.MINUTES" translate-values="{ MINUTES: 5 }" translate-interpolation="messageformat"></option>
+        <option value="600000" translate="multicharts.chart.dataAge.MINUTES" translate-values="{ MINUTES: 10 }" translate-interpolation="messageformat"></option>
+        <option value="900000" translate="multicharts.chart.dataAge.MINUTES" translate-values="{ MINUTES: 15 }" translate-interpolation="messageformat"></option>
+        <option value="1800000" translate="multicharts.chart.dataAge.MINUTES" translate-values="{ MINUTES: 30 }" translate-interpolation="messageformat"></option>
+        <option value="3600000" translate="multicharts.chart.dataAge.HOURS" translate-values="{ HOURS: 1 }" translate-interpolation="messageformat"></option>
+      </select>
+    </div>
+
+    <div class="dropdown dropdown-kebab-pf pull-right" style="margin-right:2vw">
+      <button class="btn btn-link dropdown-toggle" type="button" id="{{$ctrl.chart}}-kebab" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
+        <span class="fa fa-ellipsis-v"></span>
+      </button>
+      <ul class="dropdown-menu" aria-labelledby="{{$ctrl.chart}}-kebab">
+        <li>
+          <form name="renameForm" class="input-group" ng-submit="$ctrl.rename(chartRename)">
+            <input type="text" class="form-control" translate-attr="{ placeholder: 'multicharts.chart.rename.PLACEHOLDER' }" ng-model="chartRename"/>
+            <input type="submit" translate-attr="{ value: 'multicharts.chart.rename.SUBMIT_LABEL' }"/>
+          </form>
+        </li>
+        <li role="separator" class="divider"></li>
+        <li>
+          <a ng-click="$ctrl.removeChart(chart)" translate>multicharts.chart.DELETE_LABEL</a>
+        </li>
+      </ul>
+    </div>
+
+  </div>
+
+  <div class="card-pf-body" ng-if="$ctrl.chartConfig">
+    <pf-line-chart id="chart-{{$ctrl.chart}}" config="$ctrl.chartConfig"
+                                        chart-data="$ctrl.chartData"
+                                        show-x-axis="true"
+                                        show-y-axis="true"
+                                        ></pf-line-chart>
+
+
+  </div>
+
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/chart/en.locale.yaml	Wed Aug 30 14:21:56 2017 -0400
@@ -0,0 +1,23 @@
+multicharts:
+
+  chart:
+
+    X_AXIS_LABEL: timestamp
+    X_AXIS_DATA_TYPE: timestamp
+    DELETE_LABEL: Delete
+
+    refresh:
+      LABEL: Refresh Rate
+      DISABLED: Disabled
+      SECONDS: '{SECONDS, plural, =0{0 Seconds} one{1 Second} other{# Seconds}}{DEFAULT, select, true{ (Default)} other{}}'
+      MINUTES: '{MINUTES, plural, =0{0 Minutes} one{1 Minute} other{# Minutes}}{DEFAULT, select, true{ (Default)} other{}}'
+
+    dataAge:
+      LABEL: Max Data Age
+      SECONDS: '{SECONDS, plural, =0{0 Seconds} one{1 Second} other{# Seconds}}{DEFAULT, select, true{ (Default)} other{}}'
+      MINUTES: '{MINUTES, plural, =0{0 Minutes} one{1 Minute} other{# Minutes}}{DEFAULT, select, true{ (Default)} other{}}'
+      HOURS: '{HOURS, plural, =0{0 Hours} one{1 Hour} other{# Hours}}{DEFAULT, select, true{ (Default)} other{}}'
+
+    rename:
+      PLACEHOLDER: '@:multicharts.form.PLACEHOLDER'
+      SUBMIT_LABEL: Rename Chart
--- a/src/app/components/multichart/en.locale.yaml	Thu Aug 31 08:40:56 2017 -0400
+++ b/src/app/components/multichart/en.locale.yaml	Wed Aug 30 14:21:56 2017 -0400
@@ -8,24 +8,3 @@
     NAME: Name
     PLACEHOLDER: Enter new chart name
     SUBMIT_LABEL: Create Chart
-
-  refresh:
-    DISABLED: Disabled
-    SECONDS: '{SECONDS, plural, =0{0 Seconds} one{1 Second} other{# Seconds}}{DEFAULT, select, true{ (Default)} other{}}'
-    MINUTES: '{MINUTES, plural, =0{0 Minutes} one{1 Minute} other{# Minutes}}{DEFAULT, select, true{ (Default)} other{}}'
-
-  dataAge:
-    SECONDS: '{SECONDS, plural, =0{0 Seconds} one{1 Second} other{# Seconds}}{DEFAULT, select, true{ (Default)} other{}}'
-    MINUTES: '{MINUTES, plural, =0{0 Minutes} one{1 Minute} other{# Minutes}}{DEFAULT, select, true{ (Default)} other{}}'
-    HOURS: '{HOURS, plural, =0{0 Hours} one{1 Hour} other{# Hours}}{DEFAULT, select, true{ (Default)} other{}}'
-
-  chart:
-    X_AXIS_LABEL: timestamp
-    X_AXIS_DATA_TYPE: timestamp
-    REFRESH_RATE_LABEL: Refresh Rate
-    MAX_DATA_AGE_LABEL: Max Data Age
-    DELETE_LABEL: Delete
-
-    rename:
-      PLACEHOLDER: '@:multicharts.form.PLACEHOLDER'
-      SUBMIT_LABEL: Rename Chart
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/multichart/multichart.component.js	Wed Aug 30 14:21:56 2017 -0400
@@ -0,0 +1,40 @@
+/**
+ * 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 chartComponent from './chart/chart.component.js';
+
+export default angular
+  .module('multichartComponent', [
+    controller,
+    chartComponent
+  ])
+  .component('multichart', {
+    controller: 'MultichartController',
+    template: require('./multichart.html')
+  })
+  .name;
--- a/src/app/components/multichart/multichart.controller.js	Thu Aug 31 08:40:56 2017 -0400
+++ b/src/app/components/multichart/multichart.controller.js	Wed Aug 30 14:21:56 2017 -0400
@@ -34,8 +34,8 @@
     this.svc = multichartService;
     this.showErr = false;
 
-    $translate('multicharts.ERR_TITLE').then(s => this.scope.errTitle = s);
-    $translate('multicharts.ERR_MESSAGE').then(s => this.scope.errMessage = s);
+    $translate('multicharts.ERR_TITLE').then(s => this.errTitle = s);
+    $translate('multicharts.ERR_MESSAGE').then(s => this.errMessage = s);
   }
 
   createChart (chartName) {
@@ -49,7 +49,7 @@
     }
     this.showErr = false;
     this.svc.addChart(chartName);
-    this.scope.newChartName = '';
+    this.newChartName = '';
     let form = this.scope.newChartForm;
     form.$setPristine();
     form.$setUntouched();
--- a/src/app/components/multichart/multichart.controller.spec.js	Thu Aug 31 08:40:56 2017 -0400
+++ b/src/app/components/multichart/multichart.controller.spec.js	Wed Aug 30 14:21:56 2017 -0400
@@ -79,7 +79,7 @@
       ctrl.createChart('foo');
       svc.addChart.should.be.calledOnce();
       svc.addChart.should.be.calledWith('foo');
-      scope.newChartName.should.equal('');
+      ctrl.newChartName.should.equal('');
       scope.newChartForm.$setUntouched.should.be.calledOnce();
       scope.newChartForm.$setPristine.should.be.calledOnce();
     });
--- a/src/app/components/multichart/multichart.html	Thu Aug 31 08:40:56 2017 -0400
+++ b/src/app/components/multichart/multichart.html	Wed Aug 30 14:21:56 2017 -0400
@@ -4,97 +4,29 @@
     <li><a ui-serf="multichart" translate>multicharts.BREADCRUMB</a></li>
   </ol>
 
-  <customizable-error-message ng-show="ctrl.showErr" dismissible="true" err-title="errTitle"
-                                                    err-message="errMessage"/>
-
-  <div class="row">
-    <div class="col-xs-12 col-md-4">
-      <form name="newChartForm" class="input-group" ng-submit="ctrl.createChart(newChartName)">
-        <span class="input-group-addon" translate>multicharts.form.NAME</span>
-        <input type="text" class="form-control" translate-attr="{ placeholder: 'multicharts.form.PLACEHOLDER' }" ng-model="newChartName"/>
-        <input type="submit" translate-attr="{ value: 'multicharts.form.SUBMIT_LABEL' }"/>
-      </form>
-    </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>
-            </div>
-
-            <div class="row" style="margin-top:2vh">
-
-              <!-- Metric Controls: Refresh Rate -->
-              <div class="col-xs-12 col-md-3">
-                <label for="refreshCombo" class="label label-info" translate>multicharts.chart.REFRESH_RATE_LABEL</label>
-                <select name="refreshCombo" class="combobox form-control" ng-model="refreshRate">
-                  <option value="-1" translate>multicharts.refresh.DISABLED</option>
-                  <option value="1000" translate="multicharts.refresh.SECONDS" translate-values="{ SECONDS: 1 }" translate-interpolation="messageformat"></option>
-                  <option value="2000" selected translate="multicharts.refresh.SECONDS" translate-values="{ SECONDS: 2, DEFAULT: true }" translate-interpolation="messageformat"></option>
-                  <option value="5000" translate="multicharts.refresh.SECONDS" translate-values="{ SECONDS: 5 }" translate-interpolation="messageformat"></option>
-                  <option value="10000" translate="multicharts.refresh.SECONDS" translate-values="{ SECONDS: 10 }" translate-interpolation="messageformat"></option>
-                  <option value="30000" translate="multicharts.refresh.SECONDS" translate-values="{ SECONDS: 30 }" translate-interpolation="messageformat"></option>
-                  <option value="60000" translate="multicharts.refresh.MINUTES" translate-values="{ MINUTES: 1 }" translate-interpolation="messageformat"></option>
-                  <option value="300000" translate="multicharts.refresh.MINUTES" translate-values="{ MINUTES: 5 }" translate-interpolation="messageformat"></option>
-                  <option value="600000" translate="multicharts.refresh.MINUTES" translate-values="{ MINUTES: 10 }" translate-interpolation="messageformat"></option>
-                </select>
-              </div>
+  <customizable-error-message ng-show="$ctrl.showErr" dismissible="true" err-title="$ctrl.errTitle"
+                                                                         err-message="$ctrl.errMessage"/>
 
-              <!-- Metric Controls: Max Data Age -->
-              <div class="col-xs-12 col-md-3">
-                <label for="dataAgeCombo" class="label label-info" translate>multicharts.chart.MAX_DATA_AGE_LABEL</label>
-                <select name="dataAgeCombo" class="combobox form-control" ng-model="dataAgeLimit">
-                  <option value="10000" translate="multicharts.dataAge.SECONDS" translate-values="{ SECONDS: 10 }" translate-interpolation="messageformat"></option>
-                  <option value="30000" translate="multicharts.dataAge.SECONDS" translate-values="{ SECONDS: 30 }" translate-interpolation="messageformat"></option>
-                  <option value="60000" selected translate="multicharts.dataAge.MINUTES" translate-values="{ MINUTES: 1, DEFAULT: true}" translate-interpolation="messageformat"></option>
-                  <option value="300000" translate="multicharts.dataAge.MINUTES" translate-values="{ MINUTES: 5 }" translate-interpolation="messageformat"></option>
-                  <option value="600000" translate="multicharts.dataAge.MINUTES" translate-values="{ MINUTES: 10 }" translate-interpolation="messageformat"></option>
-                  <option value="900000" translate="multicharts.dataAge.MINUTES" translate-values="{ MINUTES: 15 }" translate-interpolation="messageformat"></option>
-                  <option value="1800000" translate="multicharts.dataAge.MINUTES" translate-values="{ MINUTES: 30 }" translate-interpolation="messageformat"></option>
-                  <option value="3600000" translate="multicharts.dataAge.HOURS" translate-values="{ HOURS: 1 }" translate-interpolation="messageformat"></option>
-                </select>
-              </div>
+    <div class="row">
+      <div class="col-xs-12 col-md-4">
+        <form name="newChartForm" class="input-group" ng-submit="$ctrl.createChart(newChartName)">
+          <span class="input-group-addon" translate>multicharts.form.NAME</span>
+          <input type="text" class="form-control" translate-attr="{ placeholder: 'multicharts.form.PLACEHOLDER' }" ng-model="newChartName"/>
+          <input type="submit" translate-attr="{ value: 'multicharts.form.SUBMIT_LABEL' }"/>
+        </form>
+      </div>
+    </div>
 
-              <div class="dropdown dropdown-kebab-pf pull-right" style="margin-right:2vw">
-                <button class="btn btn-link dropdown-toggle" type="button" id="{{chart}}-kebab" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
-                  <span class="fa fa-ellipsis-v"></span>
-                </button>
-                <ul class="dropdown-menu" aria-labelledby="{{chart}}-kebab">
-                  <li>
-                    <form name="renameForm" class="input-group" ng-submit="chartCtrl.rename(chartRename)">
-                      <input type="text" class="form-control" translate-attr="{ placeholder: 'multicharts.chart.rename.PLACEHOLDER' }" ng-model="chartRename"/>
-                      <input type="submit" translate-attr="{ value: 'multicharts.chart.rename.SUBMIT_LABEL' }"/>
-                    </form>
-                  </li>
-                  <li role="separator" class="divider"></li>
-                  <li>
-                    <a ng-click="chartCtrl.removeChart(chart)" translate>multicharts.chart.DELETE_LABEL</a>
-                  </li>
-                </ul>
-              </div>
+    <div class="row row-cards-pf">
+      <div class="container-fluid container-cards-pf">
 
-            </div>
-
-            <div class="card-pf-body" ng-if="chartCtrl.chartConfig">
-              <pf-line-chart id="chart-{{chart}}" config="chartCtrl.chartConfig"
-                                                      chart-data="chartCtrl.chartData"
-                                                      show-x-axis="true"
-                                                      show-y-axis="true"
-                                                      ></pf-line-chart>
-
-
-            </div>
-
+        <div ng-repeat="chart in $ctrl.chartNames">
+          <div class="col-xs-12 col-md-10">
+            <multichart-chart></multichart-chart>
           </div>
         </div>
+
       </div>
-
     </div>
-  </div>
 
 </div>
--- a/src/app/components/multichart/multichart.module.js	Thu Aug 31 08:40:56 2017 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,36 +0,0 @@
-/**
- * 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;
--- a/src/app/components/multichart/multichart.routing.js	Thu Aug 31 08:40:56 2017 -0400
+++ b/src/app/components/multichart/multichart.routing.js	Wed Aug 30 14:21:56 2017 -0400
@@ -30,20 +30,13 @@
 
   $stateProvider.state('multichart', {
     url: '/multichart',
-    templateProvider: $q => {
-      'ngInject';
-      return $q(resolve =>
-        require.ensure([], () => resolve(require('./multichart.html'))
-        )
-      );
-    },
-    controller: 'MultichartController as ctrl',
+    component: 'multichart',
     resolve: {
-      loadMultichart: ($q, $ocLazyLoad) => {
+      lazyLoad: ($q, $ocLazyLoad) => {
         'ngInject';
         return $q(resolve => {
-          require.ensure(['./multichart.module.js'], () => {
-            let module = require('./multichart.module.js');
+          require.ensure(['./multichart.component.js'], () => {
+            let module = require('./multichart.component.js');
             $ocLazyLoad.load({ name: module.default });
             resolve(module);
           });
--- a/src/app/components/multichart/multichart.routing.spec.js	Thu Aug 31 08:40:56 2017 -0400
+++ b/src/app/components/multichart/multichart.routing.spec.js	Wed Aug 30 14:21:56 2017 -0400
@@ -55,24 +55,8 @@
       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];
+    it('resolve should load multichart component', done => {
+      let resolveFn = args[1].resolve.lazyLoad[2];
       resolveFn.should.be.a.Function();
       resolveFn(q, ocLazyLoad);
       q.should.be.calledOnce();
@@ -81,8 +65,8 @@
       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'));
+        ocLazyLoad.load.should.be.calledWith({ name: require('./multichart.component.js').default});
+        val.should.equal(require('./multichart.component.js'));
         done();
       });
       deferred(resolve);