235 lines
7.2 KiB
JavaScript
235 lines
7.2 KiB
JavaScript
import Component from '@ember/component';
|
|
import dayjs from 'dayjs';
|
|
import Calendar from 'dayjs/plugin/calendar';
|
|
|
|
import { select, pointer } from 'd3-selection';
|
|
import { scaleLinear, scaleTime, scaleOrdinal } from 'd3-scale';
|
|
import { schemeTableau10 } from 'd3-scale-chromatic';
|
|
import { area, stack, stackOrderReverse } from 'd3-shape';
|
|
import { max, extent, bisector } from 'd3-array';
|
|
import { set } from '@ember/object';
|
|
|
|
dayjs.extend(Calendar);
|
|
|
|
function niceTimeWithSeconds(d) {
|
|
return dayjs(d).calendar(null, {
|
|
sameDay: '[Today at] h:mm:ss A',
|
|
lastDay: '[Yesterday at] h:mm:ss A',
|
|
lastWeek: '[Last] dddd at h:mm:ss A',
|
|
sameElse: 'MMM DD at h:mm:ss A',
|
|
});
|
|
}
|
|
|
|
export default Component.extend({
|
|
data: null,
|
|
empty: false,
|
|
actions: {
|
|
redraw: function(evt) {
|
|
this.drawGraphs();
|
|
},
|
|
change: function(evt) {
|
|
this.set('data', evt.data.series);
|
|
this.drawGraphs();
|
|
this.rerender();
|
|
},
|
|
},
|
|
|
|
drawGraphs: function() {
|
|
if (!this.data) {
|
|
set(this, 'empty', true);
|
|
return;
|
|
}
|
|
|
|
let svg = (this.svg = select(this.element.querySelector('svg.sparkline')));
|
|
svg.on('mouseover mousemove mouseout', null);
|
|
svg.selectAll('path').remove();
|
|
svg.selectAll('rect').remove();
|
|
|
|
let bb = svg.node().getBoundingClientRect();
|
|
let w = bb.width;
|
|
let h = bb.height;
|
|
|
|
// To be safe, filter any series that actually have no data points. This can
|
|
// happen thanks to our current provider contract allowing empty arrays for
|
|
// series data if there is no value.
|
|
let maybeData = this.data || {};
|
|
let series = maybeData.data || [];
|
|
let labels = maybeData.labels || {};
|
|
let unitSuffix = maybeData.unitSuffix || '';
|
|
let keys = Object.keys(labels).filter(l => l != 'Total');
|
|
|
|
if (series.length == 0 || keys.length == 0) {
|
|
// Put the graph in an error state that might get fixed if metrics show up
|
|
// on next poll.
|
|
set(this, 'empty', true);
|
|
return;
|
|
} else {
|
|
set(this, 'empty', false);
|
|
}
|
|
|
|
let st = stack()
|
|
.keys(keys)
|
|
.order(stackOrderReverse);
|
|
|
|
let stackData = st(series);
|
|
|
|
// Sum all of the values for each point to get max range. Technically
|
|
// stackData contains this but I didn't find reliable documentation on
|
|
// whether we can rely on the highest stacked area to always be first/last
|
|
// in array etc. so this is simpler.
|
|
let summed = series.map(d => {
|
|
let sum = 0;
|
|
keys.forEach(l => {
|
|
sum = sum + d[l];
|
|
});
|
|
return sum;
|
|
});
|
|
|
|
let x = scaleTime()
|
|
.domain(extent(series, d => d.time))
|
|
.range([0, w]);
|
|
|
|
let y = scaleLinear()
|
|
.domain([0, max(summed)])
|
|
.range([h, 0]);
|
|
|
|
let a = area()
|
|
.x(d => x(d.data.time))
|
|
.y1(d => y(d[0]))
|
|
.y0(d => y(d[1]));
|
|
|
|
// Use the grey/red we prefer by default but have more colors available in
|
|
// case user adds extra series with a custom provider.
|
|
let colorScheme = ['#DCE0E6', '#C73445'].concat(schemeTableau10);
|
|
|
|
if (keys.includes('Outbound')) {
|
|
colorScheme = ['#DCE0E6', '#0E40A3'].concat(schemeTableau10);
|
|
}
|
|
let color = scaleOrdinal(colorScheme).domain(keys);
|
|
|
|
svg
|
|
.selectAll('path')
|
|
.data(stackData)
|
|
.join('path')
|
|
.attr('fill', ({ key }) => color(key))
|
|
.attr('stroke', ({ key }) => color(key))
|
|
.attr('d', a);
|
|
|
|
let cursor = svg
|
|
.append('rect')
|
|
.attr('class', 'cursor')
|
|
.style('visibility', 'hidden')
|
|
.attr('width', 1)
|
|
.attr('height', h)
|
|
.attr('x', 0)
|
|
.attr('y', 0);
|
|
|
|
let tooltip = select(this.element.querySelector('.tooltip'));
|
|
tooltip.selectAll('.sparkline-tt-legend').remove();
|
|
tooltip.selectAll('.sparkline-tt-sum').remove();
|
|
|
|
for (var k of keys) {
|
|
let legend = tooltip.append('div').attr('class', 'sparkline-tt-legend');
|
|
|
|
legend
|
|
.append('div')
|
|
.attr('class', 'sparkline-tt-legend-color')
|
|
.style('background-color', color(k));
|
|
|
|
legend
|
|
.append('span')
|
|
.text(k)
|
|
.append('span')
|
|
.attr('class', 'sparkline-tt-legend-value');
|
|
}
|
|
|
|
let tipVals = tooltip.selectAll('.sparkline-tt-legend-value');
|
|
|
|
// Add a label for the summed value
|
|
if (keys.length > 1) {
|
|
tooltip
|
|
.append('div')
|
|
.attr('class', 'sparkline-tt-sum')
|
|
.append('span')
|
|
.text('Total')
|
|
.append('span')
|
|
.attr('class', 'sparkline-tt-sum-value');
|
|
}
|
|
|
|
let self = this;
|
|
svg
|
|
.on('mouseover', function(e) {
|
|
tooltip.style('visibility', 'visible');
|
|
cursor.style('visibility', 'visible');
|
|
// We update here since we might redraw the graph with user's cursor
|
|
// stationary over it. If that happens mouseover fires but not
|
|
// mousemove but the tooltip and cursor are wrong (based on old data).
|
|
self.updateTooltip(e, series, stackData, summed, unitSuffix, x, tooltip, tipVals, cursor);
|
|
})
|
|
.on('mousemove', function(e) {
|
|
self.updateTooltip(e, series, stackData, summed, unitSuffix, x, tooltip, tipVals, cursor);
|
|
})
|
|
.on('mouseout', function(e) {
|
|
tooltip.style('visibility', 'hidden');
|
|
cursor.style('visibility', 'hidden');
|
|
});
|
|
},
|
|
willDestroyElement: function() {
|
|
this._super(...arguments);
|
|
if (typeof this.svg !== 'undefined') {
|
|
this.svg.on('mouseover mousemove mouseout', null);
|
|
}
|
|
},
|
|
updateTooltip: function(e, series, stackData, summed, unitSuffix, x, tooltip, tipVals, cursor) {
|
|
let [mouseX] = pointer(e);
|
|
cursor.attr('x', mouseX);
|
|
|
|
let mouseTime = x.invert(mouseX);
|
|
var bisectTime = bisector(function(d) {
|
|
return d.time;
|
|
}).left;
|
|
let tipIdx = bisectTime(series, mouseTime);
|
|
|
|
tooltip
|
|
// 22 px is the correction to align the arrow on the tool tip with
|
|
// cursor.
|
|
.style('left', mouseX - 22 + 'px')
|
|
.select('.sparkline-time')
|
|
.text(niceTimeWithSeconds(mouseTime));
|
|
|
|
// Get the summed value - that's the one of the top most stack.
|
|
tooltip.select('.sparkline-tt-sum-value').text(`${shortNumStr(summed[tipIdx])}${unitSuffix}`);
|
|
|
|
tipVals.nodes().forEach((n, i) => {
|
|
let val = stackData[i][tipIdx][1] - stackData[i][tipIdx][0];
|
|
select(n).text(`${shortNumStr(val)}${unitSuffix}`);
|
|
});
|
|
cursor.attr('x', mouseX);
|
|
},
|
|
});
|
|
|
|
// Duplicated in vendor/metrics-providers/prometheus.js since we want that to
|
|
// remain a standalone example of a provider that could be loaded externally.
|
|
function shortNumStr(n) {
|
|
if (n < 1e3) {
|
|
if (Number.isInteger(n)) return '' + n;
|
|
if (n >= 100) {
|
|
// Go to 3 significant figures but wrap it in Number to avoid scientific
|
|
// notation lie 2.3e+2 for 230.
|
|
return Number(n.toPrecision(3));
|
|
}
|
|
if (n < 1) {
|
|
// Very small numbers show with limited precision to prevent long string
|
|
// of 0.000000.
|
|
return Number(n.toFixed(2));
|
|
} else {
|
|
// Two sig figs is enough below this
|
|
return Number(n.toPrecision(2));
|
|
}
|
|
}
|
|
if (n >= 1e3 && n < 1e6) return +(n / 1e3).toPrecision(3) + 'k';
|
|
if (n >= 1e6 && n < 1e9) return +(n / 1e6).toPrecision(3) + 'm';
|
|
if (n >= 1e9 && n < 1e12) return +(n / 1e9).toPrecision(3) + 'g';
|
|
if (n >= 1e12) return +(n / 1e12).toFixed(0) + 't';
|
|
}
|