UI: [VAULT-12979] Dashboard Landing Page (#21057)

Co-authored-by: Chelsea Shaw <cshaw@hashicorp.com>
Co-authored-by: clairebontempo@gmail.com <clairebontempo@gmail.com>
Co-authored-by: Angel Garbarino <angel@hashicorp.com>
This commit is contained in:
Kianna
2023-08-24 13:30:45 -07:00
committed by GitHub
parent d06b57e6f8
commit 8835514e76
70 changed files with 2244 additions and 92 deletions

3
changelog/21057.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:feature
**Dashboard UI**: Dashboard is now available in the UI as the new landing page.
```

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import getStorage from 'vault/lib/token-storage';
import timestamp from 'core/utils/timestamp';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
/**
* @module DashboardClientCountCard
* DashboardClientCountCard component are used to display total and new client count information
*
* @example
* ```js
* <Dashboard::ClientCountCard @license={{@model.license}} />
* ```
* @param {object} license - license object passed from the parent
*/
export default class DashboardClientCountCard extends Component {
@service store;
@tracked activityData = null;
@tracked clientConfig = null;
@tracked updatedAt = timestamp.now().toISOString();
constructor() {
super(...arguments);
this.fetchClientActivity.perform();
this.clientConfig = this.store.queryRecord('clients/config', {}).catch(() => {});
}
get currentMonthActivityTotalCount() {
return this.activityData?.byMonth?.lastObject?.new_clients.clients;
}
get licenseStartTime() {
return this.args.license.startTime || getStorage().getItem('vault:ui-inputted-start-date') || null;
}
@task
@waitFor
*fetchClientActivity(e) {
if (e) e.preventDefault();
this.updatedAt = timestamp.now().toISOString();
// only make the network request if we have a start_time
if (!this.licenseStartTime) return {};
try {
this.activityData = yield this.store.queryRecord('clients/activity', {
start_time: { timestamp: this.licenseStartTime },
end_time: { timestamp: this.updatedAt },
});
this.noActivityData = this.activityData.activity.id === 'no-data' ? true : false;
} catch (error) {
this.error = error;
}
}
}

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
/**
* @module DashboardLearnMoreCard
* DashboardLearnMoreCard component are used to display external links
*
* @example
* ```js
* <DashboardLearnMoreCard />
* ```
*/
export default class DashboardLearnMoreCard extends Component {
get learnMoreLinks() {
return [
{
link: '/vault/tutorials/secrets-management',
icon: 'docs-link',
title: 'Secrets Management',
},
{
link: '/vault/tutorials/monitoring',
icon: 'docs-link',
title: 'Monitor & Troubleshooting',
},
{
link: '/vault/tutorials/adp/transform',
icon: 'learn-link',
title: 'Advanced Data Protection: Transform engine',
requiredFeature: 'Transform Secrets Engine',
},
{
link: '/vault/tutorials/secrets-management/pki-engine',
icon: 'learn-link',
title: 'Build your own Certificate Authority (CA)',
},
];
}
}

View File

@@ -0,0 +1,147 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
/**
* @module DashboardQuickActionsCard
* DashboardQuickActionsCard component allows users to see a list of secrets engines filtered by
* kv, pki and database and perform certain actions based on the type of secret engine selected
*
* @example
* ```js
* <Dashboard::QuickActionsCard @secretsEngines={{@model.secretsEngines}} />
* ```
*/
const QUICK_ACTION_ENGINES = ['pki', 'kv', 'database'];
export default class DashboardQuickActionsCard extends Component {
@service router;
@tracked selectedEngine;
@tracked selectedAction;
@tracked paramValue;
get actionOptions() {
switch (this.selectedEngine.type) {
case `kv version ${this.selectedEngine?.version}`:
return ['Find KV secrets'];
case 'database':
return ['Generate credentials for database'];
case 'pki':
return ['Issue certificate', 'View certificate', 'View issuer'];
default:
return [];
}
}
get searchSelectParams() {
switch (this.selectedAction) {
case 'Find KV secrets':
return {
title: 'Secret path',
subText: 'Path of the secret you want to read, including the mount. E.g., secret/data/foo.',
buttonText: 'Read secrets',
// check kv version to figure out which model to use
model: this.selectedEngine.version === 2 ? 'secret-v2' : 'secret',
route: 'vault.cluster.secrets.backend.show',
};
case 'Generate credentials for database':
return {
title: 'Role to use',
buttonText: 'Generate credentials',
model: 'database/role',
route: 'vault.cluster.secrets.backend.credentials',
};
case 'Issue certificate':
return {
title: 'Role to use',
placeholder: 'Type to find a role',
buttonText: 'Issue leaf certificate',
model: 'pki/role',
route: 'vault.cluster.secrets.backend.pki.roles.role.generate',
};
case 'View certificate':
return {
title: 'Certificate serial number',
placeholder: '33:a3:...',
buttonText: 'View certificate',
model: 'pki/certificate/base',
route: 'vault.cluster.secrets.backend.pki.certificates.certificate.details',
};
case 'View issuer':
return {
title: 'Issuer',
placeholder: 'Type issuer name or ID',
buttonText: 'View issuer',
model: 'pki/issuer',
nameKey: 'issuerName',
route: 'vault.cluster.secrets.backend.pki.issuers.issuer.details',
};
default:
return {
placeholder: 'Please select an action above',
buttonText: 'Select an action',
model: '',
};
}
}
get filteredSecretEngines() {
return this.args.secretsEngines.filter((engine) => QUICK_ACTION_ENGINES.includes(engine.type));
}
get mountOptions() {
return this.filteredSecretEngines.map((engine) => {
let { id, type, version } = engine;
if (type === 'kv') type = `kv version ${version}`;
return { name: id, type, id, version };
});
}
@action
handleSearchEngineSelect([selection]) {
this.selectedEngine = selection;
// reset tracked properties
this.selectedAction = null;
this.paramValue = null;
}
@action
setSelectedAction(selectedAction) {
this.selectedAction = selectedAction;
this.paramValue = null;
}
@action
handleActionSelect(val) {
if (Array.isArray(val)) {
this.paramValue = val[0];
} else {
this.paramValue = val;
}
}
@action
navigateToPage() {
let searchSelectParamRoute = this.searchSelectParams.route;
// kv has a special use case where if the paramValue ends in a '/' you should
// link to different route
if (this.selectedEngine.type === 'kv') {
searchSelectParamRoute =
this.paramValue && this.paramValue?.endsWith('/')
? 'vault.cluster.secrets.backend.list'
: 'vault.cluster.secrets.backend.show';
}
this.router.transitionTo(searchSelectParamRoute, this.selectedEngine.id, this.paramValue);
}
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
/**
* @module DashboardSecretsEnginesCard
* DashboardSecretsEnginesCard component are used to display 5 secrets engines to the user.
*
* @example
* ```js
* <DashboardSecretsEnginesCard @secretsEngines={{@model.secretsEngines}} />
* ```
* @param {array} secretsEngines - list of secrets engines
*/
export default class DashboardSecretsEnginesCard extends Component {
get filteredSecretsEngines() {
const filteredEngines = this.args.secretsEngines.filter(
(secretEngine) => secretEngine.shouldIncludeInList
);
return filteredEngines.slice(0, 5);
}
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
/**
* @module DashboardVaultConfigurationCard
* DashboardVaultConfigurationCard component are used to display vault configuration.
*
* @example
* ```js
* <DashboardVaultConfigurationCard @vaultConfiguration={{@model.vaultConfiguration}} />
* ```
* @param {object} vaultConfiguration - object of vault configuration key/values
*/
export default class DashboardSecretsEnginesCard extends Component {
get tlsDisabled() {
const tlsDisableConfig = this.args.vaultConfiguration?.listeners.find((listener) => {
if (listener.config && listener.config.tls_disable) return listener.config.tls_disable;
});
return tlsDisableConfig?.config.tls_disable ? 'Enabled' : 'Disabled';
}
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
/**
* @module DashboardVaultVersionTitle
* DashboardVaultVersionTitle component are use to display the vault version title and the badges
*
* @example
* ```js
* <Dashboard::VaultVersionTitle />
* ```
*/
export default class DashboardVaultVersionTitle extends Component {
@service version;
@service namespace;
get versionHeader() {
return this.version.isEnterprise
? `Vault v${this.version.version.slice(0, this.version.version.indexOf('+'))}`
: `Vault v${this.version.version}`;
}
get namespaceDisplay() {
if (this.namespace.inRootNamespace) return 'root';
const parts = this.namespace.path?.split('/');
return parts[parts.length - 1];
}
}

View File

@@ -44,8 +44,8 @@ export default Component.extend({
window.location.hostname +
(window.location.port ? ':' + window.location.port : '');
if (!this.normalizedNamespace) return `${origin}/ui/vault/secrets`;
if (!this.normalizedNamespace) return `${origin}/ui/vault/dashboard`;
// The full URL/origin is required so that the page is reloaded.
return `${origin}/ui/vault/secrets?namespace=${encodeURIComponent(this.normalizedNamespace)}`;
return `${origin}/ui/vault/dashboard?namespace=${encodeURIComponent(this.normalizedNamespace)}`;
},
});

View File

@@ -11,7 +11,7 @@
<:logo>
<Hds::SideNav::Header::HomeLink
@icon="vault"
@route="vault.cluster"
@route="vault.cluster.dashboard"
@model={{this.currentCluster.cluster.name}}
@ariaLabel="home link"
data-test-sidebar-logo

View File

@@ -6,6 +6,7 @@
<Hds::SideNav::Portal @ariaLabel="Cluster Navigation Links" data-test-sidebar-nav-panel="Cluster" as |Nav|>
<Nav.Title data-test-sidebar-nav-heading="Vault">Vault</Nav.Title>
<Nav.Link @route="vault.cluster.dashboard" @text="Dashboard" data-test-sidebar-nav-link="Dashboard" />
<Nav.Link
@route="vault.cluster.secrets"
@current-when="vault.cluster.secrets vault.cluster.settings.mount-secret-backend vault.cluster.settings.configure-secret-backend"

View File

@@ -0,0 +1,20 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import timestamp from 'core/utils/timestamp';
export default class DashboardController extends Controller {
@tracked replicationUpdatedAt = timestamp.now().toISOString();
@action
refreshModel(e) {
if (e) e.preventDefault();
this.replicationUpdatedAt = timestamp.now().toISOString();
this.send('refreshRoute');
}
}

View File

@@ -14,6 +14,7 @@ export default class Router extends EmberRouter {
Router.map(function () {
this.route('vault', { path: '/' }, function () {
this.route('cluster', { path: '/:cluster_name' }, function () {
this.route('dashboard');
this.route('oidc-provider-ns', { path: '/*namespace/identity/oidc/provider/:provider_name/authorize' });
this.route('oidc-provider', { path: '/identity/oidc/provider/:provider_name/authorize' });
this.route('oidc-callback', { path: '/auth/*auth_path/oidc/callback' });

View File

@@ -0,0 +1,49 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
// eslint-disable-next-line ember/no-mixins
import ClusterRoute from 'vault/mixins/cluster-route';
import { action } from '@ember/object';
export default class VaultClusterDashboardRoute extends Route.extend(ClusterRoute) {
@service store;
@service namespace;
@service version;
async getVaultConfiguration() {
try {
const adapter = this.store.adapterFor('application');
const configState = await adapter.ajax('/v1/sys/config/state/sanitized', 'GET');
return configState.data;
} catch (e) {
return null;
}
}
model() {
const clusterModel = this.modelFor('vault.cluster');
const replication = {
dr: clusterModel.dr,
performance: clusterModel.performance,
};
return hash({
replication,
secretsEngines: this.store.query('secret-engine', {}),
license: this.store.queryRecord('license', {}).catch(() => null),
isRootNamespace: this.namespace.inRootNamespace,
version: this.version,
vaultConfiguration: this.getVaultConfiguration(),
});
}
@action
refreshRoute() {
this.refresh();
}
}

View File

@@ -7,6 +7,6 @@ import Route from '@ember/routing/route';
export default Route.extend({
beforeModel() {
return this.transitionTo('vault.cluster.secrets');
return this.transitionTo('vault.cluster.dashboard');
},
});

View File

@@ -0,0 +1,3 @@
.secrets-engines-card {
min-height: 480px;
}

View File

@@ -105,6 +105,7 @@
@import './components/search-select';
@import './components/selectable-card';
@import './components/selectable-card-container';
@import './components/secrets-engines-card';
// action-block extends selectable-card
@import './components/action-block';
@import './components/shamir-modal-flow';

View File

@@ -47,6 +47,10 @@
&.is-7 {
font-size: $size-7;
}
&.is-8 {
font-size: $size-8;
}
}
.form-section .title {

View File

@@ -20,6 +20,14 @@
flex-direction: row;
}
.has-gap-m {
gap: $spacing-m;
}
.has-gap-l {
gap: $spacing-l;
}
// Alignment of the items
.is-flex-v-centered {
display: flex;
@@ -68,13 +76,17 @@
flex-basis: 100%;
}
.is-flex-1 {
.is-flex-grow-1 {
flex-grow: 1;
&.basis-0 {
flex-basis: 0;
}
}
.is-flex-1 {
flex: 1;
}
.is-no-flex-grow {
flex-grow: 0 !important;
}
@@ -97,6 +109,12 @@
}
}
@include until($mobile) {
.is-flex-row {
flex-flow: column wrap;
}
}
/* CSS GRID */
.is-grid {
display: grid;
@@ -129,3 +147,7 @@
.is-grid-column-span-3 {
grid-column-end: span 3;
}
.grid-align-items-start {
align-items: start;
}

View File

@@ -36,6 +36,10 @@
border: none !important;
}
.has-border-collapse {
border-collapse: collapse;
}
// pointer helpers
.has-no-pointer {
pointer-events: none;
@@ -85,6 +89,14 @@
overflow: hidden;
}
.truncate-first-line {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
overflow: hidden;
}
// screen reader only
.sr-only {
border: 0;
@@ -103,3 +115,8 @@
.border-radius-2 {
border-radius: $radius;
}
// border-spacing
.is-border-spacing-revert {
border-spacing: revert;
}

View File

@@ -26,9 +26,16 @@
padding-right: $spacing-s;
}
.has-padding-xxs {
padding: $spacing-xxs;
}
.has-padding-m {
padding: $spacing-m;
}
.has-padding-l {
padding: $spacing-l;
}
.has-bottom-padding-s {
padding-bottom: $spacing-s;
@@ -88,6 +95,19 @@
margin-bottom: -$spacing-m;
}
.has-top-margin-xxs {
margin: $spacing-xxs 0;
}
.has-right-margin-xxs {
margin-right: $spacing-xxs;
}
.has-left-margin-xxs {
margin-left: $spacing-xxs;
}
.has-bottom-margin-xxs {
margin-bottom: $spacing-xxs !important;
}
.has-bottom-margin-xs {
margin-bottom: $spacing-xs !important;
}

View File

@@ -69,7 +69,7 @@
</g>
</svg>
<div class="is-flex-1 text">
<div class="is-flex-grow-1 text">
Sign in with Google
</div>
</div>

View File

@@ -27,25 +27,7 @@
{{this.versionText.description}}
</p>
{{#if this.noActivityData}}
{{#if (eq @model.config.enabled "On")}}
<EmptyState
@title="No data received {{if this.dateRangeMessage this.dateRangeMessage}}"
@message="Tracking is turned on and Vault is gathering data. It should appear here within 30 minutes."
/>
{{else}}
<EmptyState
@title="Data tracking is disabled"
@message="Tracking is disabled, and no data is being collected. To turn it on, edit the configuration."
>
{{#if @model.config.canEdit}}
<p>
<LinkTo @route="vault.cluster.clients.config">
Go to configuration
</LinkTo>
</p>
{{/if}}
</EmptyState>
{{/if}}
<Clients::NoData @config={{@model.config}} @dateRangeMessage={{this.dateRangeMessage}} />
{{else if this.errorObject}}
<Clients::Error @error={{this.errorObject}} />
{{else}}

View File

@@ -0,0 +1,19 @@
{{#if (eq @config.enabled "On")}}
<EmptyState
@title="No data received {{if @dateRangeMessage @dateRangeMessage}}"
@message="Tracking is turned on and Vault is gathering data. It should appear here within 30 minutes."
/>
{{else}}
<EmptyState
@title="Data tracking is disabled"
@message="Tracking is disabled, and no data is being collected. To turn it on, edit the configuration."
>
{{#if @config.canEdit}}
<p>
<LinkTo @route="vault.cluster.clients.config">
Go to configuration
</LinkTo>
</p>
{{/if}}
</EmptyState>
{{/if}}

View File

@@ -0,0 +1,58 @@
<Hds::Card::Container @hasBorder={{true}} class="has-padding-l has-bottom-padding-m" data-test-client-count-card>
<div class="is-flex-between">
<h3 class="title is-4 has-bottom-margin-xxs" data-test-client-count-title>
Client count
</h3>
<LinkTo @route="vault.cluster.clients.dashboard" class="is-no-underline">Details</LinkTo>
</div>
<hr class="has-background-gray-100" />
{{#if this.noActivityData}}
{{! This will likely not be show since the client activity api was changed to always return data. In the past it
would return no activity data. Adding this empty state here to match the current client count behavior }}
<Clients::NoData @config={{this.clientConfig}} />
{{else}}
{{#if this.fetchClientActivity.isRunning}}
<VaultLogoSpinner />
{{else}}
<div class="is-grid grid-2-columns grid-gap-2 has-top-margin-m grid-align-items-start is-flex-v-centered">
<StatText
@label="Total"
@value={{this.activityData.total.clients}}
@size="l"
@subText="The number of clients in this billing period ({{date-format
this.licenseStartTime
'MMM yyyy'
}} - {{date-format this.updatedAt 'MMM yyyy'}})."
data-test-stat-text="total-clients"
/>
<StatText
@label="New"
@value={{this.currentMonthActivityTotalCount}}
@size="l"
@subText="The number of clients new to Vault in the current month."
data-test-stat-text="new-clients"
/>
</div>
<div class="has-top-margin-l is-flex-center">
<Hds::Button
@text="Refresh"
@isIconOnly={{true}}
@color="tertiary"
@icon="sync"
disabled={{this.fetchClientActivity.isRunning}}
class="has-padding-xxs"
{{on "click" (perform this.fetchClientActivity)}}
data-test-refresh
/>
<small class="has-left-margin-xs has-text-grey">
Updated
{{date-format this.updatedAt "MMM dd, yyyy HH:mm:SS"}}
</small>
</div>
{{/if}}
{{/if}}
</Hds::Card::Container>

View File

@@ -0,0 +1,26 @@
<Hds::Card::Container @hasBorder={{true}} class="has-padding-l">
<h3 class="title is-4 has-bottom-margin-xxs" data-test-learn-more-title>Learn more</h3>
<div class="sub-text" data-test-learn-more-subtext>
Explore the features of Vault and learn advance practices with the following tutorials and documentation.
</div>
<div class="has-top-margin-xs" data-test-learn-more-links>
{{#each this.learnMoreLinks as |learnMoreLink|}}
{{#if
(or
(and learnMoreLink.requiredFeature @isEnterprise (has-feature learnMoreLink.requiredFeature))
(not learnMoreLink.requiredFeature)
)
}}
<DocLink
data-test-learn-more-link={{learnMoreLink.title}}
@path={{learnMoreLink.link}}
class="is-size-8 has-font-weight-normal is-block has-bottom-margin-xxs"
>
<Icon @name={{learnMoreLink.icon}} />
{{learnMoreLink.title}}
</DocLink>
{{/if}}
{{/each}}
</div>
</Hds::Card::Container>

View File

@@ -0,0 +1,70 @@
<Hds::Card::Container @hasBorder={{true}} class="has-padding-l">
<h3 class="title is-4">Quick actions</h3>
<div class="has-bottom-margin-m">
<h4 class="title is-6">Secrets engines</h4>
<SearchSelect
@id="secrets-engines-select"
@options={{this.mountOptions}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.handleSearchEngineSelect}}
@placeholder="Type to select a mount"
@displayInherit={{true}}
@shouldRenderName={{true}}
@passObject={{true}}
@objectKeys={{array "type" "version"}}
class="is-marginless"
data-test-secrets-engines-select
/>
</div>
{{#if this.selectedEngine}}
<h4 class="title is-6">Action</h4>
<Select
@name="action-select"
@options={{this.actionOptions}}
@isFullwidth={{true}}
@selectedValue={{this.selectedAction}}
@onChange={{this.setSelectedAction}}
@noDefault={{true}}
/>
{{#if this.searchSelectParams.model}}
<h4 class="title is-6" data-test-search-select-params-title>{{this.searchSelectParams.title}}</h4>
<SearchSelect
class="is-flex-grow-1"
@selectLimit="1"
@models={{array this.searchSelectParams.model}}
@backend={{this.selectedEngine.id}}
@placeholder={{this.searchSelectParams.placeholder}}
@disallowNewItems={{true}}
@onChange={{this.handleActionSelect}}
@fallbackComponent="input-search"
@nameKey={{this.searchSelectParams.nameKey}}
@disabled={{not this.searchSelectParams.model}}
data-test-param-select
/>
<div>
<button
type="button"
class="button is-primary has-top-margin-m"
disabled={{(not (and this.selectedAction this.selectedEngine this.paramValue))}}
{{on "click" this.navigateToPage}}
data-test-button={{this.searchSelectParams.buttonText}}
>
{{this.searchSelectParams.buttonText}}
</button>
</div>
{{/if}}
{{else}}
<EmptyState
@title="No mount selected"
@message="Select a mount above to get started."
data-test-no-mount-selected-empty
/>
{{/if}}
</Hds::Card::Container>

View File

@@ -0,0 +1,94 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Hds::Card::Container @hasBorder={{true}} class="has-padding-l has-bottom-padding-m" data-test-replication-card>
<div class="is-flex-between">
<h3 class="title is-4 has-bottom-margin-xxs" data-test-client-count-title>
Replication
</h3>
<LinkTo class="is-no-underline" @route="vault.cluster.replication.index">
Details
</LinkTo>
</div>
{{! check if dr replication and performance replication exists }}
{{#if (or @replication.dr.clusterId @replication.performance.clusterId)}}
<hr class="has-background-gray-100" />
{{! check if user has access to both perf replication and dr replication }}
{{#if (and @version.hasPerfReplication @version.hasDRReplication)}}
<div
class="is-grid grid-2-columns grid-gap-2 has-top-margin-m has-bottom-margin-xs grid-align-items-start"
data-test-dr-perf-replication
>
<Dashboard::ReplicationStateText
@title="DR primary"
@name="dr"
@state={{@replication.dr.state}}
@clusterStates={{cluster-states @replication.dr.state}}
/>
<Dashboard::ReplicationStateText
@title="Perf primary"
@name="performance"
@state={{if @replication.performance.clusterId @replication.performance.state "not set up"}}
@clusterStates={{if @replication.performance.clusterId (cluster-states @replication.performance.state)}}
/>
</div>
{{! if user only has access to dr replication }}
{{else if @version.hasDRReplication}}
<LinkTo
class="title is-5 has-text-weight-semibold is-marginless"
@route="vault.cluster.replication.mode.index"
@model="dr"
>
DR Primary
</LinkTo>
<div
class="is-grid grid-2-columns grid-gap-2 has-top-margin-m has-bottom-margin-m grid-align-items-start"
data-test-dr-replication
>
<Dashboard::ReplicationStateText
@title="state"
@state={{@replication.dr.state}}
@clusterStates={{cluster-states @replication.dr.state}}
@subText="The current operating state of the cluster."
/>
<StatText
@label="known secondaries"
@value={{@replication.dr.knownSecondaries.length}}
@size="l"
@subText="Number of secondaries connected to this primary."
data-test-stat-text="known secondaries"
/>
</div>
{{/if}}
<div class="has-top-margin-s is-flex-center">
<Hds::Button
@text="Refresh"
@isIconOnly={{true}}
@color="tertiary"
@icon="sync"
class="has-padding-xxs"
{{on "click" @refresh}}
data-test-refresh
/>
<small class="has-left-margin-xs has-text-grey">
Updated
{{date-format @updatedAt "MMM dd, yyyy HH:mm:SS"}}
</small>
</div>
{{else}}
<EmptyState
@title="Replication not set up"
@message="Data will be listed here. Enable a primary replication cluster to get started."
class="has-top-margin-m"
>
<div>
<LinkTo @route="vault.cluster.replication">Enable replication</LinkTo>
</div>
</EmptyState>
{{/if}}
</Hds::Card::Container>

View File

@@ -0,0 +1,48 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div>
{{#if @name}}
<LinkTo
class="title is-5 has-text-weight-semibold has-bottom-margin-xs"
@route="vault.cluster.replication.mode.index"
@model={{@name}}
data-test-title={{@title}}
>
{{@title}}
</LinkTo>
{{else}}
<h2 class="title is-5 has-text-weight-semibold has-bottom-margin-xs" data-test-title={{@title}}>
{{@title}}
</h2>
{{/if}}
{{#if @subText}}
<div class="title is-8 has-font-weight-normal has-text-grey-dark" data-test-subtext={{@title}}>
{{@subText}}
</div>
{{/if}}
<ToolTip @verticalPosition="above" @horizontalPosition="center" as |T|>
<T.Trigger
data-test-tooltip-trigger
tabindex="-1"
class="title is-3 has-font-weight-normal has-top-margin-xxs has-bottom-margin-xxs"
data-test-tooltip-title={{@title}}
>
{{or @state "not set up"}}
<FlightIcon
@name={{or @clusterStates.glyph "x-circle"}}
class={{if @clusterStates.isOk "has-text-success" "has-text-danger"}}
/>
</T.Trigger>
<T.Content @defaultClass="tool-tip smaller-font">
<div class="box" data-test-hover-copy-tooltip-text>
The cluster's current operating state
</div>
</T.Content>
</ToolTip>
</div>

View File

@@ -0,0 +1,71 @@
<Hds::Card::Container @hasBorder={{true}} class="has-padding-l secrets-engines-card">
<h3 class="title is-4 has-left-margin-xxs" data-test-dashboard-secrets-engines-header>Secrets engines</h3>
<Hds::Table @caption="Five secrets engines" class="is-border-spacing-revert" data-test-dashboard-secrets-engines-table>
<:body as |B|>
{{#each this.filteredSecretsEngines as |backend|}}
<B.Tr data-test-secrets-engines-row={{backend.id}}>
<B.Td class="is-flex-between is-flex-center has-gap-m">
<div>
<div class="is-flex-center">
{{#if backend.icon}}
<ToolTip @horizontalPosition="left" as |T|>
<T.Trigger>
<Icon @name={{backend.icon}} class={{unless backend.isSupportedBackend "has-text-grey"}} />
</T.Trigger>
<T.Content @defaultClass="tool-tip">
<div class="box">
{{or backend.engineType backend.path}}
</div>
</T.Content>
</ToolTip>
{{/if}}
{{#if backend.path}}
{{#if backend.isSupportedBackend}}
<LinkTo
@route={{backend.backendLink}}
@model={{backend.id}}
class="has-text-black has-text-weight-semibold"
data-test-secret-path
>
{{backend.path}}
</LinkTo>
{{else}}
<span class="has-text-grey" data-test-secret-path>{{backend.path}}</span>
{{/if}}
{{/if}}
</div>
{{#if backend.accessor}}
<code class="has-text-grey is-size-8" data-test-accessor>
{{backend.accessor}}
</code>
{{/if}}
{{#if backend.description}}
<div data-test-description class="truncate-first-line">
{{backend.description}}
</div>
{{/if}}
</div>
{{#if backend.isSupportedBackend}}
<LinkTo
@route={{backend.backendLink}}
@model={{backend.id}}
class="has-text-weight-semibold is-no-underline"
data-test-view
>
View
</LinkTo>
{{/if}}
</B.Td>
</B.Tr>
{{/each}}
</:body>
</Hds::Table>
{{#if (gt this.filteredSecretsEngines.length 4)}}
<div class="is-flex-end has-top-margin-s" data-test-secrets-engines-card-show-all>
<LinkTo @route="vault.cluster.secrets.backends" class="has-text-weight-semibold is-size-7 is-no-underline">
Show all
<Icon @name="arrow-right" />
</LinkTo>
</div>
{{/if}}
</Hds::Card::Container>

View File

@@ -0,0 +1,9 @@
<div class="has-padding-s has-top-margin-m" data-test-feedback-form>
<small>
Don't see what you're looking for on this page? Let us know via our
<Hds::Link::Inline @icon="external-link" @href="https://hashicorp.sjc1.qualtrics.com/jfe/form/SV_1SNUsZLdWHpfw0e">
feedback form
</Hds::Link::Inline>
.
</small>
</div>

View File

@@ -0,0 +1,38 @@
<Hds::Card::Container @hasBorder={{true}} class="has-padding-l">
<h3 class="title is-4" data-test-configuration-details-title>Configuration details</h3>
<Hds::Table @caption="Vault configuration details" class="is-border-spacing-revert">
<:body as |B|>
<B.Tr>
<B.Td>API_ADDR</B.Td>
<B.Td data-test-vault-config-details="api_addr">{{or @vaultConfiguration.api_addr "None"}}</B.Td>
</B.Tr>
<B.Tr>
<B.Td>Default lease TTL</B.Td>
<B.Td data-test-vault-config-details="default_lease_ttl">{{format-duration
@vaultConfiguration.default_lease_ttl
}}</B.Td>
</B.Tr>
<B.Tr>
<B.Td>Max lease TTL</B.Td>
<B.Td data-test-vault-config-details="max_lease_ttl">{{format-duration @vaultConfiguration.max_lease_ttl}}</B.Td>
</B.Tr>
<B.Tr>
<B.Td>TLS</B.Td>
<B.Td data-test-vault-config-details="tls_disable">{{this.tlsDisabled}}</B.Td>
</B.Tr>
<B.Tr>
<B.Td>Log format</B.Td>
<B.Td data-test-vault-config-details="log_format">{{or @vaultConfiguration.log_format "None"}}</B.Td>
</B.Tr>
<B.Tr>
<B.Td>Log level</B.Td>
<B.Td data-test-vault-config-details="log_level">{{@vaultConfiguration.log_level}}</B.Td>
</B.Tr>
<B.Tr>
<B.Td>Storage type</B.Td>
<B.Td data-test-vault-config-details="type">{{@vaultConfiguration.storage.type}}</B.Td>
</B.Tr>
</:body>
</Hds::Table>
</Hds::Card::Container>

View File

@@ -0,0 +1,11 @@
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3" data-test-dashboard-version-header>
{{this.versionHeader}}
{{#if @version.isEnterprise}}
<Hds::Badge @text={{this.namespaceDisplay}} @icon="org" data-test-badge-namespace />
{{/if}}
</h1>
</p.levelLeft>
</PageHeader>
<hr class="has-top-margin-xxs has-bottom-margin-l has-background-gray-200" />

View File

@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="is-flex-v-centered is-flex-1">
<div class="is-flex-v-centered is-flex-grow-1">
<div class="columns is-centered">
<div class="column is-narrow has-text-centered has-text-grey-dark has-current-color-fill">
<LogoEdition />

View File

@@ -58,7 +58,7 @@
/>
{{#each this.targets as |target|}}
<div class="is-flex-center has-border-top-light" data-test-mlef-target={{target.label}}>
<InfoTableRow @label={{target.label}} class="is-flex-1 has-no-shadow">
<InfoTableRow @label={{target.label}} class="is-flex-grow-1 has-no-shadow">
{{#if target.value.id}}
{{target.value.name}}
<span class="tag has-left-margin-s">{{target.value.id}}</span>
@@ -85,7 +85,7 @@
@onChange={{this.onTargetSelect}}
data-test-mlef-select="target-type"
/>
<div class="has-left-margin-s is-flex-1">
<div class="has-left-margin-s is-flex-grow-1">
{{#if (eq this.selectedTargetType "accessor")}}
<MountAccessorSelect
@value={{this.selectedTargetValue}}

View File

@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-grow-1 is-fullwidth">
<div class="columns is-centered is-gapless is-fullwidth">
<div class="column is-4-desktop is-6-tablet">
<div class="has-text-grey has-bottom-margin-m has-current-color-fill">

View File

@@ -7,7 +7,7 @@
{{#if @hasAltContent}}
{{yield (hash altContent=(component "splash-page/splash-content"))}}
{{else}}
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-grow-1 is-fullwidth">
<div class="columns is-centered is-gapless is-fullwidth">
<div class="column is-4-desktop is-6-tablet">
<div class="splash-page-header">

View File

@@ -143,14 +143,14 @@
</div>
<div class="column is-two-thirds is-flex-column">
{{#if (or (and @verification (eq @verification "HMAC")) @hmac)}}
<div class="field is-flex-column is-flex-1">
<div class="control is-flex-column is-flex-1">
<div class="field is-flex-column is-flex-grow-1">
<div class="control is-flex-column is-flex-grow-1">
<JsonEditor @title="HMAC" @value={{@hmac}} @valueUpdated={{action (mut @hmac)}} @mode="ruby" />
</div>
</div>
{{else}}
<div class="field is-flex-column is-flex-1">
<div class="control is-flex-column is-flex-1">
<div class="field is-flex-column is-flex-grow-1">
<div class="control is-flex-column is-flex-grow-1">
<JsonEditor
@title="Signature"
@value={{@signature}}

View File

@@ -0,0 +1,49 @@
<Dashboard::VaultVersionTitle @version={{@model.version}} />
<div class="has-bottom-margin-xl">
<div class="is-flex-row has-gap-l">
{{#if (and @model.version.isEnterprise (or @model.license @model.isRootNamespace))}}
<div class="is-flex-column is-flex-1 has-gap-l">
{{#if @model.license}}
<Dashboard::ClientCountCard @license={{@model.license}} />
{{/if}}
{{#if @model.isRootNamespace}}
<Dashboard::ReplicationCard
@replication={{@model.replication}}
@version={{@model.version}}
@refresh={{this.refreshModel}}
@updatedAt={{this.replicationUpdatedAt}}
/>
{{/if}}
<Dashboard::SecretsEnginesCard @secretsEngines={{@model.secretsEngines}} />
</div>
<div class="is-flex-column is-flex-1 has-gap-l">
<Dashboard::QuickActionsCard @secretsEngines={{@model.secretsEngines}} />
{{#if @model.vaultConfiguration}}
<Dashboard::VaultConfigurationDetailsCard @vaultConfiguration={{@model.vaultConfiguration}} />
{{/if}}
<div>
<Dashboard::LearnMoreCard @isEnterprise={{@model.version.isEnterprise}} />
<Dashboard::SurveyLinkText />
</div>
</div>
{{else}}
<div class="is-flex-column is-flex-1 has-gap-l">
<Dashboard::SecretsEnginesCard @secretsEngines={{@model.secretsEngines}} />
<div>
<Dashboard::LearnMoreCard @isEnterprise={{@model.version.isEnterprise}} />
<Dashboard::SurveyLinkText />
</div>
</div>
<div class="is-flex-column is-flex-1 has-gap-l">
<Dashboard::QuickActionsCard @secretsEngines={{@model.secretsEngines}} />
{{#if @model.vaultConfiguration}}
<Dashboard::VaultConfigurationDetailsCard @vaultConfiguration={{@model.vaultConfiguration}} />
{{/if}}
</div>
{{/if}}
</div>
</div>

View File

@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-grow-1 is-fullwidth">
<div class="columns is-centered is-gapless is-fullwidth">
<div class="column is-4-desktop is-6-tablet">
{{#if this.model.error}}

View File

@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
<div class="splash-page-container section is-flex-v-centered-tablet is-flex-grow-1 is-fullwidth">
<div class="columns is-centered is-gapless is-fullwidth">
<div class="column is-4-desktop is-6-tablet">
{{#if this.model.error}}

View File

@@ -32,7 +32,7 @@
as |Item|
>
<Item.content>
<div class="columns is-flex-1">
<div class="columns is-flex-grow-1">
<div>
<Icon @name="history" class="has-text-grey" />
Version

View File

@@ -4,7 +4,7 @@
~}}
{{#if this.showLicenseError}}
<div class="section is-flex-v-centered-tablet is-flex-1 is-fullwidth">
<div class="section is-flex-v-centered-tablet is-flex-grow-1 is-fullwidth">
<div class="columns is-centered is-gapless is-fullwidth">
<EmptyState
class="empty-state-transparent"

View File

@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="is-flex-1 is-flex-v-centered">
<div class="is-flex-grow-1 is-flex-v-centered">
<div class="empty-state-content">
<div class="is-flex-v-centered has-bottom-margin-xxl">
<div class="brand-icon-large">

View File

@@ -12,7 +12,7 @@
<span class="has-left-margin-s">
<Icon @name="certificate" @size="24" data-test-certificate-icon />
</span>
<div class="has-left-margin-m is-min-width-0 is-flex-1">
<div class="has-left-margin-m is-min-width-0 is-flex-grow-1">
<p class="has-text-weight-bold" data-test-certificate-label>
{{this.format}}
</p>

View File

@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="is-flex-v-centered is-flex-1 loader-inner-page" data-test-layout-loading>
<div class="is-flex-v-centered is-flex-grow-1 loader-inner-page" data-test-layout-loading>
<div class="columns is-centered">
<div class="column is-narrow has-text-centered has-text-grey-dark has-current-color-fill">
<div class="level is-mobile">

View File

@@ -14,11 +14,11 @@
data-test-list-item-link
>
<div class="level is-mobile">
<div class="level-left is-flex-1" data-test-list-item-content>
<div class="level-left is-flex-grow-1" data-test-list-item-content>
<LinkTo
@route={{this.link.route}}
@models={{this.link.models}}
class="has-text-weight-semibold has-text-black is-display-flex is-flex-1 is-no-underline"
class="has-text-weight-semibold has-text-black is-display-flex is-flex-grow-1 is-no-underline"
>
{{yield (hash content=(component "list-item/content"))}}
</LinkTo>
@@ -35,7 +35,7 @@
{{else}}
<div class="list-item-row">
<div class="level is-mobile">
<div class="level-left is-flex-1 has-text-weight-semibold" data-test-list-item>
<div class="level-left is-flex-grow-1 has-text-weight-semibold" data-test-list-item>
{{yield (hash content=(component "list-item/content"))}}
</div>
<div class="level-right">

View File

@@ -6,7 +6,7 @@
{{#if this.isMenu}}
{{! this is the status menu }}
<div class="level is-mobile">
<div class="is-flex-1">
<div class="is-flex-grow-1">
{{#if this.replicationUnsupported}}
Unsupported
{{else if this.replicationEnabled}}

View File

@@ -25,7 +25,7 @@
<OverviewCard @cardTitle="Generate credentials" @subText="Quickly generate credentials by typing the role name.">
<div class="has-top-margin-m is-flex">
<SearchSelect
class="is-flex-1"
class="is-flex-grow-1"
@placeholder="Type to find a role..."
@disallowNewItems={{true}}
@options={{this.roleOptions}}

View File

@@ -25,7 +25,7 @@
<OverviewCard @cardTitle="Issue certificate" @subText="Begin issuing a certificate by choosing a role.">
<div class="has-top-margin-m is-flex">
<SearchSelect
class="is-flex-1"
class="is-flex-grow-1"
@selectLimit="1"
@models={{array "pki/role"}}
@backend={{@engine.id}}
@@ -50,7 +50,7 @@
<OverviewCard @cardTitle="View certificate" @subText="Quickly view a certificate by typing its serial number.">
<div class="has-top-margin-m {{unless this.certificateValue 'is-flex'}}">
<SearchSelect
class="is-flex-1"
class="is-flex-grow-1"
@selectLimit="1"
@models={{array "pki/certificate/base"}}
@backend={{@engine.id}}
@@ -75,7 +75,7 @@
<OverviewCard @cardTitle="View issuer" @subText="Choose or type an issuer name or ID to view its details">
<div class="has-top-margin-m is-flex">
<SearchSelect
class="is-flex-1"
class="is-flex-grow-1"
@selectLimit="1"
@models={{array "pki/issuer"}}
@backend={{@engine.id}}

View File

@@ -5,12 +5,12 @@
{{#if this.importedResponse}}
<div class="is-flex-start has-top-margin-xs">
<div class="is-flex-1 basis-0 has-text-grey has-bottom-margin-xs">
<div class="is-flex-grow-1 basis-0 has-text-grey has-bottom-margin-xs">
<h2>
Imported Issuer
</h2>
</div>
<div class="is-flex-1 basis-0 has-text-grey has-bottom-margin-xs">
<div class="is-flex-grow-1 basis-0 has-text-grey has-bottom-margin-xs">
<h2>
Imported Key
</h2>
@@ -23,7 +23,7 @@
data-test-import-pair={{concat imported.issuer "_" imported.key}}
>
<div class="is-flex-start">
<div class="is-flex-1 basis-0 has-bottom-margin-xs" data-test-imported-issuer>
<div class="is-flex-grow-1 basis-0 has-bottom-margin-xs" data-test-imported-issuer>
{{#if imported.issuer}}
<LinkTo @route="issuers.issuer.details" @model={{imported.issuer}}>
{{imported.issuer}}
@@ -32,7 +32,7 @@
None
{{/if}}
</div>
<div class="is-flex-1 basis-0 has-bottom-margin-xs" data-test-imported-key>
<div class="is-flex-grow-1 basis-0 has-bottom-margin-xs" data-test-imported-key>
{{#if imported.key}}
<LinkTo @route="keys.key.details" @model={{imported.key}}>
{{imported.key}}

View File

@@ -20,7 +20,7 @@
</p>
<div class="is-flex-start">
{{#each this.inputFields as |column|}}
<div class="is-flex-1 basis-0 has-text-grey has-bottom-margin-xs">
<div class="is-flex-grow-1 basis-0 has-text-grey has-bottom-margin-xs">
<h2 data-test-info-table-label={{column.key}}>
{{column.label}}
{{#if column.helpText}}
@@ -40,7 +40,7 @@
/>
{{#each (map-by "key" this.inputFields) as |columnAttr|}}
{{#let (get crossSignRow columnAttr) as |data|}}
<div data-test-info-table-column={{columnAttr}} class="is-flex-1 basis-0 has-bottom-margin-xs">
<div data-test-info-table-column={{columnAttr}} class="is-flex-grow-1 basis-0 has-bottom-margin-xs">
{{#if (eq columnAttr "intermediateMount")}}
<LinkTo class="has-text-black has-text-weight-semibold" @route="overview" @model={{data}}>
{{data}}

View File

@@ -0,0 +1,471 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import {
visit,
currentURL,
settled,
fillIn,
click,
waitUntil,
find,
currentRouteName,
} from '@ember/test-helpers';
import { setupApplicationTest } from 'vault/tests/helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { create } from 'ember-cli-page-object';
import { selectChoose } from 'ember-power-select/test-support/helpers';
import { runCommands } from 'vault/tests/helpers/pki/pki-run-commands';
import { deleteEngineCmd } from 'vault/tests/helpers/commands';
import authPage from 'vault/tests/pages/auth';
import mountSecrets from 'vault/tests/pages/settings/mount-secret-backend';
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
import ENV from 'vault/config/environment';
import { formatNumber } from 'core/helpers/format-number';
import { pollCluster } from 'vault/tests/helpers/poll-cluster';
import { disableReplication } from 'vault/tests/helpers/replication';
import connectionPage from 'vault/tests/pages/secrets/backend/database/connection';
// selectors
import SECRETS_ENGINE_SELECTORS from 'vault/tests/helpers/components/dashboard/secrets-engines-card';
import VAULT_CONFIGURATION_SELECTORS from 'vault/tests/helpers/components/dashboard/vault-configuration-details-card';
import QUICK_ACTION_SELECTORS from 'vault/tests/helpers/components/dashboard/quick-actions-card';
import REPLICATION_CARD_SELECTORS from 'vault/tests/helpers/components/dashboard/replication-card';
const consoleComponent = create(consoleClass);
module('Acceptance | landing page dashboard', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
test('navigate to dashboard on login', async function (assert) {
await authPage.login();
assert.strictEqual(currentURL(), '/vault/dashboard');
});
test('display the version number for the title', async function (assert) {
await authPage.login();
await visit('/vault/dashboard');
const version = this.owner.lookup('service:version');
const versionName = version.version;
const versionNameEnd = version.isEnterprise ? versionName.indexOf('+') : versionName.length;
assert
.dom('[data-test-dashboard-version-header]')
.hasText(`Vault v${versionName.slice(0, versionNameEnd)} root`);
});
module('secrets engines card', function (hooks) {
hooks.beforeEach(async function () {
await authPage.login();
});
test('shows a secrets engine card', async function (assert) {
await mountSecrets.enable('pki', 'pki');
await settled();
await visit('/vault/dashboard');
assert.dom(SECRETS_ENGINE_SELECTORS.cardTitle).hasText('Secrets engines');
assert.dom('[data-test-secrets-engines-card-show-all]').doesNotExist();
// cleanup engine mount
await consoleComponent.runCommands(deleteEngineCmd('pki'));
});
test('it adds disabled css styling to unsupported secret engines', async function (assert) {
await mountSecrets.enable('nomad', 'nomad');
await settled();
await visit('/vault/dashboard');
assert.dom('[data-test-secrets-engines-row="nomad"] [data-test-view]').doesNotExist();
assert.dom('[data-test-secrets-engines-card-show-all]').doesNotExist();
// cleanup engine mount
await consoleComponent.runCommands(deleteEngineCmd('nomad'));
});
});
module('learn more card', function (hooks) {
hooks.beforeEach(function () {
return authPage.login();
});
test('shows the learn more card on community', async function (assert) {
await visit('/vault/dashboard');
assert.dom('[data-test-learn-more-title]').hasText('Learn more');
assert
.dom('[data-test-learn-more-subtext]')
.hasText(
'Explore the features of Vault and learn advance practices with the following tutorials and documentation.'
);
assert.dom('[data-test-learn-more-links] a').exists({ count: 3 });
assert
.dom('[data-test-feedback-form]')
.hasText("Don't see what you're looking for on this page? Let us know via our feedback form .");
});
test('shows the learn more card on enterprise', async function (assert) {
await visit('/vault/dashboard');
assert.dom('[data-test-learn-more-title]').hasText('Learn more');
assert
.dom('[data-test-learn-more-subtext]')
.hasText(
'Explore the features of Vault and learn advance practices with the following tutorials and documentation.'
);
assert.dom('[data-test-learn-more-links] a').exists({ count: 4 });
assert
.dom('[data-test-feedback-form]')
.hasText("Don't see what you're looking for on this page? Let us know via our feedback form .");
});
});
module('configuration details card', function (hooks) {
hooks.beforeEach(async function () {
this.data = {
api_addr: 'http://127.0.0.1:8200',
cache_size: 0,
cluster_addr: 'https://127.0.0.1:8201',
cluster_cipher_suites: '',
cluster_name: '',
default_lease_ttl: 0,
default_max_request_duration: 0,
detect_deadlocks: '',
disable_cache: false,
disable_clustering: false,
disable_indexing: false,
disable_mlock: true,
disable_performance_standby: false,
disable_printable_check: false,
disable_sealwrap: false,
disable_sentinel_trace: false,
enable_response_header_hostname: false,
enable_response_header_raft_node_id: false,
enable_ui: true,
experiments: null,
introspection_endpoint: false,
listeners: [
{
config: {
address: '0.0.0.0:8200',
cluster_address: '0.0.0.0:8201',
tls_disable: true,
},
type: 'tcp',
},
],
log_format: '',
log_level: 'debug',
log_requests_level: '',
max_lease_ttl: '48h',
pid_file: '',
plugin_directory: '',
plugin_file_permissions: 0,
plugin_file_uid: 0,
raw_storage_endpoint: true,
seals: [
{
disabled: false,
type: 'shamir',
},
],
storage: {
cluster_addr: 'https://127.0.0.1:8201',
disable_clustering: false,
raft: {
max_entry_size: '',
},
redirect_addr: 'http://127.0.0.1:8200',
type: 'raft',
},
telemetry: {
add_lease_metrics_namespace_labels: false,
circonus_api_app: '',
circonus_api_token: '',
circonus_api_url: '',
circonus_broker_id: '',
circonus_broker_select_tag: '',
circonus_check_display_name: '',
circonus_check_force_metric_activation: '',
circonus_check_id: '',
circonus_check_instance_id: '',
circonus_check_search_tag: '',
circonus_check_tags: '',
circonus_submission_interval: '',
circonus_submission_url: '',
disable_hostname: true,
dogstatsd_addr: '',
dogstatsd_tags: null,
lease_metrics_epsilon: 3600000000000,
maximum_gauge_cardinality: 500,
metrics_prefix: '',
num_lease_metrics_buckets: 168,
prometheus_retention_time: 86400000000000,
stackdriver_debug_logs: false,
stackdriver_location: '',
stackdriver_namespace: '',
stackdriver_project_id: '',
statsd_address: '',
statsite_address: '',
usage_gauge_period: 5000000000,
},
};
await authPage.login();
});
test('shows the configuration details card', async function (assert) {
this.server.get('sys/config/state/sanitized', () => ({
data: this.data,
wrap_info: null,
warnings: null,
auth: null,
}));
await authPage.login();
await visit('/vault/dashboard');
assert.dom(VAULT_CONFIGURATION_SELECTORS.cardTitle).hasText('Configuration details');
assert.dom(VAULT_CONFIGURATION_SELECTORS.apiAddr).hasText('http://127.0.0.1:8200');
assert.dom(VAULT_CONFIGURATION_SELECTORS.defaultLeaseTtl).hasText('0');
assert.dom(VAULT_CONFIGURATION_SELECTORS.maxLeaseTtl).hasText('2 days');
assert.dom(VAULT_CONFIGURATION_SELECTORS.tlsDisable).hasText('Enabled');
assert.dom(VAULT_CONFIGURATION_SELECTORS.logFormat).hasText('None');
assert.dom(VAULT_CONFIGURATION_SELECTORS.logLevel).hasText('debug');
assert.dom(VAULT_CONFIGURATION_SELECTORS.storageType).hasText('raft');
});
test('shows the tls disabled if it is disabled', async function (assert) {
this.server.get('sys/config/state/sanitized', () => {
this.data.listeners[0].config.tls_disable = false;
return {
data: this.data,
wrap_info: null,
warnings: null,
auth: null,
};
});
await authPage.login();
await visit('/vault/dashboard');
assert.dom(VAULT_CONFIGURATION_SELECTORS.tlsDisable).hasText('Disabled');
});
test('shows the tls disabled if there is no tlsDisabled returned from server', async function (assert) {
this.server.get('sys/config/state/sanitized', () => {
this.data.listeners = [];
return {
data: this.data,
wrap_info: null,
warnings: null,
auth: null,
};
});
await authPage.login();
await visit('/vault/dashboard');
assert.dom(VAULT_CONFIGURATION_SELECTORS.tlsDisable).hasText('Disabled');
});
});
module('quick actions card', function (hooks) {
hooks.beforeEach(async function () {
await authPage.login();
});
test('shows the default state of the quick actions card', async function (assert) {
assert.dom(QUICK_ACTION_SELECTORS.emptyState).exists();
});
test('shows the correct actions and links associated with pki', async function (assert) {
await mountSecrets.enable('pki', 'pki');
await runCommands([
`write pki/roles/some-role \
issuer_ref="default" \
allowed_domains="example.com" \
allow_subdomains=true \
max_ttl="720h"`,
]);
await runCommands([`write pki/root/generate/internal issuer_name="Hashicorp" common_name="Hello"`]);
await settled();
await visit('/vault/dashboard');
await selectChoose(QUICK_ACTION_SELECTORS.secretsEnginesSelect, 'pki');
await fillIn(QUICK_ACTION_SELECTORS.actionSelect, 'Issue certificate');
assert.dom(QUICK_ACTION_SELECTORS.emptyState).doesNotExist();
assert.dom(QUICK_ACTION_SELECTORS.paramsTitle).hasText('Role to use');
await selectChoose(QUICK_ACTION_SELECTORS.paramSelect, 'some-role');
assert.dom(QUICK_ACTION_SELECTORS.getActionButton('Issue leaf certificate')).exists({ count: 1 });
await click(QUICK_ACTION_SELECTORS.getActionButton('Issue leaf certificate'));
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.pki.roles.role.generate');
await visit('/vault/dashboard');
await selectChoose(QUICK_ACTION_SELECTORS.secretsEnginesSelect, 'pki');
await fillIn(QUICK_ACTION_SELECTORS.actionSelect, 'View certificate');
assert.dom(QUICK_ACTION_SELECTORS.emptyState).doesNotExist();
assert.dom(QUICK_ACTION_SELECTORS.paramsTitle).hasText('Certificate serial number');
assert.dom(QUICK_ACTION_SELECTORS.getActionButton('View certificate')).exists({ count: 1 });
await selectChoose(QUICK_ACTION_SELECTORS.paramSelect, '.ember-power-select-option', 0);
await click(QUICK_ACTION_SELECTORS.getActionButton('View certificate'));
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.pki.certificates.certificate.details'
);
await visit('/vault/dashboard');
await selectChoose(QUICK_ACTION_SELECTORS.secretsEnginesSelect, 'pki');
await fillIn(QUICK_ACTION_SELECTORS.actionSelect, 'View issuer');
assert.dom(QUICK_ACTION_SELECTORS.emptyState).doesNotExist();
assert.dom(QUICK_ACTION_SELECTORS.paramsTitle).hasText('Issuer');
assert.dom(QUICK_ACTION_SELECTORS.getActionButton('View issuer')).exists({ count: 1 });
await selectChoose(QUICK_ACTION_SELECTORS.paramSelect, '.ember-power-select-option', 0);
await click(QUICK_ACTION_SELECTORS.getActionButton('View issuer'));
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.pki.issuers.issuer.details');
// cleanup engine mount
await consoleComponent.runCommands(deleteEngineCmd('pki'));
});
const newConnection = async (backend, plugin = 'mongodb-database-plugin') => {
const name = `connection-${Date.now()}`;
await connectionPage.visitCreate({ backend });
await connectionPage.dbPlugin(plugin);
await connectionPage.name(name);
await connectionPage.connectionUrl(`mongodb://127.0.0.1:4321/${name}`);
await connectionPage.toggleVerify();
await connectionPage.save();
await connectionPage.enable();
return name;
};
test('shows the correct actions and links associated with database', async function (assert) {
await mountSecrets.enable('database', 'database');
await newConnection('database');
await runCommands([
`write database/roles/my-role \
db_name=mongodb-database-plugin \
creation_statements='{ "db": "admin", "roles": [{ "role": "readWrite" }, {"role": "read", "db": "foo"}] }' \
default_ttl="1h" \
max_ttl="24h`,
]);
await settled();
await visit('/vault/dashboard');
await selectChoose(QUICK_ACTION_SELECTORS.secretsEnginesSelect, 'database');
await fillIn(QUICK_ACTION_SELECTORS.actionSelect, 'Generate credentials for database');
assert.dom(QUICK_ACTION_SELECTORS.emptyState).doesNotExist();
assert.dom(QUICK_ACTION_SELECTORS.paramsTitle).hasText('Role to use');
assert.dom(QUICK_ACTION_SELECTORS.getActionButton('Generate credentials')).exists({ count: 1 });
await selectChoose(QUICK_ACTION_SELECTORS.paramSelect, '.ember-power-select-option', 0);
await click(QUICK_ACTION_SELECTORS.getActionButton('Generate credentials'));
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.credentials');
await consoleComponent.runCommands(deleteEngineCmd('database'));
});
test('shows the correct actions and links associated with kv v1', async function (assert) {
await runCommands(['write sys/mounts/kv type=kv', 'write kv/foo bar=baz']);
await settled();
await visit('/vault/dashboard');
await selectChoose(QUICK_ACTION_SELECTORS.secretsEnginesSelect, 'kv');
await fillIn(QUICK_ACTION_SELECTORS.actionSelect, 'Find KV secrets');
assert.dom(QUICK_ACTION_SELECTORS.emptyState).doesNotExist();
assert.dom(QUICK_ACTION_SELECTORS.paramsTitle).hasText('Secret path');
assert.dom(QUICK_ACTION_SELECTORS.getActionButton('Read secrets')).exists({ count: 1 });
await consoleComponent.runCommands(deleteEngineCmd('kv'));
});
});
module('replication and client count card community version', function () {
test('hides replication card for community version', async function (assert) {
await visit('/vault/dashboard');
assert.dom('[data-test-replication-card]').doesNotExist();
});
test('hides the client count card in community version', async function (assert) {
assert.dom('[data-test-client-count-card]').doesNotExist();
});
});
module('client counts card enterprise', function (hooks) {
hooks.before(async function () {
ENV['ember-cli-mirage'].handler = 'clients';
});
hooks.beforeEach(async function () {
this.store = this.owner.lookup('service:store');
await authPage.login();
});
hooks.after(function () {
ENV['ember-cli-mirage'].handler = null;
});
test('shows the client count card for enterprise', async function (assert) {
const version = this.owner.lookup('service:version');
assert.true(version.isEnterprise, 'version is enterprise');
assert.strictEqual(currentURL(), '/vault/dashboard');
assert.dom('[data-test-client-count-card]').exists();
const response = await this.store.peekRecord('clients/activity', 'some-activity-id');
assert.dom('[data-test-client-count-title]').hasText('Client count');
assert.dom('[data-test-stat-text="total-clients"] .stat-label').hasText('Total');
assert
.dom('[data-test-stat-text="total-clients"] .stat-value')
.hasText(formatNumber([response.total.clients]));
assert.dom('[data-test-stat-text="new-clients"] .stat-label').hasText('New');
assert
.dom('[data-test-stat-text="new-clients"] .stat-text')
.hasText('The number of clients new to Vault in the current month.');
assert
.dom('[data-test-stat-text="new-clients"] .stat-value')
.hasText(formatNumber([response.byMonth.lastObject.new_clients.clients]));
});
});
module('replication card enterprise', function (hooks) {
hooks.beforeEach(async function () {
await authPage.login();
await settled();
await disableReplication('dr');
await settled();
await disableReplication('performance');
await settled();
});
test('shows the replication card empty state in enterprise version', async function (assert) {
await visit('/vault/dashboard');
const version = this.owner.lookup('service:version');
assert.true(version.isEnterprise, 'vault is enterprise');
assert.dom(REPLICATION_CARD_SELECTORS.replicationEmptyState).exists();
assert.dom(REPLICATION_CARD_SELECTORS.replicationEmptyStateTitle).hasText('Replication not set up');
assert
.dom(REPLICATION_CARD_SELECTORS.replicationEmptyStateMessage)
.hasText('Data will be listed here. Enable a primary replication cluster to get started.');
assert.dom(REPLICATION_CARD_SELECTORS.replicationEmptyStateActions).hasText('Enable replication');
});
test('it should show replication status if both dr and performance replication are enabled as features in enterprise', async function (assert) {
const version = this.owner.lookup('service:version');
assert.true(version.isEnterprise, 'vault is enterprise');
await visit('/vault/replication');
assert.strictEqual(currentURL(), '/vault/replication');
await click('[data-test-replication-type-select="performance"]');
await fillIn('[data-test-replication-cluster-mode-select]', 'primary');
await click('[data-test-replication-enable]');
await pollCluster(this.owner);
assert.ok(
await waitUntil(() => find('[data-test-replication-dashboard]')),
'details dashboard is shown'
);
await visit('/vault/dashboard');
assert
.dom(REPLICATION_CARD_SELECTORS.getReplicationTitle('dr-perf', 'DR primary'))
.hasText('DR primary');
assert
.dom(REPLICATION_CARD_SELECTORS.getStateTooltipTitle('dr-perf', 'DR primary'))
.hasText('not set up');
assert
.dom(REPLICATION_CARD_SELECTORS.getStateTooltipIcon('dr-perf', 'DR primary', 'x-circle'))
.exists();
assert
.dom(REPLICATION_CARD_SELECTORS.getReplicationTitle('dr-perf', 'Perf primary'))
.hasText('Perf primary');
assert
.dom(REPLICATION_CARD_SELECTORS.getStateTooltipTitle('dr-perf', 'Perf primary'))
.hasText('running');
assert
.dom(REPLICATION_CARD_SELECTORS.getStateTooltipIcon('dr-perf', 'Perf primary', 'check-circle'))
.exists();
});
});
});

