diff --git a/changelog/21057.txt b/changelog/21057.txt new file mode 100644 index 000000000000..7ca81cd37632 --- /dev/null +++ b/changelog/21057.txt @@ -0,0 +1,3 @@ +```release-note:feature +**Dashboard UI**: Dashboard is now available in the UI as the new landing page. +``` \ No newline at end of file diff --git a/ui/app/components/dashboard/client-count-card.js b/ui/app/components/dashboard/client-count-card.js new file mode 100644 index 000000000000..bdb731185722 --- /dev/null +++ b/ui/app/components/dashboard/client-count-card.js @@ -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 + * + * ``` + * @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; + } + } +} diff --git a/ui/app/components/dashboard/learn-more-card.js b/ui/app/components/dashboard/learn-more-card.js new file mode 100644 index 000000000000..0da6a59d57ad --- /dev/null +++ b/ui/app/components/dashboard/learn-more-card.js @@ -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 + * + * ``` + */ + +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)', + }, + ]; + } +} diff --git a/ui/app/components/dashboard/quick-actions-card.js b/ui/app/components/dashboard/quick-actions-card.js new file mode 100644 index 000000000000..1edfac8a5e2b --- /dev/null +++ b/ui/app/components/dashboard/quick-actions-card.js @@ -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 + * + * ``` + */ + +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); + } +} diff --git a/ui/app/components/dashboard/secrets-engines-card.js b/ui/app/components/dashboard/secrets-engines-card.js new file mode 100644 index 000000000000..f95b4554f6e3 --- /dev/null +++ b/ui/app/components/dashboard/secrets-engines-card.js @@ -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 + * + * ``` + * @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); + } +} diff --git a/ui/app/components/dashboard/vault-configuration-details-card.js b/ui/app/components/dashboard/vault-configuration-details-card.js new file mode 100644 index 000000000000..51358497a338 --- /dev/null +++ b/ui/app/components/dashboard/vault-configuration-details-card.js @@ -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 + * + * ``` + * @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'; + } +} diff --git a/ui/app/components/dashboard/vault-version-title.js b/ui/app/components/dashboard/vault-version-title.js new file mode 100644 index 000000000000..e117481aea15 --- /dev/null +++ b/ui/app/components/dashboard/vault-version-title.js @@ -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 + * + * ``` + */ + +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]; + } +} diff --git a/ui/app/components/namespace-link.js b/ui/app/components/namespace-link.js index 7ed2c4c1a9ac..7e19e3148959 100644 --- a/ui/app/components/namespace-link.js +++ b/ui/app/components/namespace-link.js @@ -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)}`; }, }); diff --git a/ui/app/components/sidebar/frame.hbs b/ui/app/components/sidebar/frame.hbs index a386c5a04d8f..93266dce0714 100644 --- a/ui/app/components/sidebar/frame.hbs +++ b/ui/app/components/sidebar/frame.hbs @@ -11,7 +11,7 @@ <:logo> Vault + null), + isRootNamespace: this.namespace.inRootNamespace, + version: this.version, + vaultConfiguration: this.getVaultConfiguration(), + }); + } + + @action + refreshRoute() { + this.refresh(); + } +} diff --git a/ui/app/routes/vault/cluster/index.js b/ui/app/routes/vault/cluster/index.js index 4aeac95f8652..ba6f1fc58eb4 100644 --- a/ui/app/routes/vault/cluster/index.js +++ b/ui/app/routes/vault/cluster/index.js @@ -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'); }, }); diff --git a/ui/app/styles/components/secrets-engines-card.scss b/ui/app/styles/components/secrets-engines-card.scss new file mode 100644 index 000000000000..017f49759da8 --- /dev/null +++ b/ui/app/styles/components/secrets-engines-card.scss @@ -0,0 +1,3 @@ +.secrets-engines-card { + min-height: 480px; +} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index 9716fda68e3c..a104aded2549 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -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'; diff --git a/ui/app/styles/core/title.scss b/ui/app/styles/core/title.scss index f1fecec940e4..5d632b0491f5 100644 --- a/ui/app/styles/core/title.scss +++ b/ui/app/styles/core/title.scss @@ -47,6 +47,10 @@ &.is-7 { font-size: $size-7; } + + &.is-8 { + font-size: $size-8; + } } .form-section .title { diff --git a/ui/app/styles/helper-classes/flexbox-and-grid.scss b/ui/app/styles/helper-classes/flexbox-and-grid.scss index 3b9c4b4a7754..e04eef362a59 100644 --- a/ui/app/styles/helper-classes/flexbox-and-grid.scss +++ b/ui/app/styles/helper-classes/flexbox-and-grid.scss @@ -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; +} diff --git a/ui/app/styles/helper-classes/general.scss b/ui/app/styles/helper-classes/general.scss index 36b7c6443721..b4e0521a944b 100644 --- a/ui/app/styles/helper-classes/general.scss +++ b/ui/app/styles/helper-classes/general.scss @@ -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; +} diff --git a/ui/app/styles/helper-classes/spacing.scss b/ui/app/styles/helper-classes/spacing.scss index 1a51b746594e..66581cd96359 100644 --- a/ui/app/styles/helper-classes/spacing.scss +++ b/ui/app/styles/helper-classes/spacing.scss @@ -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; } diff --git a/ui/app/templates/components/auth-button-google.hbs b/ui/app/templates/components/auth-button-google.hbs index 984957270c2d..c8c96ad81742 100644 --- a/ui/app/templates/components/auth-button-google.hbs +++ b/ui/app/templates/components/auth-button-google.hbs @@ -69,7 +69,7 @@ -
+
Sign in with Google
\ No newline at end of file diff --git a/ui/app/templates/components/clients/dashboard.hbs b/ui/app/templates/components/clients/dashboard.hbs index 214914cfd99e..eb236d70cbb1 100644 --- a/ui/app/templates/components/clients/dashboard.hbs +++ b/ui/app/templates/components/clients/dashboard.hbs @@ -27,25 +27,7 @@ {{this.versionText.description}}

