[UI][VAULT-40923] Kubernetes Page Header (#11564) (#11574)

* Fix kubernetes tests!

* Use key icon instead of rorate-cw

* Remove unused button!

Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>
This commit is contained in:
Vault Automation 2025-12-31 08:21:06 -07:00 committed by GitHub
parent 354216300a
commit 44e02fca43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 226 additions and 144 deletions

View file

@ -75,6 +75,7 @@ export default class App extends Application {
services: [{ 'app-router': 'router' }, 'secret-mount-path', 'flash-messages', 'api', 'capabilities'],
externalRoutes: {
secrets: 'vault.cluster.secrets.backends',
secretsGeneralSettingsConfiguration: 'vault.cluster.secrets.backend.configuration.general-settings',
},
},
},

View file

@ -0,0 +1,61 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Header @title={{@model.id}} @icon={{@model.icon}}>
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</:breadcrumbs>
<:actions>
<Hds::Dropdown as |D|>
<D.ToggleButton @text="Manage" @color="secondary" data-test-dropdown="Manage" />
<D.Interactive
@icon="settings"
@route={{if @promptConfig "configure" "configuration"}}
@model={{@model.id}}
data-test-popup-menu="Configure"
>Configure</D.Interactive>
<D.Interactive
{{on "click" (fn (mut this.engineToDisable) @model)}}
@color="critical"
@icon="trash"
data-test-popup-menu="Delete"
>Delete</D.Interactive>
</Hds::Dropdown>
</:actions>
</Page::Header>
{{#if @configRoute}}
<Mount::ConfigureTabs
@configRoute={{@configRoute}}
@displayName="Kubernetes"
@path={{@model.id}}
@externalRoute="secretsGeneralSettingsConfiguration"
/>
{{else}}
<nav class="tabs" aria-label="kubernetes tabs">
<ul>
<li><LinkTo @route="overview" data-test-tab="overview">Overview</LinkTo></li>
<li><LinkTo @route="roles" data-test-tab="roles">Roles</LinkTo></li>
<li><LinkTo @route="configuration" data-test-tab="config">Configuration</LinkTo></li>
</ul>
</nav>
{{/if}}
<Toolbar aria-label="items for managing kubernetes items">
{{#if @filterRoles}}
<ToolbarFilters>
<FilterInputExplicit
@query={{@query}}
@placeholder="Filter roles"
@handleSearch={{@handleSearch}}
@handleInput={{@handleInput}}
@handleKeyDown={{@handleKeyDown}}
/>
</ToolbarFilters>
{{/if}}
<ToolbarActions>
{{yield}}
</ToolbarActions>
</Toolbar>

View file

@ -0,0 +1,57 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import type SecretsEngineResource from 'vault/resources/secrets/engine';
import type RouterService from '@ember/routing/router-service';
import type FlashMessageService from 'vault/services/flash-messages';
import type ApiService from 'vault/services/api';
/**
* @module KubernetesHeader handles the ldap page header.
*
* @example
* <SecretEngine::KubernetesHeader
* @model={{this.model}}
* />
*
* @param {object} secretsEngine - A model contains a ldap secret engine resource.
* @param {object} config - A model contains the configuration of the ldap secret engine.
*/
interface Args {
secretsEngine: SecretsEngineResource;
config: Record<string, unknown>;
}
export default class KubernetesHeader extends Component<Args> {
@service('app-router') declare readonly router: RouterService;
@service declare readonly api: ApiService;
@service declare readonly flashMessages: FlashMessageService;
@tracked engineToDisable: SecretsEngineResource | undefined = undefined;
@task
*disableEngine(engine: SecretsEngineResource) {
const { engineType, id, path } = engine;
try {
yield this.api.sys.mountsDisableSecretsEngine(id);
this.flashMessages.success(`The ${engineType} Secrets Engine at ${path} has been disabled.`);
this.router.transitionTo('vault.cluster.secrets.backends');
} catch (err) {
const { message } = yield this.api.parseError(err);
this.flashMessages.danger(
`There was an error disabling the ${engineType} Secrets Engine at ${path}: ${message}.`
);
} finally {
this.engineToDisable = undefined;
}
}
}

View file

@ -3,13 +3,14 @@
SPDX-License-Identifier: BUSL-1.1
}}
<TabPageHeader @model={{@secretsEngine}} @breadcrumbs={{@breadcrumbs}}>
{{! TODO: VAULT-40884 Add @configRoute argument with value "configuration" so mount config tabs show }}
<KubernetesHeader @model={{@secretsEngine}} @promptConfig={{@promptConfig}} @breadcrumbs={{@breadcrumbs}}>
{{#if @config}}
<ToolbarLink @route="configure" data-test-secret-backend-configure>
Edit configuration
</ToolbarLink>
{{/if}}
</TabPageHeader>
</KubernetesHeader>
{{#if @config}}
{{#if @config.disable_local_ca_jwt}}

View file

@ -3,16 +3,12 @@
SPDX-License-Identifier: BUSL-1.1
}}
<PageHeader as |p|>
<p.top>
{{! TODO: VAULT-40884 Update Page::Header to use Kubernetes::Header and pass @configRoute argument with value "configure" for mount config tabs to show}}
<Page::Header @title="Configure Kubernetes">
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
Configure Kubernetes
</h1>
</p.levelLeft>
</PageHeader>
</:breadcrumbs>
</Page::Header>
<hr class="is-marginless has-background-gray-200" />

View file

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
<TabPageHeader @model={{@secretsEngine}} @breadcrumbs={{@breadcrumbs}} />
<KubernetesHeader @model={{@secretsEngine}} @promptConfig={{@promptConfig}} @breadcrumbs={{@breadcrumbs}} />
{{#if @promptConfig}}
<ConfigCta />

View file

@ -3,23 +3,15 @@
SPDX-License-Identifier: BUSL-1.1
}}
<PageHeader as |p|>
<p.top>
<Page::Header
@title={{if @form.isNew "Create Role" "Edit Role"}}
@description="A role in Vault dictates what will be generated for Kubernetes and what kind of rules will be used to do so. It is not a
Kubernetes role."
>
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
{{if @form.isNew "Create Role" "Edit Role"}}
</h1>
</p.levelLeft>
</PageHeader>
<hr class="is-marginless has-background-gray-200" />
<p class="has-top-margin-m">
A role in Vault dictates what will be generated for Kubernetes and what kind of rules will be used to do so. It is not a
Kubernetes role.
</p>
</:breadcrumbs>
</Page::Header>
<div class="is-flex-row has-top-margin-s">
{{#each this.generationPreferences as |pref|}}

View file

@ -3,41 +3,36 @@
SPDX-License-Identifier: BUSL-1.1
}}
<PageHeader as |p|>
<p.top>
<Page::Header @title={{@role.name}}>
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-header-title>
{{@role.name}}
</h1>
</p.levelLeft>
</PageHeader>
<Toolbar aria-label="menu items for managing role {{@role.name}}">
<ToolbarActions>
{{#if @capabilities.canDelete}}
<ConfirmAction
@buttonText="Delete role"
class="toolbar-button"
@buttonColor="secondary"
@onConfirmAction={{this.delete}}
data-test-delete
/>
<div class="toolbar-separator"></div>
{{/if}}
{{#if @capabilities.canGenerateCreds}}
<ToolbarLink @route="roles.role.credentials" data-test-generate-credentials>
Generate credentials
</ToolbarLink>
{{/if}}
{{#if @capabilities.canUpdate}}
<ToolbarLink @route="roles.role.edit" data-test-edit>
Edit role
</ToolbarLink>
{{/if}}
</ToolbarActions>
</Toolbar>
</:breadcrumbs>
<:actions>
<Hds::Dropdown as |D|>
<D.ToggleButton @text="Manage" @color="secondary" data-test-dropdown="Manage" />
{{#if @capabilities.canUpdate}}
<D.Interactive @icon="edit" @route="roles.role.edit" data-test-popup-menu="Edit role">
Edit role
</D.Interactive>
{{/if}}
{{#if @capabilities.canGenerateCreds}}
<D.Interactive @icon="key" @route="roles.role.credentials" data-test-popup-menu="Generate credentials">
Generate credentials
</D.Interactive>
{{/if}}
{{#if @capabilities.canDelete}}
<D.Interactive
@color="critical"
@icon="trash"
{{on "click" (fn (mut this.showConfirmDeleteModal) true)}}
data-test-popup-menu="Delete role"
>
Delete role
</D.Interactive>
{{/if}}
</Hds::Dropdown>
</:actions>
</Page::Header>
{{#each this.displayFields as |field|}}
<InfoTableRow
@ -66,4 +61,14 @@
<InfoTableRow @label={{key}} @value={{value}} />
{{/each-in}}
</div>
{{/each}}
{{/each}}
{{#if this.showConfirmDeleteModal}}
<ConfirmModal
@color="critical"
@confirmMessage="You will not be able to recover it later."
@confirmTitle="Are you sure?"
@onClose={{fn (mut this.showConfirmDeleteModal) false}}
@onConfirm={{this.delete}}
/>
{{/if}}

View file

@ -6,6 +6,7 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import type { KubernetesRole } from 'vault/vault/secrets/kubernetes';
import type { Breadcrumb } from 'vault/app-types';
@ -34,6 +35,8 @@ export default class RoleDetailsPageComponent extends Component<Args> {
@service declare readonly api: ApiService;
@service declare readonly secretMountPath: SecretMountPath;
@tracked showConfirmDeleteModal = false;
label = (field: string) => {
return (
{

View file

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
<TabPageHeader
<KubernetesHeader
@model={{@secretsEngine}}
@filterRoles={{not @promptConfig}}
@query={{this.query}}
@ -17,7 +17,7 @@
Create role
</ToolbarLink>
{{/unless}}
</TabPageHeader>
</KubernetesHeader>
{{#if @promptConfig}}
<ConfigCta />

View file

@ -1,43 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-header-title>
<Icon @name={{@model.icon}} @size="24" class="has-text-grey-light" />
{{@model.id}}
</h1>
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="kubernetes tabs">
<ul>
<li><LinkTo @route="overview" data-test-tab="overview">Overview</LinkTo></li>
<li><LinkTo @route="roles" data-test-tab="roles">Roles</LinkTo></li>
<li><LinkTo @route="configuration" data-test-tab="config">Configuration</LinkTo></li>
</ul>
</nav>
</div>
<Toolbar aria-label="items for managing kubernetes items">
{{#if @filterRoles}}
<ToolbarFilters>
<FilterInputExplicit
@query={{@query}}
@placeholder="Filter roles"
@handleSearch={{@handleSearch}}
@handleInput={{@handleInput}}
@handleKeyDown={{@handleKeyDown}}
/>
</ToolbarFilters>
{{/if}}
<ToolbarActions>
{{yield}}
</ToolbarActions>
</Toolbar>

View file

@ -17,7 +17,7 @@ export default class KubernetesEngine extends Engine {
Resolver = Resolver;
dependencies = {
services: ['app-router', 'secret-mount-path', 'flash-messages', 'api', 'capabilities'],
externalRoutes: ['secrets'],
externalRoutes: ['secrets', 'secretsGeneralSettingsConfiguration'],
};
}

View file

@ -23,12 +23,14 @@ export default class KubernetesConfigureRoute extends Route {
@service declare readonly secretMountPath: SecretMountPath;
model() {
const { config, configError, secretsEngine } = this.modelFor('application') as KubernetesApplicationModel;
const { config, configError, secretsEngine, promptConfig } = this.modelFor(
'application'
) as KubernetesApplicationModel;
// in case of any error other than 404 we want to display that to the user
if (configError) {
throw configError;
}
return { secretsEngine, config };
return { secretsEngine, config, promptConfig };
}
setupController(controller: RouteController, resolvedModel: KubernetesConfigureModel) {

View file

@ -6,5 +6,6 @@
<Page::Configuration
@config={{this.model.config}}
@secretsEngine={{this.model.secretsEngine}}
@promptConfig={{this.model.promptConfig}}
@breadcrumbs={{this.breadcrumbs}}
/>

View file

@ -3,6 +3,6 @@
SPDX-License-Identifier: BUSL-1.1
}}
<TabPageHeader @model={{this.secretsEngine}} @breadcrumbs={{this.breadcrumbs}} />
<KubernetesHeader @model={{this.secretsEngine}} @breadcrumbs={{this.breadcrumbs}} />
<Page::Error @error={{this.model}} />

View file

@ -95,14 +95,17 @@ module('Acceptance | kubernetes | roles', function (hooks) {
assert.expect(3);
await this.visitRoles();
await click('[data-test-list-item-link]');
await click('[data-test-generate-credentials]');
await click(GENERAL.dropdownToggle('Manage'));
await click(GENERAL.menuItem('Generate credentials'));
this.validateRoute(assert, 'roles.role.credentials', 'Transitions to credentials route');
await click('[data-test-breadcrumbs] li:nth-child(3) a');
await click('[data-test-edit]');
await click(GENERAL.breadcrumbAtIdx(2));
await click(GENERAL.dropdownToggle('Manage'));
await click(GENERAL.menuItem('Edit role'));
this.validateRoute(assert, 'roles.role.edit', 'Transitions to edit route');
await click('[data-test-cancel]');
await click('[data-test-list-item-link]');
await click('[data-test-delete]');
await click(GENERAL.dropdownToggle('Manage'));
await click(GENERAL.menuItem('Delete role'));
await click(GENERAL.confirmButton);
assert
.dom('[data-test-list-item-link]')
@ -113,7 +116,8 @@ module('Acceptance | kubernetes | roles', function (hooks) {
assert.expect(1);
await this.visitRoles();
await click('[data-test-list-item-link]');
await click('[data-test-generate-credentials]');
await click(GENERAL.dropdownToggle('Manage'));
await click(GENERAL.menuItem('Generate credentials'));
await fillIn('[data-test-kubernetes-namespace]', 'test-namespace');
await click('[data-test-generate-credentials-button]');
await click('[data-test-generate-credentials-done]');

View file

@ -10,6 +10,7 @@ export const GENERAL = {
breadcrumb: '[data-test-breadcrumbs] li',
breadcrumbAtIdx: (idx: string) => `[data-test-breadcrumbs] li:nth-child(${idx + 1}) a`,
breadcrumbLink: (label: string) => `[data-test-breadcrumb="${label}"] a`,
currentBreadcrumb: (label: string) => `[data-test-breadcrumb="${label}"]`,
breadcrumbs: '[data-test-breadcrumbs]',
headerContainer: 'header.page-header',
title: '[data-test-page-title]',

View file

@ -12,7 +12,7 @@ import hbs from 'htmlbars-inline-precompile';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
module('Integration | Component | kubernetes | TabPageHeader', function (hooks) {
module('Integration | Component | kubernetes | KubernetesHeader', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kubernetes');
setupMirage(hooks);
@ -37,7 +37,7 @@ module('Integration | Component | kubernetes | TabPageHeader', function (hooks)
test('it should render breadcrumbs', async function (assert) {
await render(
hbs`<TabPageHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} @handleSearch={{this.handleSearch}} @handleInput={{this.handleInput}} @handleKeyDown={{this.handleKeyDown}} />`,
hbs`<KubernetesHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} @handleSearch={{this.handleSearch}} @handleInput={{this.handleInput}} @handleKeyDown={{this.handleKeyDown}} />`,
{
owner: this.engine,
}
@ -51,20 +51,20 @@ module('Integration | Component | kubernetes | TabPageHeader', function (hooks)
test('it should render title', async function (assert) {
await render(
hbs`<TabPageHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} @handleSearch={{this.handleSearch}} @handleInput={{this.handleInput}} @handleKeyDown={{this.handleKeyDown}} />`,
hbs`<KubernetesHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} @handleSearch={{this.handleSearch}} @handleInput={{this.handleInput}} @handleKeyDown={{this.handleKeyDown}} />`,
{
owner: this.engine,
}
);
assert
.dom('[data-test-header-title] svg')
.dom(GENERAL.icon('kubernetes-color'))
.hasClass('hds-icon-kubernetes-color', 'Correct icon renders in title');
assert.dom('[data-test-header-title]').hasText(this.mount, 'Mount path renders in title');
assert.dom(GENERAL.hdsPageHeaderTitle).hasText(this.mount, 'Mount path renders in title');
});
test('it should render tabs', async function (assert) {
await render(
hbs`<TabPageHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} @handleSearch={{this.handleSearch}} @handleInput={{this.handleInput}} @handleKeyDown={{this.handleKeyDown}}/>`,
hbs`<KubernetesHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} @handleSearch={{this.handleSearch}} @handleInput={{this.handleInput}} @handleKeyDown={{this.handleKeyDown}}/>`,
{
owner: this.engine,
}
@ -76,7 +76,7 @@ module('Integration | Component | kubernetes | TabPageHeader', function (hooks)
test('it should render filter for roles', async function (assert) {
await render(
hbs`<TabPageHeader @model={{this.model}} @filterRoles={{true}} @query="test" @breadcrumbs={{this.breadcrumbs}} @handleSearch={{this.handleSearch}} @handleInput={{this.handleInput}} @handleKeyDown={{this.handleKeyDown}} />`,
hbs`<KubernetesHeader @model={{this.model}} @filterRoles={{true}} @query="test" @breadcrumbs={{this.breadcrumbs}} @handleSearch={{this.handleSearch}} @handleInput={{this.handleInput}} @handleKeyDown={{this.handleKeyDown}} />`,
{ owner: this.engine }
);
assert.dom(GENERAL.filterInputExplicit).hasValue('test', 'Filter renders with provided value');
@ -85,9 +85,9 @@ module('Integration | Component | kubernetes | TabPageHeader', function (hooks)
test('it should yield block for toolbar actions', async function (assert) {
await render(
hbs`
<TabPageHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} @handleSearch={{this.handleSearch}} @handleInput={{this.handleInput}} @handleKeyDown={{this.handleKeyDown}}>
<KubernetesHeader @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} @handleSearch={{this.handleSearch}} @handleInput={{this.handleInput}} @handleKeyDown={{this.handleKeyDown}}>
<span data-test-yield>It yields!</span>
</TabPageHeader>
</KubernetesHeader>
`,
{ owner: this.engine }
);

View file

@ -51,8 +51,10 @@ module('Integration | Component | kubernetes | Page::Configuration', function (h
test('it should render tab page header, config cta and mount config', async function (assert) {
await this.renderComponent();
assert.dom('.title svg').hasClass('hds-icon-kubernetes-color', 'Kubernetes icon renders in title');
assert.dom('.title').hasText('kubernetes-test', 'Mount path renders in title');
assert
.dom(GENERAL.icon('kubernetes-color'))
.hasClass('hds-icon-kubernetes-color', 'Kubernetes icon renders in title');
assert.dom(GENERAL.hdsPageHeaderTitle).hasText('kubernetes-test', 'Mount path renders in title');
assert.dom(SES.configure).doesNotExist('Toolbar action does not render when engine is not configured');
assert.dom(GENERAL.emptyStateTitle).hasText('Kubernetes not configured');
assert.dom(GENERAL.emptyStateActions).hasText('Configure Kubernetes');

View file

@ -79,17 +79,15 @@ module('Integration | Component | kubernetes | Page::Role::Details', function (h
test('it should render header with role name and breadcrumbs', async function (assert) {
await this.renderComponent();
assert.dom('[data-test-header-title]').hasText(this.role.name, 'Role name renders in header');
assert.dom(GENERAL.hdsPageHeaderTitle).hasText(this.role.name, 'Role name renders in header');
assert.dom(GENERAL.breadcrumbAtIdx(0)).containsText(this.backend, 'Overview breadcrumb renders');
assert.dom(GENERAL.breadcrumbAtIdx(1)).containsText('Roles', 'Roles breadcrumb renders');
assert
.dom('[data-test-breadcrumbs] li:nth-child(1)')
.containsText(this.backend, 'Overview breadcrumb renders');
assert.dom('[data-test-breadcrumbs] li:nth-child(2) a').containsText('Roles', 'Roles breadcrumb renders');
assert
.dom('[data-test-breadcrumbs] li:nth-child(3)')
.containsText(this.role.name, 'Role breadcrumb renders');
.dom(GENERAL.currentBreadcrumb(this.role.name))
.containsText(this.role.name, 'Role name breadcrumb renders');
});
test('it should render toolbar actions', async function (assert) {
test('it should render role page header dropdown', async function (assert) {
assert.expect(5);
const transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
@ -98,14 +96,13 @@ module('Integration | Component | kubernetes | Page::Role::Details', function (h
.resolves();
await this.renderComponent();
assert.dom('[data-test-delete]').hasText('Delete role', 'Delete action renders');
await click(GENERAL.dropdownToggle('Manage'));
assert.dom(GENERAL.menuItem('Delete role')).hasText('Delete role', 'Delete action renders in dropdown');
assert
.dom('[data-test-generate-credentials]')
.dom(GENERAL.menuItem('Generate credentials'))
.hasText('Generate credentials', 'Generate credentials action renders');
assert.dom('[data-test-edit]').hasText('Edit role', 'Edit action renders');
await click('[data-test-delete]');
assert.dom(GENERAL.menuItem('Edit role')).hasText('Edit role', 'Edit action renders');
await click(GENERAL.menuItem('Delete role'));
await click(GENERAL.confirmButton);
assert.true(deleteStub.calledWith(this.role.name, this.backend), 'Request made to delete role');

View file

@ -55,8 +55,10 @@ module('Integration | Component | kubernetes | Page::Roles', function (hooks) {
test('it should render tab page header and config cta', async function (assert) {
this.promptConfig = true;
await this.renderComponent();
assert.dom('.title svg').hasClass('hds-icon-kubernetes-color', 'Kubernetes icon renders in title');
assert.dom('.title').hasText('kubernetes-test', 'Mount path renders in title');
assert
.dom(GENERAL.icon('kubernetes-color'))
.hasClass('hds-icon-kubernetes-color', 'Kubernetes icon renders in title');
assert.dom(GENERAL.hdsPageHeaderTitle).hasText('kubernetes-test', 'Mount path renders in title');
assert
.dom('[data-test-toolbar-roles-action]')
.doesNotExist('Create role', 'Toolbar action does not render when not configured');