UI: Remove tabs for each client count type (#31393)

* delete vertical bar grouped

* delete individual routes and tabs for client count types

* add nav-bar component to hide tab in production

* first round of test updates

* skip client list tests for now

* reogranize overview tests

* fix css color for chart

* change chart styling from grid to flex

* add split view for running total chart

* update latest colors from designs

* add changelog

* rename yield blocks to be more flexible

* add conditional for description

* delete routes
This commit is contained in:
claire bontempo 2025-08-01 11:02:06 -07:00 committed by GitHub
parent 4a2b5bf690
commit f04fe92b89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 363 additions and 2004 deletions

3
changelog/31393.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:change
ui/client-counts: removes tabs for each client count type and adds split view for counts per type in overview stacked bar chart
```

View File

@ -3,49 +3,43 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{! HBS display template for rendering title, description and stat boxes with a chart on the right }}
{{! Template displays a title and description above a flex row with a 1/3-width left element and
a 2/3-width right element. If a @legend arg exists, it renders centered below the right element }}
<div class="chart-wrapper single-chart-grid" data-test-chart-container={{@title}} ...attributes>
<div class="chart-header has-bottom-margin-xl">
<Hds::Card::Container class="chart-container" data-test-chart-container={{@title}} ...attributes>
<div class="has-bottom-margin-xl">
<h2 class="chart-title">{{@title}}</h2>
<p class="chart-description">
{{@description}}
</p>
{{#if @description}}
<p class="chart-description">{{@description}}</p>
{{/if}}
{{#if (has-block "customSubheader")}}
{{yield to="customSubheader"}}
{{/if}}
</div>
{{#if @hasChartData}}
{{#if (has-block "subTitle")}}
<div class="chart-subTitle">
{{yield to="subTitle"}}
</div>
{{/if}}
{{#if (has-block "stats")}}
{{yield to="stats"}}
{{/if}}
{{#if (has-block "chart")}}
<div class="chart-container-wide">
{{yield to="chart"}}
</div>
{{/if}}
{{#if (has-block "legend")}}
<div class="legend">
{{yield to="legend"}}
</div>
{{else if @legend}}
<div class="legend" data-test-chart-container-legend>
{{#each @legend as |legend idx|}}
<span class="legend-colors dot-{{idx}}"></span>
<span class="legend-label">{{capitalize legend.label}}</span>
{{/each}}
</div>
{{/if}}
{{else}}
<div class="chart-empty-state chart-container-wide">
{{yield to="emptyState"}}
<div class="chart-flex-row">
<div class="item-left">
{{#if (has-block "dataLeft")}}
{{yield to="dataLeft"}}
{{/if}}
</div>
{{/if}}
</div>
<div class="item-right">
{{#if (has-block "dataRight")}}
{{yield to="dataRight"}}
{{/if}}
{{#if @legend}}
<div class="legend-container" data-test-chart-container-legend>
{{#each @legend as |l|}}
<div class="legend-item">
<span class="dots legend-{{l.key}}"></span>
<Hds::Text::Body @tag="p" @size="100">{{capitalize l.label}}</Hds::Text::Body>
</div>
{{/each}}
</div>
{{/if}}
</div>
</div>
</Hds::Card::Container>

View File

@ -73,7 +73,7 @@
</svg>
{{#if this.activeDatum}}
<div
class="lineal-tooltip-position chart-tooltip"
class="chart-tooltip"
role="status"
{{style
--x=(this.tooltipX (xScale.compute this.activeDatum.x) xScale.bandwidth)

View File

@ -1,133 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (and @data @legend)}}
<div class="lineal-chart" data-test-chart={{or @chartTitle "grouped 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}}
{{#each @legend as |l idx|}}
<Lineal::Bars
@data={{@data}}
@x="timestamp"
@y={{l.key}}
@height={{l.key}}
@width={{this.barWidth}}
@xScale={{xScale}}
@yScale={{yScale}}
@heightScale={{hScale}}
class="lineal-chart-bar custom-bar-{{l.key}}"
transform="translate({{this.barOffset xScale.bandwidth idx}},0)"
data-test-vertical-bar
/>
{{/each}}
{{! 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="lineal-tooltip-position 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}}
{{#if this.activeDatum.tooltipUpgrade}}
<br />
<p class="has-text-highlight">{{this.activeDatum.tooltipUpgrade}}</p>
{{/if}}
</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}}
{{else}}
<EmptyState @subTitle="No data to display" @bottomBorder={{true}} />
{{/if}}

View File

@ -1,179 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* 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/charts/client-counts';
import type { ClientTypes } from 'core/utils/client-count-utils';
import ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history';
interface Args {
legend: Legend[];
data: MonthlyChartData[];
upgradeData?: ClientsVersionHistoryModel[];
chartTitle?: string;
chartHeight?: number;
}
interface Legend {
key: ClientTypes;
label: string;
}
interface AggregatedDatum {
x: string;
y: number;
legendX: string;
legendY: string[];
}
type ChartDatum = {
timestamp: string;
clientType: string;
} & {
[key in ClientTypes]?: number | undefined;
};
interface UpgradeByMonth {
[key: string]: ClientsVersionHistoryModel;
}
/**
* @module VerticalBarGrouped
* Renders a grouped 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::VerticalBarGrouped
* @chartTitle="Total monthly usage"
* @data={{this.flattenedByMonthData}}
* @legend={{array (hash key="clients" label="Total clients")}}
* @chartHeight={{250}}
* />
*/
export default class VerticalBarGrouped extends Component<Args> {
barWidth = BAR_WIDTH;
@tracked activeDatum: AggregatedDatum | null = null;
get chartHeight() {
return this.args.chartHeight || 190;
}
get dataKeys(): ClientTypes[] {
return this.args.legend.map((l: Legend) => l.key);
}
label(legendKey: string) {
return this.args.legend.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 maximum = values.length
? values.reduce((prev, currentValue) => (prev > currentValue ? prev : currentValue), 0)
: null;
const xValue = datum.timestamp;
const legend = {
x: xValue,
y: maximum ?? 0, // y-axis point where tooltip renders
legendX: parseAPITimestamp(xValue, 'MMMM yyyy') as string,
legendY:
maximum === null
? ['No data']
: this.dataKeys.map((k) => `${formatNumber([datum[k]])} ${this.label(k)}`),
tooltipUpgrade: this.upgradeMessage(datum),
};
return legend;
});
}
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.args.data.map((d) => d.timestamp);
return new Set(domain);
}
// UPGRADE STUFF
get upgradeByMonthYear(): UpgradeByMonth {
const empty: UpgradeByMonth = {};
if (!Array.isArray(this.args.upgradeData)) return empty;
return (
this.args.upgradeData?.reduce((acc, upgrade) => {
if (upgrade.timestampInstalled) {
const key = parseAPITimestamp(upgrade.timestampInstalled, 'M/yy');
acc[key as string] = upgrade;
}
return acc;
}, empty) || empty
);
}
upgradeMessage(datum: MonthlyChartData) {
const upgradeInfo = this.upgradeByMonthYear[datum.month as string];
if (upgradeInfo) {
const { version, previousVersion } = upgradeInfo;
return `Vault was upgraded
${previousVersion ? 'from ' + previousVersion : ''} to ${version}`;
}
return null;
}
// TEMPLATE HELPERS
barOffset = (bandwidth: number, idx = 0) => {
const withPadding = this.barWidth + 4;
const moved = (bandwidth - withPadding * this.args.legend.length) / 2;
return moved + idx * withPadding;
};
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

@ -47,7 +47,7 @@
@xScale={{xScale}}
@yScale={{yScale}}
@color="clientType"
@colorScale="blue-bar"
@colorScale="stacked-bar"
transform="translate({{this.barOffset xScale.bandwidth}},0)"
data-test-vertical-bar
/>
@ -75,7 +75,7 @@
{{#if this.activeDatum}}
<div
class="lineal-tooltip-position chart-tooltip"
class="chart-tooltip"
role="status"
{{style
--x=(this.tooltipX (xScale.compute this.activeDatum.x) xScale.bandwidth)

View File

@ -11,21 +11,11 @@
</LinkTo>
</li>
<li>
<LinkTo @route="vault.cluster.clients.counts.token" data-test-tab="token">
Entity/Non-entity clients
</LinkTo>
</li>
{{#if @showSecretsSyncClientCounts}}
<li>
<LinkTo @route="vault.cluster.clients.counts.sync" data-test-tab="sync">
Secrets sync clients
{{#if this.isNotProduction}}
<LinkTo @route="vault.cluster.clients.counts.client-list" data-test-tab="client list">
Client list
</LinkTo>
</li>
{{/if}}
<li>
<LinkTo @route="vault.cluster.clients.counts.acme" data-test-tab="acme">
ACME clients
</LinkTo>
{{/if}}
</li>
</ul>
</nav>

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
// TODO this component just exists while the client-list tab is in development for the 1.21 release
// Unless more is added to it, it can be removed when the `client list` tab+route is unhidden
import Component from '@glimmer/component';
import config from 'vault/config/environment';
export default class NavBar extends Component {
get isNotProduction() {
return config.environment !== 'production';
}
}

View File

@ -1,42 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{! ACME clients added in 1.17 }}
{{#if (not this.byMonthNewClients)}}
{{! byMonthNewClients is an empty array if there is no monthly data (monthly breakdown was added in 1.11)
this means the user has queried dates before ACME clients existed. we render an empty state instead of
"0 acme clients" (which is what the activity response returns) to be more explicit }}
<EmptyState
@title="No ACME clients"
@message="There is no ACME client data available for this {{if this.isDateRange 'date range' 'month'}}."
class="is-shadowless"
/>
{{else if this.isDateRange}}
<Clients::ChartContainer @title={{this.title}} @description={{this.description}} @hasChartData={{true}} class="no-legend">
<:subTitle>
<StatText
@label="Total ACME clients"
@subText="The total number of ACME requests made to Vault during this time period."
@value={{this.totalUsageCounts.acme_clients}}
@tooltipText="This number is the total for the queried date range. The chart displays a monthly breakdown of total clients per month."
@size="l"
/>
</:subTitle>
<:chart>
<Clients::Charts::VerticalBarBasic
@chartTitle={{this.title}}
@data={{this.byMonthNewClients}}
@dataKey="acme_clients"
@chartHeight={{200}}
/>
</:chart>
</Clients::ChartContainer>
{{else}}
<Clients::UsageStats @title={{this.title}} @description={{this.description}}>
<StatText @label="Total ACME clients" @value={{this.totalUsageCounts.acme_clients}} @size="l" class="column" />
</Clients::UsageStats>
{{/if}}

View File

@ -1,13 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ActivityComponent from '../activity';
export default class ClientsAcmePageComponent extends ActivityComponent {
title = 'ACME usage';
get description() {
return 'ACME clients which interacted with Vault for the first time each month. Each bar represents the total new ACME clients for that month.';
}
}

View File

@ -117,7 +117,7 @@
</Hds::Alert>
{{/if}}
<Clients::Counts::NavBar @showSecretsSyncClientCounts={{or this.hasSecretsSyncClients this.version.hasSecretsSync}} />
<Clients::Counts::NavBar />
{{! CLIENT COUNT PAGE COMPONENTS RENDER HERE }}
{{yield}}

View File

@ -1,59 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{#if this.flags.secretsSyncIsActivated}}
{{#if (not this.byMonthNewClients)}}
{{! byMonthNewClients is an empty array if there is no monthly data (monthly breakdown was added in 1.11)
this means the user has queried dates before sync clients existed. we render an empty state instead of
"0 sync clients" (which is what the activity response returns) to be more explicit }}
<EmptyState
@title="No secrets sync clients"
@message="There is no sync data available for this {{if this.isDateRange 'date range' 'month'}}."
class="is-shadowless"
/>
{{else if this.isDateRange}}
<Clients::ChartContainer
@title={{this.title}}
@description={{this.description}}
@hasChartData={{true}}
class="no-legend"
>
<:subTitle>
<StatText
@label="Total sync clients"
@subText="The total number of secrets synced from Vault to other destinations during this date range."
@value={{this.totalUsageCounts.secret_syncs}}
@tooltipText="This number is the total for the queried date range. The chart displays a monthly breakdown of total sync clients per month."
@size="l"
/>
</:subTitle>
<:chart>
<Clients::Charts::VerticalBarBasic
@chartTitle={{this.title}}
@data={{this.byMonthNewClients}}
@dataKey="secret_syncs"
@chartHeight={{200}}
/>
</:chart>
</Clients::ChartContainer>
{{else}}
<Clients::UsageStats @title={{this.title}} @description={{this.description}}>
<StatText @label="Total sync clients" @value={{this.totalUsageCounts.secret_syncs}} @size="l" class="column" />
</Clients::UsageStats>
{{/if}}
{{else}}
<EmptyState
@title="No Secrets Sync clients"
@message="No data is available because Secrets Sync has not been activated."
class="is-shadowless"
>
<Hds::Link::Standalone
@icon="chevron-right"
@iconPosition="trailing"
@text="Activate Secrets Sync"
@route="vault.cluster.sync.secrets.overview"
/>
</EmptyState>
{{/if}}

View File

@ -1,15 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ActivityComponent from '../activity';
import { service } from '@ember/service';
import type FlagsService from 'vault/services/flags';
export default class SyncComponent extends ActivityComponent {
@service declare readonly flags: FlagsService;
title = 'Secrets sync usage';
description =
'Secrets sync clients which interacted with Vault for the first time each month. Each bar represents the total new sync clients for that month.';
}

View File

@ -1,65 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (and this.byMonthNewClients this.isDateRange)}}
<Clients::ChartContainer
@title="Entity/Non-entity clients usage"
@description="Entity or non-entity clients which interacted with Vault for the first time during this date range. Each bar represents the total new clients for that month."
@legend={{this.legend}}
@hasChartData={{true}}
>
<:subTitle>
<StatText
@label="Total clients"
@subText="The total number of entity and non-entity clients for this date range."
@value={{this.tokenStats.total}}
@tooltipText="This number is the total for the queried date range. The chart displays a monthly breakdown of total clients per month."
@size="l"
/>
</:subTitle>
<:chart>
<Clients::Charts::VerticalBarStacked
@chartTitle="Entity/Non-entity clients usage"
@data={{this.byMonthNewClients}}
@chartLegend={{this.legend}}
@chartHeight={{250}}
/>
</:chart>
</Clients::ChartContainer>
{{else}}
{{! Renders when viewing a single month or activity log data that predates the monthly breakdown added in 1.11 }}
<Clients::UsageStats
@title="Entity/Non-entity usage"
@description="Client counts for this
{{if @mountPath 'mount' 'namespace and all its children'}}.
{{if
this.isCurrentMonth
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current Billing Period' filter."
}}"
>
<StatText
class="column"
@label="Total clients"
@value={{this.tokenStats.total}}
@size="l"
@subText="The number of clients which interacted with Vault during this month. This is Vaults primary billing metric."
/>
<StatText
class="column"
@label="Entity"
@value={{this.tokenStats.entity_clients}}
@size="l"
@subText="Representations of a particular user, client, or application that created a token via login."
/>
<StatText
class="column"
@label="Non-entity"
@value={{this.tokenStats.non_entity_clients}}
@size="l"
@subText="Clients created with a shared set of permissions, but not associated with an entity."
/>
</Clients::UsageStats>
{{/if}}

View File

@ -1,47 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ActivityComponent from '../activity';
import type {
ByMonthNewClients,
MountNewClients,
NamespaceByKey,
NamespaceNewClients,
} from 'core/utils/client-count-utils';
export default class ClientsTokenPageComponent extends ActivityComponent {
legend = [
{ key: 'entity_clients', label: 'entity clients' },
{ key: 'non_entity_clients', label: 'non-entity clients' },
];
calculateClientAverages(
dataset:
| (NamespaceByKey | undefined)[]
| (ByMonthNewClients | NamespaceNewClients | MountNewClients | undefined)[]
) {
return ['entity_clients', 'non_entity_clients'].reduce((count, key) => {
const average = this.average(dataset, key);
return (count += average || 0);
}, 0);
}
get hasNewClients() {
return this.byMonthNewClients.find((m) => m.entity_clients || m.non_entity_clients);
}
get tokenStats() {
if (this.totalUsageCounts) {
const { entity_clients, non_entity_clients } = this.totalUsageCounts;
return {
total: entity_clients + non_entity_clients,
entity_clients,
non_entity_clients,
};
}
return null;
}
}

View File

@ -4,8 +4,24 @@
}}
{{#if (gt @byMonthNewClients.length 1)}}
<Clients::ChartContainer @title="Vault client counts" @description={{this.chartContainerText}} @hasChartData={{true}}>
<:subTitle>
<Clients::ChartContainer
@title="Vault client counts"
@description={{this.chartContainerText}}
@legend={{this.chartLegend}}
>
<:customSubheader>
<div class="is-pulled-right">
<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>
</:customSubheader>
<:dataLeft>
<StatText
@label="Running new client total"
@subText="The number of new clients which interacted with Vault during the selected period."
@ -13,38 +29,36 @@
@size="l"
@tooltipText="This number is the total for the queried date range. The chart displays a monthly breakdown of total new clients per month."
/>
</:subTitle>
<:stats>
<div class="data-details-top has-top-padding-l">
<div class="is-flex-row">
<StatText @label="Entity" @value={{@runningTotals.entity_clients}} @size="m" />
<StatText @label="Non-entity" @value={{@runningTotals.non_entity_clients}} @size="m" class="has-left-margin-l" />
</div>
<div class="has-top-padding-l is-flex-row gap-16">
<StatText @label="Entity" @value={{@runningTotals.entity_clients}} @size="m" />
<StatText @label="Non-entity" @value={{@runningTotals.non_entity_clients}} @size="m" />
</div>
<div class="data-details-bottom is-flex-row">
<div class="has-top-padding-m is-flex-row gap-16">
<StatText @label="ACME" @value={{@runningTotals.acme_clients}} @size="m" />
{{#if @isSecretsSyncActivated}}
<StatText @label="Secret sync" @value={{@runningTotals.secret_syncs}} @size="m" class="has-left-margin-l" />
<StatText @label="Secret sync" @value={{@runningTotals.secret_syncs}} @size="m" />
{{/if}}
</div>
</:stats>
<:chart>
<Clients::Charts::VerticalBarBasic
@chartTitle="Vault client counts"
@data={{this.runningTotalData}}
@dataKey="new_clients"
@chartHeight={{200}}
/>
</:chart>
<:legend>
{{#each this.chartLegend as |l|}}
<span class="legend-colors dot-{{l.key}}"></span><span class="legend-label">{{capitalize l.label}}</span>
{{/each}}
</:legend>
</:dataLeft>
<:dataRight>
{{#if this.showStacked}}
<Clients::Charts::VerticalBarStacked
@chartTitle="Vault client counts"
@data={{this.runningTotalData}}
@chartLegend={{this.chartLegend}}
@chartHeight={{200}}
/>
{{else}}
<Clients::Charts::VerticalBarBasic
@chartTitle="Vault client counts"
@data={{this.runningTotalData}}
@dataKey="new_clients"
@chartHeight={{200}}
/>
{{/if}}
</:dataRight>
</Clients::ChartContainer>
{{else}}
{{#let (get @byMonthNewClients "0") as |singleMonthData|}}

View File

@ -4,6 +4,8 @@
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import type { ByMonthNewClients, TotalClients } from 'core/utils/client-count-utils';
import type ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history';
@ -19,6 +21,8 @@ interface Args {
}
export default class RunningTotal extends Component<Args> {
@tracked showStacked = false;
get chartContainerText() {
const { isSecretsSyncActivated } = this.args;
return `The total clients in the specified date range, displayed per month. This includes entity, non-entity${
@ -34,6 +38,14 @@ export default class RunningTotal extends Component<Args> {
}
get chartLegend() {
if (this.showStacked) {
return [
{ key: 'entity_clients', label: 'entity clients' },
{ key: 'non_entity_clients', label: 'non-entity clients' },
...(this.args.isSecretsSyncActivated ? [{ key: 'secret_syncs', label: 'secret sync clients' }] : []),
{ key: 'acme_clients', label: 'acme clients' },
];
}
return [{ key: 'new_clients', label: 'new clients' }];
}
}

View File

@ -29,9 +29,10 @@ Router.map(function () {
this.route('clients', function () {
this.route('counts', function () {
this.route('overview');
this.route('token');
this.route('sync');
this.route('acme');
// TODO remove this conditional when client count feature work for 1.21 is complete
if (config.environment !== 'production') {
this.route('client-list');
}
});
this.route('config');
this.route('edit');

View File

@ -1,8 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
export default class ClientsCountsAcmeRoute extends Route {}

View File

@ -1,8 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
export default class ClientsCountsSyncRoute extends Route {}

View File

@ -1,8 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
export default class ClientsCountsOverviewRoute extends Route {}

View File

@ -8,93 +8,40 @@
* SPDX-License-Identifier: BUSL-1.1
*/
.chart-wrapper {
border: box-shadow_variables.$light-border;
border-radius: size_variables.$radius-large;
.chart-container {
padding: size_variables.$spacing-12 size_variables.$spacing-24;
margin-bottom: size_variables.$spacing-16;
}
margin-bottom: size_variables.$spacing-24;
// GRID LAYOUT //
.single-chart-grid {
display: grid;
grid-template-columns: 1fr 0.3fr 3.7fr;
grid-template-rows: 0.5fr 1fr 1fr 0.5fr 0.25fr;
width: 100%;
&.no-legend {
grid-template-rows: 0.5fr 1fr 1fr 0.25fr;
}
}
.chart-header {
grid-column-start: 1;
grid-column-end: span col4-end;
grid-row-start: 1;
box-shadow: inset 0 -1px 0 var(--token-color-palette-neutral-200);
}
.chart-container-wide {
grid-column-start: 3;
grid-column-end: 4;
grid-row-start: 2;
grid-row-end: span 3;
justify-self: center;
height: 300px;
max-width: 700px;
width: 100%;
svg.chart {
width: 100%;
height: 100%;
}
}
.chart-empty-state {
place-self: center stretch;
grid-row-end: span 2;
grid-column-start: 1;
grid-column-end: span 3;
max-width: none;
padding-right: 20px;
padding-left: 20px;
display: flex;
> div {
box-shadow: none !important;
.header {
margin-bottom: size_variables.$spacing-36;
}
> div.empty-state {
white-space: nowrap;
align-self: stretch;
width: 100%;
// LEGEND POSITION (color and styling is in chart.scss)
.legend-container {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
}
}
.chart-subTitle {
grid-column-start: 1;
grid-column-end: 3;
grid-row-start: 2;
}
.chart-flex-row {
display: flex;
gap: 1rem;
.data-details-top {
grid-column-start: 1;
grid-column-end: 3;
grid-row-start: 3;
}
.data-details-bottom {
grid-column-start: 1;
grid-column-end: 3;
grid-row-start: 4;
}
.legend {
grid-row-start: 5;
grid-column-start: 2;
grid-column-end: 6;
align-self: center;
justify-self: center;
font-size: size_variables.$size-9;
.item-left {
flex: 1 1 33.3333%;
}
.item-right {
flex: 2 1 66.6666%;
}
}
}
// FONT STYLES //
@ -119,15 +66,3 @@ p.chart-subtext {
line-height: 16px;
margin-top: size_variables.$spacing-8;
}
h3.data-details {
font-weight: font_variables.$font-weight-bold;
font-size: size_variables.$size-8;
line-height: 14px;
margin-bottom: size_variables.$spacing-8;
}
p.data-details {
font-weight: font_variables.$font-weight-normal;
font-size: size_variables.$size-4;
}

View File

@ -22,7 +22,6 @@
@use 'core/box';
@use 'core/buttons';
@use 'core/charts';
@use 'core/charts-lineal';
@use 'core/checkbox-and-radio';
@use 'core/columns';
@use 'core/containers';

View File

@ -1,60 +0,0 @@
@use '../utils/size_variables';
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
// LINEAL STYLING //
.lineal-chart {
position: relative;
padding: 10px 10px 20px 50px;
width: 100%;
svg {
overflow: visible;
}
}
.lineal-chart-bar {
fill: var(--token-color-palette-blue-500);
}
.lineal-axis {
color: var(--token-color-palette-neutral-400);
text {
font-size: 0.75rem;
}
line {
color: var(--token-color-palette-neutral-300);
}
}
.lineal-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;
}
// @colorScale arg for Lineal::VBars is "blue-bar", indices are added by lineal
.blue-bar-1 {
color: var(--token-color-palette-blue-100);
fill: var(--token-color-palette-blue-100);
}
.blue-bar-2 {
color: var(--token-color-palette-blue-200);
fill: var(--token-color-palette-blue-200);
}
// custom bar class for manually applying bar styles
.custom-bar-clients {
color: var(--token-color-palette-blue-200);
fill: var(--token-color-palette-blue-200);
}
.custom-bar-new_clients {
fill: var(--token-color-palette-blue-200);
color: var(--token-color-palette-blue-200);
}

View File

@ -6,40 +6,36 @@
* SPDX-License-Identifier: BUSL-1.1
*/
// MISC STYLES for non-lineal charts
// LEGEND STYLING (positioning is in chart-container.scss)
$green-cyan: #06d092;
$cerulean: #02a8ef;
$blue-500: var(--token-color-palette-blue-500);
$purple-300: var(--token-color-palette-purple-300);
.legend-colors {
height: 10px;
width: 10px;
border-radius: 50%;
display: inline-block;
// numbers are indices because chart legend is iterated over
&.dot-0 {
background-color: var(--token-color-palette-blue-100);
.legend-container {
.dots {
height: 10px;
width: 10px;
border-radius: 50%;
display: inline-block;
}
&.dot-1 {
background-color: var(--token-color-palette-blue-200);
.legend-new_clients {
background-color: $blue-500;
}
&.dot-2 {
background-color: var(--token-color-palette-neutral-300);
.legend-entity_clients {
background-color: $blue-500;
}
&.dot-3 {
background-color: var(--token-color-palette-neutral-500);
.legend-non_entity_clients {
background-color: $green-cyan;
}
// custom naming for running totals, which matches the custom-bar naming
&.dot-clients {
background-color: var(--token-color-palette-blue-200);
.legend-secret_syncs {
background-color: $purple-300;
}
&.dot-new_clients {
background-color: var(--token-color-palette-blue-200);
.legend-acme_clients {
background-color: $cerulean;
}
}
.legend-label {
padding-left: size_variables.$spacing-8;
padding-right: size_variables.$spacing-36;
}
.chart-tooltip {
background-color: hsl(0, 0%, 4%);
color: white;
@ -52,10 +48,17 @@
.bold {
font-weight: font_variables.$font-weight-bold;
}
}
.is-label-fit-content {
max-width: fit-content !important;
// 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 {
@ -70,22 +73,44 @@
left: calc(50% - 5px);
}
.has-grid {
g > text {
color: var(--token-color-palette-neutral-400);
font-size: size_variables.$size-9;
// LINEAL STYLING //
.lineal-chart {
position: relative;
padding: 10px 10px 20px 50px;
width: 100%;
svg {
overflow: visible;
}
}
g > line {
// TODO: mix-blend doesn't work in firefox browser?
mix-blend-mode: darken;
.lineal-chart-bar {
fill: var(--token-color-palette-blue-500);
}
.lineal-axis {
color: var(--token-color-palette-neutral-400);
text {
font-size: 0.75rem;
}
line {
color: var(--token-color-palette-neutral-300);
}
}
.is-horizontal {
.tick > text {
font-weight: font_variables.$font-weight-semibold;
font-size: size_variables.$size-9;
}
// @colorScale arg for Lineal::VBars is "stacked-bar", indices are added by lineal
.stacked-bar-1 {
color: $blue-500;
fill: $blue-500;
}
.stacked-bar-2 {
color: $green-cyan;
fill: $green-cyan;
}
.stacked-bar-3 {
color: $purple-300;
fill: $purple-300;
}
.stacked-bar-4 {
color: $cerulean;
fill: $cerulean;
}

View File

@ -1,13 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Clients::Page::Acme
@activity={{this.model.activity}}
@versionHistory={{this.model.versionHistory}}
@startTimestamp={{this.model.startTimestamp}}
@endTimestamp={{this.model.endTimestamp}}
@namespace={{this.countsController.ns}}
@mountPath={{this.countsController.mountPath}}
/>

View File

@ -1,13 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Clients::Page::Sync
@activity={{this.model.activity}}
@versionHistory={{this.model.versionHistory}}
@startTimestamp={{this.model.startTimestamp}}
@endTimestamp={{this.model.endTimestamp}}
@namespace={{this.countsController.ns}}
@mountPath={{this.countsController.mountPath}}
/>

View File

@ -1,13 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Clients::Page::Token
@activity={{this.model.activity}}
@versionHistory={{this.model.versionHistory}}
@startTimestamp={{this.model.startTimestamp}}
@endTimestamp={{this.model.endTimestamp}}
@namespace={{this.countsController.ns}}
@mountPath={{this.countsController.mountPath}}
/>

View File

@ -56,10 +56,10 @@ module('Acceptance | clients | counts', function (hooks) {
'Start and end times added as query params'
);
await click(GENERAL.tab('token'));
await click(GENERAL.tab('client list'));
assert.strictEqual(
currentURL(),
'/vault/clients/counts/token?end_time=1698710400&start_time=1677628800',
'/vault/clients/counts/client-list?end_time=1698710400&start_time=1677628800',
'Start and end times persist through child route change'
);

View File

@ -1,140 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { visit, click, currentURL } from '@ember/test-helpers';
import { getUnixTime } from 'date-fns';
import sinon from 'sinon';
import timestamp from 'core/utils/timestamp';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CHARTS, CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
import { ACTIVITY_RESPONSE_STUB, assertBarChart } from 'vault/tests/helpers/clients/client-count-helpers';
import { formatNumber } from 'core/helpers/format-number';
import { filterActivityResponse, LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
import { selectChoose } from 'ember-power-select/test-support';
import { filterByMonthDataForMount } from 'core/utils/client-count-utils';
const { searchSelect } = GENERAL;
// integration test handle general display assertions, acceptance handles nav + filtering
module('Acceptance | clients | counts | acme', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
this.server.get('sys/internal/counters/activity', (_, req) => {
const namespace = req.requestHeaders['X-Vault-Namespace'];
return {
request_id: 'some-activity-id',
data: filterActivityResponse(ACTIVITY_RESPONSE_STUB, namespace),
};
});
// store serialized activity data for value comparison
const activity = await this.owner.lookup('service:store').queryRecord('clients/activity', {
start_time: { timestamp: getUnixTime(LICENSE_START) },
end_time: { timestamp: getUnixTime(STATIC_NOW) },
});
this.nsPath = 'ns1';
this.mountPath = 'pki-engine-0';
this.expectedValues = {
nsTotals: activity.byNamespace
.find((ns) => ns.label === this.nsPath)
.mounts.find((mount) => mount.label === this.mountPath),
nsMonthlyUsage: filterByMonthDataForMount(activity.byMonth, this.nsPath, this.mountPath),
};
await login();
return visit('/vault');
});
test('it navigates to acme tab', async function (assert) {
assert.expect(3);
await click(GENERAL.navLink('Client Count'));
await click(GENERAL.tab('acme'));
assert.strictEqual(currentURL(), '/vault/clients/counts/acme', 'it navigates to acme tab');
assert.dom(GENERAL.tab('acme')).hasClass('active');
await click(GENERAL.navLink('Back to main navigation'));
assert.strictEqual(currentURL(), '/vault/dashboard', 'it navigates back to dashboard');
});
test('it filters by mount data and renders charts', async function (assert) {
const { nsTotals, nsMonthlyUsage } = this.expectedValues;
assert.expect(4 + nsMonthlyUsage.length);
await visit('/vault/clients/counts/acme');
await selectChoose(CLIENT_COUNT.nsFilter, this.nsPath);
await selectChoose(CLIENT_COUNT.mountFilter, this.mountPath);
// each chart assertion count is data array length + 2
assertBarChart(assert, 'ACME usage', nsMonthlyUsage);
assert.strictEqual(
currentURL(),
`/vault/clients/counts/acme?mountPath=pki-engine-0&ns=${this.nsPath}`,
'namespace filter updates URL query param'
);
assert
.dom(CLIENT_COUNT.statText('Total ACME clients'))
.hasTextContaining(
`${formatNumber([nsTotals.acme_clients])}`,
'renders total new acme clients for namespace'
);
});
/**
* This test lives here because we need an acceptance test to make sure the routing works correctly,
* and to intercept the mirage request for counters/activity which doesn't work when using scenarios.
*/
test('it queries activity with namespace header when filters change', async function (assert) {
assert.expect(5);
let activityCount = 0;
const expectedNSHeader = [undefined, this.nsPath, undefined];
this.server.get('sys/internal/counters/activity', (_, req) => {
const namespace = req.requestHeaders['X-Vault-Namespace'];
assert.strictEqual(
namespace,
expectedNSHeader[activityCount],
`queries activity with correct namespace header ${activityCount}`
);
activityCount++;
return {
request_id: 'some-activity-id',
data: filterActivityResponse(ACTIVITY_RESPONSE_STUB, namespace),
};
});
await visit('/vault/clients/counts/acme');
await selectChoose(CLIENT_COUNT.nsFilter, this.nsPath);
assert.strictEqual(
currentURL(),
`/vault/clients/counts/acme?ns=${this.nsPath}`,
'namespace filter updates URL query param'
);
await click(`${CLIENT_COUNT.nsFilter} ${searchSelect.removeSelected}`);
assert.strictEqual(
currentURL(),
`/vault/clients/counts/acme`,
'namespace filter remove updates URL query param'
);
});
test('it renders empty chart for no mount data ', async function (assert) {
assert.expect(3);
await visit('/vault/clients/counts/acme');
await selectChoose(CLIENT_COUNT.nsFilter, this.nsPath);
await selectChoose(CLIENT_COUNT.mountFilter, 'auth/userpass-0');
// no data because this is an auth mount (acme_clients come from pki mounts)
assert.dom(CLIENT_COUNT.statText('Total ACME clients')).hasTextContaining('0');
assert.dom(`${CHARTS.chart('ACME usage')} ${CHARTS.verticalBar}`).isNotVisible();
assert.dom(CHARTS.container('Monthly new')).doesNotExist();
});
});

View File

@ -0,0 +1,30 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { visit, click, currentURL } from '@ember/test-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
// integration test handle general display assertions, acceptance handles nav + filtering
module.skip('Acceptance | clients | counts | client list', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(async function () {
await login();
return visit('/vault');
});
test('it navigates to client list tab', async function (assert) {
assert.expect(3);
await click(GENERAL.navLink('Client Count'));
await click(GENERAL.tab('client list'));
assert.strictEqual(currentURL(), '/vault/clients/counts/client-list', 'it navigates to client list tab');
assert.dom(GENERAL.tab('client list')).hasClass('active');
await click(GENERAL.navLink('Back to main navigation'));
assert.strictEqual(currentURL(), '/vault/dashboard', 'it navigates back to dashboard');
});
});

View File

@ -235,102 +235,59 @@ module('Acceptance | clients | overview', function (hooks) {
});
});
module('Acceptance | clients | overview | sync in license, activated', function (hooks) {
module('Acceptance | clients | overview | secrets sync', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
syncHandler(this.server);
await login();
return visit('/vault/clients/counts/overview');
});
test('it should render the correct tabs', async function (assert) {
assert.dom(GENERAL.tab('sync')).exists('shows the sync tab');
});
test('it should show secrets sync stats', async function (assert) {
assert.dom(CLIENT_COUNT.statTextValue('Secret sync')).exists('shows secret sync data on overview');
});
test('it should navigate to secrets sync page', async function (assert) {
await click(GENERAL.tab('sync'));
assert.dom(GENERAL.tab('sync')).hasClass('active');
assert.dom(GENERAL.emptyStateTitle).doesNotExist();
assert
.dom(CHARTS.chart('Secrets sync usage'))
.exists('chart is shown because feature is active and has data');
});
});
module('Acceptance | clients | overview | sync in license, not activated', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
this.server.get('/sys/license/features', () => ({ features: ['Secrets Sync'] }));
await login();
return visit('/vault/clients/counts/overview');
});
test('it should show the secrets sync tab', async function (assert) {
assert.dom(GENERAL.tab('sync')).exists('sync tab is shown because feature is in license');
});
test('it should hide secrets sync stats', async function (assert) {
assert
.dom(CLIENT_COUNT.statTextValue('Secret sync'))
.doesNotExist('stat is hidden because feature is not activated');
assert.dom(CLIENT_COUNT.statTextValue('Entity')).exists('other stats are still visible');
});
});
module('Acceptance | clients | overview | sync not in license', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
test('it should hide secrets sync stats when feature is NOT on license', async function (assert) {
// mocks endpoint for no additional license modules
this.server.get('/sys/license/features', () => ({ features: [] }));
await login();
return visit('/vault/clients/counts/overview');
});
test('it should hide the secrets sync tab', async function (assert) {
assert.dom(GENERAL.tab('sync')).doesNotExist();
});
test('it should hide secrets sync stats', async function (assert) {
await visit('/vault/clients/counts/overview');
assert.dom(CLIENT_COUNT.statTextValue('Secret sync')).doesNotExist();
assert.dom(CLIENT_COUNT.statTextValue('Entity')).exists('other stats are still visible');
});
});
module('Acceptance | clients | overview | HVD', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
syncHandler(this.server);
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
await login();
return visit('/vault/clients/counts/overview');
});
test('it should show the secrets sync tab', async function (assert) {
assert.dom(GENERAL.tab('sync')).exists();
});
test('it should show secrets sync stats', async function (assert) {
assert.dom(CLIENT_COUNT.statTextValue('Secret sync')).exists();
// TODO add assertion sync clients HIDDEN in running total chart and legend
});
module('feature is on license', function (hooks) {
hooks.beforeEach(async function () {
syncHandler(this.server);
});
test('it should show secrets sync stats when the feature is activated', async function (assert) {
syncHandler(this.server);
await login();
await visit('/vault/clients/counts/overview');
assert.dom(CLIENT_COUNT.statTextValue('Secret sync')).exists('shows secret sync data on overview');
// TODO add assertion sync clients SHOW in running total chart and legend
});
test('it should hide secrets sync stats when feature is NOT activated', async function (assert) {
this.server.get('/sys/activation-flags', () => {
return {
data: { activated: [], unactivated: ['secrets-sync'] },
};
});
await login();
await visit('/vault/clients/counts/overview');
assert
.dom(CLIENT_COUNT.statTextValue('Secret sync'))
.doesNotExist('stat is hidden because feature is not activated');
assert.dom(CLIENT_COUNT.statTextValue('Entity')).exists('other stats are still visible');
// TODO add assertion sync clients HIDDEN in running total chart and legend
});
test('it should show secrets sync stats for HVD managed clusters', async function (assert) {
// mock HVD managed cluster
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
await login();
await visit('/vault/clients/counts/overview');
assert.dom(CLIENT_COUNT.statTextValue('Secret sync')).exists();
// TODO add assertion sync clients SHOW in running total chart and legend
});
});
});

View File

@ -1,82 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import syncHandler from 'vault/mirage/handlers/sync';
import { CONFIG_RESPONSE, STATIC_NOW } from 'vault/mirage/handlers/clients';
import { visit, click, currentURL } from '@ember/test-helpers';
import sinon from 'sinon';
import timestamp from 'core/utils/timestamp';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CLIENT_COUNT, CHARTS } from 'vault/tests/helpers/clients/client-count-selectors';
module('Acceptance | clients | sync', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
module('sync activated', function (hooks) {
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
syncHandler(this.server);
const version = this.owner.lookup('service:version');
version.type = 'enterprise';
await login();
return visit('/vault/clients/counts/sync');
});
test('it should render charts when secrets sync is activated', async function (assert) {
syncHandler(this.server);
assert.dom(CHARTS.chart('Secrets sync usage')).exists('Secrets sync usage chart is rendered');
assert.dom(CLIENT_COUNT.statText('Total sync clients')).exists('Total sync clients chart is rendered');
assert.dom(GENERAL.emptyStateTitle).doesNotExist();
});
});
module('sync not activated and on license', function (hooks) {
hooks.beforeEach(async function () {
this.server.get('/sys/internal/counters/config', function () {
return CONFIG_RESPONSE;
});
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
syncHandler(this.server);
server.get('/sys/activation-flags', () => {
return {
data: {
activated: [''],
unactivated: ['secrets-sync'],
},
};
});
await login();
return visit('/vault/clients/counts/sync');
});
test('it should show an empty state when secrets sync is not activated', async function (assert) {
assert.expect(2);
this.server.get('/sys/activation-flags', () => {
assert.true(true, '/sys/activation-flags/ is called to check if secrets-sync is activated');
// called once from the higher level cluster route
return {
data: {
activated: [],
unactivated: ['secrets-sync'],
},
};
});
assert.dom(GENERAL.emptyStateTitle).exists('Shows empty state when secrets-sync is not activated');
await click(`${GENERAL.emptyStateActions} .hds-link-standalone`);
assert.strictEqual(
currentURL(),
'/vault/sync/secrets/overview',
'action button navigates to secrets sync overview page'
);
});
});
});

View File

@ -48,7 +48,7 @@ export const CHARTS = {
container: (title: string) => `[data-test-chart-container="${title}"]`,
timestamp: '[data-test-chart-container-timestamp]',
legend: '[data-test-chart-container-legend]',
legendLabel: (nth: number) => `.legend-label:nth-child(${nth * 2})`, // nth * 2 accounts for dots in legend
legendDot: (nth: number) => `.legend-item:nth-child(${nth}) > span`,
// chart elements
chart: (title: string) => `[data-test-chart="${title}"]`,

View File

@ -1,249 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, triggerEvent } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CHARTS } from 'vault/tests/helpers/clients/client-count-selectors';
import { assertBarChart } from 'vault/tests/helpers/clients/client-count-helpers';
module('Integration | Component | clients/charts/vertical-bar-grouped', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.legend = [
{ key: 'clients', label: 'Total clients' },
{ key: 'foo', label: 'Foo' },
];
this.data = [
{
timestamp: '2018-04-03T14:15:30',
clients: 14,
foo: 4,
month: '4/18',
},
{
timestamp: '2018-05-03T14:15:30',
clients: 18,
foo: 8,
month: '5/18',
},
{
timestamp: '2018-06-03T14:15:30',
clients: 114,
foo: 14,
month: '6/18',
},
{
timestamp: '2018-07-03T14:15:30',
clients: 110,
foo: 10,
month: '7/18',
},
];
this.renderComponent = async () => {
await render(
hbs`<div class="has-top-padding-xxl">
<Clients::Charts::VerticalBarGrouped @data={{this.data}} @legend={{this.legend}} @upgradeData={{this.upgradeData}} />
</div>`
);
};
});
test('it renders empty state when no data', async function (assert) {
this.data = [];
await this.renderComponent();
assert.dom(CHARTS.chart('grouped vertical bar chart')).doesNotExist();
assert.dom(GENERAL.emptyStateSubtitle).hasText('No data to display');
});
test('it renders chart with data as grouped bars', async function (assert) {
await this.renderComponent();
assert.dom(CHARTS.chart('grouped vertical bar chart')).exists();
const barCount = this.data.length * this.legend.length;
// bars are what we expect
assert.dom(CHARTS.verticalBar).exists({ count: barCount });
assert.dom(`.custom-bar-clients`).exists({ count: 4 }, 'clients bars have correct class');
assert.dom(`.custom-bar-foo`).exists({ count: 4 }, 'foo bars have correct class');
assertBarChart(assert, 'grouped vertical bar chart', this.data, true);
});
test('it renders chart with tooltips when some is missing', async function (assert) {
assert.expect(13);
this.data = [
{
timestamp: '2018-04-03T14:15:30',
month: '4/18',
expectedTooltip: 'April 2018 No data',
},
{
timestamp: '2018-05-03T14:15:30',
month: '5/18',
clients: 0,
foo: 0,
},
{
timestamp: '2018-06-03T14:15:30',
month: '6/18',
clients: 14,
foo: 4,
expectedTooltip: 'June 2018 14 Total clients 4 Foo',
},
];
await this.renderComponent();
assert.dom(CHARTS.chart('grouped vertical bar chart')).exists();
const barCount = this.data.length * this.legend.length;
assert.dom(CHARTS.verticalBar).exists({ count: barCount });
assertBarChart(assert, 'grouped vertical bar chart', this.data, true);
// TOOLTIPS - NO DATA
await triggerEvent(CHARTS.hover(this.data[0].timestamp), 'mouseover');
assert.dom(CHARTS.tooltip).isVisible(`renders tooltip on mouseover`);
assert
.dom(CHARTS.tooltip)
.hasText(this.data[0].expectedTooltip, 'renders formatted timestamp with no data message');
await triggerEvent(CHARTS.hover(this.data[2].timestamp), 'mouseout');
assert.dom(CHARTS.tooltip).doesNotExist('removes tooltip on mouseout');
// TOOLTIPS - WITH DATA
await triggerEvent(CHARTS.hover(this.data[2].timestamp), 'mouseover');
assert.dom(CHARTS.tooltip).isVisible(`renders tooltip on mouseover`);
assert.dom(CHARTS.tooltip).hasText(this.data[2].expectedTooltip, 'renders formatted timestamp with data');
await triggerEvent(CHARTS.hover(this.data[2].timestamp), 'mouseout');
assert.dom(CHARTS.tooltip).doesNotExist('removes tooltip on mouseout');
});
test('it renders upgrade data', async function (assert) {
this.upgradeData = [
{
version: '1.10.1',
previousVersion: '1.9.2',
timestampInstalled: '2018-05-03T14:15:30',
},
];
await this.renderComponent();
assert.dom(CHARTS.chart('grouped vertical bar chart')).exists();
const barCount = this.data.length * this.legend.length;
// bars are what we expect
assert.dom(CHARTS.verticalBar).exists({ count: barCount });
assert.dom(`.custom-bar-clients`).exists({ count: 4 }, 'clients bars have correct class');
assert.dom(`.custom-bar-foo`).exists({ count: 4 }, 'foo bars have correct class');
assertBarChart(assert, 'grouped vertical bar chart', this.data, true);
// TOOLTIP
await triggerEvent(CHARTS.hover('2018-05-03T14:15:30'), 'mouseover');
assert.dom(CHARTS.tooltip).isVisible(`renders tooltip on mouseover`);
assert
.dom(CHARTS.tooltip)
.hasText(
'May 2018 18 Total clients 8 Foo Vault was upgraded from 1.9.2 to 1.10.1',
'renders formatted timestamp with data'
);
await triggerEvent(CHARTS.hover('2018-05-03T14:15:30'), 'mouseout');
assert.dom(CHARTS.tooltip).doesNotExist('removes tooltip on mouseout');
});
test('it updates axis when dataset updates', async function (assert) {
const datasets = {
small: [
{
timestamp: '2020-04-01',
bar: 4,
month: '4/20',
},
{
timestamp: '2020-05-01',
bar: 8,
month: '5/20',
},
{
timestamp: '2020-06-01',
bar: 1,
},
{
timestamp: '2020-07-01',
bar: 10,
},
],
large: [
{
timestamp: '2020-08-01',
bar: 4586,
month: '8/20',
},
{
timestamp: '2020-09-01',
bar: 8928,
month: '9/20',
},
{
timestamp: '2020-10-01',
bar: 11948,
month: '10/20',
},
{
timestamp: '2020-11-01',
bar: 16943,
month: '11/20',
},
],
broken: [
{
timestamp: '2020-01-01',
bar: null,
month: '1/20',
},
{
timestamp: '2020-02-01',
bar: 0,
month: '2/20',
},
{
timestamp: '2020-03-01',
bar: 22,
month: '3/20',
},
{
timestamp: '2020-04-01',
bar: null,
month: '4/20',
},
{
timestamp: '2020-05-01',
bar: 70,
month: '5/20',
},
{
timestamp: '2020-06-01',
bar: 50,
month: '6/20',
},
],
};
this.legend = [{ key: 'bar', label: 'Some thing' }];
this.set('data', datasets.small);
await this.renderComponent();
assert.dom('[data-test-y-axis]').hasText('0 2 4 6 8 10', 'y-axis renders correctly for small values');
assert
.dom('[data-test-x-axis]')
.hasText('4/20 5/20 6/20 7/20', 'x-axis renders correctly for small values');
// Update to large dataset
this.set('data', datasets.large);
assert.dom('[data-test-y-axis]').hasText('0 5k 10k 15k', 'y-axis renders correctly for new large values');
assert
.dom('[data-test-x-axis]')
.hasText('8/20 9/20 10/20 11/20', 'x-axis renders correctly for small values');
// Update to broken dataset
this.set('data', datasets.broken);
assert.dom('[data-test-y-axis]').hasText('0 20 40 60', 'y-axis renders correctly for new broken values');
assert
.dom('[data-test-x-axis]')
.hasText('1/20 2/20 3/20 4/20 5/20 6/20', 'x-axis renders correctly for small values');
});
});

