diff --git a/changelog/_10062.txt b/changelog/_10062.txt new file mode 100644 index 0000000000..6e677690c8 --- /dev/null +++ b/changelog/_10062.txt @@ -0,0 +1,6 @@ +```release-note:improvement +ui/activity (enterprise): Add clarifying text to explain the "Initial Usage" column will only have timestamps for clients initially used after upgrading to version 1.21 +``` +```release-note:improvement +ui/activity (enterprise): Support filtering months dropdown by ISO timestamp or display value. +``` diff --git a/changelog/_9890.txt b/changelog/_9890.txt index 78cf94be76..f1fc8ae349 100644 --- a/changelog/_9890.txt +++ b/changelog/_9890.txt @@ -1,3 +1,3 @@ ```release-note:improvement -ui/activity: Reduce requests to the activity export API by only fetching new data when the dashboard initially loads or is manually refreshed. +ui/activity (enterprise): Reduce requests to the activity export API by only fetching new data when the dashboard initially loads or is manually refreshed. ``` \ No newline at end of file diff --git a/ui/app/adapters/clients/activity.js b/ui/app/adapters/clients/activity.js index 0674b61f1b..fc6aa941bd 100644 --- a/ui/app/adapters/clients/activity.js +++ b/ui/app/adapters/clients/activity.js @@ -64,6 +64,7 @@ export default class ActivityAdapter extends ApplicationAdapter { if (errorMsg) { const error = new Error(errorMsg); error.httpStatus = httpStatus; + error.path = 'sys/internal/counters/activity/export'; throw error; } } diff --git a/ui/app/components/clients/date-range.hbs b/ui/app/components/clients/date-range.hbs index 992bf833e0..c345cf111c 100644 --- a/ui/app/components/clients/date-range.hbs +++ b/ui/app/components/clients/date-range.hbs @@ -35,13 +35,16 @@ {{/if}} {{else}} - + {{! Hide if the user has made an initial query because dates can be updated by clicking "Edit" beside the date range }} + {{#unless (and @startTimestamp @endTimestamp)}} + + {{/unless}} {{/if}} diff --git a/ui/app/components/clients/filter-toolbar.hbs b/ui/app/components/clients/filter-toolbar.hbs index 1a19fa521f..40c74269a6 100644 --- a/ui/app/components/clients/filter-toolbar.hbs +++ b/ui/app/components/clients/filter-toolbar.hbs @@ -45,13 +45,6 @@ {{/let}} {{/each-in}} - @@ -70,6 +63,13 @@ {{/if}} {{/each-in}} + {{else}} None {{/if}} diff --git a/ui/app/components/clients/filter-toolbar.ts b/ui/app/components/clients/filter-toolbar.ts index 5b79ff9607..7d9e33e86e 100644 --- a/ui/app/components/clients/filter-toolbar.ts +++ b/ui/app/components/clients/filter-toolbar.ts @@ -19,14 +19,15 @@ import type { import type { HTMLElementEvent } from 'vault/forms'; interface Args { - filterQueryParams: Record; dataset: ActivityExportData[] | MountClients[]; - onFilter: CallableFunction; dropdownMonths?: string[]; + filterQueryParams: Record; + isExportData?: boolean; + onFilter: CallableFunction; } // Correspond to each search input's tracked variable in the component class -type SearchProperty = 'namespacePathSearch' | 'mountPathSearch' | 'mountTypeSearch'; +type SearchProperty = 'namespacePathSearch' | 'mountPathSearch' | 'mountTypeSearch' | 'monthSearch'; export default class ClientsFilterToolbar extends Component { filterTypes = Object.values(ClientFilters); @@ -63,30 +64,33 @@ export default class ClientsFilterToolbar extends Component { const mountTypes = new Set(); const months = new Set(); - // iterate over dataset once to get dropdown items - this.args.dataset.forEach((d) => { - // namespace_path for root is technically an empty string, so convert to 'root' - const namespace = d.namespace_path === '' ? 'root' : d.namespace_path; - if (namespace) namespacePaths.add(namespace); - if (d.mount_path) mountPaths.add(d.mount_path); - if (d.mount_type) mountTypes.add(d.mount_type); - // `client_first_used_time` only exists for the dataset rendered in the "Client list" tab (ActivityExportData) - // the "Overview tab" manually passes an array of months - if ('client_first_used_time' in d && d.client_first_used_time) { - // for now, we're only concerned with month granularity so we want the dropdown filter to contain an ISO timestamp - // of the first of the month for each client_first_used_time - const date = parseAPITimestamp(d.client_first_used_time) as Date; - const year = date.getUTCFullYear(); - const monthIdx = date.getUTCMonth(); - const timestamp = buildISOTimestamp({ year, monthIdx, isEndDate: false }); - months.add(timestamp); - } - }); + if (this.args.dataset) { + // iterate over dataset once to get dropdown items + this.args.dataset.forEach((d) => { + // namespace_path for root is technically an empty string, so convert to 'root' + const namespace = d.namespace_path === '' ? 'root' : d.namespace_path; + if (namespace) namespacePaths.add(namespace); + if (d.mount_path) mountPaths.add(d.mount_path); + if (d.mount_type) mountTypes.add(d.mount_type); + // `client_first_used_time` only exists for the dataset rendered in the "Client list" tab (ActivityExportData), + // and if the client ID was initially used in version 1.21 or later. + if ('client_first_used_time' in d && d.client_first_used_time) { + // for now, we're only concerned with month granularity so we want the dropdown filter to contain an ISO timestamp + // of the first of the month for each client_first_used_time + const date = parseAPITimestamp(d.client_first_used_time) as Date; + const year = date.getUTCFullYear(); + const monthIdx = date.getUTCMonth(); + const timestamp = buildISOTimestamp({ year, monthIdx, isEndDate: false }); + months.add(timestamp); + } + }); + } return { [ClientFilters.NAMESPACE]: [...namespacePaths], [ClientFilters.MOUNT_PATH]: [...mountPaths], [ClientFilters.MOUNT_TYPE]: [...mountTypes], + // The "Overview tab" manually passes an array of months [ClientFilters.MONTH]: this.args.dropdownMonths || [...months], }; } @@ -189,16 +193,29 @@ export default class ClientsFilterToolbar extends Component { } // TEMPLATE HELPERS - formatTimestamp = (isoTimestamp: string) => parseAPITimestamp(isoTimestamp, 'MMMM yyyy'); + formatTimestamp = (isoTimestamp: string) => parseAPITimestamp(isoTimestamp, 'MMMM yyyy') as string; searchDropdown = (dropdownItems: string[], searchProperty: SearchProperty) => { const searchInput = this[searchProperty]; - return searchInput - ? dropdownItems.filter((i) => i?.toLowerCase().includes(searchInput.toLowerCase())) - : dropdownItems; + + if (searchInput) { + return dropdownItems.filter((i) => { + const isMatch = (item: string) => item?.toLowerCase().includes(searchInput.toLowerCase()); + // For months, search both the ISO timestamp and formatted display value (e.g., "January 2024") + return searchProperty === 'monthSearch' ? isMatch(i) || isMatch(this.formatTimestamp(i)) : isMatch(i); + }); + } + + return dropdownItems; }; noItemsMessage = (searchValue: string, label: string) => { - return searchValue ? `No matching ${label}` : `No ${label} to filter`; + if (searchValue) return `No matching ${label}`; + + // The version upgrade message is only relevant if the toolbar filtering activity export data + // because that is when the months dropdown is populated by the `client_first_used_time` key. + return label === 'months' && this.args.isExportData + ? 'Filtering by month is only available for clients initially used after upgrading to version 1.21.' + : `No ${label} to filter`; }; } diff --git a/ui/app/components/clients/page-header.hbs b/ui/app/components/clients/page-header.hbs index a397adc7ce..186f2e0230 100644 --- a/ui/app/components/clients/page-header.hbs +++ b/ui/app/components/clients/page-header.hbs @@ -9,7 +9,7 @@ {{#if @activityTimestamp}} - + Dashboard last updated: {{date-format @activityTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}} diff --git a/ui/app/components/clients/page/client-list.ts b/ui/app/components/clients/page/client-list.ts index f1df17913b..1dd6934ab8 100644 --- a/ui/app/components/clients/page/client-list.ts +++ b/ui/app/components/clients/page/client-list.ts @@ -93,7 +93,8 @@ export default class ClientsClientListPageComponent extends Component { { key: 'client_first_used_time', label: 'Initial usage', - tooltip: 'When the client ID was first used in the selected billing period.', + tooltip: + 'First usage date in the billing period. Vault only provides this data for clients initially used after upgrading to version 1.21.', }, { key: 'mount_path', label: 'Mount path' }, { key: 'mount_type', label: 'Mount type' }, diff --git a/ui/app/components/clients/page/counts.hbs b/ui/app/components/clients/page/counts.hbs index 451031c2bb..49785d3535 100644 --- a/ui/app/components/clients/page/counts.hbs +++ b/ui/app/components/clients/page/counts.hbs @@ -16,7 +16,7 @@
- + This is the dashboard for your overall client count usages. Review Vault's client counting documentation diff --git a/ui/app/routes/vault/cluster/clients/counts.ts b/ui/app/routes/vault/cluster/clients/counts.ts index 594397b359..6d99fd3b70 100644 --- a/ui/app/routes/vault/cluster/clients/counts.ts +++ b/ui/app/routes/vault/cluster/clients/counts.ts @@ -77,25 +77,29 @@ export default class ClientsCountsRoute extends Route { } async fetchAndFormatExportData(startTimestamp: string | undefined, endTimestamp: string | undefined) { - const adapter = this.store.adapterFor('clients/activity'); - let exportData, exportError; - try { - const resp = await adapter.exportData({ - // the API only accepts json or csv - format: 'json', - start_time: startTimestamp, - end_time: endTimestamp, - }); - const jsonLines = await resp.text(); - const lines = jsonLines.trim().split('\n'); - exportData = lines.map((line: string) => JSON.parse(line)); - } catch (error) { - // Ideally we would not handle errors manually but this is the pattern the other client.counts - // route follow since the sys/internal/counters API doesn't always return helpful error messages. - // When these routes are migrated away from ember data we should revisit the error handling. - exportError = error as AdapterError; + // The "Client List" tab is only available on enterprise versions + if (this.version.isEnterprise) { + const adapter = this.store.adapterFor('clients/activity'); + let exportData, exportError; + try { + const resp = await adapter.exportData({ + // the API only accepts json or csv + format: 'json', + start_time: startTimestamp, + end_time: endTimestamp, + }); + const jsonLines = await resp.text(); + const lines = jsonLines.trim().split('\n'); + exportData = lines.map((line: string) => JSON.parse(line)); + } catch (error) { + // Ideally we would not handle errors manually but this is the pattern the other client.counts + // route follow since the sys/internal/counters API doesn't always return helpful error messages. + // When these routes are migrated away from ember data we should revisit the error handling. + exportError = error as AdapterError; + } + return { exportData, exportError }; } - return { exportData, exportError }; + return { exportData: null, exportError: null }; } async model(params: ClientsCountsRouteParams) { 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 31b0ee9f6a..4695d402d9 100644 --- a/ui/app/templates/vault/cluster/clients/counts/client-list.hbs +++ b/ui/app/templates/vault/cluster/clients/counts/client-list.hbs @@ -10,7 +10,7 @@ > <:actions> {{#if (eq this.model.exportError.httpStatus 403)}} - + Viewing export data requires sudo permissions. diff --git a/ui/tests/acceptance/clients/counts-test.js b/ui/tests/acceptance/clients/counts-test.js index 5cfc72bbe0..ac846c5738 100644 --- a/ui/tests/acceptance/clients/counts-test.js +++ b/ui/tests/acceptance/clients/counts-test.js @@ -14,13 +14,15 @@ import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors'; import timestamp from 'core/utils/timestamp'; import { overrideResponse } from 'vault/tests/helpers/stubs'; +import { format } from 'date-fns'; module('Acceptance | clients | counts', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); hooks.beforeEach(async function () { - sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW)); + this.timestampStub = sinon.stub(timestamp, 'now'); + this.timestampStub.returns(STATIC_NOW); clientsHandler(this.server); this.store = this.owner.lookup('service:store'); return login(); @@ -38,6 +40,17 @@ module('Acceptance | clients | counts', function (hooks) { .hasText('Only historical data may be queried. No data is available for the current month.'); }); + test('it does not make a request to the export api on community versions', async function (assert) { + assert.expect(1); + this.owner.lookup('service:version').type = 'community'; + server.get('/sys/internal/counters/activity/export', () => { + // passing "false" because a request should NOT be made, so if this assertion is hit we want it to fail + assert.true(false, 'it does not make request to export API on community versions '); + }); + await visit('/vault/clients/counts/overview'); + assert.dom(GENERAL.tab('client list')).doesNotExist(); + }); + test('it should redirect to counts overview route for transitions to parent', async function (assert) { await visit('/vault/clients'); assert.strictEqual(currentURL(), '/vault/clients/counts/overview', 'Redirects to counts overview route'); @@ -137,11 +150,21 @@ module('Acceptance | clients | counts', function (hooks) { // Change date to add query params await click(CLIENT_COUNT.dateRange.edit); await click(CLIENT_COUNT.dateRange.dropdownOption(1)); + assert + .dom(CLIENT_COUNT.activityTimestamp) + .hasTextContaining(`Dashboard last updated: ${format(STATIC_NOW, 'MMM d yyyy')}`); // Save URL with query params before clicking refresh const url = currentURL(); + // re-stub with a completely different year/month/day before clicking refresh + // to mock the timestamp updating when page reloads + const fakeUpdatedNow = new Date('2025-07-02T23:25:13Z'); + this.timestampStub.returns(fakeUpdatedNow); await click(GENERAL.button('Refresh page')); assert.true(this.refreshSpy.calledOnce, 'router.refresh() is called once'); assert.strictEqual(currentURL(), url, 'url is the same after clicking refresh'); + assert + .dom(CLIENT_COUNT.activityTimestamp) + .hasTextContaining(`Dashboard last updated: ${format(fakeUpdatedNow, 'MMM d yyyy')}`); }); test('enterprise: it refreshes the client-list route and preserves query params', async function (assert) { @@ -154,11 +177,21 @@ module('Acceptance | clients | counts', function (hooks) { // Change date to add query params await click(CLIENT_COUNT.dateRange.edit); await click(CLIENT_COUNT.dateRange.dropdownOption(1)); + assert + .dom(CLIENT_COUNT.activityTimestamp) + .hasTextContaining(`Dashboard last updated: ${format(STATIC_NOW, 'MMM d yyyy')}`); // Save URL with query params before clicking refresh const url = currentURL(); + // re-stub with a completely different year/month/day before clicking refresh + // to mock the timestamp updating when page reloads + const fakeUpdatedNow = new Date('2025-07-02T23:25:13Z'); + this.timestampStub.returns(fakeUpdatedNow); await click(GENERAL.button('Refresh page')); assert.true(this.refreshSpy.calledOnce, 'router.refresh() is called once'); assert.strictEqual(currentURL(), url, 'url is the same after clicking refresh'); + assert + .dom(CLIENT_COUNT.activityTimestamp) + .hasTextContaining(`Dashboard last updated: ${format(fakeUpdatedNow, 'MMM d yyyy')}`); }); }); }); diff --git a/ui/tests/helpers/clients/client-count-selectors.ts b/ui/tests/helpers/clients/client-count-selectors.ts index 2a160e61d6..7901d325e3 100644 --- a/ui/tests/helpers/clients/client-count-selectors.ts +++ b/ui/tests/helpers/clients/client-count-selectors.ts @@ -5,6 +5,7 @@ // TODO: separate nested into distinct exported consts export const CLIENT_COUNT = { + activityTimestamp: '[data-test-activity-timestamp]', card: (name: string) => `[data-test-card="${name}"]`, counts: { description: '[data-test-counts-description]', diff --git a/ui/tests/integration/components/clients/filter-toolbar-test.js b/ui/tests/integration/components/clients/filter-toolbar-test.js index fe559f96cf..647e658a34 100644 --- a/ui/tests/integration/components/clients/filter-toolbar-test.js +++ b/ui/tests/integration/components/clients/filter-toolbar-test.js @@ -11,28 +11,48 @@ import { GENERAL } from 'vault/tests/helpers/general-selectors'; import sinon from 'sinon'; import { FILTERS } from 'vault/tests/helpers/clients/client-count-selectors'; import { ClientFilters } from 'core/utils/client-count-utils'; +import { parseAPITimestamp } from 'core/utils/date-formatters'; module('Integration | Component | clients/filter-toolbar', function (hooks) { setupRenderingTest(hooks); hooks.beforeEach(function () { - this.dataset = [ - { namespace_path: '', mount_type: 'userpass/', mount_path: 'auth/auto/eng/core/auth/core-gh-auth/' }, - { namespace_path: '', mount_type: 'userpass/', mount_path: 'auth/auto/eng/core/auth/core-gh-auth/' }, - { namespace_path: '', mount_type: 'userpass/', mount_path: 'auth/userpass-root/' }, - { namespace_path: 'admin/', mount_type: 'token/', mount_path: 'auth/token/' }, - { namespace_path: 'ns1/', mount_type: 'token/', mount_path: 'auth/token/' }, - { namespace_path: 'ns1/', mount_type: 'ns_token/', mount_path: 'auth/token/' }, - ]; + this.generateData = ({ withTimestamps = false }) => { + const timestamps = [ + '2025-04-27T07:36:21Z', + '2025-04-01T00:00:00Z', + '2025-03-21T05:36:21Z', + '2025-03-21T07:26:21Z', + '2025-02-06T03:36:21Z', + '2025-01-29T01:36:21Z', + ]; + const data = [ + { namespace_path: '', mount_type: 'userpass/', mount_path: 'auth/auto/eng/core/auth/core-gh-auth/' }, + { namespace_path: '', mount_type: 'userpass/', mount_path: 'auth/auto/eng/core/auth/core-gh-auth/' }, + { namespace_path: '', mount_type: 'userpass/', mount_path: 'auth/userpass-root/' }, + { namespace_path: 'admin/', mount_type: 'token/', mount_path: 'auth/token/' }, + { namespace_path: 'ns1/', mount_type: 'token/', mount_path: 'auth/token/' }, + { namespace_path: 'ns1/', mount_type: 'ns_token/', mount_path: 'auth/token/' }, + ]; + // Only activity export data from Vault versions 1.21 or later will have a `client_first_used_time` + return withTimestamps + ? data.map((d, idx) => ({ ...d, client_first_used_time: timestamps[idx] })) + : data; + }; + this.onFilter = sinon.spy(); this.filterQueryParams = { namespace_path: '', mount_path: '', mount_type: '', month: '' }; - + this.dataset = undefined; + this.dropdownMonths = undefined; + this.isExportData = undefined; this.renderComponent = async () => { await render(hbs` `); }; @@ -41,6 +61,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/', + month: '2025-04-01T00:00:00Z', }; }; @@ -54,19 +75,23 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { // select mount type await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); await click(FILTERS.dropdownItem('token/')); + // select month + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + await click(FILTERS.dropdownItem('2025-04-01T00:00:00Z')); }; }); - test('it renders dropdowns', async function (assert) { + test('it renders dropdowns when there is no data', async function (assert) { await this.renderComponent(); - assert.dom(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)).hasText('Namespace'); assert.dom(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)).hasText('Mount path'); assert.dom(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)).hasText('Mount type'); + assert.dom(FILTERS.dropdownToggle(ClientFilters.MONTH)).hasText('Month'); assert.dom(FILTERS.tagContainer).hasText('Filters applied: None'); }); test('it renders dropdown items and does not include duplicates', async function (assert) { + this.dataset = this.generateData({ withTimestamps: true }); await this.renderComponent(); const expectedNamespaces = ['root', 'admin/', 'ns1/']; const expectedMountPaths = [ @@ -75,6 +100,13 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { 'auth/token/', ]; const expectedMountTypes = ['userpass/', 'token/', 'ns_token/']; + // The component normalizes timestamps to the first of the month + const expectedMonths = [ + '2025-04-01T00:00:00Z', + '2025-03-01T00:00:00Z', + '2025-02-01T00:00:00Z', + '2025-01-01T00:00:00Z', + ]; await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); assert.dom('li button').exists({ count: 3 }, 'list renders 3 namespaces'); @@ -98,9 +130,33 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { const m = expectedMountTypes[idx]; assert.dom(item).hasText(m, `it renders mount_type: ${m}`); }); + + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + assert.dom('li button').exists({ count: 4 }, 'list renders 4 months'); + findAll('li button').forEach((item, idx) => { + const m = expectedMonths[idx]; + const display = parseAPITimestamp(m, 'MMMM yyyy'); + assert.dom(item).hasText(display, `it renders month: ${m}`); + }); + }); + + test('it renders months passed in as an arg instead of from dataset', async function (assert) { + // Include timestamps in the dataset AND pass in months to ensure @dropdownMonths overrides the timestamps in dataset + this.dataset = this.generateData({ withTimestamps: true }); + this.dropdownMonths = ['2025-10-01T07:36:21Z', '2025-09-01T02:38:21Z', '2025-08-01T03:56:21Z']; + await this.renderComponent(); + + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + assert.dom('li button').exists({ count: 3 }, 'list renders 3 months'); + findAll('li button').forEach((item, idx) => { + const m = this.dropdownMonths[idx]; + const display = parseAPITimestamp(m, 'MMMM yyyy'); + assert.dom(item).hasText(display, `it renders month: ${m}`); + }); }); test('it searches dropdown items', async function (assert) { + this.dataset = this.generateData({ withTimestamps: true }); await this.renderComponent(); await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); @@ -121,21 +177,37 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { await waitUntil(() => dropdownItems.length === 2); assert.dom('ul').hasText('token/ ns_token/', 'it renders matching mount types'); - // confirm that search input is cleared and dropdown renders all items again when re-opened + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + await typeIn(FILTERS.dropdownSearch(ClientFilters.MONTH), 'y'); + dropdownItems = findAll('li button'); + await waitUntil(() => dropdownItems.length === 2); + assert.dom('ul').hasText('February 2025 January 2025', 'it renders matching months'); + // Months can be searched by the ISO timestamp or the display value + await fillIn(FILTERS.dropdownSearch(ClientFilters.MONTH), '4'); + dropdownItems = findAll('li button'); + await waitUntil(() => dropdownItems.length === 1); + assert.dom('ul').hasText('April 2025', 'it renders matching months'); + + // Re-open each dropdown to confirm search input and dropdown reset after close await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); - assert.dom('ul').hasText('root admin/ ns1/', 'it resets filter and renders all namespace path'); + assert.dom('ul').hasText('root admin/ ns1/', 'namespace dropdown resets on close'); await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); assert .dom('ul') .hasText( 'auth/auto/eng/core/auth/core-gh-auth/ auth/userpass-root/ auth/token/', - 'it resets filter and renders all mount paths' + 'mount path dropdown resets on close' ); await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); - assert.dom('ul').hasText('userpass/ token/ ns_token/', 'it resets filter and renders all mount types'); + assert.dom('ul').hasText('userpass/ token/ ns_token/', 'mount types dropdown resets on close'); + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + assert + .dom('ul') + .hasText('April 2025 March 2025 February 2025 January 2025', 'months dropdown resets on close'); }); test('it searches and renders no matches found message', async function (assert) { + this.dataset = this.generateData({ withTimestamps: true }); await this.renderComponent(); await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); @@ -155,10 +227,16 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { dropdownItems = findAll('li button'); await waitUntil(() => dropdownItems.length === 0); assert.dom('ul').hasText('No matching mount types'); + + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + await fillIn(FILTERS.dropdownSearch(ClientFilters.MONTH), 'no matches'); + dropdownItems = findAll('li button'); + await waitUntil(() => dropdownItems.length === 0); + assert.dom('ul').hasText('No matching months'); }); test('it renders no items to filter if dropdown is empty', async function (assert) { - this.dataset = [{ namespace_path: null, mount_type: null, mount_path: null }]; + this.dataset = [{ namespace_path: null, mount_type: null, mount_path: null, months: null }]; await this.renderComponent(); await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); assert.dom('ul').hasText('No namespaces to filter'); @@ -166,6 +244,36 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { assert.dom('ul').hasText('No mount paths to filter'); await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); assert.dom('ul').hasText('No mount types to filter'); + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + assert.dom('ul').hasText('No months to filter'); + }); + + test('it renders version message when no month data exists and @isExportData is true', async function (assert) { + this.dataset = this.generateData({ withTimestamps: false }); + this.isExportData = true; + await this.renderComponent(); + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + assert + .dom('ul') + .hasText( + 'Filtering by month is only available for clients initially used after upgrading to version 1.21.' + ); + }); + + test('it renders no months to filter message when data has no client_first_used_time', async function (assert) { + this.dataset = this.generateData({ withTimestamps: false }); + await this.renderComponent(); + assert.dom(FILTERS.dropdownToggle(ClientFilters.MONTH)).hasText('Month'); + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + assert.dom('ul').hasText('No months to filter'); + }); + + test('it renders no months to filter message when @dropdownMonths is empty', async function (assert) { + this.dataset = this.generateData({ withTimestamps: true }); + this.dropdownMonths = []; + await this.renderComponent(); + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + assert.dom('ul').hasText('No months to filter'); }); test('it renders no items to filter if dataset does not contain expected keys', async function (assert) { @@ -177,9 +285,12 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { assert.dom('ul').hasText('No mount paths to filter'); await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); assert.dom('ul').hasText('No mount types to filter'); + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + assert.dom('ul').hasText('No months to filter'); }); test('it selects dropdown items and renders a filter tag', async function (assert) { + this.dataset = this.generateData({ withTimestamps: true }); await this.renderComponent(); // select namespace @@ -187,34 +298,44 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { await click(FILTERS.dropdownItem('admin/')); assert.dom(FILTERS.tag(ClientFilters.NAMESPACE, 'admin/')).exists(); assert.dom(FILTERS.tag()).exists({ count: 1 }, '1 filter tag renders'); + // dropdown should close after an item is selected, reopen to assert the correct item is selected + await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); + assert.dom(FILTERS.dropdownItem('admin/')).hasAttribute('aria-selected', 'true'); + assert.dom(`${FILTERS.dropdownItem('admin/')} ${GENERAL.icon('check')}`).exists(); // select mount path await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); await click(FILTERS.dropdownItem('auth/userpass-root/')); assert.dom(FILTERS.tag(ClientFilters.MOUNT_PATH, 'auth/userpass-root/')).exists(); assert.dom(FILTERS.tag()).exists({ count: 2 }, '2 filter tags render'); + // dropdown should close after an item is selected, reopen to assert the correct item is selected + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); + assert.dom(FILTERS.dropdownItem('auth/userpass-root/')).hasAttribute('aria-selected', 'true'); + assert.dom(`${FILTERS.dropdownItem('auth/userpass-root/')} ${GENERAL.icon('check')}`).exists(); // select mount type await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); await click(FILTERS.dropdownItem('token/')); assert.dom(FILTERS.tag(ClientFilters.MOUNT_TYPE, 'token/')).exists(); assert.dom(FILTERS.tag()).exists({ count: 3 }, '3 filter tags render'); - - // dropdown closes when an item is selected, reopen each one to assert the correct item is selected - await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); - assert.dom(FILTERS.dropdownItem('admin/')).hasAttribute('aria-selected', 'true'); - assert.dom(`${FILTERS.dropdownItem('admin/')} ${GENERAL.icon('check')}`).exists(); - - await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); - assert.dom(FILTERS.dropdownItem('auth/userpass-root/')).hasAttribute('aria-selected', 'true'); - assert.dom(`${FILTERS.dropdownItem('auth/userpass-root/')} ${GENERAL.icon('check')}`).exists(); - + // dropdown should close after an item is selected, reopen to assert the correct item is selected await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); assert.dom(FILTERS.dropdownItem('token/')).hasAttribute('aria-selected', 'true'); assert.dom(`${FILTERS.dropdownItem('token/')} ${GENERAL.icon('check')}`).exists(); + + // select month + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + await click(FILTERS.dropdownItem('2025-02-01T00:00:00Z')); + assert.dom(FILTERS.tag(ClientFilters.MONTH, '2025-02-01T00:00:00Z')).exists(); + assert.dom(FILTERS.tag()).exists({ count: 4 }, '4 filter tags render'); + // dropdown should close after an item is selected, reopen to assert the correct item is selected + await click(FILTERS.dropdownToggle(ClientFilters.MONTH)); + assert.dom(FILTERS.dropdownItem('2025-02-01T00:00:00Z')).hasAttribute('aria-selected', 'true'); + assert.dom(`${FILTERS.dropdownItem('2025-02-01T00:00:00Z')} ${GENERAL.icon('check')}`).exists(); }); test('it fires callback when a filter is selected', async function (assert) { + this.dataset = this.generateData({ withTimestamps: true }); await this.renderComponent(); // select namespace @@ -241,32 +362,50 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { }); test('it renders filter tags when initialized with @filterQueryParams', async function (assert) { + this.dataset = this.generateData({ withTimestamps: true }); this.presetFilters(); await this.renderComponent(); - assert.dom(FILTERS.tag()).exists({ count: 3 }, '3 filter tags render'); + assert.dom(FILTERS.tag()).exists({ count: 4 }, '4 filter tags render'); assert.dom(FILTERS.tag(ClientFilters.NAMESPACE, 'admin/')).exists(); assert.dom(FILTERS.tag(ClientFilters.MOUNT_PATH, 'auth/userpass-root/')).exists(); assert.dom(FILTERS.tag(ClientFilters.MOUNT_TYPE, 'token/')).exists(); + assert.dom(FILTERS.tag(ClientFilters.MONTH, '2025-04-01T00:00:00Z')).exists(); }); test('it updates filters tags when initialized with @filterQueryParams', async function (assert) { - this.filterQueryParams = { namespace_path: 'ns1/', mount_path: 'auth/token/', mount_type: 'ns_token/' }; + this.dataset = this.generateData({ withTimestamps: true }); + this.filterQueryParams = { + namespace_path: 'ns1/', + mount_path: 'auth/token/', + mount_type: 'ns_token/', + month: '2025-03-01T00:00:00Z', + }; await this.renderComponent(); // Check initial filters - assert.dom(FILTERS.tagContainer).hasText('Filters applied: ns1/ auth/token/ ns_token/'); + assert + .dom(FILTERS.tagContainer) + .hasText('Filters applied: ns1/ auth/token/ ns_token/ March 2025 Clear filters'); // Change filters and confirm callback has updated values await this.selectFilters(); const [afterUpdate] = this.onFilter.lastCall.args; assert.propEqual( afterUpdate, - { namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/', month: '' }, + { + namespace_path: 'admin/', + mount_path: 'auth/userpass-root/', + mount_type: 'token/', + month: '2025-04-01T00:00:00Z', + }, 'callback fires with updated selection' ); - assert.dom(FILTERS.tagContainer).hasText('Filters applied: admin/ auth/userpass-root/ token/'); + assert + .dom(FILTERS.tagContainer) + .hasText('Filters applied: admin/ auth/userpass-root/ token/ April 2025 Clear filters'); }); test('it clears all filters', async function (assert) { + this.dataset = this.generateData({ withTimestamps: true }); this.presetFilters(); await this.renderComponent(); await click(GENERAL.button('Clear filters')); @@ -280,22 +419,29 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { }); test('it clears individual filters', async function (assert) { + this.dataset = this.generateData({ withTimestamps: true }); this.presetFilters(); await this.renderComponent(); await click(FILTERS.clearTag('admin/')); const afterClear = this.onFilter.lastCall.args[0]; assert.propEqual( afterClear, - { namespace_path: '', mount_path: 'auth/userpass-root/', mount_type: 'token/', month: '' }, + { + namespace_path: '', + mount_path: 'auth/userpass-root/', + mount_type: 'token/', + month: '2025-04-01T00:00:00Z', + }, 'onFilter callback fires with empty namespace_path' ); }); test('it renders an alert when initialized with @filterQueryParams that are not present in the dropdown', async function (assert) { - this.filterQueryParams = { namespace_path: 'admin/', mount_path: '', mount_type: 'banana' }; + this.dataset = this.generateData({ withTimestamps: true }); + this.filterQueryParams = { namespace_path: 'admin/', mount_path: '', mount_type: 'banana', month: '' }; await this.renderComponent(); assert.dom(FILTERS.tag()).exists({ count: 2 }, '2 filter tags render'); - assert.dom(FILTERS.tagContainer).hasText('Filters applied: admin/ banana'); + assert.dom(FILTERS.tagContainer).hasText('Filters applied: admin/ banana Clear filters'); assert.dom(GENERAL.inlineAlert).hasText(`Mount type "banana" not found in the current data.`); }); }); diff --git a/ui/tests/integration/components/clients/page-header-test.js b/ui/tests/integration/components/clients/page-header-test.js index eccce1544d..e658652ae9 100644 --- a/ui/tests/integration/components/clients/page-header-test.js +++ b/ui/tests/integration/components/clients/page-header-test.js @@ -163,16 +163,16 @@ module('Integration | Component | clients/page-header', function (hooks) { }); test('it refreshes route after clicking "Refresh page" button', async function (assert) { - const routeName = 'vault.cluster.clients.counts.overview'; + const routeName = 'vault.cluster.clients.counts'; const router = this.owner.lookup('service:router'); - Sinon.stub(router, 'currentRoute').value({ name: routeName }); + Sinon.stub(router, 'currentRoute').value({ parent: { name: routeName } }); const refreshStub = Sinon.stub(router, 'refresh'); this.activityTimestamp = timestamp.now().toISOString(); await this.renderComponent(); await click(GENERAL.button('Refresh page')); const [transitionRoute] = refreshStub.lastCall.args; assert.true(refreshStub.calledOnce, 'clicking "Refresh page" calls refresh()'); - assert.strictEqual(transitionRoute, routeName, 'it calls refresh() with route name'); + assert.strictEqual(transitionRoute, routeName, 'it calls refresh() with parent route name'); }); module('download naming', function () {