diff --git a/ui/app/controllers/allocations/allocation/index.js b/ui/app/controllers/allocations/allocation/index.js index b6fd778bae21..04408660eaa7 100644 --- a/ui/app/controllers/allocations/allocation/index.js +++ b/ui/app/controllers/allocations/allocation/index.js @@ -1,9 +1,15 @@ +import Ember from 'ember'; import { alias } from '@ember/object/computed'; import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { task, timeout } from 'ember-concurrency'; import Sortable from 'nomad-ui/mixins/sortable'; import { lazyClick } from 'nomad-ui/helpers/lazy-click'; +import { stats } from 'nomad-ui/utils/classes/allocation-stats-tracker'; export default Controller.extend(Sortable, { + token: service(), + queryParams: { sortProperty: 'sort', sortDescending: 'desc', @@ -15,6 +21,17 @@ export default Controller.extend(Sortable, { listToSort: alias('model.states'), sortedStates: alias('listSorted'), + stats: stats('model', function statsFetch() { + return url => this.get('token').authorizedRequest(url); + }), + + pollStats: task(function*() { + do { + yield this.get('stats').poll(); + yield timeout(1000); + } while (!Ember.testing); + }), + actions: { gotoTask(allocation, task) { this.transitionToRoute('allocations.allocation.task', task); diff --git a/ui/app/controllers/clients/client.js b/ui/app/controllers/clients/client.js index 26dfc9b64dac..33873b361e83 100644 --- a/ui/app/controllers/clients/client.js +++ b/ui/app/controllers/clients/client.js @@ -1,8 +1,11 @@ +import Ember from 'ember'; import { alias } from '@ember/object/computed'; import Controller from '@ember/controller'; import { computed } from '@ember/object'; +import { task, timeout } from 'ember-concurrency'; import Sortable from 'nomad-ui/mixins/sortable'; import Searchable from 'nomad-ui/mixins/searchable'; +import { stats } from 'nomad-ui/utils/classes/node-stats-tracker'; export default Controller.extend(Sortable, Searchable, { queryParams: { @@ -34,6 +37,17 @@ export default Controller.extend(Sortable, Searchable, { return this.get('model.drivers').sortBy('name'); }), + stats: stats('model', function statsFetch() { + return url => this.get('token').authorizedRequest(url); + }), + + pollStats: task(function*() { + do { + yield this.get('stats').poll(); + yield timeout(1000); + } while (!Ember.testing); + }), + actions: { gotoAllocation(allocation) { this.transitionToRoute('allocations.allocation', allocation); diff --git a/ui/app/routes/allocations/allocation/index.js b/ui/app/routes/allocations/allocation/index.js new file mode 100644 index 000000000000..6d23253776b0 --- /dev/null +++ b/ui/app/routes/allocations/allocation/index.js @@ -0,0 +1,12 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ + setupController(controller) { + this._super(...arguments); + controller.get('pollStats').perform(); + }, + + resetController(controller) { + controller.get('pollStats').cancelAll(); + }, +}); diff --git a/ui/app/routes/clients/client.js b/ui/app/routes/clients/client.js index 9e2493030030..6cf9332ebee5 100644 --- a/ui/app/routes/clients/client.js +++ b/ui/app/routes/clients/client.js @@ -38,4 +38,15 @@ export default Route.extend(WithWatchers, { watchAllocations: watchRelationship('allocations'), watchers: collect('watch', 'watchAllocations'), + + setupController(controller, model) { + this._super(...arguments); + if (model) { + controller.get('pollStats').perform(); + } + }, + + resetController(controller) { + controller.get('pollStats').cancelAll(); + }, }); diff --git a/ui/app/utils/classes/abstract-stats-tracker.js b/ui/app/utils/classes/abstract-stats-tracker.js new file mode 100644 index 000000000000..488dec465f06 --- /dev/null +++ b/ui/app/utils/classes/abstract-stats-tracker.js @@ -0,0 +1,27 @@ +import Mixin from '@ember/object/mixin'; +import { assert } from '@ember/debug'; + +export default Mixin.create({ + url: '', + + fetch() { + assert('StatsTrackers need a fetch method, which should have an interface like window.fetch'); + }, + + append(/* frame */) { + assert( + 'StatsTrackers need an append method, which takes the JSON response from a request to url as an argument' + ); + }, + + poll() { + const url = this.get('url'); + assert('Url must be defined', url); + + return this.get('fetch')(url) + .then(res => { + return res.json(); + }) + .then(frame => this.append(frame)); + }, +}); diff --git a/ui/app/utils/classes/allocation-stats-tracker.js b/ui/app/utils/classes/allocation-stats-tracker.js new file mode 100644 index 000000000000..46ca5365ba2b --- /dev/null +++ b/ui/app/utils/classes/allocation-stats-tracker.js @@ -0,0 +1,101 @@ +import EmberObject, { computed, get } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import RollingArray from 'nomad-ui/utils/classes/rolling-array'; +import AbstractStatsTracker from 'nomad-ui/utils/classes/abstract-stats-tracker'; + +const percent = (numerator, denominator) => { + if (!numerator || !denominator) { + return 0; + } + return numerator / denominator; +}; + +const AllocationStatsTracker = EmberObject.extend(AbstractStatsTracker, { + // Set via the stats computed property macro + allocation: null, + + bufferSize: 100, + + url: computed('allocation', function() { + return `/v1/client/allocation/${this.get('allocation.id')}/stats`; + }), + + append(frame) { + const cpuUsed = Math.floor(frame.ResourceUsage.CpuStats.TotalTicks) || 0; + this.get('cpu').push({ + timestamp: frame.Timestamp, + used: cpuUsed, + percent: percent(cpuUsed, this.get('reservedCPU')), + }); + + const memoryUsed = frame.ResourceUsage.MemoryStats.RSS; + this.get('memory').push({ + timestamp: frame.Timestamp, + used: memoryUsed, + percent: percent(memoryUsed / 1024 / 1024, this.get('reservedMemory')), + }); + + for (var taskName in frame.Tasks) { + const taskFrame = frame.Tasks[taskName]; + const stats = this.get('tasks').findBy('task', taskName); + + // If for whatever reason there is a task in the frame data that isn't in the + // allocation, don't attempt to append data for the task. + if (!stats) continue; + + const taskCpuUsed = Math.floor(taskFrame.ResourceUsage.CpuStats.TotalTicks) || 0; + stats.cpu.push({ + timestamp: taskFrame.Timestamp, + used: taskCpuUsed, + percent: percent(taskCpuUsed, stats.reservedCPU), + }); + + const taskMemoryUsed = taskFrame.ResourceUsage.MemoryStats.RSS; + stats.memory.push({ + timestamp: taskFrame.Timestamp, + used: taskMemoryUsed, + percent: percent(taskMemoryUsed / 1024 / 1024, stats.reservedMemory), + }); + } + }, + + // Static figures, denominators for stats + reservedCPU: alias('allocation.taskGroup.reservedCPU'), + reservedMemory: alias('allocation.taskGroup.reservedMemory'), + + // Dynamic figures, collected over time + // []{ timestamp: Date, used: Number, percent: Number } + cpu: computed('allocation', function() { + return RollingArray(this.get('bufferSize')); + }), + memory: computed('allocation', function() { + return RollingArray(this.get('bufferSize')); + }), + + tasks: computed('allocation', function() { + const bufferSize = this.get('bufferSize'); + return this.get('allocation.taskGroup.tasks').map(task => ({ + task: get(task, 'name'), + + // Static figures, denominators for stats + reservedCPU: get(task, 'reservedCPU'), + reservedMemory: get(task, 'reservedMemory'), + + // Dynamic figures, collected over time + // []{ timestamp: Date, used: Number, percent: Number } + cpu: RollingArray(bufferSize), + memory: RollingArray(bufferSize), + })); + }), +}); + +export default AllocationStatsTracker; + +export function stats(allocationProp, fetch) { + return computed(allocationProp, function() { + return AllocationStatsTracker.create({ + fetch: fetch.call(this), + allocation: this.get(allocationProp), + }); + }); +} diff --git a/ui/app/utils/classes/node-stats-tracker.js b/ui/app/utils/classes/node-stats-tracker.js new file mode 100644 index 000000000000..530a6619d752 --- /dev/null +++ b/ui/app/utils/classes/node-stats-tracker.js @@ -0,0 +1,62 @@ +import EmberObject, { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import RollingArray from 'nomad-ui/utils/classes/rolling-array'; +import AbstractStatsTracker from 'nomad-ui/utils/classes/abstract-stats-tracker'; + +const percent = (numerator, denominator) => { + if (!numerator || !denominator) { + return 0; + } + return numerator / denominator; +}; + +const NodeStatsTracker = EmberObject.extend(AbstractStatsTracker, { + // Set via the stats computed property macro + node: null, + + bufferSize: 100, + + url: computed('node', function() { + return `/v1/client/stats?node_id=${this.get('node.id')}`; + }), + + append(frame) { + const cpuUsed = Math.floor(frame.CPUTicksConsumed) || 0; + this.get('cpu').push({ + timestamp: frame.Timestamp, + used: cpuUsed, + percent: percent(cpuUsed, this.get('reservedCPU')), + }); + + const memoryUsed = frame.Memory.Used; + this.get('memory').push({ + timestamp: frame.Timestamp, + used: memoryUsed, + percent: percent(memoryUsed / 1024 / 1024, this.get('reservedMemory')), + }); + }, + + // Static figures, denominators for stats + reservedCPU: alias('node.resources.cpu'), + reservedMemory: alias('node.resources.memory'), + + // Dynamic figures, collected over time + // []{ timestamp: Date, used: Number, percent: Number } + cpu: computed('node', function() { + return RollingArray(this.get('bufferSize')); + }), + memory: computed('node', function() { + return RollingArray(this.get('bufferSize')); + }), +}); + +export default NodeStatsTracker; + +export function stats(nodeProp, fetch) { + return computed(nodeProp, function() { + return NodeStatsTracker.create({ + fetch: fetch.call(this), + node: this.get(nodeProp), + }); + }); +} diff --git a/ui/app/utils/classes/rolling-array.js b/ui/app/utils/classes/rolling-array.js new file mode 100644 index 000000000000..d8d945f13d83 --- /dev/null +++ b/ui/app/utils/classes/rolling-array.js @@ -0,0 +1,45 @@ +// An array with a max length. +// +// When max length is surpassed, items are removed from +// the front of the array. + +// Using Classes to extend Array is unsupported in Babel so this less +// ideal approach is taken: https://babeljs.io/docs/en/caveats#classes +export default function RollingArray(maxLength, ...items) { + const array = new Array(...items); + array.maxLength = maxLength; + + // Capture the originals of each array method, but + // associate them with the array to prevent closures. + array._push = array.push; + array._splice = array.splice; + array._unshift = array.unshift; + + array.push = function(...items) { + const returnValue = this._push(...items); + + const surplus = this.length - this.maxLength; + if (surplus > 0) { + this.splice(0, surplus); + } + + return Math.min(returnValue, this.maxLength); + }; + + array.splice = function(...args) { + const returnValue = this._splice(...args); + + const surplus = this.length - this.maxLength; + if (surplus > 0) { + this._splice(0, surplus); + } + + return returnValue; + }; + + array.unshift = function() { + throw new Error('Cannot unshift onto a RollingArray'); + }; + + return array; +} diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 3cc92a434dc9..d0b834a42949 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 13c42736cf77..e8ec62a9d910 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/allocation-stats-tracker-test.js b/ui/tests/unit/utils/allocation-stats-tracker-test.js new file mode 100644 index 000000000000..51a041ffe916 --- /dev/null +++ b/ui/tests/unit/utils/allocation-stats-tracker-test.js @@ -0,0 +1,430 @@ +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 AllocationStatsTracker, { stats } from 'nomad-ui/utils/classes/allocation-stats-tracker'; +import fetch from 'nomad-ui/utils/fetch'; + +module('Unit | Util | AllocationStatsTracker'); + +const refDate = Date.now(); + +const MockAllocation = overrides => + assign( + { + id: 'some-identifier', + taskGroup: { + reservedCPU: 200, + reservedMemory: 512, + tasks: [ + { + name: 'service', + reservedCPU: 100, + reservedMemory: 256, + }, + { + name: 'log-shipper', + reservedCPU: 50, + reservedMemory: 128, + }, + { + name: 'sidecar', + reservedCPU: 50, + reservedMemory: 128, + }, + ], + }, + }, + overrides + ); + +const mockFrame = step => ({ + ResourceUsage: { + CpuStats: { + TotalTicks: step + 100, + }, + MemoryStats: { + RSS: (step + 400) * 1024 * 1024, + }, + }, + Tasks: { + service: { + ResourceUsage: { + CpuStats: { + TotalTicks: step + 50, + }, + MemoryStats: { + RSS: (step + 100) * 1024 * 1024, + }, + }, + Timestamp: refDate + step, + }, + 'log-shipper': { + ResourceUsage: { + CpuStats: { + TotalTicks: step + 25, + }, + MemoryStats: { + RSS: (step + 50) * 1024 * 1024, + }, + }, + Timestamp: refDate + step * 10, + }, + sidecar: { + ResourceUsage: { + CpuStats: { + TotalTicks: step + 26, + }, + MemoryStats: { + RSS: (step + 51) * 1024 * 1024, + }, + }, + Timestamp: refDate + step * 100, + }, + }, + Timestamp: refDate + step * 1000, +}); + +test('the AllocationStatsTracker constructor expects a fetch definition and an allocation', function(assert) { + const tracker = AllocationStatsTracker.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 allocation id', function(assert) { + const allocation = MockAllocation(); + const tracker = AllocationStatsTracker.create({ fetch, allocation }); + + assert.equal( + tracker.get('url'), + `/v1/client/allocation/${allocation.id}/stats`, + 'Url is derived from the allocation id' + ); +}); + +test('reservedCPU and reservedMemory properties come from the allocation', function(assert) { + const allocation = MockAllocation(); + const tracker = AllocationStatsTracker.create({ fetch, allocation }); + + assert.equal( + tracker.get('reservedCPU'), + allocation.taskGroup.reservedCPU, + 'reservedCPU comes from the allocation task group' + ); + assert.equal( + tracker.get('reservedMemory'), + allocation.taskGroup.reservedMemory, + 'reservedMemory comes from the allocation task group' + ); +}); + +test('the tasks list comes from the allocation', function(assert) { + const allocation = MockAllocation(); + const tracker = AllocationStatsTracker.create({ fetch, allocation }); + + assert.equal( + tracker.get('tasks.length'), + allocation.taskGroup.tasks.length, + 'tasks matches lengths with the allocation task group' + ); + allocation.taskGroup.tasks.forEach(task => { + const trackerTask = tracker.get('tasks').findBy('task', task.name); + assert.equal(trackerTask.reservedCPU, task.reservedCPU, `CPU matches for task ${task.name}`); + assert.equal( + trackerTask.reservedMemory, + task.reservedMemory, + `Memory matches for task ${task.name}` + ); + }); +}); + +test('poll results in requesting the url and calling append with the resulting JSON', function(assert) { + const allocation = MockAllocation(); + const tracker = AllocationStatsTracker.create({ fetch, allocation, append: sinon.spy() }); + const mockFrame = { + Some: { + data: ['goes', 'here'], + twelve: 12, + }, + }; + + const server = new Pretender(function() { + this.get('/v1/client/allocation/:id/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/allocation/${allocation.id}/stats`, + 'The correct URL was requested' + ); + + return wait().then(() => { + assert.ok( + tracker.append.calledWith(mockFrame), + 'The JSON response was passed onto append as a POJO' + ); + + server.shutdown(); + }); +}); + +test('append appropriately maps a data frame to the tracked stats for cpu and memory for the allocation as well as individual tasks', function(assert) { + const allocation = MockAllocation(); + const tracker = AllocationStatsTracker.create({ fetch, allocation }); + + assert.deepEqual(tracker.get('cpu'), [], 'No tracked cpu yet'); + assert.deepEqual(tracker.get('memory'), [], 'No tracked memory yet'); + + assert.deepEqual( + tracker.get('tasks'), + [ + { task: 'service', reservedCPU: 100, reservedMemory: 256, cpu: [], memory: [] }, + { task: 'log-shipper', reservedCPU: 50, reservedMemory: 128, cpu: [], memory: [] }, + { task: 'sidecar', reservedCPU: 50, reservedMemory: 128, cpu: [], memory: [] }, + ], + 'tasks represents the tasks for the allocation with no stats yet' + ); + + tracker.append(mockFrame(1)); + + assert.deepEqual( + tracker.get('cpu'), + [{ timestamp: refDate + 1000, used: 101, percent: 101 / 200 }], + 'One frame of cpu' + ); + assert.deepEqual( + tracker.get('memory'), + [{ timestamp: refDate + 1000, used: 401 * 1024 * 1024, percent: 401 / 512 }], + 'One frame of memory' + ); + + assert.deepEqual( + tracker.get('tasks'), + [ + { + task: 'service', + reservedCPU: 100, + reservedMemory: 256, + cpu: [{ timestamp: refDate + 1, used: 51, percent: 51 / 100 }], + memory: [{ timestamp: refDate + 1, used: 101 * 1024 * 1024, percent: 101 / 256 }], + }, + { + task: 'log-shipper', + reservedCPU: 50, + reservedMemory: 128, + cpu: [{ timestamp: refDate + 10, used: 26, percent: 26 / 50 }], + memory: [{ timestamp: refDate + 10, used: 51 * 1024 * 1024, percent: 51 / 128 }], + }, + { + task: 'sidecar', + reservedCPU: 50, + reservedMemory: 128, + cpu: [{ timestamp: refDate + 100, used: 27, percent: 27 / 50 }], + memory: [{ timestamp: refDate + 100, used: 52 * 1024 * 1024, percent: 52 / 128 }], + }, + ], + 'tasks represents the tasks for the allocation, each with one frame of stats' + ); + + tracker.append(mockFrame(2)); + + assert.deepEqual( + tracker.get('cpu'), + [ + { timestamp: refDate + 1000, used: 101, percent: 101 / 200 }, + { timestamp: refDate + 2000, used: 102, percent: 102 / 200 }, + ], + 'Two frames of cpu' + ); + assert.deepEqual( + tracker.get('memory'), + [ + { timestamp: refDate + 1000, used: 401 * 1024 * 1024, percent: 401 / 512 }, + { timestamp: refDate + 2000, used: 402 * 1024 * 1024, percent: 402 / 512 }, + ], + 'Two frames of memory' + ); + + assert.deepEqual( + tracker.get('tasks'), + [ + { + task: 'service', + reservedCPU: 100, + reservedMemory: 256, + cpu: [ + { timestamp: refDate + 1, used: 51, percent: 51 / 100 }, + { timestamp: refDate + 2, used: 52, percent: 52 / 100 }, + ], + memory: [ + { timestamp: refDate + 1, used: 101 * 1024 * 1024, percent: 101 / 256 }, + { timestamp: refDate + 2, used: 102 * 1024 * 1024, percent: 102 / 256 }, + ], + }, + { + task: 'log-shipper', + reservedCPU: 50, + reservedMemory: 128, + cpu: [ + { timestamp: refDate + 10, used: 26, percent: 26 / 50 }, + { timestamp: refDate + 20, used: 27, percent: 27 / 50 }, + ], + memory: [ + { timestamp: refDate + 10, used: 51 * 1024 * 1024, percent: 51 / 128 }, + { timestamp: refDate + 20, used: 52 * 1024 * 1024, percent: 52 / 128 }, + ], + }, + { + task: 'sidecar', + reservedCPU: 50, + reservedMemory: 128, + cpu: [ + { timestamp: refDate + 100, used: 27, percent: 27 / 50 }, + { timestamp: refDate + 200, used: 28, percent: 28 / 50 }, + ], + memory: [ + { timestamp: refDate + 100, used: 52 * 1024 * 1024, percent: 52 / 128 }, + { timestamp: refDate + 200, used: 53 * 1024 * 1024, percent: 53 / 128 }, + ], + }, + ], + 'tasks represents the tasks for the allocation, each with two frames of stats' + ); +}); + +test('each stat list has maxLength equal to bufferSize', function(assert) { + const allocation = MockAllocation(); + const bufferSize = 10; + const tracker = AllocationStatsTracker.create({ fetch, allocation, 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 + 11000, + 'Old frames are removed in favor of newer ones' + ); + assert.equal( + tracker.get('memory')[0].timestamp, + refDate + 11000, + 'Old frames are removed in favor of newer ones' + ); + + tracker.get('tasks').forEach(task => { + assert.equal( + task.cpu.length, + bufferSize, + `20 calls to append, only ${bufferSize} frames in the stats array` + ); + assert.equal( + task.memory.length, + bufferSize, + `20 calls to append, only ${bufferSize} frames in the stats array` + ); + }); + + assert.equal( + tracker.get('tasks').findBy('task', 'service').cpu[0].timestamp, + refDate + 11, + 'Old frames are removed in favor of newer ones' + ); + assert.equal( + tracker.get('tasks').findBy('task', 'service').memory[0].timestamp, + refDate + 11, + 'Old frames are removed in favor of newer ones' + ); + + assert.equal( + tracker.get('tasks').findBy('task', 'log-shipper').cpu[0].timestamp, + refDate + 110, + 'Old frames are removed in favor of newer ones' + ); + assert.equal( + tracker.get('tasks').findBy('task', 'log-shipper').memory[0].timestamp, + refDate + 110, + 'Old frames are removed in favor of newer ones' + ); + + assert.equal( + tracker.get('tasks').findBy('task', 'sidecar').cpu[0].timestamp, + refDate + 1100, + 'Old frames are removed in favor of newer ones' + ); + assert.equal( + tracker.get('tasks').findBy('task', 'sidecar').memory[0].timestamp, + refDate + 1100, + 'Old frames are removed in favor of newer ones' + ); +}); + +test('the stats computed property macro constructs an AllocationStatsTracker based on an allocationProp and a fetch definition', function(assert) { + const allocation = MockAllocation(); + const fetchSpy = sinon.spy(); + + const SomeClass = EmberObject.extend({ + stats: stats('alloc', function() { + return () => fetchSpy(this); + }), + }); + const someObject = SomeClass.create({ + alloc: allocation, + }); + + assert.equal( + someObject.get('stats.url'), + `/v1/client/allocation/${allocation.id}/stats`, + 'stats computed property macro creates an AllocationStatsTracker' + ); + + 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 AllocationStatsTracker instance' + ); +}); + +test('changing the value of the allocationProp constructs a new AllocationStatsTracker', function(assert) { + const alloc1 = MockAllocation(); + const alloc2 = MockAllocation(); + const SomeClass = EmberObject.extend({ + stats: stats('alloc', () => fetch), + }); + + const someObject = SomeClass.create({ + alloc: alloc1, + }); + + const stats1 = someObject.get('stats'); + + someObject.set('alloc', alloc2); + const stats2 = someObject.get('stats'); + + assert.notOk( + stats1 === stats2, + 'Changing the value of alloc results in creating a new AllocationStatsTracker instance' + ); +}); 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 000000000000..55c6c070fbd9 --- /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' + ); +}); diff --git a/ui/tests/unit/utils/rolling-array-test.js b/ui/tests/unit/utils/rolling-array-test.js new file mode 100644 index 000000000000..dc9870a05aac --- /dev/null +++ b/ui/tests/unit/utils/rolling-array-test.js @@ -0,0 +1,92 @@ +import { isArray } from '@ember/array'; +import { module, test } from 'ember-qunit'; +import RollingArray from 'nomad-ui/utils/classes/rolling-array'; + +module('Unit | Util | RollingArray'); + +test('has a maxLength property that gets set in the constructor', function(assert) { + const array = RollingArray(10, 'a', 'b', 'c'); + assert.equal(array.maxLength, 10, 'maxLength is set in the constructor'); + assert.deepEqual( + array, + ['a', 'b', 'c'], + 'additional arguments to the constructor become elements' + ); +}); + +test('push works like Array#push', function(assert) { + const array = RollingArray(10); + const pushReturn = array.push('a'); + assert.equal( + pushReturn, + array.length, + 'the return value from push is equal to the return value of Array#push' + ); + assert.equal(array[0], 'a', 'the arguments passed to push are appended to the array'); + + array.push('b', 'c', 'd'); + assert.deepEqual( + array, + ['a', 'b', 'c', 'd'], + 'the elements already in the array are left in tact and new elements are appended' + ); +}); + +test('when pushing past maxLength, items are removed from the head of the array', function(assert) { + const array = RollingArray(3); + const pushReturn = array.push(1, 2, 3, 4); + assert.deepEqual( + array, + [2, 3, 4], + 'The first argument to push is not in the array, but the following three are' + ); + assert.equal( + pushReturn, + array.length, + 'The return value of push is still the array length despite more arguments than possible were provided to push' + ); +}); + +test('when splicing past maxLength, items are removed from the head of the array', function(assert) { + const array = RollingArray(3, 'a', 'b', 'c'); + + array.splice(1, 0, 'z'); + assert.deepEqual( + array, + ['z', 'b', 'c'], + 'The new element is inserted as the second element in the array and the first element is removed due to maxLength restrictions' + ); + + array.splice(0, 0, 'pickme'); + assert.deepEqual( + array, + ['z', 'b', 'c'], + 'The new element never makes it into the array since it was added at the head of the array and immediately removed' + ); + + array.splice(0, 1, 'pickme'); + assert.deepEqual( + array, + ['pickme', 'b', 'c'], + 'The new element makes it into the array since the previous element at the head of the array is first removed due to the second argument to splice' + ); +}); + +test('unshift throws instead of prepending elements', function(assert) { + const array = RollingArray(5); + + assert.throws( + () => { + array.unshift(1); + }, + /Cannot unshift/, + 'unshift is not supported, but is not undefined' + ); +}); + +test('RollingArray is an instance of Array', function(assert) { + const array = RollingArray(5); + assert.ok(array.constructor === Array, 'The constructor is Array'); + assert.ok(array instanceof Array, 'The instanceof check is true'); + assert.ok(isArray(array), 'The ember isArray helper works'); +});