consul/ui/packages/consul-ui/app/components/topology-metrics/series/index.js

229 lines
7.1 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';
}