From a8480fab302247a7ee43feb3c2e42393259f725a Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 29 Aug 2018 17:14:57 -0700 Subject: [PATCH 01/59] 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 02/59] 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 03/59] 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 04/59] 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 05/59] 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 06/59] 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 07/59] 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 08/59] 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 e8c860b33042f843f70761a4ad7c0c87245304da Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 09:55:39 -0700 Subject: [PATCH 09/59] Bring in new d3 dependencies --- ui/package.json | 6 ++++++ ui/yarn.lock | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/ui/package.json b/ui/package.json index b7579331c1b1..483a73fef3f3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,7 +28,13 @@ "broccoli-asset-rev": "^2.4.5", "bulma": "0.6.1", "core-js": "^2.4.1", + "d3-array": "^1.2.0", + "d3-axis": "^1.0.0", + "d3-format": "^1.3.0", "d3-selection": "^1.1.0", + "d3-scale": "^1.0.0", + "d3-shape": "^1.2.0", + "d3-time-format": "^2.1.0", "d3-transition": "^1.1.0", "ember-ajax": "^3.0.0", "ember-auto-import": "^1.0.1", diff --git a/ui/yarn.lock b/ui/yarn.lock index 5414f4bd4a3c..943460d20146 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -2611,6 +2611,18 @@ cyclist@~0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" +d3-array@^1.2.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" + +d3-axis@^1.0.0: + version "1.0.12" + resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9" + +d3-collection@1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e" + d3-color@1: version "1.0.3" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.0.3.tgz#bc7643fca8e53a8347e2fbdaffa236796b58509b" @@ -2623,16 +2635,52 @@ d3-ease@1: version "1.0.3" resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.3.tgz#68bfbc349338a380c44d8acc4fbc3304aa2d8c0e" +d3-format@1, d3-format@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.3.2.tgz#6a96b5e31bcb98122a30863f7d92365c00603562" + d3-interpolate@1: version "1.1.5" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.1.5.tgz#69e099ff39214716e563c9aec3ea9d1ea4b8a79f" dependencies: d3-color "1" +d3-path@1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.7.tgz#8de7cd693a75ac0b5480d3abaccd94793e58aae8" + +d3-scale@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-1.0.7.tgz#fa90324b3ea8a776422bd0472afab0b252a0945d" + dependencies: + d3-array "^1.2.0" + d3-collection "1" + d3-color "1" + d3-format "1" + d3-interpolate "1" + d3-time "1" + d3-time-format "2" + d3-selection@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.2.0.tgz#1b8ec1c7cedadfb691f2ba20a4a3cfbeb71bbc88" +d3-shape@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.2.2.tgz#f9dba3777a5825f9a8ce8bc928da08c17679e9a7" + dependencies: + d3-path "1" + +d3-time-format@2, d3-time-format@^2.1.0: + version "2.1.3" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.1.3.tgz#ae06f8e0126a9d60d6364eac5b1533ae1bac826b" + dependencies: + d3-time "1" + +d3-time@1: + version "1.0.10" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.10.tgz#8259dd71288d72eeacfd8de281c4bf5c7393053c" + d3-timer@1: version "1.0.6" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.6.tgz#4044bf15d7025c06ce7d1149f73cd07b54dbd784" From 5b927d36544c3e8625c00d14e50fb4915ef651bf Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 09:57:33 -0700 Subject: [PATCH 10/59] Add chart color swatches based on css colors --- ui/app/styles/charts/colors.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ui/app/styles/charts/colors.scss b/ui/app/styles/charts/colors.scss index e204c723f2b0..6eedeeaecf42 100644 --- a/ui/app/styles/charts/colors.scss +++ b/ui/app/styles/charts/colors.scss @@ -85,4 +85,12 @@ $lost: $dark; &.lost { background: $lost; } + + @each $name, $pair in $colors { + $color: nth($pair, 1); + + &.is-#{$name} { + background: $color; + } + } } From dc63be2a8fd67fa48003f90c63970e925e1f0330 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 09:57:54 -0700 Subject: [PATCH 11/59] Add support for non-list tooltips in charts --- ui/app/styles/charts/tooltip.scss | 57 +++++++++++++++++-------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/ui/app/styles/charts/tooltip.scss b/ui/app/styles/charts/tooltip.scss index 580e0ab3b87d..3e128134b893 100644 --- a/ui/app/styles/charts/tooltip.scss +++ b/ui/app/styles/charts/tooltip.scss @@ -53,41 +53,48 @@ ol { list-style: none; + } - li { - display: flex; - flex-flow: row; - flex-wrap: nowrap; - justify-content: space-between; - padding: 0.25rem 0.5rem; + ol > li, + p { + display: flex; + flex-flow: row; + flex-wrap: nowrap; + justify-content: space-between; + padding: 0.25rem 0.5rem; - span { - display: inline-block; - } + span { + display: inline-block; + } - .label { - font-weight: $weight-bold; - color: rgba($black, 0.6); - margin: 0; + .label { + font-weight: $weight-bold; + color: $black; + margin: 0; - &.is-empty { - color: rgba($grey, 0.6); - } + &.is-empty { + color: rgba($grey, 0.6); } + } + } - &.active { - color: $black; - background: $white-ter; + ol > li { + .label { + color: rgba($black, 0.6); + } - .label { - color: $black; - } - } + &.active { + color: $black; + background: $white-ter; - + li { - border-top: 1px solid $grey-light; + .label { + color: $black; } } + + + li { + border-top: 1px solid $grey-light; + } } } } From f8c8c3cec4e11cfadfa65124d5f0340cd01f1e66 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 31 Aug 2018 14:37:13 -0700 Subject: [PATCH 12/59] 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' + ); +}); From 48df4d2d29e55f0ae05610ed0adaf3f4cc0c42f2 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 09:59:02 -0700 Subject: [PATCH 13/59] New line chart component --- ui/app/components/line-chart.js | 292 +++++++++++++++++++++ ui/app/styles/charts.scss | 1 + ui/app/styles/charts/line-chart.scss | 68 +++++ ui/app/templates/components/line-chart.hbs | 29 ++ 4 files changed, 390 insertions(+) create mode 100644 ui/app/components/line-chart.js create mode 100644 ui/app/styles/charts/line-chart.scss create mode 100644 ui/app/templates/components/line-chart.hbs diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js new file mode 100644 index 000000000000..f3aa3bbbeca5 --- /dev/null +++ b/ui/app/components/line-chart.js @@ -0,0 +1,292 @@ +import Component from '@ember/component'; +import { computed, observer } from '@ember/object'; +import { guidFor } from '@ember/object/internals'; +import { run } from '@ember/runloop'; +import d3 from 'd3-selection'; +import d3Scale from 'd3-scale'; +import d3Axis from 'd3-axis'; +import d3Array from 'd3-array'; +import d3Shape from 'd3-shape'; +import d3Format from 'd3-format'; +import d3TimeFormat from 'd3-time-format'; +import WindowResizable from 'nomad-ui/mixins/window-resizable'; +import styleStringProperty from 'nomad-ui/utils/properties/style-string'; + +// Returns a new array with the specified number of points linearly +// distributed across the bounds +const lerp = ([low, high], numPoints) => { + const step = (high - low) / (numPoints - 1); + const arr = []; + for (var i = 0; i < numPoints; i++) { + arr.push(low + step * i); + } + return arr; +}; + +// Round a number or an array of numbers +const nice = val => (val instanceof Array ? val.map(nice) : Math.round(val)); + +export default Component.extend(WindowResizable, { + classNames: ['chart', 'line-chart'], + + // Public API + + data: null, + xProp: null, + yProp: null, + timeseries: false, + chartClass: 'is-primary', + + // Private Properties + + width: 0, + height: 0, + + isActive: false, + + fillId: computed(function() { + return `line-chart-fill-${guidFor(this)}`; + }), + + activeDatum: null, + + activeDatumLabel: computed('activeDatum', function() { + const datum = this.get('activeDatum'); + + if (!datum) return; + + const x = datum[this.get('xProp')]; + return this.xFormat(this.get('timeseries'))(x); + }), + + activeDatumValue: computed('activeDatum', function() { + const datum = this.get('activeDatum'); + + if (!datum) return; + + const y = datum[this.get('yProp')]; + return this.yFormat()(y); + }), + + // Overridable functions that retrurn formatter functions + xFormat(timeseries) { + return timeseries ? d3TimeFormat.timeFormat('%b') : d3Format.format(','); + }, + + yFormat() { + return d3Format.format(',.2~r'); + }, + + tooltipPosition: null, + tooltipStyle: styleStringProperty('tooltipPosition'), + + xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() { + const xProp = this.get('xProp'); + const scale = this.get('timeseries') ? d3Scale.scaleTime() : d3Scale.scaleLinear(); + + scale + .rangeRound([10, this.get('yAxisOffset')]) + .domain(d3Array.extent(this.get('data'), d => d[xProp])); + + return scale; + }), + + yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() { + const yProp = this.get('yProp'); + + return d3Scale + .scaleLinear() + .rangeRound([this.get('xAxisOffset'), 10]) + .domain([0, d3Array.max(this.get('data'), d => d[yProp])]); + }), + + xAxis: computed('xScale', function() { + const formatter = this.xFormat(this.get('timeseries')); + + return d3Axis + .axisBottom() + .scale(this.get('xScale')) + .ticks(5) + .tickFormat(formatter); + }), + + yTicks: computed('xAxisOffset', function() { + const height = this.get('xAxisOffset'); + const tickCount = Math.ceil(height / 120) * 2 + 1; + return nice(lerp(this.get('yScale').domain(), tickCount)); + }), + + yAxis: computed('yScale', function() { + const formatter = this.yFormat(); + + return d3Axis + .axisRight() + .scale(this.get('yScale')) + .tickValues(this.get('yTicks')) + .tickFormat(formatter); + }), + + yGridlines: computed('yScale', function() { + // The first gridline overlaps the x-axis, so remove it + const [, ...ticks] = this.get('yTicks'); + + return d3Axis + .axisRight() + .scale(this.get('yScale')) + .tickValues(ticks) + .tickSize(-this.get('yAxisOffset')) + .tickFormat(''); + }), + + xAxisHeight: computed(function() { + const axis = this.element.querySelector('.x-axis'); + return axis && axis.getBBox().height; + }), + + yAxisWidth: computed(function() { + const axis = this.element.querySelector('.y-axis'); + return axis && axis.getBBox().width; + }), + + xAxisOffset: computed('height', 'xAxisHeight', function() { + return this.get('height') - this.get('xAxisHeight'); + }), + + yAxisOffset: computed('width', 'yAxisWidth', function() { + return this.get('width') - this.get('yAxisWidth'); + }), + + line: computed('data.[]', 'xScale', 'yScale', function() { + const { xScale, yScale, xProp, yProp } = this.getProperties( + 'xScale', + 'yScale', + 'xProp', + 'yProp' + ); + + const line = d3Shape + .line() + .x(d => xScale(d[xProp])) + .y(d => yScale(d[yProp])); + + return line(this.get('data')); + }), + + area: computed('data.[]', 'xScale', 'yScale', function() { + const { xScale, yScale, xProp, yProp } = this.getProperties( + 'xScale', + 'yScale', + 'xProp', + 'yProp' + ); + + const area = d3Shape + .area() + .x(d => xScale(d[xProp])) + .y0(yScale(0)) + .y1(d => yScale(d[yProp])); + + return area(this.get('data')); + }), + + didInsertElement() { + this.updateDimensions(); + + const canvas = d3.select(this.element.querySelector('.canvas')); + const updateActiveDatum = this.updateActiveDatum.bind(this); + + canvas.on('mouseenter', () => { + run.schedule('afterRender', this, () => this.set('isActive', true)); + }); + + const chart = this; + canvas.on('mousemove', function() { + const mouseX = d3.mouse(this)[0]; + chart.set('latestMouseX', mouseX); + updateActiveDatum(mouseX); + }); + + canvas.on('mouseleave', () => { + this.set('isActive', false); + this.set('activeDatum', null); + }); + }, + + didUpdateAttrs() { + this.renderChart(); + }, + + updateActiveDatum(mouseX) { + const { xScale, xProp, yScale, yProp, data } = this.getProperties( + 'xScale', + 'xProp', + 'yScale', + 'yProp', + 'data' + ); + + // Map the mouse coordinate to the index in the data array + const bisector = d3Array.bisector(d => d[xProp]).left; + const x = xScale.invert(mouseX); + const index = bisector(data, x, 1); + + // The data point on either side of the cursor + const dLeft = data[index - 1]; + const dRight = data[index]; + + // Pick the closer point + const datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft; + + this.set('activeDatum', datum); + this.set('tooltipPosition', { + left: xScale(datum[xProp]), + top: yScale(datum[yProp]) - 10, + }); + }, + + updateChart: observer('data.[]', function() { + this.renderChart(); + }), + + // The renderChart method should only ever be responsible for runtime calculations + // and appending d3 created elements to the DOM (such as axes). + renderChart() { + // First, create the axes to get the dimensions of the resulting + // svg elements + this.mountD3Elements(); + + run.next(() => { + // Then, recompute anything that depends on the dimensions + // on the dimensions of the axes elements + this.notifyPropertyChange('xAxisHeight'); + this.notifyPropertyChange('yAxisWidth'); + + // Since each axis depends on the dimension of the other + // axis, the axes themselves are recomputed and need to + // be re-rendered. + this.mountD3Elements(); + if (this.get('isActive')) { + this.updateActiveDatum(this.get('latestMouseX')); + } + }); + }, + + mountD3Elements() { + d3.select(this.element.querySelector('.x-axis')).call(this.get('xAxis')); + d3.select(this.element.querySelector('.y-axis')).call(this.get('yAxis')); + d3.select(this.element.querySelector('.y-gridlines')).call(this.get('yGridlines')); + }, + + windowResizeHandler() { + run.once(this, this.updateDimensions); + }, + + updateDimensions() { + const $svg = this.$('svg'); + const width = $svg.width(); + const height = $svg.height(); + + this.setProperties({ width, height }); + this.renderChart(); + }, +}); diff --git a/ui/app/styles/charts.scss b/ui/app/styles/charts.scss index 3db77a93b90f..571535ca5ea7 100644 --- a/ui/app/styles/charts.scss +++ b/ui/app/styles/charts.scss @@ -1,4 +1,5 @@ @import './charts/distribution-bar'; +@import './charts/line-chart'; @import './charts/tooltip'; @import './charts/colors'; diff --git a/ui/app/styles/charts/line-chart.scss b/ui/app/styles/charts/line-chart.scss new file mode 100644 index 000000000000..19ba5d148173 --- /dev/null +++ b/ui/app/styles/charts/line-chart.scss @@ -0,0 +1,68 @@ +.chart.line-chart { + display: block; + height: 100%; + + svg { + display: block; + height: 100%; + width: 100%; + overflow: visible; + } + + .canvas { + .line { + fill: transparent; + stroke-width: 1.25; + } + + .hover-target { + fill: transparent; + stroke: transparent; + } + } + + .axis { + line, + path { + stroke: $grey-blue; + } + text { + fill: darken($grey-blue, 20%); + } + } + + .gridlines { + path { + stroke-width: 0; + } + + line { + stroke: lighten($grey-blue, 10%); + stroke-dasharray: 5 10; + } + } + + @each $name, $pair in $colors { + $color: nth($pair, 1); + + .canvas.is-#{$name} { + .line { + stroke: $color; + } + } + + linearGradient { + &.is-#{$name} { + > .start { + stop-color: $color; + stop-opacity: 0.6; + } + + > .end { + stop-color: $color; + stop-opacity: 0.05; + } + } + } + } +} diff --git a/ui/app/templates/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs new file mode 100644 index 000000000000..caf237d4ee2e --- /dev/null +++ b/ui/app/templates/components/line-chart.hbs @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + +
+
    +

    + + + {{activeDatumLabel}} + + {{activeDatumValue}} +

    +
