UI/Client count running totals component (#14967)

* wire up running total component

* remove waitUntil

* remove unused functions

* abstract x/y keys

* adjust tick size

* refactor helpers into utils

* cleanup

* cleanup utils files and imports

* update variables to const

* mock empty monthly data
This commit is contained in:
claire bontempo 2022-04-12 13:30:40 -05:00 committed by GitHub
parent 87a2516e73
commit 8ebdf1ee5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 787 additions and 745 deletions

View File

@ -168,6 +168,14 @@ export default class History extends Component {
return this.getActivityResponse.responseTimestamp;
}
get byMonthTotalClients() {
return this.getActivityResponse?.byMonthTotalClients;
}
get byMonthNewClients() {
return this.getActivityResponse?.byMonthNewClients;
}
get countsIncludeOlderData() {
let firstUpgrade = this.args.model.versionHistory[0];
if (!firstUpgrade) {

View File

@ -17,7 +17,8 @@ import { LIGHT_AND_DARK_BLUE, SVG_DIMENSIONS, formatNumbers } from '../../utils/
* ```js
* <LineChart @dataset={dataset} />
* ```
* @param {array} dataset - dataset is an array of objects
* @param {string} xKey - string denoting key for x-axis data (data[xKey]) of dataset
* @param {string} yKey - string denoting key for y-axis data (data[yKey]) of dataset
*/
export default class LineChart extends Component {
@ -28,7 +29,7 @@ export default class LineChart extends Component {
@tracked tooltipNew = '';
get yKey() {
return this.args.yKey || 'clients';
return this.args.yKey || 'total';
}
get xKey() {
@ -41,34 +42,34 @@ export default class LineChart extends Component {
@action
renderChart(element, args) {
let dataset = args[0];
let chartSvg = select(element);
const dataset = args[0];
const chartSvg = select(element);
chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions
// DEFINE AXES SCALES
let yScale = scaleLinear()
const yScale = scaleLinear()
.domain([0, max(dataset.map((d) => d[this.yKey]))])
.range([0, 100])
.nice();
let yAxisScale = scaleLinear()
const yAxisScale = scaleLinear()
.domain([0, max(dataset.map((d) => d[this.yKey]))])
.range([SVG_DIMENSIONS.height, 0])
.nice();
let xScale = scalePoint() // use scaleTime()?
const xScale = scalePoint() // use scaleTime()?
.domain(dataset.map((d) => d[this.xKey]))
.range([0, SVG_DIMENSIONS.width])
.padding(0.2);
// CUSTOMIZE AND APPEND AXES
let yAxis = axisLeft(yAxisScale)
.ticks(7)
const yAxis = axisLeft(yAxisScale)
.ticks(4)
.tickPadding(10)
.tickSizeInner(-SVG_DIMENSIONS.width) // makes grid lines length of svg
.tickFormat(formatNumbers);
let xAxis = axisBottom(xScale).tickSize(0);
const xAxis = axisBottom(xScale).tickSize(0);
yAxis(chartSvg.append('g'));
xAxis(chartSvg.append('g').attr('transform', `translate(0, ${SVG_DIMENSIONS.height + 10})`));
@ -76,7 +77,7 @@ export default class LineChart extends Component {
chartSvg.selectAll('.domain').remove();
// PATH BETWEEN PLOT POINTS
let lineGenerator = line()
const lineGenerator = line()
.x((d) => xScale(d[this.xKey]))
.y((d) => yAxisScale(d[this.yKey]));
@ -117,14 +118,14 @@ export default class LineChart extends Component {
.attr('cx', (d) => xScale(d[this.xKey]))
.attr('r', 10);
let hoverCircles = chartSvg.selectAll('.hover-circle');
const hoverCircles = chartSvg.selectAll('.hover-circle');
// MOUSE EVENT FOR TOOLTIP
hoverCircles.on('mouseover', (data) => {
// TODO: how to genericize this?
this.tooltipMonth = data[this.xKey];
this.tooltipTotal = `${data[this.yKey]} total clients`;
this.tooltipNew = `${data.new_clients?.clients} new clients`;
this.tooltipNew = `${data?.new_clients[this.yKey]} new clients`;
let node = hoverCircles.filter((plot) => plot[this.xKey] === data[this.xKey]).node();
this.tooltipTarget = node;
});

View File

@ -25,18 +25,28 @@ import {
* ```
* @param {array} dataset - dataset for the chart, must be an array of flattened objects
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
* @param {string} xKey - string denoting key for x-axis data (data[xKey]) of dataset
* @param {string} yKey - string denoting key for y-axis data (data[yKey]) of dataset
*/
export default class VerticalBarChart extends Component {
@tracked tooltipTarget = '';
@tracked tooltipTotal = '';
@tracked uniqueEntities = '';
@tracked nonEntityTokens = '';
@tracked entityClients = '';
@tracked nonEntityClients = '';
get chartLegend() {
return this.args.chartLegend;
}
get xKey() {
return this.args.xKey || 'month';
}
get yKey() {
return this.args.yKey || 'total';
}
@action
registerListener(element, args) {
let dataset = args[0];
@ -47,12 +57,12 @@ export default class VerticalBarChart extends Component {
// DEFINE DATA BAR SCALES
let yScale = scaleLinear()
.domain([0, max(dataset.map((d) => d.clients))]) // TODO will need to recalculate when you get the data
.domain([0, max(dataset.map((d) => d[this.yKey]))])
.range([0, 100])
.nice();
let xScale = scaleBand()
.domain(dataset.map((d) => d.month))
.domain(dataset.map((d) => d[this.xKey]))
.range([0, SVG_DIMENSIONS.width]) // set width to fix number of pixels
.paddingInner(0.85);
@ -71,17 +81,17 @@ export default class VerticalBarChart extends Component {
.attr('width', '7px')
.attr('class', 'data-bar')
.attr('height', (stackedData) => `${yScale(stackedData[1] - stackedData[0])}%`)
.attr('x', ({ data }) => xScale(data.month)) // uses destructuring because was data.data.month
.attr('x', ({ data }) => xScale(data[this.xKey])) // uses destructuring because was data.data.month
.attr('y', (data) => `${100 - yScale(data[1])}%`); // subtract higher than 100% to give space for x axis ticks
// MAKE AXES //
let yAxisScale = scaleLinear()
.domain([0, max(dataset.map((d) => d.clients))]) // TODO will need to recalculate when you get the data
.domain([0, max(dataset.map((d) => d[this.yKey]))])
.range([`${SVG_DIMENSIONS.height}`, 0])
.nice();
let yAxis = axisLeft(yAxisScale)
.ticks(7)
.ticks(6)
.tickPadding(10)
.tickSizeInner(-SVG_DIMENSIONS.width)
.tickFormat(formatNumbers);
@ -111,18 +121,14 @@ export default class VerticalBarChart extends Component {
.attr('height', '100%')
.attr('width', '30px') // three times width
.attr('y', '0') // start at bottom
.attr('x', (data) => xScale(data.month)); // not data.data because this is not stacked data
.attr('x', (data) => xScale(data[this.xKey])); // not data.data because this is not stacked data
// MOUSE EVENT FOR TOOLTIP
tooltipRect.on('mouseover', (data) => {
let hoveredMonth = data.month;
this.tooltipTotal = `${data.clients} ${data.new_clients ? 'total' : 'new'} clients`;
this.uniqueEntities = `${data.entity_clients} unique entities`;
this.nonEntityTokens = `${data.non_entity_clients} non-entity tokens`;
// let node = chartSvg
// .selectAll('rect.tooltip-rect')
// .filter(data => data.month === this.hoveredLabel)
// .node();
let hoveredMonth = data[this.xKey];
this.tooltipTotal = `${data[this.yKey]} ${data.new_clients ? 'total' : 'new'} clients`;
this.entityClients = `${data.entity_clients} entity clients`;
this.nonEntityClients = `${data.non_entity_clients} non-entity clients`;
let node = chartSvg
.selectAll('rect.data-bar')
// filter for the top data bar (so y-coord !== 0) with matching month

View File

@ -2,6 +2,7 @@ import Route from '@ember/routing/route';
import { isSameMonth } from 'date-fns';
import RSVP from 'rsvp';
import getStorage from 'vault/lib/token-storage';
import { parseRFC3339 } from 'core/utils/date-formatters';
const INPUTTED_START_DATE = 'vault:ui-inputted-start-date';
export default class HistoryRoute extends Route {
@ -26,17 +27,6 @@ export default class HistoryRoute extends Route {
}
}
parseRFC3339(timestamp) {
// convert '2021-03-21T00:00:00Z' --> ['2021', 2] (e.g. 2021 March, month is zero indexed)
if (Array.isArray(timestamp)) {
// return if already formatted correctly
return timestamp;
}
return timestamp
? [timestamp.split('-')[0], Number(timestamp.split('-')[1].replace(/^0+/, '')) - 1]
: null;
}
async model() {
let parentModel = this.modelFor('vault.cluster.clients');
let licenseStart = await this.getLicenseStartTime();
@ -45,8 +35,8 @@ export default class HistoryRoute extends Route {
return RSVP.hash({
config: parentModel.config,
activity,
startTimeFromLicense: this.parseRFC3339(licenseStart),
endTimeFromResponse: this.parseRFC3339(activity?.endTime),
startTimeFromLicense: parseRFC3339(licenseStart),
endTimeFromResponse: parseRFC3339(activity?.endTime),
versionHistory: parentModel.versionHistory,
});
}

View File

@ -1,5 +1,6 @@
import ApplicationSerializer from '../application';
import { formatISO } from 'date-fns';
import { parseAPITimestamp, parseRFC3339 } from 'core/utils/date-formatters';
export default class ActivitySerializer extends ApplicationSerializer {
flattenDataset(byNamespaceArray) {
return byNamespaceArray.map((ns) => {
@ -34,10 +35,12 @@ export default class ActivitySerializer extends ApplicationSerializer {
// for vault usage - vertical bar chart
flattenByMonths(payload, isNewClients = false) {
const sortedPayload = [...payload];
sortedPayload.reverse();
if (isNewClients) {
return payload.map((m) => {
return sortedPayload?.map((m) => {
return {
month: m.timestamp,
month: parseAPITimestamp(m.timestamp, 'M/yy'),
entity_clients: m.new_clients.counts.entity_clients,
non_entity_clients: m.new_clients.counts.non_entity_clients,
total: m.new_clients.counts.clients,
@ -45,9 +48,9 @@ export default class ActivitySerializer extends ApplicationSerializer {
};
});
} else {
return payload.map((m) => {
return sortedPayload?.map((m) => {
return {
month: m.timestamp,
month: parseAPITimestamp(m.timestamp, 'M/yy'),
entity_clients: m.counts.entity_clients,
non_entity_clients: m.counts.non_entity_clients,
total: m.counts.clients,
@ -88,13 +91,6 @@ export default class ActivitySerializer extends ApplicationSerializer {
return object;
}
parseRFC3339(timestamp) {
// convert '2021-03-21T00:00:00Z' --> ['2021', 2] (e.g. 2021 March, month is zero indexed)
return timestamp
? [timestamp.split('-')[0], Number(timestamp.split('-')[1].replace(/^0+/, '')) - 1]
: null;
}
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (payload.id === 'no-data') {
return super.normalizeResponse(store, primaryModelClass, payload, id, requestType);
@ -107,8 +103,8 @@ export default class ActivitySerializer extends ApplicationSerializer {
by_month_total_clients: this.flattenByMonths(payload.data.months),
by_month_new_clients: this.flattenByMonths(payload.data.months, { isNewClients: true }),
total: this.homogenizeClientNaming(payload.data.total),
formatted_end_time: this.parseRFC3339(payload.data.end_time),
formatted_start_time: this.parseRFC3339(payload.data.start_time),
formatted_end_time: parseRFC3339(payload.data.end_time),
formatted_start_time: parseRFC3339(payload.data.start_time),
};
delete payload.data.by_namespace;
delete payload.data.months;
@ -116,7 +112,6 @@ export default class ActivitySerializer extends ApplicationSerializer {
return super.normalizeResponse(store, primaryModelClass, transformedPayload, id, requestType);
}
}
/*
SAMPLE PAYLOAD BEFORE/AFTER:

View File

@ -131,6 +131,14 @@
<LayoutLoading />
{{else}}
{{#if this.totalUsageCounts}}
<Clients::RunningTotal
@chartLegend={{this.chartLegend}}
@barChartData={{this.byMonthNewClients}}
@lineChartData={{this.byMonthTotalClients}}
@runningTotals={{this.totalUsageCounts}}
@timestamp={{this.responseTimestamp}}
/>
{{!-- TODO CMB: remove UsageStats component from history tab (and update associated tests) --}}
<Clients::UsageStats @title="Total usage" @totalUsageCounts={{this.totalUsageCounts}} />
{{#if this.hasAttributionData}}
<Clients::Attribution

View File

@ -3,6 +3,7 @@
class="chart has-grid"
{{on "mouseleave" this.removeTooltip}}
{{did-insert this.renderChart @dataset}}
{{did-update this.renderChart @dataset}}
>
</svg>

Before

Width:  |  Height:  |  Size: 808 B

After

Width:  |  Height:  |  Size: 851 B

View File

@ -3,9 +3,7 @@
<div class="chart-header has-bottom-margin-xl">
<h2 class="chart-title">Vault client counts</h2>
<p class="chart-description">
A unique active client is any user or service that interacts with Vault. They are made up of direct entities and
non-entity tokens. As Vaults primary billing metric, your total client count is the number for which you will be
charged at the end of your billing period.
A client is any user or service that interacts with Vault. They are made up of entity clients and non-entity clients. The total client count number is an important consideration for Vault billing.
</p>
</div>
@ -20,30 +18,18 @@
<div class="data-details-top">
{{#let (get this.getTotalClients 0) as |stat|}}
<h3 class="data-details">{{capitalize stat.label}}</h3>
<h3 class="data-details">Entity clients</h3>
<p class="data-details">
{{#if stat.total}}
{{format-number stat.total}}
{{else}}
<span class="has-text-danger is-size-8">
<Icon @name="x-square-fill" />Error getting total
</span>
{{/if}}
{{format-number stat.total}}
</p>
{{/let}}
</div>
<div class="data-details-bottom">
{{#let (get this.getTotalClients 1) as |stat|}}
<h3 class="data-details">{{capitalize stat.label}}</h3>
<h3 class="data-details">Non-entity clients</h3>
<p class="data-details">
{{#if stat.total}}
{{format-number stat.total}}
{{else}}
<span class="has-text-danger is-size-8">
<Icon @name="x-square-fill" />Error getting total
</span>
{{/if}}
{{format-number stat.total}}
</p>
{{/let}}
</div>
@ -68,36 +54,27 @@
<div class="data-details-top">
{{#let (get this.getAverageNewClients 0) as |stat|}}
<h3 class="data-details">Average new {{stat.label}} per month</h3>
<h3 class="data-details">Average new entity clients per month</h3>
<p class="data-details">
{{#if stat.average}}
{{format-number stat.average}}
{{else}}
<span class="has-text-danger is-size-8">
<Icon @name="x-square-fill" />Average cannot be calculated
</span>
{{/if}}
{{format-number stat.average}}
</p>
{{/let}}
</div>
<div class="data-details-bottom">
{{#let (get this.getAverageNewClients 1) as |stat|}}
<h3 class="data-details">Average new {{stat.label}} per month</h3>
<h3 class="data-details">Average new non-entity clients per month</h3>
<p class="data-details">
{{#if stat.average}}
{{format-number stat.average}}
{{else}}
<span class="has-text-danger is-size-8">
<Icon @name="x-square-fill" />Average cannot be calculated
</span>
{{/if}}
{{format-number stat.average}}
</p>
{{/let}}
</div>
<div class="timestamp">
Updated Nov 15 2021, 4:07:32 pm
{{#if @timestamp}}
Updated
{{date-format @timestamp "MMM d yyyy, h:mm:ss aaa"}}
{{/if}}
</div>
<div class="legend-right">

View File

@ -3,6 +3,7 @@
class="chart has-grid"
{{on "mouseleave" this.removeTooltip}}
{{did-insert this.registerListener @dataset}}
{{did-update this.renderChart @dataset}}
>
</svg>
@ -21,8 +22,8 @@
}}
<div class="chart-tooltip vertical-chart">
<p>{{this.tooltipTotal}}</p>
<p>{{this.uniqueEntities}}</p>
<p>{{this.nonEntityTokens}}</p>
<p>{{this.entityClients}}</p>
<p>{{this.nonEntityClients}}</p>
</div>
<div class="chart-tooltip-arrow"></div>
{{/modal-dialog}}

Before

Width:  |  Height:  |  Size: 819 B

After

Width:  |  Height:  |  Size: 862 B

View File

@ -0,0 +1,20 @@
import { format, parseISO } from 'date-fns';
// convert RFC3339 timestamp ( '2021-03-21T00:00:00Z' ) to date object, optionally format
export const parseAPITimestamp = (timestamp, style) => {
if (!timestamp) return;
let date = parseISO(timestamp.split('T')[0]);
if (!style) return date;
return format(date, style);
};
// convert ISO timestamp '2021-03-21T00:00:00Z' to ['2021', 2]
// (e.g. 2021 March, month is zero indexed) (used by calendar widget)
export const parseRFC3339 = (timestamp) => {
if (Array.isArray(timestamp)) {
// return if already formatted correctly
return timestamp;
}
let date = parseAPITimestamp(timestamp);
return date ? [`${date.getFullYear()}`, date.getMonth()] : null;
};

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import { module, test } from 'qunit';
import { visit, currentURL, click, settled, waitUntil, find } from '@ember/test-helpers';
import { visit, currentURL, click, settled, find } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import Pretender from 'pretender';
import authPage from 'vault/tests/pages/auth';
@ -196,7 +196,7 @@ module('Acceptance | clients history tab', function (hooks) {
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('15');
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('5');
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('10');
await waitUntil(() => find('[data-test-horizontal-bar-chart]'));
await settled();
assert.dom('[data-test-horizontal-bar-chart]').exists('Shows attribution bar chart');
assert.dom('[data-test-top-attribution]').includesText('Top auth method');