View File

@@ -12,40 +12,10 @@ import { pollCluster } from 'vault/tests/helpers/poll-cluster';
import { create } from 'ember-cli-page-object';
import flashMessage from 'vault/tests/pages/components/flash-message';
import ss from 'vault/tests/pages/components/search-select';
import { disableReplication } from 'vault/tests/helpers/replication';
const searchSelect = create(ss);
const flash = create(flashMessage);
const disableReplication = async (type, assert) => {
// disable performance replication
await visit(`/vault/replication/${type}`);
if (findAll('[data-test-replication-link="manage"]').length) {
await click('[data-test-replication-link="manage"]');
await click('[data-test-disable-replication] button');
const typeDisplay = type === 'dr' ? 'Disaster Recovery' : 'Performance';
await fillIn('[data-test-confirmation-modal-input="Disable Replication?"]', typeDisplay);
await click('[data-test-confirm-button]');
await settled(); // eslint-disable-line
if (assert) {
// bypassing for now -- remove if tests pass reliably
// assert.strictEqual(
// flash.latestMessage,
// 'This cluster is having replication disabled. Vault will be unavailable for a brief period and will resume service shortly.',
// 'renders info flash when disabled'
// );
assert.ok(
await waitUntil(() => currentURL() === '/vault/replication'),
'redirects to the replication page'
);
}
await settled();
}
};
module('Acceptance | Enterprise | replication', function (hooks) {
setupApplicationTest(hooks);

View File

@@ -42,7 +42,7 @@ module('Acceptance | mfa-login', function (hooks) {
await click('[data-test-auth-submit]');
};
const didLogin = (assert) => {
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backends', 'Route transitions after login');
assert.strictEqual(currentRouteName(), 'vault.cluster.dashboard', 'Route transitions after login');
};
const validate = async (multi) => {
await fillIn('[data-test-mfa-passcode="0"]', 'test');

View File

@@ -453,6 +453,7 @@ module('Acceptance | secrets/database/*', function (hooks) {
await authPage.logout();
// Check with restricted permissions
await authPage.login(token);
await click('[data-test-sidebar-nav-link="Secrets engines"]');
assert.dom(`[data-test-auth-backend-link="${backend}"]`).exists('Shows backend on secret list page');
await navToConnection(backend, connection);
assert.strictEqual(

View File

@@ -709,6 +709,8 @@ module('Acceptance | secrets/secret/create, read, delete', function (hooks) {
await settled();
await authPage.login(userToken);
await settled();
// on login users are directed to dashboard, so we would need to visit the vault secrets page to click on an engine
await visit('vault/secrets');
// test if metadata tab there with no read access message and no ability to edit.
await click(`[data-test-auth-backend-link=${enginePath}]`);
assert

View File

@@ -27,8 +27,13 @@ module('Acceptance | sidebar navigation', function (hooks) {
return authPage.login();
});
test('it should navigate back to the dashboard when logo is clicked', async function (assert) {
await click('[data-test-sidebar-logo]');
assert.strictEqual(currentURL(), '/vault/dashboard', 'dashboard route renders');
});
test('it should link to correct routes at the cluster level', async function (assert) {
assert.expect(10);
assert.expect(11);
assert.dom(panel('Cluster')).exists('Cluster nav panel renders');
@@ -50,6 +55,7 @@ module('Acceptance | sidebar navigation', function (hooks) {
{ label: 'Raft Storage', route: '/vault/storage/raft' },
{ label: 'Seal Vault', route: '/vault/settings/seal' },
{ label: 'Secrets engines', route: '/vault/secrets' },
{ label: 'Dashboard', route: '/vault/dashboard' },
];
for (const l of links) {

View File

@@ -39,14 +39,22 @@ module('Acceptance | wrapped_token query param functionality', function (hooks)
const token = await setupWrapping();
await auth.visit({ wrapped_token: token });
await settled();
assert.strictEqual(currentURL(), '/vault/secrets', 'authenticates and redirects to home');
assert.strictEqual(
currentURL(),
'/vault/dashboard',
'authenticates and redirects to home (dashboard page)'
);
});
test('it authenticates when used with the with=token query param', async function (assert) {
const token = await setupWrapping();
await auth.visit({ wrapped_token: token, with: 'token' });
await settled();
assert.strictEqual(currentURL(), '/vault/secrets', 'authenticates and redirects to home');
assert.strictEqual(
currentURL(),
'/vault/dashboard',
'authenticates and redirects to home (dashboard page)'
);
});
test('it should authenticate when hitting logout url with wrapped_token when logged out', async function (assert) {
@@ -55,6 +63,10 @@ module('Acceptance | wrapped_token query param functionality', function (hooks)
});
await visit(`/vault/logout?wrapped_token=1234`);
assert.strictEqual(currentURL(), '/vault/secrets', 'authenticates and redirects to home');
assert.strictEqual(
currentURL(),
'/vault/dashboard',
'authenticates and redirects to home (dashboard page)'
);
});
});

View File

@@ -0,0 +1,11 @@
const SELECTORS = {
searchSelect: '.search-select',
secretsEnginesSelect: '[data-test-secrets-engines-select]',
actionSelect: '[data-test-select="action-select"]',
emptyState: '[data-test-no-mount-selected-empty]',
paramsTitle: '[data-test-search-select-params-title]',
paramSelect: '[data-test-param-select]',
getActionButton: (action) => `[data-test-button="${action}"]`,
};
export default SELECTORS;

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
const SELECTORS = {
getReplicationTitle: (type, name) => `[data-test-${type}-replication] [data-test-title="${name}"]`,
getStateTooltipTitle: (type, name) => `[data-test-${type}-replication] [data-test-tooltip-title="${name}"]`,
getStateTooltipIcon: (type, name, icon) =>
`[data-test-${type}-replication] [data-test-tooltip-title="${name}"] [data-test-icon="${icon}"]`,
drOnlyStateSubText: '[data-test-dr-replication] [data-test-subtext="state"]',
knownSecondariesLabel: '[data-test-stat-text="known secondaries"] .stat-label',
knownSecondariesSubtext: '[data-test-stat-text="known secondaries"] .stat-text',
knownSecondariesValue: '[data-test-stat-text="known secondaries"] .stat-value',
replicationEmptyState: '[data-test-replication-card] [data-test-component="empty-state"]',
replicationEmptyStateTitle:
'[data-test-replication-card] [data-test-component="empty-state"] .empty-state-title',
replicationEmptyStateMessage:
'[data-test-replication-card] [data-test-component="empty-state"] .empty-state-message',
replicationEmptyStateActions:
'[data-test-replication-card] [data-test-component="empty-state"] .empty-state-actions',
replicationEmptyStateActionsLink:
'[data-test-replication-card] [data-test-component="empty-state"] .empty-state-actions a',
};
export default SELECTORS;

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
const SELECTORS = {
cardTitle: '[data-test-dashboard-secrets-engines-header]',
secretEnginesTableRows: '[data-test-dashboard-secrets-engines-table] tr',
getSecretEngineAccessor: (engineId) => `[data-test-secrets-engines-row=${engineId}] [data-test-accessor]`,
getSecretEngineDescription: (engineId) =>
`[data-test-secrets-engines-row=${engineId}] [data-test-description]`,
};
export default SELECTORS;

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
const SELECTORS = {
cardTitle: '[data-test-configuration-details-title]',
apiAddr: '[data-test-vault-config-details="api_addr"]',
defaultLeaseTtl: '[data-test-vault-config-details="default_lease_ttl"]',
maxLeaseTtl: '[data-test-vault-config-details="max_lease_ttl"]',
tlsDisable: '[data-test-vault-config-details="tls_disable"]',
logFormat: '[data-test-vault-config-details="log_format"]',
logLevel: '[data-test-vault-config-details="log_level"]',
storageType: '[data-test-vault-config-details="type"]',
};
export default SELECTORS;

View File

@@ -0,0 +1,31 @@
import { click, fillIn, findAll, currentURL, visit, settled, waitUntil } from '@ember/test-helpers';
export const disableReplication = async (type, assert) => {
// disable performance replication
await visit(`/vault/replication/${type}`);
if (findAll('[data-test-replication-link="manage"]').length) {
await click('[data-test-replication-link="manage"]');
await click('[data-test-disable-replication] button');
const typeDisplay = type === 'dr' ? 'Disaster Recovery' : 'Performance';
await fillIn('[data-test-confirmation-modal-input="Disable Replication?"]', typeDisplay);
await click('[data-test-confirm-button]');
await settled(); // eslint-disable-line
if (assert) {
// bypassing for now -- remove if tests pass reliably
// assert.strictEqual(
// flash.latestMessage,
// 'This cluster is having replication disabled. Vault will be unavailable for a brief period and will resume service shortly.',
// 'renders info flash when disabled'
// );
assert.ok(
await waitUntil(() => currentURL() === '/vault/replication'),
'redirects to the replication page'
);
}
await settled();
}
};

View File

@@ -0,0 +1,142 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, click } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import timestamp from 'core/utils/timestamp';
import { parseAPITimestamp } from 'core/utils/date-formatters';
module('Integration | Component | dashboard/client-count-card', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.license = {
startTime: '2018-04-03T14:15:30',
};
});
test('it should display client count information', async function (assert) {
this.server.get('sys/internal/counters/activity', () => {
return {
request_id: 'some-activity-id',
data: {
months: [
{
timestamp: '2023-08-01T00:00:00-07:00',
counts: {},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {},
mounts: [{ mount_path: 'auth/up2/', counts: {} }],
},
],
new_clients: {
counts: {
clients: 12,
},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {
clients: 12,
},
mounts: [{ mount_path: 'auth/up2/', counts: {} }],
},
],
},
},
],
total: {
clients: 300417,
entity_clients: 73150,
non_entity_clients: 227267,
},
},
};
});
await render(hbs`<Dashboard::ClientCountCard @license={{this.license}} />`);
assert.dom('[data-test-client-count-title]').hasText('Client count');
assert.dom('[data-test-stat-text="total-clients"] .stat-label').hasText('Total');
assert
.dom('[data-test-stat-text="total-clients"] .stat-text')
.hasText(
`The number of clients in this billing period (Apr 2018 - ${parseAPITimestamp(
timestamp.now().toISOString(),
'MMM yyyy'
)}).`
);
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('300,417');
assert.dom('[data-test-stat-text="new-clients"] .stat-label').hasText('New');
assert
.dom('[data-test-stat-text="new-clients"] .stat-text')
.hasText('The number of clients new to Vault in the current month.');
assert.dom('[data-test-stat-text="new-clients"] .stat-value').hasText('12');
this.server.get('sys/internal/counters/activity', () => {
return {
request_id: 'some-activity-id',
data: {
months: [
{
timestamp: '2023-09-01T00:00:00-07:00',
counts: {},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {},
mounts: [{ mount_path: 'auth/up2/', counts: {} }],
},
],
new_clients: {
counts: {
clients: 5,
},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {
clients: 12,
},
mounts: [{ mount_path: 'auth/up2/', counts: {} }],
},
],
},
},
],
total: {
clients: 120,
entity_clients: 100,
non_entity_clients: 100,
},
},
};
});
await click('[data-test-refresh]');
assert.dom('[data-test-stat-text="total-clients"] .stat-label').hasText('Total');
assert
.dom('[data-test-stat-text="total-clients"] .stat-text')
.hasText(
`The number of clients in this billing period (Apr 2018 - ${parseAPITimestamp(
timestamp.now().toISOString(),
'MMM yyyy'
)}).`
);
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('120');
assert.dom('[data-test-stat-text="new-clients"] .stat-label').hasText('New');
assert
.dom('[data-test-stat-text="new-clients"] .stat-text')
.hasText('The number of clients new to Vault in the current month.');
assert.dom('[data-test-stat-text="new-clients"] .stat-value').hasText('5');
});
});

