From f17451d675059cc8f515f2ac0e9eba43cea358a2 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Tue, 16 Sep 2025 16:36:39 -0400 Subject: [PATCH] [VAULT-39375] Ability to pick namespaces in usage dashboard (#9143) (#9259) Co-authored-by: Eren Tantekin Co-authored-by: Jim Wright --- ui/app/components/usage/page.hbs | 1 + ui/app/components/usage/page.ts | 63 ++++++++++++++- .../acceptance/vault-reporting/index-test.js | 80 ++++++++++++++++++- 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/ui/app/components/usage/page.hbs b/ui/app/components/usage/page.hbs index 20fa557343..78ecf41997 100644 --- a/ui/app/components/usage/page.hbs +++ b/ui/app/components/usage/page.hbs @@ -5,5 +5,6 @@ \ No newline at end of file diff --git a/ui/app/components/usage/page.ts b/ui/app/components/usage/page.ts index ed66341fe7..5de2a38a65 100644 --- a/ui/app/components/usage/page.ts +++ b/ui/app/components/usage/page.ts @@ -8,12 +8,20 @@ import { service } from '@ember/service'; import type FlagsService from 'vault/services/flags'; import type ApiService from 'vault/services/api'; +import type NamespaceService from 'vault/services/namespace'; +import type AuthService from 'vault/vault/services/auth'; import type { getUsageDataFunction, + getNamespaceDataFunction, UsageDashboardData, } from '@hashicorp-internal/vault-reporting/types/index'; import type { UtilizationReport } from 'vault/usage'; +interface NamespaceOption { + path: string; + label: string; +} + /** * @module UsagePage * @description This component is responsible for fetching usage data and mounting the vault-reporting dashboard view. @@ -31,15 +39,21 @@ import type { UtilizationReport } from 'vault/usage'; export default class UsagePage extends Component { @service declare readonly api: ApiService; @service declare readonly flags: FlagsService; + @service declare readonly namespace: NamespaceService; + @service declare readonly auth: AuthService; - handleFetchUsageData: getUsageDataFunction = async () => { + handleFetchUsageData: getUsageDataFunction = async (namespace?: string) => { /** * We get a partially typed response from the API client, but only 1 level deep. * Casting the nested types here and falling back to defaults in the mappings. * We should get typescript errors if top level interfaces in the API client or * the vault-reporting addon change. */ - const response = (await this.api.sys.generateUtilizationReport()) as UtilizationReport; + + // Convert "root" display value back to empty string for API calls + const apiNamespace = namespace === 'root' ? undefined : namespace; + const response = (await this.api.sys.generateUtilizationReport(apiNamespace)) as UtilizationReport; + const { lease_count_quotas, replication_status, pki, secret_sync } = response; const data: UsageDashboardData = { @@ -74,4 +88,49 @@ export default class UsagePage extends Component { }; return data; }; + + handleFetchNamespaceData: getNamespaceDataFunction = async () => { + await this.namespace?.findNamespacesForUser?.perform(); + const options = this.getOptions(this.namespace?.accessibleNamespaces); + const data = { + keys: options.map((option) => option.label), + }; + return data; + }; + + /** + * getOptions from ui/app/components/namespace-picker.ts + * We might consider moving this into a util function and sharing it across both files + */ + private getOptions(accessibleNamespaces: string[]): NamespaceOption[] { + /* Each namespace option has 2 properties: { path and label } + * - path: full namespace path (used to navigate to the namespace) + * - label: text displayed inside the namespace picker dropdown (if root, then path is "", else label = path) + * + * Example: + * | path | label | + * | ---- | ----- | + * | '' | 'root' | + * | 'parent' | 'parent' | + * | 'parent/child' | 'parent/child' | + */ + const options = (accessibleNamespaces || []).map((ns: string) => ({ path: ns, label: ns })); + + // Add the user's root namespace because `sys/internal/ui/namespaces` does not include it. + const userRootNamespace = this.auth.authData?.userRootNamespace; + if (!options?.find((o) => o.path === userRootNamespace)) { + // the 'root' namespace is technically an empty string so we manually add the 'root' label. + const label = userRootNamespace === '' ? 'root' : userRootNamespace; + options.unshift({ path: userRootNamespace, label }); + } + + // If there are no namespaces returned by the internal endpoint, add the current namespace + // to the list of options. This is a fallback for when the user has access to a single namespace. + if (options.length === 0) { + // 'path' defined in the namespace service is the full namespace path + options.push({ path: this.namespace.path, label: this.namespace.path }); + } + + return options; + } } diff --git a/ui/tests/acceptance/vault-reporting/index-test.js b/ui/tests/acceptance/vault-reporting/index-test.js index f28a4afc65..8e1ddaef7d 100644 --- a/ui/tests/acceptance/vault-reporting/index-test.js +++ b/ui/tests/acceptance/vault-reporting/index-test.js @@ -5,7 +5,7 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import { visit, currentURL, waitFor, click } from '@ember/test-helpers'; +import { visit, currentURL, waitFor, click, fillIn } from '@ember/test-helpers'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { mockedResponseWithData, mockedEmptyResponse } from 'vault/tests/helpers/vault-usage/mocks'; @@ -265,4 +265,82 @@ module('Acceptance | enterprise vault-reporting', function (hooks) { .dom('[data-test-vault-reporting-dashboard-lease-count]') .includesText('Global lease count quota', 'Lease quota empty state: docs link is shown'); }); + + test('namespace lookup functionality', async function (assert) { + this.server.get('sys/internal/ui/namespaces', { + data: { + keys: ['child-ns1/', 'child-ns2/'], + }, + }); + + // Mock different responses for different namespaces + const defaultMockResponse = mockedResponseWithData; + const childNs1MockResponse = { + data: { + ...mockedResponseWithData.data, + kvv2_secrets: 200, + kvv1_secrets: 50, + }, + }; + + // Initially load default data + this.server.get('sys/utilization-report', () => defaultMockResponse); + + const namespaceService = this.owner.lookup('service:namespace'); + namespaceService.set('accessibleNamespaces', ['child-ns1/', 'child-ns2/']); + namespaceService.set('path', 'parent-ns'); + + await visit('/vault/usage-reporting'); + + // Verify initial KV secrets count (should be 40 from default mock) + await waitFor('[data-test-vault-reporting-counter="KV secrets"]'); + assert + .dom('[data-test-vault-reporting-counter="KV secrets"]') + .includesText('100', 'Initial KV secrets count is 100'); + + // Update mock to return different data for child-ns1 + this.server.get('sys/utilization-report', () => childNs1MockResponse); + + // Click the namespace picker dropdown to open it + await click('.hds-dropdown.ssu-namespace-picker button'); + + // Verify all namespaces are visible initially + assert.dom('[data-test-vault-reporting-namespace-menu-item="root"]').exists('root namespace is visible'); + assert + .dom('[data-test-vault-reporting-namespace-menu-item="child-ns1"]') + .exists('child-ns1 namespace is visible'); + assert + .dom('[data-test-vault-reporting-namespace-menu-item="child-ns2"]') + .exists('child-ns2 namespace is visible'); + + // Use the search bar to search for "ns1" + await fillIn('[data-test-vault-reporting-namespace-search]', 'ns1'); + + // Verify only child-ns1 is visible after search + assert + .dom('[data-test-vault-reporting-namespace-menu-item="child-ns1"]') + .exists('child-ns1 is visible after search'); + + // Verify root and child-ns2 are filtered out + assert + .dom('[data-test-vault-reporting-namespace-menu-item="root"]') + .doesNotExist('root namespace is filtered out'); + assert + .dom('[data-test-vault-reporting-namespace-menu-item="child-ns2"]') + .doesNotExist('child-ns2 namespace is filtered out'); + + // Click on child-ns1 to select it + await click('[data-test-vault-reporting-namespace-menu-item="child-ns1"]'); + + // Verify that child-ns1 is now displayed as the selected namespace in the closed dropdown + assert + .dom('.hds-dropdown.ssu-namespace-picker') + .includesText('child-ns1', 'child-ns1 is displayed as the selected namespace'); + + // Wait for data to be refetched and verify the KV secrets count has changed + await waitFor('[data-test-vault-reporting-counter="KV secrets"]'); + assert + .dom('[data-test-vault-reporting-counter="KV secrets"]') + .includesText('250', 'KV secrets count updated to 250 after selecting child-ns1'); + }); });