changeset 164:a5f9d8c66e1b

Use pfListView & pfPagination for jvm-list This patch introduces the PatternFly listview (& toolbar) and pagination directives to jvm-list. Additionally, there are changes to the jvms mock-endpoint so the toolbar actions (sorting & searching) can be tested in a development setting. The dependency for angular-patternfly also had to be updated from 3.23.3 to 4.4.1. Reviewed-by: aazores Review-thread: http://icedtea.classpath.org/pipermail/thermostat/2017-August/024633.html
author Alex Macdonald <almacdon@redhat.com>
date Tue, 22 Aug 2017 16:30:30 -0400
parents c029b9611981
children d805dbe67564
files mock-api/endpoints/jvms.endpoint.js mock-api/endpoints/system-info.endpoint.js package.json src/app/components/jvm-info/jvm-gc/jvm-gc.html src/app/components/jvm-info/jvm-memory/jvm-memory.html src/app/components/jvm-list/en.locale.yaml src/app/components/jvm-list/jvm-list.controller.js src/app/components/jvm-list/jvm-list.controller.spec.js src/app/components/jvm-list/jvm-list.html src/app/components/jvm-list/jvm-list.module.js src/app/components/multichart/multichart.html src/app/components/system-info/system-info.html src/assets/scss/main.scss webpack.config.js
diffstat 14 files changed, 580 insertions(+), 183 deletions(-) [+]
line wrap: on
line diff
--- a/mock-api/endpoints/jvms.endpoint.js	Thu Aug 17 09:12:58 2017 -0400
+++ b/mock-api/endpoints/jvms.endpoint.js	Tue Aug 22 16:30:30 2017 -0400
@@ -5,46 +5,33 @@
     server.logRequest('jvm-list', req);
     res.setHeader('Content-Type', 'application/json');
 
-    var limit = 4;
+    var systemLimit = 4;
     var aliveOnly = req.query.aliveOnly === 'true';
     var resp = [];
     if (req.query.limit) {
-      limit = parseInt(req.query.limit);
+      systemLimit = parseInt(req.query.limit);
       // 0 means no limit, so we'll default to 4
-      if (limit === 0) {
-        limit = 4;
+      if (systemLimit === 0) {
+        systemLimit = 4;
       }
     }
-    for (var i = 0; i < limit; i++) {
-      var jvms = [
-        {
-          'mainClass': 'c.r.t.A',
+    for (var i = 0; i < systemLimit; i++) {
+      var jvms = [];
+      for (var j = 0; j < systemLimit - i; j++) {
+        jvms.push({
+          'mainClass': 'c.r.t.' + j,
           'startTime': { $numberLong: (Date.now() - 10000000).toString() },
           'stopTime': { $numberLong: '-1' },
-          'jvmId': 'vm-0',
-          'isAlive': true
-        },
-        {
-          'mainClass': 'c.r.t.B',
-          'startTime': { $numberLong: (Date.now() - 1500000).toString() },
-          'stopTime': { $numberLong: '-1' },
-          'jvmId': 'vm-1',
+          'jvmId': 'vm-' + j,
           'isAlive': true
-        },
-        {
-          'mainClass': 'c.r.t.C',
-          'startTime': { $numberLong: (Date.now() - 25000000).toString() },
-          'stopTime': { $numberLong: '-1' },
-          'jvmId': 'vm-2',
-          'isAlive': true
-        }
-      ];
+        });
+      }
       if (!aliveOnly) {
         jvms.push({
-          'mainClass': 'c.r.t.D',
+          'mainClass': 'c.r.t.DeadVM',
           'startTime': { $numberLong: (Date.now() - 350000000).toString() },
           'stopTime': { $numberLong: Date.now().toString() },
-          'jvmId': 'vm-3',
+          'jvmId': 'vm-dead',
           'isAlive': false
         });
       }
--- a/mock-api/endpoints/system-info.endpoint.js	Thu Aug 17 09:12:58 2017 -0400
+++ b/mock-api/endpoints/system-info.endpoint.js	Tue Aug 22 16:30:30 2017 -0400
@@ -12,7 +12,8 @@
           osKernel: '4.10.11-200.fc25.x86_64',
           cpuCount: 4,
           cpuModel: 'GenuineIntel',
-          totalMemory: 16 * 1024 * 1024 * 1024
+          totalMemory: 16 * 1024 * 1024 * 1024,
+          timeCreated: { $numberLong: (Date.now() - (Math.floor(Math.random() * 100000000))).toString() }
         }]
       }
     ));