View File

@ -13,12 +13,8 @@ module('Integration | Component | clients/counts/nav-bar', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.showSecretsSyncClientCounts = false;
this.renderComponent = async () => {
await render(
hbs`<Clients::Counts::NavBar @showSecretsSyncClientCounts={{this.showSecretsSyncClientCounts}} />`
);
await render(hbs`<Clients::Counts::NavBar />`);
};
});
@ -26,21 +22,6 @@ module('Integration | Component | clients/counts/nav-bar', function (hooks) {
await this.renderComponent();
assert.dom(GENERAL.tab('overview')).hasText('Overview');
assert.dom(GENERAL.tab('token')).hasText('Entity/Non-entity clients');
assert.dom(GENERAL.tab('acme')).hasText('ACME clients');
});
test('it shows secrets sync tab if showSecretsSyncClientCounts is true', async function (assert) {
this.showSecretsSyncClientCounts = true;
await this.renderComponent();
assert.dom(GENERAL.tab('sync')).exists();
});
test('it should not show secrets sync tab if showSecretsSyncClientCounts is false', async function (assert) {
this.showSecretsSyncClientCounts = false;
await this.renderComponent();
assert.dom(GENERAL.tab('sync')).doesNotExist();
assert.dom(GENERAL.tab('client list')).hasText('Client list');
});
});