View File

@@ -0,0 +1,131 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { fillIn } from '@ember/test-helpers';
import { selectChoose } from 'ember-power-select/test-support/helpers';
import SELECTORS from 'vault/tests/helpers/components/dashboard/quick-actions-card';
module('Integration | Component | dashboard/quick-actions-card', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'kubernetes_f3400dee',
path: 'kubernetes-test/',
type: 'kubernetes',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'database_f3400dee',
path: 'database-test/',
type: 'database',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'pki_i1234dd',
path: 'apki-test/',
type: 'pki',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'secrets_j2350ii',
path: 'secrets-test/',
type: 'kv',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'nomad_123hh',
path: 'nomad/',
type: 'nomad',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'pki_f3400dee',
path: 'pki-0-test/',
type: 'pki',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'pki_i1234dd',
path: 'pki-1-test/',
description: 'pki-1-path-description',
type: 'pki',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'secrets_j2350ii',
path: 'secrets-1-test/',
type: 'kv',
},
});
this.secretsEngines = this.store.peekAll('secret-engine', {});
this.renderComponent = () => {
return render(hbs`<Dashboard::QuickActionsCard @secretsEngines={{this.secretsEngines}} />`);
};
});
test('it should show quick action empty state if no engine is selected', async function (assert) {
await this.renderComponent();
assert.dom('.title').hasText('Quick actions');
assert.dom(SELECTORS.secretsEnginesSelect).exists({ count: 1 });
assert.dom(SELECTORS.emptyState).exists({ count: 1 });
});
test('it should show correct actions for pki', async function (assert) {
await this.renderComponent();
await selectChoose(SELECTORS.secretsEnginesSelect, 'pki-0-test');
await fillIn(SELECTORS.actionSelect, 'Issue certificate');
assert.dom(SELECTORS.emptyState).doesNotExist();
await fillIn(SELECTORS.actionSelect, 'Issue certificate');
assert.dom(SELECTORS.getActionButton('Issue leaf certificate')).exists({ count: 1 });
assert.dom(SELECTORS.paramsTitle).hasText('Role to use');
await fillIn(SELECTORS.actionSelect, 'View certificate');
assert.dom(SELECTORS.paramsTitle).hasText('Certificate serial number');
assert.dom(SELECTORS.getActionButton('View certificate')).exists({ count: 1 });
await fillIn(SELECTORS.actionSelect, 'View issuer');
assert.dom(SELECTORS.paramsTitle).hasText('Issuer');
assert.dom(SELECTORS.getActionButton('View issuer')).exists({ count: 1 });
});
test('it should show correct actions for database', async function (assert) {
await this.renderComponent();
await selectChoose(SELECTORS.secretsEnginesSelect, 'database-test');
assert.dom(SELECTORS.emptyState).doesNotExist();
await fillIn(SELECTORS.actionSelect, 'Generate credentials for database');
assert.dom(SELECTORS.paramsTitle).hasText('Role to use');
assert.dom(SELECTORS.getActionButton('Generate credentials')).exists({ count: 1 });
});
test('it should show correct actions for kv', async function (assert) {
await this.renderComponent();
await selectChoose(SELECTORS.secretsEnginesSelect, 'secrets-1-test');
assert.dom(SELECTORS.emptyState).doesNotExist();
await fillIn(SELECTORS.actionSelect, 'Find KV secrets');
assert.dom(SELECTORS.paramsTitle).hasText('Secret path');
assert.dom(SELECTORS.getActionButton('Read secrets')).exists({ count: 1 });
});
});

