Add metrics rendering to the new topology view. (#8858)

* Remove unused StatsCard component

* Create Card and Stats contextual components with styling

* Send endpoint, item, and protocol to Stats as props

* WIP basic plumbing for metrics in Ember

* WIP metrics data source now works for different protocols and produces reasonable mock responses

* WIP sparkline component

* Mostly working metrics and graphs in topology

* Fix date in tooltip to actually be correct

* Clean up console.log

* Add loading frame and create a style sheet for Stats

* Various polish fixes:

 - Loading state for graph
 - Added fake latency cookie value to test loading
 - If metrics provider has no series/stats for the service show something that doesn't look broken
 - Graph hover works right to the edge now
 - Stats boxes now wrap so they are either shown or not as will fit not cut off
 - Graph resizes when browser window size changes
 - Some tweaks to number formats and stat metrics to make them more compact/useful

* Thread Protocol through topology model correctly

* Rebuild assetfs

* Fix failing tests and remove stats-card now it's changed and become different

* Fix merge conflict

* Update api doublt

* more merge fixes

* Add data-permission and id attr to Card

* Run JS linter

* Move things around so the tests run with everything available

* Get tests passing:

1. Remove fakeLatency setTimeout (will be replaced with CONSUL_LATENCY
in mocks)
2. Make sure any event handlers are removed

* Make sure the Consul/scripts are available before the app

* Make sure interval gets set if there is no cookie value

* Upgrade mocks so we can use CONSUL_LATENCY

* Fix handling of no series values from Prometheus

* Update assetfs and fix a comment

* Rebase and rebuild assetfs; fix tcp metric series units to be bits not bytes

* Rebuild assetfs

* Hide stats when provider is not configured

Co-authored-by: kenia <keniavalladarez@gmail.com>
Co-authored-by: John Cowen <jcowen@hashicorp.com>
This commit is contained in:
Paul Banks 2020-10-09 21:31:15 +01:00 committed by GitHub
parent 097b47a741
commit 40695d5919
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1793 additions and 239 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
{{yield}}
<div class="stats-card">
<header>
<YieldSlot @name="mini-stat">{{yield}}</YieldSlot>
<YieldSlot @name="header">{{yield}}</YieldSlot>
<YieldSlot @name="icon">{{yield}}</YieldSlot>
</header>
<YieldSlot @name="body">{{yield}}</YieldSlot>
</div>

View File

@ -1,4 +0,0 @@
import Component from '@ember/component';
import Slotted from 'block-slots';
export default Component.extend(Slotted, {});

View File

@ -0,0 +1,56 @@
{{#each @items as |item|}}
<div
class="card"
data-permission={{service/intention-permissions item}}
id="{{item.Namespace}}{{item.Name}}"
>
<p>
{{item.Name}}
</p>
<div class="details">
{{#if (and (and nspace (env 'CONSUL_NSPACES_ENABLED')) @type)}}
<dl class="nspace">
<dt>
<Tooltip>
Namespace
</Tooltip>
</dt>
<dd>
{{item.Namespace}}
</dd>
</dl>
{{/if}}
{{#if (eq item.Datacenter @dc)}}
{{#let (service/health-percentage item) as |percentage|}}
{{#if (not-eq percentage.passing 0)}}
<span class="passing">{{percentage.passing}}%</span>
{{/if}}
{{#if (not-eq percentage.warning 0)}}
<span class="warning">{{percentage.warning}}%</span>
{{/if}}
{{#if (not-eq percentage.critical 0)}}
<span class="critical">{{percentage.critical}}%</span>
{{/if}}
{{/let}}
{{else}}
<dl class="health">
<dt>
<Tooltip>
We are unable to determine the health status of services in remote datacenters.
</Tooltip>
</dt>
<dd>
Health
</dd>
</dl>
{{/if}}
</div>
{{#if @hasMetricsProvider }}
{{#if (eq @type 'upstream')}}
<TopologyMetrics::Stats @endpoint='upstream-summary-for-service' @service={{@service}} @item={{item.Name}} />
{{else}}
<TopologyMetrics::Stats @endpoint='downstream-summary-for-service' @service={{@service}} @item={{item.Name}} />
{{/if}}
{{/if}}
</div>
{{/each}}

View File

@ -11,48 +11,23 @@
</Tooltip>
</span>
</div>
{{#each @downstreams as |downstream|}}
<div class="card"
data-permission={{service/intention-permissions downstream}}
id="{{downstream.Namespace}}{{downstream.Name}}"
>
<p>
{{downstream.Name}}
</p>
<div class="detail">
{{#if (env 'CONSUL_NSPACES_ENABLED')}}
<dl class="nspace">
<dt>
<Tooltip>
Namespace
</Tooltip>
</dt>
<dd>
{{downstream.Namespace}}
</dd>
</dl>
{{/if}}
{{#let (service/health-percentage downstream) as |percentage|}}
{{#if (not-eq percentage.passing 0)}}
<span class="passing">{{percentage.passing}}%</span>
{{/if}}
{{#if (not-eq percentage.warning 0)}}
<span class="warning">{{percentage.warning}}%</span>
{{/if}}
{{#if (not-eq percentage.critical 0)}}
<span class="critical">{{percentage.critical}}%</span>
{{/if}}
{{/let}}
</div>
</div>
{{/each}}
<TopologyMetrics::Card
@items={{@downstreams}}
@service={{@service.Service.Service}}
@dc={{@dc}}
@hasMetricsProvider={{this.hasMetricsProvider}}
/>
</div>
{{/if}}
<div id="metrics-container">
<div>
{{@service.Service.Service}}
</div>
<div>
{{#if this.hasMetricsProvider }}
<TopologyMetrics::Series @service={{@service.Service.Service}} @protocol={{@protocol}} />
<TopologyMetrics::Stats @endpoint='summary-for-service' @service={{@service.Service.Service}} @protocol={{@protocol}} />
{{/if}}
<div class="link">
{{#if @metricsHref}}
<a class="metrics-link" href={{@metricsHref}} target="_blank" rel="noopener noreferrer">Open metrics Dashboard</a>
{{else}}
@ -74,54 +49,13 @@
{{#each-in (group-by "Datacenter" @upstreams) as |dc upstreams|}}
<div id="upstream-container">
<p>{{dc}}</p>
{{#each upstreams as |upstream|}}
<div class="card"
data-permission={{service/intention-permissions upstream}}
id="{{upstream.Namespace}}{{upstream.Name}}"
>
<p>
{{upstream.Name}}
</p>
<div class="detail">
{{#if (env 'CONSUL_NSPACES_ENABLED')}}
<dl class="nspace">
<dt>
<Tooltip>
Namespace
</Tooltip>
</dt>
<dd>
{{upstream.Namespace}}
</dd>
</dl>
{{/if}}
{{#if (eq upstream.Datacenter @dc)}}
{{#let (service/health-percentage upstream) as |percentage|}}
{{#if (not-eq percentage.passing 0)}}
<span class="passing">{{percentage.passing}}%</span>
{{/if}}
{{#if (not-eq percentage.warning 0)}}
<span class="warning">{{percentage.warning}}%</span>
{{/if}}
{{#if (not-eq percentage.critical 0)}}
<span class="critical">{{percentage.critical}}%</span>
{{/if}}
{{/let}}
{{else}}
<dl class="health">
<dt>
<Tooltip>
We are unable to determine the health status of services in remote datacenters.
</Tooltip>
</dt>
<dd>
Health
</dd>
</dl>
{{/if}}
</div>
</div>
{{/each}}
<TopologyMetrics::Card
@items={{upstreams}}
@service={{@service.Service.Service}}
@dc={{@dc}}
@type='upstream'
@hasMetricsProvider={{this.hasMetricsProvider}}
/>
</div>
{{/each-in}}
</div>

View File

@ -1,14 +1,23 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class TopologyMetrics extends Component {
@service('ui-config') cfg;
// =attributes
@tracked centerDimensions;
@tracked downView;
@tracked downLines = [];
@tracked upView;
@tracked upLines = [];
@tracked hasMetricsProvider = false;
constructor(owner, args) {
super(owner, args);
this.hasMetricsProvider = !!this.cfg.get().metrics_provider
}
// =methods
drawDownLines(items) {

View File

@ -14,10 +14,12 @@
#downstream-lines {
grid-row: 1 / 3;
grid-column: 2 / 5;
pointer-events: none;
}
#upstream-lines {
grid-row: 1 / 3;
grid-column: 6 / 9;
pointer-events: none;
}
#upstream-column {
grid-row: 1 / 3;
@ -51,8 +53,10 @@
}
#upstream-container .card,
#downstream-container .card {
padding: 12px;
display: flex;
flex-direction: column;
p {
padding: 12px 12px 0 12px;
font-size: 16px;
font-weight: 600;
margin-bottom: 0 !important;
@ -75,6 +79,10 @@
margin-top: 2px;
}
}
.details {
padding: 0 12px 12px 12px;
}
}
// Metrics Container
@ -88,7 +96,7 @@
font-size: 16px;
font-weight: bold;
}
div:nth-child(2) {
.link {
padding: 18px;
a::before {
margin-right: 4px;

View File

@ -0,0 +1,14 @@
<DataSource
@src={{uri nspace dc 'metrics' 'summary-for-service' @service @protocol}}
@onchange={{action 'change'}} />
{{on-window 'resize' (action 'redraw')}}
<div class="sparkline-wrapper">
<div class="tooltip">
<div class="sparkline-time">Timestamp</div>
</div>
<div class="sparkline-loader"><span>Loading Metrics</span></div>
<svg class="sparkline"></svg>
</div>

View File

@ -0,0 +1,234 @@
import Component from '@ember/component';
import dayjs from 'dayjs';
import Calendar from 'dayjs/plugin/calendar';
import { select, event, mouse } 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';
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,
actions: {
redraw: function(evt) {
this.drawGraphs();
},
change: function(evt) {
this.data = evt.data;
this.element.querySelector('.sparkline-loader').style.display = 'none';
this.drawGraphs();
},
},
drawGraphs: function() {
if (!this.data.series) {
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.
//
// TODO(banks): switch series provider data to be a single array with series
// values as properties as we need below to enforce sensible alignment of
// timestamps and explicit summing expectations.
let series = ((this.data || {}).series || []).filter(s => s.data.length > 0);
if (series.length == 0) {
// Put the graph in an error state that might get fixed if metrics show up
// on next poll.
let loader = this.element.querySelector('.sparkline-loader');
loader.innerHTML = 'No Metrics Available';
loader.style.display = 'block';
return;
}
// Fill the timestamps for x axis.
let data = series[0].data.map(d => {
return { time: d[0] };
});
let keys = [];
// Initialize zeros
let summed = this.data.series[0].data.map(d => 0);
for (var i = 0; i < series.length; i++) {
let s = series[i];
// Attach the value as a new field to the data grid.
s.data.map((d, idx) => {
data[idx][s.label] = d[1];
summed[idx] += d[1];
});
keys.push(s.label);
}
let st = stack()
.keys(keys)
.order(stackOrderReverse);
let stackData = st(data);
let x = scaleTime()
.domain(extent(data, 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);
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();
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');
let self = this;
svg
.on('mouseover', function() {
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(event, data, stackData, keys, x, tooltip, tipVals, cursor);
})
.on('mousemove', function(d, i) {
self.updateTooltip(event, data, stackData, keys, x, tooltip, tipVals, cursor);
})
.on('mouseout', function() {
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(event, data, stackData, keys, x, tooltip, tipVals, cursor) {
let [mouseX] = mouse(event.currentTarget);
cursor.attr('x', mouseX);
let mouseTime = x.invert(mouseX);
var bisectTime = bisector(function(d) {
return d.time;
}).left;
let tipIdx = bisectTime(data, 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));
tipVals.nodes().forEach((n, i) => {
let val = stackData[i][tipIdx][1] - stackData[i][tipIdx][0];
select(n).text(this.formatTooltip(keys[i], val));
});
cursor.attr('x', mouseX);
},
formatTooltip: function(label, val) {
switch (label) {
case 'Data rate received':
// fallthrough
case 'Data rate transmitted':
return dataRateStr(val);
default:
return shortNumStr(val);
}
},
});
// 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';
}
function dataRateStr(n) {
return shortNumStr(n) + 'bps';
}

View File

@ -0,0 +1,2 @@
@import './skin';
@import './layout';

View File

@ -0,0 +1,39 @@
#metrics-container div .sparkline-wrapper {
position: relative;
width: 100%;
padding: 0;
margin: 0;
height: 70px;
svg.sparkline {
width: 100%;
height: 70px;
padding: 0;
margin: 0;
}
.tooltip {
visibility: hidden;
position: absolute;
z-index: 100;
bottom: 78px;
width: 250px;
}
.sparkline-tt-legend-color {
display: inline-block;
}
div.sparkline-loader {
font-weight: normal;
padding-top: 15px;
font-size: 0.875rem;
color: $gray-500;
text-align: center;
span::after {
@extend %with-loading-icon, %as-pseudo;
}
}
}

View File

@ -0,0 +1,53 @@
#metrics-container div .sparkline-wrapper {
svg path {
stroke-width: 0;
}
.tooltip {
padding: 5px 10px 10px 10px;
font-size: 0.875em;
line-height: 1.5em;
font-weight: normal;
border: 1px solid #BAC1CC;
background: #fff;
border-radius: 2px;
box-sizing: border-box;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.05), 0px 4px 4px rgba(0, 0, 0, 0.1);
.sparkline-time {
padding: 0;
font-weight: bold;
font-size: 14px;
color: #000;
margin-bottom: 5px;
}
.sparkline-tt-legend {
border: 0;
}
.sparkline-tt-legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
margin: 0 5px 0 0;
padding: 0;
}
}
div.tooltip:before{
content:'';
display:block;
position: absolute;
width: 12px;
height: 12px;
left: 15px;
bottom: -7px;
border: 1px solid #BAC1CC;
border-top: 0;
border-left: 0;
background: #fff;
transform: rotate(45deg);
}
}

View File

@ -58,6 +58,9 @@
background-color: $red-500;
}
}
div:nth-child(3) {
border-top: 1px solid $gray-200;
}
}
// Metrics Container
@ -65,7 +68,7 @@
div:first-child {
background-color: $white;
}
div:nth-child(2) {
.link {
background-color: $gray-100;
a {
color: $gray-700;
@ -83,6 +86,9 @@
@extend %with-docs-mask, %as-pseudo;
}
}
div:nth-child(3) {
border-top: 1px solid $gray-200;
}
}
// SVG Line styling

View File

@ -0,0 +1,24 @@
<DataSource
@src={{uri nspace dc 'metrics' @endpoint @service @protocol}}
@onchange={{action 'statsUpdate'}}
/>
<div class="stats">
{{#if hasLoaded }}
{{#each stats as |stat|}}
<dl>
<dt>
{{stat.value}}
</dt>
<dd>
{{stat.label}}
</dd>
<Tooltip>{{{stat.desc}}}</Tooltip>
</dl>
{{else}}
<span>No Metrics Available</span>
{{/each}}
{{else}}
<span class="loader">Loading Metrics</span>
{{/if}}
</div>

View File

@ -0,0 +1,23 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class TopologyMetricsStats extends Component {
@tracked stats = null;
@tracked hasLoaded = false;
@action
statsUpdate(event) {
if (this.args.endpoint == 'summary-for-service') {
// For the main service there is just one set of stats.
this.stats = event.data.stats;
} else {
// For up/downstreams we need to pull out the stats for the service we
// represent.
this.stats = event.data.stats[this.args.item];
}
// Limit to 4 metrics for now.
this.stats = (this.stats || []).slice(0, 4);
this.hasLoaded = true;
}
}

View File

@ -0,0 +1,28 @@
.stats {
padding: 12px;
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: stretch;
width: 100%;
overflow: hidden;
height: 46px;
dl {
display:flex;
margin-bottom: 50px; // pushes wrapped metrics well out of the bounding box to hide them.
}
dt {
margin-right: 5px;
line-height: 1.5em !important;
}
dd {
color: $gray-400 !important;
}
span {
margin: 0 auto !important;
color: $gray-500;
}
span.loader::after {
@extend %with-loading-icon, %as-pseudo;
}
}

View File

@ -11,6 +11,7 @@ export default Model.extend({
Namespace: attr('string'),
Upstreams: attr(),
Downstreams: attr(),
Protocol: attr(),
meta: attr(),
Exists: computed(function() {
return true;

View File

@ -26,6 +26,7 @@ export default Service.extend({
policy: service('repository/policy'),
roles: service('repository/role'),
oidc: service('repository/oidc-provider'),
metrics: service('repository/metrics'),
type: service('data-source/protocols/http/blocking'),
@ -47,8 +48,24 @@ export default Service.extend({
}
return event;
};
let method, slug;
let method, slug, more, protocol;
switch (model) {
case 'metrics':
[method, slug, ...more] = rest;
switch (method) {
case 'summary-for-service':
[protocol, ...more] = more;
find = configuration =>
repo.findServiceSummary(protocol, slug, dc, nspace, configuration);
break;
case 'upstream-summary-for-service':
find = configuration => repo.findUpstreamSummary(slug, dc, nspace, configuration);
break;
case 'downstream-summary-for-service':
find = configuration => repo.findDownstreamSummary(slug, dc, nspace, configuration);
break;
}
break;
case 'datacenters':
case 'namespaces':
find = configuration => repo.findAll(configuration);

View File

@ -0,0 +1,55 @@
import RepositoryService from 'consul-ui/services/repository';
import { inject as service } from '@ember/service';
import { env } from 'consul-ui/env';
// meta is used by DataSource to configure polling. The interval controls how
// long between each poll to the metrics provider. TODO - make this configurable
// in the UI settings.
const meta = {
interval: env('CONSUL_METRICS_POLL_INTERVAL') || 10000,
};
export default RepositoryService.extend({
cfg: service('ui-config'),
init: function() {
this._super(...arguments);
const uiCfg = this.cfg.get();
// Inject whether or not the proxy is enabled as an option into the opaque
// JSON options the user provided.
const opts = uiCfg.metrics_provider_options || {};
opts.metrics_proxy_enabled = uiCfg.metrics_proxy_enabled;
// Inject the base app URL
const provider = uiCfg.metrics_provider || 'prometheus';
this.provider = window.consul.getMetricsProvider(provider, opts);
},
findServiceSummary: function(protocol, slug, dc, nspace, configuration = {}) {
const promises = [
// TODO: support namespaces in providers
this.provider.serviceRecentSummarySeries(slug, protocol, {}),
this.provider.serviceRecentSummaryStats(slug, protocol, {}),
];
return Promise.all(promises).then(function(results) {
return {
meta: meta,
series: results[0].series,
stats: results[1].stats,
};
});
},
findUpstreamSummary: function(slug, dc, nspace, configuration = {}) {
return this.provider.upstreamRecentSummaryStats(slug, {}).then(function(result) {
result.meta = meta;
return result;
});
},
findDownstreamSummary: function(slug, dc, nspace, configuration = {}) {
return this.provider.downstreamRecentSummaryStats(slug, {}).then(function(result) {
result.meta = meta;
return result;
});
},
});

View File

@ -0,0 +1,15 @@
import Service from '@ember/service';
export default Service.extend({
config: undefined,
get: function() {
if (this.config === undefined) {
// Load config from our special meta tag for now. Later it might come from
// an API instead/as well.
var meta = unescape(document.getElementsByName('consul-ui/ui_config')[0].content);
this.config = JSON.parse(meta);
}
return this.config;
},
});

View File

@ -68,3 +68,5 @@
@import 'consul-ui/components/consul-intention-permission-header-list';
@import 'consul-ui/components/role-selector';
@import 'consul-ui/components/topology-metrics';
@import 'consul-ui/components/topology-metrics/series';
@import 'consul-ui/components/topology-metrics/stats';

View File

@ -3,6 +3,7 @@
{{#if topology}}
<TopologyMetrics
@service={{items.firstObject}}
@protocol={{topology.Protocol}}
@upstreams={{topology.Upstreams}}
@downstreams={{filter-by 'Datacenter' topology.Datacenter topology.Downstreams}}
@dc={{topology.Datacenter}}

View File

@ -119,6 +119,13 @@ module.exports = function(defaults) {
app.import('node_modules/codemirror/mode/yaml/yaml.js', {
outputFile: 'assets/codemirror/mode/yaml/yaml.js',
});
// metrics-providers
app.import('vendor/metrics-providers/consul.js', {
outputFile: 'assets/metrics-providers/consul.js',
});
app.import('vendor/metrics-providers/prometheus.js', {
outputFile: 'assets/metrics-providers/prometheus.js',
});
let tree = app.toTree();
return tree;
};

View File

@ -26,6 +26,13 @@ module.exports = ({ appName, environment, rootURL, config }) => `
appendScript('${rootURL}assets/css.escape.js');
}
</script>
<script src="${rootURL}assets/metrics-providers/consul.js"></script>
<script src="${rootURL}assets/metrics-providers/prometheus.js"></script>
${
environment === 'production'
? `{{ range .ExtraScripts }} <script src="{{.}}"></script> {{ end }}`
: ``
}
<script src="${rootURL}assets/${appName}.js"></script>
<script>
CodeMirror.modeURL = {
@ -41,6 +48,5 @@ module.exports = ({ appName, environment, rootURL, config }) => `
}
};
</script>
${environment === 'production' ? `{{ range .ExtraScripts }} <script src="{{.}}"></script> {{ end }}` : ``}
${environment === 'test' ? `<script src="${rootURL}assets/tests.js"></script>` : ``}
`;

View File

@ -1,6 +1,10 @@
module.exports = ({ appName, environment, rootURL, config }) => `
<!-- CONSUL_VERSION: ${config.CONSUL_VERSION} -->
<meta name="consul-ui/ui_config" content="{{ jsonEncodeAndEscape .UIConfig }}" />
<meta name="consul-ui/ui_config" content="${
environment === 'production'
? `{{ jsonEncodeAndEscape .UIConfig }}`
: escape(`{"metrics_provider":"prometheus","metrics_proxy_enabled":true}`)
}" />
<link rel="icon" type="image/png" href="${rootURL}assets/favicon-32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="${rootURL}assets/favicon-16x16.png" sizes="16x16">

View File

@ -56,7 +56,7 @@
"@ember/render-modifiers": "^1.0.2",
"@glimmer/component": "^1.0.0",
"@glimmer/tracking": "^1.0.0",
"@hashicorp/consul-api-double": "^5.3.5",
"@hashicorp/consul-api-double": "^5.3.7",
"@hashicorp/ember-cli-api-double": "^3.1.0",
"@xstate/fsm": "^1.4.0",
"babel-eslint": "^10.0.3",
@ -91,6 +91,7 @@
"ember-collection": "^1.0.0-alpha.9",
"ember-composable-helpers": "~4.0.0",
"ember-computed-style": "^0.3.0",
"ember-d3": "^0.5.1",
"ember-data": "~3.20.4",
"ember-data-model-fragments": "5.0.0-beta.0",
"ember-exam": "^4.0.0",
@ -149,5 +150,8 @@
"lib/commands",
"lib/startup"
]
},
"dependencies": {
"dayjs": "^1.9.1"
}
}

View File

@ -1,35 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, find } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | stats card', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
await render(hbs`
{{#stats-card}}
{{#block-slot name='icon'}}icon{{/block-slot}}
{{#block-slot name='mini-stat'}}mini-stat{{/block-slot}}
{{#block-slot name='header'}}header{{/block-slot}}
{{#block-slot name='body'}}body{{/block-slot}}
{{/stats-card}}
`);
['icon', 'mini-stat', 'header'].forEach(item => {
assert.ok(
find('header')
.textContent.trim()
.indexOf(item) !== -1
);
});
assert.ok(
find('*')
.textContent.trim()
.indexOf('body') !== -1
);
});
});

View File

@ -0,0 +1,51 @@
(
function(global) {
// Current interface is these three methods.
const requiredMethods = [
'init',
'serviceRecentSummarySeries',
'serviceRecentSummaryStats',
'upstreamRecentSummaryStats',
'downstreamRecentSummaryStats',
];
// This is a bit gross but we want to support simple extensibility by
// including JS in the browser without forcing operators to setup a whole
// transpiling stack. So for now we use a window global as a thin registry for
// these providers.
class Consul {
constructor() {
this.registry = {};
this.providers = {};
}
registerMetricsProvider(name, provider) {
// Basic check that it matches the type we expect
for (var m of requiredMethods) {
if (typeof provider[m] !== 'function') {
throw new Error(`Can't register metrics provider '${name}': missing ${m} method.`);
}
}
this.registry[name] = provider;
}
getMetricsProvider(name, options) {
if (!(name in this.registry)) {
throw new Error(`Metrics Provider '${name}' is not registered.`);
}
if (name in this.providers) {
return this.providers[name];
}
this.providers[name] = Object.create(this.registry[name]);
this.providers[name].init(options);
return this.providers[name];
}
}
global.consul = new Consul();
}
)(window);

View File

@ -0,0 +1,684 @@
/*eslint no-console: "off"*/
(function () {
var prometheusProvider = {
options: {},
/**
* init is called when the provide is first loaded.
*
* options.providerOptions contains any operator configured parameters
* specified in the Consul agent config that is serving the UI.
*
* options.proxy.baseURL contains the base URL if the agent has a metrics
* proxy configured. If it doesn't options.proxy will be null. The provider
* should throw an Exception (TODO: specific type?) if it requires a metrics
* proxy and one is not configured.
*/
init: function(options) {
this.options = options;
},
/**
* serviceRecentSummarySeries should return time series for a recent time
* period summarizing the usage of the named service.
*
* If these metrics aren't available then empty series may be returned.
*
* The period may (later) be specified in options.startTime and
* options.endTime.
*
* The service's protocol must be given as one of Consul's supported
* protocols e.g. "tcp", "http", "http2", "grpc". If it is empty or the
* provider doesn't recognize it it should treat it as "tcp" and provide
* just basic connection stats.
*
* The expected return value is a promise which resolves to an object that
* should look like the following:
*
* {
* series: [
* {
* label: "Requests per second",
* data: [...]
* },
* ...
* ]
* }
*
* Each time series' data array is simple an array of tuples with the first
* being a Date object and the second a floating point value:
*
* [[Date(1600944516286), 1234.9], [Date(1600944526286), 1234.9], ...]
*/
serviceRecentSummarySeries: function(serviceName, protocol, options) {
// Fetch time-series
var series = []
var labels = []
// Set the start and end range here so that all queries end up with
// identical time axes. Later we might accept these as options.
var now = (new Date()).getTime()/1000;
options.start = now - (15*60);
options.end = now;
if (this.hasL7Metrics(protocol)) {
series.push(this.fetchRequestRateSeries(serviceName, options))
labels.push("Requests per second")
series.push(this.fetchErrorRateSeries(serviceName, options))
labels.push("Errors per second")
} else {
// Fallback to just L4 metrics.
series.push(this.fetchServiceRxSeries(serviceName, options))
labels.push("Data rate received")
series.push(this.fetchServiceTxSeries(serviceName, options))
labels.push("Data rate transmitted")
}
var all = Promise.allSettled(series).
then(function(results){
var data = { series: [] }
for (var i = 0; i < series.length; i++) {
if (results[i].value) {
data.series.push({
label: labels[i],
data: results[i].value
});
} else if (results[i].reason) {
console.log("ERROR processing series", labels[i], results[i].reason)
}
}
return data
})
// Fetch the metrics async, and return a promise to the result.
return all
},
/**
* serviceRecentSummaryStats should return four summary statistics for a
* recent time period for the named service.
*
* If these metrics aren't available then an empty array may be returned.
*
* The period may (later) be specified in options.startTime and
* options.endTime.
*
* The service's protocol must be given as one of Consul's supported
* protocols e.g. "tcp", "http", "http2", "grpc". If it is empty or the
* provider doesn't recognize it it should treat it as "tcp" and provide
* just basic connection stats.
*
* The expected return value is a promise which resolves to an object that
* should look like the following:
*
* {
* stats: [ // We expect four of these for now.
* {
* // label should be 3 chars or fewer as an abbreviation
* label: "SR",
* // desc describes the stat in a tooltip
* desc: "Success Rate - the percentage of all requests that were not 5xx status",
* // value is a string allowing the provider to format it and add
* // units as appropriate. It should be as compact as possible.
* value: "98%",
* }
* ]
* }
*/
serviceRecentSummaryStats: function(serviceName, protocol, options) {
// Fetch stats
var stats = [];
if (this.hasL7Metrics(protocol)) {
stats.push(this.fetchRPS(serviceName, "service", options))
stats.push(this.fetchER(serviceName, "service", options))
stats.push(this.fetchPercentile(50, serviceName, "service", options))
stats.push(this.fetchPercentile(99, serviceName, "service", options))
} else {
// Fallback to just L4 metrics.
stats.push(this.fetchConnRate(serviceName, "service", options))
stats.push(this.fetchServiceRx(serviceName, "service", options))
stats.push(this.fetchServiceTx(serviceName, "service", options))
stats.push(this.fetchServiceNoRoute(serviceName, "service", options))
}
return this.fetchStats(stats)
},
/**
* upstreamRecentSummaryStats should return four summary statistics for each
* upstream service over a recent time period.
*
* If these metrics aren't available then an empty array may be returned.
*
* The period may (later) be specified in options.startTime and
* options.endTime.
*
* The expected return value format is shown below:
*
* {
* stats: {
* // Each upstream will appear as an entry keyed by the upstream
* // service name. The value is an array of stats with the same
* // format as serviceRecentSummaryStats response.stats. Note that
* // different upstreams might show different stats depending on
* // their protocol.
* "upstream_name": [
* {label: "SR", desc: "...", value: "99%"},
* ...
* ],
* ...
* }
* }
*/
upstreamRecentSummaryStats: function(serviceName, upstreamName, options) {
return this.fetchRecentSummaryStats(serviceName, "upstream", options)
},
/**
* downstreamRecentSummaryStats should return four summary statistics for each
* downstream service over a recent time period.
*
* If these metrics aren't available then an empty array may be returned.
*
* The period may (later) be specified in options.startTime and
* options.endTime.
*
* The expected return value format is shown below:
*
* {
* stats: {
* // Each downstream will appear as an entry keyed by the downstream
* // service name. The value is an array of stats with the same
* // format as serviceRecentSummaryStats response.stats. Note that
* // different downstreams might show different stats depending on
* // their protocol.
* "downstream_name": [
* {label: "SR", desc: "...", value: "99%"},
* ...
* ],
* ...
* }
* }
*/
downstreamRecentSummaryStats: function(serviceName, options) {
return this.fetchRecentSummaryStats(serviceName, "downstream", options)
},
fetchRecentSummaryStats: function(serviceName, type, options) {
// Fetch stats
var stats = [];
// We don't know which upstreams are HTTP/TCP so just fetch all of them.
// HTTP
stats.push(this.fetchRPS(serviceName, type, options))
stats.push(this.fetchER(serviceName, type, options))
stats.push(this.fetchPercentile(50, serviceName, type, options))
stats.push(this.fetchPercentile(99, serviceName, type, options))
// L4
stats.push(this.fetchConnRate(serviceName, type, options))
stats.push(this.fetchServiceRx(serviceName, type, options))
stats.push(this.fetchServiceTx(serviceName, type, options))
stats.push(this.fetchServiceNoRoute(serviceName, type, options))
return this.fetchStatsGrouped(stats)
},
hasL7Metrics: function(protocol) {
return protocol === "http" || protocol === "http2" || protocol === "grpc"
},
fetchStats: function(statsPromises) {
var all = Promise.allSettled(statsPromises).
then(function(results){
var data = {
stats: []
}
// Add all non-empty stats
for (var i = 0; i < statsPromises.length; i++) {
if (results[i].value) {
data.stats.push(results[i].value);
} else if (results[i].reason) {
console.log("ERROR processing stat", results[i].reason)
}
}
return data
})
// Fetch the metrics async, and return a promise to the result.
return all
},
fetchStatsGrouped: function(statsPromises) {
var all = Promise.allSettled(statsPromises).
then(function(results){
var data = {
stats: {}
}
// Add all non-empty stats
for (var i = 0; i < statsPromises.length; i++) {
if (results[i].value) {
for (var group in results[i].value) {
if (!results[i].value.hasOwnProperty(group)) continue;
if (!data.stats[group]) {
data.stats[group] = []
}
data.stats[group].push(results[i].value[group])
}
} else if (results[i].reason) {
console.log("ERROR processing stat", results[i].reason)
}
}
return data
})
// Fetch the metrics async, and return a promise to the result.
return all
},
reformatSeries: function(response) {
// Handle empty results from prometheus.
if (!response || !response.data || !response.data.result
|| response.data.result.length < 1) {
return [];
}
// Reformat the prometheus data to be the format we want which is
// essentially the same but with Date objects instead of unix timestamps.
return response.data.result[0].values.map(function(val){
return [new Date(val[0]*1000), parseFloat(val[1])]
})
},
fetchRequestRateSeries: function(serviceName, options){
var q = `sum(irate(envoy_listener_http_downstream_rq_xx{local_cluster="${serviceName}",envoy_http_conn_manager_prefix="public_listener_http"}[10m]))`
return this.fetchSeries(q, options).then(this.reformatSeries, function(xhr){
// Failure. log to console and return an blank result for now.
console.log("ERROR: failed to fetch requestRate", xhr.responseText)
return []
})
},
fetchErrorRateSeries: function(serviceName, options){
// 100 * to get a result in percent
var q = `sum(`+
`irate(envoy_listener_http_downstream_rq_xx{`+
`local_cluster="${serviceName}",`+
`envoy_http_conn_manager_prefix="public_listener_http",`+
`envoy_response_code_class="5"}[10m]`+
`)`+
`)`;
return this.fetchSeries(q, options).then(this.reformatSeries, function(xhr){
// Failure. log to console and return an blank result for now.
console.log("ERROR: failed to fetch errorRate", xhr.responseText)
return []
})
},
fetchServiceRxSeries: function(serviceName, options){
var q = `8 * sum(irate(envoy_tcp_downstream_cx_rx_bytes_total{local_cluster="${serviceName}", envoy_tcp_prefix="public_listener_tcp"}[10m]))`
return this.fetchSeries(q, options).then(this.reformatSeries, function(xhr){
// Failure. log to console and return an blank result for now.
console.log("ERROR: failed to fetch rx data rate", xhr.responseText)
return []
})
},
fetchServiceTxSeries: function(serviceName, options){
var q = `8 * sum(irate(envoy_tcp_downstream_cx_tx_bytes_total{local_cluster="${serviceName}", envoy_tcp_prefix="public_listener_tcp"}[10m]))`
return this.fetchSeries(q, options).then(this.reformatSeries, function(xhr){
// Failure. log to console and return an blank result for now.
console.log("ERROR: failed to fetch tx data rate", xhr.responseText)
return []
})
},
makeSubject: function(serviceName, type) {
if (type == "upstream") {
// {{GROUP}} is a placeholder that is replaced by the upstream name
return `${serviceName} &rarr; {{GROUP}}`;
}
if (type == "downstream") {
// {{GROUP}} is a placeholder that is replaced by the downstream name
return `{{GROUP}} &rarr; ${serviceName}`;
}
return serviceName
},
makeHTTPSelector: function(serviceName, type) {
// Downstreams are totally different
if (type == "downstream") {
return `consul_service="${serviceName}"`
}
var lc = `local_cluster="${serviceName}"`
if (type == "upstream") {
lc += `,envoy_http_conn_manager_prefix=~"upstream_.*"`;
} else {
// Only care about inbound public listener
lc += `,envoy_http_conn_manager_prefix="public_listener_http"`
}
return lc
},
makeTCPSelector: function(serviceName, type) {
// Downstreams are totally different
if (type == "downstream") {
return `consul_service="${serviceName}"`
}
var lc = `local_cluster="${serviceName}"`
if (type == "upstream") {
lc += `,envoy_tcp_prefix=~"upstream_.*"`;
} else {
// Only care about inbound public listener
lc += `,envoy_tcp_prefix="public_listener_tcp"`
}
return lc
},
groupQueryHTTP: function(type, q) {
if (type == "upstream") {
q += " by (envoy_http_conn_manager_prefix)"
// Extract the raw upstream service name to group results by
q = this.upstreamRelabelQueryHTTP(q)
} else if (type == "downstream") {
q += " by (local_cluster)"
q = this.downstreamRelabelQuery(q)
}
return q
},
groupQueryTCP: function(type, q) {
if (type == "upstream") {
q += " by (envoy_tcp_prefix)"
// Extract the raw upstream service name to group results by
q = this.upstreamRelabelQueryTCP(q)
} else if (type == "downstream") {
q += " by (local_cluster)"
q = this.downstreamRelabelQuery(q)
}
return q
},
upstreamRelabelQueryHTTP: function(q) {
return `label_replace(${q}, "upstream", "$1", "envoy_http_conn_manager_prefix", "upstream_(.*)_http")`
},
upstreamRelabelQueryTCP: function(q) {
return `label_replace(${q}, "upstream", "$1", "envoy_tcp_prefix", "upstream_(.*)_tcp")`
},
downstreamRelabelQuery: function(q) {
return `label_replace(${q}, "downstream", "$1", "local_cluster", "(.*)")`
},
groupBy: function(type) {
if (type == "service") {
return false
}
return type;
},
metricPrefixHTTP: function(type) {
if (type == "downstream") {
return "envoy_cluster_upstream_rq"
}
return "envoy_http_downstream_rq";
},
metricPrefixTCP: function(type) {
if (type == "downstream") {
return "envoy_cluster_upstream_cx"
}
return "envoy_tcp_downstream_cx";
},
fetchRPS: function(serviceName, type, options){
var sel = this.makeHTTPSelector(serviceName, type)
var subject = this.makeSubject(serviceName, type)
var metricPfx = this.metricPrefixHTTP(type)
var q = `sum(rate(${metricPfx}_completed{${sel}}[15m]))`
return this.fetchStat(this.groupQueryHTTP(type, q),
"RPS",
`<b>${subject}</b> request rate averaged over the last 15 minutes`,
shortNumStr,
this.groupBy(type)
)
},
fetchER: function(serviceName, type, options){
var sel = this.makeHTTPSelector(serviceName, type)
var subject = this.makeSubject(serviceName, type)
var groupBy = ""
if (type == "upstream") {
groupBy += " by (envoy_http_conn_manager_prefix)"
} else if (type == "downstream") {
groupBy += " by (local_cluster)"
}
var metricPfx = this.metricPrefixHTTP(type)
var q = `sum(rate(${metricPfx}_xx{${sel},envoy_response_code_class="5"}[15m]))${groupBy}/sum(rate(${metricPfx}_xx{${sel}}[15m]))${groupBy}`
if (type == "upstream") {
q = this.upstreamRelabelQueryHTTP(q)
} else if (type == "downstream") {
q = this.downstreamRelabelQuery(q)
}
return this.fetchStat(q,
"ER",
`Percentage of <b>${subject}</b> requests which were 5xx status over the last 15 minutes`,
function(val){
return shortNumStr(val)+"%"
},
this.groupBy(type)
)
},
fetchPercentile: function(percentile, serviceName, type, options){
var sel = this.makeHTTPSelector(serviceName, type)
var subject = this.makeSubject(serviceName, type)
var groupBy = "le"
if (type == "upstream") {
groupBy += ",envoy_http_conn_manager_prefix"
} else if (type == "downstream") {
groupBy += ",local_cluster"
}
var metricPfx = this.metricPrefixHTTP(type)
var q = `histogram_quantile(${percentile/100}, sum by(${groupBy}) (rate(${metricPfx}_time_bucket{${sel}}[15m])))`
if (type == "upstream") {
q = this.upstreamRelabelQueryHTTP(q)
} else if (type == "downstream") {
q = this.downstreamRelabelQuery(q)
}
return this.fetchStat(q,
`P${percentile}`,
`<b>${subject}</b> ${percentile}th percentile request service time over the last 15 minutes`,
shortTimeStr,
this.groupBy(type)
)
},
fetchConnRate: function(serviceName, type, options) {
var sel = this.makeTCPSelector(serviceName, type)
var subject = this.makeSubject(serviceName, type)
var metricPfx = this.metricPrefixTCP(type)
var q = `sum(rate(${metricPfx}_total{${sel}}[15m]))`
return this.fetchStat(this.groupQueryTCP(type, q),
"CR",
`<b>${subject}</b> inbound TCP connections per second averaged over the last 15 minutes`,
shortNumStr,
this.groupBy(type)
)
},
fetchServiceRx: function(serviceName, type, options) {
var sel = this.makeTCPSelector(serviceName, type)
var subject = this.makeSubject(serviceName, type)
var metricPfx = this.metricPrefixTCP(type)
var q = `8 * sum(rate(${metricPfx}_rx_bytes_total{${sel}}[15m]))`
return this.fetchStat(this.groupQueryTCP(type, q),
"RX",
`<b>${subject}</b> received bits per second averaged over the last 15 minutes`,
shortNumStr,
this.groupBy(type)
)
},
fetchServiceTx: function(serviceName, type, options) {
var sel = this.makeTCPSelector(serviceName, type)
var subject = this.makeSubject(serviceName, type)
var metricPfx = this.metricPrefixTCP(type)
var q = `8 * sum(rate(${metricPfx}_tx_bytes_total{${sel}}[15m]))`
var self = this
return this.fetchStat(this.groupQueryTCP(type, q),
"TX",
`<b>${subject}</b> transmitted bits per second averaged over the last 15 minutes`,
shortNumStr,
this.groupBy(type)
)
},
fetchServiceNoRoute: function(serviceName, type, options) {
var sel = this.makeTCPSelector(serviceName, type)
var subject = this.makeSubject(serviceName, type)
var metricPfx = this.metricPrefixTCP(type)
var metric = "_no_route"
if (type == "downstream") {
metric = "_connect_fail"
}
var q = `sum(rate(${metricPfx}${metric}{${sel}}[15m]))`
return this.fetchStat(this.groupQueryTCP(type, q),
"NR",
`<b>${subject}</b> unroutable (failed) connections per second averaged over the last 15 minutes`,
shortNumStr,
this.groupBy(type)
)
},
fetchStat: function(promql, label, desc, formatter, groupBy) {
if (!groupBy) {
// If we don't have a grouped result and its just a single stat, return
// no result as a zero not a missing stat.
promql += " OR on() vector(0)";
}
//console.log(promql)
var params = {
query: promql,
time: (new Date).getTime()/1000
}
return this.httpGet("/api/v1/query", params).then(function(response){
if (!groupBy) {
// Not grouped, expect just one stat value return that
var v = parseFloat(response.data.result[0].value[1])
return {
label: label,
desc: desc,
value: formatter(v)
}
}
var data = {};
for (var i = 0; i < response.data.result.length; i++) {
var res = response.data.result[i];
var v = parseFloat(res.value[1]);
var groupName = res.metric[groupBy];
data[groupName] = {
label: label,
desc: desc.replace('{{GROUP}}', groupName),
value: formatter(v)
}
}
return data;
}, function(xhr){
// Failure. log to console and return an blank result for now.
console.log("ERROR: failed to fetch stat", label, xhr.responseText)
return {}
})
},
fetchSeries: function(promql, options) {
var params = {
query: promql,
start: options.start,
end: options.end,
step: "10s",
timeout: "8s"
}
return this.httpGet("/api/v1/query_range", params)
},
httpGet: function(path, params) {
var xhr = new XMLHttpRequest();
var self = this
return new Promise(function(resolve, reject){
xhr.onreadystatechange = function(){
if (xhr.readyState !== 4) return;
if (xhr.status == 200) {
// Attempt to parse response as JSON and return the object
var o = JSON.parse(xhr.responseText)
resolve(o)
}
reject(xhr)
}
var url = self.baseURL()+path;
if (params) {
var qs = Object.keys(params).
map(function(key){
return encodeURIComponent(key)+"="+encodeURIComponent(params[key])
}).
join("&")
url = url+"?"+qs
}
xhr.open("GET", url, true);
xhr.send();
});
},
baseURL: function() {
// TODO support configuring a direct Prometheus via
// metrics_provider_options_json.
return "/v1/internal/ui/metrics-proxy"
}
}
// Helper functions
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";
}
function shortTimeStr(n) {
if (n < 1e3) return Math.round(n) + "ms";
var secs = n / 1e3
if (secs < 60) return secs.toFixed(1) + "s"
var mins = secs/60
if (mins < 60) return mins.toFixed(1) + "m"
var hours = mins/60
if (hours < 24) return hours.toFixed(1) + "h"
var days = hours/24
return days.toFixed(1) + "d"
}
/* global consul:writable */
window.consul.registerMetricsProvider("prometheus", prometheusProvider)
}());

View File

@ -1527,10 +1527,10 @@
faker "^4.1.0"
js-yaml "^3.13.1"
"@hashicorp/consul-api-double@^5.3.5":
version "5.3.5"
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-5.3.5.tgz#8e39d6af4ab6d32c7d8c469bb4aab23e16971bd3"
integrity sha512-SiT2lLk0J8CwsxtuAobrweC5VdOT6b66M1gSLcT/Lcx62fOLH1X/DfMt6F2VKwC4BN8WBFZGTmn0rwdFOjKpmw==
"@hashicorp/consul-api-double@^5.3.7":
version "5.4.0"
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-5.4.0.tgz#fc75e064c3e50385f4fb8c5dd9068875806d8901"
integrity sha512-vAi580MyPoFhjDl8WhSviMzFJ1/PZesLqYCuGy8vuxqFaKCQET4AR8gRuungWSdRf5432aJXUNtXLhMHdJeNPg==
"@hashicorp/ember-cli-api-double@^3.1.0":
version "3.1.2"
@ -4513,6 +4513,11 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
dependencies:
delayed-stream "~1.0.0"
commander@2, commander@^2.20.0, commander@^2.6.0:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@2.12.2:
version "2.12.2"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555"
@ -4525,11 +4530,6 @@ commander@2.8.x:
dependencies:
graceful-readlink ">= 1.0.0"
commander@^2.20.0, commander@^2.6.0:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
@ -4890,6 +4890,262 @@ cyclist@^1.0.1:
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0:
version "1.2.4"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
d3-axis@1:
version "1.0.12"
resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9"
integrity sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==
d3-brush@1:
version "1.1.6"
resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-1.1.6.tgz#b0a22c7372cabec128bdddf9bddc058592f89e9b"
integrity sha512-7RW+w7HfMCPyZLifTz/UnJmI5kdkXtpCbombUSs8xniAyo0vIbrDzDwUJB6eJOgl9u5DQOt2TQlYumxzD1SvYA==
dependencies:
d3-dispatch "1"
d3-drag "1"
d3-interpolate "1"
d3-selection "1"
d3-transition "1"
d3-chord@1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-1.0.6.tgz#309157e3f2db2c752f0280fedd35f2067ccbb15f"
integrity sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==
dependencies:
d3-array "1"
d3-path "1"
d3-collection@1:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e"
integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==
d3-color@1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a"
integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==
d3-contour@1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-1.3.2.tgz#652aacd500d2264cb3423cee10db69f6f59bead3"
integrity sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==
dependencies:
d3-array "^1.1.1"
d3-dispatch@1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58"
integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==
d3-drag@1:
version "1.2.5"
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-1.2.5.tgz#2537f451acd39d31406677b7dc77c82f7d988f70"
integrity sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==
dependencies:
d3-dispatch "1"
d3-selection "1"
d3-dsv@1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-1.2.0.tgz#9d5f75c3a5f8abd611f74d3f5847b0d4338b885c"
integrity sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==
dependencies:
commander "2"
iconv-lite "0.4"
rw "1"
d3-ease@1:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.7.tgz#9a834890ef8b8ae8c558b2fe55bd57f5993b85e2"
integrity sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==
d3-fetch@1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-1.2.0.tgz#15ce2ecfc41b092b1db50abd2c552c2316cf7fc7"
integrity sha512-yC78NBVcd2zFAyR/HnUiBS7Lf6inSCoWcSxFfw8FYL7ydiqe80SazNwoffcqOfs95XaLo7yebsmQqDKSsXUtvA==
dependencies:
d3-dsv "1"
d3-force@1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-1.2.1.tgz#fd29a5d1ff181c9e7f0669e4bd72bdb0e914ec0b"
integrity sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==
dependencies:
d3-collection "1"
d3-dispatch "1"
d3-quadtree "1"
d3-timer "1"
d3-format@1:
version "1.4.5"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4"
integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==
d3-geo@1:
version "1.12.1"
resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.12.1.tgz#7fc2ab7414b72e59fbcbd603e80d9adc029b035f"
integrity sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==
dependencies:
d3-array "1"
d3-hierarchy@1:
version "1.1.9"
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
d3-interpolate@1:
version "1.4.0"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987"
integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==
dependencies:
d3-color "1"
d3-path@1:
version "1.0.9"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
d3-polygon@1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
d3-quadtree@1:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-1.0.7.tgz#ca8b84df7bb53763fe3c2f24bd435137f4e53135"
integrity sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==
d3-random@1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-1.1.2.tgz#2833be7c124360bf9e2d3fd4f33847cfe6cab291"
integrity sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==
d3-scale-chromatic@1:
version "1.5.0"
resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz#54e333fc78212f439b14641fb55801dd81135a98"
integrity sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==
dependencies:
d3-color "1"
d3-interpolate "1"
d3-scale@2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f"
integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==
dependencies:
d3-array "^1.2.0"
d3-collection "1"
d3-format "1"
d3-interpolate "1"
d3-time "1"
d3-time-format "2"
d3-selection-multi@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/d3-selection-multi/-/d3-selection-multi-1.0.1.tgz#cd6c25413d04a2cb97470e786f2cd877f3e34f58"
integrity sha1-zWwlQT0EosuXRw54byzYd/PjT1g=
dependencies:
d3-selection "1"
d3-transition "1"
d3-selection@1, d3-selection@^1.1.0:
version "1.4.2"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.2.tgz#dcaa49522c0dbf32d6c1858afc26b6094555bc5c"
integrity sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==
d3-shape@1:
version "1.3.7"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7"
integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==
dependencies:
d3-path "1"
d3-time-format@2:
version "2.3.0"
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.3.0.tgz#107bdc028667788a8924ba040faf1fbccd5a7850"
integrity sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==
dependencies:
d3-time "1"
d3-time@1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1"
integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==
d3-timer@1:
version "1.0.10"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5"
integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==
d3-transition@1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.3.2.tgz#a98ef2151be8d8600543434c1ca80140ae23b398"
integrity sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==
dependencies:
d3-color "1"
d3-dispatch "1"
d3-ease "1"
d3-interpolate "1"
d3-selection "^1.1.0"
d3-timer "1"
d3-voronoi@1:
version "1.1.4"
resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.4.tgz#dd3c78d7653d2bb359284ae478645d95944c8297"
integrity sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==
d3-zoom@1:
version "1.8.3"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-1.8.3.tgz#b6a3dbe738c7763121cd05b8a7795ffe17f4fc0a"
integrity sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==
dependencies:
d3-dispatch "1"
d3-drag "1"
d3-interpolate "1"
d3-selection "1"
d3-transition "1"
d3@^5.0.0:
version "5.16.0"
resolved "https://registry.yarnpkg.com/d3/-/d3-5.16.0.tgz#9c5e8d3b56403c79d4ed42fbd62f6113f199c877"
integrity sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==
dependencies:
d3-array "1"
d3-axis "1"
d3-brush "1"
d3-chord "1"
d3-collection "1"
d3-color "1"
d3-contour "1"
d3-dispatch "1"
d3-drag "1"
d3-dsv "1"
d3-ease "1"
d3-fetch "1"
d3-force "1"
d3-format "1"
d3-geo "1"
d3-hierarchy "1"
d3-interpolate "1"
d3-path "1"
d3-polygon "1"
d3-quadtree "1"
d3-random "1"
d3-scale "2"
d3-scale-chromatic "1"
d3-selection "1"
d3-shape "1"
d3-time "1"
d3-time-format "2"
d3-timer "1"
d3-transition "1"
d3-voronoi "1"
d3-zoom "1"
dag-map@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/dag-map/-/dag-map-2.0.2.tgz#9714b472de82a1843de2fba9b6876938cab44c68"
@ -4918,6 +5174,11 @@ data-urls@^1.0.1:
whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0"
dayjs@^1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.9.1.tgz#201a755f7db5103ed6de63ba93a984141c754541"
integrity sha512-01NCTBg8cuMJG1OQc6PR7T66+AFYiPwgDvdJmvJBn29NGzIG+DIFxPLNjHzwz3cpFIvG+NcwIjP9hSaPVoOaDg==
debug@2.6.9, debug@^2.1.0, debug@^2.1.1, debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@ -5977,6 +6238,17 @@ ember-copy@1.0.0, ember-copy@^1.0.0:
dependencies:
ember-cli-babel "^6.6.0"
ember-d3@^0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/ember-d3/-/ember-d3-0.5.1.tgz#b23ce145863f082b5e73d25d9a43a0f1d9e9f412"
integrity sha512-NyjTUuIOxGxZdyrxLasNwwjqyFgay1pVHGRAWFj7mriwTI44muKsM9ZMl6YeepqixceuFig2fDxHmLLrkQV+QQ==
dependencies:
broccoli-funnel "^2.0.0"
broccoli-merge-trees "^3.0.0"
d3 "^5.0.0"
d3-selection-multi "^1.0.1"
ember-cli-babel "^7.1.2"
ember-data-model-fragments@5.0.0-beta.0:
version "5.0.0-beta.0"
resolved "https://registry.yarnpkg.com/ember-data-model-fragments/-/ember-data-model-fragments-5.0.0-beta.0.tgz#da90799970317ca852f96b2ea1548ca70094a5bb"
@ -8109,7 +8381,7 @@ husky@^4.2.5:
slash "^3.0.0"
which-pm-runs "^1.0.0"
iconv-lite@0.4.24, iconv-lite@^0.4.24:
iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@ -11472,6 +11744,11 @@ run-queue@^1.0.0, run-queue@^1.0.3:
dependencies:
aproba "^1.1.1"
rw@1:
version "1.3.3"
resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
integrity sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=
rxjs@^6.4.0, rxjs@^6.5.5, rxjs@^6.6.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.0.tgz#af2901eedf02e3a83ffa7f886240ff9018bbec84"