changeset 200:42e34f861a44

Extract system-memory into component Reviewed-by: jkang Review-thread: http://icedtea.classpath.org/pipermail/thermostat/2017-September/024928.html
author Andrew Azores <aazores@redhat.com>
date Wed, 13 Sep 2017 16:27:00 -0400
parents 0767a8183508
children 0dc8909dd90e
files src/app/components/system-info/en.locale.yaml src/app/components/system-info/system-info.html src/app/components/system-info/system-info.module.js src/app/components/system-info/system-info.service.js src/app/components/system-info/system-info.service.spec.js src/app/components/system-info/system-memory.controller.js src/app/components/system-info/system-memory.controller.spec.js src/app/components/system-info/system-memory/en.locale.yaml src/app/components/system-info/system-memory/system-memory.component.js src/app/components/system-info/system-memory/system-memory.controller.js src/app/components/system-info/system-memory/system-memory.controller.spec.js src/app/components/system-info/system-memory/system-memory.html src/app/components/system-info/system-memory/system-memory.service.js src/app/components/system-info/system-memory/system-memory.service.spec.js
diffstat 14 files changed, 939 insertions(+), 791 deletions(-) [+]
line wrap: on
line diff
--- a/src/app/components/system-info/en.locale.yaml	Mon Sep 11 11:26:31 2017 -0400
+++ b/src/app/components/system-info/en.locale.yaml	Wed Sep 13 16:27:00 2017 -0400
@@ -18,30 +18,3 @@
 
   networkTable:
     HEADER: Network Interfaces
-
-  refresh:
-    DISABLED: Disabled
-    SECONDS: '{SECONDS, plural, =0{0 Seconds} one{1 Second} other{# Seconds}}{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{}}'
-
-  systemMemory:
-    DONUT_CHART_LABEL: Memory Usage
-    LINE_CHART_LABEL: Memory
-    REFRESH_RATE_LABEL: Refresh Rate
-    MAX_DATA_AGE_LABEL: Max Data Age
-
-    X_AXIS_LABEL: Time
-    Y_AXIS_LABEL: Size (MiB)
-    TOOLTIP_FMT: '{{size}} MiB'
-
-    xAxisTypes:
-      TIMESTAMP: timestamp
-      TOTAL: Total Memory
-      FREE: Free Memory
-      USED: Used Memory
-      SWAP_TOTAL: Total Swap
-      SWAP_FREE: Free Swap
-      BUFFERS: Buffers
--- a/src/app/components/system-info/system-info.html	Mon Sep 11 11:26:31 2017 -0400
+++ b/src/app/components/system-info/system-info.html	Wed Sep 13 16:27:00 2017 -0400
@@ -67,60 +67,8 @@
     <div class="row row-cards-pf">
       <div class="container container-cards-pf">
         <system-cpu system-id="ctrl.systemId"></system-cpu>
-        <div class="system-memory-charts" ng-controller="SystemMemoryController as ctrl">
-          <!-- System-Memory Donut Chart -->
-          <div class="col-xs-12 col-sm-6 col-md-6">
-            <div class="card-pf card-pf-view">
-              <div class="card-pf-heading">
-                <label class="card-pf-title" translate>systemInfo.systemMemory.DONUT_CHART_LABEL</label>
-                <mc-add class="pull-right" svc-name="{{systemId}}-memory" get-fn="ctrl.multichartFn()"></mc-add>
-              </div>
-              <div class="card-pf-body">
-                <pf-donut-pct-chart id="systemMemoryDonutChart" config="ctrl.donutConfig" data="ctrl.donutData"></pf-donut-pct-chart>
-              </div>
-            </div>
-          </div>
-          <!-- System-Memory Line Chart -->
-          <div class="col-xs-12 col-md-12">
-            <div class="card-pf card-pf-view">
-              <div class="card-pf-heading">
-                <label class="card-pf-title" translate>systemInfo.systemMemory.LINE_CHART_LABEL</label>
-              </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" translate>systemInfo.systemMemory.REFRESH_RATE_LABEL</label>
-                  <select name="refreshCombo" class="combobox form-control" ng-model="refreshRate">
-                    <option value="-1" translate>systemInfo.refresh.DISABLED</option>
-                    <option value="1000" selected translate="systemInfo.refresh.SECONDS" translate-values="{ SECONDS: 1, DEFAULT: true }" translate-interpolation="messageformat"></option>
-                    <option value="2000" translate="systemInfo.refresh.SECONDS" translate-values="{ SECONDS: 2 }" translate-interpolation="messageformat"></option>
-                    <option value="5000" translate="systemInfo.refresh.SECONDS" translate-values="{ SECONDS: 5 }" translate-interpolation="messageformat"></option>
-                    <option value="10000" translate="systemInfo.refresh.SECONDS" translate-values="{ SECONDS: 10 }" translate-interpolation="messageformat"></option>
-                    <option value="30000" translate="systemInfo.refresh.SECONDS" translate-values="{ SECONDS: 30 }" 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>systemInfo.systemMemory.MAX_DATA_AGE_LABEL</label>
-                  <select name="dataAgeCombo" class="combobox form-control" ng-model="dataAgeLimit">
-                    <option value="10000" translate="systemInfo.dataAge.SECONDS" translate-values="{ SECONDS: 10 }" translate-interpolation="messageformat"></option>
-                    <option value="30000" selected translate="systemInfo.dataAge.SECONDS" translate-values="{ SECONDS: 30, DEFAULT: true }" translate-interpolation="messageformat"></option>
-                    <option value="60000" translate="systemInfo.dataAge.MINUTES" translate-values="{ MINUTES: 1 }" translate-interpolation="messageformat"></option>
-                    <option value="300000" translate="systemInfo.dataAge.MINUTES" translate-values="{ MINUTES: 5 }" translate-interpolation="messageformat"></option>
-                    <option value="900000" translate="systemInfo.dataAge.MINUTES" translate-values="{ MINUTES: 15 }" translate-interpolation="messageformat"></option>
-                  </select>
-                </div>
-              </div>
-              <!-- Line Chart -->
-              <div class="card-pf-body">
-                <pf-line-chart id="systemMemoryLineChart" config="ctrl.lineConfig" chart-data="ctrl.lineData" set-area-chart="false" show-x-axis="true" show-y-axis="true"></pf-line-chart>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
+        <system-memory system-id="ctrl.systemId"></system-memory>
       </div>
-
     </div>
 
   </div>
--- a/src/app/components/system-info/system-info.module.js	Mon Sep 11 11:26:31 2017 -0400
+++ b/src/app/components/system-info/system-info.module.js	Wed Sep 13 16:27:00 2017 -0400
@@ -27,7 +27,7 @@
 
 import SystemInfocontroller from './system-info.controller.js';
 import systemCpu from './system-cpu/system-cpu.component.js';
