mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-03 20:40:45 -05:00
UI - Update Client Count filtering (#28036)
This commit is contained in:
parent
fe44e55943
commit
7257da888c
50 changed files with 1832 additions and 2151 deletions
3
changelog/28036.txt
Normal file
3
changelog/28036.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
ui: Update the client count dashboard to use API namespace filtering and other UX improvements
|
||||
```
|
||||
|
|
@ -92,7 +92,7 @@ export default RESTAdapter.extend({
|
|||
controlGroup.deleteControlGroupToken(controlGroupToken.accessor);
|
||||
}
|
||||
const [resp] = args;
|
||||
if (resp && resp.warnings) {
|
||||
if (resp && resp.warnings && !options.skipWarnings) {
|
||||
const flash = this.flashMessages;
|
||||
resp.warnings.forEach((message) => {
|
||||
flash.info(message);
|
||||
|
|
|
|||
|
|
@ -39,8 +39,15 @@ export default class ActivityAdapter extends ApplicationAdapter {
|
|||
|
||||
queryRecord(store, type, query) {
|
||||
const url = `${this.buildURL()}/internal/counters/activity`;
|
||||
const queryParams = this.formatQueryParams(query);
|
||||
return this.ajax(url, 'GET', { data: queryParams }).then((resp) => {
|
||||
const options = {
|
||||
data: this.formatQueryParams(query),
|
||||
};
|
||||
|
||||
if (query?.namespace) {
|
||||
options.namespace = query.namespace;
|
||||
}
|
||||
|
||||
return this.ajax(url, 'GET', options).then((resp) => {
|
||||
const response = resp || {};
|
||||
response.id = response.request_id || 'no-data';
|
||||
return response;
|
||||
|
|
@ -71,11 +78,12 @@ export default class ActivityAdapter extends ApplicationAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
urlForFindRecord(id) {
|
||||
// debug reminder so model is stored in Ember data with the same id for consistency
|
||||
// Only dashboard uses findRecord, the client count dashboard uses queryRecord
|
||||
findRecord(store, type, id) {
|
||||
if (id !== 'clients/activity') {
|
||||
debug(`findRecord('clients/activity') should pass 'clients/activity' as the id, you passed: '${id}'`);
|
||||
}
|
||||
return `${this.buildURL()}/internal/counters/activity`;
|
||||
const url = `${this.buildURL()}/internal/counters/activity`;
|
||||
return this.ajax(url, 'GET', { skipWarnings: true });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ import Component from '@glimmer/component';
|
|||
import { isSameMonth } from 'date-fns';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { calculateAverage } from 'vault/utils/chart-helpers';
|
||||
import { filterVersionHistory, hasMountsKey, hasNamespacesKey } from 'core/utils/client-count-utils';
|
||||
import {
|
||||
filterByMonthDataForMount,
|
||||
filteredTotalForMount,
|
||||
filterVersionHistory,
|
||||
} from 'core/utils/client-count-utils';
|
||||
import { service } from '@ember/service';
|
||||
import { sanitizePath } from 'core/utils/sanitize-path';
|
||||
|
||||
import type ClientsActivityModel from 'vault/models/clients/activity';
|
||||
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
|
||||
|
|
@ -19,7 +25,9 @@ import type {
|
|||
MountNewClients,
|
||||
NamespaceByKey,
|
||||
NamespaceNewClients,
|
||||
TotalClients,
|
||||
} from 'core/utils/client-count-utils';
|
||||
import type NamespaceService from 'vault/services/namespace';
|
||||
|
||||
interface Args {
|
||||
activity: ClientsActivityModel;
|
||||
|
|
@ -31,6 +39,8 @@ interface Args {
|
|||
}
|
||||
|
||||
export default class ClientsActivityComponent extends Component<Args> {
|
||||
@service declare readonly namespace: NamespaceService;
|
||||
|
||||
average = (
|
||||
data:
|
||||
| (ByMonthNewClients | NamespaceNewClients | MountNewClients | undefined)[]
|
||||
|
|
@ -40,48 +50,27 @@ export default class ClientsActivityComponent extends Component<Args> {
|
|||
return calculateAverage(data, key);
|
||||
};
|
||||
|
||||
// path of the filtered namespace OR current one, for filtering relevant data
|
||||
get namespacePathForFilter() {
|
||||
const { namespace } = this.args;
|
||||
const currentNs = this.namespace.currentNamespace;
|
||||
return sanitizePath(namespace || currentNs || 'root');
|
||||
}
|
||||
|
||||
get byMonthActivityData() {
|
||||
const { activity, namespace } = this.args;
|
||||
return namespace ? this.filteredActivityByMonth : activity.byMonth;
|
||||
const { activity, mountPath } = this.args;
|
||||
const nsPath = this.namespacePathForFilter;
|
||||
if (mountPath) {
|
||||
// only do client-side filtering if we have a mountPath filter set
|
||||
return filterByMonthDataForMount(activity.byMonth, nsPath, mountPath);
|
||||
}
|
||||
return activity.byMonth;
|
||||
}
|
||||
|
||||
get byMonthNewClients() {
|
||||
return this.byMonthActivityData ? this.byMonthActivityData?.map((m) => m?.new_clients) : [];
|
||||
}
|
||||
|
||||
get filteredActivityByMonth() {
|
||||
const { namespace, mountPath, activity } = this.args;
|
||||
if (!namespace && !mountPath) {
|
||||
return activity.byMonth;
|
||||
}
|
||||
const namespaceData = activity.byMonth
|
||||
?.map((m) => m.namespaces_by_key[namespace])
|
||||
.filter((d) => d !== undefined);
|
||||
|
||||
if (!mountPath) {
|
||||
return namespaceData || [];
|
||||
}
|
||||
|
||||
const mountData = namespaceData
|
||||
?.map((namespace) => namespace?.mounts_by_key[mountPath])
|
||||
.filter((d) => d !== undefined);
|
||||
|
||||
return mountData || [];
|
||||
}
|
||||
|
||||
get filteredActivityByNamespace() {
|
||||
const { namespace, activity } = this.args;
|
||||
return activity.byNamespace.find((ns) => ns.label === namespace);
|
||||
}
|
||||
|
||||
get filteredActivityByAuthMount() {
|
||||
return this.filteredActivityByNamespace?.mounts?.find((mount) => mount.label === this.args.mountPath);
|
||||
}
|
||||
|
||||
get filteredActivity() {
|
||||
return this.args.mountPath ? this.filteredActivityByAuthMount : this.filteredActivityByNamespace;
|
||||
}
|
||||
|
||||
get isCurrentMonth() {
|
||||
const { activity } = this.args;
|
||||
const current = parseAPITimestamp(activity.responseTimestamp) as Date;
|
||||
|
|
@ -99,62 +88,18 @@ export default class ClientsActivityComponent extends Component<Args> {
|
|||
}
|
||||
|
||||
// (object) top level TOTAL client counts for given date range
|
||||
get totalUsageCounts() {
|
||||
const { namespace, activity } = this.args;
|
||||
return namespace ? this.filteredActivity : activity.total;
|
||||
get totalUsageCounts(): TotalClients {
|
||||
const { namespace, activity, mountPath } = this.args;
|
||||
// only do this if we have a mountPath filter.
|
||||
// namespace is filtered on API layer
|
||||
if (activity?.byNamespace && namespace && mountPath) {
|
||||
return filteredTotalForMount(activity.byNamespace, namespace, mountPath);
|
||||
}
|
||||
return activity?.total;
|
||||
}
|
||||
|
||||
get upgradesDuringActivity() {
|
||||
const { versionHistory, activity } = this.args;
|
||||
return filterVersionHistory(versionHistory, activity.startTime, activity.endTime);
|
||||
}
|
||||
|
||||
// (object) single month new client data with total counts and array of
|
||||
// either namespaces or mounts
|
||||
get newClientCounts() {
|
||||
if (this.isDateRange || this.byMonthActivityData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.byMonthActivityData[0]?.new_clients;
|
||||
}
|
||||
|
||||
// total client data for horizontal bar chart in attribution component
|
||||
get totalClientAttribution() {
|
||||
const { namespace, activity } = this.args;
|
||||
if (namespace) {
|
||||
return this.filteredActivityByNamespace?.mounts || null;
|
||||
} else {
|
||||
return activity.byNamespace || null;
|
||||
}
|
||||
}
|
||||
|
||||
// new client data for horizontal bar chart
|
||||
get newClientAttribution() {
|
||||
// new client attribution only available in a single, historical month (not a date range or current month)
|
||||
if (this.isDateRange || this.isCurrentMonth || !this.newClientCounts) return null;
|
||||
|
||||
const newCounts = this.newClientCounts;
|
||||
if (this.args.namespace && hasMountsKey(newCounts)) return newCounts?.mounts;
|
||||
|
||||
if (hasNamespacesKey(newCounts)) return newCounts?.namespaces;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
get hasAttributionData() {
|
||||
const { mountPath, namespace } = this.args;
|
||||
if (!mountPath) {
|
||||
if (namespace) {
|
||||
const mounts = this.filteredActivityByNamespace?.mounts?.map((mount) => ({
|
||||
id: mount.label,
|
||||
name: mount.label,
|
||||
}));
|
||||
return mounts && mounts.length > 0;
|
||||
}
|
||||
return !!this.totalClientAttribution && this.totalUsageCounts && this.totalUsageCounts.clients !== 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,97 +3,29 @@
|
|||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
{{! only show side-by-side horizontal bar charts if data is from a single, historical month }}
|
||||
<div
|
||||
class={{concat "chart-wrapper" (if @isHistoricalMonth " dual-chart-grid" " single-chart-grid")}}
|
||||
data-test-clients-attribution
|
||||
>
|
||||
<div class="chart-header has-header-link has-bottom-margin-m">
|
||||
<div class="header-left">
|
||||
<h2 class="chart-title">Attribution</h2>
|
||||
<p class="chart-description" data-test-attribution-description>{{this.chartText.description}}</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{{#if this.showExportButton}}
|
||||
<Clients::ExportButton
|
||||
@startTimestamp={{@startTimestamp}}
|
||||
@endTimestamp={{@endTimestamp}}
|
||||
@selectedNamespace={{@selectedNamespace}}
|
||||
>
|
||||
<:alert>
|
||||
{{#if @upgradesDuringActivity}}
|
||||
<Hds::Alert class="has-top-padding-m" @type="compact" @color="warning" as |A|>
|
||||
<A.Description>
|
||||
<strong>Data contains {{pluralize @upgradesDuringActivity.length "upgrade"}}:</strong>
|
||||
</A.Description>
|
||||
<A.Description>
|
||||
<ul class="bullet">
|
||||
{{#each @upgradesDuringActivity as |upgrade|}}
|
||||
<li>
|
||||
{{upgrade.version}}
|
||||
{{this.parseAPITimestamp upgrade.timestampInstalled "(MMM d, yyyy)"}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</A.Description>
|
||||
<A.Description>
|
||||
Visit our
|
||||
<Hds::Link::Inline
|
||||
@isHrefExternal={{true}}
|
||||
@href={{doc-link
|
||||
"/vault/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"
|
||||
}}
|
||||
>
|
||||
Client count FAQ
|
||||
</Hds::Link::Inline>
|
||||
for more information.
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
{{/if}}
|
||||
</:alert>
|
||||
</Clients::ExportButton>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="chart-wrapper single-chart-grid" data-test-clients-attribution={{this.noun}}>
|
||||
<div class="chart-header has-bottom-margin-m">
|
||||
<h2 class="chart-title" data-test-attribution-title>{{capitalize this.noun}} attribution</h2>
|
||||
<p class="chart-description" data-test-attribution-description>{{this.chartText.description}}</p>
|
||||
</div>
|
||||
{{#if this.barChartTotalClients}}
|
||||
{{#if @isHistoricalMonth}}
|
||||
<div class="chart-container-left" data-test-chart-container="new-clients">
|
||||
<h2 class="chart-title">New clients</h2>
|
||||
<p class="chart-description">{{this.chartText.newCopy}}</p>
|
||||
<Clients::HorizontalBarChart
|
||||
@dataset={{this.barChartNewClients}}
|
||||
@chartLegend={{this.attributionLegend}}
|
||||
@totalCounts={{@newUsageCounts}}
|
||||
@noDataMessage="There are no new clients for this namespace during this time period."
|
||||
/>
|
||||
</div>
|
||||
{{#if @attribution}}
|
||||
<div class="chart-container-wide" data-test-chart-container={{this.noun}}>
|
||||
<Clients::HorizontalBarChart @dataset={{this.topTenAttribution}} @chartLegend={{this.attributionLegend}} />
|
||||
</div>
|
||||
<div class="chart-subTitle">
|
||||
<p class="chart-subtext" data-test-attribution-subtext>{{this.chartText.subtext}}</p>
|
||||
</div>
|
||||
|
||||
<div class="chart-container-right" data-test-chart-container="total-clients">
|
||||
<h2 class="chart-title">Total clients</h2>
|
||||
<p class="chart-description">{{this.chartText.totalCopy}}</p>
|
||||
<Clients::HorizontalBarChart @dataset={{this.barChartTotalClients}} @chartLegend={{this.attributionLegend}} />
|
||||
</div>
|
||||
{{else}}
|
||||
<div
|
||||
class={{concat (unless this.barChartTotalClients "chart-empty-state ") "chart-container-wide"}}
|
||||
data-test-chart-container="single-chart"
|
||||
>
|
||||
<Clients::HorizontalBarChart @dataset={{this.barChartTotalClients}} @chartLegend={{this.attributionLegend}} />
|
||||
</div>
|
||||
<div class="chart-subTitle">
|
||||
<p class="chart-subtext" data-test-attribution-subtext>{{this.chartText.totalCopy}}</p>
|
||||
</div>
|
||||
<div class="data-details-top" data-test-top-attribution>
|
||||
<h3 class="data-details">Top {{this.noun}}</h3>
|
||||
<p class="data-details is-word-break">{{this.topAttribution.label}}</p>
|
||||
</div>
|
||||
|
||||
<div class="data-details-top" data-test-top-attribution>
|
||||
<h3 class="data-details">Top {{this.attributionBreakdown}}</h3>
|
||||
<p class="data-details is-word-break">{{this.topClientCounts.label}}</p>
|
||||
</div>
|
||||
<div class="data-details-bottom" data-test-attribution-clients>
|
||||
<h3 class="data-details">Clients in {{this.noun}}</h3>
|
||||
<p class="data-details">{{format-number this.topAttribution.clients}}</p>
|
||||
</div>
|
||||
|
||||
<div class="data-details-bottom" data-test-attribution-clients>
|
||||
<h3 class="data-details">Clients in {{this.attributionBreakdown}}</h3>
|
||||
<p class="data-details">{{format-number this.topClientCounts.clients}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="legend">
|
||||
{{#each this.attributionLegend as |legend idx|}}
|
||||
<span class="legend-colors dot-{{idx}}"></span><span class="legend-label">{{capitalize legend.label}}</span>
|
||||
|
|
|
|||
|
|
@ -4,71 +4,29 @@
|
|||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { isSameMonth } from 'date-fns';
|
||||
import { sanitizePath } from 'core/utils/sanitize-path';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
|
||||
/**
|
||||
* @module Attribution
|
||||
* Attribution components display the top 10 total client counts for namespaces or auth methods (mounts) during a billing period.
|
||||
* A horizontal bar chart shows on the right, with the top namespace/auth method and respective client totals on the left.
|
||||
* Attribution components display the top 10 total client counts for namespaces or mounts during a billing period.
|
||||
* A horizontal bar chart shows on the right, with the top namespace/mount and respective client totals on the left.
|
||||
*
|
||||
* @example
|
||||
* <Clients::Attribution
|
||||
* @newUsageCounts={{this.newUsageCounts}}
|
||||
* @totalClientAttribution={{this.totalClientAttribution}}
|
||||
* @newClientAttribution={{this.newClientAttribution}}
|
||||
* @selectedNamespace={{this.selectedNamespace}}
|
||||
* @startTimestamp={{this.startTime}}
|
||||
* @endTimestamp={{this.endTime}}
|
||||
* @isHistoricalMonth={{false}}
|
||||
* @responseTimestamp={{this.responseTimestamp}}
|
||||
* @upgradesDuringActivity={{array (hash version="1.10.1" previousVersion="1.9.1" timestampInstalled= "2021-11-18T10:23:16Z") }}
|
||||
* @noun="mount"
|
||||
* @attribution={{array (hash label="my-kv" clients=100)}}
|
||||
* @responseTimestamp="2018-04-03T14:15:30"
|
||||
* @isSecretsSyncActivated={{true}}
|
||||
* />
|
||||
*
|
||||
* @param {object} newUsageCounts - object with new client counts for chart tooltip text
|
||||
* @param {array} totalClientAttribution - array of objects containing a label and breakdown of client counts for total clients
|
||||
* @param {array} newClientAttribution - array of objects containing a label and breakdown of client counts for new clients
|
||||
* @param {string} selectedNamespace - namespace selected from filter bar
|
||||
* @param {string} startTimestamp - timestamp string from activity response to render start date for CSV modal and whether copy reads 'month' or 'date range'
|
||||
* @param {string} endTimestamp - timestamp string from activity response to render end date for CSV modal and whether copy reads 'month' or 'date range'
|
||||
* @param {string} noun - noun which reflects the type of data and used in title. Should be "namespace" (default) or "mount"
|
||||
* @param {array} attribution - array of objects containing a label and breakdown of client counts for total clients
|
||||
* @param {string} responseTimestamp - ISO timestamp created in serializer to timestamp the response, renders in bottom left corner below attribution chart
|
||||
* @param {boolean} isHistoricalMonth - when true data is from a single, historical month so side-by-side charts should display for attribution data
|
||||
* @param {array} upgradesDuringActivity - array of objects containing version history upgrade data
|
||||
* @param {boolean} isSecretsSyncActivated - boolean to determine if secrets sync is activated
|
||||
* @param {boolean} isSecretsSyncActivated - boolean reflecting if secrets sync is activated. Determines the labels and data shown
|
||||
*/
|
||||
|
||||
export default class Attribution extends Component {
|
||||
@service download;
|
||||
@service store;
|
||||
@service namespace;
|
||||
|
||||
@tracked canDownload = false;
|
||||
@tracked showExportModal = false;
|
||||
@tracked exportFormat = 'csv';
|
||||
@tracked downloadError = '';
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.getExportCapabilities(this.args.selectedNamespace);
|
||||
}
|
||||
|
||||
@waitFor
|
||||
async getExportCapabilities(ns = '') {
|
||||
try {
|
||||
// selected namespace usually ends in /
|
||||
const url = ns
|
||||
? `${sanitizePath(ns)}/sys/internal/counters/activity/export`
|
||||
: 'sys/internal/counters/activity/export';
|
||||
const cap = await this.store.findRecord('capabilities', url);
|
||||
this.canDownload = cap.canSudo;
|
||||
} catch (e) {
|
||||
// if we can't read capabilities, default to show
|
||||
this.canDownload = true;
|
||||
}
|
||||
get noun() {
|
||||
return this.args.noun || 'namespace';
|
||||
}
|
||||
|
||||
get attributionLegend() {
|
||||
|
|
@ -84,69 +42,38 @@ export default class Attribution extends Component {
|
|||
return attributionLegend;
|
||||
}
|
||||
|
||||
get isSingleMonth() {
|
||||
if (!this.args.startTimestamp && !this.args.endTimestamp) return false;
|
||||
const startDateObject = parseAPITimestamp(this.args.startTimestamp);
|
||||
const endDateObject = parseAPITimestamp(this.args.endTimestamp);
|
||||
return isSameMonth(startDateObject, endDateObject);
|
||||
}
|
||||
|
||||
get showExportButton() {
|
||||
const hasData = this.args.totalClientAttribution ? this.args.totalClientAttribution.length > 0 : false;
|
||||
return hasData && this.canDownload;
|
||||
}
|
||||
|
||||
get isSingleNamespace() {
|
||||
// if a namespace is selected, then we're viewing top 10 auth methods (mounts)
|
||||
return !!this.args.selectedNamespace;
|
||||
get sortedAttribution() {
|
||||
if (this.args.attribution) {
|
||||
// shallow copy so it doesn't mutate the data during tests
|
||||
return this.args.attribution?.slice().sort((a, b) => b.clients - a.clients);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// truncate data before sending to chart component
|
||||
get barChartTotalClients() {
|
||||
return this.args.totalClientAttribution?.slice(0, 10);
|
||||
get topTenAttribution() {
|
||||
return this.sortedAttribution.slice(0, 10);
|
||||
}
|
||||
|
||||
get barChartNewClients() {
|
||||
return this.args.newClientAttribution?.slice(0, 10);
|
||||
}
|
||||
|
||||
get topClientCounts() {
|
||||
// get top namespace or auth method
|
||||
return this.args.totalClientAttribution ? this.args.totalClientAttribution[0] : null;
|
||||
}
|
||||
|
||||
get attributionBreakdown() {
|
||||
// display text for hbs
|
||||
return this.isSingleNamespace ? 'auth method' : 'namespace';
|
||||
get topAttribution() {
|
||||
// get top namespace or mount
|
||||
return this.sortedAttribution[0] ?? null;
|
||||
}
|
||||
|
||||
get chartText() {
|
||||
if (!this.args.totalClientAttribution) {
|
||||
return { description: 'There is a problem gathering data' };
|
||||
}
|
||||
const dateText = this.isSingleMonth ? 'month' : 'date range';
|
||||
switch (this.isSingleNamespace) {
|
||||
case true:
|
||||
return {
|
||||
description:
|
||||
'This data shows the top ten authentication methods by client count within this namespace, and can be used to understand where clients are originating. Authentication methods are organized by path.',
|
||||
newCopy: `The new clients used by the auth method for this ${dateText}. This aids in understanding which auth methods create and use new clients${
|
||||
dateText === 'date range' ? ' over time.' : '.'
|
||||
}`,
|
||||
totalCopy: `The total clients used by the auth method for this ${dateText}. This number is useful for identifying overall usage volume. `,
|
||||
};
|
||||
case false:
|
||||
return {
|
||||
description:
|
||||
'This data shows the top ten namespaces by client count and can be used to understand where clients are originating. Namespaces are identified by path. To see all namespaces, export this data.',
|
||||
newCopy: `The new clients in the namespace for this ${dateText}.
|
||||
This aids in understanding which namespaces create and use new clients${
|
||||
dateText === 'date range' ? ' over time.' : '.'
|
||||
}`,
|
||||
totalCopy: `The total clients in the namespace for this ${dateText}. This number is useful for identifying overall usage volume.`,
|
||||
};
|
||||
default:
|
||||
return '';
|
||||
if (this.noun === 'namespace') {
|
||||
return {
|
||||
subtext: 'This data shows the top ten namespaces by total clients for the date range selected.',
|
||||
description:
|
||||
'This data shows the top ten namespaces by total clients and can be used to understand where clients are originating. Namespaces are identified by path.',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
subtext:
|
||||
'The total clients used by the mounts for this date range. This number is useful for identifying overall usage volume.',
|
||||
description:
|
||||
'This data shows the top ten mounts by client count within this namespace, and can be used to understand where clients are originating. Mounts are organized by path.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@
|
|||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if @legend}}
|
||||
{{#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>
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
<div>
|
||||
{{#if this.data}}
|
||||
<div class="lineal-chart" data-test-chart={{or @chartTitle "line chart"}}>
|
||||
<Lineal::Fluid as |width|>
|
||||
{{#let
|
||||
(scale-point domain=this.xDomain range=(array 0 width) padding=0.2)
|
||||
(scale-linear domain=this.yDomain range=(array this.chartHeight 0) nice=true)
|
||||
(scale-linear range=(array 0 this.chartHeight))
|
||||
as |xScale yScale tooltipScale|
|
||||
}}
|
||||
<svg width={{width}} height={{this.chartHeight}} class="chart has-grid">
|
||||
<title>{{@chartTitle}}</title>
|
||||
|
||||
{{#if (and xScale.isValid yScale.isValid)}}
|
||||
<Lineal::Axis
|
||||
@scale={{yScale}}
|
||||
@tickCount="4"
|
||||
@tickPadding={{10}}
|
||||
@tickSizeInner={{concat "-" width}}
|
||||
@tickFormat={{this.formatCount}}
|
||||
@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}}
|
||||
@tickFormat={{this.formatMonth}}
|
||||
@tickCount={{this.data.length}}
|
||||
class="lineal-axis"
|
||||
data-test-x-axis
|
||||
/>
|
||||
{{#each this.upgradedMonths as |d|}}
|
||||
<circle
|
||||
class="upgrade-circle"
|
||||
cx={{xScale.compute d.x}}
|
||||
cy={{yScale.compute d.y}}
|
||||
r="10"
|
||||
fill="#FDEEBA"
|
||||
stroke="none"
|
||||
data-test-line-chart="upgrade-{{d.month}}"
|
||||
></circle>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
<Lineal::Line
|
||||
@data={{this.data}}
|
||||
@x="x"
|
||||
@y="y"
|
||||
@xScale={{xScale}}
|
||||
@yScale={{yScale}}
|
||||
stroke="#0c56e9"
|
||||
stroke-width="0.5"
|
||||
fill="none"
|
||||
/>
|
||||
{{! this is here to qualify the scales }}
|
||||
<Lineal::Line
|
||||
@data={{this.data}}
|
||||
@x="x"
|
||||
@y="y"
|
||||
@xScale={{xScale}}
|
||||
@yScale={{tooltipScale}}
|
||||
stroke="none"
|
||||
fill="none"
|
||||
/>
|
||||
{{#if (and xScale.isValid yScale.isValid)}}
|
||||
{{#each this.data as |d|}}
|
||||
{{#if (this.hasValue d.y)}}
|
||||
<circle
|
||||
cx={{xScale.compute d.x}}
|
||||
cy={{yScale.compute d.y}}
|
||||
r="3.5"
|
||||
fill="#cce3fe"
|
||||
stroke="#0c56e9"
|
||||
stroke-width="1.5"
|
||||
data-test-plot-point
|
||||
></circle>
|
||||
<circle
|
||||
role="button"
|
||||
aria-label="Show exact counts for {{date-format d.x 'MMMM yyyy'}}"
|
||||
cx={{xScale.compute d.x}}
|
||||
cy={{yScale.compute d.y}}
|
||||
r="10"
|
||||
fill="transparent"
|
||||
{{on "mouseover" (fn (mut this.activeDatum) d)}}
|
||||
{{on "mouseout" (fn (mut this.activeDatum) null)}}
|
||||
data-test-hover-circle={{date-format d.x "M/yy"}}
|
||||
></circle>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
</svg>
|
||||
{{#if this.activeDatum}}
|
||||
<div
|
||||
class="lineal-tooltip-position from-top-left chart-tooltip"
|
||||
role="status"
|
||||
{{style
|
||||
--x=(this.tooltipX (xScale.compute this.activeDatum.x))
|
||||
--y=(this.tooltipY (yScale.compute this.activeDatum.y))
|
||||
}}
|
||||
data-test-tooltip
|
||||
>
|
||||
|
||||
<p class="bold" data-test-tooltip-month>{{date-format this.activeDatum.x "MMMM yyyy"}}</p>
|
||||
<p>{{format-number this.activeDatum.y}} total clients</p>
|
||||
<p>{{format-number (or this.activeDatum.new)}} new clients</p>
|
||||
{{#if this.activeDatum.tooltipUpgrade}}
|
||||
<br />
|
||||
<p class="has-text-highlight">{{this.activeDatum.tooltipUpgrade}}</p>
|
||||
{{/if}}
|
||||
<div class="chart-tooltip-arrow"></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
</Lineal::Fluid>
|
||||
</div>
|
||||
{{else}}
|
||||
<EmptyState @subTitle={{or @noDataMessage "No data to display"}} @bottomBorder={{true}} />
|
||||
{{/if}}
|
||||
</div>
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { SVG_DIMENSIONS, numericalAxisLabel } from 'vault/utils/chart-helpers';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { format, isValid } from 'date-fns';
|
||||
import { debug } from '@ember/debug';
|
||||
|
||||
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
|
||||
import type { MonthlyChartData, Timestamp } from 'vault/vault/charts/client-counts';
|
||||
import type { TotalClients } from 'core/utils/client-count-utils';
|
||||
|
||||
interface Args {
|
||||
dataset: MonthlyChartData[];
|
||||
upgradeData: ClientsVersionHistoryModel[];
|
||||
xKey?: string;
|
||||
yKey?: string;
|
||||
chartHeight?: number;
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
x: Date;
|
||||
y: number | null;
|
||||
new: number;
|
||||
tooltipUpgrade: string | null;
|
||||
month: string; // used for test selectors and to match key on upgradeData
|
||||
}
|
||||
|
||||
interface UpgradeByMonth {
|
||||
[key: string]: ClientsVersionHistoryModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @module LineChart
|
||||
* LineChart components are used to display time-based data in a line plot with accompanying tooltip
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <LineChart @dataset={{dataset}} @upgradeData={{this.versionHistory}}/>
|
||||
* ```
|
||||
* @param {array} dataset - array of objects containing data to be plotted
|
||||
* @param {string} [xKey=clients] - string denoting key for x-axis data of dataset. Should reference a timestamp string.
|
||||
* @param {string} [yKey=timestamp] - string denoting key for y-axis data of dataset. Should reference a number or null.
|
||||
* @param {array} [upgradeData=null] - array of objects containing version history from the /version-history endpoint
|
||||
* @param {number} [chartHeight=190] - height of chart in pixels
|
||||
*/
|
||||
export default class LineChart extends Component<Args> {
|
||||
// Chart settings
|
||||
get yKey() {
|
||||
return this.args.yKey || 'clients';
|
||||
}
|
||||
get xKey() {
|
||||
return this.args.xKey || 'timestamp';
|
||||
}
|
||||
get chartHeight() {
|
||||
return this.args.chartHeight || SVG_DIMENSIONS.height;
|
||||
}
|
||||
// Plot points
|
||||
get data(): ChartData[] {
|
||||
try {
|
||||
return this.args.dataset?.map((datum) => {
|
||||
const timestamp = parseAPITimestamp(datum[this.xKey as keyof Timestamp]) as Date;
|
||||
if (isValid(timestamp) === false)
|
||||
throw new Error(`Unable to parse value "${datum[this.xKey as keyof Timestamp]}" as date`);
|
||||
const upgradeMessage = this.getUpgradeMessage(datum);
|
||||
return {
|
||||
x: timestamp,
|
||||
y: (datum[this.yKey as keyof TotalClients] as number) ?? null,
|
||||
new: this.getNewClients(datum),
|
||||
tooltipUpgrade: upgradeMessage,
|
||||
month: datum.month,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
debug(e as string);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
get upgradedMonths() {
|
||||
// only render upgrade month circle if datum has client count data (the y value)
|
||||
return this.data.filter((datum) => datum.tooltipUpgrade && datum.y);
|
||||
}
|
||||
// Domains
|
||||
get yDomain() {
|
||||
const counts: number[] = this.data
|
||||
.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() {
|
||||
// these values are date objects but are already in chronological order so we use scale-point (instead of scale-time)
|
||||
// which calculates the x-scale based on the number of data points
|
||||
return this.data.map((d) => d.x);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
getUpgradeMessage(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;
|
||||
}
|
||||
getNewClients(datum: MonthlyChartData) {
|
||||
if (!datum?.new_clients) return 0;
|
||||
return (datum?.new_clients[this.yKey as keyof TotalClients] as number) || 0;
|
||||
}
|
||||
|
||||
// TEMPLATE HELPERS
|
||||
hasValue = (count: number | null) => {
|
||||
return typeof count === 'number' ? true : false;
|
||||
};
|
||||
formatCount = (num: number): string => {
|
||||
return numericalAxisLabel(num) || num.toString();
|
||||
};
|
||||
formatMonth = (date: Date) => {
|
||||
return format(date, 'M/yy');
|
||||
};
|
||||
tooltipX = (original: number) => {
|
||||
return original.toString();
|
||||
};
|
||||
tooltipY = (original: number) => {
|
||||
return `${this.chartHeight - original + 15}`;
|
||||
};
|
||||
}
|
||||
133
ui/app/components/clients/charts/vertical-bar-grouped.hbs
Normal file
133
ui/app/components/clients/charts/vertical-bar-grouped.hbs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
{{!
|
||||
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}}
|
||||
179
ui/app/components/clients/charts/vertical-bar-grouped.ts
Normal file
179
ui/app/components/clients/charts/vertical-bar-grouped.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
|
|
@ -5,8 +5,11 @@
|
|||
|
||||
<div ...attributes>
|
||||
<Hds::Text::Display @tag="p" class="has-bottom-margin-xs">
|
||||
Date range
|
||||
Client counting period
|
||||
</Hds::Text::Display>
|
||||
<Hds::Text::Body @tag="p" @color="faint" @size="300">
|
||||
The dashboard displays client count activity during the specified date range below. Click edit to update the date range.
|
||||
</Hds::Text::Body>
|
||||
|
||||
<div class="is-flex-align-baseline">
|
||||
{{#if (and @startTime @endTime)}}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,26 @@
|
|||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<Hds::Button
|
||||
data-test-attribution-export-button
|
||||
@text="Export activity data"
|
||||
@color="secondary"
|
||||
{{on "click" (fn (mut this.showExportModal) true)}}
|
||||
/>
|
||||
<Hds::PageHeader class="has-top-padding-l has-bottom-padding-m" as |PH|>
|
||||
<PH.Title>Vault Usage Metrics</PH.Title>
|
||||
<PH.Description>
|
||||
This dashboard surfaces Vault client usage over time.
|
||||
<Hds::Link::Inline @href={{doc-link "/vault/docs/concepts/client-count"}}>Documentation is available here</Hds::Link::Inline>.
|
||||
Date queries are sent in UTC.
|
||||
</PH.Description>
|
||||
<PH.Actions>
|
||||
{{#if this.showExportButton}}
|
||||
<Hds::Button
|
||||
data-test-export-button
|
||||
@text="Export activity data"
|
||||
@color="secondary"
|
||||
@icon="download"
|
||||
{{on "click" (fn (mut this.showExportModal) true)}}
|
||||
/>
|
||||
{{/if}}
|
||||
</PH.Actions>
|
||||
</Hds::PageHeader>
|
||||
|
||||
{{! MODAL FOR CSV DOWNLOAD }}
|
||||
{{#if this.showExportModal}}
|
||||
<Hds::Modal id="attribution-csv-download-modal" class="has-text-left" @onClose={{this.resetModal}} as |M|>
|
||||
<M.Header @icon="info" data-test-export-modal-title>
|
||||
|
|
@ -66,7 +78,35 @@
|
|||
/>
|
||||
<Hds::Button @text="Cancel" @color="secondary" {{on "click" F.close}} />
|
||||
</Hds::ButtonSet>
|
||||
{{yield to="alert"}}
|
||||
{{#if @upgradesDuringActivity}}
|
||||
<Hds::Alert class="has-top-padding-m" @type="compact" @color="warning" data-test-export-upgrade-warning as |A|>
|
||||
<A.Description>
|
||||
<strong>Data contains {{pluralize @upgradesDuringActivity.length "upgrade"}}:</strong>
|
||||
</A.Description>
|
||||
<A.Description>
|
||||
<ul class="bullet">
|
||||
{{#each @upgradesDuringActivity as |upgrade|}}
|
||||
<li>
|
||||
{{upgrade.version}}
|
||||
{{this.parseAPITimestamp upgrade.timestampInstalled "(MMM d, yyyy)"}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</A.Description>
|
||||
<A.Description>
|
||||
Visit our
|
||||
<Hds::Link::Inline
|
||||
@isHrefExternal={{true}}
|
||||
@href={{doc-link
|
||||
"/vault/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"
|
||||
}}
|
||||
>
|
||||
Client count FAQ
|
||||
</Hds::Link::Inline>
|
||||
for more information.
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
{{/if}}
|
||||
</M.Footer>
|
||||
</Hds::Modal>
|
||||
{{/if}}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
|
|
@ -13,26 +14,54 @@ import { format, isSameMonth } from 'date-fns';
|
|||
import { task } from 'ember-concurrency';
|
||||
|
||||
/**
|
||||
* @module ClientsExportButtonComponent
|
||||
* ClientsExportButton components are used to display the export button, manage the modal, and download the file from the clients export API
|
||||
* @module ClientsPageHeader
|
||||
* ClientsPageHeader components are used to render a header and check for export capabilities before rendering an export button.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <Clients::ExportButton @startTimestamp="2022-06-01T23:00:11.050Z" @endTimestamp="2022-12-01T23:00:11.050Z" @selectedNamespace="foo" />
|
||||
* <Clients::PageHeader @startTimestamp="2022-06-01T23:00:11.050Z" @endTimestamp="2022-12-01T23:00:11.050Z" @namespace="foo" @upgradesDuringActivity={{array (hash version="1.10.1" previousVersion="1.9.1" timestampInstalled= "2021-11-18T10:23:16Z") }} />
|
||||
* ```
|
||||
* @param {string} [startTimestamp] - ISO timestamp of start time, to be passed to export request
|
||||
* @param {string} [endTimestamp] - ISO timestamp of end time, to be passed to export request
|
||||
* @param {string} [namespace] - namespace filter. Will be appended to the current namespace in the export request.
|
||||
* @param {string} [upgradesDuringActivity] - array of objects containing version history upgrade data
|
||||
* @param {boolean} [noData = false] - when true, export button will hide regardless of capabilities
|
||||
*/
|
||||
export default class ClientsExportButtonComponent extends Component {
|
||||
export default class ClientsPageHeaderComponent extends Component {
|
||||
@service download;
|
||||
@service namespace;
|
||||
@service store;
|
||||
|
||||
@tracked canDownload = false;
|
||||
@tracked showExportModal = false;
|
||||
@tracked exportFormat = 'csv';
|
||||
@tracked downloadError = '';
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.getExportCapabilities(this.args.namespace);
|
||||
}
|
||||
|
||||
get showExportButton() {
|
||||
if (this.args.noData === true) return false;
|
||||
return this.canDownload;
|
||||
}
|
||||
|
||||
@waitFor
|
||||
async getExportCapabilities(ns = '') {
|
||||
try {
|
||||
// selected namespace usually ends in /
|
||||
const url = ns
|
||||
? `${sanitizePath(ns)}/sys/internal/counters/activity/export`
|
||||
: 'sys/internal/counters/activity/export';
|
||||
const cap = await this.store.findRecord('capabilities', url);
|
||||
this.canDownload = cap.canSudo;
|
||||
} catch (e) {
|
||||
// if we can't read capabilities, default to show
|
||||
this.canDownload = true;
|
||||
}
|
||||
}
|
||||
|
||||
get formattedStartDate() {
|
||||
if (!this.args.startTimestamp) return null;
|
||||
return parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy');
|
||||
|
|
@ -55,8 +84,8 @@ export default class ClientsExportButtonComponent extends Component {
|
|||
|
||||
get namespaceFilter() {
|
||||
const currentNs = this.namespace.path;
|
||||
const { selectedNamespace } = this.args;
|
||||
return selectedNamespace ? sanitizePath(`${currentNs}/${selectedNamespace}`) : sanitizePath(currentNs);
|
||||
const { namespace } = this.args;
|
||||
return namespace ? sanitizePath(`${currentNs}/${namespace}`) : sanitizePath(currentNs);
|
||||
}
|
||||
|
||||
async getExportData() {
|
||||
|
|
@ -71,6 +100,10 @@ export default class ClientsExportButtonComponent extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
parseAPITimestamp = (timestamp, format) => {
|
||||
return parseAPITimestamp(timestamp, format);
|
||||
};
|
||||
|
||||
exportChartData = task({ drop: true }, async (filename) => {
|
||||
try {
|
||||
const contents = await this.getExportData();
|
||||
|
|
@ -32,20 +32,6 @@
|
|||
/>
|
||||
</:subTitle>
|
||||
|
||||
<:stats>
|
||||
{{#let (this.average this.byMonthActivityData "acme_clients") as |avg|}}
|
||||
{{! 0 is falsy, intentionally hide 0 averages }}
|
||||
{{#if avg}}
|
||||
<StatText
|
||||
@label="Average ACME clients per month"
|
||||
@value={{avg}}
|
||||
@size="m"
|
||||
class="data-details-top has-top-padding-l"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
</:stats>
|
||||
|
||||
<:chart>
|
||||
<Clients::Charts::VerticalBarBasic
|
||||
@chartTitle={{this.title}}
|
||||
|
|
|
|||
|
|
@ -3,21 +3,15 @@
|
|||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<PageHeader as |p|>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3">
|
||||
Vault Usage Metrics
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
|
||||
<p class="has-bottom-margin-xl">
|
||||
This dashboard surfaces Vault client usage over time.
|
||||
<Hds::Link::Inline @href={{doc-link "/vault/docs/concepts/client-count"}}>Documentation is available here</Hds::Link::Inline>.
|
||||
Date queries are sent in UTC.
|
||||
</p>
|
||||
<Clients::PageHeader
|
||||
@startTimestamp={{@startTimestamp}}
|
||||
@endTimestamp={{@endTimestamp}}
|
||||
@namespace={{@namespace}}
|
||||
@upgradesDuringActivity={{this.upgradesDuringActivity}}
|
||||
@noData={{not @activity.total.clients}}
|
||||
/>
|
||||
|
||||
<div class="box is-sideless is-fullwidth is-marginless is-bottomless is-shadowless">
|
||||
<Clients::DateRange
|
||||
@startTime={{@startTimestamp}}
|
||||
@endTime={{@endTimestamp}}
|
||||
|
|
@ -42,7 +36,7 @@
|
|||
</Hds::Alert>
|
||||
{{/if}}
|
||||
|
||||
{{#if @startTimestamp}}
|
||||
{{#if (or @mountPath this.mountPaths)}}
|
||||
<Hds::Text::Display @tag="p">
|
||||
Filters
|
||||
</Hds::Text::Display>
|
||||
|
|
@ -62,11 +56,12 @@
|
|||
@disallowNewItems={{true}}
|
||||
@fallbackComponent="input-search"
|
||||
@onChange={{fn this.setFilterValue "ns"}}
|
||||
@placeholder="Filter by namespace"
|
||||
@placeholder="Namespace within {{this.namespacePathForFilter}}"
|
||||
@displayInherit={{true}}
|
||||
class="is-marginless"
|
||||
data-test-counts-namespaces
|
||||
/>
|
||||
<div class="has-left-margin-xs"></div>
|
||||
{{/if}}
|
||||
{{#if (or @mountPath this.mountPaths)}}
|
||||
<SearchSelect
|
||||
|
|
@ -77,7 +72,7 @@
|
|||
@disallowNewItems={{true}}
|
||||
@fallbackComponent="input-search"
|
||||
@onChange={{fn this.setFilterValue "mountPath"}}
|
||||
@placeholder="Filter by mount path"
|
||||
@placeholder="Mount path within {{this.namespacePathForFilter}}"
|
||||
@displayInherit={{true}}
|
||||
data-test-counts-auth-mounts
|
||||
/>
|
||||
|
|
@ -86,7 +81,7 @@
|
|||
</Toolbar>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.filteredActivity}}
|
||||
{{#if this.totalUsageCounts}}
|
||||
{{#if this.upgradeExplanations}}
|
||||
<Hds::Alert data-test-clients-upgrade-warning @type="inline" @color="warning" class="has-bottom-margin-m" as |A|>
|
||||
<A.Title>
|
||||
|
|
@ -128,9 +123,12 @@
|
|||
{{! CLIENT COUNT PAGE COMPONENTS RENDER HERE }}
|
||||
{{yield}}
|
||||
|
||||
{{else if (and (not @config.billingStartTimestamp) (not @startTimestamp))}}
|
||||
{{! Empty state for no billing/license start date }}
|
||||
<EmptyState @title={{this.versionText.title}} @message={{this.versionText.message}} />
|
||||
{{else if (and this.version.isCommunity (not @startTimestamp))}}
|
||||
{{! Empty state for community without start query param }}
|
||||
<EmptyState
|
||||
@title="No start date found"
|
||||
@message="In order to get the most from this data, please enter a start month above. Vault will calculate new clients starting from that month."
|
||||
/>
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="No data received {{if this.dateRangeMessage this.dateRangeMessage}}"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { service } from '@ember/service';
|
|||
import { action } from '@ember/object';
|
||||
import { isSameMonth, isAfter } from 'date-fns';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { filterVersionHistory } from 'core/utils/client-count-utils';
|
||||
import { filteredTotalForMount, filterVersionHistory, TotalClients } from 'core/utils/client-count-utils';
|
||||
import { sanitizePath } from 'core/utils/sanitize-path';
|
||||
|
||||
import type AdapterError from '@ember-data/adapter';
|
||||
import type FlagsService from 'vault/services/flags';
|
||||
|
|
@ -17,6 +18,7 @@ import type VersionService from 'vault/services/version';
|
|||
import type ClientsActivityModel from 'vault/models/clients/activity';
|
||||
import type ClientsConfigModel from 'vault/models/clients/config';
|
||||
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
|
||||
import type NamespaceService from 'vault/services/namespace';
|
||||
interface Args {
|
||||
activity: ClientsActivityModel;
|
||||
activityError?: AdapterError;
|
||||
|
|
@ -32,6 +34,7 @@ interface Args {
|
|||
export default class ClientsCountsPageComponent extends Component<Args> {
|
||||
@service declare readonly flags: FlagsService;
|
||||
@service declare readonly version: VersionService;
|
||||
@service declare readonly namespace: NamespaceService;
|
||||
@service declare readonly store: StoreService;
|
||||
|
||||
get formattedStartDate() {
|
||||
|
|
@ -53,11 +56,15 @@ export default class ClientsCountsPageComponent extends Component<Args> {
|
|||
return null;
|
||||
}
|
||||
|
||||
get upgradeExplanations() {
|
||||
// passed into page-header for the export modal alert
|
||||
get upgradesDuringActivity() {
|
||||
const { versionHistory, activity } = this.args;
|
||||
const upgradesDuringActivity = filterVersionHistory(versionHistory, activity.startTime, activity.endTime);
|
||||
if (upgradesDuringActivity.length) {
|
||||
return upgradesDuringActivity.map((upgrade: ClientsVersionHistoryModel) => {
|
||||
return filterVersionHistory(versionHistory, activity.startTime, activity.endTime);
|
||||
}
|
||||
|
||||
get upgradeExplanations() {
|
||||
if (this.upgradesDuringActivity.length) {
|
||||
return this.upgradesDuringActivity.map((upgrade: ClientsVersionHistoryModel) => {
|
||||
let explanation;
|
||||
const date = parseAPITimestamp(upgrade.timestampInstalled, 'MMM d, yyyy');
|
||||
const version = upgrade.version || '';
|
||||
|
|
@ -96,27 +103,56 @@ export default class ClientsCountsPageComponent extends Component<Args> {
|
|||
};
|
||||
}
|
||||
|
||||
// path of the filtered namespace OR current one, for filtering relevant data
|
||||
get namespacePathForFilter() {
|
||||
const { namespace } = this.args;
|
||||
const currentNs = this.namespace.currentNamespace;
|
||||
return sanitizePath(namespace || currentNs || 'root');
|
||||
}
|
||||
|
||||
// activityForNamespace gets the byNamespace data for the selected or current namespace so we can get the list of mounts from that namespace for attribution
|
||||
get activityForNamespace() {
|
||||
const { activity } = this.args;
|
||||
const nsPath = this.namespacePathForFilter;
|
||||
// we always return activity for namespace, either the selected filter or the current
|
||||
return activity?.byNamespace?.find((ns) => sanitizePath(ns.label) === nsPath);
|
||||
}
|
||||
|
||||
// duplicate of the method found in the activity component, so that we render the child only when there is activity to view
|
||||
get totalUsageCounts(): TotalClients {
|
||||
const { namespace, mountPath, activity } = this.args;
|
||||
if (mountPath) {
|
||||
// only do this if we have a mountPath filter.
|
||||
// namespace is filtered on API layer
|
||||
return filteredTotalForMount(activity.byNamespace, namespace, mountPath);
|
||||
}
|
||||
return activity?.total;
|
||||
}
|
||||
|
||||
// namespace list for the search-select filter
|
||||
get namespaces() {
|
||||
return this.args.activity.byNamespace
|
||||
? this.args.activity.byNamespace.map((namespace) => ({
|
||||
name: namespace.label,
|
||||
id: namespace.label,
|
||||
}))
|
||||
? this.args.activity.byNamespace
|
||||
.map((namespace) => ({
|
||||
name: namespace.label,
|
||||
id: namespace.label,
|
||||
}))
|
||||
.filter((ns) => sanitizePath(ns.name) !== this.namespacePathForFilter)
|
||||
: [];
|
||||
}
|
||||
|
||||
// mounts within the current/filtered namespace for the sesarch-select filter
|
||||
get mountPaths() {
|
||||
if (this.namespaces.length) {
|
||||
return this.activityForNamespace?.mounts.map((mount) => ({
|
||||
return (
|
||||
this.activityForNamespace?.mounts.map((mount) => ({
|
||||
id: mount.label,
|
||||
name: mount.label,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
// banner contents shown if startTime returned from activity API (which matches the first month with data) is after the queried startTime
|
||||
get startTimeDiscrepancy() {
|
||||
// show banner if startTime returned from activity log (response) is after the queried startTime
|
||||
const { activity, config } = this.args;
|
||||
const activityStartDateObject = parseAPITimestamp(activity.startTime) as Date;
|
||||
const queryStartDateObject = parseAPITimestamp(this.args.startTimestamp) as Date;
|
||||
|
|
@ -136,26 +172,9 @@ export default class ClientsCountsPageComponent extends Component<Args> {
|
|||
}
|
||||
}
|
||||
|
||||
get activityForNamespace() {
|
||||
const { activity, namespace } = this.args;
|
||||
return namespace ? activity.byNamespace.find((ns) => ns.label === namespace) : null;
|
||||
}
|
||||
|
||||
get filteredActivity() {
|
||||
// return activity counts based on selected namespace and auth mount values
|
||||
const { namespace, mountPath, activity } = this.args;
|
||||
if (namespace) {
|
||||
return mountPath
|
||||
? this.activityForNamespace?.mounts.find((mount) => mount.label === mountPath)
|
||||
: this.activityForNamespace;
|
||||
}
|
||||
return activity?.total;
|
||||
}
|
||||
|
||||
// the dashboard should show sync tab if the flag is on or there's data
|
||||
get hasSecretsSyncClients(): boolean {
|
||||
const { activity } = this.args;
|
||||
// if there is any sync client data, show it
|
||||
return activity && activity?.total?.secret_syncs > 0;
|
||||
return this.args.activity?.total?.secret_syncs > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
@ -166,9 +185,12 @@ export default class ClientsCountsPageComponent extends Component<Args> {
|
|||
@action
|
||||
setFilterValue(type: 'ns' | 'mountPath', [value]: [string | undefined]) {
|
||||
const params = { [type]: value };
|
||||
// unset mountPath value when namespace is cleared
|
||||
if (type === 'ns' && !value) {
|
||||
// unset mountPath value when namespace is cleared
|
||||
params['mountPath'] = undefined;
|
||||
} else if (type === 'mountPath' && !this.args.namespace) {
|
||||
// set namespace when mountPath set without namespace already set
|
||||
params['ns'] = this.namespacePathForFilter;
|
||||
}
|
||||
this.args.onFilterChange(params);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,17 +13,18 @@
|
|||
@responseTimestamp={{@activity.responseTimestamp}}
|
||||
@mountPath={{@mountPath}}
|
||||
/>
|
||||
|
||||
{{#if this.hasAttributionData}}
|
||||
<Clients::Attribution
|
||||
@isSecretsSyncActivated={{this.flags.secretsSyncIsActivated}}
|
||||
@newUsageCounts={{this.newClientCounts}}
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@newClientAttribution={{this.newClientAttribution}}
|
||||
@selectedNamespace={{@namespace}}
|
||||
@startTimestamp={{@startTimestamp}}
|
||||
@endTimestamp={{@endTimestamp}}
|
||||
@noun="namespace"
|
||||
@attribution={{@activity.byNamespace}}
|
||||
@responseTimestamp={{@activity.responseTimestamp}}
|
||||
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
|
||||
@upgradesDuringActivity={{this.upgradesDuringActivity}}
|
||||
@isSecretsSyncActivated={{this.flags.secretsSyncIsActivated}}
|
||||
/>
|
||||
<Clients::Attribution
|
||||
@noun="mount"
|
||||
@attribution={{this.namespaceMountAttribution}}
|
||||
@responseTimestamp={{@activity.responseTimestamp}}
|
||||
@isSecretsSyncActivated={{this.flags.secretsSyncIsActivated}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
|
@ -5,8 +5,23 @@
|
|||
|
||||
import ActivityComponent from '../activity';
|
||||
import { service } from '@ember/service';
|
||||
import { sanitizePath } from 'core/utils/sanitize-path';
|
||||
import type FlagsService from 'vault/services/flags';
|
||||
|
||||
export default class ClientsOverviewPageComponent extends ActivityComponent {
|
||||
@service declare readonly flags: FlagsService;
|
||||
|
||||
get hasAttributionData() {
|
||||
// we hide attribution data when mountPath filter present
|
||||
// or if there's no data
|
||||
if (this.args.mountPath || !this.totalUsageCounts.clients) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// mounts attribution
|
||||
get namespaceMountAttribution() {
|
||||
const { activity } = this.args;
|
||||
const nsLabel = this.namespacePathForFilter;
|
||||
return activity?.byNamespace?.find((ns) => sanitizePath(ns.label) === nsLabel)?.mounts || [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,20 +30,6 @@
|
|||
/>
|
||||
</:subTitle>
|
||||
|
||||
<:stats>
|
||||
{{#let (this.average this.byMonthActivityData "secret_syncs") as |avg|}}
|
||||
{{! intentionally hides a 0 average (0 is falsy) }}
|
||||
{{#if avg}}
|
||||
<StatText
|
||||
@label="Average sync clients per month"
|
||||
@value={{avg}}
|
||||
@size="m"
|
||||
class="data-details-top has-top-padding-l"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
</:stats>
|
||||
|
||||
<:chart>
|
||||
<Clients::Charts::VerticalBarBasic
|
||||
@chartTitle={{this.title}}
|
||||
|
|
|
|||
|
|
@ -21,15 +21,6 @@
|
|||
/>
|
||||
</:subTitle>
|
||||
|
||||
<:stats>
|
||||
<StatText
|
||||
@label="Average total clients per month"
|
||||
@value={{this.averageTotalClients}}
|
||||
@size="m"
|
||||
class="data-details-top has-top-padding-l"
|
||||
/>
|
||||
</:stats>
|
||||
|
||||
<:chart>
|
||||
<Clients::Charts::VerticalBarStacked
|
||||
@chartTitle="Entity/Non-entity clients usage"
|
||||
|
|
|
|||
|
|
@ -29,10 +29,6 @@ export default class ClientsTokenPageComponent extends ActivityComponent {
|
|||
}, 0);
|
||||
}
|
||||
|
||||
get averageTotalClients() {
|
||||
return this.calculateClientAverages(this.byMonthActivityData);
|
||||
}
|
||||
|
||||
get averageNewClients() {
|
||||
return this.calculateClientAverages(this.byMonthNewClients);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,13 +36,20 @@
|
|||
</:stats>
|
||||
|
||||
<:chart>
|
||||
<Clients::Charts::Line
|
||||
@dataset={{@byMonthActivityData}}
|
||||
<Clients::Charts::VerticalBarGrouped
|
||||
@data={{this.runningTotalData}}
|
||||
@legend={{this.chartLegend}}
|
||||
@upgradeData={{@upgradeData}}
|
||||
@chartHeight="250"
|
||||
@chartTitle="Vault client counts line chart"
|
||||
@chartTitle="Vault client counts"
|
||||
/>
|
||||
</: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>
|
||||
</Clients::ChartContainer>
|
||||
{{else}}
|
||||
{{#let (get @byMonthActivityData "0") as |singleMonthData|}}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,18 @@
|
|||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import type { ByMonthClients, TotalClients } from 'core/utils/client-count-utils';
|
||||
import ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history';
|
||||
|
||||
interface Args {
|
||||
isSecretsSyncActivated: boolean;
|
||||
byMonthActivityData: ByMonthClients[];
|
||||
isHistoricalMonth: boolean;
|
||||
isCurrentMonth: boolean;
|
||||
runningTotals: TotalClients;
|
||||
upgradesDuringActivity: ClientsVersionHistoryModel[];
|
||||
responseTimestamp: string;
|
||||
mountPath: string;
|
||||
}
|
||||
|
||||
export default class RunningTotal extends Component<Args> {
|
||||
|
|
@ -16,4 +25,18 @@ export default class RunningTotal extends Component<Args> {
|
|||
isSecretsSyncActivated ? ', ACME and secrets sync clients' : ' and ACME clients'
|
||||
}. The total client count number is an important consideration for Vault billing.`;
|
||||
}
|
||||
|
||||
get runningTotalData() {
|
||||
return this.args.byMonthActivityData.map((monthly) => ({
|
||||
...monthly,
|
||||
new_clients: monthly.new_clients?.clients,
|
||||
}));
|
||||
}
|
||||
|
||||
get chartLegend() {
|
||||
return [
|
||||
{ key: 'clients', label: 'total clients' },
|
||||
{ key: 'new_clients', label: 'new clients' },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ import Route from '@ember/routing/route';
|
|||
import { service } from '@ember/service';
|
||||
import { fromUnixTime } from 'date-fns';
|
||||
|
||||
import type AdapterError from '@ember-data/adapter';
|
||||
import type FlagsService from 'vault/services/flags';
|
||||
import type NamespaceService from 'vault/services/namespace';
|
||||
import type StoreService from 'vault/services/store';
|
||||
import type VersionService from 'vault/services/version';
|
||||
import type { ModelFrom } from 'vault/vault/route';
|
||||
import type ClientsRoute from '../clients';
|
||||
import type ClientsCountsController from 'vault/controllers/vault/cluster/clients/counts';
|
||||
import type ClientsActivityModel from 'vault/vault/models/clients/activity';
|
||||
|
||||
export interface ClientsCountsRouteParams {
|
||||
start_time?: string | number | undefined;
|
||||
|
|
@ -21,8 +24,17 @@ export interface ClientsCountsRouteParams {
|
|||
mountPath?: string | undefined;
|
||||
}
|
||||
|
||||
interface ActivityAdapterQuery {
|
||||
start_time: { timestamp: number } | undefined;
|
||||
end_time: { timestamp: number } | undefined;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export type ClientsCountsRouteModel = ModelFrom<ClientsCountsRoute>;
|
||||
|
||||
export default class ClientsCountsRoute extends Route {
|
||||
@service declare readonly flags: FlagsService;
|
||||
@service declare readonly namespace: NamespaceService;
|
||||
@service declare readonly store: StoreService;
|
||||
@service declare readonly version: VersionService;
|
||||
|
||||
|
|
@ -56,16 +68,23 @@ export default class ClientsCountsRoute extends Route {
|
|||
return timestamp;
|
||||
}
|
||||
|
||||
async getActivity(params: ClientsCountsRouteParams) {
|
||||
async getActivity(params: ClientsCountsRouteParams): Promise<{
|
||||
activity: ClientsActivityModel;
|
||||
activityError: AdapterError;
|
||||
}> {
|
||||
let activity, activityError;
|
||||
// if CE without start time we want to skip the activity call
|
||||
// so that the user is forced to choose a date range
|
||||
if (this.version.isEnterprise || params.start_time) {
|
||||
const query = {
|
||||
const query: ActivityAdapterQuery = {
|
||||
// start and end params are optional -- if not provided, will fallback to API default
|
||||
start_time: this.formatTimeQuery(params?.start_time),
|
||||
end_time: this.formatTimeQuery(params?.end_time),
|
||||
};
|
||||
if (params?.ns) {
|
||||
// only set explicit namespace if it's a query param
|
||||
query.namespace = params.ns;
|
||||
}
|
||||
try {
|
||||
activity = await this.store.queryRecord('clients/activity', query);
|
||||
} catch (error) {
|
||||
|
|
@ -97,7 +116,11 @@ export default class ClientsCountsRoute extends Route {
|
|||
activityError,
|
||||
config,
|
||||
// activity.startTime corresponds to first month with data, but we want first month returned or requested
|
||||
startTimestamp: this.paramOrResponseTimestamp(params?.start_time, activity?.byMonth[0]?.timestamp),
|
||||
// unless no months present, then we can fallback to response's start time
|
||||
startTimestamp: this.paramOrResponseTimestamp(
|
||||
params?.start_time,
|
||||
activity?.byMonth[0]?.timestamp || activity?.startTime
|
||||
),
|
||||
endTimestamp: this.paramOrResponseTimestamp(params?.end_time, activity?.endTime),
|
||||
versionHistory,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,13 +21,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.dual-chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-template-rows: 0.7fr 1fr 1fr 1fr 0.3fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: span col4-end;
|
||||
|
|
|
|||
|
|
@ -46,3 +46,13 @@
|
|||
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 {
|
||||
color: var(--token-color-palette-blue-100);
|
||||
fill: var(--token-color-palette-blue-100);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@
|
|||
&.dot-3 {
|
||||
background-color: var(--token-color-palette-neutral-500);
|
||||
}
|
||||
// custom naming for running totals, which matches the custom-bar naming
|
||||
&.dot-clients {
|
||||
background-color: var(--token-color-palette-blue-200);
|
||||
}
|
||||
&.dot-new_clients {
|
||||
background-color: var(--token-color-palette-blue-100);
|
||||
}
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
|
|
@ -83,13 +90,6 @@
|
|||
// RESPONSIVE STYLING //
|
||||
|
||||
@media only screen and (max-width: 950px) {
|
||||
.dual-chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: 0.2fr 0.75fr 0.75fr 0.2fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-container-left {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 4;
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import isEmpty from '@ember/utils/lib/is_empty';
|
||||
import { parseAPITimestamp } from 'core/utils/date-formatters';
|
||||
import { sanitizePath } from 'core/utils/sanitize-path';
|
||||
import { compareAsc, getUnixTime, isWithinInterval } from 'date-fns';
|
||||
|
||||
import type ClientsConfigModel from 'vault/models/clients/config';
|
||||
import type ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history';
|
||||
|
||||
/*
|
||||
|
|
@ -27,6 +27,15 @@ export const CLIENT_TYPES = [
|
|||
|
||||
export type ClientTypes = (typeof CLIENT_TYPES)[number];
|
||||
|
||||
// generates a block of total clients with 0's for use as defaults
|
||||
function emptyCounts() {
|
||||
return CLIENT_TYPES.reduce((prev, type) => {
|
||||
const key = type;
|
||||
prev[key as ClientTypes] = 0;
|
||||
return prev;
|
||||
}, {} as TotalClientsSometimes) as TotalClients;
|
||||
}
|
||||
|
||||
// returns array of VersionHistoryModels for noteworthy upgrades: 1.9, 1.10
|
||||
// that occurred between timestamps (i.e. queried activity data)
|
||||
export const filterVersionHistory = (
|
||||
|
|
@ -66,6 +75,82 @@ export const filterVersionHistory = (
|
|||
return [];
|
||||
};
|
||||
|
||||
// This method is used to return totals relevant only to the specified
|
||||
// mount path within the specified namespace.
|
||||
export const filteredTotalForMount = (
|
||||
byNamespace: ByNamespaceClients[],
|
||||
nsPath: string,
|
||||
mountPath: string
|
||||
): TotalClients => {
|
||||
if (!nsPath || !mountPath || isEmpty(byNamespace)) return emptyCounts();
|
||||
return (
|
||||
byNamespace
|
||||
.find((namespace) => namespace.label === nsPath)
|
||||
?.mounts.find((mount: MountClients) => mount.label === mountPath) || emptyCounts()
|
||||
);
|
||||
};
|
||||
|
||||
// This method is used to filter byMonth data and return data for only
|
||||
// the specified mount within the specified namespace. If data exists
|
||||
// for the month but not the mount, it should return zero'd data. If
|
||||
// no data exists for the month is returns the month as-is.
|
||||
export const filterByMonthDataForMount = (
|
||||
byMonth: ByMonthClients[],
|
||||
namespacePath: string,
|
||||
mountPath: string
|
||||
): ByMonthClients[] => {
|
||||
if (byMonth && namespacePath && mountPath) {
|
||||
const months: ByMonthClients[] = JSON.parse(JSON.stringify(byMonth));
|
||||
return [...months].map((m) => {
|
||||
if (m?.clients === undefined) {
|
||||
// if the month doesn't have data we can just return the block
|
||||
return m;
|
||||
}
|
||||
|
||||
const nsData = m.namespaces?.find((ns) => sanitizePath(ns.label) === sanitizePath(namespacePath));
|
||||
const mountData = nsData?.mounts.find((mount) => sanitizePath(mount.label) === sanitizePath(mountPath));
|
||||
if (mountData) {
|
||||
// if we do have mount data, we need to add in new_client namespace information
|
||||
const nsNew = m.new_clients?.namespaces?.find(
|
||||
(ns) => sanitizePath(ns.label) === sanitizePath(namespacePath)
|
||||
);
|
||||
const mountNew =
|
||||
nsNew?.mounts.find((mount) => sanitizePath(mount.label) === sanitizePath(mountPath)) ||
|
||||
emptyCounts();
|
||||
return {
|
||||
month: m.month,
|
||||
timestamp: m.timestamp,
|
||||
...mountData,
|
||||
namespaces: [], // this is just for making TS happy, matching the ByMonthClients shape
|
||||
new_clients: {
|
||||
month: m.month,
|
||||
timestamp: m.timestamp,
|
||||
label: mountPath,
|
||||
namespaces: [], // this is just for making TS happy, matching the ByMonthClients shape
|
||||
...mountNew,
|
||||
},
|
||||
} as ByMonthClients;
|
||||
}
|
||||
// if the month has data but none for this mount, return mocked zeros
|
||||
return {
|
||||
month: m.month,
|
||||
timestamp: m.timestamp,
|
||||
label: mountPath,
|
||||
namespaces: [], // this is just for making TS happy, matching the ByMonthClients shape
|
||||
...emptyCounts(),
|
||||
new_clients: {
|
||||
timestamp: m.timestamp,
|
||||
month: m.month,
|
||||
label: mountPath,
|
||||
namespaces: [], // this is just for making TS happy, matching the ByMonthClients shape
|
||||
...emptyCounts(),
|
||||
},
|
||||
} as ByMonthClients;
|
||||
});
|
||||
}
|
||||
return byMonth;
|
||||
};
|
||||
|
||||
// METHODS FOR SERIALIZING ACTIVITY RESPONSE
|
||||
export const formatDateObject = (dateObj: { monthIdx: number; year: number }, isEnd: boolean) => {
|
||||
const { year, monthIdx } = dateObj;
|
||||
|
|
@ -75,48 +160,36 @@ export const formatDateObject = (dateObj: { monthIdx: number; year: number }, is
|
|||
return getUnixTime(utc);
|
||||
};
|
||||
|
||||
export const formatByMonths = (
|
||||
monthsArray: (ActivityMonthBlock | EmptyActivityMonthBlock | NoNewClientsActivityMonthBlock)[]
|
||||
) => {
|
||||
export const formatByMonths = (monthsArray: ActivityMonthBlock[]): ByMonthNewClients[] => {
|
||||
const sortedPayload = sortMonthsByTimestamp(monthsArray);
|
||||
return sortedPayload?.map((m) => {
|
||||
const month = parseAPITimestamp(m.timestamp, 'M/yy') as string;
|
||||
const { timestamp } = m;
|
||||
// counts are null if there is no monthly data
|
||||
if (m.counts) {
|
||||
const totalClientsByNamespace = formatByNamespace(m.namespaces);
|
||||
const newClientsByNamespace = formatByNamespace(m.new_clients?.namespaces);
|
||||
|
||||
let newClients: ByMonthNewClients = { month, timestamp, namespaces: [] };
|
||||
if (m.new_clients?.counts) {
|
||||
newClients = {
|
||||
month,
|
||||
timestamp,
|
||||
...destructureClientCounts(m?.new_clients?.counts),
|
||||
namespaces: formatByNamespace(m.new_clients?.namespaces),
|
||||
};
|
||||
}
|
||||
if (monthIsEmpty(m)) {
|
||||
// empty month
|
||||
return {
|
||||
month,
|
||||
timestamp,
|
||||
...destructureClientCounts(m.counts),
|
||||
namespaces: formatByNamespace(m.namespaces),
|
||||
namespaces_by_key: namespaceArrayToObject(
|
||||
totalClientsByNamespace,
|
||||
newClientsByNamespace,
|
||||
month,
|
||||
m.timestamp
|
||||
),
|
||||
new_clients: newClients,
|
||||
namespaces: [],
|
||||
new_clients: { month, timestamp, namespaces: [] },
|
||||
};
|
||||
}
|
||||
|
||||
let newClients: ByMonthNewClients = { month, timestamp, namespaces: [] };
|
||||
if (monthWithAllCounts(m)) {
|
||||
newClients = {
|
||||
month,
|
||||
timestamp,
|
||||
...destructureClientCounts(m?.new_clients.counts),
|
||||
namespaces: formatByNamespace(m.new_clients.namespaces),
|
||||
};
|
||||
}
|
||||
// empty month
|
||||
return {
|
||||
month,
|
||||
timestamp,
|
||||
namespaces: [],
|
||||
namespaces_by_key: {},
|
||||
new_clients: { month, timestamp, namespaces: [] },
|
||||
...destructureClientCounts(m.counts),
|
||||
namespaces: formatByNamespace(m.namespaces),
|
||||
new_clients: newClients,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
@ -154,75 +227,22 @@ export const destructureClientCounts = (verboseObject: Counts | ByNamespaceClien
|
|||
);
|
||||
};
|
||||
|
||||
export const sortMonthsByTimestamp = (
|
||||
monthsArray: (ActivityMonthBlock | EmptyActivityMonthBlock | NoNewClientsActivityMonthBlock)[]
|
||||
) => {
|
||||
export const sortMonthsByTimestamp = (monthsArray: ActivityMonthBlock[]) => {
|
||||
const sortedPayload = [...monthsArray];
|
||||
return sortedPayload.sort((a, b) =>
|
||||
compareAsc(parseAPITimestamp(a.timestamp) as Date, parseAPITimestamp(b.timestamp) as Date)
|
||||
);
|
||||
};
|
||||
|
||||
export const namespaceArrayToObject = (
|
||||
monthTotals: ByNamespaceClients[],
|
||||
// technically this arg (monthNew) is the same type as above, just nested inside monthly new clients
|
||||
monthNew: ByMonthClients['new_clients']['namespaces'] | null,
|
||||
month: string,
|
||||
timestamp: string
|
||||
) => {
|
||||
// namespaces_by_key is used to filter monthly activity data by namespace
|
||||
// it's an object in each month data block where the keys are namespace paths
|
||||
// and values include new and total client counts for that namespace in that month
|
||||
const namespaces_by_key = monthTotals.reduce((nsObject: { [key: string]: NamespaceByKey }, ns) => {
|
||||
const keyedNs: NamespaceByKey = {
|
||||
...destructureClientCounts(ns),
|
||||
timestamp,
|
||||
month,
|
||||
mounts_by_key: {},
|
||||
new_clients: {
|
||||
month,
|
||||
timestamp,
|
||||
label: ns.label,
|
||||
mounts: [],
|
||||
},
|
||||
};
|
||||
const newNsClients = monthNew?.find((n) => n.label === ns.label);
|
||||
// mounts_by_key is is used to filter further in a namespace and get monthly activity by mount
|
||||
// it's an object inside the namespace block where the keys are mount paths
|
||||
// and the values include new and total client counts for that mount in that month
|
||||
keyedNs.mounts_by_key = ns.mounts.reduce(
|
||||
(mountObj: { [key: string]: MountByKey }, mount) => {
|
||||
const mountNewClients = newNsClients ? newNsClients.mounts.find((m) => m.label === mount.label) : {};
|
||||
mountObj[mount.label] = {
|
||||
...mount,
|
||||
timestamp,
|
||||
month,
|
||||
new_clients: {
|
||||
timestamp,
|
||||
month,
|
||||
label: mount.label,
|
||||
...mountNewClients,
|
||||
},
|
||||
};
|
||||
|
||||
return mountObj;
|
||||
},
|
||||
{} as { [key: string]: MountByKey }
|
||||
);
|
||||
if (newNsClients) {
|
||||
keyedNs.new_clients = { month, timestamp, ...newNsClients };
|
||||
}
|
||||
nsObject[ns.label] = keyedNs;
|
||||
return nsObject;
|
||||
}, {});
|
||||
|
||||
return namespaces_by_key;
|
||||
};
|
||||
|
||||
// type guards for conditionals
|
||||
function _hasConfig(model: ClientsConfigModel | object): model is ClientsConfigModel {
|
||||
if (!model) return false;
|
||||
return 'billingStartTimestamp' in model;
|
||||
function monthIsEmpty(month: ActivityMonthBlock): month is ActivityMonthEmpty {
|
||||
return !month || month?.counts === null;
|
||||
}
|
||||
function monthWithoutNewCounts(month: ActivityMonthBlock): month is ActivityMonthNoNewClients {
|
||||
return month?.counts !== null && month?.new_clients?.counts === null;
|
||||
}
|
||||
function monthWithAllCounts(month: ActivityMonthBlock): month is ActivityMonthStandard {
|
||||
return month?.counts !== null && month?.new_clients?.counts !== null;
|
||||
}
|
||||
|
||||
export function hasMountsKey(
|
||||
|
|
@ -268,11 +288,9 @@ export interface ByMonthClients extends TotalClients {
|
|||
month: string;
|
||||
timestamp: string;
|
||||
namespaces: ByNamespaceClients[];
|
||||
namespaces_by_key: { [key: string]: NamespaceByKey };
|
||||
new_clients: ByMonthNewClients;
|
||||
}
|
||||
|
||||
// clients numbers are only returned if month is of type ActivityMonthBlock
|
||||
export interface ByMonthNewClients extends TotalClientsSometimes {
|
||||
month: string;
|
||||
timestamp: string;
|
||||
|
|
@ -282,7 +300,6 @@ export interface ByMonthNewClients extends TotalClientsSometimes {
|
|||
export interface NamespaceByKey extends TotalClients {
|
||||
month: string;
|
||||
timestamp: string;
|
||||
mounts_by_key: { [key: string]: MountByKey };
|
||||
new_clients: NamespaceNewClients;
|
||||
}
|
||||
|
||||
|
|
@ -315,7 +332,7 @@ export interface NamespaceObject {
|
|||
mounts: { mount_path: string; counts: Counts }[];
|
||||
}
|
||||
|
||||
export interface ActivityMonthBlock {
|
||||
type ActivityMonthStandard = {
|
||||
timestamp: string; // YYYY-MM-01T00:00:00Z (always the first day of the month)
|
||||
counts: Counts;
|
||||
namespaces: NamespaceObject[];
|
||||
|
|
@ -324,9 +341,8 @@ export interface ActivityMonthBlock {
|
|||
namespaces: NamespaceObject[];
|
||||
timestamp: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NoNewClientsActivityMonthBlock {
|
||||
};
|
||||
type ActivityMonthNoNewClients = {
|
||||
timestamp: string; // YYYY-MM-01T00:00:00Z (always the first day of the month)
|
||||
counts: Counts;
|
||||
namespaces: NamespaceObject[];
|
||||
|
|
@ -334,14 +350,14 @@ export interface NoNewClientsActivityMonthBlock {
|
|||
counts: null;
|
||||
namespaces: null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmptyActivityMonthBlock {
|
||||
};
|
||||
type ActivityMonthEmpty = {
|
||||
timestamp: string; // YYYY-MM-01T00:00:00Z (always the first day of the month)
|
||||
counts: null;
|
||||
namespaces: null;
|
||||
new_clients: null;
|
||||
}
|
||||
};
|
||||
export type ActivityMonthBlock = ActivityMonthEmpty | ActivityMonthNoNewClients | ActivityMonthStandard;
|
||||
|
||||
export interface Counts {
|
||||
acme_clients: number;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export const CONFIG_RESPONSE = {
|
|||
},
|
||||
};
|
||||
|
||||
// --------- FOR DATA GENERATION
|
||||
function getSum(array, key) {
|
||||
return array.reduce((sum, { counts }) => sum + counts[key], 0);
|
||||
}
|
||||
|
|
@ -186,6 +187,89 @@ function generateActivityResponse(startDate, endDate) {
|
|||
};
|
||||
}
|
||||
|
||||
// --------- FOR MOCK FILTERING
|
||||
|
||||
/**
|
||||
* Helper fn for calculating total counts based on array containing counts block
|
||||
*/
|
||||
function calcCounts(arr) {
|
||||
return arr.reduce(
|
||||
(prev, ns) => {
|
||||
const base = ns.counts;
|
||||
prev.entity_clients += base.entity_clients;
|
||||
prev.non_entity_clients += base.non_entity_clients;
|
||||
prev.clients += base.clients;
|
||||
prev.secret_syncs += base.secret_syncs;
|
||||
prev.acme_clients += base.acme_clients;
|
||||
return prev;
|
||||
},
|
||||
{
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
clients: 0,
|
||||
secret_syncs: 0,
|
||||
acme_clients: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper fn to filter namespaces to include the namespace itself, and any children
|
||||
*/
|
||||
function filterByNamespace(namespaces, namespacePath) {
|
||||
// if we simply do a check for startsWith, filtering for `ns1` will include `ns11` as well as the desired `ns1/child`
|
||||
return namespaces.filter(
|
||||
(ns) => ns.namespace_path === namespacePath || ns.namespace_path.startsWith(`${namespacePath}/`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper fn to filter months data from activity response
|
||||
*/
|
||||
function filterMonths(months, namespacePath) {
|
||||
return months.map((month) => {
|
||||
if (!month.namespaces) return month;
|
||||
|
||||
const newMonth = {
|
||||
...month,
|
||||
};
|
||||
const filteredNs = filterByNamespace(month.namespaces, namespacePath);
|
||||
const monthsCount = calcCounts(filteredNs);
|
||||
|
||||
if (month.new_clients?.namespaces) {
|
||||
const filteredNewNs = filterByNamespace(month.new_clients.namespaces, namespacePath);
|
||||
const newCount = calcCounts(filteredNewNs);
|
||||
|
||||
newMonth.new_clients.namespaces = filteredNewNs;
|
||||
newMonth.new_clients.counts = newCount;
|
||||
}
|
||||
|
||||
newMonth.namespaces = filteredNs;
|
||||
newMonth.counts = monthsCount;
|
||||
return newMonth;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Util to mock filter namespace data from the activity response, matching what the API does
|
||||
*/
|
||||
export function filterActivityResponse(originalData, namespacePath) {
|
||||
// make a deep copy of the object so we don't mutate the original
|
||||
const data = JSON.parse(JSON.stringify(originalData));
|
||||
if (!namespacePath) return data;
|
||||
|
||||
const filteredMonths = filterMonths(data.months, namespacePath);
|
||||
const filteredNs = filterByNamespace(data.by_namespace, namespacePath);
|
||||
const filteredTotals = calcCounts(filteredNs);
|
||||
return {
|
||||
...data,
|
||||
months: filteredMonths,
|
||||
by_namespace: filteredNs,
|
||||
total: filteredTotals,
|
||||
};
|
||||
}
|
||||
|
||||
// --------- SERVER FN
|
||||
export default function (server) {
|
||||
server.get('sys/license/status', function () {
|
||||
return {
|
||||
|
|
@ -206,6 +290,7 @@ export default function (server) {
|
|||
|
||||
server.get('/sys/internal/counters/activity', (schema, req) => {
|
||||
const activities = schema['clients/activities'];
|
||||
const namespace = req.requestHeaders['X-Vault-Namespace'];
|
||||
let { start_time, end_time } = req.queryParams;
|
||||
if (!start_time && !end_time) {
|
||||
// if there are no date query params, the activity log default behavior
|
||||
|
|
@ -237,9 +322,13 @@ export default function (server) {
|
|||
lease_id: '',
|
||||
renewable: false,
|
||||
lease_duration: 0,
|
||||
data,
|
||||
data: filterActivityResponse(data, namespace),
|
||||
wrap_info: null,
|
||||
warnings: null,
|
||||
warnings: req.queryParams.end_time
|
||||
? null
|
||||
: [
|
||||
'Since this usage period includes both the current month and at least one historical month, counts returned in this usage period are an estimate. Client counts for this period will no longer be estimated at the start of the next month.',
|
||||
],
|
||||
auth: null,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,9 +30,12 @@ module('Acceptance | clients | counts', function (hooks) {
|
|||
assert.expect(2);
|
||||
this.owner.lookup('service:version').type = 'community';
|
||||
await visit('/vault/clients/counts/overview');
|
||||
|
||||
assert.dom(GENERAL.emptyStateTitle).hasText('No data received');
|
||||
assert.dom(GENERAL.emptyStateMessage).hasText('Select a start date above to query client count data.');
|
||||
assert.dom(GENERAL.emptyStateTitle).hasText('No start date found');
|
||||
assert
|
||||
.dom(GENERAL.emptyStateMessage)
|
||||
.hasText(
|
||||
'In order to get the most from this data, please enter a start month above. Vault will calculate new clients starting from that month.'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should redirect to counts overview route for transitions to parent', async function (assert) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ 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 { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
|
||||
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;
|
||||
|
||||
|
|
@ -26,25 +28,26 @@ module('Acceptance | clients | counts | acme', function (hooks) {
|
|||
|
||||
hooks.beforeEach(async function () {
|
||||
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
|
||||
this.server.get('sys/internal/counters/activity', () => {
|
||||
this.server.get('sys/internal/counters/activity', (_, req) => {
|
||||
const namespace = req.requestHeaders['X-Vault-Namespace'];
|
||||
return {
|
||||
request_id: 'some-activity-id',
|
||||
data: ACTIVITY_RESPONSE_STUB,
|
||||
data: filterActivityResponse(ACTIVITY_RESPONSE_STUB, namespace),
|
||||
};
|
||||
});
|
||||
// store serialized activity data for value comparison
|
||||
const { byMonth, byNamespace } = await this.owner
|
||||
.lookup('service:store')
|
||||
.queryRecord('clients/activity', {
|
||||
start_time: { timestamp: getUnixTime(LICENSE_START) },
|
||||
end_time: { timestamp: getUnixTime(STATIC_NOW) },
|
||||
});
|
||||
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: byNamespace.find((ns) => ns.label === this.nsPath),
|
||||
nsMonthlyUsage: byMonth.map((m) => m?.namespaces_by_key[this.nsPath]).filter((d) => !!d),
|
||||
nsMonthActivity: byMonth.find(({ month }) => month === '9/23').namespaces_by_key[this.nsPath],
|
||||
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 authPage.login();
|
||||
|
|
@ -61,21 +64,21 @@ module('Acceptance | clients | counts | acme', function (hooks) {
|
|||
assert.strictEqual(currentURL(), '/vault/dashboard', 'it navigates back to dashboard');
|
||||
});
|
||||
|
||||
test('it filters by namespace data and renders charts', async function (assert) {
|
||||
const { nsTotals, nsMonthlyUsage, nsMonthActivity } = this.expectedValues;
|
||||
test('it filters by mount data and renders charts', async function (assert) {
|
||||
const { nsTotals, nsMonthlyUsage } = this.expectedValues;
|
||||
const nsMonthlyNew = nsMonthlyUsage.map((m) => m?.new_clients);
|
||||
assert.expect(7 + nsMonthlyUsage.length + nsMonthlyNew.length);
|
||||
|
||||
await visit('/vault/clients/counts/acme');
|
||||
await click(searchSelect.trigger('namespace-search-select'));
|
||||
await click(searchSelect.option(searchSelect.optionIndex(this.nsPath)));
|
||||
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);
|
||||
assertBarChart(assert, 'Monthly new', nsMonthlyNew);
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/clients/counts/acme?ns=${this.nsPath}`,
|
||||
`/vault/clients/counts/acme?mountPath=pki-engine-0&ns=${this.nsPath}`,
|
||||
'namespace filter updates URL query param'
|
||||
);
|
||||
assert
|
||||
|
|
@ -84,60 +87,59 @@ module('Acceptance | clients | counts | acme', function (hooks) {
|
|||
`${formatNumber([nsTotals.acme_clients])}`,
|
||||
'renders total acme clients for namespace'
|
||||
);
|
||||
// there is only one month in the stubbed data, so in this case the average is the same as the total new clients
|
||||
|
||||
// TODO: update this
|
||||
assert
|
||||
.dom(CLIENT_COUNT.statText('Average new ACME clients per month'))
|
||||
.hasTextContaining(
|
||||
`${formatNumber([nsMonthActivity.new_clients.acme_clients])}`,
|
||||
'renders average acme clients for namespace'
|
||||
);
|
||||
.hasTextContaining(`13`, 'renders average acme clients for namespace');
|
||||
});
|
||||
|
||||
test('it filters by mount data and renders charts', async function (assert) {
|
||||
const { nsTotals, nsMonthlyUsage, nsMonthActivity } = this.expectedValues;
|
||||
const mountTotals = nsTotals.mounts.find((m) => m.label === this.mountPath);
|
||||
const mountMonthlyUsage = nsMonthlyUsage.map((ns) => ns.mounts_by_key[this.mountPath]).filter((d) => !!d);
|
||||
const mountMonthlyNew = mountMonthlyUsage.map((m) => m?.new_clients);
|
||||
assert.expect(7 + mountMonthlyUsage.length + mountMonthlyNew.length);
|
||||
/**
|
||||
* 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 click(searchSelect.trigger('namespace-search-select'));
|
||||
await click(searchSelect.option(searchSelect.optionIndex(this.nsPath)));
|
||||
await click(searchSelect.trigger('mounts-search-select'));
|
||||
await click(searchSelect.option(searchSelect.optionIndex(this.mountPath)));
|
||||
await selectChoose(CLIENT_COUNT.nsFilter, this.nsPath);
|
||||
|
||||
// each chart assertion count is data array length + 2
|
||||
assertBarChart(assert, 'ACME usage', mountMonthlyUsage);
|
||||
assertBarChart(assert, 'Monthly new', mountMonthlyNew);
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
`/vault/clients/counts/acme?mountPath=${this.mountPath}&ns=${this.nsPath}`,
|
||||
'mount filter updates URL query param'
|
||||
`/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'
|
||||
);
|
||||
assert
|
||||
.dom(CLIENT_COUNT.statText('Total ACME clients'))
|
||||
.hasTextContaining(
|
||||
`${formatNumber([mountTotals.acme_clients])}`,
|
||||
'renders total acme clients for mount'
|
||||
);
|
||||
// there is only one month in the stubbed data, so in this case the average is the same as the total new clients
|
||||
const mountMonthActivity = nsMonthActivity.mounts_by_key[this.mountPath];
|
||||
assert
|
||||
.dom(CLIENT_COUNT.statText('Average new ACME clients per month'))
|
||||
.hasTextContaining(
|
||||
`${formatNumber([mountMonthActivity.new_clients.acme_clients])}`,
|
||||
'renders average acme clients for mount'
|
||||
);
|
||||
});
|
||||
|
||||
test('it renders empty chart for no mount data ', async function (assert) {
|
||||
assert.expect(3);
|
||||
await visit('/vault/clients/counts/acme');
|
||||
await click(searchSelect.trigger('namespace-search-select'));
|
||||
await click(searchSelect.option(searchSelect.optionIndex(this.nsPath)));
|
||||
await click(searchSelect.trigger('mounts-search-select'));
|
||||
await selectChoose(CLIENT_COUNT.nsFilter, this.nsPath);
|
||||
await selectChoose(CLIENT_COUNT.mountFilter, 'auth/authid/0');
|
||||
// no data because this is an auth mount (acme_clients come from pki mounts)
|
||||
await click(searchSelect.option(searchSelect.optionIndex('auth/authid/0')));
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -9,21 +9,16 @@ import { setupMirage } from 'ember-cli-mirage/test-support';
|
|||
import clientsHandler, { STATIC_NOW, LICENSE_START, UPGRADE_DATE } from 'vault/mirage/handlers/clients';
|
||||
import syncHandler from 'vault/mirage/handlers/sync';
|
||||
import sinon from 'sinon';
|
||||
import { visit, click, findAll, settled, fillIn } from '@ember/test-helpers';
|
||||
import { visit, click, findAll, fillIn, currentURL } from '@ember/test-helpers';
|
||||
import authPage from 'vault/tests/pages/auth';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { CHARTS, CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
|
||||
import { create } from 'ember-cli-page-object';
|
||||
import { clickTrigger } from 'ember-power-select/test-support/helpers';
|
||||
import { formatNumber } from 'core/helpers/format-number';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import ss from 'vault/tests/pages/components/search-select';
|
||||
import { runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands';
|
||||
import { selectChoose } from 'ember-power-select/test-support';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const searchSelect = create(ss);
|
||||
|
||||
module('Acceptance | clients | overview', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
|
@ -31,26 +26,41 @@ module('Acceptance | clients | overview', function (hooks) {
|
|||
hooks.beforeEach(async function () {
|
||||
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
|
||||
clientsHandler(this.server);
|
||||
// stub secrets sync being activated
|
||||
this.server.get('/sys/activation-flags', function () {
|
||||
return {
|
||||
data: {
|
||||
activated: ['secrets-sync'],
|
||||
unactivated: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
this.store = this.owner.lookup('service:store');
|
||||
await authPage.login();
|
||||
return visit('/vault/clients/counts/overview');
|
||||
});
|
||||
|
||||
test('it should render charts', async function (assert) {
|
||||
assert
|
||||
.dom(`${GENERAL.flashMessage}.is-info`)
|
||||
.includesText(
|
||||
'counts returned in this usage period are an estimate',
|
||||
'Shows warning from API about client count estimations'
|
||||
);
|
||||
assert
|
||||
.dom(CLIENT_COUNT.dateRange.dateDisplay('start'))
|
||||
.hasText('July 2023', 'billing start month is correctly parsed from license');
|
||||
assert
|
||||
.dom(CLIENT_COUNT.dateRange.dateDisplay('end'))
|
||||
.hasText('January 2024', 'billing start month is correctly parsed from license');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock).exists('Shows attribution area');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 });
|
||||
assert
|
||||
.dom(CHARTS.container('Vault client counts'))
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert
|
||||
.dom(`${CHARTS.container('Vault client counts')} ${CHARTS.xAxisLabel}`)
|
||||
.hasText('7/23', 'x-axis labels start with billing start date');
|
||||
assert.strictEqual(findAll(CHARTS.plotPoint).length, 5, 'line chart plots 5 points to match query');
|
||||
assert.dom(CHARTS.xAxisLabel).exists({ count: 7 }, 'chart months matches query');
|
||||
});
|
||||
|
||||
test('it should update charts when querying date ranges', async function (assert) {
|
||||
|
|
@ -68,14 +78,9 @@ module('Acceptance | clients | overview', function (hooks) {
|
|||
assert
|
||||
.dom(CHARTS.container('Vault client counts'))
|
||||
.doesNotExist('running total month over month charts do not show');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock).exists('attribution area shows');
|
||||
assert
|
||||
.dom(`${CHARTS.container('new-clients')} ${GENERAL.emptyStateTitle}`)
|
||||
.exists('new client attribution has empty state');
|
||||
assert
|
||||
.dom(GENERAL.emptyStateSubtitle)
|
||||
.hasText('There are no new clients for this namespace during this time period.');
|
||||
assert.dom(CHARTS.container('total-clients')).exists('total client attribution chart shows');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 });
|
||||
assert.dom(CHARTS.container('namespace')).exists('namespace attribution chart shows');
|
||||
assert.dom(CHARTS.container('mount')).exists('mount attribution chart shows');
|
||||
|
||||
// reset to billing period
|
||||
await click(CLIENT_COUNT.dateRange.edit);
|
||||
|
|
@ -87,14 +92,20 @@ module('Acceptance | clients | overview', function (hooks) {
|
|||
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), upgradeMonth);
|
||||
await click(GENERAL.saveButton);
|
||||
|
||||
assert.dom(CLIENT_COUNT.attributionBlock).exists('Shows attribution area');
|
||||
assert
|
||||
.dom(CLIENT_COUNT.dateRange.dateDisplay('start'))
|
||||
.hasText('September 2023', 'billing start month is correctly parsed from license');
|
||||
assert
|
||||
.dom(CLIENT_COUNT.dateRange.dateDisplay('end'))
|
||||
.hasText('January 2024', 'billing start month is correctly parsed from license');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 });
|
||||
assert
|
||||
.dom(CHARTS.container('Vault client counts'))
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert
|
||||
.dom(`${CHARTS.container('Vault client counts')} ${CHARTS.xAxisLabel}`)
|
||||
.hasText('9/23', 'x-axis labels start with queried start month (upgrade date)');
|
||||
assert.strictEqual(findAll(CHARTS.plotPoint).length, 5, 'line chart plots 5 points to match query');
|
||||
assert.dom(CHARTS.xAxisLabel).exists({ count: 5 }, 'chart months matches query');
|
||||
|
||||
// query for single, historical month (upgrade month)
|
||||
await click(CLIENT_COUNT.dateRange.edit);
|
||||
|
|
@ -108,9 +119,9 @@ module('Acceptance | clients | overview', function (hooks) {
|
|||
assert
|
||||
.dom(CHARTS.container('Vault client counts'))
|
||||
.doesNotExist('running total month over month charts do not show');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock).exists('attribution area shows');
|
||||
assert.dom(CHARTS.container('new-clients')).exists('new client attribution chart shows');
|
||||
assert.dom(CHARTS.container('total-clients')).exists('total client attribution chart shows');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 });
|
||||
assert.dom(CHARTS.container('namespace')).exists('namespace attribution chart shows');
|
||||
assert.dom(CHARTS.container('mount')).exists('mount attribution chart shows');
|
||||
|
||||
// query historical date range (from September 2023 to December 2023)
|
||||
await click(CLIENT_COUNT.dateRange.edit);
|
||||
|
|
@ -118,11 +129,18 @@ module('Acceptance | clients | overview', function (hooks) {
|
|||
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), '2023-12');
|
||||
await click(GENERAL.saveButton);
|
||||
|
||||
assert.dom(CLIENT_COUNT.attributionBlock).exists('Shows attribution area');
|
||||
assert
|
||||
.dom(CLIENT_COUNT.dateRange.dateDisplay('start'))
|
||||
.hasText('September 2023', 'billing start month is correctly parsed from license');
|
||||
assert
|
||||
.dom(CLIENT_COUNT.dateRange.dateDisplay('end'))
|
||||
.hasText('December 2023', 'billing start month is correctly parsed from license');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 });
|
||||
assert
|
||||
.dom(CHARTS.container('Vault client counts'))
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert.strictEqual(findAll(CHARTS.plotPoint).length, 4, 'line chart plots 4 points to match query');
|
||||
|
||||
assert.dom(CHARTS.xAxisLabel).exists({ count: 4 }, 'chart months matches query');
|
||||
const xAxisLabels = findAll(CHARTS.xAxisLabel);
|
||||
assert
|
||||
.dom(xAxisLabels[xAxisLabels.length - 1])
|
||||
|
|
@ -147,33 +165,31 @@ module('Acceptance | clients | overview', function (hooks) {
|
|||
});
|
||||
|
||||
test('totals filter correctly with full data', async function (assert) {
|
||||
// stub secrets sync being activated
|
||||
this.server.get('/sys/activation-flags', function () {
|
||||
return {
|
||||
data: {
|
||||
activated: ['secrets-sync'],
|
||||
unactivated: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
assert
|
||||
.dom(CHARTS.container('Vault client counts'))
|
||||
.exists('Shows running totals with monthly breakdown charts');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock).exists('Shows attribution area');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 });
|
||||
|
||||
const response = await this.store.peekRecord('clients/activity', 'some-activity-id');
|
||||
// FILTER BY NAMESPACE
|
||||
await clickTrigger();
|
||||
await searchSelect.options.objectAt(0).click();
|
||||
await settled();
|
||||
const topNamespace = response.byNamespace[0];
|
||||
const topMount = topNamespace.mounts[0];
|
||||
const topNamespace = response.byNamespace.sort((a, b) => b.clients - a.clients)[0];
|
||||
const topMount = topNamespace?.mounts.sort((a, b) => b.clients - a.clients)[0];
|
||||
|
||||
assert.dom(CLIENT_COUNT.selectedNs).hasText(topNamespace.label, 'selects top namespace');
|
||||
assert.dom('[data-test-top-attribution]').includesText('Top auth method');
|
||||
assert
|
||||
.dom('[data-test-attribution-clients] p')
|
||||
.dom(`${CLIENT_COUNT.attributionBlock('namespace')} [data-test-top-attribution]`)
|
||||
.includesText(`Top namespace ${topNamespace.label}`);
|
||||
// this math works because there are no nested namespaces in the mirage data
|
||||
assert
|
||||
.dom(`${CLIENT_COUNT.attributionBlock('namespace')} [data-test-attribution-clients] p`)
|
||||
.includesText(`${formatNumber([topNamespace.clients])}`, 'top attribution clients accurate');
|
||||
|
||||
// Filter by top namespace
|
||||
await selectChoose(CLIENT_COUNT.nsFilter, topNamespace.label);
|
||||
assert.dom(CLIENT_COUNT.selectedNs).hasText(topNamespace.label, 'selects top namespace');
|
||||
assert
|
||||
.dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-top-attribution]`)
|
||||
.includesText('Top mount');
|
||||
assert
|
||||
.dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-attribution-clients] p`)
|
||||
.includesText(`${formatNumber([topMount.clients])}`, 'top attribution clients accurate');
|
||||
|
||||
let expectedStats = {
|
||||
|
|
@ -189,12 +205,9 @@ module('Acceptance | clients | overview', function (hooks) {
|
|||
}
|
||||
|
||||
// FILTER BY AUTH METHOD
|
||||
await clickTrigger();
|
||||
await searchSelect.options.objectAt(0).click();
|
||||
await settled();
|
||||
assert.ok(true, 'Filter by first auth method');
|
||||
await selectChoose(CLIENT_COUNT.mountFilter, topMount.label);
|
||||
assert.dom(CLIENT_COUNT.selectedAuthMount).hasText(topMount.label, 'selects top mount');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock).doesNotExist('Does not show attribution block');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock()).doesNotExist('Does not show attribution block');
|
||||
|
||||
expectedStats = {
|
||||
Entity: formatNumber([topMount.entity_clients]),
|
||||
|
|
@ -208,8 +221,9 @@ module('Acceptance | clients | overview', function (hooks) {
|
|||
.includesText(`${expectedStats[label]}`, `label: "${label} "renders accurate mount client counts`);
|
||||
}
|
||||
|
||||
// Remove namespace filter without first removing auth method filter
|
||||
await click(GENERAL.searchSelect.removeSelected);
|
||||
assert.ok(true, 'Remove namespace filter without first removing auth method filter');
|
||||
assert.strictEqual(currentURL(), '/vault/clients/counts/overview', 'removes both query params');
|
||||
assert.dom('[data-test-top-attribution]').includesText('Top namespace');
|
||||
assert
|
||||
.dom('[data-test-attribution-clients]')
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { runCmd, deleteEngineCmd, createNS } from 'vault/tests/helpers/commands'
|
|||
|
||||
import { DASHBOARD } from 'vault/tests/helpers/components/dashboard/dashboard-selectors';
|
||||
import { CUSTOM_MESSAGES } from 'vault/tests/helpers/config-ui/message-selectors';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
|
||||
const authenticatedMessageResponse = {
|
||||
request_id: '664fbad0-fcd8-9023-4c5b-81a7962e9f4b',
|
||||
|
|
@ -397,12 +398,11 @@ module('Acceptance | landing page dashboard', function (hooks) {
|
|||
});
|
||||
|
||||
test('shows the client count card for enterprise', async function (assert) {
|
||||
assert.expect(9);
|
||||
const version = this.owner.lookup('service:version');
|
||||
assert.true(version.isEnterprise, 'version is enterprise');
|
||||
assert.strictEqual(currentURL(), '/vault/dashboard');
|
||||
assert.dom(DASHBOARD.cardName('client-count')).exists();
|
||||
const response = await this.store.findRecord('clients/activity', 'clients/activity');
|
||||
const response = await this.store.peekRecord('clients/activity', 'clients/activity');
|
||||
assert.dom('[data-test-client-count-title]').hasText('Client count');
|
||||
assert.dom('[data-test-stat-text="Total"] .stat-label').hasText('Total');
|
||||
assert.dom('[data-test-stat-text="Total"] .stat-value').hasText(formatNumber([response.total.clients]));
|
||||
|
|
@ -413,6 +413,9 @@ module('Acceptance | landing page dashboard', function (hooks) {
|
|||
assert
|
||||
.dom('[data-test-stat-text="New"] .stat-value')
|
||||
.hasText(formatNumber([response.byMonth.lastObject.new_clients.clients]));
|
||||
assert
|
||||
.dom(`${GENERAL.flashMessage}.is-info`)
|
||||
.doesNotExist('Does not show warning about client count estimations');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -427,7 +430,6 @@ module('Acceptance | landing page dashboard', function (hooks) {
|
|||
});
|
||||
|
||||
test('shows the replication card empty state in enterprise version', async function (assert) {
|
||||
assert.expect(5);
|
||||
await visit('/vault/dashboard');
|
||||
const version = this.owner.lookup('service:version');
|
||||
assert.true(version.isEnterprise, 'vault is enterprise');
|
||||
|
|
@ -440,7 +442,6 @@ module('Acceptance | landing page dashboard', function (hooks) {
|
|||
});
|
||||
|
||||
test('hides the replication card on a non-root namespace enterprise version', async function (assert) {
|
||||
assert.expect(3);
|
||||
await visit('/vault/dashboard');
|
||||
const version = this.owner.lookup('service:version');
|
||||
assert.true(version.isEnterprise, 'vault is enterprise');
|
||||
|
|
@ -451,7 +452,6 @@ module('Acceptance | landing page dashboard', function (hooks) {
|
|||
});
|
||||
|
||||
test('it should show replication status if both dr and performance replication are enabled as features in enterprise', async function (assert) {
|
||||
assert.expect(9);
|
||||
const version = this.owner.lookup('service:version');
|
||||
assert.true(version.isEnterprise, 'vault is enterprise');
|
||||
await visit('/vault/replication');
|
||||
|
|
@ -483,7 +483,6 @@ module('Acceptance | landing page dashboard', function (hooks) {
|
|||
this.server.get('/sys/internal/ui/unauthenticated-messages', function () {
|
||||
return authenticatedMessageResponse;
|
||||
});
|
||||
assert.expect(7);
|
||||
await visit('/vault/dashboard');
|
||||
const modalId = 'some-awesome-id-1';
|
||||
const alertId = 'some-awesome-id-2';
|
||||
|
|
@ -497,7 +496,6 @@ module('Acceptance | landing page dashboard', function (hooks) {
|
|||
assert.dom(CUSTOM_MESSAGES.alertAction('link')).hasText('some link title');
|
||||
});
|
||||
test('it shows the multiple modal messages', async function (assert) {
|
||||
assert.expect(8);
|
||||
const modalIdOne = 'some-awesome-id-2';
|
||||
const modalIdTwo = 'some-awesome-id-1';
|
||||
|
||||
|
|
@ -521,7 +519,6 @@ module('Acceptance | landing page dashboard', function (hooks) {
|
|||
await click(CUSTOM_MESSAGES.modalButton(modalIdTwo));
|
||||
});
|
||||
test('it shows the multiple banner messages', async function (assert) {
|
||||
assert.expect(5);
|
||||
const bannerIdOne = 'some-awesome-id-2';
|
||||
const bannerIdTwo = 'some-awesome-id-1';
|
||||
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export const ACTIVITY_RESPONSE_STUB = {
|
|||
timestamp: '2023-07-01T00:00:00Z',
|
||||
counts: {
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -142,7 +142,7 @@ export const ACTIVITY_RESPONSE_STUB = {
|
|||
namespace_path: '',
|
||||
counts: {
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -162,7 +162,7 @@ export const ACTIVITY_RESPONSE_STUB = {
|
|||
mount_path: 'auth/authid/0',
|
||||
counts: {
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
clients: 200,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 0,
|
||||
|
|
@ -184,7 +184,7 @@ export const ACTIVITY_RESPONSE_STUB = {
|
|||
new_clients: {
|
||||
counts: {
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -195,7 +195,7 @@ export const ACTIVITY_RESPONSE_STUB = {
|
|||
namespace_path: '',
|
||||
counts: {
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -215,7 +215,7 @@ export const ACTIVITY_RESPONSE_STUB = {
|
|||
mount_path: 'auth/authid/0',
|
||||
counts: {
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
clients: 200,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 0,
|
||||
|
|
@ -240,7 +240,7 @@ export const ACTIVITY_RESPONSE_STUB = {
|
|||
timestamp: '2023-08-01T00:00:00Z',
|
||||
counts: {
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -251,7 +251,7 @@ export const ACTIVITY_RESPONSE_STUB = {
|
|||
namespace_path: '',
|
||||
counts: {
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -271,7 +271,7 @@ export const ACTIVITY_RESPONSE_STUB = {
|
|||
mount_path: 'auth/authid/0',
|
||||
counts: {
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
clients: 200,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 0,
|
||||
|
|
@ -729,7 +729,6 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
month: '6/23',
|
||||
timestamp: '2023-06-01T00:00:00Z',
|
||||
namespaces: [],
|
||||
namespaces_by_key: {},
|
||||
new_clients: {
|
||||
month: '6/23',
|
||||
timestamp: '2023-06-01T00:00:00Z',
|
||||
|
|
@ -740,7 +739,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
month: '7/23',
|
||||
timestamp: '2023-07-01T00:00:00Z',
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -748,7 +747,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
{
|
||||
label: 'root',
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -764,7 +763,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
{
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
clients: 200,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 0,
|
||||
|
|
@ -780,120 +779,11 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
],
|
||||
},
|
||||
],
|
||||
namespaces_by_key: {
|
||||
root: {
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
timestamp: '2023-07-01T00:00:00Z',
|
||||
month: '7/23',
|
||||
new_clients: {
|
||||
month: '7/23',
|
||||
timestamp: '2023-07-01T00:00:00Z',
|
||||
label: 'root',
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
mounts: [
|
||||
{
|
||||
label: 'pki-engine-0',
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
label: 'kvv2-engine-0',
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
mounts_by_key: {
|
||||
'pki-engine-0': {
|
||||
label: 'pki-engine-0',
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2023-07-01T00:00:00Z',
|
||||
month: '7/23',
|
||||
new_clients: {
|
||||
month: '7/23',
|
||||
timestamp: '2023-07-01T00:00:00Z',
|
||||
label: 'pki-engine-0',
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
},
|
||||
'auth/authid/0': {
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2023-07-01T00:00:00Z',
|
||||
month: '7/23',
|
||||
new_clients: {
|
||||
month: '7/23',
|
||||
timestamp: '2023-07-01T00:00:00Z',
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
},
|
||||
'kvv2-engine-0': {
|
||||
label: 'kvv2-engine-0',
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 100,
|
||||
timestamp: '2023-07-01T00:00:00Z',
|
||||
month: '7/23',
|
||||
new_clients: {
|
||||
month: '7/23',
|
||||
timestamp: '2023-07-01T00:00:00Z',
|
||||
label: 'kvv2-engine-0',
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new_clients: {
|
||||
month: '7/23',
|
||||
timestamp: '2023-07-01T00:00:00Z',
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -901,7 +791,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
{
|
||||
label: 'root',
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -917,7 +807,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
{
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
clients: 200,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 0,
|
||||
|
|
@ -939,7 +829,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
month: '8/23',
|
||||
timestamp: '2023-08-01T00:00:00Z',
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -947,7 +837,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
{
|
||||
label: 'root',
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
clients: 400,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
|
|
@ -963,7 +853,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
{
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
clients: 200,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 0,
|
||||
|
|
@ -979,70 +869,6 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
],
|
||||
},
|
||||
],
|
||||
namespaces_by_key: {
|
||||
root: {
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 100,
|
||||
timestamp: '2023-08-01T00:00:00Z',
|
||||
month: '8/23',
|
||||
new_clients: {
|
||||
label: 'root',
|
||||
month: '8/23',
|
||||
timestamp: '2023-08-01T00:00:00Z',
|
||||
mounts: [],
|
||||
},
|
||||
mounts_by_key: {
|
||||
'pki-engine-0': {
|
||||
label: 'pki-engine-0',
|
||||
acme_clients: 100,
|
||||
clients: 100,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2023-08-01T00:00:00Z',
|
||||
month: '8/23',
|
||||
new_clients: {
|
||||
label: 'pki-engine-0',
|
||||
month: '8/23',
|
||||
timestamp: '2023-08-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
'auth/authid/0': {
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
entity_clients: 100,
|
||||
non_entity_clients: 100,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2023-08-01T00:00:00Z',
|
||||
month: '8/23',
|
||||
new_clients: {
|
||||
label: 'auth/authid/0',
|
||||
month: '8/23',
|
||||
timestamp: '2023-08-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
'kvv2-engine-0': {
|
||||
label: 'kvv2-engine-0',
|
||||
acme_clients: 0,
|
||||
clients: 100,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 100,
|
||||
timestamp: '2023-08-01T00:00:00Z',
|
||||
month: '8/23',
|
||||
new_clients: {
|
||||
label: 'kvv2-engine-0',
|
||||
month: '8/23',
|
||||
timestamp: '2023-08-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new_clients: {
|
||||
month: '8/23',
|
||||
timestamp: '2023-08-01T00:00:00Z',
|
||||
|
|
@ -1127,222 +953,6 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
|
|||
],
|
||||
},
|
||||
],
|
||||
namespaces_by_key: {
|
||||
ns1: {
|
||||
acme_clients: 934,
|
||||
clients: 1981,
|
||||
entity_clients: 708,
|
||||
non_entity_clients: 182,
|
||||
secret_syncs: 157,
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
month: '9/23',
|
||||
new_clients: {
|
||||
month: '9/23',
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
label: 'ns1',
|
||||
acme_clients: 53,
|
||||
clients: 173,
|
||||
entity_clients: 34,
|
||||
non_entity_clients: 62,
|
||||
secret_syncs: 24,
|
||||
mounts: [
|
||||
{
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 96,
|
||||
entity_clients: 34,
|
||||
non_entity_clients: 62,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
label: 'pki-engine-0',
|
||||
acme_clients: 53,
|
||||
clients: 53,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
label: 'kvv2-engine-0',
|
||||
acme_clients: 0,
|
||||
clients: 24,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 24,
|
||||
},
|
||||
],
|
||||
},
|
||||
mounts_by_key: {
|
||||
'pki-engine-0': {
|
||||
label: 'pki-engine-0',
|
||||
acme_clients: 934,
|
||||
clients: 934,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
month: '9/23',
|
||||
new_clients: {
|
||||
month: '9/23',
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
label: 'pki-engine-0',
|
||||
acme_clients: 53,
|
||||
clients: 53,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
},
|
||||
'auth/authid/0': {
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 890,
|
||||
entity_clients: 708,
|
||||
non_entity_clients: 182,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
month: '9/23',
|
||||
new_clients: {
|
||||
month: '9/23',
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 96,
|
||||
entity_clients: 34,
|
||||
non_entity_clients: 62,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
},
|
||||
'kvv2-engine-0': {
|
||||
label: 'kvv2-engine-0',
|
||||
acme_clients: 0,
|
||||
clients: 157,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 157,
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
month: '9/23',
|
||||
new_clients: {
|
||||
month: '9/23',
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
label: 'kvv2-engine-0',
|
||||
acme_clients: 0,
|
||||
clients: 24,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 24,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
root: {
|
||||
acme_clients: 994,
|
||||
clients: 1947,
|
||||
entity_clients: 124,
|
||||
non_entity_clients: 748,
|
||||
secret_syncs: 81,
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
month: '9/23',
|
||||
new_clients: {
|
||||
month: '9/23',
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
label: 'root',
|
||||
acme_clients: 91,
|
||||
clients: 191,
|
||||
entity_clients: 25,
|
||||
non_entity_clients: 50,
|
||||
secret_syncs: 25,
|
||||
mounts: [
|
||||
{
|
||||
label: 'pki-engine-0',
|
||||
acme_clients: 91,
|
||||
clients: 91,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 75,
|
||||
entity_clients: 25,
|
||||
non_entity_clients: 50,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
label: 'kvv2-engine-0',
|
||||
acme_clients: 0,
|
||||
clients: 25,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
mounts_by_key: {
|
||||
'pki-engine-0': {
|
||||
label: 'pki-engine-0',
|
||||
acme_clients: 994,
|
||||
clients: 994,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
month: '9/23',
|
||||
new_clients: {
|
||||
month: '9/23',
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
label: 'pki-engine-0',
|
||||
acme_clients: 91,
|
||||
clients: 91,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
},
|
||||
'auth/authid/0': {
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 872,
|
||||
entity_clients: 124,
|
||||
non_entity_clients: 748,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
month: '9/23',
|
||||
new_clients: {
|
||||
month: '9/23',
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
label: 'auth/authid/0',
|
||||
acme_clients: 0,
|
||||
clients: 75,
|
||||
entity_clients: 25,
|
||||
non_entity_clients: 50,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
},
|
||||
'kvv2-engine-0': {
|
||||
label: 'kvv2-engine-0',
|
||||
acme_clients: 0,
|
||||
clients: 81,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 81,
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
month: '9/23',
|
||||
new_clients: {
|
||||
month: '9/23',
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
label: 'kvv2-engine-0',
|
||||
acme_clients: 0,
|
||||
clients: 25,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 25,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new_clients: {
|
||||
month: '9/23',
|
||||
timestamp: '2023-09-01T00:00:00Z',
|
||||
|
|
|
|||
|
|
@ -25,12 +25,15 @@ export const CLIENT_COUNT = {
|
|||
statTextValue: (label: string) =>
|
||||
label ? `[data-test-stat-text="${label}"] .stat-value` : '[data-test-stat-text]',
|
||||
usageStats: (title: string) => `[data-test-usage-stats="${title}"]`,
|
||||
attributionBlock: '[data-test-clients-attribution]',
|
||||
attributionBlock: (type: string) =>
|
||||
type ? `[data-test-clients-attribution="${type}"]` : '[data-test-clients-attribution]',
|
||||
filterBar: '[data-test-clients-filter-bar]',
|
||||
nsFilter: '#namespace-search-select',
|
||||
mountFilter: '#mounts-search-select',
|
||||
selectedAuthMount: 'div#mounts-search-select [data-test-selected-option] div',
|
||||
selectedNs: 'div#namespace-search-select [data-test-selected-option] div',
|
||||
upgradeWarning: '[data-test-clients-upgrade-warning]',
|
||||
exportButton: '[data-test-attribution-export-button]',
|
||||
exportButton: '[data-test-export-button]',
|
||||
};
|
||||
|
||||
export const CHARTS = {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ module('Integration | Component | clients/charts/vertical-bar-basic', function (
|
|||
});
|
||||
|
||||
test('it renders when some months have no data', async function (assert) {
|
||||
assert.expect(10);
|
||||
await render(
|
||||
hbs`<Clients::Charts::VerticalBarBasic @data={{this.data}} @dataKey="secret_syncs" @chartTitle="My chart"/>`
|
||||
);
|
||||
|
|
@ -51,6 +50,7 @@ module('Integration | Component | clients/charts/vertical-bar-basic', function (
|
|||
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');
|
||||
|
|
@ -70,8 +70,6 @@ module('Integration | Component | clients/charts/vertical-bar-basic', function (
|
|||
|
||||
// 0 is different than null (no data)
|
||||
test('it renders when all months have 0 clients', async function (assert) {
|
||||
assert.expect(9);
|
||||
|
||||
this.data = [
|
||||
{
|
||||
month: '6/22',
|
||||
|
|
@ -110,7 +108,6 @@ module('Integration | Component | clients/charts/vertical-bar-basic', function (
|
|||
});
|
||||
|
||||
test('it renders underlying data', async function (assert) {
|
||||
assert.expect(3);
|
||||
await render(
|
||||
hbs`<Clients::Charts::VerticalBarBasic @data={{this.data}} @dataKey="secret_syncs" @showTable={{true}} @chartTitle="My chart"/>`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,13 +8,25 @@ import sinon from 'sinon';
|
|||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { endOfMonth, formatRFC3339 } from 'date-fns';
|
||||
import { formatRFC3339 } from 'date-fns';
|
||||
import subMonths from 'date-fns/subMonths';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import { SERIALIZED_ACTIVITY_RESPONSE } from 'vault/tests/helpers/clients/client-count-helpers';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { capabilitiesStub, overrideResponse } from 'vault/tests/helpers/stubs';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { CLIENT_TYPES } from 'core/utils/client-count-utils';
|
||||
|
||||
const CLIENTS_ATTRIBUTION = {
|
||||
title: '[data-test-attribution-title]',
|
||||
description: '[data-test-attribution-description]',
|
||||
subtext: '[data-test-attribution-subtext]',
|
||||
timestamp: '[data-test-attribution-timestamp]',
|
||||
chart: '[data-test-horizontal-bar-chart]',
|
||||
topItem: '[data-test-top-attribution]',
|
||||
topItemCount: '[data-test-attribution-clients]',
|
||||
yLabel: '[data-test-group="y-labels"]',
|
||||
yLabels: '[data-test-group="y-labels"] text',
|
||||
};
|
||||
module('Integration | Component | clients/attribution', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
|
@ -24,15 +36,15 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
|||
});
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
const { total, by_namespace } = SERIALIZED_ACTIVITY_RESPONSE;
|
||||
const mockNow = this.timestampStub();
|
||||
this.mockNow = mockNow;
|
||||
this.startTimestamp = formatRFC3339(subMonths(mockNow, 6));
|
||||
this.timestamp = formatRFC3339(mockNow);
|
||||
this.selectedNamespace = null;
|
||||
this.totalUsageCounts = total;
|
||||
this.totalClientAttribution = [...by_namespace];
|
||||
this.namespaceMountsData = by_namespace.find((ns) => ns.label === 'ns1').mounts;
|
||||
this.namespaceAttribution = SERIALIZED_ACTIVITY_RESPONSE.by_namespace;
|
||||
this.authMountAttribution = SERIALIZED_ACTIVITY_RESPONSE.by_namespace.find(
|
||||
(ns) => ns.label === 'ns1'
|
||||
).mounts;
|
||||
});
|
||||
|
||||
test('it renders empty state with no data', async function (assert) {
|
||||
|
|
@ -40,206 +52,137 @@ module('Integration | Component | clients/attribution', function (hooks) {
|
|||
<Clients::Attribution />
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-component="empty-state"]').exists();
|
||||
assert.dom('[data-test-empty-state-title]').hasText('No data found');
|
||||
assert.dom('[data-test-attribution-description]').hasText('There is a problem gathering data');
|
||||
assert.dom('[data-test-attribution-export-button]').doesNotExist();
|
||||
assert.dom('[data-test-attribution-timestamp]').doesNotHaveTextContaining('Updated');
|
||||
assert.dom(GENERAL.emptyStateTitle).hasText('No data found');
|
||||
assert.dom(CLIENTS_ATTRIBUTION.title).hasText('Namespace attribution', 'uses default noun');
|
||||
assert.dom(CLIENTS_ATTRIBUTION.timestamp).hasNoText();
|
||||
});
|
||||
|
||||
test('it updates language based on noun', async function (assert) {
|
||||
this.noun = '';
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@noun={{this.noun}}
|
||||
@attribution={{this.namespaceAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
/>
|
||||
`);
|
||||
assert.dom(CLIENTS_ATTRIBUTION.timestamp).includesText('Updated Apr 3');
|
||||
|
||||
// when noun is blank, uses default
|
||||
assert.dom(CLIENTS_ATTRIBUTION.title).hasText('Namespace attribution');
|
||||
assert
|
||||
.dom(CLIENTS_ATTRIBUTION.description)
|
||||
.hasText(
|
||||
'This data shows the top ten namespaces by total clients and can be used to understand where clients are originating. Namespaces are identified by path.'
|
||||
);
|
||||
assert
|
||||
.dom(CLIENTS_ATTRIBUTION.subtext)
|
||||
.hasText('This data shows the top ten namespaces by total clients for the date range selected.');
|
||||
|
||||
// when noun is mount
|
||||
this.set('noun', 'mount');
|
||||
assert.dom(CLIENTS_ATTRIBUTION.title).hasText('Mount attribution');
|
||||
assert
|
||||
.dom(CLIENTS_ATTRIBUTION.description)
|
||||
.hasText(
|
||||
'This data shows the top ten mounts by client count within this namespace, and can be used to understand where clients are originating. Mounts are organized by path.'
|
||||
);
|
||||
assert
|
||||
.dom(CLIENTS_ATTRIBUTION.subtext)
|
||||
.hasText(
|
||||
'The total clients used by the mounts for this date range. This number is useful for identifying overall usage volume.'
|
||||
);
|
||||
|
||||
// when noun is namespace
|
||||
this.set('noun', 'namespace');
|
||||
assert.dom(CLIENTS_ATTRIBUTION.title).hasText('Namespace attribution');
|
||||
assert
|
||||
.dom(CLIENTS_ATTRIBUTION.description)
|
||||
.hasText(
|
||||
'This data shows the top ten namespaces by total clients and can be used to understand where clients are originating. Namespaces are identified by path.'
|
||||
);
|
||||
assert
|
||||
.dom(CLIENTS_ATTRIBUTION.subtext)
|
||||
.hasText('This data shows the top ten namespaces by total clients for the date range selected.');
|
||||
});
|
||||
|
||||
test('it renders with data for namespaces', async function (assert) {
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@attribution={{this.namespaceAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@startTimestamp={{this.startTimestamp}}
|
||||
@endTimestamp={{this.timestamp}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
@isHistoricalMonth={{false}}
|
||||
/>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-component="empty-state"]').doesNotExist();
|
||||
assert.dom('[data-test-horizontal-bar-chart]').exists('chart displays');
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
assert.dom(GENERAL.emptyStateTitle).doesNotExist();
|
||||
assert.dom(CLIENTS_ATTRIBUTION.chart).exists();
|
||||
assert.dom(CLIENTS_ATTRIBUTION.topItem).includesText('namespace').includesText('ns1');
|
||||
assert.dom(CLIENTS_ATTRIBUTION.topItemCount).includesText('namespace').includesText('18,903');
|
||||
assert
|
||||
.dom('[data-test-attribution-description]')
|
||||
.hasText(
|
||||
'This data shows the top ten namespaces by client count and can be used to understand where clients are originating. Namespaces are identified by path. To see all namespaces, export this data.'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-attribution-subtext]')
|
||||
.hasText(
|
||||
'The total clients in the namespace for this date range. This number is useful for identifying overall usage volume.'
|
||||
);
|
||||
assert.dom('[data-test-top-attribution]').includesText('namespace').includesText('ns1');
|
||||
assert.dom('[data-test-attribution-clients]').includesText('namespace').includesText('18,903');
|
||||
.dom(CLIENTS_ATTRIBUTION.yLabels)
|
||||
.exists({ count: 2 }, 'bars reflect number of namespaces in single month');
|
||||
assert.dom(CLIENTS_ATTRIBUTION.yLabel).hasText('ns1root');
|
||||
});
|
||||
|
||||
test('it renders two charts and correct text for single, historical month', async function (assert) {
|
||||
this.start = formatRFC3339(subMonths(this.mockNow, 1));
|
||||
this.end = formatRFC3339(subMonths(endOfMonth(this.mockNow), 1));
|
||||
test('it renders with data for mounts', async function (assert) {
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@startTimestamp={{this.start}}
|
||||
@endTimestamp={{this.end}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
@isHistoricalMonth={{true}}
|
||||
@noun="mount"
|
||||
@attribution={{this.authMountAttribution}}
|
||||
/>
|
||||
`);
|
||||
assert
|
||||
.dom('[data-test-attribution-description]')
|
||||
.includesText(
|
||||
'This data shows the top ten namespaces by client count and can be used to understand where clients are originating. Namespaces are identified by path. To see all namespaces, export this data.',
|
||||
'renders correct auth attribution description'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-chart-container="total-clients"] .chart-description')
|
||||
.includesText(
|
||||
'The total clients in the namespace for this month. This number is useful for identifying overall usage volume.',
|
||||
'renders total monthly namespace text'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-chart-container="new-clients"] .chart-description')
|
||||
.includesText(
|
||||
'The new clients in the namespace for this month. This aids in understanding which namespaces create and use new clients.',
|
||||
'renders new monthly namespace text'
|
||||
);
|
||||
this.set('selectedNamespace', 'ns1');
|
||||
|
||||
assert.dom(GENERAL.emptyStateTitle).doesNotExist();
|
||||
assert.dom(CLIENTS_ATTRIBUTION.chart).exists();
|
||||
assert.dom(CLIENTS_ATTRIBUTION.topItem).includesText('mount').includesText('auth/authid/0');
|
||||
assert.dom(CLIENTS_ATTRIBUTION.topItemCount).includesText('mount').includesText('8,394');
|
||||
assert
|
||||
.dom('[data-test-attribution-description]')
|
||||
.includesText(
|
||||
'This data shows the top ten authentication methods by client count within this namespace, and can be used to understand where clients are originating. Authentication methods are organized by path.',
|
||||
'renders correct auth attribution description'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-chart-container="total-clients"] .chart-description')
|
||||
.includesText(
|
||||
'The total clients used by the auth method for this month. This number is useful for identifying overall usage volume.',
|
||||
'renders total monthly auth method text'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-chart-container="new-clients"] .chart-description')
|
||||
.includesText(
|
||||
'The new clients used by the auth method for this month. This aids in understanding which auth methods create and use new clients.',
|
||||
'renders new monthly auth method text'
|
||||
);
|
||||
.dom(CLIENTS_ATTRIBUTION.yLabels)
|
||||
.exists({ count: 3 }, 'bars reflect number of mounts in single month');
|
||||
assert.dom(CLIENTS_ATTRIBUTION.yLabel).hasText('auth/authid/0pki-engine-0kvv2-engine-0');
|
||||
});
|
||||
|
||||
test('it renders single chart for current month', async function (assert) {
|
||||
test('it shows secret syncs when flag is on', async function (assert) {
|
||||
this.isSecretsSyncActivated = true;
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@attribution={{this.namespaceAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@startTimestamp={{this.timestamp}}
|
||||
@endTimestamp={{this.timestamp}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
@isHistoricalMonth={{false}}
|
||||
@isSecretsSyncActivated={{true}}
|
||||
/>
|
||||
`);
|
||||
assert
|
||||
.dom('[data-test-chart-container="single-chart"]')
|
||||
.exists('renders single chart with total clients');
|
||||
assert
|
||||
.dom('[data-test-attribution-subtext]')
|
||||
.hasTextContaining('this month', 'renders total monthly namespace text');
|
||||
|
||||
assert.dom('[data-test-group="secret_syncs"] rect').exists({ count: 2 });
|
||||
});
|
||||
|
||||
test('it renders single chart and correct text for for date range', async function (assert) {
|
||||
test('it hids secret syncs when flag is off or missing', async function (assert) {
|
||||
this.isSecretsSyncActivated = true;
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@attribution={{this.namespaceAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@startTimestamp={{this.startTimestamp}}
|
||||
@endTimestamp={{this.timestamp}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
@isHistoricalMonth={{false}}
|
||||
/>
|
||||
`);
|
||||
|
||||
assert
|
||||
.dom('[data-test-chart-container="single-chart"]')
|
||||
.exists('renders single chart with total clients');
|
||||
assert
|
||||
.dom('[data-test-attribution-subtext]')
|
||||
.hasTextContaining('date range', 'renders total monthly namespace text');
|
||||
assert.dom('[data-test-group="secret_syncs"]').doesNotExist();
|
||||
});
|
||||
|
||||
test('it renders with data for selected namespace auth methods for a date range', async function (assert) {
|
||||
this.set('selectedNamespace', 'ns1');
|
||||
test('it sorts and limits before rendering bars', async function (assert) {
|
||||
this.tooManyAttributions = Array(15)
|
||||
.fill(null)
|
||||
.map((_, idx) => {
|
||||
const attr = { label: `ns${idx}` };
|
||||
CLIENT_TYPES.forEach((type) => {
|
||||
attr[type] = 10 + idx;
|
||||
});
|
||||
return attr;
|
||||
});
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@totalClientAttribution={{this.namespaceMountsData}}
|
||||
@totalUsageCounts={{this.totalUsageCounts}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@startTimestamp={{this.startTimestamp}}
|
||||
@endTimestamp={{this.timestamp}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
@isHistoricalMonth={{this.isHistoricalMonth}}
|
||||
@attribution={{this.tooManyAttributions}}
|
||||
/>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-component="empty-state"]').doesNotExist();
|
||||
assert.dom('[data-test-horizontal-bar-chart]').exists('chart displays');
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
assert
|
||||
.dom('[data-test-attribution-description]')
|
||||
.hasText(
|
||||
'This data shows the top ten authentication methods by client count within this namespace, and can be used to understand where clients are originating. Authentication methods are organized by path.'
|
||||
);
|
||||
assert
|
||||
.dom('[data-test-attribution-subtext]')
|
||||
.hasText(
|
||||
'The total clients used by the auth method for this date range. This number is useful for identifying overall usage volume.'
|
||||
);
|
||||
assert.dom('[data-test-top-attribution]').includesText('auth method').includesText('auth/authid/0');
|
||||
assert.dom('[data-test-attribution-clients]').includesText('auth method').includesText('8,394');
|
||||
});
|
||||
|
||||
test('it shows the export button if user does has SUDO capabilities', async function (assert) {
|
||||
this.server.post('/sys/capabilities-self', () =>
|
||||
capabilitiesStub('sys/internal/counters/activity/export', ['sudo'])
|
||||
);
|
||||
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
/>
|
||||
`);
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
});
|
||||
|
||||
test('it hides the export button if user does not have SUDO capabilities', async function (assert) {
|
||||
this.server.post('/sys/capabilities-self', () =>
|
||||
capabilitiesStub('sys/internal/counters/activity/export', ['read'])
|
||||
);
|
||||
|
||||
await render(hbs`
|
||||
<Clients::Attribution
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
/>
|
||||
`);
|
||||
assert.dom('[data-test-attribution-export-button]').doesNotExist();
|
||||
});
|
||||
|
||||
test('defaults to show the export button if capabilities cannot be read', async function (assert) {
|
||||
this.server.post('/sys/capabilities-self', () => overrideResponse(403));
|
||||
|
||||
await render(hbs`
|
||||
<Clients::ExportButton
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
/>
|
||||
`);
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
assert.dom(CLIENTS_ATTRIBUTION.yLabels).exists({ count: 10 }, 'only 10 bars are shown');
|
||||
assert.dom(CLIENTS_ATTRIBUTION.topItem).includesText('ns14');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import sinon from 'sinon';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { find, render, findAll } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { format, formatRFC3339, subMonths } from 'date-fns';
|
||||
import timestamp from 'core/utils/timestamp';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
|
||||
module('Integration | Component | clients/line-chart', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
hooks.before(function () {
|
||||
this.timestampStub = sinon.replace(timestamp, 'now', sinon.fake.returns(new Date('2018-04-03T14:15:30')));
|
||||
});
|
||||
hooks.beforeEach(function () {
|
||||
this.set('xKey', 'foo');
|
||||
this.set('yKey', 'bar');
|
||||
this.set('dataset', [
|
||||
{
|
||||
foo: '2018-04-03T14:15:30',
|
||||
bar: 4,
|
||||
expectedLabel: '4/18',
|
||||
},
|
||||
{
|
||||
foo: '2018-05-03T14:15:30',
|
||||
bar: 8,
|
||||
expectedLabel: '5/18',
|
||||
},
|
||||
{
|
||||
foo: '2018-06-03T14:15:30',
|
||||
bar: 14,
|
||||
expectedLabel: '6/18',
|
||||
},
|
||||
{
|
||||
foo: '2018-07-03T14:15:30',
|
||||
bar: 10,
|
||||
expectedLabel: '7/18',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line @dataset={{this.dataset}} @xKey={{this.xKey}} @yKey={{this.yKey}} />
|
||||
</div>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-chart="line chart"]').exists('Chart is rendered');
|
||||
assert
|
||||
.dom('[data-test-plot-point]')
|
||||
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
|
||||
findAll('[data-test-x-axis] text').forEach((e, i) => {
|
||||
// For some reason the first axis label is not rendered
|
||||
assert
|
||||
.dom(e)
|
||||
.hasText(
|
||||
`${this.dataset[i].expectedLabel}`,
|
||||
`renders x-axis label: ${this.dataset[i].expectedLabel}`
|
||||
);
|
||||
});
|
||||
assert.dom('[data-test-y-axis] text').hasText('0', `y-axis starts at 0`);
|
||||
});
|
||||
|
||||
test('it renders upgrade data', async function (assert) {
|
||||
const now = this.timestampStub();
|
||||
this.set('dataset', [
|
||||
{
|
||||
foo: formatRFC3339(subMonths(now, 4)),
|
||||
bar: 4,
|
||||
month: format(subMonths(now, 4), 'M/yy'),
|
||||
},
|
||||
{
|
||||
foo: formatRFC3339(subMonths(now, 3)),
|
||||
bar: 8,
|
||||
month: format(subMonths(now, 3), 'M/yy'),
|
||||
},
|
||||
{
|
||||
foo: formatRFC3339(subMonths(now, 2)),
|
||||
bar: 14,
|
||||
month: format(subMonths(now, 2), 'M/yy'),
|
||||
},
|
||||
{
|
||||
foo: formatRFC3339(subMonths(now, 1)),
|
||||
bar: 10,
|
||||
month: format(subMonths(now, 1), 'M/yy'),
|
||||
},
|
||||
]);
|
||||
this.set('upgradeData', [
|
||||
{
|
||||
id: '1.10.1',
|
||||
previousVersion: '1.9.2',
|
||||
timestampInstalled: formatRFC3339(subMonths(now, 2)),
|
||||
},
|
||||
]);
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
@xKey={{this.xKey}}
|
||||
@yKey={{this.yKey}}
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
assert.dom('[data-test-chart="line chart"]').exists('Chart is rendered');
|
||||
assert
|
||||
.dom('[data-test-plot-point]')
|
||||
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
|
||||
assert
|
||||
.dom(find(`[data-test-line-chart="upgrade-${this.dataset[2].month}"]`))
|
||||
.hasStyle(
|
||||
{ fill: 'rgb(253, 238, 186)' },
|
||||
`upgrade data point ${this.dataset[2].month} has yellow highlight`
|
||||
);
|
||||
});
|
||||
|
||||
test('it renders tooltip', async function (assert) {
|
||||
assert.expect(1);
|
||||
const now = this.timestampStub();
|
||||
const tooltipData = [
|
||||
{
|
||||
month: format(subMonths(now, 4), 'M/yy'),
|
||||
timestamp: formatRFC3339(subMonths(now, 4)),
|
||||
clients: 4,
|
||||
new_clients: {
|
||||
clients: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
month: format(subMonths(now, 3), 'M/yy'),
|
||||
timestamp: formatRFC3339(subMonths(now, 3)),
|
||||
clients: 8,
|
||||
new_clients: {
|
||||
clients: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
month: format(subMonths(now, 2), 'M/yy'),
|
||||
timestamp: formatRFC3339(subMonths(now, 2)),
|
||||
clients: 14,
|
||||
new_clients: {
|
||||
clients: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
month: format(subMonths(now, 1), 'M/yy'),
|
||||
timestamp: formatRFC3339(subMonths(now, 1)),
|
||||
clients: 20,
|
||||
new_clients: {
|
||||
clients: 4,
|
||||
},
|
||||
},
|
||||
];
|
||||
this.set('dataset', tooltipData);
|
||||
this.set('upgradeData', [
|
||||
{
|
||||
id: '1.10.1',
|
||||
previousVersion: '1.9.2',
|
||||
timestampInstalled: formatRFC3339(subMonths(now, 2)),
|
||||
},
|
||||
]);
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const tooltipHoverCircles = findAll('[data-test-hover-circle]');
|
||||
assert.strictEqual(tooltipHoverCircles.length, tooltipData.length, 'all data circles are rendered');
|
||||
|
||||
// FLAKY after adding a11y testing, skip for now
|
||||
// for (const [i, bar] of tooltipHoverCircles.entries()) {
|
||||
// await triggerEvent(bar, 'mouseover');
|
||||
// const tooltip = document.querySelector('.ember-modal-dialog');
|
||||
// const { month, clients, new_clients } = tooltipData[i];
|
||||
// assert
|
||||
// .dom(tooltip)
|
||||
// .includesText(
|
||||
// `${formatChartDate(month)} ${clients} total clients ${new_clients.clients} new clients`,
|
||||
// `tooltip text is correct for ${month}`
|
||||
// );
|
||||
// }
|
||||
});
|
||||
|
||||
test('it fails gracefully when data is not formatted correctly', async function (assert) {
|
||||
this.set('dataset', [
|
||||
{
|
||||
foo: 1,
|
||||
bar: 4,
|
||||
},
|
||||
{
|
||||
foo: 2,
|
||||
bar: 8,
|
||||
},
|
||||
{
|
||||
foo: 3,
|
||||
bar: 14,
|
||||
},
|
||||
{
|
||||
foo: 4,
|
||||
bar: 10,
|
||||
},
|
||||
]);
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@xKey={{this.xKey}}
|
||||
@yKey={{this.yKey}}
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-line-chart]').doesNotExist('Chart is not rendered');
|
||||
assert
|
||||
.dom('[data-test-component="empty-state"]')
|
||||
.hasText('No data to display', 'Shows empty state when time date is not formatted correctly');
|
||||
});
|
||||
|
||||
test('it fails gracefully when upgradeData is an object', async function (assert) {
|
||||
this.set('upgradeData', { some: 'object' });
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
@xKey={{this.xKey}}
|
||||
@yKey={{this.yKey}}
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
|
||||
assert
|
||||
.dom('[data-test-plot-point]')
|
||||
.exists({ count: this.dataset.length }, 'chart still renders when upgradeData is not an array');
|
||||
});
|
||||
|
||||
test('it fails gracefully when upgradeData has incorrect key names', async function (assert) {
|
||||
this.set('upgradeData', [{ incorrect: 'key names' }]);
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
@xKey={{this.xKey}}
|
||||
@yKey={{this.yKey}}
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
|
||||
assert
|
||||
.dom('[data-test-plot-point]')
|
||||
.exists({ count: this.dataset.length }, 'chart still renders when upgradeData has incorrect keys');
|
||||
});
|
||||
|
||||
test('it renders empty state when no dataset', async function (assert) {
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line @noDataMessage="this is a custom message to explain why you're not seeing a line chart"/>
|
||||
</div>
|
||||
`);
|
||||
|
||||
assert.dom('[data-test-component="empty-state"]').exists('renders empty state when no data');
|
||||
assert
|
||||
.dom(GENERAL.emptyStateSubtitle)
|
||||
.hasText(
|
||||
`this is a custom message to explain why you're not seeing a line chart`,
|
||||
'custom message renders'
|
||||
);
|
||||
});
|
||||
|
||||
test('it updates axis when dataset updates', async function (assert) {
|
||||
const datasets = {
|
||||
small: [
|
||||
{
|
||||
foo: '2020-04-01',
|
||||
bar: 4,
|
||||
month: '4/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-05-01',
|
||||
bar: 8,
|
||||
month: '5/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-06-01',
|
||||
bar: 1,
|
||||
},
|
||||
{
|
||||
foo: '2020-07-01',
|
||||
bar: 10,
|
||||
},
|
||||
],
|
||||
large: [
|
||||
{
|
||||
foo: '2020-08-01',
|
||||
bar: 4586,
|
||||
month: '8/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-09-01',
|
||||
bar: 8928,
|
||||
month: '9/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-10-01',
|
||||
bar: 11948,
|
||||
month: '10/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-11-01',
|
||||
bar: 16943,
|
||||
month: '11/20',
|
||||
},
|
||||
],
|
||||
broken: [
|
||||
{
|
||||
foo: '2020-01-01',
|
||||
bar: null,
|
||||
month: '1/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-02-01',
|
||||
bar: 0,
|
||||
month: '2/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-03-01',
|
||||
bar: 22,
|
||||
month: '3/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-04-01',
|
||||
bar: null,
|
||||
month: '4/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-05-01',
|
||||
bar: 70,
|
||||
month: '5/20',
|
||||
},
|
||||
{
|
||||
foo: '2020-06-01',
|
||||
bar: 50,
|
||||
month: '6/20',
|
||||
},
|
||||
],
|
||||
};
|
||||
this.set('dataset', datasets.small);
|
||||
await render(hbs`
|
||||
<div class="chart-container-wide">
|
||||
<Clients::Charts::Line
|
||||
@dataset={{this.dataset}}
|
||||
@upgradeData={{this.upgradeData}}
|
||||
@xKey={{this.xKey}}
|
||||
@yKey={{this.yKey}}
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
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('dataset', 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('dataset', 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');
|
||||
assert.dom('[data-test-hover-circle]').exists({ count: 4 }, 'only render circles for non-null values');
|
||||
|
||||
assert
|
||||
.dom('[data-test-hover-circle="1/20"]')
|
||||
.doesNotExist('first month dot does not exist because value is null');
|
||||
assert
|
||||
.dom('[data-test-hover-circle="4/20"]')
|
||||
.doesNotExist('other null count month dot also does not render');
|
||||
// Note: the line should also show a gap, but this is difficult to test for
|
||||
});
|
||||
});
|
||||
|
|
@ -4,17 +4,19 @@
|
|||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import Sinon from 'sinon';
|
||||
import { Response } from 'miragejs';
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { click, fillIn, render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { Response } from 'miragejs';
|
||||
import Sinon from 'sinon';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { overrideResponse } from 'vault/tests/helpers/stubs';
|
||||
import { capabilitiesStub, overrideResponse } from 'vault/tests/helpers/stubs';
|
||||
import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
|
||||
|
||||
module('Integration | Component | clients/export-button', function (hooks) {
|
||||
// this test coverage mostly is around the export button functionality
|
||||
// since everything else is static
|
||||
module('Integration | Component | clients/page-header', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
|
|
@ -23,52 +25,64 @@ module('Integration | Component | clients/export-button', function (hooks) {
|
|||
this.startTimestamp = '2022-06-01T23:00:11.050Z';
|
||||
this.endTimestamp = '2022-12-01T23:00:11.050Z';
|
||||
this.selectedNamespace = undefined;
|
||||
this.upgradesDuringActivity = [];
|
||||
this.noData = undefined;
|
||||
this.server.post('/sys/capabilities-self', () =>
|
||||
capabilitiesStub('sys/internal/counters/activity/export', ['sudo'])
|
||||
);
|
||||
|
||||
this.renderComponent = async () => {
|
||||
return render(hbs`
|
||||
<Clients::ExportButton
|
||||
<Clients::PageHeader
|
||||
@startTimestamp={{this.startTimestamp}}
|
||||
@endTimestamp={{this.endTimestamp}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
@namespace={{this.selectedNamespace}}
|
||||
@upgradesDuringActivity={{this.upgradesDuringActivity}}
|
||||
@noData={{this.noData}}
|
||||
/>`);
|
||||
};
|
||||
});
|
||||
|
||||
test('it renders modal with yielded alert', async function (assert) {
|
||||
await render(hbs`
|
||||
<Clients::ExportButton
|
||||
@startTimestamp={{this.startTimestamp}}
|
||||
@endTimestamp={{this.endTimestamp}}
|
||||
>
|
||||
<:alert>
|
||||
<Hds::Alert class="has-top-padding-m" @type="compact" @color="warning" as |A|>
|
||||
<A.Description data-test-custom-alert>Yielded alert!</A.Description>
|
||||
</Hds::Alert>
|
||||
</:alert>
|
||||
</Clients::ExportButton>
|
||||
`);
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
assert.dom('[data-test-custom-alert]').hasText('Yielded alert!');
|
||||
test('it shows the export button if user does has SUDO capabilities', async function (assert) {
|
||||
await this.renderComponent();
|
||||
assert.dom(CLIENT_COUNT.exportButton).exists();
|
||||
});
|
||||
|
||||
test('shows the API error on the modal', async function (assert) {
|
||||
test('it hides the export button if user does has SUDO capabilities but there is no data', async function (assert) {
|
||||
this.noData = true;
|
||||
await this.renderComponent();
|
||||
assert.dom(CLIENT_COUNT.exportButton).doesNotExist();
|
||||
});
|
||||
|
||||
test('it hides the export button if user does not have SUDO capabilities', async function (assert) {
|
||||
this.server.post('/sys/capabilities-self', () =>
|
||||
capabilitiesStub('sys/internal/counters/activity/export', ['read'])
|
||||
);
|
||||
|
||||
await this.renderComponent();
|
||||
assert.dom(CLIENT_COUNT.exportButton).doesNotExist();
|
||||
});
|
||||
|
||||
test('defaults to show the export button if capabilities cannot be read', async function (assert) {
|
||||
this.server.post('/sys/capabilities-self', () => overrideResponse(403));
|
||||
|
||||
await this.renderComponent();
|
||||
assert.dom(CLIENT_COUNT.exportButton).exists();
|
||||
});
|
||||
|
||||
test('it shows the export API error on the modal', async function (assert) {
|
||||
this.server.get('/sys/internal/counters/activity/export', function () {
|
||||
return new Response(
|
||||
403,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
{ errors: ['this is an error from the API'] }
|
||||
);
|
||||
return overrideResponse(403);
|
||||
});
|
||||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await click(GENERAL.confirmButton);
|
||||
assert.dom('[data-test-export-error]').hasText('this is an error from the API');
|
||||
assert.dom('[data-test-export-error]').hasText('permission denied');
|
||||
});
|
||||
|
||||
test('it works for json format', async function (assert) {
|
||||
test('it exports when json format', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
assert.deepEqual(req.queryParams, {
|
||||
|
|
@ -81,14 +95,14 @@ module('Integration | Component | clients/export-button', function (hooks) {
|
|||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await fillIn('[data-test-download-format]', 'jsonl');
|
||||
await click(GENERAL.confirmButton);
|
||||
const extension = this.downloadStub.lastCall.args[2];
|
||||
assert.strictEqual(extension, 'jsonl');
|
||||
});
|
||||
|
||||
test('it works for csv format', async function (assert) {
|
||||
test('it exports when csv format', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
|
||||
|
|
@ -102,7 +116,7 @@ module('Integration | Component | clients/export-button', function (hooks) {
|
|||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await fillIn('[data-test-download-format]', 'csv');
|
||||
await click(GENERAL.confirmButton);
|
||||
const extension = this.downloadStub.lastCall.args[2];
|
||||
|
|
@ -118,14 +132,10 @@ module('Integration | Component | clients/export-button', function (hooks) {
|
|||
return new Response(200, { 'Content-Type': 'text/csv' }, '');
|
||||
});
|
||||
|
||||
await render(hbs`
|
||||
<Clients::ExportButton
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
/>
|
||||
`);
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(CLIENT_COUNT.exportButton).exists();
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await click(GENERAL.confirmButton);
|
||||
});
|
||||
test('it sends the selected namespace in export request', async function (assert) {
|
||||
|
|
@ -136,15 +146,9 @@ module('Integration | Component | clients/export-button', function (hooks) {
|
|||
});
|
||||
this.selectedNamespace = 'foobar/';
|
||||
|
||||
await render(hbs`
|
||||
<Clients::ExportButton
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
/>
|
||||
`);
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await this.renderComponent();
|
||||
assert.dom(CLIENT_COUNT.exportButton).exists();
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await click(GENERAL.confirmButton);
|
||||
});
|
||||
|
||||
|
|
@ -158,32 +162,31 @@ module('Integration | Component | clients/export-button', function (hooks) {
|
|||
});
|
||||
this.selectedNamespace = 'bar/';
|
||||
|
||||
await render(hbs`
|
||||
<Clients::ExportButton
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
@selectedNamespace={{this.selectedNamespace}}
|
||||
/>
|
||||
`);
|
||||
assert.dom('[data-test-attribution-export-button]').exists();
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(CLIENT_COUNT.exportButton).exists();
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await click(GENERAL.confirmButton);
|
||||
});
|
||||
|
||||
test('it shows a no data message if endpoint returns 204', async function (assert) {
|
||||
test('it shows a no data message if export returns 204', async function (assert) {
|
||||
this.server.get('/sys/internal/counters/activity/export', () => overrideResponse(204));
|
||||
await this.renderComponent();
|
||||
|
||||
await render(hbs`
|
||||
<Clients::ExportButton
|
||||
@totalClientAttribution={{this.totalClientAttribution}}
|
||||
@responseTimestamp={{this.timestamp}}
|
||||
/>
|
||||
`);
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await click(GENERAL.confirmButton);
|
||||
assert.dom('[data-test-export-error]').hasText('no data to export in provided time range.');
|
||||
});
|
||||
|
||||
test('it shows upgrade data in export modal', async function (assert) {
|
||||
this.upgradesDuringActivity = [
|
||||
{ version: '1.10.1', previousVersion: '1.9.9', timestampInstalled: '2021-11-18T10:23:16Z' },
|
||||
];
|
||||
await this.renderComponent();
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
assert.dom('[data-test-export-upgrade-warning]').includesText('1.10.1 (Nov 18, 2021)');
|
||||
});
|
||||
|
||||
module('download naming', function () {
|
||||
test('is correct for date range', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
|
@ -197,7 +200,7 @@ module('Integration | Component | clients/export-button', function (hooks) {
|
|||
});
|
||||
|
||||
await this.renderComponent();
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await click(GENERAL.confirmButton);
|
||||
const args = this.downloadStub.lastCall.args;
|
||||
const [filename] = args;
|
||||
|
|
@ -217,7 +220,7 @@ module('Integration | Component | clients/export-button', function (hooks) {
|
|||
});
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename] = this.downloadStub.lastCall.args;
|
||||
assert.strictEqual(filename, 'clients_export_June 2022', 'csv has single month in filename');
|
||||
|
|
@ -236,7 +239,7 @@ module('Integration | Component | clients/export-button', function (hooks) {
|
|||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename] = this.downloadStub.lastCall.args;
|
||||
assert.strictEqual(filename, 'clients_export');
|
||||
|
|
@ -258,7 +261,7 @@ module('Integration | Component | clients/export-button', function (hooks) {
|
|||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename] = this.downloadStub.lastCall.args;
|
||||
assert.strictEqual(filename, 'clients_export_bar');
|
||||
|
|
@ -279,7 +282,7 @@ module('Integration | Component | clients/export-button', function (hooks) {
|
|||
|
||||
await this.renderComponent();
|
||||
|
||||
await click('[data-test-attribution-export-button]');
|
||||
await click(CLIENT_COUNT.exportButton);
|
||||
await click(GENERAL.confirmButton);
|
||||
const [filename] = this.downloadStub.lastCall.args;
|
||||
assert.strictEqual(filename, 'clients_export_foo');
|
||||
|
|
@ -59,9 +59,8 @@ module('Integration | Component | clients | Clients::Page::Acme', function (hook
|
|||
|
||||
test('it should render with full month activity data charts', async function (assert) {
|
||||
const monthCount = this.activity.byMonth.length;
|
||||
assert.expect(8 + monthCount * 2);
|
||||
assert.expect(7 + monthCount * 2);
|
||||
const expectedTotal = formatNumber([this.activity.total.acme_clients]);
|
||||
const expectedAvg = formatNumber([calculateAverage(this.activity.byMonth, 'acme_clients')]);
|
||||
const expectedNewAvg = formatNumber([
|
||||
calculateAverage(
|
||||
this.activity.byMonth.map((m) => m?.new_clients),
|
||||
|
|
@ -75,7 +74,6 @@ module('Integration | Component | clients | Clients::Page::Acme', function (hook
|
|||
`Total ACME clients The total number of ACME requests made to Vault during this time period. ${expectedTotal}`,
|
||||
`renders correct total acme stat ${expectedTotal}`
|
||||
);
|
||||
assert.dom(statText('Average ACME clients per month')).hasTextContaining(`${expectedAvg}`);
|
||||
assert.dom(statText('Average new ACME clients per month')).hasTextContaining(`${expectedNewAvg}`);
|
||||
|
||||
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
|
||||
|
|
@ -88,7 +86,7 @@ module('Integration | Component | clients | Clients::Page::Acme', function (hook
|
|||
});
|
||||
|
||||
test('it should render stats without chart for a single month', async function (assert) {
|
||||
assert.expect(5);
|
||||
assert.expect(4);
|
||||
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]);
|
||||
|
|
@ -96,7 +94,6 @@ module('Integration | Component | clients | Clients::Page::Acme', function (hook
|
|||
|
||||
assert.dom(CHARTS.chart('ACME 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 ACME clients per month')).doesNotExist();
|
||||
assert.dom(statText('Average new ACME clients per month')).doesNotExist();
|
||||
assert
|
||||
.dom(usageStats('ACME usage'))
|
||||
|
|
@ -108,7 +105,7 @@ module('Integration | Component | clients | Clients::Page::Acme', function (hook
|
|||
|
||||
// EMPTY STATES
|
||||
test('it should render empty state when ACME data does not exist for a date range', async function (assert) {
|
||||
assert.expect(8);
|
||||
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
|
||||
|
|
@ -124,7 +121,6 @@ module('Integration | Component | clients | Clients::Page::Acme', function (hook
|
|||
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 ACME clients per month')).doesNotExist();
|
||||
assert.dom(statText('Average new ACME clients per month')).doesNotExist();
|
||||
assert.dom(usageStats('ACME usage')).doesNotExist();
|
||||
});
|
||||
|
|
@ -141,7 +137,7 @@ module('Integration | Component | clients | Clients::Page::Acme', function (hook
|
|||
});
|
||||
|
||||
test('it should render empty total usage chart when monthly counts are null or 0', async function (assert) {
|
||||
assert.expect(9);
|
||||
assert.expect(8);
|
||||
// manually stub because mirage isn't setup to handle mixed data yet
|
||||
const counts = {
|
||||
acme_clients: 0,
|
||||
|
|
@ -155,7 +151,6 @@ module('Integration | Component | clients | Clients::Page::Acme', function (hook
|
|||
month: '3/24',
|
||||
timestamp: '2024-03-01T00:00:00Z',
|
||||
namespaces: [],
|
||||
namespaces_by_key: {},
|
||||
new_clients: {
|
||||
month: '3/24',
|
||||
timestamp: '2024-03-01T00:00:00Z',
|
||||
|
|
@ -167,7 +162,6 @@ module('Integration | Component | clients | Clients::Page::Acme', function (hook
|
|||
timestamp: '2024-04-01T00:00:00Z',
|
||||
...counts,
|
||||
namespaces: [],
|
||||
namespaces_by_key: {},
|
||||
new_clients: {
|
||||
month: '4/24',
|
||||
timestamp: '2024-04-01T00:00:00Z',
|
||||
|
|
@ -198,7 +192,6 @@ module('Integration | Component | clients | Clients::Page::Acme', function (hook
|
|||
assert
|
||||
.dom(CHARTS.container('Monthly new'))
|
||||
.doesNotExist('empty monthly new chart does not render at all');
|
||||
assert.dom(statText('Average ACME clients per month')).doesNotExist();
|
||||
assert.dom(statText('Average new ACME clients per month')).doesNotExist();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -265,9 +265,9 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
|
|||
);
|
||||
});
|
||||
|
||||
test('it should render empty state for no start or license start time', async function (assert) {
|
||||
test('it should render empty state for no start when CE', async function (assert) {
|
||||
this.owner.lookup('service:version').type = 'community';
|
||||
this.startTimestamp = null;
|
||||
this.config.billingStartTimestamp = null;
|
||||
this.activity = {};
|
||||
|
||||
await this.renderComponent();
|
||||
|
|
|
|||
110
ui/tests/integration/components/clients/page/overview-test.js
Normal file
110
ui/tests/integration/components/clients/page/overview-test.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||
import { 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 { filterActivityResponse } from 'vault/mirage/handlers/clients';
|
||||
import { CHARTS, CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
|
||||
|
||||
module('Integration | Component | clients/page/overview', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
this.server.get('sys/internal/counters/activity', (_, req) => {
|
||||
const namespace = req.requestHeaders['X-Vault-Namespace'];
|
||||
if (namespace === 'no-data') {
|
||||
return {
|
||||
request_id: 'some-activity-id',
|
||||
data: {
|
||||
by_namespace: [],
|
||||
end_time: '2024-08-31T23:59:59Z',
|
||||
months: [],
|
||||
start_time: '2024-01-01T00:00:00Z',
|
||||
total: {
|
||||
distinct_entities: 0,
|
||||
entity_clients: 0,
|
||||
non_entity_tokens: 0,
|
||||
non_entity_clients: 0,
|
||||
clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
request_id: 'some-activity-id',
|
||||
data: filterActivityResponse(ACTIVITY_RESPONSE_STUB, namespace),
|
||||
};
|
||||
});
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.mountPath = '';
|
||||
this.namespace = '';
|
||||
this.versionHistory = '';
|
||||
});
|
||||
|
||||
test('it hides attribution data when mount filter applied', async function (assert) {
|
||||
this.mountPath = '';
|
||||
this.activity = await this.store.queryRecord('clients/activity', {
|
||||
namespace: 'ns1',
|
||||
});
|
||||
await render(
|
||||
hbs`<Clients::Page::Overview @activity={{this.activity}} @namespace="ns1" @mountPath={{this.mountPath}} />`
|
||||
);
|
||||
|
||||
assert.dom(CHARTS.container('Vault client counts')).exists('shows running totals');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock('namespace')).exists();
|
||||
assert.dom(CLIENT_COUNT.attributionBlock('mount')).exists();
|
||||
|
||||
this.set('mountPath', 'auth/authid/0');
|
||||
assert.dom(CHARTS.container('Vault client counts')).exists('shows running totals');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock('namespace')).doesNotExist();
|
||||
assert.dom(CLIENT_COUNT.attributionBlock('mount')).doesNotExist();
|
||||
});
|
||||
|
||||
test('it hides attribution data when no data returned', async function (assert) {
|
||||
this.mountPath = '';
|
||||
this.activity = await this.store.queryRecord('clients/activity', {
|
||||
namespace: 'no-data',
|
||||
});
|
||||
await render(hbs`<Clients::Page::Overview @activity={{this.activity}} />`);
|
||||
assert.dom(CLIENT_COUNT.usageStats('Total usage')).exists();
|
||||
assert.dom(CHARTS.container('Vault client counts')).doesNotExist('usage stats instead of running totals');
|
||||
assert.dom(CLIENT_COUNT.attributionBlock('namespace')).doesNotExist();
|
||||
assert.dom(CLIENT_COUNT.attributionBlock('mount')).doesNotExist();
|
||||
});
|
||||
|
||||
test('it shows the correct mount attributions', async function (assert) {
|
||||
this.nsService = this.owner.lookup('service:namespace');
|
||||
const rootActivity = await this.store.queryRecord('clients/activity', {});
|
||||
this.activity = rootActivity;
|
||||
await render(hbs`<Clients::Page::Overview @activity={{this.activity}} />`);
|
||||
// start at "root" namespace
|
||||
let expectedMounts = rootActivity.byNamespace.find((ns) => ns.label === 'root').mounts;
|
||||
assert
|
||||
.dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-group="y-labels"] text`)
|
||||
.exists({ count: expectedMounts.length });
|
||||
assert
|
||||
.dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-group="y-labels"]`)
|
||||
.includesText(expectedMounts[0].label);
|
||||
|
||||
// now pretend we're querying within a child namespace
|
||||
this.nsService.path = 'ns1';
|
||||
this.activity = await this.store.queryRecord('clients/activity', {
|
||||
namespace: 'ns1',
|
||||
});
|
||||
expectedMounts = rootActivity.byNamespace.find((ns) => ns.label === 'ns1').mounts;
|
||||
assert
|
||||
.dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-group="y-labels"] text`)
|
||||
.exists({ count: expectedMounts.length });
|
||||
assert
|
||||
.dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-group="y-labels"]`)
|
||||
.includesText(expectedMounts[0].label);
|
||||
});
|
||||
});
|
||||
|
|
@ -52,7 +52,6 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
|
|||
|
||||
assert.dom(CHARTS.chart('Secrets sync usage')).doesNotExist();
|
||||
assert.dom(statText('Total sync clients')).doesNotExist();
|
||||
assert.dom(statText('Average sync clients per month')).doesNotExist();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -74,9 +73,8 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
|
|||
|
||||
test('it should render with full month activity data', async function (assert) {
|
||||
const monthCount = this.activity.byMonth.length;
|
||||
assert.expect(8 + monthCount * 2);
|
||||
assert.expect(7 + monthCount * 2);
|
||||
const expectedTotal = formatNumber([this.activity.total.secret_syncs]);
|
||||
const expectedAvg = formatNumber([calculateAverage(this.activity.byMonth, 'secret_syncs')]);
|
||||
const expectedNewAvg = formatNumber([
|
||||
calculateAverage(
|
||||
this.activity.byMonth.map((m) => m?.new_clients),
|
||||
|
|
@ -91,12 +89,6 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
|
|||
`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}`
|
||||
);
|
||||
assert
|
||||
.dom(statText('Average sync clients per month'))
|
||||
.hasText(
|
||||
`Average sync clients per month ${expectedAvg}`,
|
||||
`renders correct average sync stat ${expectedAvg}`
|
||||
);
|
||||
assert.dom(statText('Average new sync clients per month')).hasTextContaining(`${expectedNewAvg}`);
|
||||
|
||||
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
|
||||
|
|
@ -109,7 +101,6 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
|
|||
});
|
||||
|
||||
test('it should render stats without chart for a single month', async function (assert) {
|
||||
assert.expect(5);
|
||||
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]);
|
||||
|
|
@ -117,7 +108,6 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
|
|||
|
||||
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 sync clients per month')).doesNotExist();
|
||||
assert.dom(statText('Average new sync clients per month')).doesNotExist();
|
||||
assert
|
||||
.dom(usageStats('Secrets sync usage'))
|
||||
|
|
@ -129,7 +119,7 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
|
|||
|
||||
// EMPTY STATES
|
||||
test('it should render empty state when sync data does not exist for a date range', async function (assert) {
|
||||
assert.expect(8);
|
||||
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
|
||||
|
|
@ -143,7 +133,6 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
|
|||
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 sync clients per month')).doesNotExist();
|
||||
assert.dom(statText('Average new sync clients per month')).doesNotExist();
|
||||
assert.dom(usageStats('Secrets sync usage')).doesNotExist();
|
||||
});
|
||||
|
|
@ -181,20 +170,14 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
|
|||
this.activity.byMonth = [
|
||||
{
|
||||
...monthData,
|
||||
namespaces_by_key: {
|
||||
root: {
|
||||
...monthData,
|
||||
mounts_by_key: {},
|
||||
},
|
||||
},
|
||||
new_clients: {
|
||||
...monthData,
|
||||
},
|
||||
},
|
||||
];
|
||||
this.activity.total = counts;
|
||||
const monthCount = this.activity.byMonth.length;
|
||||
assert.expect(6 + monthCount * 2);
|
||||
|
||||
assert.expect(6);
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(CHARTS.chart('Secrets sync usage')).exists('renders empty sync usage chart');
|
||||
|
|
@ -203,9 +186,6 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
|
|||
.hasText(
|
||||
'Total sync clients The total number of secrets synced from Vault to other destinations during this date range. 0'
|
||||
);
|
||||
assert
|
||||
.dom(statText('Average sync clients per month'))
|
||||
.doesNotExist('Does not render average if the calculation is 0');
|
||||
findAll(`${CHARTS.chart('Secrets sync usage')} ${CHARTS.xAxisLabel}`).forEach((e, i) => {
|
||||
assert
|
||||
.dom(e)
|
||||
|
|
@ -221,7 +201,6 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
|
|||
assert
|
||||
.dom(CHARTS.container('Monthly new'))
|
||||
.doesNotExist('empty monthly new chart does not render at all');
|
||||
assert.dom(statText('Average sync clients per month')).doesNotExist();
|
||||
assert.dom(statText('Average new sync clients per month')).doesNotExist();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,14 +70,8 @@ module('Integration | Component | clients | Clients::Page::Token', function (hoo
|
|||
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 + 8);
|
||||
const getAverage = (data) => {
|
||||
const average = ['entity_clients', 'non_entity_clients'].reduce((count, key) => {
|
||||
return (count += calculateAverage(data, key) || 0);
|
||||
}, 0);
|
||||
return formatNumber([average]);
|
||||
};
|
||||
const expectedAvg = getAverage(this.activity.byMonth);
|
||||
assert.expect(count + 7);
|
||||
|
||||
const expectedTotal = formatNumber([entity_clients + non_entity_clients]);
|
||||
const chart = CHARTS.container('Entity/Non-entity clients usage');
|
||||
await this.renderComponent();
|
||||
|
|
@ -85,9 +79,6 @@ module('Integration | Component | clients | Clients::Page::Token', function (hoo
|
|||
assert
|
||||
.dom(CLIENT_COUNT.statTextValue('Total clients'))
|
||||
.hasText(expectedTotal, 'renders correct total clients');
|
||||
assert
|
||||
.dom(CLIENT_COUNT.statTextValue('Average total clients per month'))
|
||||
.hasText(expectedAvg, 'renders correct average 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');
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ module('Integration | Component | clients/running-total', function (hooks) {
|
|||
await this.renderComponent();
|
||||
|
||||
assert.dom(CHARTS.container('Vault client counts')).exists('running total component renders');
|
||||
assert.dom(CHARTS.chart('Vault client counts line chart')).exists('line chart renders');
|
||||
assert.dom(CHARTS.chart('Vault client counts')).exists('bar chart renders');
|
||||
|
||||
const expectedValues = {
|
||||
'Running client total': formatNumber([this.totalUsageCounts.clients]),
|
||||
|
|
@ -85,21 +85,18 @@ module('Integration | Component | clients/running-total', function (hooks) {
|
|||
);
|
||||
}
|
||||
|
||||
// assert line chart is correct
|
||||
// assert grouped bar chart is correct
|
||||
findAll(CHARTS.xAxisLabel).forEach((e, i) => {
|
||||
assert
|
||||
.dom(e)
|
||||
.hasText(
|
||||
`${this.byMonthActivity[i].month}`,
|
||||
`renders x-axis labels for line chart: ${this.byMonthActivity[i].month}`
|
||||
`renders x-axis labels for bar chart: ${this.byMonthActivity[i].month}`
|
||||
);
|
||||
});
|
||||
assert
|
||||
.dom(CHARTS.plotPoint)
|
||||
.exists(
|
||||
{ count: this.byMonthActivity.filter((m) => m.clients).length },
|
||||
'renders correct number of plot points'
|
||||
);
|
||||
.dom(CHARTS.verticalBar)
|
||||
.exists({ count: this.byMonthActivity.length * 2 }, 'renders correct number of bars ');
|
||||
});
|
||||
|
||||
test('it renders with no new monthly data', async function (assert) {
|
||||
|
|
@ -111,7 +108,7 @@ module('Integration | Component | clients/running-total', function (hooks) {
|
|||
await this.renderComponent();
|
||||
|
||||
assert.dom(CHARTS.container('Vault client counts')).exists('running total component renders');
|
||||
assert.dom(CHARTS.chart('Vault client counts line chart')).exists('line chart renders');
|
||||
assert.dom(CHARTS.chart('Vault client counts')).exists('bar chart renders');
|
||||
|
||||
const expectedValues = {
|
||||
Entity: formatNumber([this.totalUsageCounts.entity_clients]),
|
||||
|
|
@ -168,7 +165,7 @@ module('Integration | Component | clients/running-total', function (hooks) {
|
|||
`stat label: ${label} renders single month new clients: ${expectedStats[label]}`
|
||||
);
|
||||
}
|
||||
assert.dom(CHARTS.chart('Vault client counts line chart')).doesNotExist('line chart does not render');
|
||||
assert.dom(CHARTS.chart('Vault client counts')).doesNotExist('bar chart does not render');
|
||||
assert.dom(CLIENT_COUNT.statTextValue()).exists({ count: 10 }, 'renders 10 stat text containers');
|
||||
});
|
||||
|
||||
|
|
@ -178,7 +175,7 @@ module('Integration | Component | clients/running-total', function (hooks) {
|
|||
await this.renderComponent();
|
||||
|
||||
assert.dom(CHARTS.container('Vault client counts')).exists('running total component renders');
|
||||
assert.dom(CHARTS.chart('Vault client counts line chart')).exists('line chart renders');
|
||||
assert.dom(CHARTS.chart('Vault client counts')).exists('bar chart renders');
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import {
|
|||
formatByMonths,
|
||||
formatByNamespace,
|
||||
destructureClientCounts,
|
||||
namespaceArrayToObject,
|
||||
sortMonthsByTimestamp,
|
||||
filterByMonthDataForMount,
|
||||
} from 'core/utils/client-count-utils';
|
||||
import clientsHandler from 'vault/mirage/handlers/clients';
|
||||
import {
|
||||
|
|
@ -125,7 +125,7 @@ module('Integration | Util | client count utils', function (hooks) {
|
|||
});
|
||||
|
||||
test('formatByMonths: it formats the months array', async function (assert) {
|
||||
assert.expect(9);
|
||||
assert.expect(7);
|
||||
const original = [...RESPONSE.months];
|
||||
|
||||
const [formattedNoData, formattedWithActivity, formattedNoNew] = formatByMonths(RESPONSE.months);
|
||||
|
|
@ -135,7 +135,7 @@ module('Integration | Util | client count utils', function (hooks) {
|
|||
const [expectedNoData, expectedWithActivity, expectedNoNew] = SERIALIZED_ACTIVITY_RESPONSE.by_month;
|
||||
|
||||
assert.propEqual(formattedNoData, expectedNoData, 'it formats months without data');
|
||||
['namespaces', 'new_clients', 'namespaces_by_key'].forEach((key) => {
|
||||
['namespaces', 'new_clients'].forEach((key) => {
|
||||
assert.propEqual(
|
||||
formattedWithActivity[key],
|
||||
expectedWithActivity[key],
|
||||
|
|
@ -192,33 +192,6 @@ module('Integration | Util | client count utils', function (hooks) {
|
|||
assert.propEqual(RESPONSE.months, original, 'it does not modify original array');
|
||||
});
|
||||
|
||||
test('namespaceArrayToObject: it returns namespaces_by_key and mounts_by_key', async function (assert) {
|
||||
// namespaceArrayToObject only called when there are counts, so skip month 0 which has no counts
|
||||
for (let i = 1; i < RESPONSE.months.length; i++) {
|
||||
const original = { ...RESPONSE.months[i] };
|
||||
const expectedObject = SERIALIZED_ACTIVITY_RESPONSE.by_month[i].namespaces_by_key;
|
||||
const formattedTotal = formatByNamespace(RESPONSE.months[i].namespaces);
|
||||
const testObject = namespaceArrayToObject(
|
||||
formattedTotal,
|
||||
formatByNamespace(RESPONSE.months[i].new_clients.namespaces),
|
||||
`${i + 6}/23`,
|
||||
original.timestamp
|
||||
);
|
||||
const { root } = testObject;
|
||||
const { root: expectedRoot } = expectedObject;
|
||||
|
||||
assert.propEqual(
|
||||
root?.new_clients,
|
||||
expectedRoot?.new_clients,
|
||||
`it formats namespaces new_clients for ${original.timestamp}`
|
||||
);
|
||||
assert.propEqual(root.mounts_by_key, expectedRoot.mounts_by_key, 'it formats namespaces mounts_by_key');
|
||||
assert.propContains(root, expectedRoot, 'namespace has correct keys');
|
||||
|
||||
assert.propEqual(RESPONSE.months[i], original, 'it does not modify original month data');
|
||||
}
|
||||
});
|
||||
|
||||
// TESTS FOR COMBINED ACTIVITY DATA - no mount attribution < 1.10
|
||||
test('it formats the namespaces array with no mount attribution (activity log data < 1.10)', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
|
@ -272,7 +245,7 @@ module('Integration | Util | client count utils', function (hooks) {
|
|||
});
|
||||
|
||||
test('it formats the months array with mixed activity data', async function (assert) {
|
||||
assert.expect(3);
|
||||
assert.expect(2);
|
||||
|
||||
const [, formattedWithActivity] = formatByMonths(MIXED_RESPONSE.months);
|
||||
// mirage isn't set up to generate mixed data, so hardcoding the expected responses here
|
||||
|
|
@ -349,90 +322,163 @@ module('Integration | Util | client count utils', function (hooks) {
|
|||
},
|
||||
'it formats combined data for monthly new_clients spanning upgrade to 1.10'
|
||||
);
|
||||
assert.propEqual(
|
||||
formattedWithActivity.namespaces_by_key,
|
||||
{
|
||||
root: {
|
||||
acme_clients: 0,
|
||||
clients: 3,
|
||||
entity_clients: 3,
|
||||
month: '4/24',
|
||||
mounts_by_key: {
|
||||
'auth/u/': {
|
||||
acme_clients: 0,
|
||||
clients: 1,
|
||||
entity_clients: 1,
|
||||
label: 'auth/u/',
|
||||
month: '4/24',
|
||||
new_clients: {
|
||||
acme_clients: 0,
|
||||
clients: 1,
|
||||
entity_clients: 1,
|
||||
label: 'auth/u/',
|
||||
month: '4/24',
|
||||
timestamp: '2024-04-01T00:00:00Z',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2024-04-01T00:00:00Z',
|
||||
},
|
||||
'no mount accessor (pre-1.10 upgrade?)': {
|
||||
acme_clients: 0,
|
||||
clients: 2,
|
||||
entity_clients: 2,
|
||||
label: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
month: '4/24',
|
||||
new_clients: {
|
||||
acme_clients: 0,
|
||||
clients: 2,
|
||||
entity_clients: 2,
|
||||
label: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
month: '4/24',
|
||||
timestamp: '2024-04-01T00:00:00Z',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2024-04-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
module('filterByMonthDataForMount', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.getExpected = (label, count = 0, newCount = 0) => {
|
||||
return {
|
||||
month: '6/23',
|
||||
namespaces: [],
|
||||
label,
|
||||
timestamp: '2023-06-01T00:00:00Z',
|
||||
acme_clients: count,
|
||||
clients: count,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
new_clients: {
|
||||
acme_clients: 0,
|
||||
clients: 3,
|
||||
entity_clients: 3,
|
||||
label: 'root',
|
||||
month: '4/24',
|
||||
timestamp: '2024-04-01T00:00:00Z',
|
||||
mounts: [
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 2,
|
||||
entity_clients: 2,
|
||||
label: 'no mount accessor (pre-1.10 upgrade?)',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
{
|
||||
acme_clients: 0,
|
||||
clients: 1,
|
||||
entity_clients: 1,
|
||||
label: 'auth/u/',
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
month: '6/23',
|
||||
timestamp: '2023-06-01T00:00:00Z',
|
||||
namespaces: [],
|
||||
label,
|
||||
acme_clients: newCount,
|
||||
clients: newCount,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
test('it works when month has no data', async function (assert) {
|
||||
const months = [
|
||||
{
|
||||
month: '6/23',
|
||||
timestamp: '2023-06-01T00:00:00Z',
|
||||
namespaces: [],
|
||||
new_clients: {
|
||||
month: '6/23',
|
||||
timestamp: '2023-06-01T00:00:00Z',
|
||||
namespaces: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = filterByMonthDataForMount(months, 'root', 'some-mount');
|
||||
// no data is different than zero, it implies no data was being saved at that time
|
||||
// so we don't fill in missing data with zeros to differentiate those two states
|
||||
assert.deepEqual(result[0], months[0], 'does not change month when no data');
|
||||
});
|
||||
|
||||
test('it works when month has no new clients', async function (assert) {
|
||||
const months = [
|
||||
{
|
||||
month: '6/23',
|
||||
timestamp: '2023-06-01T00:00:00Z',
|
||||
acme_clients: 11,
|
||||
clients: 11,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
timestamp: '2024-04-01T00:00:00Z',
|
||||
namespaces: [
|
||||
{
|
||||
label: 'root',
|
||||
acme_clients: 11,
|
||||
clients: 11,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
mounts: [
|
||||
{
|
||||
label: 'some-mount',
|
||||
acme_clients: 11,
|
||||
clients: 11,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
new_clients: {
|
||||
month: '6/23',
|
||||
timestamp: '2023-06-01T00:00:00Z',
|
||||
namespaces: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
'it formats combined data for monthly namespaces_by_key spanning upgrade to 1.10'
|
||||
);
|
||||
];
|
||||
|
||||
let result = filterByMonthDataForMount(months, 'root', 'some-mount');
|
||||
assert.propEqual(result[0], this.getExpected('some-mount', 11), 'works when mount is found');
|
||||
result = filterByMonthDataForMount(months, 'root', 'another-mount');
|
||||
assert.deepEqual(result[0], this.getExpected('another-mount', 0), 'works when mount is not found');
|
||||
result = filterByMonthDataForMount(months, 'unknown-child', 'some-mount');
|
||||
assert.deepEqual(result[0], this.getExpected('some-mount', 0), 'works when namespace is not found');
|
||||
});
|
||||
|
||||
test('it works when month has new clients', async function (assert) {
|
||||
const months = [
|
||||
{
|
||||
month: '6/23',
|
||||
timestamp: '2023-06-01T00:00:00Z',
|
||||
acme_clients: 22,
|
||||
clients: 22,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
namespaces: [
|
||||
{
|
||||
label: 'root',
|
||||
acme_clients: 22,
|
||||
clients: 22,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
mounts: [
|
||||
{
|
||||
label: 'some-mount',
|
||||
acme_clients: 22,
|
||||
clients: 22,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
new_clients: {
|
||||
month: '6/23',
|
||||
timestamp: '2023-06-01T00:00:00Z',
|
||||
namespaces: [
|
||||
{
|
||||
label: 'root',
|
||||
acme_clients: 11,
|
||||
clients: 11,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
mounts: [
|
||||
{
|
||||
label: 'some-mount',
|
||||
acme_clients: 11,
|
||||
clients: 11,
|
||||
entity_clients: 0,
|
||||
non_entity_clients: 0,
|
||||
secret_syncs: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
let result = filterByMonthDataForMount(months, 'root', 'some-mount');
|
||||
assert.propEqual(result[0], this.getExpected('some-mount', 22, 11), 'works when mount is found');
|
||||
result = filterByMonthDataForMount(months, 'root', 'another-mount');
|
||||
assert.deepEqual(result[0], this.getExpected('another-mount', 0), 'works when mount is not found');
|
||||
result = filterByMonthDataForMount(months, 'unknown-child', 'some-mount');
|
||||
assert.deepEqual(result[0], this.getExpected('some-mount', 0), 'works when namespace is not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
33
ui/tests/unit/adapters/application-test.js
Normal file
33
ui/tests/unit/adapters/application-test.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { module, test } from 'qunit';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { setupTest } from 'vault/tests/helpers';
|
||||
|
||||
module('Unit | Adapter | application', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.server.get('/some-url', function () {
|
||||
return {
|
||||
warnings: ['this is a warning'],
|
||||
};
|
||||
});
|
||||
this.adapter = this.owner.lookup('adapter:application');
|
||||
});
|
||||
|
||||
test('it triggers info flash message when warnings returned from API', async function (assert) {
|
||||
const flashSuccessSpy = Sinon.spy(this.owner.lookup('service:flash-messages'), 'info');
|
||||
await this.adapter.ajax('/v1/some-url', 'GET', { skipWarnings: true });
|
||||
assert.true(flashSuccessSpy.notCalled, 'flash is not called when skipWarnings option passed');
|
||||
await this.adapter.ajax('/v1/some-url', 'GET', {});
|
||||
assert.true(flashSuccessSpy.calledOnce);
|
||||
assert.true(flashSuccessSpy.calledWith('this is a warning'));
|
||||
});
|
||||
});
|
||||
|
|
@ -166,6 +166,26 @@ module('Unit | Adapter | clients activity', function (hooks) {
|
|||
this.store.queryRecord(this.modelName, {});
|
||||
});
|
||||
|
||||
test('it adds the passed namespace to the request header', async function (assert) {
|
||||
assert.expect(2);
|
||||
const queryParams = {
|
||||
start_time: { timestamp: this.startDate.toISOString() },
|
||||
end_time: { timestamp: this.endDate.toISOString() },
|
||||
// the adapter does not do any more transformations, so it must be called
|
||||
// with the combined current + selected namespace
|
||||
namespace: 'foobar/baz',
|
||||
};
|
||||
this.server.get('sys/internal/counters/activity', (schema, req) => {
|
||||
assert.propEqual(req.queryParams, {
|
||||
start_time: this.startDate.toISOString(),
|
||||
end_time: this.endDate.toISOString(),
|
||||
});
|
||||
assert.strictEqual(req.requestHeaders['X-Vault-Namespace'], 'foobar/baz');
|
||||
});
|
||||
|
||||
this.store.queryRecord(this.modelName, queryParams);
|
||||
});
|
||||
|
||||
module('exportData', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.adapter = this.store.adapterFor('clients/activity');
|
||||
|
|
|
|||
Loading…
Reference in a new issue