View File

@ -1,183 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { render, findAll } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import clientsHandler, { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
import { getUnixTime } from 'date-fns';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CLIENT_COUNT, CHARTS } from 'vault/tests/helpers/clients/client-count-selectors';
import { formatNumber } from 'core/helpers/format-number';
import { assertBarChart } from 'vault/tests/helpers/clients/client-count-helpers';
const START_TIME = getUnixTime(LICENSE_START);
const END_TIME = getUnixTime(STATIC_NOW);
const { statText, usageStats } = CLIENT_COUNT;
module('Integration | Component | clients | Clients::Page::Acme', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
clientsHandler(this.server);
this.store = this.owner.lookup('service:store');
const activityQuery = {
start_time: { timestamp: START_TIME },
end_time: { timestamp: END_TIME },
};
// set this to 0
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.startTimestamp = START_TIME;
this.endTimestamp = END_TIME;
this.renderComponent = () =>
render(hbs`
<Clients::Page::Acme
@activity={{this.activity}}
@versionHistory={{this.versionHistory}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@namespace={{this.countsController.ns}}
@mountPath={{this.countsController.mountPath}}
/>
`);
// Fails on #ember-testing-container
setRunOptions({
rules: {
'scrollable-region-focusable': { enabled: false },
},
});
});
test('it should render with full month activity data charts', async function (assert) {
const monthCount = this.activity.byMonth.length;
assert.expect(3 + monthCount);
const expectedTotal = formatNumber([this.activity.total.acme_clients]);
await this.renderComponent();
assert
.dom(statText('Total ACME clients'))
.hasText(
`Total ACME clients The total number of ACME requests made to Vault during this time period. ${expectedTotal}`,
`renders correct total acme stat ${expectedTotal}`
);
assertBarChart(assert, 'ACME usage', this.activity.byMonth);
});
test('it should render stats without chart for a single month', async function (assert) {
assert.expect(2);
const activityQuery = { start_time: { timestamp: END_TIME }, end_time: { timestamp: END_TIME } };
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
const expectedTotal = formatNumber([this.activity.total.acme_clients]);
await this.renderComponent();
assert.dom(CHARTS.chart('ACME usage')).doesNotExist('total usage chart does not render');
assert
.dom(usageStats('ACME usage'))
.hasText(
`ACME usage Client usage tutorial ACME clients which interacted with Vault for the first time each month. Each bar represents the total new ACME clients for that month. Total ACME clients ${expectedTotal}`,
'it renders usage stats with single month copy'
);
});
// EMPTY STATES
test('it should render empty state when ACME data does not exist for a date range', async function (assert) {
assert.expect(7);
// this happens when a user queries historical data that predates the monthly breakdown (added in 1.11)
// only entity + non-entity clients existed then, so we show an empty state for ACME clients
// because the activity response just returns { acme_clients: 0 } which isn't very clear
this.activity.byMonth = [];
await this.renderComponent();
assert.dom(GENERAL.emptyStateTitle).hasText('No ACME clients');
assert
.dom(GENERAL.emptyStateMessage)
.hasText('There is no ACME client data available for this date range.');
assert.dom(CHARTS.chart('ACME usage')).doesNotExist('vertical bar chart does not render');
assert.dom(CHARTS.container('Monthly new')).doesNotExist('monthly new chart does not render');
assert.dom(statText('Total ACME clients')).doesNotExist();
assert.dom(statText('Average new ACME clients per month')).doesNotExist();
assert.dom(usageStats('ACME usage')).doesNotExist();
});
test('it should render empty state when ACME data does not exist for a single month', async function (assert) {
assert.expect(1);
const activityQuery = { start_time: { timestamp: START_TIME }, end_time: { timestamp: START_TIME } };
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.activity.byMonth = [];
await this.renderComponent();
assert.dom(GENERAL.emptyStateMessage).hasText('There is no ACME client data available for this month.');
});
test('it should render empty total usage chart when monthly counts are null or 0', async function (assert) {
assert.expect(8);
// manually stub because mirage isn't setup to handle mixed data yet
const counts = {
acme_clients: 0,
clients: 19,
entity_clients: 0,
non_entity_clients: 19,
secret_syncs: 0,
};
this.activity.byMonth = [
{
month: '3/24',
timestamp: '2024-03-01T00:00:00Z',
namespaces: [],
new_clients: {
month: '3/24',
timestamp: '2024-03-01T00:00:00Z',
namespaces: [],
},
},
{
month: '4/24',
timestamp: '2024-04-01T00:00:00Z',
...counts,
namespaces: [],
new_clients: {
month: '4/24',
timestamp: '2024-04-01T00:00:00Z',
namespaces: [],
},
},
];
this.activity.total = counts;
await this.renderComponent();
assert.dom(CHARTS.chart('ACME usage')).exists('renders empty ACME usage chart');
assert
.dom(statText('Total ACME clients'))
.hasTextContaining('The total number of ACME requests made to Vault during this time period. 0');
findAll(`${CHARTS.chart('ACME usage')} ${CHARTS.xAxisLabel}`).forEach((e, i) => {
assert
.dom(e)
.hasText(
`${this.activity.byMonth[i].month}`,
`renders x-axis labels for empty bar chart: ${this.activity.byMonth[i].month}`
);
});
findAll(`${CHARTS.chart('ACME usage')} ${CHARTS.verticalBar}`).forEach((e, i) => {
assert.dom(e).isNotVisible(`does not render data bar for: ${this.activity.byMonth[i].month}`);
});
assert
.dom(CHARTS.container('Monthly new'))
.doesNotExist('empty monthly new chart does not render at all');
assert.dom(statText('Average new ACME clients per month')).doesNotExist();
});
});

