UI: Add charts to ACME client count tab (#26385)

* use model returned by route model hook for ts declaration

* remove hasActivity helper

* refactor mirage so namespace totals are summed from monthly data

* add charts to acme tab

* add controller, update counts test

* add test for acme page

* selector cleanup

* update empty state handling for cc charts

* cleanup conditional logic

* add acme acceptance tests for filtering

* wrap up util updates

* finish acceptance tests

* update usage stats

* wrap up number updates from latest stubbed response
This commit is contained in:
claire bontempo
2024-04-16 20:58:54 -07:00
committed by GitHub
parent 3db6aa202c
commit 3f19f8b0f0
22 changed files with 1059 additions and 547 deletions

View File

@@ -3,12 +3,97 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="chart-wrapper" data-test-usage-stats>
<div class="chart-header has-bottom-margin-m">
<h2 class="chart-title">ACME usage</h2>
<p class="chart-description has-bottom-padding-m">This data can be used to understand how many ACME clients have been
used for the queried
{{if this.isDateRange "date range" "month"}}. Each ACME request is counted as one client.</p>
{{! ACME clients added in 1.17 }}
{{#if (not this.byMonthActivityData)}}
{{! byMonthActivityData is an empty array if there is no monthly data (monthly breakdown was added in 1.11)
this means the user has queried dates before ACME clients existed. we render an empty state instead of
"0 acme clients" (which is what the activity response returns) to be more explicit }}
<EmptyState
@title="No ACME clients"
@message="There is no ACME client data available for this {{if this.isDateRange 'date range' 'month'}}."
class="is-shadowless"
/>
{{else if this.isDateRange}}
<Clients::ChartContainer
@title={{this.title}}
@description={{this.description}}
@timestamp={{@activity.responseTimestamp}}
@hasChartData={{true}}
class="no-legend"
>
<:subTitle>
<StatText
@label="Total ACME clients"
@subText="The total number of ACME requests made to Vault during this time period."
@value={{this.totalUsageCounts.acme_clients}}
@size="l"
/>
</:subTitle>
<:stats>
{{#let (this.average this.byMonthActivityData "acme_clients") as |avg|}}
{{! 0 is falsy, intentionally hide 0 averages }}
{{#if avg}}
<StatText
@label="Average ACME clients per month"
@value={{avg}}
@size="m"
class="data-details-top has-top-padding-l"
/>
{{/if}}
{{/let}}
</:stats>
<:chart>
<Clients::Charts::VerticalBarBasic
@chartTitle={{this.title}}
@data={{this.byMonthActivityData}}
@dataKey="acme_clients"
@chartHeight={{200}}
/>
</:chart>
</Clients::ChartContainer>
{{#if this.totalUsageCounts.acme_clients}}
{{! no need to render two empty charts! hide this one if there are no acme clients }}
<Clients::ChartContainer
@title="Monthly new"
@description="ACME clients which interacted with Vault for the first time each month. Each bar represents the total new ACME clients for that month."
@timestamp={{@activity.responseTimestamp}}
@hasChartData={{true}}
class="no-legend"
data-test-chart="monthly new"
>
<:stats>
{{#let (this.average this.byMonthNewClients "acme_clients") as |avg|}}
{{#if avg}}
<StatText
@label="Average new ACME clients per month"
@value={{avg}}
@size="m"
class="chart-subTitle has-top-padding-l"
/>
{{/if}}
{{/let}}
</:stats>
<:chart>
<Clients::Charts::VerticalBarBasic
@chartTitle="Monthly new"
@data={{this.byMonthNewClients}}
@dataKey="acme_clients"
@chartHeight={{200}}
/>
</:chart>
</Clients::ChartContainer>
{{/if}}
{{else}}
<div class="chart-wrapper" data-test-usage-stats>
<div class="chart-header has-bottom-margin-m">
<h2 class="chart-title">{{this.title}}</h2>
<p class="chart-description has-bottom-padding-m">{{this.description}}</p>
</div>
<StatText @label="Total ACME clients" @value={{this.totalUsageCounts.acme_clients}} @size="l" />
</div>
<StatText @label="Total ACME clients" @value={{this.totalUsageCounts.acme_clients}} @size="l" />
</div>
{{/if}}

View File

@@ -5,4 +5,11 @@
import ActivityComponent from '../activity';
export default class ClientsAcmePageComponent extends ActivityComponent {}
export default class ClientsAcmePageComponent extends ActivityComponent {
title = 'ACME usage';
get description() {
return `This data can be used to understand how many ACME clients have been used for the queried ${
this.isDateRange ? 'date range' : 'month'
}. Each ACME request is counted as one client.`;
}
}

View File

@@ -92,7 +92,7 @@
{{/if}}
{{#if (or @mountPath this.mountPaths)}}
<SearchSelect
@id="auth-method-search-select"
@id="mounts-search-select"
@options={{this.mountPaths}}
@inputValue={{if @mountPath (array @mountPath)}}
@selectLimit="1"

View File

@@ -25,7 +25,6 @@
@value={{this.averageTotalClients}}
@size="m"
class="data-details-top has-top-padding-l"
data-test-chart-stat="monthly total"
/>
<StatText
@@ -33,7 +32,6 @@
@value={{this.averageNewClients}}
@size="m"
class="data-details-bottom has-top-padding-l"
data-test-chart-stat="monthly new"
/>
</:stats>
@@ -57,7 +55,6 @@
@value={{this.average this.byMonthNewClients "entity_clients"}}
@size="m"
class="chart-subTitle has-top-padding-l"
data-test-chart-stat="entity"
/>
<StatText
@@ -65,7 +62,6 @@
@value={{this.average this.byMonthNewClients "non_entity_clients"}}
@size="m"
class="data-details-top has-top-padding-l"
data-test-chart-stat="nonentity"
/>
</:stats>

View File

@@ -24,7 +24,6 @@
@value={{@totalUsageCounts.clients}}
@size="l"
@subText="The number of clients which interacted with Vault during this month. This is Vaults primary billing metric."
data-test-stat-text="total-clients"
/>
</div>
<div class="column">
@@ -34,7 +33,6 @@
@value={{@totalUsageCounts.entity_clients}}
@size="l"
@subText="Representations of a particular user, client, or application that created a token via login."
data-test-stat-text="entity-clients"
/>
</div>
<div class="column">
@@ -44,7 +42,6 @@
@value={{@totalUsageCounts.non_entity_clients}}
@size="l"
@subText="Clients created with a shared set of permissions, but not associated with an entity."
data-test-stat-text="non-entity-clients"
/>
</div>
{{#if @showSecretSyncs}}
@@ -55,7 +52,6 @@
@value={{@totalUsageCounts.secret_syncs}}
@size="l"
@subText="A secret with a configured sync destination qualifies as a unique and active client."
data-test-stat-text="secret-syncs"
/>
</div>
{{/if}}

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller, { inject as controller } from '@ember/controller';
import type ClientsCountsController from '../counts';
export default class ClientsCountsAcmeController extends Controller {
// not sure why this needs to be cast to never but this definitely accepts a string to point to the controller
@controller('vault.cluster.clients.counts' as never)
declare readonly countsController: ClientsCountsController;
}

View File

@@ -5,7 +5,7 @@
<div
class={{concat "stat-text-container " @size (unless @subText "-no-subText")}}
data-test-stat-text-container={{(or @label "true")}}
data-test-stat-text={{or @label "true"}}
...attributes
>
<div class="stat-label has-bottom-margin-xs">{{@label}}</div>

View File

@@ -167,7 +167,7 @@ export const namespaceArrayToObject = (
...mount,
timestamp,
month,
new_clients: { month, ...newMountClients },
new_clients: { month, timestamp, ...newMountClients },
};
}
return mountObj;
@@ -177,7 +177,7 @@ export const namespaceArrayToObject = (
...destructureClientCounts(ns),
timestamp,
month,
new_clients: { month, ...newNsClients },
new_clients: { month, timestamp, ...newNsClients },
mounts_by_key,
};
}
@@ -241,6 +241,7 @@ export interface NamespaceByKey extends TotalClients {
export interface NamespaceNewClients extends TotalClients {
month: string;
timestamp: string;
label: string;
mounts: MountClients[];
}
@@ -254,6 +255,7 @@ export interface MountByKey extends TotalClients {
export interface MountNewClients extends TotalClients {
month: string;
timestamp: string;
label: string;
}

View File

@@ -72,16 +72,18 @@ function generateMounts(pathPrefix, counts) {
});
}
function generateNamespaceBlock(idx = 0, isLowerCounts = false, ns) {
function generateNamespaceBlock(idx = 0, isLowerCounts = false, ns, skipCounts = false) {
const min = isLowerCounts ? 10 : 50;
const max = isLowerCounts ? 100 : 5000;
const max = isLowerCounts ? 100 : 1000;
const nsBlock = {
namespace_id: ns?.namespace_id || (idx === 0 ? 'root' : Math.random().toString(36).slice(2, 7) + idx),
namespace_path: ns?.namespace_path || (idx === 0 ? '' : `ns/${idx}`),
namespace_path: ns?.namespace_path || (idx === 0 ? '' : `ns${idx}`),
counts: {},
mounts: {},
};
if (skipCounts) return nsBlock; // skip counts to generate empty ns block with namespace ids and paths
// * ADD NEW CLIENT TYPES HERE and pass to a new generateMounts() function below
const [acme_clients, entity_clients, non_entity_clients, secret_syncs] = CLIENT_TYPES.map(() =>
randomBetween(min, max)
@@ -144,12 +146,34 @@ function generateMonths(startDate, endDate, namespaces) {
return months;
}
function generateActivityResponse(namespaces, startDate, endDate) {
function generateActivityResponse(startDate, endDate) {
let namespaces = Array.from(Array(12)).map((v, idx) => generateNamespaceBlock(idx, null, null, true));
const months = generateMonths(startDate, endDate, namespaces);
if (months.length) {
const monthlyCounts = months.filter((m) => m.counts);
// sum namespace counts from monthly data
namespaces.forEach((ns) => {
const nsData = monthlyCounts.map((d) =>
d.namespaces.find((n) => n.namespace_path === ns.namespace_path)
);
const mountCounts = nsData.flatMap((d) => d.mounts);
const paths = nsData[0].mounts.map(({ mount_path }) => mount_path);
ns.mounts = paths.map((path) => {
const counts = getTotalCounts(mountCounts.filter((m) => m.mount_path === path));
return { mount_path: path, counts };
});
ns.counts = getTotalCounts(nsData);
});
} else {
// if no monthly data due to upgrade stuff, generate counts
namespaces = Array.from(Array(12)).map((v, idx) => generateNamespaceBlock(idx));
}
namespaces.sort((a, b) => b.counts.clients - a.counts.clients);
return {
start_time: isAfter(new Date(startDate), COUNTS_START) ? startDate : formatRFC3339(COUNTS_START),
end_time: endDate,
by_namespace: namespaces.sort((a, b) => b.counts.clients - a.counts.clients),
months: generateMonths(startDate, endDate, namespaces),
by_namespace: namespaces,
months,
total: getTotalCounts(namespaces),
};
}
@@ -186,13 +210,12 @@ export default function (server) {
// backend returns a timestamp if given unix time, so first convert to timestamp string here
if (!start_time.includes('T')) start_time = fromUnixTime(start_time).toISOString();
if (!end_time.includes('T')) end_time = fromUnixTime(end_time).toISOString();
const namespaces = Array.from(Array(12)).map((v, idx) => generateNamespaceBlock(idx));
return {
request_id: 'some-activity-id',
lease_id: '',
renewable: false,
lease_duration: 0,
data: generateActivityResponse(namespaces, start_time, end_time),
data: generateActivityResponse(start_time, end_time),
wrap_info: null,
warnings: null,
auth: null,

View File

@@ -0,0 +1,152 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { visit, click, currentURL } from '@ember/test-helpers';
import { getUnixTime } from 'date-fns';
import sinon from 'sinon';
import timestamp from 'core/utils/timestamp';
import authPage from 'vault/tests/pages/auth';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
import { ACTIVITY_RESPONSE_STUB, assertChart } from 'vault/tests/helpers/clients/client-count-helpers';
import { formatNumber } from 'core/helpers/format-number';
import { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
const { searchSelect } = GENERAL;
// integration test handle general display assertions, acceptance handles nav + filtering
module('Acceptance | clients | counts | acme', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.before(function () {
sinon.stub(timestamp, 'now').callsFake(() => STATIC_NOW);
});
hooks.beforeEach(async function () {
this.server.get('sys/internal/counters/activity', () => {
return {
request_id: 'some-activity-id',
data: ACTIVITY_RESPONSE_STUB,
};
});
// store serialized activity data for value comparison
const { byMonth, byNamespace } = await this.owner
.lookup('service:store')
.queryRecord('clients/activity', {
start_time: { timestamp: getUnixTime(LICENSE_START) },
end_time: { timestamp: getUnixTime(STATIC_NOW) },
});
this.nsPath = 'ns1';
this.mountPath = 'pki-engine-0';
this.expectedValues = {
nsTotals: byNamespace.find((ns) => ns.label === this.nsPath),
nsMonthlyUsage: byMonth.map((m) => m?.namespaces_by_key[this.nsPath]).filter((d) => !!d),
nsMonthActivity: byMonth.find(({ month }) => month === '9/23').namespaces_by_key[this.nsPath],
};
await authPage.login();
return visit('/vault');
});
hooks.after(function () {
timestamp.now.restore();
});
test('it navigates to acme tab', async function (assert) {
assert.expect(3);
await click(GENERAL.navLink('Client Count'));
await click(GENERAL.tab('acme'));
assert.strictEqual(currentURL(), '/vault/clients/counts/acme', 'it navigates to acme tab');
assert.dom(GENERAL.tab('acme')).hasClass('active');
await click(GENERAL.navLink('Back to main navigation'));
assert.strictEqual(currentURL(), '/vault/dashboard', 'it navigates back to dashboard');
});
test('it filters by namespace data and renders charts', async function (assert) {
const { nsTotals, nsMonthlyUsage, nsMonthActivity } = this.expectedValues;
const nsMonthlyNew = nsMonthlyUsage.map((m) => m?.new_clients);
assert.expect(7 + nsMonthlyUsage.length + nsMonthlyNew.length);
await visit('/vault/clients/counts/acme');
await click(searchSelect.trigger('namespace-search-select'));
await click(searchSelect.option(searchSelect.optionIndex(this.nsPath)));
// each chart assertion count is data array length + 2
assertChart(assert, 'ACME usage', nsMonthlyUsage);
assertChart(assert, 'Monthly new', nsMonthlyNew);
assert.strictEqual(
currentURL(),
`/vault/clients/counts/acme?ns=${this.nsPath}`,
'namespace filter updates URL query param'
);
assert
.dom(CLIENT_COUNT.statText('Total ACME clients'))
.hasTextContaining(
`${formatNumber([nsTotals.acme_clients])}`,
'renders total acme clients for namespace'
);
// there is only one month in the stubbed data, so in this case the average is the same as the total new clients
assert
.dom(CLIENT_COUNT.statText('Average new ACME clients per month'))
.hasTextContaining(
`${formatNumber([nsMonthActivity.new_clients.acme_clients])}`,
'renders average acme clients for namespace'
);
});
test('it filters by mount data and renders charts', async function (assert) {
const { nsTotals, nsMonthlyUsage, nsMonthActivity } = this.expectedValues;
const mountTotals = nsTotals.mounts.find((m) => m.label === this.mountPath);
const mountMonthlyUsage = nsMonthlyUsage.map((ns) => ns.mounts_by_key[this.mountPath]).filter((d) => !!d);
const mountMonthlyNew = mountMonthlyUsage.map((m) => m?.new_clients);
assert.expect(7 + mountMonthlyUsage.length + mountMonthlyNew.length);
await visit('/vault/clients/counts/acme');
await click(searchSelect.trigger('namespace-search-select'));
await click(searchSelect.option(searchSelect.optionIndex(this.nsPath)));
await click(searchSelect.trigger('mounts-search-select'));
await click(searchSelect.option(searchSelect.optionIndex(this.mountPath)));
// each chart assertion count is data array length + 2
assertChart(assert, 'ACME usage', mountMonthlyUsage);
assertChart(assert, 'Monthly new', mountMonthlyNew);
assert.strictEqual(
currentURL(),
`/vault/clients/counts/acme?mountPath=${this.mountPath}&ns=${this.nsPath}`,
'mount filter updates URL query param'
);
assert
.dom(CLIENT_COUNT.statText('Total ACME clients'))
.hasTextContaining(
`${formatNumber([mountTotals.acme_clients])}`,
'renders total acme clients for mount'
);
// there is only one month in the stubbed data, so in this case the average is the same as the total new clients
const mountMonthActivity = nsMonthActivity.mounts_by_key[this.mountPath];
assert
.dom(CLIENT_COUNT.statText('Average new ACME clients per month'))
.hasTextContaining(
`${formatNumber([mountMonthActivity.new_clients.acme_clients])}`,
'renders average acme clients for mount'
);
});
test('it renders empty chart for no mount data ', async function (assert) {
assert.expect(3);
await visit('/vault/clients/counts/acme');
await click(searchSelect.trigger('namespace-search-select'));
await click(searchSelect.option(searchSelect.optionIndex(this.nsPath)));
await click(searchSelect.trigger('mounts-search-select'));
// no data because this is an auth mount (acme_clients come from pki mounts)
await click(searchSelect.option(searchSelect.optionIndex('auth/authid/0')));
assert.dom(CLIENT_COUNT.statText('Total ACME clients')).hasTextContaining('0');
assert.dom(`${CLIENT_COUNT.charts.chart('ACME usage')} ${CLIENT_COUNT.charts.dataBar}`).isNotVisible();
assert.dom(CLIENT_COUNT.charts.chart('Monthly new')).doesNotExist();
});
});

View File

@@ -35,11 +35,10 @@ module('Acceptance | clients | sync | activated', function (hooks) {
test('it should render charts when secrets sync is activated', async function (assert) {
syncHandler(this.server);
assert
.dom(CLIENT_COUNT.charts.chart('Secrets sync usage'))
.exists('Secrets sync usage chart is rendered');
assert.dom(CLIENT_COUNT.syncTab.total).exists('Total sync clients chart is rendered');
assert.dom(CLIENT_COUNT.statText('Total sync clients')).exists('Total sync clients chart is rendered');
assert.dom(GENERAL.emptyStateTitle).doesNotExist();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -16,19 +16,11 @@ export const CLIENT_COUNT = {
mountPaths: '[data-test-counts-auth-mounts]',
startDiscrepancy: '[data-test-counts-start-discrepancy]',
},
tokenTab: {
entity: '[data-test-monthly-new-entity]',
nonentity: '[data-test-monthly-new-nonentity]',
legend: '[data-test-monthly-new-legend]',
},
syncTab: {
total: '[data-test-total-sync-clients]',
average: '[data-test-average-sync-clients]',
},
statText: (label: string) => `[data-test-stat-text="${label}"]`,
charts: {
chart: (title: string) => `[data-test-chart="${title}"]`, // newer lineal charts
statTextValue: (label: string) =>
label ? `[data-test-stat-text-container="${label}"] .stat-value` : '[data-test-stat-text-container]',
label ? `[data-test-stat-text="${label}"] .stat-value` : '[data-test-stat-text]',
legend: '[data-test-chart-container-legend]',
legendLabel: (nth: number) => `.legend-label:nth-child(${nth * 2})`, // nth * 2 accounts for dots in legend
timestamp: '[data-test-chart-container-timestamp]',
@@ -72,7 +64,7 @@ export const CLIENT_COUNT = {
},
runningTotalMonthStats: '[data-test-running-total="single-month-stats"]',
runningTotalMonthlyCharts: '[data-test-running-total="monthly-charts"]',
selectedAuthMount: 'div#auth-method-search-select [data-test-selected-option] div',
selectedAuthMount: 'div#mounts-search-select [data-test-selected-option] div',
selectedNs: 'div#namespace-search-select [data-test-selected-option] div',
upgradeWarning: '[data-test-clients-upgrade-warning]',
};

View File

@@ -48,6 +48,7 @@ export const GENERAL = {
deleteRow: (idx = 0) => `[data-test-kv-delete-row="${idx}"]`,
},
searchSelect: {
trigger: (id: string) => `[data-test-component="search-select"]#${id} .ember-basic-dropdown-trigger`,
options: '.ember-power-select-option',
optionIndex: (text: string) =>
findAll('.ember-power-select-options li').findIndex((e) => e.textContent?.trim() === text),

View File

@@ -0,0 +1,196 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render, findAll } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import clientsHandler, { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
import { getUnixTime } from 'date-fns';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
import { formatNumber } from 'core/helpers/format-number';
import { calculateAverage } from 'vault/utils/chart-helpers';
import { dateFormat } from 'core/helpers/date-format';
import { assertChart } from 'vault/tests/helpers/clients/client-count-helpers';
const START_TIME = getUnixTime(LICENSE_START);
const END_TIME = getUnixTime(STATIC_NOW);
const { statText, charts, usageStats } = CLIENT_COUNT;
module('Integration | Component | clients | Clients::Page::Acme', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
clientsHandler(this.server);
this.store = this.owner.lookup('service:store');
const activityQuery = {
start_time: { timestamp: START_TIME },
end_time: { timestamp: END_TIME },
};
// set this to 0
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.startTimestamp = START_TIME;
this.endTimestamp = END_TIME;
this.isSecretsSyncActivated = true;
this.renderComponent = () =>
render(hbs`
<Clients::Page::Acme
@activity={{this.activity}}
@versionHistory={{this.versionHistory}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@namespace={{this.countsController.ns}}
@mountPath={{this.countsController.mountPath}}
/>
`);
});
test('it should render with full month activity data charts', async function (assert) {
const monthCount = this.activity.byMonth.length;
assert.expect(8 + monthCount * 2);
const expectedTotal = formatNumber([this.activity.total.acme_clients]);
const expectedAvg = formatNumber([calculateAverage(this.activity.byMonth, 'acme_clients')]);
const expectedNewAvg = formatNumber([
calculateAverage(
this.activity.byMonth.map((m) => m?.new_clients),
'acme_clients'
),
]);
await this.renderComponent();
assert
.dom(statText('Total ACME clients'))
.hasText(
`Total ACME clients The total number of ACME requests made to Vault during this time period. ${expectedTotal}`,
`renders correct total acme stat ${expectedTotal}`
);
assert.dom(statText('Average ACME clients per month')).hasTextContaining(`${expectedAvg}`);
assert.dom(statText('Average new ACME clients per month')).hasTextContaining(`${expectedNewAvg}`);
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
withTimeZone: true,
});
assert.dom(charts.timestamp).hasText(`Updated ${formattedTimestamp}`, 'renders response timestamp');
assertChart(assert, 'ACME usage', this.activity.byMonth);
assertChart(assert, 'Monthly new', this.activity.byMonth);
});
test('it should render stats without chart for a single month', async function (assert) {
assert.expect(5);
const activityQuery = { start_time: { timestamp: END_TIME }, end_time: { timestamp: END_TIME } };
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
const expectedTotal = formatNumber([this.activity.total.acme_clients]);
await this.renderComponent();
assert.dom(charts.chart('ACME usage')).doesNotExist('total usage chart does not render');
assert.dom(charts.chart('Monthly new')).doesNotExist('monthly new chart does not render');
assert.dom(statText('Average ACME clients per month')).doesNotExist();
assert.dom(statText('Average new ACME clients per month')).doesNotExist();
assert
.dom(usageStats)
.hasText(
`ACME usage This data can be used to understand how many ACME clients have been used for the queried month. Each ACME request is counted as one client. Total ACME clients ${expectedTotal}`,
'it renders usage stats with single month copy'
);
});
// EMPTY STATES
test('it should render empty state when ACME data does not exist for a date range', async function (assert) {
assert.expect(8);
// this happens when a user queries historical data that predates the monthly breakdown (added in 1.11)
// only entity + non-entity clients existed then, so we show an empty state for ACME clients
// because the activity response just returns { acme_clients: 0 } which isn't very clear
this.activity.byMonth = [];
await this.renderComponent();
assert.dom(GENERAL.emptyStateTitle).hasText('No ACME clients');
assert
.dom(GENERAL.emptyStateMessage)
.hasText('There is no ACME client data available for this date range.');
assert.dom(charts.chart('ACME usage')).doesNotExist('vertical bar chart does not render');
assert.dom(charts.chart('Monthly new')).doesNotExist('monthly new chart does not render');
assert.dom(statText('Total ACME clients')).doesNotExist();
assert.dom(statText('Average ACME clients per month')).doesNotExist();
assert.dom(statText('Average new ACME clients per month')).doesNotExist();
assert.dom(usageStats).doesNotExist();
});
test('it should render empty state when ACME data does not exist for a single month', async function (assert) {
assert.expect(1);
const activityQuery = { start_time: { timestamp: START_TIME }, end_time: { timestamp: START_TIME } };
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.activity.byMonth = [];
await this.renderComponent();
assert.dom(GENERAL.emptyStateMessage).hasText('There is no ACME client data available for this month.');
});
test('it should render empty total usage chart when monthly counts are null or 0', async function (assert) {
assert.expect(9);
// manually stub because mirage isn't setup to handle mixed data yet
const counts = {
acme_clients: 0,
clients: 19,
entity_clients: 0,
non_entity_clients: 19,
secret_syncs: 0,
};
this.activity.byMonth = [
{
month: '3/24',
timestamp: '2024-03-01T00:00:00Z',
namespaces: [],
namespaces_by_key: {},
new_clients: {
month: '3/24',
timestamp: '2024-03-01T00:00:00Z',
namespaces: [],
},
},
{
month: '4/24',
timestamp: '2024-04-01T00:00:00Z',
...counts,
namespaces: [],
namespaces_by_key: {},
new_clients: {
month: '4/24',
timestamp: '2024-04-01T00:00:00Z',
namespaces: [],
},
},
];
this.activity.total = counts;
await this.renderComponent();
assert.dom(charts.chart('ACME usage')).exists('renders empty ACME usage chart');
assert
.dom(statText('Total ACME clients'))
.hasTextContaining('The total number of ACME requests made to Vault during this time period. 0');
findAll(`${charts.chart('ACME usage')} ${charts.xAxisLabel}`).forEach((e, i) => {
assert
.dom(e)
.hasText(
`${this.activity.byMonth[i].month}`,
`renders x-axis labels for empty bar chart: ${this.activity.byMonth[i].month}`
);
});
findAll(`${charts.chart('ACME usage')} ${charts.dataBar}`).forEach((e, i) => {
assert.dom(e).isNotVisible(`does not render data bar for: ${this.activity.byMonth[i].month}`);
});
assert.dom(charts.chart('Monthly new')).doesNotExist('empty monthly new chart does not render at all');
assert.dom(statText('Average ACME clients per month')).doesNotExist();
assert.dom(statText('Average new ACME clients per month')).doesNotExist();
});
});

View File

@@ -183,8 +183,7 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
// in the app the component is rerender on query param change
assertion = null;
await click(`${CLIENT_COUNT.counts.mountPaths} button`);
assertion = (params) => assert.true(params.ns.includes('ns/'), 'Namespace value sent on change');
assertion = (params) => assert.true(params.ns.includes('ns'), 'Namespace value sent on change');
await selectChoose(CLIENT_COUNT.counts.namespaces, '.ember-power-select-option', 0);
assertion = (params) =>

View File

@@ -18,7 +18,7 @@ import { dateFormat } from 'core/helpers/date-format';
const START_TIME = getUnixTime(LICENSE_START);
const END_TIME = getUnixTime(STATIC_NOW);
const { syncTab, charts, usageStats } = CLIENT_COUNT;
const { statText, charts, usageStats } = CLIENT_COUNT;
module('Integration | Component | clients | Clients::Page::Sync', function (hooks) {
setupRenderingTest(hooks);
@@ -57,13 +57,13 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
const expectedAvg = formatNumber([calculateAverage(this.activity.byMonth, 'secret_syncs')]);
await this.renderComponent();
assert
.dom(syncTab.total)
.dom(statText('Total sync clients'))
.hasText(
`Total sync clients The total number of secrets synced from Vault to other destinations during this date range. ${expectedTotal}`,
`renders correct total sync stat ${expectedTotal}`
);
assert
.dom(syncTab.average)
.dom(statText('Average sync clients per month'))
.hasText(
`Average sync clients per month ${expectedAvg}`,
`renders correct average sync stat ${expectedAvg}`
@@ -104,8 +104,10 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
withTimeZone: true,
});
assert.dom(charts.timestamp).hasText(`Updated ${formattedTimestamp}`, 'renders timestamp');
assert.dom(syncTab.total).doesNotExist('total sync counts does not exist');
assert.dom(syncTab.average).doesNotExist('average sync client counts does not exist');
assert.dom(statText('Total sync clients')).doesNotExist('total sync counts does not exist');
assert
.dom(statText('Average sync clients per month'))
.doesNotExist('average sync client counts does not exist');
});
test('it should render stats without chart for a single month', async function (assert) {
@@ -122,8 +124,10 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
`Secrets sync usage This data can be used to understand how many secrets sync clients have been used for this date range. Each Vault secret that is synced to at least one destination counts as one Vault client. Total sync clients ${total}`,
'renders sync stats instead of chart'
);
assert.dom(syncTab.total).doesNotExist('total sync counts does not exist');
assert.dom(syncTab.average).doesNotExist('average sync client counts does not exist');
assert.dom(statText('Average total clients per month')).doesNotExist('total sync counts does not exist');
assert
.dom(statText('Average sync clients per month'))
.doesNotExist('average sync client counts does not exist');
});
test('it should render an empty state if secrets sync is not activated', async function (assert) {
@@ -138,8 +142,8 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
assert.dom(GENERAL.emptyStateActions).hasText('Activate Secrets Sync');
assert.dom(charts.chart('Secrets sync usage')).doesNotExist();
assert.dom(syncTab.total).doesNotExist();
assert.dom(syncTab.average).doesNotExist();
assert.dom(statText('Total sync clients')).doesNotExist();
assert.dom(statText('Average sync clients per month')).doesNotExist();
});
test('it should render an empty chart if secrets sync is activated but no secrets synced', async function (assert) {
@@ -180,10 +184,12 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
await this.renderComponent();
assert
.dom(syncTab.total)
.dom(statText('Total sync clients'))
.hasText(
'Total sync clients The total number of secrets synced from Vault to other destinations during this date range. 0'
);
assert.dom(syncTab.average).doesNotExist('Does not render average if the calculation is 0');
assert
.dom(statText('Average sync clients per month'))
.doesNotExist('Does not render average if the calculation is 0');
});
});

View File

@@ -161,8 +161,12 @@ module('Integration | Component | clients | Page::Token', function (hooks) {
assert.dom(`${chart} ${CLIENT_COUNT.charts.verticalBar}`).doesNotExist('Chart does not render');
assert.dom(`${chart} ${CLIENT_COUNT.charts.legend}`).doesNotExist('Legend does not render');
assert.dom(GENERAL.emptyStateTitle).hasText('No new clients');
assert.dom(CLIENT_COUNT.tokenTab.entity).doesNotExist('New client counts does not exist');
assert.dom(CLIENT_COUNT.tokenTab.nonentity).doesNotExist('Average new client counts does not exist');
assert
.dom(CLIENT_COUNT.statText('Average new entity clients per month'))
.doesNotExist('New client counts does not exist');
assert
.dom(CLIENT_COUNT.statText('Average new non-entity clients per month'))
.doesNotExist('Average new client counts does not exist');
});
test('it should render usage stats', async function (assert) {

View File

@@ -25,15 +25,15 @@ module('Integration | Component | clients/usage-stats', function (hooks) {
await this.renderComponent();
assert.dom('[data-test-stat-text]').exists({ count: 3 }, 'Renders 3 Stat texts even with no data passed');
assert.dom('[data-test-stat-text="total-clients"]').exists('Total clients exists');
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('-', 'renders dash when no data');
assert.dom('[data-test-stat-text="entity-clients"]').exists('Entity clients exists');
assert.dom('[data-test-stat-text="Total clients"]').exists('Total clients exists');
assert.dom('[data-test-stat-text="Total clients"] .stat-value').hasText('-', 'renders dash when no data');
assert.dom('[data-test-stat-text="Entity clients"]').exists('Entity clients exists');
assert
.dom('[data-test-stat-text="entity-clients"] .stat-value')
.dom('[data-test-stat-text="Entity clients"] .stat-value')
.hasText('-', 'renders dash when no data');
assert.dom('[data-test-stat-text="non-entity-clients"]').exists('Non entity clients exists');
assert.dom('[data-test-stat-text="Non-entity clients"]').exists('Non entity clients exists');
assert
.dom('[data-test-stat-text="non-entity-clients"] .stat-value')
.dom('[data-test-stat-text="Non-entity clients"] .stat-value')
.hasText('-', 'renders dash when no data');
assert
.dom('a')
@@ -51,13 +51,13 @@ module('Integration | Component | clients/usage-stats', function (hooks) {
assert.dom('[data-test-stat-text]').exists({ count: 3 }, 'Renders 3 Stat texts');
assert
.dom('[data-test-stat-text="total-clients"] .stat-value')
.dom('[data-test-stat-text="Total clients"] .stat-value')
.hasText('17', 'Total clients shows passed value');
assert
.dom('[data-test-stat-text="entity-clients"] .stat-value')
.dom('[data-test-stat-text="Entity clients"] .stat-value')
.hasText('7', 'entity clients shows passed value');
assert
.dom('[data-test-stat-text="non-entity-clients"] .stat-value')
.dom('[data-test-stat-text="Non-entity clients"] .stat-value')
.hasText('10', 'non entity clients shows passed value');
});
@@ -78,7 +78,7 @@ module('Integration | Component | clients/usage-stats', function (hooks) {
assert.dom('[data-test-stat-text]').exists({ count: 4 }, 'Renders 4 Stat texts');
assert
.dom('[data-test-stat-text="secret-syncs"] .stat-value')
.dom('[data-test-stat-text="Secrets sync clients"] .stat-value')
.hasText('5', 'secrets sync clients shows passed value');
});
@@ -88,7 +88,7 @@ module('Integration | Component | clients/usage-stats', function (hooks) {
await this.renderComponent();
assert.dom('[data-test-stat-text]').exists({ count: 3 }, 'Renders 3 Stat texts');
assert.dom('[data-test-stat-text="secret-syncs"] .stat-value').doesNotExist();
assert.dom('[data-test-stat-text="Secrets sync clients"] .stat-value').doesNotExist();
});
});
});

View File

@@ -12,6 +12,7 @@ import sinon from 'sinon';
import { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
import timestamp from 'core/utils/timestamp';
import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers';
import { formatNumber } from 'core/helpers/format-number';
module('Integration | Component | dashboard/client-count-card', function (hooks) {
setupRenderingTest(hooks);
@@ -33,6 +34,8 @@ module('Integration | Component | dashboard/client-count-card', function (hooks)
test('it should display client count information', async function (assert) {
assert.expect(9);
const { months, total } = ACTIVITY_RESPONSE_STUB;
const [latestMonth] = months.slice(-1);
this.server.get('sys/internal/counters/activity', () => {
// this assertion should be hit twice, once initially and then again clicking 'refresh'
assert.true(true, 'makes request to sys/internal/counters/activity');
@@ -48,13 +51,16 @@ module('Integration | Component | dashboard/client-count-card', function (hooks)
assert
.dom('[data-test-stat-text="total-clients"] .stat-text')
.hasText('The number of clients in this billing period (Jul 2023 - Jan 2024).');
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('7,805');
assert
.dom('[data-test-stat-text="total-clients"] .stat-value')
.hasText(`${formatNumber([total.clients])}`);
assert.dom('[data-test-stat-text="new-clients"] .stat-label').hasText('New');
assert
.dom('[data-test-stat-text="new-clients"] .stat-text')
.hasText('The number of clients new to Vault in the current month.');
assert.dom('[data-test-stat-text="new-clients"] .stat-value').hasText('336');
assert
.dom('[data-test-stat-text="new-clients"] .stat-value')
.hasText(`${formatNumber([latestMonth.new_clients.counts.clients])}`);
// fires second request to /activity
await click('[data-test-refresh]');
});

View File

@@ -14,7 +14,7 @@ module('Integration | Component | StatText', function (hooks) {
test('it renders', async function (assert) {
await render(hbs`<StatText />`);
assert.dom('[data-test-stat-text-container]').exists('renders element');
assert.dom('[data-test-stat-text]').exists('renders element');
});
test('it renders passed in attributes', async function (assert) {

View File

@@ -98,11 +98,11 @@ module('Integration | Util | client count utils', function (hooks) {
assert.expect(2);
const original = { ...RESPONSE.total };
const expected = {
entity_clients: 1816,
non_entity_clients: 3117,
secret_syncs: 2672,
acme_clients: 200,
clients: 7805,
acme_clients: 9702,
clients: 35287,
entity_clients: 8258,
non_entity_clients: 8227,
secret_syncs: 9100,
};
assert.propEqual(destructureClientCounts(RESPONSE.total), expected);
assert.propEqual(RESPONSE.total, original, 'it does not modify original object');
@@ -302,6 +302,7 @@ module('Integration | Util | client count utils', function (hooks) {
entity_clients: 1,
label: 'auth/u/',
month: '4/24',
timestamp: '2024-04-01T00:00:00Z',
non_entity_clients: 0,
secret_syncs: 0,
},
@@ -321,6 +322,7 @@ module('Integration | Util | client count utils', function (hooks) {
entity_clients: 2,
label: 'no mount accessor (pre-1.10 upgrade?)',
month: '4/24',
timestamp: '2024-04-01T00:00:00Z',
non_entity_clients: 0,
secret_syncs: 0,
},
@@ -335,6 +337,7 @@ module('Integration | Util | client count utils', function (hooks) {
entity_clients: 3,
label: 'root',
month: '4/24',
timestamp: '2024-04-01T00:00:00Z',
mounts: [
{
acme_clients: 0,