Vault Automation 5ead15b8f2
UI: Add month filtering to client count dashboard (#9148) (#9255)
* delete activity component, convert date-formatters to ts

* add "month" filter to overview tab

* add test coverage for date range dropdown

* add month filtering to client-list

* remove old comment

* wire up clients to route filters for client-list

* adds changelog

* only link to client-list for enterprise versions

* add refresh page link

* render all tabs, add custom empty state for secret sycn clients

* cleanup unused service imports

* revert billing periods as first of the month

* first round of test updates

* update client count utils test

* fix comment typo

* organize tests

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
2025-09-10 18:09:20 +00:00

213 lines
9.4 KiB
JavaScript

/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { click, find, findAll, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers';
import { CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import { ClientFilters, flattenMounts } from 'core/utils/client-count-utils';
import { parseAPITimestamp } from 'core/utils/date-formatters';
module('Integration | Component | clients/page/overview', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
this.server.get('sys/internal/counters/activity', () => {
return {
request_id: 'some-activity-id',
data: ACTIVITY_RESPONSE_STUB,
};
});
this.store = this.owner.lookup('service:store');
this.activity = await this.store.queryRecord('clients/activity', {});
this.mostRecentMonth = this.activity.byMonth[this.activity.byMonth.length - 1];
this.onFilterChange = sinon.spy();
this.filterQueryParams = { namespace_path: '', mount_path: '', mount_type: '', month: '' };
this.renderComponent = () =>
render(hbs`
<Clients::Page::Overview
@activity={{this.activity}}
@onFilterChange={{this.onFilterChange}}
@filterQueryParams={{this.filterQueryParams}}
/>`);
this.assertTableData = async (assert, filterKey, filterValue) => {
const expectedData = flattenMounts(this.activity.byNamespace).filter(
(d) => d[filterKey] === filterValue
);
// Find all rendered rows and assert they satisfy the filter value and table data matches expected values
const rows = findAll(GENERAL.tableRow());
rows.forEach((_, idx) => {
assert.dom(GENERAL.tableData(idx, filterKey)).hasText(filterValue);
// Get namespace and mount paths to find original data in expectedData
const rowMountPath = find(GENERAL.tableData(idx, 'mount_path')).innerText;
const rowNsPath = find(GENERAL.tableData(idx, 'namespace_path')).innerText;
// find the expected clients from the response and assert the table matches
const { clients: expectedClients } = expectedData.find(
(d) => d.mount_path === rowMountPath && d.namespace_path === rowNsPath
);
assert.dom(GENERAL.tableData(idx, 'clients')).hasText(`${expectedClients}`);
});
};
});
test('it hides attribution when there is no data', async function (assert) {
// Stub activity response when there's no activity data
this.server.get('sys/internal/counters/activity', () => {
return {
request_id: 'some-activity-id',
data: {
by_namespace: [],
end_time: '2024-08-31T23:59:59Z',
months: [],
start_time: '2024-01-01T00:00:00Z',
total: {
distinct_entities: 0,
entity_clients: 0,
non_entity_tokens: 0,
non_entity_clients: 0,
clients: 0,
secret_syncs: 0,
},
},
};
});
this.activity = await this.store.queryRecord('clients/activity', {});
await this.renderComponent();
assert.dom(CLIENT_COUNT.card('Client attribution')).doesNotExist('it does not render attribution card');
});
test('it initially renders attribution with by_namespace data', async function (assert) {
await this.renderComponent();
const topNamespace = this.activity.byNamespace[0];
const topMount = topNamespace.mounts[0];
// Assert table renders namespace with the highest counts at the top
assert.dom(GENERAL.tableData(0, 'namespace_path')).hasText(topNamespace.label);
assert.dom(GENERAL.tableData(0, 'clients')).hasText(`${topMount.clients}`);
});
test('it renders dropdown lists from activity response to filter table data', async function (assert) {
const expectedMonths = this.activity.byMonth
.map((m) => parseAPITimestamp(m.timestamp, 'MMMM yyyy'))
.reverse();
const mounts = flattenMounts(this.activity.byNamespace);
const expectedNamespaces = [...new Set(mounts.map((m) => m.namespace_path))];
const expectedMountPaths = [...new Set(mounts.map((m) => m.mount_path))];
const expectedMountTypes = [...new Set(mounts.map((m) => m.mount_type))];
await this.renderComponent();
// Select each filter
await click(FILTERS.dropdownToggle(ClientFilters.MONTH));
findAll(`${FILTERS.dropdown(ClientFilters.MONTH)} li button`).forEach((item, idx) => {
const expected = expectedMonths[idx];
assert.dom(item).hasText(expected, `month dropdown renders: ${expected}`);
});
await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE));
findAll(`${FILTERS.dropdown(ClientFilters.NAMESPACE)} li button`).forEach((item, idx) => {
const expected = expectedNamespaces[idx];
assert.dom(item).hasText(expected, `namespace dropdown renders: ${expected}`);
});
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH));
findAll(`${FILTERS.dropdown(ClientFilters.MOUNT_PATH)} li button`).forEach((item, idx) => {
const expected = expectedMountPaths[idx];
assert.dom(item).hasText(expected, `mount_path dropdown renders: ${expected}`);
});
await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE));
findAll(`${FILTERS.dropdown(ClientFilters.MOUNT_TYPE)} li button`).forEach((item, idx) => {
const expected = expectedMountTypes[idx];
assert.dom(item).hasText(expected, `mount_type dropdown renders: ${expected}`);
});
});
// * FILTERING ASSERTIONS
// Filtering tests are split between integration and acceptance tests
// because changing filters updates the URL query params
test('it shows correct empty state message when selected month has no data', async function (assert) {
this.filterQueryParams[ClientFilters.MONTH] = '2023-06-01T00:00:00Z';
await this.renderComponent();
assert
.dom(CLIENT_COUNT.card('table empty state'))
.hasText('No data found Clear or change filters to view client count data. Client count documentation');
});
test('it filters data if @filterQueryParams specify a month', async function (assert) {
const filterKey = 'month';
const filterValue = this.mostRecentMonth.timestamp;
this.filterQueryParams[filterKey] = filterValue;
await this.renderComponent();
// Drill down to new_clients then grab the first mount
const sortedMounts = flattenMounts(this.mostRecentMonth.new_clients.namespaces).sort(
(a, b) => b.clients - a.clients
);
const topMount = sortedMounts[0];
assert.dom(GENERAL.tableData(0, 'namespace_path')).hasText(topMount.namespace_path);
assert.dom(GENERAL.tableData(0, 'clients')).hasText(`${topMount.clients}`);
assert.dom(GENERAL.tableData(0, 'mount_path')).hasText(topMount.mount_path);
});
test('it filters data if @filterQueryParams specify a namespace_path', async function (assert) {
const filterKey = 'namespace_path';
const filterValue = 'ns1/';
this.filterQueryParams[filterKey] = filterValue;
await this.renderComponent();
await this.assertTableData(assert, filterKey, filterValue);
});
test('it filters data if @filterQueryParams specify a mount_path', async function (assert) {
const filterKey = 'mount_path';
const filterValue = 'acme/pki/0/';
this.filterQueryParams[filterKey] = filterValue;
await this.renderComponent();
await this.assertTableData(assert, filterKey, filterValue);
});
test('it filters data if @filterQueryParams specify a mount_type', async function (assert) {
const filterKey = 'mount_type';
const filterValue = 'kv';
this.filterQueryParams[filterKey] = filterValue;
await this.renderComponent();
await this.assertTableData(assert, filterKey, filterValue);
});
test('it filters data if @filterQueryParams specify a multiple filters', async function (assert) {
this.filterQueryParams = {
month: this.mostRecentMonth.timestamp,
namespace_path: 'ns1/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
};
const { namespace_path, mount_path, mount_type } = this.filterQueryParams;
await this.renderComponent();
const expectedData = flattenMounts(this.mostRecentMonth.new_clients.namespaces).find(
(d) => d.namespace_path === namespace_path && d.mount_path === mount_path && d.mount_type === mount_type
);
assert.dom(GENERAL.tableRow()).exists({ count: 1 });
assert.dom(GENERAL.tableData(0, 'namespace_path')).hasText(expectedData.namespace_path);
assert.dom(GENERAL.tableData(0, 'mount_path')).hasText(expectedData.mount_path);
assert.dom(GENERAL.tableData(0, 'mount_type')).hasText(expectedData.mount_type);
assert.dom(GENERAL.tableData(0, 'clients')).hasText(`${expectedData.clients}`);
});
test('it renders empty state message when filter selections yield no results', async function (assert) {
this.filterQueryParams = { namespace_path: 'dev/', mount_path: 'pluto/', mount_type: 'banana' };
await this.renderComponent();
assert
.dom(CLIENT_COUNT.card('table empty state'))
.hasText('No data found Clear or change filters to view client count data. Client count documentation');
});
});