diff --git a/ui/app/components/allocation-row.js b/ui/app/components/allocation-row.js index d2a7433020b..5a85cb020d4 100644 --- a/ui/app/components/allocation-row.js +++ b/ui/app/components/allocation-row.js @@ -2,12 +2,15 @@ import Ember from 'ember'; import { inject as service } from '@ember/service'; import Component from '@ember/component'; import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; import { run } from '@ember/runloop'; -import { lazyClick } from '../helpers/lazy-click'; import { task, timeout } from 'ember-concurrency'; +import { lazyClick } from '../helpers/lazy-click'; +import AllocationStatsTracker from 'nomad-ui/utils/classes/allocation-stats-tracker'; export default Component.extend({ store: service(), + token: service(), tagName: 'tr', @@ -18,14 +21,21 @@ export default Component.extend({ // Used to determine whether the row should mention the node or the job context: null, - backoffSequence: computed(() => [500, 800, 1300, 2100, 3400, 5500]), - // Internal state - stats: null, statsError: false, enablePolling: computed(() => !Ember.testing), + stats: computed('allocation', function() { + return AllocationStatsTracker.create({ + fetch: url => this.get('token').authorizedRequest(url), + allocation: this.get('allocation'), + }); + }), + + cpu: alias('stats.cpu.lastObject'), + memory: alias('stats.memory.lastObject'), + onClick() {}, click(event) { @@ -39,23 +49,18 @@ export default Component.extend({ run.scheduleOnce('afterRender', this, qualifyAllocation); } else { this.get('fetchStats').cancelAll(); - this.set('stats', null); } }, - fetchStats: task(function*(allocation) { - const backoffSequence = this.get('backoffSequence').slice(); - const maxTiming = backoffSequence.pop(); - + fetchStats: task(function*() { do { try { - const stats = yield allocation.fetchStats(); - this.set('stats', stats); + yield this.get('stats.poll').perform(); this.set('statsError', false); } catch (error) { this.set('statsError', true); } - yield timeout(backoffSequence.shift() || maxTiming); + yield timeout(500); } while (this.get('enablePolling')); }).drop(), }); @@ -63,7 +68,7 @@ export default Component.extend({ function qualifyAllocation() { const allocation = this.get('allocation'); return allocation.reload().then(() => { - this.get('fetchStats').perform(allocation); + this.get('fetchStats').perform(); // Make sure that the job record in the store for this allocation // is complete and not a partial from the list endpoint diff --git a/ui/app/components/freestyle/sg-line-chart.js b/ui/app/components/freestyle/sg-line-chart.js new file mode 100644 index 00000000000..1da331dc4f3 --- /dev/null +++ b/ui/app/components/freestyle/sg-line-chart.js @@ -0,0 +1,76 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import d3TimeFormat from 'd3-time-format'; + +export default Component.extend({ + timerTicks: 0, + + startTimer: function() { + this.set( + 'timer', + setInterval(() => { + this.incrementProperty('timerTicks'); + + const ref = this.get('lineChartLive'); + ref.addObject({ ts: Date.now(), val: Math.random() * 30 + 20 }); + if (ref.length > 60) { + ref.splice(0, ref.length - 60); + } + }, 500) + ); + }.on('init'), + + willDestroy() { + clearInterval(this.get('timer')); + }, + + lineChartData: computed(() => { + return [ + { year: 2010, value: 10 }, + { year: 2011, value: 10 }, + { year: 2012, value: 20 }, + { year: 2013, value: 30 }, + { year: 2014, value: 50 }, + { year: 2015, value: 80 }, + { year: 2016, value: 130 }, + { year: 2017, value: 210 }, + { year: 2018, value: 340 }, + ]; + }), + + lineChartMild: computed(() => { + return [ + { year: 2010, value: 100 }, + { year: 2011, value: 90 }, + { year: 2012, value: 120 }, + { year: 2013, value: 130 }, + { year: 2014, value: 115 }, + { year: 2015, value: 105 }, + { year: 2016, value: 90 }, + { year: 2017, value: 85 }, + { year: 2018, value: 90 }, + ]; + }), + + 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 []; + }), + + secondsFormat() { + return d3TimeFormat.timeFormat('%H:%M:%S'); + }, +}); diff --git a/ui/app/components/freestyle/sg-stats-time-series.js b/ui/app/components/freestyle/sg-stats-time-series.js new file mode 100644 index 00000000000..ac2a445979f --- /dev/null +++ b/ui/app/components/freestyle/sg-stats-time-series.js @@ -0,0 +1,76 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import d3TimeFormat from 'd3-time-format'; +import moment from 'moment'; + +export default Component.extend({ + timerTicks: 0, + + startTimer: function() { + this.set( + 'timer', + setInterval(() => { + const metricsHigh = this.get('metricsHigh'); + const prev = metricsHigh.length ? metricsHigh[metricsHigh.length - 1].value : 0.9; + this.appendTSValue( + metricsHigh, + Math.min(Math.max(prev + Math.random() * 0.05 - 0.025, 0.5), 1) + ); + + const metricsLow = this.get('metricsLow'); + const prev2 = metricsLow.length ? metricsLow[metricsLow.length - 1].value : 0.1; + this.appendTSValue( + metricsLow, + Math.min(Math.max(prev2 + Math.random() * 0.05 - 0.025, 0), 0.5) + ); + }, 1000) + ); + }.on('init'), + + appendTSValue(array, value, maxLength = 300) { + array.addObject({ + timestamp: Date.now(), + value, + }); + + if (array.length > maxLength) { + array.splice(0, array.length - maxLength); + } + }, + + willDestroy() { + clearInterval(this.get('timer')); + }, + + metricsHigh: computed(() => { + return []; + }), + + metricsLow: computed(() => { + return []; + }), + + staticMetrics: computed(() => { + const ts = offset => + moment() + .subtract(offset, 'm') + .toDate(); + return [ + { timestamp: ts(20), value: 0.5 }, + { timestamp: ts(18), value: 0.5 }, + { timestamp: ts(16), value: 0.4 }, + { timestamp: ts(14), value: 0.3 }, + { timestamp: ts(12), value: 0.9 }, + { timestamp: ts(10), value: 0.3 }, + { timestamp: ts(8), value: 0.3 }, + { timestamp: ts(6), value: 0.4 }, + { timestamp: ts(4), value: 0.5 }, + { timestamp: ts(2), value: 0.6 }, + { timestamp: ts(0), value: 0.6 }, + ]; + }), + + secondsFormat() { + return d3TimeFormat.timeFormat('%H:%M:%S'); + }, +}); diff --git a/ui/app/components/line-chart.js b/ui/app/components/line-chart.js new file mode 100644 index 00000000000..52c5de96427 --- /dev/null +++ b/ui/app/components/line-chart.js @@ -0,0 +1,345 @@ +import Component from '@ember/component'; +import { computed, observer } from '@ember/object'; +import { guidFor } from '@ember/object/internals'; +import { run } from '@ember/runloop'; +import d3 from 'd3-selection'; +import d3Scale from 'd3-scale'; +import d3Axis from 'd3-axis'; +import d3Array from 'd3-array'; +import d3Shape from 'd3-shape'; +import d3Format from 'd3-format'; +import d3TimeFormat from 'd3-time-format'; +import WindowResizable from 'nomad-ui/mixins/window-resizable'; +import styleStringProperty from 'nomad-ui/utils/properties/style-string'; + +// Returns a new array with the specified number of points linearly +// distributed across the bounds +const lerp = ([low, high], numPoints) => { + const step = (high - low) / (numPoints - 1); + const arr = []; + for (var i = 0; i < numPoints; i++) { + arr.push(low + step * i); + } + return arr; +}; + +// Round a number or an array of numbers +const nice = val => (val instanceof Array ? val.map(nice) : Math.round(val)); + +export default Component.extend(WindowResizable, { + classNames: ['chart', 'line-chart'], + + // Public API + + data: null, + xProp: null, + yProp: null, + timeseries: false, + chartClass: 'is-primary', + + title: 'Line Chart', + description: null, + + // Private Properties + + width: 0, + height: 0, + + isActive: false, + + fillId: computed(function() { + return `line-chart-fill-${guidFor(this)}`; + }), + + maskId: computed(function() { + return `line-chart-mask-${guidFor(this)}`; + }), + + activeDatum: null, + + activeDatumLabel: computed('activeDatum', function() { + const datum = this.get('activeDatum'); + + if (!datum) return; + + const x = datum[this.get('xProp')]; + return this.xFormat(this.get('timeseries'))(x); + }), + + activeDatumValue: computed('activeDatum', function() { + const datum = this.get('activeDatum'); + + if (!datum) return; + + const y = datum[this.get('yProp')]; + return this.yFormat()(y); + }), + + // Overridable functions that retrurn formatter functions + xFormat(timeseries) { + return timeseries ? d3TimeFormat.timeFormat('%b') : d3Format.format(','); + }, + + yFormat() { + return d3Format.format(',.2~r'); + }, + + tooltipPosition: null, + tooltipStyle: styleStringProperty('tooltipPosition'), + + xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() { + const xProp = this.get('xProp'); + const scale = this.get('timeseries') ? d3Scale.scaleTime() : d3Scale.scaleLinear(); + const data = this.get('data'); + + const domain = data.length ? d3Array.extent(this.get('data'), d => d[xProp]) : [0, 1]; + + scale.rangeRound([10, this.get('yAxisOffset')]).domain(domain); + + return scale; + }), + + xRange: computed('data.[]', 'xFormat', 'xProp', 'timeseries', function() { + const { xProp, timeseries, data } = this.getProperties('xProp', 'timeseries', 'data'); + const range = d3Array.extent(data, d => d[xProp]); + const formatter = this.xFormat(timeseries); + + return range.map(formatter); + }), + + yRange: computed('data.[]', 'yFormat', 'yProp', function() { + const yProp = this.get('yProp'); + const range = d3Array.extent(this.get('data'), d => d[yProp]); + const formatter = this.yFormat(); + + return range.map(formatter); + }), + + yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() { + const yProp = this.get('yProp'); + let max = d3Array.max(this.get('data'), d => d[yProp]) || 1; + if (max > 1) { + max = nice(max); + } + + return d3Scale + .scaleLinear() + .rangeRound([this.get('xAxisOffset'), 10]) + .domain([0, max]); + }), + + xAxis: computed('xScale', function() { + const formatter = this.xFormat(this.get('timeseries')); + + return d3Axis + .axisBottom() + .scale(this.get('xScale')) + .ticks(5) + .tickFormat(formatter); + }), + + yTicks: computed('xAxisOffset', function() { + const height = this.get('xAxisOffset'); + const tickCount = Math.ceil(height / 120) * 2 + 1; + const domain = this.get('yScale').domain(); + const ticks = lerp(domain, tickCount); + return domain[1] - domain[0] > 1 ? nice(ticks) : ticks; + }), + + yAxis: computed('yScale', function() { + const formatter = this.yFormat(); + + return d3Axis + .axisRight() + .scale(this.get('yScale')) + .tickValues(this.get('yTicks')) + .tickFormat(formatter); + }), + + yGridlines: computed('yScale', function() { + // The first gridline overlaps the x-axis, so remove it + const [, ...ticks] = this.get('yTicks'); + + return d3Axis + .axisRight() + .scale(this.get('yScale')) + .tickValues(ticks) + .tickSize(-this.get('yAxisOffset')) + .tickFormat(''); + }), + + xAxisHeight: computed(function() { + // Avoid divide by zero errors by always having a height + if (!this.element) return 1; + + const axis = this.element.querySelector('.x-axis'); + return axis && axis.getBBox().height; + }), + + yAxisWidth: computed(function() { + // Avoid divide by zero errors by always having a width + if (!this.element) return 1; + + const axis = this.element.querySelector('.y-axis'); + return axis && axis.getBBox().width; + }), + + xAxisOffset: computed('height', 'xAxisHeight', function() { + return this.get('height') - this.get('xAxisHeight'); + }), + + yAxisOffset: computed('width', 'yAxisWidth', function() { + return this.get('width') - this.get('yAxisWidth'); + }), + + line: computed('data.[]', 'xScale', 'yScale', function() { + const { xScale, yScale, xProp, yProp } = this.getProperties( + 'xScale', + 'yScale', + 'xProp', + 'yProp' + ); + + const line = d3Shape + .line() + .defined(d => d[yProp] != null) + .x(d => xScale(d[xProp])) + .y(d => yScale(d[yProp])); + + return line(this.get('data')); + }), + + area: computed('data.[]', 'xScale', 'yScale', function() { + const { xScale, yScale, xProp, yProp } = this.getProperties( + 'xScale', + 'yScale', + 'xProp', + 'yProp' + ); + + const area = d3Shape + .area() + .defined(d => d[yProp] != null) + .x(d => xScale(d[xProp])) + .y0(yScale(0)) + .y1(d => yScale(d[yProp])); + + return area(this.get('data')); + }), + + didInsertElement() { + this.updateDimensions(); + + const canvas = d3.select(this.element.querySelector('.canvas')); + const updateActiveDatum = this.updateActiveDatum.bind(this); + + const chart = this; + canvas.on('mouseenter', function() { + const mouseX = d3.mouse(this)[0]; + chart.set('latestMouseX', mouseX); + updateActiveDatum(mouseX); + run.schedule('afterRender', chart, () => chart.set('isActive', true)); + }); + + canvas.on('mousemove', function() { + const mouseX = d3.mouse(this)[0]; + chart.set('latestMouseX', mouseX); + updateActiveDatum(mouseX); + }); + + canvas.on('mouseleave', () => { + run.schedule('afterRender', this, () => this.set('isActive', false)); + this.set('activeDatum', null); + }); + }, + + didUpdateAttrs() { + this.renderChart(); + }, + + updateActiveDatum(mouseX) { + const { xScale, xProp, yScale, yProp, data } = this.getProperties( + 'xScale', + 'xProp', + 'yScale', + 'yProp', + 'data' + ); + + 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); + const index = bisector(data, x, 1); + + // The data point on either side of the cursor + const dLeft = data[index - 1]; + const dRight = data[index]; + + 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', { + left: xScale(datum[xProp]), + top: yScale(datum[yProp]) - 10, + }); + }, + + updateChart: observer('data.[]', function() { + this.renderChart(); + }), + + // The renderChart method should only ever be responsible for runtime calculations + // and appending d3 created elements to the DOM (such as axes). + renderChart() { + // There is nothing to do if the element hasn't been inserted yet + if (!this.element) return; + + // First, create the axes to get the dimensions of the resulting + // svg elements + this.mountD3Elements(); + + run.next(() => { + // Then, recompute anything that depends on the dimensions + // on the dimensions of the axes elements + this.notifyPropertyChange('xAxisHeight'); + this.notifyPropertyChange('yAxisWidth'); + + // Since each axis depends on the dimension of the other + // axis, the axes themselves are recomputed and need to + // be re-rendered. + this.mountD3Elements(); + if (this.get('isActive')) { + this.updateActiveDatum(this.get('latestMouseX')); + } + }); + }, + + mountD3Elements() { + d3.select(this.element.querySelector('.x-axis')).call(this.get('xAxis')); + d3.select(this.element.querySelector('.y-axis')).call(this.get('yAxis')); + d3.select(this.element.querySelector('.y-gridlines')).call(this.get('yGridlines')); + }, + + windowResizeHandler() { + run.once(this, this.updateDimensions); + }, + + updateDimensions() { + const $svg = this.$('svg'); + const width = $svg.width(); + const height = $svg.height(); + + this.setProperties({ width, height }); + this.renderChart(); + }, +}); diff --git a/ui/app/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 new file mode 100644 index 00000000000..a93a8190a9a --- /dev/null +++ b/ui/app/components/stats-time-series.js @@ -0,0 +1,59 @@ +import { computed } from '@ember/object'; +import moment from 'moment'; +import d3TimeFormat from 'd3-time-format'; +import d3Format from 'd3-format'; +import d3Scale from 'd3-scale'; +import d3Array from 'd3-array'; +import LineChart from 'nomad-ui/components/line-chart'; +import formatDuration from 'nomad-ui/utils/format-duration'; + +export default LineChart.extend({ + xProp: 'timestamp', + yProp: 'percent', + timeseries: true, + + xFormat() { + return d3TimeFormat.timeFormat('%H:%M:%S'); + }, + + yFormat() { + return d3Format.format('.1~%'); + }, + + // Specific a11y descriptors + title: 'Stats Time Series Chart', + + description: computed('data.[]', 'xProp', 'yProp', function() { + const { xProp, yProp, data } = this.getProperties('data', 'xProp', 'yProp'); + const yRange = d3Array.extent(data, d => d[yProp]); + const xRange = d3Array.extent(data, d => d[xProp]); + const yFormatter = this.yFormat(); + + const duration = formatDuration(xRange[1] - xRange[0], 'ms', true); + + return `Time series data for the last ${duration}, with values ranging from ${yFormatter(yRange[0])} to ${yFormatter(yRange[1])}`; + }), + + xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() { + const xProp = this.get('xProp'); + const scale = this.get('timeseries') ? d3Scale.scaleTime() : d3Scale.scaleLinear(); + const data = this.get('data'); + + const [low, high] = d3Array.extent(data, d => d[xProp]); + const minLow = moment(high) + .subtract(5, 'minutes') + .toDate(); + + const extent = data.length ? [Math.min(low, minLow), high] : [minLow, new Date()]; + scale.rangeRound([10, this.get('yAxisOffset')]).domain(extent); + + return scale; + }), + + yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() { + return d3Scale + .scaleLinear() + .rangeRound([this.get('xAxisOffset'), 10]) + .domain([0, 1]); + }), +}); diff --git a/ui/app/components/task-row.js b/ui/app/components/task-row.js new file mode 100644 index 00000000000..970ba43df0f --- /dev/null +++ b/ui/app/components/task-row.js @@ -0,0 +1,64 @@ +import Ember from 'ember'; +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; +import { task, timeout } from 'ember-concurrency'; +import { lazyClick } from '../helpers/lazy-click'; + +export default Component.extend({ + store: service(), + token: service(), + statsTrackersRegistry: service('stats-trackers-registry'), + + tagName: 'tr', + classNames: ['task-row', 'is-interactive'], + + task: null, + + // Internal state + statsError: false, + + enablePolling: computed(() => !Ember.testing), + + // Since all tasks for an allocation share the same tracker, use the registry + stats: computed('task', function() { + return this.get('statsTrackersRegistry').getTracker(this.get('task.allocation')); + }), + + taskStats: computed('task.name', 'stats.tasks.[]', function() { + const ret = this.get('stats.tasks').findBy('task', this.get('task.name')); + return ret; + }), + + cpu: alias('taskStats.cpu.lastObject'), + memory: alias('taskStats.memory.lastObject'), + + onClick() {}, + + click(event) { + lazyClick([this.get('onClick'), event]); + }, + + fetchStats: task(function*() { + do { + try { + yield this.get('stats.poll').perform(); + this.set('statsError', false); + } catch (error) { + this.set('statsError', true); + } + yield timeout(500); + } while (this.get('enablePolling')); + }).drop(), + + didReceiveAttrs() { + const allocation = this.get('task.allocation'); + + if (allocation) { + this.get('fetchStats').perform(); + } else { + this.get('fetchStats').cancelAll(); + } + }, +}); diff --git a/ui/app/controllers/allocations/allocation/index.js b/ui/app/controllers/allocations/allocation/index.js index b6fd778bae2..4d6697c1404 100644 --- a/ui/app/controllers/allocations/allocation/index.js +++ b/ui/app/controllers/allocations/allocation/index.js @@ -1,9 +1,12 @@ import { alias } from '@ember/object/computed'; import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; import Sortable from 'nomad-ui/mixins/sortable'; import { lazyClick } from 'nomad-ui/helpers/lazy-click'; export default Controller.extend(Sortable, { + token: service(), + queryParams: { sortProperty: 'sort', sortDescending: 'desc', diff --git a/ui/app/helpers/format-duration.js b/ui/app/helpers/format-duration.js index c85a14b41e9..8ff4f73f342 100644 --- a/ui/app/helpers/format-duration.js +++ b/ui/app/helpers/format-duration.js @@ -1,8 +1,8 @@ import Helper from '@ember/component/helper'; import formatDuration from '../utils/format-duration'; -function formatDurationHelper([duration], { units }) { - return formatDuration(duration, units); +function formatDurationHelper([duration], { units, longForm }) { + return formatDuration(duration, units, longForm); } export default Helper.helper(formatDurationHelper); diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js index f98e3fc22fa..1a7e030a530 100644 --- a/ui/app/models/allocation.js +++ b/ui/app/models/allocation.js @@ -6,7 +6,6 @@ import { belongsTo } from 'ember-data/relationships'; import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes'; import intersection from 'lodash.intersection'; import shortUUIDProperty from '../utils/properties/short-uuid'; -import AllocationStats from '../utils/classes/allocation-stats'; const STATUS_ORDER = { pending: 1, @@ -74,18 +73,6 @@ export default Model.extend({ return []; }), - fetchStats() { - return this.get('token') - .authorizedRequest(`/v1/client/allocation/${this.get('id')}/stats`) - .then(res => res.json()) - .then(json => { - return new AllocationStats({ - stats: json, - allocation: this, - }); - }); - }, - states: fragmentArray('task-state'), rescheduleEvents: fragmentArray('reschedule-event'), diff --git a/ui/app/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/charts.scss b/ui/app/styles/charts.scss index 3db77a93b90..571535ca5ea 100644 --- a/ui/app/styles/charts.scss +++ b/ui/app/styles/charts.scss @@ -1,4 +1,5 @@ @import './charts/distribution-bar'; +@import './charts/line-chart'; @import './charts/tooltip'; @import './charts/colors'; diff --git a/ui/app/styles/charts/colors.scss b/ui/app/styles/charts/colors.scss index e204c723f2b..6eedeeaecf4 100644 --- a/ui/app/styles/charts/colors.scss +++ b/ui/app/styles/charts/colors.scss @@ -85,4 +85,12 @@ $lost: $dark; &.lost { background: $lost; } + + @each $name, $pair in $colors { + $color: nth($pair, 1); + + &.is-#{$name} { + background: $color; + } + } } diff --git a/ui/app/styles/charts/line-chart.scss b/ui/app/styles/charts/line-chart.scss new file mode 100644 index 00000000000..eff9ec53998 --- /dev/null +++ b/ui/app/styles/charts/line-chart.scss @@ -0,0 +1,69 @@ +.chart.line-chart { + display: block; + height: 100%; + + svg { + display: block; + height: 100%; + width: 100%; + overflow: visible; + } + + .canvas { + .line { + fill: transparent; + stroke-width: 1.25; + } + + .hover-target { + fill: transparent; + stroke: transparent; + } + } + + .axis { + line, + path { + stroke: $grey-blue; + } + + text { + fill: darken($grey-blue, 20%); + } + } + + .gridlines { + path { + stroke-width: 0; + } + + line { + stroke: lighten($grey-blue, 10%); + stroke-dasharray: 5 10; + } + } + + @each $name, $pair in $colors { + $color: nth($pair, 1); + + .canvas.is-#{$name} { + .line { + stroke: $color; + } + } + + linearGradient { + &.is-#{$name} { + > .start { + stop-color: $color; + stop-opacity: 0.6; + } + + > .end { + stop-color: $color; + stop-opacity: 0.05; + } + } + } + } +} diff --git a/ui/app/styles/charts/tooltip.scss b/ui/app/styles/charts/tooltip.scss index 580e0ab3b87..028b7b9442c 100644 --- a/ui/app/styles/charts/tooltip.scss +++ b/ui/app/styles/charts/tooltip.scss @@ -15,6 +15,10 @@ pointer-events: none; z-index: $z-tooltip; + &.is-snappy { + transition: 0.2s top ease-out, 0.05s left ease-out; + } + &::before { pointer-events: none; display: inline-block; @@ -53,41 +57,48 @@ ol { list-style: none; + } - li { - display: flex; - flex-flow: row; - flex-wrap: nowrap; - justify-content: space-between; - padding: 0.25rem 0.5rem; + ol > li, + p { + display: flex; + flex-flow: row; + flex-wrap: nowrap; + justify-content: space-between; + padding: 0.25rem 0.5rem; - span { - display: inline-block; - } + span { + display: inline-block; + } - .label { - font-weight: $weight-bold; - color: rgba($black, 0.6); - margin: 0; + .label { + font-weight: $weight-bold; + color: $black; + margin: 0; - &.is-empty { - color: rgba($grey, 0.6); - } + &.is-empty { + color: rgba($grey, 0.6); } + } + } - &.active { - color: $black; - background: $white-ter; + ol > li { + .label { + color: rgba($black, 0.6); + } - .label { - color: $black; - } - } + &.active { + color: $black; + background: $white-ter; - + li { - border-top: 1px solid $grey-light; + .label { + color: $black; } } + + + li { + border-top: 1px solid $grey-light; + } } } } 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..335d2cf5aac 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 @@ -34,52 +50,14 @@ Last Event {{#t.sort-by prop="events.lastObject.time"}}Time{{/t.sort-by}} Addresses + CPU + Memory {{/t.head}} {{#t.body as |row|}} - - - {{#if (not row.model.driverStatus.healthy)}} - - {{x-icon "warning" class="is-warning"}} - - {{/if}} - - - {{#link-to "allocations.allocation.task" row.model.allocation row.model class="is-primary"}} - {{row.model.name}} - {{/link-to}} - - {{row.model.state}} - - {{#if row.model.events.lastObject.message}} - {{row.model.events.lastObject.message}} - {{else}} - No message - {{/if}} - - {{moment-format row.model.events.lastObject.time "MM/DD/YY HH:mm:ss"}} - - - - + {{task-row + data-test-task-row=row.model.name + task=row.model + onClick=(action "taskClick" row.model.allocation row.model)}} {{/t.body}} {{/list-table}}
diff --git a/ui/app/templates/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/allocation-row.hbs b/ui/app/templates/components/allocation-row.hbs index fd16e5bc906..229efef853f 100644 --- a/ui/app/templates/components/allocation-row.hbs +++ b/ui/app/templates/components/allocation-row.hbs @@ -1,11 +1,11 @@ {{#if allocation.unhealthyDrivers.length}} - + {{x-icon "warning" class="is-warning"}} {{/if}} {{#if allocation.nextAllocation}} - + {{x-icon "history" class="is-faded"}} {{/if}} @@ -42,41 +42,41 @@ {{allocation.jobVersion}} {{/if}} - {{#if (and (not stats) fetchStats.isRunning)}} + {{#if (and (not cpu) fetchStats.isRunning)}} ... {{else if (not allocation)}} {{! nothing when there's no allocation}} {{else if statsError}} - + {{x-icon "warning" class="is-warning"}} {{else}} -
+ {{/if}} - {{#if (and (not stats) fetchStats.isRunning)}} + {{#if (and (not memory) fetchStats.isRunning)}} ... {{else if (not allocation)}} {{! nothing when there's no allocation}} {{else if statsError}} - + {{x-icon "warning" class="is-warning"}} {{else}} -
+ {{/if}} diff --git a/ui/app/templates/components/client-node-row.hbs b/ui/app/templates/components/client-node-row.hbs index 269e522a39c..21bf6597bef 100644 --- a/ui/app/templates/components/client-node-row.hbs +++ b/ui/app/templates/components/client-node-row.hbs @@ -1,6 +1,6 @@ {{#if node.unhealthyDrivers.length}} - + {{x-icon "warning" class="is-warning"}} {{/if}} diff --git a/ui/app/templates/components/freestyle/sg-line-chart.hbs b/ui/app/templates/components/freestyle/sg-line-chart.hbs new file mode 100644 index 00000000000..014b061bd2a --- /dev/null +++ b/ui/app/templates/components/freestyle/sg-line-chart.hbs @@ -0,0 +1,42 @@ +{{#freestyle-usage "line-chart-standard" title="Standard"}} +
+ {{line-chart data=lineChartData xProp="year" yProp="value" chartClass="is-primary"}} +
+
+ {{line-chart data=lineChartMild xProp="year" yProp="value" chartClass="is-info"}} +
+{{/freestyle-usage}} + +{{#freestyle-usage "line-chart-fill-width" title="Fluid width"}} +
+ {{line-chart data=lineChartData xProp="year" yProp="value" chartClass="is-danger"}} +
+
+ {{line-chart data=lineChartMild xProp="year" yProp="value" chartClass="is-warning"}} +
+{{/freestyle-usage}} +{{#freestyle-annotation}} +

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

+{{/freestyle-annotation}} + +{{#freestyle-usage "line-chart-live-data" title="Live data"}} +
+ {{line-chart + data=lineChartLive + xProp="ts" + yProp="val" + timeseries=true + chartClass="is-primary" + 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/freestyle/sg-progress-bar.hbs b/ui/app/templates/components/freestyle/sg-progress-bar.hbs index 24e34f00e6d..edf0d096cda 100644 --- a/ui/app/templates/components/freestyle/sg-progress-bar.hbs +++ b/ui/app/templates/components/freestyle/sg-progress-bar.hbs @@ -1,5 +1,5 @@ {{#freestyle-usage "progress-bar" title="Progress Bar"}} -
+