From a8480fab302247a7ee43feb3c2e42393259f725a Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 29 Aug 2018 17:14:57 -0700 Subject: [PATCH 1/9] An array subclass that enforces a maxLength The maxLength is enforced by removing elements from the head of the list. --- ui/app/utils/classes/rolling-array.js | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 ui/app/utils/classes/rolling-array.js diff --git a/ui/app/utils/classes/rolling-array.js b/ui/app/utils/classes/rolling-array.js new file mode 100644 index 000000000000..df81dac2a1d1 --- /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 returnValue; + }; + + 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; +} From 5c5e44df615ce90a9963e9cb10b67e548104ca2d Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 29 Aug 2018 17:15:55 -0700 Subject: [PATCH 2/9] An abstract class for capturing nomad stats It follows the form of poll -> json parse -> append, Where append is defined in subclasses to add data from the new frame to long-lived rolling arrays of data. --- .../utils/classes/abstract-stats-tracker.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 ui/app/utils/classes/abstract-stats-tracker.js 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..25aa24ca4cde --- /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('StatTrackers 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' + ); + }, + + 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)); + }, +}); From 1df44a6d08e82ab96d8b6ee4b2ffcf144a0b2c29 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 29 Aug 2018 17:17:18 -0700 Subject: [PATCH 3/9] An implementation of StatsTracker for allocations It accumulates CPU and Memory usage for the allocation as a whole as well as by task. --- .../utils/classes/allocation-stats-tracker.js | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 ui/app/utils/classes/allocation-stats-tracker.js 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..a7e92394d8f6 --- /dev/null +++ b/ui/app/utils/classes/allocation-stats-tracker.js @@ -0,0 +1,101 @@ +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 AllocationStatsTracker = EmberObject.extend(AbstractStatsTracker, { + // Set via the stats computed property macro + allocation: null, + + bufferSize: 10, + + 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: task.get('name'), + + // Static figures, denominators for stats + reservedCPU: task.get('reservedCPU'), + reservedMemory: task.get('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), + }); + }); +} From 008227204f94d6c3c4cea4b509d6501e25e907b5 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 29 Aug 2018 17:18:42 -0700 Subject: [PATCH 4/9] Example of usage of the AllocationsStatsTracker --- .../controllers/allocations/allocation/index.js | 16 ++++++++++++++++ ui/app/routes/allocations/allocation/index.js | 12 ++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 ui/app/routes/allocations/allocation/index.js diff --git a/ui/app/controllers/allocations/allocation/index.js b/ui/app/controllers/allocations/allocation/index.js index b6fd778bae21..38597df9d8f9 100644 --- a/ui/app/controllers/allocations/allocation/index.js +++ b/ui/app/controllers/allocations/allocation/index.js @@ -1,9 +1,14 @@ 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 +20,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*() { + while (true) { + yield this.get('stats').poll(); + yield timeout(1000); + } + }), + actions: { gotoTask(allocation, task) { this.transitionToRoute('allocations.allocation.task', task); 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(); + }, +}); From d25c0d60b94dbf53eeb5eed05630bdc84d30e447 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 30 Aug 2018 17:26:41 -0700 Subject: [PATCH 5/9] Unit tests for RollingArray --- ui/app/utils/classes/rolling-array.js | 2 +- ui/tests/unit/utils/rolling-array-test.js | 92 +++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 ui/tests/unit/utils/rolling-array-test.js diff --git a/ui/app/utils/classes/rolling-array.js b/ui/app/utils/classes/rolling-array.js index df81dac2a1d1..d8d945f13d83 100644 --- a/ui/app/utils/classes/rolling-array.js +++ b/ui/app/utils/classes/rolling-array.js @@ -23,7 +23,7 @@ export default function RollingArray(maxLength, ...items) { this.splice(0, surplus); } - return returnValue; + return Math.min(returnValue, this.maxLength); }; array.splice = function(...args) { 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'); +}); From 405cf822d81abf01e5b3e3a7d486f970b14ca29b Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 30 Aug 2018 19:57:28 -0700 Subject: [PATCH 6/9] Unit Tests for AllocationStatsTracker --- .../utils/classes/abstract-stats-tracker.js | 4 +- .../utils/classes/allocation-stats-tracker.js | 10 +- .../utils/allocation-stats-tracker-test.js | 430 ++++++++++++++++++ 3 files changed, 437 insertions(+), 7 deletions(-) create mode 100644 ui/tests/unit/utils/allocation-stats-tracker-test.js diff --git a/ui/app/utils/classes/abstract-stats-tracker.js b/ui/app/utils/classes/abstract-stats-tracker.js index 25aa24ca4cde..488dec465f06 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 a7e92394d8f6..46ca5365ba2b 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 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' + ); +}); From c455a399ddc2b4dc68170f8e82b4c84d45947663 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 31 Aug 2018 14:36:23 -0700 Subject: [PATCH 7/9] A StatsTracker for client-level statistics --- ui/app/utils/classes/node-stats-tracker.js | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 ui/app/utils/classes/node-stats-tracker.js 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), + }); + }); +} From 3c0977702e190e90ae3dbe23c0b7b44bba3e46f6 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 31 Aug 2018 14:36:43 -0700 Subject: [PATCH 8/9] Example usage of the NodeStatsTracker --- ui/app/controllers/clients/client.js | 13 +++++++++++++ ui/app/routes/clients/client.js | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/ui/app/controllers/clients/client.js b/ui/app/controllers/clients/client.js index 26dfc9b64dac..cdc15915c049 100644 --- a/ui/app/controllers/clients/client.js +++ b/ui/app/controllers/clients/client.js @@ -1,8 +1,10 @@ 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 +36,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*() { + while (true) { + yield this.get('stats').poll(); + yield timeout(1000); + } + }), + actions: { gotoAllocation(allocation) { this.transitionToRoute('allocations.allocation', allocation); diff --git a/ui/app/routes/clients/client.js b/ui/app/routes/clients/client.js index 9e2493030030..ea040b3dc5dc 100644 --- a/ui/app/routes/clients/client.js +++ b/ui/app/routes/clients/client.js @@ -38,4 +38,13 @@ export default Route.extend(WithWatchers, { watchAllocations: watchRelationship('allocations'), watchers: collect('watch', 'watchAllocations'), + + setupController(controller) { + this._super(...arguments); + controller.get('pollStats').perform(); + }, + + resetController(controller) { + controller.get('pollStats').cancelAll(); + }, }); From f8c8c3cec4e11cfadfa65124d5f0340cd01f1e66 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 31 Aug 2018 14:37:13 -0700 Subject: [PATCH 9/9] Test coverage for NodeStatsTracker --- .../allocations/allocation/index.js | 5 +- ui/app/controllers/clients/client.js | 5 +- ui/app/routes/clients/client.js | 6 +- ui/mirage/config.js | 2 +- ui/mirage/factories/node.js | 4 + .../unit/utils/node-stats-tracker-test.js | 222 ++++++++++++++++++ 6 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 ui/tests/unit/utils/node-stats-tracker-test.js diff --git a/ui/app/controllers/allocations/allocation/index.js b/ui/app/controllers/allocations/allocation/index.js index 38597df9d8f9..04408660eaa7 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 cdc15915c049..33873b361e83 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 ea040b3dc5dc..6cf9332ebee5 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 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/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' + ); +});