View File

@ -1,192 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render, findAll } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
import syncHandler from 'vault/mirage/handlers/sync';
import { getUnixTime } from 'date-fns';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CLIENT_COUNT, CHARTS } from 'vault/tests/helpers/clients/client-count-selectors';
import { formatNumber } from 'core/helpers/format-number';
import { assertBarChart } from 'vault/tests/helpers/clients/client-count-helpers';
const START_TIME = getUnixTime(LICENSE_START);
const END_TIME = getUnixTime(STATIC_NOW);
const { statText, usageStats } = CLIENT_COUNT;
module('Integration | Component | clients | Clients::Page::Sync', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
this.renderComponent = () =>
render(hbs`
<Clients::Page::Sync
@activity={{this.activity}}
@versionHistory={{this.versionHistory}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@namespace={{this.countsController.ns}}
@mountPath={{this.countsController.mountPath}}
/>
`);
});
module('with secrets sync not activated', function () {
test('it should render an empty state', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.emptyStateTitle).hasText('No Secrets Sync clients');
assert
.dom(GENERAL.emptyStateMessage)
.hasText('No data is available because Secrets Sync has not been activated.');
assert.dom(GENERAL.emptyStateActions).hasText('Activate Secrets Sync');
assert.dom(CHARTS.chart('Secrets sync usage')).doesNotExist();
assert.dom(statText('Total sync clients')).doesNotExist();
});
});
module('with secrets sync activated', function (hooks) {
hooks.beforeEach(async function () {
syncHandler(this.server);
this.owner.lookup('service:flags').activatedFlags = ['secrets-sync'];
this.store = this.owner.lookup('service:store');
const activityQuery = {
start_time: { timestamp: START_TIME },
end_time: { timestamp: END_TIME },
};
// set this to 0
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.startTimestamp = START_TIME;
this.endTimestamp = END_TIME;
});
test('it should render with full month activity data', async function (assert) {
const monthCount = this.activity.byMonth.length;
assert.expect(3 + monthCount);
const expectedTotal = formatNumber([this.activity.total.secret_syncs]);
await this.renderComponent();
assert
.dom(statText('Total sync clients'))
.hasText(
`Total sync clients The total number of secrets synced from Vault to other destinations during this date range. ${expectedTotal}`,
`renders correct total sync stat ${expectedTotal}`
);
assertBarChart(assert, 'Secrets sync usage', this.activity.byMonth);
});
test('it should render stats without chart for a single month', async function (assert) {
const activityQuery = { start_time: { timestamp: END_TIME }, end_time: { timestamp: END_TIME } };
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
const expectedTotal = formatNumber([this.activity.total.secret_syncs]);
await this.renderComponent();
assert.dom(CHARTS.chart('Secrets sync usage')).doesNotExist('total usage chart does not render');
assert.dom(CHARTS.container('Monthly new')).doesNotExist('monthly new chart does not render');
assert.dom(statText('Average new sync clients per month')).doesNotExist();
assert
.dom(usageStats('Secrets sync usage'))
.hasText(
`Secrets sync usage Client usage tutorial Secrets sync clients which interacted with Vault for the first time each month. Each bar represents the total new sync clients for that month. Total sync clients ${expectedTotal}`,
'it renders usage stats with single month copy'
);
});
// EMPTY STATES
test('it should render empty state when sync data does not exist for a date range', async function (assert) {
assert.expect(7);
// this happens when a user queries historical data that predates the monthly breakdown (added in 1.11)
// only entity + non-entity clients existed then, so we show an empty state for sync clients
// because the activity response just returns { secret_syncs: 0 } which isn't very clear
this.activity.byMonth = [];
await this.renderComponent();
assert.dom(GENERAL.emptyStateTitle).hasText('No secrets sync clients');
assert.dom(GENERAL.emptyStateMessage).hasText('There is no sync data available for this date range.');
assert.dom(CHARTS.chart('Secrets sync usage')).doesNotExist('vertical bar chart does not render');
assert.dom(CHARTS.container('Monthly new')).doesNotExist('monthly new chart does not render');
assert.dom(statText('Total sync clients')).doesNotExist();
assert.dom(statText('Average new sync clients per month')).doesNotExist();
assert.dom(usageStats('Secrets sync usage')).doesNotExist();
});
test('it should render empty state when sync data does not exist for a single month', async function (assert) {
assert.expect(1);
const activityQuery = { start_time: { timestamp: START_TIME }, end_time: { timestamp: START_TIME } };
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.activity.byMonth = [];
await this.renderComponent();
assert.dom(GENERAL.emptyStateMessage).hasText('There is no sync data available for this month.');
});
test('it should render an empty total usage chart if secrets sync is activated but monthly syncs are null or 0', async function (assert) {
// manually stub because mirage isn't setup to handle mixed data yet
const counts = {
clients: 10,
entity_clients: 4,
non_entity_clients: 6,
secret_syncs: 0,
};
const monthData = {
month: '1/24',
timestamp: '2024-01-01T00:00:00-08:00',
...counts,
namespaces: [
{
label: 'root',
...counts,
mounts: [],
},
],
};
this.activity.byMonth = [
{
...monthData,
new_clients: {
...monthData,
},
},
];
this.activity.total = counts;
assert.expect(6);
await this.renderComponent();
assert.dom(CHARTS.chart('Secrets sync usage')).exists('renders empty sync usage chart');
assert
.dom(statText('Total sync clients'))
.hasText(
'Total sync clients The total number of secrets synced from Vault to other destinations during this date range. 0'
);
findAll(`${CHARTS.chart('Secrets sync usage')} ${CHARTS.xAxisLabel}`).forEach((e, i) => {
assert
.dom(e)
.hasText(
`${this.activity.byMonth[i].month}`,
`renders x-axis labels for empty bar chart: ${this.activity.byMonth[i].month}`
);
});
findAll(`${CHARTS.chart('Secrets sync usage')} ${CHARTS.verticalBar}`).forEach((e, i) => {
assert.dom(e).isNotVisible(`does not render data bar for: ${this.activity.byMonth[i].month}`);
});
assert
.dom(CHARTS.container('Monthly new'))
.doesNotExist('empty monthly new chart does not render at all');
assert.dom(statText('Average new sync clients per month')).doesNotExist();
});
});
});

