Http request volume/dropdown (#7016)

* init dropdown

* add dropdown to storybook

* move http requests components into container

* add event handler for selecting new time window

* no need for this. in the template

* filter bar chart and table

* add bar chart transitions

* handle Last 12 Months in dropdown

* don't use fake data

* start tests

* add jsdoc and notes for storybook

* add container to storybook

* compute filteredCounters when counters change

* move static dropdown options to template

* add tests

* style the dropdown

* use this.elementId

* fix linting errors

* use ember array extensions

* use fillIn instead of page object and make dom assertions consistent

* calculate the correct percent change between months

* use data-test selector instead of id

* show plus or minus next to percent change
This commit is contained in:
Noelle Daley 2019-07-03 10:46:40 -07:00 committed by GitHub
parent 19b67fc617
commit 5cd7e924fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 405 additions and 31 deletions

View File

@ -4,6 +4,8 @@ import d3Scale from 'd3-scale';
import d3Axis from 'd3-axis';
import d3TimeFormat from 'd3-time-format';
import d3Tip from 'd3-tip';
import d3Transition from 'd3-transition';
import d3Ease from 'd3-ease';
import { assign } from '@ember/polyfills';
import { computed } from '@ember/object';
import { run } from '@ember/runloop';
@ -29,6 +31,8 @@ import { task, waitForEvent } from 'ember-concurrency';
const HEIGHT = 240;
const HOVER_PADDING = 12;
const BASE_SPEED = 150;
const DURATION = BASE_SPEED * 2;
export default Component.extend({
classNames: ['http-requests-bar-chart-container'],
@ -83,6 +87,10 @@ export default Component.extend({
});
},
didUpdateAttrs() {
this.renderBarChart();
},
renderBarChart() {
const { margin, width, xScale, yScale, parsedCounters, elementId } = this;
const height = this.height();
@ -146,11 +154,22 @@ export default Component.extend({
.append('rect')
.attr('class', 'bar');
const t = d3Transition
.transition()
.duration(DURATION)
.ease(d3Ease.easeQuad);
bars
.merge(barsEnter)
.attr('width', xScale.bandwidth())
.attr('height', counter => height - yScale(counter.total))
.attr('x', counter => xScale(counter.start_time))
// set the initial y value to 0 so the bars animate upwards
.attr('y', () => yScale(0))
.attr('width', xScale.bandwidth())
.transition(t)
.delay(function(d, i) {
return i * BASE_SPEED;
})
.attr('height', counter => height - yScale(counter.total))
.attr('y', counter => yScale(counter.total));
bars.exit().remove();

View File

@ -0,0 +1,56 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import isWithinRange from 'date-fns/is_within_range';
import addMonths from 'date-fns/add_months';
/**
* @module HttpRequestsContainer
* The HttpRequestsContainer component is the parent component of the HttpRequestsDropdown, HttpRequestsBarChart, and HttpRequestsTable components. It is used to handle filtering the bar chart and table according to selected time window from the dropdown.
*
* @example
* ```js
* <HttpRequestsContainer @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({
classNames: ['http-requests-container'],
counters: null,
timeWindow: 'All',
filteredCounters: computed('counters', 'timeWindow', function() {
const { counters, timeWindow } = this;
if (timeWindow === 'All') {
return counters;
}
let filteredCounters = [];
if (timeWindow === 'Last 12 Months') {
const today = new Date();
const TwelveMonthsAgo = addMonths(today, -12);
filteredCounters = counters.filter(counter => {
return isWithinRange(counter.start_time, TwelveMonthsAgo, today);
});
return filteredCounters;
}
filteredCounters = counters.filter(counter => {
const year = counter.start_time.substr(0, 4);
return year === timeWindow;
});
return filteredCounters;
}),
actions: {
updateTimeWindow(newValue) {
this.set('timeWindow', newValue);
},
},
});

View File

@ -0,0 +1,46 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
/**
* @module HttpRequestsDropdown
* HttpRequestsDropdown components are used to render a dropdown that filters the HttpRequestsBarChart.
*
* @example
* ```js
* <HttpRequestsDropdown @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({
classNames: ['http-requests-dropdown'],
counters: null,
timeWindow: 'All',
options: computed('counters', function() {
let counters = this.counters || [];
let options = [];
if (counters.length) {
const years = counters.map(counter => counter.start_time.substr(0, 4)).uniq();
years.sort().reverse();
options = options.concat(years);
}
return options;
}),
onChange() {},
actions: {
onSelectTimeWindow(e) {
const newValue = e.target.value;
const { timeWindow } = this;
if (newValue !== timeWindow) {
this.onChange(newValue);
}
},
},
});

View File

@ -30,21 +30,22 @@ export default Component.extend({
counters.forEach(month => {
if (previousMonthVal) {
let percentChange = (((month.total - previousMonthVal) / month.total) * 100).toFixed(1);
let percentChange = (((previousMonthVal - month.total) / previousMonthVal) * 100).toFixed(1);
// a negative value indicates a percentage increase, so we swap the value
percentChange = -percentChange;
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;
}
previousMonthVal = month.total;
});
return countersWithPercentChange;
}),

View File

@ -95,6 +95,11 @@
}
}
.toolbar-label {
padding: $spacing-xs;
color: $grey;
}
.toolbar-separator {
border-right: $light-border;
height: 32px;

View File

@ -0,0 +1,10 @@
{{#if (gt counters.length 1) }}
<Toolbar>
<HttpRequestsDropdown @counters={{counters}} @onChange={{action "updateTimeWindow"}} @timeWindow={{timeWindow}}/>
</Toolbar>
<HttpRequestsBarChart @counters={{filteredCounters}} />
{{/if}}
<AlertInline @type="info" @message="Metrics are recorded only for interactions that produce or use a Vault token." />
<HttpRequestsTable @counters={{filteredCounters}} />

View File

@ -0,0 +1,12 @@
<label for="date-range" class="is-label toolbar-label">
Date Range
</label>
<div class="select">
<select class="select" id="date-range" data-test-date-range name="selectedTimeWindow" data-test-timewindow-select onchange={{action "onSelectTimeWindow"}}>
<option value="All" selected={{eq timeWindow "All"}}>All</option>/>
<option value="Last 12 Months" selected={{eq timeWindow "Last 12 Months"}}>Last 12 Months</option>
{{#each options as |op|}}
<option value={{op}} selected={{eq timeWindow op}}>{{op}}</option>
{{/each}}
</select>
</div>

View File

@ -13,7 +13,7 @@
<tbody>
{{#each (reverse countersWithChange) as |c|}}
<tr>
<th scope="row" class="has-text-black">{{ format-utc c.start_time '%B %Y' }}</th>
<th scope="row" class="start-time 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}}">

View File

@ -6,8 +6,4 @@
</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}} />
<HttpRequestsContainer @counters={{model.counters}}/>

View File

@ -54,10 +54,12 @@
"columnify": "^1.5.4",
"cool-checkboxes-for-bulma.io": "^1.1.0",
"d3-axis": "^1.0.8",
"d3-ease": "^1.0.5",
"d3-scale": "^1.0.7",
"d3-selection": "^1.3.0",
"d3-time-format": "^2.1.1",
"d3-tip": "^0.9.1",
"d3-transition": "^1.2.0",
"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-container.js. To make changes, first edit that file and run "yarn gen-story-md http-requests-container" to re-generate the content.-->
## HttpRequestsContainer
The HttpRequestsContainer component is the parent component of the HttpRequestsDropdown, HttpRequestsBarChart, and HttpRequestsTable components. It is used to handle filtering the bar chart and table according to selected time window from the dropdown.
| 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
<HttpRequestsContainer @counters={counters}/>
```
**See**
- [Uses of HttpRequestsContainer](https://github.com/hashicorp/vault/search?l=Handlebars&q=HttpRequestsContainer+OR+http-requests-container)
- [HttpRequestsContainer Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/http-requests-container.js)
---

View File

@ -0,0 +1,36 @@
/* 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-container.md';
const COUNTERS = [
{ start_time: '2017-04-01T00:00:00Z', total: 5500 },
{ start_time: '2018-06-01T00:00:00Z', total: 4500 },
{ start_time: '2018-07-01T00:00:00Z', total: 4500 },
{ start_time: '2018-08-01T00:00:00Z', total: 6500 },
{ start_time: '2018-09-01T00:00:00Z', total: 5500 },
{ start_time: '2018-10-01T00:00:00Z', total: 4500 },
{ start_time: '2018-11-01T00:00:00Z', total: 6500 },
{ start_time: '2018-12-01T00:00:00Z', total: 5500 },
{ start_time: '2019-01-01T00:00:00Z', total: 2500 },
{ start_time: '2019-02-01T00:00:00Z', total: 3500 },
{ start_time: '2019-03-01T00:00:00Z', total: 5000 },
];
storiesOf('HttpRequests/Container/', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(withKnobs())
.add(
`HttpRequestsContainer`,
() => ({
template: hbs`
<h5 class="title is-5">Http Requests Container</h5>
<HttpRequestsContainer @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-dropdown.js. To make changes, first edit that file and run "yarn gen-story-md http-requests-dropdown" to re-generate the content.-->
## HttpRequestsDropdown
HttpRequestsDropdown components are used to render a dropdown that filters the HttpRequestsBarChart.
| 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
<HttpRequestsDropdown @counters={counters} />
```
**See**
- [Uses of HttpRequestsDropdown](https://github.com/hashicorp/vault/search?l=Handlebars&q=HttpRequestsDropdown+OR+http-requests-dropdown)
- [HttpRequestsDropdown Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/http-requests-dropdown.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-dropdown.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/Dropdown/', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(withKnobs())
.add(
`HttpRequestsDropdown`,
() => ({
template: hbs`
<h5 class="title is-5">Http Requests Dropdown</h5>
<HttpRequestsDropdown @counters={{counters}}/>
`,
context: {
counters: object('counters', COUNTERS),
},
}),
{ notes }
);

View File

@ -5,24 +5,25 @@ 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 },
{ start_time: '2018-12-01T00:00:00Z', total: 5500 },
{ start_time: '2019-01-01T00:00:00Z', total: 4500 },
{ start_time: '2019-02-01T00:00:00Z', total: 5000 },
{ start_time: '2019-03-01T00:00:00Z', total: 5000 },
];
storiesOf('HttpRequests/Table/', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(
withKnobs()
)
.add(`HttpRequestsTable`, () => ({
template: hbs`
.addDecorator(withKnobs())
.add(
`HttpRequestsTable`,
() => ({
template: hbs`
<h5 class="title is-5">Http Requests Table</h5>
<HttpRequestsTable @counters={{counters}}/>
`,
context: {
counters: object('counters', COUNTERS),
}
}),
{ notes }
);
context: {
counters: object('counters', COUNTERS),
},
}),
{ notes }
);

View File

@ -0,0 +1,51 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
const COUNTERS = [
{ start_time: '2018-12-01T00:00:00Z', total: 5500 },
{ start_time: '2019-01-01T00:00:00Z', total: 4500 },
{ start_time: '2019-02-01T00:00:00Z', total: 5000 },
{ start_time: '2019-03-01T00:00:00Z', total: 5000 },
];
module('Integration | Component | http-requests-container', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
this.set('counters', COUNTERS);
});
test('it renders', async function(assert) {
await render(hbs`<HttpRequestsContainer @counters={{counters}}/>`);
assert.dom('.http-requests-container').exists();
assert.dom('.http-requests-dropdown').exists();
assert.dom('.http-requests-bar-chart-container').exists();
assert.dom('.http-requests-table').exists();
});
test('it does not render a bar chart for 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`<HttpRequestsContainer @counters={{one_month_counter}}/>`);
assert.dom('.http-requests-table').exists();
assert.dom('.http-requests-bar-chart-container').doesNotExist();
});
test('it filters the data according to the dropdown', async function(assert) {
await render(hbs`<HttpRequestsContainer @counters={{counters}}/>`);
await fillIn('[data-test-timewindow-select]', '2018');
assert.dom('.shadow-bars> .bar').exists({ count: 1 }, 'filters the bar chart to the selected year');
assert.dom('.start-time').exists({ count: 1 }, 'filters the table to the selected year');
});
});

View File

@ -0,0 +1,30 @@
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: '2018-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-dropdown', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
this.set('counters', COUNTERS);
});
test('it renders with options', async function(assert) {
await render(hbs`<HttpRequestsDropdown @counters={{counters}} />`);
assert.dom('[data-test-date-range]').hasValue('All', 'shows all data by default');
assert.equal(
this.element.querySelector('[data-test-date-range]').options.length,
4,
'it adds an option for each year in the data set'
);
});
});

View File

@ -44,11 +44,21 @@ module('Integration | Component | http-requests-table', function(hooks) {
});
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)
);
const simple_counters = [
{ start_time: '2019-04-01T00:00:00Z', total: 1 },
{ start_time: '2019-05-01T00:00:00Z', total: 2 },
{ start_time: '2019-06-01T00:00:00Z', total: 1 },
{ start_time: '2019-07-01T00:00:00Z', total: 1 },
];
this.set('counters', simple_counters);
assert.ok(this.element.textContent.includes(expected));
await render(hbs`<HttpRequestsTable @counters={{counters}}/>`);
// the expectedValues are in reverse chronological order because that is the order
// that the table shows its data.
let expectedValues = ['', '-50%', '100%', ''];
this.element.querySelectorAll('[data-test-change]').forEach((td, i) => {
return assert.equal(td.textContent.trim(), expectedValues[i]);
});
});
});

View File

@ -6503,6 +6503,16 @@ d3-color@1:
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.2.3.tgz#6c67bb2af6df3cc8d79efcc4d3a3e83e28c8048f"
integrity sha512-x37qq3ChOTLd26hnps36lexMRhNXEtVxZ4B25rL0DVdDsGQIJGB18S7y9XDwlDD6MD/ZBzITCf4JjGMM10TZkw==
d3-dispatch@1:
version "1.0.5"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.5.tgz#e25c10a186517cd6c82dd19ea018f07e01e39015"
integrity sha512-vwKx+lAqB1UuCeklr6Jh1bvC4SZgbSqbkGBLClItFBIYH4vqDJCA7qfoy14lXmJdnBOdxndAMxjCbImJYW7e6g==
d3-ease@1, d3-ease@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.5.tgz#8ce59276d81241b1b72042d6af2d40e76d936ffb"
integrity sha512-Ct1O//ly5y5lFM9YTdu+ygq7LleSgSE4oj7vUt9tPLHUi8VCV7QoizGpdWRWAwCO9LdYzIrQDg97+hGVdsSGPQ==
d3-format@1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.3.2.tgz#6a96b5e31bcb98122a30863f7d92365c00603562"
@ -6528,7 +6538,7 @@ d3-scale@^1.0.7:
d3-time "1"
d3-time-format "2"
d3-selection@^1.3.0:
d3-selection@^1.1.0, 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==
@ -6545,6 +6555,11 @@ d3-time@1:
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.11.tgz#1d831a3e25cd189eb256c17770a666368762bbce"
integrity sha512-Z3wpvhPLW4vEScGeIMUckDW7+3hWKOQfAWg/U7PlWBnQmeKQ00gCUsTtWSYulrKNA7ta8hJ+xXc6MHrMuITwEw==
d3-timer@1:
version "1.0.9"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.9.tgz#f7bb8c0d597d792ff7131e1c24a36dd471a471ba"
integrity sha512-rT34J5HnQUHhcLvhSB9GjCkN0Ddd5Y8nCwDBG2u6wQEeYxT/Lf51fTFFkldeib/sE/J0clIe0pnCfs6g/lRbyg==
d3-tip@^0.9.1:
version "0.9.1"
resolved "https://registry.yarnpkg.com/d3-tip/-/d3-tip-0.9.1.tgz#84e6d331c4e6650d80c5228a07e41820609ab64b"
@ -6553,6 +6568,18 @@ d3-tip@^0.9.1:
d3-collection "^1.0.4"
d3-selection "^1.3.0"
d3-transition@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.2.0.tgz#f538c0e21b2aa1f05f3e965f8567e81284b3b2b8"
integrity sha512-VJ7cmX/FPIPJYuaL2r1o1EMHLttvoIuZhhuAlRoOxDzogV8iQS6jYulDm3xEU3TqL80IZIhI551/ebmCMrkvhw==
dependencies:
d3-color "1"
d3-dispatch "1"
d3-ease "1"
d3-interpolate "1"
d3-selection "^1.1.0"
d3-timer "1"
dag-map@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/dag-map/-/dag-map-2.0.2.tgz#9714b472de82a1843de2fba9b6876938cab44c68"