diff --git a/ui/app/utils/classes/abstract-stats-tracker.js b/ui/app/utils/classes/abstract-stats-tracker.js index 25aa24ca4..488dec465 100644 --- a/ui/app/utils/classes/abstract-stats-tracker.js +++ b/ui/app/utils/classes/abstract-stats-tracker.js @@ -5,12 +5,12 @@ export default Mixin.create({ url: '', fetch() { - assert('StatTrackers need a fetch method, which should have an interface like window.fetch'); + assert('StatsTrackers need a fetch method, which should have an interface like window.fetch'); }, append(/* frame */) { assert( - 'StatTrackers need an append method, which takes the JSON response from a request to url as an argument' + 'StatsTrackers need an append method, which takes the JSON response from a request to url as an argument' ); }, diff --git a/ui/app/utils/classes/allocation-stats-tracker.js b/ui/app/utils/classes/allocation-stats-tracker.js index a7e92394d..46ca5365b 100644 --- a/ui/app/utils/classes/allocation-stats-tracker.js +++ b/ui/app/utils/classes/allocation-stats-tracker.js @@ -1,4 +1,4 @@ -import EmberObject, { computed } from '@ember/object'; +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'; @@ -14,7 +14,7 @@ const AllocationStatsTracker = EmberObject.extend(AbstractStatsTracker, { // Set via the stats computed property macro allocation: null, - bufferSize: 10, + bufferSize: 100, url: computed('allocation', function() { return `/v1/client/allocation/${this.get('allocation.id')}/stats`; @@ -75,11 +75,11 @@ const AllocationStatsTracker = EmberObject.extend(AbstractStatsTracker, { tasks: computed('allocation', function() { const bufferSize = this.get('bufferSize'); return this.get('allocation.taskGroup.tasks').map(task => ({ - task: task.get('name'), + task: get(task, 'name'), // Static figures, denominators for stats - reservedCPU: task.get('reservedCPU'), - reservedMemory: task.get('reservedMemory'), + reservedCPU: get(task, 'reservedCPU'), + reservedMemory: get(task, 'reservedMemory'), // Dynamic figures, collected over time // []{ timestamp: Date, used: Number, percent: Number } 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 000000000..51a041ffe --- /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' + ); +});