View File

@ -1,115 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import clientsHandler, { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
import { getUnixTime } from 'date-fns';
import { formatNumber } from 'core/helpers/format-number';
import { CHARTS, CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
import { assertBarChart } from 'vault/tests/helpers/clients/client-count-helpers';
const START_TIME = getUnixTime(LICENSE_START);
const END_TIME = getUnixTime(STATIC_NOW);
module('Integration | Component | clients | Clients::Page::Token', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
clientsHandler(this.server);
const store = this.owner.lookup('service:store');
const activityQuery = {
start_time: { timestamp: START_TIME },
end_time: { timestamp: END_TIME },
};
this.activity = await store.queryRecord('clients/activity', activityQuery);
this.newActivity = this.activity.byMonth.map((d) => d.new_clients);
this.versionHistory = await store
.findAll('clients/version-history')
.then((response) => {
return response.map(({ version, previousVersion, timestampInstalled }) => {
return {
version,
previousVersion,
timestampInstalled,
};
});
})
.catch(() => []);
this.startTimestamp = START_TIME;
this.endTimestamp = END_TIME;
this.renderComponent = () =>
render(hbs`
<Clients::Page::Token
@activity={{this.activity}}
@versionHistory={{this.versionHistory}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@namespace={{this.ns}}
@mountPath={{this.mountPath}}
/>
`);
// Fails on #ember-testing-container
setRunOptions({
rules: {
'scrollable-region-focusable': { enabled: false },
},
});
});
test('it should render monthly total chart', async function (assert) {
const count = this.activity.byMonth.length;
const { entity_clients, non_entity_clients } = this.activity.total;
assert.expect(count + 6);
const expectedTotal = formatNumber([entity_clients + non_entity_clients]);
const chart = CHARTS.container('Entity/Non-entity clients usage');
await this.renderComponent();
assert
.dom(CLIENT_COUNT.statTextValue('Total clients'))
.hasText(expectedTotal, 'renders correct total clients');
// assert bar chart is correct
assert.dom(`${chart} ${CHARTS.xAxis}`).hasText('7/23 8/23 9/23 10/23 11/23 12/23 1/24');
assertBarChart(assert, 'Entity/Non-entity clients usage', this.activity.byMonth, true);
assert.dom(`${chart} ${CHARTS.legendLabel(1)}`).hasText('Entity clients', 'Legend label renders');
assert.dom(`${chart} ${CHARTS.legendLabel(2)}`).hasText('Non-entity clients', 'Legend label renders');
});
test('it should render usage stats', async function (assert) {
assert.expect(6);
this.activity.endTime = this.activity.startTime;
const checkUsage = () => {
const { entity_clients, non_entity_clients } = this.activity.total;
assert
.dom(CLIENT_COUNT.statTextValue('Total clients'))
.hasText(formatNumber([entity_clients + non_entity_clients]), 'Total clients value renders');
assert
.dom(CLIENT_COUNT.statTextValue('Entity'))
.hasText(formatNumber([entity_clients]), 'Entity value renders');
assert
.dom(CLIENT_COUNT.statTextValue('Non-entity'))
.hasText(formatNumber([non_entity_clients]), 'Non-entity value renders');
};
// total usage should display for single month query
await this.renderComponent();
checkUsage();
// total usage should display when there is no monthly data
this.activity.byMonth = null;
await this.renderComponent();
checkUsage();
});
});

