From 2e6d5b0703b07a14ce921f12ef93eeb04c36168f Mon Sep 17 00:00:00 2001 From: lane-wetmore Date: Fri, 23 May 2025 10:58:48 -0500 Subject: [PATCH] UI: Replace Client Count Attribution Charts with Table (#30678) * replace attribution charts with a table by month * update tests to include mount_type * fix another portion of tests that were missing secret sync stat and testing for old attribution charts * add tests for attribution table * add changelog and tidy * remove remaining todos * tidy * reset month query param in ce * fix tests missing month param * add margin to pagination in accordance to helios rec * remove query param, update change log, move table into own comp * remove commented code * remove month query params * tidy * update test mount paths * remove unused client attribution component * update tests --- changelog/30678.txt | 5 + ui/app/components/clients/attribution.hbs | 39 ---- ui/app/components/clients/attribution.js | 77 -------- ui/app/components/clients/date-range.ts | 1 + ui/app/components/clients/page/overview.hbs | 29 +-- ui/app/components/clients/page/overview.ts | 35 +++- ui/app/components/clients/table.hbs | 59 ++++++ ui/app/components/clients/table.ts | 77 ++++++++ ui/lib/core/addon/utils/client-count-utils.ts | 30 ++- ui/tests/acceptance/clients/counts-test.js | 4 +- .../acceptance/clients/counts/acme-test.js | 2 +- .../clients/counts/overview-test.js | 32 ---- .../helpers/clients/client-count-helpers.js | 84 ++++++-- .../helpers/clients/client-count-selectors.ts | 10 +- .../components/clients/attribution-test.js | 181 ------------------ .../components/clients/page/overview-test.js | 148 +++++++++----- .../utils/client-count-utils-test.js | 20 +- 17 files changed, 399 insertions(+), 434 deletions(-) create mode 100644 changelog/30678.txt delete mode 100644 ui/app/components/clients/attribution.hbs delete mode 100644 ui/app/components/clients/attribution.js create mode 100644 ui/app/components/clients/table.hbs create mode 100644 ui/app/components/clients/table.ts delete mode 100644 ui/tests/integration/components/clients/attribution-test.js diff --git a/changelog/30678.txt b/changelog/30678.txt new file mode 100644 index 0000000000..35763ca4e3 --- /dev/null +++ b/changelog/30678.txt @@ -0,0 +1,5 @@ +```release-note:change +ui/activity: Replaces mount and namespace attribution charts with a table to allow sorting +client count data by `namespace`, `mount_path`, `mount_type` or number of clients for +a selected month. +``` diff --git a/ui/app/components/clients/attribution.hbs b/ui/app/components/clients/attribution.hbs deleted file mode 100644 index 10abd62946..0000000000 --- a/ui/app/components/clients/attribution.hbs +++ /dev/null @@ -1,39 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -}} - -
-
-

{{capitalize this.noun}} attribution

-

{{this.chartText.description}}

-
- {{#if @attribution}} -
- -
-
-

{{this.chartText.subtext}}

-
- -
-

Top {{this.noun}}

-

{{this.topAttribution.label}}

-
- -
-

Clients in {{this.noun}}

-

{{format-number this.topAttribution.clients}}

-
- -
- {{#each this.attributionLegend as |legend idx|}} - {{capitalize legend.label}} - {{/each}} -
- {{else}} -
- -
- {{/if}} -
\ No newline at end of file diff --git a/ui/app/components/clients/attribution.js b/ui/app/components/clients/attribution.js deleted file mode 100644 index d12692ca74..0000000000 --- a/ui/app/components/clients/attribution.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Component from '@glimmer/component'; - -/** - * @module Attribution - * Attribution components display the top 10 total client counts for namespaces or mounts during a billing period. - * A horizontal bar chart shows on the right, with the top namespace/mount and respective client totals on the left. - * - * @example - * - * - * @param {string} noun - noun which reflects the type of data and used in title. Should be "namespace" (default) or "mount" - * @param {array} attribution - array of objects containing a label and breakdown of client counts for total clients - * @param {boolean} isSecretsSyncActivated - boolean reflecting if secrets sync is activated. Determines the labels and data shown - */ - -export default class Attribution extends Component { - get noun() { - return this.args.noun || 'namespace'; - } - - get attributionLegend() { - const attributionLegend = [ - { key: 'entity_clients', label: 'entity clients' }, - { key: 'non_entity_clients', label: 'non-entity clients' }, - { key: 'acme_clients', label: 'ACME clients' }, - ]; - - if (this.args.isSecretsSyncActivated) { - attributionLegend.push({ key: 'secret_syncs', label: 'secrets sync clients' }); - } - return attributionLegend; - } - - get sortedAttribution() { - if (this.args.attribution) { - // shallow copy so it doesn't mutate the data during tests - return this.args.attribution?.slice().sort((a, b) => b.clients - a.clients); - } - return []; - } - - // truncate data before sending to chart component - get topTenAttribution() { - return this.sortedAttribution.slice(0, 10); - } - - get topAttribution() { - // get top namespace or mount - return this.sortedAttribution[0] ?? null; - } - - get chartText() { - if (this.noun === 'namespace') { - return { - subtext: 'This data shows the top ten namespaces by total clients for the date range selected.', - description: - 'This data shows the top ten namespaces by total clients and can be used to understand where clients are originating. Namespaces are identified by path.', - }; - } else { - return { - subtext: - 'The total clients used by the mounts for this date range. This number is useful for identifying overall usage volume.', - description: - 'This data shows the top ten mounts by client count within this namespace, and can be used to understand where clients are originating. Mounts are organized by path.', - }; - } - } -} diff --git a/ui/app/components/clients/date-range.ts b/ui/app/components/clients/date-range.ts index b450dbb4a7..31fabee972 100644 --- a/ui/app/components/clients/date-range.ts +++ b/ui/app/components/clients/date-range.ts @@ -18,6 +18,7 @@ interface OnChangeParams { start_time: number | undefined; end_time: number | undefined; } + interface Args { onChange: (callback: OnChangeParams) => void; setEditModalVisible: (visible: boolean) => void; diff --git a/ui/app/components/clients/page/overview.hbs b/ui/app/components/clients/page/overview.hbs index 816a02d273..d983768f43 100644 --- a/ui/app/components/clients/page/overview.hbs +++ b/ui/app/components/clients/page/overview.hbs @@ -14,16 +14,21 @@ /> {{#if this.hasAttributionData}} - - {{#if this.namespaceMountAttribution}} - - {{/if}} + + + + {{#each this.months as |m|}} + + {{/each}} + + + {{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/page/overview.ts b/ui/app/components/clients/page/overview.ts index e4292161e3..33047e6467 100644 --- a/ui/app/components/clients/page/overview.ts +++ b/ui/app/components/clients/page/overview.ts @@ -5,23 +5,40 @@ import ActivityComponent from '../activity'; import { service } from '@ember/service'; -import { sanitizePath } from 'core/utils/sanitize-path'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { HTMLElementEvent } from 'vault/forms'; +import { parseAPITimestamp } from 'core/utils/date-formatters'; +import { formatTableData, TableData } from 'core/utils/client-count-utils'; import type FlagsService from 'vault/services/flags'; +import type RouterService from '@ember/routing/router-service'; export default class ClientsOverviewPageComponent extends ActivityComponent { @service declare readonly flags: FlagsService; + @service('app-router') declare readonly router: RouterService; + + @tracked selectedMonth = ''; get hasAttributionData() { - // we hide attribution data when mountPath filter present + // we hide attribution table when mountPath filter present // or if there's no data - if (this.args.mountPath || !this.totalUsageCounts.clients) return false; - return true; + return !this.args.mountPath && this.totalUsageCounts.clients; } - // mounts attribution - get namespaceMountAttribution() { - const { activity } = this.args; - const nsLabel = this.namespacePathForFilter; - return activity?.byNamespace?.find((ns) => sanitizePath(ns.label) === nsLabel)?.mounts || []; + get months() { + return this.byMonthNewClients.map((m) => ({ + display: parseAPITimestamp(m.timestamp, 'MMMM yyyy'), + value: m.month, + })); + } + + get tableData(): TableData[] | undefined { + if (!this.selectedMonth) return undefined; + return formatTableData(this.byMonthNewClients, this.selectedMonth); + } + + @action + selectMonth(e: HTMLElementEvent) { + this.selectedMonth = e.target.value; } } diff --git a/ui/app/components/clients/table.hbs b/ui/app/components/clients/table.hbs new file mode 100644 index 0000000000..a37c90bdc1 --- /dev/null +++ b/ui/app/components/clients/table.hbs @@ -0,0 +1,59 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + +{{#if @data}} + + <:body as |B|> + + {{B.data.namespace}} + {{B.data.label}} + {{#if (eq B.data.mount_type "deleted mount")}} + + {{else}} + {{B.data.mount_type}} + {{/if}} + {{B.data.clients}} + + + + + +{{else}} + + + + + + + + + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/table.ts b/ui/app/components/clients/table.ts new file mode 100644 index 0000000000..ae020ac0eb --- /dev/null +++ b/ui/app/components/clients/table.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import Ember from 'ember'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { paginate } from 'core/utils/paginate-list'; +import { MountClients } from 'core/utils/client-count-utils'; + +interface TableData extends MountClients { + namespace: string; +} + +interface Args { + data: TableData[]; +} + +type TableColumn = 'namespace' | 'label' | 'mount_type' | 'clients'; +type SortDirection = 'asc' | 'desc'; + +export default class Table extends Component { + @tracked currentPage = 1; + @tracked sortColumn: TableColumn = 'clients'; + @tracked sortDirection: SortDirection = 'desc'; + + pageSize = Ember.testing ? 3 : 10; // lower in tests to test pagination without seeding more data + + get paginatedTableData(): TableData[] { + const sorted = this.sortTableData(this.args.data); + const paginated = paginate(sorted, { + page: this.currentPage, + pageSize: this.pageSize, + }); + + return paginated; + } + + get tableHeaderMessage(): string { + return this.args.data + ? 'No data is available for the selected month' + : 'Select a month to view client attribution'; + } + + get tableBodyMessage(): string { + return this.args.data + ? 'View the namespace mount breakdown of clients by selecting another month.' + : 'View the namespace mount breakdown of clients by selecting a month.'; + } + + sortTableData(data: TableData[]): TableData[] { + if (this.sortColumn) { + data = [...data].sort((a, b) => { + const valA = a[this.sortColumn]; + const valB = b[this.sortColumn]; + + if (valA < valB) return this.sortDirection === 'asc' ? -1 : 1; + if (valA > valB) return this.sortDirection === 'asc' ? 1 : -1; + return 0; + }); + } + return data; + } + + @action + onPageChange(page: number) { + this.currentPage = page; + } + + @action + updateSort(column: TableColumn, direction: SortDirection) { + this.sortColumn = column; + this.sortDirection = direction; + } +} diff --git a/ui/lib/core/addon/utils/client-count-utils.ts b/ui/lib/core/addon/utils/client-count-utils.ts index 502a296563..114e3b73f4 100644 --- a/ui/lib/core/addon/utils/client-count-utils.ts +++ b/ui/lib/core/addon/utils/client-count-utils.ts @@ -205,7 +205,11 @@ export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByN // transform to an empty array for type consistency let mounts: MountClients[] | [] = []; if (Array.isArray(ns.mounts)) { - mounts = ns.mounts.map((m) => ({ label: m.mount_path, ...destructureClientCounts(m.counts) })); + mounts = ns.mounts.map((m) => ({ + label: m.mount_path, + mount_type: m.mount_type, + ...destructureClientCounts(m.counts), + })); } return { label, @@ -215,6 +219,23 @@ export const formatByNamespace = (namespaceArray: NamespaceObject[] | null): ByN }); }; +export const formatTableData = (byMonthNewClients: ByMonthNewClients[], month: string): TableData[] => { + const monthData = byMonthNewClients.find((m) => m.month === month); + const namespaces = monthData?.namespaces; + + let data: TableData[] = []; + // iterate over namespaces to add "namespace" to each mount object + namespaces?.forEach((n) => { + const mounts: TableData[] = n.mounts.map((m) => { + // add namespace to mount block + return { ...m, namespace: n.label }; + }); + data = [...data, ...mounts]; + }); + + return data; +}; + // This method returns only client types from the passed object, excluding other keys such as "label". // when querying historical data the response will always contain the latest client type keys because the activity log is // constructed based on the version of Vault the user is on (key values will be 0) @@ -281,6 +302,7 @@ export interface ByNamespaceClients extends TotalClients { export interface MountClients extends TotalClients { label: string; + mount_type: string; } export interface ByMonthClients extends TotalClients { @@ -322,13 +344,17 @@ export interface MountNewClients extends TotalClientsSometimes { label: string; } +export interface TableData extends MountClients { + namespace: string; +} + // API RESPONSE SHAPE (prior to serialization) export interface NamespaceObject { namespace_id: string; namespace_path: string; counts: Counts; - mounts: { mount_path: string; counts: Counts }[]; + mounts: { mount_path: string; counts: Counts; mount_type: string }[]; } type ActivityMonthStandard = { diff --git a/ui/tests/acceptance/clients/counts-test.js b/ui/tests/acceptance/clients/counts-test.js index 3fd21ef05a..2788331e41 100644 --- a/ui/tests/acceptance/clients/counts-test.js +++ b/ui/tests/acceptance/clients/counts-test.js @@ -130,7 +130,7 @@ module('Acceptance | clients | counts', function (hooks) { counts: getCounts(), mounts: [ { - mount_path: 'auth/authid/0', + mount_path: 'auth/userpass-0', counts: getCounts(), }, ], @@ -145,7 +145,7 @@ module('Acceptance | clients | counts', function (hooks) { counts: getCounts(), mounts: [ { - mount_path: 'auth/authid/0', + mount_path: 'auth/userpass-0', counts: getCounts(), }, ], diff --git a/ui/tests/acceptance/clients/counts/acme-test.js b/ui/tests/acceptance/clients/counts/acme-test.js index 8d7de1b9fc..19f84f5ae2 100644 --- a/ui/tests/acceptance/clients/counts/acme-test.js +++ b/ui/tests/acceptance/clients/counts/acme-test.js @@ -131,7 +131,7 @@ module('Acceptance | clients | counts | acme', function (hooks) { assert.expect(3); await visit('/vault/clients/counts/acme'); await selectChoose(CLIENT_COUNT.nsFilter, this.nsPath); - await selectChoose(CLIENT_COUNT.mountFilter, 'auth/authid/0'); + await selectChoose(CLIENT_COUNT.mountFilter, 'auth/userpass-0'); // no data because this is an auth mount (acme_clients come from pki mounts) assert.dom(CLIENT_COUNT.statText('Total ACME clients')).hasTextContaining('0'); assert.dom(`${CHARTS.chart('ACME usage')} ${CHARTS.verticalBar}`).isNotVisible(); diff --git a/ui/tests/acceptance/clients/counts/overview-test.js b/ui/tests/acceptance/clients/counts/overview-test.js index a590b5e9ed..26a8b2ce85 100644 --- a/ui/tests/acceptance/clients/counts/overview-test.js +++ b/ui/tests/acceptance/clients/counts/overview-test.js @@ -58,7 +58,6 @@ module('Acceptance | clients | overview', function (hooks) { assert .dom(CLIENT_COUNT.dateRange.dateDisplay('end')) .hasText('January 2024', 'billing start month is correctly parsed from license'); - assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 }); assert .dom(CHARTS.container('Vault client counts')) .exists('Shows running totals with monthly breakdown charts'); @@ -87,9 +86,6 @@ module('Acceptance | clients | overview', function (hooks) { assert .dom(CHARTS.container('Vault client counts')) .doesNotExist('running total month over month charts do not show'); - assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 }); - assert.dom(CHARTS.container('namespace')).exists('namespace attribution chart shows'); - assert.dom(CHARTS.container('mount')).exists('mount attribution chart shows'); // change to start on month/year of upgrade to 1.10 await click(CLIENT_COUNT.dateRange.edit); @@ -99,7 +95,6 @@ module('Acceptance | clients | overview', function (hooks) { assert .dom(CLIENT_COUNT.dateRange.dateDisplay('start')) .hasText('September 2023', 'billing start month is correctly parsed from license'); - assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 }); assert .dom(CHARTS.container('Vault client counts')) .exists('Shows running totals with monthly breakdown charts'); @@ -120,9 +115,6 @@ module('Acceptance | clients | overview', function (hooks) { assert .dom(CHARTS.container('Vault client counts')) .doesNotExist('running total month over month charts do not show'); - assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 }); - assert.dom(CHARTS.container('namespace')).exists('namespace attribution chart shows'); - assert.dom(CHARTS.container('mount')).exists('mount attribution chart shows'); // query historical date range (from September 2023 to December 2023) await click(CLIENT_COUNT.dateRange.edit); @@ -136,7 +128,6 @@ module('Acceptance | clients | overview', function (hooks) { assert .dom(CLIENT_COUNT.dateRange.dateDisplay('end')) .hasText('December 2023', 'billing start month is correctly parsed from license'); - assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 }); assert .dom(CHARTS.container('Vault client counts')) .exists('Shows running totals with monthly breakdown charts'); @@ -163,7 +154,6 @@ module('Acceptance | clients | overview', function (hooks) { assert .dom(CHARTS.container('Vault client counts')) .exists('Shows running totals with monthly breakdown charts'); - assert.dom(CLIENT_COUNT.attributionBlock()).exists({ count: 2 }); const response = await this.store.peekRecord('clients/activity', 'some-activity-id'); const orderedNs = response.byNamespace.sort((a, b) => b.clients - a.clients); @@ -172,23 +162,9 @@ module('Acceptance | clients | overview', function (hooks) { const filterNamespace = topNamespace.label === 'root' ? orderedNs[1] : topNamespace; const topMount = filterNamespace?.mounts.sort((a, b) => b.clients - a.clients)[0]; - assert - .dom(`${CLIENT_COUNT.attributionBlock('namespace')} [data-test-top-attribution]`) - .includesText(`Top namespace ${topNamespace.label}`); - // this math works because there are no nested namespaces in the mirage data - assert - .dom(`${CLIENT_COUNT.attributionBlock('namespace')} [data-test-attribution-clients] p`) - .includesText(`${formatNumber([topNamespace.clients])}`, 'top attribution clients accurate'); - // Filter by top namespace await selectChoose(CLIENT_COUNT.nsFilter, filterNamespace.label); assert.dom(CLIENT_COUNT.selectedNs).hasText(filterNamespace.label, 'selects top namespace'); - assert - .dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-top-attribution]`) - .includesText('Top mount'); - assert - .dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-attribution-clients] p`) - .includesText(`${formatNumber([topMount.clients])}`, 'top attribution clients accurate'); let expectedStats = { Entity: formatNumber([filterNamespace.entity_clients]), @@ -205,7 +181,6 @@ module('Acceptance | clients | overview', function (hooks) { // FILTER BY AUTH METHOD await selectChoose(CLIENT_COUNT.mountFilter, topMount.label); assert.dom(CLIENT_COUNT.selectedAuthMount).hasText(topMount.label, 'selects top mount'); - assert.dom(CLIENT_COUNT.attributionBlock()).doesNotExist('Does not show attribution block'); expectedStats = { Entity: formatNumber([topMount.entity_clients]), @@ -222,13 +197,6 @@ module('Acceptance | clients | overview', function (hooks) { // Remove namespace filter without first removing auth method filter await click(GENERAL.searchSelect.removeSelected); assert.strictEqual(currentURL(), '/vault/clients/counts/overview', 'removes both query params'); - assert.dom('[data-test-top-attribution]').includesText('Top namespace'); - assert - .dom('[data-test-attribution-clients]') - .hasTextContaining( - `${formatNumber([topNamespace.clients])}`, - 'top attribution clients back to unfiltered value' - ); expectedStats = { Entity: formatNumber([response.total.entity_clients]), diff --git a/ui/tests/helpers/clients/client-count-helpers.js b/ui/tests/helpers/clients/client-count-helpers.js index 292c5b4274..b43e60b3cf 100644 --- a/ui/tests/helpers/clients/client-count-helpers.js +++ b/ui/tests/helpers/clients/client-count-helpers.js @@ -45,7 +45,8 @@ export const ACTIVITY_RESPONSE_STUB = { }, mounts: [ { - mount_path: 'auth/authid/0', + mount_path: 'auth/userpass-0', + mount_type: 'userpass', counts: { acme_clients: 0, clients: 8394, @@ -56,6 +57,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, { mount_path: 'kvv2-engine-0', + mount_type: 'kv', counts: { acme_clients: 0, clients: 4810, @@ -66,6 +68,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, { mount_path: 'pki-engine-0', + mount_type: 'pki', counts: { acme_clients: 5699, clients: 5699, @@ -88,7 +91,8 @@ export const ACTIVITY_RESPONSE_STUB = { }, mounts: [ { - mount_path: 'auth/authid/0', + mount_path: 'auth/userpass-0', + mount_type: 'userpass', counts: { acme_clients: 0, clients: 8091, @@ -99,6 +103,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, { mount_path: 'kvv2-engine-0', + mount_type: 'kv', counts: { acme_clients: 0, clients: 4290, @@ -109,6 +114,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, { mount_path: 'pki-engine-0', + mount_type: 'pki', counts: { acme_clients: 4003, clients: 4003, @@ -150,6 +156,7 @@ export const ACTIVITY_RESPONSE_STUB = { mounts: [ { mount_path: 'pki-engine-0', + mount_type: 'pki', counts: { acme_clients: 100, clients: 100, @@ -159,7 +166,8 @@ export const ACTIVITY_RESPONSE_STUB = { }, }, { - mount_path: 'auth/authid/0', + mount_path: 'auth/userpass-0', + mount_type: 'userpass', counts: { acme_clients: 0, clients: 200, @@ -170,6 +178,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, { mount_path: 'kvv2-engine-0', + mount_type: 'kv', counts: { acme_clients: 0, clients: 100, @@ -203,6 +212,7 @@ export const ACTIVITY_RESPONSE_STUB = { mounts: [ { mount_path: 'pki-engine-0', + mount_type: 'pki', counts: { acme_clients: 100, clients: 100, @@ -212,7 +222,8 @@ export const ACTIVITY_RESPONSE_STUB = { }, }, { - mount_path: 'auth/authid/0', + mount_path: 'auth/userpass-0', + mount_type: 'userpass', counts: { acme_clients: 0, clients: 200, @@ -223,6 +234,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, { mount_path: 'kvv2-engine-0', + mount_type: 'kv', counts: { acme_clients: 0, clients: 100, @@ -259,6 +271,7 @@ export const ACTIVITY_RESPONSE_STUB = { mounts: [ { mount_path: 'pki-engine-0', + mount_type: 'pki', counts: { acme_clients: 100, clients: 100, @@ -268,7 +281,8 @@ export const ACTIVITY_RESPONSE_STUB = { }, }, { - mount_path: 'auth/authid/0', + mount_path: 'auth/userpass-0', + mount_type: 'userpass', counts: { acme_clients: 0, clients: 200, @@ -279,6 +293,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, { mount_path: 'kvv2-engine-0', + mount_type: 'kv', counts: { acme_clients: 0, clients: 100, @@ -327,7 +342,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, }, { - mount_path: 'auth/authid/0', + mount_path: 'auth/userpass-0', counts: { acme_clients: 0, clients: 890, @@ -370,7 +385,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, }, { - mount_path: 'auth/authid/0', + mount_path: 'auth/userpass-0', counts: { acme_clients: 0, clients: 872, @@ -423,7 +438,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, }, { - mount_path: 'auth/authid/0', + mount_path: 'auth/userpass-0', counts: { acme_clients: 0, clients: 75, @@ -456,7 +471,7 @@ export const ACTIVITY_RESPONSE_STUB = { }, mounts: [ { - mount_path: 'auth/authid/0', + mount_path: 'auth/userpass-0', counts: { acme_clients: 0, clients: 96, @@ -530,6 +545,7 @@ export const MIXED_ACTIVITY_RESPONSE_STUB = { secret_syncs: 0, }, mount_path: 'no mount accessor (pre-1.10 upgrade?)', + mount_type: 'no mount path (pre-1.10 upgrade?)', }, { counts: { @@ -539,7 +555,8 @@ export const MIXED_ACTIVITY_RESPONSE_STUB = { non_entity_clients: 0, secret_syncs: 0, }, - mount_path: 'auth/u/', + mount_path: 'auth/userpass-0', + mount_type: 'userpass', }, ], namespace_id: 'root', @@ -580,6 +597,7 @@ export const MIXED_ACTIVITY_RESPONSE_STUB = { secret_syncs: 0, }, mount_path: 'no mount accessor (pre-1.10 upgrade?)', + mount_type: 'no mount path (pre-1.10 upgrade?)', }, { counts: { @@ -589,7 +607,8 @@ export const MIXED_ACTIVITY_RESPONSE_STUB = { non_entity_clients: 0, secret_syncs: 0, }, - mount_path: 'auth/u/', + mount_path: 'auth/userpass-0', + mount_type: 'userpass', }, ], namespace_id: 'root', @@ -623,6 +642,7 @@ export const MIXED_ACTIVITY_RESPONSE_STUB = { secret_syncs: 0, }, mount_path: 'no mount accessor (pre-1.10 upgrade?)', + mount_type: 'no mount path (pre-1.10 upgrade?)', }, { counts: { @@ -632,7 +652,8 @@ export const MIXED_ACTIVITY_RESPONSE_STUB = { non_entity_clients: 0, secret_syncs: 0, }, - mount_path: 'auth/u/', + mount_path: 'auth/userpass-0', + mount_type: 'userpass', }, ], namespace_id: 'root', @@ -664,7 +685,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { secret_syncs: 4810, mounts: [ { - label: 'auth/authid/0', + label: 'auth/userpass-0', + mount_type: 'userpass', acme_clients: 0, clients: 8394, entity_clients: 4256, @@ -673,6 +695,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'kvv2-engine-0', + mount_type: 'kv', acme_clients: 0, clients: 4810, entity_clients: 0, @@ -681,6 +704,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'pki-engine-0', + mount_type: 'pki', acme_clients: 5699, clients: 5699, entity_clients: 0, @@ -698,7 +722,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { secret_syncs: 4290, mounts: [ { - label: 'auth/authid/0', + label: 'auth/userpass-0', + mount_type: 'userpass', acme_clients: 0, clients: 8091, entity_clients: 4002, @@ -707,6 +732,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'kvv2-engine-0', + mount_type: 'kv', acme_clients: 0, clients: 4290, entity_clients: 0, @@ -715,6 +741,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'pki-engine-0', + mount_type: 'pki', acme_clients: 4003, clients: 4003, entity_clients: 0, @@ -754,6 +781,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { mounts: [ { label: 'pki-engine-0', + mount_type: 'pki', acme_clients: 100, clients: 100, entity_clients: 0, @@ -761,7 +789,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { secret_syncs: 0, }, { - label: 'auth/authid/0', + label: 'auth/userpass-0', + mount_type: 'userpass', acme_clients: 0, clients: 200, entity_clients: 100, @@ -770,6 +799,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'kvv2-engine-0', + mount_type: 'kv', acme_clients: 0, clients: 100, entity_clients: 0, @@ -798,6 +828,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { mounts: [ { label: 'pki-engine-0', + mount_type: 'pki', acme_clients: 100, clients: 100, entity_clients: 0, @@ -805,7 +836,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { secret_syncs: 0, }, { - label: 'auth/authid/0', + label: 'auth/userpass-0', + mount_type: 'userpass', acme_clients: 0, clients: 200, entity_clients: 100, @@ -814,6 +846,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'kvv2-engine-0', + mount_type: 'kv', acme_clients: 0, clients: 100, entity_clients: 0, @@ -844,6 +877,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { mounts: [ { label: 'pki-engine-0', + mount_type: 'pki', acme_clients: 100, clients: 100, entity_clients: 0, @@ -851,7 +885,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { secret_syncs: 0, }, { - label: 'auth/authid/0', + label: 'auth/userpass-0', + mount_type: 'userpass', acme_clients: 0, clients: 200, entity_clients: 100, @@ -860,6 +895,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'kvv2-engine-0', + mount_type: 'kv', acme_clients: 0, clients: 100, entity_clients: 0, @@ -901,7 +937,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { secret_syncs: 0, }, { - label: 'auth/authid/0', + label: 'auth/userpass-0', acme_clients: 0, clients: 890, entity_clients: 708, @@ -935,7 +971,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { secret_syncs: 0, }, { - label: 'auth/authid/0', + label: 'auth/userpass-0', acme_clients: 0, clients: 872, entity_clients: 124, @@ -972,6 +1008,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { mounts: [ { label: 'pki-engine-0', + mount_type: 'pki', acme_clients: 91, clients: 91, entity_clients: 0, @@ -979,7 +1016,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { secret_syncs: 0, }, { - label: 'auth/authid/0', + label: 'auth/userpass-0', + mount_type: 'userpass', acme_clients: 0, clients: 75, entity_clients: 25, @@ -988,6 +1026,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'kvv2-engine-0', + mount_type: 'kv', acme_clients: 0, clients: 25, entity_clients: 0, @@ -1005,7 +1044,8 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { secret_syncs: 24, mounts: [ { - label: 'auth/authid/0', + label: 'auth/userpass-0', + mount_type: 'userpass', acme_clients: 0, clients: 96, entity_clients: 34, @@ -1014,6 +1054,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'pki-engine-0', + mount_type: 'pki', acme_clients: 53, clients: 53, entity_clients: 0, @@ -1022,6 +1063,7 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { }, { label: 'kvv2-engine-0', + mount_type: 'kv', acme_clients: 0, clients: 24, entity_clients: 0, diff --git a/ui/tests/helpers/clients/client-count-selectors.ts b/ui/tests/helpers/clients/client-count-selectors.ts index 8cdf9affad..c68f95a73e 100644 --- a/ui/tests/helpers/clients/client-count-selectors.ts +++ b/ui/tests/helpers/clients/client-count-selectors.ts @@ -26,8 +26,14 @@ export const CLIENT_COUNT = { statTextValue: (label: string) => label ? `[data-test-stat-text="${label}"] .stat-value` : '[data-test-stat-text]', usageStats: (title: string) => `[data-test-usage-stats="${title}"]`, - attributionBlock: (type: string) => - type ? `[data-test-clients-attribution="${type}"]` : '[data-test-clients-attribution]', + attribution: { + card: '[data-test-card="attribution"]', + table: '[data-test-clients-attribution-table]', + row: '[data-test-attribution-table-row', + counts: (index: number) => `[data-test-attribution-table-counts="${index}"]`, + pagination: '[data-test-pagination', + paginationInfo: '.hds-pagination-info', + }, filterBar: '[data-test-clients-filter-bar]', nsFilter: '#namespace-search-select', mountFilter: '#mounts-search-select', diff --git a/ui/tests/integration/components/clients/attribution-test.js b/ui/tests/integration/components/clients/attribution-test.js deleted file mode 100644 index 98eee690ba..0000000000 --- a/ui/tests/integration/components/clients/attribution-test.js +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { module, test } from 'qunit'; -import sinon from 'sinon'; -import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; -import { formatRFC3339 } from 'date-fns'; -import subMonths from 'date-fns/subMonths'; -import timestamp from 'core/utils/timestamp'; -import { SERIALIZED_ACTIVITY_RESPONSE } from 'vault/tests/helpers/clients/client-count-helpers'; -import { setupMirage } from 'ember-cli-mirage/test-support'; -import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { CLIENT_TYPES } from 'core/utils/client-count-utils'; - -const CLIENTS_ATTRIBUTION = { - title: '[data-test-attribution-title]', - description: '[data-test-attribution-description]', - subtext: '[data-test-attribution-subtext]', - timestamp: '[data-test-attribution-timestamp]', - chart: '[data-test-horizontal-bar-chart]', - topItem: '[data-test-top-attribution]', - topItemCount: '[data-test-attribution-clients]', - yLabel: '[data-test-group="y-labels"]', - yLabels: '[data-test-group="y-labels"] text', -}; -module('Integration | Component | clients/attribution', function (hooks) { - setupRenderingTest(hooks); - setupMirage(hooks); - - hooks.before(function () { - this.timestampStub = sinon.replace(timestamp, 'now', sinon.fake.returns(new Date('2018-04-03T14:15:30'))); - }); - - hooks.beforeEach(function () { - const mockNow = this.timestampStub(); - this.mockNow = mockNow; - this.startTimestamp = formatRFC3339(subMonths(mockNow, 6)); - this.selectedNamespace = null; - this.namespaceAttribution = SERIALIZED_ACTIVITY_RESPONSE.by_namespace; - this.authMountAttribution = SERIALIZED_ACTIVITY_RESPONSE.by_namespace.find( - (ns) => ns.label === 'ns1' - ).mounts; - }); - - test('it renders empty state with no data', async function (assert) { - await render(hbs` - - `); - - assert.dom(GENERAL.emptyStateTitle).hasText('No data found'); - assert.dom(CLIENTS_ATTRIBUTION.title).hasText('Namespace attribution', 'uses default noun'); - }); - - test('it updates language based on noun', async function (assert) { - this.noun = ''; - await render(hbs` - - `); - - // when noun is blank, uses default - assert.dom(CLIENTS_ATTRIBUTION.title).hasText('Namespace attribution'); - assert - .dom(CLIENTS_ATTRIBUTION.description) - .hasText( - 'This data shows the top ten namespaces by total clients and can be used to understand where clients are originating. Namespaces are identified by path.' - ); - assert - .dom(CLIENTS_ATTRIBUTION.subtext) - .hasText('This data shows the top ten namespaces by total clients for the date range selected.'); - - // when noun is mount - this.set('noun', 'mount'); - assert.dom(CLIENTS_ATTRIBUTION.title).hasText('Mount attribution'); - assert - .dom(CLIENTS_ATTRIBUTION.description) - .hasText( - 'This data shows the top ten mounts by client count within this namespace, and can be used to understand where clients are originating. Mounts are organized by path.' - ); - assert - .dom(CLIENTS_ATTRIBUTION.subtext) - .hasText( - 'The total clients used by the mounts for this date range. This number is useful for identifying overall usage volume.' - ); - - // when noun is namespace - this.set('noun', 'namespace'); - assert.dom(CLIENTS_ATTRIBUTION.title).hasText('Namespace attribution'); - assert - .dom(CLIENTS_ATTRIBUTION.description) - .hasText( - 'This data shows the top ten namespaces by total clients and can be used to understand where clients are originating. Namespaces are identified by path.' - ); - assert - .dom(CLIENTS_ATTRIBUTION.subtext) - .hasText('This data shows the top ten namespaces by total clients for the date range selected.'); - }); - - test('it renders with data for namespaces', async function (assert) { - await render(hbs` - - `); - - assert.dom(GENERAL.emptyStateTitle).doesNotExist(); - assert.dom(CLIENTS_ATTRIBUTION.chart).exists(); - assert.dom(CLIENTS_ATTRIBUTION.topItem).includesText('namespace').includesText('ns1'); - assert.dom(CLIENTS_ATTRIBUTION.topItemCount).includesText('namespace').includesText('18,903'); - assert - .dom(CLIENTS_ATTRIBUTION.yLabels) - .exists({ count: 2 }, 'bars reflect number of namespaces in single month'); - assert.dom(CLIENTS_ATTRIBUTION.yLabel).hasText('ns1root'); - }); - - test('it renders with data for mounts', async function (assert) { - await render(hbs` - - `); - - assert.dom(GENERAL.emptyStateTitle).doesNotExist(); - assert.dom(CLIENTS_ATTRIBUTION.chart).exists(); - assert.dom(CLIENTS_ATTRIBUTION.topItem).includesText('mount').includesText('auth/authid/0'); - assert.dom(CLIENTS_ATTRIBUTION.topItemCount).includesText('mount').includesText('8,394'); - assert - .dom(CLIENTS_ATTRIBUTION.yLabels) - .exists({ count: 3 }, 'bars reflect number of mounts in single month'); - assert.dom(CLIENTS_ATTRIBUTION.yLabel).hasText('auth/authid/0pki-engine-0kvv2-engine-0'); - }); - - test('it shows secret syncs when flag is on', async function (assert) { - this.isSecretsSyncActivated = true; - await render(hbs` - - `); - - assert.dom('[data-test-group="secret_syncs"] rect').exists({ count: 2 }); - }); - - test('it hids secret syncs when flag is off or missing', async function (assert) { - this.isSecretsSyncActivated = true; - await render(hbs` - - `); - - assert.dom('[data-test-group="secret_syncs"]').doesNotExist(); - }); - - test('it sorts and limits before rendering bars', async function (assert) { - this.tooManyAttributions = Array(15) - .fill(null) - .map((_, idx) => { - const attr = { label: `ns${idx}` }; - CLIENT_TYPES.forEach((type) => { - attr[type] = 10 + idx; - }); - return attr; - }); - await render(hbs` - - `); - assert.dom(CLIENTS_ATTRIBUTION.yLabels).exists({ count: 10 }, 'only 10 bars are shown'); - assert.dom(CLIENTS_ATTRIBUTION.topItem).includesText('ns14'); - }); -}); diff --git a/ui/tests/integration/components/clients/page/overview-test.js b/ui/tests/integration/components/clients/page/overview-test.js index a24e70af55..4de5d576ac 100644 --- a/ui/tests/integration/components/clients/page/overview-test.js +++ b/ui/tests/integration/components/clients/page/overview-test.js @@ -5,13 +5,14 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'vault/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { click, fillIn, findAll, render, triggerEvent } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { setRunOptions } from 'ember-a11y-testing/test-support'; import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers'; import { filterActivityResponse } from 'vault/mirage/handlers/clients'; -import { CHARTS, CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors'; +import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; module('Integration | Component | clients/page/overview', function (hooks) { setupRenderingTest(hooks); @@ -48,71 +49,118 @@ module('Integration | Component | clients/page/overview', function (hooks) { this.mountPath = ''; this.namespace = ''; this.versionHistory = ''; + this.activity = await this.store.queryRecord('clients/activity', {}); // Fails on #ember-testing-container setRunOptions({ rules: { - 'scrollable-region-focusable': { enabled: false }, + 'aria-prohibited-attr': { enabled: false }, }, }); }); - test('it hides attribution data when mount filter applied', async function (assert) { - this.mountPath = ''; + test('it shows empty state message upon initial load', async function (assert) { + await render(hbs``); + + assert.dom(GENERAL.selectByAttr('attribution-month')).exists('shows month selection dropdown'); + + assert.dom(CLIENT_COUNT.attribution.card).exists('shows card for table state'); + assert + .dom(CLIENT_COUNT.attribution.card) + .hasText( + 'Select a month to view client attribution View the namespace mount breakdown of clients by selecting a month. Client count documentation', + 'Show initial table state message' + ); + }); + + test('it shows correct state message when month selection has no data', async function (assert) { + await render(hbs``); + + assert.dom(GENERAL.selectByAttr('attribution-month')).exists('shows month selection dropdown'); + await fillIn(GENERAL.selectByAttr('attribution-month'), '6/23'); + + assert + .dom(CLIENT_COUNT.attribution.card) + .hasText( + 'No data is available for the selected month View the namespace mount breakdown of clients by selecting another month. Client count documentation', + 'Shows correct message for a month selection with no data' + ); + }); + + test('it shows table when month selection has data', async function (assert) { + await render(hbs``); + + assert.dom(GENERAL.selectByAttr('attribution-month')).exists('shows month selection dropdown'); + await fillIn(GENERAL.selectByAttr('attribution-month'), '9/23'); + + assert.dom(CLIENT_COUNT.attribution.card).doesNotExist('does not show card when table has data'); + assert.dom(CLIENT_COUNT.attribution.table).exists('shows table'); + assert.dom(CLIENT_COUNT.attribution.paginationInfo).hasText('1–3 of 6', 'shows correct pagination info'); + }); + + test('it filters the table when a namespace filter is applied', async function (assert) { + this.namespace = 'ns1'; this.activity = await this.store.queryRecord('clients/activity', { - namespace: 'ns1', + namespace: this.namespace, + }); + await render(hbs``); + + await fillIn(GENERAL.selectByAttr('attribution-month'), '9/23'); + + assert.dom(CLIENT_COUNT.attribution.card).doesNotExist('does not show card when table has data'); + assert.dom(CLIENT_COUNT.attribution.table).exists(); + assert.dom(CLIENT_COUNT.attribution.paginationInfo).hasText('1–3 of 3', 'shows correct pagination info'); + }); + + test('it hides the table when a mount filter is applied', async function (assert) { + this.namespace = 'ns1'; + this.mountPath = 'auth/userpass-0'; + this.activity = await this.store.queryRecord('clients/activity', { + namespace: this.namespace, + mountPath: this.mountPath, }); await render( - hbs`` + hbs`` ); - - assert.dom(CHARTS.container('Vault client counts')).exists('shows running totals'); - assert.dom(CLIENT_COUNT.attributionBlock('namespace')).exists(); - assert.dom(CLIENT_COUNT.attributionBlock('mount')).exists(); - - this.set('mountPath', 'auth/authid/0'); - assert.dom(CHARTS.container('Vault client counts')).exists('shows running totals'); - assert.dom(CLIENT_COUNT.attributionBlock('namespace')).doesNotExist(); - assert.dom(CLIENT_COUNT.attributionBlock('mount')).doesNotExist(); + assert.dom(CLIENT_COUNT.attribution.card).doesNotExist('does not show card when table has data'); + assert + .dom(CLIENT_COUNT.attribution.table) + .doesNotExist('does not show table when a mount filter is applied'); }); - test('it hides attribution data when no data returned', async function (assert) { - this.mountPath = ''; - this.activity = await this.store.queryRecord('clients/activity', { - namespace: 'no-data', - }); - await render(hbs``); - assert.dom(CLIENT_COUNT.usageStats('Total usage')).exists(); - assert.dom(CHARTS.container('Vault client counts')).doesNotExist('usage stats instead of running totals'); - assert.dom(CLIENT_COUNT.attributionBlock('namespace')).doesNotExist(); - assert.dom(CLIENT_COUNT.attributionBlock('mount')).doesNotExist(); + test('it paginates table data', async function (assert) { + await render(hbs``); + + await fillIn(GENERAL.selectByAttr('attribution-month'), '9/23'); + + assert + .dom(CLIENT_COUNT.attribution.row) + .exists({ count: 3 }, 'Correct number of table rows render based on page size'); + assert.dom(CLIENT_COUNT.attribution.counts(0)).hasText('96', 'First page shows data'); + assert.dom(CLIENT_COUNT.attribution.pagination).exists('shows pagination'); + assert.dom(CLIENT_COUNT.attribution.paginationInfo).hasText('1–3 of 6', 'shows correct pagination info'); + + await click(GENERAL.pagination.next); + + assert.dom(CLIENT_COUNT.attribution.counts(0)).hasText('53', 'Second page shows new data'); + assert.dom(CLIENT_COUNT.attribution.paginationInfo).hasText('4–6 of 6', 'shows correct pagination info'); }); - test('it shows the correct mount attributions', async function (assert) { - this.nsService = this.owner.lookup('service:namespace'); - const rootActivity = await this.store.queryRecord('clients/activity', {}); - this.activity = rootActivity; + test('it shows correct month options for billing period', async function (assert) { await render(hbs``); - // start at "root" namespace - let expectedMounts = rootActivity.byNamespace.find((ns) => ns.label === 'root').mounts; - assert - .dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-group="y-labels"] text`) - .exists({ count: expectedMounts.length }); - assert - .dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-group="y-labels"]`) - .includesText(expectedMounts[0].label); - // now pretend we're querying within a child namespace - this.nsService.path = 'ns1'; - this.activity = await this.store.queryRecord('clients/activity', { - namespace: 'ns1', - }); - expectedMounts = rootActivity.byNamespace.find((ns) => ns.label === 'ns1').mounts; - assert - .dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-group="y-labels"] text`) - .exists({ count: expectedMounts.length }); - assert - .dom(`${CLIENT_COUNT.attributionBlock('mount')} [data-test-group="y-labels"]`) - .includesText(expectedMounts[0].label); + assert.dom(GENERAL.selectByAttr('attribution-month')).exists('shows month selection dropdown'); + await fillIn(GENERAL.selectByAttr('attribution-month'), ''); + await triggerEvent(GENERAL.selectByAttr('attribution-month'), 'change'); + + // assert that months options in select are those of selected billing period + const expectedMonths = this.activity.byMonth.map((m) => m.month); + + // '' represents default state of 'Select month' + const expectedOptions = ['', ...expectedMonths]; + const actualOptions = findAll(`${GENERAL.selectByAttr('attribution-month')} option`).map( + (option) => option.value + ); + assert.deepEqual(actualOptions, expectedOptions, 'All