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; +}