{{#if this.noActivityData}} - {{#if (eq @model.config.enabled "On")}} - - {{else}} - - {{#if @model.config.canEdit}} -

- - Go to configuration - -

- {{/if}} -
- {{/if}} + {{else if this.errorObject}} {{else}} diff --git a/ui/app/templates/components/clients/no-data.hbs b/ui/app/templates/components/clients/no-data.hbs new file mode 100644 index 000000000000..3c69c526f290 --- /dev/null +++ b/ui/app/templates/components/clients/no-data.hbs @@ -0,0 +1,19 @@ +{{#if (eq @config.enabled "On")}} + +{{else}} + + {{#if @config.canEdit}} +

+ + Go to configuration + +

+ {{/if}} +
+{{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/dashboard/client-count-card.hbs b/ui/app/templates/components/dashboard/client-count-card.hbs new file mode 100644 index 000000000000..8855f4e37e96 --- /dev/null +++ b/ui/app/templates/components/dashboard/client-count-card.hbs @@ -0,0 +1,58 @@ + +
+

+ Client count +

+ + Details +
+ +
+ + {{#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 }} + + {{else}} + {{#if this.fetchClientActivity.isRunning}} + + {{else}} +
+ + +
+ +
+ + + Updated + {{date-format this.updatedAt "MMM dd, yyyy HH:mm:SS"}} + +
+ {{/if}} + {{/if}} +
\ No newline at end of file diff --git a/ui/app/templates/components/dashboard/learn-more-card.hbs b/ui/app/templates/components/dashboard/learn-more-card.hbs new file mode 100644 index 000000000000..69bef79a1891 --- /dev/null +++ b/ui/app/templates/components/dashboard/learn-more-card.hbs @@ -0,0 +1,26 @@ + +

Learn more

+
+ Explore the features of Vault and learn advance practices with the following tutorials and documentation. +
+
+ {{#each this.learnMoreLinks as |learnMoreLink|}} + {{#if + (or + (and learnMoreLink.requiredFeature @isEnterprise (has-feature learnMoreLink.requiredFeature)) + (not learnMoreLink.requiredFeature) + ) + }} + + + {{learnMoreLink.title}} + + {{/if}} + + {{/each}} +
+
\ No newline at end of file diff --git a/ui/app/templates/components/dashboard/quick-actions-card.hbs b/ui/app/templates/components/dashboard/quick-actions-card.hbs new file mode 100644 index 000000000000..6ebe801a5a8d --- /dev/null +++ b/ui/app/templates/components/dashboard/quick-actions-card.hbs @@ -0,0 +1,70 @@ + +

Quick actions

+ +
+

Secrets engines

+ +
+ + {{#if this.selectedEngine}} +

Action

+