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:
parent
097b47a741
commit
40695d5919
File diff suppressed because one or more lines are too long
|
@ -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>
|
|
@ -1,4 +0,0 @@
|
|||
import Component from '@ember/component';
|
||||
import Slotted from 'block-slots';
|
||||
|
||||
export default Component.extend(Slotted, {});
|
|
@ -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}}
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
@import './skin';
|
||||
@import './layout';
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ export default Model.extend({
|
|||
Namespace: attr('string'),
|
||||
Upstreams: attr(),
|
||||
Downstreams: attr(),
|
||||
Protocol: attr(),
|
||||
meta: attr(),
|
||||
Exists: computed(function() {
|
||||
return true;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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>` : ``}
|
||||
`;
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
@ -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} → {{GROUP}}`;
|
||||
}
|
||||
if (type == "downstream") {
|
||||
// {{GROUP}} is a placeholder that is replaced by the downstream name
|
||||
return `{{GROUP}} → ${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)
|
||||
|
||||
}());
|
297
ui-v2/yarn.lock
297
ui-v2/yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue