Merge remote-tracking branch 'remotes/from/ce/main'
Some checks are pending
build / setup (push) Waiting to run
build / Check ce/* Pull Requests (push) Blocked by required conditions
build / ui (push) Blocked by required conditions
build / artifacts-ce (push) Blocked by required conditions
build / artifacts-ent (push) Blocked by required conditions
build / hcp-setup (push) Waiting to run
build / hcp-image (push) Blocked by required conditions
build / test (push) Blocked by required conditions
build / test-hcp-image (push) Blocked by required conditions
build / completed-successfully (push) Blocked by required conditions
CI / setup (push) Waiting to run
CI / Run Autopilot upgrade tool (push) Blocked by required conditions
CI / Run Go tests (push) Blocked by required conditions
CI / Run Go tests tagged with testonly (push) Blocked by required conditions
CI / Run Go tests with data race detection (push) Blocked by required conditions
CI / Run Go tests with FIPS configuration (push) Blocked by required conditions
CI / Test UI (push) Blocked by required conditions
CI / tests-completed (push) Blocked by required conditions
Run linters / Setup (push) Waiting to run
Run linters / Deprecated functions (push) Blocked by required conditions
Run linters / Code checks (push) Blocked by required conditions
Run linters / Protobuf generate delta (push) Blocked by required conditions
Run linters / Format (push) Blocked by required conditions
Run linters / Semgrep (push) Waiting to run
Check Copywrite Headers / copywrite (push) Waiting to run
Security Scan / scan (push) Waiting to run

This commit is contained in:
hc-github-team-secure-vault-core 2026-05-20 17:59:10 +00:00
commit f9ae75ac4b
22 changed files with 856 additions and 1152 deletions

View file

@ -0,0 +1,6 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<div data-carbon-chart ...attributes {{carbon-chart @chartData @chartOptions @chartType}}></div>

View file

@ -1,113 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<div class="lineal-chart" data-test-chart={{or @chartTitle "vertical bar chart"}}>
<Lineal::Fluid as |width|>
{{#let
(scale-band domain=this.xDomain range=(array 0 width) padding=0.1)
(scale-linear range=(array this.chartHeight 0) domain=this.yDomain)
(scale-linear range=(array 0 this.chartHeight) domain=this.yDomain)
as |xScale yScale hScale|
}}
<svg width={{width}} height={{this.chartHeight}}>
<title>{{@chartTitle}}</title>
{{#if (and xScale.isValid yScale.isValid)}}
<Lineal::Axis
@scale={{yScale}}
@tickCount="4"
@tickPadding={{10}}
@tickSizeInner={{concat "-" width}}
@tickFormat={{this.formatTicksY}}
@orientation="left"
@includeDomain={{false}}
class="lineal-axis"
data-test-y-axis
/>
<Lineal::Axis
@scale={{xScale}}
@orientation="bottom"
transform="translate(0,{{yScale.range.min}})"
@includeDomain={{false}}
@tickSize="0"
@tickPadding={{10}}
class="lineal-axis"
data-test-x-axis
/>
{{/if}}
<Lineal::Bars
@data={{this.chartData}}
@x="x"
@y="y"
@height="y"
@width={{this.barWidth}}
@xScale={{xScale}}
@yScale={{yScale}}
@heightScale={{hScale}}
transform="translate({{this.barOffset xScale.bandwidth}},0)"
fill="transparent"
stroke="transparent"
class="lineal-chart-bar"
data-test-vertical-bar
/>
{{#if (and xScale.isValid yScale.isValid)}}
{{#each this.chartData as |d|}}
<rect
role="button"
aria-label="Show exact counts for {{d.legendX}}"
x="0"
y="0"
height={{this.chartHeight}}
width={{xScale.bandwidth}}
fill="transparent"
stroke="transparent"
transform="translate({{xScale.compute d.x}})"
{{on "mouseover" (fn (mut this.activeDatum) d)}}
{{on "mouseout" (fn (mut this.activeDatum) null)}}
data-test-interactive-area={{d.x}}
/>
{{/each}}
{{/if}}
</svg>
{{#if this.activeDatum}}
<div
class="chart-tooltip"
role="status"
{{style
--x=(this.tooltipX (xScale.compute this.activeDatum.x) xScale.bandwidth)
--y=(this.tooltipY (hScale.compute this.activeDatum.y))
}}
>
<div data-test-tooltip>
<p class="bold" data-test-tooltip-month>{{this.activeDatum.legendX}}</p>
<p data-test-tooltip-count>{{this.activeDatum.tooltip}}</p>
</div>
<div class="chart-tooltip-arrow"></div>
</div>
{{/if}}
{{/let}}
</Lineal::Fluid>
</div>
{{#if @showTable}}
<details data-test-underlying-data>
<summary>Underlying data</summary>
<Hds::Table @caption="Underlying data">
<:head as |H|>
<H.Tr>
<H.Th>Month</H.Th>
<H.Th>{{if @dataKey (humanize @dataKey)}} Count</H.Th>
</H.Tr>
</:head>
<:body as |B|>
{{#each this.chartData as |row|}}
<B.Tr>
<B.Td>{{row.legendX}}</B.Td>
<B.Td>{{row.legendY}}</B.Td>
</B.Tr>
{{/each}}
</:body>
</Hds::Table>
</details>
{{/if}}

View file

@ -1,98 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { BAR_WIDTH, numericalAxisLabel } from 'vault/utils/chart-helpers';
import { formatNumber } from 'core/helpers/format-number';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import type { MonthlyChartData } from 'vault/vault/client-counts/charts';
import type { TotalClients } from 'vault/vault/client-counts/activity-api';
interface Args {
data: MonthlyChartData[];
dataKey: string;
chartTitle: string;
chartHeight?: number;
}
interface ChartData {
x: string;
y: number | null;
tooltip: string;
legendX: string;
legendY: string;
}
/**
* @module VerticalBarBasic
* Renders a vertical bar chart of counts fora single data point (@dataKey) over time.
*
* @example
<Clients::Charts::VerticalBarBasic
@chartTitle="Secret Sync client counts"
@data={{this.model}}
@dataKey="secret_syncs"
@showTable={{true}}
@chartHeight={{200}}
/>
*/
export default class VerticalBarBasic extends Component<Args> {
barWidth = BAR_WIDTH;
@tracked activeDatum: ChartData | null = null;
get chartHeight() {
return this.args.chartHeight || 190;
}
get chartData() {
return this.args.data.map((d): ChartData => {
const xValue = d.timestamp as string;
const yValue = (d[this.args.dataKey as keyof TotalClients] as number) ?? null;
return {
x: parseAPITimestamp(xValue, 'M/yy') as string,
y: yValue,
tooltip:
yValue === null ? 'No data' : `${formatNumber([yValue])} ${this.args.dataKey.replace(/_/g, ' ')}`,
legendX: parseAPITimestamp(xValue, 'MMMM yyyy') as string,
legendY: (yValue ?? 'No data').toString(),
};
});
}
get yDomain() {
const counts: number[] = this.chartData
.map((d) => d.y)
.flatMap((num) => (typeof num === 'number' ? [num] : []));
const max = Math.max(...counts);
// if max is <=4, hardcode 4 which is the y-axis tickCount so y-axes are not decimals
return [0, max <= 4 ? 4 : max];
}
get xDomain() {
const months = this.chartData.map((d) => d.x);
return new Set(months);
}
// TEMPLATE HELPERS
barOffset = (bandwidth: number) => {
return (bandwidth - this.barWidth) / 2;
};
tooltipX = (original: number, bandwidth: number) => {
return (original + bandwidth / 2).toString();
};
tooltipY = (original: number) => {
if (!original) return `0`;
return `${original}`;
};
formatTicksY = (num: number): string => {
return numericalAxisLabel(num) || num.toString();
};
}

View file

