diff --git a/ui/app/controllers/allocations/allocation/index.js b/ui/app/controllers/allocations/allocation/index.js index 38597df9d..04408660e 100644 --- a/ui/app/controllers/allocations/allocation/index.js +++ b/ui/app/controllers/allocations/allocation/index.js @@ -1,3 +1,4 @@ +import Ember from 'ember'; import { alias } from '@ember/object/computed'; import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; @@ -25,10 +26,10 @@ export default Controller.extend(Sortable, { }), pollStats: task(function*() { - while (true) { + do { yield this.get('stats').poll(); yield timeout(1000); - } + } while (!Ember.testing); }), actions: { diff --git a/ui/app/controllers/clients/client.js b/ui/app/controllers/clients/client.js index cdc15915c..33873b361 100644 --- a/ui/app/controllers/clients/client.js +++ b/ui/app/controllers/clients/client.js @@ -1,3 +1,4 @@ +import Ember from 'ember'; import { alias } from '@ember/object/computed'; import Controller from '@ember/controller'; import { computed } from '@ember/object'; @@ -41,10 +42,10 @@ export default Controller.extend(Sortable, Searchable, { }), pollStats: task(function*() { - while (true) { + do { yield this.get('stats').poll(); yield timeout(1000); - } + } while (!Ember.testing); }), actions: { diff --git a/ui/app/routes/clients/client.js b/ui/app/routes/clients/client.js index ea040b3dc..6cf9332eb 100644 --- a/ui/app/routes/clients/client.js +++ b/ui/app/routes/clients/client.js @@ -39,9 +39,11 @@ export default Route.extend(WithWatchers, { watchers: collect('watch', 'watchAllocations'), - setupController(controller) { + setupController(controller, model) { this._super(...arguments); - controller.get('pollStats').perform(); + if (model) { + controller.get('pollStats').perform(); + } }, resetController(controller) { diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 3cc92a434..d0b834a42 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -306,7 +306,7 @@ export default function() { this.get('/client/allocation/:id/stats', clientAllocationStatsHandler); this.get('/client/fs/logs/:allocation_id', clientAllocationLog); - this.get('/client/v1/client/stats', function({ clientStats }, { queryParams }) { + this.get('/client/stats', function({ clientStats }, { queryParams }) { return this.serialize(clientStats.find(queryParams.node_id)); }); diff --git a/ui/mirage/factories/node.js b/ui/mirage/factories/node.js index 13c42736c..e8ec62a9d 100644 --- a/ui/mirage/factories/node.js +++ b/ui/mirage/factories/node.js @@ -117,6 +117,10 @@ export default Factory.extend({ node.update({ eventIds: events.mapBy('id'), }); + + server.create('client-stats', { + id: node.id, + }); }, }); diff --git a/ui/tests/unit/utils/node-stats-tracker-test.js b/ui/tests/unit/utils/node-stats-tracker-test.js new file mode 100644 index 000000000..55c6c070f --- /dev/null +++ b/ui/tests/unit/utils/node-stats-tracker-test.js @@ -0,0 +1,222 @@ +import EmberObject from '@ember/object'; +import { assign } from '@ember/polyfills'; +import wait from 'ember-test-helpers/wait'; +import { module, test } from 'ember-qunit'; +import sinon from 'sinon'; +import Pretender from 'pretender'; +import NodeStatsTracker, { stats } from 'nomad-ui/utils/classes/node-stats-tracker'; +import fetch from 'nomad-ui/utils/fetch'; + +module('Unit | Util | NodeStatsTracker'); + +const refDate = Date.now(); + +const MockNode = overrides => + assign( + { + id: 'some-identifier', + resources: { + cpu: 2000, + memory: 4096, + }, + }, + overrides + ); + +const mockFrame = step => ({ + CPUTicksConsumed: step + 1000, + Memory: { + Used: (step + 2048) * 1024 * 1024, + }, + Timestamp: refDate + step, +}); + +test('the NodeStatsTracker constructor expects a fetch definition and a node', function(assert) { + const tracker = NodeStatsTracker.create(); + assert.throws( + () => { + tracker.poll(); + }, + /StatsTrackers need a fetch method/, + 'Polling does not work without a fetch method provided' + ); +}); + +test('the url property is computed based off the node id', function(assert) { + const node = MockNode(); + const tracker = NodeStatsTracker.create({ fetch, node }); + + assert.equal( + tracker.get('url'), + `/v1/client/stats?node_id=${node.id}`, + 'Url is derived from the node id' + ); +}); + +test('reservedCPU and reservedMemory properties come from the node', function(assert) { + const node = MockNode(); + const tracker = NodeStatsTracker.create({ fetch, node }); + + assert.equal(tracker.get('reservedCPU'), node.resources.cpu, 'reservedCPU comes from the node'); + assert.equal( + tracker.get('reservedMemory'), + node.resources.memory, + 'reservedMemory comes from the node' + ); +}); + +test('poll results in requesting the url and calling append with the resulting JSON', function(assert) { + const node = MockNode(); + const tracker = NodeStatsTracker.create({ fetch, node, append: sinon.spy() }); + const mockFrame = { + Some: { + data: ['goes', 'here'], + twelve: 12, + }, + }; + + const server = new Pretender(function() { + this.get('/v1/client/stats', () => [200, {}, JSON.stringify(mockFrame)]); + }); + + tracker.poll(); + + assert.equal(server.handledRequests.length, 1, 'Only one request was made'); + assert.equal( + server.handledRequests[0].url, + `/v1/client/stats?node_id=${node.id}`, + 'The correct URL was requested' + ); + + return wait().then(() => { + assert.ok( + tracker.append.calledWith(mockFrame), + 'The JSON response was passed into append as a POJO' + ); + + server.shutdown(); + }); +}); + +test('append appropriately maps a data frame to the tracked stats for cpu and memory for the node', function(assert) { + const node = MockNode(); + const tracker = NodeStatsTracker.create({ fetch, node }); + + assert.deepEqual(tracker.get('cpu'), [], 'No tracked cpu yet'); + assert.deepEqual(tracker.get('memory'), [], 'No tracked memory yet'); + + tracker.append(mockFrame(1)); + + assert.deepEqual( + tracker.get('cpu'), + [{ timestamp: refDate + 1, used: 1001, percent: 1001 / 2000 }], + 'One frame of cpu' + ); + + assert.deepEqual( + tracker.get('memory'), + [{ timestamp: refDate + 1, used: 2049 * 1024 * 1024, percent: 2049 / 4096 }], + 'One frame of memory' + ); + + tracker.append(mockFrame(2)); + + assert.deepEqual( + tracker.get('cpu'), + [ + { timestamp: refDate + 1, used: 1001, percent: 1001 / 2000 }, + { timestamp: refDate + 2, used: 1002, percent: 1002 / 2000 }, + ], + 'Two frames of cpu' + ); + + assert.deepEqual( + tracker.get('memory'), + [ + { timestamp: refDate + 1, used: 2049 * 1024 * 1024, percent: 2049 / 4096 }, + { timestamp: refDate + 2, used: 2050 * 1024 * 1024, percent: 2050 / 4096 }, + ], + 'Two frames of memory' + ); +}); + +test('each stat list has maxLength equal to bufferSize', function(assert) { + const node = MockNode(); + const bufferSize = 10; + const tracker = NodeStatsTracker.create({ fetch, node, bufferSize }); + + for (let i = 1; i <= 20; i++) { + tracker.append(mockFrame(i)); + } + + assert.equal( + tracker.get('cpu.length'), + bufferSize, + `20 calls to append, only ${bufferSize} frames in the stats array` + ); + assert.equal( + tracker.get('memory.length'), + bufferSize, + `20 calls to append, only ${bufferSize} frames in the stats array` + ); + + assert.equal( + tracker.get('cpu')[0].timestamp, + refDate + 11, + 'Old frames are removed in favor of newer ones' + ); + assert.equal( + tracker.get('memory')[0].timestamp, + refDate + 11, + 'Old frames are removed in favor of newer ones' + ); +}); + +test('the stats computed property macro constructs a NodeStatsTracker based on a nodeProp and a fetch definition', function(assert) { + const node = MockNode(); + const fetchSpy = sinon.spy(); + + const SomeClass = EmberObject.extend({ + stats: stats('theNode', function() { + return () => fetchSpy(this); + }), + }); + const someObject = SomeClass.create({ + theNode: node, + }); + + assert.equal( + someObject.get('stats.url'), + `/v1/client/stats?node_id=${node.id}`, + 'stats computed property macro creates a NodeStatsTracker' + ); + + someObject.get('stats').fetch(); + + assert.ok( + fetchSpy.calledWith(someObject), + 'the fetch factory passed into the macro gets called to assign a bound version of fetch to the NodeStatsTracker instance' + ); +}); + +test('changing the value of the nodeProp constructs a new NodeStatsTracker', function(assert) { + const node1 = MockNode(); + const node2 = MockNode(); + const SomeClass = EmberObject.extend({ + stats: stats('theNode', () => fetch), + }); + + const someObject = SomeClass.create({ + theNode: node1, + }); + + const stats1 = someObject.get('stats'); + + someObject.set('theNode', node2); + const stats2 = someObject.get('stats'); + + assert.notOk( + stats1 === stats2, + 'Changing the value of the node results in creating a new NodeStatsTracker instance' + ); +});