diff --git a/changelog/_12548.txt b/changelog/_12548.txt
new file mode 100644
index 0000000000..4302de05d0
--- /dev/null
+++ b/changelog/_12548.txt
@@ -0,0 +1,3 @@
+```release-note:feature
+**Billing metrics dashboard**: Create a new billing dashboard with responsive layout to display metric data.
+```
diff --git a/ui/app/components/billing/date-range.hbs b/ui/app/components/billing/date-range.hbs
new file mode 100644
index 0000000000..06c12b4b86
--- /dev/null
+++ b/ui/app/components/billing/date-range.hbs
@@ -0,0 +1,21 @@
+{{!
+ Copyright IBM Corp. 2016, 2025
+ SPDX-License-Identifier: BUSL-1.1
+}}
+
+
+
+
+ {{#each this.dateDropdownOptions as |option|}}
+
+ {{option.label}}
+
+ {{/each}}
+
+ Values update every 10 minutes.
+ Last updated:
+ {{date-format @selectedDateOption.updated_at "hh:mm:ss a"}}
+
\ No newline at end of file
diff --git a/ui/app/components/billing/date-range.ts b/ui/app/components/billing/date-range.ts
new file mode 100644
index 0000000000..8bbc033377
--- /dev/null
+++ b/ui/app/components/billing/date-range.ts
@@ -0,0 +1,41 @@
+/**
+ * Copyright IBM Corp. 2016, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { dateFormat } from 'core/helpers/date-format';
+
+import type { Month } from 'vault/vault/billing/overview';
+
+interface Args {
+ months: Month[];
+ onDateChange: (selectedMonth: Month | null | undefined) => void;
+ selectedDateOption: Month | null | undefined;
+}
+
+export default class BillingDateRange extends Component {
+ get selectedDate() {
+ return this.args.selectedDateOption;
+ }
+
+ get dateDropdownOptions() {
+ 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 });
+ }
+
+ return options;
+ }
+
+ @action
+ updateSelectedDropdownOption(dropdownOption: string) {
+ const selectedDateOption: Month | undefined = this.args.months.find(
+ (option) => option.month === dropdownOption
+ );
+ this.args.onDateChange(selectedDateOption);
+ }
+}
diff --git a/ui/app/components/billing/metric-card.hbs b/ui/app/components/billing/metric-card.hbs
new file mode 100644
index 0000000000..6f0b96067d
--- /dev/null
+++ b/ui/app/components/billing/metric-card.hbs
@@ -0,0 +1,40 @@
+{{!
+ Copyright IBM Corp. 2016, 2025
+ SPDX-License-Identifier: BUSL-1.1
+}}
+
+
+
+ {{@title}}
+
+
+ {{this.description}}
+
+
+
+
+ Total
+
+ {{or this.total "0"}}
+
+ {{#each-in @metrics as |metricKey metricValue|}}
+ {{#let (this.metricDetails metricKey) as |display|}}
+
+ {{#if display.tooltipText}}
+
+ {{display.label}}
+
+
+
+
+ {{else}}
+
+ {{display.label}}
+
+ {{/if}}
+ {{or metricValue "0"}}
+
+ {{/let}}
+ {{/each-in}}
+
+
\ No newline at end of file
diff --git a/ui/app/components/billing/metric-card.ts b/ui/app/components/billing/metric-card.ts
new file mode 100644
index 0000000000..ae53ab77e7
--- /dev/null
+++ b/ui/app/components/billing/metric-card.ts
@@ -0,0 +1,79 @@
+/**
+ * Copyright IBM Corp. 2016, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { toLabel } from 'core/helpers/to-label';
+import { calculateSum } from 'vault/utils/chart-helpers';
+import { NormalizedBillingMetrics } from 'vault/utils/metrics-helpers';
+
+interface Args {
+ title: string;
+ metrics: Record;
+}
+
+export default class MetricCard extends Component {
+ get total() {
+ const sums = Object.values(this.args.metrics).filter((metric) => metric !== undefined);
+ return calculateSum(sums);
+ }
+
+ get description() {
+ switch (this.args.title) {
+ case 'Secrets':
+ return 'Highest number of static secrets, static roles, and dynamic roles managed on the cluster during the month. Secrets replicated to this cluster are not counted.';
+ case 'Credential units':
+ return 'Certificates, tokens, and other credentials issued during the month, adjusted by their duration.';
+ case 'Data protection calls':
+ return 'Total number of data elements processed.';
+ case 'Managed keys':
+ return 'Highest number of cryptographic keys managed on the cluster during the month. Keys replicated to this cluster are not counted.';
+ default:
+ return '';
+ }
+ }
+
+ metricDetailsMap: Record = {
+ [NormalizedBillingMetrics.STATIC_SECRETS_KV]: {
+ label: 'KV Secrets',
+ },
+ [NormalizedBillingMetrics.DYNAMIC_ROLES]: {
+ label: 'Dynamic roles',
+ tooltipText: 'Highest number of dynamic roles for the month',
+ },
+ [NormalizedBillingMetrics.STATIC_ROLES]: {
+ label: 'Static roles',
+ tooltipText: 'Highest number of static roles for the month',
+ },
+ [NormalizedBillingMetrics.PKI_UNITS_TOTAL]: {
+ label: 'PKI units',
+ tooltipText: 'Total number of X.509 certificates issued, normalized by their duration.',
+ },
+ [NormalizedBillingMetrics.SSH_UNITS_OTP_UNITS]: {
+ label: 'SSH OTP units',
+ tooltipText:
+ 'Total number of SSH one-time passwords issued, normalized by their duration. Each OTP is 0.0014 units.',
+ },
+ [NormalizedBillingMetrics.SSH_UNITS_CERTIFICATE_UNITS]: {
+ label: 'SSH certificate units',
+ tooltipText: 'Total number of SSH certificates issued, normalized by their duration.',
+ },
+ [NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSIT]: {
+ label: 'Transit',
+ },
+ [NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSFORM]: {
+ label: 'Transform',
+ },
+ [NormalizedBillingMetrics.MANAGED_KEYS_TOTP]: {
+ label: 'TOTP',
+ },
+ [NormalizedBillingMetrics.MANAGED_KEYS_KMSE]: {
+ label: 'KMSE',
+ },
+ };
+
+ metricDetails = (key: string): { label: string; tooltipText?: string; count?: number } => {
+ return this.metricDetailsMap[key] || { label: toLabel([key]) };
+ };
+}
diff --git a/ui/app/components/billing/page/overview.hbs b/ui/app/components/billing/page/overview.hbs
new file mode 100644
index 0000000000..43e808c311
--- /dev/null
+++ b/ui/app/components/billing/page/overview.hbs
@@ -0,0 +1,54 @@
+{{!
+ Copyright IBM Corp. 2016, 2025
+ SPDX-License-Identifier: BUSL-1.1
+}}
+
+
+ <:breadcrumbs>
+
+
+ <:description>
+
+ Data reflects usage across this Vault cluster. Billing metrics are used in license utilization.
+
+
+ <:actions>
+ {{! TODO: replace with actual documentation link once available }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Resources
+
+
+
+
+
+
+
+ Details by metric
+
+ {{#each-in this.detailsByMetric as |cardTitle cardKey|}}
+
+ {{/each-in}}
+
+
+
\ No newline at end of file
diff --git a/ui/app/components/billing/page/overview.ts b/ui/app/components/billing/page/overview.ts
new file mode 100644
index 0000000000..c19f1e1bea
--- /dev/null
+++ b/ui/app/components/billing/page/overview.ts
@@ -0,0 +1,138 @@
+/**
+ * Copyright IBM Corp. 2016, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { service } from '@ember/service';
+import { normalizeMetricData, NormalizedBillingMetrics } from 'vault/utils/metrics-helpers';
+
+import type ApiService from 'vault/services/api';
+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 default class BillingPageOverview extends Component {
+ @service declare readonly api: ApiService;
+
+ @tracked selectedDateOption: Month | null | undefined = null;
+ @tracked normalizedMetricData: NormalizedMetricsData | undefined = {};
+ @tracked months: Month[] = [];
+
+ /** Reference to the scheduled timer, used to cancel on cleanup. */
+ private _timer: ReturnType | null = null;
+
+ /** Milliseconds to wait between each poll. Updated dynamically based on API response. */
+ private _interval = 5000;
+
+ detailsByMetric = {
+ Secrets: [
+ NormalizedBillingMetrics.STATIC_SECRETS_KV,
+ NormalizedBillingMetrics.DYNAMIC_ROLES,
+ NormalizedBillingMetrics.STATIC_ROLES,
+ ],
+ 'Credential units': [
+ NormalizedBillingMetrics.PKI_UNITS_TOTAL,
+ NormalizedBillingMetrics.SSH_UNITS_OTP_UNITS,
+ NormalizedBillingMetrics.SSH_UNITS_CERTIFICATE_UNITS,
+ ],
+ 'Data protection calls': [
+ NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSFORM,
+ NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSIT,
+ ],
+ 'Managed keys': [NormalizedBillingMetrics.MANAGED_KEYS_TOTP, NormalizedBillingMetrics.MANAGED_KEYS_KMSE],
+ };
+
+ constructor(owner: unknown, args: object) {
+ super(owner, args);
+ this.startPoll();
+ }
+
+ get selectedDate() {
+ return this.selectedDateOption ?? this.months[0] ?? null;
+ }
+
+ /**
+ * 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.
+ */
+ calculatePollingInterval(updatedAt: string): number {
+ const msUntilRefresh = new Date(updatedAt).getTime() + REFRESH_PERIOD_MS - Date.now();
+ // If data is already stale, wait a full period rather than re-polling the api immediately.
+ return msUntilRefresh > 0 ? msUntilRefresh : REFRESH_PERIOD_MS;
+ }
+
+ fetchBillingMetrics = async () => {
+ const response: SystemReadBillingOverviewResponse | null | undefined =
+ await this.api.sys.systemReadBillingOverview();
+ this.months = (response?.months as Month[]) || [];
+ const updatedMonthFromSelectedMonth = this.months.find(
+ (month: Month) => month.month === this.selectedDateOption?.month
+ );
+ const updatedMonth: Month | undefined = updatedMonthFromSelectedMonth || this.months[0];
+
+ if (updatedMonth?.updated_at) {
+ this._interval = this.calculatePollingInterval(updatedMonth.updated_at);
+ }
+
+ this.onDateChange(updatedMonth ?? null);
+ return this.months;
+ };
+
+ /**
+ * Starts the polling loop, invoking fetchBillingMetrics immediately and then
+ * repeatedly on each interval. No-ops if polling is already active.
+ */
+ startPoll() {
+ if (this._timer) return;
+
+ const poll = async () => {
+ try {
+ await this.fetchBillingMetrics();
+ } catch (e) {
+ // Error fetching billing metrics
+ } finally {
+ // Schedule the next poll using the current interval value,
+ // which may have been updated by the callback.
+ this._timer = setTimeout(poll, this._interval);
+ }
+ };
+
+ poll();
+ }
+
+ /**
+ * Stops the polling loop and cancels any pending scheduled poll.
+ */
+ stopPoll() {
+ if (this._timer) {
+ clearTimeout(this._timer);
+ this._timer = null;
+ }
+ }
+
+ metricsForCard = (cardData: string[]) => {
+ const metrics: NormalizedMetricsData = {};
+ // Iterate over keys for that card's data
+ // so only relevant metrics are passed to each card
+ for (const key of cardData) {
+ metrics[key] = this.normalizedMetricData?.[key];
+ }
+
+ return metrics;
+ };
+
+ @action
+ onDateChange(dropdownOption: Month | null | undefined) {
+ this.selectedDateOption = dropdownOption;
+ this.normalizedMetricData = normalizeMetricData(dropdownOption);
+ }
+
+ willDestroy() {
+ super.willDestroy();
+ this.stopPoll();
+ }
+}
diff --git a/ui/app/components/billing/summary-card.hbs b/ui/app/components/billing/summary-card.hbs
new file mode 100644
index 0000000000..065582a48a
--- /dev/null
+++ b/ui/app/components/billing/summary-card.hbs
@@ -0,0 +1,43 @@
+{{!
+ Copyright IBM Corp. 2016, 2025
+ SPDX-License-Identifier: BUSL-1.1
+}}
+
+
+
+ {{@title}}
+
+
+ {{#each this.summaryMetricKeys as |cardKey|}}
+ {{#let (this.summaryMetric cardKey) as |summaryInfo|}}
+
+
+ {{#if summaryInfo.tooltipText}}
+
+ {{summaryInfo.label}}
+
+
+
+
+ {{else}}
+
+ {{summaryInfo.label}}
+
+ {{/if}}
+
+
+ {{#if summaryInfo.showBadge}}
+
+ {{else}}
+ {{summaryInfo.total}}
+ {{/if}}
+
+
+ {{/let}}
+ {{/each}}
+
+
\ No newline at end of file
diff --git a/ui/app/components/billing/summary-card.ts b/ui/app/components/billing/summary-card.ts
new file mode 100644
index 0000000000..bbefcc630c
--- /dev/null
+++ b/ui/app/components/billing/summary-card.ts
@@ -0,0 +1,65 @@
+/**
+ * Copyright IBM Corp. 2016, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { toLabel } from 'core/helpers/to-label';
+import { NormalizedMetricsData } from 'vault/vault/billing/overview';
+import { NormalizedBillingMetrics } from 'vault/utils/metrics-helpers';
+
+interface SummaryMetricInfo {
+ label: string;
+ tooltipText?: string;
+ count?: number;
+ showBadge?: boolean;
+ total?: number | boolean | undefined;
+}
+interface Args {
+ title: string;
+ metrics: Record;
+ normalizedMetricData: NormalizedMetricsData;
+}
+
+export default class SummaryCard extends Component {
+ summaryMetricKeys = [
+ NormalizedBillingMetrics.STATIC_SECRETS_TOTAL,
+ NormalizedBillingMetrics.PKI_UNITS_TOTAL,
+ NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TOTAL,
+ NormalizedBillingMetrics.MANAGED_KEYS_TOTAL,
+ NormalizedBillingMetrics.KMIP_USED_IN_MONTH,
+ NormalizedBillingMetrics.EXTERNAL_PLUGINS_TOTAL,
+ ];
+
+ summaryMetricMap: Record = {
+ [NormalizedBillingMetrics.STATIC_SECRETS_TOTAL]: {
+ label: 'Secrets',
+ },
+ [NormalizedBillingMetrics.PKI_UNITS_TOTAL]: {
+ label: 'PKI units',
+ },
+ [NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TOTAL]: {
+ label: 'Data protection calls',
+ },
+ [NormalizedBillingMetrics.MANAGED_KEYS_TOTAL]: {
+ label: 'Managed keys',
+ },
+ [NormalizedBillingMetrics.KMIP_USED_IN_MONTH]: {
+ label: 'KMIP',
+ tooltipText: 'Whether KMIP was enabled on the cluster at any time during the month.',
+ showBadge: true,
+ },
+ [NormalizedBillingMetrics.EXTERNAL_PLUGINS_TOTAL]: {
+ label: 'Plugins',
+ tooltipText: 'Highest number of plugins enabled on the cluster at any time during the month.',
+ },
+ };
+
+ summaryMetric = (key: string): SummaryMetricInfo => {
+ if (this.summaryMetricMap?.[key]) {
+ this.summaryMetricMap[key].total = this.args.normalizedMetricData[key];
+ }
+
+ return this.summaryMetricMap[key] || { label: toLabel([key]) };
+ };
+}
diff --git a/ui/app/router.js b/ui/app/router.js
index b8af919877..7ac7838656 100644
--- a/ui/app/router.js
+++ b/ui/app/router.js
@@ -219,6 +219,9 @@ Router.map(function () {
this.route('show', { path: '/:policy_name' });
this.route('edit', { path: '/:policy_name/edit' });
});
+ this.route('billing', function () {
+ this.route('overview');
+ });
this.route('resilience-recovery');
this.route('replication-dr-promote', function () {
this.route('details');
diff --git a/ui/app/routes/vault/cluster/billing/overview.ts b/ui/app/routes/vault/cluster/billing/overview.ts
new file mode 100644
index 0000000000..290097dc94
--- /dev/null
+++ b/ui/app/routes/vault/cluster/billing/overview.ts
@@ -0,0 +1,32 @@
+/**
+ * Copyright IBM Corp. 2016, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Route from '@ember/routing/route';
+import { service } from '@ember/service';
+
+import type ApiService from 'vault/services/api';
+import type Controller from '@ember/controller';
+import type { Breadcrumb } from 'vault/vault/app-types';
+import type { Month } from 'vault/vault/billing/overview';
+
+interface RouteController extends Controller {
+ breadcrumbs: Array;
+ pollBillingOverview: ReturnType;
+ fetchBillingMetrics: () => Promise;
+ months: Month[];
+}
+
+export default class BillingOverviewRoute extends Route {
+ @service declare readonly api: ApiService;
+
+ setupController(controller: RouteController, resolvedModel: Month[]) {
+ super.setupController(controller, resolvedModel);
+
+ controller.breadcrumbs = [
+ { label: 'Vault', route: 'vault.cluster.dashboard', icon: 'vault' },
+ { label: 'Billing metrics' },
+ ];
+ }
+}
diff --git a/ui/app/services/version.js b/ui/app/services/version.js
index 6e88582dd2..38a237a309 100644
--- a/ui/app/services/version.js
+++ b/ui/app/services/version.js
@@ -52,6 +52,11 @@ export default class VersionService extends Service {
return this.features.includes('Control Groups');
}
+ // Consumption Billing will only be present on the platform-standard module introduced in 2.0.0.
+ get hasConsumptionBilling() {
+ return this.features.includes('Consumption Billing');
+ }
+
get hasSecretsSync() {
const isEnterprise = this.isEnterprise;
const isHvdManaged = this.flags.isHvdManaged;
diff --git a/ui/app/templates/vault/cluster/billing/overview.hbs b/ui/app/templates/vault/cluster/billing/overview.hbs
new file mode 100644
index 0000000000..355f091f07
--- /dev/null
+++ b/ui/app/templates/vault/cluster/billing/overview.hbs
@@ -0,0 +1,8 @@
+{{!
+ Copyright IBM Corp. 2016, 2025
+ SPDX-License-Identifier: BUSL-1.1
+}}
+
+
+
+{{outlet}}
\ No newline at end of file
diff --git a/ui/app/utils/metrics-helpers.ts b/ui/app/utils/metrics-helpers.ts
new file mode 100644
index 0000000000..06b03d124b
--- /dev/null
+++ b/ui/app/utils/metrics-helpers.ts
@@ -0,0 +1,79 @@
+/**
+ * Copyright IBM Corp. 2016, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import type { Month, NormalizedMetricsData } from 'vault/vault/billing/overview';
+
+export enum NormalizedBillingMetrics {
+ DATA_PROTECTION_CALLS_TRANSFORM = 'data_protection_calls_transform',
+ DATA_PROTECTION_CALLS_TRANSIT = 'data_protection_calls_transit',
+ DYNAMIC_ROLES = 'dynamic_roles',
+ KMIP_USED_IN_MONTH = 'kmip_used_in_month',
+ MANAGED_KEYS = 'managed_keys',
+ MANAGED_KEYS_KMSE = 'managed_keys_kmse',
+ MANAGED_KEYS_TOTP = 'managed_keys_totp',
+ PKI_UNITS_TOTAL = 'pki_units_total',
+ SSH_UNITS = 'ssh_units',
+ SSH_UNITS_CERTIFICATE_UNITS = 'ssh_units_certificate_units',
+ SSH_UNITS_OTP_UNITS = 'ssh_units_otp_units',
+ STATIC_ROLES = 'static_roles',
+ STATIC_SECRETS_KV = 'static_secrets_kv',
+ STATIC_SECRETS_TOTAL = 'static_secrets_total',
+ DATA_PROTECTION_CALLS_TOTAL = `data_protection_calls_total`,
+ MANAGED_KEYS_TOTAL = `managed_keys_total`,
+ EXTERNAL_PLUGINS_TOTAL = 'external_plugins_total',
+}
+
+export enum BillingMetricsKeys {
+ USED_IN_MONTH = 'used_in_month',
+ KMIP = 'kmip',
+ TOTAL = 'total',
+}
+
+export function normalizeMetricData(metric: Month | null | undefined) {
+ const { usage_metrics } = metric || {};
+ if (!usage_metrics) return;
+
+ const normalized: NormalizedMetricsData = {};
+
+ for (const metric of usage_metrics) {
+ if (metric.metric_name === 'kmip') {
+ const kmipKey = `${metric.metric_name}_used_in_month`;
+ normalized[kmipKey] = metric.metric_data.used_in_month;
+ }
+
+ const metricName = metric.metric_name;
+ const total = metric.metric_data?.total;
+
+ if (typeof total === 'number') {
+ normalized[`${metricName}_total`] = total;
+ }
+
+ for (const detail of metric.metric_data?.metric_details ?? []) {
+ // Skip detail entries that are missing a type or a numeric count — both are required to build a valid normalized key.
+ if (!detail.type || typeof detail.count !== 'number') continue;
+ // Prefix parent metric_name to detail "type" to avoid future naming collisions.
+ // For example the 'kv' type in the `metrics_details`
+ // becomes `static_secrets_kv`:
+ // {
+ // metric_name: 'static_secrets',
+ // metric_data: {
+ // total: 10,
+ // metric_details: [{ type: 'kv', count: 10 }],
+ // },
+ // },
+ const detailName = `${metricName}_${detail.type}`;
+ normalized[detailName] = detail.count;
+ }
+ }
+ // The API omits metrics that have zero usage rather than returning them with a count of 0.
+ // To avoid blank values in the UI, we explicitly set any missing metric keys to 0.
+ for (const metricsKey of Object.values(NormalizedBillingMetrics)) {
+ if (!(metricsKey in normalized)) {
+ normalized[metricsKey] = 0;
+ }
+ }
+
+ return normalized;
+}
diff --git a/ui/lib/core/addon/components/sidebar/nav/cluster.hbs b/ui/lib/core/addon/components/sidebar/nav/cluster.hbs
index ab5f4e69d3..2769c9dbcd 100644
--- a/ui/lib/core/addon/components/sidebar/nav/cluster.hbs
+++ b/ui/lib/core/addon/components/sidebar/nav/cluster.hbs
@@ -59,6 +59,14 @@
data-test-sidebar-nav-link="Client count"
/>
{{/if}}
+ {{#if this.version.hasConsumptionBilling}}
+
+ {{/if}}
{{#if (and this.cluster.usingRaft this.isRootNamespace (has-permission "status" routeParams="raft"))}}
`[data-test-metric-detail="${metricKey}"]`,
+ metricDetailValue: (metricKey) => `[data-test-metric-detail-value="${metricKey}"]`,
+};
+
+module('Acceptance | billing/overview', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(async function () {
+ this.version = this.owner.lookup('service:version');
+ this.server.get('/sys/billing/overview', () => mockMetrics);
+
+ // Stub the API service
+ const api = this.owner.lookup('service:api');
+ this.billingStub = sinon.stub(api.sys, 'systemReadBillingOverview').resolves(mockMetrics);
+ });
+
+ hooks.afterEach(function () {
+ this.billingStub?.restore();
+ });
+
+ test('display billing/overview when license endpoint has consumption billing', async function (assert) {
+ this.server.get('/sys/license/features', () => ({ features: ['Consumption Billing'] }));
+ await login();
+
+ assert.dom(GENERAL.navLink('Billing metrics')).exists('Billing metrics nav link is present');
+ assert.dom(GENERAL.navLink('Billing metrics')).hasText('Billing metrics');
+ await click(GENERAL.navLink('Billing metrics'));
+ assert.strictEqual(currentURL(), '/vault/billing/overview');
+ assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Billing metrics');
+ assert
+ .dom(GENERAL.hdsPageHeaderDescription)
+ .hasText(
+ 'Data reflects usage across this Vault cluster. Billing metrics are used in license utilization.'
+ );
+ assert.dom(GENERAL.cardContainer('Summary')).exists();
+
+ assert.dom(GENERAL.cardContainer('Secrets')).exists();
+
+ 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)).exists();
+ assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.DYNAMIC_ROLES)).hasText('0');
+ assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.STATIC_ROLES)).exists();
+ assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.STATIC_ROLES)).hasText('0');
+
+ assert.dom(GENERAL.cardContainer('Credential units')).exists();
+ assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.PKI_UNITS_TOTAL)).exists();
+ assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.PKI_UNITS_TOTAL)).hasText('100.1234');
+ assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.SSH_UNITS_OTP_UNITS)).exists();
+ assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.SSH_UNITS_OTP_UNITS)).hasText('50.1234');
+ assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.SSH_UNITS_CERTIFICATE_UNITS)).exists();
+ assert
+ .dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.SSH_UNITS_CERTIFICATE_UNITS))
+ .hasText('50.1234');
+
+ assert.dom(GENERAL.cardContainer('Data protection calls')).exists();
+ assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSFORM)).exists();
+ assert
+ .dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSFORM))
+ .hasText('0');
+ assert.dom(SELECTORS.metricDetail(NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSIT)).exists();
+ assert
+ .dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.DATA_PROTECTION_CALLS_TRANSIT))
+ .hasText('0');
+
+ 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.metricDetail(NormalizedBillingMetrics.MANAGED_KEYS_KMSE)).exists();
+ assert.dom(SELECTORS.metricDetailValue(NormalizedBillingMetrics.MANAGED_KEYS_KMSE)).hasText('30');
+ 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();
+
+ assert.dom(GENERAL.navLink('Billing metrics')).doesNotExist('Billing metrics nav link is not present');
+ await logout();
+ });
+});
diff --git a/ui/tests/unit/utils/metric-helpers-test.js b/ui/tests/unit/utils/metric-helpers-test.js
new file mode 100644
index 0000000000..9cee7f990f
--- /dev/null
+++ b/ui/tests/unit/utils/metric-helpers-test.js
@@ -0,0 +1,120 @@
+/**
+ * Copyright IBM Corp. 2016, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { normalizeMetricData } from 'vault/utils/metrics-helpers';
+import { module, test } from 'qunit';
+
+module('Unit | Utility | metric utils', function () {
+ test('normalizeMetricData returns undefined for null or undefined input', function (assert) {
+ assert.strictEqual(normalizeMetricData(null), undefined, 'Returns undefined for null input');
+ assert.strictEqual(normalizeMetricData(undefined), undefined, 'Returns undefined for undefined input');
+ });
+
+ test('normalizeMetricData returns all zeros for missing usage_metrics', function (assert) {
+ const metric = {
+ month: '2026-03',
+ updated_at: '2026-04-01T06:59:59Z',
+ usage_metrics: [
+ {
+ metric_data: {
+ metric_details: [],
+ total: 0,
+ },
+ metric_name: 'static_secrets',
+ },
+ {
+ metric_data: {
+ metric_details: [],
+ total: 0,
+ },
+ metric_name: 'dynamic_roles',
+ },
+ {
+ metric_data: {
+ metric_details: [],
+ total: 0,
+ },
+ metric_name: 'auto_rotated_roles',
+ },
+ {
+ metric_data: {
+ used_in_month: false,
+ },
+ metric_name: 'kmip',
+ },
+ {
+ metric_data: {
+ total: 0,
+ },
+ metric_name: 'external_plugins',
+ },
+ {
+ metric_data: {
+ metric_details: [],
+ total: 0,
+ },
+ metric_name: 'data_protection_calls',
+ },
+ {
+ metric_data: {
+ total: 0,
+ },
+ metric_name: 'pki_units',
+ },
+ {
+ metric_data: {
+ metric_details: [],
+ total: 0,
+ },
+ metric_name: 'managed_keys',
+ },
+ ],
+ };
+ const expected = {
+ auto_rotated_roles_total: 0,
+ data_protection_calls_total: 0,
+ data_protection_calls_transform: 0,
+ data_protection_calls_transit: 0,
+ dynamic_roles: 0,
+ dynamic_roles_total: 0,
+ external_plugins_total: 0,
+ kmip_used_in_month: false,
+ managed_keys: 0,
+ managed_keys_kmse: 0,
+ managed_keys_total: 0,
+ managed_keys_totp: 0,
+ pki_units_total: 0,
+ ssh_units: 0,
+ ssh_units_certificate_units: 0,
+ ssh_units_otp_units: 0,
+ static_roles: 0,
+ static_secrets_kv: 0,
+ static_secrets_total: 0,
+ };
+ assert.deepEqual(normalizeMetricData(metric), expected, 'Returns all zeros for missing usage_metrics');
+ });
+
+ test('normalizeMetricData handles metric_details with missing type or count', function (assert) {
+ const metric = {
+ usage_metrics: [
+ {
+ metric_name: 'static_secrets',
+ metric_data: {
+ total: 5,
+ metric_details: [
+ { type: 'kv', count: 5 },
+ { type: null, count: 3 },
+ { type: 'foo' },
+ { count: 2 },
+ ],
+ },
+ },
+ ],
+ };
+ const result = normalizeMetricData(metric);
+ assert.strictEqual(result.static_secrets_kv, 5, 'Only valid detail is included');
+ assert.strictEqual(result.static_secrets_foo, undefined, 'Detail with missing count is ignored');
+ });
+});
diff --git a/ui/types/vault/billing/overview.ts b/ui/types/vault/billing/overview.ts
new file mode 100644
index 0000000000..b1c16b5e5b
--- /dev/null
+++ b/ui/types/vault/billing/overview.ts
@@ -0,0 +1,34 @@
+/**
+ * Copyright IBM Corp. 2016, 2025
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+export enum MetricNameEnum {
+ STATIC_SECRETS = 'static_secrets',
+ DATA_PROTECTION_CALLS = 'data_protection_calls',
+ MANAGED_KEYS = 'managed_keys',
+ KMIP = 'kmip',
+ EXTERNAL_PLUGINS = 'external_plugins',
+ DYNAMIC_ROLES = 'dynamic_roles',
+ PKI_UNITS = 'pki_units',
+ SSH_UNITS = 'ssh_units',
+}
+
+export interface Month {
+ month: string;
+ updated_at: string;
+ usage_metrics: MetricData[];
+}
+
+export interface MetricData {
+ metric_name: MetricNameEnum;
+ metric_data: {
+ metric_details: Array<{ type: string; count: number }>;
+ used_in_month?: boolean;
+ total: number;
+ };
+}
+
+export interface NormalizedMetricsData {
+ [key: string]: number | boolean | undefined;
+}