View File

@ -6,16 +6,17 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render } from '@ember/test-helpers';
import { click, find, 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 { formatRFC3339, getUnixTime } from 'date-fns';
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 { setRunOptions } from 'ember-a11y-testing/test-support';
import { CLIENT_COUNT, CHARTS } from 'vault/tests/helpers/clients/client-count-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
const START_TIME = getUnixTime(LICENSE_START);
@ -32,15 +33,9 @@ module('Integration | Component | clients/running-total', function (hooks) {
end_time: { timestamp: getUnixTime(timestamp.now()) },
};
const activity = await store.queryRecord('clients/activity', activityQuery);
this.byMonthActivity = activity.byMonth;
this.newActivity = this.byMonthActivity.map((d) => d.new_clients);
this.byMonthNewClients = activity.byMonth.map((d) => d.new_clients);
this.totalUsageCounts = activity.total;
this.set('timestamp', formatRFC3339(timestamp.now()));
this.set('chartLegend', [
{ label: 'entity clients', key: 'entity_clients' },
{ label: 'non-entity clients', key: 'non_entity_clients' },
]);
this.isSecretsSyncActivated = true;
this.isHistoricalMonth = false;
@ -48,7 +43,7 @@ module('Integration | Component | clients/running-total', function (hooks) {
await render(hbs`
<Clients::RunningTotal
@isSecretsSyncActivated={{this.isSecretsSyncActivated}}
@byMonthNewClients={{this.byMonthActivity}}
@byMonthNewClients={{this.byMonthNewClients}}
@runningTotals={{this.totalUsageCounts}}
@upgradeData={{this.upgradesDuringActivity}}
@isHistoricalMonth={{this.isHistoricalMonth}}
@ -68,6 +63,10 @@ module('Integration | Component | clients/running-total', function (hooks) {
assert.dom(CHARTS.container('Vault client counts')).exists('running total component renders');
assert.dom(CHARTS.chart('Vault client counts')).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}`);
const expectedValues = {
'Running new client total': formatNumber([this.totalUsageCounts.clients]),
@ -90,45 +89,58 @@ module('Integration | Component | clients/running-total', function (hooks) {
assert
.dom(e)
.hasText(
`${this.byMonthActivity[i].month}`,
`renders x-axis labels for bar chart: ${this.byMonthActivity[i].month}`
`${this.byMonthNewClients[i].month}`,
`renders x-axis labels for bar chart: ${this.byMonthNewClients[i].month}`
);
});
assert
.dom(CHARTS.verticalBar)
.exists({ count: this.byMonthActivity.length }, 'renders correct number of bars ');
.exists({ count: this.byMonthNewClients.length }, 'renders correct number of bars ');
});
test('it renders with no new monthly data', async function (assert) {
this.byMonthActivity = this.byMonthActivity.map((d) => ({
...d,
new_clients: { month: d.month },
}));
test('it toggles to split chart by client type', async function (assert) {
await this.renderComponent();
await click(GENERAL.inputByAttr('toggle view'));
assert.dom(CHARTS.container('Vault client counts')).exists('running total component renders');
assert.dom(CHARTS.chart('Vault client counts')).exists('bar chart renders');
assert.dom(CHARTS.legend).hasText('Entity clients Non-entity clients Secret sync clients Acme clients');
const expectedValues = {
Entity: formatNumber([this.totalUsageCounts.entity_clients]),
'Non-entity': formatNumber([this.totalUsageCounts.non_entity_clients]),
ACME: formatNumber([this.totalUsageCounts.acme_clients]),
'Secret sync': formatNumber([this.totalUsageCounts.secret_syncs]),
};
for (const label in expectedValues) {
// assert each legend item is correct
const expectedLegend = [
{ label: 'Entity clients', color: 'rgb(28, 52, 95)' },
{ label: 'Non-entity clients', color: 'rgb(6, 208, 146)' },
{ label: 'Secret sync clients', color: 'rgb(145, 28, 237)' },
{ label: 'Acme clients', color: 'rgb(2, 168, 239)' },
];
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, `actual color: ${dotColor}, expected color: ${color}`);
});
// assert bar chart is correct
findAll(CHARTS.xAxisLabel).forEach((e, i) => {
assert
.dom(CLIENT_COUNT.statTextValue(label))
.dom(e)
.hasText(
`${expectedValues[label]}`,
`stat label: ${label} renders correct total: ${expectedValues[label]}`
`${this.byMonthNewClients[i].month}`,
`renders x-axis labels for bar chart: ${this.byMonthNewClients[i].month}`
);
}
});
const months = this.byMonthNewClients.length;
const barsPerMonth = expectedLegend.length;
assert
.dom(CHARTS.verticalBar)
.exists({ count: months * barsPerMonth }, `renders ${barsPerMonth} bars per month`);
});
test('it renders with single historical month data', async function (assert) {
const singleMonthNew = this.newActivity[this.newActivity.length - 1];
this.byMonthActivity = [singleMonthNew];
const singleMonthNew = this.byMonthNewClients[this.byMonthNewClients.length - 1];
this.byMonthNewClients = [singleMonthNew];
this.isHistoricalMonth = true;
await this.renderComponent();
const expectedStats = {
@ -152,6 +164,8 @@ module('Integration | Component | clients/running-total', function (hooks) {
test('it hides secret sync totals when feature is not activated', async function (assert) {
this.isSecretsSyncActivated = false;
// reset secret sync clients to 0
this.byMonthNewClients = this.byMonthNewClients.map((obj) => ({ ...obj, secret_syncs: 0 }));
await this.renderComponent();
@ -160,5 +174,31 @@ module('Integration | Component | clients/running-total', function (hooks) {
assert.dom(CLIENT_COUNT.statTextValue('Entity')).exists();
assert.dom(CLIENT_COUNT.statTextValue('Non-entity')).exists();
assert.dom(CLIENT_COUNT.statTextValue('Secret sync')).doesNotExist('does not render secret syncs');
// 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');
// assert each legend item is correct
const expectedLegend = [
{ label: 'Entity clients', color: 'rgb(28, 52, 95)' },
{ label: 'Non-entity clients', color: 'rgb(6, 208, 146)' },
{ label: 'Acme clients', color: 'rgb(2, 168, 239)' },
];
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, `actual color: ${dotColor}, expected color: ${color}`);
});
const months = this.byMonthNewClients.length;
const barsPerMonth = expectedLegend.length;
assert
.dom(CHARTS.verticalBar)
.exists({ count: months * barsPerMonth }, `renders ${barsPerMonth} bars per month`);
});
});