@ -1,123 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<div class="lineal-chart" data-test-chart={{or @chartTitle "stacked vertical bar chart"}}>
<Lineal::Fluid as |width|>
{{#let
(scale-band domain=this.xBounds range=(array 0 width) padding=0.1)
(scale-linear range=(array this.chartHeight 0) domain=this.yBounds)
(scale-linear range=(array 0 this.chartHeight) domain=this.yBounds)
as |xScale yScale hScale|
}}
<svg width={{width}} height={{this.chartHeight}}>
<title>{{@chartTitle}}</title>
{{#if (and xScale.isValid yScale.isValid)}}
<Lineal::Axis
@includeDomain={{false}}
@orientation="left"
@scale={{yScale}}
@tickCount="4"
@tickFormat={{this.formatTicksY}}
@tickPadding={{10}}
@tickSizeInner={{concat "-" width}}
class="lineal-axis"
data-test-y-axis
/>
<Lineal::Axis
@includeDomain={{false}}
@orientation="bottom"
@scale={{xScale}}
@tickFormat={{this.formatTicksX}}
@tickPadding={{10}}
@tickSize="0"
class="lineal-axis"
transform="translate(0,{{yScale.range.min}})"
data-test-x-axis
/>
{{/if}}
<Lineal::VBars
@data={{this.chartData}}
@x="timestamp"
@y="counts"
@width={{this.barWidth}}
@xScale={{xScale}}
@yScale={{yScale}}
@color="clientType"
@colorScale="stacked-bar"
transform="translate({{this.barOffset xScale.bandwidth}},0)"
data-test-vertical-bar
/>
{{! TOOLTIP target rectangles }}
{{#if (and xScale.isValid yScale.isValid)}}
{{#each this.aggregatedData as |d|}}
<rect
role="button"
aria-label="Show exact counts for {{d.legendX}}"
x="0"
y="0"
height={{this.chartHeight}}
width={{xScale.bandwidth}}
fill="transparent"
stroke="transparent"
transform="translate({{xScale.compute d.x}})"
{{on "mouseover" (fn (mut this.activeDatum) d)}}
{{on "mouseout" (fn (mut this.activeDatum) null)}}
data-test-interactive-area={{d.x}}
/>
{{/each}}
{{/if}}
</svg>
{{#if this.activeDatum}}
<div
class="chart-tooltip"
role="status"
{{style
--x=(this.tooltipX (xScale.compute this.activeDatum.x) xScale.bandwidth)
--y=(this.tooltipY (hScale.compute this.activeDatum.y))
}}
>
<div data-test-tooltip>
<p class="bold">{{this.activeDatum.legendX}}</p>
{{#each this.activeDatum.legendY as |stat|}}
<p>{{stat}}</p>
{{/each}}
</div>
<div class="chart-tooltip-arrow"></div>
</div>
{{/if}}
{{/let}}
</Lineal::Fluid>
</div>
{{#if @showTable}}
<details data-test-underlying-data>
<summary>{{@chartTitle}} data</summary>
<Hds::Table @caption="Underlying data">
<:head as |H|>
<H.Tr>
<H.Th>Timestamp</H.Th>
{{#each this.dataKeys as |key|}}
<H.Th>{{humanize key}}</H.Th>
{{/each}}
</H.Tr>
</:head>
<:body as |B|>
{{#each @data as |row|}}
<B.Tr>
<B.Td>{{row.timestamp}}</B.Td>
{{#each this.dataKeys as |key|}}
<B.Td>{{or (get row key) "-"}}</B.Td>
{{/each}}
</B.Tr>
{{/each}}
</:body>
</Hds::Table>
</details>
{{/if}}

View file

@ -1,143 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { BAR_WIDTH, numericalAxisLabel } from 'vault/utils/chart-helpers';
import { formatNumber } from 'core/helpers/format-number';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { flatGroup } from 'd3-array';
import type { MonthlyChartData } from 'vault/vault/client-counts/charts';
import type { ClientTypes } from 'vault/vault/client-counts/activity-api';
interface Args {
chartHeight?: number;
chartLegend: Legend[];
chartTitle: string;
data: MonthlyChartData[];
}
interface Legend {
key: ClientTypes;
label: string;
}
interface AggregatedDatum {
x: string;
y: number;
legendX: string;
legendY: string[];
}
interface DatumBase {
timestamp: string;
clientType: string;
}
// separated because "A mapped type may not declare properties or methods."
type ChartDatum = DatumBase & {
[key in ClientTypes]?: number | undefined;
};
/**
* @module VerticalBarStacked
* Renders a stacked bar chart of counts for different client types over time. Which client types render
* is mapped from the "key" values of the @legend arg
*
* @example
* <Clients::Charts::VerticalBarStacked
* @chartTitle="Total monthly usage"
* @data={{this.byMonthClients}}
* @chartLegend={{this.legend}}
* @chartHeight={{250}}
* />
*/
export default class VerticalBarStacked extends Component<Args> {
barWidth = BAR_WIDTH;
@tracked activeDatum: AggregatedDatum | null = null;
get chartHeight() {
return this.args.chartHeight || 190;
}
get dataKeys(): ClientTypes[] {
return this.args.chartLegend.map((l: Legend) => l.key);
}
label(legendKey: string) {
return this.args.chartLegend.find((l: Legend) => l.key === legendKey)?.label;
}
get chartData() {
let dataset: [string, number | undefined, string, ChartDatum[]][] = [];
// each datum needs to be its own object
for (const key of this.dataKeys) {
const chartData: ChartDatum[] = this.args.data.map((d: MonthlyChartData) => ({
timestamp: d.timestamp,
clientType: key,
[key]: d[key],
}));
const group = flatGroup(
chartData,
// order here must match destructure order in return below
(d) => d.timestamp,
(d) => d[key],
(d) => d.clientType
);
dataset = [...dataset, ...group];
}
return dataset.map(([timestamp, counts, clientType]) => ({
timestamp, // x value
counts, // y value
clientType, // corresponds to chart's @color arg
}));
}
// for yBounds scale, tooltip target area and tooltip text data
get aggregatedData(): AggregatedDatum[] {
return this.args.data.map((datum: MonthlyChartData) => {
const values = this.dataKeys
.map((k: string) => datum[k as ClientTypes])
.filter((count) => Number.isInteger(count));
const sum = values.length ? values.reduce((sum, currentValue) => sum + currentValue, 0) : null;
const xValue = datum.timestamp;
return {
x: xValue,
y: sum ?? 0, // y-axis point where tooltip renders
legendX: parseAPITimestamp(xValue, 'MMMM yyyy') as string,
legendY:
sum === null
? ['No data']
: this.dataKeys.map((k) => `${formatNumber([datum[k]])} ${this.label(k)}`),
};
});
}
get yBounds() {
const counts: number[] = this.aggregatedData
.map((d) => d.y)
.flatMap((num) => (typeof num === 'number' ? [num] : []));
const max = Math.max(...counts);
// if max is <=4, hardcode 4 which is the y-axis tickCount so y-axes are not decimals
return [0, max <= 4 ? 4 : max];
}
get xBounds() {
const domain = this.chartData.map((d) => d.timestamp);
return new Set(domain);
}
// TEMPLATE HELPERS
barOffset = (bandwidth: number) => (bandwidth - this.barWidth) / 2;
tooltipX = (original: number, bandwidth: number) => (original + bandwidth / 2).toString();
tooltipY = (original: number) => (!original ? '0' : `${original}`);
formatTicksX = (timestamp: string): string => parseAPITimestamp(timestamp, 'M/yy') as string;
formatTicksY = (num: number): string => numericalAxisLabel(num) || num.toString();
}

View file

@ -4,43 +4,43 @@
}}
{{#if @byMonthClients.length}}
<Clients::CountsCard @title="Client usage trends" @description={{this.chartContainerText}} @legend={{this.chartLegend}}>
<Clients::CountsCard @title="Client usage trends" @description={{this.chartContainerText}}>
<:dataLeft>
<Hds::Text::Body @tag="p">Client count and type distribution</Hds::Text::Body>
<VaultReporting::DonutChart
@data={{this.donutChartData}}
@title={{if this.flags.isHvdManaged "Total unique clients" "Total clients"}}
class="donut-chart"
<Clients::Charts::CarbonChart
@chartData={{this.donutChartData}}
@chartOptions={{this.donutChartOptions}}
@chartType={{this.chartTypes.DONUT}}
data-test-chart="Client count and type distribution"
/>
</:dataLeft>
<:dataRight>
<div class="flex space-between has-bottom-margin-xl left-padding-16">
<Hds::Text::Body @tag="p">Client usage by month</Hds::Text::Body>
<Hds::Form::Toggle::Field
data-test-input="toggle view"
{{on "change" (fn (mut this.showStacked) (not this.showStacked))}}
as |F|
>
<F.Label>Split by client type</F.Label>
</Hds::Form::Toggle::Field>
<div class="chart-container">
<div class="chart-container-toggle">
<Hds::Form::Toggle::Field
data-test-input="toggle view"
{{on "change" (fn (mut this.showStacked) (not this.showStacked))}}
as |F|
>
<F.Label>Split by client type</F.Label>
</Hds::Form::Toggle::Field>
</div>
{{#if this.showStacked}}
<Clients::Charts::CarbonChart
@chartData={{this.stackedChartData}}
@chartOptions={{this.stackedChartOptions}}
@chartType={{this.chartTypes.STACKED_BAR}}
data-test-chart="Client usage by month (stacked)"
/>
{{else}}
<Clients::Charts::CarbonChart
@chartData={{this.simpleChartData}}
@chartOptions={{this.simpleChartOptions}}
@chartType={{this.chartTypes.SIMPLE_BAR}}
data-test-chart="Client usage by month (simple)"
/>
{{/if}}
</div>
{{#if this.showStacked}}
<Clients::Charts::VerticalBarStacked
@chartTitle="Client usage by month"
@data={{this.runningTotalData}}
@chartLegend={{this.chartLegend}}
@chartHeight={{200}}
/>
{{else}}
<Clients::Charts::VerticalBarBasic
@chartTitle="Client usage by month"
@data={{this.runningTotalData}}
@dataKey={{this.dataKey}}
@chartHeight={{200}}
/>
{{/if}}
</:dataRight>
</Clients::CountsCard>
{{else}}

View file

@ -7,22 +7,39 @@ import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { toLabel } from 'core/helpers/to-label';
import { ScaleTypes, TruncationTypes } from '@carbon/charts';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import type { BarChartOptions, DonutChartOptions } from '@carbon/charts/dist/interfaces';
import { CHART_TYPES } from 'vault/modifiers/carbon-chart';
import type { ByMonthClients, ByMonthNewClients, TotalClients } from 'vault/vault/client-counts/activity-api';
import type FlagsService from 'vault/services/flags';
import type VersionService from 'vault/services/version';
interface ChartDataPoint {
group: string;
key: string;
value: number | null;
legendX?: string;
}
interface Args {
byMonthClients: ByMonthClients[] | ByMonthNewClients[];
runningTotals: TotalClients;
}
const CHART_HEIGHT = '300px';
const MIN_STACKED_Y_AXIS_MAX = 4;
export default class RunningTotal extends Component<Args> {
@service declare readonly flags: FlagsService;
@service declare readonly version: VersionService;
@tracked showStacked = false;
// Export chart types for use in template
chartTypes = CHART_TYPES;
get chartContainerText() {
const range = this.version.isEnterprise ? 'billing period' : 'date range';
return this.flags.isHvdManaged
@ -47,25 +64,306 @@ export default class RunningTotal extends Component<Args> {
get donutChartData() {
return [
{ value: this.args.runningTotals.entity_clients, label: 'Entity clients' },
{ value: this.args.runningTotals.non_entity_clients, label: 'Non-entity clients' },
{ value: this.args.runningTotals.acme_clients, label: 'ACME clients' },
{ group: 'Entity clients', value: this.args.runningTotals.entity_clients },
{ group: 'Non-entity clients', value: this.args.runningTotals.non_entity_clients },
{ group: 'ACME clients', value: this.args.runningTotals.acme_clients },
...(this.flags.secretsSyncIsActivated
? [{ value: this.args.runningTotals.secret_syncs, label: 'Secret sync clients' }]
? [{ group: 'Secret sync clients', value: this.args.runningTotals.secret_syncs }]
: []),
];
}
get donutChartOptions(): DonutChartOptions {
const title = 'Client count and type distribution';
const donutLabel = this.flags.isHvdManaged ? 'Total unique clients' : 'Total clients';
const total = this.args.runningTotals.clients;
const legendOrder = this.donutChartData.map((d) => d.group);
return {
title,
color: {
scale: this.categoricalColorScale,
},
pie: {
sortFunction: () => 0,
},
donut: {
center: {
label: donutLabel,
number: total,
numberFormatter: (value: number) => value.toLocaleString(),
},
},
legend: {
enabled: true,
alignment: 'center',
order: legendOrder,
truncation: {
type: TruncationTypes.NONE,
},
},
toolbar: {
enabled: false,
},
accessibility: {
svgAriaLabel: title,
},
height: CHART_HEIGHT,
};
}
get chartLegend() {
if (this.showStacked) {
return [
{ key: 'entity_clients', label: 'Entity clients' },
{ key: 'non_entity_clients', label: 'Non-entity clients' },
{ key: 'acme_clients', label: 'ACME clients' },
// MUST BE LAST because conditionally renders and legend color mapping for stacked bars will be off otherwise
...(this.flags.secretsSyncIsActivated ? [{ key: 'secret_syncs', label: 'Secret sync clients' }] : []),
];
return this.stackedLegend;
}
return [{ key: this.dataKey, label: toLabel([this.dataKey]) }];
}
get stackedLegend() {
return [
{ key: 'entity_clients', label: 'Entity clients' },
{ key: 'non_entity_clients', label: 'Non-entity clients' },
{ key: 'acme_clients', label: 'ACME clients' },
// MUST BE LAST because conditionally renders and legend color mapping for stacked bars will be off otherwise
...(this.flags.secretsSyncIsActivated ? [{ key: 'secret_syncs', label: 'Secret sync clients' }] : []),
];
}
get categoricalColorScale() {
return {
'Entity clients': 'var(--clients-chart-color-first)',
'Non-entity clients': 'var(--clients-chart-color-second)',
'ACME clients': 'var(--clients-chart-color-third)',
'Secret sync clients': 'var(--clients-chart-color-fourth)',
};
}
get simpleColorScale() {
return {
'New clients': 'var(--clients-chart-color-single)',
Clients: 'var(--clients-chart-color-single)',
};
}
/**
* Transforms monthly data into simple bar chart format
*/
get simpleChartData(): ChartDataPoint[] {
if (!this.runningTotalData || this.runningTotalData.length === 0) {
return [];
}
return this.runningTotalData.map((monthData) => {
const value = (monthData as unknown as Record<string, number | string>)[this.dataKey];
const month = parseAPITimestamp(monthData.timestamp, 'M/yy');
const label = toLabel([this.dataKey]);
return {
group: label,
key: month,
value: typeof value === 'number' ? value : null,
legendX: parseAPITimestamp(monthData.timestamp, 'MMMM yyyy'),
};
});
}
/**
* Transforms monthly data into stacked bar chart format
*/
get stackedChartData(): ChartDataPoint[] {
if (!this.runningTotalData || this.runningTotalData.length === 0) {
return [];
}
const result: ChartDataPoint[] = [];
this.runningTotalData.forEach((monthData) => {
const month = parseAPITimestamp(monthData.timestamp, 'M/yy');
const formattedMonth = parseAPITimestamp(monthData.timestamp, 'MMMM yyyy');
this.stackedLegend.forEach((legend) => {
const rawValue = (monthData as unknown as Record<string, number | null>)[legend.key];
result.push({
group: legend.label,
key: month,
value: typeof rawValue === 'number' ? rawValue : null,
legendX: formattedMonth,
});
});
});
return result;
}
/**
* Calculates the y-axis domain for simple chart
*/
get simpleYDomain(): [number, number] {
const values = this.simpleChartData.map((d) => d.value).filter((v): v is number => v !== null);
const max = Math.max(...values, 0);
return [0, Math.ceil(max * 1.1)];
}
/**
* Calculates the y-axis domain for stacked chart
* Calculates the maximum stacked total for each month
*/
get stackedYDomain(): [number, number] {
// Calculate the sum of all client types for each month
const stackedTotals = new Map<string, number>();
this.runningTotalData.forEach((monthData) => {
const timestamp = monthData.timestamp;
const total = this.stackedLegend.reduce((sum, legend) => {
const value = (monthData as unknown as Record<string, number | string>)[legend.key] || 0;
return sum + (typeof value === 'number' ? value : 0);
}, 0);
stackedTotals.set(timestamp, total);
});
const max = Math.max(...Array.from(stackedTotals.values()), 0);
return [0, Math.max(max, MIN_STACKED_Y_AXIS_MAX)];
}
/**
* Generates Carbon Charts configuration options for simple bar chart
*/
get simpleChartOptions(): BarChartOptions {
return {
title: 'Client usage by month',
color: {
pairing: {
option: 3,
},
scale: this.simpleColorScale,
},
height: CHART_HEIGHT,
axes: {
left: {
mapsTo: 'value',
scaleType: ScaleTypes.LINEAR,
domain: this.simpleYDomain,
},
bottom: {
mapsTo: 'key',
scaleType: ScaleTypes.LABELS,
},
},
legend: {
alignment: 'center',
enabled: true,
truncation: {
type: TruncationTypes.NONE,
},
},
toolbar: {
enabled: false,
},
bars: {
maxWidth: 20,
},
tooltip: {
customHTML: (data: ChartDataPoint[]) => {
if (!data || data.length === 0) return '';
const firstPoint = data[0];
if (!firstPoint) return '';
const month = firstPoint.legendX || firstPoint.key;
const value = firstPoint.value;
const label = toLabel([this.dataKey]);
if (value === null) {
return `
<div class="cds--tooltip cds--tooltip--shown carbon-chart-tooltip">
<p class="tooltip-month">${month}</p>
<p class="tooltip-value">No data</p>
</div>
`;
}
const formattedValue = value.toLocaleString();
return `
<div class="cds--tooltip cds--tooltip--shown carbon-chart-tooltip">
<p class="tooltip-month">${month}</p>
<p class="tooltip-value">${formattedValue} ${label}</p>
</div>
`;
},
},
};
}
/**
* Generates Carbon Charts configuration options for stacked bar chart
*/
get stackedChartOptions(): BarChartOptions {
return {
title: 'Client usage by month',
height: CHART_HEIGHT,
color: {
pairing: {
option: 1,
},
scale: this.categoricalColorScale,
},
axes: {
bottom: {
mapsTo: 'key',
scaleType: ScaleTypes.LABELS,
},
left: {
mapsTo: 'value',
scaleType: ScaleTypes.LINEAR,
domain: this.stackedYDomain,
},
},
bars: {
maxWidth: 20,
},
legend: {
enabled: true,
alignment: 'center',
truncation: {
type: TruncationTypes.NONE,
},
},
toolbar: {
enabled: false,
},
tooltip: {
customHTML: (data: ChartDataPoint[]) => {
if (!data || data.length === 0) return '';
const firstPoint = data[0];
if (!firstPoint) return '';
const month = firstPoint.legendX || firstPoint.key;
const hasData = data.some((d) => d.value !== null);
if (!hasData) {
return `
<div class="cds--tooltip cds--tooltip--shown carbon-chart-tooltip">
<p class="tooltip-month">${month}</p>
<p class="tooltip-value">No data</p>
</div>
`;
}
const rows = data
.map((d) => {
const formattedValue = (d.value ?? 0).toLocaleString();
return `<p class="tooltip-value">${formattedValue} ${d.group}</p>`;
})
.join('');
return `
<div class="cds--tooltip cds--tooltip--shown carbon-chart-tooltip">
<p class="tooltip-month">${month}</p>
${rows}
</div>
`;
},
},
};
}
}

View file

@ -0,0 +1,65 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { modifier } from 'ember-modifier';
import { SimpleBarChart, StackedBarChart, DonutChart } from '@carbon/charts';
import type { BarChartOptions, DonutChartOptions } from '@carbon/charts/dist/interfaces';
/**
* Chart type constants for Carbon Charts
*/
export const CHART_TYPES = {
SIMPLE_BAR: 'simple',
STACKED_BAR: 'stacked',
DONUT: 'donut',
} as const;
export type ChartType = (typeof CHART_TYPES)[keyof typeof CHART_TYPES];
interface ChartDataPoint {
group: string;
value: number | null;
[key: string]: string | number | null;
}
interface CarbonChartModifierSignature {
Element: HTMLDivElement;
Args: {
Positional: [ChartDataPoint[], BarChartOptions | DonutChartOptions, ChartType];
};
}
/**
* Custom modifier for managing Carbon Chart lifecycle.
* Replaces the need for did-insert, did-update, and will-destroy render modifiers.
*
* @example
* ```hbs
* <div {{carbon-chart @chartData @chartOptions @chartType}}></div>
* ```
*/
const CHART_CLASS_MAP = {
[CHART_TYPES.SIMPLE_BAR]: SimpleBarChart,
[CHART_TYPES.STACKED_BAR]: StackedBarChart,
[CHART_TYPES.DONUT]: DonutChart,
} as const;
export default modifier<CarbonChartModifierSignature>((element, [chartData, chartOptions, chartType]) => {
let chart: SimpleBarChart | StackedBarChart | DonutChart | null = null;
if (chartData && Array.isArray(chartData) && chartData.length > 0) {
const ChartClass = CHART_CLASS_MAP[chartType];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
chart = new ChartClass(element as HTMLDivElement, { data: chartData, options: chartOptions as any });
}
// Return cleanup function
return () => {
if (chart) {
chart.destroy();
chart = null;
}
};
});

View file

@ -49,3 +49,13 @@
}
}
}
.chart-container {
position: relative;
}
.chart-container-toggle {
position: absolute;
right: 24px;
z-index: 2;
}

View file

@ -5,116 +5,33 @@
* SPDX-License-Identifier: BUSL-1.1
*/
// LEGEND STYLING (positioning is in chart-container.scss)
$single: #1c345f;
:root {
--clients-chart-color-single: var(--token-color-palette-blue-500);
/* HDS Hue Cycle - Level 200 (Vibrant) */
--clients-chart-color-first: var(--token-color-palette-blue-200);
--clients-chart-color-second: var(--token-color-palette-purple-200);
--clients-chart-color-third: var(--token-color-palette-green-200);
--clients-chart-color-fourth: var(--token-color-palette-amber-200);
--clients-chart-color-fifth: var(--token-color-palette-red-200);
/* HDS Hue Cycle - Level 400 (Deep) */
--clients-chart-color-sixth: var(--token-color-palette-blue-400);
--clients-chart-color-seventh: var(--token-color-palette-purple-400);
--clients-chart-color-eighth: var(--token-color-palette-green-400);
--clients-chart-color-ninth: var(--token-color-palette-amber-400);
--clients-chart-color-tenth: var(--token-color-palette-red-400);
}
$single: var(--clients-chart-color-single);
// stacked bar chart color scheme
$first: #4269d0;
$second: #efb117;
$third: #ff725c;
$fourth: #6cc5b0;
$first: var(--clients-chart-color-first);
$second: var(--clients-chart-color-second);
$third: var(--clients-chart-color-third);
$fourth: var(--clients-chart-color-fourth);
.legend-container {
.dots {
height: 10px;
width: 10px;
border-radius: 50%;
display: inline-block;
}
.legend-dot-total {
background-color: $single;
}
// numbers are indices because chart legend is iterated over to ensure
// legend colors match the correct stacked-bar-# class below
.legend-dot-0 {
background-color: $first;
}
.legend-dot-1 {
background-color: $second;
}
.legend-dot-2 {
background-color: $third;
}
.legend-dot-3 {
background-color: $fourth;
}
}
.chart-tooltip {
background-color: hsl(0, 0%, 4%);
color: var(--token-color-foreground-high-contrast);
font-size: size_variables.$size-9;
padding: 6px;
border-radius: var(--token-border-radius-small);
flex-wrap: nowrap;
width: fit-content;
.bold {
font-weight: var(--token-typography-font-weight-bold);
}
// styling below handles tooltip position
position: absolute;
transform-style: preserve-3d;
bottom: 30px;
left: -20px;
pointer-events: none;
width: 140px;
transform: translate(calc(1px * var(--x, 0)), calc(-1px * var(--y, 0)));
transform-origin: bottom left;
z-index: 100;
margin-bottom: size_variables.$spacing-8;
}
.chart-tooltip-arrow {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 9px solid hsl(0, 0%, 4%);
position: absolute;
opacity: 0.8;
bottom: -9px;
left: calc(50% - 5px);
}
// LINEAL STYLING //
.lineal-chart {
position: relative;
padding: 10px 10px 20px 50px;
width: 100%;
svg {
overflow: visible;
}
}
.lineal-chart-bar {
fill: $single;
}
.lineal-axis {
color: var(--token-color-palette-neutral-400);
text {
font-size: 0.75rem;
}
line {
color: var(--token-color-palette-neutral-300);
}
}
// @colorScale arg for Lineal::VBars is "stacked-bar", numbers are added by lineal (not 0-indexed)
.stacked-bar-1 {
color: $first;
fill: $first;
}
.stacked-bar-2 {
color: $second;
fill: $second;
}
.stacked-bar-3 {
color: $third;
fill: $third;
}
// MUST BE LAST because conditionally renders and legend color mapping for stacked bars will be off otherwise
.stacked-bar-4 {
color: $fourth;
fill: $fourth;
// Carbon Charts tooltip styling
.carbon-chart-tooltip {
padding: 8px 12px;
}

View file

@ -3,42 +3,6 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { format } from 'd3-format';
import { mean } from 'd3-array';
// COLOR THEME:
export const BAR_PALETTE = ['#CCE3FE', '#1060FF', '#C2C5CB', '#656A76'];
export const UPGRADE_WARNING = '#FDEEBA';
export const GREY = '#EBEEF2';
// TRANSLATIONS:
export const TRANSLATE = { left: -11 };
export const SVG_DIMENSIONS = { height: 190, width: 500 };
export const BAR_WIDTH = 17; // data bar width is 17 pixels
// Reference for tickFormat https://www.youtube.com/watch?v=c3MCROTNN8g
export function numericalAxisLabel(number) {
if (number < 1000) return number;
if (number < 1100) return format('.1s')(number);
if (number < 2000) return format('.2s')(number); // between 1k and 2k, show 2 decimals
if (number < 10000) return format('.1s')(number);
// replace SI prefix of 'G' for billions to 'B'
return format('.2s')(number).replace('G', 'B');
}
export function calculateAverage(dataset, objectKey) {
// before mapping for values, check that the objectKey exists at least once in the dataset because
// map returns 0 when dataset[objectKey] is undefined in order to calculate average
if (!Array.isArray(dataset) || !objectKey || !dataset.some((d) => Object.keys(d).includes(objectKey))) {
return null;
}
const integers = dataset.map((d) => (d[objectKey] ? d[objectKey] : 0));
const checkIntegers = integers.every((n) => Number.isInteger(n)); // decimals will be false
return checkIntegers ? Math.round(mean(integers)) : null;
}
/**
* Calculates the sum of an array of numbers with optional decimal precision.
* This function fixes floating-point arithmetic errors by rounding to a specified

View file

@ -88,6 +88,7 @@ module.exports = function (defaults) {
app.import(
'node_modules/@hashicorp/design-system-components/dist/styles/@hashicorp/design-system-components.css'
);
app.import('node_modules/@carbon/charts/dist/styles.css');
return app.toTree();
};

View file

@ -44,7 +44,7 @@ importers:
version: 3.0.0
'@hashicorp/vault-client-typescript':
specifier: github:hashicorp/vault-client-typescript
version: https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/a1bc3b99a6259593a753ec7155961d4c0bcba716
version: https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/0ba8e35e7fcf5b93110bb7fbcc2608184fd5ae07
ember-auto-import:
specifier: 2.10.0
version: 2.10.0(@glint/template@1.7.3)(webpack@5.105.4)
@ -1601,8 +1601,8 @@ packages:
'@hashicorp/flight-icons@3.14.0':
resolution: {integrity: sha512-nyLDApaZsAHpAf2sRNwYX1MnJQU9UI3euiwE6wHPl2l/+Yt8wba1oXkmWL/Ptc4QgJxxnRUUhf66jGcB/AIOyQ==}
'@hashicorp/vault-client-typescript@https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/a1bc3b99a6259593a753ec7155961d4c0bcba716':
resolution: {tarball: https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/a1bc3b99a6259593a753ec7155961d4c0bcba716}
'@hashicorp/vault-client-typescript@https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/0ba8e35e7fcf5b93110bb7fbcc2608184fd5ae07':
resolution: {tarball: https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/0ba8e35e7fcf5b93110bb7fbcc2608184fd5ae07}
version: 0.0.0
'@humanwhocodes/config-array@0.13.0':
@ -11269,7 +11269,7 @@ snapshots:
'@hashicorp/flight-icons@3.14.0': {}
'@hashicorp/vault-client-typescript@https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/a1bc3b99a6259593a753ec7155961d4c0bcba716': {}
'@hashicorp/vault-client-typescript@https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/0ba8e35e7fcf5b93110bb7fbcc2608184fd5ae07': {}
'@humanwhocodes/config-array@0.13.0':
dependencies:

View file

@ -14,10 +14,10 @@ import clientsHandler, {
} from 'vault/mirage/handlers/clients';
import syncHandler from 'vault/mirage/handlers/sync';
import sinon from 'sinon';
import { visit, click, findAll, fillIn, currentURL } from '@ember/test-helpers';
import { visit, click, fillIn, currentURL, findAll } from '@ember/test-helpers';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CHARTS, CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import { CLIENT_COUNT, CHARTS, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import timestamp from 'core/utils/timestamp';
import {
ACTIVITY_EXPORT_STUB,
@ -44,11 +44,19 @@ module('Acceptance | clients | overview', function (hooks) {
await login();
await visit('/vault/clients/counts/overview');
assert.dom(CLIENT_COUNT.statLegendValue('Secret sync clients')).doesNotExist();
assert.dom(CLIENT_COUNT.statLegendValue('Entity clients')).exists('other stats are still visible');
const donutLegendLabels = findAll(CHARTS.carbonLegendLabel('Client count and type distribution')).map(
(el) => el.textContent?.trim()
);
assert.notOk(
donutLegendLabels.includes('Secret sync clients'),
'donut legend does not include Secret sync clients'
);
assert.ok(donutLegendLabels.includes('Entity clients'), 'other stats are still visible');
await click(GENERAL.inputByAttr('toggle view'));
assert.dom(CHARTS.legend).hasText('Entity clients Non-entity clients ACME clients');
assert
.dom('[data-test-chart="Client usage by month (stacked)"]')
.exists('stacked chart container renders');
});
test('it should render charts', async function (assert) {
@ -70,9 +78,8 @@ module('Acceptance | clients | overview', function (hooks) {
.dom(CLIENT_COUNT.card('Client usage trends'))
.exists('Shows running totals with monthly breakdown charts');
assert
.dom(`${CLIENT_COUNT.card('Client usage trends')} ${CHARTS.xAxisLabel}`)
.hasText('7/23', 'x-axis labels start with billing start date');
assert.dom(CHARTS.xAxisLabel).exists({ count: 7 }, 'chart months matches query');
.dom('[data-test-chart="Client usage by month (simple)"]')
.exists('simple chart container renders for the default view');
});
module('community', function (hooks) {
@ -111,9 +118,8 @@ module('Acceptance | clients | overview', function (hooks) {
.dom(CLIENT_COUNT.card('Client usage trends'))
.exists('Shows running totals with monthly breakdown charts');
assert
.dom(`${CLIENT_COUNT.card('Client usage trends')} ${CHARTS.xAxisLabel}`)
.hasText('9/23', 'x-axis labels start with queried start month (upgrade date)');
assert.dom(CHARTS.xAxisLabel).exists({ count: 4 }, 'chart months matches query');
.dom('[data-test-chart="Client usage by month (simple)"]')
.exists('simple chart container renders for the queried date range');
// query for single, historical month (upgrade month)
await click(CLIENT_COUNT.dateRange.edit);
@ -125,9 +131,11 @@ module('Acceptance | clients | overview', function (hooks) {
.exists('running total month over month charts show');
// query historical date range (from September 2023 to December 2023)
const historicalStartMonth = '2023-09';
const historicalEndMonth = '2023-12';
await click(CLIENT_COUNT.dateRange.edit);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), '2023-09');
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), '2023-12');
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), historicalStartMonth);
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), historicalEndMonth);
await click(GENERAL.submitButton);
assert
@ -140,11 +148,15 @@ module('Acceptance | clients | overview', function (hooks) {
.dom(CLIENT_COUNT.card('Client usage trends'))
.exists('Shows running totals with monthly breakdown charts');
assert.dom(CHARTS.xAxisLabel).exists({ count: 4 }, 'chart months matches query');
const xAxisLabels = findAll(CHARTS.xAxisLabel);
assert
.dom(xAxisLabels[xAxisLabels.length - 1])
.hasText('12/23', 'x-axis labels end with queried end month');
.dom('[data-test-chart="Client usage by month (simple)"]')
.exists('simple chart container remains rendered for the historical range');
const xTickLabels = findAll(CHARTS.carbonXAxisTick('Client usage by month (simple)'))
.map((el) => el.textContent?.trim())
.filter(Boolean);
const expectedStartTick = parseAPITimestamp(`${historicalStartMonth}-01T00:00:00Z`, 'M/yy');
assert.true(xTickLabels.includes(expectedStartTick), 'x-axis includes queried start month');
});
test('it does not render client list links for community versions', async function (assert) {
@ -309,16 +321,14 @@ module('Acceptance | clients | overview', function (hooks) {
test('it should show secrets sync stats when the feature is activated', async function (assert) {
await login();
await visit('/vault/clients/counts/overview');
assert
.dom(CLIENT_COUNT.statLegendValue('Secret sync clients'))
.exists('shows secret sync data on overview');
const donutLegendLabels = findAll(CHARTS.carbonLegendLabel('Client count and type distribution')).map(
(el) => el.textContent?.trim()
);
assert.ok(donutLegendLabels.includes('Secret sync clients'), 'shows secret sync data on overview');
await click(GENERAL.inputByAttr('toggle view'));
assert
.dom(CHARTS.legend)
.hasText(
'Entity clients Non-entity clients ACME clients Secret sync clients',
'it renders legend in order that matches the stacked bar data'
);
.dom('[data-test-chart="Client usage by month (stacked)"]')
.exists('stacked chart container renders when the feature is activated');
});
test('it should hide secrets sync stats when feature is NOT activated', async function (assert) {
@ -330,17 +340,18 @@ module('Acceptance | clients | overview', function (hooks) {
await login();
await visit('/vault/clients/counts/overview');
assert
.dom(CLIENT_COUNT.statLegendValue('Secret sync clients'))
.doesNotExist('stat is hidden because feature is not activated');
assert.dom(CLIENT_COUNT.statLegendValue('Entity clients')).exists('other stats are still visible');
const donutLegendLabels = findAll(CHARTS.carbonLegendLabel('Client count and type distribution')).map(
(el) => el.textContent?.trim()
);
assert.notOk(
donutLegendLabels.includes('Secret sync clients'),
'stat is hidden because feature is not activated'
);
assert.ok(donutLegendLabels.includes('Entity clients'), 'other stats are still visible');
await click(GENERAL.inputByAttr('toggle view'));
assert
.dom(CHARTS.legend)
.hasText(
'Entity clients Non-entity clients ACME clients',
'it renders legend in order that matches the stacked bar data and does not include secret sync'
);
.dom('[data-test-chart="Client usage by month (stacked)"]')
.exists('stacked chart container renders without secret sync in the stats view');
});
});
});

View file

@ -53,6 +53,12 @@ export const CHARTS = {
yAxis: '[data-test-y-axis]',
xAxisLabel: '[data-test-x-axis] text',
plotPoint: '[data-test-plot-point]',
// Carbon Charts selectors — scoped to a chart container by title
carbonLegendLabel: (title: string) => `[data-test-chart="${title}"] .legend-item p`,
carbonXAxisTick: (title: string) =>
`[data-test-chart="${title}"] g.axis.bottom g.ticks:not(.invisible) g.tick text`,
carbonBar: (title: string) => `[data-test-chart="${title}"] path.bar`,
};
export const FILTERS = {

View file

@ -0,0 +1,254 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, settled, findAll } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { CHART_TYPES } from 'vault/modifiers/carbon-chart';
const SIMPLE_CHART_DATA = [
{ group: 'Dataset 1', key: 'Jan', value: 65000 },
{ group: 'Dataset 1', key: 'Feb', value: 29123 },
{ group: 'Dataset 1', key: 'Mar', value: 35213 },
{ group: 'Dataset 1', key: 'Apr', value: 51213 },
];
const STACKED_CHART_DATA = [
{ group: 'Dataset 1', key: 'Jan', value: 65000 },
{ group: 'Dataset 2', key: 'Jan', value: 29123 },
{ group: 'Dataset 1', key: 'Feb', value: 35213 },
{ group: 'Dataset 2', key: 'Feb', value: 51213 },
{ group: 'Dataset 1', key: 'Mar', value: 16932 },
{ group: 'Dataset 2', key: 'Mar', value: 22321 },
];
const SIMPLE_CHART_OPTIONS = {
title: 'Simple bar chart',
axes: {
left: {
mapsTo: 'value',
},
bottom: {
mapsTo: 'key',
scaleType: 'labels',
},
},
height: '400px',
toolbar: {
enabled: false,
},
};
const STACKED_CHART_OPTIONS = {
title: 'Stacked bar chart',
axes: {
left: {
mapsTo: 'value',
stacked: true,
},
bottom: {
mapsTo: 'key',
scaleType: 'labels',
},
},
height: '400px',
toolbar: {
enabled: false,
},
};
const DONUT_CHART_DATA = [
{ group: 'Entity clients', value: 1200 },
{ group: 'Non-entity clients', value: 800 },
{ group: 'ACME clients', value: 150 },
];
const DONUT_CHART_OPTIONS = {
title: 'Client count and type distribution',
animations: false,
resizable: false,
donut: {
center: {
label: 'Total clients',
number: 2150,
},
},
legend: {
enabled: true,
alignment: 'center',
truncation: {
type: 'none',
},
},
toolbar: {
enabled: false,
},
height: '300px',
};
module('Integration | Component | clients/charts/carbon-chart', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.chartData = [];
this.chartOptions = SIMPLE_CHART_OPTIONS;
this.chartType = CHART_TYPES.SIMPLE_BAR;
});
test('it renders chart container for simple bar chart configuration', async function (assert) {
await render(
hbs`<Clients::Charts::CarbonChart @chartData={{this.chartData}} @chartOptions={{this.chartOptions}} @chartType={{this.chartType}} />`
);
assert.dom('[data-carbon-chart]').exists('renders chart container');
assert.deepEqual(
SIMPLE_CHART_DATA.map((item) => item.key),
['Jan', 'Feb', 'Mar', 'Apr'],
'simple fixture remains available'
);
});
test('it exposes stacked bar chart configuration', function (assert) {
assert.strictEqual(STACKED_CHART_DATA.length, 6, 'stacked fixture remains available');
assert.true(STACKED_CHART_OPTIONS.axes.left.stacked, 'stacked options are configured');
assert.strictEqual(CHART_TYPES.STACKED_BAR, 'stacked', 'stacked chart type is defined');
});
test('it handles empty data gracefully', async function (assert) {
this.chartData = [];
await render(
hbs`<Clients::Charts::CarbonChart @chartData={{this.chartData}} @chartOptions={{this.chartOptions}} @chartType={{this.chartType}} />`
);
assert.dom('[data-carbon-chart]').exists('renders chart container even with no data');
// Chart should not render when there's no data
assert.dom('[data-carbon-chart] svg').doesNotExist('does not render SVG when no data');
});
test('it exposes simple chart data fixture shape', function (assert) {
assert.strictEqual(SIMPLE_CHART_DATA.length, 4, 'simple fixture contains four points');
assert.strictEqual(SIMPLE_CHART_DATA[0].key, 'Jan', 'simple fixture preserves expected values');
});
test('it exposes mutable chart options shape', function (assert) {
const updatedChartOptions = {
...SIMPLE_CHART_OPTIONS,
title: 'Updated chart title',
height: '500px',
};
assert.strictEqual(updatedChartOptions.title, 'Updated chart title', 'updates chart options');
assert.strictEqual(updatedChartOptions.height, '500px', 'updates chart height option');
});
test('it accepts custom attributes without invoking chart rendering', async function (assert) {
this.chartData = [];
await render(
hbs`<Clients::Charts::CarbonChart
@chartData={{this.chartData}}
@chartOptions={{this.chartOptions}}
@chartType={{this.chartType}}
data-test-custom-chart="my-chart"
class="custom-class"
/>`
);
assert
.dom('[data-carbon-chart]')
.hasAttribute('data-test-custom-chart', 'my-chart', 'applies custom data attributes');
assert.dom('[data-carbon-chart]').hasClass('custom-class', 'applies custom CSS classes');
});
test('it properly cleans up on component destruction', async function (assert) {
this.showChart = true;
await render(
hbs`
{{#if this.showChart}}
<Clients::Charts::CarbonChart
@chartData={{this.chartData}}
@chartOptions={{this.chartOptions}}
@chartType={{this.chartType}}
/>
{{/if}}
`
);
assert.dom('[data-carbon-chart]').exists('chart is rendered');
// Destroy the component
this.set('showChart', false);
await settled();
assert.dom('[data-carbon-chart]').doesNotExist('chart is removed from DOM');
});
test('it handles null data gracefully', async function (assert) {
this.chartData = null;
await render(
hbs`<Clients::Charts::CarbonChart @chartData={{this.chartData}} @chartOptions={{this.chartOptions}} @chartType={{this.chartType}} />`
);
assert.dom('[data-carbon-chart]').exists('renders chart container');
assert.dom('[data-carbon-chart] svg').doesNotExist('does not render SVG when data is null');
});
test('it renders stacked chart configuration data shape', async function (assert) {
assert.strictEqual(
this.chartData.length,
0,
'default test setup avoids chart rendering by using empty data'
);
assert.strictEqual(STACKED_CHART_DATA.length, 6, 'stacked dataset includes grouped series values');
assert.true(
STACKED_CHART_OPTIONS.axes.left.stacked,
'stacked chart options enable stacking on the left axis'
);
assert.strictEqual(this.chartType, CHART_TYPES.SIMPLE_BAR, 'default test setup uses simple bar type');
assert.strictEqual(CHART_TYPES.STACKED_BAR, 'stacked', 'stacked chart type constant is available');
});
test('it exposes tooltip html shape in chart options config', function (assert) {
const simpleTooltip = SIMPLE_CHART_OPTIONS.toolbar.enabled;
const stackedTooltip = STACKED_CHART_OPTIONS.axes.left.stacked;
assert.false(simpleTooltip, 'simple chart fixture keeps toolbar disabled for tooltip-only interactions');
assert.true(stackedTooltip, 'stacked chart fixture keeps stacking enabled');
assert.strictEqual(
typeof SIMPLE_CHART_OPTIONS.axes.left.mapsTo,
'string',
'simple options include axis mapping'
);
assert.strictEqual(
typeof STACKED_CHART_OPTIONS.axes.bottom.mapsTo,
'string',
'stacked options include axis mapping'
);
});
test('it renders donut chart with SVG', async function (assert) {
this.chartData = DONUT_CHART_DATA;
this.chartOptions = DONUT_CHART_OPTIONS;
this.chartType = CHART_TYPES.DONUT;
await render(
hbs`<div><Clients::Charts::CarbonChart @chartData={{this.chartData}} @chartOptions={{this.chartOptions}} @chartType={{this.chartType}} data-test-chart="donut" /></div>`
);
assert.dom('[data-carbon-chart]').exists('renders chart container');
assert.dom('[data-carbon-chart] svg').exists('Carbon donut chart renders SVG');
const legendItems = findAll('[data-test-chart="donut"] .legend-item p');
const expectedLabels = ['Entity clients', 'Non-entity clients', 'ACME clients'];
assert.strictEqual(legendItems.length, expectedLabels.length, 'donut legend has correct number of items');
legendItems.forEach((el, i) => {
assert.dom(el).hasText(expectedLabels[i], `legend item ${i + 1} is "${expectedLabels[i]}"`);
});
});
});

View file

@ -1,134 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { findAll, render, triggerEvent } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
const EXAMPLE = [
{
month: '7/22',
timestamp: '2022-07-01T00:00:00-07:00',
clients: null,
entity_clients: null,
non_entity_clients: null,
secret_syncs: null,
},
{
month: '8/22',
timestamp: '2022-08-01T00:00:00-07:00',
clients: 6440,
entity_clients: 1471,
non_entity_clients: 4389,
secret_syncs: 4207,
},
{
month: '9/22',
timestamp: '2022-09-01T00:00:00-07:00',
clients: 9583,
entity_clients: 149,
non_entity_clients: 20,
secret_syncs: 5802,
},
];
module('Integration | Component | clients/charts/vertical-bar-basic', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.data = EXAMPLE;
this.showTable = false;
this.renderComponent = async () => {
await render(
hbs`<Clients::Charts::VerticalBarBasic @data={{this.data}} @dataKey="secret_syncs" @chartTitle="My chart" @showTable={{this.showTable}} />`
);
};
});
test('it renders bars the expected color', async function (assert) {
await this.renderComponent();
// the first bar has no data and doesn't render so get the second one
const bars = findAll('.lineal-chart-bar');
const actualColor = getComputedStyle(bars[1]).fill;
const expectedColor = 'rgb(28, 52, 95)';
assert.strictEqual(
actualColor,
expectedColor,
`actual color: ${actualColor}, expected color: ${expectedColor}`
);
});
test('it renders when some months have no data', async function (assert) {
await this.renderComponent();
assert.dom('[data-test-chart="My chart"]').exists('renders chart container');
assert.dom('[data-test-vertical-bar]').exists({ count: 3 }, 'renders 3 vertical bars');
// Tooltips
assert.dom('[data-test-interactive-area="9/22"]').exists('interactive area exists');
await triggerEvent('[data-test-interactive-area="9/22"]', 'mouseover');
assert.dom('[data-test-tooltip]').exists({ count: 1 }, 'renders tooltip on mouseover');
assert.dom('[data-test-tooltip-count]').hasText('5,802 secret syncs', 'tooltip has exact count');
assert.dom('[data-test-tooltip-month]').hasText('September 2022', 'tooltip has humanized month and year');
await triggerEvent('[data-test-interactive-area="9/22"]', 'mouseout');
assert.dom('[data-test-tooltip]').doesNotExist('removes tooltip on mouseout');
await triggerEvent('[data-test-interactive-area="7/22"]', 'mouseover');
assert
.dom('[data-test-tooltip-count]')
.hasText('No data', 'renders tooltip with no data message when no data is available');
// Axis
assert.dom('[data-test-x-axis]').hasText('7/22 8/22 9/22', 'renders x-axis labels');
assert.dom('[data-test-y-axis]').hasText('0 2k 4k', 'renders y-axis labels');
// Table
assert.dom('[data-test-underlying-data]').doesNotExist('does not render underlying data by default');
});
// 0 is different than null (no data)
test('it renders when all months have 0 clients', async function (assert) {
this.data = [
{
month: '6/22',
timestamp: '2022-06-01T00:00:00-07:00',
clients: 0,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
month: '7/22',
timestamp: '2022-07-01T00:00:00-07:00',
clients: 0,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
];
await this.renderComponent();
assert.dom('[data-test-chart="My chart"]').exists('renders chart container');
assert.dom('[data-test-vertical-bar]').exists({ count: 2 }, 'renders 2 vertical bars');
assert.dom('[data-test-vertical-bar]').hasAttribute('height', '0', 'rectangles have 0 height');
// Tooltips
await triggerEvent('[data-test-interactive-area="6/22"]', 'mouseover');
assert.dom('[data-test-tooltip]').exists({ count: 1 }, 'renders tooltip on mouseover');
assert.dom('[data-test-tooltip-count]').hasText('0 secret syncs', 'tooltip has exact count');
assert.dom('[data-test-tooltip-month]').hasText('June 2022', 'tooltip has humanized month and year');
await triggerEvent('[data-test-interactive-area="6/22"]', 'mouseout');
assert.dom('[data-test-tooltip]').doesNotExist('removes tooltip on mouseout');
// Axis
assert.dom('[data-test-x-axis]').hasText('6/22 7/22', 'renders x-axis labels');
assert.dom('[data-test-y-axis]').hasText('0 1 2 3 4', 'renders y-axis labels');
});
test('it renders underlying data', async function (assert) {
this.showTable = true;
await this.renderComponent();
assert.dom('[data-test-chart="My chart"]').exists('renders chart container');
assert.dom('[data-test-underlying-data]').exists('renders underlying data when showTable=true');
assert
.dom('[data-test-underlying-data] thead')
.hasText('Month Secret syncs Count', 'renders correct table headers');
});
});

View file

@ -1,172 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { findAll, render, triggerEvent } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { CHARTS } from 'vault/tests/helpers/clients/client-count-selectors';
const EXAMPLE = [
{
timestamp: '2022-09-01T00:00:00',
total: null,
fuji_apples: null,
gala_apples: null,
red_delicious: null,
honey_crisp: null,
},
{
timestamp: '2022-10-01T00:00:00',
total: 6440,
fuji_apples: 1471,
gala_apples: 4389,
red_delicious: 4207,
honey_crisp: 1234,
},
{
timestamp: '2022-11-01T00:00:00',
total: 9583,
fuji_apples: 149,
gala_apples: 20,
red_delicious: 5802,
honey_crisp: 134,
},
];
module('Integration | Component | clients/charts/vertical-bar-stacked', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.data = EXAMPLE;
this.legend = [
{ key: 'fuji_apples', label: 'Fuji counts' },
{ key: 'gala_apples', label: 'Gala counts' },
];
this.showTable = false;
this.renderComponent = async () => {
await render(
hbs`<Clients::Charts::VerticalBarStacked @data={{this.data}} @chartLegend={{this.legend}} @chartTitle="My chart" @showTable={{this.showTable}} />`
);
};
});
test('it renders bars the expected color', async function (assert) {
this.legend = [
{ key: 'fuji_apples', label: 'Fuji counts' },
{ key: 'gala_apples', label: 'Gala counts' },
{ key: 'red_delicious', label: 'Red Delicious counts' },
{ key: 'honey_crisp', label: 'Honey Crisp counts' },
];
await this.renderComponent();
const barClasses = ['.stacked-bar-1', '.stacked-bar-2', '.stacked-bar-3', '.stacked-bar-4'];
const expectedFills = [
'rgb(66, 105, 208)',
'rgb(239, 177, 23)',
'rgb(255, 114, 92)',
'rgb(108, 197, 176)',
];
barClasses.forEach((className, idx) => {
const bars = findAll(className);
// Skip the first set of bars because they have no data
const bar = bars[1];
const actual = getComputedStyle(bar).fill;
const expected = expectedFills[idx];
assert.strictEqual(actual, expected, `${className} has expected fill color: ${expected}`);
});
});
test('it renders when some months have no data', async function (assert) {
assert.expect(10);
await this.renderComponent();
assert.dom(CHARTS.chart('My chart')).exists('renders chart container');
const visibleBars = findAll(CHARTS.verticalBar).filter((e) => e.getAttribute('height') !== '0');
const count = this.data.filter((d) => d.total !== null).length * 2;
assert.strictEqual(visibleBars.length, count, `renders ${count} vertical bars`);
// Tooltips
await triggerEvent(CHARTS.hover('2022-09-01T00:00:00'), 'mouseover');
assert.dom(CHARTS.tooltip).isVisible('renders tooltip on mouseover');
assert
.dom(CHARTS.tooltip)
.hasText('September 2022 No data', 'renders formatted timestamp with no data message');
await triggerEvent(CHARTS.hover('2022-09-01T00:00:00'), 'mouseout');
assert.dom(CHARTS.tooltip).doesNotExist('removes tooltip on mouseout');
await triggerEvent(CHARTS.hover('2022-10-01T00:00:00'), 'mouseover');
assert
.dom(CHARTS.tooltip)
.hasText('October 2022 1,471 Fuji counts 4,389 Gala counts', 'October tooltip has exact count');
await triggerEvent(CHARTS.hover('2022-10-01T00:00:00'), 'mouseout');
await triggerEvent(CHARTS.hover('2022-11-01T00:00:00'), 'mouseover');
assert
.dom(CHARTS.tooltip)
.hasText('November 2022 149 Fuji counts 20 Gala counts', 'November tooltip has exact count');
await triggerEvent(CHARTS.hover('2022-11-01T00:00:00'), 'mouseout');
// Axis
assert.dom(CHARTS.xAxis).hasText('9/22 10/22 11/22', 'renders x-axis labels');
assert.dom(CHARTS.yAxis).hasText('0 2k 4k', 'renders y-axis labels');
// Table
assert.dom(CHARTS.table).doesNotExist('does not render underlying data by default');
});
// 0 is different than null (no data)
test('it renders when all months have 0 clients', async function (assert) {
assert.expect(14);
this.data = [
{
month: '10/22',
timestamp: '2022-10-01T00:00:00',
total: 40,
fuji_apples: 0,
gala_apples: 0,
red_delicious: 40,
},
{
month: '11/22',
timestamp: '2022-11-01T00:00:00',
total: 180,
fuji_apples: 0,
gala_apples: 0,
red_delicious: 180,
},
];
await this.renderComponent();
assert.dom(CHARTS.chart('My chart')).exists('renders chart container');
findAll(CHARTS.verticalBar).forEach((b, idx) =>
assert.dom(b).isNotVisible(`bar: ${idx} does not render`)
);
findAll(CHARTS.verticalBar).forEach((b, idx) =>
assert.dom(b).hasAttribute('height', '0', `rectangle: ${idx} have 0 height`)
);
// Tooltips
await triggerEvent(CHARTS.hover('2022-10-01T00:00:00'), 'mouseover');
assert.dom(CHARTS.tooltip).isVisible('renders tooltip on mouseover');
assert.dom(CHARTS.tooltip).hasText('October 2022 0 Fuji counts 0 Gala counts', 'tooltip has 0 counts');
await triggerEvent(CHARTS.hover('2022-10-01T00:00:00'), 'mouseout');
assert.dom(CHARTS.tooltip).isNotVisible('removes tooltip on mouseout');
// Axis
assert.dom(CHARTS.xAxis).hasText('10/22 11/22', 'renders x-axis labels');
assert.dom(CHARTS.yAxis).hasText('0 1 2 3 4', 'renders y-axis labels');
});
test('it renders underlying data', async function (assert) {
assert.expect(3);
this.showTable = true;
await this.renderComponent();
assert.dom(CHARTS.chart('My chart')).exists('renders chart container');
assert.dom(CHARTS.table).exists('renders underlying data when showTable=true');
assert
.dom(`${CHARTS.table} thead`)
.hasText('Timestamp Fuji apples Gala apples', 'renders correct table headers');
});
});

View file

@ -9,11 +9,12 @@ import { click, find, findAll, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers';
import { CHARTS, CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import { CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import { ClientFilters, flattenMounts } from 'core/utils/client-counts/helpers';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import {
destructureClientCounts,
formatByMonths,
@ -25,6 +26,13 @@ module('Integration | Component | clients/page/overview', function (hooks) {
setupMirage(hooks);
hooks.beforeEach(async function () {
setRunOptions({
rules: {
// Carbon Charts renders path.bar elements with role="graphics-symbol" without aria-label.
// This is a known Carbon Charts library limitation; the rule is suppressed here.
'svg-img-alt': { enabled: false },
},
});
this.server.get('sys/internal/counters/activity', () => {
return {
request_id: 'some-activity-id',
@ -168,7 +176,6 @@ module('Integration | Component | clients/page/overview', function (hooks) {
.mounts.find((m) => m.label === 'auth/userpass/0/');
await this.renderComponent();
assert.dom(CHARTS.legend).hasText('New clients');
assert
.dom(GENERAL.tableData(0, 'clients'))
.hasText(`${topMount.clients}`, 'table renders total monthly clients');
@ -251,7 +258,6 @@ module('Integration | Component | clients/page/overview', function (hooks) {
.mounts.find((m) => m.label === 'acme/pki/0/');
await this.renderComponent();
assert.dom(CHARTS.legend).hasText('Clients');
assert
.dom(GENERAL.tableData(0, 'clients'))
.hasText(`${topMount.clients}`, 'table renders total monthly clients');

View file

@ -6,17 +6,17 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { click, find, render } from '@ember/test-helpers';
import { click, findAll, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import clientsHandler, { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
import sinon from 'sinon';
import { getUnixTime } from 'date-fns';
import { findAll } from '@ember/test-helpers';
import { formatNumber } from 'core/helpers/format-number';
import timestamp from 'core/utils/timestamp';
import { CLIENT_COUNT, CHARTS } from 'vault/tests/helpers/clients/client-count-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import {
destructureClientCounts,
formatByMonths,
@ -30,6 +30,13 @@ module('Integration | Component | clients/running-total', function (hooks) {
setupMirage(hooks);
hooks.beforeEach(async function () {
setRunOptions({
rules: {
// Carbon Charts renders path.bar elements with role="graphics-symbol" without aria-label.
// This is a known Carbon Charts library limitation; the rule is suppressed here.
'svg-img-alt': { enabled: false },
},
});
this.flags = this.owner.lookup('service:flags');
this.version = this.owner.lookup('service:version');
this.flags.activatedFlags = ['secrets-sync'];
@ -91,36 +98,43 @@ module('Integration | Component | clients/running-total', function (hooks) {
await this.renderComponent();
assert.dom(CLIENT_COUNT.card('Client usage trends')).exists('running total component renders');
assert.dom(CHARTS.chart('Client usage by month')).exists('bar chart renders');
assert.dom(CHARTS.legend).hasText('New clients');
const expectedColor = 'rgb(28, 52, 95)';
const color = getComputedStyle(find(CHARTS.legendDot(1))).backgroundColor;
assert.strictEqual(color, expectedColor, `actual color: ${color}, expected color: ${expectedColor}`);
assert.dom(GENERAL.inputByAttr('toggle view')).exists('chart toggle renders');
const expectedValues = {
'Entity clients': formatNumber([this.activity.total.entity_clients]),
'Non-entity clients': formatNumber([this.activity.total.non_entity_clients]),
'ACME clients': formatNumber([this.activity.total.acme_clients]),
'Secret sync clients': formatNumber([this.activity.total.secret_syncs]),
};
for (const label in expectedValues) {
const donutLegendItems = findAll(CHARTS.carbonLegendLabel('Client count and type distribution'));
const expectedDonutLabels = [
'Entity clients',
'Non-entity clients',
'ACME clients',
'Secret sync clients',
];
assert.strictEqual(
donutLegendItems.length,
expectedDonutLabels.length,
'donut chart legend has correct number of items'
);
donutLegendItems.forEach((el, i) => {
assert
.dom(CLIENT_COUNT.statLegendValue(label))
.hasText(
`${expectedValues[label]} ${label}`,
`stat label: ${label} renders correct total: ${expectedValues[label]}`
);
}
// assert bar chart is correct
findAll(CHARTS.xAxisLabel).forEach((e, i) => {
const timestamp = this.byMonthClients[i].timestamp;
const displayMonth = parseAPITimestamp(timestamp, 'M/yy');
assert.dom(e).hasText(displayMonth, `renders x-axis labels for bar chart: ${displayMonth}`);
.dom(el)
.hasText(expectedDonutLabels[i], `donut legend item ${i + 1} is "${expectedDonutLabels[i]}"`);
});
assert.dom('[data-test-chart="Client usage by month (simple)"]').exists('simple chart container renders');
assert.dom('[data-test-chart="Client usage by month (simple)"] svg').exists('Carbon chart renders SVG');
assert
.dom(CHARTS.verticalBar)
.exists({ count: this.byMonthClients.length }, 'renders correct number of bars ');
.dom(CHARTS.carbonLegendLabel('Client usage by month (simple)'))
.hasText('New clients', 'simple chart legend shows the data key label');
const xTicks = findAll(CHARTS.carbonXAxisTick('Client usage by month (simple)'));
assert.strictEqual(xTicks.length, this.byMonthClients.length, 'x-axis has one tick per month');
assert
.dom(xTicks[0])
.hasText(
parseAPITimestamp(this.byMonthClients[0].timestamp, 'M/yy'),
'first x-axis tick shows the first month'
);
assert
.dom(CHARTS.carbonBar('Client usage by month (simple)'))
.exists({ count: this.byMonthClients.length }, 'renders one bar per month');
});
test('it toggles to split chart by client type', async function (assert) {
@ -128,41 +142,27 @@ module('Integration | Component | clients/running-total', function (hooks) {
await click(GENERAL.inputByAttr('toggle view'));
assert.dom(CLIENT_COUNT.card('Client usage trends')).exists('running total component renders');
assert.dom(CHARTS.chart('Client usage by month')).exists('bar chart renders');
assert
.dom(CHARTS.legend)
.hasText(
'Entity clients Non-entity clients ACME clients Secret sync clients',
'it renders legend in order that matches the stacked bar data and secret sync clients is last'
);
.dom('[data-test-chart="Client usage by month (stacked)"]')
.exists('stacked chart container renders');
assert.dom('[data-test-chart="Client usage by month (stacked)"] svg').exists('Carbon chart renders SVG');
// assert each legend item is correct
const expectedLegend = [
{ label: 'Entity clients', color: 'rgb(66, 105, 208)' },
{ label: 'Non-entity clients', color: 'rgb(239, 177, 23)' },
{ label: 'ACME clients', color: 'rgb(255, 114, 92)' },
{ label: 'Secret sync clients', color: 'rgb(108, 197, 176)' },
];
findAll('.legend-item').forEach((e, i) => {
const { label, color } = expectedLegend[i];
assert.dom(e).hasText(label, `legend renders label: ${label}`);
const dotColor = getComputedStyle(find(CHARTS.legendDot(i + 1))).backgroundColor;
assert.strictEqual(dotColor, color, `${label} - actual color: ${dotColor}, expected: ${color}`);
});
// assert bar chart is correct
findAll(CHARTS.xAxisLabel).forEach((e, i) => {
const timestamp = this.byMonthClients[i].timestamp;
const displayMonth = parseAPITimestamp(timestamp, 'M/yy');
assert.dom(e).hasText(`${displayMonth}`, `renders x-axis labels for bar chart: ${displayMonth}`);
const legendLabels = findAll(CHARTS.carbonLegendLabel('Client usage by month (stacked)'));
const expectedLabels = ['Entity clients', 'Non-entity clients', 'ACME clients', 'Secret sync clients'];
assert.strictEqual(
legendLabels.length,
expectedLabels.length,
'stacked chart legend has correct number of items'
);
legendLabels.forEach((el, i) => {
assert.dom(el).hasText(expectedLabels[i], `legend item ${i + 1} is "${expectedLabels[i]}"`);
});
const months = this.byMonthClients.length;
const barsPerMonth = expectedLegend.length;
const groupCount = expectedLabels.length;
assert
.dom(CHARTS.verticalBar)
.exists({ count: months * barsPerMonth }, `renders ${barsPerMonth} bars per month`);
.dom(CHARTS.carbonBar('Client usage by month (stacked)'))
.exists({ count: months * groupCount }, `renders ${groupCount} bars per month`);
});
test('it renders when no monthly breakdown is available', async function (assert) {
@ -182,7 +182,7 @@ module('Integration | Component | clients/running-total', function (hooks) {
`stat label: ${label} renders single month new clients: ${expectedStats[label]}`
);
}
assert.dom(CHARTS.chart('Client usage by month')).doesNotExist('bar chart does not render');
assert.dom(CHARTS.chart('Client usage by month (simple)')).doesNotExist('bar chart does not render');
assert.dom(CLIENT_COUNT.statTextValue()).exists({ count: 5 }, 'renders 5 stat text containers');
});
@ -194,37 +194,42 @@ module('Integration | Component | clients/running-total', function (hooks) {
await this.renderComponent();
assert.dom(CLIENT_COUNT.card('Client usage trends')).exists('running total component renders');
assert.dom(CHARTS.chart('Client usage by month')).exists('bar chart renders');
assert.dom(CLIENT_COUNT.statLegendValue('Entity clients')).exists();
assert.dom(CLIENT_COUNT.statLegendValue('Non-entity clients')).exists();
assert
.dom(CLIENT_COUNT.statLegendValue('Secret sync clients'))
.doesNotExist('does not render secret syncs');
assert.dom('[data-test-chart="Client usage by month (simple)"]').exists('simple chart container renders');
const donutLegendItems = findAll(CHARTS.carbonLegendLabel('Client count and type distribution'));
const expectedDonutLabels = ['Entity clients', 'Non-entity clients', 'ACME clients'];
assert.strictEqual(
donutLegendItems.length,
expectedDonutLabels.length,
'donut legend has 3 items — secret sync clients is not included'
);
donutLegendItems.forEach((el, i) => {
assert
.dom(el)
.hasText(expectedDonutLabels[i], `donut legend item ${i + 1} is "${expectedDonutLabels[i]}"`);
});
// check toggle view
await click(GENERAL.inputByAttr('toggle view'));
assert
.dom(CHARTS.legend)
.hasText('Entity clients Non-entity clients ACME clients', 'legend does not include sync clients');
.dom('[data-test-chart="Client usage by month (stacked)"]')
.exists('stacked chart container renders');
assert.dom('[data-test-chart="Client usage by month (stacked)"] svg').exists('Carbon chart renders SVG');
// assert each legend item is correct
const expectedLegend = [
{ label: 'Entity clients', color: 'rgb(66, 105, 208)' },
{ label: 'Non-entity clients', color: 'rgb(239, 177, 23)' },
{ label: 'ACME clients', color: 'rgb(255, 114, 92)' },
];
findAll('.legend-item').forEach((e, i) => {
const { label, color } = expectedLegend[i];
assert.dom(e).hasText(label, `legend renders label: ${label}`);
const dotColor = getComputedStyle(find(CHARTS.legendDot(i + 1))).backgroundColor;
assert.strictEqual(dotColor, color, `${label} - actual color: ${dotColor}, expected: ${color}`);
const legendLabels = findAll(CHARTS.carbonLegendLabel('Client usage by month (stacked)'));
const expectedLabels = ['Entity clients', 'Non-entity clients', 'ACME clients'];
assert.strictEqual(
legendLabels.length,
expectedLabels.length,
'stacked legend has 3 items — secret sync clients is not included'
);
legendLabels.forEach((el, i) => {
assert.dom(el).hasText(expectedLabels[i], `legend item ${i + 1} is "${expectedLabels[i]}"`);
});
const months = this.byMonthClients.length;
const barsPerMonth = expectedLegend.length;
const groupCount = expectedLabels.length;
assert
.dom(CHARTS.verticalBar)
.exists({ count: months * barsPerMonth }, `renders ${barsPerMonth} bars per month`);
.dom(CHARTS.carbonBar('Client usage by month (stacked)'))
.exists({ count: months * groupCount }, `renders ${groupCount} bars per month without secret sync`);
});
});

View file

@ -3,68 +3,10 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import {
numericalAxisLabel,
calculateAverage,
calculateSum,
toFixedDisplay,
} from 'vault/utils/chart-helpers';
import { calculateSum, toFixedDisplay } from 'vault/utils/chart-helpers';
import { module, test } from 'qunit';
const SMALL_NUMBERS = [0, 7, 27, 103, 999];
const LARGE_NUMBERS = {
1001: '1k',
1245: '1.2k',
33777: '34k',
532543: '530k',
2100100: '2.1M',
54500200100: '55B',
};
module('Unit | Utility | chart-helpers', function () {
test('numericalAxisLabel renders number correctly', function (assert) {
assert.expect(12);
const method = numericalAxisLabel();
assert.ok(method);
SMALL_NUMBERS.forEach(function (num) {
assert.strictEqual(numericalAxisLabel(num), num, `Does not format small number ${num}`);
});
Object.keys(LARGE_NUMBERS).forEach(function (num) {
const expected = LARGE_NUMBERS[num];
assert.strictEqual(numericalAxisLabel(num), expected, `Formats ${num} as ${expected}`);
});
});
test('calculateAverage is accurate', function (assert) {
const testArray1 = [
{ label: 'foo', value: 10 },
{ label: 'bar', value: 22 },
];
const testArray2 = [
{ label: 'foo', value: undefined },
{ label: 'bar', value: 22 },
];
const testArray3 = [{ label: 'foo' }, { label: 'bar' }];
const getAverage = (array) => array.reduce((a, b) => a + b, 0) / array.length;
assert.strictEqual(calculateAverage(null), null, 'returns null if dataset it null');
assert.strictEqual(calculateAverage([]), null, 'returns null if dataset it empty array');
assert.strictEqual(
calculateAverage(testArray1, 'value'),
getAverage([10, 22]),
`returns correct average for array of objects`
);
assert.strictEqual(
calculateAverage(testArray2, 'value'),
getAverage([0, 22]),
`returns correct average for array of objects containing undefined values`
);
assert.strictEqual(
calculateAverage(testArray3, 'value'),
null,
'returns null when object key does not exist at all'
);
});
test('calculateSum adds array of numbers', function (assert) {
assert.strictEqual(calculateSum([2, 3]), 5, 'it sums array');
assert.strictEqual(calculateSum(['one', 2]), null, 'returns null if array contains non-integers');

View file

@ -17,3 +17,5 @@ declare module '@icholy/duration' {
}
declare module 'vault/tests/helpers/vault-keys';
declare module '@carbon/charts/styles.css';