[UI] VAULT-41960 resilience and recovery sidebar (#12056) (#12170)

* VAULT-41960 resiliance and recovery sidebar

* Add breadcrumbs and fix failing tests

* Update link to be external

* Update mode title

* Fix tests

* More tests

* Passing tests!

* Fix sidebar highlight issue

* Update remaining breadcrumbs and fix tests

* Fix recovery tests

* Add resilience and recovery tests

* Sidebar clients rearrangement

* Dasherize and address feedback

* Add copyright headers

* Move to snapshots component for integration test

* Create recovery route file in cluster

* Update ts file and create resilience route

* Remove unused comment

* Add display-nav-item helper

* remove extra nav instantiation

* Add copywrite header

* Address feedback!

* Add more tests!

* Remaining helper tests

* last resilience recovery test

Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>
This commit is contained in:
Vault Automation 2026-02-04 15:06:25 -05:00 committed by GitHub
parent c6170d36a8
commit 0079d343d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1014 additions and 316 deletions

View file

@ -55,11 +55,19 @@ export default class App extends Application {
{ 'app-router': 'router' },
'store',
'version',
// services needed for tools sidebar component
'permissions',
'current-cluster',
'flags',
'-portal',
'control-group',
],
externalRoutes: {
replication: 'vault.cluster.replication.index',
vault: 'vault.cluster',
recoverySnapshots: 'vault.cluster.recovery.snapshots',
settingsSeal: 'vault.cluster.settings.seal',
replicationMode: 'vault.cluster.replication.mode',
},
},
},

View file

