Merge remote-tracking branch 'remotes/from/ce/main'

This commit is contained in:
hc-github-team-secure-vault-core 2026-04-02 16:17:27 +00:00
commit f63543cd91
19 changed files with 1010 additions and 1 deletions

3
changelog/_12548.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
**Billing metrics dashboard**: Create a new billing dashboard with responsive layout to display metric data.
```

View File

@ -0,0 +1,21 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<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" />
{{#each this.dateDropdownOptions as |option|}}
<D.Checkmark
@selected={{eq option.value this.selectedDate.month}}
{{on "click" (fn this.updateSelectedDropdownOption 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::Layout::Flex>

View File

@ -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<Args> {
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);
}
}

View File

@ -0,0 +1,40 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Card::Container class="has-padding-m" @level="mid" @hasBorder={{true}} data-test-card-container={{@title}}>
<Hds::Text::Display @tag="h2" @size="300" @weight="medium" class="has-bottom-margin-xxs">
{{@title}}
</Hds::Text::Display>
<Hds::Text::Body @tag="p" @color="foreground-faint" class="has-bottom-margin-m">
{{this.description}}
</Hds::Text::Body>
<Hds::Layout::Flex @direction="row" @gap="16" @wrap="wrap">
<Hds::Layout::Flex::Item @basis="15%" @grow={{true}} @shrink={{true}}>
<Hds::Text::Display @tag="h3" @color="foreground-primary">
Total
</Hds::Text::Display>
<Hds::Text::Body @tag="p">{{or this.total "0"}}</Hds::Text::Body>
</Hds::Layout::Flex::Item>
{{#each-in @metrics as |metricKey metricValue|}}
{{#let (this.metricDetails metricKey) as |display|}}
<Hds::Layout::Flex::Item @basis="15%" @grow={{true}} @shrink={{true}} data-test-metric-detail={{metricKey}}>
{{#if display.tooltipText}}
<Hds::Text::Display @tag="h3" @color="foreground-primary">
{{display.label}}
<Hds::TooltipButton @text={{display.tooltipText}} aria-label={{display.label}}>
<Hds::Icon @name="info" />
</Hds::TooltipButton>
</Hds::Text::Display>
{{else}}
<Hds::Text::Display @tag="h3" @color="foreground-primary">
{{display.label}}
</Hds::Text::Display>
{{/if}}
<Hds::Text::Body @tag="p" data-test-metric-detail-value={{metricKey}}>{{or metricValue "0"}}</Hds::Text::Body>
</Hds::Layout::Flex::Item>
{{/let}}
{{/each-in}}
</Hds::Layout::Flex>
</Hds::Card::Container>

View File

@ -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<string, number>;
}
export default class MetricCard extends Component<Args> {
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<string, { label: string; tooltipText?: string }> = {
[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]) };
};
}

View File

@ -0,0 +1,54 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Header @title="Billing metrics">
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</:breadcrumbs>
<:description>
<Hds::Text::Body>
Data reflects usage across this Vault cluster. Billing metrics are used in license utilization.
</Hds::Text::Body>
</:description>
<:actions>
{{! TODO: replace with actual documentation link once available }}
<Hds::Link::Standalone @icon="docs-link" @text="Documentation" @href={{doc-link "/system/billing/certificates"}} />
</:actions>
</Page::Header>
<Billing::DateRange @months={{this.months}} @onDateChange={{this.onDateChange}} @selectedDateOption={{this.selectedDate}} />
<Hds::Layout::Flex @direction="row" @gap="16" @wrap={{true}} class="has-top-margin-s">
<Hds::Layout::Flex::Item @basis="30%" @grow={{true}} @shrink={{true}}>
<Billing::SummaryCard @title="Summary" @normalizedMetricData={{this.normalizedMetricData}} />
<Hds::Card::Container class="has-padding-m has-top-margin-m" @level="mid" @hasBorder={{true}}>
<Hds::Text::Display @tag="h2" @size="400" @weight="medium" class="has-bottom-margin-m">
Resources
</Hds::Text::Display>
<Hds::Link::Standalone
@icon="docs-link"
@text="How are billing metrics calculated?"
@iconPosition="trailing"
@href={{doc-link "/system/billing/certificates"}}
/>
<Hds::Link::Standalone
@icon="docs-link"
@text="What else can I do with these values?"
@iconPosition="trailing"
@href={{doc-link "/system/billing/certificates"}}
/>
</Hds::Card::Container>
</Hds::Layout::Flex::Item>
<Hds::Layout::Flex::Item @basis="60%" @grow={{true}} @shrink={{true}}>
<Hds::Text::Display @tag="h2" @weight="medium" @size="400" class="has-bottom-margin-s">Details by metric</Hds::Text::Display>
<Hds::Layout::Flex @direction="column" @gap="16">
{{#each-in this.detailsByMetric as |cardTitle cardKey|}}
<Billing::MetricCard @title={{cardTitle}} @metrics={{this.metricsForCard cardKey}} />
{{/each-in}}
</Hds::Layout::Flex>
</Hds::Layout::Flex::Item>
</Hds::Layout::Flex>

View File

@ -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<typeof setTimeout> | 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();
}
}

View File

@ -0,0 +1,43 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Card::Container class="has-padding-m" @level="mid" @hasBorder={{true}} data-test-card-container="Summary">
<Hds::Text::Display @tag="h2" @size="400" @weight="medium" class="has-bottom-margin-m">
{{@title}}
</Hds::Text::Display>
<Hds::Layout::Flex @direction="column" @gap="16">
{{#each this.summaryMetricKeys as |cardKey|}}
{{#let (this.summaryMetric cardKey) as |summaryInfo|}}
<Hds::Layout::Flex @direction="row" @gap="16" @alignItems="center">
<Hds::Layout::Flex::Item @basis="65%" @grow={{true}}>
{{#if summaryInfo.tooltipText}}
<Hds::Text::Display @size="200" @weight="semibold" @color="foreground-primary">
{{summaryInfo.label}}
<Hds::TooltipButton @text={{summaryInfo.tooltipText}} aria-label={{summaryInfo.label}}>
<Hds::Icon @name="info" />
</Hds::TooltipButton>
</Hds::Text::Display>
{{else}}
<Hds::Text::Display @color="foreground-primary">
{{summaryInfo.label}}
</Hds::Text::Display>
{{/if}}
</Hds::Layout::Flex::Item>
<Hds::Layout::Flex::Item @basis="35%" @grow={{false}}>
{{#if summaryInfo.showBadge}}
<Hds::Badge
@text={{if summaryInfo.total "Enabled" "Not enabled"}}
@color={{if summaryInfo.total "success" "neutral"}}
@size="small"
/>
{{else}}
<Hds::Text::Body>{{summaryInfo.total}}</Hds::Text::Body>
{{/if}}
</Hds::Layout::Flex::Item>
</Hds::Layout::Flex>
{{/let}}
{{/each}}
</Hds::Layout::Flex>
</Hds::Card::Container>

View File

@ -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<string, number>;
normalizedMetricData: NormalizedMetricsData;
}
export default class SummaryCard extends Component<Args> {
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<string, SummaryMetricInfo> = {
[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]) };
};
}

View File

@ -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');

View File

@ -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<Breadcrumb>;
pollBillingOverview: ReturnType<typeof import('ember-concurrency').task>;
fetchBillingMetrics: () => Promise<Month[]>;
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' },
];
}
}

View File

@ -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;

View File

@ -0,0 +1,8 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Billing::Page::Overview @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />
{{outlet}}

View File

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

View File

@ -59,6 +59,14 @@
data-test-sidebar-nav-link="Client count"
/>
{{/if}}
{{#if this.version.hasConsumptionBilling}}
<Nav.Link
@route="vault.cluster.billing.overview"
@text="Billing metrics"
@hasSubItems={{true}}
data-test-sidebar-nav-link="Billing metrics"
/>
{{/if}}
{{#if (and this.cluster.usingRaft this.isRootNamespace (has-permission "status" routeParams="raft"))}}
<Nav.Link
@route="vault.cluster.storage"

View File

@ -133,7 +133,8 @@ export default class NavBar extends Helper {
this.permissions.hasNavPermission('clients', 'activity') &&
!this.cluster?.dr?.isSecondary &&
!this.hasChrootNamespace &&
!this.version.hasPKIOnly
!this.version.hasPKIOnly &&
!this.version.hasConsumptionBilling
);
}

View File

@ -0,0 +1,235 @@
/**
* Copyright IBM Corp. 2016, 2025
* 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 { click, currentURL } from '@ember/test-helpers';
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.
*/
],
},
],
};
const SELECTORS = {
metricDetail: (metricKey) => `[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();
});
});

View File

@ -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');
});
});

View File

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