-import SystemMemoryController from './system-memory.controller.js';
+import systemMemory from './system-memory/system-memory.component.js';
 import systemNetwork from './system-network/system-network.component.js';
 import service from './system-info.service.js';
 import components from 'shared/components/components.module.js';
@@ -36,7 +36,7 @@
   .module('systemInfo', [
     SystemInfocontroller,
     systemCpu,
-    SystemMemoryController,
+    systemMemory,
     systemNetwork,
     service,
     components
--- a/src/app/components/system-info/system-info.service.js	Mon Sep 11 11:26:31 2017 -0400
+++ b/src/app/components/system-info/system-info.service.js	Wed Sep 13 16:27:00 2017 -0400
@@ -45,13 +45,6 @@
     });
   }
 
-  getMemoryInfo (systemId) {
-    return this.http.get(urlJoin(this.gatewayUrl, 'system-memory', '0.0.1', 'systems', systemId), {
-      params: {
-        sort: '-timeStamp'
-      }
-    });
-  }
 }
 
 export default angular
--- a/src/app/components/system-info/system-info.service.spec.js	Mon Sep 11 11:26:31 2017 -0400
+++ b/src/app/components/system-info/system-info.service.spec.js	Wed Sep 13 16:27:00 2017 -0400
@@ -72,22 +72,4 @@
     });
   });
 
-  describe('getMemoryInfo(systemId)', () => {
-    it('should resolve mock data', done => {
-      let expected = {
-        total: 16384,
-        used: 9001
-      };
-      httpBackend.when('GET', 'http://example.com:1234/system-memory/0.0.1/systems/foo-systemId?sort=-timeStamp')
-        .respond(expected);
-      svc.getMemoryInfo('foo-systemId').then(res => {
-        res.data.should.deepEqual(expected);
-        done();
-      });
-      httpBackend.expectGET('http://example.com:1234/system-memory/0.0.1/systems/foo-systemId?sort=-timeStamp');
-      httpBackend.flush();
-      scope.$apply();
-    });
-  });
-
 });
