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:
parent
87a2516e73
commit
8ebdf1ee5a
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 |
|
@ -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 Vault’s 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">
|
||||
|
|
|
@ -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 |
|
@ -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
|
@ -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');
|
||||
|
||||
|
|
Loading…
Reference in New Issue