diff --git a/ui/app/components/billing/date-range.hbs b/ui/app/components/billing/date-range.hbs
index 06c12b4b86..8b7c3840ca 100644
--- a/ui/app/components/billing/date-range.hbs
+++ b/ui/app/components/billing/date-range.hbs
@@ -5,17 +5,33 @@
-
+
{{#each this.dateDropdownOptions as |option|}}
{{option.label}}
{{/each}}
- Values update every 10 minutes.
- Last updated:
- {{date-format @selectedDateOption.updated_at "hh:mm:ss a"}}
+
+ {{#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}}
+
\ No newline at end of file
diff --git a/ui/app/components/billing/date-range.ts b/ui/app/components/billing/date-range.ts
index 8bbc033377..457cfb0c79 100644
--- a/ui/app/components/billing/date-range.ts
+++ b/ui/app/components/billing/date-range.ts
@@ -24,8 +24,8 @@ export default class BillingDateRange extends Component {
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;
diff --git a/ui/app/components/billing/page/overview.hbs b/ui/app/components/billing/page/overview.hbs
index c3c8bb2a45..c901a0e059 100644
--- a/ui/app/components/billing/page/overview.hbs
+++ b/ui/app/components/billing/page/overview.hbs
@@ -9,7 +9,7 @@
<:description>
- 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.
<:actions>
@@ -17,7 +17,12 @@
-
+
diff --git a/ui/app/components/billing/page/overview.ts b/ui/app/components/billing/page/overview.ts
index adbc28f923..fe1721ba8b 100644
--- a/ui/app/components/billing/page/overview.ts
+++ b/ui/app/components/billing/page/overview.ts
@@ -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.
diff --git a/ui/tests/acceptance/billing/overview-test.js b/ui/tests/acceptance/billing/overview-test.js
index a16c29f10c..c4e8e974dd 100644
--- a/ui/tests/acceptance/billing/overview-test.js
+++ b/ui/tests/acceptance/billing/overview-test.js
@@ -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();
diff --git a/ui/tests/helpers/general-selectors.ts b/ui/tests/helpers/general-selectors.ts
index da484f84d7..2a58163047 100644
--- a/ui/tests/helpers/general-selectors.ts
+++ b/ui/tests/helpers/general-selectors.ts
@@ -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}"]`,
};