UI: Update client count overview table and add filtering (#8663) (#9057)

* rename query params to match keys from api response

* move type guard check to util

* update color scheme

* remove passing selected namespace to export activity data request

* remove namespace and mount filter toolbar

* update response, sort by client count number

* remove default page size for testing

* implement table and filters in overview tab

* remove old query params

* cleanup unused args

* revert page header changes

* update mirage, remove month from utils

* update client count utils

* one more color!

* reset table to page 1;

* workaround to force Hds::Pagination::Numbered to update when currentPage changes

* add empty state test for no attribution

* delete unused methods

* add test for new utils

* add changelog

* rename changelog

---------

Co-authored-by: claire bontempo <cbontempo@hashicorp.com>
This commit is contained in:
Vault Automation 2025-09-02 14:43:02 -06:00 committed by GitHub
parent 1fcf55471d
commit 2b469deeca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1005 additions and 1285 deletions

View File

@ -7,53 +7,35 @@
// 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 { cached } from '@glimmer/tracking';
import { isSameMonth } from 'date-fns';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import {
filterByMonthDataForMount,
filteredTotalForMount,
filterVersionHistory,
} from 'core/utils/client-count-utils';
import { service } from '@ember/service';
import { sanitizePath } from 'core/utils/sanitize-path';
import type ClientsActivityModel from 'vault/models/clients/activity';
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
import type { TotalClients } from 'core/utils/client-count-utils';
import type { ClientFilterTypes } from 'core/utils/client-count-utils';
import type NamespaceService from 'vault/services/namespace';
/* 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 */
interface Args {
activity: ClientsActivityModel;
versionHistory: ClientsVersionHistoryModel[];
startTimestamp: string;
endTimestamp: string;
namespace: string;
mountPath: string;
mountType: string;
onFilterChange: CallableFunction;
filterQueryParams: Record<ClientFilterTypes, string>;
}
export default class ClientsActivityComponent extends Component<Args> {
@service declare readonly namespace: NamespaceService;
// path of the filtered namespace OR current one, for filtering relevant data
get namespacePathForFilter() {
const { namespace } = this.args;
const currentNs = this.namespace.currentNamespace;
return sanitizePath(namespace || currentNs || 'root');
}
@cached
get byMonthNewClients() {
const { activity, mountPath } = this.args;
const nsPath = this.namespacePathForFilter;
const data = mountPath
? filterByMonthDataForMount(activity.byMonth, nsPath, mountPath)
: activity.byMonth;
return data ? data?.map((m) => m?.new_clients) : [];
return this.args.activity.byMonth?.map((m) => m?.new_clients) || [];
}
@cached
get isCurrentMonth() {
const { activity } = this.args;
const current = parseAPITimestamp(activity.responseTimestamp) as Date;
@ -62,6 +44,7 @@ export default class ClientsActivityComponent extends Component<Args> {
return isSameMonth(start, current) && isSameMonth(end, current);
}
@cached
get isDateRange() {
const { activity } = this.args;
return !isSameMonth(
@ -70,19 +53,14 @@ export default class ClientsActivityComponent extends Component<Args> {
);
}
// (object) top level TOTAL client counts for given date range
get totalUsageCounts(): TotalClients {
const { namespace, activity, mountPath } = this.args;
// only do this if we have a mountPath filter.
// namespace is filtered on API layer
if (activity?.byNamespace && namespace && mountPath) {
return filteredTotalForMount(activity.byNamespace, namespace, mountPath);
}
return activity?.total;
@action
handleFilter(filters: Record<ClientFilterTypes, string>) {
const { namespace_path, mount_path, mount_type } = filters;
this.args.onFilterChange({ namespace_path, mount_path, mount_type });
}
get upgradesDuringActivity() {
const { versionHistory, activity } = this.args;
return filterVersionHistory(versionHistory, activity.startTime, activity.endTime);
@action
resetFilters() {
this.handleFilter({ namespace_path: '', mount_path: '', mount_type: '' });
}
}

View File

@ -10,7 +10,7 @@
{{#each @namespaces as |namespace|}}
<D.Checkmark
{{on "click" (fn this.updateFilter this.filterTypes.NAMESPACE namespace D.close)}}
@selected={{eq namespace this.nsLabel}}
@selected={{eq namespace this.namespace_path}}
data-test-dropdown-item={{namespace}}
>{{namespace}}</D.Checkmark>
{{/each}}
@ -22,7 +22,7 @@
{{#each @mountPaths as |mountPath|}}
<D.Checkmark
{{on "click" (fn this.updateFilter this.filterTypes.MOUNT_PATH mountPath D.close)}}
@selected={{eq mountPath this.mountPath}}
@selected={{eq mountPath this.mount_path}}
data-test-dropdown-item={{mountPath}}
>{{mountPath}}</D.Checkmark>
{{/each}}
@ -34,7 +34,7 @@
{{#each @mountTypes as |mountType|}}
<D.Checkmark
{{on "click" (fn this.updateFilter this.filterTypes.MOUNT_TYPE mountType D.close)}}
@selected={{eq mountType this.mountType}}
@selected={{eq mountType this.mount_type}}
data-test-dropdown-item={{mountType}}
>{{mountType}}</D.Checkmark>
{{/each}}
@ -54,7 +54,7 @@
<Hds::Text::Body @color="faint">Filters applied:</Hds::Text::Body>
{{! render tags based on applied @filters and not the internally tracked properties }}
{{#each-in @appliedFilters as |filter value|}}
{{#if (and (this.supportedFilter filter) value)}}
{{#if value}}
<div>
<Hds::Tag
@text={{value}}

View File

@ -6,7 +6,7 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { ClientFilters, ClientFilterTypes } from 'core/utils/client-count-utils';
import { ClientFilters, ClientFilterTypes, filterIsSupported } from 'core/utils/client-count-utils';
interface Args {
onFilter: CallableFunction;
@ -16,21 +16,21 @@ interface Args {
export default class ClientsFilterToolbar extends Component<Args> {
filterTypes = ClientFilters;
@tracked nsLabel = '';
@tracked mountPath = '';
@tracked mountType = '';
@tracked namespace_path = '';
@tracked mount_path = '';
@tracked mount_type = '';
constructor(owner: unknown, args: Args) {
super(owner, args);
const { nsLabel, mountPath, mountType } = this.args.appliedFilters;
this.nsLabel = nsLabel;
this.mountPath = mountPath;
this.mountType = mountType;
const { namespace_path, mount_path, mount_type } = this.args.appliedFilters;
this.namespace_path = namespace_path;
this.mount_path = mount_path;
this.mount_type = mount_type;
}
get anyFilters() {
return (
Object.keys(this.args.appliedFilters).every((f) => this.supportedFilter(f)) &&
Object.keys(this.args.appliedFilters).every((f) => filterIsSupported(f)) &&
Object.values(this.args.appliedFilters).some((v) => !!v)
);
}
@ -46,9 +46,9 @@ export default class ClientsFilterToolbar extends Component<Args> {
if (filterKey) {
this[filterKey] = '';
} else {
this.nsLabel = '';
this.mountPath = '';
this.mountType = '';
this.namespace_path = '';
this.mount_path = '';
this.mount_type = '';
}
// Fire callback so URL query params update when filters are cleared
this.applyFilters();
@ -57,13 +57,9 @@ export default class ClientsFilterToolbar extends Component<Args> {
@action
applyFilters() {
this.args.onFilter({
nsLabel: this.nsLabel,
mountPath: this.mountPath,
mountType: this.mountType,
namespace_path: this.namespace_path,
mount_path: this.mount_path,
mount_type: this.mount_type,
});
}
// Helper function
supportedFilter = (f: string): f is ClientFilterTypes =>
Object.values(this.filterTypes).includes(f as ClientFilterTypes);
}

View File

@ -26,7 +26,6 @@ import { task } from 'ember-concurrency';
* @param {string} [startTimestamp] - ISO timestamp of start time, to be passed to export request
* @param {string} [endTimestamp] - ISO timestamp of end time, to be passed to export request
* @param {number} [retentionMonths = 48] - number of months for historical billing, to be passed to date picker
* @param {string} [namespace] - namespace filter. Will be appended to the current namespace in the export request.
* @param {string} [upgradesDuringActivity] - array of objects containing version history upgrade data
* @param {boolean} [noData = false] - when true, export button will hide regardless of capabilities
* @param {function} [onChange] - callback when a new date range is saved, to be passed to date picker
@ -45,7 +44,7 @@ export default class ClientsPageHeaderComponent extends Component {
constructor() {
super(...arguments);
this.getExportCapabilities(this.args.namespace);
this.getExportCapabilities();
}
get showExportButton() {
@ -54,7 +53,8 @@ export default class ClientsPageHeaderComponent extends Component {
}
@waitFor
async getExportCapabilities(ns = '') {
async getExportCapabilities() {
const ns = this.namespace.path;
try {
// selected namespace usually ends in /
const url = ns
@ -89,16 +89,10 @@ export default class ClientsPageHeaderComponent extends Component {
get formattedCsvFileName() {
const endRange = this.showEndDate ? `-${this.formattedEndDate}` : '';
const csvDateRange = this.formattedStartDate ? `_${this.formattedStartDate + endRange}` : '';
const ns = this.namespaceFilter ? `_${this.namespaceFilter}` : '';
const ns = this.namespace.path ? `_${this.namespace.path}` : '';
return `clients_export${ns}${csvDateRange}`;
}
get namespaceFilter() {
const currentNs = this.namespace.path;
const { namespace } = this.args;
return namespace ? sanitizePath(`${currentNs}/${namespace}`) : sanitizePath(currentNs);
}
get showCommunity() {
return this.version.isCommunity && !!this.formattedStartDate && !!this.formattedEndDate;
}
@ -111,14 +105,10 @@ export default class ClientsPageHeaderComponent extends Component {
format: this.exportFormat === 'jsonl' ? 'json' : 'csv',
start_time: startTimestamp,
end_time: endTimestamp,
namespace: this.namespaceFilter,
namespace: this.namespace.path,
});
}
parseAPITimestamp = (timestamp, format) => {
return parseAPITimestamp(timestamp, format);
};
exportChartData = task({ drop: true }, async (filename) => {
try {
const contents = await this.getExportData();
@ -145,4 +135,9 @@ export default class ClientsPageHeaderComponent extends Component {
setEditModalVisible(visible) {
this.showEditModal = visible;
}
// LOCAL TEMPLATE HELPERS
parseAPITimestamp = (timestamp, format) => {
return parseAPITimestamp(timestamp, format);
};
}

View File

@ -34,7 +34,7 @@ export default class ClientsClientListPageComponent extends ActivityComponent {
@action
handleFilter(filters: Record<ClientFilterTypes, string>) {
const { nsLabel, mountPath, mountType } = filters;
this.args.onFilterChange({ nsLabel, mountPath, mountType });
const { namespace_path, mount_path, mount_type } = filters;
this.args.onFilterChange({ namespace_path, mount_path, mount_type });
}
}

View File

@ -9,7 +9,6 @@
@activityTimestamp={{@activity.responseTimestamp}}
@startTimestamp={{@startTimestamp}}
@endTimestamp={{@endTimestamp}}
@namespace={{@namespace}}
@upgradesDuringActivity={{this.upgradesDuringActivity}}
@noData={{not @activity.total.clients}}
@onChange={{this.onDateChange}}
@ -35,52 +34,7 @@
</Hds::Alert>
{{/if}}
{{#if (or @namespace this.namespaces @mountPath this.mountPaths)}}
<Hds::Text::Display @tag="p">
Filters
</Hds::Text::Display>
<Hds::Text::Body @tag="p" @color="faint">
Apply a filter to look at data from a specific namespace and drill down by mount. The mount filter includes auth
methods, KV engines, and PKI engines. Each mount type generates a different type of client and will not be applicable
to every tab.
</Hds::Text::Body>
<Toolbar aria-label="toolbar for filtering client count data" class="has-bottom-margin-m" data-test-clients-filter-bar>
<ToolbarFilters>
{{#if (or @namespace this.namespaces)}}
<SearchSelect
@id="namespace-search-select"
@options={{this.namespaces}}
@inputValue={{if @namespace (array @namespace)}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{fn this.setFilterValue "ns"}}
@placeholder="Namespace within {{this.namespacePathForFilter}}"
@displayInherit={{true}}
class="is-marginless"
data-test-counts-namespaces
/>
<div class="has-left-margin-xs"></div>
{{/if}}
{{#if (or @mountPath this.mountPaths)}}
<SearchSelect
@id="mounts-search-select"
@options={{this.mountPaths}}
@inputValue={{if @mountPath (array @mountPath)}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{fn this.setFilterValue "mountPath"}}
@placeholder="Mount path within {{this.namespacePathForFilter}}"
@displayInherit={{true}}
data-test-counts-auth-mounts
/>
{{/if}}
</ToolbarFilters>
</Toolbar>
{{/if}}
{{#if this.totalUsageCounts}}
{{#if @activity.total}}
{{#if this.upgradeExplanations}}
<Hds::Alert data-test-clients-upgrade-warning @type="inline" @color="warning" class="has-bottom-margin-m" as |A|>
<A.Title>
@ -133,11 +87,9 @@
@message={{if
this.version.isCommunity
"Select a start and end date above to query client count data."
"Update the filter values or click the button to reset them."
"Select a different date range to view client count data."
}}
>
<Hds::Button @text="Reset filters" @color="tertiary" @icon="reload" {{on "click" this.resetFilters}} />
</EmptyState>
/>
{{/if}}
{{/if}}
</div>

View File

@ -8,8 +8,7 @@ import { service } from '@ember/service';
import { action } from '@ember/object';
import { isSameMonth, isAfter } from 'date-fns';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { filteredTotalForMount, filterVersionHistory, TotalClients } from 'core/utils/client-count-utils';
import { sanitizePath } from 'core/utils/sanitize-path';
import { filterVersionHistory } from 'core/utils/client-count-utils';
import type AdapterError from '@ember-data/adapter/error';
import type FlagsService from 'vault/services/flags';
@ -18,15 +17,12 @@ import type VersionService from 'vault/services/version';
import type ClientsActivityModel from 'vault/models/clients/activity';
import type ClientsConfigModel from 'vault/models/clients/config';
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
import type NamespaceService from 'vault/services/namespace';
interface Args {
activity: ClientsActivityModel;
activityError?: AdapterError;
config: ClientsConfigModel;
endTimestamp: string; // ISO format
mountPath: string;
namespace: string;
onFilterChange: CallableFunction;
startTimestamp: string; // ISO format
versionHistory: ClientsVersionHistoryModel[];
@ -35,7 +31,6 @@ interface Args {
export default class ClientsCountsPageComponent extends Component<Args> {
@service declare readonly flags: FlagsService;
@service declare readonly version: VersionService;
@service declare readonly namespace: NamespaceService;
@service declare readonly store: Store;
get formattedStartDate() {
@ -112,54 +107,6 @@ export default class ClientsCountsPageComponent extends Component<Args> {
};
}
// path of the filtered namespace OR current one, for filtering relevant data
get namespacePathForFilter() {
const { namespace } = this.args;
const currentNs = this.namespace.currentNamespace;
return sanitizePath(namespace || currentNs || 'root');
}
// activityForNamespace gets the byNamespace data for the selected or current namespace so we can get the list of mounts from that namespace for attribution
get activityForNamespace() {
const { activity } = this.args;
const nsPath = this.namespacePathForFilter;
// we always return activity for namespace, either the selected filter or the current
return activity?.byNamespace?.find((ns) => sanitizePath(ns.label) === nsPath);
}
// duplicate of the method found in the activity component, so that we render the child only when there is activity to view
get totalUsageCounts(): TotalClients | undefined {
const { namespace, mountPath, activity } = this.args;
if (mountPath) {
// only do this if we have a mountPath filter.
// namespace is filtered on API layer
return filteredTotalForMount(activity.byNamespace, namespace, mountPath);
}
return activity?.total;
}
// namespace list for the search-select filter
get namespaces() {
return this.args.activity?.byNamespace
? this.args.activity.byNamespace
.map((namespace) => ({
name: namespace.label,
id: namespace.label,
}))
.filter((ns) => sanitizePath(ns.name) !== this.namespacePathForFilter)
: [];
}
// mounts within the current/filtered namespace for the sesarch-select filter
get mountPaths() {
return (
this.activityForNamespace?.mounts.map((mount) => ({
id: mount.label,
name: mount.label,
})) || []
);
}
// banner contents shown if startTime returned from activity API (which matches the first month with data) is after the queried startTime
get startTimeDiscrepancy() {
const { activity, config } = this.args;
@ -190,27 +137,4 @@ export default class ClientsCountsPageComponent extends Component<Args> {
onDateChange(params: { start_time: number | undefined; end_time: number | undefined }) {
this.args.onFilterChange(params);
}
@action
setFilterValue(type: 'ns' | 'mountPath', [value]: [string]) {
const params = { [type]: value };
if (type === 'ns' && !value) {
// unset mountPath value when namespace is cleared
params['mountPath'] = '';
} else if (type === 'mountPath' && !this.args.namespace) {
// set namespace when mountPath set without namespace already set
params['ns'] = this.namespacePathForFilter;
}
this.args.onFilterChange(params);
}
@action
resetFilters() {
this.args.onFilterChange({
start_time: undefined,
end_time: undefined,
ns: '',
mountPath: '',
});
}
}

View File

@ -8,16 +8,18 @@
@byMonthNewClients={{this.byMonthNewClients}}
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
@isCurrentMonth={{this.isCurrentMonth}}
@runningTotals={{this.totalUsageCounts}}
@upgradeData={{this.upgradesDuringActivity}}
@mountPath={{@mountPath}}
@runningTotals={{@activity.total}}
/>
{{#if this.hasAttributionData}}
<Clients::CountsCard @title="Client attribution" @legend={{this.chartLegend}}>
{{! by_namespace is an empty array when there is no client count activity data }}
{{#if @activity.byNamespace}}
<Clients::CountsCard
@title="Client attribution"
@description="Select a month to view the client count per mount for that month."
>
<:subheader>
<Hds::Form::Select::Base
class="has-bottom-margin-m"
class="has-top-margin-m"
aria-label="Month"
name="month"
{{on "input" this.selectMonth}}
@ -28,10 +30,25 @@
<S.Options>
<option value="">Select month</option>
{{#each this.months as |m|}}
<option value={{m.value}} selected={{eq m.value this.selectedMonth}}>{{m.display}}</option>
<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
@namespaces={{this.namespaceLabels}}
@mountPaths={{this.mountPaths}}
@mountTypes={{this.mountTypes}}
@onFilter={{this.handleFilter}}
@appliedFilters={{@filterQueryParams}}
/>
</div>
{{/if}}
</:subheader>
<:table>
@ -40,23 +57,13 @@
@data={{this.tableData}}
@columns={{this.tableColumns}}
@initiallySortBy={{hash column="clients" direction="desc"}}
@setPageSize={{10}}
@showPaginationSizeSelector={{true}}
>
<:emptyState>
<Hds::ApplicationState as |A|>
<A.Header
@title={{if
this.selectedMonth
"No data is available for the selected month"
"Select a month to view client attribution"
}}
/>
<A.Body
@text="View the namespace mount breakdown of clients by selecting {{if
this.selectedMonth
'another'
'a'
}} month."
/>
<A.Header @title="No data found" />
<A.Body @text="Clear or change filters to view client count data." />
<A.Footer as |F|>
<F.LinkStandalone
@icon="file-text"
@ -70,5 +77,4 @@
</Clients::Table>
</:table>
</Clients::CountsCard>
{{/if}}

View File

@ -5,11 +5,12 @@
import ActivityComponent from '../activity';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { cached, tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { HTMLElementEvent } from 'vault/forms';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { formatTableData, TableData } from 'core/utils/client-count-utils';
import { filterTableData, flattenMounts, type MountClients } from 'core/utils/client-count-utils';
import type FlagsService from 'vault/services/flags';
import type RouterService from '@ember/routing/router-service';
@ -19,35 +20,64 @@ export default class ClientsOverviewPageComponent extends ActivityComponent {
@tracked selectedMonth = '';
get hasAttributionData() {
// we hide attribution table when mountPath filter present
// or if there's no data
return !this.args.mountPath && this.totalUsageCounts.clients;
@cached
get clientsByMount() {
const namespaceData = this.selectedMonth
? // Find the namespace data for the selected month
this.byMonthNewClients.find((m) => m.timestamp === this.selectedMonth)?.namespaces
: // If no month is selected the table displays all of the by_namespace activity for the queried date range
this.args.activity.byNamespace;
// Get the array of "mounts" data nested in each namespace object and flatten
return flattenMounts(namespaceData || []);
}
// DROPDOWN GETTERS
@cached
get months() {
return this.byMonthNewClients.reverse().map((m) => ({
display: parseAPITimestamp(m.timestamp, 'MMMM yyyy'),
value: m.month,
}));
return this.byMonthNewClients
.reverse()
.map((m) => ({ timestamp: m.timestamp, display: parseAPITimestamp(m.timestamp, 'MMMM yyyy') }));
}
get tableData(): TableData[] | undefined {
if (!this.selectedMonth) return undefined;
return formatTableData(this.byMonthNewClients, this.selectedMonth);
@cached
get namespaceLabels() {
return this.args.activity.byNamespace.map((n) => n.label);
}
@cached
get mountPaths() {
return [...new Set(this.clientsByMount.map((m: MountClients) => m.label))];
}
@cached
get mountTypes() {
return [...new Set(this.clientsByMount.map((m: MountClients) => m.mount_type))];
}
// end dropdown getters
get tableData() {
if (this.clientsByMount?.length) {
return filterTableData(this.clientsByMount, this.args.filterQueryParams);
}
return null;
}
get tableColumns() {
return [
{ key: 'namespace_path', label: 'Namespace', isSortable: true },
{ key: 'label', label: 'Mount path', isSortable: true },
{ key: 'mount_path', label: 'Mount path', isSortable: true },
{ key: 'mount_type', label: 'Mount type', isSortable: true },
{ key: 'clients', label: 'Counts', isSortable: true },
{ key: 'clients', label: 'Client count', isSortable: true },
];
}
@action
selectMonth(e: HTMLElementEvent<HTMLInputElement>) {
this.selectedMonth = e.target.value;
// Reset filters when no month is selected
if (this.selectedMonth === '') {
this.resetFilters();
}
}
}

View File

@ -85,7 +85,7 @@
{{! Renders when viewing the current month or for activity log data that predates the monthly breakdown added in 1.11 }}
<Clients::UsageStats
@title="Total usage"
@description="These totals are within this {{or @mountPath 'namespace and all its children'}}. {{if
@description="These totals are within this namespace and all its children. {{if
@isCurrentMonth
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current billing period' filter."
}}"

View File

@ -7,7 +7,6 @@ import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import type { ByMonthNewClients, TotalClients } from 'core/utils/client-count-utils';
import type ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history';
interface Args {
isSecretsSyncActivated: boolean;
@ -15,9 +14,6 @@ interface Args {
isHistoricalMonth: boolean;
isCurrentMonth: boolean;
runningTotals: TotalClients;
upgradesDuringActivity: ClientsVersionHistoryModel[];
responseTimestamp: string;
mountPath: string;
}
export default class RunningTotal extends Component<Args> {

View File

@ -12,6 +12,7 @@
@sortBy={{this.sortColumn}}
@sortOrder={{this.sortDirection}}
@onSort={{this.updateSort}}
{{did-update this.resetPagination @data}}
...attributes
>
<:body as |B|>
@ -29,17 +30,21 @@
</:body>
</Hds::AdvancedTable>
<Hds::Pagination::Numbered
@currentPage={{this.currentPage}}
@currentPageSize={{this.pageSize}}
@onPageChange={{fn this.handlePaginationChange "currentPage"}}
@onPageSizeChange={{fn this.handlePaginationChange "pageSize"}}
@pageSizes={{array 5 10 50 100}}
@showSizeSelector={{or @showPaginationSizeSelector false}}
@totalItems={{@data.length}}
data-test-pagination
class="has-top-margin-m"
/>
{{! WORKAROUND to manually re-render Hds::Pagination::Numbered to force update @currentPage }}
{{#if this.renderPagination}}
<Hds::Pagination::Numbered
@currentPage={{this.currentPage}}
@currentPageSize={{this.pageSize}}
@onPageChange={{fn this.handlePaginationChange "currentPage"}}
@onPageSizeChange={{fn this.handlePaginationChange "pageSize"}}
@pageSizes={{array 5 10 50 100}}
@showSizeSelector={{or @showPaginationSizeSelector false}}
@totalItems={{@data.length}}
data-test-pagination
class="has-top-margin-m"
/>
{{/if}}
{{else}}
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-l" data-test-card="table empty state">
{{#if (has-block "emptyState")}}

View File

@ -4,10 +4,10 @@
*/
import Component from '@glimmer/component';
import Ember from 'ember';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { cached, tracked } from '@glimmer/tracking';
import { paginate } from 'core/utils/paginate-list';
import { next } from '@ember/runloop';
/**
* @module ClientsTable
@ -49,24 +49,37 @@ interface Args {
export default class ClientsTable extends Component<Args> {
@tracked currentPage = 1;
@tracked pageSize = Ember.testing ? 3 : 10; // lower in tests to test pagination without seeding more data
@tracked pageSize = 5; // Can be overridden by @setPageSize
@tracked sortColumn = '';
@tracked sortDirection: SortDirection;
@tracked sortDirection: SortDirection = 'asc'; // default is 'asc' for consistency with HDS defaults
// WORKAROUND to manually re-render Hds::Pagination::Numbered to force update @currentPage
@tracked renderPagination = true;
constructor(owner: unknown, args: Args) {
super(owner, args);
const { column = '', direction = 'asc' } = this.args.initiallySortBy || {};
this.sortColumn = column;
this.sortDirection = direction; // default is 'asc' for consistency with HDS defaults
const { column, direction } = this.args.initiallySortBy || {};
if (column) {
this.sortColumn = column;
}
if (direction) {
this.sortDirection = direction;
}
// Override default page size with a custom amount.
// pageSize can be updated by the end user if @showPaginationSizeSelector is true
// pageSize can be updated by the user if @showPaginationSizeSelector is true
if (this.args.setPageSize) {
this.pageSize = this.args.setPageSize;
}
}
@cached
get columnKeys() {
return this.args.columns.map((k: TableColumn) => k['key']);
}
@cached
get paginatedTableData(): Record<string, any>[] {
const sorted = this.sortTableData(this.args.data);
const paginated = paginate(sorted, {
@ -77,10 +90,6 @@ export default class ClientsTable extends Component<Args> {
return paginated;
}
get columnKeys() {
return this.args.columns.map((k: TableColumn) => k['key']);
}
// This table is paginated so we cannot use any out of the box filtering
// from the HDS component and must manually sort data.
sortTableData(data: Record<string, any>[]): Record<string, any>[] {
@ -102,6 +111,18 @@ export default class ClientsTable extends Component<Args> {
this[action] = value;
}
@action
async resetPagination() {
// setPageSize is intentionally NOT reset here so user changes to page size
// are preserved regardless of whether or not the table data updates.
this.renderPagination = false;
this.currentPage = 1;
// WORKAROUND to manually re-render Hds::Pagination::Numbered to force update @currentPage
next(() => {
this.renderPagination = true;
});
}
@action
updateSort(column: string, direction: SortDirection) {
// Update tracked variables so paginatedTableData recomputes

View File

@ -10,7 +10,7 @@ import { ClientFilters } from 'core/utils/client-count-utils';
import type { ClientsCountsRouteParams } from 'vault/routes/vault/cluster/clients/counts';
// these params refire the request to /sys/internal/counters/activity
const ACTIVITY_QUERY_PARAMS = ['start_time', 'end_time', 'ns'];
const ACTIVITY_QUERY_PARAMS = ['start_time', 'end_time'];
// these params client-side filter table data
const DROPDOWN_FILTERS = Object.values(ClientFilters);
const queryParamKeys = [...ACTIVITY_QUERY_PARAMS, ...DROPDOWN_FILTERS];
@ -19,10 +19,9 @@ export default class ClientsCountsController extends Controller {
start_time: string | number | undefined = undefined;
end_time: string | number | undefined = undefined;
ns: string | undefined = undefined; // TODO delete when filter toolbar is removed
nsLabel = '';
mountPath = '';
mountType = '';
namespace_path = '';
mount_path = '';
mount_type = '';
// 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

@ -20,15 +20,14 @@ import type ClientsActivityModel from 'vault/vault/models/clients/activity';
export interface ClientsCountsRouteParams {
start_time?: string | number | undefined;
end_time?: string | number | undefined;
ns?: string | undefined;
mountPath?: string;
mountType?: string;
namespace_path?: string;
mount_path?: string;
mount_type?: string;
}
interface ActivityAdapterQuery {
start_time: { timestamp: number } | undefined;
end_time: { timestamp: number } | undefined;
namespace?: string;
}
export type ClientsCountsRouteModel = ModelFrom<ClientsCountsRoute>;
@ -40,10 +39,13 @@ export default class ClientsCountsRoute extends Route {
@service declare readonly version: VersionService;
queryParams = {
// These query params make a new request to the API
start_time: { refreshModel: true, replace: true },
end_time: { refreshModel: true, replace: true },
ns: { refreshModel: true, replace: true },
mountPath: { refreshModel: false, replace: true },
// These query params just filter client-side data
namespace_path: { refreshModel: false, replace: true },
mount_path: { refreshModel: false, replace: true },
mount_type: { refreshModel: false, replace: true },
};
beforeModel() {
@ -81,10 +83,6 @@ export default class ClientsCountsRoute extends Route {
start_time: this.formatTimeQuery(params?.start_time),
end_time: this.formatTimeQuery(params?.end_time),
};
if (params?.ns) {
// only set explicit namespace if it's a query param
query.namespace = params.ns;
}
try {
activity = await this.store.queryRecord('clients/activity', query);
} catch (error) {
@ -131,8 +129,9 @@ export default class ClientsCountsRoute extends Route {
controller.setProperties({
start_time: undefined,
end_time: undefined,
ns: undefined,
mountPath: '',
namespace_path: '',
mount_path: '',
mount_type: '',
});
}
}

View File

@ -7,10 +7,11 @@
*/
// LEGEND STYLING (positioning is in chart-container.scss)
$green-cyan: #06d092;
$cerulean: #02a8ef;
$blue-500: var(--token-color-palette-blue-500);
$purple-300: var(--token-color-palette-purple-300);
$blue-500: #1c345f;
$secret_syncs: #6cc5b0;
$acme_clients: #ff725c;
$entity_clients: #4269d0;
$non_entity_clients: #efb117;
.legend-container {
.dots {
@ -23,16 +24,16 @@ $purple-300: var(--token-color-palette-purple-300);
background-color: $blue-500;
}
.legend-entity_clients {
background-color: $blue-500;
background-color: $entity_clients;
}
.legend-non_entity_clients {
background-color: $green-cyan;
background-color: $non_entity_clients;
}
.legend-secret_syncs {
background-color: $purple-300;
background-color: $secret_syncs;
}
.legend-acme_clients {
background-color: $cerulean;
background-color: $acme_clients;
}
}
@ -99,18 +100,18 @@ $purple-300: var(--token-color-palette-purple-300);
// @colorScale arg for Lineal::VBars is "stacked-bar", indices are added by lineal
.stacked-bar-1 {
color: $blue-500;
fill: $blue-500;
color: $entity_clients;
fill: $entity_clients;
}
.stacked-bar-2 {
color: $green-cyan;
fill: $green-cyan;
color: $non_entity_clients;
fill: $non_entity_clients;
}
.stacked-bar-3 {
color: $purple-300;
fill: $purple-300;
color: $secret_syncs;
fill: $secret_syncs;
}
.stacked-bar-4 {
color: $cerulean;
fill: $cerulean;
color: $acme_clients;
fill: $acme_clients;
}

View File

@ -8,8 +8,6 @@
@activityError={{this.model.activityError}}
@config={{this.model.config}}
@endTimestamp={{this.model.endTimestamp}}
@mountPath={{this.mountPath}}
@namespace={{this.ns}}
@onFilterChange={{this.updateQueryParams}}
@startTimestamp={{this.model.startTimestamp}}
@versionHistory={{this.model.versionHistory}}

View File

@ -7,8 +7,8 @@
@activity={{this.model.activity}}
@onFilterChange={{this.countsController.updateQueryParams}}
@filterQueryParams={{hash
nsLabel=this.countsController.nsLabel
mountPath=this.countsController.mountPath
mountType=this.countsController.mountType
namespace_path=this.countsController.namespace_path
mount_path=this.countsController.mount_path
mount_type=this.countsController.mount_type
}}
/>

View File

@ -6,8 +6,10 @@
<Clients::Page::Overview
@activity={{this.model.activity}}
@versionHistory={{this.model.versionHistory}}
@startTimestamp={{this.model.startTimestamp}}
@endTimestamp={{this.model.endTimestamp}}
@namespace={{this.countsController.ns}}
@mountPath={{this.countsController.mountPath}}
@onFilterChange={{this.countsController.updateQueryParams}}
@filterQueryParams={{hash
namespace_path=this.countsController.namespace_path
mount_path=this.countsController.mount_path
mount_type=this.countsController.mount_type
}}
/>

View File

@ -3,10 +3,9 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import isEmpty from '@ember/utils/lib/is_empty';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { sanitizePath } from 'core/utils/sanitize-path';
import { compareAsc, getUnixTime, isWithinInterval } from 'date-fns';
import type ClientsVersionHistoryModel from 'vault/vault/models/clients/version-history';
/*
@ -29,22 +28,13 @@ export type ClientTypes = (typeof CLIENT_TYPES)[number];
// map to dropdowns for filtering client count tables
export enum ClientFilters {
NAMESPACE = 'nsLabel',
MOUNT_PATH = 'mountPath',
MOUNT_TYPE = 'mountType',
NAMESPACE = 'namespace_path',
MOUNT_PATH = 'mount_path',
MOUNT_TYPE = 'mount_type',
}
export type ClientFilterTypes = (typeof ClientFilters)[keyof typeof ClientFilters];
// generates a block of total clients with 0's for use as defaults
function emptyCounts() {
return CLIENT_TYPES.reduce((prev, type) => {
const key = type;
prev[key as ClientTypes] = 0;
return prev;
}, {} as TotalClientsSometimes) as TotalClients;
}
// returns array of VersionHistoryModels for noteworthy upgrades: 1.9, 1.10
// that occurred between timestamps (i.e. queried activity data)
export const filterVersionHistory = (
@ -84,83 +74,6 @@ export const filterVersionHistory = (
return [];
};
// This method is used to return totals relevant only to the specified
// mount path within the specified namespace.
export const filteredTotalForMount = (
byNamespace: ByNamespaceClients[],
nsPath: string,
mountPath: string
): TotalClients => {
if (!nsPath || !mountPath || isEmpty(byNamespace)) return emptyCounts();
return (
byNamespace
.find((namespace) => sanitizePath(namespace.label) === sanitizePath(nsPath))
?.mounts.find((mount: MountClients) => sanitizePath(mount.label) === sanitizePath(mountPath)) ||
emptyCounts()
);
};
// This method is used to filter byMonth data and return data for only
// the specified mount within the specified namespace. If data exists
// for the month but not the mount, it should return zero'd data. If
// no data exists for the month is returns the month as-is.
export const filterByMonthDataForMount = (
byMonth: ByMonthClients[],
namespacePath: string,
mountPath: string
): ByMonthClients[] => {
if (byMonth && namespacePath && mountPath) {
const months: ByMonthClients[] = JSON.parse(JSON.stringify(byMonth));
return [...months].map((m) => {
if (m?.clients === undefined) {
// if the month doesn't have data we can just return the block
return m;
}
const nsData = m.namespaces?.find((ns) => sanitizePath(ns.label) === sanitizePath(namespacePath));
const mountData = nsData?.mounts.find((mount) => sanitizePath(mount.label) === sanitizePath(mountPath));
if (mountData) {
// if we do have mount data, we need to add in new_client namespace information
const nsNew = m.new_clients?.namespaces?.find(
(ns) => sanitizePath(ns.label) === sanitizePath(namespacePath)
);
const mountNew =
nsNew?.mounts.find((mount) => sanitizePath(mount.label) === sanitizePath(mountPath)) ||
emptyCounts();
return {
month: m.month,
timestamp: m.timestamp,
...mountData,
namespaces: [], // this is just for making TS happy, matching the ByMonthClients shape
new_clients: {
month: m.month,
timestamp: m.timestamp,
label: mountPath,
namespaces: [], // this is just for making TS happy, matching the ByMonthClients shape
...mountNew,
},
} as ByMonthClients;
}
// if the month has data but none for this mount, return mocked zeros
return {
month: m.month,
timestamp: m.timestamp,
label: mountPath,
namespaces: [], // this is just for making TS happy, matching the ByMonthClients shape
...emptyCounts(),
new_clients: {
timestamp: m.timestamp,
month: m.month,
label: mountPath,
namespaces: [], // this is just for making TS happy, matching the ByMonthClients shape
...emptyCounts(),
},
} as ByMonthClients;
});
}
return byMonth;
};
// METHODS FOR SERIALIZING ACTIVITY RESPONSE
export const formatDateObject = (dateObj: { monthIdx: number; year: number }, isEnd: boolean) => {
const { year, monthIdx } = dateObj;
@ -173,29 +86,25 @@ export const formatDateObject = (dateObj: { monthIdx: number; year: number }, is
export const formatByMonths = (monthsArray: ActivityMonthBlock[]): ByMonthNewClients[] => {
const sortedPayload = sortMonthsByTimestamp(monthsArray);
return sortedPayload?.map((m) => {
const month = parseAPITimestamp(m.timestamp, 'M/yy') as string;
const { timestamp } = m;
if (monthIsEmpty(m)) {
// empty month
return {
month,
timestamp,
namespaces: [],
new_clients: { month, timestamp, namespaces: [] },
new_clients: { timestamp, namespaces: [] },
};
}
let newClients: ByMonthNewClients = { month, timestamp, namespaces: [] };
let newClients: ByMonthNewClients = { timestamp, namespaces: [] };
if (monthWithAllCounts(m)) {
newClients = {
month,
timestamp,
...destructureClientCounts(m?.new_clients.counts),
namespaces: formatByNamespace(m.new_clients.namespaces),
};
}
return {
month,
timestamp,
...destructureClientCounts(m.counts),
namespaces: formatByNamespace(m.namespaces),
@ -217,6 +126,7 @@ export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByN
mounts = ns.mounts.map((m) => ({
label: m.mount_path,
namespace_path: nsLabel,
mount_path: m.mount_path,
mount_type: m.mount_type,
...destructureClientCounts(m.counts),
}));
@ -229,23 +139,6 @@ export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByN
});
};
export const formatTableData = (byMonthNewClients: ByMonthNewClients[], month: string): TableData[] => {
const monthData = byMonthNewClients.find((m) => m.month === month);
const namespaces = monthData?.namespaces;
let data: TableData[] = [];
// iterate over namespaces to add "namespace" to each mount object
namespaces?.forEach((n) => {
const mounts: TableData[] = n.mounts.map((m) => {
// add namespace to mount block
return { ...m, namespace_path: n.label };
});
data = [...data, ...mounts];
});
return data;
};
// This method returns only client types from the passed object, excluding other keys such as "label".
// when querying historical data the response will always contain the latest client type keys because the activity log is
// constructed based on the version of Vault the user is on (key values will be 0)
@ -266,7 +159,30 @@ export const sortMonthsByTimestamp = (monthsArray: ActivityMonthBlock[]) => {
);
};
// type guards for conditionals
export const filterTableData = (
data: MountClients[],
filters: Record<ClientFilterTypes, string>
): MountClients[] => {
// Return original data if no filters are specified
if (!filters || Object.values(filters).every((v) => !v)) {
return data;
}
return data.filter((datum) => {
// Datum must satisfy every filter
return Object.entries(filters).every(([filterKey, filterValue]) => {
// If no filter is specified for that key, return true
if (!filterValue) return true;
// Otherwise only return true if the datum matches the filter
return datum[filterKey as ClientFilterTypes] === filterValue;
});
});
};
export const flattenMounts = (namespaceArray: ByNamespaceClients[]) =>
namespaceArray.map((n) => n.mounts).flat();
// TYPE GUARDS FOR CONDITIONALS
function monthIsEmpty(month: ActivityMonthBlock): month is ActivityMonthEmpty {
return !month || month?.counts === null;
}
@ -275,6 +191,10 @@ function monthWithAllCounts(month: ActivityMonthBlock): month is ActivityMonthSt
return month?.counts !== null && month?.new_clients?.counts !== null;
}
export function filterIsSupported(f: string): f is ClientFilterTypes {
return Object.values(ClientFilters).includes(f as ClientFilterTypes);
}
export function hasMountsKey(
obj: ByMonthNewClients | NamespaceNewClients | MountNewClients
): obj is NamespaceNewClients {
@ -312,52 +232,44 @@ export interface ByNamespaceClients extends TotalClients {
export interface MountClients extends TotalClients {
label: string;
mount_path: string;
mount_type: string;
namespace_path: string;
}
export interface ByMonthClients extends TotalClients {
month: string;
timestamp: string;
namespaces: ByNamespaceClients[];
new_clients: ByMonthNewClients;
}
export interface ByMonthNewClients extends TotalClientsSometimes {
month: string;
timestamp: string;
namespaces: ByNamespaceClients[];
}
export interface NamespaceByKey extends TotalClients {
month: string;
timestamp: string;
new_clients: NamespaceNewClients;
}
export interface NamespaceNewClients extends TotalClientsSometimes {
month: string;
timestamp: string;
label: string;
mounts: MountClients[];
}
export interface MountByKey extends TotalClients {
month: string;
timestamp: string;
label: string;
new_clients: MountNewClients;
}
export interface MountNewClients extends TotalClientsSometimes {
month: string;
timestamp: string;
label: string;
}
export interface TableData extends MountClients {
namespace_path: string;
}
// API RESPONSE SHAPE (prior to serialization)
export interface NamespaceObject {

View File

@ -66,8 +66,11 @@ function generateMountBlock(path, counts) {
obj[key] = 0;
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]);
return {
mount_path: path,
mount_type: setMountType(),
counts: {
...baseObject,
// object contains keys for which 0-values of base object to overwrite
@ -97,13 +100,13 @@ function generateNamespaceBlock(idx = 0, isLowerCounts = false, ns, skipCounts =
// each mount type generates a different type of client
return [
generateMountBlock(`auth/authid/${idx}`, {
generateMountBlock(`auth/token/${idx}`, {
clients: non_entity_clients + entity_clients,
non_entity_clients,
entity_clients,
}),
generateMountBlock(`kvv2-engine-${idx}`, { clients: secret_syncs, secret_syncs }),
generateMountBlock(`pki-engine-${idx}`, { clients: acme_clients, acme_clients }),
generateMountBlock(`secrets/kv/${idx}`, { clients: secret_syncs, secret_syncs }),
generateMountBlock(`acme/pki/${idx}`, { clients: acme_clients, acme_clients }),
];
};
@ -167,10 +170,9 @@ function generateActivityResponse(startDate, endDate) {
d.namespaces.find((n) => n.namespace_path === ns.namespace_path)
);
const mountCounts = nsData.flatMap((d) => d.mounts);
const paths = nsData[0].mounts.map(({ mount_path }) => mount_path);
ns.mounts = paths.map((path) => {
const counts = getTotalCounts(mountCounts.filter((m) => m.mount_path === path));
return { mount_path: path, counts };
ns.mounts = nsData[0].mounts.map((mount) => {
const counts = getTotalCounts(mountCounts.filter((m) => m.mount_path === mount.mount_path));
return { ...mount, counts };
});
ns.counts = getTotalCounts(nsData);
});
@ -254,7 +256,7 @@ function filterMonths(months, namespacePath) {
/**
* Util to mock filter namespace data from the activity response, matching what the API does
*/
export function filterActivityResponse(originalData, namespacePath) {
function filterActivityResponse(originalData, namespacePath) {
// make a deep copy of the object so we don't mutate the original
const data = JSON.parse(JSON.stringify(originalData));
if (!namespacePath) return data;

View File

@ -42,10 +42,10 @@ module('Acceptance | clients | counts | client list', function (hooks) {
test('filters are preset if URL includes query params', async function (assert) {
assert.expect(4);
const ns = 'ns1';
const mPath = 'auth/userpass-0';
const mPath = 'auth/userpass/0';
const mType = 'userpass';
await visit(
`vault/clients/counts/client-list?nsLabel=${ns}&mountPath=${mPath}&mountType=${mType}&&start_time=1717113600`
`vault/clients/counts/client-list?namespace_path=${ns}&mount_path=${mPath}&mount_type=${mType}&start_time=1717113600`
);
assert.dom(FILTERS.tag()).exists({ count: 3 }, '3 filter tags render');
assert.dom(FILTERS.tag(ClientFilters.NAMESPACE, ns)).exists();
@ -56,7 +56,7 @@ module('Acceptance | clients | counts | client list', function (hooks) {
test('selecting filters update URL query params', async function (assert) {
assert.expect(3);
const ns = 'ns1';
const mPath = 'auth/userpass-0';
const mPath = 'auth/userpass/0';
const mType = 'userpass';
const url = '/vault/clients/counts/client-list';
await visit(url);
@ -73,7 +73,7 @@ module('Acceptance | clients | counts | client list', function (hooks) {
await click(GENERAL.button('Apply filters'));
assert.strictEqual(
currentURL(),
`${url}?mountPath=${encodeURIComponent(mPath)}&mountType=${mType}&nsLabel=${ns}`,
`${url}?mount_path=${encodeURIComponent(mPath)}&mount_type=${mType}&namespace_path=${ns}`,
'url query params match filters'
);
await click(GENERAL.button('Clear filters'));

View File

@ -17,12 +17,11 @@ import sinon from 'sinon';
import { visit, click, findAll, fillIn, currentURL } from '@ember/test-helpers';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CHARTS, CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
import { formatNumber } from 'core/helpers/format-number';
import { CHARTS, CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import timestamp from 'core/utils/timestamp';
import { runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands';
import { selectChoose } from 'ember-power-select/test-support';
import { format } from 'date-fns';
import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers';
import { ClientFilters, flattenMounts } from 'core/utils/client-count-utils';
module('Acceptance | clients | overview', function (hooks) {
setupApplicationTest(hooks);
@ -31,217 +30,7 @@ module('Acceptance | clients | overview', function (hooks) {
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
clientsHandler(this.server);
// stub secrets sync being activated
this.server.get('/sys/activation-flags', function () {
return {
data: {
activated: ['secrets-sync'],
unactivated: [],
},
};
});
this.store = this.owner.lookup('service:store');
await 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');
});
test('it should update charts when querying date ranges', async function (assert) {
// query for single, historical month with no new counts (July 2023)
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');
await click(CLIENT_COUNT.dateRange.edit);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), licenseStartMonth);
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), licenseStartMonth);
await click(GENERAL.submitButton);
assert
.dom(CLIENT_COUNT.usageStats('Vault client counts'))
.doesNotExist('running total single month stat boxes do not show');
assert
.dom(CLIENT_COUNT.card('Client usage trends for selected billing period'))
.doesNotExist('running total month over month charts do not show');
// change to start on month/year of upgrade to 1.10
await click(CLIENT_COUNT.dateRange.edit);
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');
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('9/23', 'x-axis labels start with queried start month (upgrade date)');
assert.dom(CHARTS.xAxisLabel).exists({ count: 4 }, 'chart months matches query');
// query for single, historical month (upgrade month)
await click(CLIENT_COUNT.dateRange.edit);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), upgradeMonth);
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), upgradeMonth);
await click(GENERAL.submitButton);
assert
.dom(CLIENT_COUNT.usageStats('Vault client counts'))
.exists('running total single month usage stats show');
assert
.dom(CLIENT_COUNT.card('Client usage trends for selected billing period'))
.doesNotExist('running total month over month charts do not show');
// query historical date range (from September 2023 to December 2023)
await click(CLIENT_COUNT.dateRange.edit);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), '2023-09');
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), '2023-12');
await click(GENERAL.submitButton);
assert
.dom(CLIENT_COUNT.dateRange.dateDisplay('start'))
.hasText('September 2023', 'billing start month is correctly parsed from license');
assert
.dom(CLIENT_COUNT.dateRange.dateDisplay('end'))
.hasText('December 2023', 'billing start month is correctly parsed from license');
assert
.dom(CLIENT_COUNT.card('Client usage trends for selected billing period'))
.exists('Shows running totals with monthly breakdown charts');
assert.dom(CHARTS.xAxisLabel).exists({ count: 4 }, 'chart months matches query');
const xAxisLabels = findAll(CHARTS.xAxisLabel);
assert
.dom(xAxisLabels[xAxisLabels.length - 1])
.hasText('12/23', 'x-axis labels end with queried end month');
// query month older than count start date
await click(CLIENT_COUNT.dateRange.edit);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), '2020-07');
await click(GENERAL.submitButton);
assert
.dom(CLIENT_COUNT.counts.startDiscrepancy)
.hasTextContaining(
'You requested data from July 2020. We only have data from January 2023, and that is what is being shown here.',
'warning banner displays that date queried was prior to count start date'
);
});
test('totals filter correctly with full data', async function (assert) {
assert
.dom(CLIENT_COUNT.card('Client usage trends for selected billing period'))
.exists('Shows running totals with monthly breakdown charts');
const response = await this.store.peekRecord('clients/activity', 'some-activity-id');
const orderedNs = response.byNamespace.sort((a, b) => b.clients - a.clients);
const topNamespace = orderedNs[0];
// the namespace dropdown excludes the current namespace, so use second-largest if that's the case
const filterNamespace = topNamespace.label === 'root' ? orderedNs[1] : topNamespace;
const topMount = filterNamespace?.mounts.sort((a, b) => b.clients - a.clients)[0];
// Filter by top namespace
await selectChoose(CLIENT_COUNT.nsFilter, filterNamespace.label);
assert.dom(CLIENT_COUNT.selectedNs).hasText(filterNamespace.label, 'selects top namespace');
let expectedStats = {
Entity: formatNumber([filterNamespace.entity_clients]),
'Non-entity': formatNumber([filterNamespace.non_entity_clients]),
ACME: formatNumber([filterNamespace.acme_clients]),
'Secret sync': formatNumber([filterNamespace.secret_syncs]),
};
for (const label in expectedStats) {
assert
.dom(CLIENT_COUNT.statTextValue(label))
.includesText(`${expectedStats[label]}`, `label: ${label} renders accurate namespace client counts`);
}
// FILTER BY AUTH METHOD
await selectChoose(CLIENT_COUNT.mountFilter, topMount.label);
assert.dom(CLIENT_COUNT.selectedAuthMount).hasText(topMount.label, 'selects top mount');
expectedStats = {
Entity: formatNumber([topMount.entity_clients]),
'Non-entity': formatNumber([topMount.non_entity_clients]),
ACME: formatNumber([topMount.acme_clients]),
'Secret sync': formatNumber([topMount.secret_syncs]),
};
for (const label in expectedStats) {
assert
.dom(CLIENT_COUNT.statTextValue(label))
.includesText(`${expectedStats[label]}`, `label: "${label} "renders accurate mount client counts`);
}
// Remove namespace filter without first removing auth method filter
await click(GENERAL.searchSelect.removeSelected);
assert.strictEqual(currentURL(), '/vault/clients/counts/overview', 'removes both query params');
expectedStats = {
Entity: formatNumber([response.total.entity_clients]),
'Non-entity': formatNumber([response.total.non_entity_clients]),
ACME: formatNumber([response.total.acme_clients]),
'Secret sync': formatNumber([response.total.secret_syncs]),
};
for (const label in expectedStats) {
assert
.dom(CLIENT_COUNT.statTextValue(label))
.includesText(`${expectedStats[label]}`, `label: ${label} is back to unfiltered value`);
}
});
test('it updates export button visibility as namespace is filtered', async function (assert) {
const ns = 'ns7';
// create a user that only has export access for specific namespace
const userToken = await runCmd(
tokenWithPolicyCmd(
'cc-export',
`
path "${ns}/sys/internal/counters/activity/export" {
capabilities = ["sudo"]
}
`
)
);
await login(userToken);
await visit('/vault/clients/counts/overview');
assert.dom(CLIENT_COUNT.exportButton).doesNotExist();
// FILTER BY ALLOWED NAMESPACE
await selectChoose('#namespace-search-select', ns);
assert.dom(CLIENT_COUNT.exportButton).exists();
});
});
module('Acceptance | clients | overview | secrets sync', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
clientsHandler(this.server);
});
test('it should hide secrets sync stats when feature is NOT on license', async function (assert) {
@ -256,13 +45,238 @@ module('Acceptance | clients | overview | secrets sync', function (hooks) {
assert.dom(CHARTS.legend).hasText('Entity clients Non-entity clients Acme clients');
});
module('feature is on license', function (hooks) {
// These tests use the clientsHandler which dynamically generates activity data, used for asserting date querying, etc
module('dynamic data', 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];
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');
});
test('it should update charts when querying date ranges', async function (assert) {
// query for single, historical month with no new counts (July 2023)
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');
await click(CLIENT_COUNT.dateRange.edit);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), licenseStartMonth);
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), licenseStartMonth);
await click(GENERAL.submitButton);
assert
.dom(CLIENT_COUNT.usageStats('Vault client counts'))
.doesNotExist('running total single month stat boxes do not show');
assert
.dom(CLIENT_COUNT.card('Client usage trends for selected billing period'))
.doesNotExist('running total month over month charts do not show');
// change to start on month/year of upgrade to 1.10
await click(CLIENT_COUNT.dateRange.edit);
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');
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('9/23', 'x-axis labels start with queried start month (upgrade date)');
assert.dom(CHARTS.xAxisLabel).exists({ count: 4 }, 'chart months matches query');
// query for single, historical month (upgrade month)
await click(CLIENT_COUNT.dateRange.edit);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), upgradeMonth);
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), upgradeMonth);
await click(GENERAL.submitButton);
assert
.dom(CLIENT_COUNT.usageStats('Vault client counts'))
.exists('running total single month usage stats show');
assert
.dom(CLIENT_COUNT.card('Client usage trends for selected billing period'))
.doesNotExist('running total month over month charts do not show');
// query historical date range (from September 2023 to December 2023)
await click(CLIENT_COUNT.dateRange.edit);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), '2023-09');
await fillIn(CLIENT_COUNT.dateRange.editDate('end'), '2023-12');
await click(GENERAL.submitButton);
assert
.dom(CLIENT_COUNT.dateRange.dateDisplay('start'))
.hasText('September 2023', 'billing start month is correctly parsed from license');
assert
.dom(CLIENT_COUNT.dateRange.dateDisplay('end'))
.hasText('December 2023', 'billing start month is correctly parsed from license');
assert
.dom(CLIENT_COUNT.card('Client usage trends for selected billing period'))
.exists('Shows running totals with monthly breakdown charts');
assert.dom(CHARTS.xAxisLabel).exists({ count: 4 }, 'chart months matches query');
const xAxisLabels = findAll(CHARTS.xAxisLabel);
assert
.dom(xAxisLabels[xAxisLabels.length - 1])
.hasText('12/23', 'x-axis labels end with queried end month');
// query month older than count start date
await click(CLIENT_COUNT.dateRange.edit);
await fillIn(CLIENT_COUNT.dateRange.editDate('start'), '2020-07');
await click(GENERAL.submitButton);
assert
.dom(CLIENT_COUNT.counts.startDiscrepancy)
.hasTextContaining(
'You requested data from July 2020. We only have data from January 2023, and that is what is being shown here.',
'warning banner displays that date queried was prior to count start date'
);
});
});
// * FILTERING ASSERTIONS
// These tests use the static data from the ACTIVITY_RESPONSE_STUB to assert filtering
// Filtering tests are split between integration and acceptance tests
// because changing filters updates the URL query params.
module('static data', function (hooks) {
hooks.beforeEach(async function () {
this.server.get('sys/internal/counters/activity', () => {
return {
request_id: 'some-activity-id',
data: ACTIVITY_RESPONSE_STUB,
};
});
const staticActivity = await this.store.findRecord('clients/activity', 'some-activity-id');
this.staticMostRecentMonth = staticActivity.byMonth[staticActivity.byMonth.length - 1];
await login();
return visit('/vault/clients/counts/overview');
});
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 { 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.NAMESPACE));
await click(FILTERS.dropdownItem(namespace_path));
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH));
await click(FILTERS.dropdownItem(mount_path));
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE));
await click(FILTERS.dropdownItem(mount_type));
await click(GENERAL.button('Apply filters'));
assert.strictEqual(
currentURL(),
`${url}?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(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);
assert.dom(GENERAL.tableData(0, 'mount_path')).hasText(mount_path);
});
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 { namespace_path, mount_type, mount_path } = mounts[0];
await fillIn(GENERAL.selectByAttr('attribution-month'), this.staticMostRecentMonth.timestamp);
await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE));
await click(FILTERS.dropdownItem(namespace_path));
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH));
await click(FILTERS.dropdownItem(mount_path));
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE));
await click(FILTERS.dropdownItem(mount_type));
await click(GENERAL.button('Apply filters'));
assert.dom(GENERAL.tableRow()).exists({ count: 1 }, 'it only renders the filtered table row');
await click(FILTERS.clearTag(namespace_path));
assert.strictEqual(
currentURL(),
`${url}?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, 'mount_type')).hasText(mount_type);
assert.dom(GENERAL.tableData(1, 'mount_path')).hasText(mount_path);
await click(GENERAL.button('Clear filters'));
assert.strictEqual(currentURL(), url, 'url does not have any query params');
assert
.dom(GENERAL.tableRow())
.exists({ count: mounts.length }, 'it renders all data when filters are cleared');
});
test('it clears query params when month is unselected', async function (assert) {
const url = '/vault/clients/counts/overview';
const mounts = flattenMounts(this.staticMostRecentMonth.new_clients.namespaces);
const { namespace_path, mount_type, mount_path } = mounts[0];
await fillIn(GENERAL.selectByAttr('attribution-month'), this.staticMostRecentMonth.timestamp);
await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE));
await click(FILTERS.dropdownItem(namespace_path));
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH));
await click(FILTERS.dropdownItem(mount_path));
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE));
await click(FILTERS.dropdownItem(mount_type));
await click(GENERAL.button('Apply filters'));
assert.strictEqual(
currentURL(),
`${url}?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');
});
});
module('license includes secrets sync feature', function (hooks) {
hooks.beforeEach(async function () {
syncHandler(this.server);
});
test('it should show secrets sync stats when the feature is activated', async function (assert) {
syncHandler(this.server);
await login();
await visit('/vault/clients/counts/overview');
assert.dom(CLIENT_COUNT.statTextValue('Secret sync')).exists('shows secret sync data on overview');

View File

@ -45,7 +45,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'auth/userpass-0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
counts: {
acme_clients: 0,
@ -56,18 +56,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'kvv2-engine-0',
mount_type: 'kv',
counts: {
acme_clients: 0,
clients: 4810,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 4810,
},
},
{
mount_path: 'pki-engine-0',
mount_path: 'acme/pki/0',
mount_type: 'pki',
counts: {
acme_clients: 5699,
@ -77,6 +66,17 @@ export const ACTIVITY_RESPONSE_STUB = {
secret_syncs: 0,
},
},
{
mount_path: 'secrets/kv/0',
mount_type: 'kv',
counts: {
acme_clients: 0,
clients: 4810,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 4810,
},
},
],
},
{
@ -91,7 +91,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'auth/userpass-0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
counts: {
acme_clients: 0,
@ -102,7 +102,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'kvv2-engine-0',
mount_path: 'secrets/kv/0',
mount_type: 'kv',
counts: {
acme_clients: 0,
@ -113,7 +113,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'pki-engine-0',
mount_path: 'acme/pki/0',
mount_type: 'pki',
counts: {
acme_clients: 4003,
@ -155,18 +155,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'pki-engine-0',
mount_type: 'pki',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
},
{
mount_path: 'auth/userpass-0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
counts: {
acme_clients: 0,
@ -177,7 +166,18 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'kvv2-engine-0',
mount_path: 'acme/pki/0',
mount_type: 'pki',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
},
{
mount_path: 'secrets/kv/0',
mount_type: 'kv',
counts: {
acme_clients: 0,
@ -211,18 +211,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'pki-engine-0',
mount_type: 'pki',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
},
{
mount_path: 'auth/userpass-0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
counts: {
acme_clients: 0,
@ -233,7 +222,18 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'kvv2-engine-0',
mount_path: 'acme/pki/0',
mount_type: 'pki',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
},
{
mount_path: 'secrets/kv/0',
mount_type: 'kv',
counts: {
acme_clients: 0,
@ -270,18 +270,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'pki-engine-0',
mount_type: 'pki',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
},
{
mount_path: 'auth/userpass-0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
counts: {
acme_clients: 0,
@ -292,7 +281,18 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'kvv2-engine-0',
mount_path: 'acme/pki/0',
mount_type: 'pki',
counts: {
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
},
{
mount_path: 'secrets/kv/0',
mount_type: 'kv',
counts: {
acme_clients: 0,
@ -332,7 +332,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'pki-engine-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: 'kvv2-engine-0',
mount_path: 'secrets/kv/0',
counts: {
acme_clients: 0,
clients: 157,
@ -375,7 +375,7 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'pki-engine-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: 'kvv2-engine-0',
mount_path: 'secrets/kv/0',
counts: {
acme_clients: 0,
clients: 81,
@ -428,7 +428,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'pki-engine-0',
mount_path: 'acme/pki/0',
mount_type: 'pki',
counts: {
acme_clients: 91,
clients: 91,
@ -438,7 +439,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'auth/userpass-0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
counts: {
acme_clients: 0,
clients: 75,
@ -448,7 +450,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'kvv2-engine-0',
mount_path: 'secrets/kv/0',
mount_type: 'kv',
counts: {
acme_clients: 0,
clients: 25,
@ -471,7 +474,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
mounts: [
{
mount_path: 'auth/userpass-0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
counts: {
acme_clients: 0,
clients: 96,
@ -481,7 +485,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'pki-engine-0',
mount_path: 'acme/pki/0',
mount_type: 'pki',
counts: {
acme_clients: 53,
clients: 53,
@ -491,7 +496,8 @@ export const ACTIVITY_RESPONSE_STUB = {
},
},
{
mount_path: 'kvv2-engine-0',
mount_path: 'secrets/kv/0',
mount_type: 'kv',
counts: {
acme_clients: 0,
clients: 24,
@ -555,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',
},
],
@ -607,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',
},
],
@ -652,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',
},
],
@ -685,7 +691,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 4810,
mounts: [
{
label: 'auth/userpass-0',
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
namespace_path: 'ns1',
acme_clients: 0,
@ -695,17 +702,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
mount_type: 'kv',
namespace_path: 'ns1',
acme_clients: 0,
clients: 4810,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 4810,
},
{
label: 'pki-engine-0',
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
mount_type: 'pki',
namespace_path: 'ns1',
acme_clients: 5699,
@ -714,6 +712,17 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
non_entity_clients: 0,
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
mount_type: 'kv',
namespace_path: 'ns1',
acme_clients: 0,
clients: 4810,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 4810,
},
],
},
{
@ -725,7 +734,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 4290,
mounts: [
{
label: 'auth/userpass-0',
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
namespace_path: 'root',
acme_clients: 0,
@ -735,7 +745,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
mount_type: 'kv',
namespace_path: 'root',
acme_clients: 0,
@ -745,7 +756,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 4290,
},
{
label: 'pki-engine-0',
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
mount_type: 'pki',
namespace_path: 'root',
acme_clients: 4003,
@ -759,17 +771,14 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
],
by_month: [
{
month: '6/23',
timestamp: '2023-06-01T00:00:00Z',
namespaces: [],
new_clients: {
month: '6/23',
timestamp: '2023-06-01T00:00:00Z',
namespaces: [],
},
},
{
month: '7/23',
timestamp: '2023-07-01T00:00:00Z',
acme_clients: 100,
clients: 400,
@ -786,18 +795,9 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 100,
mounts: [
{
label: 'pki-engine-0',
namespace_path: 'root',
mount_type: 'pki',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
label: 'auth/userpass-0',
label: 'auth/userpass/0',
namespace_path: 'root',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
acme_clients: 0,
clients: 200,
@ -806,8 +806,20 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
label: 'acme/pki/0',
namespace_path: 'root',
mount_path: 'acme/pki/0',
mount_type: 'pki',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
namespace_path: 'root',
mount_path: 'secrets/kv/0',
mount_type: 'kv',
acme_clients: 0,
clients: 100,
@ -819,7 +831,6 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
},
],
new_clients: {
month: '7/23',
timestamp: '2023-07-01T00:00:00Z',
acme_clients: 100,
clients: 400,
@ -836,17 +847,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 100,
mounts: [
{
label: 'pki-engine-0',
namespace_path: 'root',
mount_type: 'pki',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
label: 'auth/userpass-0',
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
namespace_path: 'root',
acme_clients: 0,
@ -856,7 +858,19 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
namespace_path: 'root',
mount_type: 'pki',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
mount_type: 'kv',
namespace_path: 'root',
acme_clients: 0,
@ -871,7 +885,6 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
},
},
{
month: '8/23',
timestamp: '2023-08-01T00:00:00Z',
acme_clients: 100,
clients: 400,
@ -888,17 +901,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 100,
mounts: [
{
label: 'pki-engine-0',
namespace_path: 'root',
mount_type: 'pki',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
label: 'auth/userpass-0',
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
namespace_path: 'root',
mount_type: 'userpass',
acme_clients: 0,
@ -908,7 +912,20 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
namespace_path: 'root',
mount_type: 'pki',
acme_clients: 100,
clients: 100,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
namespace_path: 'root',
mount_type: 'kv',
acme_clients: 0,
@ -921,13 +938,11 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
},
],
new_clients: {
month: '8/23',
timestamp: '2023-08-01T00:00:00Z',
namespaces: [],
},
},
{
month: '9/23',
timestamp: '2023-09-01T00:00:00Z',
acme_clients: 1928,
clients: 3928,
@ -944,7 +959,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 157,
mounts: [
{
label: 'pki-engine-0',
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
acme_clients: 934,
clients: 934,
entity_clients: 0,
@ -952,7 +968,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'auth/userpass-0',
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
acme_clients: 0,
clients: 890,
entity_clients: 708,
@ -960,7 +977,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
acme_clients: 0,
clients: 157,
entity_clients: 0,
@ -978,7 +996,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 81,
mounts: [
{
label: 'pki-engine-0',
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
acme_clients: 994,
clients: 994,
entity_clients: 0,
@ -986,7 +1005,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'auth/userpass-0',
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
acme_clients: 0,
clients: 872,
entity_clients: 124,
@ -994,7 +1014,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
acme_clients: 0,
clients: 81,
entity_clients: 0,
@ -1005,7 +1026,6 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
},
],
new_clients: {
month: '9/23',
timestamp: '2023-09-01T00:00:00Z',
acme_clients: 144,
clients: 364,
@ -1022,7 +1042,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 25,
mounts: [
{
label: 'pki-engine-0',
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
mount_type: 'pki',
acme_clients: 91,
clients: 91,
@ -1031,7 +1052,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'auth/userpass-0',
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
acme_clients: 0,
clients: 75,
@ -1040,7 +1062,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
mount_type: 'kv',
acme_clients: 0,
clients: 25,
@ -1059,7 +1082,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 24,
mounts: [
{
label: 'auth/userpass-0',
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
acme_clients: 0,
clients: 96,
@ -1068,7 +1092,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'pki-engine-0',
label: 'acme/pki/0',
mount_path: 'acme/pki/0',
mount_type: 'pki',
acme_clients: 53,
clients: 53,
@ -1077,7 +1102,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = {
secret_syncs: 0,
},
{
label: 'kvv2-engine-0',
label: 'secrets/kv/0',
mount_path: 'secrets/kv/0',
mount_type: 'kv',
acme_clients: 0,
clients: 24,

View File

@ -54,6 +54,7 @@ export const CHARTS = {
};
export const FILTERS = {
dropdown: (name: string) => `[data-test-dropdown="${name}"]`,
dropdownToggle: (name: string) => `[data-test-dropdown="${name}"] button`,
dropdownItem: (name: string) => `[data-test-dropdown-item="${name}"]`,
tag: (filter?: string, value?: string) =>

View File

@ -20,7 +20,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) {
this.mountPaths = ['auth/token/', 'auth/auto/eng/core/auth/core-gh-auth/', 'auth/userpass-root/'];
this.mountTypes = ['token/', 'userpass/', 'ns_token/'];
this.onFilter = sinon.spy();
this.appliedFilters = { nsLabel: '', mountPath: '', mountType: '' };
this.appliedFilters = { namespace_path: '', mount_path: '', mount_type: '' };
this.renderComponent = async () => {
await render(hbs`
@ -34,7 +34,11 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) {
};
this.presetFilters = () => {
this.appliedFilters = { nsLabel: 'admin/', mountPath: 'auth/userpass-root/', mountType: 'token/' };
this.appliedFilters = {
namespace_path: 'admin/',
mount_path: 'auth/userpass-root/',
mount_type: 'token/',
};
};
this.selectFilters = async () => {
@ -121,7 +125,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) {
});
test('it applies updated filters when filters are preset', async function (assert) {
this.appliedFilters = { mountPath: 'auth/token/', mountType: 'ns_token/', nsLabel: 'ns1' };
this.appliedFilters = { namespace_path: 'ns1', mount_path: 'auth/token/', mount_type: 'ns_token/' };
await this.renderComponent();
// Check initial filters
await click(GENERAL.button('Apply filters'));
@ -133,7 +137,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) {
const [afterUpdate] = this.onFilter.lastCall.args;
assert.propEqual(
afterUpdate,
{ mountPath: 'auth/userpass-root/', mountType: 'token/', nsLabel: 'admin/' },
{ namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/' },
'callback fires with updated selection'
);
});
@ -159,7 +163,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) {
const [beforeClear] = this.onFilter.lastCall.args;
assert.propEqual(
beforeClear,
{ mountPath: 'auth/userpass-root/', mountType: 'token/', nsLabel: 'admin/' },
{ namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/' },
'callback fires with preset filters'
);
// now clear filters and confirm values are cleared
@ -167,7 +171,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) {
const [afterClear] = this.onFilter.lastCall.args;
assert.propEqual(
afterClear,
{ mountPath: '', mountType: '', nsLabel: '' },
{ namespace_path: '', mount_path: '', mount_type: '' },
'onFilter callback has empty values when "Clear filters" is clicked'
);
});
@ -180,15 +184,15 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) {
const [beforeClear] = this.onFilter.lastCall.args;
assert.propEqual(
beforeClear,
{ mountPath: 'auth/userpass-root/', mountType: 'token/', nsLabel: 'admin/' },
{ namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/' },
'callback fires with preset filters'
);
await click(FILTERS.clearTag('admin/'));
const afterClear = this.onFilter.lastCall.args[0];
assert.propEqual(
afterClear,
{ mountPath: 'auth/userpass-root/', mountType: 'token/', nsLabel: '' },
'onFilter callback fires with empty nsLabel'
{ namespace_path: '', mount_path: 'auth/userpass-root/', mount_type: 'token/' },
'onFilter callback fires with empty namespace_path'
);
});

View File

@ -24,7 +24,6 @@ module('Integration | Component | clients/page-header', function (hooks) {
this.downloadStub = Sinon.stub(this.owner.lookup('service:download'), 'download');
this.startTimestamp = '2022-06-01T23:00:11.050Z';
this.endTimestamp = '2022-12-01T23:00:11.050Z';
this.selectedNamespace = undefined;
this.upgradesDuringActivity = [];
this.noData = undefined;
this.server.post('/sys/capabilities-self', () =>
@ -36,7 +35,6 @@ module('Integration | Component | clients/page-header', function (hooks) {
<Clients::PageHeader
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@namespace={{this.selectedNamespace}}
@upgradesDuringActivity={{this.upgradesDuringActivity}}
@noData={{this.noData}}
/>`);
@ -141,36 +139,6 @@ module('Integration | Component | clients/page-header', function (hooks) {
await click(CLIENT_COUNT.exportButton);
await click(GENERAL.confirmButton);
});
test('it sends the selected namespace in export request', async function (assert) {
assert.expect(2);
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
assert.strictEqual(req.requestHeaders['X-Vault-Namespace'], 'foobar');
return new Response(200, { 'Content-Type': 'text/csv' }, '');
});
this.selectedNamespace = 'foobar/';
await this.renderComponent();
assert.dom(CLIENT_COUNT.exportButton).exists();
await click(CLIENT_COUNT.exportButton);
await click(GENERAL.confirmButton);
});
test('it sends the current + selected namespace in export request', async function (assert) {
assert.expect(2);
const namespaceSvc = this.owner.lookup('service:namespace');
namespaceSvc.path = 'foo';
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
assert.strictEqual(req.requestHeaders['X-Vault-Namespace'], 'foo/bar');
return new Response(200, { 'Content-Type': 'text/csv' }, '');
});
this.selectedNamespace = 'bar/';
await this.renderComponent();
assert.dom(CLIENT_COUNT.exportButton).exists();
await click(CLIENT_COUNT.exportButton);
await click(GENERAL.confirmButton);
});
test('it shows a no data message if export returns 204', async function (assert) {
this.server.get('/sys/internal/counters/activity/export', () => overrideResponse(204));
@ -258,7 +226,7 @@ module('Integration | Component | clients/page-header', function (hooks) {
this.startTimestamp = undefined;
this.endTimestamp = undefined;
const namespace = this.owner.lookup('service:namespace');
namespace.path = 'bar/';
namespace.path = 'bar';
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
assert.deepEqual(req.queryParams, {
@ -275,27 +243,5 @@ module('Integration | Component | clients/page-header', function (hooks) {
const [filename] = this.downloadStub.lastCall.args;
assert.strictEqual(filename, 'clients_export_bar');
});
test('includes selectedNamespace', async function (assert) {
assert.expect(2);
this.startTimestamp = undefined;
this.endTimestamp = undefined;
this.selectedNamespace = 'foo/';
this.server.get('/sys/internal/counters/activity/export', function (_, req) {
assert.deepEqual(req.queryParams, {
format: 'csv',
});
return new Response(200, { 'Content-Type': 'text/csv' }, '');
});
await this.renderComponent();
await click(CLIENT_COUNT.exportButton);
await click(GENERAL.confirmButton);
await waitUntil(() => this.downloadStub.calledOnce);
const [filename] = this.downloadStub.lastCall.args;
assert.strictEqual(filename, 'clients_export_foo');
});
});
});

View File

@ -16,7 +16,6 @@ import clientsHandler, {
import { fromUnixTime, getUnixTime } from 'date-fns';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
import { selectChoose } from 'ember-power-select/test-support';
import timestamp from 'core/utils/timestamp';
import sinon from 'sinon';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
@ -53,8 +52,6 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
@versionHistory={{this.versionHistory}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@namespace={{this.namespace}}
@mountPath={{this.mountPath}}
@onFilterChange={{this.onFilterChange}}
>
<div data-test-yield>Yield block</div>
@ -164,41 +161,6 @@ module('Integration | Component | clients | Page::Counts', function (hooks) {
});
});
test('it should render namespace and auth mount filters', async function (assert) {
assert.expect(5);
this.namespace = 'root';
this.mountPath = 'auth/authid0';
let assertion = (params) =>
assert.deepEqual(params, { ns: undefined, mountPath: '' }, 'Auth mount cleared with namespace');
this.onFilterChange = (params) => {
if (assertion) {
assertion(params);
}
const keys = Object.keys(params);
this.namespace = keys.includes('ns') ? params.ns : this.namespace;
this.mountPath = keys.includes('mountPath') ? params.mountPath : this.mountPath;
};
await this.renderComponent();
assert.dom(CLIENT_COUNT.counts.namespaces).includesText(this.namespace, 'Selected namespace renders');
assert.dom(CLIENT_COUNT.counts.mountPaths).includesText(this.mountPath, 'Selected auth mount renders');
await click(`${CLIENT_COUNT.counts.namespaces} button`);
// this is only necessary in tests since SearchSelect does not respond to initialValue changes
// in the app the component is rerender on query param change
assertion = null;
await click(`${CLIENT_COUNT.counts.mountPaths} button`);
assertion = (params) => assert.true(params.ns.includes('ns'), 'Namespace value sent on change');
await selectChoose(CLIENT_COUNT.counts.namespaces, '.ember-power-select-option', 0);
assertion = (params) =>
assert.true(params.mountPath.includes('auth/'), 'Auth mount value sent on change');
await selectChoose(CLIENT_COUNT.counts.mountPaths, 'auth/authid0');
});
test('it should render start time discrepancy alert', async function (assert) {
this.startTimestamp = new Date('2022-06-01T00:00:00Z').toISOString();

View File

@ -8,159 +8,178 @@ import { setupRenderingTest } from 'vault/tests/helpers';
import { click, fillIn, findAll, render, triggerEvent } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers';
import { filterActivityResponse } from 'vault/mirage/handlers/clients';
import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
import { CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import { ClientFilters, flattenMounts } from 'core/utils/client-count-utils';
module('Integration | Component | clients/page/overview', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
this.server.get('sys/internal/counters/activity', (_, req) => {
const namespace = req.requestHeaders['X-Vault-Namespace'];
if (namespace === 'no-data') {
return {
request_id: 'some-activity-id',
data: {
by_namespace: [],
end_time: '2024-08-31T23:59:59Z',
months: [],
start_time: '2024-01-01T00:00:00Z',
total: {
distinct_entities: 0,
entity_clients: 0,
non_entity_tokens: 0,
non_entity_clients: 0,
clients: 0,
secret_syncs: 0,
},
},
};
}
this.server.get('sys/internal/counters/activity', () => {
return {
request_id: 'some-activity-id',
data: filterActivityResponse(ACTIVITY_RESPONSE_STUB, namespace),
data: ACTIVITY_RESPONSE_STUB,
};
});
this.store = this.owner.lookup('service:store');
this.mountPath = '';
this.namespace = '';
this.versionHistory = '';
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.renderComponent = () =>
render(hbs`
<Clients::Page::Overview
@activity={{this.activity}}
@onFilterChange={{this.onFilterChange}}
@filterQueryParams={{this.filterQueryParams}}
/>`);
});
// Fails on #ember-testing-container
setRunOptions({
rules: {
'aria-prohibited-attr': { enabled: false },
},
test('it hides attribution when there is no data', async function (assert) {
// Stub activity response when there's no activity data
this.server.get('sys/internal/counters/activity', () => {
return {
request_id: 'some-activity-id',
data: {
by_namespace: [],
end_time: '2024-08-31T23:59:59Z',
months: [],
start_time: '2024-01-01T00:00:00Z',
total: {
distinct_entities: 0,
entity_clients: 0,
non_entity_tokens: 0,
non_entity_clients: 0,
clients: 0,
secret_syncs: 0,
},
},
};
});
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 empty state message upon initial load', async function (assert) {
await render(hbs`<Clients::Page::Overview @activity={{this.activity}}/>`);
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'))
.exists('shows card for table state')
.hasText(
'Select a month to view client attribution View the namespace mount breakdown of clients by selecting a month. Client count documentation',
'Show initial table state message'
);
});
test('it shows correct state message when month selection has no data', async function (assert) {
await render(hbs`<Clients::Page::Overview @activity={{this.activity}} />`);
assert.dom(GENERAL.selectByAttr('attribution-month')).exists('shows month selection dropdown');
await fillIn(GENERAL.selectByAttr('attribution-month'), '6/23');
assert
.dom(CLIENT_COUNT.card('table empty state'))
.hasText(
'No data is available for the selected month View the namespace mount breakdown of clients by selecting another month. Client count documentation',
'Shows correct message for a month selection with no data'
);
.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 render(hbs`<Clients::Page::Overview @activity={{this.activity}} />`);
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('13 of 6', 'shows correct pagination info');
});
test('it filters the table when a namespace filter is applied', async function (assert) {
this.namespace = 'ns1';
this.activity = await this.store.queryRecord('clients/activity', {
namespace: this.namespace,
});
await render(hbs`<Clients::Page::Overview @activity={{this.activity}} @namespace={{this.namespace}} />`);
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();
assert.dom(GENERAL.paginationInfo).hasText('13 of 3', 'shows correct pagination info');
});
test('it hides the table when a mount filter is applied', async function (assert) {
this.namespace = 'ns1';
this.mountPath = 'auth/userpass-0';
this.activity = await this.store.queryRecord('clients/activity', {
namespace: this.namespace,
mountPath: this.mountPath,
});
await render(
hbs`<Clients::Page::Overview @activity={{this.activity}} @namespace={{this.namespace}} @mountPath={{this.mountPath}}/>`
);
assert.dom(CLIENT_COUNT.card('table empty state')).doesNotExist('does not show card when table has data');
assert
.dom(GENERAL.table('attribution'))
.doesNotExist('does not show table when a mount filter is applied');
});
test('it paginates table data', async function (assert) {
await render(hbs`<Clients::Page::Overview @activity={{this.activity}} />`);
await fillIn(GENERAL.selectByAttr('attribution-month'), '9/23');
assert
.dom(GENERAL.tableRow())
.exists({ count: 3 }, 'Correct number of table rows render based on page size');
assert.dom(GENERAL.tableData(0, 'clients')).hasText('96', 'First page shows data');
assert.dom(GENERAL.pagination).exists('shows pagination');
assert.dom(GENERAL.paginationInfo).hasText('13 of 6', 'shows correct pagination info');
await click(GENERAL.nextPage);
assert.dom(GENERAL.tableData(0, 'clients')).hasText('53', 'Second page shows new data');
assert.dom(GENERAL.paginationInfo).hasText('46 of 6', 'shows correct pagination info');
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 render(hbs`<Clients::Page::Overview @activity={{this.activity}} />`);
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
const expectedMonths = this.activity.byMonth.reverse().map((m) => m.month);
// '' represents default state of 'Select month'
const expectedOptions = ['', ...expectedMonths];
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) {
await this.renderComponent();
const topNamespace = this.activity.byNamespace[0];
const topMount = topNamespace.mounts[0];
// Assert table renders namespace with the highest counts at the top
assert.dom(GENERAL.tableData(0, 'namespace_path')).hasText(topNamespace.label);
assert.dom(GENERAL.tableData(0, 'clients')).hasText(`${topMount.clients}`);
});
test('it renders dropdown lists from activity response to filter table data', async function (assert) {
const expectedNamespaces = this.activity.byNamespace.map((n) => n.label);
const mounts = flattenMounts(this.mostRecentMonth.new_clients.namespaces);
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.NAMESPACE));
findAll(`${FILTERS.dropdown(ClientFilters.NAMESPACE)} li button`).forEach((item, idx) => {
const expected = expectedNamespaces[idx];
assert.dom(item).hasText(expected, `namespace dropdown renders: ${expected}`);
});
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH));
findAll(`${FILTERS.dropdown(ClientFilters.MOUNT_PATH)} li button`).forEach((item, idx) => {
const expected = expectedMountPaths[idx];
assert.dom(item).hasText(expected, `mount_path dropdown renders: ${expected}`);
});
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE));
findAll(`${FILTERS.dropdown(ClientFilters.MOUNT_TYPE)} li button`).forEach((item, idx) => {
const expected = expectedMountTypes[idx];
assert.dom(item).hasText(expected, `mount_type dropdown renders: ${expected}`);
});
});
// * 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) {
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(
(a, b) => b.clients - a.clients
);
const topMount = sortedMounts[0];
assert.dom(GENERAL.tableData(0, 'namespace_path')).hasText(topMount.namespace_path);
assert.dom(GENERAL.tableData(0, 'clients')).hasText(`${topMount.clients}`);
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');
});
});

View File

@ -17,6 +17,7 @@ import timestamp from 'core/utils/timestamp';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { CLIENT_COUNT, CHARTS } from 'vault/tests/helpers/clients/client-count-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { parseAPITimestamp } from 'core/utils/date-formatters';
const START_TIME = getUnixTime(LICENSE_START);
@ -45,7 +46,6 @@ module('Integration | Component | clients/running-total', function (hooks) {
@isSecretsSyncActivated={{this.isSecretsSyncActivated}}
@byMonthNewClients={{this.byMonthNewClients}}
@runningTotals={{this.totalUsageCounts}}
@upgradeData={{this.upgradesDuringActivity}}
@isHistoricalMonth={{this.isHistoricalMonth}}
/>
`);
@ -88,12 +88,9 @@ module('Integration | Component | clients/running-total', function (hooks) {
// assert bar chart is correct
findAll(CHARTS.xAxisLabel).forEach((e, i) => {
assert
.dom(e)
.hasText(
`${this.byMonthNewClients[i].month}`,
`renders x-axis labels for bar chart: ${this.byMonthNewClients[i].month}`
);
const timestamp = this.byMonthNewClients[i].timestamp;
const displayMonth = parseAPITimestamp(timestamp, 'M/yy');
assert.dom(e).hasText(displayMonth, `renders x-axis labels for bar chart: ${displayMonth}`);
});
assert
.dom(CHARTS.verticalBar)
@ -112,27 +109,24 @@ module('Integration | Component | clients/running-total', function (hooks) {
// assert each legend item is correct
const expectedLegend = [
{ label: 'Entity clients', color: 'rgb(28, 52, 95)' },
{ label: 'Non-entity clients', color: 'rgb(6, 208, 146)' },
{ label: 'Secret sync clients', color: 'rgb(145, 28, 237)' },
{ label: 'Acme clients', color: 'rgb(2, 168, 239)' },
{ label: 'Entity clients', color: 'rgb(66, 105, 208)' },
{ label: 'Non-entity clients', color: 'rgb(239, 177, 23)' },
{ label: 'Secret sync clients', color: 'rgb(108, 197, 176)' },
{ label: 'Acme clients', color: 'rgb(255, 114, 92)' },
];
findAll('.legend-item').forEach((e, i) => {
const { label, color } = expectedLegend[i];
assert.dom(e).hasText(label, `legend renders label: ${label}`);
const dotColor = getComputedStyle(find(CHARTS.legendDot(i + 1))).backgroundColor;
assert.strictEqual(dotColor, color, `actual color: ${dotColor}, expected color: ${color}`);
assert.strictEqual(dotColor, color, `${label} - actual color: ${dotColor}, expected: ${color}`);
});
// assert bar chart is correct
findAll(CHARTS.xAxisLabel).forEach((e, i) => {
assert
.dom(e)
.hasText(
`${this.byMonthNewClients[i].month}`,
`renders x-axis labels for bar chart: ${this.byMonthNewClients[i].month}`
);
const timestamp = this.byMonthNewClients[i].timestamp;
const displayMonth = parseAPITimestamp(timestamp, 'M/yy');
assert.dom(e).hasText(`${displayMonth}`, `renders x-axis labels for bar chart: ${displayMonth}`);
});
const months = this.byMonthNewClients.length;
@ -189,16 +183,16 @@ module('Integration | Component | clients/running-total', function (hooks) {
// assert each legend item is correct
const expectedLegend = [
{ label: 'Entity clients', color: 'rgb(28, 52, 95)' },
{ label: 'Non-entity clients', color: 'rgb(6, 208, 146)' },
{ label: 'Acme clients', color: 'rgb(2, 168, 239)' },
{ label: 'Entity clients', color: 'rgb(66, 105, 208)' },
{ label: 'Non-entity clients', color: 'rgb(239, 177, 23)' },
{ label: 'Acme clients', color: 'rgb(255, 114, 92)' },
];
findAll('.legend-item').forEach((e, i) => {
const { label, color } = expectedLegend[i];
assert.dom(e).hasText(label, `legend renders label: ${label}`);
const dotColor = getComputedStyle(find(CHARTS.legendDot(i + 1))).backgroundColor;
assert.strictEqual(dotColor, color, `actual color: ${dotColor}, expected color: ${color}`);
assert.strictEqual(dotColor, color, `${label} - actual color: ${dotColor}, expected: ${color}`);
});
const months = this.byMonthNewClients.length;

View File

@ -5,7 +5,7 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, fillIn, findAll, render } from '@ember/test-helpers';
import { click, fillIn, findAll, render, waitFor } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors';
@ -14,8 +14,9 @@ const MOCK_DATA = [
{ island: 'Maldives', visit_length: 5, is_booked: false, trip_date: '2025-06-22T00:00:00.000Z' },
{ island: 'Bora Bora', visit_length: 7, is_booked: true, trip_date: '2025-03-15T00:00:00.000Z' },
{ island: 'Fiji', visit_length: 10, is_booked: true, trip_date: '2025-09-08T00:00:00.000Z' },
{ island: 'Seychelles', visit_length: 6, is_booked: false, trip_date: '2025-12-03T00:00:00.000Z' },
{ island: 'Santorini', visit_length: 4, is_booked: false, trip_date: '2026-04-10T00:00:00.000Z' },
{ island: 'Maui', visit_length: 8, is_booked: true, trip_date: '2026-01-18T00:00:00.000Z' },
{ island: 'Seychelles', visit_length: 6, is_booked: false, trip_date: '2025-12-03T00:00:00.000Z' },
];
module('Integration | Component | clients/table', function (hooks) {
setupRenderingTest(hooks);
@ -70,7 +71,7 @@ module('Integration | Component | clients/table', function (hooks) {
this.data = MOCK_DATA;
await this.renderComponent();
assert.dom(CLIENT_COUNT.card('table empty state')).doesNotExist();
assert.dom(GENERAL.paginationInfo).hasText(`13 of ${this.data.length}`);
assert.dom(GENERAL.paginationInfo).hasText(`15 of ${this.data.length}`);
await click(GENERAL.nextPage);
assert.dom(GENERAL.tableData(0, 'island')).hasText('Seychelles', 'it paginates the data');
});
@ -88,30 +89,39 @@ module('Integration | Component | clients/table', function (hooks) {
await this.renderComponent();
const [firstColumn, secondColumn, thirdColumn, fourthColumn] = findAll(GENERAL.icon('swap-vertical'));
await click(firstColumn);
assertSortOrder(['Bora Bora', 'Fiji', 'Maldives'], { column: 'island', page: 1 });
assertSortOrder(['Bora Bora', 'Fiji', 'Maldives', 'Maui', 'Santorini'], { column: 'island', page: 1 });
await click(GENERAL.nextPage);
assertSortOrder(['Maui', 'Seychelles'], { column: 'island', page: 2 });
assertSortOrder(['Seychelles'], { column: 'island', page: 2 });
await click(GENERAL.prevPage);
await click(secondColumn);
assertSortOrder(['5', '6', '7'], { column: 'visit_length', page: 1 });
assertSortOrder(['4', '5', '6', '7', '8'], { column: 'visit_length', page: 1 });
await click(GENERAL.nextPage);
assertSortOrder(['8', '10'], { column: 'visit_length', page: 2 });
assertSortOrder(['10'], { column: 'visit_length', page: 2 });
await click(GENERAL.prevPage);
await click(thirdColumn);
assertSortOrder(['false', 'false', 'true'], { column: 'is_booked', page: 1 });
assertSortOrder(['false', 'false', 'false', 'true', 'true'], { column: 'is_booked', page: 1 });
await click(GENERAL.nextPage);
assertSortOrder(['true', 'true'], { column: 'is_booked', page: 2 });
assertSortOrder(['true'], { column: 'is_booked', page: 2 });
await click(GENERAL.prevPage);
await click(fourthColumn);
assertSortOrder(['2025-03-15T00:00:00.000Z', '2025-06-22T00:00:00.000Z', '2025-09-08T00:00:00.000Z'], {
column: 'trip_date',
page: 1,
});
assertSortOrder(
[
'2025-03-15T00:00:00.000Z',
'2025-06-22T00:00:00.000Z',
'2025-09-08T00:00:00.000Z',
'2025-12-03T00:00:00.000Z',
'2026-01-18T00:00:00.000Z',
],
{
column: 'trip_date',
page: 1,
}
);
await click(GENERAL.nextPage);
assertSortOrder(['2025-12-03T00:00:00.000Z', '2026-01-18T00:00:00.000Z'], {
assertSortOrder(['2026-04-10T00:00:00.000Z'], {
column: 'trip_date',
page: 2,
});
@ -125,12 +135,12 @@ module('Integration | Component | clients/table', function (hooks) {
assert
.dom(`${GENERAL.tableColumnHeader(2, { isAdvanced: true })} ${GENERAL.icon('arrow-down')}`)
.exists();
const firstPage = ['Fiji', 'Maui', 'Bora Bora'];
const firstPage = ['Fiji', 'Maui', 'Bora Bora', 'Seychelles', 'Maldives'];
firstPage.forEach((value, idx) => {
assert.dom(GENERAL.tableData(idx, 'island')).hasText(value, `page 1, row ${idx} has ${value}`);
});
await click(GENERAL.nextPage);
const secondPage = ['Seychelles', 'Maldives'];
const secondPage = ['Santorini'];
secondPage.forEach((value, idx) => {
assert.dom(GENERAL.tableData(idx, 'island')).hasText(value, `page 2, row ${idx} has ${value}`);
});
@ -186,4 +196,36 @@ module('Integration | Component | clients/table', function (hooks) {
.exists('it renders a badge for the deleted mount')
.hasText('Deleted');
});
test('it resets pagination when data changes', async function (assert) {
// We need more than 5 rows, so here's more mock data!
const moreData = [
{ island: 'Tahiti', visit_length: 12, is_booked: true, trip_date: '2025-05-10T00:00:00.000Z' },
{ island: 'Barbados', visit_length: 6, is_booked: false, trip_date: '2025-08-25T00:00:00.000Z' },
{ island: 'Cyprus', visit_length: 9, is_booked: true, trip_date: '2026-03-12T00:00:00.000Z' },
{ island: 'Jamaica', visit_length: 7, is_booked: false, trip_date: '2025-11-05T00:00:00.000Z' },
{ island: 'Crete', visit_length: 11, is_booked: true, trip_date: '2026-06-18T00:00:00.000Z' },
{ island: 'Aruba', visit_length: 5, is_booked: false, trip_date: '2025-10-14T00:00:00.000Z' },
];
this.data = [...MOCK_DATA, ...moreData];
this.showPaginationSizeSelector = true;
await this.renderComponent();
await fillIn(GENERAL.paginationSizeSelector, '10'); // Default is 5, so change to something else
await click(GENERAL.nextPage);
assert.dom(GENERAL.paginationInfo).hasText(`1112 of ${this.data.length}`, 'it navigates to next page');
assert.dom(GENERAL.tableRow()).exists({ count: 2 }, '2 row renders');
// Changing the @data arg should trigger an update and reset pagination
// We have to use `this.set` to trigger did-update
this.set('data', [
{ island: 'Palawan', visit_length: 9, is_booked: true, trip_date: '2025-11-14T00:00:00.000Z' },
{ island: 'Mykonos', visit_length: 3, is_booked: false, trip_date: '2026-02-28T00:00:00.000Z' },
]);
// There's a workaround using next() from @ember/runloop because the Hds::Pagination::Numbered component
// doesn't re-render when @currentPage updates. When that's fixed at the source we should be able to remove waitFor
await waitFor(GENERAL.paginationInfo);
assert.dom(GENERAL.paginationInfo).hasText(`12 of ${this.data.length}`);
assert.dom(GENERAL.tableRow()).exists({ count: 2 }, '2 rows render');
assert.dom(GENERAL.paginationSizeSelector).hasValue('10', 'page selector is unchanged when data updates');
});
});

View File

@ -12,8 +12,8 @@ import {
formatByNamespace,
destructureClientCounts,
sortMonthsByTimestamp,
filterByMonthDataForMount,
filteredTotalForMount,
flattenMounts,
filterTableData,
} from 'core/utils/client-count-utils';
import clientsHandler from 'vault/mirage/handlers/clients';
import {
@ -125,6 +125,23 @@ module('Integration | Util | client count utils', function (hooks) {
});
});
test('flattenMounts: it flattens mount data', async function (assert) {
assert.expect(2);
const original = [...SERIALIZED_ACTIVITY_RESPONSE.by_namespace];
const expected = [
...SERIALIZED_ACTIVITY_RESPONSE.by_namespace[0].mounts,
...SERIALIZED_ACTIVITY_RESPONSE.by_namespace[1].mounts,
];
const actual = flattenMounts(SERIALIZED_ACTIVITY_RESPONSE.by_namespace);
assert.propEqual(actual, expected, 'it returns mounts from each namespace object into a single array');
assert.propEqual(
SERIALIZED_ACTIVITY_RESPONSE.by_namespace,
original,
'it does not modify original by_namespace array'
);
});
test('formatByMonths: it formats the months array', async function (assert) {
assert.expect(7);
const original = [...RESPONSE.months];
@ -233,6 +250,7 @@ module('Integration | Util | client count utils', function (hooks) {
clients: 30,
entity_clients: 10,
label: 'no mount accessor (pre-1.10 upgrade?)',
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
mount_type: '',
namespace_path: 'root',
non_entity_clients: 20,
@ -266,6 +284,7 @@ module('Integration | Util | client count utils', function (hooks) {
clients: 2,
entity_clients: 2,
label: 'no mount accessor (pre-1.10 upgrade?)',
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
mount_type: 'no mount path (pre-1.10 upgrade?)',
namespace_path: 'root',
non_entity_clients: 0,
@ -275,7 +294,8 @@ module('Integration | Util | client count utils', function (hooks) {
acme_clients: 0,
clients: 1,
entity_clients: 1,
label: 'auth/userpass-0',
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
namespace_path: 'root',
non_entity_clients: 0,
@ -294,7 +314,7 @@ module('Integration | Util | client count utils', function (hooks) {
acme_clients: 0,
clients: 3,
entity_clients: 3,
month: '4/24',
namespaces: [
{
acme_clients: 0,
@ -307,6 +327,7 @@ module('Integration | Util | client count utils', function (hooks) {
clients: 2,
entity_clients: 2,
label: 'no mount accessor (pre-1.10 upgrade?)',
mount_path: 'no mount accessor (pre-1.10 upgrade?)',
mount_type: 'no mount path (pre-1.10 upgrade?)',
namespace_path: 'root',
non_entity_clients: 0,
@ -316,7 +337,8 @@ module('Integration | Util | client count utils', function (hooks) {
acme_clients: 0,
clients: 1,
entity_clients: 1,
label: 'auth/userpass-0',
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
namespace_path: 'root',
non_entity_clients: 0,
@ -335,237 +357,111 @@ module('Integration | Util | client count utils', function (hooks) {
);
});
module('filterByMonthDataForMount', function (hooks) {
hooks.beforeEach(function () {
this.getExpected = (label, count = 0, newCount = 0) => {
return {
month: '6/23',
namespaces: [],
label,
timestamp: '2023-06-01T00:00:00Z',
acme_clients: count,
clients: count,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
new_clients: {
month: '6/23',
timestamp: '2023-06-01T00:00:00Z',
namespaces: [],
label,
acme_clients: newCount,
clients: newCount,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
};
};
module('filterTableData', function (hooks) {
hooks.beforeEach(async function () {
const activityByMount = flattenMounts(SERIALIZED_ACTIVITY_RESPONSE.by_namespace);
this.mockMountData = [...activityByMount];
// copy mock data before using the filterTableData function to assert filtering doesn't modify the original array
const original = [...this.mockMountData];
this.assertOriginal = (assert) =>
assert.propEqual(this.mockMountData, original, 'filtering does not mutate dataset');
});
test('it works when month has no data', async function (assert) {
const months = [
test('it returns original data if no filters are passed', async function (assert) {
const emptyObject = filterTableData(this.mockMountData, {});
assert.propEqual(emptyObject, this.mockMountData, 'when filters arg is an empty object');
this.assertOriginal(assert);
const emptyValues = filterTableData(this.mockMountData, {
namespace_path: '',
mount_path: '',
mount_type: '',
});
assert.propEqual(emptyValues, this.mockMountData, 'when filters have are empty strings');
this.assertOriginal(assert);
const nullFilter = filterTableData(this.mockMountData, null);
assert.propEqual(nullFilter, this.mockMountData, 'returns all data when no filters are null');
this.assertOriginal(assert);
});
test('it filters data for a single filter', async function (assert) {
const namespaceFilter = filterTableData(this.mockMountData, {
namespace_path: 'root',
mount_path: '',
mount_type: '',
});
const expectedNamespaceFilter = this.mockMountData.filter((m) => m.namespace_path === 'root');
assert.propEqual(namespaceFilter, expectedNamespaceFilter, 'it filters by namespace_path');
this.assertOriginal(assert);
const mountPathFilter = filterTableData(this.mockMountData, {
namespace_path: '',
mount_path: 'acme/pki/0',
mount_type: '',
});
const expectedMountPathFilter = this.mockMountData.filter((m) => m.mount_path === 'acme/pki/0');
assert.propEqual(mountPathFilter, expectedMountPathFilter, 'it filters by mount_path');
this.assertOriginal(assert);
const mountTypeFilter = filterTableData(this.mockMountData, {
namespace_path: '',
mount_path: '',
mount_type: 'userpass',
});
const expectedMountTypeFilter = this.mockMountData.filter((m) => m.mount_type === 'userpass');
assert.propEqual(mountTypeFilter, expectedMountTypeFilter, 'it filters by mount_type');
this.assertOriginal(assert);
});
test('it filters data for a multiple filters', async function (assert) {
const twoFilters = filterTableData(this.mockMountData, {
namespace_path: 'root',
mount_path: '',
mount_type: 'userpass',
});
const expectedTwoFilters = this.mockMountData.filter(
(m) => m.namespace_path === 'root' && m.mount_type === 'userpass'
);
assert.propEqual(twoFilters, expectedTwoFilters, 'it filters by namespace_path and mount_type');
this.assertOriginal(assert);
const allFilters = filterTableData(this.mockMountData, {
namespace_path: 'root',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
});
const expectedAllFilters = [
{
month: '6/23',
timestamp: '2023-06-01T00:00:00Z',
namespaces: [],
new_clients: {
month: '6/23',
timestamp: '2023-06-01T00:00:00Z',
namespaces: [],
},
},
];
const result = filterByMonthDataForMount(months, 'root', 'some-mount');
// no data is different than zero, it implies no data was being saved at that time
// so we don't fill in missing data with zeros to differentiate those two states
assert.deepEqual(result[0], months[0], 'does not change month when no data');
});
test('it works when month has no new clients', async function (assert) {
const months = [
{
month: '6/23',
timestamp: '2023-06-01T00:00:00Z',
acme_clients: 11,
clients: 11,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
namespaces: [
{
label: 'root',
acme_clients: 11,
clients: 11,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
mounts: [
{
label: 'some-mount',
acme_clients: 11,
clients: 11,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
],
},
],
new_clients: {
month: '6/23',
timestamp: '2023-06-01T00:00:00Z',
namespaces: [],
},
},
];
let result = filterByMonthDataForMount(months, 'root', 'some-mount');
assert.propEqual(result[0], this.getExpected('some-mount', 11), 'works when mount is found');
result = filterByMonthDataForMount(months, 'root', 'another-mount');
assert.deepEqual(result[0], this.getExpected('another-mount', 0), 'works when mount is not found');
result = filterByMonthDataForMount(months, 'unknown-child', 'some-mount');
assert.deepEqual(result[0], this.getExpected('some-mount', 0), 'works when namespace is not found');
});
test('it works when month has new clients', async function (assert) {
const months = [
{
month: '6/23',
timestamp: '2023-06-01T00:00:00Z',
acme_clients: 22,
clients: 22,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
namespaces: [
{
label: 'root',
acme_clients: 22,
clients: 22,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
mounts: [
{
label: 'some-mount',
acme_clients: 22,
clients: 22,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
],
},
],
new_clients: {
month: '6/23',
timestamp: '2023-06-01T00:00:00Z',
namespaces: [
{
label: 'root',
acme_clients: 11,
clients: 11,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
mounts: [
{
label: 'some-mount',
acme_clients: 11,
clients: 11,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
],
},
],
},
},
];
let result = filterByMonthDataForMount(months, 'root', 'some-mount');
assert.propEqual(result[0], this.getExpected('some-mount', 22, 11), 'works when mount is found');
result = filterByMonthDataForMount(months, 'root', 'another-mount');
assert.deepEqual(result[0], this.getExpected('another-mount', 0), 'works when mount is not found');
result = filterByMonthDataForMount(months, 'unknown-child', 'some-mount');
assert.deepEqual(result[0], this.getExpected('some-mount', 0), 'works when namespace is not found');
});
});
module('filteredTotalForMount', function (hooks) {
hooks.beforeEach(function () {
this.byNamespace = SERIALIZED_ACTIVITY_RESPONSE.by_namespace;
this.byMonth = SERIALIZED_ACTIVITY_RESPONSE.by_month;
});
const emptyCounts = {
acme_clients: 0,
clients: 0,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
};
[
{
when: 'no namespace filter passed',
result: 'it returns empty counts',
ns: '',
mount: 'auth/userpass-0',
expected: emptyCounts,
},
{
when: 'no mount filter passed',
result: 'it returns empty counts',
ns: 'ns1',
mount: '',
expected: emptyCounts,
},
{
when: 'no matching ns/mount exists',
result: 'it returns empty counts',
ns: 'ns1',
mount: 'auth/userpass-1',
expected: emptyCounts,
},
{
when: 'mount and label have extra slashes',
result: 'it returns the data sanitized',
ns: 'ns1/',
mount: 'auth/userpass-0',
expected: {
label: 'auth/userpass-0',
label: 'auth/userpass/0',
mount_path: 'auth/userpass/0',
mount_type: 'userpass',
namespace_path: 'ns1',
acme_clients: 0,
clients: 8394,
entity_clients: 4256,
non_entity_clients: 4138,
secret_syncs: 0,
},
},
{
when: 'mount within root',
result: 'it returns the data',
ns: 'root',
mount: 'kvv2-engine-0',
expected: {
label: 'kvv2-engine-0',
mount_type: 'kv',
namespace_path: 'root',
acme_clients: 0,
clients: 4290,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 4290,
clients: 8091,
entity_clients: 4002,
non_entity_clients: 4089,
secret_syncs: 0,
},
},
].forEach((testCase) => {
test(`it returns correct values when ${testCase.when}`, async function (assert) {
const actual = filteredTotalForMount(this.byNamespace, testCase.ns, testCase.mount);
assert.deepEqual(actual, testCase.expected);
];
assert.propEqual(allFilters, expectedAllFilters, 'it filters by all filters');
this.assertOriginal(assert);
});
test('it returns an empty array when there are no matches', async function (assert) {
const noMatches = filterTableData(this.mockMountData, {
namespace_path: 'does not exist',
mount_path: '',
mount_type: '',
});
assert.propEqual(noMatches, [], 'returns an empty array when no data matches filters');
this.assertOriginal(assert);
});
test('it returns an empty array when filter includes keys the dataset does not contain', async function (assert) {
const noMatches = filterTableData(this.mockMountData, { foo: 'root', bar: '' });
assert.propEqual(noMatches, [], 'returns an empty array when no keys match dataset');
this.assertOriginal(assert);
});
});
});