-
+
+{{/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/components/line-chart.hbs b/ui/app/templates/components/line-chart.hbs
new file mode 100644
index 00000000000..77861245b72
--- /dev/null
+++ b/ui/app/templates/components/line-chart.hbs
@@ -0,0 +1,37 @@
+
+ {{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}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/templates/components/stats-time-series.hbs b/ui/app/templates/components/stats-time-series.hbs
new file mode 100644
index 00000000000..494fc7ebf00
--- /dev/null
+++ b/ui/app/templates/components/stats-time-series.hbs
@@ -0,0 +1 @@
+{{partial "components/line-chart"}}
diff --git a/ui/app/templates/components/task-row.hbs b/ui/app/templates/components/task-row.hbs
new file mode 100644
index 00000000000..54464ed91e8
--- /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}}
+
+ {{/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}}
+
+ {{/if}}
+
diff --git a/ui/app/templates/freestyle.hbs b/ui/app/templates/freestyle.hbs
index e33756a9000..87bdb7c7685 100644
--- a/ui/app/templates/freestyle.hbs
+++ b/ui/app/templates/freestyle.hbs
@@ -108,9 +108,17 @@
{{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}}
+
+ {{#section.subsection name="Stats Time Series"}}
+ {{freestyle/sg-stats-time-series}}
+ {{/section.subsection}}
{{/freestyle-section}}
{{/freestyle-guide}}
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 00000000000..08a8f03f556
--- /dev/null
+++ b/ui/app/utils/classes/abstract-stats-tracker.js
@@ -0,0 +1,54 @@
+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');
+ },
+
+ append(/* frame */) {
+ assert(
+ 'StatsTrackers need an append method, which takes the JSON response from a request to url as an argument'
+ );
+ },
+
+ 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
new file mode 100644
index 00000000000..432fb4b24ec
--- /dev/null
+++ b/ui/app/utils/classes/allocation-stats-tracker.js
@@ -0,0 +1,116 @@
+import EmberObject, { computed, get } from '@ember/object';
+import { alias } from '@ember/object/computed';
+import RollingArray from 'nomad-ui/utils/classes/rolling-array';
+import AbstractStatsTracker from 'nomad-ui/utils/classes/abstract-stats-tracker';
+
+const percent = (numerator, denominator) => {
+ if (!numerator || !denominator) {
+ return 0;
+ }
+ return numerator / denominator;
+};
+
+const empty = ts => ({ timestamp: ts, used: null, percent: null });
+
+const AllocationStatsTracker = EmberObject.extend(AbstractStatsTracker, {
+ // Set via the stats computed property macro
+ allocation: null,
+
+ 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').pushObject({
+ timestamp,
+ used: cpuUsed,
+ percent: percent(cpuUsed, this.get('reservedCPU')),
+ });
+
+ const memoryUsed = frame.ResourceUsage.MemoryStats.RSS;
+ this.get('memory').pushObject({
+ 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 frameTimestamp = new Date(Math.floor(taskFrame.Timestamp / 1000000));
+
+ const taskCpuUsed = Math.floor(taskFrame.ResourceUsage.CpuStats.TotalTicks) || 0;
+ stats.cpu.pushObject({
+ timestamp: frameTimestamp,
+ used: taskCpuUsed,
+ percent: percent(taskCpuUsed, stats.reservedCPU),
+ });
+
+ const taskMemoryUsed = taskFrame.ResourceUsage.MemoryStats.RSS;
+ 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'),
+
+ // 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');
+ const tasks = this.get('allocation.taskGroup.tasks') || [];
+ return tasks.map(task => ({
+ task: get(task, 'name'),
+
+ // Static figures, denominators for stats
+ reservedCPU: get(task, 'reservedCPU'),
+ reservedMemory: get(task, 'reservedMemory'),
+
+ // Dynamic figures, collected over time
+ // []{ timestamp: Date, used: Number, percent: Number }
+ cpu: RollingArray(bufferSize),
+ memory: RollingArray(bufferSize),
+ }));
+ }),
+});
+
+export default AllocationStatsTracker;
+
+export function stats(allocationProp, fetch) {
+ return computed(allocationProp, function() {
+ return AllocationStatsTracker.create({
+ fetch: fetch.call(this),
+ allocation: this.get(allocationProp),
+ });
+ });
+}
diff --git a/ui/app/utils/classes/allocation-stats.js b/ui/app/utils/classes/allocation-stats.js
deleted file mode 100644
index 463affbb4d2..00000000000
--- 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;
- }),
-});
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 00000000000..e61fbc27fcc
--- /dev/null
+++ b/ui/app/utils/classes/node-stats-tracker.js
@@ -0,0 +1,70 @@
+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 empty = ts => ({ timestamp: ts, used: null, percent: null });
+
+const NodeStatsTracker = EmberObject.extend(AbstractStatsTracker, {
+ // Set via the stats computed property macro
+ node: null,
+
+ 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').pushObject({
+ timestamp,
+ used: cpuUsed,
+ percent: percent(cpuUsed, this.get('reservedCPU')),
+ });
+
+ const memoryUsed = frame.Memory.Used;
+ 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'),
+
+ // Dynamic figures, collected over time
+ // []{ timestamp: Date, used: Number, percent: Number }
+ cpu: computed('node', function() {
+ return RollingArray(this.get('bufferSize'));
+ }),
+ memory: computed('node', function() {
+ return RollingArray(this.get('bufferSize'));
+ }),
+});
+
+export default NodeStatsTracker;
+
+export function stats(nodeProp, fetch) {
+ return computed(nodeProp, function() {
+ return NodeStatsTracker.create({
+ fetch: fetch.call(this),
+ node: this.get(nodeProp),
+ });
+ });
+}
diff --git a/ui/app/utils/classes/rolling-array.js b/ui/app/utils/classes/rolling-array.js
new file mode 100644
index 00000000000..02790b88e12
--- /dev/null
+++ b/ui/app/utils/classes/rolling-array.js
@@ -0,0 +1,51 @@
+// An array with a max length.
+//
+// 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;
+
+ // 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);
+ }
+ };
+
+ array.push = function(...items) {
+ push.apply(this, items);
+ this._limit();
+ return this.length;
+ };
+
+ array.splice = function(...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 = insertAt.apply(this, args);
+ this._limit();
+ this.arrayContentDidChange();
+ return returnValue;
+ };
+
+ array.unshift = function() {
+ throw new Error('Cannot unshift onto a RollingArray');
+ };
+
+ return array;
+}
diff --git a/ui/app/utils/format-duration.js b/ui/app/utils/format-duration.js
index 2f8ea1b72b2..11856bf4421 100644
--- a/ui/app/utils/format-duration.js
+++ b/ui/app/utils/format-duration.js
@@ -1,18 +1,52 @@
import moment from 'moment';
+/**
+ * Metadata for all unit types
+ * name: identifier for the unit. Also maps to moment methods when applicable
+ * suffix: the preferred suffix for a unit
+ * inMoment: whether or not moment can be used to compute this unit value
+ * pluralizable: whether or not this suffix can be pluralized
+ * longSuffix: the suffix to use instead of suffix when longForm is true
+ */
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) {
+ // Long form means always using full words (seconds insteand of s) which means
+ // pluralization is necessary.
+ suffix = amount === 1 ? unit.longSuffix : unit.longSuffix.pluralize();
+ } else {
+ // In the normal case, only pluralize based on the pluralizable flag
+ suffix = amount === 1 || !unit.pluralizable ? unit.suffix : unit.suffix.pluralize();
+ }
+
+ // A space should go between the value and the unit when the unit is a full word
+ // 300ns vs. 1 hour
+ const addSpace = unit.pluralizable || (longForm && unit.longSuffix);
+ return `${amount}${addSpace ? ' ' : ''}${suffix}`;
+};
+
+/**
+ * Format a Duration at a preferred precision
+ *
+ * @param {Number} duration The duration to format
+ * @param {String} units The units for the duration. Default to nanoseconds.
+ * @param {Boolean} longForm Whether or not to expand single character suffixes,
+ * used to ensure screen readers correctly read units.
+ */
+export default function formatDuration(duration = 0, units = 'ns', longForm = false) {
const durationParts = {};
// Moment only handles up to millisecond precision.
@@ -46,9 +80,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 +90,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/mirage/config.js b/ui/mirage/config.js
index 3cc92a434dc..d0b834a4294 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/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/mirage/factories/node.js b/ui/mirage/factories/node.js
index 13c42736cf7..e8ec62a9d91 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/package.json b/ui/package.json
index b7579331c1b..e47c7febf93 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-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",
"ember-ajax": "^3.0.0",
"ember-auto-import": "^1.0.1",
@@ -88,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/allocation-row-test.js b/ui/tests/integration/allocation-row-test.js
index 3049737f28a..a656732eb80 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();
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/components/line-chart-test.js b/ui/tests/unit/components/line-chart-test.js
new file mode 100644
index 00000000000..fda7021f162
--- /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'
+ );
+});
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 00000000000..401a8a5b153
--- /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'
+ );
+});
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
new file mode 100644
index 00000000000..cbfa181fa4f
--- /dev/null
+++ b/ui/tests/unit/utils/allocation-stats-tracker-test.js
@@ -0,0 +1,455 @@
+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() * 1000000;
+const makeDate = ts => new Date(ts / 1000000);
+
+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.fetch();
+ },
+ /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.get('poll').perform();
+
+ 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: makeDate(refDate + 1000), used: 101, percent: 101 / 200 }],
+ 'One frame of cpu'
+ );
+ assert.deepEqual(
+ tracker.get('memory'),
+ [
+ {
+ timestamp: makeDate(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: 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: 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: 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'
+ );
+
+ tracker.append(mockFrame(2));
+
+ assert.deepEqual(
+ tracker.get('cpu'),
+ [
+ { 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: 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'
+ );
+
+ assert.deepEqual(
+ tracker.get('tasks'),
+ [
+ {
+ task: 'service',
+ reservedCPU: 100,
+ reservedMemory: 256,
+ cpu: [
+ { timestamp: makeDate(refDate + 1), used: 51, percent: 51 / 100 },
+ { timestamp: makeDate(refDate + 2), used: 52, percent: 52 / 100 },
+ ],
+ memory: [
+ { timestamp: makeDate(refDate + 1), used: 101 * 1024 * 1024, percent: 101 / 256 },
+ { timestamp: makeDate(refDate + 2), used: 102 * 1024 * 1024, percent: 102 / 256 },
+ ],
+ },
+ {
+ task: 'log-shipper',
+ reservedCPU: 50,
+ reservedMemory: 128,
+ cpu: [
+ { timestamp: makeDate(refDate + 10), used: 26, percent: 26 / 50 },
+ { timestamp: makeDate(refDate + 20), used: 27, percent: 27 / 50 },
+ ],
+ memory: [
+ { timestamp: makeDate(refDate + 10), used: 51 * 1024 * 1024, percent: 51 / 128 },
+ { timestamp: makeDate(refDate + 20), used: 52 * 1024 * 1024, percent: 52 / 128 },
+ ],
+ },
+ {
+ task: 'sidecar',
+ reservedCPU: 50,
+ reservedMemory: 128,
+ cpu: [
+ { timestamp: makeDate(refDate + 100), used: 27, percent: 27 / 50 },
+ { timestamp: makeDate(refDate + 200), used: 28, percent: 28 / 50 },
+ ],
+ memory: [
+ { timestamp: makeDate(refDate + 100), used: 52 * 1024 * 1024, percent: 52 / 128 },
+ { timestamp: makeDate(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,
+ +makeDate(refDate + 11000),
+ 'Old frames are removed in favor of newer ones'
+ );
+ assert.equal(
+ +tracker.get('memory')[0].timestamp,
+ +makeDate(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,
+ +makeDate(refDate + 11),
+ 'Old frames are removed in favor of newer ones'
+ );
+ assert.equal(
+ +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,
+ +makeDate(refDate + 110),
+ 'Old frames are removed in favor of newer ones'
+ );
+ assert.equal(
+ +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,
+ +makeDate(refDate + 1100),
+ 'Old frames are removed in favor of newer ones'
+ );
+ assert.equal(
+ +tracker.get('tasks').findBy('task', 'sidecar').memory[0].timestamp,
+ +makeDate(refDate + 1100),
+ 'Old frames are removed in favor of newer ones'
+ );
+});
+
+test('the stats computed property macro constructs an AllocationStatsTracker based on an allocationProp and a fetch definition', function(assert) {
+ const allocation = MockAllocation();
+ const fetchSpy = sinon.spy();
+
+ const SomeClass = EmberObject.extend({
+ stats: stats('alloc', function() {
+ return () => fetchSpy(this);
+ }),
+ });
+ const someObject = SomeClass.create({
+ alloc: allocation,
+ });
+
+ assert.equal(
+ someObject.get('stats.url'),
+ `/v1/client/allocation/${allocation.id}/stats`,
+ 'stats computed property macro creates an AllocationStatsTracker'
+ );
+
+ someObject.get('stats').fetch();
+
+ assert.ok(
+ fetchSpy.calledWith(someObject),
+ 'the fetch factory passed into the macro gets called to assign a bound version of fetch to the AllocationStatsTracker instance'
+ );
+});
+
+test('changing the value of the allocationProp constructs a new AllocationStatsTracker', function(assert) {
+ const alloc1 = MockAllocation();
+ const alloc2 = MockAllocation();
+ const SomeClass = EmberObject.extend({
+ stats: stats('alloc', () => fetch),
+ });
+
+ const someObject = SomeClass.create({
+ alloc: alloc1,
+ });
+
+ const stats1 = someObject.get('stats');
+
+ someObject.set('alloc', alloc2);
+ const stats2 = someObject.get('stats');
+
+ assert.notOk(
+ stats1 === stats2,
+ 'Changing the value of alloc results in creating a new AllocationStatsTracker instance'
+ );
+});
diff --git a/ui/tests/unit/utils/format-duration-test.js b/ui/tests/unit/utils/format-duration-test.js
index c4867590f32..69cb02b3225 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);
+});
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 00000000000..5dfbb19ff41
--- /dev/null
+++ b/ui/tests/unit/utils/node-stats-tracker-test.js
@@ -0,0 +1,223 @@
+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() * 1000000;
+const makeDate = ts => new Date(ts / 1000000);
+
+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.fetch();
+ },
+ /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.get('poll').perform();
+
+ 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: makeDate(refDate + 1), used: 1001, percent: 1001 / 2000 }],
+ 'One frame of cpu'
+ );
+
+ assert.deepEqual(
+ tracker.get('memory'),
+ [{ timestamp: makeDate(refDate + 1), used: 2049 * 1024 * 1024, percent: 2049 / 4096 }],
+ 'One frame of memory'
+ );
+
+ tracker.append(mockFrame(2));
+
+ assert.deepEqual(
+ tracker.get('cpu'),
+ [
+ { timestamp: makeDate(refDate + 1), used: 1001, percent: 1001 / 2000 },
+ { timestamp: makeDate(refDate + 2), used: 1002, percent: 1002 / 2000 },
+ ],
+ 'Two frames of cpu'
+ );
+
+ assert.deepEqual(
+ tracker.get('memory'),
+ [
+ { 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'
+ );
+});
+
+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,
+ +makeDate(refDate + 11),
+ 'Old frames are removed in favor of newer ones'
+ );
+ assert.equal(
+ +tracker.get('memory')[0].timestamp,
+ +makeDate(refDate + 11),
+ 'Old frames are removed in favor of newer ones'
+ );
+});
+
+test('the stats computed property macro constructs a NodeStatsTracker based on a nodeProp and a fetch definition', function(assert) {
+ const node = MockNode();
+ const fetchSpy = sinon.spy();
+
+ const SomeClass = EmberObject.extend({
+ stats: stats('theNode', function() {
+ return () => fetchSpy(this);
+ }),
+ });
+ const someObject = SomeClass.create({
+ theNode: node,
+ });
+
+ assert.equal(
+ someObject.get('stats.url'),
+ `/v1/client/stats?node_id=${node.id}`,
+ 'stats computed property macro creates a NodeStatsTracker'
+ );
+
+ someObject.get('stats').fetch();
+
+ assert.ok(
+ fetchSpy.calledWith(someObject),
+ 'the fetch factory passed into the macro gets called to assign a bound version of fetch to the NodeStatsTracker instance'
+ );
+});
+
+test('changing the value of the nodeProp constructs a new NodeStatsTracker', function(assert) {
+ const node1 = MockNode();
+ const node2 = MockNode();
+ const SomeClass = EmberObject.extend({
+ stats: stats('theNode', () => fetch),
+ });
+
+ const someObject = SomeClass.create({
+ theNode: node1,
+ });
+
+ const stats1 = someObject.get('stats');
+
+ someObject.set('theNode', node2);
+ const stats2 = someObject.get('stats');
+
+ assert.notOk(
+ stats1 === stats2,
+ 'Changing the value of the node results in creating a new NodeStatsTracker instance'
+ );
+});
diff --git a/ui/tests/unit/utils/rolling-array-test.js b/ui/tests/unit/utils/rolling-array-test.js
new file mode 100644
index 00000000000..dc9870a05aa
--- /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');
+});
diff --git a/ui/yarn.lock b/ui/yarn.lock
index 5414f4bd4a3..d29c333a7fe 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"
@@ -6197,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"