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 @@
-