UI: Client cout usage dashboard GA improvements (#10062) (#10096)

* add clarifying text for client_first_used_time

* add test coverage

* add changelog

* add conditional so export request only made on enterprise

* add enterprise note to last changelog

* revert change that rendered date range edit after query

* move button to match other filter designs

* fix route typo and add path to error message

* add test coverage

* update test assertions

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Vault Automation 2025-10-13 19:51:49 -04:00 committed by GitHub
parent a5c1e7f428
commit f07b613f0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 316 additions and 103 deletions

6
changelog/_10062.txt Normal file
View file

@ -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.
```

View file

@ -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.
```

View file

@ -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;
}
}

View file

@ -35,13 +35,16 @@
{{/if}}
</Hds::Dropdown>
{{else}}
<Hds::Button
class="has-left-margin-xs"
@text="Set date range"
@icon="edit"
{{on "click" (fn @setEditModalVisible true)}}
data-test-date-range-edit
/>
{{! 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)}}
<Hds::Button
class="has-left-margin-xs"
@text="Set date range"
@icon="edit"
{{on "click" (fn @setEditModalVisible true)}}
data-test-date-range-edit
/>
{{/unless}}
{{/if}}
</div>

View file

@ -45,13 +45,6 @@
{{/let}}
{{/each-in}}
</Hds::SegmentedGroup>
<Hds::Button
@icon="x-circle"
@text="Clear filters"
@color="tertiary"
{{on "click" (fn this.clearFilters "")}}
data-test-button="Clear filters"
/>
</Hds::ButtonSet>
<Hds::Layout::Flex class="has-top-margin-s" @gap="8" @align="center" data-test-filter-tag-container>
@ -70,6 +63,13 @@
</div>
{{/if}}
{{/each-in}}
<Hds::Button
@icon="x-circle"
@text="Clear filters"
@color="tertiary"
{{on "click" (fn this.clearFilters "")}}
data-test-button="Clear filters"
/>
{{else}}
<Hds::Text::Body @color="faint">None</Hds::Text::Body>
{{/if}}

View file

@ -19,14 +19,15 @@ import type {
import type { HTMLElementEvent } from 'vault/forms';
interface Args {
filterQueryParams: Record<ClientFilterTypes, string>;
dataset: ActivityExportData[] | MountClients[];
onFilter: CallableFunction;
dropdownMonths?: string[];
filterQueryParams: Record<ClientFilterTypes, string>;
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<Args> {
filterTypes = Object.values(ClientFilters);
@ -63,30 +64,33 @@ export default class ClientsFilterToolbar extends Component<Args> {
const mountTypes = new Set<string>();
const months = new Set<string>();
// 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<Args> {
}
// 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`;
};
}

View file

@ -9,7 +9,7 @@
</PH.Title>
{{#if @activityTimestamp}}
<PH.Subtitle>
<PH.Subtitle data-test-activity-timestamp>
Dashboard last updated:
{{date-format @activityTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
<Hds::Button

View file

@ -122,7 +122,7 @@ export default class ClientsPageHeaderComponent extends Component {
@action
refreshRoute() {
this.router.refresh(this.router.currentRoute.name);
this.router.refresh(this.router.currentRoute.parent.name);
}
@action

View file

@ -21,6 +21,7 @@
@dataset={{@exportData}}
@onFilter={{this.handleFilter}}
@filterQueryParams={{@filterQueryParams}}
@isExportData={{true}}
/>
</:subheader>

View file

@ -93,7 +93,8 @@ export default class ClientsClientListPageComponent extends Component<Args> {
{
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' },

View file

@ -16,7 +16,7 @@
</div>
<div class="has-top-bottom-margin">
<Hds::Text::Body @tag="p" @size="100">
<Hds::Text::Body @tag="p" @size="100" class="has-bottom-margin-s">
This is the dashboard for your overall client count usages. Review Vault's
<Hds::Link::Inline @href={{doc-link "/vault/docs/concepts/client-count"}} @isHrefExternal={{true}}>
client counting documentation</Hds::Link::Inline>

View file

@ -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) {

View file

@ -10,7 +10,7 @@
>
<:actions>
{{#if (eq this.model.exportError.httpStatus 403)}}
<Hds::Text::Body @tag="p" @color="faint">
<Hds::Text::Body @tag="p" @color="faint" class="has-bottom-margin-s">
Viewing export data requires
<Hds::Text::Code class="code-in-text">sudo</Hds::Text::Code>
permissions.

View file

@ -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')}`);
});
});
});

View file

@ -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]',

View file

@ -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`
<Clients::FilterToolbar
@dataset={{this.dataset}}
@onFilter={{this.onFilter}}
@filterQueryParams={{this.filterQueryParams}}
@isExportData={{this.isExportData}}
@dropdownMonths={{this.dropdownMonths}}
/>`);
};
@ -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.`);
});
});

View file

@ -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 () {