UI: Client count error handling (#11852) (#11859)

* move utils to a folder

* separate methods for serializing

* separate test coverage too

* make formatQueryParams a separate util

* use api service to request export data

* consolidate error templates

* move export request to parent route

* replace EmptyState with ApplicationState

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-01-20 19:56:29 -07:00 committed by GitHub
parent 318d6a2843
commit 04d1d4ca76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 686 additions and 598 deletions

View file

@ -6,25 +6,13 @@
import queryParamString from 'vault/utils/query-param-string';
import ApplicationAdapter from '../application';
import { debug } from '@ember/debug';
import { parseJSON, isValid } from 'date-fns';
import { formatQueryParams } from 'core/utils/client-counts/serializers';
export default class ActivityAdapter extends ApplicationAdapter {
formatQueryParams({ start_time, end_time }) {
const query = {};
if (start_time && isValid(parseJSON(start_time))) {
query.start_time = start_time;
}
if (end_time && isValid(parseJSON(end_time))) {
query.end_time = end_time;
}
return query;
}
queryRecord(store, type, query) {
const url = `${this.buildURL()}/internal/counters/activity`;
const options = {
data: this.formatQueryParams(query),
data: formatQueryParams(query),
};
if (query?.namespace) {

View file

@ -1,41 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::ApplicationState class="top-padding-32" ...attributes as |A|>
<A.Header
@icon="skip"
@errorCode={{@error.httpStatus}}
@title={{or @title (if (eq @error.httpStatus 403) "You are not authorized" "Error")}}
data-test-empty-state-title
/>
<A.Body data-test-empty-state-message>
{{#if (eq @error.httpStatus 403)}}
<p>
You must be granted permissions to view this page. Ask your administrator if you think you should have access to the
<code>{{@error.path}}</code>
endpoint.
</p>
{{else}}
<ul>
{{#if @error.message}}
<li>{{@error.message}}</li>
<hr />
{{/if}}
{{#each @error.errors as |error|}}
<li>
{{error}}
</li>
{{/each}}
</ul>
{{/if}}
</A.Body>
<A.Footer data-test-empty-state-actions>
{{#if (has-block "actions")}}
{{yield to="actions"}}
{{/if}}
</A.Footer>
</Hds::ApplicationState>

View file

@ -9,7 +9,7 @@ import { action } from '@ember/object';
import { debounce } from '@ember/runloop';
import { capitalize } from '@ember/string';
import { buildISOTimestamp, parseAPITimestamp } from 'core/utils/date-formatters';
import { ClientFilters } from 'core/utils/client-count-utils';
import { ClientFilters } from 'core/utils/client-counts/helpers';
import type {
ActivityExportData,

View file

@ -3,88 +3,95 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Text::Body class="has-top-margin-l has-bottom-margin-m" @tag="p" @size="100" @color="faint">The client list data below
comes from the
<Hds::Link::Inline
@icon="docs-link"
@iconPosition="trailing"
@isHrefExternal={{true}}
@href={{doc-link "/vault/api-docs/system/internal-counters#activity-export"}}
>
Activity Export API
</Hds::Link::Inline>. It may take up to ten minutes for new client IDs to appear in the export data.
</Hds::Text::Body>
{{#if @exportData}}
<Hds::Text::Body class="has-top-margin-l has-bottom-margin-m" @tag="p" @size="100" @color="faint">The client list data
below comes from the
<Hds::Link::Inline
@icon="docs-link"
@iconPosition="trailing"
@isHrefExternal={{true}}
@href={{doc-link "/vault/api-docs/system/internal-counters#activity-export"}}
>
Activity Export API
</Hds::Link::Inline>. It may take up to ten minutes for new client IDs to appear in the export data.
</Hds::Text::Body>
<Clients::CountsCard data-test-card="Export activity data">
<:subheader>
<Clients::FilterToolbar
@dataset={{@exportData}}
@onFilter={{this.handleFilter}}
@filterQueryParams={{@filterQueryParams}}
@isExportData={{true}}
/>
</:subheader>
<Clients::CountsCard data-test-card="Export activity data">
<:subheader>
<Clients::FilterToolbar
@dataset={{@exportData}}
@onFilter={{this.handleFilter}}
@filterQueryParams={{@filterQueryParams}}
@isExportData={{true}}
/>
</:subheader>
<:table>
<Hds::Tabs @onClickTab={{this.onClickTab}} @selectedTabIndex={{this.selectedTabIndex}} as |T|>
{{#each-in this.exportDataByTab as |tabName exportData|}}
{{#let (this.filterData exportData) as |tableData|}}
<T.Tab @count={{or tableData.length "0"}} data-test-tab={{tabName}}>{{tabName}}</T.Tab>
<T.Panel>
<div class="has-top-margin-xs">
{{#if this.filtersAreApplied}}
<Hds::Text::Body @tag="p" @color="faint" class="has-bottom-margin-xs" data-test-table-summary={{tabName}}>
Summary:
{{pluralize tableData.length "client"}}
{{if (eq tableData.length 1) "matches" "match"}}
the filter criteria.
</Hds::Text::Body>
{{/if}}
<:table>
<Hds::Tabs @onClickTab={{this.onClickTab}} @selectedTabIndex={{this.selectedTabIndex}} as |T|>
{{#each-in this.exportDataByTab as |tabName exportData|}}
{{#let (this.filterData exportData) as |tableData|}}
<T.Tab @count={{or tableData.length "0"}} data-test-tab={{tabName}}>{{tabName}}</T.Tab>
<T.Panel>
<div class="has-top-margin-xs">
{{#if this.filtersAreApplied}}
<Hds::Text::Body @tag="p" @color="faint" class="has-bottom-margin-xs" data-test-table-summary={{tabName}}>
Summary:
{{pluralize tableData.length "client"}}
{{if (eq tableData.length 1) "matches" "match"}}
the filter criteria.
</Hds::Text::Body>
{{/if}}
{{! Elements "behind" tabs always render on the DOM and are just superficially hidden/shown. }}
{{! The export data can be many rows so for performance only render the currently selected tab }}
{{#if (eq tabName this.selectedTab)}}
<Clients::Table
data-test-table="attribution"
@data={{tableData}}
@columns={{this.tableColumns tabName}}
@setPageSize={{50}}
@showPaginationSizeSelector={{true}}
>
{{! Elements "behind" tabs always render on the DOM and are just superficially hidden/shown. }}
{{! The export data can be many rows so for performance only render the currently selected tab }}
{{#if (eq tabName this.selectedTab)}}
<Clients::Table
data-test-table="attribution"
@data={{tableData}}
@columns={{this.tableColumns tabName}}
@setPageSize={{50}}
@showPaginationSizeSelector={{true}}
>
<:emptyState>
{{#if (and (eq tabName "Secret sync") (not this.flags.secretsSyncIsActivated))}}
<Hds::ApplicationState as |A|>
<A.Header @title="No secret sync clients" />
<A.Body @text="No data is available because Secrets Sync has not been activated." />
<A.Body>
<Hds::Link::Standalone
@icon="chevron-right"
@iconPosition="trailing"
@text="Activate Secrets Sync"
@route="vault.cluster.sync.secrets.overview"
<:emptyState>
{{#if (and (eq tabName "Secret sync") (not this.flags.secretsSyncIsActivated))}}
<Hds::ApplicationState as |A|>
<A.Header @title="No secret sync clients" />
<A.Body @text="No data is available because Secrets Sync has not been activated." />
<A.Body>
<Hds::Link::Standalone
@icon="chevron-right"
@iconPosition="trailing"
@text="Activate Secrets Sync"
@route="vault.cluster.sync.secrets.overview"
/>
</A.Body>
</Hds::ApplicationState>
{{else}}
<Hds::ApplicationState as |A|>
<A.Header @title="No data found" />
<A.Body
@text="Select another client type {{if
this.filtersAreApplied
'or update filters'
''
}} to view client count data."
/>
</A.Body>
</Hds::ApplicationState>
{{else}}
<Hds::ApplicationState as |A|>
<A.Header @title="No data found" />
<A.Body
@text="Select another client type {{if
this.filtersAreApplied
'or update filters'
''
}} to view client count data."
/>
</Hds::ApplicationState>
{{/if}}
</:emptyState>
</Clients::Table>
{{/if}}
</div>
</T.Panel>
{{/let}}
{{/each-in}}
</Hds::Tabs>
</:table>
</Clients::CountsCard>
</Hds::ApplicationState>
{{/if}}
</:emptyState>
</Clients::Table>
{{/if}}
</div>
</T.Panel>
{{/let}}
{{/each-in}}
</Hds::Tabs>
</:table>
</Clients::CountsCard>
{{else}}
<Hds::ApplicationState class="top-padding-32" as |A|>
<A.Header data-test-empty-state-title @title="No data found" />
<A.Body data-test-empty-state-message @text="No data to export in provided time range." />
</Hds::ApplicationState>
{{/if}}

View file

@ -7,7 +7,7 @@ import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { HTMLElementEvent } from 'vault/forms';
import { filterIsSupported, filterTableData } from 'core/utils/client-count-utils';
import { filterIsSupported, filterTableData } from 'core/utils/client-counts/helpers';
import { service } from '@ember/service';
import FlagsService from 'vault/services/flags';
@ -44,13 +44,15 @@ export default class ClientsClientListPageComponent extends Component<Args> {
constructor(owner: unknown, args: Args) {
super(owner, args);
this.args.exportData.forEach((data: ActivityExportData) => {
const tabName = CLIENT_TYPE_MAP[data.client_type];
this.exportDataByTab[tabName].push(data);
});
if (this.args.exportData) {
this.args.exportData.forEach((data: ActivityExportData) => {
const tabName = CLIENT_TYPE_MAP[data.client_type];
this.exportDataByTab[tabName].push(data);
});
const firstTab = Object.keys(this.exportDataByTab)[0] as ClientListTabs;
this.selectedTab = firstTab;
const firstTab = Object.keys(this.exportDataByTab)[0] as ClientListTabs;
this.selectedTab = firstTab;
}
}
get selectedTabIndex() {

View file

@ -26,7 +26,10 @@
{{#if (eq @activity.id "no-data")}}
<Clients::NoData @config={{@config}} />
{{else if @activityError}}
<Clients::Counts::Error @error={{@activityError}} />
<Hds::ApplicationState class="top-padding-32" as |A|>
<A.Header data-test-empty-state-title @title={{this.error.title}} @icon="skip" @errorCode={{this.error.httpStatus}} />
<A.Body data-test-empty-state-message @text={{this.error.text}} />
</Hds::ApplicationState>
{{else}}
{{#if (eq @config.enabled "Off")}}
<Hds::Alert @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>

View file

@ -7,7 +7,7 @@ import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { filterVersionHistory } from 'core/utils/client-count-utils';
import { filterVersionHistory } from 'core/utils/client-counts/helpers';
import type AdapterError from '@ember-data/adapter/error';
import type FlagsService from 'vault/services/flags';
@ -30,6 +30,20 @@ export default class ClientsCountsPageComponent extends Component<Args> {
@service declare readonly flags: FlagsService;
@service declare readonly version: VersionService;
get error() {
const { httpStatus, message, path } = this.args.activityError || {};
let title = 'Error',
text = message;
if (httpStatus === 403) {
const endpoint = path ? `the ${path} endpoint` : 'this endpoint';
title = 'You are not authorized';
text = `You must be granted permissions to view this page. Ask your administrator if you think you should have access to ${endpoint}.`;
}
return { title, text, httpStatus };
}
get formattedStartDate() {
return this.args.startTimestamp ? parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy') : null;
}

View file

@ -6,7 +6,7 @@
import Component from '@glimmer/component';
import { cached, tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { filterTableData, flattenMounts } from 'core/utils/client-count-utils';
import { filterTableData, flattenMounts } from 'core/utils/client-counts/helpers';
import { service } from '@ember/service';
import type ClientsActivityModel from 'vault/vault/models/clients/activity';

View file

@ -6,7 +6,7 @@
import Controller from '@ember/controller';
import { action, set } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { ClientFilters } from 'core/utils/client-count-utils';
import { ClientFilters } from 'core/utils/client-counts/helpers';
import type { ClientsCountsRouteParams } from 'vault/routes/vault/cluster/clients/counts';

View file

@ -5,7 +5,10 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { formatExportData, formatQueryParams } from 'core/utils/client-counts/serializers';
import type AdapterError from '@ember-data/adapter/error';
import type ApiService from 'vault/services/api';
import type FlagsService from 'vault/services/flags';
import type NamespaceService from 'vault/services/namespace';
import type Store from '@ember-data/store';
@ -32,6 +35,7 @@ interface ActivityAdapterQuery {
export type ClientsCountsRouteModel = ModelFrom<ClientsCountsRoute>;
export default class ClientsCountsRoute extends Route {
@service declare readonly api: ApiService;
@service declare readonly flags: FlagsService;
@service declare readonly namespace: NamespaceService;
@service declare readonly store: Store;
@ -80,25 +84,37 @@ export default class ClientsCountsRoute extends Route {
// The "Client List" tab is only available on enterprise versions
// For now, it is also hidden on HVD managed clusters
if (this.version.isEnterprise && !this.flags.isHvdManaged) {
const adapter = this.store.adapterFor('clients/activity');
let exportData, exportError;
const { start_time, end_time } = formatQueryParams({
start_time: startTimestamp,
end_time: endTimestamp,
});
let exportData, cannotRequestExport;
try {
const resp = await adapter.exportData({
// the API only accepts json or csv
format: 'json',
start_time: startTimestamp,
end_time: endTimestamp,
const { raw } = await this.api.sys.internalClientActivityExportRaw({
end_time,
format: 'json', // the API only accepts json or csv
start_time,
});
const jsonLines = await resp.text();
const lines = jsonLines.trim().split('\n');
exportData = lines.map((line: string) => JSON.parse(line));
} catch (error) {
// Ideally we would not handle errors manually but this is the pattern the other client.counts
// route follow since the sys/internal/counters API doesn't always return helpful error messages.
// When these routes are migrated away from ember data we should revisit the error handling.
exportError = error as AdapterError;
// If it's not a 200 but didn't throw an error then it's likely a 204 (empty response).
exportData = raw.status === 200 ? await formatExportData(raw, { isDownload: false }) : null;
} catch (e) {
const { status, path, response } = await this.api.parseError(e);
// Show a custom error message when the user does not have permission
if (status === 403) {
cannotRequestExport = true;
} else {
// re-throw if not a permissions error
throw {
httpStatus: status,
path,
message: response?.message,
errors: response?.errors || [],
error: response?.error,
};
}
}
return { exportData, exportError };
return { exportData, cannotRequestExport };
}
return { exportData: null, exportError: null };
}
@ -106,16 +122,16 @@ export default class ClientsCountsRoute extends Route {
async model(params: ClientsCountsRouteParams) {
const { config, versionHistory } = this.modelFor('vault.cluster.clients') as ModelFrom<ClientsRoute>;
const { activity, activityError } = await this.getActivity(params);
const { exportData, exportError } = await this.fetchAndFormatExportData(
const { exportData, cannotRequestExport } = await this.fetchAndFormatExportData(
activity?.startTime,
activity?.endTime
);
return {
activity,
activityError,
cannotRequestExport,
config,
exportData,
exportError,
// We always want to return the start and end time from the activity response
// so they serve as the source of truth for the time period of the displayed client count data
startTimestamp: activity?.startTime,

View file

@ -5,7 +5,11 @@
import ApplicationSerializer from '../application';
import { formatISO } from 'date-fns';
import { formatByMonths, formatByNamespace, destructureClientCounts } from 'core/utils/client-count-utils';
import {
destructureClientCounts,
formatByMonths,
formatByNamespace,
} from 'core/utils/client-counts/serializers';
import timestamp from 'core/utils/timestamp';
// see tests/helpers/clients/client-count-helpers for sample API response (ACTIVITY_RESPONSE_STUB)

View file

@ -3,28 +3,27 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{#if this.model.exportError}}
<Clients::Counts::Error
@title={{if (eq this.model.exportError.message "No data to export in provided time range.") "No data found"}}
@error={{this.model.exportError}}
>
<:actions>
{{#if (eq this.model.exportError.httpStatus 403)}}
<Hds::Text::Body @tag="p" @color="faint" class="has-bottom-margin-s">
Viewing export data requires
<Hds::Text::Code class="code-in-text">sudo</Hds::Text::Code>
permissions.
<Hds::Link::Standalone
@icon="docs-link"
@iconPosition="trailing"
@text="Client Export Documentation"
@isHrefExternal={{true}}
@href={{doc-link "/vault/api-docs/system/internal-counters#activity-export"}}
/>
</Hds::Text::Body>
{{/if}}
</:actions>
</Clients::Counts::Error>
{{#if this.model.cannotRequestExport}}
<Hds::ApplicationState class="has-top-margin-xxl" as |A|>
<A.Header @icon="skip" @title="You are not authorized" data-test-empty-state-title />
<A.Body data-test-empty-state-message>
Viewing export data requires
<Hds::Text::Code class="code-in-text">sudo</Hds::Text::Code>
permissions to
<Hds::Text::Code class="code-in-text">/sys/internal/counters/activity/export</Hds::Text::Code>.
</A.Body>
<A.Footer data-test-empty-state-actions>
<Hds::Link::Standalone
@icon="docs-link"
@iconPosition="trailing"
@text="Client Export Documentation"
@isHrefExternal={{true}}
@href={{doc-link "/vault/api-docs/system/internal-counters#activity-export"}}
/>
</A.Footer>
</Hds::ApplicationState>
{{else}}
<Clients::Page::ClientList
@exportData={{this.model.exportData}}

View file

@ -3,39 +3,4 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (eq @model.httpStatus 404)}}
<NotFound @model={{this.model}} />
{{else}}
<div class="is-flex is-flex-grow-1">
<Hds::ApplicationState class="align-self-center" as |A|>
<A.Header
data-test-empty-state-title
@title={{if (eq @model.httpStatus 403) "You are not authorized" "Error"}}
@icon="skip"
@errorCode={{@model.httpStatus}}
/>
<A.Body data-test-empty-state-message>
{{#if (eq @model.httpStatus 403)}}
<p>
You must be granted permissions to view this page. Ask your administrator if you think you should have access to
the
<code>{{@model.path}}</code>
endpoint.
</p>
{{else}}
<ul>
{{#if @model.message}}
<li>{{@model.message}}</li>
<hr />
{{/if}}
{{#each @model.errors as |error|}}
<li>
{{error}}
</li>
{{/each}}
</ul>
{{/if}}
</A.Body>
</Hds::ApplicationState>
</div>
{{/if}}
<Page::Error @error={{this.model}} />

View file

@ -4,32 +4,19 @@
*/
import { isSameMonthUTC, parseAPITimestamp } from 'core/utils/date-formatters';
import { compareAsc, isWithinInterval } from 'date-fns';
import { isWithinInterval } from 'date-fns';
import { ROOT_NAMESPACE } from 'vault/services/namespace';
import { sanitizePath } from './sanitize-path';
import type ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history';
import type {
ActivityExportData,
ActivityMonthBlock,
ActivityMonthEmpty,
ActivityMonthStandard,
ByMonthNewClients,
ByNamespaceClients,
ClientFilterTypes,
ClientTypes,
Counts,
MountClients,
MountNewClients,
NamespaceNewClients,
NamespaceObject,
} from 'vault/vault/client-counts/activity-api';
/*
The client count utils are responsible for serializing the sys/internal/counters/activity API response
The initial API response shape and serialized types are defined below.
To help visualize there are sample responses in ui/tests/helpers/clients.js
The client count utils define consts and methods, such as filtering, related to client count data.
*/
// Add new sys/activity/counters client count types here
@ -93,85 +80,6 @@ export const filterVersionHistory = (
return [];
};
// METHODS FOR SERIALIZING ACTIVITY RESPONSE
export const formatByMonths = (monthsArray: ActivityMonthBlock[]): ByMonthNewClients[] => {
const sortedPayload = sortMonthsByTimestamp(monthsArray);
return sortedPayload?.map((m) => {
const { timestamp } = m;
if (monthIsEmpty(m)) {
// empty month
return {
timestamp,
namespaces: [],
new_clients: { timestamp, namespaces: [] },
};
}
let newClients: ByMonthNewClients = { timestamp, namespaces: [] };
if (monthWithAllCounts(m)) {
newClients = {
timestamp,
...destructureClientCounts(m?.new_clients.counts),
namespaces: formatByNamespace(m.new_clients.namespaces),
};
}
return {
timestamp,
...destructureClientCounts(m.counts),
namespaces: formatByNamespace(m.namespaces),
new_clients: newClients,
};
});
};
export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByNamespaceClients[] => {
if (!Array.isArray(namespaceArray)) return [];
return namespaceArray.map((ns) => {
// i.e. 'namespace_path' is an empty string for 'root', so use namespace_id
const nsLabel = ns.namespace_path === '' ? ns.namespace_id : ns.namespace_path;
// data prior to adding mount granularity will still have a mounts array,
// but the mount_path value will be "no mount accessor (pre-1.10 upgrade?)" (ref: vault/activity_log_util_common.go)
// transform to an empty array for type consistency
let mounts: MountClients[] | [] = [];
if (Array.isArray(ns.mounts)) {
mounts = ns.mounts.map((m) => ({
label: m.mount_path,
namespace_path: nsLabel,
mount_path: m.mount_path,
// sanitized so it matches activity export data because mount_type there does NOT have a trailing slash
mount_type: sanitizePath(m.mount_type),
...destructureClientCounts(m.counts),
}));
}
return {
label: nsLabel,
...destructureClientCounts(ns.counts),
mounts,
};
});
};
// This method returns only client types from the passed object, excluding other keys such as "label".
// when querying historical data the response will always contain the latest client type keys because the activity log is
// constructed based on the version of Vault the user is on (key values will be 0)
export const destructureClientCounts = (verboseObject: Counts | ByNamespaceClients) => {
return CLIENT_TYPES.reduce(
(newObj: Record<ClientTypes, Counts[ClientTypes]>, clientType: ClientTypes) => {
newObj[clientType] = verboseObject[clientType];
return newObj;
},
{} as Record<ClientTypes, Counts[ClientTypes]>
);
};
export const sortMonthsByTimestamp = (monthsArray: ActivityMonthBlock[]) => {
const sortedPayload = [...monthsArray];
return sortedPayload.sort((a, b) =>
compareAsc(parseAPITimestamp(a.timestamp) as Date, parseAPITimestamp(b.timestamp) as Date)
);
};
// *Performance note*
// The client dashboard renders dropdown lists that specify filters. When the user selects a dropdown item (filter)
// it updates the query param and this method is called to filter the data passed to the displayed table.
@ -224,27 +132,5 @@ const matchesFilter = (
export const flattenMounts = (namespaceArray: ByNamespaceClients[]) =>
namespaceArray.map((n) => n.mounts).flat();
// TYPE GUARDS FOR CONDITIONALS
function monthIsEmpty(month: ActivityMonthBlock): month is ActivityMonthEmpty {
return !month || month?.counts === null;
}
function monthWithAllCounts(month: ActivityMonthBlock): month is ActivityMonthStandard {
return month?.counts !== null && month?.new_clients?.counts !== null;
}
export function filterIsSupported(f: string): f is ClientFilterTypes {
return Object.values(ClientFilters).includes(f as ClientFilterTypes);
}
export function hasMountsKey(
obj: ByMonthNewClients | NamespaceNewClients | MountNewClients
): obj is NamespaceNewClients {
return 'mounts' in obj;
}
export function hasNamespacesKey(
obj: ByMonthNewClients | NamespaceNewClients | MountNewClients
): obj is ByMonthNewClients {
return 'namespaces' in obj;
}
export const filterIsSupported = (f: string): f is ClientFilterTypes =>
Object.values(ClientFilters).includes(f as ClientFilterTypes);

View file

@ -0,0 +1,138 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { compareAsc, isValid, parseJSON } from 'date-fns';
import { sanitizePath } from '../sanitize-path';
import type {
ActivityMonthBlock,
ActivityMonthEmpty,
ActivityMonthStandard,
ByMonthNewClients,
ByNamespaceClients,
ClientTypes,
Counts,
MountClients,
NamespaceObject,
} from 'vault/vault/client-counts/activity-api';
import { CLIENT_TYPES } from './helpers';
/*
These client count utils are responsible for serializing the sys/internal/counters/activity API response.
To help visualize there are sample responses in ui/tests/helpers/clients.js
*/
// This method returns only client types from the passed object, excluding other keys such as "label".
// when querying historical data the response will always contain the latest client type keys because the activity log is
// constructed based on the version of Vault the user is on (key values will be 0)
export const destructureClientCounts = (verboseObject: Counts | ByNamespaceClients) => {
return CLIENT_TYPES.reduce(
(newObj: Record<ClientTypes, Counts[ClientTypes]>, clientType: ClientTypes) => {
newObj[clientType] = verboseObject[clientType];
return newObj;
},
{} as Record<ClientTypes, Counts[ClientTypes]>
);
};
export const formatByMonths = (monthsArray: ActivityMonthBlock[]): ByMonthNewClients[] => {
const sortedPayload = sortMonthsByTimestamp(monthsArray);
return sortedPayload?.map((m) => {
const { timestamp } = m;
if (monthIsEmpty(m)) {
// empty month
return {
timestamp,
namespaces: [],
new_clients: { timestamp, namespaces: [] },
};
}
let newClients: ByMonthNewClients = { timestamp, namespaces: [] };
if (monthWithAllCounts(m)) {
newClients = {
timestamp,
...destructureClientCounts(m?.new_clients.counts),
namespaces: formatByNamespace(m.new_clients.namespaces),
};
}
return {
timestamp,
...destructureClientCounts(m.counts),
namespaces: formatByNamespace(m.namespaces),
new_clients: newClients,
};
});
};
export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByNamespaceClients[] => {
if (!Array.isArray(namespaceArray)) return [];
return namespaceArray.map((ns) => {
// i.e. 'namespace_path' is an empty string for 'root', so use namespace_id
const nsLabel = ns.namespace_path === '' ? ns.namespace_id : ns.namespace_path;
// data prior to adding mount granularity will still have a mounts array,
// but the mount_path value will be "no mount accessor (pre-1.10 upgrade?)" (ref: vault/activity_log_util_common.go)
// transform to an empty array for type consistency
let mounts: MountClients[] | [] = [];
if (Array.isArray(ns.mounts)) {
mounts = ns.mounts.map((m) => ({
label: m.mount_path,
namespace_path: nsLabel,
mount_path: m.mount_path,
// sanitized so it matches activity export data because mount_type there does NOT have a trailing slash
mount_type: sanitizePath(m.mount_type),
...destructureClientCounts(m.counts),
}));
}
return {
label: nsLabel,
...destructureClientCounts(ns.counts),
mounts,
};
});
};
export const formatExportData = async (resp: Response, { isDownload = false }) => {
// The response from the export API is a ReadableStream
const blob = await resp.blob();
// If the user wants to download the export data just return the blob.
if (isDownload) return blob;
// Otherwise format to JSON to render dataset in a table.
const jsonLines = await blob.text();
const lines = jsonLines.trim().split('\n');
return lines.map((line: string) => JSON.parse(line));
};
export const formatQueryParams = (query: { start_time?: string; end_time?: string } = {}) => {
const { start_time, end_time } = query;
const formattedQuery: Partial<Record<'start_time' | 'end_time', string>> = {};
if (start_time && isValid(parseJSON(start_time))) {
formattedQuery.start_time = start_time;
}
if (end_time && isValid(parseJSON(end_time))) {
formattedQuery.end_time = end_time;
}
return formattedQuery;
};
export const sortMonthsByTimestamp = (monthsArray: ActivityMonthBlock[]) => {
const sortedPayload = [...monthsArray];
return sortedPayload.sort((a, b) =>
compareAsc(parseAPITimestamp(a.timestamp) as Date, parseAPITimestamp(b.timestamp) as Date)
);
};
// TYPE GUARDS FOR CONDITIONALS
function monthIsEmpty(month: ActivityMonthBlock): month is ActivityMonthEmpty {
return !month || month?.counts === null;
}
function monthWithAllCounts(month: ActivityMonthBlock): month is ActivityMonthStandard {
return month?.counts !== null && month?.new_clients?.counts !== null;
}

View file

@ -15,7 +15,7 @@ import {
subMonths,
} from 'date-fns';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { CLIENT_TYPES } from 'core/utils/client-count-utils';
import { CLIENT_TYPES } from 'core/utils/client-counts/helpers';
/*
HOW TO ADD NEW TYPES:

View file

@ -10,11 +10,12 @@ import { visit, click, currentURL } from '@ember/test-helpers';
import sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { ClientFilters } from 'core/utils/client-count-utils';
import { ClientFilters } from 'core/utils/client-counts/helpers';
import { CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import { ACTIVITY_EXPORT_STUB } from 'vault/tests/helpers/clients/client-count-helpers';
import timestamp from 'core/utils/timestamp';
import clientsHandler, { STATIC_NOW } from 'vault/mirage/handlers/clients';
import { getErrorResponse } from 'vault/tests/helpers/api/error-response';
// integration test handle general display assertions, acceptance handles nav + filtering
module('Acceptance | clients | counts | client list', function (hooks) {
@ -32,16 +33,17 @@ module('Acceptance | clients | counts | client list', function (hooks) {
//* End CE setup
// The activity export endpoint returns a ReadableStream of json lines, this is not easily mocked using mirage.
// Stubbing the adapter method return instead.
// Stubbing the api service method instead.
const mockResponse = {
status: 200,
ok: true,
text: () => Promise.resolve(ACTIVITY_EXPORT_STUB.trim()),
raw: new Response(ACTIVITY_EXPORT_STUB, {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
};
const store = this.owner.lookup('service:store');
const adapter = store.adapterFor('clients/activity');
this.exportDataStub = sinon.stub(adapter, 'exportData');
const api = this.owner.lookup('service:api');
this.exportDataStub = sinon.stub(api.sys, 'internalClientActivityExportRaw');
this.exportDataStub.resolves(mockResponse);
await login();
return visit('/vault');
});
@ -126,7 +128,10 @@ module('Acceptance | clients | counts | client list', function (hooks) {
});
test('it renders error message if export has no data', async function (assert) {
this.exportDataStub.throws(new Error('No data to export in provided time range.'));
const emptyResponse = {
raw: new Response({}, { status: 204, headers: { 'Content-Type': 'application/json' } }),
};
this.exportDataStub.resolves(emptyResponse);
await visit('/vault/clients/counts/client-list');
await click(CLIENT_COUNT.dateRange.edit);
await click(CLIENT_COUNT.dateRange.dropdownOption(4));
@ -138,14 +143,22 @@ module('Acceptance | clients | counts | client list', function (hooks) {
});
test('it renders error message for permission denied', async function (assert) {
this.exportDataStub.throws(new Error('permission denied'));
const error = { errors: ['1 error occurred:\n\t* permission denied\n\n'] };
this.exportDataStub.rejects(getErrorResponse(error, 403));
await visit('/vault/clients/counts/client-list');
await click(CLIENT_COUNT.dateRange.edit);
await click(CLIENT_COUNT.dateRange.dropdownOption(4));
assert.dom(GENERAL.emptyStateTitle).hasText('Error');
assert.dom(GENERAL.emptyStateMessage).hasText('permission denied');
// Assert the empty state message renders below the page header so user can query other dates
assert.dom(GENERAL.tab('overview')).exists('Overview tab still renders');
assert.dom(GENERAL.tab('client list')).exists('Client list tab still renders');
assert.dom(GENERAL.emptyStateTitle).hasText('You are not authorized');
assert
.dom(GENERAL.emptyStateMessage)
.hasText('Viewing export data requires sudo permissions to /sys/internal/counters/activity/export.');
assert.dom(GENERAL.emptyStateActions).hasText('Client Export Documentation');
});
// since permissions errors are specially handled, test that a non 403 is handled correctly
test('it renders error message for a server error', async function (assert) {
const error = { errors: ['uh oh'] };
this.exportDataStub.rejects(getErrorResponse(error, 500));
await visit('/vault/clients/counts/client-list');
assert.dom(GENERAL.pageError.errorTitle(500)).hasText('Error');
assert.dom(GENERAL.pageError.errorDetails).hasText('uh oh');
});
});

View file

@ -23,7 +23,7 @@ import {
ACTIVITY_EXPORT_STUB,
ACTIVITY_RESPONSE_STUB,
} from 'vault/tests/helpers/clients/client-count-helpers';
import { ClientFilters, flattenMounts } from 'core/utils/client-count-utils';
import { ClientFilters, flattenMounts } from 'core/utils/client-counts/helpers';
import { parseAPITimestamp } from 'core/utils/date-formatters';
module('Acceptance | clients | overview', function (hooks) {

View file

@ -10,7 +10,7 @@ import { hbs } from 'ember-cli-htmlbars';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import { FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import { ClientFilters } from 'core/utils/client-count-utils';
import { ClientFilters } from 'core/utils/client-counts/helpers';
import { parseAPITimestamp } from 'core/utils/date-formatters';
module('Integration | Component | clients/filter-toolbar', function (hooks) {

View file

@ -11,7 +11,7 @@ import { ACTIVITY_EXPORT_STUB } from 'vault/tests/helpers/clients/client-count-h
import { CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import { ClientFilters } from 'core/utils/client-count-utils';
import { ClientFilters } from 'core/utils/client-counts/helpers';
const EXPORT_TAB_TO_TYPE = {
Entity: 'entity',

View file

@ -12,7 +12,7 @@ import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count
import { CHARTS, CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import { ClientFilters, flattenMounts } from 'core/utils/client-count-utils';
import { ClientFilters, flattenMounts } from 'core/utils/client-counts/helpers';
import { parseAPITimestamp } from 'core/utils/date-formatters';
module('Integration | Component | clients/page/overview', function (hooks) {

View file

@ -6,30 +6,14 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import {
filterVersionHistory,
formatByMonths,
formatByNamespace,
destructureClientCounts,
sortMonthsByTimestamp,
flattenMounts,
filterTableData,
} from 'core/utils/client-count-utils';
import { filterVersionHistory, flattenMounts, filterTableData } from 'core/utils/client-counts/helpers';
import clientsHandler from 'vault/mirage/handlers/clients';
import {
ACTIVITY_RESPONSE_STUB as RESPONSE,
MIXED_ACTIVITY_RESPONSE_STUB as MIXED_RESPONSE,
SERIALIZED_ACTIVITY_RESPONSE,
ENTITY_EXPORT,
} from 'vault/tests/helpers/clients/client-count-helpers';
/*
formatByNamespace, formatByMonths, destructureClientCounts are utils
used to normalize the sys/counters/activity response in the clients/activity
serializer. these functions are tested individually here, instead of all at once
in a serializer test for easier debugging
*/
module('Integration | Util | client count utils', function (hooks) {
module('Unit | Util | client counts | helpers', function (hooks) {
setupTest(hooks);
module('filterVersionHistory', function (hooks) {
@ -143,221 +127,6 @@ module('Integration | Util | client count utils', function (hooks) {
);
});
test('formatByMonths: it formats the months array', async function (assert) {
assert.expect(7);
const original = [...RESPONSE.months];
const [formattedNoData, formattedWithActivity, formattedNoNew] = formatByMonths(RESPONSE.months);
// instead of asserting the whole expected response, broken up so tests are easier to debug
// but kept whole above to copy/paste updated response expectations in the future
const [expectedNoData, expectedWithActivity, expectedNoNew] = SERIALIZED_ACTIVITY_RESPONSE.by_month;
assert.propEqual(formattedNoData, expectedNoData, 'it formats months without data');
['namespaces', 'new_clients'].forEach((key) => {
assert.propEqual(
formattedWithActivity[key],
expectedWithActivity[key],
`it formats ${key} array for months with data`
);
assert.propEqual(
formattedNoNew[key],
expectedNoNew[key],
`it formats the ${key} array for months with no new clients`
);
});
assert.propEqual(RESPONSE.months, original, 'it does not modify original months array');
assert.propEqual(formatByMonths([]), [], 'it returns an empty array if the months key is empty');
});
test('formatByNamespace: it formats namespace array with mounts', async function (assert) {
const original = [...RESPONSE.by_namespace];
const expectedNs1 = SERIALIZED_ACTIVITY_RESPONSE.by_namespace.find((ns) => ns.label === 'ns1/');
const formattedNs1 = formatByNamespace(RESPONSE.by_namespace).find((ns) => ns.label === 'ns1/');
assert.expect(2 + formattedNs1.mounts.length);
assert.propEqual(formattedNs1, expectedNs1, 'it formats ns1/ namespace');
assert.propEqual(RESPONSE.by_namespace, original, 'it does not modify original by_namespace array');
formattedNs1.mounts.forEach((mount) => {
const expectedMount = expectedNs1.mounts.find((m) => m.label === mount.label);
assert.propEqual(mount, expectedMount, `${mount.label} has expected key/value pairs`);
});
});
test('destructureClientCounts: it returns relevant key names when both old and new keys exist', async function (assert) {
assert.expect(2);
const original = { ...RESPONSE.total };
const expected = {
acme_clients: 9702,
clients: 35287,
entity_clients: 8258,
non_entity_clients: 8227,
secret_syncs: 9100,
};
assert.propEqual(destructureClientCounts(RESPONSE.total), expected);
assert.propEqual(RESPONSE.total, original, 'it does not modify original object');
});
test('sortMonthsByTimestamp: sorts timestamps chronologically, oldest to most recent', async function (assert) {
assert.expect(2);
// API returns them in order so this test is extra extra
const unOrdered = [RESPONSE.months[1], RESPONSE.months[0], RESPONSE.months[3], RESPONSE.months[2]]; // mixup order
const original = [...RESPONSE.months];
const expected = RESPONSE.months;
assert.propEqual(sortMonthsByTimestamp(unOrdered), expected);
assert.propEqual(RESPONSE.months, original, 'it does not modify original array');
});
// 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);
const noMounts = [
{
namespace_id: 'root',
namespace_path: '',
counts: {
entity_clients: 10,
non_entity_clients: 20,
secret_syncs: 0,
acme_clients: 0,
clients: 30,
},
mounts: [
{
counts: {
entity_clients: 10,
non_entity_clients: 20,
secret_syncs: 0,
acme_clients: 0,
clients: 30,
},
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
mount_type: '',
},
],
},
];
const expected = [
{
acme_clients: 0,
clients: 30,
entity_clients: 10,
label: 'root',
mounts: [
{
acme_clients: 0,
clients: 30,
entity_clients: 10,
label: 'no mount accessor (pre-1.10 upgrade?)',
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
mount_type: '',
namespace_path: 'root',
non_entity_clients: 20,
secret_syncs: 0,
},
],
non_entity_clients: 20,
secret_syncs: 0,
},
];
assert.propEqual(formatByNamespace(noMounts), expected, 'it formats namespace without mounts');
assert.propEqual(formatByNamespace([]), [], 'it returns an empty array if the by_namespace key is empty');
});
test('it formats the months array with mixed activity data', async function (assert) {
assert.expect(2);
const [, formattedWithActivity] = formatByMonths(MIXED_RESPONSE.months);
// mirage isn't set up to generate mixed data, so hardcoding the expected responses here
assert.propEqual(
formattedWithActivity.namespaces,
[
{
acme_clients: 0,
clients: 3,
entity_clients: 3,
label: 'root',
mounts: [
{
acme_clients: 0,
clients: 2,
entity_clients: 2,
label: 'no mount accessor (pre-1.10 upgrade?)',
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
mount_type: 'no mount path (pre-1.10 upgrade?)',
namespace_path: 'root',
non_entity_clients: 0,
secret_syncs: 0,
},
{
acme_clients: 0,
clients: 1,
entity_clients: 1,
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'root',
non_entity_clients: 0,
secret_syncs: 0,
},
],
non_entity_clients: 0,
secret_syncs: 0,
},
],
'it formats combined data for monthly namespaces spanning upgrade to 1.10'
);
assert.propEqual(
formattedWithActivity.new_clients,
{
acme_clients: 0,
clients: 3,
entity_clients: 3,
namespaces: [
{
acme_clients: 0,
clients: 3,
entity_clients: 3,
label: 'root',
mounts: [
{
acme_clients: 0,
clients: 2,
entity_clients: 2,
label: 'no mount accessor (pre-1.10 upgrade?)',
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
mount_type: 'no mount path (pre-1.10 upgrade?)',
namespace_path: 'root',
non_entity_clients: 0,
secret_syncs: 0,
},
{
acme_clients: 0,
clients: 1,
entity_clients: 1,
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'root',
non_entity_clients: 0,
secret_syncs: 0,
},
],
non_entity_clients: 0,
secret_syncs: 0,
},
],
non_entity_clients: 0,
secret_syncs: 0,
timestamp: '2024-04-01T00:00:00Z',
},
'it formats combined data for monthly new_clients spanning upgrade to 1.10'
);
});
module('filterTableData', function (hooks) {
hooks.beforeEach(async function () {
const activityByMount = flattenMounts(SERIALIZED_ACTIVITY_RESPONSE.by_namespace);

View file

@ -0,0 +1,325 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import {
destructureClientCounts,
formatByMonths,
formatByNamespace,
formatQueryParams,
sortMonthsByTimestamp,
} from 'core/utils/client-counts/serializers';
import {
ACTIVITY_RESPONSE_STUB as RESPONSE,
MIXED_ACTIVITY_RESPONSE_STUB as MIXED_RESPONSE,
SERIALIZED_ACTIVITY_RESPONSE,
} from 'vault/tests/helpers/clients/client-count-helpers';
module('Unit | Util | client counts | serializers', function (hooks) {
setupTest(hooks);
test('destructureClientCounts: it returns relevant key names when both old and new keys exist', async function (assert) {
assert.expect(2);
const original = { ...RESPONSE.total };
const expected = {
acme_clients: 9702,
clients: 35287,
entity_clients: 8258,
non_entity_clients: 8227,
secret_syncs: 9100,
};
assert.propEqual(destructureClientCounts(RESPONSE.total), expected);
assert.propEqual(RESPONSE.total, original, 'it does not modify original object');
});
test('formatByMonths: it formats the months array', async function (assert) {
assert.expect(7);
const original = [...RESPONSE.months];
const [formattedNoData, formattedWithActivity, formattedNoNew] = formatByMonths(RESPONSE.months);
// instead of asserting the whole expected response, broken up so tests are easier to debug
// but kept whole above to copy/paste updated response expectations in the future
const [expectedNoData, expectedWithActivity, expectedNoNew] = SERIALIZED_ACTIVITY_RESPONSE.by_month;
assert.propEqual(formattedNoData, expectedNoData, 'it formats months without data');
['namespaces', 'new_clients'].forEach((key) => {
assert.propEqual(
formattedWithActivity[key],
expectedWithActivity[key],
`it formats ${key} array for months with data`
);
assert.propEqual(
formattedNoNew[key],
expectedNoNew[key],
`it formats the ${key} array for months with no new clients`
);
});
assert.propEqual(RESPONSE.months, original, 'it does not modify original months array');
assert.propEqual(formatByMonths([]), [], 'it returns an empty array if the months key is empty');
});
test('formatByNamespace: it formats namespace array with mounts', async function (assert) {
const original = [...RESPONSE.by_namespace];
const expectedNs1 = SERIALIZED_ACTIVITY_RESPONSE.by_namespace.find((ns) => ns.label === 'ns1/');
const formattedNs1 = formatByNamespace(RESPONSE.by_namespace).find((ns) => ns.label === 'ns1/');
assert.expect(2 + formattedNs1.mounts.length);
assert.propEqual(formattedNs1, expectedNs1, 'it formats ns1/ namespace');
assert.propEqual(RESPONSE.by_namespace, original, 'it does not modify original by_namespace array');
formattedNs1.mounts.forEach((mount) => {
const expectedMount = expectedNs1.mounts.find((m) => m.label === mount.label);
assert.propEqual(mount, expectedMount, `${mount.label} has expected key/value pairs`);
});
});
module('formatQueryParams', function (hooks) {
hooks.beforeEach(function () {
this.assertQuery = ({ query, expected }, assert) => {
const result = formatQueryParams(query);
assert.propEqual(
result,
expected,
`returned params: ${JSON.stringify(result)} matches expected: ${JSON.stringify(expected)}`
);
assert.strictEqual(result?.start_time, expected?.start_time, 'query has expected start_time');
assert.strictEqual(result?.end_time, expected?.end_time, 'query has expected end_time');
};
});
test('formatQueryParams: it returns formatted query params with valid ISO date strings', function (assert) {
const query = { start_time: '2023-01-01T00:00:00.000Z', end_time: '2023-12-31T23:59:59.999Z' };
this.assertQuery({ query, expected: query }, assert);
});
test('it returns undefined for invalid date strings', function (assert) {
const query = { start_time: 'invalid-date', end_time: 'not-a-date' };
const expected = {};
this.assertQuery({ query, expected }, assert);
});
test('it handles mixed valid and invalid dates', function (assert) {
const query = { start_time: '2023-01-01T00:00:00.000Z', end_time: 'invalid-date' };
const expected = { start_time: '2023-01-01T00:00:00.000Z' };
this.assertQuery({ query, expected }, assert);
});
test('it handles empty strings', function (assert) {
let query = { start_time: '', end_time: '' };
let expected = {};
this.assertQuery({ query, expected }, assert);
query = { start_time: '2023-01-01T00:00:00.000Z', end_time: '' };
expected = { start_time: '2023-01-01T00:00:00.000Z' };
this.assertQuery({ query, expected }, assert);
query = { start_time: '', end_time: '2023-12-31T23:59:59.999Z' };
expected = { end_time: '2023-12-31T23:59:59.999Z' };
this.assertQuery({ query, expected }, assert);
});
test('it handles undefined values', function (assert) {
let query = { start_time: undefined, end_time: undefined };
let expected = {};
this.assertQuery({ query, expected }, assert);
query = { start_time: '2023-01-01T00:00:00.000Z', end_time: undefined };
expected = { start_time: '2023-01-01T00:00:00.000Z' };
this.assertQuery({ query, expected }, assert);
query = { start_time: undefined, end_time: '2023-12-31T23:59:59.999Z' };
expected = { end_time: '2023-12-31T23:59:59.999Z' };
this.assertQuery({ query, expected }, assert);
});
test('it handles null values', function (assert) {
let query = { start_time: null, end_time: null };
let expected = {};
this.assertQuery({ query, expected }, assert);
query = { start_time: '2023-01-01T00:00:00.000Z', end_time: null };
expected = { start_time: '2023-01-01T00:00:00.000Z' };
this.assertQuery({ query, expected }, assert);
query = { start_time: null, end_time: '2023-12-31T23:59:59.999Z' };
expected = { end_time: '2023-12-31T23:59:59.999Z' };
this.assertQuery({ query, expected }, assert);
});
test('it handles missing properties', function (assert) {
const query = {};
const expected = {};
this.assertQuery({ query, expected }, assert);
});
test('it does not accept date strings that are not ISO formatted', function (assert) {
const query = { start_time: '2023-06-15', end_time: '2023-06-16T14:30:00Z' };
const expected = { end_time: '2023-06-16T14:30:00Z' };
this.assertQuery({ query, expected }, assert);
});
});
test('sortMonthsByTimestamp: sorts timestamps chronologically, oldest to most recent', async function (assert) {
assert.expect(2);
// API returns them in order so this test is extra extra
const unOrdered = [RESPONSE.months[1], RESPONSE.months[0], RESPONSE.months[3], RESPONSE.months[2]]; // mixup order
const original = [...RESPONSE.months];
const expected = RESPONSE.months;
assert.propEqual(sortMonthsByTimestamp(unOrdered), expected);
assert.propEqual(RESPONSE.months, original, 'it does not modify original array');
});
// 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);
const noMounts = [
{
namespace_id: 'root',
namespace_path: '',
counts: {
entity_clients: 10,
non_entity_clients: 20,
secret_syncs: 0,
acme_clients: 0,
clients: 30,
},
mounts: [
{
counts: {
entity_clients: 10,
non_entity_clients: 20,
secret_syncs: 0,
acme_clients: 0,
clients: 30,
},
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
mount_type: '',
},
],
},
];
const expected = [
{
acme_clients: 0,
clients: 30,
entity_clients: 10,
label: 'root',
mounts: [
{
acme_clients: 0,
clients: 30,
entity_clients: 10,
label: 'no mount accessor (pre-1.10 upgrade?)',
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
mount_type: '',
namespace_path: 'root',
non_entity_clients: 20,
secret_syncs: 0,
},
],
non_entity_clients: 20,
secret_syncs: 0,
},
];
assert.propEqual(formatByNamespace(noMounts), expected, 'it formats namespace without mounts');
assert.propEqual(formatByNamespace([]), [], 'it returns an empty array if the by_namespace key is empty');
});
test('it formats the months array with mixed activity data', async function (assert) {
assert.expect(2);
const [, formattedWithActivity] = formatByMonths(MIXED_RESPONSE.months);
// mirage isn't set up to generate mixed data, so hardcoding the expected responses here
assert.propEqual(
formattedWithActivity.namespaces,
[
{
acme_clients: 0,
clients: 3,
entity_clients: 3,
label: 'root',
mounts: [
{
acme_clients: 0,
clients: 2,
entity_clients: 2,
label: 'no mount accessor (pre-1.10 upgrade?)',
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
mount_type: 'no mount path (pre-1.10 upgrade?)',
namespace_path: 'root',
non_entity_clients: 0,
secret_syncs: 0,
},
{
acme_clients: 0,
clients: 1,
entity_clients: 1,
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'root',
non_entity_clients: 0,
secret_syncs: 0,
},
],
non_entity_clients: 0,
secret_syncs: 0,
},
],
'it formats combined data for monthly namespaces spanning upgrade to 1.10'
);
assert.propEqual(
formattedWithActivity.new_clients,
{
acme_clients: 0,
clients: 3,
entity_clients: 3,
namespaces: [
{
acme_clients: 0,
clients: 3,
entity_clients: 3,
label: 'root',
mounts: [
{
acme_clients: 0,
clients: 2,
entity_clients: 2,
label: 'no mount accessor (pre-1.10 upgrade?)',
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
mount_type: 'no mount path (pre-1.10 upgrade?)',
namespace_path: 'root',
non_entity_clients: 0,
secret_syncs: 0,
},
{
acme_clients: 0,
clients: 1,
entity_clients: 1,
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'root',
non_entity_clients: 0,
secret_syncs: 0,
},
],
non_entity_clients: 0,
secret_syncs: 0,
},
],
non_entity_clients: 0,
secret_syncs: 0,
timestamp: '2024-04-01T00:00:00Z',
},
'it formats combined data for monthly new_clients spanning upgrade to 1.10'
);
});
});

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { CLIENT_TYPES, ClientFilters, EXPORT_CLIENT_TYPES } from 'core/utils/client-count-utils';
import { CLIENT_TYPES, ClientFilters, EXPORT_CLIENT_TYPES } from 'core/utils/client-counts/helpers';
// At time of writing ClientTypes are: 'acme_clients' | 'clients' | 'entity_clients' | 'non_entity_clients' | 'secret_syncs'
export type ClientTypes = (typeof CLIENT_TYPES)[number];