mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-28 04:10:44 -04:00
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
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:
commit
f9ae75ac4b
22 changed files with 856 additions and 1152 deletions
6
ui/app/components/clients/charts/carbon-chart.hbs
Normal file
6
ui/app/components/clients/charts/carbon-chart.hbs
Normal 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>
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
ui/app/modifiers/carbon-chart.ts
Normal file
65
ui/app/modifiers/carbon-chart.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
@ -49,3 +49,13 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-container-toggle {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
254
ui/tests/integration/components/charts/carbon-chart-test.js
Normal file
254
ui/tests/integration/components/charts/carbon-chart-test.js
Normal 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]}"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
2
ui/types/global.d.ts
vendored
2
ui/types/global.d.ts
vendored
|
|
@ -17,3 +17,5 @@ declare module '@icholy/duration' {
|
|||
}
|
||||
|
||||
declare module 'vault/tests/helpers/vault-keys';
|
||||
|
||||
declare module '@carbon/charts/styles.css';
|
||||
|
|
|
|||
Loading…
Reference in a new issue