[VAULT-39375] Ability to pick namespaces in usage dashboard (#9143) (#9259)

Co-authored-by: Eren Tantekin <eren.tantekin@hashicorp.com>
Co-authored-by: Jim Wright <jim.wright@hashicorp.com>
This commit is contained in:
Vault Automation 2025-09-16 16:36:39 -04:00 committed by GitHub
parent 64fd8225bc
commit f17451d675
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 141 additions and 3 deletions

View File

@ -5,5 +5,6 @@
<VaultReporting::Views::Dashboard
@onFetchUsageData={{this.handleFetchUsageData}}
@onFetchNamespaceData={{this.handleFetchNamespaceData}}
@isVaultDedicated={{this.flags.isHvdManaged}}
/>

View File

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

View File

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