mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-03 20:40:45 -05:00
* 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:
parent
a5c1e7f428
commit
f07b613f0a
17 changed files with 316 additions and 103 deletions
6
changelog/_10062.txt
Normal file
6
changelog/_10062.txt
Normal 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.
|
||||
```
|
||||
|
|
@ -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.
|
||||
```
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
@dataset={{@exportData}}
|
||||
@onFilter={{this.handleFilter}}
|
||||
@filterQueryParams={{@filterQueryParams}}
|
||||
@isExportData={{true}}
|
||||
/>
|
||||
</:subheader>
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]',
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue