From 2b469deecabdce5f9e6bc821cb7a8fd84c040355 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Tue, 2 Sep 2025 14:43:02 -0600 Subject: [PATCH] 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 --- ui/app/components/clients/activity.ts | 56 +-- ui/app/components/clients/filter-toolbar.hbs | 8 +- ui/app/components/clients/filter-toolbar.ts | 34 +- ui/app/components/clients/page-header.js | 25 +- ui/app/components/clients/page/client-list.ts | 4 +- ui/app/components/clients/page/counts.hbs | 54 +-- ui/app/components/clients/page/counts.ts | 78 +-- ui/app/components/clients/page/overview.hbs | 50 +- ui/app/components/clients/page/overview.ts | 60 ++- ui/app/components/clients/running-total.hbs | 2 +- ui/app/components/clients/running-total.ts | 4 - ui/app/components/clients/table.hbs | 27 +- ui/app/components/clients/table.ts | 45 +- .../vault/cluster/clients/counts.ts | 9 +- ui/app/routes/vault/cluster/clients/counts.ts | 23 +- ui/app/styles/core/charts.scss | 33 +- .../vault/cluster/clients/counts.hbs | 2 - .../cluster/clients/counts/client-list.hbs | 6 +- .../vault/cluster/clients/counts/overview.hbs | 10 +- ui/lib/core/addon/utils/client-count-utils.ts | 162 ++----- ui/mirage/handlers/clients.js | 18 +- .../clients/counts/client-list-test.js | 8 +- .../clients/counts/overview-test.js | 446 +++++++++--------- .../helpers/clients/client-count-helpers.js | 308 ++++++------ .../helpers/clients/client-count-selectors.ts | 1 + .../components/clients/filter-toolbar-test.js | 22 +- .../components/clients/page-header-test.js | 56 +-- .../components/clients/page/counts-test.js | 38 -- .../components/clients/page/overview-test.js | 237 +++++----- .../components/clients/running-total-test.js | 38 +- .../components/clients/table-test.js | 74 ++- .../utils/client-count-utils-test.js | 352 +++++--------- 32 files changed, 1005 insertions(+), 1285 deletions(-) diff --git a/ui/app/components/clients/activity.ts b/ui/app/components/clients/activity.ts index c2aa1c4951..0fba35f6d0 100644 --- a/ui/app/components/clients/activity.ts +++ b/ui/app/components/clients/activity.ts @@ -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; } export default class ClientsActivityComponent extends Component { @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 { 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 { ); } - // (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) { + 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: '' }); } } diff --git a/ui/app/components/clients/filter-toolbar.hbs b/ui/app/components/clients/filter-toolbar.hbs index 8b0006d2fc..f95f9beaa2 100644 --- a/ui/app/components/clients/filter-toolbar.hbs +++ b/ui/app/components/clients/filter-toolbar.hbs @@ -10,7 +10,7 @@ {{#each @namespaces as |namespace|}} {{namespace}} {{/each}} @@ -22,7 +22,7 @@ {{#each @mountPaths as |mountPath|}} {{mountPath}} {{/each}} @@ -34,7 +34,7 @@ {{#each @mountTypes as |mountType|}} {{mountType}} {{/each}} @@ -54,7 +54,7 @@ Filters applied: {{! 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}}
{ 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 { 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 { @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); } diff --git a/ui/app/components/clients/page-header.js b/ui/app/components/clients/page-header.js index 12efd7399a..5496989f2c 100644 --- a/ui/app/components/clients/page-header.js +++ b/ui/app/components/clients/page-header.js @@ -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); + }; } diff --git a/ui/app/components/clients/page/client-list.ts b/ui/app/components/clients/page/client-list.ts index b76fe982d5..9514ca64c1 100644 --- a/ui/app/components/clients/page/client-list.ts +++ b/ui/app/components/clients/page/client-list.ts @@ -34,7 +34,7 @@ export default class ClientsClientListPageComponent extends ActivityComponent { @action handleFilter(filters: Record) { - 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 }); } } diff --git a/ui/app/components/clients/page/counts.hbs b/ui/app/components/clients/page/counts.hbs index 4c3ff32484..042e385bb6 100644 --- a/ui/app/components/clients/page/counts.hbs +++ b/ui/app/components/clients/page/counts.hbs @@ -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 @@ {{/if}} - {{#if (or @namespace this.namespaces @mountPath this.mountPaths)}} - - Filters - - - 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. - - - - {{#if (or @namespace this.namespaces)}} - -
- {{/if}} - {{#if (or @mountPath this.mountPaths)}} - - {{/if}} -
-
- {{/if}} - - {{#if this.totalUsageCounts}} + {{#if @activity.total}} {{#if this.upgradeExplanations}} @@ -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." }} - > - - + /> {{/if}} {{/if}}
\ No newline at end of file diff --git a/ui/app/components/clients/page/counts.ts b/ui/app/components/clients/page/counts.ts index 8baffd3a4d..a5f963345b 100644 --- a/ui/app/components/clients/page/counts.ts +++ b/ui/app/components/clients/page/counts.ts @@ -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 { @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 { }; } - // 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 { 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: '', - }); - } } diff --git a/ui/app/components/clients/page/overview.hbs b/ui/app/components/clients/page/overview.hbs index d4fd8299d1..59072d9263 100644 --- a/ui/app/components/clients/page/overview.hbs +++ b/ui/app/components/clients/page/overview.hbs @@ -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}} - +{{! by_namespace is an empty array when there is no client count activity data }} +{{#if @activity.byNamespace}} + <:subheader> {{#each this.months as |m|}} - + {{/each}} + + {{#if this.selectedMonth}} +
+ Use the filters + to view the clients attributed by path. + + +
+ {{/if}} <:table> @@ -40,23 +57,13 @@ @data={{this.tableData}} @columns={{this.tableColumns}} @initiallySortBy={{hash column="clients" direction="desc"}} + @setPageSize={{10}} + @showPaginationSizeSelector={{true}} > <:emptyState> - - + +
- {{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/page/overview.ts b/ui/app/components/clients/page/overview.ts index 05d839b7bd..41f5b23c67 100644 --- a/ui/app/components/clients/page/overview.ts +++ b/ui/app/components/clients/page/overview.ts @@ -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) { this.selectedMonth = e.target.value; + // Reset filters when no month is selected + if (this.selectedMonth === '') { + this.resetFilters(); + } } } diff --git a/ui/app/components/clients/running-total.hbs b/ui/app/components/clients/running-total.hbs index babf0f3d80..b0ca15b541 100644 --- a/ui/app/components/clients/running-total.hbs +++ b/ui/app/components/clients/running-total.hbs @@ -85,7 +85,7 @@ {{! Renders when viewing the current month or for activity log data that predates the monthly breakdown added in 1.11 }} { diff --git a/ui/app/components/clients/table.hbs b/ui/app/components/clients/table.hbs index 2180a55202..464a7d90f4 100644 --- a/ui/app/components/clients/table.hbs +++ b/ui/app/components/clients/table.hbs @@ -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 @@ - + {{! WORKAROUND to manually re-render Hds::Pagination::Numbered to force update @currentPage }} + {{#if this.renderPagination}} + + {{/if}} + {{else}} {{#if (has-block "emptyState")}} diff --git a/ui/app/components/clients/table.ts b/ui/app/components/clients/table.ts index 0cd8c95f28..4907ea15ad 100644 --- a/ui/app/components/clients/table.ts +++ b/ui/app/components/clients/table.ts @@ -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 { @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[] { const sorted = this.sortTableData(this.args.data); const paginated = paginate(sorted, { @@ -77,10 +90,6 @@ export default class ClientsTable extends Component { 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[]): Record[] { @@ -102,6 +111,18 @@ export default class ClientsTable extends Component { 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 diff --git a/ui/app/controllers/vault/cluster/clients/counts.ts b/ui/app/controllers/vault/cluster/clients/counts.ts index adf7014401..84cede0443 100644 --- a/ui/app/controllers/vault/cluster/clients/counts.ts +++ b/ui/app/controllers/vault/cluster/clients/counts.ts @@ -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 diff --git a/ui/app/routes/vault/cluster/clients/counts.ts b/ui/app/routes/vault/cluster/clients/counts.ts index f58469a311..81ff282fee 100644 --- a/ui/app/routes/vault/cluster/clients/counts.ts +++ b/ui/app/routes/vault/cluster/clients/counts.ts @@ -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; @@ -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: '', }); } } diff --git a/ui/app/styles/core/charts.scss b/ui/app/styles/core/charts.scss index 6b3e6cbb3a..88db198796 100644 --- a/ui/app/styles/core/charts.scss +++ b/ui/app/styles/core/charts.scss @@ -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; } diff --git a/ui/app/templates/vault/cluster/clients/counts.hbs b/ui/app/templates/vault/cluster/clients/counts.hbs index 7c0e80d213..b98fa74e4a 100644 --- a/ui/app/templates/vault/cluster/clients/counts.hbs +++ b/ui/app/templates/vault/cluster/clients/counts.hbs @@ -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}} diff --git a/ui/app/templates/vault/cluster/clients/counts/client-list.hbs b/ui/app/templates/vault/cluster/clients/counts/client-list.hbs index 79a0edba6e..464115fb5a 100644 --- a/ui/app/templates/vault/cluster/clients/counts/client-list.hbs +++ b/ui/app/templates/vault/cluster/clients/counts/client-list.hbs @@ -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 }} /> \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/clients/counts/overview.hbs b/ui/app/templates/vault/cluster/clients/counts/overview.hbs index def76c2bc7..3bc899ea62 100644 --- a/ui/app/templates/vault/cluster/clients/counts/overview.hbs +++ b/ui/app/templates/vault/cluster/clients/counts/overview.hbs @@ -6,8 +6,10 @@ \ No newline at end of file diff --git a/ui/lib/core/addon/utils/client-count-utils.ts b/ui/lib/core/addon/utils/client-count-utils.ts index d2067345d7..79625048d9 100644 --- a/ui/lib/core/addon/utils/client-count-utils.ts +++ b/ui/lib/core/addon/utils/client-count-utils.ts @@ -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 +): 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 { diff --git a/ui/mirage/handlers/clients.js b/ui/mirage/handlers/clients.js index 8236c47903..4f1fd98501 100644 --- a/ui/mirage/handlers/clients.js +++ b/ui/mirage/handlers/clients.js @@ -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; diff --git a/ui/tests/acceptance/clients/counts/client-list-test.js b/ui/tests/acceptance/clients/counts/client-list-test.js index 9b614ecb8f..f19f1603b6 100644 --- a/ui/tests/acceptance/clients/counts/client-list-test.js +++ b/ui/tests/acceptance/clients/counts/client-list-test.js @@ -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')); diff --git a/ui/tests/acceptance/clients/counts/overview-test.js b/ui/tests/acceptance/clients/counts/overview-test.js index 502685685c..17b099a024 100644 --- a/ui/tests/acceptance/clients/counts/overview-test.js +++ b/ui/tests/acceptance/clients/counts/overview-test.js @@ -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'); diff --git a/ui/tests/helpers/clients/client-count-helpers.js b/ui/tests/helpers/clients/client-count-helpers.js index 1d4d4efde6..734df6ebf6 100644 --- a/ui/tests/helpers/clients/client-count-helpers.js +++ b/ui/tests/helpers/clients/client-count-helpers.js @@ -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, diff --git a/ui/tests/helpers/clients/client-count-selectors.ts b/ui/tests/helpers/clients/client-count-selectors.ts index 6124f4d814..db0f6bd12e 100644 --- a/ui/tests/helpers/clients/client-count-selectors.ts +++ b/ui/tests/helpers/clients/client-count-selectors.ts @@ -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) => diff --git a/ui/tests/integration/components/clients/filter-toolbar-test.js b/ui/tests/integration/components/clients/filter-toolbar-test.js index 0ef84d34c0..e3579deaa1 100644 --- a/ui/tests/integration/components/clients/filter-toolbar-test.js +++ b/ui/tests/integration/components/clients/filter-toolbar-test.js @@ -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' ); }); diff --git a/ui/tests/integration/components/clients/page-header-test.js b/ui/tests/integration/components/clients/page-header-test.js index 7be13f5a84..d1375ee54c 100644 --- a/ui/tests/integration/components/clients/page-header-test.js +++ b/ui/tests/integration/components/clients/page-header-test.js @@ -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) { `); @@ -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'); - }); }); }); diff --git a/ui/tests/integration/components/clients/page/counts-test.js b/ui/tests/integration/components/clients/page/counts-test.js index 8c97b4cfee..e1e1e2ad20 100644 --- a/ui/tests/integration/components/clients/page/counts-test.js +++ b/ui/tests/integration/components/clients/page/counts-test.js @@ -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}} >
Yield block
@@ -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(); diff --git a/ui/tests/integration/components/clients/page/overview-test.js b/ui/tests/integration/components/clients/page/overview-test.js index c252ad3d36..e7308e847c 100644 --- a/ui/tests/integration/components/clients/page/overview-test.js +++ b/ui/tests/integration/components/clients/page/overview-test.js @@ -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` + `); + }); - // 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``); - + 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``); - - 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``); + 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('1–3 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``); - - 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('1–3 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`` - ); - 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``); - - 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('1–3 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('4–6 of 6', 'shows correct pagination info'); + assert.dom(GENERAL.paginationInfo).hasText('1–6 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``); + 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