mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-07 21:36:26 +02:00
Merge remote-tracking branch 'remotes/from/ce/main'
This commit is contained in:
commit
f63543cd91
3
changelog/_12548.txt
Normal file
3
changelog/_12548.txt
Normal file
@ -0,0 +1,3 @@
|
||||
```release-note:feature
|
||||
**Billing metrics dashboard**: Create a new billing dashboard with responsive layout to display metric data.
|
||||
```
|
||||
21
ui/app/components/billing/date-range.hbs
Normal file
21
ui/app/components/billing/date-range.hbs
Normal 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>
|
||||
41
ui/app/components/billing/date-range.ts
Normal file
41
ui/app/components/billing/date-range.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
40
ui/app/components/billing/metric-card.hbs
Normal file
40
ui/app/components/billing/metric-card.hbs
Normal 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>
|
||||
79
ui/app/components/billing/metric-card.ts
Normal file
79
ui/app/components/billing/metric-card.ts
Normal 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]) };
|
||||
};
|
||||
}
|
||||
54
ui/app/components/billing/page/overview.hbs
Normal file
54
ui/app/components/billing/page/overview.hbs
Normal 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>
|
||||
138
ui/app/components/billing/page/overview.ts
Normal file
138
ui/app/components/billing/page/overview.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
43
ui/app/components/billing/summary-card.hbs
Normal file
43
ui/app/components/billing/summary-card.hbs
Normal 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>
|
||||
65
ui/app/components/billing/summary-card.ts
Normal file
65
ui/app/components/billing/summary-card.ts
Normal 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]) };
|
||||
};
|
||||
}
|
||||
@ -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');
|
||||
|
||||
32
ui/app/routes/vault/cluster/billing/overview.ts
Normal file
32
ui/app/routes/vault/cluster/billing/overview.ts
Normal 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' },
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
8
ui/app/templates/vault/cluster/billing/overview.hbs
Normal file
8
ui/app/templates/vault/cluster/billing/overview.hbs
Normal 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}}
|
||||
79
ui/app/utils/metrics-helpers.ts
Normal file
79
ui/app/utils/metrics-helpers.ts
Normal 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;
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
235
ui/tests/acceptance/billing/overview-test.js
Normal file
235
ui/tests/acceptance/billing/overview-test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
120
ui/tests/unit/utils/metric-helpers-test.js
Normal file
120
ui/tests/unit/utils/metric-helpers-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
34
ui/types/vault/billing/overview.ts
Normal file
34
ui/types/vault/billing/overview.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user