@ -4,46 +4,64 @@
}}
<Recovery::Page::Header
@title="Secrets Recovery"
@title="Secrets recovery"
@subtitle="Recover lost or deleted data from a raft snapshot. Supported data includes KV v1 and Cubbyhole secrets or Database static roles."
@breadcrumbs={{@breadcrumbs}}
/>
{{#if @model.showCommunityMessage}}
<EmptyState
@title="Secrets Recovery is an enterprise feature"
@icon="sync-reverse"
@message="Secrets Recovery allows you to restore accidentally deleted or lost secrets from a snapshot. The snapshots can be provided via upload or loaded from external storage."
>
<Hds::Button
@text="Learn more about upgrading"
@color="tertiary"
@icon="docs-link"
@iconPosition="trailing"
@href={{doc-link "/vault/docs/enterprise"}}
@isHrefExternal={{true}}
/>
</EmptyState>
{{else if (not @model.snapshots)}}
{{! Currently, only a single snapshot is supported and the UI automatically redirects users to "recovery.snapshots.snapshot.manage" if one exists.
In the future, this may change to support multiple loaded snapshots and a LIST view will be built then. }}
{{#let (get this.emptyStateDetails this.state) as |d|}}
<EmptyState @title={{d.title}} @icon={{d.icon}} @message={{d.message}}>
{{#if @model.snapshots.showRaftStorageMessage}}
<hr class="has-background-gray-300" />
{{#if (eq this.state this.viewState.ALLOW_UPLOAD)}}
<Hds::Button @text={{d.buttonText}} @color={{d.buttonColor}} @route="vault.cluster.recovery.snapshots.load" />
{{else}}
<Hds::Button
@text={{d.buttonText}}
@color={{d.buttonColor}}
@icon={{d.buttonIcon}}
@iconPosition="trailing"
@route={{d.buttonRoute}}
@query={{if d.buttonRoute (hash namespace="")}}
@href={{doc-link d.buttonHref}}
@isHrefExternal={{if d.buttonHref true}}
/>
{{/if}}
<Hds::ApplicationState as |A|>
<A.Header @title="Raft storage required" @icon="info" data-test-empty-state-title />
<A.Body @text="Raft storage must be used in order to recover data from a snapshot." data-test-empty-state-message />
<A.Footer data-test-empty-state-actions as |F|>
<F.LinkStandalone
@text="Snapshot management"
@icon="docs-link"
@iconPosition="trailing"
@href={{doc-link "/vault/docs/sysadmin/snapshots"}}
@isHrefExternal={{true}}
/>
</A.Footer>
</Hds::ApplicationState>
{{else}}
{{#if @model.showCommunityMessage}}
<EmptyState
@title="Secrets Recovery is an enterprise feature"
@icon="sync-reverse"
@message="Secrets Recovery allows you to restore accidentally deleted or lost secrets from a snapshot. The snapshots can be provided via upload or loaded from external storage."
>
<Hds::Button
@text="Learn more about upgrading"
@color="tertiary"
@icon="docs-link"
@iconPosition="trailing"
@href={{doc-link "/vault/docs/enterprise"}}
@isHrefExternal={{true}}
/>
</EmptyState>
{{/let}}
{{else if (not @model.snapshots)}}
{{! Currently, only a single snapshot is supported and the UI automatically redirects users to "recovery.snapshots.snapshot.manage" if one exists.
In the future, this may change to support multiple loaded snapshots and a LIST view will be built then. }}
{{#let (get this.emptyStateDetails this.state) as |d|}}
<EmptyState @title={{d.title}} @icon={{d.icon}} @message={{d.message}}>
{{#if (eq this.state this.viewState.ALLOW_UPLOAD)}}
<Hds::Button @text={{d.buttonText}} @color={{d.buttonColor}} @route="vault.cluster.recovery.snapshots.load" />
{{else}}
<Hds::Button
@text={{d.buttonText}}
@color={{d.buttonColor}}
@icon={{d.buttonIcon}}
@iconPosition="trailing"
@route={{d.buttonRoute}}
@query={{if d.buttonRoute (hash namespace="")}}
@href={{doc-link d.buttonHref}}
@isHrefExternal={{if d.buttonHref true}}
/>
{{/if}}
</EmptyState>
{{/let}}
{{/if}}
{{/if}}

View file

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Recovery::Page::Header @title="Upload Snapshot" @breadcrumbs={{@breadcrumbs}} />
<Recovery::Page::Header @title="Upload snapshot" @breadcrumbs={{@breadcrumbs}} />
{{#if this.bannerError}}
<div class="has-top-padding-m has-bottom-padding-m">

View file

@ -4,7 +4,7 @@
}}
<Recovery::Page::Header
@title="Secrets Recovery"
@title="Secrets recovery"
@subtitle="Recover lost or deleted data from a raft snapshot. Supported data includes KV v1 and Cubbyhole secrets or Database static roles."
@action={{(hash
text="Recover secrets"

View file

@ -4,7 +4,7 @@
}}
<Recovery::Page::Header
@title="Secrets Recovery"
@title="Secrets recovery"
@subtitle="Recover lost or deleted data from a raft snapshot. Supported data includes KV v1 and Cubbyhole secrets or Database static roles."
@breadcrumbs={{@breadcrumbs}}
/>

View file

@ -100,7 +100,7 @@ export default class ClusterModel extends Model {
return this.rm.mode;
}
get replicationModeForDisplay() {
return this.replicationMode === 'dr' ? 'Disaster Recovery' : 'Performance';
return this.replicationMode === 'dr' ? 'Disaster recovery' : 'Performance';
}
get replicationIsInitializing() {
// a mode of null only happens when a cluster is being initialized

View file

@ -220,6 +220,7 @@ Router.map(function () {
this.route('show', { path: '/:policy_name' });
this.route('edit', { path: '/:policy_name/edit' });
});
this.route('resilience-recovery');
this.route('replication-dr-promote', function () {
this.route('details');
});

View file

@ -38,7 +38,7 @@ export default class RecoverySnapshotsRoute extends Route {
}
redirect(model: SnapshotsRouteModel) {
if (model.snapshots.length === 1) {
if (Array.isArray(model.snapshots) && model.snapshots.length === 1) {
const snapshot_id = model.snapshots[0];
this.router.transitionTo('vault.cluster.recovery.snapshots.snapshot.manage', snapshot_id);
}
@ -59,6 +59,13 @@ export default class RecoverySnapshotsRoute extends Route {
if (error.status === 404) {
return [];
}
if (error.message === 'raft storage is not in use') {
return {
showRaftStorageMessage: true,
};
}
throw {
httpStatus: error.status,
...error,

View file

@ -29,7 +29,7 @@ export default class RecoverySnapshotsIndexRoute extends Route {
controller.breadcrumbs = [
{ label: 'Vault', route: 'vault.cluster.dashboard', icon: 'vault' },
{ label: 'Secrets Recovery' },
{ label: 'Secrets recovery' },
];
}
}

View file

@ -59,7 +59,8 @@ export default class RecoverySnapshotsLoadRoute extends Route {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'Secrets Recovery', route: 'vault.cluster.recovery.snapshots' },
{ label: 'Vault', route: 'vault.cluster.dashboard' },
{ label: 'Secrets recovery', route: 'vault.cluster.recovery.snapshots' },
{ label: 'Upload' },
];
}

View file

@ -26,7 +26,8 @@ export default class RecoverySnapshotsSnapshotDetailsRoute extends Route {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'Secrets Recovery', route: 'vault.cluster.recovery.snapshots' },
{ label: 'Vault', route: 'vault.cluster.dashboard' },
{ label: 'Secrets recovery', route: 'vault.cluster.recovery.snapshots' },
{ label: 'Details' },
];
}

View file

@ -58,7 +58,7 @@ export default class RecoverySnapshotsSnapshotManageRoute extends Route {
controller.breadcrumbs = [
{ label: 'Vault', route: 'vault.cluster.dashboard', icon: 'vault' },
{ label: 'Secrets Recovery' },
{ label: 'Secrets recovery' },
];
}
}

View file

@ -0,0 +1,28 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import RouterService from '@ember/routing/router-service';
import { service } from '@ember/service';
import { computeNavBar, RouteName } from 'core/helpers/display-nav-item';
import type CurrentClusterService from 'vault/services/current-cluster';
import type ClusterModel from 'vault/models/cluster';
export default class ResilienceRecoveryRoute extends Route {
@service declare readonly router: RouterService;
@service declare readonly currentCluster: CurrentClusterService;
beforeModel() {
const cluster = this.currentCluster.cluster as ClusterModel | null;
if (computeNavBar(this, RouteName.SECRETS_RECOVERY)) {
this.router.replaceWith('vault.cluster.recovery.snapshots');
} else if (computeNavBar(this, RouteName.SEAL)) {
this.router.replaceWith('vault.cluster.settings.seal', cluster?.name);
} else if (computeNavBar(this, RouteName.REPLICATION)) {
this.router.replaceWith('vault.cluster.replication.index');
}
}
}

View file

@ -0,0 +1,8 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Sidebar::Nav::ResilienceAndRecovery />
{{outlet}}

View file

@ -3,32 +3,4 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Recovery::Page::Header
@title="Secrets Recovery"
@subtitle="Recover lost or deleted data from a raft snapshot. Supported data includes KV v1 and Cubbyhole secrets or Database static roles."
@breadcrumbs={{array
(hash label="Vault" route="vault.cluster.dashboard" icon="vault")
(hash label="Secrets Recovery" current="true")
}}
/>
<hr class="has-background-gray-300" />
{{#if (eq this.model.message "raft storage is not in use")}}
<Hds::ApplicationState @align="center" class="top-padding-32" as |A|>
<A.Header @title="Raft storage required" @icon="info" data-test-empty-state-title />
<A.Body @text="Raft storage must be used in order to recover data from a snapshot." data-test-empty-state-message />
<A.Footer data-test-empty-state-actions as |F|>
<F.LinkStandalone
@text="Snapshot management"
@icon="docs-link"
@iconPosition="trailing"
@href={{doc-link "/vault/docs/sysadmin/snapshots"}}
@isHrefExternal={{true}}
/>
</A.Footer>
</Hds::ApplicationState>
{{else}}
<Page::Error @error={{this.model}} />
{{/if}}
<Page::Error @error={{this.model}} />

View file

@ -2,8 +2,15 @@
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Sidebar::Nav::ResilienceAndRecovery />
<Page::Header @title="Seal this Vault" />
<Page::Header @title="Seal Vault">
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array (hash label="Vault" route="vault.cluster.dashboard" icon="vault") (hash label="Seal Vault")}}
/>
</:breadcrumbs>
</Page::Header>
{{#if this.model.seal.canUpdate}}
<SealAction @onSeal={{action "seal"}} />

View file

@ -10,7 +10,7 @@
</h3>
<p class="has-top-padding-s" data-test-promote-description>
Promote this cluster to a
{{this.model.replicationModeForDisplay}}
{{lowercase this.model.replicationModeForDisplay}}
primary
</p>
</div>

View file

@ -5,11 +5,11 @@
<Page::Header @title={{@title}}>
<:breadcrumbs>
{{#if (not (or @isSummaryDashboard @isSecondary))}}
{{#unless @isSecondary}}
<Page::Breadcrumbs
@breadcrumbs={{array (hash label="Replication" route="vault.cluster.replication.index") (hash label=@title)}}
@breadcrumbs={{array (hash label="Vault" route="vault" icon="vault" linkExternal=true) (hash label=@title)}}
/>
{{/if}}
{{/unless}}
</:breadcrumbs>
<:badges>
{{#if @data.anyReplicationEnabled}}

View file

@ -23,7 +23,7 @@ import { getOwner } from '@ember/owner';
*/
const MODE = {
dr: 'Disaster Recovery',
dr: 'Disaster recovery',
performance: 'Performance',
};
@ -74,7 +74,7 @@ export default class ReplicationPage extends Component {
get formattedReplicationMode() {
// dr or performance 🤯
if (this.isSummaryDashboard) {
return 'Disaster Recovery & Performance';
return 'Disaster recovery and performance';
}
const mode = this.args.model.replicationMode;
return MODE[mode];

View file

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::AppSideNav::Portal @ariaLabel="Client Count Navigation Links" data-test-sidebar-nav-panel="Client Count" as |Nav|>
<Hds::AppSideNav::Portal @ariaLabel="Client count Navigation Links" data-test-sidebar-nav-panel="Client count" as |Nav|>
<Nav.BackLink
@route="vault.cluster"
@current-when={{false}}
@ -12,7 +12,7 @@
data-test-sidebar-nav-link="Back to main navigation"
/>
<Nav.Title data-test-sidebar-nav-heading="Client Count">Client Count</Nav.Title>
<Nav.Title data-test-sidebar-nav-heading="Client count">Client count</Nav.Title>
<Nav.Link
@route="vault.cluster.clients.counts.overview"

View file

@ -16,14 +16,16 @@
data-test-sidebar-nav-link="Secrets Sync"
/>
{{/if}}
{{#unless (or this.cluster.dr.isSecondary this.flags.isHvdManaged)}}
{{#if (display-nav-item this.navSection.resilienceAndRecovery)}}
<Nav.Link
@route="vault.cluster.recovery.snapshots"
@text="Secrets Recovery"
@badge={{if this.version.isCommunity "Enterprise" ""}}
data-test-sidebar-nav-link="Secrets Recovery"
@route="vault.cluster.resilience-recovery"
@text="Resilience and recovery"
@hasSubItems={{true}}
data-test-sidebar-nav-link="Resilience and recovery"
/>
{{/unless}}
{{/if}}
{{#if (or (has-permission "access") (has-permission "policies"))}}
<Nav.Link
@route={{this.accessRoute}}
@ -44,27 +46,12 @@
{{/if}}
{{#if
(or
(and this.isRootNamespace (has-permission "status" routeParams=(array "replication" "raft" "license" "seal")))
(and this.isRootNamespace (has-permission "status" routeParams="raft"))
(and (has-permission "clients" routeParams="activity") (not this.hasChrootNamespace))
)
}}
<Nav.Title data-test-sidebar-nav-heading="Monitoring">Monitoring</Nav.Title>
{{/if}}
{{#if
(and
this.version.isEnterprise
this.isRootNamespace
(not this.cluster.replicationRedacted)
(has-permission "status" routeParams="replication")
)
}}
<Nav.Link
@route="vault.cluster.replication.index"
@text="Replication"
data-test-sidebar-nav-link="Replication"
@hasSubItems={{true}}
/>
{{/if}}
{{#if (and this.cluster.usingRaft this.isRootNamespace (has-permission "status" routeParams="raft"))}}
<Nav.Link
@route="vault.cluster.storage"
@ -73,32 +60,7 @@
data-test-sidebar-nav-link="Raft Storage"
/>
{{/if}}
{{#if
(and
(has-permission "clients" routeParams="activity")
(not this.cluster.dr.isSecondary)
(not this.hasChrootNamespace)
(not this.version.hasPKIOnly)
)
}}
<Nav.Link
@route="vault.cluster.clients"
@text="Client Count"
@hasSubItems={{true}}
data-test-sidebar-nav-link="Client Count"
/>
{{/if}}
{{#if
(or
this.canAccessVaultUsageDashboard
(and
this.version.features
this.isRootNamespace
(has-permission "status" routeParams="license")
(not this.cluster.dr.isSecondary)
)
)
}}
{{#if (display-nav-item this.navSection.reporting)}}
<Nav.Link
@route={{if this.canAccessVaultUsageDashboard "vault.cluster.usage-reporting" "vault.cluster.license"}}
@text="Reporting"
@ -106,12 +68,12 @@
@hasSubItems={{true}}
/>
{{/if}}
{{#if (and this.isRootNamespace (has-permission "status" routeParams="seal") (not this.cluster.dr.isSecondary))}}
{{#if (display-nav-item this.navSection.clientCount)}}
<Nav.Link
@route="vault.cluster.settings.seal"
@model={{this.cluster.name}}
@text="Seal Vault"
data-test-sidebar-nav-link="Seal Vault"
@route="vault.cluster.clients"
@text="Client count"
@hasSubItems={{true}}
data-test-sidebar-nav-link="Client count"
/>
{{/if}}
</Hds::AppSideNav::Portal>

View file

@ -5,15 +5,21 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { NavSection } from 'core/helpers/display-nav-item';
export default class SidebarNavClusterComponent extends Component {
@service currentCluster;
@service flags;
@service version;
@service auth;
@service namespace;
@service permissions;
navSection = {
resilienceAndRecovery: NavSection.RESILIENCE_AND_RECOVERY,
reporting: NavSection.REPORTING,
clientCount: NavSection.CLIENT_COUNT,
};
get cluster() {
return this.currentCluster.cluster;
}
@ -27,22 +33,6 @@ export default class SidebarNavClusterComponent extends Component {
return this.namespace.inRootNamespace && !this.hasChrootNamespace;
}
get canAccessVaultUsageDashboard() {
/*
A user can access Vault Usage if they satisfy the following conditions:
1) They have access to sys/v1/utilization-report endpoint
2) They are either
a) enterprise cluster and root namespace
b) hvd cluster and /admin namespace
*/
const hasPermission = this.permissions.hasNavPermission('monitoring');
const isEnterprise = this.version.isEnterprise;
const isCorrectNamespace = this.isRootNamespace || this.namespace.inHvdAdminNamespace;
return hasPermission && isEnterprise && isCorrectNamespace;
}
get showSecretsSync() {
// always show for HVD managed clusters
if (this.flags.isHvdManaged) return true;

View file

@ -13,17 +13,10 @@
/>
<Nav.Title data-test-sidebar-nav-heading="Reporting">Reporting</Nav.Title>
{{#if this.canAccessVaultUsageDashboard}}
{{#if (display-nav-item this.routeName.vaultUsage)}}
<Nav.Link @route="vault.cluster.usage-reporting" @text="Vault usage" data-test-sidebar-nav-link="Vault usage" />
{{/if}}
{{#if
(and
this.version.features
this.isRootNamespace
(has-permission "status" routeParams="license")
(not this.cluster.dr.isSecondary)
)
}}
{{#if (display-nav-item this.routeName.license)}}
<Nav.Link
@route="vault.cluster.license"
@model={{this.cluster.name}}

View file

@ -5,12 +5,10 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { RouteName } from 'core/helpers/display-nav-item';
import type CurrentClusterService from 'vault/services/current-cluster';
import type VersionService from 'vault/services/version';
import type NamespaceService from 'vault/services/namespace';
import type ClusterModel from 'vault/models/cluster';
import type PermissionsService from 'vault/services/permissions';
interface Args {
isEngine?: boolean;
@ -18,36 +16,13 @@ interface Args {
export default class SidebarNavReportingComponent extends Component<Args> {
@service declare readonly currentCluster: CurrentClusterService;
@service declare readonly version: VersionService;
@service declare readonly namespace: NamespaceService;
@service declare readonly permissions: PermissionsService;
routeName = {
vaultUsage: RouteName.VAULT_USAGE,
license: RouteName.LICENSE,
};
get cluster() {
return this.currentCluster.cluster as ClusterModel | null;
}
get hasChrootNamespace() {
return this.cluster?.hasChrootNamespace;
}
get isRootNamespace() {
// should only return true if we're in the true root namespace
return this.namespace.inRootNamespace && !this.hasChrootNamespace;
}
get canAccessVaultUsageDashboard() {
/*
A user can access Vault Usage if they satisfy the following conditions:
1) They have access to sys/v1/utilization-report endpoint
2) They are either
a) enterprise cluster and root namespace
b) hvd cluster and /admin namespace
*/
const hasPermission = this.permissions.hasNavPermission('monitoring');
const isEnterprise = this.version.isEnterprise;
const isCorrectNamespace = this.isRootNamespace || this.namespace.inHvdAdminNamespace;
return hasPermission && isEnterprise && isCorrectNamespace;
}
}

View file

@ -0,0 +1,70 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::AppSideNav::Portal
@ariaLabel="Resilience and recovery Navigation Links"
data-test-sidebar-nav-panel="Resilience and recovery"
as |Nav|
>
<Nav.BackLink
@route={{if @isEngine "vault" "vault.cluster"}}
@isRouteExternal={{@isEngine}}
@current-when={{false}}
@icon="arrow-left"
@text="Back to main navigation"
data-test-sidebar-nav-link="Back to main navigation"
/>
{{#if (or (display-nav-item this.routeName.secretsRecovery) (display-nav-item this.routeName.seal))}}
<Nav.Title data-test-sidebar-nav-heading="Resilience and recovery">Resilience and recovery</Nav.Title>
{{/if}}
{{#if (display-nav-item this.routeName.secretsRecovery)}}
<Nav.Link
@route={{if @isEngine "recoverySnapshots" "vault.cluster.recovery.snapshots"}}
@isRouteExternal={{@isEngine}}
@text="Secrets recovery"
@badge={{if this.version.isCommunity "Enterprise" ""}}
data-test-sidebar-nav-link="Secrets recovery"
/>
{{/if}}
{{#if (display-nav-item this.routeName.seal)}}
<Nav.Link
@route={{if @isEngine "settingsSeal" "vault.cluster.settings.seal"}}
@isRouteExternal={{@isEngine}}
@model={{this.cluster.name}}
@text="Seal Vault"
data-test-sidebar-nav-link="Seal Vault"
/>
{{/if}}
{{#if (display-nav-item this.routeName.replication)}}
<Nav.Title data-test-sidebar-nav-heading="Replication">Replication</Nav.Title>
<Nav.Link
@route={{if @isEngine "replication" "vault.cluster.replication.index"}}
@isRouteExternal={{@isEngine}}
@text="Overview"
data-test-sidebar-nav-link="Overview"
/>
{{#if (not-eq this.cluster.mode "unsupported")}}
{{#if (has-feature "DR Replication")}}
<Nav.Link
@route={{if @isEngine "replicationMode" "vault.cluster.replication.mode"}}
@isRouteExternal={{@isEngine}}
@model="dr"
@text="Disaster recovery"
data-test-sidebar-nav-link="Disaster recovery"
/>
{{/if}}
{{#if (has-feature "Performance Replication")}}
<Nav.Link
@route={{if @isEngine "replicationMode" "vault.cluster.replication.mode"}}
@isRouteExternal={{@isEngine}}
@model="performance"
@text="Performance replication"
data-test-sidebar-nav-link="Performance replication"
/>
{{/if}}
{{/if}}
{{/if}}
</Hds::AppSideNav::Portal>

View file

@ -0,0 +1,31 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { RouteName } from 'core/helpers/display-nav-item';
import type CurrentClusterService from 'vault/services/current-cluster';
import type VersionService from 'vault/services/version';
import type ClusterModel from 'vault/models/cluster';
interface Args {
isEngine?: boolean;
}
export default class SidebarNavResilienceAndRecoveryComponent extends Component<Args> {
@service declare readonly currentCluster: CurrentClusterService;
@service declare readonly version: VersionService;
routeName = {
secretsRecovery: RouteName.SECRETS_RECOVERY,
seal: RouteName.SEAL,
replication: RouteName.REPLICATION,
};
get cluster() {
return this.currentCluster.cluster as ClusterModel | null;
}
}

View file

@ -3,7 +3,13 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Header @title={{this.title}} />
<Page::Header @title={{this.title}}>
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array (hash label="Vault" route="vault.cluster.dashboard" icon="vault") (hash label=this.title)}}
/>
</:breadcrumbs>
</Page::Header>
<EmptyState
@title="Upgrade to use {{this.featureName}}"

View file

@ -0,0 +1,145 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Helper from '@ember/component/helper';
import { service } from '@ember/service';
import { getOwner, setOwner } from '@ember/owner';
import type CurrentClusterService from 'vault/services/current-cluster';
import type VersionService from 'vault/services/version';
import type NamespaceService from 'vault/services/namespace';
import type ClusterModel from 'vault/models/cluster';
import type PermissionsService from 'vault/services/permissions';
import type FlagsService from 'vault/services/flags';
export enum RouteName {
SECRETS_RECOVERY = 'secrets-recovery',
SEAL = 'seal',
REPLICATION = 'replication',
VAULT_USAGE = 'vault-usage',
LICENSE = 'license',
}
export enum NavSection {
RESILIENCE_AND_RECOVERY = 'resilience-and-recovery',
REPORTING = 'reporting',
CLIENT_COUNT = 'client-count',
}
export default class NavBar extends Helper {
@service declare readonly currentCluster: CurrentClusterService;
@service declare readonly version: VersionService;
@service declare readonly namespace: NamespaceService;
@service declare readonly permissions: PermissionsService;
@service declare readonly flags: FlagsService;
compute([navItem]: string[]) {
const { SECRETS_RECOVERY, SEAL, REPLICATION, VAULT_USAGE, LICENSE } = RouteName;
const { RESILIENCE_AND_RECOVERY, REPORTING, CLIENT_COUNT } = NavSection;
switch (navItem) {
// client count nav items
case CLIENT_COUNT:
return this.supportsClientCount;
// reporting nav items
case VAULT_USAGE:
return this.canAccessVaultUsageDashboard;
case LICENSE:
return this.supportsLicense;
case REPORTING:
return this.canAccessVaultUsageDashboard || this.supportsLicense;
// resilience and recovery nav items
case SECRETS_RECOVERY:
return this.supportsSnapshots;
case SEAL:
return this.canSeal;
case REPLICATION:
return this.supportsReplication;
case RESILIENCE_AND_RECOVERY:
return this.supportsSnapshots || this.canSeal || this.supportsReplication;
default:
return true;
}
}
get cluster() {
return this.currentCluster.cluster as ClusterModel | null;
}
get hasChrootNamespace() {
return this.cluster?.hasChrootNamespace;
}
get isRootNamespace() {
// should only return true if we're in the true root namespace
return this.namespace.inRootNamespace && !this.hasChrootNamespace;
}
get supportsReplication() {
return (
this.version.isEnterprise &&
this.isRootNamespace &&
!this.cluster?.replicationRedacted &&
this.permissions.hasNavPermission('status', 'replication')
);
}
get canSeal() {
return (
this.isRootNamespace &&
this.permissions.hasNavPermission('status', 'seal') &&
!this.cluster?.dr?.isSecondary
);
}
get supportsSnapshots() {
return (this.cluster && !this.cluster?.dr?.isSecondary) || !this.flags.isHvdManaged;
}
get canAccessVaultUsageDashboard() {
/*
A user can access Vault Usage if they satisfy the following conditions:
1) They have access to sys/v1/utilization-report endpoint
2) They are either
a) enterprise cluster and root namespace
b) hvd cluster and /admin namespace
*/
const hasPermission = this.permissions.hasNavPermission('monitoring');
const isEnterprise = this.version.isEnterprise;
const isCorrectNamespace = this.isRootNamespace || this.namespace.inHvdAdminNamespace;
return hasPermission && isEnterprise && isCorrectNamespace;
}
get supportsLicense() {
return (
this?.version?.features &&
this.isRootNamespace &&
this.permissions.hasNavPermission('status', 'license') &&
!this.cluster?.dr?.isSecondary
);
}
get supportsClientCount() {
return (
this.permissions.hasNavPermission('clients', 'activity') &&
!this.cluster?.dr?.isSecondary &&
!this.hasChrootNamespace &&
!this.version.hasPKIOnly
);
}
}
export function computeNavBar(context: object, navItem: string): boolean {
const navBar = new NavBar();
const owner = getOwner(context);
if (owner) {
setOwner(navBar, owner);
return navBar.compute([navItem]);
}
return true; // when in doubt
}

View file

@ -0,0 +1,6 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/components/sidebar/nav/resilience-and-recovery';

View file

@ -0,0 +1,6 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/helpers/display-nav-item';

View file

@ -4,13 +4,19 @@
}}
{{#if @replicationDisabled}}
<Page::Header @title={{this.title}} />
<Page::Header @title={{this.title}}>
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array (hash label="Vault" route="vault" icon="vault" linkExternal=true) (hash label=this.title)}}
/>
</:breadcrumbs>
</Page::Header>
<div class="box is-sideless is-fullwidth is-marginless">
{{#if (eq @replicationMode "dr")}}
<h2 class="title is-flex-center is-5 is-marginless">
<Icon @size="24" @name="replication-direct" />
Disaster Recovery (DR) Replication
Disaster recovery (DR) replication
</h2>
<p class="help has-text-grey-dark">
{{replication-mode-description "dr"}}
@ -18,7 +24,7 @@
{{else if (eq @replicationMode "performance")}}
<h2 class="title is-flex-center is-5 is-marginless">
<Icon @size="24" @name="replication-perf" />
Performance Replication
Performance replication
</h2>
{{#if (has-feature "Performance Replication")}}
<p class="help has-text-grey-dark">
@ -26,7 +32,7 @@
</p>
{{else}}
<p class="help has-text-grey-dark">
Performance Replication is a feature of Vault Enterprise Premium
Performance replication is a feature of Vault Enterprise Premium
</p>
{{/if}}
{{/if}}

View file

@ -24,13 +24,13 @@ import Component from '@glimmer/component';
export default class PageModeIndex extends Component {
get title() {
if (this.args.replicationMode === 'dr') {
return 'Enable Disaster Recovery Replication';
return 'Enable disaster recovery replication';
}
if (this.args.replicationMode === 'performance') {
return 'Enable Performance Replication';
return 'Enable performance replication';
}
// should never get here, but have safe fallback just in case
return 'Enable Replication';
return 'Enable replication';
}
canEnable = (type) => {

View file

@ -23,9 +23,14 @@ const Eng = Engine.extend({
'app-router',
'store',
'version',
// services needed for tools sidebar component
'permissions',
'current-cluster',
'flags',
'-portal',
'control-group',
],
externalRoutes: ['replication', 'vault'],
externalRoutes: ['replication', 'vault', 'recoverySnapshots', 'settingsSeal', 'replicationMode'],
},
});

View file

@ -3,32 +3,6 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::AppSideNav::Portal @ariaLabel="Replication Navigation Links" data-test-sidebar-nav-panel="Replication" as |Nav|>
<Nav.BackLink
@route="vault"
@current-when={{false}}
@isRouteExternal={{true}}
@icon="arrow-left"
@text="Back to main navigation"
data-test-sidebar-nav-link="Back to main navigation"
/>
<Nav.Title data-test-sidebar-nav-heading="Replication">Replication</Nav.Title>
<Nav.Link
@route="replication"
@current-when="replication"
@isRouteExternal={{true}}
@text="Overview"
data-test-sidebar-nav-link="Overview"
/>
{{#if (not-eq this.model.mode "unsupported")}}
{{#if (has-feature "DR Replication")}}
<Nav.Link @route="mode" @model="dr" @text="Disaster Recovery" data-test-sidebar-nav-link="Disaster Recovery" />
{{/if}}
{{#if (has-feature "Performance Replication")}}
<Nav.Link @route="mode" @model="performance" @text="Performance" data-test-sidebar-nav-link="Performance" />
{{/if}}
{{/if}}
</Hds::AppSideNav::Portal>
<Sidebar::Nav::ResilienceAndRecovery @isEngine={{true}} />
{{outlet}}

View file

@ -7,13 +7,31 @@
<div class="container is-widescreen">
{{#if (eq this.model.mode "unsupported")}}
{{! Replication is unsupported in non-enterprise or when using non-transactional storage (eg inmem) }}
<Page::Header @title="Replication unsupported" />
<Page::Header @title="Replication unsupported">
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array
(hash label="Vault" route="vault" icon="vault" linkExternal=true)
(hash label="Replication unsupported")
}}
/>
</:breadcrumbs>
</Page::Header>
<EmptyState @title="The current cluster configuration does not support replication" />
{{else if this.model.replicationIsInitializing}}
<LayoutLoading />
{{else if this.model.allReplicationDisabled}}
<Page::Header @title="Enable Replication" />
<Page::Header @title="Enable replication">
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array
(hash label="Vault" route="vault" icon="vault" linkExternal=true)
(hash label="Enable replication")
}}
/>
</:breadcrumbs>
</Page::Header>
<div class="box is-sideless is-fullwidth is-marginless">
<p class="has-text-grey-dark box is-shadowless is-fullwidth has-slim-padding">
@ -102,7 +120,16 @@
</ReplicationPage>
{{else}}
{{! Renders when at least one mode is not enabled }}
<Page::Header @title="Replication" />
<Page::Header @title="Replication">
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array
(hash label="Vault" route="vault" icon="vault" linkExternal=true)
(hash label="Replication")
}}
/>
</:breadcrumbs>
</Page::Header>
<div class="box is-sideless is-fullwidth is-marginless flex-col">
<ReplicationOverviewMode

View file

@ -13,6 +13,7 @@
<:breadcrumbs>
<Page::Breadcrumbs
@breadcrumbs={{array
(hash label="Vault" route="vault" icon="vault" linkExternal=true)
(hash label="Replication" route="index")
(hash label=this.model.replicationModeForDisplay)
}}

View file

@ -36,7 +36,7 @@ module('Acceptance | chroot-namespace enterprise ui', function (hooks) {
assert.dom(navLink(nav)).exists(`Shows ${nav} nav item in chroot listener`);
});
// Client count is not root-only, but it is hidden for chroot
['Replication', 'Raft Storage', 'License', 'Seal Vault', 'Client Count'].forEach((nav) => {
['Replication', 'Raft Storage', 'License', 'Seal Vault', 'Client count'].forEach((nav) => {
assert.dom(navLink(nav)).doesNotExist(`Does not show ${nav} nav item in chroot listener`);
});
@ -57,7 +57,7 @@ module('Acceptance | chroot-namespace enterprise ui', function (hooks) {
[('Dashboard', 'Secrets Engines', 'Access', 'Operational tools')].forEach((nav) => {
assert.dom(navLink(nav)).exists(`Shows ${nav} nav item for user with default policy`);
});
['Client Count', 'Replication', 'Raft Storage', 'License', 'Seal Vault'].forEach((nav) => {
['Client count', 'Replication', 'Raft Storage', 'License', 'Seal Vault'].forEach((nav) => {
assert.dom(navLink(nav)).doesNotExist(`Does not show ${nav} nav item for user with default policy`);
});
@ -87,7 +87,7 @@ module('Acceptance | chroot-namespace enterprise ui', function (hooks) {
['Dashboard', 'Secrets Engines', 'Access', 'Operational tools'].forEach((nav) => {
assert.dom(navLink(nav)).exists(`Shows ${nav} nav item for user with read access policy`);
});
['Replication', 'Raft Storage', 'License', 'Seal Vault', 'Client Count'].forEach((nav) => {
['Replication', 'Raft Storage', 'License', 'Seal Vault', 'Client count'].forEach((nav) => {
assert.dom(navLink(nav)).doesNotExist(`Does not show ${nav} nav item for user with read access policy`);
});
@ -119,7 +119,7 @@ module('Acceptance | chroot-namespace enterprise ui', function (hooks) {
['Dashboard', 'Secrets Engines', 'Access', 'Operational tools'].forEach((nav) => {
assert.dom(navLink(nav)).exists(`Shows ${nav} nav item`);
});
['Client Count', 'Replication', 'Raft Storage', 'License', 'Seal Vault'].forEach((nav) => {
['Client count', 'Replication', 'Raft Storage', 'License', 'Seal Vault'].forEach((nav) => {
assert.dom(navLink(nav)).doesNotExist(`Does not show ${nav} nav item`);
});
@ -128,7 +128,7 @@ module('Acceptance | chroot-namespace enterprise ui', function (hooks) {
['Dashboard', 'Secrets Engines', 'Access', 'Operational tools'].forEach((nav) => {
assert.dom(navLink(nav)).exists(`Shows ${nav} nav item within child namespace`);
});
['Replication', 'Raft Storage', 'License', 'Seal Vault', 'Client Count'].forEach((nav) => {
['Replication', 'Raft Storage', 'License', 'Seal Vault', 'Client count'].forEach((nav) => {
assert.dom(navLink(nav)).doesNotExist(`Does not show ${nav} nav item within child namespace`);
});

View file

@ -71,7 +71,7 @@ module('Acceptance | clients | counts | client list', function (hooks) {
test('it navigates to client list tab', async function (assert) {
assert.expect(3);
await click(GENERAL.navLink('Client Count'));
await click(GENERAL.navLink('Client count'));
await click(GENERAL.tab('client list'));
assert.strictEqual(currentURL(), '/vault/clients/counts/client-list', 'it navigates to client list tab');
assert.dom(GENERAL.tab('client list')).hasClass('active');

View file

@ -140,7 +140,7 @@ module('Acceptance | Enterprise | replication-secondaries', function (hooks) {
assert
.dom('[data-test-promote-description]')
.hasText(
'Promote this cluster to a Disaster Recovery primary',
'Promote this cluster to a disaster recovery primary',
'shows the correct description for a DR secondary'
);
assert.dom(GENERAL.badge('secondary')).includesText('secondary', 'shows the DR secondary mode badge');

View file

@ -60,20 +60,21 @@ module('Acceptance | Enterprise | replication modes', function (hooks) {
await this.setupMocks(STATUS_DISABLED_RESPONSE);
await visit('/vault/replication');
await assertTitle(assert, 'Enable Replication');
await assertTitle(assert, 'Enable replication');
// Nav links
assert.dom(GENERAL.navLink('Performance')).exists('shows performance link');
assert.dom(GENERAL.navLink('Disaster Recovery')).exists('shows dr link');
assert.dom(GENERAL.navHeading('Replication')).exists('shows replication nav heading');
assert.dom(GENERAL.navLink('Performance replication')).exists('shows performance link');
assert.dom(GENERAL.navLink('Disaster recovery')).exists('shows dr link');
await click(GENERAL.navLink('Performance'));
await click(GENERAL.navLink('Performance replication'));
assert.strictEqual(currentURL(), '/vault/replication/performance', 'it navigates to the correct page');
await settled();
assert.dom(s.enableForm).exists();
await click(GENERAL.navLink('Disaster Recovery'));
await click(GENERAL.navLink('Disaster recovery'));
await assertTitle(assert, 'Enable Disaster Recovery Replication', 'dr');
await assertTitle(assert, 'Enable disaster recovery replication', 'dr');
});
['primary', 'secondary'].forEach((mode) => {
@ -89,16 +90,16 @@ module('Acceptance | Enterprise | replication modes', function (hooks) {
assert.dom(s.detailLink('dr')).hasText('Enable', 'CTA to enable dr');
// Nav links
assert.dom(GENERAL.navLink('Performance')).exists('shows performance link');
assert.dom(GENERAL.navLink('Disaster Recovery')).exists('shows dr link');
assert.dom(GENERAL.navLink('Performance replication')).exists('shows performance link');
assert.dom(GENERAL.navLink('Disaster recovery')).exists('shows dr link');
await click(GENERAL.navLink('Performance'));
await click(GENERAL.navLink('Performance replication'));
assert.strictEqual(currentURL(), `/vault/replication/performance`, `goes to correct URL`);
await waitFor(s.dashboard);
assert.dom(s.dashboard).exists(`it shows the replication dashboard`);
await click(GENERAL.navLink('Disaster Recovery'));
await assertTitle(assert, 'Enable Disaster Recovery Replication', 'dr');
await click(GENERAL.navLink('Disaster recovery'));
await assertTitle(assert, 'Enable disaster recovery replication', 'dr');
assert.dom(s.enableForm).exists('it shows the enable view for dr');
});
});
@ -114,16 +115,16 @@ module('Acceptance | Enterprise | replication modes', function (hooks) {
assert.dom(s.detailLink('dr')).hasText('Details', 'CTA to see dr details');
// Nav links
assert.dom(GENERAL.navLink('Performance')).exists('shows performance link');
assert.dom(GENERAL.navLink('Disaster Recovery')).exists('shows dr link');
assert.dom(GENERAL.navLink('Performance replication')).exists('shows performance link');
assert.dom(GENERAL.navLink('Disaster recovery')).exists('shows dr link');
await click(GENERAL.navLink('Performance'));
await click(GENERAL.navLink('Performance replication'));
assert.strictEqual(currentURL(), `/vault/replication/performance`, `goes to correct URL`);
await waitFor(s.enableForm);
assert.dom(s.enableForm).exists('it shows the enable view for performance');
await click(GENERAL.navLink('Disaster Recovery'));
await assertTitle(assert, 'Disaster Recovery', 'Disaster Recovery');
await click(GENERAL.navLink('Disaster recovery'));
await assertTitle(assert, 'Disaster recovery', 'Disaster recovery');
assert.dom(GENERAL.badge('primary')).exists('shows primary badge for dr');
assert.dom(s.dashboard).exists(`it shows the replication dashboard`);
});
@ -134,17 +135,17 @@ module('Acceptance | Enterprise | replication modes', function (hooks) {
performance: mockReplicationBlock('primary'),
});
await visit('/vault/replication');
await assertTitle(assert, 'Disaster Recovery & Performance', 'Disaster Recovery & Performance');
await assertTitle(assert, 'Disaster recovery and performance', 'Disaster recovery and performance');
assert.dom(GENERAL.badge('primary')).exists('shows primary badge for dr');
assert.dom(s.summaryCard).exists({ count: 2 }, 'shows 2 summary cards');
await click(GENERAL.navLink('Performance'));
await click(GENERAL.navLink('Performance replication'));
await assertTitle(assert, 'Performance', 'Performance');
assert.dom(GENERAL.badge('primary')).exists('shows primary badge for dr');
assert.dom(s.enableForm).doesNotExist();
await click(GENERAL.navLink('Disaster Recovery'));
await assertTitle(assert, 'Disaster Recovery', 'Disaster Recovery');
await click(GENERAL.navLink('Disaster recovery'));
await assertTitle(assert, 'Disaster recovery', 'Disaster recovery');
assert.dom(GENERAL.badge('primary')).exists('shows primary badge for dr');
assert.dom(s.enableForm).doesNotExist();
});

View file

@ -49,7 +49,7 @@ module('Acceptance | Enterprise | replication', function (hooks) {
await settled();
assert
.dom(GENERAL.hdsPageHeaderTitle)
.includesText('Disaster Recovery', 'it displays the replication type correctly');
.includesText('Disaster recovery', 'it displays the replication type correctly');
assert.dom(GENERAL.badge('primary')).includesText('primary', 'it displays the cluster mode correctly');
await addSecondary(secondaryName);
@ -256,7 +256,7 @@ module('Acceptance | Enterprise | replication', function (hooks) {
.doesNotExist(`does not render replication summary card when both modes are not enabled as primary`);
// enable DR primary replication
await click('[data-test-sidebar-nav-link="Disaster Recovery"]');
await click(GENERAL.navLink('Disaster recovery'));
// let the controller set replicationMode in afterModel
await waitFor('[data-test-replication-enable-form]');
await click(GENERAL.submitButton);

View file

@ -5,7 +5,7 @@
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { click, currentURL } from '@ember/test-helpers';
import { click, currentURL, visit } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from '../helpers/general-selectors';
@ -27,26 +27,8 @@ module('Acceptance | Enterprise | sidebar navigation', function (hooks) {
await click(GENERAL.navLink('Secrets Sync'));
assert.strictEqual(currentURL(), '/vault/sync/secrets/overview', 'Sync route renders');
await click(GENERAL.navLink('Replication'));
assert.strictEqual(currentURL(), '/vault/replication', 'Replication route renders');
assert.dom(panel('Replication')).exists(`Replication nav panel renders`);
assert.dom(GENERAL.navLink('Overview')).hasClass('active', 'Overview link is active');
assert.dom(GENERAL.navLink('Performance')).exists('Performance link exists');
assert.dom(GENERAL.navLink('Disaster Recovery')).exists('DR link exists');
await click(GENERAL.navLink('Performance'));
assert.strictEqual(
currentURL(),
'/vault/replication/performance',
'Replication performance route renders'
);
await click(GENERAL.navLink('Disaster Recovery'));
assert.strictEqual(currentURL(), '/vault/replication/dr', 'Replication DR route renders');
await click(GENERAL.navLink('Back to main navigation'));
await click(GENERAL.navLink('Client Count'));
assert.dom(panel('Client Count')).exists('Client Count nav panel renders');
await click(GENERAL.navLink('Client count'));
assert.dom(panel('Client count')).exists('Client count nav panel renders');
assert.dom(GENERAL.navLink('Client Usage')).hasClass('active', 'Client Usage link is active');
assert.strictEqual(currentURL(), '/vault/clients/counts/overview', 'Client counts route renders');
await click(GENERAL.navLink('Back to main navigation'));
@ -109,4 +91,16 @@ module('Acceptance | Enterprise | sidebar navigation', function (hooks) {
await click(GENERAL.navLink('License'));
assert.strictEqual(currentURL(), '/vault/license', 'License route renders');
});
test('it should navigate to Resilience and recovery level > Replication (enterprise)', async function (assert) {
await visit('/vault/replication');
await click(GENERAL.navLink('Performance replication'));
assert.dom(GENERAL.navLink('Performance replication')).exists('Performance link exists');
await click(GENERAL.navLink('Overview'));
assert.strictEqual(currentURL(), '/vault/replication', 'replication route renders');
await click(GENERAL.navLink('Disaster recovery'));
assert.strictEqual(currentURL(), '/vault/replication/dr', 'Replication DR route renders');
await click(GENERAL.navLink('Secrets recovery'));
assert.strictEqual(currentURL(), '/vault/recovery/snapshots', 'snapshots route renders');
});
});

View file

@ -46,7 +46,7 @@ module('Acceptance | recovery | snapshots', function (hooks) {
await visit('/vault/recovery/snapshots');
assert
.dom(`${GENERAL.navLink('Secrets Recovery')} .hds-badge`)
.dom(`${GENERAL.navLink('Secrets recovery')} .hds-badge`)
.hasText('Enterprise', 'side nav link renders "Enterprise" badge');
assert.strictEqual(currentURL(), '/vault/recovery/snapshots');
assert.dom(GENERAL.emptyStateTitle).hasText('Secrets Recovery is an enterprise feature');
@ -94,7 +94,7 @@ module('Acceptance | recovery | snapshots', function (hooks) {
};
});
await click(GENERAL.navLink('Secrets Recovery'));
await click(GENERAL.navLink('Resilience and recovery'));
assert.strictEqual(currentURL(), '/vault/recovery/snapshots/1234/manage');
assert.strictEqual(currentRouteName(), 'vault.cluster.recovery.snapshots.snapshot.manage');
});

View file

@ -8,13 +8,10 @@ import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import replicationHandlers from 'vault/mirage/handlers/replication';
import { click } from '@ember/test-helpers';
import { click, visit } from '@ember/test-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
const SELECTORS = {
navReplication: '[data-test-sidebar-nav-link="Replication"]',
navPerformance: '[data-test-sidebar-nav-link="Performance"]',
navDR: '[data-test-sidebar-nav-link="Disaster Recovery"]',
title: '[data-test-replication-title]',
primaryCluster: `${GENERAL.infoRowValue('primary_cluster_addr')}`,
replicationSet: `${GENERAL.infoRowValue('Replication set')}`,
@ -30,10 +27,11 @@ module('Acceptance | Enterprise | replication navigation', function (hooks) {
});
test('navigate between replication types updates page', async function (assert) {
await click(SELECTORS.navReplication);
assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Disaster Recovery & Performance');
await visit('/vault/replication');
await click(GENERAL.navLink('Overview'));
assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Disaster recovery and performance');
assert.dom(GENERAL.badge('primary')).exists('shows primary badge for dr');
await click(SELECTORS.navPerformance);
await click(GENERAL.navLink('Performance replication'));
// Ensure data is expected for performance
assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Performance');
@ -43,8 +41,8 @@ module('Acceptance | Enterprise | replication navigation', function (hooks) {
assert.dom(SELECTORS.knownSecondariesTitle).hasText('0 Known secondaries');
// Nav to DR and see updated data
await click(SELECTORS.navDR);
assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Disaster Recovery');
await click(GENERAL.navLink('Disaster recovery'));
assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Disaster recovery');
assert.dom(GENERAL.badge('primary')).exists('shows primary badge for dr');
assert.dom(SELECTORS.primaryCluster).hasText('dr-foobar');
assert.dom(SELECTORS.replicationSet).hasText('dr-cluster-id');

View file

@ -0,0 +1,77 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { click, currentRouteName, currentURL } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from '../helpers/general-selectors';
import sinon from 'sinon';
import * as displayNavItem from 'core/helpers/display-nav-item';
module('Acceptance | Enterprise | resilience-recovery', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
this.permissionsStub = sinon.stub(displayNavItem, 'computeNavBar');
await login();
});
hooks.afterEach(function () {
this.permissionsStub.restore();
});
test('it should redirect to recovery snapshots route when replication is disabled', async function (assert) {
this.permissionsStub.callsFake((context, routeName) => {
if (routeName === displayNavItem.RouteName.SECRETS_RECOVERY) {
return true;
}
return false;
});
await click(GENERAL.navLink('Resilience and recovery'));
assert.strictEqual(currentURL(), '/vault/recovery/snapshots', 'snapshots url renders');
assert.strictEqual(
currentRouteName(),
'vault.cluster.recovery.snapshots.index',
'snapshots route renders'
);
});
test('it should redirect to seal route when user has permission and secrets recovery is not supported', async function (assert) {
this.permissionsStub.callsFake((context, routeName) => {
if (routeName === displayNavItem.RouteName.SEAL) {
return true;
}
return false;
});
await click(GENERAL.navLink('Resilience and recovery'));
assert.strictEqual(currentURL(), '/vault/settings/seal', 'seal route renders');
assert.strictEqual(currentRouteName(), 'vault.cluster.settings.seal', 'seal route renders');
});
test('it should redirect to replication route when secrets recovery or seal is not supported', async function (assert) {
this.permissionsStub.callsFake((context, routeName) => {
if (routeName === displayNavItem.RouteName.REPLICATION) {
return true;
}
return false;
});
await click(GENERAL.navLink('Resilience and recovery'));
assert.strictEqual(currentURL(), '/vault/replication', 'replication route renders');
assert.strictEqual(currentRouteName(), 'vault.cluster.replication.index', 'replication route renders');
});
});

View file

@ -40,7 +40,7 @@ module('Acceptance | sidebar navigation', function (hooks) {
});
test('it should link to correct routes at the cluster level', async function (assert) {
assert.expect(9);
assert.expect(8);
assert.dom(panel('Cluster')).exists('Cluster nav panel renders');
@ -59,7 +59,6 @@ module('Acceptance | sidebar navigation', function (hooks) {
const links = [
{ label: 'Raft Storage', route: '/vault/storage/raft' },
{ label: 'Seal Vault', route: '/vault/settings/seal' },
{ label: 'Secrets Engines', route: '/vault/secrets-engines' },
{ label: 'Dashboard', route: '/vault/dashboard' },
];
@ -115,8 +114,8 @@ module('Acceptance | sidebar navigation', function (hooks) {
test('it should link to correct routes at the client counts level', async function (assert) {
assert.expect(7);
await click(link('Client Count'));
assert.dom(panel('Client Count')).exists('Client counts nav panel renders');
await click(link('Client count'));
assert.dom(panel('Client count')).exists('Client counts nav panel renders');
assert.strictEqual(currentURL(), '/vault/clients/counts/overview', 'Top level nav link renders overview');
assert.dom(link('Client Usage')).hasClass('active');
await click(link('Configuration'));

View file

@ -50,7 +50,7 @@ export const disableReplication = async (type, assert) => {
await click('[data-test-disable-replication] button');
const typeDisplay = type === 'dr' ? 'Disaster Recovery' : 'Performance';
const typeDisplay = type === 'dr' ? 'Disaster recovery' : 'Performance';
await fillIn('[data-test-confirmation-modal-input="Disable Replication?"]', typeDisplay);
await click(GENERAL.confirmButton);
await settled(); // eslint-disable-line

View file

@ -43,7 +43,7 @@ module('Integration | Component | replication page/mode-index', function (hooks)
test('it renders correctly when replication disabled', async function (assert) {
await this.renderComponent();
assert.dom(S.title).hasText('Enable Disaster Recovery Replication');
assert.dom(S.title).hasText('Enable disaster recovery replication');
assert.dom(S.enableForm).exists();
assert.dom(S.notAllowed).doesNotExist();
assert.dom(GENERAL.submitButton).exists('Enable button shows by default if no permissions available');
@ -82,7 +82,7 @@ module('Integration | Component | replication page/mode-index', function (hooks)
test('it renders correctly when replication disabled', async function (assert) {
await this.renderComponent();
assert.dom(S.title).hasText('Enable Performance Replication');
assert.dom(S.title).hasText('Enable performance replication');
assert.dom(S.enableForm).exists();
assert.dom(S.notAllowed).doesNotExist();
assert.dom(GENERAL.submitButton).exists('Enable button shows by default if no permissions available');

View file

@ -24,6 +24,23 @@ module('Integration | Component | recovery/snapshots', function (hooks) {
this.renderComponent = () => render(hbs`<Recovery::Page::Snapshots @model={{this.model}}/>`);
});
test('it displays raft empty state if showRaftStorageMessage is passed into model', async function (assert) {
this.model = { snapshots: { showRaftStorageMessage: true } };
await render(hbs`<Recovery::Page::Snapshots @model={{this.model}}/>`);
assert
.dom(GENERAL.emptyStateTitle)
.hasText('Raft storage required ', ' Raft storage required empty state title renders');
assert
.dom(GENERAL.emptyStateMessage)
.hasText(
'Raft storage must be used in order to recover data from a snapshot.',
'Raft storage empty state message'
);
assert.dom(GENERAL.emptyStateActions).hasText('Snapshot management', 'Raft storage empty state action');
});
test('it displays empty state in CE', async function (assert) {
this.model = { snapshots: [], showCommunityMessage: true };

View file

@ -61,7 +61,7 @@ module('Integration | Component | replication-page', function (hooks) {
await render(
hbs`<ReplicationPage @model={{this.model}} as |Page|><Page.header @showTabs={{false}} /></ReplicationPage>`
);
assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Disaster Recovery');
assert.dom(GENERAL.hdsPageHeaderTitle).hasText('Disaster recovery');
this.model.replicationMode = 'performance';
await render(

View file

@ -51,7 +51,10 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
assert
.dom(GENERAL.navLink())
.exists({ count: 3 }, 'Nav links are hidden other than secrets, recovery and dashboard');
.exists(
{ count: 3 },
'Nav links are hidden other than secrets, recovery (nested in Resilience and recovery nav link) and dashboard'
);
assert.dom(GENERAL.navHeading()).exists({ count: 1 }, 'Headings are hidden other than Vault');
});
@ -60,16 +63,14 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
'Dashboard',
'Secrets Engines',
'Secrets Sync',
'Secrets Recovery',
'Access',
'Operational tools',
'Replication',
'Resilience and recovery',
'Reporting',
'Raft Storage',
'Client Count',
'Seal Vault',
'Client count',
];
// do not add PKI-only Secrets feature as it hides Client Count nav link
// do not add PKI-only Secrets feature as it hides Client count nav link
const features = allFeatures().filter((feat) => feat !== 'PKI-only Secrets');
stubFeaturesAndPermissions(this.owner, true, true, features);
await renderComponent();
@ -81,14 +82,7 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
});
test('it should hide enterprise related links in child namespace', async function (assert) {
const links = [
'Disaster Recovery',
'Performance',
'Replication',
'Raft Storage',
'License',
'Seal Vault',
];
const links = ['Raft Storage', 'License'];
this.owner.lookup('service:namespace').set('path', 'foo');
const stubs = stubFeaturesAndPermissions(this.owner, true, true);
stubs.hasNavPermission.callsFake((route) => route !== 'clients');

View file

@ -0,0 +1,91 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { stubFeaturesAndPermissions } from 'vault/tests/helpers/components/sidebar-nav';
import { capitalize } from '@ember/string';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
const renderComponent = () => {
return render(hbs`
<Sidebar::Frame @isVisible={{true}}>
<Sidebar::Nav::ResilienceAndRecovery />
</Sidebar::Frame>
`);
};
module('Integration | Component | sidebar-nav-resilience-and-recovery', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.flags = this.owner.lookup('service:flags');
setRunOptions({
rules: {
// This is an issue with Hds::AppHeader::HomeLink
'aria-prohibited-attr': { enabled: false },
// TODO: fix use Dropdown on user-menu
'nested-interactive': { enabled: false },
},
});
});
test('it should hide links user does not have access to other than secrets recovery', async function (assert) {
await renderComponent();
stubFeaturesAndPermissions(this.owner);
assert
.dom(GENERAL.navLink())
.exists({ count: 2 }, 'Nav links are hidden other than back and recovery link');
});
test('it should render nav headings and links', async function (assert) {
const links = [
'Back to main navigation',
'Secrets recovery',
'Seal Vault',
'Overview',
'Performance replication',
'Disaster recovery',
];
stubFeaturesAndPermissions(this.owner, true);
await renderComponent();
assert.dom(GENERAL.navHeading()).exists({ count: 2 }, 'Correct number of headings render');
assert
.dom(GENERAL.navHeading('Resilience and recovery'))
.hasText('Resilience and recovery', 'Resilience and recovery heading renders');
assert.dom(GENERAL.navHeading('Replication')).hasText('Replication', 'Replication heading renders');
assert.dom(GENERAL.navLink()).exists({ count: links.length }, 'Correct number of links render');
links.forEach((link) => {
const name = capitalize(link);
assert.dom(GENERAL.navLink(name)).hasText(name, `${name} link renders`);
});
});
test('it shows Seal Vault when user is enterprise and in root namespace and has nav permissions', async function (assert) {
stubFeaturesAndPermissions(this.owner, true, true, [], true);
await renderComponent();
assert.dom(GENERAL.navLink('Seal Vault')).exists();
});
test('it does NOT show snapshots when user is in HVD admin namespace', async function (assert) {
const stubs = stubFeaturesAndPermissions(this.owner, true, false, [], false);
stubs.hasNavPermission.callsFake((route) => route === 'snapshots');
this.flags.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
const namespace = this.owner.lookup('service:namespace');
namespace.setNamespace('admin');
await renderComponent();
assert.dom(GENERAL.navLink('Secrets recovery')).doesNotExist();
});
});

View file

@ -0,0 +1,279 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { computeNavBar, NavSection, RouteName } from 'core/helpers/display-nav-item';
import { setupRenderingTest } from 'ember-qunit';
import { module, test } from 'qunit';
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import sinon from 'sinon';
import { ROOT_NAMESPACE } from 'vault/services/namespace';
class PermissionsService extends Service {
@tracked globPaths = null;
@tracked exactPaths = null;
@tracked canViewAll = false;
@tracked chrootNamespace = null;
hasNavPermission(...args) {
const [route, routeParams, requireAll] = args;
void (typeof route === 'string');
void (routeParams === undefined || Array.isArray(routeParams));
void (requireAll === undefined || typeof requireAll === 'boolean');
if (this.canViewAll) return true;
if (this.globPaths || this.exactPaths) return true;
return false;
}
}
module('Unit | Helper | displayNavItem', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:permissions', PermissionsService);
this.permissions = this.owner.lookup('service:permissions');
this.namespace = this.owner.lookup('service:namespace');
this.currentCluster = this.owner.lookup('service:current-cluster');
this.flags = this.owner.lookup('service:flags');
this.version = this.owner.lookup('service:version');
this.permissionsStub = sinon.stub(this.permissions, 'hasNavPermission');
});
hooks.afterEach(function () {
this.permissionsStub.restore();
});
module('client count', function () {
test('it returns true when there are permissions and cluster is not secondary', function (assert) {
this.permissionsStub.returns(true);
this.currentCluster.setCluster({
name: 'cluster-0',
hasChrootNamespace: false,
dr: { isSecondary: false },
});
this.version.features = [];
const supportsClientCount = computeNavBar(this, NavSection.CLIENT_COUNT);
assert.true(supportsClientCount);
});
test('it returns false when there are no permissions and cluster is secondary', function (assert) {
this.permissionsStub.returns(false);
this.currentCluster.setCluster({
hasChrootNamespace: true,
dr: { isSecondary: true },
});
this.version.features = ['PKI-only Secrets'];
const supportsClientCount = computeNavBar(this, NavSection.CLIENT_COUNT);
assert.false(supportsClientCount);
});
});
module('vault usage', function () {
test('it returns true when there are permissions and is enterprise', function (assert) {
this.permissionsStub.returns(true);
this.version.type = 'enterprise';
this.flags.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
const supportsClientCount = computeNavBar(this, RouteName.VAULT_USAGE);
assert.true(supportsClientCount);
});
test('it returns false when there are no permissions', function (assert) {
this.permissionsStub.returns(false);
this.version.type = 'community';
this.flags.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
const supportsClientCount = computeNavBar(this, RouteName.VAULT_USAGE);
assert.false(supportsClientCount);
});
});
module('license', function () {
test('it returns true when there are permissions and is enterprise', function (assert) {
this.permissionsStub.returns(true);
this.version.features = ['Secrets sync'];
this.flags.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
this.namespace.path = ROOT_NAMESPACE;
this.currentCluster.setCluster({
hasChrootNamespace: false,
dr: { isSecondary: false },
});
const supportsClientCount = computeNavBar(this, RouteName.LICENSE);
assert.true(supportsClientCount);
});
test('it returns false when there are no permissions', function (assert) {
this.permissionsStub.returns(false);
this.version.features = ['Secrets sync'];
this.flags.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
const supportsClientCount = computeNavBar(this, RouteName.LICENSE);
assert.false(supportsClientCount);
});
});
module('reporting', function () {
test('it returns true when user can access vault usage', function (assert) {
this.permissionsStub.returns(true);
this.version.type = 'enterprise';
this.flags.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
const supportsClientCount = computeNavBar(this, RouteName.REPORTING);
assert.true(supportsClientCount);
});
test('it returns true when can support license but not vault usage', function (assert) {
this.permissionsStub.returns(true);
this.version.features = ['Secrets sync'];
this.flags.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
this.namespace.path = ROOT_NAMESPACE;
this.currentCluster.setCluster({
hasChrootNamespace: false,
dr: { isSecondary: false },
});
const supportsClientCount = computeNavBar(this, RouteName.REPORTING);
assert.true(supportsClientCount);
});
test('it returns false when user cannot access vault usage or cannot support license', function (assert) {
this.permissionsStub.returns(false);
this.version.features = [];
this.version.type = 'community';
const supportsClientCount = computeNavBar(this, RouteName.LICENSE);
assert.false(supportsClientCount);
});
});
module('secrets recovery', function () {
test('it returns true when dr is not secondary', function (assert) {
this.currentCluster.setCluster({ dr: { isSecondary: false } });
const supportsSecretsRecovery = computeNavBar(this, RouteName.SECRETS_RECOVERY);
assert.true(supportsSecretsRecovery);
});
test('it returns true when flags is not hvd managed and dr is secondary', function (assert) {
this.currentCluster.setCluster({ dr: { isSecondary: true } });
this.flags.featureFlags = [];
const supportsSecretsRecovery = computeNavBar(this, RouteName.SECRETS_RECOVERY);
assert.true(supportsSecretsRecovery);
});
test('it returns false when flags is hvd managed and dr is secondary', function (assert) {
this.currentCluster.setCluster({ dr: { isSecondary: false } });
this.flags.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
const supportsSecretsRecovery = computeNavBar(this, RouteName.SECRETS_RECOVERY);
assert.true(supportsSecretsRecovery);
});
});
module('seal', function () {
test('it returns true when in root namespace and has permissions', function (assert) {
this.namespace.path = ROOT_NAMESPACE;
this.permissionsStub.returns(true);
this.currentCluster.setCluster({ dr: { isSecondary: false } });
const supportsSecretsRecovery = computeNavBar(this, RouteName.SEAL);
assert.true(supportsSecretsRecovery);
});
test('it returns false when flags is hvd managed and dr is secondary', function (assert) {
this.namespace.path = 'child-namespace';
this.permissionsStub.returns(false);
this.currentCluster.setCluster({ dr: { isSecondary: true } });
const supportsSecretsRecovery = computeNavBar(this, RouteName.SEAL);
assert.false(supportsSecretsRecovery);
});
});
module('replication', function () {
test('it returns true when in root namespace, on enterprise, and has permissions', function (assert) {
this.version.type = 'enterprise';
this.namespace.path = ROOT_NAMESPACE;
this.currentCluster.setCluster({
replicationRedacted: false,
hasChrootNamespace: false,
});
this.permissionsStub.returns(true);
const supportsSecretsRecovery = computeNavBar(this, RouteName.REPLICATION);
assert.true(supportsSecretsRecovery);
});
test('it returns false when hasChrootNamespace is true and type is community', function (assert) {
this.version.type = 'community';
this.namespace.path = ROOT_NAMESPACE;
this.currentCluster.setCluster({
replicationRedacted: false,
hasChrootNamespace: true,
});
this.permissionsStub.returns(false);
const supportsSecretsRecovery = computeNavBar(this, RouteName.REPLICATION);
assert.false(supportsSecretsRecovery);
});
});
module('resilience and recovery', function () {
test('it returns true when supports secrets recovery', function (assert) {
this.currentCluster.setCluster({ dr: { isSecondary: false } });
const supportsSecretsRecovery = computeNavBar(this, NavSection.RESILIENCE_AND_RECOVERY);
assert.true(supportsSecretsRecovery);
});
test('it returns true when supports replication but does not support secrets recovery or cannot seal', function (assert) {
this.version.type = 'enterprise';
this.namespace.path = ROOT_NAMESPACE;
this.currentCluster.setCluster({ dr: { isSecondary: true }, replicationRedacted: false });
this.permissionsStub.returns(true);
const supportsSecretsRecovery = computeNavBar(this, NavSection.RESILIENCE_AND_RECOVERY);
assert.true(supportsSecretsRecovery);
});
test('it returns false when does not supports replication, support secrets recovery and cannot seal', function (assert) {
this.version.type = 'community';
this.namespace.path = ROOT_NAMESPACE;
this.currentCluster.setCluster({ dr: { isSecondary: true }, replicationRedacted: true });
this.flags.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
this.permissionsStub.returns(false);
const supportsSecretsRecovery = computeNavBar(this, NavSection.RESILIENCE_AND_RECOVERY);
assert.false(supportsSecretsRecovery);
});
});
});

View file

@ -16,5 +16,5 @@ export default class PermissionsService extends Service {
canViewAll: boolean | null;
permissionsBanner: string | null;
chrootNamespace: string | null | undefined;
hasNavPermission: (string) => boolean;
hasNavPermission: (navItem: string, routeParams?: string | string[], requireAll?: boolean) => boolean;
}