Add HTTP Request Volume page (#6925)

* Add http request volume table (#6765)

* init http metrics page

* remove flex-table-column

* add http requests table

* calculate percent change between each counter

* start percent change tests

* style request table

* show percent more/less glyph

* add percent more less tests

* add inline alert about recorded metrics

* make arrows diagonal

* remove conditional inside countersWithChange

* add better error msg

* use tagName and wrapping element a la glimmer components

* extend ClusterRouteBase so auth and seal checks happen

* make table accessible

* remove curlies

* add HttpRequestsTable to storybook

* make table accessible

* use qunit dom for better assertions

* remove EmptyState since we will never have 0 requests

* ensure counters is set in test context

* Http request volume/add barchart (#6814)

* Add http request volume table (#6765)

* init http metrics page

* remove flex-table-column

* add http requests table

* calculate percent change between each counter

* start percent change tests

* style request table

* show percent more/less glyph

* add percent more less tests

* add inline alert about recorded metrics

* make arrows diagonal

* remove conditional inside countersWithChange

* add better error msg

* use tagName and wrapping element a la glimmer components

* extend ClusterRouteBase so auth and seal checks happen

* make table accessible

* remove curlies

* add HttpRequestsTable to storybook

* make table accessible

* use qunit dom for better assertions

* remove EmptyState since we will never have 0 requests

* ensure counters is set in test context

* add http-requests-bar-chart

* add HttpRequestsBarChart tests

* add HttpRequestsBarChart to Storybook

* format total number of requests according to locale

* do not show extra minus sign when percent change is negative

* add link to request metrics in status bar menu

* only show bar chart if we have data for more than 1 month

* make ticks lighter

* ensure charts show data for correct month

* make example counters response look like the adapter response instead of the raw api response

* ensure ui shows the same utc date as the api response

* add format-utc tests

* downgrade to d3 v4 to support ie11

* add gridlines

* move dasharray to css

* use scheduleOnce instead of debounce to prevent multiple re-renders

* add key function to bars

* add exit case when data is no longer in parsedCounters

* fix timestamp in table test

* fix timestamps

* use utcParse and fallback to isoParse for non-UTC dates

* fix bar chart tests
This commit is contained in:
Noelle Daley 2019-06-19 16:14:25 -07:00 committed by GitHub
parent 5309609758
commit 4fd783d3f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 739 additions and 21 deletions

View File

@ -0,0 +1,14 @@
import Application from './application';
export default Application.extend({
queryRecord() {
return this.ajax(this.urlForQuery(), 'GET').then(resp => {
resp.id = resp.request_id;
return resp;
});
},
urlForQuery() {
return this.buildURL() + '/internal/counters/requests';
},
});

View File

@ -1,7 +0,0 @@
import Component from '@ember/component';
export default Component.extend({
classNames: 'column',
header: null,
content: null,
});

View File

@ -0,0 +1,161 @@
import Component from '@ember/component';
import d3 from 'd3-selection';
import d3Scale from 'd3-scale';
import d3Axis from 'd3-axis';
import d3TimeFormat from 'd3-time-format';
import { assign } from '@ember/polyfills';
import { computed } from '@ember/object';
import { run, debounce } from '@ember/runloop';
import { task, waitForEvent } from 'ember-concurrency';
/**
* @module HttpRequestsBarChart
* HttpRequestsBarChart components are used to render a bar chart with the total number of HTTP Requests to a Vault server per month.
*
* @example
* ```js
* <HttpRequestsBarChart @counters={{counters}} />
* ```
*
* @param counters=null {Array} - A list of objects containing the total number of HTTP Requests for each month. `counters` should be the response from the `/internal/counters/requests` endpoint which looks like:
* COUNTERS = [
* {
* "start_time": "2019-05-01T00:00:00Z",
* "total": 50
* }
* ]
*/
const HEIGHT = 240;
export default Component.extend({
classNames: ['http-requests-bar-chart-container'],
counters: null,
margin: { top: 24, right: 16, bottom: 24, left: 16 },
padding: 0.04,
width: 0,
height() {
const { margin } = this;
return HEIGHT - margin.top - margin.bottom;
},
parsedCounters: computed('counters', function() {
// parse the start times so bars and ticks display properly
const { counters } = this;
return counters.map(counter => {
return assign({}, counter, { start_time: d3TimeFormat.isoParse(counter.start_time) });
});
}),
yScale: computed('parsedCounters', 'height', function() {
const { parsedCounters } = this;
const height = this.height();
const counterTotals = parsedCounters.map(c => c.total);
return d3Scale
.scaleLinear()
.domain([0, Math.max(...counterTotals)])
.range([height, 0]);
}),
xScale: computed('parsedCounters', 'width', function() {
const { parsedCounters, width, margin, padding } = this;
return d3Scale
.scaleBand()
.domain(parsedCounters.map(c => c.start_time))
.rangeRound([0, width - margin.left - margin.right], 0.05)
.paddingInner(padding)
.paddingOuter(padding);
}),
didInsertElement() {
this._super(...arguments);
const { margin } = this;
// set the width after the element has been rendered because the chart axes depend on it.
// this helps us avoid an arbitrary hardcoded width which causes alignment & resizing problems.
run.schedule('afterRender', this, () => {
this.set('width', this.element.clientWidth - margin.left - margin.right);
this.renderBarChart();
});
},
renderBarChart() {
const { margin, width, xScale, yScale, parsedCounters } = this;
const height = this.height();
const barChartSVG = d3.select('.http-requests-bar-chart');
const barsContainer = d3.select('#bars-container');
// render the chart
d3.select('.http-requests-bar-chart')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.attr('viewBox', `0 0 ${width} ${height}`);
// scale and render the axes
const yAxis = d3Axis
.axisRight(yScale)
.ticks(3, '.0s')
.tickSizeOuter(0);
const xAxis = d3Axis
.axisBottom(xScale)
.tickFormat(d3TimeFormat.utcFormat('%b %Y'))
.tickSizeOuter(0);
barChartSVG
.select('g.x-axis')
.attr('transform', `translate(0,${height})`)
.call(xAxis);
barChartSVG
.select('g.y-axis')
.attr('transform', `translate(${width - margin.left - margin.right}, 0)`)
.call(yAxis);
// render the gridlines
const gridlines = d3Axis
.axisRight(yScale)
.ticks(3)
.tickFormat('')
.tickSize(width - margin.left - margin.right);
barChartSVG.select('.gridlines').call(gridlines);
// render the bars
const bars = barsContainer.selectAll('.bar').data(parsedCounters, c => +c.start_time);
bars.exit().remove();
const barsEnter = bars
.enter()
.append('rect')
.attr('class', 'bar');
bars
.merge(barsEnter)
.attr('width', xScale.bandwidth())
.attr('height', counter => height - yScale(counter.total))
// the offset between each bar
.attr('x', counter => xScale(counter.start_time))
.attr('y', counter => yScale(counter.total));
},
updateDimensions() {
const newWidth = this.element.clientWidth;
const { margin } = this;
this.set('width', newWidth - margin.left - margin.right);
this.renderBarChart();
},
waitForResize: task(function*() {
while (true) {
yield waitForEvent(window, 'resize');
run.scheduleOnce('afterRender', this, 'updateDimensions');
}
})
.on('didInsertElement')
.cancelOn('willDestroyElement')
.drop(),
});

View File

@ -0,0 +1,51 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { assign } from '@ember/polyfills';
/**
* @module HttpRequestsTable
* `HttpRequestsTable` components render a table with the total number of HTTP Requests to a Vault server per month.
*
* @example
* ```js
* <HttpRequestsTable @counters={{counters}} />
* ```
*
* @param counters=null {Array} - A list of objects containing the total number of HTTP Requests for each month. `counters` should be the response from the `/internal/counters/requests` endpoint which looks like:
* COUNTERS = [
* {
* "start_time": "2019-05-01T00:00:00Z",
* "total": 50
* }
* ]
*/
export default Component.extend({
tagName: '',
counters: null,
countersWithChange: computed('counters', function() {
let counters = this.counters || [];
let countersWithPercentChange = [];
let previousMonthVal;
counters.forEach(month => {
if (previousMonthVal) {
let percentChange = (((month.total - previousMonthVal) / month.total) * 100).toFixed(1);
let glyph;
if (percentChange > 0) {
glyph = 'arrow-up';
} else if (percentChange < 0) {
glyph = 'arrow-down';
}
percentChange = Math.abs(percentChange);
const newCounter = assign({ percentChange, glyph }, month);
countersWithPercentChange.push(newCounter);
} else {
// we're looking at the first counter in the list, so there is no % change value.
countersWithPercentChange.push(month);
previousMonthVal = month.total;
}
});
return countersWithPercentChange;
}),
});

View File

@ -0,0 +1,8 @@
import { helper } from '@ember/component/helper';
export function formatNumber([number]) {
// formats a number according to the locale
return new Intl.NumberFormat().format(number);
}
export default helper(formatNumber);

View File

@ -0,0 +1,15 @@
import { helper } from '@ember/component/helper';
import d3 from 'd3-time-format';
export function formatUtc([date, specifier]) {
// given a date, format and display it as UTC.
const format = d3.utcFormat(specifier);
const parse = d3.utcParse('%Y-%m-%dT%H:%M:%SZ');
// if a date isn't already in UTC, fallback to isoParse to convert it to UTC
const parsedDate = parse(date) || d3.isoParse(date);
return format(parsedDate);
}
export default helper(formatUtc);

32
ui/app/models/requests.js Normal file
View File

@ -0,0 +1,32 @@
import DS from 'ember-data';
const { attr } = DS;
/* sample response
{
"request_id": "75cbaa46-e741-3eba-2be2-325b1ba8f03f",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"counters": [
{
"start_time": "2019-05-01T00:00:00Z",
"total": 50
},
{
"start_time": "2019-04-01T00:00:00Z",
"total": 45
}
]
},
"wrap_info": null,
"warnings": null,
"auth": null
}
*/
export default DS.Model.extend({
counters: attr('array'),
});

View File

@ -14,6 +14,7 @@ Router.map(function() {
this.route('init');
this.route('logout');
this.route('license');
this.route('requests', { path: '/metrics/requests' });
this.route('settings', function() {
this.route('index', { path: '/' });
this.route('seal');

View File

@ -0,0 +1,7 @@
import ClusterRouteBase from './cluster-route-base';
export default ClusterRouteBase.extend({
model() {
return this.store.queryRecord('requests', {});
},
});

View File

@ -28,6 +28,9 @@ const API_PATHS = {
license: 'sys/license',
seal: 'sys/seal',
},
metrics: {
requests: 'sys/internal/counters/requests',
},
};
const API_PATHS_TO_ROUTE_PARAMS = {

View File

@ -0,0 +1,39 @@
.http-requests-bar-chart-container {
margin-top: $spacing-s;
margin-bottom: $spacing-m;
display: flex;
}
.http-requests-bar-chart {
margin: auto;
overflow: inherit;
.tick {
line {
stroke: $light-grey;
}
text {
fill: $grey;
font-size: $size-8;
}
}
.gridlines {
.domain {
stroke: unset;
}
line {
stroke-dasharray: 5 5;
}
}
.x-axis,
.y-axis {
.domain,
line {
stroke: $grey-light;
}
}
}

View File

@ -0,0 +1,41 @@
.http-requests-table {
& .is-collapsed {
visibility: collapse;
}
& th,
td {
padding: $spacing-s;
}
& th {
color: $grey-dark;
font-weight: 500;
font-size: $size-8;
}
& tbody th {
font-size: $size-7;
}
& tr {
border-bottom: 1px solid $grey-light;
}
& td {
color: $grey-darkest;
}
& .percent-change {
font-weight: 500;
font-size: $size-7;
}
& .arrow-up {
transform: rotate(45deg);
}
& .arrow-down {
transform: rotate(-45deg);
}
}

View File

@ -55,6 +55,8 @@
@import './components/form-section';
@import './components/global-flash';
@import './components/hover-copy-button';
@import './components/http-requests-table';
@import './components/http-requests-bar-chart';
@import './components/init-illustration';
@import './components/info-table-row';
@import './components/input-hint';

View File

@ -1,14 +0,0 @@
<div class="thead">
<div class="th">
{{header}}
</div>
</div>
<div class="columns is-mobile">
<div class="column">
<div class="td">
<code>
{{content}}
</code>
</div>
</div>
</div>

View File

@ -0,0 +1,18 @@
{{! template-lint-disable no-inline-styles}}
<svg class="http-requests-bar-chart">
<defs>
<linearGradient id="bg-gradient" gradientTransform="rotate(90)">
<stop stop-color="#5b92ff" stop-opacity="1" offset="0%"></stop>
<stop stop-color="#5b92ff" stop-opacity="0.5" offset="100%"></stop>
</linearGradient>
</defs>
<g class="gridlines"></g>
<g class="x-axis"></g>
<g class="y-axis"></g>
<g clip-path="url(#bars-container)">
<rect x="0" y="0" width="100%" height="100%" style="fill: url(#bg-gradient)"></rect>
<clipPath id="bars-container">
</clipPath>
</g>
</svg>

View File

@ -0,0 +1,32 @@
<div class="http-requests-table">
<table class="is-fullwidth">
<caption class="is-collapsed">HTTP Request Volume</caption>
<thead class="has-text-weight-semibold">
<tr>
<th scope="col">Month</th>
<th scope="col">Requests</th>
{{#if (gt counters.length 1)}}
<th scope="col">Change</th>
{{/if}}
</tr>
</thead>
<tbody>
{{#each (reverse countersWithChange) as |c|}}
<tr>
<th scope="row" class="has-text-black">{{ format-utc c.start_time '%B %Y' }}</th>
<td>{{format-number c.total}}</td>
{{#if (gt counters.length 1)}}
<td class="has-text-grey-dark percent-change" data-test-change="{{c.percentChange}}">
{{#if c.percentChange}}
<Icon @glyph={{c.glyph}} class={{c.glyph}}/>
{{c.percentChange}}%
{{else}}
<Icon @glyph="minus-plain" />
{{/if}}
</td>
{{/if}}
</tr>
{{/each}}
</tbody>
</table>
</div>

View File

@ -116,5 +116,25 @@
</li>
</ul>
</nav>
{{#if (has-permission 'metrics' routeParams='requests')}}
<hr />
<nav class="menu">
<div class="menu-label">
Metrics
</div>
<ul class="menu-list">
<li class="action">
{{#if activeCluster.unsealed}}
{{#link-to "vault.cluster.requests"}}
<div class="level is-mobile">
<span class="level-left">HTTP Requests</span>
<Chevron class="has-text-grey-light level-right" />
</div>
{{/link-to}}
{{/if}}
</li>
</ul>
</nav>
{{/if}}
</div>
</div>

View File

@ -0,0 +1,13 @@
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
HTTP Request Volume
</h1>
</p.levelLeft>
</PageHeader>
{{#if (gt model.counters.length 1) }}
<HttpRequestsBarChart @counters={{model.counters}}/>
{{/if}}
<AlertInline @type="info" @message="Metrics are recorded only for interactions that produce or use a Vault token." />
<HttpRequestsTable @counters={{model.counters}} />

View File

@ -54,6 +54,10 @@
"codemirror": "5.15.2",
"columnify": "^1.5.4",
"cool-checkboxes-for-bulma.io": "^1.1.0",
"d3-axis": "^1.0.8",
"d3-scale": "^1.0.7",
"d3-selection": "^1.3.0",
"d3-time-format": "^2.1.1",
"date-fns": "^1.29.0",
"deepmerge": "^2.1.1",
"doctoc": "^1.4.0",

View File

@ -0,0 +1,22 @@
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in app/components/http-requests-bar-chart.js. To make changes, first edit that file and run "yarn gen-story-md http-requests-bar-chart" to re-generate the content.-->
## HttpRequestsBarChart
`HttpRequestsBarChart` components are used to render a bar chart with the total number of HTTP Requests to a Vault server per month.
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| counters | <code>Array</code> | <code></code> | A list of objects containing the total number of HTTP Requests for each month. `counters` should be the response from the `/internal/counters/requests` endpoint. |
**Example**
```js
<HttpRequestsBarChart @counters={{counters}} />
```
**See**
- [Uses of HttpRequestsBarChart](https://github.com/hashicorp/vault/search?l=Handlebars&q=HttpRequestsBarChart+OR+http-requests-bar-chart)
- [HttpRequestsBarChart Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/http-requests-bar-chart.js)
---

View File

@ -0,0 +1,28 @@
/* eslint-disable import/extensions */
import hbs from 'htmlbars-inline-precompile';
import { storiesOf } from '@storybook/ember';
import { withKnobs, object } from '@storybook/addon-knobs';
import notes from './http-requests-bar-chart.md';
const COUNTERS = [
{ start_time: '2019-04-01T00:00:00Z', total: 5500 },
{ start_time: '2019-05-01T00:00:00Z', total: 4500 },
{ start_time: '2019-06-01T00:00:00Z', total: 5000 },
];
storiesOf('HttpRequests/BarChart/', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(
withKnobs()
)
.add(`HttpRequestsBarChart`, () => ({
template: hbs`
<h5 class="title is-5">Http Requests Bar Chart</h5>
<HttpRequestsBarChart @counters={{counters}}/>
`,
context: {
counters: object('counters', COUNTERS)
},
}),
{notes}
);

View File

@ -0,0 +1,22 @@
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in app/components/http-requests-table.js. To make changes, first edit that file and run "yarn gen-story-md http-requests-table" to re-generate the content.-->
## HttpRequestsTable
`HttpRequestsTable` components render a table with the total number of HTTP Requests to a Vault server per month.
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| counters | <code>Array</code> | <code></code> | A list of objects containing the total number of HTTP Requests for each month. `counters` should be the response from the `/internal/counters/requests` endpoint. |
**Example**
```js
<HttpRequestsTable @counters={{counters}} />
```
**See**
- [Uses of HttpRequestsTable](https://github.com/hashicorp/vault/search?l=Handlebars&q=HttpRequestsTable+OR+http-requests-table)
- [HttpRequestsTable Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/http-requests-table.js)
---

View File

@ -0,0 +1,28 @@
/* eslint-disable import/extensions */
import hbs from 'htmlbars-inline-precompile';
import { storiesOf } from '@storybook/ember';
import { withKnobs, object } from '@storybook/addon-knobs';
import notes from './http-requests-table.md';
const COUNTERS = [
{ start_time: '2019-04-01T00:00:00Z', total: 5500 },
{ start_time: '2019-05-01T00:00:00Z', total: 4500 },
{ start_time: '2019-06-01T00:00:00Z', total: 5000 },
];
storiesOf('HttpRequests/Table/', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(
withKnobs()
)
.add(`HttpRequestsTable`, () => ({
template: hbs`
<h5 class="title is-5">Http Requests Table</h5>
<HttpRequestsTable @counters={{counters}}/>
`,
context: {
counters: object('counters', COUNTERS),
}
}),
{ notes }
);

View File

@ -0,0 +1,48 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
const COUNTERS = [
{ start_time: '2019-04-01T00:00:00Z', total: 5500 },
{ start_time: '2019-05-01T00:00:00Z', total: 4500 },
{ start_time: '2019-06-01T00:00:00Z', total: 5000 },
];
module('Integration | Component | http-requests-bar-chart', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
this.set('counters', COUNTERS);
});
test('it renders', async function(assert) {
await render(hbs`<HttpRequestsBarChart @counters={{counters}}/>`);
assert.dom('.http-requests-bar-chart').exists();
});
test('it renders the correct number of bars, ticks, and gridlines', async function(assert) {
await render(hbs`<HttpRequestsBarChart @counters={{counters}}/>`);
assert.equal(this.element.querySelectorAll('.bar').length, 3);
assert.equal(this.element.querySelectorAll('.tick').length, 9), 'it renders the ticks and gridlines';
});
test('it formats the ticks', async function(assert) {
await render(hbs`<HttpRequestsBarChart @counters={{counters}}/>`);
debugger;
assert.equal(
this.element.querySelector('.x-axis>.tick').textContent,
'Apr 2019',
'x axis ticks should should show the month and year'
);
assert.equal(
this.element.querySelectorAll('.y-axis>.tick')[1].textContent,
'2k',
'y axis ticks should round to the nearest thousand'
);
});
});

View File

@ -0,0 +1,54 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
const COUNTERS = [
{ start_time: '2019-04-01T00:00:00Z', total: 5500 },
{ start_time: '2019-05-01T00:00:00Z', total: 4500 },
{ start_time: '2019-06-01T00:00:00Z', total: 5000 },
];
module('Integration | Component | http-requests-table', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
this.set('counters', COUNTERS);
});
test('it renders', async function(assert) {
await render(hbs`<HttpRequestsTable @counters={{counters}}/>`);
assert.dom('.http-requests-table').exists();
});
test('it does not show Change column with less than one month of data', async function(assert) {
const one_month_counter = [
{
start_time: '2019-05-01T00:00:00Z',
total: 50,
},
];
this.set('one_month_counter', one_month_counter);
await render(hbs`<HttpRequestsTable @counters={{one_month_counter}}/>`);
assert.dom('.http-requests-table').exists();
assert.dom('[data-test-change]').doesNotExist();
});
test('it shows Change column for more than one month of data', async function(assert) {
await render(hbs`<HttpRequestsTable @counters={{counters}}/>`);
assert.dom('[data-test-change]').exists();
});
test('it shows the percent change between each time window', async function(assert) {
await render(hbs`<HttpRequestsTable @counters={{counters}}/>`);
const expected = Math.abs(
(((COUNTERS[1].total - COUNTERS[0].total) / COUNTERS[1].total) * 100).toFixed(1)
);
assert.ok(this.element.textContent.includes(expected));
});
});

View File

@ -0,0 +1,14 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { formatUtc } from '../../../helpers/format-utc';
module('Integration | Helper | format-utc', function(hooks) {
setupRenderingTest(hooks);
test('it formats a UTC date string and maintains the timezone', function(assert) {
let expected = 'Apr 01 2019, 00:00';
let dateTime = '2019-04-01T00:00:00Z';
let result = formatUtc([dateTime, '%b %d %Y, %H:%M']);
assert.equal(result, expected, 'it displays the date in UTC');
});
});

View File

@ -6401,6 +6401,68 @@ cyclist@~0.2.2:
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640"
integrity sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=
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.0.8:
version "1.0.12"
resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-1.0.12.tgz#cdf20ba210cfbb43795af33756886fb3638daac9"
integrity sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==
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.2.3"
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.2.3.tgz#6c67bb2af6df3cc8d79efcc4d3a3e83e28c8048f"
integrity sha512-x37qq3ChOTLd26hnps36lexMRhNXEtVxZ4B25rL0DVdDsGQIJGB18S7y9XDwlDD6MD/ZBzITCf4JjGMM10TZkw==
d3-format@1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.3.2.tgz#6a96b5e31bcb98122a30863f7d92365c00603562"
integrity sha512-Z18Dprj96ExragQ0DeGi+SYPQ7pPfRMtUXtsg/ChVIKNBCzjO8XYJvRTC1usblx52lqge56V5ect+frYTQc8WQ==
d3-interpolate@1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.3.2.tgz#417d3ebdeb4bc4efcc8fd4361c55e4040211fd68"
integrity sha512-NlNKGopqaz9qM1PXh9gBF1KSCVh+jSFErrSlD/4hybwoNX/gt1d8CDbDW+3i+5UOHhjC6s6nMvRxcuoMVNgL2w==
dependencies:
d3-color "1"
d3-scale@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-1.0.7.tgz#fa90324b3ea8a776422bd0472afab0b252a0945d"
integrity sha512-KvU92czp2/qse5tUfGms6Kjig0AhHOwkzXG0+PqIJB3ke0WUv088AHMZI0OssO9NCkXt4RP8yju9rpH8aGB7Lw==
dependencies:
d3-array "^1.2.0"
d3-collection "1"
d3-color "1"
d3-format "1"
d3-interpolate "1"
d3-time "1"
d3-time-format "2"
d3-selection@^1.3.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.0.tgz#ab9ac1e664cf967ebf1b479cc07e28ce9908c474"
integrity sha512-EYVwBxQGEjLCKF2pJ4+yrErskDnz5v403qvAid96cNdCMr8rmCYfY5RGzWz24mdIbxmDf6/4EAH+K9xperD5jg==
d3-time-format@2, d3-time-format@^2.1.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.1.3.tgz#ae06f8e0126a9d60d6364eac5b1533ae1bac826b"
integrity sha512-6k0a2rZryzGm5Ihx+aFMuO1GgelgIz+7HhB4PH4OEndD5q2zGn1mDfRdNrulspOfR6JXkb2sThhDK41CSK85QA==
dependencies:
d3-time "1"
d3-time@1:
version "1.0.11"
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.11.tgz#1d831a3e25cd189eb256c17770a666368762bbce"
integrity sha512-Z3wpvhPLW4vEScGeIMUckDW7+3hWKOQfAWg/U7PlWBnQmeKQ00gCUsTtWSYulrKNA7ta8hJ+xXc6MHrMuITwEw==
dag-map@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/dag-map/-/dag-map-2.0.2.tgz#9714b472de82a1843de2fba9b6876938cab44c68"