--- a/package.json	Thu Aug 17 09:12:58 2017 -0400
+++ b/package.json	Tue Aug 22 16:30:30 2017 -0400
@@ -12,7 +12,7 @@
   "devDependencies": {
     "@uirouter/angularjs": "^1.0.0",
     "angular-mocks": "1.5.*",
-    "angular-patternfly": "^3.23.3",
+    "angular-patternfly": "^4.4.1",
     "angular-translate": "^2.15.2",
     "angular-translate-interpolation-messageformat": "^2.15.2",
     "babel-core": "^6.24.0",
--- a/src/app/components/jvm-info/jvm-gc/jvm-gc.html	Thu Aug 17 09:12:58 2017 -0400
+++ b/src/app/components/jvm-info/jvm-gc/jvm-gc.html	Tue Aug 22 16:30:30 2017 -0400
@@ -28,7 +28,7 @@
   <div class="row row-cards-pf">
     <div class="container-fluid container-cards-pf">
 
-      <div class="col">
+      <div class="col-md-12">
         <div class="card-pf card-pf-view">
           <div class="card-pf-heading">
             <label class="card-pf-title" translate>jvmGc.CARD_TITLE</label>
@@ -36,11 +36,11 @@
           <div ng-repeat="collector in ctrl.collectors">
             <div class="card-pf-body text-center">
               <mc-add class="pull-right" svc-name="{{ctrl.jvmId}}-{{sanitize(collector)}}-gc" get-fn="ctrl.multichartFn(collector)"></mc-add>
-              <div pf-line-chart id="chart-{{collector}}" config="ctrl.chartConfigs[collector]"
+              <pf-line-chart id="chart-{{collector}}" config="ctrl.chartConfigs[collector]"
                                                           chart-data="ctrl.chartData[collector]"
                                                           show-x-axis="true"
                                                           show-y-axis="true"
-                                                          ></div>
+                                                          ></pf-line-chart>
             </div>
           </div>
         </div>
--- a/src/app/components/jvm-info/jvm-memory/jvm-memory.html	Thu Aug 17 09:12:58 2017 -0400
+++ b/src/app/components/jvm-info/jvm-memory/jvm-memory.html	Tue Aug 22 16:30:30 2017 -0400
@@ -24,7 +24,7 @@
           </div>
           <div class="card-pf-body">
             <mc-add class="pull-right" svc-name="{{ctrl.jvmId}}-metaspace" get-fn="ctrl.multichartMetaspace()"></mc-add>
-            <div pf-donut-pct-chart id="metaspaceChart" config="ctrl.metaspaceConfig" data="ctrl.metaspaceData"></div>
+            <pf-donut-pct-chart id="metaspaceChart" config="ctrl.metaspaceConfig" data="ctrl.metaspaceData"></pf-donut-pct-chart>
           </div>
         </div>
       </div>
@@ -46,8 +46,8 @@
                 <label translate="jvmMemory.SPACE" translate-values="{ index: space.index }"></label>
                 <mc-add class="pull-right" svc-name="{{ctrl.jvmId}}-{{sanitize(generation.name)}}-space{{space.index}}"
                                            get-fn="ctrl.multichartSpace(index, space.index)"></mc-add>
-                <div pf-donut-pct-chart id="gen-{{gen.index}}-space-{{space.index}}"
-                  config="ctrl.spaceConfigs['gen-' + generation.index + '-space-' + space.index]" data="space"></div>
+                <pf-donut-pct-chart id="gen-{{gen.index}}-space-{{space.index}}"
+                  config="ctrl.spaceConfigs['gen-' + generation.index + '-space-' + space.index]" data="space"></pf-donut-pct-chart>
               </div>
             </div>
           </div>
--- a/src/app/components/jvm-list/en.locale.yaml	Thu Aug 17 09:12:58 2017 -0400
+++ b/src/app/components/jvm-list/en.locale.yaml	Tue Aug 22 16:30:30 2017 -0400
@@ -1,8 +1,20 @@
 jvmList:
   BREADCRUMB: '@:landing.nav.JVM_LISTING'
-  ALIVE_ONLY: Alive Only
-  SYSTEM_CARD_LABEL: Monitored System
-  CREATED_ON: <strong>Created On</strong> {{date}}
+  ALIVE_ONLY: Alive JVMs Only
+  SYSTEM_INFO_LABEL: System Information
+  START_TIME_LIST: Start Time:&#160;{{date}}
+  START_TIME_CARD: <strong>Start Time:</strong>{{date}}
 
   ERR_TITLE: Unable to retrieve data.
   ERR_MESSAGE: Error while retrieving Thermostat JVM Listing.
+  
+  HOSTNAME_TITLE: 'Hostname'
+
+  filterConfig:
+    HOSTNAME_PLACEHOLDER: Filter by System Hostname...
+    JVM_NAME_TITLE: JVM MainClass Name
+    JVM_NAME_PLACEHOLDER: Filter by JVM Class Name...
+
+  sortConfig:  
+    TIME_CREATED_TITLE: 'Host: Time Created'
+    NUM_JVMS_TITLE: 'Host: Number of JVMs'
\ No newline at end of file
--- a/src/app/components/jvm-list/jvm-list.controller.js	Thu Aug 17 09:12:58 2017 -0400
+++ b/src/app/components/jvm-list/jvm-list.controller.js	Tue Aug 22 16:30:30 2017 -0400
@@ -26,21 +26,42 @@
  */
 
 import filters from 'shared/filters/filters.module.js';
-import service from './jvm-list.service.js';
 import directives from 'shared/directives/directives.module.js';
+import jvmListService from './jvm-list.service.js';
+import systemInfoService from 'components/system-info/system-info.service.js';
 
 class JvmListController {
-  constructor (jvmListService, $scope, $location, $timeout, $anchorScroll, $translate) {
+  constructor (jvmListService, systemInfoService, $scope, $location, $timeout, $translate) {
     'ngInject';
+    this.scope = $scope;
     this.jvmListService = jvmListService;
+    this.systemInfoService = systemInfoService;
     this.scope = $scope;
     this.location = $location;
     this.timeout = $timeout;
-    this.anchorScroll = $anchorScroll;
+    this.translate = $translate;
+    this.systemsOpen = {};
+
+    $scope.sortConfig = {};
+    $scope.filterConfig = {};
+    $scope.listConfig = {
+      showSelectBox: false,
+      useExpandingRows: true,
+      onClick: item => $location.hash(this.changeLocationHash(item))
+    };
 
-    $translate('jvmList.ERR_TITLE').then(s => $scope.errTitle = s);
-    $translate('jvmList.ERR_MESSAGE').then(s => $scope.errMessage = s);
+    // Settings for pfPagination
+    $scope.pageNumber = 1;
+    $scope.pageSize = 4;
+    $scope.pageSizeIncrements = [4, 8, 12, 16, 20];
 
+    $scope.emptyStateConfig = {
+      icon: 'pficon-warning-triangle-o',
+    };
+    $translate('jvmList.ERR_TITLE').then(s => $scope.emptyStateConfig.title = s);
+    $translate('jvmList.ERR_MESSAGE').then(s => $scope.emptyStateConfig.info = s);
+
+    $location.hash('');
     this.aliveOnly = true;
     let aliveOnlySwitch = angular.element('#aliveOnlyState');
     aliveOnlySwitch.bootstrapSwitch();
@@ -49,42 +70,197 @@
       this.loadData();
     });
 
-    this.title = 'JVM Listing';
-    this.showErr = false;
-    this.systemsOpen = {};
+    this.loadData();
+    this.constructToolbarSettings();
+  }
 
-    this.loadData();
+  /**
+   * Given an item, it will append or remove the item from the location
+   * hash depending on the result of (systemsOpen[item.systemId])
+   * E.g., if systemsOpen[item.systemId] is true, then the item is
+   * currently expanded in the pf-list-view, and the URL hash should be
+   * appended with the systemId of the item. Subsequently calling
+   * changeLocationHash with the same item will toggle it's boolean
+   * state in systemsOpen, and the location hash will be rebuilt to
+   * include only systems that are currently open.
+   * @param {Object} item
+   * @param {String || Number} hash
+   */
+  changeLocationHash (item, hash = this.location.hash()) {
+    this.systemsOpen[item.systemId] = !this.systemsOpen[item.systemId];
+    if (this.systemsOpen[item.systemId]) {
+      if (hash === '') {
+        hash = item.hostname;
+      } else {
+        hash += '+' + item.hostname;
+      }
+    } else { // rebuild the location hash string
+      hash = '';
+      for (let index in this.systemsOpen) {
+        if (hash === '' && this.systemsOpen[index]) {
+          hash = index;
+        } else if (this.systemsOpen[index]) {
+          hash += '+' + index;
+        }
+      }
+    }
+    return hash;
   }
 
   loadData () {
+    this.scope.allItems = [];
+    this.scope.items = this.scope.allItems;
     this.jvmListService.getSystems(this.aliveOnly).then(
       resp => {
         this.showErr = false;
         this.systems = resp.data.response;
 
-        for (var i = 0; i < this.systems.length; i++) {
+        for (let i = 0; i < this.systems.length; i++) {
           let system = this.systems[i];
           this.systemsOpen[system.systemId] = false;
+          this.systemInfoService.getSystemInfo(system.systemId).then(
+            resp => {
+              this.scope.allItems.push({
+                systemId: system.systemId,
+                hostname: resp.data.response[0].hostname,
+                jvms: system.jvms,
+                timeCreated: resp.data.response[0].timeCreated
+              });
+            }
+          );
         }
 
         if (this.systems.length === 1) {
           this.systemsOpen[this.systems[0].systemId] = true;
         }
-
-        let hash = this.location.hash();
-        if (hash) {
-          this.systemsOpen[hash] = true;
-        }
-        this.onload();
+        this.scope.listConfig.itemsAvailable = true;
+        this.scope.toolbarConfig.filterConfig.resultsCount = this.systems.length;
       },
       () => {
+        this.scope.listConfig.itemsAvailable = false;
         this.showErr = true;
       }
     );
   }
 
-  onload () {
-    this.timeout(this.anchorScroll);
+  constructToolbarSettings () {
+    this.scope.filterConfig = {
+      fields: [
+        {
+          id: 'hostname',
+          filterType: 'text'
+        },
+        {
+          id: 'jvmName',
+          filterType: 'text'
+        }
+      ],
+      resultsCount: this.scope.items.length,
+      totalCount: this.scope.allItems.length,
+      appliedFilters: [],
+      onFilterChange: filters => {
+        this.applyFilters(filters);
+        this.scope.toolbarConfig.filterConfig.resultsCount = this.scope.items.length;
+      }
+    };
+    this.translate('jvmList.HOSTNAME_TITLE').then(s => this.scope.filterConfig.fields[0].title = s);
+    this.translate('jvmList.filterConfig.HOSTNAME_PLACEHOLDER').then(s => this.scope.filterConfig.fields[0].placeholder = s);
+    this.translate('jvmList.filterConfig.JVM_NAME_TITLE').then(s => this.scope.filterConfig.fields[1].title = s);
+    this.translate('jvmList.filterConfig.JVM_NAME_PLACEHOLDER').then(s => this.scope.filterConfig.fields[1].placeholder = s);
+
+    this.scope.sortConfig = {
+      fields: [
+        {
+          id: 'name',
+          sortType: 'alpha'
+        },
+        {
+          id: 'timeCreated',
+          sortType: 'numeric'
+        },
+        {
+          id: 'numJvms',
+          sortType: 'numeric'
+        }
+      ],
+      onSortChange: () => {
+        this.sortItems();
+      }
+    };
+    this.translate('jvmList.HOSTNAME_TITLE').then(s => this.scope.sortConfig.fields[0].title = s);
+    this.translate('jvmList.sortConfig.TIME_CREATED_TITLE').then(s => this.scope.sortConfig.fields[1].title = s);
+    this.translate('jvmList.sortConfig.NUM_JVMS_TITLE').then(s => this.scope.sortConfig.fields[2].title = s);
+
+    this.scope.toolbarConfig = {
+      filterConfig: this.scope.filterConfig,
+      sortConfig: this.scope.sortConfig
+    };
+  }
+
+  /**
+   * Starter code for matchesFilter, matchesFilters, applyFilters, and compareFn borrowed
+   * from the Angular-PatternFly API at:
+   * http://www.patternfly.org/angular-patternfly/#/api/patternfly.toolbars.componenet:pfToolbar
+   */
+  matchesFilter (item, filter) {
+    let match = false;
+    let re = new RegExp(filter.value, 'i');
+    if (filter.id === 'hostname') {
+      match = item.hostname.match(re) !== null;
+    } else if (filter.id === 'jvmName') {
+      for (let i = 0; i < item.jvms.length; i++) {
+        match = (item.jvms[i].mainClass).match(re) !== null;
+        if (match) {
+          break;
+        }
+      }
+    }
+    return match;
+  }
+
+  matchesFilters (item, filters) {
+    let matches = true;
+    filters.forEach(filter => {
+      if (!this.matchesFilter(item, filter)) {
+        matches = false;
+        return false;
+      } else {
+        return true;
+      }
+    });
+    return matches;
+  }
+
+  applyFilters (filters) {
+    this.scope.items = [];
+    if (filters && filters.length > 0) {
+      this.scope.allItems.forEach(item => {
+        if (this.matchesFilters(item, filters)) {
+          this.scope.items.push(item);
+        }
+      });
+    } else {
+      this.scope.items = this.scope.allItems;
+    }
+  }
+
+  compareFn (item1, item2) {
+    let compValue = 0;
+    if (this.scope.sortConfig.currentField.id === 'name') {
+      compValue = item1.hostname.localeCompare(item2.systemId);
+    } else if (this.scope.sortConfig.currentField.id === 'timeCreated') {
+      compValue = item1.timeCreated.$numberLong > item2.timeCreated.$numberLong ? 1 : -1;
+    } else if (this.scope.sortConfig.currentField.id === 'numJvms') {
+      compValue = item1.jvms.length > item2.jvms.length ? 1 : -1;
+    }
+    if (!this.scope.sortConfig.isAscending) {
+      compValue = compValue * -1;
+    }
+    return compValue;
+  }
+
+  sortItems () {
+    this.scope.items.sort((item1, item2) => this.compareFn(item1, item2));
   }
 
 }
@@ -92,9 +268,11 @@
 export default angular
   .module('jvmList.controller', [
     'patternfly',
+    'patternfly.toolbars',
     filters,
     directives,
-    service
+    jvmListService,
+    systemInfoService
   ])
   .controller('JvmListController', JvmListController)
   .name;
--- a/src/app/components/jvm-list/jvm-list.controller.spec.js	Thu Aug 17 09:12:58 2017 -0400
+++ b/src/app/components/jvm-list/jvm-list.controller.spec.js	Tue Aug 22 16:30:30 2017 -0400
@@ -29,7 +29,53 @@
 
   beforeEach(angular.mock.module('jvmList.controller'));
 
-  let ctrl, svc, scope, promise, location, timeout, anchorScroll, translate;
+  let controller, jvmListSvc, systemInfoSvc, scope, promise, location, timeout, translate;
+
+  let fooItem = {
+    hostname: 'foo',
+    systemId: 'foo',
+    timeCreated: { $numberLong: '1500000000000' },
+    jvms: [{
+      mainClass: 'fooClass'
+    }]
+  };
+
+  let barbazItem = {
+    hostname: 'barbaz',
+    systemId: 'barbaz',
+    timeCreated: { $numberLong: '1400000000000' },
+    jvms: [
+      {
+        mainClass: 'barClass'
+      },
+      {
+        mainClass: 'bazClass'
+      }
+    ]
+  };
+
+  let filters = [
+    {
+      id: 'hostname',
+      title: 'Hostname',
+      value: 'foo'
+    },
+    {
+      id: 'jvmName',
+      title: 'JVM MainClass Name',
+      value: 'fooClass'
+    }
+  ];
+
+  let generateSortConfig = (ascending, id) => {
+    return {
+      isAscending: ascending,
+      currentField: {
+        id: id
+      }
+    };
+  };
+
   beforeEach(inject(($q, $rootScope, $controller) => {
     'ngInject';
     sinon.stub(angular, 'element').withArgs('#aliveOnlyState').returns({
@@ -39,39 +85,107 @@
     scope = $rootScope;
     promise = $q.defer();
     location = {
-      hash: () => ''
+      hash: sinon.stub().returns('')
     };
     timeout = sinon.spy();
-    anchorScroll = sinon.spy();
     translate = sinon.stub().returns({
       then: sinon.stub().yields()
     });
 
-    svc = {
+    jvmListSvc = {
       getSystems: sinon.stub().returns(promise.promise)
     };
-    ctrl = $controller('JvmListController', {
-      jvmListService: svc,
+    systemInfoSvc = {
+      getSystemInfo: sinon.stub().returns(promise.promise)
+    };
+    controller = $controller('JvmListController', {
+      jvmListService: jvmListSvc,
+      systemInfoService: systemInfoSvc,
+      $scope: scope,
       $location: location,
-      $scope: scope,
       $timeout: timeout,
-      $anchorScroll: anchorScroll,
       $translate: translate
     });
-    sinon.spy(ctrl, 'onload');
+    sinon.spy(controller, 'applyFilters');
+    sinon.spy(controller, 'changeLocationHash');
+    sinon.spy(controller, 'compareFn');
+    sinon.spy(controller, 'matchesFilter');
+    sinon.spy(controller, 'matchesFilters');
+    sinon.spy(controller, 'sortItems');
+    sinon.spy(scope.items, 'sort');
   }));
 
   afterEach(() => {
-    ctrl.onload.restore();
     angular.element.restore();
   });
 
   it('should exist', () => {
-    should.exist(ctrl);
+    should.exist(controller);
+  });
+
+  describe('listConfig', () => {
+    it('should use a fn (changeLocationHash) for onClick attribute', () => {
+      let fn = scope.listConfig.onClick;
+      fn.should.be.a.Function();
+      fn('');
+      controller.changeLocationHash.should.be.calledOnce();
+    });
   });
 
-  it('should set a title', () => {
-    ctrl.title.should.equal('JVM Listing');
+  describe('changeLocationHash', () => {
+    it('should add system hostname to url when opened', () => {
+      let prevLocationHash = '';
+      controller.systemsOpen[fooItem.systemId] = false;
+      let result = controller.changeLocationHash(fooItem, prevLocationHash);
+      result.should.equal(fooItem.hostname);
+    });
+
+    it('should append system hostname if more than one open', () => {
+      let prevLocationHash = 'foo';
+      controller.systemsOpen[fooItem.systemId] = true;
+      let result = controller.changeLocationHash(barbazItem, prevLocationHash);
+      result.should.equal(fooItem.hostname + '+' + barbazItem.hostname);
+    });
+
+    it('should rebuild location hashes when list-view rows are closed', () => {
+      let prevLocationHash = 'foo+bar+baz';
+      controller.systemsOpen.foo = true;
+      controller.systemsOpen.bar = true;
+      controller.systemsOpen.baz = true;
+      let result = controller.changeLocationHash(fooItem, prevLocationHash);
+      result.should.equal('bar+baz');
+    });
+
+  });
+
+  describe('aliveOnly', () => {
+    it('should default to true', () => {
+      controller.should.have.ownProperty('aliveOnly');
+      controller.aliveOnly.should.equal(true);
+    });
+
+    it('should be bound to aliveOnlyState', () => {
+      angular.element.should.be.calledWith('#aliveOnlyState');
+    });
+
+    it('should set up bootstrap switch', () => {
+      angular.element('#aliveOnlyState').bootstrapSwitch.should.be.calledOnce();
+    });
+
+    it('should update state on switch event', () => {
+      let stateWidget = angular.element('#aliveOnlyState');
+      stateWidget.on.should.be.calledOnce();
+      stateWidget.on.should.be.calledWith('switchChange.bootstrapSwitch', sinon.match.func);
+
+      jvmListSvc.getSystems.should.be.calledOnce();
+      jvmListSvc.getSystems.firstCall.should.be.calledWith(true);
+      let fn = stateWidget.on.args[0][1];
+      fn(null, false);
+      controller.aliveOnly.should.equal(false);
+      jvmListSvc.getSystems.should.be.calledTwice();
+      jvmListSvc.getSystems.secondCall.should.be.calledWith(false);
+      promise.resolve();
+    });
   });
 
   describe('loadData', () => {
@@ -88,36 +202,14 @@
       };
       promise.resolve({ data: data });
       scope.$apply();
-      ctrl.should.have.ownProperty('systems');
-      ctrl.systems.should.deepEqual(data.response);
-      ctrl.showErr.should.equal(false);
-      ctrl.systemsOpen.should.deepEqual({
+      controller.should.have.ownProperty('systems');
+      controller.systems.should.deepEqual(data.response);
+      controller.showErr.should.equal(false);
+      controller.systemsOpen.should.deepEqual({
         foo: false,
         bar: false
       });
-      ctrl.onload.should.be.calledOnce();
-      done();
-    });
-
-    it('should set systemsOpen to true and anchorScroll if corresponding hash provided', done => {
-      let data = {
-        response: [
-          {
-            systemId: 'foo'
-          },
-          {
-            systemId: 'bar'
-          }
-        ]
-      };
-      location.hash = () => 'foo';
-      promise.resolve({ data: data });
-      scope.$apply();
-      ctrl.systemsOpen.should.deepEqual({
-        foo: true,
-        bar: false
-      });
-      ctrl.onload.should.be.calledOnce();
+      scope.listConfig.itemsAvailable = true;
       done();
     });
 
@@ -134,11 +226,11 @@
       };
       promise.resolve({ data: data });
       scope.$apply();
-      ctrl.systemsOpen.should.deepEqual({
+      controller.systemsOpen.should.deepEqual({
         foo: false,
         bar: false
       });
-      ctrl.onload.should.be.calledOnce();
+      scope.listConfig.itemsAvailable = true;
       done();
     });
 
@@ -152,57 +244,157 @@
       };
       promise.resolve({ data: data });
       scope.$apply();
-      ctrl.systemsOpen.should.deepEqual({
+      controller.systemsOpen.should.deepEqual({
         foo: true
       });
-      ctrl.onload.should.be.calledOnce();
+      scope.listConfig.itemsAvailable = true;
       done();
     });
 
     it('should set error flag when service rejects', done => {
       promise.reject();
       scope.$apply();
-      ctrl.should.have.ownProperty('showErr');
-      ctrl.showErr.should.equal(true);
+      controller.should.have.ownProperty('showErr');
+      controller.showErr.should.equal(true);
+      scope.listConfig.itemsAvailable = false;
       done();
     });
   });
 
-  describe('onload', () => {
-    it('should call timeout with argument of anchorScroll', () => {
-      timeout.reset();
-      ctrl.onload();
-      timeout.should.be.calledWith(anchorScroll);
+  describe('constructToolbarSettings', () => {
+    it('should use a function for filterConfig.onFilterChange', () => {
+      scope.items.length = 2;
+      let fn = scope.filterConfig.onFilterChange;
+      fn.should.be.a.Function();
+      let filter = {
+        id: 'hostname',
+        title: 'Hostname',
+        value: 'foobar'
+      };
+      fn(filter);
+      controller.applyFilters.should.be.calledOnce();
+      scope.toolbarConfig.filterConfig.resultsCount.should.equal(2);
+    });
+
+    it('should use fn sortItems() for sortConfig.onSortChange', () => {
+      let fn = scope.sortConfig.onSortChange;
+      fn.should.be.a.Function();
+      fn();
+      controller.sortItems.should.be.calledOnce();
     });
   });
 
-  describe('aliveOnly', () => {
-    it('should default to true', () => {
-      ctrl.should.have.ownProperty('aliveOnly');
-      ctrl.aliveOnly.should.equal(true);
+  describe('Filter and Sorting helper functions', () => {
+    it('matchesFilter should match hostnames', () => {
+      let filter = {
+        id: 'hostname',
+        title: 'Hostname',
+        value: 'foo'
+      };
+
+      controller.matchesFilter(fooItem, filter).should.equal(true);
+
+      filter.value = 'bar';
+      controller.matchesFilter(fooItem, filter).should.equal(false);
+
+      filter = {
+        id: 'jvmName',
+        title: 'JVM MainClass Name',
+        value: 'fooClass'
+      };
+      controller.matchesFilter(fooItem, filter).should.equal(true);
+
+      filter.value = 'barClass';
+      controller.matchesFilter(fooItem, filter).should.equal(false);
     });
 
-    it('should be bound to aliveOnlyState', () => {
-      angular.element.should.be.calledWith('#aliveOnlyState');
+    it('matchesFilter should match JVM mainclass names', () => {
+      let filter = {
+        id: 'jvmName',
+        title: 'JVM MainClass Name',
+        value: 'fooClass'
+      };
+      controller.matchesFilter(fooItem, filter).should.equal(true);
+
+      filter.value = 'barClass';
+      controller.matchesFilter(fooItem, filter).should.equal(false);
     });
 
-    it('should set up bootstrap switch', () => {
-      angular.element('#aliveOnlyState').bootstrapSwitch.should.be.calledOnce();
+    it('matchesFilter should return false if an invalid filter is supplied', () => {
+      let filter = {
+        id: 'fakeId',
+        title: 'fakeTitle',
+        value: 'fakeValue'
+      };
+      controller.matchesFilter(fooItem, filter).should.equal(false);
+    });
+
+    it('matchesFilters should delegate matching to matchesFilter', () => {
+      controller.matchesFilters(fooItem, filters).should.equal(true);
+      controller.matchesFilter.should.be.calledTwice();
+    });
+
+    it('matchesFilters should be false if at least one filter returns false', () => {
+      controller.matchesFilters(barbazItem, filters).should.equal(false);
     });
 
-    it('should update state on switch event', () => {
-      let stateWidget = angular.element('#aliveOnlyState');
-      stateWidget.on.should.be.calledOnce();
-      stateWidget.on.should.be.calledWith('switchChange.bootstrapSwitch', sinon.match.func);
+    it('applyFilters should delegate filtering to matchesFilters', () => {
+      scope.allItems = [fooItem, barbazItem];
+      controller.applyFilters(filters);
+      controller.matchesFilters.should.be.calledTwice();
+      scope.items[0].should.equal(scope.allItems[0]);
+      scope.items.length.should.equal(1);
+    });
+
+    it('sortItems should sort the array using the compareFn', () => {
+      controller.scope.sortConfig = generateSortConfig(true, 'hostname');
+      scope.items = [fooItem, barbazItem];
+      controller.sortItems();
+      controller.compareFn.should.be.calledOnce();
+    });
+
+    describe('compareFn', () => {
+      it('compareFn should compare hostnames', () => {
+        controller.scope.sortConfig = generateSortConfig(true, 'name');
+        let result = controller.compareFn(fooItem, barbazItem);
+        result.should.equal(1);
+      });
+
+      it('compareFn should compare by timeCreated', () => {
+        controller.scope.sortConfig = generateSortConfig(true, 'timeCreated');
+        let result = controller.compareFn(fooItem, barbazItem);
+        result.should.equal(1);
 
-      svc.getSystems.should.be.calledOnce();
-      svc.getSystems.firstCall.should.be.calledWith(true);
-      let fn = stateWidget.on.args[0][1];
-      fn(null, false);
-      ctrl.aliveOnly.should.equal(false);
-      svc.getSystems.should.be.calledTwice();
-      svc.getSystems.secondCall.should.be.calledWith(false);
-      promise.resolve();
+        result = controller.compareFn(barbazItem, fooItem);
+        result.should.equal(-1);
+      });
+
+      it('compareFn should compare by numJvms', () => {
+        controller.scope.sortConfig = generateSortConfig(true, 'numJvms');
+
+        let result = controller.compareFn(fooItem, barbazItem);
+        result.should.equal(-1);
+
+        result = controller.compareFn(barbazItem, fooItem);
+        result.should.equal(1);
+      });
+
+      it('compareFn should allow for descending order', () => {
+        controller.scope.sortConfig = generateSortConfig(false, 'numJvms');
+        scope.sortConfig.currentField.id = 'numJvms';
+        let result = controller.compareFn(fooItem, barbazItem);
+        result.should.equal(1);
+
+        result = controller.compareFn(barbazItem, fooItem);
+        result.should.equal(-1);
+      });
+
+      it('compareFn should return 0 if an invalid field id is supplied', () => {
+        controller.scope.sortConfig = generateSortConfig(true, 'numJvms');
+        scope.sortConfig.currentField.id = 'fakeId';
+        let result = controller.compareFn(fooItem, barbazItem);
+        result.should.equal(0);
+      });
     });
   });
 
--- a/src/app/components/jvm-list/jvm-list.html	Thu Aug 17 09:12:58 2017 -0400
+++ b/src/app/components/jvm-list/jvm-list.html	Tue Aug 22 16:30:30 2017 -0400
@@ -3,63 +3,70 @@
     <li><a ui-sref="landing">Thermostat</a></li>
     <li><a ui-sref="jvmList" translate>jvmList.BREADCRUMB</a></li>
   </ol>
-
-  <customizable-error-message ng-show="ctrl.showErr" dismissible="true" err-message="errMessage" err-title="errTitle"></customizable-error-message>
-
-  <div ng-show="!ctrl.showErr" >
+  <div class="jvmList-container" ng-controller="JvmListController as controller">
     <div class="text-right">
       <label for="aliveOnlySwitch" class="label label-default" translate>jvmList.ALIVE_ONLY</label>
       <input class="bootstrap-switch pull-right" id="aliveOnlyState" name="aliveOnlySwitch" data-size="mini" type="checkbox" checked/>
-      <button type="button" class="btn btn-default" id="refreshButton" ng-click="ctrl.loadData()"><span class="fa fa-refresh"></span></button>
+      <button type="button" class="btn btn-default" id="refreshButton" ng-click="controller.loadData()"><span class="fa fa-refresh"></span></button>
+    </div>
+    <div class="pfToolbar col-md-12">
+      <pf-toolbar id="jvmListToolbar" config="toolbarConfig"></pf-toolbar>
     </div>
-
-    <uib-accordion close-others="true">
-
-      <uib-accordion-group class="cards-pf" ng-repeat="system in ctrl.systems" id="{{system.systemId}}" is-open="ctrl.systemsOpen[system.systemId]">
-
-        <uib-accordion-heading><a ui-sref="{'#': system.systemId}">{{system.systemId}}</a></uib-accordion-heading>
-        <div class="container-fluid container-cards-pf">
-          <div class="row row-cards-pf">
-
-            <div class="col-xs-12 col-md-4 col-lg-3">
-              <div ui-sref="systemInfo({ systemId: system.systemId })" class="card-pf card-pf-view card-pf-view-select card-pf-view-single-select">
+    <div class="pfListView col-md-12">
+      <pf-list-view config="listConfig"
+                    items="items"
+                    empty-state-config="emptyStateConfig">
+        <div class="list-view-pf-left">
+          <span class="pficon pficon-screen list-view-pf-icon-med"></span>
+        </div>
+        <div class="list-view-pf-description">
+          <div class="list-group-item-heading">
+            {{item.hostname}}
+          </div>
+          <div class="list-view-pf-additional-info">
+            <div class="list-view-pf-additional-info-item">
+              <span class="pficon pficon-virtual-machine"></span>
+              <strong>{{item.jvms.length}}</strong> JVMs
+            </div>
+            <div class="list-view-pf-additional-info-item" ng-click="$event.stopPropagation()">
+              <span class="pficon pficon-server"></span>
+              <a ui-sref="systemInfo({ systemId: item.systemId })" translate>jvmList.SYSTEM_INFO_LABEL</a>
+            </div>
+            <div class="list-view-pf-additional-info-item">
+              <span class="fa fa-clock-o"></span>
+               <span translate="jvmList.START_TIME_LIST" translate-values="{ date: '{{item.timeCreated | timestampToDate}}' }"></span>
+            </div>
+          </div>
+        </div>
+        <list-expanded-content>
+          <div class="jvmPagination col-md-12">
+            <pf-pagination
+                page-size="pageSize"
+                page-number="pageNumber"
+                page-size-increments="pageSizeIncrements"
+                num-total-items="$parent.item.jvms.length">
+            </pf-pagination>
+          </div>
+          <div ng-repeat="jvm in $parent.item.jvms | startFrom:(pageNumber - 1) * pageSize | limitTo:pageSize">
+            <div class="col-xs-12 col-sm-6 col-md-3 col-lg-3">
+              <div ui-sref="jvmInfo({ systemId: $parent.$parent.item.systemId, jvmId: jvm.jvmId })" class="card-pf card-pf-view card-pf-view-select">
                 <div class="card-pf-top-element">
-                  <span class="pficon pficon-server card-pf-icon-circle"></span>
+                  <span class="pficon pficon-virtual-machine card-pf-icon-circle"></span>
                 </div>
                 <div class="card-pf-body">
                   <h2 class="card-pf-title text-center ellipsis-word-wrap">
-                    {{system.systemId}}
+                    {{jvm.mainClass | extractClass:true}}
                   </h2>
-                  <p class="card-pf-info text-center" translate>jvmList.SYSTEM_CARD_LABEL</p>
+                  <div class="card-pf-info text-center" translate>
+                    <span class="pull-right pficon" ng-class="jvm.isAlive ? 'pficon-ok' : 'pficon-error-circle-o'"></span>
+                    <span translate="jvmList.START_TIME_CARD" translate-values="{ date: '{{jvm.startTime | timestampToDate}}' }"></span>
+                  </div>
                 </div>
               </div>
             </div>
-
-            <div ng-repeat="jvm in system.jvms">
-
-              <div class="col-xs-12 col-md-4 col-lg-3">
-                <div ui-sref="jvmInfo({ systemId: system.systemId, jvmId: jvm.jvmId })" class="card-pf card-pf-view card-pf-view-select card-pf-view-single-select">
-                  <div class="card-pf-top-element">
-                    <span class="pficon pficon-virtual-machine card-pf-icon-circle"></span>
-                  </div>
-                  <div class="card-pf-body">
-                    <h2 class="card-pf-title text-center ellipsis-word-wrap">
-                      {{jvm.mainClass | extractClass:true}}
-                    </h2>
-                    <div class="card-pf-info text-center" translate>
-                      <span translate="jvmList.CREATED_ON" translate-values="{ date: '{{jvm.startTime | timestampToDate}}' }"></span>
-                      <span class="pull-right pficon" ng-class="jvm.isAlive ? 'pficon-ok' : 'pficon-error-circle-o'"></span>
-                    </div>
-                  </div>
-                </div>
-              </div>
-
-            </div>
-          </div><!-- /row -->
-        </div>
-        </uib-accordion-group>
-
-        </uib-accordion>
+          </div>
+        </list-expanded-content>
+      </pf-list-view>
+    </div>
   </div>
-
 </div><!-- /container -->
--- a/src/app/components/jvm-list/jvm-list.module.js	Thu Aug 17 09:12:58 2017 -0400
+++ b/src/app/components/jvm-list/jvm-list.module.js	Tue Aug 22 16:30:30 2017 -0400
@@ -26,11 +26,13 @@
  */
 
 import JvmListController from './jvm-list.controller.js';
-import service from './jvm-list.service.js';
+import jvmListService from './jvm-list.service.js';
+import systemInfoService from 'components/system-info/system-info.service.js';
 
 export default angular
   .module('jvmList', [
     JvmListController,
-    service
+    jvmListService,
+    systemInfoService,
   ])
   .name;
--- a/src/app/components/multichart/multichart.html	Thu Aug 17 09:12:58 2017 -0400
+++ b/src/app/components/multichart/multichart.html	Tue Aug 22 16:30:30 2017 -0400
@@ -81,11 +81,11 @@
             </div>
 
             <div class="card-pf-body" ng-if="chartCtrl.chartConfig">
-              <div pf-line-chart id="chart-{{chart}}" config="chartCtrl.chartConfig"
+              <pf-line-chart id="chart-{{chart}}" config="chartCtrl.chartConfig"
                                                       chart-data="chartCtrl.chartData"
                                                       show-x-axis="true"
                                                       show-y-axis="true"
-                                                      ></div>
+                                                      ></pf-line-chart>
 
 
             </div>
--- a/src/app/components/system-info/system-info.html	Thu Aug 17 09:12:58 2017 -0400
+++ b/src/app/components/system-info/system-info.html	Tue Aug 22 16:30:30 2017 -0400
@@ -84,27 +84,27 @@
     <div class="row row-cards-pf">
       <div class="container container-cards-pf">
         <!-- System-CPU Donut Chart -->
-        <div class="col-xs-12 col-md-6" ng-controller="SystemCpuController as ctrl">
+        <div class="col-xs-12 col-sm-6 col-md-6" ng-controller="SystemCpuController as ctrl">
           <div class="card-pf card-pf-view">
             <div class="card-pf-heading">
               <label class="card-pf-title" translate>systemInfo.systemCpu.CHART_LABEL</label>
               <mc-add class="pull-right" svc-name="{{systemId}}-cpu" get-fn="ctrl.multichartFn()"></mc-add>
             </div>
             <div class="card-pf-body">
-              <div pf-donut-pct-chart id="cpuChart" config="ctrl.config" data="ctrl.data"></div>
+              <pf-donut-pct-chart id="cpuChart" config="ctrl.config" data="ctrl.data"></pf-donut-pct-chart>
             </div>
           </div>
         </div>
         <div class="system-memory-charts" ng-controller="SystemMemoryController as ctrl">
           <!-- System-Memory Donut Chart -->
-          <div class="col-xs-12 col-md-6">
+          <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">
-                <div pf-donut-pct-chart id="systemMemoryDonutChart" config="ctrl.donutConfig" data="ctrl.donutData"></div>
+                <pf-donut-pct-chart id="systemMemoryDonutChart" config="ctrl.donutConfig" data="ctrl.donutData"></pf-donut-pct-chart>
               </div>
             </div>
           </div>
@@ -141,7 +141,7 @@
               </div>
               <!-- Line Chart -->
               <div class="card-pf-body">
-                <div 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 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>
--- a/src/assets/scss/main.scss	Thu Aug 17 09:12:58 2017 -0400
+++ b/src/assets/scss/main.scss	Tue Aug 22 16:30:30 2017 -0400
@@ -46,3 +46,20 @@
   white-space: nowrap;
   text-overflow: ellipsis;
 }
+
+// list-view selected row background colour
+.list-group-item-container {
+  background: $color-pf-black-100;
+}
+
+// makes pf-pagination line up with the vm cards
+.jvmPagination {
+  padding-left: 10px;
+  padding-right: 10px;
+  padding-bottom: 10px;
+}
+
+// aligns the 'alive vms only' component with the pf-list-view
+.text-right {
+  padding-right: 20px;
+}
--- a/webpack.config.js	Thu Aug 17 09:12:58 2017 -0400
+++ b/webpack.config.js	Tue Aug 22 16:30:30 2017 -0400
@@ -54,6 +54,7 @@
       'bootstrap-switch': 'angular-patternfly/node_modules/patternfly/node_modules/bootstrap-switch',
 
       'assets': path.resolve(__dirname, 'src', 'assets'),
+      'components': path.resolve(__dirname, 'src', 'app', 'components'),
       'images': 'assets/images',
       'scss': 'assets/scss',
       'shared': path.resolve(__dirname, 'src', 'app', 'shared'),