View File

@@ -0,0 +1,180 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import timestamp from 'core/utils/timestamp';
import SELECTORS from 'vault/tests/helpers/components/dashboard/replication-card';
module('Integration | Component | dashboard/replication-card', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.replication = {
dr: {
clusterId: '123',
state: 'running',
},
performance: {
clusterId: 'abc-1',
state: 'running',
},
};
this.version = {
hasPerfReplication: true,
hasDRReplication: true,
};
this.updatedAt = timestamp.now().toISOString();
this.refresh = () => {};
});
test('it should display replication information if both dr and performance replication are enabled as features', async function (assert) {
await render(
hbs`
<Dashboard::ReplicationCard
@replication={{this.replication}}
@version={{this.version}}
@updatedAt={{this.updatedAt}}
@refresh={{this.refresh}} />
`
);
assert.dom(SELECTORS.getReplicationTitle('dr-perf', 'DR primary')).hasText('DR primary');
assert.dom(SELECTORS.getStateTooltipTitle('dr-perf', 'DR primary')).hasText('running');
assert.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'DR primary', 'check-circle')).exists();
assert.dom(SELECTORS.getReplicationTitle('dr-perf', 'Perf primary')).hasText('Perf primary');
assert.dom(SELECTORS.getStateTooltipTitle('dr-perf', 'Perf primary')).hasText('running');
assert.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'Perf primary', 'check-circle')).exists();
});
test('it should display replication information if both dr and performance replication are enabled as features and only dr is setup', async function (assert) {
this.replication = {
dr: {
clusterId: '123',
state: 'running',
},
performance: {
clusterId: '',
},
};
await render(
hbs`
<Dashboard::ReplicationCard
@replication={{this.replication}}
@version={{this.version}}
@updatedAt={{this.updatedAt}}
@refresh={{this.refresh}} />
`
);
assert.dom(SELECTORS.getReplicationTitle('dr-perf', 'DR primary')).hasText('DR primary');
assert.dom(SELECTORS.getStateTooltipTitle('dr-perf', 'DR primary')).hasText('running');
assert.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'DR primary', 'check-circle')).exists();
assert
.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'DR primary', 'check-circle'))
.hasClass('has-text-success');
assert.dom(SELECTORS.getReplicationTitle('dr-perf', 'Perf primary')).hasText('Perf primary');
assert.dom(SELECTORS.getStateTooltipTitle('dr-perf', 'Perf primary')).hasText('not set up');
assert.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'Perf primary', 'x-circle')).exists();
assert
.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'Perf primary', 'x-circle'))
.hasClass('has-text-danger');
});
test('it should display only dr replication information if vault version only has hasDRReplication', async function (assert) {
this.version = {
hasPerfReplication: false,
hasDRReplication: true,
};
this.replication = {
dr: {
clusterId: '123',
state: 'running',
knownSecondaries: [{ id: 1 }],
},
};
await render(
hbs`
<Dashboard::ReplicationCard
@replication={{this.replication}}
@version={{this.version}}
@updatedAt={{this.updatedAt}}
@refresh={{this.refresh}} />
`
);
assert.dom(SELECTORS.getReplicationTitle('dr', 'state')).hasText('state');
assert.dom(SELECTORS.drOnlyStateSubText).hasText('The current operating state of the cluster.');
assert.dom(SELECTORS.getStateTooltipTitle('dr', 'state')).hasText('running');
assert.dom(SELECTORS.getStateTooltipIcon('dr', 'state', 'check-circle')).exists();
assert.dom(SELECTORS.getStateTooltipIcon('dr', 'state', 'check-circle')).hasClass('has-text-success');
assert.dom(SELECTORS.knownSecondariesLabel).hasText('known secondaries');
assert.dom(SELECTORS.knownSecondariesSubtext).hasText('Number of secondaries connected to this primary.');
assert.dom(SELECTORS.knownSecondariesValue).hasText('1');
});
test('it should show correct icons if dr and performance replication is idle or shutdown states', async function (assert) {
this.replication = {
dr: {
clusterId: 'abc',
state: 'idle',
},
performance: {
clusterId: 'def',
state: 'shutdown',
},
};
await render(
hbs`
<Dashboard::ReplicationCard
@replication={{this.replication}}
@version={{this.version}}
@updatedAt={{this.updatedAt}}
@refresh={{this.refresh}} />
`
);
assert.dom(SELECTORS.getReplicationTitle('dr-perf', 'DR primary')).hasText('DR primary');
assert.dom(SELECTORS.getStateTooltipTitle('dr-perf', 'DR primary')).hasText('idle');
assert.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'DR primary', 'x-square')).exists();
assert
.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'DR primary', 'x-square'))
.hasClass('has-text-danger');
assert.dom(SELECTORS.getReplicationTitle('dr-perf', 'Perf primary')).hasText('Perf primary');
assert.dom(SELECTORS.getStateTooltipTitle('dr-perf', 'Perf primary')).hasText('shutdown');
assert.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'Perf primary', 'x-circle')).exists();
assert
.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'Perf primary', 'x-circle'))
.hasClass('has-text-danger');
});
test('it should show empty state', async function (assert) {
this.replication = {
dr: {
clusterId: '',
},
performance: {
clusterId: '',
},
};
await render(
hbs`
<Dashboard::ReplicationCard
@replication={{this.replication}}
@version={{this.version}}
@updatedAt={{this.updatedAt}}
@refresh={{this.refresh}} />
`
);
assert.dom(SELECTORS.replicationEmptyState).exists();
assert.dom(SELECTORS.replicationEmptyStateTitle).hasText('Replication not set up');
assert
.dom(SELECTORS.replicationEmptyStateMessage)
.hasText('Data will be listed here. Enable a primary replication cluster to get started.');
assert.dom(SELECTORS.replicationEmptyStateActions).hasText('Enable replication');
});
});

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupMirage } from 'ember-cli-mirage/test-support';
import SELECTORS from 'vault/tests/helpers/components/dashboard/replication-card';
module('Integration | Component | dashboard/replication-state-text', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.name = 'DR Primary';
this.clusterState = {
glyph: 'circle-check',
isOk: true,
};
});
test('it displays replication states', async function (assert) {
await render(
hbs`
<Dashboard::ReplicationStateText
@name={{this.name}}
@version={{this.version}}
@subText={{this.subText}}
@clusterStates={{this.clusterStates}} />
`
);
assert.dom(SELECTORS.getReplicationTitle('dr-perf', 'DR primary')).hasText('DR primary');
assert.dom(SELECTORS.getStateTooltipTitle('dr-perf', 'DR primary')).hasText('running');
assert.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'DR primary', 'check-circle')).exists();
this.name = 'DR Primary';
this.clusterState = {
glyph: 'x-circle',
isOk: false,
};
await render(
hbs`
<Dashboard::ReplicationStateText
@name={{this.name}}
@version={{this.version}}
@subText={{this.subText}}
@clusterStates={{this.clusterStates}} />
`
);
assert.dom(SELECTORS.getReplicationTitle('dr-perf', 'Perf primary')).hasText('Perf primary');
assert.dom(SELECTORS.getStateTooltipTitle('dr-perf', 'Perf primary')).hasText('running');
assert.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'Perf primary', 'x-circle')).exists();
assert
.dom(SELECTORS.getStateTooltipIcon('dr-perf', 'Perf primary', 'x-circle'))
.hasClass('has-text-danger');
});
});