--- a/src/app/components/system-info/system-memory.controller.js	Mon Sep 11 11:26:31 2017 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,231 +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 filters from 'shared/filters/filters.module.js';
-import service from './system-info.service.js';
-
-class SystemMemoryController {
-  constructor (systemInfoService, $scope, $interval, pfUtils,
-    dateFilter, DATE_FORMAT, $q, $translate) {
-    'ngInject';
-    this.svc = systemInfoService;
-    this.scope = $scope;
-    this.interval = $interval;
-    this.dateFilter = dateFilter;
-    this.dateFormat = DATE_FORMAT;
-    this.q = $q;
-    this.translate = $translate;
-
-    this.scope.refreshRate = '1000';
-    this.scope.dataAgeLimit = '30000';
-
-    this.scope.$watch('refreshRate', newRefreshRate => this.setRefreshRate(newRefreshRate));
-    this.scope.$watch('dataAgeLimit', () => this.trimData());
-    this.scope.$on('$destroy', () => this.stopUpdating());
-
-    this.setupDonutChart();
-    this.setupLineChart(pfUtils);
-
-    this.update();
-  }
-
-  setupDonutChart () {
-    this.donutConfig = {
-      chartId: 'systemMemoryDonutChart',
-      units: '%'
-    };
-
-    this.donutData = {
-      used: 0,
-      total: 100
-    };
-  }
-
-  setupLineChart (pfUtils) {
-    this.translate([
-      'systemInfo.systemMemory.X_AXIS_LABEL',
-      'systemInfo.systemMemory.Y_AXIS_LABEL'
-    ]).then(translations => {
-      this.lineConfig = {
-        chartId: 'systemMemoryLineChart',
-        color: {
-          pattern: [
-            pfUtils.colorPalette.red,    // total memory
-            pfUtils.colorPalette.blue,   // free memory
-            pfUtils.colorPalette.orange, // used memory
-            pfUtils.colorPalette.gold,   // total swap
-            pfUtils.colorPalette.purple, // free swap
-            pfUtils.colorPalette.green   // buffers
-          ]
-        },
-        grid: { y: {show: true} },
-        point: { r: 2 },
-        legend : { 'show': true },
-        tooltip: {
-          format: {
-            // TODO: this should be localized too, but c3 doesn't allow for the tooltip
-            // formatter to be a promise, only a function, and angular-translate only
-            // returns promises
-            value: memoryValue => { return memoryValue + ' MiB'; }
-          }
-        },
-        transition: { duration: 50 },
-        axis: {
-          x: {
-            type: 'timeseries',
-            label: {
-              text: translations['systemInfo.systemMemory.X_AXIS_LABEL'],
-              position: 'outer-center'
-            },
-            tick : {
-              format: timestamp => this.dateFilter(timestamp, this.dateFormat.time.medium),
-              count: 5,
-              fit: false
-            }
-          },
-          y: {
-            min: 0,
-            padding: 0,
-            tick: 10,
-            label: {
-              text: translations['systemInfo.systemMemory.Y_AXIS_LABEL'],
-              position: 'outer-middle'
-            }
-          }
-        }
-      };
-    });
-
-    this.translate([
-      'systemInfo.systemMemory.xAxisTypes.TIMESTAMP',
-      'systemInfo.systemMemory.xAxisTypes.TOTAL',
-      'systemInfo.systemMemory.xAxisTypes.FREE',
-      'systemInfo.systemMemory.xAxisTypes.USED',
-      'systemInfo.systemMemory.xAxisTypes.SWAP_TOTAL',
-      'systemInfo.systemMemory.xAxisTypes.SWAP_FREE',
-      'systemInfo.systemMemory.xAxisTypes.BUFFERS'
-    ]).then(translations => {
-      this.lineData = {
-        xData: [translations['systemInfo.systemMemory.xAxisTypes.TIMESTAMP']],
-        yData0: [translations['systemInfo.systemMemory.xAxisTypes.TOTAL']],
-        yData1: [translations['systemInfo.systemMemory.xAxisTypes.FREE']],
-        yData2: [translations['systemInfo.systemMemory.xAxisTypes.USED']],
-        yData3: [translations['systemInfo.systemMemory.xAxisTypes.SWAP_TOTAL']],
-        yData4: [translations['systemInfo.systemMemory.xAxisTypes.SWAP_FREE']],
-        yData5: [translations['systemInfo.systemMemory.xAxisTypes.BUFFERS']]
-      };
-    });
-  }
-
-  processData (resp) {
-    for (let i = resp.data.response.length - 1; i >= 0; i--) {
-      let data = resp.data.response[i];
-      let free = data.free;
-      let total = data.total;
-      let used = total - free;
-      let usage = Math.round((used) / total * 100);
-
-      // update the memory time series chart
-      this.lineConfig.axis.y.max = total;
-      this.lineData.xData.push(data.timeStamp);
-      this.lineData.yData0.push(total);
-      this.lineData.yData1.push(free);
-      this.lineData.yData2.push(used);
-      this.lineData.yData3.push(data.swapTotal);
-      this.lineData.yData4.push(data.swapFree);
-      this.lineData.yData5.push(data.buffers);
-      this.trimData();
-
-      // update the memory donut chart
-      this.donutData.used = usage;
-    }
-  }
-
-  update () {
-    this.svc.getMemoryInfo(this.scope.systemId)
-      .then(response => this.processData(response), angular.noop);
-  }
-
-  setRefreshRate (refreshRate) {
-    this.stopUpdating();
-    if (refreshRate > 0) {
-      this.refresh = this.interval(() => this.update(), refreshRate);
-      this.update();
-    }
-  }
-
-  stopUpdating () {
-    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) {
-      let oldest = this.lineData.xData[1];
-      if (angular.isDefined(oldest) && oldest < oldestLimit) {
-        this.lineData.xData.splice(1, 1);
-        this.lineData.yData0.splice(1, 1);
-        this.lineData.yData1.splice(1, 1);
-        this.lineData.yData2.splice(1, 1);
-        this.lineData.yData3.splice(1, 1);
-        this.lineData.yData4.splice(1, 1);
-        this.lineData.yData5.splice(1, 1);
-      } else {
-        break;
-      }
-    }
-  }
-
-  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
-  .module('systemMemory.controller', [
-    'patternfly',
-    'patternfly.charts',
-    filters,
-    service
-  ])
-  .controller('SystemMemoryController', SystemMemoryController)
-  .name;
--- a/src/app/components/system-info/system-memory.controller.spec.js	Mon Sep 11 11:26:31 2017 -0400
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,453 +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.
- */
-
-describe('SystemMemoryController', () => {
-
-  beforeEach(angular.mock.module('systemMemory.controller'));
-
-  let service, scope, interval, memoryPromise, controller,
-    dateFilterStub, dateFormatSpy, translate;
-
-  beforeEach(inject($controller => {
-    'ngInject';
-
-    let systemPromise = sinon.spy();
-    let cpuPromise = sinon.spy();
-    memoryPromise = {
-      then: sinon.spy()
-    };
-    service = {
-      systemPromise: systemPromise,
-      cpuPromise: cpuPromise,
-      memoryPromise: memoryPromise,
-      getSystemInfo: sinon.stub().returns({ then: systemPromise }),
-      getCpuInfo: sinon.stub().returns({ then: cpuPromise }),
-      getMemoryInfo: sinon.stub().returns(memoryPromise)
-    };
-
-    dateFilterStub = sinon.stub().returns('mockDate');
-    dateFormatSpy = {
-      time: {
-        medium: sinon.spy()
-      }
-    };
-    scope = {
-      $on: sinon.spy(),
-      $watch: sinon.spy()
-    };
-
-    interval = sinon.stub().returns('interval-sentinel');
-    interval.cancel = sinon.stub().returns(interval.sentinel);
-
-    translate = sinon.stub().returns({
-      then: sinon.stub().yields({
-        'systemInfo.systemMemory.X_AXIS_LABEL': 'Time',
-        'systemInfo.systemMemory.Y_AXIS_LABEL': 'Size (MiB)',
-        'systemInfo.systemMemory.xAxisTypes.TIMESTAMP': 'timestamp',
-        'systemInfo.systemMemory.xAxisTypes.TOTAL': 'Total Memory',
-        'systemInfo.systemMemory.xAxisTypes.FREE': 'Free Memory',
-        'systemInfo.systemMemory.xAxisTypes.USED': 'Used Memory',
-        'systemInfo.systemMemory.xAxisTypes.SWAP_TOTAL': 'Total Swap',
-        'systemInfo.systemMemory.xAxisTypes.SWAP_FREE': 'Free Swap',
-        'systemInfo.systemMemory.xAxisTypes.BUFFERS': 'Buffers',
-      })
-    });
-
-    controller = $controller('SystemMemoryController', {
-      systemId: 'foo-systemId',
-      systemInfoService: service,
-      $scope: scope,
-      $interval: interval,
-      dateFilter: dateFilterStub,
-      DATE_FORMAT: dateFormatSpy,
-      $translate: translate
-    });
-
-  }));
-
-  it('should exist', () => {
-    should.exist(controller);
-    should.exist(service);
-  });
-
-  it('should update on initialization', () => {
-    service.getMemoryInfo.should.be.called();
-  });
-
-  it('should call to service on update', () => {
-    controller.update();
-    service.getMemoryInfo.should.be.called();
-    memoryPromise.then.should.be.calledWith(sinon.match.func);
-    let successHandler = memoryPromise.then.args[1][0];
-    successHandler({
-      data: {
-        response: {
-          systemId: 'foo-systemId',
-          agentId: 'mock-agentId',
-          timeStamp: Date.now(),
-          total: 16384,
-          free: 0,
-          buffers: 1,
-          cached: 2,
-          swapTotal: 3,
-          swapFree: 4,
-          commitLimit: 0
-        }
-      }
-    });
-    let errorHandler = memoryPromise.then.args[1][1];
-    errorHandler.should.equal(angular.noop);
-    errorHandler();
-  });
-
-  it('should set initial data objects', () => {
-    controller.should.have.ownProperty('donutData');
-    controller.donutData.should.deepEqual({
-      used: 0,
-      total: 100
-    });
-    controller.should.have.ownProperty('lineData');
-    controller.lineData.should.deepEqual({
-      xData: ['timestamp'],
-      yData0: ['Total Memory'],
-      yData1: ['Free Memory'],
-      yData2: ['Used Memory'],
-      yData3: ['Total Swap'],
-      yData4: ['Free Swap'],
-      yData5: ['Buffers']
-    });
-  });
-
-  it('should set interval on setting refresh rate', () => {
-    interval.should.not.be.called();
-    interval.cancel.should.not.be.called();
-    controller.setRefreshRate(1);
-    interval.should.be.called();
-    interval.cancel.should.not.be.called();
-  });
-
-  it('should disable when setRefreshRate is called with a non-positive value', () => {
-    interval.cancel.should.not.be.called();
-    controller.setRefreshRate.should.not.be.called();
-    controller.update.should.not.be.called();
-
-    controller.setRefreshRate(1);
-
-    interval.cancel.should.not.be.called();
-    controller.should.have.ownProperty('refresh');
-
-    controller.setRefreshRate(-1);
-
-    interval.cancel.should.be.calledOnce();
-    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];
-    refreshFn.should.be.a.Function();
-    refreshFn(1);
-    let intervalFn = interval.args[0][0];
-    let callCount = service.getMemoryInfo.callCount;
-    intervalFn();
-    service.getMemoryInfo.callCount.should.equal(callCount + 1);
-
-  });
-
-  it ('should call trimData() on dataAgeLimit change', () => {
-    scope.$watch.should.be.calledWith(sinon.match('dataAgeLimit'));
-    scope.$watch.args[1][0].should.equal('dataAgeLimit');
-    let watchFn = scope.$watch.args[1][1];
-    watchFn.should.be.a.Function();
-    controller.trimData = sinon.spy();
-    let callCount = controller.trimData.callCount;
-    watchFn();
-    controller.trimData.callCount.should.equal(callCount + 1);
-  });
-
-  describe('chart configs', () => {
-    it('should set an initial config object', () => {
-      controller.should.have.ownProperty('donutConfig');
-      controller.should.have.ownProperty('lineConfig');
-    });
-
-    it('should use dateFilter with DATE_FORMAT.time.medium to format x ticks', () => {
-      let fn = controller.lineConfig.axis.x.tick.format;
-      fn.should.be.a.Function();
-      fn('fooTimestamp').should.equal('mockDate');
-      dateFilterStub.should.be.calledWith('fooTimestamp', dateFormatSpy.time.medium);
-    });
-
-
-    it('line chart should set a custom tooltip', () => {
-      let tooltipFormat = controller.lineConfig.tooltip.format;
-      tooltipFormat.should.have.ownProperty('value');
-      tooltipFormat.value.should.be.a.Function();
-      tooltipFormat.value(100).should.equal('100 MiB');
-    });
-  });
-
-  describe('processData', () => {
-    it('should process singleton service results', () => {
-      controller.donutData.should.deepEqual({
-        used: 0,
-        total: 100
-      });
-      controller.lineData.should.deepEqual({
-        xData: ['timestamp'],
-        yData0: ['Total Memory'],
-        yData1: ['Free Memory'],
-        yData2: ['Used Memory'],
-        yData3: ['Total Swap'],
-        yData4: ['Free Swap'],
-        yData5: ['Buffers']
-      });
-      let timestamp = Date.now();
-      controller.processData({
-        data: {
-          response: [
-            {
-              systemId: 'foo-systemId',
-              agentId: 'mock-agentId',
-              timeStamp: timestamp,
-              total: 16384,
-              free: 0,
-              buffers: 1,
-              cached: 2,
-              swapTotal: 3,
-              swapFree: 4,
-              commitLimit: 0
-            }
-          ]
-        }
-      });
-      controller.donutData.should.deepEqual({
-        used: 100,
-        total: 100
-      });
-      controller.lineData.should.deepEqual({
-        xData: ['timestamp', timestamp],
-        yData0: ['Total Memory', 16384],
-        yData1: ['Free Memory', 0],
-        yData2: ['Used Memory', 16384],
-        yData3: ['Total Swap', 3],
-        yData4: ['Free Swap', 4],
-        yData5: ['Buffers', 1]
-      });
-    });
-
-    it('should process multiple service results', () => {
-      controller.donutData.should.deepEqual({
-        used: 0,
-        total: 100
-      });
-      controller.lineData.should.deepEqual({
-        xData: ['timestamp'],
-        yData0: ['Total Memory'],
-        yData1: ['Free Memory'],
-        yData2: ['Used Memory'],
-        yData3: ['Total Swap'],
-        yData4: ['Free Swap'],
-        yData5: ['Buffers']
-      });
-      let timestampA = Date.now();
-      let timestampB = Date.now() - 1000;
-      controller.processData({
-        data: {
-          response: [
-            {
-              systemId: 'foo-systemId',
-              agentId: 'mock-agentId',
-              timeStamp: timestampA,
-              total: 16384,
-              free: 0,
-              buffers: 0,
-              cached: 0,
-              swapTotal: 0,
-              swapFree: 0,
-              commitLimit: 0
-            },
-            {
-              systemId: 'foo-systemId',
-              agentId: 'mock-agentId',
-              timeStamp: timestampB,
-              total: 16384,
-              free: 0,
-              buffers: 0,
-              cached: 0,
-              swapTotal: 0,
-              swapFree: 0,
-              commitLimit: 0
-            }
-          ]
-        }
-      });
-      controller.lineData.xData.length.should.equal(3);
-      controller.lineData.xData[1].should.equal(timestampB);
-      controller.lineData.xData[2].should.equal(timestampA);
-    });
-
-    it('should append new data to line chart data object', () => {
-      let timestampA = Date.now();
-      let timestampB = Date.now() + 1000;
-      controller.processData({
-        data: {
-          response: [
-            {
-              systemId: 'foo-systemId',
-              agentId: 'mock-agentId',
-              timeStamp: timestampA,
-              total: 16384,
-              free: 0,
-              buffers: 0,
-              cached: 0,
-              swapTotal: 0,
-              swapFree: 0,
-              commitLimit: 0
-            }
-          ]
-        }
-      });
-      controller.processData({
-        data: {
-          response: [
-            {
-              systemId: 'foo-systemId',
-              agentId: 'mock-agentId',
-              timeStamp: timestampB,
-              total: 16384,
-              free: 0,
-              buffers: 0,
-              cached: 0,
-              swapTotal: 0,
-              swapFree: 0,
-              commitLimit: 0
-            }
-          ]
-        }
-      });
-      controller.lineData.xData.length.should.equal(3);
-      controller.lineData.xData[1].should.equal(timestampA);
-      controller.lineData.xData[2].should.equal(timestampB);
-    });
-
-    it('should remove data that is older than dataAgeLimit', () => {
-      controller.dataAgeLimit = 30000;
-      let timestampA = Date.now() - 30001;
-      let timestampB = Date.now;
-      controller.processData({
-        data: {
-          response: [
-            {
-              systemId: 'foo-systemId',
-              agentId: 'mock-agentId',
-              timeStamp: timestampA,
-              total: 16384,
-              free: 0,
-              buffers: 0,
-              cached: 0,
-              swapTotal: 0,
-              swapFree: 0,
-              commitLimit: 0
-            }
-          ]
-        }
-      });
-      controller.processData({
-        data: {
-          response: [
-            {
-              systemId: 'foo-systemId',
-              agentId: 'mock-agentId',
-              timeStamp: timestampB,
-              total: 16384,
-              free: 0,
-              buffers: 0,
-              cached: 0,
-              swapTotal: 0,
-              swapFree: 0,
-              commitLimit: 0
-            }
-          ]
-        }
-      });
-      controller.lineData.xData.length.should.equal(2);
-      controller.lineData.xData[1].should.equal(timestampB);
-    });
-  });
-
-  describe('on destroy', () => {
-    it('should set an ondestroy handler', () => {
-      scope.$on.should.be.calledWith('$destroy', sinon.match.func);
-    });
-
-    it('should cancel refresh', () => {
-      controller.refresh = 'interval-sentinel';
-      let refreshFn = scope.$on.args[0][1];
-      refreshFn();
-      interval.cancel.should.be.calledWith('interval-sentinel');
-    });
-
-    it('should do nothing if refresh undefined', () => {
-      controller.refresh = undefined;
-      let refreshFn = scope.$on.args[0][1];
-      refreshFn();
-      interval.cancel.should.not.be.called();
-    });
-  });
-});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/system-info/system-memory/en.locale.yaml	Wed Sep 13 16:27:00 2017 -0400
@@ -0,0 +1,27 @@
+systemMemory:
+
+  refresh:
+    DISABLED: Disabled
+    SECONDS: '{SECONDS, plural, =0{0 Seconds} one{1 Second} other{# Seconds}}{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{}}'
+
+  DONUT_CHART_LABEL: Memory Usage
+  LINE_CHART_LABEL: Memory
+  REFRESH_RATE_LABEL: Refresh Rate
+  MAX_DATA_AGE_LABEL: Max Data Age
+
+  X_AXIS_LABEL: Time
+  Y_AXIS_LABEL: Size (MiB)
+  TOOLTIP_FMT: '{{size}} MiB'
+
+  xAxisTypes:
+    TIMESTAMP: timestamp
+    TOTAL: Total Memory
+    FREE: Free Memory
+    USED: Used Memory
+    SWAP_TOTAL: Total Swap
+    SWAP_FREE: Free Swap
+    BUFFERS: Buffers
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/system-info/system-memory/system-memory.component.js	Wed Sep 13 16:27:00 2017 -0400
@@ -0,0 +1,43 @@
+/**
+ * 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 './system-memory.controller.js';
+import service from './system-memory.service.js';
+
+export default angular
+  .module('systemMemory.component', [
+    controller,
+    service
+  ])
+  .component('systemMemory', {
+    bindings: {
+      systemId: '<'
+    },
+    controller: 'SystemMemoryController',
+    template: require('./system-memory.html')
+  })
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/system-info/system-memory/system-memory.controller.js	Wed Sep 13 16:27:00 2017 -0400
@@ -0,0 +1,250 @@
+/**
+ * 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 filters from 'shared/filters/filters.module.js';
+import service from './system-memory.service.js';
+
+class SystemMemoryController {
+  constructor (systemMemoryService, $interval, pfUtils,
+    dateFilter, DATE_FORMAT, $translate) {
+    'ngInject';
+    this._svc = systemMemoryService;
+    this._interval = $interval;
+    this._dateFilter = dateFilter;
+    this._dateFormat = DATE_FORMAT;
+    this._translate = $translate;
+
+    this._refreshRate = 1000;
+    this._dataAgeLimit = 30000;
+
+    this._setupDonutChart();
+    this._setupLineChart(pfUtils);
+  }
+
+  $onInit () {
+    this._start();
+  }
+
+  $onDestroy () {
+    this._stop();
+  }
+
+  _setupDonutChart () {
+    this.donutConfig = {
+      chartId: 'systemMemoryDonutChart',
+      units: '%'
+    };
+
+    this.donutData = {
+      used: 0,
+      total: 100
+    };
+  }
+
+  _setupLineChart (pfUtils) {
+    this._translate([
+      'systemMemory.X_AXIS_LABEL',
+      'systemMemory.Y_AXIS_LABEL'
+    ]).then(translations => {
+      this.lineConfig = {
+        chartId: 'systemMemoryLineChart',
+        color: {
+          pattern: [
+            pfUtils.colorPalette.red,    // total memory
+            pfUtils.colorPalette.blue,   // free memory
+            pfUtils.colorPalette.orange, // used memory
+            pfUtils.colorPalette.gold,   // total swap
+            pfUtils.colorPalette.purple, // free swap
+            pfUtils.colorPalette.green   // buffers
+          ]
+        },
+        grid: { y: {show: true} },
+        point: { r: 2 },
+        legend : { 'show': true },
+        tooltip: {
+          format: {
+            // TODO: this should be localized too, but c3 doesn't allow for the tooltip
+            // formatter to be a promise, only a function, and angular-translate only
+            // returns promises
+            value: memoryValue => { return memoryValue + ' MiB'; }
+          }
+        },
+        transition: { duration: 50 },
+        axis: {
+          x: {
+            type: 'timeseries',
+            label: {
+              text: translations['systemMemory.X_AXIS_LABEL'],
+              position: 'outer-center'
+            },
+            tick : {
+              format: timestamp => this._dateFilter(timestamp, this._dateFormat.time.medium),
+              count: 5,
+              fit: false
+            }
+          },
+          y: {
+            min: 0,
+            padding: 0,
+            tick: 10,
+            label: {
+              text: translations['systemMemory.Y_AXIS_LABEL'],
+              position: 'outer-middle'
+            }
+          }
+        }
+      };
+    });
+
+    this._translate([
+      'systemMemory.xAxisTypes.TIMESTAMP',
+      'systemMemory.xAxisTypes.TOTAL',
+      'systemMemory.xAxisTypes.FREE',
+      'systemMemory.xAxisTypes.USED',
+      'systemMemory.xAxisTypes.SWAP_TOTAL',
+      'systemMemory.xAxisTypes.SWAP_FREE',
+      'systemMemory.xAxisTypes.BUFFERS'
+    ]).then(translations => {
+      this.lineData = {
+        xData: [translations['systemMemory.xAxisTypes.TIMESTAMP']],
+        yData0: [translations['systemMemory.xAxisTypes.TOTAL']],
+        yData1: [translations['systemMemory.xAxisTypes.FREE']],
+        yData2: [translations['systemMemory.xAxisTypes.USED']],
+        yData3: [translations['systemMemory.xAxisTypes.SWAP_TOTAL']],
+        yData4: [translations['systemMemory.xAxisTypes.SWAP_FREE']],
+        yData5: [translations['systemMemory.xAxisTypes.BUFFERS']]
+      };
+    });
+  }
+
+  _processData (resp) {
+    for (let i = resp.data.response.length - 1; i >= 0; i--) {
+      let data = resp.data.response[i];
+      let free = data.free;
+      let total = data.total;
+      let used = total - free;
+      let usage = Math.round((used) / total * 100);
+
+      // update the memory time series chart
+      this.lineConfig.axis.y.max = total;
+      this.lineData.xData.push(data.timeStamp);
+      this.lineData.yData0.push(total);
+      this.lineData.yData1.push(free);
+      this.lineData.yData2.push(used);
+      this.lineData.yData3.push(data.swapTotal);
+      this.lineData.yData4.push(data.swapFree);
+      this.lineData.yData5.push(data.buffers);
+      this._trimData();
+
+      // update the memory donut chart
+      this.donutData.used = usage;
+    }
+  }
+
+  _start () {
+    this._stop();
+    this._update();
+    this._refresh = this._interval(() => this._update(), this._refreshRate);
+  }
+
+  _update () {
+    this._svc.getMemoryInfo(this.systemId)
+      .then(response => this._processData(response), angular.noop);
+  }
+
+  set refreshRate (refreshRate) {
+    this._stop();
+    this._refreshRate = parseInt(refreshRate);
+    if (refreshRate > 0) {
+      this._start();
+    }
+  }
+
+  get refreshRate () {
+    return this._refreshRate.toString();
+  }
+
+  set dataAgeLimit (val) {
+    this._dataAgeLimit = val;
+    this._trimData();
+  }
+
+  get dataAgeLimit () {
+    return this._dataAgeLimit.toString();
+  }
+
+  _stop () {
+    if (angular.isDefined(this._refresh)) {
+      this._interval.cancel(this._refresh);
+      delete this._refresh;
+    }
+  }
+
+  _trimData () {
+    let now = Date.now();
+    let oldestLimit = now - this._dataAgeLimit;
+    while (true) {
+      let oldest = this.lineData.xData[1];
+      if (angular.isDefined(oldest) && oldest < oldestLimit) {
+        this.lineData.xData.splice(1, 1);
+        this.lineData.yData0.splice(1, 1);
+        this.lineData.yData1.splice(1, 1);
+        this.lineData.yData2.splice(1, 1);
+        this.lineData.yData3.splice(1, 1);
+        this.lineData.yData4.splice(1, 1);
+        this.lineData.yData5.splice(1, 1);
+      } else {
+        break;
+      }
+    }
+  }
+
+  multichartFn () {
+    return new Promise(resolve =>
+      this._svc.getMemoryInfo(this.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
+  .module('systemMemory.controller', [
+    'patternfly',
+    'patternfly.charts',
+    filters,
+    service
+  ])
+  .controller('SystemMemoryController', SystemMemoryController)
+  .name;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/system-info/system-memory/system-memory.controller.spec.js	Wed Sep 13 16:27:00 2017 -0400
@@ -0,0 +1,429 @@
+/**
+ * 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 './system-memory.controller.js';
+
+describe('SystemMemoryController', () => {
+
+  beforeEach(angular.mock.module(controllerModule));
+
+  let service, interval, memoryPromise, controller,
+    dateFilterStub, dateFormatSpy, translate;
+
+  beforeEach(inject($controller => {
+    'ngInject';
+
+    memoryPromise = {
+      then: sinon.spy()
+    };
+    service = {
+      memoryPromise: memoryPromise,
+      getMemoryInfo: sinon.stub().returns(memoryPromise)
+    };
+
+    dateFilterStub = sinon.stub().returns('mockDate');
+    dateFormatSpy = {
+      time: {
+        medium: sinon.spy()
+      }
+    };
+
+    interval = sinon.stub().returns('interval-sentinel');
+    interval.cancel = sinon.stub().returns(interval.sentinel);
+
+    translate = sinon.stub().returns({
+      then: sinon.stub().yields({
+        'systemMemory.X_AXIS_LABEL': 'Time',
+        'systemMemory.Y_AXIS_LABEL': 'Size (MiB)',
+        'systemMemory.xAxisTypes.TIMESTAMP': 'timestamp',
+        'systemMemory.xAxisTypes.TOTAL': 'Total Memory',
+        'systemMemory.xAxisTypes.FREE': 'Free Memory',
+        'systemMemory.xAxisTypes.USED': 'Used Memory',
+        'systemMemory.xAxisTypes.SWAP_TOTAL': 'Total Swap',
+        'systemMemory.xAxisTypes.SWAP_FREE': 'Free Swap',
+        'systemMemory.xAxisTypes.BUFFERS': 'Buffers',
+      })
+    });
+
+    controller = $controller('SystemMemoryController', {
+      systemMemoryService: service,
+      $interval: interval,
+      dateFilter: dateFilterStub,
+      DATE_FORMAT: dateFormatSpy,
+      $translate: translate
+    });
+    controller.systemId = 'foo-systemId';
+    controller.$onInit();
+  }));
+
+  it('should exist', () => {
+    should.exist(controller);
+    should.exist(service);
+  });
+
+  it('should start on initialization', () => {
+    interval.should.be.calledOnce();
+  });
+
+  it('should call to service on update', () => {
+    controller._update();
+    service.getMemoryInfo.should.be.called();
+    memoryPromise.then.should.be.calledWith(sinon.match.func);
+    let successHandler = memoryPromise.then.args[0][0];
+    successHandler({
+      data: {
+        response: {
+          systemId: 'foo-systemId',
+          agentId: 'mock-agentId',
+          timeStamp: Date.now(),
+          total: 16384,
+          free: 0,
+          buffers: 1,
+          cached: 2,
+          swapTotal: 3,
+          swapFree: 4,
+          commitLimit: 0
+        }
+      }
+    });
+    let errorHandler = memoryPromise.then.args[0][1];
+    errorHandler.should.equal(angular.noop);
+  });
+
+  it('should set initial data objects', () => {
+    controller.should.have.ownProperty('donutData');
+    controller.donutData.should.deepEqual({
+      used: 0,
+      total: 100
+    });
+    controller.should.have.ownProperty('lineData');
+    controller.lineData.should.deepEqual({
+      xData: ['timestamp'],
+      yData0: ['Total Memory'],
+      yData1: ['Free Memory'],
+      yData2: ['Used Memory'],
+      yData3: ['Total Swap'],
+      yData4: ['Free Swap'],
+      yData5: ['Buffers']
+    });
+  });
+
+  it('should set interval on setting refresh rate', () => {
+    interval.should.be.calledOnce();
+    interval.cancel.should.not.be.called();
+    controller.refreshRate = 1;
+    interval.should.be.calledTwice();
+    interval.cancel.should.be.calledOnce();
+  });
+
+  it('should disable when refresh rate is set to non-positive value', () => {
+    interval.should.be.calledOnce();
+    interval.cancel.should.not.be.called();
+
+    controller.refreshRate = 1;
+
+    interval.should.be.calledTwice();
+    interval.cancel.should.be.calledOnce();
+
+    controller.refreshRate = -1;
+
+    interval.should.be.calledTwice();
+    interval.cancel.should.be.calledTwice();
+  });
+
+  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.args[1][0];
+        prom({
+          data: {
+            response: [
+              {
+                total: tup[0],
+                free: tup[1]
+              }
+            ]
+          }
+        });
+      });
+    });
+
+  });
+
+  it('should call _update() on refresh', () => {
+    controller.refreshRate = 1;
+    controller.refreshRate.should.equal('1');
+    let intervalFn = interval.args[0][0];
+    let callCount = service.getMemoryInfo.callCount;
+    intervalFn();
+    service.getMemoryInfo.callCount.should.equal(callCount + 1);
+  });
+
+  it ('should call trimData() on dataAgeLimit change', () => {
+    sinon.spy(controller, '_trimData');
+    controller._trimData.should.not.be.called();
+    controller.dataAgeLimit = 30000;
+    controller.dataAgeLimit.should.equal('30000');
+    controller._trimData.should.be.calledOnce();
+  });
+
+  describe('chart configs', () => {
+    it('should set an initial config object', () => {
+      controller.should.have.ownProperty('donutConfig');
+      controller.should.have.ownProperty('lineConfig');
+    });
+
+    it('should use dateFilter with DATE_FORMAT.time.medium to format x ticks', () => {
+      let fn = controller.lineConfig.axis.x.tick.format;
+      fn.should.be.a.Function();
+      fn('fooTimestamp').should.equal('mockDate');
+      dateFilterStub.should.be.calledWith('fooTimestamp', dateFormatSpy.time.medium);
+    });
+
+
+    it('line chart should set a custom tooltip', () => {
+      let tooltipFormat = controller.lineConfig.tooltip.format;
+      tooltipFormat.should.have.ownProperty('value');
+      tooltipFormat.value.should.be.a.Function();
+      tooltipFormat.value(100).should.equal('100 MiB');
+    });
+  });
+
+  describe('_processData', () => {
+    it('should process singleton service results', () => {
+      controller.donutData.should.deepEqual({
+        used: 0,
+        total: 100
+      });
+      controller.lineData.should.deepEqual({
+        xData: ['timestamp'],
+        yData0: ['Total Memory'],
+        yData1: ['Free Memory'],
+        yData2: ['Used Memory'],
+        yData3: ['Total Swap'],
+        yData4: ['Free Swap'],
+        yData5: ['Buffers']
+      });
+      let timestamp = Date.now();
+      controller._processData({
+        data: {
+          response: [
+            {
+              systemId: 'foo-systemId',
+              agentId: 'mock-agentId',
+              timeStamp: timestamp,
+              total: 16384,
+              free: 0,
+              buffers: 1,
+              cached: 2,
+              swapTotal: 3,
+              swapFree: 4,
+              commitLimit: 0
+            }
+          ]
+        }
+      });
+      controller.donutData.should.deepEqual({
+        used: 100,
+        total: 100
+      });
+      controller.lineData.should.deepEqual({
+        xData: ['timestamp', timestamp],
+        yData0: ['Total Memory', 16384],
+        yData1: ['Free Memory', 0],
+        yData2: ['Used Memory', 16384],
+        yData3: ['Total Swap', 3],
+        yData4: ['Free Swap', 4],
+        yData5: ['Buffers', 1]
+      });
+    });
+
+    it('should process multiple service results', () => {
+      controller.donutData.should.deepEqual({
+        used: 0,
+        total: 100
+      });
+      controller.lineData.should.deepEqual({
+        xData: ['timestamp'],
+        yData0: ['Total Memory'],
+        yData1: ['Free Memory'],
+        yData2: ['Used Memory'],
+        yData3: ['Total Swap'],
+        yData4: ['Free Swap'],
+        yData5: ['Buffers']
+      });
+      let timestampA = Date.now();
+      let timestampB = Date.now() - 1000;
+      controller._processData({
+        data: {
+          response: [
+            {
+              systemId: 'foo-systemId',
+              agentId: 'mock-agentId',
+              timeStamp: timestampA,
+              total: 16384,
+              free: 0,
+              buffers: 0,
+              cached: 0,
+              swapTotal: 0,
+              swapFree: 0,
+              commitLimit: 0
+            },
+            {
+              systemId: 'foo-systemId',
+              agentId: 'mock-agentId',
+              timeStamp: timestampB,
+              total: 16384,
+              free: 0,
+              buffers: 0,
+              cached: 0,
+              swapTotal: 0,
+              swapFree: 0,
+              commitLimit: 0
+            }
+          ]
+        }
+      });
+      controller.lineData.xData.length.should.equal(3);
+      controller.lineData.xData[1].should.equal(timestampB);
+      controller.lineData.xData[2].should.equal(timestampA);
+    });
+
+    it('should append new data to line chart data object', () => {
+      let timestampA = Date.now();
+      let timestampB = Date.now() + 1000;
+      controller._processData({
+        data: {
+          response: [
+            {
+              systemId: 'foo-systemId',
+              agentId: 'mock-agentId',
+              timeStamp: timestampA,
+              total: 16384,
+              free: 0,
+              buffers: 0,
+              cached: 0,
+              swapTotal: 0,
+              swapFree: 0,
+              commitLimit: 0
+            }
+          ]
+        }
+      });
+      controller._processData({
+        data: {
+          response: [
+            {
+              systemId: 'foo-systemId',
+              agentId: 'mock-agentId',
+              timeStamp: timestampB,
+              total: 16384,
+              free: 0,
+              buffers: 0,
+              cached: 0,
+              swapTotal: 0,
+              swapFree: 0,
+              commitLimit: 0
+            }
+          ]
+        }
+      });
+      controller.lineData.xData.length.should.equal(3);
+      controller.lineData.xData[1].should.equal(timestampA);
+      controller.lineData.xData[2].should.equal(timestampB);
+    });
+
+    it('should remove data that is older than dataAgeLimit', () => {
+      controller.dataAgeLimit = 30000;
+      let timestampA = Date.now() - 30001;
+      let timestampB = Date.now;
+      controller._processData({
+        data: {
+          response: [
+            {
+              systemId: 'foo-systemId',
+              agentId: 'mock-agentId',
+              timeStamp: timestampA,
+              total: 16384,
+              free: 0,
+              buffers: 0,
+              cached: 0,
+              swapTotal: 0,
+              swapFree: 0,
+              commitLimit: 0
+            }
+          ]
+        }
+      });
+      controller._processData({
+        data: {
+          response: [
+            {
+              systemId: 'foo-systemId',
+              agentId: 'mock-agentId',
+              timeStamp: timestampB,
+              total: 16384,
+              free: 0,
+              buffers: 0,
+              cached: 0,
+              swapTotal: 0,
+              swapFree: 0,
+              commitLimit: 0
+            }
+          ]
+        }
+      });
+      controller.lineData.xData.length.should.equal(2);
+      controller.lineData.xData[1].should.equal(timestampB);
+    });
+  });
+
+  describe('on destroy', () => {
+    it('should cancel refresh', () => {
+      controller.$onDestroy();
+      interval.cancel.should.be.calledWith('interval-sentinel');
+    });
+
+    it('should do nothing if refresh undefined', () => {
+      delete controller._refresh;
+      controller.$onDestroy();
+      interval.cancel.should.not.be.called();
+    });
+  });
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/system-info/system-memory/system-memory.html	Wed Sep 13 16:27:00 2017 -0400
@@ -0,0 +1,58 @@
+<div class="system-memory-charts">
+  <div class="col-xs-12 col-sm-6 col-md-6">
+    <div class="card-pf card-pf-view">
+
+      <div class="card-pf-heading">
+        <label class="card-pf-title" translate>systemMemory.DONUT_CHART_LABEL</label>
+        <mc-add class="pull-right" svc-name="{{$ctrl.systemId}}-memory" get-fn="$ctrl.multichartFn()"></mc-add>
+      </div>
+
+      <div class="card-pf-body">
+        <pf-donut-pct-chart id="systemMemoryDonutChart" config="$ctrl.donutConfig" data="$ctrl.donutData"></pf-donut-pct-chart>
+      </div>
+    </div>
+  </div>
+
+  <div class="col-xs-12 col-md-12">
+    <div class="card-pf card-pf-view">
+
+      <div class="card-pf-heading">
+        <label class="card-pf-title" translate>systemMemory.LINE_CHART_LABEL</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>systemMemory.REFRESH_RATE_LABEL</label>
+          <select name="refreshCombo" class="combobox form-control" ng-model="$ctrl.refreshRate">
+            <option value="-1" translate>systemMemory.refresh.DISABLED</option>
+            <option value="1000" selected translate="systemMemory.refresh.SECONDS" translate-values="{ SECONDS: 1, DEFAULT: true }" translate-interpolation="messageformat"></option>
+            <option value="2000" translate="systemMemory.refresh.SECONDS" translate-values="{ SECONDS: 2 }" translate-interpolation="messageformat"></option>
+            <option value="5000" translate="systemMemory.refresh.SECONDS" translate-values="{ SECONDS: 5 }" translate-interpolation="messageformat"></option>
+            <option value="10000" translate="systemMemory.refresh.SECONDS" translate-values="{ SECONDS: 10 }" translate-interpolation="messageformat"></option>
+            <option value="30000" translate="systemMemory.refresh.SECONDS" translate-values="{ SECONDS: 30 }" 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>systemMemory.MAX_DATA_AGE_LABEL</label>
+          <select name="dataAgeCombo" class="combobox form-control" ng-model="$ctrl.dataAgeLimit">
+            <option value="10000" translate="systemMemory.dataAge.SECONDS" translate-values="{ SECONDS: 10 }" translate-interpolation="messageformat"></option>
+            <option value="30000" selected translate="systemMemory.dataAge.SECONDS" translate-values="{ SECONDS: 30, DEFAULT: true }" translate-interpolation="messageformat"></option>
+            <option value="60000" translate="systemMemory.dataAge.MINUTES" translate-values="{ MINUTES: 1 }" translate-interpolation="messageformat"></option>
+            <option value="300000" translate="systemMemory.dataAge.MINUTES" translate-values="{ MINUTES: 5 }" translate-interpolation="messageformat"></option>
+            <option value="900000" translate="systemMemory.dataAge.MINUTES" translate-values="{ MINUTES: 15 }" translate-interpolation="messageformat"></option>
+          </select>
+        </div>
+      </div>
+
+      <!-- Line Chart -->
+      <div class="card-pf-body">
+        <pf-line-chart id="systemMemoryLineChart" config="$ctrl.lineConfig" chart-data="$ctrl.lineData" set-area-chart="false" show-x-axis="true" show-y-axis="true"></pf-line-chart>
+      </div>
+
+    </div>
+  </div>
+
+</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/system-info/system-memory/system-memory.service.js	Wed Sep 13 16:27:00 2017 -0400
@@ -0,0 +1,52 @@
+/**
+ * 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 config from 'shared/config/config.module.js';
+import urlJoin from 'url-join';
+
+class SystemMemoryService {
+  constructor ($q, $http, gatewayUrl) {
+    'ngInject';
+    this.q = $q;
+    this.http = $http;
+    this.gatewayUrl = gatewayUrl;
+  }
+
+  getMemoryInfo (systemId) {
+    return this.http.get(urlJoin(this.gatewayUrl, 'system-memory', '0.0.1', 'systems', systemId), {
+      params: {
+        sort: '-timeStamp'
+      }
+    });
+  }
+}
+
+export default angular
+  .module('systemMemory.service', [config])
+  .service('systemMemoryService', SystemMemoryService)
+  .name;
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/app/components/system-info/system-memory/system-memory.service.spec.js	Wed Sep 13 16:27:00 2017 -0400
@@ -0,0 +1,77 @@
+/**
+ * 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 configModule from 'shared/config/config.module.js';
+import serviceModule from './system-memory.service.js';
+
+describe('SystemMemoryService', () => {
+
+  beforeEach(() => {
+    angular.mock.module(configModule, $provide => {
+      'ngInject';
+      $provide.constant('gatewayUrl', 'http://example.com:1234');
+    });
+
+    angular.mock.module(serviceModule);
+  });
+
+  let httpBackend, scope, svc;
+  beforeEach(inject(($httpBackend, $rootScope, systemMemoryService) => {
+    'ngInject';
+    httpBackend = $httpBackend;
+
+    scope = $rootScope;
+    svc = systemMemoryService;
+  }));
+
+  afterEach(() => {
+    httpBackend.verifyNoOutstandingExpectation();
+    httpBackend.verifyNoOutstandingRequest();
+  });
+
+  it('should exist', () => {
+    should.exist(svc);
+  });
+
+  describe('getMemoryInfo(systemId)', () => {
+    it('should resolve mock data', done => {
+      let expected = {
+        total: 16384,
+        used: 9001
+      };
+      httpBackend.when('GET', 'http://example.com:1234/system-memory/0.0.1/systems/foo-systemId?sort=-timeStamp')
+        .respond(expected);
+      svc.getMemoryInfo('foo-systemId').then(res => {
+        res.data.should.deepEqual(expected);
+        done();
+      });
+      httpBackend.expectGET('http://example.com:1234/system-memory/0.0.1/systems/foo-systemId?sort=-timeStamp');
+      httpBackend.flush();
+      scope.$apply();
+    });
+  });
+});