diff --git a/ui/app/components/freestyle/sg-line-chart.js b/ui/app/components/freestyle/sg-line-chart.js index 145bcbabeb0..1da331dc4f3 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 3ef5b1908af..e8846026d2e 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); } @@ -182,6 +183,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 +200,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])); @@ -244,6 +247,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 +258,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', { diff --git a/ui/app/components/primary-metric.js b/ui/app/components/primary-metric.js new file mode 100644 index 00000000000..be5f3a61cdc --- /dev/null +++ b/ui/app/components/primary-metric.js @@ -0,0 +1,99 @@ +import Ember from 'ember'; +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { computed } from '@ember/object'; +import { task, timeout } from 'ember-concurrency'; + +export default Component.extend({ + token: service(), + statsTrackersRegistry: service('stats-trackers-registry'), + + classNames: ['primary-metric'], + + // One of Node, Allocation, or TaskState + resource: null, + + // 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'); + return this.get('statsTrackersRegistry').getTracker(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 { + this.get('tracker.poll').perform(); + yield timeout(100); + } while (!Ember.testing); + }), + + didReceiveAttrs() { + if (this.get('tracker')) { + this.get('poller').perform(); + } + }, + + willDestroy() { + this.get('poller').cancelAll(); + this.get('tracker.signalPause').perform(); + }, +}); diff --git a/ui/app/components/stats-time-series.js b/ui/app/components/stats-time-series.js index 90a229e6390..73076217182 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() { @@ -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; }), diff --git a/ui/app/controllers/allocations/allocation/index.js b/ui/app/controllers/allocations/allocation/index.js index 04408660eaa..4d6697c1404 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 33873b361e8..26dfc9b64da 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: { @@ -37,17 +34,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/allocations/allocation/index.js b/ui/app/routes/allocations/allocation/index.js deleted file mode 100644 index 6d23253776b..00000000000 --- 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(); - }, -}); diff --git a/ui/app/routes/clients/client.js b/ui/app/routes/clients/client.js index 6cf9332ebee..9e249303003 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/services/stats-trackers-registry.js b/ui/app/services/stats-trackers-registry.js new file mode 100644 index 00000000000..0233c74d367 --- /dev/null +++ b/ui/app/services/stats-trackers-registry.js @@ -0,0 +1,49 @@ +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'; +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); + }, + + // 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; + + 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; + }, +}); diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 3b99ae2aea0..d64a988efd1 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 b8f84f0a9ad..dacc562f5f1 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 00000000000..e48b52d16e3 --- /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/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs index 590e3bfafbb..72deebf802a 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 diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index 6746fb05bad..dad7a4a71fc 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}}
diff --git a/ui/app/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs index 3b84f643e04..2c286e4ba50 100644 --- a/ui/app/templates/clients/client.hbs +++ b/ui/app/templates/clients/client.hbs @@ -77,6 +77,22 @@
{{/if}} +
+
+ Resource Utilization +
+
+
+
+ {{primary-metric resource=model metric="cpu"}} +
+
+ {{primary-metric resource=model metric="memory"}} +
+
+
+
+
Allocations {{model.allocations.length}}
diff --git a/ui/app/templates/components/freestyle/sg-line-chart.hbs b/ui/app/templates/components/freestyle/sg-line-chart.hbs index 67dd939b01d..014b061bd2a 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}} diff --git a/ui/app/templates/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs index 33e8dc19f14..eb993918d54 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 new file mode 100644 index 00000000000..10b9c0c114a --- /dev/null +++ b/ui/app/templates/components/primary-metric.hbs @@ -0,0 +1,28 @@ +

{{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}} / {{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 488dec465f0..08a8f03f556 100644 --- a/ui/app/utils/classes/abstract-stats-tracker.js +++ b/ui/app/utils/classes/abstract-stats-tracker.js @@ -1,9 +1,12 @@ import Mixin from '@ember/object/mixin'; import { assert } from '@ember/debug'; +import { task, timeout } from 'ember-concurrency'; export default Mixin.create({ url: '', + bufferSize: 500, + fetch() { assert('StatsTrackers need a fetch method, which should have an interface like window.fetch'); }, @@ -14,14 +17,38 @@ export default Mixin.create({ ); }, - 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)); + 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(); + + 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(), + + 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 46ca5365ba2..432fb4b24ec 100644 --- a/ui/app/utils/classes/allocation-stats-tracker.js +++ b/ui/app/utils/classes/allocation-stats-tracker.js @@ -10,27 +10,29 @@ 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, - bufferSize: 100, - url: computed('allocation', function() { return `/v1/client/allocation/${this.get('allocation.id')}/stats`; }), 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')), }); @@ -43,22 +45,34 @@ 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), }); } }, + 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'), @@ -74,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/app/utils/classes/node-stats-tracker.js b/ui/app/utils/classes/node-stats-tracker.js index 530a6619d75..e61fbc27fcc 100644 --- a/ui/app/utils/classes/node-stats-tracker.js +++ b/ui/app/utils/classes/node-stats-tracker.js @@ -10,32 +10,40 @@ 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, - bufferSize: 100, - url: computed('node', function() { return `/v1/client/stats?node_id=${this.get('node.id')}`; }), 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').pushObject({ + timestamp, used: cpuUsed, percent: percent(cpuUsed, this.get('reservedCPU')), }); const memoryUsed = frame.Memory.Used; - this.get('memory').push({ - timestamp: frame.Timestamp, + this.get('memory').pushObject({ + timestamp, used: memoryUsed, percent: percent(memoryUsed / 1024 / 1024, this.get('reservedMemory')), }); }, + 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'), diff --git a/ui/app/utils/classes/rolling-array.js b/ui/app/utils/classes/rolling-array.js index d8d945f13d8..02790b88e12 100644 --- a/ui/app/utils/classes/rolling-array.js +++ b/ui/app/utils/classes/rolling-array.js @@ -3,37 +3,43 @@ // 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; - - array.push = function(...items) { - const returnValue = this._push(...items); - + // 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) { + push.apply(this, items); + this._limit(); + return this.length; }; array.splice = function(...args) { - const returnValue = this._splice(...args); - - const surplus = this.length - this.maxLength; - if (surplus > 0) { - this._splice(0, surplus); - } + 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 = insertAt.apply(this, args); + this._limit(); + this.arrayContentDidChange(); return returnValue; }; diff --git a/ui/mirage/factories/client-allocation-stats.js b/ui/mirage/factories/client-allocation-stats.js index e4573ed08d4..369fa416313 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; diff --git a/ui/package.json b/ui/package.json index 483a73fef3f..e47c7febf93 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/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js index ddd17e91b36..14447a14631 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 1c766d429c3..b13ce5ade54 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 cc30097f708..7d7df686df4 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/integration/primary-metric-test.js b/ui/tests/integration/primary-metric-test.js new file mode 100644 index 00000000000..d11c612b932 --- /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'); + }); +}); diff --git a/ui/tests/pages/allocations/detail.js b/ui/tests/pages/allocations/detail.js index 26ec8fe734e..c824f961036 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 80ef65ee0bf..249986cfb92 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 309a3f176d0..c4b98d9ded3 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]'), 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 00000000000..9916fafcfb7 --- /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(); +}); diff --git a/ui/tests/unit/utils/allocation-stats-tracker-test.js b/ui/tests/unit/utils/allocation-stats-tracker-test.js index 51a041ffe91..cbfa181fa4f 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 55c6c070fbd..5dfbb19ff41 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' ); }); diff --git a/ui/yarn.lock b/ui/yarn.lock index 943460d2014..d29c333a7fe 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"