+
From 5e09491bab11b33176bee401b28bbb36dedcff0c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 09:59:28 -0700 Subject: [PATCH 14/59] Styleguide entry for the line chart component --- ui/app/components/freestyle/sg-line-chart.js | 61 +++++++++++++++++++ .../components/freestyle/sg-line-chart.hbs | 29 +++++++++ ui/app/templates/freestyle.hbs | 4 ++ 3 files changed, 94 insertions(+) create mode 100644 ui/app/components/freestyle/sg-line-chart.js create mode 100644 ui/app/templates/components/freestyle/sg-line-chart.hbs diff --git a/ui/app/components/freestyle/sg-line-chart.js b/ui/app/components/freestyle/sg-line-chart.js new file mode 100644 index 000000000000..94f9e1105402 --- /dev/null +++ b/ui/app/components/freestyle/sg-line-chart.js @@ -0,0 +1,61 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import d3TimeFormat from 'd3-time-format'; + +export default Component.extend({ + timerTicks: 0, + + startTimer: function() { + this.set( + 'timer', + setInterval(() => { + this.incrementProperty('timerTicks'); + const ref = this.get('lineChartLive'); + ref.addObject({ ts: Date.now(), val: Math.random() * 30 + 20 }); + if (ref.length > 60) { + ref.splice(0, ref.length - 60); + } + }, 500) + ); + }.on('init'), + + willDestroy() { + clearInterval(this.get('timer')); + }, + + lineChartData: computed(() => { + return [ + { year: 2010, value: 10 }, + { year: 2011, value: 10 }, + { year: 2012, value: 20 }, + { year: 2013, value: 30 }, + { year: 2014, value: 50 }, + { year: 2015, value: 80 }, + { year: 2016, value: 130 }, + { year: 2017, value: 210 }, + { year: 2018, value: 340 }, + ]; + }), + + lineChartMild: computed(() => { + return [ + { year: 2010, value: 100 }, + { year: 2011, value: 90 }, + { year: 2012, value: 120 }, + { year: 2013, value: 130 }, + { year: 2014, value: 115 }, + { year: 2015, value: 105 }, + { year: 2016, value: 90 }, + { year: 2017, value: 85 }, + { year: 2018, value: 90 }, + ]; + }), + + lineChartLive: computed(() => { + return []; + }), + + secondsFormat() { + return d3TimeFormat.timeFormat('%H:%M:%S'); + }, +}); diff --git a/ui/app/templates/components/freestyle/sg-line-chart.hbs b/ui/app/templates/components/freestyle/sg-line-chart.hbs new file mode 100644 index 000000000000..bebc7214f931 --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-line-chart.hbs @@ -0,0 +1,29 @@ +{{#freestyle-usage "line-chart-standard"}} +
+ {{line-chart data=lineChartData xProp="year" yProp="value" chartClass="is-primary"}} +
+
+ {{line-chart data=lineChartMild xProp="year" yProp="value" chartClass="is-info"}} +
+{{/freestyle-usage}} + +{{#freestyle-usage "line-chart-fill-width"}} +
+ {{line-chart data=lineChartData xProp="year" yProp="value" chartClass="is-danger"}} +
+
+ {{line-chart data=lineChartMild xProp="year" yProp="value" chartClass="is-warning"}} +
+{{/freestyle-usage}} + +{{#freestyle-usage "line-chart-live-data"}} +
+ {{line-chart + data=lineChartLive + xProp="ts" + yProp="val" + timeseries=true + chartClass="is-primary" + xFormat=secondsFormat}} +
+{{/freestyle-usage}} diff --git a/ui/app/templates/freestyle.hbs b/ui/app/templates/freestyle.hbs index e33756a90000..5f9c9489c87f 100644 --- a/ui/app/templates/freestyle.hbs +++ b/ui/app/templates/freestyle.hbs @@ -108,6 +108,10 @@ {{freestyle/sg-distribution-bar-jumbo}} {{/section.subsection}} + {{#section.subsection name="Line Chart"}} + {{freestyle/sg-line-chart}} + {{/section.subsection}} + {{#section.subsection name="Progress Bar"}} {{freestyle/sg-progress-bar}} {{/section.subsection}} From bb40cb029bffc97f9785b07609b494e98208776d Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 17:15:48 -0700 Subject: [PATCH 15/59] Don't round numbers when the domain is between 0 and 1 --- ui/app/components/line-chart.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index f3aa3bbbeca5..c973ae47c86b 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -113,7 +113,9 @@ export default Component.extend(WindowResizable, { yTicks: computed('xAxisOffset', function() { const height = this.get('xAxisOffset'); const tickCount = Math.ceil(height / 120) * 2 + 1; - return nice(lerp(this.get('yScale').domain(), tickCount)); + const domain = this.get('yScale').domain(); + const ticks = lerp(domain, tickCount); + return domain[1] - domain[0] > 1 ? nice(ticks) : ticks; }), yAxis: computed('yScale', function() { From db6ad98eb4ecbf8267d4338063bb412708624713 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 17:16:20 -0700 Subject: [PATCH 16/59] Make the tooltip animation snappier for line charts When data is coming in live, the tooltip can get bogged down by updates causing the tooltip to never make it under the mouse, which looks like either lag or a bug. --- ui/app/styles/charts/tooltip.scss | 4 ++++ ui/app/templates/components/line-chart.hbs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/app/styles/charts/tooltip.scss b/ui/app/styles/charts/tooltip.scss index 3e128134b893..028b7b9442cf 100644 --- a/ui/app/styles/charts/tooltip.scss +++ b/ui/app/styles/charts/tooltip.scss @@ -15,6 +15,10 @@ pointer-events: none; z-index: $z-tooltip; + &.is-snappy { + transition: 0.2s top ease-out, 0.05s left ease-out; + } + &::before { pointer-events: none; display: inline-block; diff --git a/ui/app/templates/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs index caf237d4ee2e..a5add6a2492d 100644 --- a/ui/app/templates/components/line-chart.hbs +++ b/ui/app/templates/components/line-chart.hbs @@ -16,7 +16,7 @@ -
+

    From 0d7c22ff0884a9486c8e266f44a12ecb9763cf92 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 17:17:21 -0700 Subject: [PATCH 17/59] Stats time series A use-case specific line-chart for showing utilization metrics by percent --- ui/app/components/stats-time-series.js | 41 +++++++++++++++++++ .../components/stats-time-series.hbs | 1 + 2 files changed, 42 insertions(+) create mode 100644 ui/app/components/stats-time-series.js create mode 100644 ui/app/templates/components/stats-time-series.hbs diff --git a/ui/app/components/stats-time-series.js b/ui/app/components/stats-time-series.js new file mode 100644 index 000000000000..90a229e63900 --- /dev/null +++ b/ui/app/components/stats-time-series.js @@ -0,0 +1,41 @@ +import { computed } from '@ember/object'; +import moment from 'moment'; +import d3TimeFormat from 'd3-time-format'; +import d3Format from 'd3-format'; +import d3Scale from 'd3-scale'; +import d3Array from 'd3-array'; +import LineChart from 'nomad-ui/components/line-chart'; + +export default LineChart.extend({ + xProp: 'timestamp', + yProp: 'value', + timeseries: true, + + xFormat() { + return d3TimeFormat.timeFormat('%H:%M:%S'); + }, + + yFormat() { + return d3Format.format('.1~%'); + }, + + xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() { + const xProp = this.get('xProp'); + const scale = this.get('timeseries') ? d3Scale.scaleTime() : d3Scale.scaleLinear(); + + const [low, high] = d3Array.extent(this.get('data'), d => d[xProp]); + const minLow = moment(high) + .subtract(5, 'minutes') + .toDate(); + scale.rangeRound([10, this.get('yAxisOffset')]).domain([Math.min(low, minLow), high]); + + return scale; + }), + + yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() { + return d3Scale + .scaleLinear() + .rangeRound([this.get('xAxisOffset'), 10]) + .domain([0, 1]); + }), +}); diff --git a/ui/app/templates/components/stats-time-series.hbs b/ui/app/templates/components/stats-time-series.hbs new file mode 100644 index 000000000000..494fc7ebf007 --- /dev/null +++ b/ui/app/templates/components/stats-time-series.hbs @@ -0,0 +1 @@ +{{partial "components/line-chart"}} From b84d75597dece91255321cbf25dcb35d1beebba6 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 17:59:37 -0700 Subject: [PATCH 18/59] Avoid race conditions around showing and hiding the line chart tooltip --- ui/app/components/line-chart.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index c973ae47c86b..633c33945a55 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -197,11 +197,14 @@ export default Component.extend(WindowResizable, { const canvas = d3.select(this.element.querySelector('.canvas')); const updateActiveDatum = this.updateActiveDatum.bind(this); - canvas.on('mouseenter', () => { - run.schedule('afterRender', this, () => this.set('isActive', true)); + const chart = this; + canvas.on('mouseenter', function() { + const mouseX = d3.mouse(this)[0]; + chart.set('latestMouseX', mouseX); + updateActiveDatum(mouseX); + run.schedule('afterRender', chart, () => chart.set('isActive', true)); }); - const chart = this; canvas.on('mousemove', function() { const mouseX = d3.mouse(this)[0]; chart.set('latestMouseX', mouseX); @@ -209,7 +212,7 @@ export default Component.extend(WindowResizable, { }); canvas.on('mouseleave', () => { - this.set('isActive', false); + run.schedule('afterRender', this, () => this.set('isActive', false)); this.set('activeDatum', null); }); }, From 4b67b7668c10db84287541955b2136c4c0be41ca Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 7 Sep 2018 18:00:25 -0700 Subject: [PATCH 19/59] Use "global" gradients via a clipping mask and a rect w/100% height --- ui/app/components/line-chart.js | 4 +++ ui/app/templates/components/line-chart.hbs | 29 +++++++++++----------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index 633c33945a55..e268c49fa413 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -48,6 +48,10 @@ export default Component.extend(WindowResizable, { return `line-chart-fill-${guidFor(this)}`; }), + maskId: computed(function() { + return `line-chart-mask-${guidFor(this)}`; + }), + activeDatum: null, activeDatumLabel: computed('activeDatum', function() { diff --git a/ui/app/templates/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs index a5add6a2492d..33e8dc19f148 100644 --- a/ui/app/templates/components/line-chart.hbs +++ b/ui/app/templates/components/line-chart.hbs @@ -1,29 +1,28 @@ - - - - - + + + + + + - +

    -
      -

      - - - {{activeDatumLabel}} - - {{activeDatumValue}} -

      -
    +

    + + + {{activeDatumLabel}} + + {{activeDatumValue}} +

    From a3e858e27e9bacab0af53396c7cb5a9a2a33b46f Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 10 Sep 2018 14:38:20 -0700 Subject: [PATCH 20/59] Updates to the styleguide --- ui/app/components/freestyle/sg-line-chart.js | 31 +++++++++++++++++++ ui/app/components/line-chart.js | 6 +++- ui/app/styles/charts/line-chart.scss | 1 + .../components/freestyle/sg-line-chart.hbs | 20 ++++++++++-- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/ui/app/components/freestyle/sg-line-chart.js b/ui/app/components/freestyle/sg-line-chart.js index 94f9e1105402..a149830e0c1f 100644 --- a/ui/app/components/freestyle/sg-line-chart.js +++ b/ui/app/components/freestyle/sg-line-chart.js @@ -10,11 +10,34 @@ export default Component.extend({ 'timer', setInterval(() => { this.incrementProperty('timerTicks'); + const ref = this.get('lineChartLive'); ref.addObject({ ts: Date.now(), val: Math.random() * 30 + 20 }); if (ref.length > 60) { ref.splice(0, ref.length - 60); } + + if (this.get('timerTicks') % 2 === 0) { + const ref2 = this.get('metrics'); + const prev = ref2.length ? ref2[ref2.length - 1].value : 0.9; + ref2.addObject({ + timestamp: Date.now(), + value: Math.min(Math.max(prev + Math.random() * 0.05 - 0.025, 0), 1), + }); + if (ref2.length > 300) { + ref2.splice(0, ref2.length - 300); + } + + const ref3 = this.get('metrics2'); + const prev2 = ref3.length ? ref3[ref3.length - 1].value : 0.1; + ref3.addObject({ + timestamp: Date.now(), + value: Math.min(Math.max(prev2 + Math.random() * 0.05 - 0.025, 0), 1), + }); + if (ref3.length > 300) { + ref3.splice(0, ref3.length - 300); + } + } }, 500) ); }.on('init'), @@ -55,6 +78,14 @@ export default Component.extend({ return []; }), + metrics: computed(() => { + return []; + }), + + metrics2: computed(() => { + return []; + }), + secondsFormat() { return d3TimeFormat.timeFormat('%H:%M:%S'); }, diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index e268c49fa413..683afc36f7e1 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -97,11 +97,15 @@ export default Component.extend(WindowResizable, { yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() { const yProp = this.get('yProp'); + let max = d3Array.max(this.get('data'), d => d[yProp]); + if (max > 1) { + max = nice(max); + } return d3Scale .scaleLinear() .rangeRound([this.get('xAxisOffset'), 10]) - .domain([0, d3Array.max(this.get('data'), d => d[yProp])]); + .domain([0, max]); }), xAxis: computed('xScale', function() { diff --git a/ui/app/styles/charts/line-chart.scss b/ui/app/styles/charts/line-chart.scss index 19ba5d148173..eff9ec539981 100644 --- a/ui/app/styles/charts/line-chart.scss +++ b/ui/app/styles/charts/line-chart.scss @@ -26,6 +26,7 @@ path { stroke: $grey-blue; } + text { fill: darken($grey-blue, 20%); } diff --git a/ui/app/templates/components/freestyle/sg-line-chart.hbs b/ui/app/templates/components/freestyle/sg-line-chart.hbs index bebc7214f931..cdb7d081a585 100644 --- a/ui/app/templates/components/freestyle/sg-line-chart.hbs +++ b/ui/app/templates/components/freestyle/sg-line-chart.hbs @@ -1,4 +1,4 @@ -{{#freestyle-usage "line-chart-standard"}} +{{#freestyle-usage "line-chart-standard" title="Standard"}}
    {{line-chart data=lineChartData xProp="year" yProp="value" chartClass="is-primary"}}
    @@ -7,7 +7,7 @@
{{/freestyle-usage}} -{{#freestyle-usage "line-chart-fill-width"}} +{{#freestyle-usage "line-chart-fill-width" title="Fluid width"}}
{{line-chart data=lineChartData xProp="year" yProp="value" chartClass="is-danger"}}
@@ -15,8 +15,11 @@ {{line-chart data=lineChartMild xProp="year" yProp="value" chartClass="is-warning"}}
{{/freestyle-usage}} +{{#freestyle-annotation}} +

A line chart will assume the width of its container. This includes the dimensions of the axes, which are calculated based on real DOM measurements. This requires a two-pass render: first the axes are placed with their real domains (in order to capture width and height of tick labels), second the axes are adjusted to make sure both the x and y axes are within the height and width bounds of the container.

+{{/freestyle-annotation}} -{{#freestyle-usage "line-chart-live-data"}} +{{#freestyle-usage "line-chart-live-data" title="Live data"}}
{{line-chart data=lineChartLive @@ -27,3 +30,14 @@ xFormat=secondsFormat}}
{{/freestyle-usage}} + +{{#freestyle-usage "stats-chart"}} +
+
+ {{stats-time-series data=metrics chartClass="is-info"}} +
+
+ {{stats-time-series data=metrics2 chartClass="is-info"}} +
+
+{{/freestyle-usage}} From fd80df66a50218080bb0f6394fb72b7d037f9710 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 10 Sep 2018 16:06:02 -0700 Subject: [PATCH 21/59] Split the line-chart and stats-time-series freestyle entries --- ui/app/components/freestyle/sg-line-chart.js | 30 -------- .../freestyle/sg-stats-time-series.js | 76 +++++++++++++++++++ .../components/freestyle/sg-line-chart.hbs | 11 --- .../freestyle/sg-stats-time-series.hbs | 20 +++++ ui/app/templates/freestyle.hbs | 4 + 5 files changed, 100 insertions(+), 41 deletions(-) create mode 100644 ui/app/components/freestyle/sg-stats-time-series.js create mode 100644 ui/app/templates/components/freestyle/sg-stats-time-series.hbs diff --git a/ui/app/components/freestyle/sg-line-chart.js b/ui/app/components/freestyle/sg-line-chart.js index a149830e0c1f..145bcbabeb0d 100644 --- a/ui/app/components/freestyle/sg-line-chart.js +++ b/ui/app/components/freestyle/sg-line-chart.js @@ -16,28 +16,6 @@ export default Component.extend({ if (ref.length > 60) { ref.splice(0, ref.length - 60); } - - if (this.get('timerTicks') % 2 === 0) { - const ref2 = this.get('metrics'); - const prev = ref2.length ? ref2[ref2.length - 1].value : 0.9; - ref2.addObject({ - timestamp: Date.now(), - value: Math.min(Math.max(prev + Math.random() * 0.05 - 0.025, 0), 1), - }); - if (ref2.length > 300) { - ref2.splice(0, ref2.length - 300); - } - - const ref3 = this.get('metrics2'); - const prev2 = ref3.length ? ref3[ref3.length - 1].value : 0.1; - ref3.addObject({ - timestamp: Date.now(), - value: Math.min(Math.max(prev2 + Math.random() * 0.05 - 0.025, 0), 1), - }); - if (ref3.length > 300) { - ref3.splice(0, ref3.length - 300); - } - } }, 500) ); }.on('init'), @@ -78,14 +56,6 @@ export default Component.extend({ return []; }), - metrics: computed(() => { - return []; - }), - - metrics2: computed(() => { - return []; - }), - secondsFormat() { return d3TimeFormat.timeFormat('%H:%M:%S'); }, diff --git a/ui/app/components/freestyle/sg-stats-time-series.js b/ui/app/components/freestyle/sg-stats-time-series.js new file mode 100644 index 000000000000..ac2a445979fd --- /dev/null +++ b/ui/app/components/freestyle/sg-stats-time-series.js @@ -0,0 +1,76 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import d3TimeFormat from 'd3-time-format'; +import moment from 'moment'; + +export default Component.extend({ + timerTicks: 0, + + startTimer: function() { + this.set( + 'timer', + setInterval(() => { + const metricsHigh = this.get('metricsHigh'); + const prev = metricsHigh.length ? metricsHigh[metricsHigh.length - 1].value : 0.9; + this.appendTSValue( + metricsHigh, + Math.min(Math.max(prev + Math.random() * 0.05 - 0.025, 0.5), 1) + ); + + const metricsLow = this.get('metricsLow'); + const prev2 = metricsLow.length ? metricsLow[metricsLow.length - 1].value : 0.1; + this.appendTSValue( + metricsLow, + Math.min(Math.max(prev2 + Math.random() * 0.05 - 0.025, 0), 0.5) + ); + }, 1000) + ); + }.on('init'), + + appendTSValue(array, value, maxLength = 300) { + array.addObject({ + timestamp: Date.now(), + value, + }); + + if (array.length > maxLength) { + array.splice(0, array.length - maxLength); + } + }, + + willDestroy() { + clearInterval(this.get('timer')); + }, + + metricsHigh: computed(() => { + return []; + }), + + metricsLow: computed(() => { + return []; + }), + + staticMetrics: computed(() => { + const ts = offset => + moment() + .subtract(offset, 'm') + .toDate(); + return [ + { timestamp: ts(20), value: 0.5 }, + { timestamp: ts(18), value: 0.5 }, + { timestamp: ts(16), value: 0.4 }, + { timestamp: ts(14), value: 0.3 }, + { timestamp: ts(12), value: 0.9 }, + { timestamp: ts(10), value: 0.3 }, + { timestamp: ts(8), value: 0.3 }, + { timestamp: ts(6), value: 0.4 }, + { timestamp: ts(4), value: 0.5 }, + { timestamp: ts(2), value: 0.6 }, + { timestamp: ts(0), value: 0.6 }, + ]; + }), + + secondsFormat() { + return d3TimeFormat.timeFormat('%H:%M:%S'); + }, +}); diff --git a/ui/app/templates/components/freestyle/sg-line-chart.hbs b/ui/app/templates/components/freestyle/sg-line-chart.hbs index cdb7d081a585..67dd939b01df 100644 --- a/ui/app/templates/components/freestyle/sg-line-chart.hbs +++ b/ui/app/templates/components/freestyle/sg-line-chart.hbs @@ -30,14 +30,3 @@ xFormat=secondsFormat}} {{/freestyle-usage}} - -{{#freestyle-usage "stats-chart"}} -
-
- {{stats-time-series data=metrics chartClass="is-info"}} -
-
- {{stats-time-series data=metrics2 chartClass="is-info"}} -
-
-{{/freestyle-usage}} diff --git a/ui/app/templates/components/freestyle/sg-stats-time-series.hbs b/ui/app/templates/components/freestyle/sg-stats-time-series.hbs new file mode 100644 index 000000000000..3b077e1d5529 --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-stats-time-series.hbs @@ -0,0 +1,20 @@ +{{#freestyle-usage "stats-time-series-standard" title="Stats Time Series"}} +
+ {{stats-time-series data=staticMetrics chartClass="is-primary"}} +
+{{/freestyle-usage}} + +{{#freestyle-usage "stats-time-series-comparison" title="Stats Time Series High/Low Comparison"}} +
+
+ {{stats-time-series data=metricsHigh chartClass="is-info"}} +
+
+ {{stats-time-series data=metricsLow chartClass="is-info"}} +
+
+{{/freestyle-usage}} +{{#freestyle-annotation}} +

Line charts, and therefore stats time series charts, use a constant linear gradient with a height equal to the canvas. This makes the color intensity of the gradient at values consistent across charts as long as those charts have the same y-axis domain.

+

This is used to great effect with stats charts since they all have a y-axis domain of 0-100%.

+{{/freestyle-annotation}} diff --git a/ui/app/templates/freestyle.hbs b/ui/app/templates/freestyle.hbs index 5f9c9489c87f..87bdb7c7685f 100644 --- a/ui/app/templates/freestyle.hbs +++ b/ui/app/templates/freestyle.hbs @@ -115,6 +115,10 @@ {{#section.subsection name="Progress Bar"}} {{freestyle/sg-progress-bar}} {{/section.subsection}} + + {{#section.subsection name="Stats Time Series"}} + {{freestyle/sg-stats-time-series}} + {{/section.subsection}} {{/freestyle-section}} {{/freestyle-guide}} From 40861aaae33c76d2bb79e295ce063b4b49299456 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 10 Sep 2018 20:47:55 -0700 Subject: [PATCH 22/59] Unit test coverage for the line chart component --- ui/app/components/line-chart.js | 9 ++ ui/tests/unit/components/line-chart-test.js | 148 ++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 ui/tests/unit/components/line-chart-test.js diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index 683afc36f7e1..3ef5b1908af3 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -149,11 +149,17 @@ export default Component.extend(WindowResizable, { }), xAxisHeight: computed(function() { + // Avoid divide by zero errors by always having a height + if (!this.element) return 1; + const axis = this.element.querySelector('.x-axis'); return axis && axis.getBBox().height; }), yAxisWidth: computed(function() { + // Avoid divide by zero errors by always having a width + if (!this.element) return 1; + const axis = this.element.querySelector('.y-axis'); return axis && axis.getBBox().width; }), @@ -264,6 +270,9 @@ export default Component.extend(WindowResizable, { // The renderChart method should only ever be responsible for runtime calculations // and appending d3 created elements to the DOM (such as axes). renderChart() { + // There is nothing to do if the element hasn't been inserted yet + if (!this.element) return; + // First, create the axes to get the dimensions of the resulting // svg elements this.mountD3Elements(); diff --git a/ui/tests/unit/components/line-chart-test.js b/ui/tests/unit/components/line-chart-test.js new file mode 100644 index 000000000000..fda7021f162d --- /dev/null +++ b/ui/tests/unit/components/line-chart-test.js @@ -0,0 +1,148 @@ +import { test, moduleForComponent } from 'ember-qunit'; +import d3Format from 'd3-format'; + +moduleForComponent('line-chart', 'Unit | Component | line-chart'); + +const data = [ + { foo: 1, bar: 100 }, + { foo: 2, bar: 200 }, + { foo: 3, bar: 300 }, + { foo: 8, bar: 400 }, + { foo: 4, bar: 500 }, +]; + +test('x scale domain is the min and max values in data based on the xProp value', function(assert) { + const chart = this.subject(); + + chart.setProperties({ + xProp: 'foo', + data, + }); + + let [xDomainLow, xDomainHigh] = chart.get('xScale').domain(); + assert.equal( + xDomainLow, + Math.min(...data.mapBy('foo')), + 'Domain lower bound is the lowest foo value' + ); + assert.equal( + xDomainHigh, + Math.max(...data.mapBy('foo')), + 'Domain upper bound is the highest foo value' + ); + + chart.set('data', [...data, { foo: 12, bar: 600 }]); + + [, xDomainHigh] = chart.get('xScale').domain(); + assert.equal(xDomainHigh, 12, 'When the data changes, the xScale is recalculated'); +}); + +test('y scale domain uses the max value in the data based off of yProp, but is always zero-based', function(assert) { + const chart = this.subject(); + + chart.setProperties({ + yProp: 'bar', + data, + }); + + let [yDomainLow, yDomainHigh] = chart.get('yScale').domain(); + assert.equal(yDomainLow, 0, 'Domain lower bound is always 0'); + assert.equal( + yDomainHigh, + Math.max(...data.mapBy('bar')), + 'Domain upper bound is the highest bar value' + ); + + chart.set('data', [...data, { foo: 12, bar: 600 }]); + + [, yDomainHigh] = chart.get('yScale').domain(); + assert.equal(yDomainHigh, 600, 'When the data changes, the yScale is recalculated'); +}); + +test('the number of yTicks is always odd (to always have a mid-line) and is based off the chart height', function(assert) { + const chart = this.subject(); + + chart.setProperties({ + yProp: 'bar', + xAxisOffset: 100, + data, + }); + + assert.equal(chart.get('yTicks').length, 3); + + chart.set('xAxisOffset', 240); + assert.equal(chart.get('yTicks').length, 5); + + chart.set('xAxisOffset', 241); + assert.equal(chart.get('yTicks').length, 7); +}); + +test('the values for yTicks are rounded to whole numbers', function(assert) { + const chart = this.subject(); + + chart.setProperties({ + yProp: 'bar', + xAxisOffset: 100, + data, + }); + + assert.deepEqual(chart.get('yTicks'), [0, 250, 500]); + + chart.set('xAxisOffset', 240); + assert.deepEqual(chart.get('yTicks'), [0, 125, 250, 375, 500]); + + chart.set('xAxisOffset', 241); + assert.deepEqual(chart.get('yTicks'), [0, 83, 167, 250, 333, 417, 500]); +}); + +test('the values for yTicks are fractions when the domain is between 0 and 1', function(assert) { + const chart = this.subject(); + + chart.setProperties({ + yProp: 'bar', + xAxisOffset: 100, + data: [ + { foo: 1, bar: 0.1 }, + { foo: 2, bar: 0.2 }, + { foo: 3, bar: 0.3 }, + { foo: 8, bar: 0.4 }, + { foo: 4, bar: 0.5 }, + ], + }); + + assert.deepEqual(chart.get('yTicks'), [0, 0.25, 0.5]); +}); + +test('activeDatumLabel is the xProp value of the activeDatum formatted with xFormat', function(assert) { + const chart = this.subject(); + + chart.setProperties({ + xProp: 'foo', + yProp: 'bar', + data, + activeDatum: data[1], + }); + + assert.equal( + chart.get('activeDatumLabel'), + d3Format.format(',')(data[1].foo), + 'activeDatumLabel correctly formats the correct prop of the correct datum' + ); +}); + +test('activeDatumValue is the yProp value of the activeDatum formatted with yFormat', function(assert) { + const chart = this.subject(); + + chart.setProperties({ + xProp: 'foo', + yProp: 'bar', + data, + activeDatum: data[1], + }); + + assert.equal( + chart.get('activeDatumValue'), + d3Format.format(',.2~r')(data[1].bar), + 'activeDatumValue correctly formats the correct prop of the correct datum' + ); +}); From efb130163874ea6a374f14e541470ef7dd4eee20 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 10 Sep 2018 21:10:09 -0700 Subject: [PATCH 23/59] Unit test coverage for the stats-time-series chart --- .../unit/components/stats-time-series-test.js | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 ui/tests/unit/components/stats-time-series-test.js diff --git a/ui/tests/unit/components/stats-time-series-test.js b/ui/tests/unit/components/stats-time-series-test.js new file mode 100644 index 000000000000..401a8a5b153a --- /dev/null +++ b/ui/tests/unit/components/stats-time-series-test.js @@ -0,0 +1,101 @@ +import { test, moduleForComponent } from 'ember-qunit'; +import moment from 'moment'; +import d3Format from 'd3-format'; +import d3TimeFormat from 'd3-time-format'; + +moduleForComponent('stats-time-series', 'Unit | Component | stats-time-series'); + +const ts = (offset, resolution = 'm') => + moment() + .subtract(offset, resolution) + .toDate(); + +const wideData = [ + { timestamp: ts(20), value: 0.5 }, + { timestamp: ts(18), value: 0.5 }, + { timestamp: ts(16), value: 0.4 }, + { timestamp: ts(14), value: 0.3 }, + { timestamp: ts(12), value: 0.9 }, + { timestamp: ts(10), value: 0.3 }, + { timestamp: ts(8), value: 0.3 }, + { timestamp: ts(6), value: 0.4 }, + { timestamp: ts(4), value: 0.5 }, + { timestamp: ts(2), value: 0.6 }, + { timestamp: ts(0), value: 0.6 }, +]; + +const narrowData = [ + { timestamp: ts(20, 's'), value: 0.5 }, + { timestamp: ts(18, 's'), value: 0.5 }, + { timestamp: ts(16, 's'), value: 0.4 }, + { timestamp: ts(14, 's'), value: 0.3 }, + { timestamp: ts(12, 's'), value: 0.9 }, + { timestamp: ts(10, 's'), value: 0.3 }, +]; + +test('xFormat is time-formatted for hours, minutes, and seconds', function(assert) { + const chart = this.subject(); + + chart.set('data', wideData); + + wideData.forEach(datum => { + assert.equal( + chart.xFormat()(datum.timestamp), + d3TimeFormat.timeFormat('%H:%M:%S')(datum.timestamp) + ); + }); +}); + +test('yFormat is percent-formatted', function(assert) { + const chart = this.subject(); + + chart.set('data', wideData); + + wideData.forEach(datum => { + assert.equal(chart.yFormat()(datum.value), d3Format.format('.1~%')(datum.value)); + }); +}); + +test('x scale domain is at least five minutes', function(assert) { + const chart = this.subject(); + + chart.set('data', narrowData); + + assert.equal( + +chart.get('xScale').domain()[0], + +moment(Math.max(...narrowData.mapBy('timestamp'))) + .subtract(5, 'm') + .toDate(), + 'The lower bound of the xScale is 5 minutes ago' + ); +}); + +test('x scale domain is greater than five minutes when the domain of the data is larger than five minutes', function(assert) { + const chart = this.subject(); + + chart.set('data', wideData); + + assert.equal( + +chart.get('xScale').domain()[0], + Math.min(...wideData.mapBy('timestamp')), + 'The lower bound of the xScale is the oldest timestamp in the dataset' + ); +}); + +test('y scale domain is always 0 to 1 (0 to 100%)', function(assert) { + const chart = this.subject(); + + chart.set('data', wideData); + + assert.deepEqual( + [Math.min(...wideData.mapBy('value')), Math.max(...wideData.mapBy('value'))], + [0.3, 0.9], + 'The bounds of the value prop of the dataset is narrower than 0 - 1' + ); + + assert.deepEqual( + chart.get('yScale').domain(), + [0, 1], + 'The bounds of the yScale are still 0 and 1' + ); +}); From 4cd9164d4ead4590c5761cc1465e98e92a923322 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 13 Sep 2018 12:25:35 -0700 Subject: [PATCH 24/59] Use addObject to get kvo behaviors --- ui/app/utils/classes/node-stats-tracker.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/ui/app/utils/classes/node-stats-tracker.js b/ui/app/utils/classes/node-stats-tracker.js index 530a6619d752..2933553b3bca 100644 --- a/ui/app/utils/classes/node-stats-tracker.js +++ b/ui/app/utils/classes/node-stats-tracker.js @@ -21,19 +21,24 @@ const NodeStatsTracker = EmberObject.extend(AbstractStatsTracker, { }), append(frame) { + const timestamp = new Date(Math.floor(frame.Timestamp / 1000000)); + const cpuUsed = Math.floor(frame.CPUTicksConsumed) || 0; - this.get('cpu').push({ - timestamp: frame.Timestamp, + this.get('cpu').addObject({ + timestamp, used: cpuUsed, percent: percent(cpuUsed, this.get('reservedCPU')), }); const memoryUsed = frame.Memory.Used; - this.get('memory').push({ - timestamp: frame.Timestamp, + this.get('memory').addObject({ + timestamp, used: memoryUsed, percent: percent(memoryUsed / 1024 / 1024, this.get('reservedMemory')), }); + + // this.notifyPropertyChange('cpu'); + // this.notifyPropertyChange('memory'); }, // Static figures, denominators for stats From 48d02205615fa0aa2e2b7b4e4a92f1be00d49134 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 13 Sep 2018 12:26:18 -0700 Subject: [PATCH 25/59] Use percent for the y-axis binding --- ui/app/components/stats-time-series.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/app/components/stats-time-series.js b/ui/app/components/stats-time-series.js index 90a229e63900..403c6a7d7da2 100644 --- a/ui/app/components/stats-time-series.js +++ b/ui/app/components/stats-time-series.js @@ -8,7 +8,7 @@ import LineChart from 'nomad-ui/components/line-chart'; export default LineChart.extend({ xProp: 'timestamp', - yProp: 'value', + yProp: 'percent', timeseries: true, xFormat() { From b80016d5a0d7b05d76205a2b978145cba7ce276c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 13 Sep 2018 12:27:27 -0700 Subject: [PATCH 26/59] Add stat charts to the client page --- ui/app/templates/clients/client.hbs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ui/app/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs index 3b84f643e044..885fd4989102 100644 --- a/ui/app/templates/clients/client.hbs +++ b/ui/app/templates/clients/client.hbs @@ -77,6 +77,24 @@ {{/if}} +
+
+ Resource Utilization +
+
+
+
+ CPU + {{stats-time-series data=stats.cpu chartClass="is-info"}} +
+
+ Memory + {{stats-time-series data=stats.memory chartClass="is-danger"}} +
+
+
+
+
Allocations {{model.allocations.length}}
From 44b16b2b7c83cc4ef0750daa12a661d30a73fa13 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 13 Sep 2018 16:31:31 -0700 Subject: [PATCH 27/59] Full markup for time series metrics --- ui/app/templates/clients/client.hbs | 40 +++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/ui/app/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs index 885fd4989102..4e251ff7f263 100644 --- a/ui/app/templates/clients/client.hbs +++ b/ui/app/templates/clients/client.hbs @@ -85,11 +85,47 @@
CPU - {{stats-time-series data=stats.cpu chartClass="is-info"}} +
+ {{stats-time-series data=stats.cpu chartClass="is-info"}} +
+
+
+
+ + {{stats.cpu.lastObject.percent}} + +
+
+
+ {{format-percentage stats.cpu.lastObject.percent total=1}} +
+
+ {{stats.cpu.lastObject.used}} Mhz / {{stats.reservedCPU}} Mhz reserved
Memory - {{stats-time-series data=stats.memory chartClass="is-danger"}} +
+ {{stats-time-series data=stats.memory chartClass="is-danger"}} +
+
+
+
+ + {{stats.memory.lastObject.percent}} + +
+
+
+ {{format-percentage stats.memory.lastObject.percent total=1}} +
+
+ {{format-bytes stats.memory.lastObject.used}} MiB / {{stats.reservedMemory}} MiB reserved
From 48910d8334933a17cf48e3e45a7dc5bfb3d1f3c1 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 13 Sep 2018 16:32:29 -0700 Subject: [PATCH 28/59] New primary-metric component It encapsulates all the tracker, polling, and markup for this style of metric. --- ui/app/components/primary-metric.js | 103 ++++++++++++++++++ .../templates/components/primary-metric.hbs | 26 +++++ .../utils/classes/abstract-stats-tracker.js | 4 +- 3 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 ui/app/components/primary-metric.js create mode 100644 ui/app/templates/components/primary-metric.hbs diff --git a/ui/app/components/primary-metric.js b/ui/app/components/primary-metric.js new file mode 100644 index 000000000000..8423af248f0d --- /dev/null +++ b/ui/app/components/primary-metric.js @@ -0,0 +1,103 @@ +import Ember from 'ember'; +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { computed } from '@ember/object'; +import NodeStatsTracker from 'nomad-ui/utils/classes/node-stats-tracker'; +import AllocationStatsTracker from 'nomad-ui/utils/classes/allocation-stats-tracker'; +import { task, timeout } from 'ember-concurrency'; + +export default Component.extend({ + token: service(), + + // One of Node, Allocation, or TaskState + resource: null, + + // cpu or memory + metric: null, + + // An instance of a StatsTracker. An alternative interface to resource + tracker: computed('trackedResource', 'type', function() { + const resource = this.get('trackedResource'); + + if (!resource) return; + + const Constructor = this.get('type') === 'node' ? NodeStatsTracker : AllocationStatsTracker; + const resourceProp = this.get('type') === 'node' ? 'node' : 'allocation'; + return Constructor.create({ + fetch: url => this.get('token').authorizedRequest(url), + [resourceProp]: resource, + }); + }), + + type: computed('resource', function() { + const resource = this.get('resource'); + return resource && resource.constructor.modelName; + }), + + trackedResource: computed('resource', 'type', function() { + // TaskStates use the allocation stats tracker + return this.get('type') === 'task-state' + ? this.get('resource.allocation') + : this.get('resource'); + }), + + metricLabel: computed('metric', function() { + const metric = this.get('metric'); + const mappings = { + cpu: 'CPU', + memory: 'Memory', + }; + return mappings[metric] || metric; + }), + + data: computed('resource', 'metric', 'type', function() { + if (!this.get('tracker')) return []; + + const metric = this.get('metric'); + if (this.get('type') === 'task-state') { + // handle getting the right task out of the tracker + const task = this.get('tracker.tasks').findBy('task', this.get('resource.name')); + return task && task[metric]; + } + + return this.get(`tracker.${metric}`); + }), + + reservedAmount: computed('resource', 'metric', 'type', function() { + const metricProperty = this.get('metric') === 'cpu' ? 'reservedCPU' : 'reservedMemory'; + + if (this.get('type') === 'task-state') { + const task = this.get('tracker.tasks').findBy('task', this.get('resource.name')); + return task[metricProperty]; + } + + return this.get(`tracker.${metricProperty}`); + }), + + chartClass: computed('metric', function() { + const metric = this.get('metric'); + const mappings = { + cpu: 'is-info', + memory: 'is-danger', + }; + + return mappings[metric] || 'is-primary'; + }), + + poller: task(function*() { + do { + yield this.get('tracker').poll(); + yield timeout(2000); + } while (!Ember.testing); + }), + + didReceiveAttrs() { + if (this.get('tracker')) { + this.get('poller').perform(); + } + }, + + willDestroy() { + this.get('poller').cancelAll(); + }, +}); diff --git a/ui/app/templates/components/primary-metric.hbs b/ui/app/templates/components/primary-metric.hbs new file mode 100644 index 000000000000..d8d4ab9acfa3 --- /dev/null +++ b/ui/app/templates/components/primary-metric.hbs @@ -0,0 +1,26 @@ +{{metricLabel}} +
+ {{stats-time-series data=data chartClass=chartClass}} +
+
+
+
+ + {{data.lastObject.percent}} + +
+
+
+ {{format-percentage data.lastObject.percent total=1}} +
+
+{{#if (eq metric "cpu")}} + {{data.lastObject.used}} Mhz / {{reservedAmount}} Mhz reserved +{{else if (eq metric "memory")}} + {{format-bytes data.lastObject.used}} MiB / {{reservedAmount}} MiB reserved +{{else}} + {{data.lastObject.used}} / {{reservedAmount}} reserved +{{/if}} diff --git a/ui/app/utils/classes/abstract-stats-tracker.js b/ui/app/utils/classes/abstract-stats-tracker.js index 488dec465f06..4569b29b5e30 100644 --- a/ui/app/utils/classes/abstract-stats-tracker.js +++ b/ui/app/utils/classes/abstract-stats-tracker.js @@ -19,9 +19,7 @@ export default Mixin.create({ assert('Url must be defined', url); return this.get('fetch')(url) - .then(res => { - return res.json(); - }) + .then(res => res.json()) .then(frame => this.append(frame)); }, }); From 76f9c13cb2c3937980adf697ea932bbc8a3dd371 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 13 Sep 2018 16:33:44 -0700 Subject: [PATCH 29/59] Use the new primary-metric component on the client detail page --- ui/app/templates/clients/client.hbs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ui/app/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs index 4e251ff7f263..7d83e4ae2759 100644 --- a/ui/app/templates/clients/client.hbs +++ b/ui/app/templates/clients/client.hbs @@ -131,6 +131,22 @@
+
+
+ Resource Utilization +
+
+
+
+ {{primary-metric resource=model metric="cpu"}} +
+
+ {{primary-metric resource=model metric="memory"}} +
+
+
+
+
Allocations {{model.allocations.length}}
From 1572e8d820b3c3eae9a00edd6ddfc5b62940f12e Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 13 Sep 2018 16:34:56 -0700 Subject: [PATCH 30/59] Remove old stat tracking code from the client page In favor of the new primary-metric components --- ui/app/controllers/clients/client.js | 11 ------ ui/app/routes/clients/client.js | 11 ------ ui/app/templates/clients/client.hbs | 54 ---------------------------- 3 files changed, 76 deletions(-) diff --git a/ui/app/controllers/clients/client.js b/ui/app/controllers/clients/client.js index 33873b361e83..7ded2c1be70d 100644 --- a/ui/app/controllers/clients/client.js +++ b/ui/app/controllers/clients/client.js @@ -37,17 +37,6 @@ 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/clients/client.js b/ui/app/routes/clients/client.js index 6cf9332ebee5..9e2493030030 100644 --- a/ui/app/routes/clients/client.js +++ b/ui/app/routes/clients/client.js @@ -38,15 +38,4 @@ 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/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs index 7d83e4ae2759..2c286e4ba50b 100644 --- a/ui/app/templates/clients/client.hbs +++ b/ui/app/templates/clients/client.hbs @@ -77,60 +77,6 @@
{{/if}} -
-
- Resource Utilization -
-
-
-
- CPU -
- {{stats-time-series data=stats.cpu chartClass="is-info"}} -
-
-
-
- - {{stats.cpu.lastObject.percent}} - -
-
-
- {{format-percentage stats.cpu.lastObject.percent total=1}} -
-
- {{stats.cpu.lastObject.used}} Mhz / {{stats.reservedCPU}} Mhz reserved -
-
- Memory -
- {{stats-time-series data=stats.memory chartClass="is-danger"}} -
-
-
-
- - {{stats.memory.lastObject.percent}} - -
-
-
- {{format-percentage stats.memory.lastObject.percent total=1}} -
-
- {{format-bytes stats.memory.lastObject.used}} MiB / {{stats.reservedMemory}} MiB reserved -
-
-
-
-
Resource Utilization From 9a102b73b9f1479a7280ab259cc49e8997d4b6ce Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 14 Sep 2018 08:57:26 -0700 Subject: [PATCH 31/59] Make rollingArray work with mutable array extension methods --- .../utils/classes/abstract-stats-tracker.js | 2 ++ .../utils/classes/allocation-stats-tracker.js | 2 -- ui/app/utils/classes/node-stats-tracker.js | 2 -- ui/app/utils/classes/rolling-array.js | 24 ++++++++++++------- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/ui/app/utils/classes/abstract-stats-tracker.js b/ui/app/utils/classes/abstract-stats-tracker.js index 4569b29b5e30..825851d22d77 100644 --- a/ui/app/utils/classes/abstract-stats-tracker.js +++ b/ui/app/utils/classes/abstract-stats-tracker.js @@ -4,6 +4,8 @@ import { assert } from '@ember/debug'; export default Mixin.create({ url: '', + bufferSize: 500, + fetch() { assert('StatsTrackers need a fetch method, which should have an interface like window.fetch'); }, diff --git a/ui/app/utils/classes/allocation-stats-tracker.js b/ui/app/utils/classes/allocation-stats-tracker.js index 46ca5365ba2b..d0a4422df9ef 100644 --- a/ui/app/utils/classes/allocation-stats-tracker.js +++ b/ui/app/utils/classes/allocation-stats-tracker.js @@ -14,8 +14,6 @@ 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`; }), diff --git a/ui/app/utils/classes/node-stats-tracker.js b/ui/app/utils/classes/node-stats-tracker.js index 2933553b3bca..ca6122d2f9df 100644 --- a/ui/app/utils/classes/node-stats-tracker.js +++ b/ui/app/utils/classes/node-stats-tracker.js @@ -14,8 +14,6 @@ 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')}`; }), diff --git a/ui/app/utils/classes/rolling-array.js b/ui/app/utils/classes/rolling-array.js index d8d945f13d83..329229bc8976 100644 --- a/ui/app/utils/classes/rolling-array.js +++ b/ui/app/utils/classes/rolling-array.js @@ -15,25 +15,33 @@ export default function RollingArray(maxLength, ...items) { array._splice = array.splice; array._unshift = array.unshift; - array.push = function(...items) { - const returnValue = this._push(...items); + // All mutable array methods build on top of insertAt + array._insertAt = array.insertAt; + // Bring the length back down to maxLength by removing from the front + array._limit = function() { const surplus = this.length - this.maxLength; if (surplus > 0) { this.splice(0, surplus); } + }; - return Math.min(returnValue, this.maxLength); + array.push = function(...items) { + this._push(...items); + this._limit(); + return this.length; }; array.splice = function(...args) { const returnValue = this._splice(...args); + this._limit(); + return returnValue; + }; - const surplus = this.length - this.maxLength; - if (surplus > 0) { - this._splice(0, surplus); - } - + array.insertAt = function(...args) { + const returnValue = this._insertAt(...args); + this._limit(); + this.arrayContentDidChange(); return returnValue; }; From adc05976c2c7f97d46c214518d448b0b13e377d6 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 14 Sep 2018 09:38:17 -0700 Subject: [PATCH 32/59] Use the prototype instead of "private" property backups --- ui/app/utils/classes/rolling-array.js | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/ui/app/utils/classes/rolling-array.js b/ui/app/utils/classes/rolling-array.js index 329229bc8976..02790b88e12c 100644 --- a/ui/app/utils/classes/rolling-array.js +++ b/ui/app/utils/classes/rolling-array.js @@ -3,21 +3,18 @@ // When max length is surpassed, items are removed from // the front of the array. +// Native array methods +let { push, splice } = Array.prototype; + +// Ember array prototype extension +let { insertAt } = Array.prototype; + // 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; - - // All mutable array methods build on top of insertAt - array._insertAt = array.insertAt; - // Bring the length back down to maxLength by removing from the front array._limit = function() { const surplus = this.length - this.maxLength; @@ -27,19 +24,20 @@ export default function RollingArray(maxLength, ...items) { }; array.push = function(...items) { - this._push(...items); + push.apply(this, items); this._limit(); return this.length; }; array.splice = function(...args) { - const returnValue = this._splice(...args); + const returnValue = splice.apply(this, args); this._limit(); return returnValue; }; + // All mutable array methods build on top of insertAt array.insertAt = function(...args) { - const returnValue = this._insertAt(...args); + const returnValue = insertAt.apply(this, args); this._limit(); this.arrayContentDidChange(); return returnValue; From 79c866707299922a00d5918cc588f6536bc04acd Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 14 Sep 2018 10:19:35 -0700 Subject: [PATCH 33/59] Handle the length = 0 and length = 1 cases for activeDatum --- ui/app/components/line-chart.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index 3ef5b1908af3..707c231dccae 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -244,6 +244,8 @@ export default Component.extend(WindowResizable, { 'data' ); + if (!data || !data.length) return; + // Map the mouse coordinate to the index in the data array const bisector = d3Array.bisector(d => d[xProp]).left; const x = xScale.invert(mouseX); @@ -253,8 +255,15 @@ export default Component.extend(WindowResizable, { const dLeft = data[index - 1]; const dRight = data[index]; - // Pick the closer point - const datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft; + let datum; + + // If there is only one point, it's the activeDatum + if (dLeft && !dRight) { + datum = dLeft; + } else { + // Pick the closer point + datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft; + } this.set('activeDatum', datum); this.set('tooltipPosition', { From 58349199c6bd69a9872e242298c46fdb7e7d5263 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 14 Sep 2018 10:20:33 -0700 Subject: [PATCH 34/59] Style the primary-metric pattern --- ui/app/components/primary-metric.js | 2 ++ ui/app/styles/components.scss | 1 + ui/app/styles/components/boxed-section.scss | 1 + ui/app/styles/components/primary-metric.scss | 30 +++++++++++++++++++ .../templates/components/primary-metric.hbs | 24 ++++++++------- 5 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 ui/app/styles/components/primary-metric.scss diff --git a/ui/app/components/primary-metric.js b/ui/app/components/primary-metric.js index 8423af248f0d..b7d1873a13f8 100644 --- a/ui/app/components/primary-metric.js +++ b/ui/app/components/primary-metric.js @@ -9,6 +9,8 @@ import { task, timeout } from 'ember-concurrency'; export default Component.extend({ token: service(), + classNames: ['primary-metric'], + // One of Node, Allocation, or TaskState resource: null, diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 3b99ae2aea0b..d64a988efd1d 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -16,6 +16,7 @@ @import './components/node-status-light'; @import './components/nomad-logo'; @import './components/page-layout'; +@import './components/primary-metric'; @import './components/simple-list'; @import './components/status-text'; @import './components/timeline'; diff --git a/ui/app/styles/components/boxed-section.scss b/ui/app/styles/components/boxed-section.scss index b8f84f0a9ad4..dacc562f5f1c 100644 --- a/ui/app/styles/components/boxed-section.scss +++ b/ui/app/styles/components/boxed-section.scss @@ -34,6 +34,7 @@ & + .boxed-section-body { border-top: none; + padding-top: 0.75em; } } diff --git a/ui/app/styles/components/primary-metric.scss b/ui/app/styles/components/primary-metric.scss new file mode 100644 index 000000000000..e48b52d16e3c --- /dev/null +++ b/ui/app/styles/components/primary-metric.scss @@ -0,0 +1,30 @@ +.primary-metric { + background: $white-bis; + border-radius: $radius; + padding: 0.75em; + color: $grey-dark; + + .title { + color: $grey; + font-weight: $weight-normal; + } + + .primary-graphic { + height: 150px; + } + + .secondary-graphic { + padding: 0.75em; + padding-bottom: 0; + margin-bottom: 0; + + > .column { + padding: 0.5rem 0.75rem; + } + } + + .annotation { + padding: 0 0.75em; + margin-top: -0.75rem; + } +} diff --git a/ui/app/templates/components/primary-metric.hbs b/ui/app/templates/components/primary-metric.hbs index d8d4ab9acfa3..4ae759b43d0b 100644 --- a/ui/app/templates/components/primary-metric.hbs +++ b/ui/app/templates/components/primary-metric.hbs @@ -1,8 +1,8 @@ -{{metricLabel}} -
+

{{metricLabel}}

+
{{stats-time-series data=data chartClass=chartClass}}
-
+
- {{format-percentage data.lastObject.percent total=1}} + {{format-percentage data.lastObject.percent total=1}}
-{{#if (eq metric "cpu")}} - {{data.lastObject.used}} Mhz / {{reservedAmount}} Mhz reserved -{{else if (eq metric "memory")}} - {{format-bytes data.lastObject.used}} MiB / {{reservedAmount}} MiB reserved -{{else}} - {{data.lastObject.used}} / {{reservedAmount}} reserved -{{/if}} +
+ {{#if (eq metric "cpu")}} + {{data.lastObject.used}} Mhz / {{reservedAmount}} Mhz reserved + {{else if (eq metric "memory")}} + {{format-bytes data.lastObject.used}} / {{reservedAmount}} MiB reserved + {{else}} + {{data.lastObject.used}} / {{reservedAmount}} reserved + {{/if}} +
From f84b14540112d0cdc668d70870dac5bdfc56122e Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 14 Sep 2018 10:21:01 -0700 Subject: [PATCH 35/59] Use the appropriate methods and types in the stat trackers --- .../utils/classes/allocation-stats-tracker.js | 20 +++++++++++-------- ui/app/utils/classes/node-stats-tracker.js | 4 ++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/ui/app/utils/classes/allocation-stats-tracker.js b/ui/app/utils/classes/allocation-stats-tracker.js index d0a4422df9ef..5053ccea4a15 100644 --- a/ui/app/utils/classes/allocation-stats-tracker.js +++ b/ui/app/utils/classes/allocation-stats-tracker.js @@ -19,16 +19,18 @@ const AllocationStatsTracker = EmberObject.extend(AbstractStatsTracker, { }), append(frame) { + const timestamp = new Date(Math.floor(frame.Timestamp / 1000000)); + const cpuUsed = Math.floor(frame.ResourceUsage.CpuStats.TotalTicks) || 0; - this.get('cpu').push({ - timestamp: frame.Timestamp, + this.get('cpu').pushObject({ + timestamp, used: cpuUsed, percent: percent(cpuUsed, this.get('reservedCPU')), }); const memoryUsed = frame.ResourceUsage.MemoryStats.RSS; - this.get('memory').push({ - timestamp: frame.Timestamp, + this.get('memory').pushObject({ + timestamp, used: memoryUsed, percent: percent(memoryUsed / 1024 / 1024, this.get('reservedMemory')), }); @@ -41,16 +43,18 @@ const AllocationStatsTracker = EmberObject.extend(AbstractStatsTracker, { // allocation, don't attempt to append data for the task. if (!stats) continue; + const frameTimestamp = new Date(Math.floor(taskFrame.Timestamp / 1000000)); + const taskCpuUsed = Math.floor(taskFrame.ResourceUsage.CpuStats.TotalTicks) || 0; - stats.cpu.push({ - timestamp: taskFrame.Timestamp, + stats.cpu.pushObject({ + timestamp: frameTimestamp, used: taskCpuUsed, percent: percent(taskCpuUsed, stats.reservedCPU), }); const taskMemoryUsed = taskFrame.ResourceUsage.MemoryStats.RSS; - stats.memory.push({ - timestamp: taskFrame.Timestamp, + stats.memory.pushObject({ + timestamp: frameTimestamp, used: taskMemoryUsed, percent: percent(taskMemoryUsed / 1024 / 1024, stats.reservedMemory), }); diff --git a/ui/app/utils/classes/node-stats-tracker.js b/ui/app/utils/classes/node-stats-tracker.js index ca6122d2f9df..3b2702d23852 100644 --- a/ui/app/utils/classes/node-stats-tracker.js +++ b/ui/app/utils/classes/node-stats-tracker.js @@ -22,14 +22,14 @@ const NodeStatsTracker = EmberObject.extend(AbstractStatsTracker, { const timestamp = new Date(Math.floor(frame.Timestamp / 1000000)); const cpuUsed = Math.floor(frame.CPUTicksConsumed) || 0; - this.get('cpu').addObject({ + this.get('cpu').pushObject({ timestamp, used: cpuUsed, percent: percent(cpuUsed, this.get('reservedCPU')), }); const memoryUsed = frame.Memory.Used; - this.get('memory').addObject({ + this.get('memory').pushObject({ timestamp, used: memoryUsed, percent: percent(memoryUsed / 1024 / 1024, this.get('reservedMemory')), From 509f42ca0c6eac2571fd314ae2834708b4a9192f Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 14 Sep 2018 10:21:28 -0700 Subject: [PATCH 36/59] Add resource utilization graphs to the allocation index page --- .../templates/allocations/allocation/index.hbs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs index 590e3bfafbb6..72deebf802a8 100644 --- a/ui/app/templates/allocations/allocation/index.hbs +++ b/ui/app/templates/allocations/allocation/index.hbs @@ -17,6 +17,22 @@
+
+
+ Resource Utilization +
+
+
+
+ {{primary-metric resource=model metric="cpu"}} +
+
+ {{primary-metric resource=model metric="memory"}} +
+
+
+
+
Tasks From 7c2484774912a00194c6220771b721f4f84b5dec Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 14 Sep 2018 10:21:48 -0700 Subject: [PATCH 37/59] Add resource utilization graphs to the task index page --- .../allocations/allocation/task/index.hbs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index 6746fb05badb..dad7a4a71fcd 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -25,6 +25,22 @@
+
+
+ Resource Utilization +
+
+
+
+ {{primary-metric resource=model metric="cpu"}} +
+
+ {{primary-metric resource=model metric="memory"}} +
+
+
+
+ {{#if ports.length}}
From cf9490ce047bac880ebbfc80693b9dd5636f9764 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 17 Sep 2018 15:53:59 -0700 Subject: [PATCH 38/59] New service to manage stats trackers This solves two problems: 1. redundant trackers making redundant requests 2. trackers being obliterated as soon as the primary metric component is destroyed It introduces a new problem where visiting more and more node and allocation pages adds to an ever-growing list of trackers that can assume lots of memory, but it solves the problem by using a least-recently-used cache to limit the number of trackers tracked. --- ui/app/services/stats-trackers-registry.js | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 ui/app/services/stats-trackers-registry.js diff --git a/ui/app/services/stats-trackers-registry.js b/ui/app/services/stats-trackers-registry.js new file mode 100644 index 000000000000..81a8540b0680 --- /dev/null +++ b/ui/app/services/stats-trackers-registry.js @@ -0,0 +1,43 @@ +import Service, { inject as service } from '@ember/service'; +import { LRUMap } from 'lru_map'; +import NodeStatsTracker from 'nomad-ui/utils/classes/node-stats-tracker'; +import AllocationStatsTracker from 'nomad-ui/utils/classes/allocation-stats-tracker'; + +// An unbounded number of stat trackers is a great way to gobble up all the memory +// on a machine. This max number is unscientific, but aims to balance losing +// stat trackers a user is likely to return to with preventing gc from freeing +// memory occupied by stat trackers a user is likely to no longer care about +const MAX_STAT_TRACKERS = 10; +let registry; + +export default Service.extend({ + token: service(), + + init() { + // The LRUMap limits the number of trackers tracked by making room for + // new entries beyond the limit by removing the least recently used entry. + registry = new LRUMap(MAX_STAT_TRACKERS); + }, + + getTracker(resource) { + if (!resource) return; + + const type = resource && resource.constructor.modelName; + const key = `${type}:${resource.get('id')}`; + + const cachedTracker = registry.get(key); + if (cachedTracker) return cachedTracker; + + const Constructor = type === 'node' ? NodeStatsTracker : AllocationStatsTracker; + const resourceProp = type === 'node' ? 'node' : 'allocation'; + + const tracker = Constructor.create({ + fetch: url => this.get('token').authorizedRequest(url), + [resourceProp]: resource, + }); + + registry.set(key, tracker); + + return tracker; + }, +}); From 1a6682d23d3a4cca438ed54683a1204f7836562b Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 17 Sep 2018 15:55:48 -0700 Subject: [PATCH 39/59] New LRUMap dep --- ui/package.json | 5 ++++- ui/yarn.lock | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/package.json b/ui/package.json index 483a73fef3f3..e47c7febf931 100644 --- a/ui/package.json +++ b/ui/package.json @@ -31,8 +31,8 @@ "d3-array": "^1.2.0", "d3-axis": "^1.0.0", "d3-format": "^1.3.0", - "d3-selection": "^1.1.0", "d3-scale": "^1.0.0", + "d3-selection": "^1.1.0", "d3-shape": "^1.2.0", "d3-time-format": "^2.1.0", "d3-transition": "^1.1.0", @@ -94,5 +94,8 @@ "lib/bulma", "lib/calendar" ] + }, + "dependencies": { + "lru_map": "^0.3.3" } } diff --git a/ui/yarn.lock b/ui/yarn.lock index 943460d20146..d29c333a7fe6 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -6245,6 +6245,10 @@ lru-cache@^4.1.1: pseudomap "^1.0.2" yallist "^2.1.2" +lru_map@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd" + make-dir@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.0.0.tgz#97a011751e91dd87cfadef58832ebb04936de978" From 81788cf411e7fa20450450085b66c16a9e9c194e Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 17 Sep 2018 15:56:32 -0700 Subject: [PATCH 40/59] Clean up old controller code --- ui/app/controllers/allocations/allocation/index.js | 14 -------------- ui/app/controllers/clients/client.js | 3 --- ui/app/routes/allocations/allocation/index.js | 12 ------------ 3 files changed, 29 deletions(-) delete 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 04408660eaa7..4d6697c1404b 100644 --- a/ui/app/controllers/allocations/allocation/index.js +++ b/ui/app/controllers/allocations/allocation/index.js @@ -1,11 +1,8 @@ -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(), @@ -21,17 +18,6 @@ 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 7ded2c1be70d..26dfc9b64dac 100644 --- a/ui/app/controllers/clients/client.js +++ b/ui/app/controllers/clients/client.js @@ -1,11 +1,8 @@ -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: { diff --git a/ui/app/routes/allocations/allocation/index.js b/ui/app/routes/allocations/allocation/index.js deleted file mode 100644 index 6d23253776b0..000000000000 --- a/ui/app/routes/allocations/allocation/index.js +++ /dev/null @@ -1,12 +0,0 @@ -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 670b246f4c5034178b6e54402059aa6341923ed6 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 17 Sep 2018 15:57:58 -0700 Subject: [PATCH 41/59] Use the new stats tracker service to get stats trackers in primary metric --- ui/app/components/primary-metric.js | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/ui/app/components/primary-metric.js b/ui/app/components/primary-metric.js index b7d1873a13f8..a46a75461999 100644 --- a/ui/app/components/primary-metric.js +++ b/ui/app/components/primary-metric.js @@ -2,12 +2,11 @@ import Ember from 'ember'; import Component from '@ember/component'; import { inject as service } from '@ember/service'; import { computed } from '@ember/object'; -import NodeStatsTracker from 'nomad-ui/utils/classes/node-stats-tracker'; -import AllocationStatsTracker from 'nomad-ui/utils/classes/allocation-stats-tracker'; import { task, timeout } from 'ember-concurrency'; export default Component.extend({ token: service(), + statsTrackersRegistry: service('stats-trackers-registry'), classNames: ['primary-metric'], @@ -20,15 +19,7 @@ export default Component.extend({ // An instance of a StatsTracker. An alternative interface to resource tracker: computed('trackedResource', 'type', function() { const resource = this.get('trackedResource'); - - if (!resource) return; - - const Constructor = this.get('type') === 'node' ? NodeStatsTracker : AllocationStatsTracker; - const resourceProp = this.get('type') === 'node' ? 'node' : 'allocation'; - return Constructor.create({ - fetch: url => this.get('token').authorizedRequest(url), - [resourceProp]: resource, - }); + return this.get('statsTrackersRegistry').getTracker(resource); }), type: computed('resource', function() { From f0208c0a2407ab6ca1f50900f11cedefd64b07bc Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 17 Sep 2018 15:58:28 -0700 Subject: [PATCH 42/59] Add request throttling to the abstract stats tracker This is the best of three options 1. Users of stats trackers control polling (old method) 2. Stat tracker is stateful and has start/stop methods (like logging) 3. Stat trackers blindly throttle requests This is the best option because it means N number of concurrent users of a stats tracker can request polling without inundating the tracker with redundant frames (or the network with redundant requests), but they also don't have to coordinate amongst themselves to determine what state a tracker should be in. --- ui/app/components/primary-metric.js | 4 ++-- ui/app/utils/classes/abstract-stats-tracker.js | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ui/app/components/primary-metric.js b/ui/app/components/primary-metric.js index a46a75461999..7aae5f33d707 100644 --- a/ui/app/components/primary-metric.js +++ b/ui/app/components/primary-metric.js @@ -79,8 +79,8 @@ export default Component.extend({ poller: task(function*() { do { - yield this.get('tracker').poll(); - yield timeout(2000); + yield this.get('tracker.poll').perform(); + yield timeout(10); } while (!Ember.testing); }), diff --git a/ui/app/utils/classes/abstract-stats-tracker.js b/ui/app/utils/classes/abstract-stats-tracker.js index 825851d22d77..f730936e04fe 100644 --- a/ui/app/utils/classes/abstract-stats-tracker.js +++ b/ui/app/utils/classes/abstract-stats-tracker.js @@ -1,5 +1,6 @@ import Mixin from '@ember/object/mixin'; import { assert } from '@ember/debug'; +import { task, timeout } from 'ember-concurrency'; export default Mixin.create({ url: '', @@ -16,12 +17,18 @@ export default Mixin.create({ ); }, - poll() { + // Uses EC as a form of debounce to prevent multiple + // references to the same tracker from flooding the tracker, + // but also avoiding the issue where different places where the + // same tracker is used needs to coordinate. + poll: task(function*() { const url = this.get('url'); assert('Url must be defined', url); - return this.get('fetch')(url) + yield this.get('fetch')(url) .then(res => res.json()) .then(frame => this.append(frame)); - }, + + yield timeout(2000); + }).drop(), }); From cf57ddc89e1a1bd3998be6bf2d31c36102444d44 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 17 Sep 2018 16:58:26 -0700 Subject: [PATCH 43/59] Gap support for line charts --- ui/app/components/freestyle/sg-line-chart.js | 14 ++++++++++++++ ui/app/components/line-chart.js | 2 ++ .../components/freestyle/sg-line-chart.hbs | 10 ++++++++++ 3 files changed, 26 insertions(+) diff --git a/ui/app/components/freestyle/sg-line-chart.js b/ui/app/components/freestyle/sg-line-chart.js index 145bcbabeb0d..1da331dc4f37 100644 --- a/ui/app/components/freestyle/sg-line-chart.js +++ b/ui/app/components/freestyle/sg-line-chart.js @@ -52,6 +52,20 @@ export default Component.extend({ ]; }), + lineChartGapData: computed(() => { + return [ + { year: 2010, value: 10 }, + { year: 2011, value: 10 }, + { year: 2012, value: null }, + { year: 2013, value: 30 }, + { year: 2014, value: 50 }, + { year: 2015, value: 80 }, + { year: 2016, value: null }, + { year: 2017, value: 210 }, + { year: 2018, value: 340 }, + ]; + }), + lineChartLive: computed(() => { return []; }), diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index 707c231dccae..6c2eb61c4ce9 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -182,6 +182,7 @@ export default Component.extend(WindowResizable, { const line = d3Shape .line() + .defined(d => d[yProp] != null) .x(d => xScale(d[xProp])) .y(d => yScale(d[yProp])); @@ -198,6 +199,7 @@ export default Component.extend(WindowResizable, { const area = d3Shape .area() + .defined(d => d[yProp] != null) .x(d => xScale(d[xProp])) .y0(yScale(0)) .y1(d => yScale(d[yProp])); diff --git a/ui/app/templates/components/freestyle/sg-line-chart.hbs b/ui/app/templates/components/freestyle/sg-line-chart.hbs index 67dd939b01df..014b061bd2ad 100644 --- a/ui/app/templates/components/freestyle/sg-line-chart.hbs +++ b/ui/app/templates/components/freestyle/sg-line-chart.hbs @@ -30,3 +30,13 @@ xFormat=secondsFormat}}
{{/freestyle-usage}} + +{{#freestyle-usage "line-chart-with-gaps" title="Data with gaps"}} +
+ {{line-chart + data=lineChartGapData + xProp="year" + yProp="value" + chartClass="is-primary"}} +
+{{/freestyle-usage}} From 8de545c1b73fb4ce5b62022bda18287c2a6b1384 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 17 Sep 2018 16:59:09 -0700 Subject: [PATCH 44/59] Add cancelation support to stats trackers --- ui/app/components/primary-metric.js | 5 +++-- ui/app/utils/classes/abstract-stats-tracker.js | 16 ++++++++++++++++ ui/app/utils/classes/allocation-stats-tracker.js | 12 ++++++++++++ ui/app/utils/classes/node-stats-tracker.js | 8 ++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/ui/app/components/primary-metric.js b/ui/app/components/primary-metric.js index 7aae5f33d707..c6df40daf335 100644 --- a/ui/app/components/primary-metric.js +++ b/ui/app/components/primary-metric.js @@ -79,8 +79,8 @@ export default Component.extend({ poller: task(function*() { do { - yield this.get('tracker.poll').perform(); - yield timeout(10); + this.get('tracker.poll').perform(); + yield timeout(100); } while (!Ember.testing); }), @@ -92,5 +92,6 @@ export default Component.extend({ willDestroy() { this.get('poller').cancelAll(); + this.get('tracker.signalPause').perform(); }, }); diff --git a/ui/app/utils/classes/abstract-stats-tracker.js b/ui/app/utils/classes/abstract-stats-tracker.js index f730936e04fe..6ffb5ee4d179 100644 --- a/ui/app/utils/classes/abstract-stats-tracker.js +++ b/ui/app/utils/classes/abstract-stats-tracker.js @@ -17,11 +17,20 @@ export default Mixin.create({ ); }, + pause() { + assert( + 'StatsTrackers need a pause method, which takes no arguments but adds a frame of data at the current timestamp with null as the value' + ); + }, + // Uses EC as a form of debounce to prevent multiple // references to the same tracker from flooding the tracker, // but also avoiding the issue where different places where the // same tracker is used needs to coordinate. poll: task(function*() { + // Interrupt any pause attempt + this.get('signalPause').cancelAll(); + const url = this.get('url'); assert('Url must be defined', url); @@ -31,4 +40,11 @@ export default Mixin.create({ yield timeout(2000); }).drop(), + + signalPause: task(function*() { + // wait 2 seconds + yield timeout(2000); + // if no poll called in 2 seconds, pause + this.pause(); + }).drop(), }); diff --git a/ui/app/utils/classes/allocation-stats-tracker.js b/ui/app/utils/classes/allocation-stats-tracker.js index 5053ccea4a15..ecc88fbdc341 100644 --- a/ui/app/utils/classes/allocation-stats-tracker.js +++ b/ui/app/utils/classes/allocation-stats-tracker.js @@ -10,6 +10,8 @@ const percent = (numerator, denominator) => { return numerator / denominator; }; +const empty = ts => ({ timestamp: ts, used: null, percent: null }); + const AllocationStatsTracker = EmberObject.extend(AbstractStatsTracker, { // Set via the stats computed property macro allocation: null, @@ -61,6 +63,16 @@ const AllocationStatsTracker = EmberObject.extend(AbstractStatsTracker, { } }, + pause() { + const ts = new Date(); + this.get('memory').pushObject(empty(ts)); + this.get('cpu').pushObject(empty(ts)); + this.get('tasks').forEach(task => { + task.memory.pushObject(empty(ts)); + task.cpu.pushObject(empty(ts)); + }); + }, + // Static figures, denominators for stats reservedCPU: alias('allocation.taskGroup.reservedCPU'), reservedMemory: alias('allocation.taskGroup.reservedMemory'), diff --git a/ui/app/utils/classes/node-stats-tracker.js b/ui/app/utils/classes/node-stats-tracker.js index 3b2702d23852..b4ec9603d37e 100644 --- a/ui/app/utils/classes/node-stats-tracker.js +++ b/ui/app/utils/classes/node-stats-tracker.js @@ -10,6 +10,8 @@ const percent = (numerator, denominator) => { return numerator / denominator; }; +const empty = ts => ({ timestamp: ts, used: null, percent: null }); + const NodeStatsTracker = EmberObject.extend(AbstractStatsTracker, { // Set via the stats computed property macro node: null, @@ -39,6 +41,12 @@ const NodeStatsTracker = EmberObject.extend(AbstractStatsTracker, { // this.notifyPropertyChange('memory'); }, + pause() { + const ts = new Date(); + this.get('memory').pushObject(empty(ts)); + this.get('cpu').pushObject(empty(ts)); + }, + // Static figures, denominators for stats reservedCPU: alias('node.resources.cpu'), reservedMemory: alias('node.resources.memory'), From 0ede4c512ea6b7186cf5b2810a09ac35a2f9f2c2 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 19 Sep 2018 14:15:32 -0700 Subject: [PATCH 45/59] Integration tests for the primary-metric component --- ui/app/templates/components/line-chart.hbs | 2 +- .../templates/components/primary-metric.hbs | 6 +- ui/tests/integration/primary-metric-test.js | 198 ++++++++++++++++++ 3 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 ui/tests/integration/primary-metric-test.js diff --git a/ui/app/templates/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs index 33e8dc19f148..eb993918d54f 100644 --- a/ui/app/templates/components/line-chart.hbs +++ b/ui/app/templates/components/line-chart.hbs @@ -1,4 +1,4 @@ - + diff --git a/ui/app/templates/components/primary-metric.hbs b/ui/app/templates/components/primary-metric.hbs index 4ae759b43d0b..dd2914dcd611 100644 --- a/ui/app/templates/components/primary-metric.hbs +++ b/ui/app/templates/components/primary-metric.hbs @@ -4,7 +4,7 @@
-
+
- {{format-percentage data.lastObject.percent total=1}} + {{format-percentage data.lastObject.percent total=1}}
-
+
{{#if (eq metric "cpu")}} {{data.lastObject.used}} Mhz / {{reservedAmount}} Mhz reserved {{else if (eq metric "memory")}} diff --git a/ui/tests/integration/primary-metric-test.js b/ui/tests/integration/primary-metric-test.js new file mode 100644 index 000000000000..d11c612b932e --- /dev/null +++ b/ui/tests/integration/primary-metric-test.js @@ -0,0 +1,198 @@ +import EmberObject, { computed } from '@ember/object'; +import Service from '@ember/service'; +import { getOwner } from '@ember/application'; +import { test, moduleForComponent } from 'ember-qunit'; +import wait from 'ember-test-helpers/wait'; +import hbs from 'htmlbars-inline-precompile'; +import { find } from 'ember-native-dom-helpers'; +import { task } from 'ember-concurrency'; +import sinon from 'sinon'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer'; + +moduleForComponent('primary-metric', 'Integration | Component | primary metric', { + integration: true, + beforeEach() { + fragmentSerializerInitializer(getOwner(this)); + this.store = getOwner(this).lookup('service:store'); + this.server = startMirage(); + this.server.create('namespace'); + this.server.create('node'); + + const getTrackerSpy = (this.getTrackerSpy = sinon.spy()); + const trackerPollSpy = (this.trackerPollSpy = sinon.spy()); + const trackerSignalPauseSpy = (this.trackerSignalPauseSpy = sinon.spy()); + + const MockTracker = EmberObject.extend({ + poll: task(function*() { + yield trackerPollSpy(); + }), + signalPause: task(function*() { + yield trackerSignalPauseSpy(); + }), + + cpu: computed(() => []), + memory: computed(() => []), + }); + + const mockStatsTrackersRegistry = Service.extend({ + getTracker(...args) { + getTrackerSpy(...args); + return MockTracker.create(); + }, + }); + + this.register('service:stats-trackers-registry', mockStatsTrackersRegistry); + this.statsTrackersRegistry = getOwner(this).lookup('service:stats-trackers-registry'); + }, + afterEach() { + this.server.shutdown(); + }, +}); + +const commonTemplate = hbs` + {{primary-metric + resource=resource + metric=metric}} +`; + +test('Contains a line chart, a percentage bar, a percentage figure, and an absolute usage figure', function(assert) { + let resource; + const metric = 'cpu'; + + this.store.findAll('node'); + + return wait() + .then(() => { + resource = this.store.peekAll('node').get('firstObject'); + this.setProperties({ resource, metric }); + + this.render(commonTemplate); + return wait(); + }) + .then(() => { + assert.ok(find('[data-test-line-chart]'), 'Line chart'); + assert.ok(find('[data-test-percentage-bar]'), 'Percentage bar'); + assert.ok(find('[data-test-percentage]'), 'Percentage figure'); + assert.ok(find('[data-test-absolute-value]'), 'Absolute usage figure'); + }); +}); + +test('The CPU metric maps to is-info', function(assert) { + let resource; + const metric = 'cpu'; + + this.store.findAll('node'); + + return wait() + .then(() => { + resource = this.store.peekAll('node').get('firstObject'); + this.setProperties({ resource, metric }); + + this.render(commonTemplate); + return wait(); + }) + .then(() => { + assert.ok( + find('[data-test-line-chart] .canvas').classList.contains('is-info'), + 'Info class for CPU metric' + ); + }); +}); + +test('The Memory metric maps to is-danger', function(assert) { + let resource; + const metric = 'memory'; + + this.store.findAll('node'); + + return wait() + .then(() => { + resource = this.store.peekAll('node').get('firstObject'); + this.setProperties({ resource, metric }); + + this.render(commonTemplate); + return wait(); + }) + .then(() => { + assert.ok( + find('[data-test-line-chart] .canvas').classList.contains('is-danger'), + 'Danger class for Memory metric' + ); + }); +}); + +test('Gets the tracker from the tracker registry', function(assert) { + let resource; + const metric = 'cpu'; + + this.store.findAll('node'); + + return wait() + .then(() => { + resource = this.store.peekAll('node').get('firstObject'); + this.setProperties({ resource, metric }); + + this.render(commonTemplate); + return wait(); + }) + .then(() => { + assert.ok( + this.getTrackerSpy.calledWith(resource), + 'Uses the tracker registry to get the tracker for the provided resource' + ); + }); +}); + +test('Immediately polls the tracker', function(assert) { + let resource; + const metric = 'cpu'; + + this.store.findAll('node'); + + return wait() + .then(() => { + resource = this.store.peekAll('node').get('firstObject'); + this.setProperties({ resource, metric }); + + this.render(commonTemplate); + return wait(); + }) + .then(() => { + assert.ok(this.trackerPollSpy.calledOnce, 'The tracker is polled immediately'); + }); +}); + +test('A pause signal is sent to the tracker when the component is destroyed', function(assert) { + let resource; + const metric = 'cpu'; + + // Capture a reference to the spy before the component is destroyed + const trackerSignalPauseSpy = this.trackerSignalPauseSpy; + + this.store.findAll('node'); + + return wait() + .then(() => { + resource = this.store.peekAll('node').get('firstObject'); + this.setProperties({ resource, metric, showComponent: true }); + this.render(hbs` + {{#if showComponent}} + {{primary-metric + resource=resource + metric=metric}} + }} + {{/if}} + `); + return wait(); + }) + .then(() => { + assert.notOk(trackerSignalPauseSpy.called, 'No pause signal has been sent yet'); + // This will toggle the if statement, resulting the primary-metric component being destroyed. + this.set('showComponent', false); + return wait(); + }) + .then(() => { + assert.ok(trackerSignalPauseSpy.calledOnce, 'A pause signal is sent to the tracker'); + }); +}); From 01195a810cd24cae5e0a55587aa9804e8119af87 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 19 Sep 2018 15:19:06 -0700 Subject: [PATCH 46/59] Unit tests for the stats trackers service --- ui/app/services/stats-trackers-registry.js | 6 + ui/app/utils/classes/node-stats-tracker.js | 3 - .../services/stats-trackers-registry-test.js | 152 ++++++++++++++++++ 3 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 ui/tests/unit/services/stats-trackers-registry-test.js diff --git a/ui/app/services/stats-trackers-registry.js b/ui/app/services/stats-trackers-registry.js index 81a8540b0680..0233c74d3679 100644 --- a/ui/app/services/stats-trackers-registry.js +++ b/ui/app/services/stats-trackers-registry.js @@ -1,3 +1,4 @@ +import { computed } from '@ember/object'; import Service, { inject as service } from '@ember/service'; import { LRUMap } from 'lru_map'; import NodeStatsTracker from 'nomad-ui/utils/classes/node-stats-tracker'; @@ -19,6 +20,11 @@ export default Service.extend({ registry = new LRUMap(MAX_STAT_TRACKERS); }, + // A read-only way of getting a reference to the registry. + // Since this could be overwritten by a bad actor, it isn't + // used in getTracker + registryRef: computed(() => registry), + getTracker(resource) { if (!resource) return; diff --git a/ui/app/utils/classes/node-stats-tracker.js b/ui/app/utils/classes/node-stats-tracker.js index b4ec9603d37e..e61fbc27fcc3 100644 --- a/ui/app/utils/classes/node-stats-tracker.js +++ b/ui/app/utils/classes/node-stats-tracker.js @@ -36,9 +36,6 @@ const NodeStatsTracker = EmberObject.extend(AbstractStatsTracker, { used: memoryUsed, percent: percent(memoryUsed / 1024 / 1024, this.get('reservedMemory')), }); - - // this.notifyPropertyChange('cpu'); - // this.notifyPropertyChange('memory'); }, pause() { diff --git a/ui/tests/unit/services/stats-trackers-registry-test.js b/ui/tests/unit/services/stats-trackers-registry-test.js new file mode 100644 index 000000000000..9916fafcfb7e --- /dev/null +++ b/ui/tests/unit/services/stats-trackers-registry-test.js @@ -0,0 +1,152 @@ +import EmberObject from '@ember/object'; +import { getOwner } from '@ember/application'; +import Service from '@ember/service'; +import wait from 'ember-test-helpers/wait'; +import { moduleFor, test } from 'ember-qunit'; +import Pretender from 'pretender'; +import sinon from 'sinon'; +import fetch from 'nomad-ui/utils/fetch'; +import NodeStatsTracker from 'nomad-ui/utils/classes/node-stats-tracker'; + +moduleFor('service:stats-trackers-registry', 'Unit | Service | Stats Trackers Registry', { + beforeEach() { + // Inject a mock token service + const authorizedRequestSpy = (this.tokenAuthorizedRequestSpy = sinon.spy()); + const mockToken = Service.extend({ + authorizedRequest(url) { + authorizedRequestSpy(url); + return fetch(url); + }, + }); + + this.register('service:token', mockToken); + this.token = getOwner(this).lookup('service:token'); + this.server = new Pretender(function() { + this.get('/v1/client/stats', () => [ + 200, + {}, + JSON.stringify({ + Timestamp: 1234567890, + CPUTicksConsumed: 11, + Memory: { + Used: 12, + }, + }), + ]); + }); + }, + afterEach() { + this.server.shutdown(); + }, + subject() { + return getOwner(this) + .factoryFor('service:stats-trackers-registry') + .create(); + }, +}); + +const makeModelMock = (modelName, defaults) => { + const Class = EmberObject.extend(defaults); + Class.prototype.constructor.modelName = modelName; + return Class; +}; + +const mockNode = makeModelMock('node', { id: 'test' }); + +test('Creates a tracker when one isn’t found', function(assert) { + const registry = this.subject(); + const id = 'id'; + + assert.equal(registry.get('registryRef').size, 0, 'Nothing in the registry yet'); + + const tracker = registry.getTracker(mockNode.create({ id })); + assert.ok(tracker instanceof NodeStatsTracker, 'The correct type of tracker is made'); + assert.equal(registry.get('registryRef').size, 1, 'The tracker was added to the registry'); + assert.deepEqual( + Array.from(registry.get('registryRef').keys()), + [`node:${id}`], + 'The object in the registry has the correct key' + ); +}); + +test('Returns an existing tracker when one is found', function(assert) { + const registry = this.subject(); + const node = mockNode.create(); + + const tracker1 = registry.getTracker(node); + const tracker2 = registry.getTracker(node); + + assert.equal(tracker1, tracker2, 'Returns an existing tracker for the same resource'); + assert.equal(registry.get('registryRef').size, 1, 'Only one tracker in the registry'); +}); + +test('Registry does not depend on persistent object references', function(assert) { + const registry = this.subject(); + const id = 'some-id'; + + const node1 = mockNode.create({ id }); + const node2 = mockNode.create({ id }); + + assert.notEqual(node1, node2, 'Two different resources'); + assert.equal(node1.get('id'), node2.get('id'), 'With the same IDs'); + assert.equal(node1.constructor.modelName, node2.constructor.modelName, 'And the same className'); + + assert.equal(registry.getTracker(node1), registry.getTracker(node2), 'Return the same tracker'); + assert.equal(registry.get('registryRef').size, 1, 'Only one tracker in the registry'); +}); + +test('Has a max size', function(assert) { + const registry = this.subject(); + const ref = registry.get('registryRef'); + + // Kind of a silly assertion, but the exact limit is arbitrary. Whether it's 10 or 1000 + // isn't important as long as there is one. + assert.ok(ref.limit < Infinity, `A limit (${ref.limit}) is set`); +}); + +test('Removes least recently used when something needs to be removed', function(assert) { + const registry = this.subject(); + const activeNode = mockNode.create({ id: 'active' }); + const inactiveNode = mockNode.create({ id: 'inactive' }); + const limit = registry.get('registryRef').limit; + + // First put in the two tracked nodes + registry.getTracker(activeNode); + registry.getTracker(inactiveNode); + + for (let i = 0; i < limit; i++) { + // Add a new tracker to the registry + const newNode = mockNode.create({ id: `node-${i}` }); + registry.getTracker(newNode); + + // But read the active node tracker to keep it fresh + registry.getTracker(activeNode); + } + + const ref = registry.get('registryRef'); + assert.equal(ref.size, ref.limit, 'The limit was reached'); + + assert.ok( + ref.get('node:active'), + 'The active tracker is still in the registry despite being added first' + ); + assert.notOk( + ref.get('node:inactive'), + 'The inactive tracker got pushed out due to not being accessed' + ); +}); + +test('Trackers are created using the token authorizedRequest', function(assert) { + const registry = this.subject(); + const node = mockNode.create(); + + const tracker = registry.getTracker(node); + + tracker.get('poll').perform(); + assert.ok( + this.tokenAuthorizedRequestSpy.calledWith(`/v1/client/stats?node_id=${node.get('id')}`), + 'The token service authorizedRequest function was used' + ); + + return wait(); +}); From 28d8f797e6f47b335bae8bcca5aa14226ee07dc7 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 19 Sep 2018 16:32:53 -0700 Subject: [PATCH 47/59] Handle the empty data cases --- ui/app/components/line-chart.js | 9 +++++---- ui/app/components/stats-time-series.js | 7 +++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index 6c2eb61c4ce9..e8846026d2e0 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -87,17 +87,18 @@ export default Component.extend(WindowResizable, { xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() { const xProp = this.get('xProp'); const scale = this.get('timeseries') ? d3Scale.scaleTime() : d3Scale.scaleLinear(); + const data = this.get('data'); - scale - .rangeRound([10, this.get('yAxisOffset')]) - .domain(d3Array.extent(this.get('data'), d => d[xProp])); + const domain = data.length ? d3Array.extent(this.get('data'), d => d[xProp]) : [0, 1]; + + scale.rangeRound([10, this.get('yAxisOffset')]).domain(domain); return scale; }), yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() { const yProp = this.get('yProp'); - let max = d3Array.max(this.get('data'), d => d[yProp]); + let max = d3Array.max(this.get('data'), d => d[yProp]) || 1; if (max > 1) { max = nice(max); } diff --git a/ui/app/components/stats-time-series.js b/ui/app/components/stats-time-series.js index 403c6a7d7da2..730762171826 100644 --- a/ui/app/components/stats-time-series.js +++ b/ui/app/components/stats-time-series.js @@ -22,12 +22,15 @@ export default LineChart.extend({ xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() { const xProp = this.get('xProp'); const scale = this.get('timeseries') ? d3Scale.scaleTime() : d3Scale.scaleLinear(); + const data = this.get('data'); - const [low, high] = d3Array.extent(this.get('data'), d => d[xProp]); + const [low, high] = d3Array.extent(data, d => d[xProp]); const minLow = moment(high) .subtract(5, 'minutes') .toDate(); - scale.rangeRound([10, this.get('yAxisOffset')]).domain([Math.min(low, minLow), high]); + + const extent = data.length ? [Math.min(low, minLow), high] : [minLow, new Date()]; + scale.rangeRound([10, this.get('yAxisOffset')]).domain(extent); return scale; }), From f4ae9e19a3939f0da4a20c24522d982b7881bcce Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 19 Sep 2018 16:33:18 -0700 Subject: [PATCH 48/59] Always return valid dates for timestamps --- ui/mirage/factories/client-allocation-stats.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/mirage/factories/client-allocation-stats.js b/ui/mirage/factories/client-allocation-stats.js index e4573ed08d42..369fa416313e 100644 --- a/ui/mirage/factories/client-allocation-stats.js +++ b/ui/mirage/factories/client-allocation-stats.js @@ -6,13 +6,15 @@ export default Factory.extend({ _taskNames: () => [], // Set by allocation + timestamp: () => Date.now() * 1000000, + tasks() { var hash = {}; this._taskNames.forEach(task => { hash[task] = { Pids: null, ResourceUsage: generateResources(), - Timestamp: Date.now(), + Timestamp: Date.now() * 1000000, }; }); return hash; From 866f650de896c1e880246b83d9a0f7472a9f932c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 19 Sep 2018 16:33:51 -0700 Subject: [PATCH 49/59] Acceptance test coverage for all the pages with resource utilization graphs --- ui/app/components/primary-metric.js | 2 ++ ui/app/templates/components/primary-metric.hbs | 2 +- ui/app/utils/classes/allocation-stats-tracker.js | 3 ++- ui/tests/acceptance/allocation-detail-test.js | 6 ++++++ ui/tests/acceptance/client-detail-test.js | 10 ++++++++++ ui/tests/acceptance/task-detail-test.js | 6 ++++++ ui/tests/pages/allocations/detail.js | 15 ++++++++++++++- ui/tests/pages/allocations/task/detail.js | 5 +++++ ui/tests/pages/clients/detail.js | 5 +++++ 9 files changed, 51 insertions(+), 3 deletions(-) diff --git a/ui/app/components/primary-metric.js b/ui/app/components/primary-metric.js index c6df40daf335..be5f3a61cdca 100644 --- a/ui/app/components/primary-metric.js +++ b/ui/app/components/primary-metric.js @@ -16,6 +16,8 @@ export default Component.extend({ // cpu or memory metric: null, + 'data-test-primary-metric': true, + // An instance of a StatsTracker. An alternative interface to resource tracker: computed('trackedResource', 'type', function() { const resource = this.get('trackedResource'); diff --git a/ui/app/templates/components/primary-metric.hbs b/ui/app/templates/components/primary-metric.hbs index dd2914dcd611..10b9c0c114a4 100644 --- a/ui/app/templates/components/primary-metric.hbs +++ b/ui/app/templates/components/primary-metric.hbs @@ -1,4 +1,4 @@ -

{{metricLabel}}

+

{{metricLabel}}

{{stats-time-series data=data chartClass=chartClass}}
diff --git a/ui/app/utils/classes/allocation-stats-tracker.js b/ui/app/utils/classes/allocation-stats-tracker.js index ecc88fbdc341..432fb4b24ec6 100644 --- a/ui/app/utils/classes/allocation-stats-tracker.js +++ b/ui/app/utils/classes/allocation-stats-tracker.js @@ -88,7 +88,8 @@ const AllocationStatsTracker = EmberObject.extend(AbstractStatsTracker, { tasks: computed('allocation', function() { const bufferSize = this.get('bufferSize'); - return this.get('allocation.taskGroup.tasks').map(task => ({ + const tasks = this.get('allocation.taskGroup.tasks') || []; + return tasks.map(task => ({ task: get(task, 'name'), // Static figures, denominators for stats diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js index ddd17e91b36b..14447a14631a 100644 --- a/ui/tests/acceptance/allocation-detail-test.js +++ b/ui/tests/acceptance/allocation-detail-test.js @@ -64,6 +64,12 @@ test('/allocation/:id should name the allocation and link to the corresponding j }); }); +test('/allocation/:id should include resource utilization graphs', function(assert) { + assert.equal(Allocation.resourceCharts.length, 2, 'Two resource utilization graphs'); + assert.equal(Allocation.resourceCharts.objectAt(0).name, 'CPU', 'First chart is CPU'); + assert.equal(Allocation.resourceCharts.objectAt(1).name, 'Memory', 'Second chart is Memory'); +}); + test('/allocation/:id should list all tasks for the allocation', function(assert) { assert.equal( Allocation.tasks.length, diff --git a/ui/tests/acceptance/client-detail-test.js b/ui/tests/acceptance/client-detail-test.js index 1c766d429c39..b13ce5ade54d 100644 --- a/ui/tests/acceptance/client-detail-test.js +++ b/ui/tests/acceptance/client-detail-test.js @@ -93,6 +93,16 @@ test('/clients/:id should list additional detail for the node below the title', }); }); +test('/clients/:id should include resource utilization graphs', function(assert) { + ClientDetail.visit({ id: node.id }); + + andThen(() => { + assert.equal(ClientDetail.resourceCharts.length, 2, 'Two resource utilization graphs'); + assert.equal(ClientDetail.resourceCharts.objectAt(0).name, 'CPU', 'First chart is CPU'); + assert.equal(ClientDetail.resourceCharts.objectAt(1).name, 'Memory', 'Second chart is Memory'); + }); +}); + test('/clients/:id should list all allocations on the node', function(assert) { const allocationsCount = server.db.allocations.where({ nodeId: node.id }).length; diff --git a/ui/tests/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js index cc30097f708a..7d7df686df4c 100644 --- a/ui/tests/acceptance/task-detail-test.js +++ b/ui/tests/acceptance/task-detail-test.js @@ -94,6 +94,12 @@ test('breadcrumbs match jobs / job / task group / allocation / task', function(a }); }); +test('/allocation/:id/:task_name should include resource utilization graphs', function(assert) { + assert.equal(Task.resourceCharts.length, 2, 'Two resource utilization graphs'); + assert.equal(Task.resourceCharts.objectAt(0).name, 'CPU', 'First chart is CPU'); + assert.equal(Task.resourceCharts.objectAt(1).name, 'Memory', 'Second chart is Memory'); +}); + test('the addresses table lists all reserved and dynamic ports', function(assert) { const taskResources = allocation.taskResourcesIds .map(id => server.db.taskResources.find(id)) diff --git a/ui/tests/pages/allocations/detail.js b/ui/tests/pages/allocations/detail.js index 26ec8fe734ea..c824f9610367 100644 --- a/ui/tests/pages/allocations/detail.js +++ b/ui/tests/pages/allocations/detail.js @@ -1,4 +1,12 @@ -import { clickable, create, collection, isPresent, text, visitable } from 'ember-cli-page-object'; +import { + attribute, + clickable, + create, + collection, + isPresent, + text, + visitable, +} from 'ember-cli-page-object'; export default create({ visit: visitable('/allocations/:id'), @@ -15,6 +23,11 @@ export default create({ visitClient: clickable('[data-test-client-link]'), }, + resourceCharts: collection('[data-test-primary-metric]', { + name: text('[data-test-primary-metric-title]'), + chartClass: attribute('class', '[data-test-percentage-chart] progress'), + }), + tasks: collection('[data-test-task-row]', { name: text('[data-test-name]'), state: text('[data-test-state]'), diff --git a/ui/tests/pages/allocations/task/detail.js b/ui/tests/pages/allocations/task/detail.js index 80ef65ee0bff..249986cfb929 100644 --- a/ui/tests/pages/allocations/task/detail.js +++ b/ui/tests/pages/allocations/task/detail.js @@ -25,6 +25,11 @@ export default create({ return this.breadcrumbs.toArray().find(crumb => crumb.id === id); }, + resourceCharts: collection('[data-test-primary-metric]', { + name: text('[data-test-primary-metric-title]'), + chartClass: attribute('class', '[data-test-percentage-chart] progress'), + }), + hasAddresses: isPresent('[data-test-task-addresses]'), addresses: collection('[data-test-task-address]', { name: text('[data-test-task-address-name]'), diff --git a/ui/tests/pages/clients/detail.js b/ui/tests/pages/clients/detail.js index 309a3f176d0a..c4b98d9ded3d 100644 --- a/ui/tests/pages/clients/detail.js +++ b/ui/tests/pages/clients/detail.js @@ -38,6 +38,11 @@ export default create({ eligibilityDefinition: text('[data-test-eligibility]'), datacenterDefinition: text('[data-test-datacenter-definition]'), + resourceCharts: collection('[data-test-primary-metric]', { + name: text('[data-test-primary-metric-title]'), + chartClass: attribute('class', '[data-test-percentage-chart] progress'), + }), + ...allocations(), attributesTable: isPresent('[data-test-attributes]'), From 47ec74eb3ade984109c6a6b6cbb745aa0ea903a1 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 19 Sep 2018 19:30:18 -0700 Subject: [PATCH 50/59] Update stat tracker unit tests --- .../utils/classes/abstract-stats-tracker.js | 16 ++- .../utils/allocation-stats-tracker-test.js | 111 +++++++++++------- .../unit/utils/node-stats-tracker-test.js | 27 +++-- 3 files changed, 92 insertions(+), 62 deletions(-) diff --git a/ui/app/utils/classes/abstract-stats-tracker.js b/ui/app/utils/classes/abstract-stats-tracker.js index 6ffb5ee4d179..08a8f03f5568 100644 --- a/ui/app/utils/classes/abstract-stats-tracker.js +++ b/ui/app/utils/classes/abstract-stats-tracker.js @@ -31,12 +31,16 @@ export default Mixin.create({ // Interrupt any pause attempt this.get('signalPause').cancelAll(); - const url = this.get('url'); - assert('Url must be defined', url); - - yield this.get('fetch')(url) - .then(res => res.json()) - .then(frame => this.append(frame)); + try { + const url = this.get('url'); + assert('Url must be defined', url); + + yield this.get('fetch')(url) + .then(res => res.json()) + .then(frame => this.append(frame)); + } catch (error) { + throw new Error(error); + } yield timeout(2000); }).drop(), diff --git a/ui/tests/unit/utils/allocation-stats-tracker-test.js b/ui/tests/unit/utils/allocation-stats-tracker-test.js index 51a041ffe916..cbfa181fa4f9 100644 --- a/ui/tests/unit/utils/allocation-stats-tracker-test.js +++ b/ui/tests/unit/utils/allocation-stats-tracker-test.js @@ -9,7 +9,8 @@ import fetch from 'nomad-ui/utils/fetch'; module('Unit | Util | AllocationStatsTracker'); -const refDate = Date.now(); +const refDate = Date.now() * 1000000; +const makeDate = ts => new Date(ts / 1000000); const MockAllocation = overrides => assign( @@ -91,7 +92,7 @@ test('the AllocationStatsTracker constructor expects a fetch definition and an a const tracker = AllocationStatsTracker.create(); assert.throws( () => { - tracker.poll(); + tracker.fetch(); }, /StatsTrackers need a fetch method/, 'Polling does not work without a fetch method provided' @@ -159,7 +160,7 @@ test('poll results in requesting the url and calling append with the resulting J this.get('/v1/client/allocation/:id/stats', () => [200, {}, JSON.stringify(mockFrame)]); }); - tracker.poll(); + tracker.get('poll').perform(); assert.equal(server.handledRequests.length, 1, 'Only one request was made'); assert.equal( @@ -199,12 +200,18 @@ test('append appropriately maps a data frame to the tracked stats for cpu and me assert.deepEqual( tracker.get('cpu'), - [{ timestamp: refDate + 1000, used: 101, percent: 101 / 200 }], + [{ timestamp: makeDate(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 }], + [ + { + timestamp: makeDate(refDate + 1000), + used: 401 * 1024 * 1024, + percent: 401 / 512, + }, + ], 'One frame of memory' ); @@ -215,22 +222,40 @@ test('append appropriately maps a data frame to the tracked stats for cpu and me 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 }], + cpu: [{ timestamp: makeDate(refDate + 1), used: 51, percent: 51 / 100 }], + memory: [ + { + timestamp: makeDate(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 }], + cpu: [{ timestamp: makeDate(refDate + 10), used: 26, percent: 26 / 50 }], + memory: [ + { + timestamp: makeDate(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 }], + cpu: [{ timestamp: makeDate(refDate + 100), used: 27, percent: 27 / 50 }], + memory: [ + { + timestamp: makeDate(refDate + 100), + used: 52 * 1024 * 1024, + percent: 52 / 128, + }, + ], }, ], 'tasks represents the tasks for the allocation, each with one frame of stats' @@ -241,16 +266,16 @@ test('append appropriately maps a data frame to the tracked stats for cpu and me assert.deepEqual( tracker.get('cpu'), [ - { timestamp: refDate + 1000, used: 101, percent: 101 / 200 }, - { timestamp: refDate + 2000, used: 102, percent: 102 / 200 }, + { timestamp: makeDate(refDate + 1000), used: 101, percent: 101 / 200 }, + { timestamp: makeDate(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 }, + { timestamp: makeDate(refDate + 1000), used: 401 * 1024 * 1024, percent: 401 / 512 }, + { timestamp: makeDate(refDate + 2000), used: 402 * 1024 * 1024, percent: 402 / 512 }, ], 'Two frames of memory' ); @@ -263,12 +288,12 @@ test('append appropriately maps a data frame to the tracked stats for cpu and me reservedCPU: 100, reservedMemory: 256, cpu: [ - { timestamp: refDate + 1, used: 51, percent: 51 / 100 }, - { timestamp: refDate + 2, used: 52, percent: 52 / 100 }, + { timestamp: makeDate(refDate + 1), used: 51, percent: 51 / 100 }, + { timestamp: makeDate(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 }, + { timestamp: makeDate(refDate + 1), used: 101 * 1024 * 1024, percent: 101 / 256 }, + { timestamp: makeDate(refDate + 2), used: 102 * 1024 * 1024, percent: 102 / 256 }, ], }, { @@ -276,12 +301,12 @@ test('append appropriately maps a data frame to the tracked stats for cpu and me reservedCPU: 50, reservedMemory: 128, cpu: [ - { timestamp: refDate + 10, used: 26, percent: 26 / 50 }, - { timestamp: refDate + 20, used: 27, percent: 27 / 50 }, + { timestamp: makeDate(refDate + 10), used: 26, percent: 26 / 50 }, + { timestamp: makeDate(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 }, + { timestamp: makeDate(refDate + 10), used: 51 * 1024 * 1024, percent: 51 / 128 }, + { timestamp: makeDate(refDate + 20), used: 52 * 1024 * 1024, percent: 52 / 128 }, ], }, { @@ -289,12 +314,12 @@ test('append appropriately maps a data frame to the tracked stats for cpu and me reservedCPU: 50, reservedMemory: 128, cpu: [ - { timestamp: refDate + 100, used: 27, percent: 27 / 50 }, - { timestamp: refDate + 200, used: 28, percent: 28 / 50 }, + { timestamp: makeDate(refDate + 100), used: 27, percent: 27 / 50 }, + { timestamp: makeDate(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 }, + { timestamp: makeDate(refDate + 100), used: 52 * 1024 * 1024, percent: 52 / 128 }, + { timestamp: makeDate(refDate + 200), used: 53 * 1024 * 1024, percent: 53 / 128 }, ], }, ], @@ -323,13 +348,13 @@ test('each stat list has maxLength equal to bufferSize', function(assert) { ); assert.equal( - tracker.get('cpu')[0].timestamp, - refDate + 11000, + +tracker.get('cpu')[0].timestamp, + +makeDate(refDate + 11000), 'Old frames are removed in favor of newer ones' ); assert.equal( - tracker.get('memory')[0].timestamp, - refDate + 11000, + +tracker.get('memory')[0].timestamp, + +makeDate(refDate + 11000), 'Old frames are removed in favor of newer ones' ); @@ -347,35 +372,35 @@ test('each stat list has maxLength equal to bufferSize', function(assert) { }); assert.equal( - tracker.get('tasks').findBy('task', 'service').cpu[0].timestamp, - refDate + 11, + +tracker.get('tasks').findBy('task', 'service').cpu[0].timestamp, + +makeDate(refDate + 11), 'Old frames are removed in favor of newer ones' ); assert.equal( - tracker.get('tasks').findBy('task', 'service').memory[0].timestamp, - refDate + 11, + +tracker.get('tasks').findBy('task', 'service').memory[0].timestamp, + +makeDate(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, + +tracker.get('tasks').findBy('task', 'log-shipper').cpu[0].timestamp, + +makeDate(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, + +tracker.get('tasks').findBy('task', 'log-shipper').memory[0].timestamp, + +makeDate(refDate + 110), 'Old frames are removed in favor of newer ones' ); assert.equal( - tracker.get('tasks').findBy('task', 'sidecar').cpu[0].timestamp, - refDate + 1100, + +tracker.get('tasks').findBy('task', 'sidecar').cpu[0].timestamp, + +makeDate(refDate + 1100), 'Old frames are removed in favor of newer ones' ); assert.equal( - tracker.get('tasks').findBy('task', 'sidecar').memory[0].timestamp, - refDate + 1100, + +tracker.get('tasks').findBy('task', 'sidecar').memory[0].timestamp, + +makeDate(refDate + 1100), 'Old frames are removed in favor of newer ones' ); }); diff --git a/ui/tests/unit/utils/node-stats-tracker-test.js b/ui/tests/unit/utils/node-stats-tracker-test.js index 55c6c070fbd9..5dfbb19ff411 100644 --- a/ui/tests/unit/utils/node-stats-tracker-test.js +++ b/ui/tests/unit/utils/node-stats-tracker-test.js @@ -9,7 +9,8 @@ import fetch from 'nomad-ui/utils/fetch'; module('Unit | Util | NodeStatsTracker'); -const refDate = Date.now(); +const refDate = Date.now() * 1000000; +const makeDate = ts => new Date(ts / 1000000); const MockNode = overrides => assign( @@ -35,7 +36,7 @@ test('the NodeStatsTracker constructor expects a fetch definition and a node', f const tracker = NodeStatsTracker.create(); assert.throws( () => { - tracker.poll(); + tracker.fetch(); }, /StatsTrackers need a fetch method/, 'Polling does not work without a fetch method provided' @@ -79,7 +80,7 @@ test('poll results in requesting the url and calling append with the resulting J this.get('/v1/client/stats', () => [200, {}, JSON.stringify(mockFrame)]); }); - tracker.poll(); + tracker.get('poll').perform(); assert.equal(server.handledRequests.length, 1, 'Only one request was made'); assert.equal( @@ -109,13 +110,13 @@ test('append appropriately maps a data frame to the tracked stats for cpu and me assert.deepEqual( tracker.get('cpu'), - [{ timestamp: refDate + 1, used: 1001, percent: 1001 / 2000 }], + [{ timestamp: makeDate(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 }], + [{ timestamp: makeDate(refDate + 1), used: 2049 * 1024 * 1024, percent: 2049 / 4096 }], 'One frame of memory' ); @@ -124,8 +125,8 @@ test('append appropriately maps a data frame to the tracked stats for cpu and me assert.deepEqual( tracker.get('cpu'), [ - { timestamp: refDate + 1, used: 1001, percent: 1001 / 2000 }, - { timestamp: refDate + 2, used: 1002, percent: 1002 / 2000 }, + { timestamp: makeDate(refDate + 1), used: 1001, percent: 1001 / 2000 }, + { timestamp: makeDate(refDate + 2), used: 1002, percent: 1002 / 2000 }, ], 'Two frames of cpu' ); @@ -133,8 +134,8 @@ test('append appropriately maps a data frame to the tracked stats for cpu and me 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 }, + { timestamp: makeDate(refDate + 1), used: 2049 * 1024 * 1024, percent: 2049 / 4096 }, + { timestamp: makeDate(refDate + 2), used: 2050 * 1024 * 1024, percent: 2050 / 4096 }, ], 'Two frames of memory' ); @@ -161,13 +162,13 @@ test('each stat list has maxLength equal to bufferSize', function(assert) { ); assert.equal( - tracker.get('cpu')[0].timestamp, - refDate + 11, + +tracker.get('cpu')[0].timestamp, + +makeDate(refDate + 11), 'Old frames are removed in favor of newer ones' ); assert.equal( - tracker.get('memory')[0].timestamp, - refDate + 11, + +tracker.get('memory')[0].timestamp, + +makeDate(refDate + 11), 'Old frames are removed in favor of newer ones' ); }); From 65336ccd96351937c7832a19b66ba7b56729a5aa Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 20 Sep 2018 15:36:13 -0700 Subject: [PATCH 51/59] Use the StatsTracker method of getting alloc stats in alloc row --- ui/app/components/allocation-row.js | 30 +++++++++++-------- .../templates/components/allocation-row.hbs | 16 +++++----- ui/tests/integration/allocation-row-test.js | 3 -- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/ui/app/components/allocation-row.js b/ui/app/components/allocation-row.js index d2a7433020ba..ef4f3a45b841 100644 --- a/ui/app/components/allocation-row.js +++ b/ui/app/components/allocation-row.js @@ -2,12 +2,15 @@ import Ember from 'ember'; import { inject as service } from '@ember/service'; import Component from '@ember/component'; import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; import { run } from '@ember/runloop'; -import { lazyClick } from '../helpers/lazy-click'; import { task, timeout } from 'ember-concurrency'; +import { lazyClick } from '../helpers/lazy-click'; +import AllocationStatsTracker from 'nomad-ui/utils/classes/allocation-stats-tracker'; export default Component.extend({ store: service(), + token: service(), tagName: 'tr', @@ -18,14 +21,21 @@ export default Component.extend({ // Used to determine whether the row should mention the node or the job context: null, - backoffSequence: computed(() => [500, 800, 1300, 2100, 3400, 5500]), - // Internal state - stats: null, statsError: false, enablePolling: computed(() => !Ember.testing), + stats: computed('allocation', function() { + return AllocationStatsTracker.create({ + fetch: url => this.get('token').authorizedRequest(url), + allocation: this.get('allocation'), + }); + }), + + cpu: alias('stats.cpu.lastObject'), + memory: alias('stats.memory.lastObject'), + onClick() {}, click(event) { @@ -43,19 +53,15 @@ export default Component.extend({ } }, - fetchStats: task(function*(allocation) { - const backoffSequence = this.get('backoffSequence').slice(); - const maxTiming = backoffSequence.pop(); - + fetchStats: task(function*() { do { try { - const stats = yield allocation.fetchStats(); - this.set('stats', stats); + yield this.get('stats.poll').perform(); this.set('statsError', false); } catch (error) { this.set('statsError', true); } - yield timeout(backoffSequence.shift() || maxTiming); + yield timeout(500); } while (this.get('enablePolling')); }).drop(), }); @@ -63,7 +69,7 @@ export default Component.extend({ function qualifyAllocation() { const allocation = this.get('allocation'); return allocation.reload().then(() => { - this.get('fetchStats').perform(allocation); + this.get('fetchStats').perform(); // Make sure that the job record in the store for this allocation // is complete and not a partial from the list endpoint diff --git a/ui/app/templates/components/allocation-row.hbs b/ui/app/templates/components/allocation-row.hbs index fd16e5bc9064..72baf4a40a5a 100644 --- a/ui/app/templates/components/allocation-row.hbs +++ b/ui/app/templates/components/allocation-row.hbs @@ -42,7 +42,7 @@ {{allocation.jobVersion}} {{/if}} - {{#if (and (not stats) fetchStats.isRunning)}} + {{#if (and (not cpu) fetchStats.isRunning)}} ... {{else if (not allocation)}} {{! nothing when there's no allocation}} @@ -51,18 +51,18 @@ {{x-icon "warning" class="is-warning"}} {{else}} -
+
- {{stats.percentCPU}} + {{cpu.percent}}
{{/if}} - {{#if (and (not stats) fetchStats.isRunning)}} + {{#if (and (not memory) fetchStats.isRunning)}} ... {{else if (not allocation)}} {{! nothing when there's no allocation}} @@ -71,12 +71,12 @@ {{x-icon "warning" class="is-warning"}} {{else}} -
+
- {{stats.percentMemory}} + {{memory.percent}}
{{/if}} diff --git a/ui/tests/integration/allocation-row-test.js b/ui/tests/integration/allocation-row-test.js index 3049737f28a1..a656732eb80f 100644 --- a/ui/tests/integration/allocation-row-test.js +++ b/ui/tests/integration/allocation-row-test.js @@ -34,7 +34,6 @@ test('Allocation row polls for stats, even when it errors or has an invalid resp 'Valid JSON', JSON.stringify({ ResourceUsage: generateResources() }), ]; - const backoffSequence = [50]; this.server.get('/client/allocation/:id/stats', function() { const response = frames[++currentFrame]; @@ -61,7 +60,6 @@ test('Allocation row polls for stats, even when it errors or has an invalid resp this.setProperties({ allocation, - backoffSequence, context: 'job', enablePolling: true, }); @@ -70,7 +68,6 @@ test('Allocation row polls for stats, even when it errors or has an invalid resp {{allocation-row allocation=allocation context=context - backoffSequence=backoffSequence enablePolling=enablePolling}} `); return wait(); From 4d5fa15ea7b7325805f949ce12f873a3467eaa1f Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 20 Sep 2018 15:40:50 -0700 Subject: [PATCH 52/59] Remove no longer used allocation-stats class --- ui/app/models/allocation.js | 13 ---------- ui/app/utils/classes/allocation-stats.js | 33 ------------------------ 2 files changed, 46 deletions(-) delete mode 100644 ui/app/utils/classes/allocation-stats.js diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js index f98e3fc22fae..1a7e030a5304 100644 --- a/ui/app/models/allocation.js +++ b/ui/app/models/allocation.js @@ -6,7 +6,6 @@ import { belongsTo } from 'ember-data/relationships'; import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes'; import intersection from 'lodash.intersection'; import shortUUIDProperty from '../utils/properties/short-uuid'; -import AllocationStats from '../utils/classes/allocation-stats'; const STATUS_ORDER = { pending: 1, @@ -74,18 +73,6 @@ export default Model.extend({ return []; }), - fetchStats() { - return this.get('token') - .authorizedRequest(`/v1/client/allocation/${this.get('id')}/stats`) - .then(res => res.json()) - .then(json => { - return new AllocationStats({ - stats: json, - allocation: this, - }); - }); - }, - states: fragmentArray('task-state'), rescheduleEvents: fragmentArray('reschedule-event'), diff --git a/ui/app/utils/classes/allocation-stats.js b/ui/app/utils/classes/allocation-stats.js deleted file mode 100644 index 463affbb4d23..000000000000 --- a/ui/app/utils/classes/allocation-stats.js +++ /dev/null @@ -1,33 +0,0 @@ -import EmberObject, { computed } from '@ember/object'; -import { alias, readOnly } from '@ember/object/computed'; - -export default EmberObject.extend({ - allocation: null, - stats: null, - - reservedMemory: alias('allocation.taskGroup.reservedMemory'), - reservedCPU: alias('allocation.taskGroup.reservedCPU'), - - memoryUsed: readOnly('stats.ResourceUsage.MemoryStats.RSS'), - cpuUsed: computed('stats.ResourceUsage.CpuStats.TotalTicks', function() { - return Math.floor(this.get('stats.ResourceUsage.CpuStats.TotalTicks') || 0); - }), - - percentMemory: computed('reservedMemory', 'memoryUsed', function() { - const used = this.get('memoryUsed') / 1024 / 1024; - const total = this.get('reservedMemory'); - if (!total || !used) { - return 0; - } - return used / total; - }), - - percentCPU: computed('reservedCPU', 'cpuUsed', function() { - const used = this.get('cpuUsed'); - const total = this.get('reservedCPU'); - if (!total || !used) { - return 0; - } - return used / total; - }), -}); From 1e34a6ac72d6db1c47e9d7fb9144ec90a0dd4ee6 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 20 Sep 2018 17:48:18 -0700 Subject: [PATCH 53/59] Add utilization stats to the task rows on allocation detail --- ui/app/components/allocation-row.js | 1 - ui/app/components/task-row.js | 64 +++++++++++++++ .../allocations/allocation/index.hbs | 50 ++---------- ui/app/templates/components/task-row.hbs | 79 +++++++++++++++++++ 4 files changed, 149 insertions(+), 45 deletions(-) create mode 100644 ui/app/components/task-row.js create mode 100644 ui/app/templates/components/task-row.hbs diff --git a/ui/app/components/allocation-row.js b/ui/app/components/allocation-row.js index ef4f3a45b841..5a85cb020d4d 100644 --- a/ui/app/components/allocation-row.js +++ b/ui/app/components/allocation-row.js @@ -49,7 +49,6 @@ export default Component.extend({ run.scheduleOnce('afterRender', this, qualifyAllocation); } else { this.get('fetchStats').cancelAll(); - this.set('stats', null); } }, diff --git a/ui/app/components/task-row.js b/ui/app/components/task-row.js new file mode 100644 index 000000000000..970ba43df0fa --- /dev/null +++ b/ui/app/components/task-row.js @@ -0,0 +1,64 @@ +import Ember from 'ember'; +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import { task, timeout } from 'ember-concurrency'; +import { lazyClick } from '../helpers/lazy-click'; + +export default Component.extend({ + store: service(), + token: service(), + statsTrackersRegistry: service('stats-trackers-registry'), + + tagName: 'tr', + classNames: ['task-row', 'is-interactive'], + + task: null, + + // Internal state + statsError: false, + + enablePolling: computed(() => !Ember.testing), + + // Since all tasks for an allocation share the same tracker, use the registry + stats: computed('task', function() { + return this.get('statsTrackersRegistry').getTracker(this.get('task.allocation')); + }), + + taskStats: computed('task.name', 'stats.tasks.[]', function() { + const ret = this.get('stats.tasks').findBy('task', this.get('task.name')); + return ret; + }), + + cpu: alias('taskStats.cpu.lastObject'), + memory: alias('taskStats.memory.lastObject'), + + onClick() {}, + + click(event) { + lazyClick([this.get('onClick'), event]); + }, + + fetchStats: task(function*() { + do { + try { + yield this.get('stats.poll').perform(); + this.set('statsError', false); + } catch (error) { + this.set('statsError', true); + } + yield timeout(500); + } while (this.get('enablePolling')); + }).drop(), + + didReceiveAttrs() { + const allocation = this.get('task.allocation'); + + if (allocation) { + this.get('fetchStats').perform(); + } else { + this.get('fetchStats').cancelAll(); + } + }, +}); diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs index 72deebf802a8..335d2cf5aac3 100644 --- a/ui/app/templates/allocations/allocation/index.hbs +++ b/ui/app/templates/allocations/allocation/index.hbs @@ -50,52 +50,14 @@ Last Event {{#t.sort-by prop="events.lastObject.time"}}Time{{/t.sort-by}} Addresses + CPU + Memory {{/t.head}} {{#t.body as |row|}} - - - {{#if (not row.model.driverStatus.healthy)}} - - {{x-icon "warning" class="is-warning"}} - - {{/if}} - - - {{#link-to "allocations.allocation.task" row.model.allocation row.model class="is-primary"}} - {{row.model.name}} - {{/link-to}} - - {{row.model.state}} - - {{#if row.model.events.lastObject.message}} - {{row.model.events.lastObject.message}} - {{else}} - No message - {{/if}} - - {{moment-format row.model.events.lastObject.time "MM/DD/YY HH:mm:ss"}} - -
    - {{#with row.model.resources.networks.firstObject as |network|}} - {{#each network.reservedPorts as |port|}} -
  • - {{port.Label}}: - {{network.ip}}:{{port.Value}} -
  • - {{/each}} - {{#each network.dynamicPorts as |port|}} -
  • - {{port.Label}}: - {{network.ip}}:{{port.Value}} -
  • - {{/each}} - {{/with}} -
- - + {{task-row + data-test-task-row=row.model.name + task=row.model + onClick=(action "taskClick" row.model.allocation row.model)}} {{/t.body}} {{/list-table}}
diff --git a/ui/app/templates/components/task-row.hbs b/ui/app/templates/components/task-row.hbs new file mode 100644 index 000000000000..79e77e25c602 --- /dev/null +++ b/ui/app/templates/components/task-row.hbs @@ -0,0 +1,79 @@ + + {{#if (not task.driverStatus.healthy)}} + + {{x-icon "warning" class="is-warning"}} + + {{/if}} + + + {{#link-to "allocations.allocation.task" task.allocation task class="is-primary"}} + {{task.name}} + {{/link-to}} + +{{task.state}} + + {{#if task.events.lastObject.message}} + {{task.events.lastObject.message}} + {{else}} + No message + {{/if}} + +{{moment-format task.events.lastObject.time "MM/DD/YY HH:mm:ss"}} + +
    + {{#with task.resources.networks.firstObject as |network|}} + {{#each network.reservedPorts as |port|}} +
  • + {{port.Label}}: + {{network.ip}}:{{port.Value}} +
  • + {{/each}} + {{#each network.dynamicPorts as |port|}} +
  • + {{port.Label}}: + {{network.ip}}:{{port.Value}} +
  • + {{/each}} + {{/with}} +
+ + + {{#if (and (not cpu) fetchStats.isRunning)}} + ... + {{else if (not task)}} + {{! nothing when there's no task}} + {{else if statsError}} + + {{x-icon "warning" class="is-warning"}} + + {{else}} +
+ + {{cpu.percent}} + +
+ {{/if}} + + + {{#if (and (not memory) fetchStats.isRunning)}} + ... + {{else if (not task)}} + {{! nothing when there's no task}} + {{else if statsError}} + + {{x-icon "warning" class="is-warning"}} + + {{else}} +
+ + {{memory.percent}} + +
+ {{/if}} + From 9a6d2bee104822be392d6820f15a04ee0f54450b Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 24 Sep 2018 20:01:42 -0700 Subject: [PATCH 54/59] Add a longForm option to format-duration --- ui/app/helpers/format-duration.js | 4 +-- ui/app/utils/format-duration.js | 27 +++++++++++++-------- ui/tests/unit/utils/format-duration-test.js | 7 ++++++ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/ui/app/helpers/format-duration.js b/ui/app/helpers/format-duration.js index c85a14b41e91..8ff4f73f3423 100644 --- a/ui/app/helpers/format-duration.js +++ b/ui/app/helpers/format-duration.js @@ -1,8 +1,8 @@ import Helper from '@ember/component/helper'; import formatDuration from '../utils/format-duration'; -function formatDurationHelper([duration], { units }) { - return formatDuration(duration, units); +function formatDurationHelper([duration], { units, longForm }) { + return formatDuration(duration, units, longForm); } export default Helper.helper(formatDurationHelper); diff --git a/ui/app/utils/format-duration.js b/ui/app/utils/format-duration.js index 2f8ea1b72b26..a7f2da486a9d 100644 --- a/ui/app/utils/format-duration.js +++ b/ui/app/utils/format-duration.js @@ -4,15 +4,26 @@ const allUnits = [ { name: 'years', suffix: 'year', inMoment: true, pluralizable: true }, { name: 'months', suffix: 'month', inMoment: true, pluralizable: true }, { name: 'days', suffix: 'day', inMoment: true, pluralizable: true }, - { name: 'hours', suffix: 'h', inMoment: true, pluralizable: false }, - { name: 'minutes', suffix: 'm', inMoment: true, pluralizable: false }, - { name: 'seconds', suffix: 's', inMoment: true, pluralizable: false }, + { name: 'hours', suffix: 'h', longSuffix: 'hour', inMoment: true, pluralizable: false }, + { name: 'minutes', suffix: 'm', longSuffix: 'minute', inMoment: true, pluralizable: false }, + { name: 'seconds', suffix: 's', longSuffix: 'second', inMoment: true, pluralizable: false }, { name: 'milliseconds', suffix: 'ms', inMoment: true, pluralizable: false }, { name: 'microseconds', suffix: 'µs', inMoment: false, pluralizable: false }, { name: 'nanoseconds', suffix: 'ns', inMoment: false, pluralizable: false }, ]; -export default function formatDuration(duration = 0, units = 'ns') { +const pluralizeUnits = (amount, unit, longForm) => { + let suffix; + if (longForm && unit.longSuffix) { + suffix = amount === 1 ? unit.longSuffix : unit.longSuffix.pluralize(); + } else { + suffix = amount === 1 || !unit.pluralizable ? unit.suffix : unit.suffix.pluralize(); + } + const addSpace = unit.pluralizable || (longForm && unit.longSuffix); + return `${amount}${addSpace ? ' ' : ''}${suffix}`; +}; + +export default function formatDuration(duration = 0, units = 'ns', longForm = false) { const durationParts = {}; // Moment only handles up to millisecond precision. @@ -46,9 +57,7 @@ export default function formatDuration(duration = 0, units = 'ns') { const displayParts = allUnits.reduce((parts, unitType) => { if (durationParts[unitType.name]) { const count = durationParts[unitType.name]; - const suffix = - count === 1 || !unitType.pluralizable ? unitType.suffix : unitType.suffix.pluralize(); - parts.push(`${count}${unitType.pluralizable ? ' ' : ''}${suffix}`); + parts.push(pluralizeUnits(count, unitType, longForm)); } return parts; }, []); @@ -58,7 +67,5 @@ export default function formatDuration(duration = 0, units = 'ns') { } // When the duration is 0, show 0 in terms of `units` - const unitTypeForUnits = allUnits.findBy('suffix', units); - const suffix = unitTypeForUnits.pluralizable ? units.pluralize() : units; - return `0${unitTypeForUnits.pluralizable ? ' ' : ''}${suffix}`; + return pluralizeUnits(0, allUnits.findBy('suffix', units), longForm); } diff --git a/ui/tests/unit/utils/format-duration-test.js b/ui/tests/unit/utils/format-duration-test.js index c4867590f32d..69cb02b32252 100644 --- a/ui/tests/unit/utils/format-duration-test.js +++ b/ui/tests/unit/utils/format-duration-test.js @@ -26,3 +26,10 @@ test('When duration is 0, 0 is shown in terms of the units provided to the funct assert.equal(formatDuration(0), '0ns', 'formatDuration(0) -> 0ns'); assert.equal(formatDuration(0, 'year'), '0 years', 'formatDuration(0, "year") -> 0 years'); }); + +test('The longForm option expands suffixes to words', function(assert) { + const expectation1 = '3 seconds 20ms'; + const expectation2 = '5 hours 59 minutes'; + assert.equal(formatDuration(3020, 'ms', true), expectation1, expectation1); + assert.equal(formatDuration(60 * 5 + 59, 'm', true), expectation2, expectation2); +}); From d83d2314de069f007bf7abd9b822a9b37cbfde2b Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 24 Sep 2018 21:19:18 -0700 Subject: [PATCH 55/59] Add a11y features to the line-chart component - Treat it as an image - Add a title and a description - Hide the axes, just in case --- ui/app/components/line-chart.js | 19 +++++++++++++++++++ ui/app/templates/components/line-chart.hbs | 15 ++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js index e8846026d2e0..52c5de96427d 100644 --- a/ui/app/components/line-chart.js +++ b/ui/app/components/line-chart.js @@ -37,6 +37,9 @@ export default Component.extend(WindowResizable, { timeseries: false, chartClass: 'is-primary', + title: 'Line Chart', + description: null, + // Private Properties width: 0, @@ -96,6 +99,22 @@ export default Component.extend(WindowResizable, { return scale; }), + xRange: computed('data.[]', 'xFormat', 'xProp', 'timeseries', function() { + const { xProp, timeseries, data } = this.getProperties('xProp', 'timeseries', 'data'); + const range = d3Array.extent(data, d => d[xProp]); + const formatter = this.xFormat(timeseries); + + return range.map(formatter); + }), + + yRange: computed('data.[]', 'yFormat', 'yProp', function() { + const yProp = this.get('yProp'); + const range = d3Array.extent(this.get('data'), d => d[yProp]); + const formatter = this.yFormat(); + + return range.map(formatter); + }), + yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() { const yProp = this.get('yProp'); let max = d3Array.max(this.get('data'), d => d[yProp]) || 1; diff --git a/ui/app/templates/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs index eb993918d54f..77861245b729 100644 --- a/ui/app/templates/components/line-chart.hbs +++ b/ui/app/templates/components/line-chart.hbs @@ -1,4 +1,13 @@ - + + {{title}} + + {{#if description}} + {{description}} + {{else}} + X-axis values range from {{xRange.firstObject}} to {{xRange.lastObject}}, + and Y-axis values range from {{yRange.firstObject}} to {{yRange.lastObject}}. + {{/if}} + @@ -14,8 +23,8 @@ - - + +

From 6d4d5200954806b59fa859e821c59ba42df57bda Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 24 Sep 2018 21:20:02 -0700 Subject: [PATCH 56/59] Override the a11y title and description for the stats time series chart Since this is a use case specific chart, we can use use case specific language in our labels. --- ui/app/components/stats-time-series.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ui/app/components/stats-time-series.js b/ui/app/components/stats-time-series.js index 730762171826..dc3a0122bdeb 100644 --- a/ui/app/components/stats-time-series.js +++ b/ui/app/components/stats-time-series.js @@ -5,6 +5,7 @@ import d3Format from 'd3-format'; import d3Scale from 'd3-scale'; import d3Array from 'd3-array'; import LineChart from 'nomad-ui/components/line-chart'; +import formatDuration from 'nomad-ui/utils/format-duration'; export default LineChart.extend({ xProp: 'timestamp', @@ -19,6 +20,20 @@ export default LineChart.extend({ return d3Format.format('.1~%'); }, + // Specific a11y descriptors + title: 'Stats Time Series Chart', + + description: computed('data.[]', 'xProp', 'yProp', function() { + const { xProp, yProp, data } = this.getProperties('data', 'xProp', 'yProp'); + const yRange = d3Array.extent(data, d => d[yProp]); + const xRange = d3Array.extent(data, d => d[xProp]); + const yFormatter = this.yFormat(); + + const duration = formatDuration(xRange[1] - xRange[0], 'ms', true); + + return `Time-series data for the last ${duration}, with values ranging from ${yFormatter(yRange[0])} to ${yFormatter(yRange[1])}`; + }), + xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() { const xProp = this.get('xProp'); const scale = this.get('timeseries') ? d3Scale.scaleTime() : d3Scale.scaleLinear(); From 2c20678d8b70996a73f2408d0caaa6076cb5966f Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 17 Oct 2018 07:17:24 -0700 Subject: [PATCH 57/59] Add role="tooltip" to tooltips throughout the app --- ui/app/templates/components/allocation-row.hbs | 12 ++++++------ ui/app/templates/components/client-node-row.hbs | 2 +- .../components/freestyle/sg-progress-bar.hbs | 12 ++++++------ ui/app/templates/components/task-row.hbs | 8 ++++---- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/ui/app/templates/components/allocation-row.hbs b/ui/app/templates/components/allocation-row.hbs index 72baf4a40a5a..229efef853f4 100644 --- a/ui/app/templates/components/allocation-row.hbs +++ b/ui/app/templates/components/allocation-row.hbs @@ -1,11 +1,11 @@ {{#if allocation.unhealthyDrivers.length}} - + {{x-icon "warning" class="is-warning"}} {{/if}} {{#if allocation.nextAllocation}} - + {{x-icon "history" class="is-faded"}} {{/if}} @@ -47,11 +47,11 @@ {{else if (not allocation)}} {{! nothing when there's no allocation}} {{else if statsError}} - + {{x-icon "warning" class="is-warning"}} {{else}} -

+