[UI][VAULT-44082] Billing metrics dashboard date subtext and update dashboard copy (#14174) (#14230)

* Update billing date subtext

* Update billing acceptance tests...

* Code cleanup and tests

Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-04-23 13:12:34 -04:00 committed by GitHub
parent ebe1a5d496
commit cb9e2e49bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 73 additions and 155 deletions

View File

@ -5,17 +5,33 @@
<Hds::Layout::Flex class="has-top-margin-l" @align="center">
<Hds::Dropdown @isInline={{true}} as |D|>
<D.ToggleButton @text="From start of {{date-format this.selectedDate.month 'MMMM yyyy'}}" @color="secondary" />
<D.ToggleButton
@text="{{date-format this.selectedDate.month 'MMMM yyyy'}}"
@color="secondary"
data-test-dropdown="Date range"
/>
{{#each this.dateDropdownOptions as |option|}}
<D.Checkmark
@selected={{eq option.value this.selectedDate.month}}
{{on "click" (fn this.updateSelectedDropdownOption option.value)}}
data-test-popup-menu={{option.value}}
>
{{option.label}}
</D.Checkmark>
{{/each}}
</Hds::Dropdown>
<Hds::Text::Body @tag="p" @size="200" @color="foreground-primary" class="has-left-margin-s">Values update every 10 minutes.
Last updated:
{{date-format @selectedDateOption.updated_at "hh:mm:ss a"}}</Hds::Text::Body>
<Hds::Text::Body
@tag="p"
@size="200"
@color="foreground-primary"
class="has-left-margin-s"
data-test-text-body="Last updated date time"
>
{{#if @isSelectedDateInvalid}}
No data available.
{{else}}
Values update every 10 minutes. Last updated:
{{date-format @selectedDateOption.updated_at "MMMM d, yyyy, hh:mm:ss aaa" withTimeZone=true}}
{{/if}}
</Hds::Text::Body>
</Hds::Layout::Flex>

View File

@ -24,8 +24,8 @@ export default class BillingDateRange extends Component<Args> {
const options = [];
for (const option of this.args.months) {
const formattedDate = dateFormat([option.month, 'MMM yyyy'], {});
options.push({ label: `From start of ${formattedDate}`, value: option.month });
const formattedDate = dateFormat([option.month, 'MMMM yyyy'], {});
options.push({ label: formattedDate, value: option.month });
}
return options;

View File

@ -9,7 +9,7 @@
</:breadcrumbs>
<:description>
<Hds::Text::Body>
Data reflects usage across this Vault cluster. Billing metrics are used in license utilization.
Data reflects usage across this Vault cluster. Billing metrics determine license utilization.
</Hds::Text::Body>
</:description>
<:actions>
@ -17,7 +17,12 @@
</:actions>
</Page::Header>
<Billing::DateRange @months={{this.months}} @onDateChange={{this.onDateChange}} @selectedDateOption={{this.selectedDate}} />
<Billing::DateRange
@months={{this.months}}
@onDateChange={{this.onDateChange}}
@selectedDateOption={{this.selectedDate}}
@isSelectedDateInvalid={{this.isSelectedDateInvalid}}
/>
<Hds::Layout::Flex @direction="row" @gap="16" @wrap={{true}} class="has-top-margin-s">
<Hds::Layout::Flex::Item @basis="30%" @grow={{true}} @shrink={{true}}>

View File

@ -14,6 +14,7 @@ import type { Month, NormalizedMetricsData } from 'vault/vault/billing/overview'
import type { SystemReadBillingOverviewResponse } from '@hashicorp/vault-client-typescript';
const REFRESH_PERIOD_MS = 10 * 60 * 1000 + 30 * 1000; // 10 minutes 30 seconds
export const INVALID_DATE_TIME = '0001-01-01T00:00:00Z';
export default class BillingPageOverview extends Component {
@service declare readonly api: ApiService;
@ -28,6 +29,8 @@ export default class BillingPageOverview extends Component {
/** Milliseconds to wait between each poll. Updated dynamically based on API response. */
private _interval = 5000;
invalidDateTime = INVALID_DATE_TIME;
detailsByMetric = {
Secrets: [
NormalizedBillingMetrics.STATIC_SECRETS_KV,
@ -55,6 +58,10 @@ export default class BillingPageOverview extends Component {
return this.selectedDateOption ?? this.months[0] ?? null;
}
get isSelectedDateInvalid() {
return this.selectedDate?.updated_at === this.invalidDateTime;
}
/**
* Calculates how long to wait before the next poll based on when the data was last updated.
* Waits until 10m30s after `updated_at`, so polls align with the server's refresh cadence.

View File

@ -12,144 +12,8 @@ import sinon from 'sinon';
import { login, logout } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { NormalizedBillingMetrics } from 'vault/utils/metrics-helpers';
const mockMetrics = {
months: [
{
month: '2026-02',
updated_at: '2026-01-14T10:49:00Z', // Signal partial-month data
usage_metrics: [
{
metric_name: 'static_secrets',
metric_data: {
total: 10,
metric_details: [{ type: 'kv', count: 10 }],
},
},
{
metric_name: 'dynamic_roles',
metric_data: {
total: 60,
metric_details: [
{ type: 'aws_dynamic', count: 22 },
{ type: 'azure_dynamic', count: 20 },
{ type: 'database_dynamic', count: 30 },
],
},
},
{
metric_name: 'auto_rotated_roles',
metric_data: {
total: 30,
metric_details: [
{ type: 'aws_static', count: 22 },
{ type: 'azure_static', count: 20 },
],
},
},
{ metric_name: 'kmip', metric_data: { used_in_month: true } },
{ metric_name: 'pki_units', metric_data: { total: 100.1234 } },
{ metric_name: 'data_protection_calls', metric_data: { total: 12 } },
{
metric_name: 'ssh_units',
metric_data: {
total: 100.2468,
metric_details: [
{ type: 'otp_units', count: 50.1234 },
{ type: 'certificate_units', count: 50.1234 },
],
},
},
{
metric_name: 'managed_keys',
metric_data: {
total: 82,
metric_details: [
{ type: 'totp', count: 52 },
{ type: 'kmse', count: 30 },
],
},
},
/**
* Other metrics:
* - external_plugins
* - managed_keys (types: totp, kmse)
* - data_protection_calls (types: transit, transform)
* - id_token_units (types: oidc, spiffe) // not adding in 2.0.0
*
* Additional metrics to be added for new features in Vault 1.22/2.0.
*/
],
},
{
month: '2026-01',
updated_at: '2026-01-14T10:49:00Z', // Signal partial-month data
usage_metrics: [
{
metric_name: 'static_secrets',
metric_data: {
total: 10,
metric_details: [{ type: 'kv', count: 10 }],
},
},
{
metric_name: 'dynamic_roles',
metric_data: {
total: 60,
metric_details: [
{ type: 'aws_dynamic', count: 10 },
{ type: 'azure_dynamic', count: 20 },
{ type: 'database_dynamic', count: 30 },
],
},
},
{
metric_name: 'auto_rotated_roles',
metric_data: {
total: 30,
metric_details: [
{ type: 'aws_static', count: 10 },
{ type: 'azure_static', count: 20 },
],
},
},
{ metric_name: 'kmip', metric_data: { used_in_month: true } },
{ metric_name: 'pki_units', metric_data: { total: 100.1234 } },
{ metric_name: 'data_protection_calls', metric_data: { total: 22 } },
{ metric_name: 'managed_keys', metric_data: { total: 44 } },
{
metric_name: 'ssh_units',
metric_data: {
total: 100.2468,
metric_details: [
{ type: 'otp_units', count: 50.1234 },
{ type: 'certificate_units', count: 51.22 },
],
},
},
{
metric_name: 'managed_keys',
metric_data: {
total: 82,
metric_details: [
{ type: 'totp', count: 2 },
{ type: 'kmse', count: 1 },
],
},
},
/**
* Other metrics:
* - external_plugins
* - managed_keys (types: totp, kmse)
* - data_protection_calls (types: transit, transform)
* - id_token_units (types: oidc, spiffe) // not adding in 2.0.0
*
* Additional metrics to be added for new features in Vault 1.22/2.0.
*/
],
},
],
};
import { dateFormat } from 'core/helpers/date-format';
import { METRICS_DATA_RESPONSE } from 'vault/tests/helpers/billing/stubs';
const SELECTORS = {
metricDetail: (metricKey) => `[data-test-metric-detail="${metricKey}"]`,
@ -162,11 +26,12 @@ module('Acceptance | billing/overview', function (hooks) {
hooks.beforeEach(async function () {
this.version = this.owner.lookup('service:version');
this.server.get('/sys/billing/overview', () => mockMetrics);
this.mockMetrics = METRICS_DATA_RESPONSE.data;
this.server.get('/sys/billing/overview', () => this.mockMetrics);
// Stub the API service
const api = this.owner.lookup('service:api');
this.billingStub = sinon.stub(api.sys, 'systemReadBillingOverview').resolves(mockMetrics);
this.billingStub = sinon.stub(api.sys, 'systemReadBillingOverview').resolves(this.mockMetrics);
});
hooks.afterEach(function () {
@ -185,8 +50,17 @@ module('Acceptance | billing/overview', function (hooks) {
assert
.dom(GENERAL.hdsPageHeaderDescription)
.hasText(
'Data reflects usage across this Vault cluster. Billing metrics are used in license utilization.'
'Data reflects usage across this Vault cluster. Billing metrics determine license utilization.'
);
assert.dom(GENERAL.textBody('Last updated date time')).hasText(
`Values update every 10 minutes. Last updated: ${dateFormat(
[this.mockMetrics.months[0].updated_at, 'MMMM d, yyyy, hh:mm:ss aaa'],
{
withTimeZone: true,
}
)}`
);
assert.dom(GENERAL.cardContainer('Summary')).exists();
assert.dom(GENERAL.cardContainer('Secrets')).exists();
@ -194,9 +68,9 @@ module('Acceptance | billing/overview', function (hooks) {
assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.STATIC_SECRETS_KV)).exists();
assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.STATIC_SECRETS_KV)).hasText('10');
assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.DYNAMIC_ROLES_TOTAL)).exists();
assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.DYNAMIC_ROLES_TOTAL)).hasText('60');
assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.DYNAMIC_ROLES_TOTAL)).hasText('130');
assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.AUTO_ROTATED_ROLES_TOTAL)).exists();
assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.AUTO_ROTATED_ROLES_TOTAL)).hasText('30');
assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.AUTO_ROTATED_ROLES_TOTAL)).hasText('70');
assert.dom(GENERAL.cardContainer('Credential units')).exists();
assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.PKI_UNITS_TOTAL)).exists();
@ -212,19 +86,34 @@ module('Acceptance | billing/overview', function (hooks) {
assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSFORM)).exists();
assert
.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSFORM))
.hasText('0');
.hasText('220');
assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSIT)).exists();
assert
.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSIT))
.hasText('0');
.hasText('200');
assert.dom(GENERAL.cardContainer('Managed keys')).exists();
assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.MANAGED_KEYS_TOTP)).exists();
assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.MANAGED_KEYS_TOTP)).hasText('52');
assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.MANAGED_KEYS_TOTP)).hasText('220');
assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.MANAGED_KEYS_KMSE)).exists();
assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.MANAGED_KEYS_KMSE)).hasText('30');
assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.MANAGED_KEYS_KMSE)).hasText('210');
await logout();
});
test('display no data available when updated_at is invalid', async function (assert) {
this.server.get('/sys/license/features', () => ({ features: ['Consumption Billing'] }));
const mockMetricsInvalidDate = { ...this.mockMetrics };
mockMetricsInvalidDate.months[1].updated_at = '0001-01-01T00:00:00Z';
this.server.get('/sys/billing/overview', () => mockMetricsInvalidDate);
await login();
assert.dom(GENERAL.navLink('Billing metrics')).hasText('Billing metrics');
await click(GENERAL.navLink('Billing metrics'));
await click(GENERAL.dropdownToggle('Date range'));
await click(GENERAL.menuItem('2025-12'));
assert.dom(GENERAL.textBody('Last updated date time')).hasText('No data available.');
await logout();
});
test('hide billing/overview when license endpoint does not have consumption billing', async function (assert) {
this.server.get('/sys/license/features', () => ({ features: [] }));
await login();

View File

@ -195,4 +195,5 @@ export const GENERAL = {
tooltip: (label: string) => `[data-test-tooltip="${label}"]`,
tooltipText: '.hds-tooltip-container',
textDisplay: (attr: string) => `[data-test-text-display="${attr}"]`,
textBody: (attr: string) => `[data-test-text-body="${attr}"]`,
};