UI: Add month filtering to client count dashboard (#9148) (#9255)

* delete activity component, convert date-formatters to ts

* add "month" filter to overview tab

* add test coverage for date range dropdown

* add month filtering to client-list

* remove old comment

* wire up clients to route filters for client-list

* adds changelog

* only link to client-list for enterprise versions

* add refresh page link

* render all tabs, add custom empty state for secret sycn clients

* cleanup unused service imports

* revert billing periods as first of the month

* first round of test updates

* update client count utils test

* fix comment typo

* organize tests

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Vault Automation 2025-09-10 12:09:20 -06:00 committed by GitHub
parent 50fba16df4
commit 5ead15b8f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 792 additions and 569 deletions

3
changelog/_9148.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
ui/activity: Adds filtering by month to the Client Count dashboard to link client counts to specific client IDs from the export API
```

View file

@ -1,35 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
// base component for counts child routes that can be extended as needed
// contains getters that filter and extract data from activity model for use in charts
import Component from '@glimmer/component';
import { action } from '@ember/object';
import type ClientsActivityModel from 'vault/models/clients/activity';
import type { ActivityExportData, ClientFilterTypes } from 'core/utils/client-count-utils';
/* This component does not actually render and is the base class to house
shared computations between the Clients::Page::Overview and Clients::Page::List components */
export interface Args {
activity: ClientsActivityModel;
exportData: ActivityExportData[];
onFilterChange: CallableFunction;
filterQueryParams: Record<ClientFilterTypes, string>;
}
export default class ClientsActivityComponent extends Component<Args> {
@action
handleFilter(filters: Record<ClientFilterTypes, string>) {
const { namespace_path, mount_path, mount_type } = filters;
this.args.onFilterChange({ namespace_path, mount_path, mount_type });
}
@action
resetFilters() {
this.handleFilter({ namespace_path: '', mount_path: '', mount_type: '' });
}
}

View file

@ -7,9 +7,10 @@ import { action } from '@ember/object';
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { buildISOTimestamp, parseAPITimestamp } from 'core/utils/date-formatters';
import timestamp from 'core/utils/timestamp';
import { format } from 'date-fns';
import type VersionService from 'vault/services/version';
import type { HTMLElementEvent } from 'forms';
@ -67,16 +68,11 @@ export default class ClientsDateRangeComponent extends Component<Args> {
const periods: string[] = [];
for (let i = 1; i <= count; i++) {
const startDate = new Date(this.args.billingStartTime);
const utcMonth = startDate.getUTCMonth();
const startDate = parseAPITimestamp(this.args.billingStartTime) as Date;
const utcYear = startDate.getUTCFullYear() - i;
startDate.setUTCFullYear(utcYear);
startDate.setUTCMonth(utcMonth);
periods.push(startDate.toISOString());
}
return periods;
}
@ -128,18 +124,11 @@ export default class ClientsDateRangeComponent extends Component<Args> {
}
// HELPERS
formatModalTimestamp(modalValue: string, isEnd: boolean) {
formatModalTimestamp(modalValue: string, isEndDate: boolean) {
const [yearString, month] = modalValue.split('-');
const monthIdx = Number(month) - 1;
const year = Number(yearString);
// day = 0 for Date.UTC(year, month, day) returns the last day of the previous month,
// which is why the monthIdx is increased by one for end dates.
// Date.UTC() also returns December if -1 is passed (which happens when January is selected)
const utc = isEnd
? new Date(Date.UTC(year, monthIdx + 1, 0, 23, 59, 59))
: new Date(Date.UTC(year, monthIdx, 1));
return utc.toISOString();
return buildISOTimestamp({ monthIdx, year, isEndDate });
}
setTrackedFromArgs() {

View file

@ -31,7 +31,7 @@
@selected={{eq item (get this filterProperty)}}
data-test-dropdown-item={{item}}
>
{{item}}
{{if (eq filterProperty "month") (this.formatTimestamp item) item}}
</D.Checkmark>
{{else}}
<D.Description
@ -61,7 +61,7 @@
{{#if value}}
<div>
<Hds::Tag
@text={{value}}
@text={{if (eq filter "month") (this.formatTimestamp value) value}}
{{! Filter renders in a tooltip if exceeds 20 characters }}
@tooltipPlacement="bottom"
@onDismiss={{fn this.clearFilters filter}}

View file

@ -8,15 +8,21 @@ import { cached, tracked } from '@glimmer/tracking';
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, type ClientFilterTypes } from 'core/utils/client-count-utils';
import {
ActivityExportData,
ClientFilters,
MountClients,
type ClientFilterTypes,
} from 'core/utils/client-count-utils';
import type { HTMLElementEvent } from 'vault/forms';
interface Args {
filterQueryParams: Record<ClientFilterTypes, string>;
// Dataset objects technically have more keys than the client filter types, but at minimum they contain ClientFilterTypes
dataset: Record<ClientFilterTypes, string>[];
dataset: ActivityExportData[] | MountClients[];
onFilter: CallableFunction;
dropdownMonths?: string[];
}
// Correspond to each search input's tracked variable in the component class
@ -29,18 +35,21 @@ export default class ClientsFilterToolbar extends Component<Args> {
@tracked namespace_path: string;
@tracked mount_path: string;
@tracked mount_type: string;
@tracked month: string;
// Tracked search inputs
@tracked namespacePathSearch = '';
@tracked mountPathSearch = '';
@tracked mountTypeSearch = '';
@tracked monthSearch = '';
constructor(owner: unknown, args: Args) {
super(owner, args);
const { namespace_path, mount_path, mount_type } = this.args.filterQueryParams;
const { namespace_path, mount_path, mount_type, month } = this.args.filterQueryParams;
this.namespace_path = namespace_path || '';
this.mount_path = mount_path || '';
this.mount_type = mount_type || '';
this.month = month || '';
}
get anyFilters() {
@ -52,6 +61,7 @@ export default class ClientsFilterToolbar extends Component<Args> {
const namespacePaths = new Set<string>();
const mountPaths = new Set<string>();
const mountTypes = new Set<string>();
const months = new Set<string>();
// iterate over dataset once to get dropdown items
this.args.dataset.forEach((d) => {
@ -60,12 +70,24 @@ export default class ClientsFilterToolbar extends Component<Args> {
if (namespace) namespacePaths.add(namespace);
if (d.mount_path) mountPaths.add(d.mount_path);
if (d.mount_type) mountTypes.add(d.mount_type);
// `client_first_used_time` only exists for the dataset rendered in the "Client list" tab (ActivityExportData)
// the "Overview tab" manually passes an array of months
if ('client_first_used_time' in d && d.client_first_used_time) {
// for now, we're only concerned with month granularity so we want the dropdown filter to contain an ISO timestamp
// of the first of the month for each client_first_used_time
const date = parseAPITimestamp(d.client_first_used_time) as Date;
const year = date.getUTCFullYear();
const monthIdx = date.getUTCMonth();
const timestamp = buildISOTimestamp({ year, monthIdx, isEndDate: false });
months.add(timestamp);
}
});
return {
[ClientFilters.NAMESPACE]: [...namespacePaths],
[ClientFilters.MOUNT_PATH]: [...mountPaths],
[ClientFilters.MOUNT_TYPE]: [...mountTypes],
[ClientFilters.MONTH]: this.args.dropdownMonths || [...months],
};
}
@ -87,6 +109,11 @@ export default class ClientsFilterToolbar extends Component<Args> {
dropdownItems: this.dropdownItems[ClientFilters.MOUNT_TYPE],
searchProperty: 'mountTypeSearch',
},
[ClientFilters.MONTH]: {
label: 'month',
dropdownItems: this.dropdownItems[ClientFilters.MONTH],
searchProperty: 'monthSearch',
},
};
}
@ -98,6 +125,8 @@ export default class ClientsFilterToolbar extends Component<Args> {
.flatMap((f: ClientFilters) => {
const filterValue = this.filterProps[f];
const inDropdown = this.dropdownItems[f].includes(filterValue);
// Don't show an alert for the "Month" filter because it doesn't match dataset values one to one
if (ClientFilters.MONTH === f) return [];
return !inDropdown && filterValue ? [alert(this.dropdownConfig[f].label, filterValue)] : [];
})
.join(' ');
@ -137,6 +166,7 @@ export default class ClientsFilterToolbar extends Component<Args> {
this.namespace_path = '';
this.mount_path = '';
this.mount_type = '';
this.month = '';
}
this.applyFilters();
}
@ -159,6 +189,8 @@ export default class ClientsFilterToolbar extends Component<Args> {
}
// TEMPLATE HELPERS
formatTimestamp = (isoTimestamp: string) => parseAPITimestamp(isoTimestamp, 'MMMM yyyy');
searchDropdown = (dropdownItems: string[], searchProperty: SearchProperty) => {
const searchInput = this[searchProperty];
return searchInput

View file

@ -10,8 +10,17 @@
{{#if @activityTimestamp}}
<PH.Subtitle>
Last Updated:
Dashboard last updated:
{{date-format @activityTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
<Hds::Button
@color="tertiary"
@icon="reload"
@isIconOnly={{true}}
@size="small"
@text="Refresh page"
data-test-button="Refresh page"
{{on "click" this.refreshRoute}}
/>
</PH.Subtitle>
{{/if}}
{{#if this.version.isEnterprise}}
@ -25,10 +34,6 @@
</PH.Description>
{{/if}}
<PH.Description>
This is the dashboard for your overall client count usages. Review Vault's
<Hds::Link::Inline @href={{doc-link "/vault/docs/concepts/client-count"}}>client counting documentation
</Hds::Link::Inline>for more information.
{{#if this.showCommunity}}
<div class="has-top-padding-m">
<Hds::Text::Display @tag="h3">Client counting period</Hds::Text::Display>

View file

@ -33,6 +33,7 @@ import { task } from 'ember-concurrency';
export default class ClientsPageHeaderComponent extends Component {
@service download;
@service namespace;
@service router;
@service store;
@service version;
@ -120,9 +121,8 @@ export default class ClientsPageHeaderComponent extends Component {
});
@action
setExportFormat(evt) {
const { value } = evt.target;
this.exportFormat = value;
refreshRoute() {
this.router.refresh();
}
@action
@ -136,6 +136,12 @@ export default class ClientsPageHeaderComponent extends Component {
this.showEditModal = visible;
}
@action
setExportFormat(evt) {
const { value } = evt.target;
this.exportFormat = value;
}
// LOCAL TEMPLATE HELPERS
parseAPITimestamp = (timestamp, format) => {
return parseAPITimestamp(timestamp, format);

View file

@ -3,6 +3,18 @@
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>
<Clients::CountsCard data-test-card="Export activity data">
<:subheader>
<Clients::FilterToolbar
@ -19,7 +31,7 @@
<T.Tab @count={{or tableData.length "0"}} data-test-tab={{tabName}}>{{tabName}}</T.Tab>
<T.Panel>
<div class="has-top-margin-xs">
{{#if this.anyFilters}}
{{#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"}}
@ -38,11 +50,33 @@
@setPageSize={{50}}
@showPaginationSizeSelector={{true}}
>
<:emptyState>
<Hds::ApplicationState as |A|>
<A.Header @title="No data found" />
<A.Body @text="Clear or change filters to view client count data." />
</Hds::ApplicationState>
{{#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."
/>
</Hds::ApplicationState>
{{/if}}
</:emptyState>
</Clients::Table>
{{/if}}

View file

@ -3,12 +3,18 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import ActivityComponent, { Args } from '../activity';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { HTMLElementEvent } from 'vault/forms';
import { filterIsSupported, filterTableData, type ActivityExportData } from 'core/utils/client-count-utils';
import {
filterIsSupported,
filterTableData,
type ClientFilterTypes,
type ActivityExportData,
} from 'core/utils/client-count-utils';
import { service } from '@ember/service';
import FlagsService from 'vault/services/flags';
// Define the base mapping to derive types from
const CLIENT_TYPE_MAP = {
@ -21,24 +27,30 @@ const CLIENT_TYPE_MAP = {
// Dynamically derive the tab values from the mapping
type ClientListTabs = (typeof CLIENT_TYPE_MAP)[keyof typeof CLIENT_TYPE_MAP];
export default class ClientsClientListPageComponent extends ActivityComponent {
@tracked selectedTab: ClientListTabs;
@tracked exportDataByTab;
export interface Args {
exportData: ActivityExportData[];
onFilterChange: CallableFunction;
filterQueryParams: Record<ClientFilterTypes, string>;
}
export default class ClientsClientListPageComponent extends Component<Args> {
@service declare readonly flags: FlagsService;
@tracked selectedTab: ClientListTabs = 'Entity';
@tracked exportDataByTab: Record<ClientListTabs, ActivityExportData[]> = {
Entity: [],
'Non-entity': [],
ACME: [],
'Secret sync': [],
};
constructor(owner: unknown, args: Args) {
super(owner, args);
this.exportDataByTab = this.args.exportData.reduce(
(obj, data) => {
const clientLabel = CLIENT_TYPE_MAP[data.client_type];
if (!obj[clientLabel]) {
obj[clientLabel] = [];
}
obj[clientLabel].push(data);
return obj;
},
{} as Record<ClientListTabs, ActivityExportData[]>
);
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;
@ -53,13 +65,18 @@ export default class ClientsClientListPageComponent extends ActivityComponent {
return Object.keys(this.exportDataByTab) as ClientListTabs[];
}
@action
handleFilter(filters: Record<ClientFilterTypes, string>) {
this.args.onFilterChange(filters);
}
@action
onClickTab(_event: HTMLElementEvent<HTMLInputElement>, idx: number) {
const tab = this.tabs[idx];
this.selectedTab = tab ?? this.tabs[0]!;
}
get anyFilters() {
get filtersAreApplied() {
return (
Object.keys(this.args.filterQueryParams).every((f) => filterIsSupported(f)) &&
Object.values(this.args.filterQueryParams).some((v) => !!v)

View file

@ -16,6 +16,12 @@
</div>
<div class="has-top-bottom-margin">
<Hds::Text::Body @tag="p" @size="100">
This is the dashboard for your overall client count usages. Review Vault's
<Hds::Link::Inline @href={{doc-link "/vault/docs/concepts/client-count"}} @isHrefExternal={{true}}>
client counting documentation</Hds::Link::Inline>
for more information.
</Hds::Text::Body>
{{#if (eq @activity.id "no-data")}}
<Clients::NoData @config={{@config}} />

View file

@ -10,8 +10,6 @@ import { parseAPITimestamp } from 'core/utils/date-formatters';
import { filterVersionHistory } from 'core/utils/client-count-utils';
import type AdapterError from '@ember-data/adapter/error';
import type FlagsService from 'vault/services/flags';
import type Store from '@ember-data/store';
import type VersionService from 'vault/services/version';
import type ClientsActivityModel from 'vault/models/clients/activity';
import type ClientsConfigModel from 'vault/models/clients/config';
@ -28,9 +26,7 @@ interface Args {
}
export default class ClientsCountsPageComponent extends Component<Args> {
@service declare readonly flags: FlagsService;
@service declare readonly version: VersionService;
@service declare readonly store: Store;
get formattedStartDate() {
return this.args.startTimestamp ? parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy') : null;

View file

@ -12,35 +12,15 @@
@description="Select a month to view the client count per mount for that month."
>
<:subheader>
<Hds::Form::Select::Base
class="has-top-margin-m"
aria-label="Month"
name="month"
{{on "input" this.selectMonth}}
@width="200px"
data-test-select="attribution-month"
as |S|
>
<S.Options>
<option value="">Select month</option>
{{#each this.months as |m|}}
<option value={{m.timestamp}} selected={{eq m.timestamp this.selectedMonth}}>{{m.display}}</option>
{{/each}}
</S.Options>
</Hds::Form::Select::Base>
{{#if this.selectedMonth}}
<div>
<Hds::Text::Body class="has-top-margin-l has-bottom-margin-m" @tag="p" @size="100" @color="faint">Use the filters
to view the clients attributed by path.
</Hds::Text::Body>
<Clients::FilterToolbar
@dataset={{this.activityData}}
@onFilter={{this.handleFilter}}
@filterQueryParams={{@filterQueryParams}}
/>
</div>
{{/if}}
<Hds::Text::Body class="has-top-margin-l has-bottom-margin-m" @tag="p" @size="100" @color="faint">Use the filters to
view the clients attributed by path.
</Hds::Text::Body>
<Clients::FilterToolbar
@dataset={{this.activityData}}
@onFilter={{this.handleFilter}}
@filterQueryParams={{@filterQueryParams}}
@dropdownMonths={{this.months}}
/>
</:subheader>
<:table>

View file

@ -3,21 +3,20 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import ActivityComponent from '../activity';
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { cached, tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { HTMLElementEvent } from 'vault/forms';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { filterTableData, flattenMounts } from 'core/utils/client-count-utils';
import { filterTableData, flattenMounts, type ClientFilterTypes } from 'core/utils/client-count-utils';
import type FlagsService from 'vault/services/flags';
import type RouterService from '@ember/routing/router-service';
import type ClientsActivityModel from 'vault/vault/models/clients/activity';
export default class ClientsOverviewPageComponent extends ActivityComponent {
@service declare readonly flags: FlagsService;
@service('app-router') declare readonly router: RouterService;
export interface Args {
activity: ClientsActivityModel;
onFilterChange: CallableFunction;
filterQueryParams: Record<ClientFilterTypes, string>;
}
export default class ClientsOverviewPageComponent extends Component<Args> {
@tracked selectedMonth = '';
@cached
@ -25,13 +24,12 @@ export default class ClientsOverviewPageComponent extends ActivityComponent {
return this.args.activity.byMonth?.map((m) => m?.new_clients) || [];
}
@cached
// Supplies data passed to dropdown filters
// Supplies data passed to dropdown filters (except months which is computed below )
get activityData() {
// Find the namespace data for the selected month
// If no month is selected the table displays all of the activity for the queried date range
const namespaceData = this.selectedMonth
? this.byMonthNewClients.find((m) => m.timestamp === this.selectedMonth)?.namespaces
// If no month is selected the table displays all of the activity for the queried date range.
const selectedMonth = this.args.filterQueryParams.month;
const namespaceData = selectedMonth
? this.byMonthNewClients.find((m) => m.timestamp === selectedMonth)?.namespaces
: this.args.activity.byNamespace;
// Get the array of "mounts" data nested in each namespace object and flatten
@ -40,14 +38,16 @@ export default class ClientsOverviewPageComponent extends ActivityComponent {
@cached
get months() {
return this.byMonthNewClients
.reverse()
.map((m) => ({ timestamp: m.timestamp, display: parseAPITimestamp(m.timestamp, 'MMMM yyyy') }));
return this.byMonthNewClients.reverse().map((m) => m.timestamp);
}
get tableData() {
if (this.activityData?.length) {
return filterTableData(this.activityData, this.args.filterQueryParams);
// Reset the `month` query param because it determines which dataset (see this.activityData)
// is passed to the table and is does not filter for key/value pairs within this dataset.
const filters = { ...this.args.filterQueryParams };
filters.month = '';
return filterTableData(this.activityData, filters);
}
return null;
}
@ -62,11 +62,7 @@ export default class ClientsOverviewPageComponent extends ActivityComponent {
}
@action
selectMonth(e: HTMLElementEvent<HTMLInputElement>) {
this.selectedMonth = e.target.value;
// Reset filters when no month is selected
if (this.selectedMonth === '') {
this.resetFilters();
}
handleFilter(filters: Record<ClientFilterTypes, string>) {
this.args.onFilterChange(filters);
}
}

View file

@ -25,6 +25,13 @@
<B.Td data-test-table-data={{key}} class="white-space-nowrap">
<Hds::Copy::Snippet @textToCopy={{value}} @color="secondary" />
</B.Td>
{{else if (and (eq key "clients") this.version.isEnterprise)}}
<B.Td data-test-table-data={{key}} class="white-space-nowrap">
<Hds::Link::Inline
@route="vault.cluster.clients.counts.client-list"
@query={{this.generateQueryParams B.data}}
>{{value}}</Hds::Link::Inline>
</B.Td>
{{else}}
<B.Td class="white-space-nowrap" data-test-table-data={{key}}>
{{! stringify value if it is an array or object, otherwise render directly }}

View file

@ -9,6 +9,10 @@ import { cached, tracked } from '@glimmer/tracking';
import { paginate } from 'core/utils/paginate-list';
import { next } from '@ember/runloop';
import type { ClientFilterTypes } from 'core/utils/client-count-utils';
import { service } from '@ember/service';
import VersionService from 'vault/services/version';
/**
* @module ClientsTable
* ClientsTable renders a paginated table for a passed dataset. HDS table components handle basic sorting
@ -48,6 +52,8 @@ interface Args {
}
export default class ClientsTable extends Component<Args> {
@service declare readonly version: VersionService;
@tracked currentPage = 1;
@tracked pageSize = 5; // Can be overridden by @setPageSize
@tracked sortColumn = '';
@ -132,4 +138,9 @@ export default class ClientsTable extends Component<Args> {
// TEMPLATE HELPERS
isObject = (value: any) => typeof value === 'object';
generateQueryParams = (datum: Record<ClientFilterTypes, any>) => {
const { namespace_path = '', mount_path = '', mount_type = '' } = datum;
return { namespace_path, mount_path, mount_type };
};
}

View file

@ -5,6 +5,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 type { ClientsCountsRouteParams } from 'vault/routes/vault/cluster/clients/counts';
@ -22,6 +23,17 @@ export default class ClientsCountsController extends Controller {
namespace_path = '';
mount_path = '';
mount_type = '';
// Tracked because clients/page/overview.ts has a getter that needs to recompute when this changes
@tracked month = '';
get filterQueryParams() {
return {
namespace_path: this.namespace_path,
mount_path: this.mount_path,
mount_type: this.mount_type,
month: this.month,
};
}
// using router.transitionTo to update the query params results in the model hook firing each time
// this happens when the queryParams object is not added to the route or refreshModel is explicitly set to false

View file

@ -21,6 +21,7 @@ export interface ClientsCountsRouteParams {
namespace_path?: string;
mount_path?: string;
mount_type?: string;
month?: string;
}
interface ActivityAdapterQuery {
@ -44,6 +45,7 @@ export default class ClientsCountsRoute extends Route {
namespace_path: { refreshModel: false, replace: true },
mount_path: { refreshModel: false, replace: true },
mount_type: { refreshModel: false, replace: true },
month: { refreshModel: false, replace: true },
};
beforeModel() {
@ -92,11 +94,12 @@ export default class ClientsCountsRoute extends Route {
resetController(controller: ClientsCountsController, isExiting: boolean) {
if (isExiting) {
controller.setProperties({
start_time: undefined,
end_time: undefined,
start_time: '',
end_time: '',
namespace_path: '',
mount_path: '',
mount_type: '',
month: '',
});
}
}

View file

@ -13,7 +13,6 @@ import type Store from '@ember-data/store';
export default class ClientsCountsClientListRoute extends Route {
@service declare readonly store: Store;
// TODO - will there always be a start/end timestamp? What's the error scenario
async fetchAndFormatExportData(startTimestamp: string | undefined, endTimestamp: string | undefined) {
const adapter = this.store.adapterFor('clients/activity');
let exportData, exportError;

View file

@ -18,7 +18,8 @@
@icon="docs-link"
@iconPosition="trailing"
@text="Client Export Documentation"
@href={{doc-link "/vault/api-docs/secret/databases"}}
@isHrefExternal={{true}}
@href={{doc-link "/vault/api-docs/system/internal-counters#activity-export"}}
/>
</Hds::Text::Body>
{{/if}}
@ -28,10 +29,6 @@
<Clients::Page::ClientList
@exportData={{this.model.exportData}}
@onFilterChange={{this.countsController.updateQueryParams}}
@filterQueryParams={{hash
namespace_path=this.countsController.namespace_path
mount_path=this.countsController.mount_path
mount_type=this.countsController.mount_type
}}
@filterQueryParams={{this.countsController.filterQueryParams}}
/>
{{/if}}

View file

@ -6,9 +6,5 @@
<Clients::Page::Overview
@activity={{this.model.activity}}
@onFilterChange={{this.countsController.updateQueryParams}}
@filterQueryParams={{hash
namespace_path=this.countsController.namespace_path
mount_path=this.countsController.mount_path
mount_type=this.countsController.mount_type
}}
@filterQueryParams={{this.countsController.filterQueryParams}}
/>

View file

@ -3,8 +3,10 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { isSameMonthUTC, parseAPITimestamp } from 'core/utils/date-formatters';
import { compareAsc, 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';
@ -31,6 +33,8 @@ export enum ClientFilters {
NAMESPACE = 'namespace_path',
MOUNT_PATH = 'mount_path',
MOUNT_TYPE = 'mount_type',
// this filter/query param does not map to a key in either API response and is handled ~special~
MONTH = 'month',
}
export type ClientFilterTypes = (typeof ClientFilters)[keyof typeof ClientFilters];
@ -126,7 +130,8 @@ export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByN
label: m.mount_path,
namespace_path: nsLabel,
mount_path: m.mount_path,
mount_type: m.mount_type,
// 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),
}));
}
@ -185,17 +190,24 @@ export function filterTableData(
}
const matchesFilter = (
datum: MountClients | ActivityExportData,
datum: ActivityExportData | MountClients,
filterKey: ClientFilterTypes,
filterValue: string
) => {
// Only ActivityExportData data is ever filtered by 'client_first_used_time' (not MountClients)
if (filterKey === ClientFilters.MONTH) {
return 'client_first_used_time' in datum
? isSameMonthUTC(datum.client_first_used_time, filterValue)
: false;
}
const datumValue = datum[filterKey];
// The API returns and empty string as the namespace_path for the "root" namespace.
// When a user selects "root" as a namespace filter we need to match the datum value
// as either an empty string (for the activity export data) OR as "root"
// (the by_namespace data is serialized to make "root" the namespace_path).
if (filterKey === 'namespace_path' && filterValue === 'root') {
return datumValue === '' || datumValue === filterValue;
if (filterKey === ClientFilters.NAMESPACE && filterValue === 'root') {
return datumValue === ROOT_NAMESPACE || datumValue === filterValue;
}
return datumValue === filterValue;
};

View file

@ -1,31 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { format, parseISO } from 'date-fns';
export const datetimeLocalStringFormat = "yyyy-MM-dd'T'HH:mm";
export const ARRAY_OF_MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
// convert API timestamp ( '2021-03-21T00:00:00Z' ) to date object, optionally format
export const parseAPITimestamp = (timestamp, style) => {
if (typeof timestamp !== 'string') return timestamp;
const date = parseISO(timestamp.split('T')[0]);
if (!style) return date;
return format(date, style);
};

View file

@ -0,0 +1,64 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { format, parse, parseISO } from 'date-fns';
import isValid from 'date-fns/isValid';
export const datetimeLocalStringFormat = "yyyy-MM-dd'T'HH:mm";
export const ARRAY_OF_MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
// convert API timestamp ( '2021-03-21T00:00:00Z' ) to date object, optionally format
export const parseAPITimestamp = (timestamp: string, style?: string): Date | string | null => {
if (!timestamp || typeof timestamp !== 'string') return null;
if (!style) {
// If no style, return a date object in UTC
const parsed = parseISO(timestamp) as Date;
return isValid(parsed) ? parsed : null;
}
// Otherwise format it as a calendar date that is timezone agnostic.
const yearMonthDay = timestamp.split('T')[0] ?? '';
const date = parse(yearMonthDay, 'yyyy-MM-dd', new Date()); // 'yyyy-MM-dd' lets parse() know the format of yearMonthDay
return format(date, style) as string;
};
export const buildISOTimestamp = (args: { monthIdx: number; year: number; isEndDate: boolean }) => {
const { monthIdx, year, isEndDate } = args;
// passing `0` for the "day" arg to Date.UTC() returns the last day of the previous month
// which is why the monthIdx is increased by one for end dates.
// Date.UTC() also returns December if -1 is passed (which happens when January is selected)
const utc = isEndDate
? new Date(Date.UTC(year, monthIdx + 1, 0, 23, 59, 59))
: new Date(Date.UTC(year, monthIdx, 1));
// remove milliseconds to return a UTC timestamp that matches the API
// e.g. "2025-05-01T00:00:00Z" or "2025-09-30T23:59:59Z"
return utc.toISOString().replace('.000', '');
};
export const isSameMonthUTC = (timestampA: string, timestampB: string): boolean => {
const dateA = parseAPITimestamp(timestampA) as Date;
const dateB = parseAPITimestamp(timestampB) as Date;
if (isValid(dateA) && isValid(dateB)) {
// Compare in UTC as any date-fns comparisons will be in localized timezones!
return dateA.getUTCFullYear() === dateB.getUTCFullYear() && dateA.getUTCMonth() === dateB.getUTCMonth();
}
return false;
};

View file

@ -5,7 +5,6 @@
import {
addMonths,
differenceInCalendarMonths,
endOfMonth,
formatRFC3339,
fromUnixTime,
@ -13,7 +12,6 @@ import {
isBefore,
isSameMonth,
isWithinInterval,
startOfMonth,
subMonths,
} from 'date-fns';
import { parseAPITimestamp } from 'core/utils/date-formatters';
@ -30,7 +28,7 @@ export const STATIC_NOW = new Date('2024-01-25T23:59:59Z');
export const STATIC_PREVIOUS_MONTH = new Date('2023-12-25T23:59:59Z');
const COUNTS_START = subMonths(STATIC_NOW, 12); // user started Vault cluster on 2023-01-25
// upgrade happened 2 month after license start
export const UPGRADE_DATE = addMonths(LICENSE_START, 2); // monthly attribution added
export const UPGRADE_DATE = new Date('2023-09-01T00:00:00Z'); // monthly attribution added
// exported so that tests not using this scenario can use the same response
export const CONFIG_RESPONSE = {
@ -67,7 +65,8 @@ function generateMountBlock(path, counts) {
return obj;
}, {});
// this logic is random nonsense just to have some mounts be "deleted"
const setMountType = () => (counts.clients % 5 <= 1 ? 'deleted mount' : path.split('/')[1]);
// "mount_type" from /sys/internal/counters/activity ends in a trailing slash (but in the export activity data it does not)
const setMountType = () => (counts.clients % 5 <= 1 ? 'deleted mount' : `${path.split('/')[1]}/`);
return {
mount_path: path,
mount_type: setMountType(),
@ -100,13 +99,13 @@ function generateNamespaceBlock(idx = 0, isLowerCounts = false, ns, skipCounts =
// each mount type generates a different type of client
return [
generateMountBlock(`auth/token/${idx}`, {
generateMountBlock(`auth/token/${idx}/`, {
clients: non_entity_clients + entity_clients,
non_entity_clients,
entity_clients,
}),
generateMountBlock(`secrets/kv/${idx}`, { clients: secret_syncs, secret_syncs }),
generateMountBlock(`acme/pki/${idx}`, { clients: acme_clients, acme_clients }),
generateMountBlock(`secrets/kv/${idx}/`, { clients: secret_syncs, secret_syncs }),
generateMountBlock(`acme/pki/${idx}/`, { clients: acme_clients, acme_clients }),
];
};
@ -122,19 +121,23 @@ function generateNamespaceBlock(idx = 0, isLowerCounts = false, ns, skipCounts =
function generateMonths(startDate, endDate, namespaces) {
const startDateObject = parseAPITimestamp(startDate);
const endDateObject = parseAPITimestamp(endDate);
const numberOfMonths = differenceInCalendarMonths(endDateObject, startDateObject) + 1;
const startMonth = startDateObject.getUTCMonth() + startDateObject.getUTCFullYear() * 12;
const endMonth = endDateObject.getUTCMonth() + endDateObject.getUTCFullYear() * 12;
const numberOfMonths = endMonth - startMonth + 1;
const months = [];
// only generate monthly block if queried dates span or follow upgrade to 1.10
const upgradeWithin = isWithinInterval(UPGRADE_DATE, { start: startDateObject, end: endDateObject });
const upgradeAfter = isAfter(startDateObject, UPGRADE_DATE);
if (upgradeWithin || upgradeAfter) {
for (let i = 0; i < numberOfMonths; i++) {
const month = addMonths(startOfMonth(startDateObject), i);
const month = new Date(Date.UTC(startDateObject.getUTCFullYear(), startDateObject.getUTCMonth() + i));
const hasNoData = isBefore(month, UPGRADE_DATE) && !isSameMonth(month, UPGRADE_DATE);
if (hasNoData) {
months.push({
timestamp: formatRFC3339(month),
timestamp: month.toISOString(),
counts: null,
namespaces: null,
new_clients: null,
@ -145,7 +148,7 @@ function generateMonths(startDate, endDate, namespaces) {
const monthNs = namespaces.map((ns, idx) => generateNamespaceBlock(idx, false, ns));
const newClients = namespaces.map((ns, idx) => generateNamespaceBlock(idx, true, ns));
months.push({
timestamp: formatRFC3339(month),
timestamp: month.toISOString(),
counts: getTotalCounts(monthNs),
namespaces: monthNs.sort((a, b) => b.counts.clients - a.counts.clients),
new_clients: {
@ -155,7 +158,6 @@ function generateMonths(startDate, endDate, namespaces) {
});
}
}
return months;
}
@ -295,6 +297,7 @@ export default function (server) {
const activities = schema['clients/activities'];
const namespace = req.requestHeaders['X-Vault-Namespace'];
let { start_time, end_time } = req.queryParams;
if (!start_time) {
// if there are no date query params, the activity log default behavior
// queries from the builtin license start timestamp to the current month

View file

@ -19,9 +19,12 @@ import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CHARTS, CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import timestamp from 'core/utils/timestamp';
import { format } from 'date-fns';
import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers';
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 { parseAPITimestamp } from 'core/utils/date-formatters';
module('Acceptance | clients | overview', function (hooks) {
setupApplicationTest(hooks);
@ -29,8 +32,10 @@ module('Acceptance | clients | overview', function (hooks) {
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
// These tests use the clientsHandler which dynamically generates activity data, used for asserting date querying, etc
clientsHandler(this.server);
this.store = this.owner.lookup('service:store');
this.version = this.owner.lookup('service:version');
});
test('it should hide secrets sync stats when feature is NOT on license', async function (assert) {
@ -45,59 +50,45 @@ module('Acceptance | clients | overview', function (hooks) {
assert.dom(CHARTS.legend).hasText('Entity clients Non-entity clients ACME clients');
});
// These tests use the clientsHandler which dynamically generates activity data, used for asserting date querying, etc
module('dynamic data', function (hooks) {
test('it should render charts', async function (assert) {
await login();
await visit('/vault/clients/counts/overview');
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', 'start month is correctly parsed from license');
assert
.dom(CLIENT_COUNT.dateRange.dateDisplay('end'))
.hasText('January 2024', 'end month is correctly parsed from STATIC_NOW');
assert
.dom(CLIENT_COUNT.card('Client usage trends for selected billing period'))
.exists('Shows running totals with monthly breakdown charts');
assert
.dom(`${CLIENT_COUNT.card('Client usage trends for selected billing period')} ${CHARTS.xAxisLabel}`)
.hasText('7/23', 'x-axis labels start with billing start date');
assert.dom(CHARTS.xAxisLabel).exists({ count: 7 }, 'chart months matches query');
});
module('community', function (hooks) {
hooks.beforeEach(async function () {
// stub secrets sync being activated
this.server.get('/sys/activation-flags', function () {
return {
data: {
activated: ['secrets-sync'],
unactivated: [],
},
};
});
this.activity = await this.store.findRecord('clients/activity', 'some-activity-id');
this.mostRecentMonth = this.activity.byMonth[this.activity.byMonth.length - 1];
this.version.type = 'community';
await 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.card('Client usage trends for selected billing period'))
.exists('Shows running totals with monthly breakdown charts');
assert
.dom(`${CLIENT_COUNT.card('Client usage trends for selected billing period')} ${CHARTS.xAxisLabel}`)
.hasText('7/23', 'x-axis labels start with billing start date');
assert.dom(CHARTS.xAxisLabel).exists({ count: 7 }, 'chart months matches query');
return await visit('/vault/clients/counts/overview');
});
test('it should update charts when querying date ranges', async function (assert) {
// query for single, historical month with no new counts (July 2023), which means there is no monthly breakdown
const service = this.owner.lookup('service:version');
service.type = 'community';
const licenseStartMonth = format(LICENSE_START, 'yyyy-MM');
const upgradeMonth = format(UPGRADE_DATE, 'yyyy-MM');
const endMonth = format(STATIC_PREVIOUS_MONTH, 'yyyy-MM');
// Use parseAPITimestamp because we want a date string that is timezone agnostic (so it stays in UTC)
const clientCountingStartDate = parseAPITimestamp(LICENSE_START.toISOString(), 'yyyy-MM');
const upgradeMonth = parseAPITimestamp(UPGRADE_DATE.toISOString(), 'yyyy-MM');
const endMonth = parseAPITimestamp(STATIC_PREVIOUS_MONTH.toISOString(), 'yyyy-MM');
await click(CLIENT_COUNT.dateRange.edit);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), licenseStartMonth);
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), licenseStartMonth);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), clientCountingStartDate);
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), clientCountingStartDate);
await click(GENERAL.submitButton);
assert
.dom(CLIENT_COUNT.usageStats('Client usage'))
@ -111,9 +102,10 @@ module('Acceptance | clients | overview', function (hooks) {
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), upgradeMonth);
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), endMonth);
await click(GENERAL.submitButton);
assert
.dom(CLIENT_COUNT.dateRange.dateDisplay('start'))
.hasText('September 2023', 'billing start month is correctly parsed from license');
.hasText('September 2023', 'client count start month is correctly parsed from start query');
assert
.dom(CLIENT_COUNT.card('Client usage trends for selected billing period'))
.exists('Shows running totals with monthly breakdown charts');
@ -139,10 +131,10 @@ module('Acceptance | clients | overview', function (hooks) {
assert
.dom(CLIENT_COUNT.dateRange.dateDisplay('start'))
.hasText('September 2023', 'billing start month is correctly parsed from license');
.hasText('September 2023', 'it displays correct start time');
assert
.dom(CLIENT_COUNT.dateRange.dateDisplay('end'))
.hasText('December 2023', 'billing start month is correctly parsed from license');
.hasText('December 2023', 'it displays correct end time');
assert
.dom(CLIENT_COUNT.card('Client usage trends for selected billing period'))
.exists('Shows running totals with monthly breakdown charts');
@ -153,6 +145,12 @@ module('Acceptance | clients | overview', function (hooks) {
.dom(xAxisLabels[xAxisLabels.length - 1])
.hasText('12/23', 'x-axis labels end with queried end month');
});
test('it does not render client list links for community versions', async function (assert) {
assert
.dom(`${GENERAL.tableData(0, 'clients')} a`)
.doesNotExist('client counts do not render as hyperlinks');
});
});
// * FILTERING ASSERTIONS
@ -176,9 +174,11 @@ module('Acceptance | clients | overview', function (hooks) {
test('it filters attribution table when filters are applied', async function (assert) {
const url = '/vault/clients/counts/overview';
const topMount = flattenMounts(this.staticMostRecentMonth.new_clients.namespaces)[0];
const timestamp = this.staticMostRecentMonth.timestamp;
const { namespace_path, mount_type, mount_path } = topMount;
assert.strictEqual(currentURL(), url, 'URL does not contain query params');
await fillIn(GENERAL.selectByAttr('attribution-month'), this.staticMostRecentMonth.timestamp);
await click(FILTERS.dropdownToggle(ClientFilters.MONTH));
await click(FILTERS.dropdownItem(timestamp));
await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE));
await click(FILTERS.dropdownItem(namespace_path));
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH));
@ -187,12 +187,12 @@ module('Acceptance | clients | overview', function (hooks) {
await click(FILTERS.dropdownItem(mount_type));
assert.strictEqual(
currentURL(),
`${url}?mount_path=${encodeURIComponent(
`${url}?month=${encodeURIComponent(timestamp)}&mount_path=${encodeURIComponent(
mount_path
)}&mount_type=${mount_type}&namespace_path=${namespace_path}`,
'url query params match filters'
);
assert.dom(FILTERS.tag()).exists({ count: 3 }, '3 filter tags render');
assert.dom(FILTERS.tag()).exists({ count: 4 }, '4 filter tags render');
assert.dom(GENERAL.tableRow()).exists({ count: 1 }, 'it only renders the filtered table row');
assert.dom(GENERAL.tableData(0, 'namespace_path')).hasText(namespace_path);
assert.dom(GENERAL.tableData(0, 'mount_type')).hasText(mount_type);
@ -202,8 +202,10 @@ module('Acceptance | clients | overview', function (hooks) {
test('it updates table when filters are cleared', async function (assert) {
const url = '/vault/clients/counts/overview';
const mounts = flattenMounts(this.staticMostRecentMonth.new_clients.namespaces);
const timestamp = this.staticMostRecentMonth.timestamp;
const { namespace_path, mount_type, mount_path } = mounts[0];
await fillIn(GENERAL.selectByAttr('attribution-month'), this.staticMostRecentMonth.timestamp);
await click(FILTERS.dropdownToggle(ClientFilters.MONTH));
await click(FILTERS.dropdownItem(timestamp));
await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE));
await click(FILTERS.dropdownItem(namespace_path));
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH));
@ -214,13 +216,15 @@ module('Acceptance | clients | overview', function (hooks) {
await click(FILTERS.clearTag(namespace_path));
assert.strictEqual(
currentURL(),
`${url}?mount_path=${encodeURIComponent(mount_path)}&mount_type=${mount_type}`,
`${url}?month=${encodeURIComponent(timestamp)}&mount_path=${encodeURIComponent(
mount_path
)}&mount_type=${mount_type}`,
'url does not have namespace_path query param'
);
assert.dom(GENERAL.tableRow()).exists({ count: 2 }, 'it renders 2 data rows that match filters');
assert.dom(GENERAL.tableData(0, 'namespace_path')).hasText('root');
assert.dom(GENERAL.tableData(0, 'mount_type')).hasText(mount_type);
assert.dom(GENERAL.tableData(1, 'namespace_path')).hasText('ns1');
assert.dom(GENERAL.tableData(1, 'namespace_path')).hasText('ns1/');
assert.dom(GENERAL.tableData(1, 'mount_type')).hasText(mount_type);
assert.dom(GENERAL.tableData(1, 'mount_path')).hasText(mount_path);
await click(GENERAL.button('Clear filters'));
@ -230,11 +234,13 @@ module('Acceptance | clients | overview', function (hooks) {
.exists({ count: mounts.length }, 'it renders all data when filters are cleared');
});
test('it clears query params when month is unselected', async function (assert) {
test('it renders client counts for full billing period when month is unselected', async function (assert) {
const url = '/vault/clients/counts/overview';
const mounts = flattenMounts(this.staticMostRecentMonth.new_clients.namespaces);
const timestamp = this.staticMostRecentMonth.timestamp;
const { namespace_path, mount_type, mount_path } = mounts[0];
await fillIn(GENERAL.selectByAttr('attribution-month'), this.staticMostRecentMonth.timestamp);
await click(FILTERS.dropdownToggle(ClientFilters.MONTH));
await click(FILTERS.dropdownItem(timestamp));
await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE));
await click(FILTERS.dropdownItem(namespace_path));
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH));
@ -243,13 +249,48 @@ module('Acceptance | clients | overview', function (hooks) {
await click(FILTERS.dropdownItem(mount_type));
assert.strictEqual(
currentURL(),
`${url}?mount_path=${encodeURIComponent(
`${url}?month=${encodeURIComponent(timestamp)}&mount_path=${encodeURIComponent(
mount_path
)}&mount_type=${mount_type}&namespace_path=${namespace_path}`,
'url query params match filters'
);
await fillIn(GENERAL.selectByAttr('attribution-month'), '');
assert.strictEqual(currentURL(), url, 'url query params clear when month is not selected');
await click(FILTERS.clearTag('September 2023'));
assert
.dom(GENERAL.tableData(0, 'clients'))
.hasText('4003', 'the table renders clients for the full billing period (not September)');
assert.strictEqual(
currentURL(),
`${url}?mount_path=${encodeURIComponent(
mount_path
)}&mount_type=${mount_type}&namespace_path=${namespace_path}`,
'url does not include month'
);
});
test('enterprise: it navigates to the client list page when clicking the client count hyperlink', async function (assert) {
const mockResponse = {
status: 200,
ok: true,
text: () => Promise.resolve(ACTIVITY_EXPORT_STUB.trim()),
};
const adapter = this.store.adapterFor('clients/activity');
const exportDataStub = sinon.stub(adapter, 'exportData');
exportDataStub.resolves(mockResponse);
const timestamp = this.staticMostRecentMonth.timestamp;
await click(FILTERS.dropdownToggle(ClientFilters.MONTH));
await click(FILTERS.dropdownItem(timestamp));
await click(`${GENERAL.tableData(0, 'clients')} a`);
const url = '/vault/clients/counts/client-list';
const monthQp = encodeURIComponent(timestamp);
const ns = encodeURIComponent('ns1/');
const mPath = encodeURIComponent('auth/userpass/0/');
const mType = 'userpass';
assert.strictEqual(
currentURL(),
`${url}?month=${monthQp}&mount_path=${mPath}&mount_type=${mType}&namespace_path=${ns}`,
'url query params match filters'
);
exportDataStub.restore();
});
});

View file

@ -35,7 +35,7 @@ export const ACTIVITY_RESPONSE_STUB = {
by_namespace: [
{
namespace_id: 'e67m31',
namespace_path: 'ns1',
namespace_path: 'ns1/',
counts: {
acme_clients: 5699,
clients: 18903,
@ -45,8 +45,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass/',
counts: {
acme_clients: 0,
clients: 8394,
@ -56,8 +56,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'acme/pki/0',
mount_type: 'pki',
mount_path: 'acme/pki/0/',
mount_type: 'pki/',
counts: {
acme_clients: 5699,
clients: 5699,
@ -67,8 +67,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'secrets/kv/0',
mount_type: 'kv',
mount_path: 'secrets/kv/0/',
mount_type: 'kv/',
counts: {
acme_clients: 0,
clients: 4810,
@ -91,8 +91,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass/',
counts: {
acme_clients: 0,
clients: 8091,
@ -102,8 +102,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'secrets/kv/0',
mount_type: 'kv',
mount_path: 'secrets/kv/0/',
mount_type: 'kv/',
counts: {
acme_clients: 0,
clients: 4290,
@ -113,8 +113,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'acme/pki/0',
mount_type: 'pki',
mount_path: 'acme/pki/0/',
mount_type: 'pki/',
counts: {
acme_clients: 4003,
clients: 4003,
@ -155,8 +155,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass/',
counts: {
acme_clients: 0,
clients: 200,
@ -166,8 +166,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'acme/pki/0',
mount_type: 'pki',
mount_path: 'acme/pki/0/',
mount_type: 'pki/',
counts: {
acme_clients: 100,
clients: 100,
@ -177,8 +177,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'secrets/kv/0',
mount_type: 'kv',
mount_path: 'secrets/kv/0/',
mount_type: 'kv/',
counts: {
acme_clients: 0,
clients: 100,
@ -211,8 +211,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass/',
counts: {
acme_clients: 0,
clients: 200,
@ -222,8 +222,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'acme/pki/0',
mount_type: 'pki',
mount_path: 'acme/pki/0/',
mount_type: 'pki/',
counts: {
acme_clients: 100,
clients: 100,
@ -233,8 +233,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'secrets/kv/0',
mount_type: 'kv',
mount_path: 'secrets/kv/0/',
mount_type: 'kv/',
counts: {
acme_clients: 0,
clients: 100,
@ -270,8 +270,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass/',
counts: {
acme_clients: 0,
clients: 200,
@ -281,8 +281,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'acme/pki/0',
mount_type: 'pki',
mount_path: 'acme/pki/0/',
mount_type: 'pki/',
counts: {
acme_clients: 100,
clients: 100,
@ -292,8 +292,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'secrets/kv/0',
mount_type: 'kv',
mount_path: 'secrets/kv/0/',
mount_type: 'kv/',
counts: {
acme_clients: 0,
clients: 100,
@ -322,7 +322,7 @@ export const ACTIVITY_RESPONSE_STUB = {
namespaces: [
{
namespace_id: 'e67m31',
namespace_path: 'ns1',
namespace_path: 'ns1/',
counts: {
acme_clients: 934,
clients: 1981,
@ -332,7 +332,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'acme/pki/0',
mount_path: 'acme/pki/0/',
counts: {
acme_clients: 934,
clients: 934,
@ -342,7 +342,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'auth/userpass/0',
mount_path: 'auth/userpass/0/',
counts: {
acme_clients: 0,
clients: 890,
@ -352,7 +352,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'secrets/kv/0',
mount_path: 'secrets/kv/0/',
counts: {
acme_clients: 0,
clients: 157,
@ -375,7 +375,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'acme/pki/0',
mount_path: 'acme/pki/0/',
counts: {
acme_clients: 994,
clients: 994,
@ -385,7 +385,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'auth/userpass/0',
mount_path: 'auth/userpass/0/',
counts: {
acme_clients: 0,
clients: 872,
@ -395,7 +395,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'secrets/kv/0',
mount_path: 'secrets/kv/0/',
counts: {
acme_clients: 0,
clients: 81,
@ -428,8 +428,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'acme/pki/0',
mount_type: 'pki',
mount_path: 'acme/pki/0/',
mount_type: 'pki/',
counts: {
acme_clients: 91,
clients: 91,
@ -439,8 +439,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass/',
counts: {
acme_clients: 0,
clients: 75,
@ -450,8 +450,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'secrets/kv/0',
mount_type: 'kv',
mount_path: 'secrets/kv/0/',
mount_type: 'kv/',
counts: {
acme_clients: 0,
clients: 25,
@ -464,7 +464,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
{
namespace_id: 'e67m31',
namespace_path: 'ns1',
namespace_path: 'ns1/',
counts: {
acme_clients: 53,
clients: 173,
@ -474,8 +474,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass/',
counts: {
acme_clients: 0,
clients: 96,
@ -485,8 +485,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'acme/pki/0',
mount_type: 'pki',
mount_path: 'acme/pki/0/',
mount_type: 'pki/',
counts: {
acme_clients: 53,
clients: 53,
@ -496,8 +496,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'secrets/kv/0',
mount_type: 'kv',
mount_path: 'secrets/kv/0/',
mount_type: 'kv/',
counts: {
acme_clients: 0,
clients: 24,
@ -561,7 +561,7 @@ export const MIXED_ACTIVITY_RESPONSE_STUB = {
non_entity_clients: 0,
secret_syncs: 0,
},
mount_path: 'auth/userpass/0',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
},
],
@ -613,7 +613,7 @@ export const MIXED_ACTIVITY_RESPONSE_STUB = {
non_entity_clients: 0,
secret_syncs: 0,
},
mount_path: 'auth/userpass/0',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
},
],
@ -658,7 +658,7 @@ export const MIXED_ACTIVITY_RESPONSE_STUB = {
non_entity_clients: 0,
secret_syncs: 0,
},
mount_path: 'auth/userpass/0',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
},
],
@ -683,7 +683,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
},
by_namespace: [
{
label: 'ns1',
label: 'ns1/',
acme_clients: 5699,
clients: 18903,
entity_clients: 4256,
@ -691,10 +691,10 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 4810,
mounts: [
{
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'ns1',
namespace_path: 'ns1/',
acme_clients: 0,
clients: 8394,
entity_clients: 4256,
@ -702,10 +702,10 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
label: 'acme/pki/0/',
mount_path: 'acme/pki/0/',
mount_type: 'pki',
namespace_path: 'ns1',
namespace_path: 'ns1/',
acme_clients: 5699,
clients: 5699,
entity_clients: 0,
@ -713,10 +713,10 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
label: 'secrets/kv/0/',
mount_path: 'secrets/kv/0/',
mount_type: 'kv',
namespace_path: 'ns1',
namespace_path: 'ns1/',
acme_clients: 0,
clients: 4810,
entity_clients: 0,
@ -734,8 +734,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 4290,
mounts: [
{
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'root',
acme_clients: 0,
@ -745,8 +745,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
label: 'secrets/kv/0/',
mount_path: 'secrets/kv/0/',
mount_type: 'kv',
namespace_path: 'root',
acme_clients: 0,
@ -756,8 +756,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 4290,
},
{
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
label: 'acme/pki/0/',
mount_path: 'acme/pki/0/',
mount_type: 'pki',
namespace_path: 'root',
acme_clients: 4003,
@ -795,9 +795,9 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 100,
mounts: [
{
label: 'auth/userpass/0',
label: 'auth/userpass/0/',
namespace_path: 'root',
mount_path: 'auth/userpass/0',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
acme_clients: 0,
clients: 200,
@ -806,9 +806,9 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'acme/pki/0',
label: 'acme/pki/0/',
namespace_path: 'root',
mount_path: 'acme/pki/0',
mount_path: 'acme/pki/0/',
mount_type: 'pki',
acme_clients: 100,
clients: 100,
@ -817,9 +817,9 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
label: 'secrets/kv/0/',
namespace_path: 'root',
mount_path: 'secrets/kv/0',
mount_path: 'secrets/kv/0/',
mount_type: 'kv',
acme_clients: 0,
clients: 100,
@ -847,8 +847,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 100,
mounts: [
{
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'root',
acme_clients: 0,
@ -858,8 +858,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
label: 'acme/pki/0/',
mount_path: 'acme/pki/0/',
namespace_path: 'root',
mount_type: 'pki',
acme_clients: 100,
@ -869,8 +869,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
label: 'secrets/kv/0/',
mount_path: 'secrets/kv/0/',
mount_type: 'kv',
namespace_path: 'root',
acme_clients: 0,
@ -901,8 +901,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 100,
mounts: [
{
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
namespace_path: 'root',
mount_type: 'userpass',
acme_clients: 0,
@ -912,8 +912,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
label: 'acme/pki/0/',
mount_path: 'acme/pki/0/',
namespace_path: 'root',
mount_type: 'pki',
acme_clients: 100,
@ -924,8 +924,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
},
{
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
label: 'secrets/kv/0/',
mount_path: 'secrets/kv/0/',
namespace_path: 'root',
mount_type: 'kv',
acme_clients: 0,
@ -951,7 +951,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 238,
namespaces: [
{
label: 'ns1',
label: 'ns1/',
acme_clients: 934,
clients: 1981,
entity_clients: 708,
@ -959,8 +959,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 157,
mounts: [
{
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
label: 'acme/pki/0/',
mount_path: 'acme/pki/0/',
acme_clients: 934,
clients: 934,
entity_clients: 0,
@ -968,8 +968,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
acme_clients: 0,
clients: 890,
entity_clients: 708,
@ -977,8 +977,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
label: 'secrets/kv/0/',
mount_path: 'secrets/kv/0/',
acme_clients: 0,
clients: 157,
entity_clients: 0,
@ -996,8 +996,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 81,
mounts: [
{
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
label: 'acme/pki/0/',
mount_path: 'acme/pki/0/',
acme_clients: 994,
clients: 994,
entity_clients: 0,
@ -1005,8 +1005,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
acme_clients: 0,
clients: 872,
entity_clients: 124,
@ -1014,8 +1014,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
label: 'secrets/kv/0/',
mount_path: 'secrets/kv/0/',
acme_clients: 0,
clients: 81,
entity_clients: 0,
@ -1042,8 +1042,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 25,
mounts: [
{
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
label: 'acme/pki/0/',
mount_path: 'acme/pki/0/',
mount_type: 'pki',
acme_clients: 91,
clients: 91,
@ -1052,8 +1052,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
acme_clients: 0,
clients: 75,
@ -1062,8 +1062,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
label: 'secrets/kv/0/',
mount_path: 'secrets/kv/0/',
mount_type: 'kv',
acme_clients: 0,
clients: 25,
@ -1074,7 +1074,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
],
},
{
label: 'ns1',
label: 'ns1/',
acme_clients: 53,
clients: 173,
entity_clients: 34,
@ -1082,8 +1082,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 24,
mounts: [
{
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
acme_clients: 0,
clients: 96,
@ -1092,8 +1092,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
label: 'acme/pki/0/',
mount_path: 'acme/pki/0/',
mount_type: 'pki',
acme_clients: 53,
clients: 53,
@ -1102,8 +1102,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
label: 'secrets/kv/0/',
mount_path: 'secrets/kv/0/',
mount_type: 'kv',
acme_clients: 0,
clients: 24,
@ -1119,61 +1119,62 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
],
};
export const ENTITY_EXPORT = `{"entity_name":"entity_b3e2a7ff","entity_alias_name":"bob","local_entity_alias":false,"client_id":"5692c6ef-c871-128e-fb06-df2be7bfc0db","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_userpass_f47ad0b4","mount_type":"userpass","mount_path":"auth/userpass/","token_creation_time":"2025-08-15T23:48:09Z","client_first_used_time":"2025-08-15T23:48:09Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f"]}
{"entity_name":"bob-smith","entity_alias_name":"bob","local_entity_alias":false,"client_id":"23a04911-5d72-ba98-11d3-527f2fcf3a81","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_userpass_de28062c","mount_type":"userpass","mount_path":"auth/userpass-test/","token_creation_time":"2025-08-15T23:52:38Z","client_first_used_time":"2025-08-15T23:53:19Z","policies":["base"],"entity_metadata":{"organization":"ACME Inc.","team":"QA"},"entity_alias_metadata":{},"entity_alias_custom_metadata":{"account":"Tester Account"},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f"]}
{"entity_name":"alice-johnson","entity_alias_name":"alice","local_entity_alias":false,"client_id":"a7c8d912-4f61-23b5-88e4-627a3dcf2b92","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_userpass_f47ad0b4","mount_type":"userpass","mount_path":"auth/userpass/","token_creation_time":"2025-08-16T09:15:42Z","client_first_used_time":"2025-08-16T09:16:03Z","policies":["admin","audit"],"entity_metadata":{"organization":"TechCorp","team":"DevOps","location":"San Francisco"},"entity_alias_metadata":{"department":"Engineering"},"entity_alias_custom_metadata":{"role":"Senior Engineer"},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f","a1b2c3d4-5e6f-7g8h-9i0j-k1l2m3n4o5p6"]}
{"entity_name":"charlie-brown","entity_alias_name":"charlie","local_entity_alias":true,"client_id":"b9e5f824-7c92-34d6-a1f8-738b4ecf5d73","client_type":"entity","namespace_id":"whUNi","namespace_path":"ns2/","mount_accessor":"auth_ldap_8a3b9c2d","mount_type":"ldap","mount_path":"auth/ldap/","token_creation_time":"2025-08-16T14:22:17Z","client_first_used_time":"2025-08-16T14:22:45Z","policies":["developer","read-only"],"entity_metadata":{"organization":"StartupXYZ","team":"Backend"},"entity_alias_metadata":{"cn":"charlie.brown","ou":"development"},"entity_alias_custom_metadata":{"project":"microservices"},"entity_group_ids":["c7d8e9f0-1a2b-3c4d-5e6f-789012345678"]}
{"entity_name":"diana-prince","entity_alias_name":"diana","local_entity_alias":false,"client_id":"e4f7a935-2b68-47c9-b3e6-849c5dfb7a84","client_type":"entity","namespace_id":"aT9S5","namespace_path":"ns1/","mount_accessor":"auth_oidc_1f2e3d4c","mount_type":"oidc","mount_path":"auth/oidc/","token_creation_time":"2025-08-17T11:08:33Z","client_first_used_time":"2025-08-17T11:09:01Z","policies":["security","compliance"],"entity_metadata":{"organization":"SecureTech","team":"Security","clearance":"high"},"entity_alias_metadata":{"email":"diana.prince@securetech.com"},"entity_alias_custom_metadata":{"access_level":"L4"},"entity_group_ids":["f8e7d6c5-4b3a-2918-7654-321098765432"]}
{"entity_name":"frank-castle","entity_alias_name":"frank","local_entity_alias":false,"client_id":"c6b9d248-5a71-39e4-c7f2-951d8eaf6b95","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_jwt_9d8c7b6a","mount_type":"jwt","mount_path":"auth/jwt/","token_creation_time":"2025-08-17T16:43:28Z","client_first_used_time":"2025-08-17T16:44:12Z","policies":["operations","monitoring"],"entity_metadata":{"organization":"CloudOps","team":"SRE","region":"us-east-1"},"entity_alias_metadata":{"sub":"frank.castle@cloudops.io","iss":"https://auth.cloudops.io"},"entity_alias_custom_metadata":{"on_call":"true","expertise":"kubernetes"},"entity_group_ids":["9a8b7c6d-5e4f-3210-9876-543210fedcba"]}
{"entity_name":"grace-hopper","entity_alias_name":"grace","local_entity_alias":true,"client_id":"d8a3e517-6f94-42b7-d5c8-062f9bce4a73","client_type":"entity","namespace_id":"YMjS8","namespace_path":"ns5/","mount_accessor":"auth_userpass_3e2d1c0b","mount_type":"userpass","mount_path":"auth/userpass-legacy/","token_creation_time":"2025-08-18T08:17:55Z","client_first_used_time":"2025-08-18T08:18:23Z","policies":["legacy-admin","data-access"],"entity_metadata":{"organization":"LegacySystems","team":"Platform","tenure":"senior"},"entity_alias_metadata":{"legacy_id":"grace.hopper.001"},"entity_alias_custom_metadata":{"system_access":"mainframe","certification":"vault-admin"},"entity_group_ids":["1f2e3d4c-5b6a-7980-1234-567890abcdef"]}
export const ENTITY_EXPORT = `{"entity_name":"entity_b3e2a7ff","entity_alias_name":"bob","local_entity_alias":false,"client_id":"5692c6ef-c871-128e-fb06-df2be7bfc0db","client_type":"entity","namespace_id":"vK5Bt","namespace_path":"ns1/","mount_accessor":"auth_userpass_f47ad0b4","mount_type":"userpass","mount_path":"auth/userpass/0/","token_creation_time":"2022-09-15T23:48:09Z","client_first_used_time":"2023-09-15T23:48:09Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f"]}
{"entity_name":"entity_b3e2a7ff","entity_alias_name":"bob","local_entity_alias":false,"client_id":"daf8420c-0b6b-34e6-ff38-ee1ed093bea9","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_userpass_f47ad0b4","mount_type":"userpass","mount_path":"auth/userpass/","token_creation_time":"2020-08-15T23:48:09Z","client_first_used_time":"2025-07-15T23:48:09Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f"]}
{"entity_name":"bob-smith","entity_alias_name":"bob","local_entity_alias":false,"client_id":"23a04911-5d72-ba98-11d3-527f2fcf3a81","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_userpass_de28062c","mount_type":"userpass","mount_path":"auth/userpass-test/","token_creation_time":"2020-08-15T23:52:38Z","client_first_used_time":"2025-08-15T23:53:19Z","policies":["base"],"entity_metadata":{"organization":"ACME Inc.","team":"QA"},"entity_alias_metadata":{},"entity_alias_custom_metadata":{"account":"Tester Account"},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f"]}
{"entity_name":"alice-johnson","entity_alias_name":"alice","local_entity_alias":false,"client_id":"a7c8d912-4f61-23b5-88e4-627a3dcf2b92","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_userpass_f47ad0b4","mount_type":"userpass","mount_path":"auth/userpass/","token_creation_time":"2020-08-16T09:15:42Z","client_first_used_time":"2025-09-16T09:16:03Z","policies":["admin","audit"],"entity_metadata":{"organization":"TechCorp","team":"DevOps","location":"San Francisco"},"entity_alias_metadata":{"department":"Engineering"},"entity_alias_custom_metadata":{"role":"Senior Engineer"},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f","a1b2c3d4-5e6f-7g8h-9i0j-k1l2m3n4o5p6"]}
{"entity_name":"charlie-brown","entity_alias_name":"charlie","local_entity_alias":true,"client_id":"b9e5f824-7c92-34d6-a1f8-738b4ecf5d73","client_type":"entity","namespace_id":"whUNi","namespace_path":"ns2/","mount_accessor":"auth_ldap_8a3b9c2d","mount_type":"ldap","mount_path":"auth/ldap/","token_creation_time":"2020-08-16T14:22:17Z","client_first_used_time":"2025-10-16T14:22:45Z","policies":["developer","read-only"],"entity_metadata":{"organization":"StartupXYZ","team":"Backend"},"entity_alias_metadata":{"cn":"charlie.brown","ou":"development"},"entity_alias_custom_metadata":{"project":"microservices"},"entity_group_ids":["c7d8e9f0-1a2b-3c4d-5e6f-789012345678"]}
{"entity_name":"diana-prince","entity_alias_name":"diana","local_entity_alias":false,"client_id":"e4f7a935-2b68-47c9-b3e6-849c5dfb7a84","client_type":"entity","namespace_id":"aT9S5","namespace_path":"ns1/","mount_accessor":"auth_oidc_1f2e3d4c","mount_type":"oidc","mount_path":"auth/oidc/","token_creation_time":"2020-08-17T11:08:33Z","client_first_used_time":"2025-11-17T11:09:01Z","policies":["security","compliance"],"entity_metadata":{"organization":"SecureTech","team":"Security","clearance":"high"},"entity_alias_metadata":{"email":"diana.prince@securetech.com"},"entity_alias_custom_metadata":{"access_level":"L4"},"entity_group_ids":["f8e7d6c5-4b3a-2918-7654-321098765432"]}
{"entity_name":"frank-castle","entity_alias_name":"frank","local_entity_alias":false,"client_id":"c6b9d248-5a71-39e4-c7f2-951d8eaf6b95","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_jwt_9d8c7b6a","mount_type":"jwt","mount_path":"auth/jwt/","token_creation_time":"2020-08-17T16:43:28Z","client_first_used_time":"2025-12-17T16:44:12Z","policies":["operations","monitoring"],"entity_metadata":{"organization":"CloudOps","team":"SRE","region":"us-east-1"},"entity_alias_metadata":{"sub":"frank.castle@cloudops.io","iss":"https://auth.cloudops.io"},"entity_alias_custom_metadata":{"on_call":"true","expertise":"kubernetes"},"entity_group_ids":["9a8b7c6d-5e4f-3210-9876-543210fedcba"]}
{"entity_name":"grace-hopper","entity_alias_name":"grace","local_entity_alias":true,"client_id":"d8a3e517-6f94-42b7-d5c8-062f9bce4a73","client_type":"entity","namespace_id":"YMjS8","namespace_path":"ns5/","mount_accessor":"auth_userpass_3e2d1c0b","mount_type":"userpass","mount_path":"auth/userpass-legacy/","token_creation_time":"2020-08-18T08:17:55Z","client_first_used_time":"2025-06-18T08:18:23Z","policies":["legacy-admin","data-access"],"entity_metadata":{"organization":"LegacySystems","team":"Platform","tenure":"senior"},"entity_alias_metadata":{"legacy_id":"grace.hopper.001"},"entity_alias_custom_metadata":{"system_access":"mainframe","certification":"vault-admin"},"entity_group_ids":["1f2e3d4c-5b6a-7980-1234-567890abcdef"]}
`;
const NON_ENTITY_EXPORT = `{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"46dcOXXH+P1VEQiKTQjtWXEtBlbHdMOWwz+svXf3xuU=","client_type":"non-entity-token","namespace_id":"whUNi","namespace_path":"ns2/","mount_accessor":"auth_ns_token_3b2bf405","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"VKAJVITyTwyqF1GUzwYHwkaK6bbnL1zN8ZJ7viKR8no=","client_type":"non-entity-token","namespace_id":"omjn8","namespace_path":"ns8/","mount_accessor":"auth_ns_token_07b90be7","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"ww4L5n9WE32lPNh3UBgT3JxTDZb1a+m/3jqUffp04tQ=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"cBLb9erIROCw7cczXpfkXTOdnZoVwfWF4EAPD9k61lU=","client_type":"non-entity-token","namespace_id":"aT9S5","namespace_path":"ns1/","mount_accessor":"auth_ns_token_62a4e52a","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"KMHoH3Kvr6nnW2ZIs+i37pYvyVtnuaL3DmyVxUL6boI=","client_type":"non-entity-token","namespace_id":"YMjS8","namespace_path":"ns5/","mount_accessor":"auth_ns_token_45cbc810","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"hcMH4P4IGAN13cJqkwIJLXYoPLTodtOj/wPTZKS0x4U=","client_type":"non-entity-token","namespace_id":"ZNdL5","namespace_path":"ns7/","mount_accessor":"auth_ns_token_8bbd9440","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Oby0ABLmfhqYdfqGfljGHHhAA5zX+BwsGmFu4QGJZd0=","client_type":"non-entity-token","namespace_id":"bJIgY","namespace_path":"ns9/","mount_accessor":"auth_ns_token_8d188479","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Z6MjZuH/VD7HU11efiKoM/hfoxssSbeu4c6DhC7zUZ4=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"1UxaPHJUOPWrf0ivMgBURK6WHzbfXGkcn/C/xI3AeHQ=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:24Z","client_first_used_time":"2025-08-15T16:19:24Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"hfFbwhMucs/f84p2QTOiBLT72i0WLVkIgCGV7RIuWlo=","client_type":"non-entity-token","namespace_id":"x6sKN","namespace_path":"ns4/","mount_accessor":"auth_ns_token_2aaebdc2","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"sOdIr+zoNqOUa4hq6Jv4LCGVr0sTLGbvcRPVGAtUA7g=","client_type":"non-entity-token","namespace_id":"Rsvk5","namespace_path":"ns6/","mount_accessor":"auth_ns_token_f603fd8d","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"vOIAwNhe6P6HFdJQgUIU/8K6Z5e+oxyVP5x3KtTKS6U=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"ZOkJY3P7IzOqulsnEI0JAQQXwTPnXmpGUh9otqNUclc=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Lsha/HH+xLZq92XG4GYZVlwVQCiqPCUIuoego4aCybU=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Tsl/u7CDTYSXA9HRwlNTW7K/yyEe5PDkLOVTvTWy3q0=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"vnq6JntpiGV4FN6GDICLECe2in31aanLA6Q1UWqBmL0=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:24Z","client_first_used_time":"2025-08-15T16:19:24Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"MRMrywfPPL3QnKFMBGfRjjmaefBRH1VKpQVIfrd0Xb4=","client_type":"non-entity-token","namespace_id":"6aDiU","namespace_path":"ns3/","mount_accessor":"auth_ns_token_ef771c23","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Rce6fjHs15+hDl5XdXbWmzGNYrTcQsJuaoqfs9Vrhvw=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:24Z","client_first_used_time":"2025-08-15T16:19:24Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
const NON_ENTITY_EXPORT = `{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"46dcOXXH+P1VEQiKTQjtWXEtBlbHdMOWwz+svXf3xuU=","client_type":"non-entity-token","namespace_id":"whUNi","namespace_path":"ns2/","mount_accessor":"auth_ns_token_3b2bf405","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:21Z","client_first_used_time":"2025-05-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"VKAJVITyTwyqF1GUzwYHwkaK6bbnL1zN8ZJ7viKR8no=","client_type":"non-entity-token","namespace_id":"omjn8","namespace_path":"ns8/","mount_accessor":"auth_ns_token_07b90be7","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:22Z","client_first_used_time":"2025-05-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"ww4L5n9WE32lPNh3UBgT3JxTDZb1a+m/3jqUffp04tQ=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:23Z","client_first_used_time":"2025-05-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"cBLb9erIROCw7cczXpfkXTOdnZoVwfWF4EAPD9k61lU=","client_type":"non-entity-token","namespace_id":"aT9S5","namespace_path":"ns1/","mount_accessor":"auth_ns_token_62a4e52a","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:21Z","client_first_used_time":"2025-06-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"KMHoH3Kvr6nnW2ZIs+i37pYvyVtnuaL3DmyVxUL6boI=","client_type":"non-entity-token","namespace_id":"YMjS8","namespace_path":"ns5/","mount_accessor":"auth_ns_token_45cbc810","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:22Z","client_first_used_time":"2025-06-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"hcMH4P4IGAN13cJqkwIJLXYoPLTodtOj/wPTZKS0x4U=","client_type":"non-entity-token","namespace_id":"ZNdL5","namespace_path":"ns7/","mount_accessor":"auth_ns_token_8bbd9440","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:22Z","client_first_used_time":"2025-06-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Oby0ABLmfhqYdfqGfljGHHhAA5zX+BwsGmFu4QGJZd0=","client_type":"non-entity-token","namespace_id":"bJIgY","namespace_path":"ns9/","mount_accessor":"auth_ns_token_8d188479","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:22Z","client_first_used_time":"2025-06-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Z6MjZuH/VD7HU11efiKoM/hfoxssSbeu4c6DhC7zUZ4=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:23Z","client_first_used_time":"2025-07-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"1UxaPHJUOPWrf0ivMgBURK6WHzbfXGkcn/C/xI3AeHQ=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:24Z","client_first_used_time":"2025-07-15T16:19:24Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"hfFbwhMucs/f84p2QTOiBLT72i0WLVkIgCGV7RIuWlo=","client_type":"non-entity-token","namespace_id":"x6sKN","namespace_path":"ns4/","mount_accessor":"auth_ns_token_2aaebdc2","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:21Z","client_first_used_time":"2025-07-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"sOdIr+zoNqOUa4hq6Jv4LCGVr0sTLGbvcRPVGAtUA7g=","client_type":"non-entity-token","namespace_id":"Rsvk5","namespace_path":"ns6/","mount_accessor":"auth_ns_token_f603fd8d","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:22Z","client_first_used_time":"2025-07-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"vOIAwNhe6P6HFdJQgUIU/8K6Z5e+oxyVP5x3KtTKS6U=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"ZOkJY3P7IzOqulsnEI0JAQQXwTPnXmpGUh9otqNUclc=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Lsha/HH+xLZq92XG4GYZVlwVQCiqPCUIuoego4aCybU=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Tsl/u7CDTYSXA9HRwlNTW7K/yyEe5PDkLOVTvTWy3q0=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:23Z","client_first_used_time":"2025-09-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"vnq6JntpiGV4FN6GDICLECe2in31aanLA6Q1UWqBmL0=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:24Z","client_first_used_time":"2025-09-15T16:19:24Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"MRMrywfPPL3QnKFMBGfRjjmaefBRH1VKpQVIfrd0Xb4=","client_type":"non-entity-token","namespace_id":"6aDiU","namespace_path":"ns3/","mount_accessor":"auth_ns_token_ef771c23","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:21Z","client_first_used_time":"2025-09-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Rce6fjHs15+hDl5XdXbWmzGNYrTcQsJuaoqfs9Vrhvw=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2020-08-15T16:19:24Z","client_first_used_time":"2025-09-15T16:19:24Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
`;
const ACME_EXPORT = `{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.3U8nSB_yMBvrdu7PvAVykKurDiaH_vQGaEdAUsp-Cew","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:47:54Z","client_first_used_time":"2025-08-21T18:47:54Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.77tKDzxw0i81Nr4XLliTP9xRsztXLTuS16nN32B9jHA","client_type":"pki-acme","namespace_id":"whUNi","namespace_path":"ns2/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:48:17Z","client_first_used_time":"2025-08-21T18:48:17Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.RoN77EahLU0wfem4z--ZqJSaqOZ7RvBWR3OkPHM_xaw","client_type":"pki-acme","namespace_id":"omjn8","namespace_path":"ns8/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:49:26Z","client_first_used_time":"2025-08-21T18:49:26Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.5S7vaaJIrXormQSLHv4YkBhVfu6Ug0GERhTVTCrq-Fk","client_type":"pki-acme","namespace_id":"aT9S5","namespace_path":"ns1/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:45:12Z","client_first_used_time":"2025-08-21T18:45:12Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.IeJQMwtkReJHVNL6fZLmqiu8-Re4JdKCQixXkfcaSRE","client_type":"pki-acme","namespace_id":"YMjS8","namespace_path":"ns5/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:45:41Z","client_first_used_time":"2025-08-21T18:45:41Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.vTm3SCZom90qy3SuyIacpVsQgGLx7ASf3SeGpqn5XBA","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:47:19Z","client_first_used_time":"2025-08-21T18:47:19Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.64jWs15k6roUH6MiQ2u80K08Bmqw8IQOpqTpDZgZ1f4","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:47:25Z","client_first_used_time":"2025-08-21T18:47:25Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.RkjnwyIIn6bnc4LDdKQ9HNfnhuVXT7vQONXgGHJl4CE","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:49:21Z","client_first_used_time":"2025-08-21T18:49:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.uozIMLVXDMU7Fc2TFFwq0-uE1GFSui5rbTI1XyNAYBY","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:44:44Z","client_first_used_time":"2025-08-21T18:44:44Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.WiLdlzq93WtVmObB__CC2SPX6sI7EVLTTzxOIRHHN3o","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:44:49Z","client_first_used_time":"2025-08-21T18:44:49Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.P65jgamzwLYbKyxTlJFD5DL3sIUbusbXcQhYaysgzlU","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:45:59Z","client_first_used_time":"2025-08-21T18:45:59Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.2REWUkDLXAG2UB0ZJQcjPnHc4H39aq8fG3LMaHSHKow","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:46:05Z","client_first_used_time":"2025-08-21T18:46:05Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.Eeyq9-EfWv-iE9Aj3DzCU4r9P8V1Maewx51vcxMN-jA","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:46:10Z","client_first_used_time":"2025-08-21T18:46:10Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.vaeb2KR58sRuMUdUlv2TsbaOkSICTAxmJxhkuOs8ZiM","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:46:22Z","client_first_used_time":"2025-08-21T18:46:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.xEPG0eNfrAfRgXg6AKjsCrFPMs0IbLTCfUsCie_rfzY","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:46:51Z","client_first_used_time":"2025-08-21T18:46:51Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.Bkg4862LEoFXJUDWlfFtJHU9a69KRJPiEdw5XCbkkAI","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:47:42Z","client_first_used_time":"2025-08-21T18:47:42Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
const ACME_EXPORT = `{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.3U8nSB_yMBvrdu7PvAVykKurDiaH_vQGaEdAUsp-Cew","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:47:54Z","client_first_used_time":"2025-06-21T18:47:54Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.77tKDzxw0i81Nr4XLliTP9xRsztXLTuS16nN32B9jHA","client_type":"pki-acme","namespace_id":"whUNi","namespace_path":"ns2/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:48:17Z","client_first_used_time":"2025-07-21T18:48:17Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.RoN77EahLU0wfem4z--ZqJSaqOZ7RvBWR3OkPHM_xaw","client_type":"pki-acme","namespace_id":"omjn8","namespace_path":"ns8/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:49:26Z","client_first_used_time":"2025-08-21T18:49:26Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.5S7vaaJIrXormQSLHv4YkBhVfu6Ug0GERhTVTCrq-Fk","client_type":"pki-acme","namespace_id":"aT9S5","namespace_path":"ns1/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:45:12Z","client_first_used_time":"2025-08-21T18:45:12Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.IeJQMwtkReJHVNL6fZLmqiu8-Re4JdKCQixXkfcaSRE","client_type":"pki-acme","namespace_id":"YMjS8","namespace_path":"ns5/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:45:41Z","client_first_used_time":"2025-08-21T18:45:41Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.vTm3SCZom90qy3SuyIacpVsQgGLx7ASf3SeGpqn5XBA","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:47:19Z","client_first_used_time":"2025-08-21T18:47:19Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.64jWs15k6roUH6MiQ2u80K08Bmqw8IQOpqTpDZgZ1f4","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:47:25Z","client_first_used_time":"2025-08-21T18:47:25Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.RkjnwyIIn6bnc4LDdKQ9HNfnhuVXT7vQONXgGHJl4CE","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:49:21Z","client_first_used_time":"2025-08-21T18:49:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.uozIMLVXDMU7Fc2TFFwq0-uE1GFSui5rbTI1XyNAYBY","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:44:44Z","client_first_used_time":"2025-08-21T18:44:44Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.WiLdlzq93WtVmObB__CC2SPX6sI7EVLTTzxOIRHHN3o","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:44:49Z","client_first_used_time":"2025-08-21T18:44:49Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.P65jgamzwLYbKyxTlJFD5DL3sIUbusbXcQhYaysgzlU","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:45:59Z","client_first_used_time":"2025-08-21T18:45:59Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.2REWUkDLXAG2UB0ZJQcjPnHc4H39aq8fG3LMaHSHKow","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:46:05Z","client_first_used_time":"2025-08-21T18:46:05Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.Eeyq9-EfWv-iE9Aj3DzCU4r9P8V1Maewx51vcxMN-jA","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:46:10Z","client_first_used_time":"2025-08-21T18:46:10Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.vaeb2KR58sRuMUdUlv2TsbaOkSICTAxmJxhkuOs8ZiM","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:46:22Z","client_first_used_time":"2025-08-21T18:46:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.xEPG0eNfrAfRgXg6AKjsCrFPMs0IbLTCfUsCie_rfzY","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:46:51Z","client_first_used_time":"2025-08-21T18:46:51Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.Bkg4862LEoFXJUDWlfFtJHU9a69KRJPiEdw5XCbkkAI","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2020-08-21T18:47:42Z","client_first_used_time":"2025-08-21T18:47:42Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
`;
const SECRET_SYNC_EXPORT = `{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.3U8nSB_yMBvrdu7PvAVykKurDiaH_vQGaEdAUsp-Cew","client_type":"secret-sync","namespace_id":"root","namespace_path":"","mount_accessor":"kv_06dad7b8","mount_type":"kv","mount_path":"secrets/kv/0","token_creation_time":"2025-08-21T18:47:54Z","client_first_used_time":"2025-08-21T18:47:54Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.77tKDzxw0i81Nr4XLliTP9xRsztXLTuS16nN32B9jHA","client_type":"secret-sync","namespace_id":"ZNdL5","namespace_path":"ns7/","mount_accessor":"kv_06dad7b8","mount_type":"ns_kv","mount_path":"secrets/kv/0","token_creation_time":"2025-08-21T18:48:17Z","client_first_used_time":"2025-08-21T18:48:17Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.RoN77EahLU0wfem4z--ZqJSaqOZ7RvBWR3OkPHM_xaw","client_type":"secret-sync","namespace_id":"bJIgY","namespace_path":"ns9/","mount_accessor":"kv_12abc3d4","mount_type":"ns_kv","mount_path":"secrets/kv/1","token_creation_time":"2025-08-21T18:49:26Z","client_first_used_time":"2025-08-21T18:49:26Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.5S7vaaJIrXormQSLHv4YkBhVfu6Ug0GERhTVTCrq-Fk","client_type":"secret-sync","namespace_id":"x6sKN","namespace_path":"ns4/","mount_accessor":"kv_06dad7b8","mount_type":"ns_kv","mount_path":"secrets/kv/0","token_creation_time":"2025-08-21T18:45:12Z","client_first_used_time":"2025-08-21T18:45:12Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.IeJQMwtkReJHVNL6fZLmqiu8-Re4JdKCQixXkfcaSRE","client_type":"secret-sync","namespace_id":"Rsvk5","namespace_path":"ns6/","mount_accessor":"kv_12abc3d4","mount_type":"ns_kv","mount_path":"secrets/kv/1","token_creation_time":"2025-08-21T18:45:41Z","client_first_used_time":"2025-08-21T18:45:41Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.vTm3SCZom90qy3SuyIacpVsQgGLx7ASf3SeGpqn5XBA","client_type":"secret-sync","namespace_id":"root","namespace_path":"","mount_accessor":"kv_06dad7b8","mount_type":"kv","mount_path":"secrets/kv/0","token_creation_time":"2025-08-21T18:47:19Z","client_first_used_time":"2025-08-21T18:47:19Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.64jWs15k6roUH6MiQ2u80K08Bmqw8IQOpqTpDZgZ1f4","client_type":"secret-sync","namespace_id":"6aDiU","namespace_path":"ns3/","mount_accessor":"kv_12abc3d4","mount_type":"kv","mount_path":"secrets/kv/1","token_creation_time":"2025-08-21T18:47:25Z","client_first_used_time":"2025-08-21T18:47:25Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.RkjnwyIIn6bnc4LDdKQ9HNfnhuVXT7vQONXgGHJl4CE","client_type":"secret-sync","namespace_id":"root","namespace_path":"","mount_accessor":"kv_06dad7b8","mount_type":"kv","mount_path":"secrets/kv/0","token_creation_time":"2025-08-21T18:49:21Z","client_first_used_time":"2025-08-21T18:49:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
const SECRET_SYNC_EXPORT = `{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.3U8nSB_yMBvrdu7PvAVykKurDiaH_vQGaEdAUsp-Cew","client_type":"secret-sync","namespace_id":"root","namespace_path":"","mount_accessor":"kv_06dad7b8","mount_type":"kv","mount_path":"secrets/kv/0/","token_creation_time":"2020-08-21T18:47:54Z","client_first_used_time":"2025-05-21T18:47:54Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.77tKDzxw0i81Nr4XLliTP9xRsztXLTuS16nN32B9jHA","client_type":"secret-sync","namespace_id":"ZNdL5","namespace_path":"ns7/","mount_accessor":"kv_06dad7b8","mount_type":"ns_kv","mount_path":"secrets/kv/0/","token_creation_time":"2020-08-21T18:48:17Z","client_first_used_time":"2025-05-21T18:48:17Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.RoN77EahLU0wfem4z--ZqJSaqOZ7RvBWR3OkPHM_xaw","client_type":"secret-sync","namespace_id":"bJIgY","namespace_path":"ns9/","mount_accessor":"kv_12abc3d4","mount_type":"ns_kv","mount_path":"secrets/kv/1","token_creation_time":"2020-08-21T18:49:26Z","client_first_used_time":"2025-06-21T18:49:26Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.5S7vaaJIrXormQSLHv4YkBhVfu6Ug0GERhTVTCrq-Fk","client_type":"secret-sync","namespace_id":"x6sKN","namespace_path":"ns4/","mount_accessor":"kv_06dad7b8","mount_type":"ns_kv","mount_path":"secrets/kv/0/","token_creation_time":"2020-08-21T18:45:12Z","client_first_used_time":"2025-06-21T18:45:12Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.IeJQMwtkReJHVNL6fZLmqiu8-Re4JdKCQixXkfcaSRE","client_type":"secret-sync","namespace_id":"Rsvk5","namespace_path":"ns6/","mount_accessor":"kv_12abc3d4","mount_type":"ns_kv","mount_path":"secrets/kv/1","token_creation_time":"2020-08-21T18:45:41Z","client_first_used_time":"2025-07-21T18:45:41Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.vTm3SCZom90qy3SuyIacpVsQgGLx7ASf3SeGpqn5XBA","client_type":"secret-sync","namespace_id":"root","namespace_path":"","mount_accessor":"kv_06dad7b8","mount_type":"kv","mount_path":"secrets/kv/0/","token_creation_time":"2020-08-21T18:47:19Z","client_first_used_time":"2025-08-21T18:47:19Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.64jWs15k6roUH6MiQ2u80K08Bmqw8IQOpqTpDZgZ1f4","client_type":"secret-sync","namespace_id":"6aDiU","namespace_path":"ns3/","mount_accessor":"kv_12abc3d4","mount_type":"kv","mount_path":"secrets/kv/1","token_creation_time":"2020-08-21T18:47:25Z","client_first_used_time":"2025-08-21T18:47:25Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.RkjnwyIIn6bnc4LDdKQ9HNfnhuVXT7vQONXgGHJl4CE","client_type":"secret-sync","namespace_id":"root","namespace_path":"","mount_accessor":"kv_06dad7b8","mount_type":"kv","mount_path":"secrets/kv/0/","token_creation_time":"2020-08-21T18:49:21Z","client_first_used_time":"2025-08-21T18:49:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]}
`;
export const ACTIVITY_EXPORT_STUB = ENTITY_EXPORT + NON_ENTITY_EXPORT + ACME_EXPORT + SECRET_SYNC_EXPORT;

View file

@ -13,7 +13,10 @@ export const CLIENT_COUNT = {
mountPaths: '[data-test-counts-auth-mounts]',
},
dateRange: {
dropdownOption: (idx = 0) => `[data-test-date-range-billing-start="${idx}"]`,
dropdownOption: (idx: number | null) =>
typeof idx === 'number'
? `[data-test-date-range-billing-start="${idx}"]`
: '[data-test-date-range-billing-start]',
dateDisplay: (name: string) => (name ? `[data-test-date-range="${name}"]` : '[data-test-date-range]'),
edit: '[data-test-date-range-edit]',
editModal: '[data-test-date-range-edit-modal]',

View file

@ -5,7 +5,7 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { click, fillIn, render } from '@ember/test-helpers';
import { click, fillIn, findAll, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import Sinon from 'sinon';
import timestamp from 'core/utils/timestamp';
@ -22,7 +22,7 @@ module('Integration | Component | clients/date-range', function (hooks) {
this.now = timestamp.now();
this.startTimestamp = '2018-01-01T14:15:30';
this.endTimestamp = '2019-01-31T14:15:30';
this.billingStartTime = '2018-01-01T14:15:30';
this.billingStartTime = '';
this.retentionMonths = 48;
this.onChange = Sinon.spy();
this.setEditModalVisible = Sinon.stub().callsFake((visible) => {
@ -51,8 +51,8 @@ module('Integration | Component | clients/date-range', function (hooks) {
await fillIn(DATE_RANGE.editDate('end'), '2018-03');
await click(GENERAL.submitButton);
const { start_time, end_time } = this.onChange.lastCall.args[0];
assert.strictEqual(start_time, '2018-01-01T00:00:00.000Z', 'it formats start_time param');
assert.strictEqual(end_time, '2018-03-31T23:59:59.000Z', 'it formats end_time param');
assert.strictEqual(start_time, '2018-01-01T00:00:00Z', 'it formats start_time param');
assert.strictEqual(end_time, '2018-03-31T23:59:59Z', 'it formats end_time param');
assert.dom(DATE_RANGE.editModal).doesNotExist('closes modal');
});
@ -101,4 +101,29 @@ module('Integration | Component | clients/date-range', function (hooks) {
await click(GENERAL.submitButton);
assert.false(this.onChange.called);
});
module('enterprise', function (hooks) {
hooks.beforeEach(function () {
this.version = this.owner.lookup('service:version');
this.version.type = 'enterprise';
this.billingStartTime = '2018-01-01T14:15:30';
});
test('it billing start date dropdown for enterprise', async function (assert) {
await this.renderComponent();
await click(DATE_RANGE.edit);
const expectedPeriods = [
'January 2018',
'January 2017',
'January 2016',
'January 2015',
'January 2014',
];
const dropdownList = findAll(DATE_RANGE.dropdownOption(null));
dropdownList.forEach((item, idx) => {
const month = expectedPeriods[idx];
assert.dom(item).hasText(month, `dropdown index: ${idx} renders ${month}`);
});
});
});
});

View file

@ -25,7 +25,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) {
{ namespace_path: 'ns1/', mount_type: 'ns_token/', mount_path: 'auth/token/' },
];
this.onFilter = sinon.spy();
this.filterQueryParams = { namespace_path: '', mount_path: '', mount_type: '' };
this.filterQueryParams = { namespace_path: '', mount_path: '', mount_type: '', month: '' };
this.renderComponent = async () => {
await render(hbs`
@ -260,7 +260,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) {
const [afterUpdate] = this.onFilter.lastCall.args;
assert.propEqual(
afterUpdate,
{ namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/' },
{ namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/', month: '' },
'callback fires with updated selection'
);
assert.dom(FILTERS.tagContainer).hasText('Filters applied: admin/ auth/userpass-root/ token/');
@ -273,7 +273,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) {
const [afterClear] = this.onFilter.lastCall.args;
assert.propEqual(
afterClear,
{ namespace_path: '', mount_path: '', mount_type: '' },
{ namespace_path: '', mount_path: '', mount_type: '', month: '' },
'onFilter callback has empty values when "Clear filters" is clicked'
);
assert.dom(FILTERS.tagContainer).hasText('Filters applied: None');
@ -286,7 +286,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) {
const afterClear = this.onFilter.lastCall.args[0];
assert.propEqual(
afterClear,
{ namespace_path: '', mount_path: 'auth/userpass-root/', mount_type: 'token/' },
{ namespace_path: '', mount_path: 'auth/userpass-root/', mount_type: 'token/', month: '' },
'onFilter callback fires with empty namespace_path'
);
});

View file

@ -250,6 +250,8 @@ module('Integration | Component | clients/page/client-list', function (hooks) {
});
test('it renders empty state message when filter selections yield no results', async function (assert) {
const flags = this.owner.lookup('service:flags');
flags.activatedFlags = ['secrets-sync'];
this.filterQueryParams = { namespace_path: 'dev/', mount_path: 'pluto/', mount_type: 'banana' };
await this.renderComponent();
@ -257,7 +259,18 @@ module('Integration | Component | clients/page/client-list', function (hooks) {
await click(GENERAL.hdsTab(tabName));
assert
.dom(CLIENT_COUNT.card('table empty state'))
.hasText('No data found Clear or change filters to view client count data.');
.hasText('No data found Select another client type or update filters to view client count data.');
}
});
test('it renders empty state message when secret sync is not activated', async function (assert) {
this.filterQueryParams = { namespace_path: 'dev/', mount_path: 'pluto/', mount_type: 'banana' };
await this.renderComponent();
await click(GENERAL.hdsTab('Secret sync'));
assert
.dom(CLIENT_COUNT.card('table empty state'))
.hasText(
'No secret sync clients No data is available because Secrets Sync has not been activated. Activate Secrets Sync'
);
});
});

View file

@ -94,10 +94,10 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
.hasText('Tracking is disabled', 'Config disabled alert renders');
});
const jan23start = '2023-01-01T00:00:00.000Z';
const jan23start = '2023-01-01T00:00:00Z';
// license start is July 2, 2024 on date change it recalculates start to beginning of the month
const july23start = '2023-07-01T00:00:00.000Z';
const dec23end = '2023-12-31T23:59:59.000Z';
const july23start = '2023-07-01T00:00:00Z';
const dec23end = '2023-12-31T23:59:59Z';
const testCases = [
{
scenario: 'changing start only',
@ -123,7 +123,7 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
},
];
testCases.forEach((testCase) => {
test(`it should send correct millis value on filter change when ${testCase.scenario}`, async function (assert) {
test(`it should send correct timestamp on filter change when ${testCase.scenario}`, async function (assert) {
assert.expect(5);
this.owner.lookup('service:version').type = 'community';
this.onFilterChange = (params) => {

View file

@ -5,7 +5,7 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { click, fillIn, find, findAll, render, triggerEvent } from '@ember/test-helpers';
import { click, find, findAll, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers';
@ -13,6 +13,7 @@ import { CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import { ClientFilters, flattenMounts } from 'core/utils/client-count-utils';
import { parseAPITimestamp } from 'core/utils/date-formatters';
module('Integration | Component | clients/page/overview', function (hooks) {
setupRenderingTest(hooks);
@ -30,7 +31,7 @@ module('Integration | Component | clients/page/overview', function (hooks) {
this.activity = await this.store.queryRecord('clients/activity', {});
this.mostRecentMonth = this.activity.byMonth[this.activity.byMonth.length - 1];
this.onFilterChange = sinon.spy();
this.filterQueryParams = { namespace_path: '', mount_path: '', mount_type: '' };
this.filterQueryParams = { namespace_path: '', mount_path: '', mount_type: '', month: '' };
this.renderComponent = () =>
render(hbs`
<Clients::Page::Overview
@ -40,11 +41,9 @@ module('Integration | Component | clients/page/overview', function (hooks) {
/>`);
this.assertTableData = async (assert, filterKey, filterValue) => {
const expectedData = flattenMounts(this.mostRecentMonth.new_clients.namespaces).filter(
const expectedData = flattenMounts(this.activity.byNamespace).filter(
(d) => d[filterKey] === filterValue
);
await fillIn(GENERAL.selectByAttr('attribution-month'), this.mostRecentMonth.timestamp);
assert.dom(GENERAL.tableRow()).exists({ count: expectedData.length });
// Find all rendered rows and assert they satisfy the filter value and table data matches expected values
const rows = findAll(GENERAL.tableRow());
rows.forEach((_, idx) => {
@ -85,45 +84,6 @@ module('Integration | Component | clients/page/overview', function (hooks) {
this.activity = await this.store.queryRecord('clients/activity', {});
await this.renderComponent();
assert.dom(CLIENT_COUNT.card('Client attribution')).doesNotExist('it does not render attribution card');
assert.dom(GENERAL.selectByAttr('attribution-month')).doesNotExist('it hides months dropdown');
});
test('it shows correct state message when selected month has no data', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('attribution-month')).exists('shows month selection dropdown');
await fillIn(GENERAL.selectByAttr('attribution-month'), '2023-06-01T00:00:00Z');
assert
.dom(CLIENT_COUNT.card('table empty state'))
.hasText('No data found Clear or change filters to view client count data. Client count documentation');
});
test('it shows table when month selection has data', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('attribution-month')).exists('shows month selection dropdown');
await fillIn(GENERAL.selectByAttr('attribution-month'), '9/23');
assert.dom(CLIENT_COUNT.card('table empty state')).doesNotExist('does not show card when table has data');
assert.dom(GENERAL.table('attribution')).exists('shows table');
assert.dom(GENERAL.paginationInfo).hasText('16 of 6', 'shows correct pagination info');
assert.dom(GENERAL.paginationSizeSelector).hasValue('10', 'page size selector defaults to "10"');
});
test('it shows correct month options for billing period', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.selectByAttr('attribution-month')).exists('shows month selection dropdown');
await fillIn(GENERAL.selectByAttr('attribution-month'), '');
await triggerEvent(GENERAL.selectByAttr('attribution-month'), 'change');
// assert that months options in select are those of selected billing period
// '' represents default state of 'Select month'
const expectedOptions = ['', ...this.activity.byMonth.reverse().map((m) => m.timestamp)];
const actualOptions = findAll(`${GENERAL.selectByAttr('attribution-month')} option`).map(
(option) => option.value
);
assert.deepEqual(actualOptions, expectedOptions, 'All <option> values match expected list');
});
test('it initially renders attribution with by_namespace data', async function (assert) {
@ -136,18 +96,22 @@ module('Integration | Component | clients/page/overview', function (hooks) {
});
test('it renders dropdown lists from activity response to filter table data', async function (assert) {
const mounts = flattenMounts(this.mostRecentMonth.new_clients.namespaces);
const expectedMonths = this.activity.byMonth
.map((m) => parseAPITimestamp(m.timestamp, 'MMMM yyyy'))
.reverse();
const mounts = flattenMounts(this.activity.byNamespace);
const expectedNamespaces = [...new Set(mounts.map((m) => m.namespace_path))];
const expectedMountPaths = [...new Set(mounts.map((m) => m.mount_path))];
const expectedMountTypes = [...new Set(mounts.map((m) => m.mount_type))];
await this.renderComponent();
// Assert filters do not exist until month is selected
assert.dom(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)).doesNotExist();
assert.dom(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)).doesNotExist();
assert.dom(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)).doesNotExist();
// Select month
await fillIn(GENERAL.selectByAttr('attribution-month'), this.mostRecentMonth.timestamp);
// Select each filter
await click(FILTERS.dropdownToggle(ClientFilters.MONTH));
findAll(`${FILTERS.dropdown(ClientFilters.MONTH)} li button`).forEach((item, idx) => {
const expected = expectedMonths[idx];
assert.dom(item).hasText(expected, `month dropdown renders: ${expected}`);
});
await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE));
findAll(`${FILTERS.dropdown(ClientFilters.NAMESPACE)} li button`).forEach((item, idx) => {
const expected = expectedNamespaces[idx];
@ -169,13 +133,23 @@ module('Integration | Component | clients/page/overview', function (hooks) {
// * FILTERING ASSERTIONS
// Filtering tests are split between integration and acceptance tests
// because changing filters updates the URL query params/
test('it filters attribution table by month', async function (assert) {
// because changing filters updates the URL query params
test('it shows correct empty state message when selected month has no data', async function (assert) {
this.filterQueryParams[ClientFilters.MONTH] = '2023-06-01T00:00:00Z';
await this.renderComponent();
assert
.dom(CLIENT_COUNT.card('table empty state'))
.hasText('No data found Clear or change filters to view client count data. Client count documentation');
});
test('it filters data if @filterQueryParams specify a month', async function (assert) {
const filterKey = 'month';
const filterValue = this.mostRecentMonth.timestamp;
this.filterQueryParams[filterKey] = filterValue;
await this.renderComponent();
const mostRecentMonth = this.mostRecentMonth;
await fillIn(GENERAL.selectByAttr('attribution-month'), mostRecentMonth.timestamp);
// Drill down to new_clients then grab the first mount
const sortedMounts = flattenMounts(mostRecentMonth.new_clients.namespaces).sort(
const sortedMounts = flattenMounts(this.mostRecentMonth.new_clients.namespaces).sort(
(a, b) => b.clients - a.clients
);
const topMount = sortedMounts[0];
@ -184,29 +158,9 @@ module('Integration | Component | clients/page/overview', function (hooks) {
assert.dom(GENERAL.tableData(0, 'mount_path')).hasText(topMount.mount_path);
});
test('it resets pagination when a month is selected change', async function (assert) {
const attributionByMount = flattenMounts(this.activity.byNamespace);
await this.renderComponent();
// Decrease page size for test so we don't have to seed more data
await fillIn(GENERAL.paginationSizeSelector, '5');
assert.dom(GENERAL.paginationInfo).hasText(`15 of ${attributionByMount.length}`);
// Change pages because we should go back to page 1 when a month is selected
await click(GENERAL.nextPage);
assert.dom(GENERAL.tableRow()).exists({ count: 1 }, '1 row render');
assert.dom(GENERAL.paginationInfo).hasText(`66 of ${attributionByMount.length}`);
// Select a month and assert table resets to page 1
await fillIn(GENERAL.selectByAttr('attribution-month'), this.mostRecentMonth.timestamp);
const monthMounts = flattenMounts(this.mostRecentMonth.new_clients.namespaces);
assert
.dom(GENERAL.paginationInfo)
.hasText(`15 of ${monthMounts.length}`, 'pagination resets to page one');
assert.dom(GENERAL.tableRow()).exists({ count: 5 }, '5 rows render');
assert.dom(GENERAL.paginationSizeSelector).hasValue('5', 'size selector does not reset to 10');
});
test('it filters data if @filterQueryParams specify a namespace_path', async function (assert) {
const filterKey = 'namespace_path';
const filterValue = 'ns1';
const filterValue = 'ns1/';
this.filterQueryParams[filterKey] = filterValue;
await this.renderComponent();
await this.assertTableData(assert, filterKey, filterValue);
@ -214,7 +168,7 @@ module('Integration | Component | clients/page/overview', function (hooks) {
test('it filters data if @filterQueryParams specify a mount_path', async function (assert) {
const filterKey = 'mount_path';
const filterValue = 'acme/pki/0';
const filterValue = 'acme/pki/0/';
this.filterQueryParams[filterKey] = filterValue;
await this.renderComponent();
await this.assertTableData(assert, filterKey, filterValue);
@ -230,14 +184,14 @@ module('Integration | Component | clients/page/overview', function (hooks) {
test('it filters data if @filterQueryParams specify a multiple filters', async function (assert) {
this.filterQueryParams = {
namespace_path: 'ns1',
mount_path: 'auth/userpass/0',
month: this.mostRecentMonth.timestamp,
namespace_path: 'ns1/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
};
const { namespace_path, mount_path, mount_type } = this.filterQueryParams;
await this.renderComponent();
await fillIn(GENERAL.selectByAttr('attribution-month'), this.mostRecentMonth.timestamp);
const expectedData = flattenMounts(this.mostRecentMonth.new_clients.namespaces).find(
(d) => d.namespace_path === namespace_path && d.mount_path === mount_path && d.mount_type === mount_type
);

View file

@ -173,8 +173,8 @@ module('Integration | Util | client count utils', function (hooks) {
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');
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');
@ -295,8 +295,8 @@ module('Integration | Util | client count utils', function (hooks) {
acme_clients: 0,
clients: 1,
entity_clients: 1,
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'root',
non_entity_clients: 0,
@ -338,8 +338,8 @@ module('Integration | Util | client count utils', function (hooks) {
acme_clients: 0,
clients: 1,
entity_clients: 1,
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'root',
non_entity_clients: 0,
@ -398,10 +398,10 @@ module('Integration | Util | client count utils', function (hooks) {
const mountPathFilter = filterTableData(this.mockMountData, {
namespace_path: '',
mount_path: 'acme/pki/0',
mount_path: 'acme/pki/0/',
mount_type: '',
});
const expectedMountPathFilter = this.mockMountData.filter((m) => m.mount_path === 'acme/pki/0');
const expectedMountPathFilter = this.mockMountData.filter((m) => m.mount_path === 'acme/pki/0/');
assert.propEqual(mountPathFilter, expectedMountPathFilter, 'it filters by mount_path');
this.assertOriginal(assert);
@ -429,13 +429,13 @@ module('Integration | Util | client count utils', function (hooks) {
const allFilters = filterTableData(this.mockMountData, {
namespace_path: 'root',
mount_path: 'auth/userpass/0',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
});
const expectedAllFilters = [
{
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'root',
acme_clients: 0,
@ -476,8 +476,8 @@ module('Integration | Util | client count utils', function (hooks) {
acme_clients: 0,
clients: 8091,
entity_clients: 4002,
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'root',
non_entity_clients: 4089,
@ -487,8 +487,8 @@ module('Integration | Util | client count utils', function (hooks) {
acme_clients: 0,
clients: 4290,
entity_clients: 0,
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
label: 'secrets/kv/0/',
mount_path: 'secrets/kv/0/',
mount_type: 'kv',
namespace_path: 'root',
non_entity_clients: 0,
@ -498,16 +498,16 @@ module('Integration | Util | client count utils', function (hooks) {
acme_clients: 4003,
clients: 4003,
entity_clients: 0,
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
label: 'acme/pki/0/',
mount_path: 'acme/pki/0/',
mount_type: 'pki',
namespace_path: 'root',
non_entity_clients: 0,
secret_syncs: 0,
},
{
client_first_used_time: '2025-08-15T23:48:09Z',
client_id: '5692c6ef-c871-128e-fb06-df2be7bfc0db',
client_first_used_time: '2025-07-15T23:48:09Z',
client_id: 'daf8420c-0b6b-34e6-ff38-ee1ed093bea9',
client_type: 'entity',
entity_alias_custom_metadata: {},
entity_alias_metadata: {},
@ -522,7 +522,7 @@ module('Integration | Util | client count utils', function (hooks) {
namespace_id: 'root',
namespace_path: '',
policies: [],
token_creation_time: '2025-08-15T23:48:09Z',
token_creation_time: '2020-08-15T23:48:09Z',
},
{
client_first_used_time: '2025-08-15T23:53:19Z',
@ -546,10 +546,10 @@ module('Integration | Util | client count utils', function (hooks) {
namespace_id: 'root',
namespace_path: '',
policies: ['base'],
token_creation_time: '2025-08-15T23:52:38Z',
token_creation_time: '2020-08-15T23:52:38Z',
},
{
client_first_used_time: '2025-08-16T09:16:03Z',
client_first_used_time: '2025-09-16T09:16:03Z',
client_id: 'a7c8d912-4f61-23b5-88e4-627a3dcf2b92',
client_type: 'entity',
entity_alias_custom_metadata: {
@ -573,10 +573,10 @@ module('Integration | Util | client count utils', function (hooks) {
namespace_id: 'root',
namespace_path: '',
policies: ['admin', 'audit'],
token_creation_time: '2025-08-16T09:15:42Z',
token_creation_time: '2020-08-16T09:15:42Z',
},
{
client_first_used_time: '2025-08-17T16:44:12Z',
client_first_used_time: '2025-12-17T16:44:12Z',
client_id: 'c6b9d248-5a71-39e4-c7f2-951d8eaf6b95',
client_type: 'entity',
entity_alias_custom_metadata: {
@ -602,14 +602,14 @@ module('Integration | Util | client count utils', function (hooks) {
namespace_id: 'root',
namespace_path: '',
policies: ['operations', 'monitoring'],
token_creation_time: '2025-08-17T16:43:28Z',
token_creation_time: '2020-08-17T16:43:28Z',
},
];
assert.propEqual(
filteredData,
expected,
"filtered data includes items with namespace_path equal to either 'root' or an empty string"
);
filteredData.forEach((d, idx) => {
const identifier = idx < 3 ? `label: ${d.label}` : `client_id: ${d.client_id}`;
assert.propEqual(d, expected[idx], `filtered data contains ${identifier}`);
});
this.assertOriginal(assert);
});
});

View file

@ -5,7 +5,7 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { buildISOTimestamp, isSameMonthUTC, parseAPITimestamp } from 'core/utils/date-formatters';
module('Integration | Util | date formatters utils', function (hooks) {
setupTest(hooks);
@ -14,9 +14,14 @@ module('Integration | Util | date formatters utils', function (hooks) {
const timestamp = '2025-05-01T00:00:00Z';
const parsed = parseAPITimestamp(timestamp);
assert.true(parsed instanceof Date, 'parsed timestamp is a date object');
assert.strictEqual(parsed.getFullYear(), 2025, 'parsed timestamp is correct year');
assert.strictEqual(parsed.getMonth(), 4, 'parsed timestamp is correct month (months are 0-indexed)');
assert.strictEqual(parsed.getDate(), 1, 'parsed timestamp is first of the month');
assert.strictEqual(parsed.getUTCFullYear(), 2025, 'parsed timestamp is correct year');
assert.strictEqual(parsed.getUTCMonth(), 4, 'parsed timestamp is correct month (months are 0-indexed)');
assert.strictEqual(parsed.getUTCDate(), 1, 'parsed timestamp is first of the month');
assert.strictEqual(
parsed.toISOString().replace('.000', ''),
timestamp,
'parsed ISO is the same date (in UTC)'
);
});
test('parseAPITimestamp: it formats midnight timestamps in UTC', async function (assert) {
@ -26,14 +31,93 @@ module('Integration | Util | date formatters utils', function (hooks) {
assert.strictEqual(formatted, '05 01 2025', 'it returns the expected year, month and day');
});
test('parseAPITimestamp: it returns the original value if timestamp is not a string', async function (assert) {
const unix = new Date().getTime();
assert.strictEqual(parseAPITimestamp(unix), unix);
});
test('parseAPITimestamp: it formats end of the day timestamps in UTC', async function (assert) {
const timestamp = '2025-09-30T23:59:59Z';
const formatted = parseAPITimestamp(timestamp, 'MM dd yyyy');
assert.strictEqual(formatted, '09 30 2025', 'it formats the date in UTC');
});
test('parseAPITimestamp: it returns null for invalid timestamps', function (assert) {
const unix = new Date().getTime();
assert.strictEqual(parseAPITimestamp(unix), null, 'it returns null for unix arg');
assert.strictEqual(parseAPITimestamp(null), null, 'it returns null for null arg');
assert.strictEqual(parseAPITimestamp(undefined), null, 'it returns null for undefined arg');
assert.strictEqual(parseAPITimestamp(''), null, 'it returns null for an empty string arg');
assert.strictEqual(parseAPITimestamp('invalid'), null, 'it returns null for an invalid string');
});
test('parseAPITimestamp: it handles future dates to prep for the next y2k', function (assert) {
const futureDate = '9999-12-31T23:59:59Z';
const parsed = parseAPITimestamp(futureDate);
assert.true(parsed instanceof Date, 'parsed future date is a date object');
assert.strictEqual(parsed.getUTCFullYear(), 9999, 'parsed future date has correct year');
assert.strictEqual(parsed.getUTCMonth(), 11, 'parsed future date has correct month');
assert.strictEqual(parsed.getUTCDate(), 31, 'parsed future date has correct day');
});
test('buildISOTimestamp: it formats an ISO timestamp for the start of the month', async function (assert) {
const timestamp = buildISOTimestamp({ monthIdx: 0, year: 2025, isEndDate: false });
assert.strictEqual(
timestamp,
'2025-01-01T00:00:00Z',
'it returns an ISO string for the first of the month at midnight'
);
});
test('buildISOTimestamp: it formats an ISO timestamp for the end of the month', async function (assert) {
const timestamp = buildISOTimestamp({ monthIdx: 0, year: 2025, isEndDate: true });
assert.strictEqual(timestamp, '2025-01-31T23:59:59Z', 'ISO string is for the last day and hour');
});
test('buildISOTimestamp: it formats an ISO timestamp for leap years', async function (assert) {
const timestamp = buildISOTimestamp({ monthIdx: 1, year: 2024, isEndDate: true });
assert.strictEqual(timestamp, '2024-02-29T23:59:59Z');
});
test('isSameMonthUTC: it returns true for timestamps in the same month', function (assert) {
const timestampA = '2025-07-01T00:00:00Z';
const timestampB = '2025-07-15T23:48:09Z';
assert.true(isSameMonthUTC(timestampA, timestampB));
});
test('isSameMonthUTC: it returns false for timestamps in different months', function (assert) {
const timestampA = '2025-09-01T00:00:00Z';
const timestampB = '2025-07-15T23:48:09Z';
assert.false(isSameMonthUTC(timestampA, timestampB));
});
test('isSameMonthUTC: it returns false for timestamps in different years', function (assert) {
const timestampA = '2025-12-31T23:59:59Z';
const timestampB = '2026-12-01T00:00:00Z';
assert.false(isSameMonthUTC(timestampA, timestampB));
});
test('isSameMonthUTC: it returns true when both timestamps are the same', function (assert) {
const timestamp = '2025-07-15T23:48:09Z';
assert.true(isSameMonthUTC(timestamp, timestamp));
});
test('isSameMonthUTC: it returns true for timestamps on the first and last day of the month', function (assert) {
const start = '2025-07-01T00:00:00Z';
const end = '2025-07-31T23:59:59Z';
assert.true(isSameMonthUTC(start, end));
});
test('isSameMonthUTC: it returns false for timestamps a second apart on different days', function (assert) {
const endJuly = '2025-07-31T23:59:59Z';
const startAugust = '2025-08-01T00:00:00Z';
assert.false(isSameMonthUTC(endJuly, startAugust));
});
test('isSameMonthUTC: it returns true if passed a timestamp with a timezone offset', function (assert) {
const utc = '2025-07-01T00:00:00Z';
const localTimezone = '2025-07-01T07:00:00+07:00'; // same time in UTC+7
assert.true(isSameMonthUTC(utc, localTimezone));
});
test('isSameMonthUTC: it returns false for invalid inputs', function (assert) {
assert.false(isSameMonthUTC(null, '2025-07-01T00:00:00Z'), 'null input returns false');
assert.false(isSameMonthUTC('2025-07-01T00:00:00Z', undefined), 'undefined input returns false');
assert.false(isSameMonthUTC(12345, '2025-07-01T00:00:00Z'), 'non-string input returns false');
});
});