View File

@@ -0,0 +1,108 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import SELECTORS from 'vault/tests/helpers/components/dashboard/secrets-engines-card';
module('Integration | Component | dashboard/secrets-engines-card', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'kubernetes_f3400dee',
path: 'kubernetes-test/',
type: 'kubernetes',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'pki_i1234dd',
path: 'pki-test/',
type: 'pki',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'secrets_j2350ii',
path: 'secrets-test/',
type: 'kv',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'nomad_123hh',
path: 'nomad/',
type: 'nomad',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'pki_f3400dee',
path: 'pki-0-test/',
type: 'pki',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'pki_i1234dd',
path: 'pki-1-test/',
description: 'pki-1-path-description',
type: 'pki',
},
});
this.store.pushPayload('secret-engine', {
modelName: 'secret-engine',
data: {
accessor: 'secrets_j2350ii',
path: 'secrets-1-test/',
type: 'kv',
},
});
this.secretsEngines = this.store.peekAll('secret-engine', {});
this.renderComponent = () => {
return render(hbs`<Dashboard::SecretsEnginesCard @secretsEngines={{this.secretsEngines}} />`);
};
});
test('it should display only five secrets engines', async function (assert) {
await this.renderComponent();
assert.dom(SELECTORS.cardTitle).hasText('Secrets engines');
assert.dom(SELECTORS.secretEnginesTableRows).exists({ count: 5 });
});
test('it should display the secrets engines accessor and path', async function (assert) {
await this.renderComponent();
assert.dom(SELECTORS.cardTitle).hasText('Secrets engines');
assert.dom(SELECTORS.secretEnginesTableRows).exists({ count: 5 });
this.secretsEngines.slice(0, 5).forEach((engine) => {
assert.dom(SELECTORS.getSecretEngineAccessor(engine.id)).hasText(engine.accessor);
if (engine.description) {
assert.dom(SELECTORS.getSecretEngineDescription(engine.id)).hasText(engine.description);
} else {
assert.dom(SELECTORS.getSecretEngineDescription(engine.id)).doesNotExist(engine.description);
}
});
});
test('it adds disabled css styling to unsupported secret engines', async function (assert) {
await this.renderComponent();
assert.dom('[data-test-secrets-engines-row="nomad"] [data-test-view]').doesNotExist();
assert.dom('[data-test-icon="nomad"]').hasClass('has-text-grey');
});
});

View File

@@ -39,7 +39,7 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
await renderComponent();
assert
.dom('[data-test-sidebar-nav-link]')
.exists({ count: 1 }, 'Nav links are hidden other than secrets');
.exists({ count: 2 }, 'Nav links are hidden other than secrets and dashboard');
assert
.dom('[data-test-sidebar-nav-heading]')
.exists({ count: 1 }, 'Headings are hidden other than Vault');
@@ -47,6 +47,7 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
test('it should render nav links', async function (assert) {
const links = [
'Dashboard',
'Secrets engines',
'Access',
'Policies',

View File

@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'vault/tests/helpers';
module('Unit | Route | vault/cluster/dashboard', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
const route = this.owner.lookup('route:vault/cluster/dashboard');
assert.ok(route);
});
});