diff --git a/ui/README.md b/ui/README.md index b5fb1a5110088..338c34618b48d 100644 --- a/ui/README.md +++ b/ui/README.md @@ -17,6 +17,7 @@ - [Writing Stories](#writing-stories) - [Adding a new story](#adding-a-new-story) - [Code Generators](#code-generators-1) + - [Storybook Deployment](#storybook-deployment) - [Further Reading / Useful Links](#further-reading--useful-links) @@ -67,6 +68,11 @@ long-form version of the npm script: Make use of the many generators for code, try `ember help generate` for more details. If you're using a component that can be widely-used, consider making it an `addon` component instead (see [this PR](https://github.com/hashicorp/vault/pull/6629) for more details) +eg. a reusable component named foo that you'd like in the core engine + +- `ember g component foo --in lib/core` +- `echo "export { default } from 'core/components/foo';" > lib/core/app/components/foo.js` + ### Running Tests Running tests will spin up a Vault dev server on port 9200 via a @@ -158,7 +164,7 @@ Note that placing a param inside brackets (e.g. `[closedLabel=More options]` ind 2. Generate a new story with `ember generate story [name-of-component]` 3. Inside the newly generated `stories` file, add at least one example of the component. If the component should be interactive, enable the [Storybook Knobs addon](https://github.com/storybooks/storybook/tree/master/addons/knobs). -4. Generate the `notes` file for the component with `yarn gen-story-md [name-of-component]` (e.g. `yarn gen-md alert-banner`). This will generate markdown documentation of the component and place it at `vault/ui/stories/[name-of-component].md`. If your component is a template-only component, you will need to manually create the markdown file. +4. Generate the `notes` file for the component with `yarn gen-story-md [name-of-component] [name-of-engine-or-addon]` (e.g. `yarn gen-md alert-banner core`). This will generate markdown documentation of the component and place it at `vault/ui/stories/[name-of-component].md`. If your component is a template-only component, you will need to manually create the markdown file. See the [Storybook Docs](https://storybook.js.org/docs/basics/introduction/) for more information on writing stories. diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js index 110060a6eab11..dd7d17220a8a9 100644 --- a/ui/app/adapters/cluster.js +++ b/ui/app/adapters/cluster.js @@ -183,7 +183,10 @@ export default ApplicationAdapter.extend({ }, generateDrOperationToken(data, options) { - const verb = options && options.checkStatus ? 'GET' : 'PUT'; + let verb = options && options.checkStatus ? 'GET' : 'PUT'; + if (options.cancel) { + verb = 'DELETE'; + } let url = `${this.buildURL()}/replication/dr/secondary/generate-operation-token/`; if (!data || data.pgp_key || data.attempt) { // start the generation diff --git a/ui/app/adapters/replication-mode.js b/ui/app/adapters/replication-mode.js new file mode 100644 index 0000000000000..baa09570f821b --- /dev/null +++ b/ui/app/adapters/replication-mode.js @@ -0,0 +1,14 @@ +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + getStatusUrl(mode) { + return this.buildURL() + `/replication/${mode}/status`; + }, + + fetchStatus(mode) { + let url = this.getStatusUrl(mode); + return this.ajax(url, 'GET', { unauthenticated: true }).then(resp => { + return resp.data; + }); + }, +}); diff --git a/ui/app/app.js b/ui/app/app.js index 067a6f8c8744a..509014ac6830e 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -31,6 +31,9 @@ App = Application.extend({ 'version', 'wizard', ], + externalRoutes: { + replication: 'vault.cluster.replication.index', + }, }, }, kmip: { diff --git a/ui/app/components/shamir-modal-flow.js b/ui/app/components/shamir-modal-flow.js new file mode 100644 index 0000000000000..2a06e6f185618 --- /dev/null +++ b/ui/app/components/shamir-modal-flow.js @@ -0,0 +1,39 @@ +/** + * @module ShamirModalFlow + * ShamirModalFlow is an extension of the ShamirFlow component that does the Generate Action Token workflow inside of a Modal. + * Please note, this is not an extensive list of the required parameters -- please see ShamirFlow for others + * + * @example + * ```js + * This copy is the main paragraph when the token flow has not started + * ``` + * @param {function} onClose - This function will be triggered when the modal intends to be closed + */ +import { inject as service } from '@ember/service'; +import ShamirFlow from './shamir-flow'; +import layout from '../templates/components/shamir-modal-flow'; + +export default ShamirFlow.extend({ + layout, + store: service(), + onClose: () => {}, + actions: { + onCancelClose() { + if (this.encoded_token) { + this.send('reset'); + } else if (this.generateAction && !this.started) { + if (this.generateStep !== 'chooseMethod') { + this.send('reset'); + } + } else { + const adapter = this.get('store').adapterFor('cluster'); + adapter.generateDrOperationToken(this.model, { cancel: true }); + this.send('reset'); + } + this.onClose(); + }, + onClose() { + this.onClose(); + }, + }, +}); diff --git a/ui/app/controllers/vault/cluster/replication-dr-promote.js b/ui/app/controllers/vault/cluster/replication-dr-promote/index.js similarity index 51% rename from ui/app/controllers/vault/cluster/replication-dr-promote.js rename to ui/app/controllers/vault/cluster/replication-dr-promote/index.js index 35ca35f0e782a..0adb652d4f549 100644 --- a/ui/app/controllers/vault/cluster/replication-dr-promote.js +++ b/ui/app/controllers/vault/cluster/replication-dr-promote/index.js @@ -3,4 +3,9 @@ import Controller from '@ember/controller'; export default Controller.extend({ queryParams: ['action'], action: '', + actions: { + onPromote() { + this.transitionToRoute('vault.cluster.replication.mode.index', 'dr'); + }, + }, }); diff --git a/ui/app/mixins/cluster-route.js b/ui/app/mixins/cluster-route.js index b11940db71425..b16695627493b 100644 --- a/ui/app/mixins/cluster-route.js +++ b/ui/app/mixins/cluster-route.js @@ -9,6 +9,7 @@ const CLUSTER = 'vault.cluster'; const CLUSTER_INDEX = 'vault.cluster.index'; const OIDC_CALLBACK = 'vault.cluster.oidc-callback'; const DR_REPLICATION_SECONDARY = 'vault.cluster.replication-dr-promote'; +const DR_REPLICATION_SECONDARY_DETAILS = 'vault.cluster.replication-dr-promote.details'; const EXCLUDED_REDIRECT_URLS = ['/vault/logout']; export { INIT, UNSEAL, AUTH, CLUSTER, CLUSTER_INDEX, DR_REPLICATION_SECONDARY }; @@ -70,6 +71,13 @@ export default Mixin.create({ return UNSEAL; } if (get(cluster, 'dr.isSecondary')) { + if (transition && transition.targetName === DR_REPLICATION_SECONDARY_DETAILS) { + return DR_REPLICATION_SECONDARY_DETAILS; + } + if (this.router.currentRouteName === DR_REPLICATION_SECONDARY_DETAILS) { + return DR_REPLICATION_SECONDARY_DETAILS; + } + return DR_REPLICATION_SECONDARY; } if (!isAuthed) { diff --git a/ui/app/models/cluster.js b/ui/app/models/cluster.js index f7542c1ce2e96..dea9ae2317ddc 100644 --- a/ui/app/models/cluster.js +++ b/ui/app/models/cluster.js @@ -46,57 +46,22 @@ export default DS.Model.extend({ //otherwise the particular mode will have the relevant mode attr through replication-attributes mode: attr('string'), allReplicationDisabled: and('{dr,performance}.replicationDisabled'), - anyReplicationEnabled: or('{dr,performance}.replicationEnabled'), - stateDisplay(state) { - if (!state) { - return null; - } - const defaultDisp = 'Synced'; - const displays = { - 'stream-wals': 'Streaming', - 'merkle-diff': 'Determining sync status', - 'merkle-sync': 'Syncing', - }; - - return displays[state] || defaultDisp; - }, - - drStateDisplay: computed('dr.state', function() { - return this.stateDisplay(this.get('dr.state')); - }), - - performanceStateDisplay: computed('performance.state', function() { - return this.stateDisplay(this.get('performance.state')); - }), - - stateGlyph(state) { - const glyph = 'check-circle-outline'; - - const glyphs = { - 'stream-wals': 'android-sync', - 'merkle-diff': 'android-sync', - 'merkle-sync': null, - }; - - return glyphs[state] || glyph; - }, - - drStateGlyph: computed('dr.state', function() { - return this.stateGlyph(this.get('dr.state')); - }), - - performanceStateGlyph: computed('performance.state', function() { - return this.stateGlyph(this.get('performance.state')); - }), - dr: fragment('replication-attributes'), performance: fragment('replication-attributes'), // this service exposes what mode the UI is currently viewing // replicationAttrs will then return the relevant `replication-attributes` fragment rm: service('replication-mode'), replicationMode: alias('rm.mode'), + replicationModeForDisplay: computed('replicationMode', function() { + return this.replicationMode === 'dr' ? 'Disaster Recovery' : 'Performance'; + }), + replicationIsInitializing: computed('dr.mode', 'performance.mode', function() { + // a mode of null only happens when a cluster is being initialized + // otherwise the mode will be 'disabled', 'primary', 'secondary' + return !this.dr.mode || !this.performance.mode; + }), replicationAttrs: computed('dr.mode', 'performance.mode', 'replicationMode', function() { const replicationMode = this.get('replicationMode'); return replicationMode ? get(this, replicationMode) : null; diff --git a/ui/app/models/replication-attributes.js b/ui/app/models/replication-attributes.js index 24c2f464ea6fa..55bba8104b784 100644 --- a/ui/app/models/replication-attributes.js +++ b/ui/app/models/replication-attributes.js @@ -18,16 +18,25 @@ export default Fragment.extend({ isPrimary: match('mode', /primary/), knownSecondaries: attr('array'), + secondaries: attr('array'), // secondary attrs isSecondary: match('mode', /secondary/), - + connection_state: attr('string'), modeForUrl: computed('mode', function() { const mode = this.get('mode'); return mode === 'bootstrapping' ? 'bootstrapping' : (this.get('isSecondary') && 'secondary') || (this.get('isPrimary') && 'primary'); }), + modeForHeader: computed('mode', function() { + const mode = this.mode; + if (!mode) { + // mode will be false or undefined if it calls the status endpoint while still setting up the cluster + return 'loading'; + } + return mode; + }), secondaryId: attr('string'), primaryClusterAddr: attr('string'), knownPrimaryClusterAddrs: attr('array'), diff --git a/ui/app/models/replication-mode.js b/ui/app/models/replication-mode.js new file mode 100644 index 0000000000000..df79d46ac94f9 --- /dev/null +++ b/ui/app/models/replication-mode.js @@ -0,0 +1,38 @@ +import DS from 'ember-data'; +const { attr } = DS; + +/* sample response + +{ + "request_id": "d81bba81-e8a1-0ee9-240e-a77d36e3e08f", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "cluster_id": "ab7d4191-d1a3-b4d6-6297-5a41af6154ae", + "known_secondaries": [ + "test" + ], + "last_performance_wal": 72, + "last_reindex_epoch": "1588281113", + "last_wal": 73, + "merkle_root": "c8d258d376f01d98156f74e8d8f82ea2aca8dc4a", + "mode": "primary", + "primary_cluster_addr": "", + "reindex_building_progress": 26838, + "reindex_building_total": 305443, + "reindex_in_progress": true, + "reindex_stage": "building", + "state": "running" + }, + "wrap_info": null, + "warnings": null, + "auth": null +} + + +*/ + +export default DS.Model.extend({ + status: attr('object'), +}); diff --git a/ui/app/router.js b/ui/app/router.js index 2682227e02656..916dacbb88292 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -127,7 +127,9 @@ Router.map(function() { this.route('show', { path: '/:policy_name' }); this.route('edit', { path: '/:policy_name/edit' }); }); - this.route('replication-dr-promote'); + this.route('replication-dr-promote', function() { + this.route('details'); + }); if (config.addRootMounts) { config.addRootMounts.call(this); } diff --git a/ui/app/routes/vault/cluster/replication-dr-promote.js b/ui/app/routes/vault/cluster/replication-dr-promote/details.js similarity index 83% rename from ui/app/routes/vault/cluster/replication-dr-promote.js rename to ui/app/routes/vault/cluster/replication-dr-promote/details.js index 84de8c5881bec..3d716ad990dbf 100644 --- a/ui/app/routes/vault/cluster/replication-dr-promote.js +++ b/ui/app/routes/vault/cluster/replication-dr-promote/details.js @@ -1,5 +1,5 @@ import { inject as service } from '@ember/service'; -import Base from './cluster-route-base'; +import Base from '../cluster-route-base'; export default Base.extend({ replicationMode: service(), diff --git a/ui/app/routes/vault/cluster/replication-dr-promote/index.js b/ui/app/routes/vault/cluster/replication-dr-promote/index.js new file mode 100644 index 0000000000000..3d716ad990dbf --- /dev/null +++ b/ui/app/routes/vault/cluster/replication-dr-promote/index.js @@ -0,0 +1,10 @@ +import { inject as service } from '@ember/service'; +import Base from '../cluster-route-base'; + +export default Base.extend({ + replicationMode: service(), + beforeModel() { + this._super(...arguments); + this.get('replicationMode').setMode('dr'); + }, +}); diff --git a/ui/app/serializers/replication-mode.js b/ui/app/serializers/replication-mode.js new file mode 100644 index 0000000000000..2e6e9210bf9f3 --- /dev/null +++ b/ui/app/serializers/replication-mode.js @@ -0,0 +1,12 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const normalizedPayload = { + id: payload.id, + status: payload.data, + }; + + return this._super(store, primaryModelClass, normalizedPayload, id, requestType); + }, +}); diff --git a/ui/app/styles/components/action-block.scss b/ui/app/styles/components/action-block.scss new file mode 100644 index 0000000000000..137b4fa540c9e --- /dev/null +++ b/ui/app/styles/components/action-block.scss @@ -0,0 +1,67 @@ +@mixin stacked-grid { + grid-template-columns: 1fr; + grid-row: 1/1; +} +@mixin stacked-content { + margin-bottom: $spacing-l; +} + +.action-block { + @extend .selectable-card; + grid-template-columns: 2fr 1fr; + display: grid; + padding: $spacing-m $spacing-l; + line-height: inherit; + grid-gap: $spacing-m; + + @include until($mobile) { + @include stacked-grid(); + } +} + +.action-block-info { + @include until($mobile) { + @include stacked-content(); + } +} + +.action-block.stacked { + @include stacked-grid(); +} +.stacked > .action-block-info { + @include stacked-content(); +} + +.action-block-title { + font-size: $size-5; + font-weight: $font-weight-bold; +} +.action-block-action { + text-align: right; + @include until($mobile) { + text-align: left; + } +} + +/* Action Block Grid */ +.replication-actions-grid-layout { + display: flex; + flex-wrap: wrap; + margin: $spacing-m 0; + @include until($tablet) { + display: block; + } +} + +.replication-actions-grid-item { + flex-basis: 50%; + padding: $spacing-s; +} + +.replication-actions-grid-item .action-block { + height: 100%; + width: 100%; + @include until($tablet) { + height: inherit; + } +} diff --git a/ui/app/styles/components/empty-state.scss b/ui/app/styles/components/empty-state.scss index b023403cfadb4..fb4d4b6e6c49f 100644 --- a/ui/app/styles/components/empty-state.scss +++ b/ui/app/styles/components/empty-state.scss @@ -1,8 +1,8 @@ .empty-state { align-items: center; color: $grey; - display: flex; background: $ui-gray-010; + display: flex; justify-content: center; padding: $spacing-xxl $spacing-s; box-shadow: 0 -2px 0 -1px $ui-gray-300; @@ -36,3 +36,8 @@ margin-left: $spacing-s; } } + +.empty-state-icon > .hs-icon { + float: left; + margin-right: $spacing-xs; +} diff --git a/ui/app/styles/components/info-table-row.scss b/ui/app/styles/components/info-table-row.scss index bca18fd24b7c1..d3a0193c6369f 100644 --- a/ui/app/styles/components/info-table-row.scss +++ b/ui/app/styles/components/info-table-row.scss @@ -17,6 +17,8 @@ } .column { + align-self: center; + &.info-table-row-edit { padding-bottom: 0.3rem; padding-top: 0.3rem; @@ -25,6 +27,10 @@ textarea { min-height: 35px; } + + .helper-text { + font-weight: normal; + } } .hs-icon { diff --git a/ui/app/styles/components/info-table.scss b/ui/app/styles/components/info-table.scss new file mode 100644 index 0000000000000..00d9678a13d23 --- /dev/null +++ b/ui/app/styles/components/info-table.scss @@ -0,0 +1,9 @@ +.info-table { + &.vlt-table td { + padding-top: 0px; + padding-bottom: 0px; + } + .info-table-row { + box-shadow: none; + } +} diff --git a/ui/app/styles/components/known-secondaries-card.scss b/ui/app/styles/components/known-secondaries-card.scss new file mode 100644 index 0000000000000..63f1025be2f2e --- /dev/null +++ b/ui/app/styles/components/known-secondaries-card.scss @@ -0,0 +1,18 @@ +.selectable-card.secondaries { + grid-column: 2/3; + grid-row: 1/3; + + @include until($mobile) { + grid-column: 1/1; + grid-row: 1/1; + } + + .secondaries-table { + margin-bottom: $spacing-s; + } + + .link { + font-size: $size-7; + text-decoration: none; + } +} diff --git a/ui/app/styles/components/modal.scss b/ui/app/styles/components/modal.scss index fe784140d97b5..fe629265737ae 100644 --- a/ui/app/styles/components/modal.scss +++ b/ui/app/styles/components/modal.scss @@ -5,6 +5,8 @@ .modal-card { box-shadow: $box-shadow-highest; border: 1px solid $grey-light; + max-height: calc(100vh - 70px); + margin-top: 60px; &-head { border-radius: 0; @@ -39,6 +41,38 @@ } } +.modal-card-title.title { + display: flex; + align-items: center; +} + pre { background-color: inherit; } + +.is-highlight { + .modal-card-head { + background: $yellow-010; + border: 1px solid $yellow-100; + } + .modal-card-title { + color: $yellow-dark; + } +} + +.modal-confirm-section .is-help { + color: $grey; + margin: $spacing-xxs 0; + strong { + color: inherit; + } +} + +.modal-confirm-section { + margin: $spacing-xl 0 $spacing-m; +} + +.modal-card-foot-outlined { + background: #f7f8fa; + border-top: 1px solid #bac1cc; +} diff --git a/ui/app/styles/components/replication-dashboard.scss b/ui/app/styles/components/replication-dashboard.scss new file mode 100644 index 0000000000000..3cca15c76a63c --- /dev/null +++ b/ui/app/styles/components/replication-dashboard.scss @@ -0,0 +1,131 @@ +.replication-dashboard { + box-shadow: none; + + .selectable-card { + line-height: normal; + + &:hover { + box-shadow: 0 0 0 1px rgba($grey-dark, 0.3); + } + + .toolbar-link { + color: $blue-500; + } + } + + .helper-text { + font-weight: $font-weight-normal; + } + + .title.is-6 { + margin-bottom: $spacing-xs; + } + + .reindexing-alert, + .syncing-alert { + margin-top: $spacing-xl; + } + + .selectable-card-container { + margin-top: $spacing-xl; + display: grid; + + &.primary, + .summary { + margin: 2rem 0 2rem 0; + grid-template-columns: 1fr 2fr; + + @include until($mobile) { + grid-template-columns: 1fr; + } + } + + &.secondary { + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + grid-gap: $spacing-xl; + } + + .card-container { + display: grid; + grid-gap: $spacing-s; + grid-template-columns: 1fr 1fr; + grid-template-rows: 0.2fr 0.2fr 0.2fr; + padding: $spacing-l; + line-height: 1.5; + + &.summary { + grid-template-rows: 0.2fr 1fr 0.2fr 1fr; + } + + &.has-border-danger:hover { + box-shadow: none; + } + + @include until(1320px) { + // prevent an issue with the card descriptions wrapping and expanding height + min-height: 250px; + } + + .grid-item-top-left { + grid-column: 1 / span 1; + display: flex; + } + .grid-item-top-right { + grid-column: 2 / span 1; + justify-self: right; + } + .grid-item-left { + grid-column: 1/1; + grid-row: 2/2; + } + .grid-item-right { + grid-column: 2/2; + grid-row: 2/2; + } + + .grid-item-bottom-left { + grid-column: 1/1; + grid-row: 3/3; + display: flex; + align-items: center; + } + .grid-item-bottom-right { + grid-column: 2/2; + grid-row: 3/3; + } + + .grid-item-second-row { + grid-column: 1 / span 2; + grid-row: 2/2; + } + + .grid-item-third-row { + grid-column: 1 / span 2; + grid-row: 3/4; + + .empty-state { + padding: 0px 12px; + box-shadow: none; + } + } + .grid-item-bottom-row { + grid-column: 1 / span 2; + grid-row: 4/4; + } + } + + &.summary { + margin-bottom: $spacing-xl; + } + } + .summary-state { + padding-bottom: $spacing-xl; + border-bottom: 1px solid rgba($grey-dark, 0.3); + } + + // prevent double lines at the bottom of the dashboard + &.box { + padding-bottom: 0; + padding-top: 1px; // at least 1px so border still shows + } +} diff --git a/ui/app/styles/components/replication-doc-link.scss b/ui/app/styles/components/replication-doc-link.scss new file mode 100644 index 0000000000000..6bf2d02dfc601 --- /dev/null +++ b/ui/app/styles/components/replication-doc-link.scss @@ -0,0 +1,8 @@ +.documentation-link { + margin: $spacing-s 0 $spacing-l 0; + float: right; + + .doc-link { + font-weight: normal; + } +} diff --git a/ui/app/styles/components/replication-header.scss b/ui/app/styles/components/replication-header.scss new file mode 100644 index 0000000000000..7a93426306da8 --- /dev/null +++ b/ui/app/styles/components/replication-header.scss @@ -0,0 +1,5 @@ +.replication-header { + .tabs-container { + margin-bottom: $spacing-l; + } +} diff --git a/ui/app/styles/components/replication-mode-summary.scss b/ui/app/styles/components/replication-mode-summary.scss new file mode 100644 index 0000000000000..27ba8a577026d --- /dev/null +++ b/ui/app/styles/components/replication-mode-summary.scss @@ -0,0 +1,11 @@ +.replication-description { + flex-shrink: 1; + + .title { + margin-bottom: $spacing-xs; + } + + .detail-tags { + margin-bottom: $spacing-m; + } +} diff --git a/ui/app/styles/components/replication-page.scss b/ui/app/styles/components/replication-page.scss new file mode 100644 index 0000000000000..8809a1f6dc1b4 --- /dev/null +++ b/ui/app/styles/components/replication-page.scss @@ -0,0 +1,10 @@ +.replication-page { + .empty-state { + background: none; + + .empty-state-message { + padding-bottom: $spacing-s; + border-bottom: 1px solid $grey-light; + } + } +} diff --git a/ui/app/styles/components/replication-primary-card.scss b/ui/app/styles/components/replication-primary-card.scss new file mode 100644 index 0000000000000..8cf99be14e845 --- /dev/null +++ b/ui/app/styles/components/replication-primary-card.scss @@ -0,0 +1,15 @@ +.replication { + .selectable-card { + display: initial; + line-height: normal; + padding: $spacing-l; + + &:hover { + box-shadow: 0 0 0 1px rgba($grey-dark, 0.3); + } + + .card-title { + margin-bottom: 2rem; + } + } +} diff --git a/ui/app/styles/components/replication-summary.scss b/ui/app/styles/components/replication-summary.scss new file mode 100644 index 0000000000000..7e308a2d4cd79 --- /dev/null +++ b/ui/app/styles/components/replication-summary.scss @@ -0,0 +1,9 @@ +.replication { + .toolbar { + border-top: 0px; + } + + .helper-text { + font-weight: normal; + } +} diff --git a/ui/app/styles/components/selectable-card.scss b/ui/app/styles/components/selectable-card.scss index a2939ceccd94b..4bdc15c1a1d1c 100644 --- a/ui/app/styles/components/selectable-card.scss +++ b/ui/app/styles/components/selectable-card.scss @@ -67,12 +67,21 @@ font-weight: 500; line-height: 1.33; } + + .vlt-table { + max-height: 200px; + overflow-y: auto; + } } .selectable-card.is-rounded { border-radius: $radius; } +.selectable-card.has-border-danger { + box-shadow: none; +} + .change-metric-icon.is-decrease { transform: rotate(135deg); } diff --git a/ui/app/styles/components/ui-wizard.scss b/ui/app/styles/components/ui-wizard.scss index 186bfadbe8205..3dd3397b57a06 100644 --- a/ui/app/styles/components/ui-wizard.scss +++ b/ui/app/styles/components/ui-wizard.scss @@ -191,7 +191,7 @@ } .progress-bar { - background: $ui-gray-100; + background: $progress-bar-background-color; box-shadow: inset 0 0 0 1px $ui-gray-200; display: flex; height: $wizard-progress-bar-height; diff --git a/ui/app/styles/components/vlt-table.scss b/ui/app/styles/components/vlt-table.scss index 2b61b079fec5b..c55633de6906c 100644 --- a/ui/app/styles/components/vlt-table.scss +++ b/ui/app/styles/components/vlt-table.scss @@ -4,6 +4,15 @@ height: 0; } + &.sticky-header { + thead th { + position: sticky; + background: #fff; + box-shadow: 0 1px 0px 0px rgba($grey-dark, 0.3); + top: 0; + } + } + th, td { padding: $spacing-s; @@ -34,4 +43,9 @@ td.no-padding { padding: 0; } + + code { + font-size: $size-7; + color: $black; + } } diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index a9c4d267b61ac..1a91cc109b878 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -60,9 +60,11 @@ @import './components/http-requests-bar-chart'; @import './components/http-requests-table'; @import './components/init-illustration'; +@import './components/info-table'; @import './components/info-table-row'; @import './components/input-hint'; @import './components/kmip-role-edit'; +@import './components/known-secondaries-card.scss'; @import './components/linked-block'; @import './components/list-item-row'; @import './components/list-pagination'; @@ -78,10 +80,19 @@ @import './components/radio-card'; @import './components/radial-progress'; @import './components/raft-join'; +@import './components/replication-dashboard'; +@import './components/replication-doc-link'; +@import './components/replication-header'; +@import './components/replication-mode-summary'; +@import './components/replication-page'; +@import './components/replication-primary-card'; +@import './components/replication-summary'; @import './components/role-item'; @import './components/search-select'; @import './components/selectable-card'; @import './components/selectable-card-container.scss'; +// action-block extends selectable-card +@import './components/action-block.scss'; @import './components/shamir-progress'; @import './components/sidebar'; @import './components/splash-page'; diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index 7ba1504256854..884ffd0dfcbde 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -39,6 +39,10 @@ display: flex; flex-direction: column; } +.is-flex-row { + display: flex; + flex-direction: row; +} .is-flex-v-centered { display: flex; align-items: center; @@ -143,6 +147,30 @@ font-size: $size-8; text-transform: lowercase; } -.has-bottom-margin { +.has-bottom-margin-xs { + margin-bottom: $spacing-xs; +} +.has-bottom-margin-s { + margin-bottom: $spacing-s; +} +.has-bottom-margin-m { margin-bottom: $spacing-m; } +.has-bottom-margin-l { + margin-bottom: $spacing-l; +} +.has-top-margin-xl { + margin-top: $spacing-xl; +} +.has-border-danger { + border: 1px solid $danger; +} + +ul.bullet { + list-style: disc; + padding-left: $spacing-m; +} + +.has-text-semibold { + font-weight: $font-weight-semibold; +} diff --git a/ui/app/styles/core/message.scss b/ui/app/styles/core/message.scss index f1a60248eae23..83e4d70f33881 100644 --- a/ui/app/styles/core/message.scss +++ b/ui/app/styles/core/message.scss @@ -25,6 +25,10 @@ font-size: 16px; font-weight: $font-weight-bold; line-height: 1.25; + + .progress { + margin-left: $spacing-xs; + } } .close-button + .message-title { diff --git a/ui/app/styles/core/progress.scss b/ui/app/styles/core/progress.scss index 3454e73bd7a38..ceb4f8e9827e2 100644 --- a/ui/app/styles/core/progress.scss +++ b/ui/app/styles/core/progress.scss @@ -1,9 +1,9 @@ -.progress[value]::-webkit-progress-bar, -.progress[value]::-webkit-progress-value { - border-radius: 2px; -} .progress { - border-radius: 0; + -webkit-appearance: none; + -moz-appearance: none; + background: $progress-bar-background-color; + box-shadow: inset 0 0 0 1px $ui-gray-200; + border-radius: $radius; margin-bottom: 0; &.is-small { height: 0.5rem; @@ -11,10 +11,24 @@ &.is-narrow { width: 30px; } + &.is-medium { + width: 120px; + } } -.progress.is-rounded { - border-radius: 2px; + +// style the container in chrome +.progress[value]::-webkit-progress-bar { + box-shadow: inset 0 0 0 1px $ui-gray-200; +} + +// style the bar in chrome +.progress[value]::-webkit-progress-value { + border-radius: $radius; + transition: width 1s ease-out; } -.progress.is-bordered { - box-shadow: 0 0 0 4px $progress-bar-background-color; + +// style the bar in firefox +.progress[value]::-moz-progress-bar { + border-radius: $radius; + transition: width 1s ease-out; } diff --git a/ui/app/styles/utils/_bulma_variables.scss b/ui/app/styles/utils/_bulma_variables.scss index 339d478d718fa..bc20bb541356a 100644 --- a/ui/app/styles/utils/_bulma_variables.scss +++ b/ui/app/styles/utils/_bulma_variables.scss @@ -65,7 +65,7 @@ $navbar-background-color: transparent; $menu-item-hover-background-color: $blue; $menu-item-hover-color: $white; -$progress-bar-background-color: lighten($grey-light, 15%); +$progress-bar-background-color: $ui-gray-050; $base-border: 1px solid $ui-gray-300; $light-border: 1px solid $ui-gray-200; diff --git a/ui/app/templates/components/shamir-flow.hbs b/ui/app/templates/components/shamir-flow.hbs index edc58ea02e8ac..ace02e1d89a00 100644 --- a/ui/app/templates/components/shamir-flow.hbs +++ b/ui/app/templates/components/shamir-flow.hbs @@ -119,7 +119,7 @@ {{else}}
-
+
{{#if errors}}
{{message-error errors=errors}} diff --git a/ui/app/templates/components/shamir-modal-flow.hbs b/ui/app/templates/components/shamir-modal-flow.hbs new file mode 100644 index 0000000000000..954a9e20dbfd2 --- /dev/null +++ b/ui/app/templates/components/shamir-modal-flow.hbs @@ -0,0 +1,194 @@ + + +
+ +
+
\ No newline at end of file diff --git a/ui/app/templates/components/transit-key-action/datakey.hbs b/ui/app/templates/components/transit-key-action/datakey.hbs index b79fbac460e9f..ed311aea82c66 100644 --- a/ui/app/templates/components/transit-key-action/datakey.hbs +++ b/ui/app/templates/components/transit-key-action/datakey.hbs @@ -89,7 +89,7 @@
-

Plaintext is base64 encoded

+

Plaintext is base64 encoded

Ciphertext

{{ciphertext}} diff --git a/ui/app/templates/components/wizard/replication-details.hbs b/ui/app/templates/components/wizard/replication-details.hbs deleted file mode 100644 index e04d4da686c70..0000000000000 --- a/ui/app/templates/components/wizard/replication-details.hbs +++ /dev/null @@ -1,19 +0,0 @@ - - -

- Here you can see the details about your new replication cluster, manage or disable replication, and handle secondary clusters. You can also get a quick status by hovering over the "Replication" link at the top. -

-
-
-

- Ready to move on? -

- -
-
diff --git a/ui/app/templates/components/wizard/replication-setup.hbs b/ui/app/templates/components/wizard/replication-setup.hbs index 23ac6056e1ff4..449ac22b0b339 100644 --- a/ui/app/templates/components/wizard/replication-setup.hbs +++ b/ui/app/templates/components/wizard/replication-setup.hbs @@ -8,6 +8,16 @@

Vault has two kinds of replication, each for a different purpose. Do you want to keep a backup of your data, or are you more interested in speed of access?

+ + + + + + +

diff --git a/ui/app/templates/partials/status/cluster.hbs b/ui/app/templates/partials/status/cluster.hbs index 150e8dd0daacd..b084a56d639ce 100644 --- a/ui/app/templates/partials/status/cluster.hbs +++ b/ui/app/templates/partials/status/cluster.hbs @@ -62,6 +62,30 @@


{{/if}} + {{else}} + {{#if (has-permission 'status' routeParams='replication')}} + +
+ {{/if}} {{/if}} {{/if}} {{/unless}} diff --git a/ui/app/templates/vault/cluster/oidc-callback.hbs b/ui/app/templates/vault/cluster/oidc-callback.hbs index e0317112a891d..9f73e7e5910a0 100644 --- a/ui/app/templates/vault/cluster/oidc-callback.hbs +++ b/ui/app/templates/vault/cluster/oidc-callback.hbs @@ -1,7 +1,7 @@
-
+
- -

- Disaster Recovery secondary is enabled -

-
- - - {{#if (eq action 'promote')}} - - - {{/if}} - {{#if (eq action 'update')}} - - {{/if}} - {{#unless action}} - -

- Generate an Operation Token by entering a portion of the master key. - Once all portions are entered, the generated operation token may be used to manage your secondary Disaster Recovery cluster. -

-
- {{/unless}} -
- diff --git a/ui/app/templates/vault/cluster/replication-dr-promote/details.hbs b/ui/app/templates/vault/cluster/replication-dr-promote/details.hbs new file mode 100644 index 0000000000000..8ee73bd585c4d --- /dev/null +++ b/ui/app/templates/vault/cluster/replication-dr-promote/details.hbs @@ -0,0 +1,45 @@ +
+
+ + + {{#if Page.isDisabled}} + + + + {{else}} + + + + + + {{/if}} + +
+
diff --git a/ui/app/templates/vault/cluster/replication-dr-promote/index.hbs b/ui/app/templates/vault/cluster/replication-dr-promote/index.hbs new file mode 100644 index 0000000000000..da24814d600d9 --- /dev/null +++ b/ui/app/templates/vault/cluster/replication-dr-promote/index.hbs @@ -0,0 +1,41 @@ +
+
+ + + {{#if Page.isDisabled}} + + + + {{else}} +
+ +
+ {{/if}} +
+
+
diff --git a/ui/lib/core/addon/components/alert-banner.js b/ui/lib/core/addon/components/alert-banner.js index 275b4b94eeb1a..9eb6dde75ec7c 100644 --- a/ui/lib/core/addon/components/alert-banner.js +++ b/ui/lib/core/addon/components/alert-banner.js @@ -12,9 +12,11 @@ import layout from '../templates/components/alert-banner'; * * ``` * - * @param type=null {String} - The banner type. This comes from the message-types helper. - * @param [message=null {String}] - The message to display within the banner. - * @param [title=null {String}] - A title to show above the message. If this is not provided, there are default values for each type of alert. + * @param {String} type=null - The banner type. This comes from the message-types helper. + * @param {String} [secondIconType=null] - If you want a second icon to appear to the right of the title. This comes from the message-types helper. + * @param {Object} [progressBar=null] - An object containing a value and maximum for a progress bar. Will be displayed next to the message title. + * @param {String} [message=null] - The message to display within the banner. + * @param {String} [title=null] - A title to show above the message. If this is not provided, there are default values for each type of alert. * */ @@ -23,6 +25,8 @@ export default Component.extend({ type: null, message: null, title: null, + secondIconType: null, + progressBar: null, yieldWithoutColumn: false, classNameBindings: ['containerClass'], @@ -33,4 +37,8 @@ export default Component.extend({ alertType: computed('type', function() { return messageTypes([this.get('type')]); }), + + secondAlertType: computed('secondIconType', function() { + return messageTypes([this.get('secondIconType')]); + }), }); diff --git a/ui/lib/core/addon/components/confirmation-modal.js b/ui/lib/core/addon/components/confirmation-modal.js new file mode 100644 index 0000000000000..b2bac1d6582f8 --- /dev/null +++ b/ui/lib/core/addon/components/confirmation-modal.js @@ -0,0 +1,37 @@ +/** + * @module ConfirmationModal + * ConfirmationModal components are used to provide an alternative to ConfirmationButton that automatically prompts the user to fill in confirmation text before they can continue with a potentially destructive action. It is built off the Modal component + * + * @example + * ```js + * + * ``` + * @param {function} onConfirm - onConfirm is the action that happens when user clicks onConfirm after filling in the confirmation block + * @param {boolean} isActive - Controls whether the modal is "active" eg. visible or not. + * @param {string} title - Title of the modal + * @param {function} onClose - specify what to do when user attempts to close modal + * @param {string} [buttonText=Confirm] - Button text on the confirm button + * @param {string} [confirmText=Yes] - The confirmation text that the user must type before continuing + * @param {string} [buttonClass=is-danger] - extra class to add to confirm button (eg. "is-danger") + * @param {sting} [type=warning] - Applies message-type styling to header. Override to default with empty string + * @param {string} [toConfirmMsg] - Finishes the sentence "Type YES to confirm ..." + * @param {string} [testSelector] - The unique test selector used on the input to fill in text during tests. + */ + +import Component from '@ember/component'; +import layout from '../templates/components/confirmation-modal'; + +export default Component.extend({ + layout, + buttonClass: 'is-danger', + buttonText: 'Confirm', + confirmText: 'Yes', + type: 'warning', + toConfirmMsg: '', + testSelector: '', +}); diff --git a/ui/lib/core/addon/components/empty-state.js b/ui/lib/core/addon/components/empty-state.js index 6d7c6d550bcab..2a2f36ac47f76 100644 --- a/ui/lib/core/addon/components/empty-state.js +++ b/ui/lib/core/addon/components/empty-state.js @@ -13,7 +13,7 @@ import layout from '../templates/components/empty-state'; * * @param title=null{String} - A short label for the empty state * @param message=null{String} - A description of why a user might be seeing the empty state and possibly instructions for actions they may take. - * + * @param [icon='']{String} - A optional param to display icon to the right of the title */ export default Component.extend({ @@ -21,4 +21,5 @@ export default Component.extend({ tagName: '', title: null, message: null, + icon: '', }); diff --git a/ui/lib/core/addon/components/info-table-row.js b/ui/lib/core/addon/components/info-table-row.js index ef6895c25ab4b..2fadd1fa4c157 100644 --- a/ui/lib/core/addon/components/info-table-row.js +++ b/ui/lib/core/addon/components/info-table-row.js @@ -11,11 +11,12 @@ import layout from '../templates/components/info-table-row'; * * @example * ```js - * + * * ``` * * @param value=null {any} - The the data to be displayed - by default the content of the component will only show if there is a value. Also note that special handling is given to boolean values - they will render `Yes` for true and `No` for false. * @param label=null {string} - The display name for the value. + * @param helperText=null {string} - Text to describe the value displayed beneath the label. * @param alwaysRender=false {Boolean} - Indicates if the component content should be always be rendered. When false, the value of `value` will be used to determine if the component should render. * */ @@ -27,6 +28,7 @@ export default Component.extend({ alwaysRender: false, label: null, + helperText: null, value: null, valueIsBoolean: computed('value', function() { diff --git a/ui/lib/core/addon/components/info-table.js b/ui/lib/core/addon/components/info-table.js new file mode 100644 index 0000000000000..13959817d403e --- /dev/null +++ b/ui/lib/core/addon/components/info-table.js @@ -0,0 +1,27 @@ +import Component from '@ember/component'; +import layout from '../templates/components/info-table'; + +/** + * @module InfoTable + * InfoTable components are a table with a single column and header. They are used to render a list of InfoTableRow components. + * + * @example + * ```js + * + * ``` + * @param {String} [title=Info Table] - The title of the table. Used for accessibility purposes. + * @param {String} header=null - The column header. + * @param {Array} items=null - An array of strings which will be used as the InfoTableRow value. + */ + +export default Component.extend({ + layout, + tagName: '', + title: 'Info Table', + header: null, + items: null, +}); diff --git a/ui/app/components/key-value-header.js b/ui/lib/core/addon/components/key-value-header.js similarity index 96% rename from ui/app/components/key-value-header.js rename to ui/lib/core/addon/components/key-value-header.js index fb743b9f99904..79bf7bcf21fcb 100644 --- a/ui/app/components/key-value-header.js +++ b/ui/lib/core/addon/components/key-value-header.js @@ -1,9 +1,11 @@ import { computed } from '@ember/object'; import Component from '@ember/component'; import utils from 'vault/lib/key-utils'; +import layout from '../templates/components/key-value-header'; import { encodePath } from 'vault/utils/path-encoding-helpers'; export default Component.extend({ + layout, tagName: 'nav', classNames: 'key-value-header breadcrumb', ariaLabel: 'breadcrumbs', diff --git a/ui/app/components/learn-link.js b/ui/lib/core/addon/components/learn-link.js similarity index 100% rename from ui/app/components/learn-link.js rename to ui/lib/core/addon/components/learn-link.js diff --git a/ui/app/components/modal.js b/ui/lib/core/addon/components/modal.js similarity index 54% rename from ui/app/components/modal.js rename to ui/lib/core/addon/components/modal.js index 55c5dc1f49875..fda8df3a2e5c1 100644 --- a/ui/app/components/modal.js +++ b/ui/lib/core/addon/components/modal.js @@ -10,12 +10,32 @@ * @param {function} onClose - onClose is the action taken when someone clicks the modal background or close button (if shown). * @param {string} [title] - This text shows up in the header section of the modal. * @param {boolean} [showCloseButton=false] - controls whether the close button in the top right corner shows. + * @param {string} type=null - The header type. This comes from the message-types helper. */ import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { messageTypes } from 'core/helpers/message-types'; +import layout from '../templates/components/modal'; export default Component.extend({ + layout, title: null, showCloseButton: false, + type: null, + glyph: computed('type', function() { + const modalType = this.get('type'); + if (!modalType) { + return; + } + return messageTypes([this.get('type')]); + }), + modalClass: computed('type', function() { + const modalType = this.get('type'); + if (!modalType) { + return 'modal'; + } + return 'modal ' + messageTypes([this.get('type')]).class; + }), onClose: () => {}, }); diff --git a/ui/lib/core/addon/components/replication-action-generate-token.js b/ui/lib/core/addon/components/replication-action-generate-token.js new file mode 100644 index 0000000000000..4127ea6bea640 --- /dev/null +++ b/ui/lib/core/addon/components/replication-action-generate-token.js @@ -0,0 +1,6 @@ +import Actions from './replication-actions-single'; +import layout from '../templates/components/replication-action-generate-token'; + +export default Actions.extend({ + layout, +}); diff --git a/ui/lib/core/addon/components/replication-actions-single.js b/ui/lib/core/addon/components/replication-actions-single.js index 90573cbea1ca1..b922c382a9aeb 100644 --- a/ui/lib/core/addon/components/replication-actions-single.js +++ b/ui/lib/core/addon/components/replication-actions-single.js @@ -3,7 +3,7 @@ import Component from '@ember/component'; export default Component.extend({ onSubmit() {}, replicationMode: null, - replicationDisplayMode: null, + replicationModeForDisplay: null, model: null, actions: { diff --git a/ui/lib/core/addon/components/replication-actions.js b/ui/lib/core/addon/components/replication-actions.js index 53b009a525965..7081749f0d999 100644 --- a/ui/lib/core/addon/components/replication-actions.js +++ b/ui/lib/core/addon/components/replication-actions.js @@ -1,6 +1,5 @@ import { alias } from '@ember/object/computed'; import Component from '@ember/component'; -import { computed } from '@ember/object'; import ReplicationActions from 'core/mixins/replication-actions'; import layout from '../templates/components/replication-actions'; @@ -10,7 +9,6 @@ const DEFAULTS = { primary_cluster_addr: null, errors: [], id: null, - replicationMode: null, force: false, }; @@ -19,7 +17,6 @@ export default Component.extend(ReplicationActions, DEFAULTS, { replicationMode: null, model: null, cluster: alias('model'), - reset() { if (!this || this.isDestroyed || this.isDestroying) { return; @@ -27,19 +24,9 @@ export default Component.extend(ReplicationActions, DEFAULTS, { this.setProperties(DEFAULTS); }, - replicationDisplayMode: computed('replicationMode', function() { - const replicationMode = this.get('replicationMode'); - if (replicationMode === 'dr') { - return 'DR'; - } - if (replicationMode === 'performance') { - return 'Performance'; - } - }), - actions: { onSubmit() { - return this.submitHandler(...arguments); + return this.submitHandler.perform(...arguments); }, clear() { this.reset(); diff --git a/ui/lib/core/addon/components/replication-dashboard.js b/ui/lib/core/addon/components/replication-dashboard.js new file mode 100644 index 0000000000000..650a7ec247571 --- /dev/null +++ b/ui/lib/core/addon/components/replication-dashboard.js @@ -0,0 +1,102 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { clusterStates } from 'core/helpers/cluster-states'; +import { capitalize } from '@ember/string'; +import { htmlSafe } from '@ember/template'; +import layout from '../templates/components/replication-dashboard'; + +/** + * @module ReplicationDashboard + * The `ReplicationDashboard` component is a contextual component of the replication-page component. + * It organizes cluster data specific to mode (dr or performance) and also the type (primary or secondary). + * It is the parent contextual component of the replication--card components. + * + * @example + * ```js + * + * ``` + * @param {Object} data=null - An Ember data object that is pulled from the Ember Cluster Model. + * @param {String} [componentToRender=''] - A string that determines which card component is displayed. There are three options, replication-primary-card, replication-secondary-card, replication-summary-card. + * @param {Boolean} [isSecondary=false] - Used to determine the title and display logic. + * @param {Boolean} [isSummaryDashboard=false] - Only true when the cluster is both a dr and performance primary. If true, replicationDetailsSummary is populated and used to pass through the cluster details. + * @param {Object} replicationDetailsSummary=null - An Ember data object computed off the Ember Model. It combines the Model.dr and Model.performance objects into one and contains details specific to the mode replication. + * @param {Object} replicationDetails=null - An Ember data object pulled from the Ember Model. It contains details specific to the whether the replication is dr or performance. + * @param {String} clusterMode=null - The cluster mode passed through to a table component. + * @param {Object} reindexingDetails=null - An Ember data object used to show a reindexing progress bar. + */ + +export default Component.extend({ + layout, + componentToRender: '', + data: null, + isSecondary: false, + isSummaryDashboard: false, + replicationDetails: null, + replicationDetailsSummary: null, + isSyncing: computed('replicationDetails.{state}', 'isSecondary', function() { + const { state } = this.replicationDetails; + const isSecondary = this.isSecondary; + return isSecondary && state && clusterStates([state]).isSyncing; + }), + isReindexing: computed('replicationDetails.{reindex_in_progress}', function() { + const { replicationDetails } = this; + return !!replicationDetails.reindex_in_progress; + }), + reindexingStage: computed('replicationDetails.{reindex_stage}', function() { + const { replicationDetails } = this; + const stage = replicationDetails.reindex_stage; + // specify the stage if we have one + if (stage) { + return `: ${capitalize(stage)}`; + } + return ''; + }), + progressBar: computed('replicationDetails.{reindex_building_progress,reindex_building_total}', function() { + const { reindex_building_progress, reindex_building_total } = this.replicationDetails; + let progressBar = null; + + if (reindex_building_progress && reindex_building_total) { + progressBar = { + value: reindex_building_progress, + max: reindex_building_total, + }; + } + + return progressBar; + }), + summaryState: computed( + 'replicationDetailsSummary.dr.{state}', + 'replicationDetailsSummary.performance.{state}', + function() { + const { replicationDetailsSummary } = this; + const drState = replicationDetailsSummary.dr.state; + const performanceState = replicationDetailsSummary.performance.state; + + if (drState !== performanceState) { + // when DR and Performance is enabled on the same cluster, + // the states should always be the same + // we are leaving this console log statement to be sure + console.log('DR State: ', drState, 'Performance State: ', performanceState); + } + + return drState; + } + ), + reindexMessage: computed('isSecondary', 'progressBar', function() { + if (!this.isSecondary) { + return htmlSafe( + 'This can cause a delay depending on the size of the data store. You can not use Vault during this time.' + ); + } + return 'This can cause a delay depending on the size of the data store. You can use Vault during this time.'; + }), +}); diff --git a/ui/lib/core/addon/components/replication-doc-link.js b/ui/lib/core/addon/components/replication-doc-link.js new file mode 100644 index 0000000000000..af4f814a4ab2b --- /dev/null +++ b/ui/lib/core/addon/components/replication-doc-link.js @@ -0,0 +1,17 @@ +import Component from '@ember/component'; +import layout from '../templates/components/replication-doc-link'; + +/** + * @module ReplicationDocLink + * The `ReplicationDocLink` component is a learn link with helper text used on the Replication Dashboards. + * The link takes you to the key monitoring metrics learn doc. + * + * @example + * ```js + * + * ``` + */ + +export default Component.extend({ + layout, +}); diff --git a/ui/lib/core/addon/components/replication-header.js b/ui/lib/core/addon/components/replication-header.js new file mode 100644 index 0000000000000..f34a1ac4db54c --- /dev/null +++ b/ui/lib/core/addon/components/replication-header.js @@ -0,0 +1,31 @@ +import Component from '@ember/component'; +import layout from '../templates/components/replication-header'; + +/** + * @module ReplicationHeader + * The `ReplicationHeader` is a header component used on the Replication Dashboards. + * + * @example + * ```js + * + * ``` + * @param {Object} model=null - An Ember data object pulled from the Ember cluster model. + * @param {String} title=null - The title of the header. + * @param {String} [secondaryID=null] - The secondaryID pulled off of the model object. + * @param {Boolean} isSummaryDashboard=false - True when you have both a primary performance and dr cluster dashboard. + */ + +export default Component.extend({ + layout, + data: null, + classNames: ['replication-header'], + isSecondary: null, + secondaryId: null, + isSummaryDashboard: false, + 'data-test-replication-header': true, +}); diff --git a/ui/lib/core/addon/components/replication-mode-summary.js b/ui/lib/core/addon/components/replication-mode-summary.js index 7e19a887eeead..8b01268987612 100644 --- a/ui/lib/core/addon/components/replication-mode-summary.js +++ b/ui/lib/core/addon/components/replication-mode-summary.js @@ -15,7 +15,7 @@ export default Component.extend({ version: service(), router: service(), namespace: service(), - classNameBindings: ['isMenu::box', 'isMenu::level'], + classNameBindings: ['isMenu::box'], attributeBindings: ['href', 'target'], display: 'banner', isMenu: equal('display', 'menu'), @@ -48,4 +48,9 @@ export default Component.extend({ clusterIdDisplay: replicationAttr('clusterIdDisplay'), mode: null, cluster: null, + modeState: computed('cluster', 'mode', function() { + const { cluster, mode } = this; + const clusterState = cluster[mode].state; + return clusterState; + }), }); diff --git a/ui/lib/core/addon/components/replication-page.js b/ui/lib/core/addon/components/replication-page.js new file mode 100644 index 0000000000000..10876f744cf35 --- /dev/null +++ b/ui/lib/core/addon/components/replication-page.js @@ -0,0 +1,142 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import layout from '../templates/components/replication-page'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; + +/** + * @module ReplicationPage + * The `ReplicationPage` component is the parent contextual component that holds the replication-dashboard, and various replication--card components. + * It is the top level component on routes displaying replication dashboards. + * + * @example + * ```js + * + * ``` + * @param {Object} cluster=null - An Ember data object that is pulled from the Ember Cluster Model. + */ + +const MODE = { + dr: 'Disaster Recovery', + performance: 'Performance', +}; + +export default Component.extend({ + layout, + store: service(), + router: service(), + reindexingDetails: null, + didReceiveAttrs() { + this._super(arguments); + this.getReplicationModeStatus.perform(); + }, + getReplicationModeStatus: task(function*() { + let resp; + const { replicationMode } = this.model; + + if (this.isSummaryDashboard) { + // the summary dashboard is not mode specific and will error + // while running replication/null/status in the replication-mode adapter + return; + } + + try { + resp = yield this.get('store') + .adapterFor('replication-mode') + .fetchStatus(replicationMode); + } catch (e) { + // do not handle error + } + this.set('reindexingDetails', resp); + }), + isSummaryDashboard: computed('model.dr.{mode}', 'model.performance.{mode}', function() { + const router = this.router; + const currentRoute = router.get('currentRouteName'); + + // we only show the summary dashboard in the replication index route + if (currentRoute === 'vault.cluster.replication.index') { + const drMode = this.model.dr.mode; + const performanceMode = this.model.performance.mode; + return drMode === 'primary' && performanceMode === 'primary'; + } + }), + formattedReplicationMode: computed('model.{replicationMode}', 'isSummaryDashboard', function() { + // dr or performance 🤯 + const { isSummaryDashboard } = this; + if (isSummaryDashboard) { + return 'Disaster Recovery & Performance'; + } + const mode = this.model.replicationMode; + return MODE[mode]; + }), + clusterMode: computed('model.{replicationAttrs}', 'isSummaryDashboard', function() { + // primary or secondary + const { model } = this; + const { isSummaryDashboard } = this; + if (isSummaryDashboard) { + // replicationAttrs does not exist when summaryDashboard + return 'primary'; + } + return model.replicationAttrs.mode; + }), + isLoadingData: computed('clusterMode', 'model.{replicationAttrs}', function() { + const { clusterMode } = this; + const { model } = this; + const { isSummaryDashboard } = this; + if (isSummaryDashboard) { + return false; + } + const clusterId = model.replicationAttrs.clusterId; + const replicationDisabled = model.replicationAttrs.replicationDisabled; + if (clusterMode === 'bootstrapping' || (!clusterId && !replicationDisabled)) { + // if clusterMode is bootstrapping + // if no clusterId, the data hasn't loaded yet, wait for another status endpoint to be called + return true; + } + return false; + }), + isSecondary: computed('clusterMode', function() { + const { clusterMode } = this; + return clusterMode === 'secondary'; + }), + replicationDetailsSummary: computed('isSummaryDashboard', function() { + const { model } = this; + const { isSummaryDashboard } = this; + if (!isSummaryDashboard) { + return; + } + if (isSummaryDashboard) { + let combinedObject = {}; + combinedObject.dr = model['dr']; + combinedObject.performance = model['performance']; + return combinedObject; + } + }), + replicationDetails: computed('model.{replicationMode}', 'isSummaryDashboard', function() { + const { model } = this; + const { isSummaryDashboard } = this; + if (isSummaryDashboard) { + // Cannot return null + return {}; + } + const replicationMode = model.replicationMode; + return model[replicationMode]; + }), + isDisabled: computed('replicationDetails.{mode}', function() { + if (this.replicationDetails.mode === 'disabled' || this.replicationDetails.mode === 'primary') { + return true; + } + return false; + }), + message: computed('model.{anyReplicationEnabled}', 'formattedReplicationMode', function() { + let msg; + if (this.model.anyReplicationEnabled) { + msg = `This ${this.formattedReplicationMode} secondary has not been enabled. You can do so from the ${this.formattedReplicationMode} Primary.`; + } else { + msg = `This cluster has not been enabled as a ${this.formattedReplicationMode} Secondary. You can do so by enabling replication and adding a secondary from the ${this.formattedReplicationMode} Primary.`; + } + return msg; + }), +}); diff --git a/ui/lib/core/addon/components/replication-secondary-card.js b/ui/lib/core/addon/components/replication-secondary-card.js new file mode 100644 index 0000000000000..a72535e6ce47b --- /dev/null +++ b/ui/lib/core/addon/components/replication-secondary-card.js @@ -0,0 +1,58 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import layout from '../templates/components/replication-secondary-card'; +import { clusterStates } from 'core/helpers/cluster-states'; + +/** + * @module ReplicationSecondaryCard + * The `ReplicationSecondaryCard` component is a card-like component. It displays cluster mode details specific for DR and Performance Secondaries. + * + * @example + * ```js + * + * ``` + * @param {String} [title=null] - The title to be displayed on the top left corner of the card. + * @param {Object} replicationDetails=null - An Ember data object pulled from the Ember Model. It contains details specific to the mode's replication. + */ + +export default Component.extend({ + layout, + tagName: '', + title: null, + replicationDetails: null, + state: computed('replicationDetails.{state}', function() { + return this.replicationDetails && this.replicationDetails.state + ? this.replicationDetails.state + : 'unknown'; + }), + connection: computed('replicationDetails.{connection_state}', function() { + return this.replicationDetails.connection_state ? this.replicationDetails.connection_state : 'unknown'; + }), + lastRemoteWAL: computed('replicationDetails.{lastRemoteWAL}', function() { + return this.replicationDetails && this.replicationDetails.lastRemoteWAL + ? this.replicationDetails.lastRemoteWAL + : 0; + }), + inSyncState: computed('state', function() { + // if our definition of what is considered 'synced' changes, + // we should use the clusterStates helper instead + return this.state === 'stream-wals'; + }), + hasErrorClass: computed('replicationDetails', 'title', 'state', 'connection', function() { + const { title, state, connection } = this; + + // only show errors on the state card + if (title === 'Status') { + const currentClusterisOk = clusterStates([state]).isOk; + const primaryIsOk = clusterStates([connection]).isOk; + return !(currentClusterisOk && primaryIsOk); + } + return false; + }), + knownPrimaryClusterAddrs: computed('replicationDetails.{knownPrimaryClusterAddrs}', function() { + return this.replicationDetails.knownPrimaryClusterAddrs; + }), +}); diff --git a/ui/lib/core/addon/components/replication-summary-card.js b/ui/lib/core/addon/components/replication-summary-card.js new file mode 100644 index 0000000000000..d4b7adc0c28eb --- /dev/null +++ b/ui/lib/core/addon/components/replication-summary-card.js @@ -0,0 +1,44 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import layout from '../templates/components/replication-summary-card'; + +/** + * @module ReplicationSummaryCard + * The `ReplicationSummaryCard` is a card-like component. It displays cluster mode details for both DR and Performance + * + * @example + * ```js + * + * ``` + * @param {String} [title=null] - The title to be displayed on the top left corner of the card. + * @param {Object} replicationDetails=null - An Ember data object computed off the Ember Model. It combines the Model.dr and Model.performance objects into one and contains details specific to the mode replication. + */ + +export default Component.extend({ + layout, + title: null, + replicationDetails: null, + lastDrWAL: computed('replicationDetails.dr.{lastWAL}', function() { + return this.replicationDetails.dr.lastWAL || 0; + }), + lastPerformanceWAL: computed('replicationDetails.performance.{lastWAL}', function() { + return this.replicationDetails.performance.lastWAL || 0; + }), + merkleRootDr: computed('replicationDetails.dr.{merkleRoot}', function() { + return this.replicationDetails.dr.merkleRoot || ''; + }), + merkleRootPerformance: computed('replicationDetails.performance.{merkleRoot}', function() { + return this.replicationDetails.performance.merkleRoot || ''; + }), + knownSecondariesDr: computed('replicationDetails.dr.{knownSecondaries}', function() { + const knownSecondaries = this.replicationDetails.dr.knownSecondaries; + return knownSecondaries.length; + }), + knownSecondariesPerformance: computed('replicationDetails.performance.{knownSecondaries}', function() { + const knownSecondaries = this.replicationDetails.performance.knownSecondaries; + return knownSecondaries.length; + }), +}); diff --git a/ui/lib/core/addon/components/replication-table-rows.js b/ui/lib/core/addon/components/replication-table-rows.js new file mode 100644 index 0000000000000..c9f868e3a1075 --- /dev/null +++ b/ui/lib/core/addon/components/replication-table-rows.js @@ -0,0 +1,37 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import layout from '../templates/components/replication-table-rows'; + +/** + * @module ReplicationTableRows + * The `ReplicationTableRows` component is table component. It displays cluster mode details specific to the cluster of the Dashboard it is used on. + * + * @example + * ```js + * + * ``` + * @param {Object} replicationDetails=null - An Ember data object pulled from the Ember Model. It contains details specific to the whether the replication is dr or performance. + * @param {String} clusterMode=null - The cluster mode (e.g. primary or secondary) passed through to a table component. + */ + +export default Component.extend({ + layout, + classNames: ['replication-table-rows'], + replicationDetails: null, + clusterMode: null, + secondaryId: computed('replicationDetails.{secondaryId}', function() { + return this.replicationDetails.secondaryId; + }), + primaryClusterAddr: computed('replicationDetails.{primaryClusterAddr}', function() { + return this.replicationDetails.primaryClusterAddr || 'None set'; + }), + merkleRoot: computed('replicationDetails.{merkleRoot}', function() { + return this.replicationDetails.merkleRoot || 'unknown'; + }), + clusterId: computed('replicationDetails.{clusterId}', function() { + return this.replicationDetails.clusterId || 'unknown'; + }), +}); diff --git a/ui/lib/core/addon/helpers/cluster-states.js b/ui/lib/core/addon/helpers/cluster-states.js new file mode 100644 index 0000000000000..6b149af36b34c --- /dev/null +++ b/ui/lib/core/addon/helpers/cluster-states.js @@ -0,0 +1,64 @@ +import { helper as buildHelper } from '@ember/component/helper'; + +// A hash of cluster states to ensure that the status menu and replication dashboards +// display states and glyphs consistently +// this includes states for the primary vault cluster and the connection_state + +export const CLUSTER_STATES = { + running: { + glyph: 'check-circle-outline', + isOk: true, + isSyncing: false, + }, + ready: { + glyph: 'check-circle-outline', + isOk: true, + isSyncing: false, + }, + 'stream-wals': { + glyph: 'check-circle-outline', + isOk: true, + isSyncing: false, + }, + 'merkle-diff': { + glyph: 'android-sync', + isOk: true, + isSyncing: true, + }, + connecting: { + glyph: 'android-sync', + isOk: true, + isSyncing: true, + }, + 'merkle-sync': { + glyph: 'android-sync', + isOk: true, + isSyncing: true, + }, + idle: { + glyph: 'cancel-square-outline', + isOk: false, + isSyncing: false, + }, + transient_failure: { + glyph: 'cancel-circle-outline', + isOk: false, + isSyncing: false, + }, + shutdown: { + glyph: 'cancel-circle-outline', + isOk: false, + isSyncing: false, + }, +}; + +export function clusterStates([state]) { + const defaultDisplay = { + glyph: '', + isOk: null, + isSyncing: null, + }; + return CLUSTER_STATES[state] || defaultDisplay; +} + +export default buildHelper(clusterStates); diff --git a/ui/app/helpers/date-format.js b/ui/lib/core/addon/helpers/date-format.js similarity index 100% rename from ui/app/helpers/date-format.js rename to ui/lib/core/addon/helpers/date-format.js diff --git a/ui/app/helpers/format-number.js b/ui/lib/core/addon/helpers/format-number.js similarity index 100% rename from ui/app/helpers/format-number.js rename to ui/lib/core/addon/helpers/format-number.js diff --git a/ui/lib/core/addon/helpers/message-types.js b/ui/lib/core/addon/helpers/message-types.js index 87d79083ea0d8..76958e95e1698 100644 --- a/ui/lib/core/addon/helpers/message-types.js +++ b/ui/lib/core/addon/helpers/message-types.js @@ -25,6 +25,12 @@ export const MESSAGE_TYPES = { glyph: 'alert-triangle', text: 'Warning', }, + loading: { + class: 'is-success', + glyphClass: 'has-text-success', + glyph: 'loading', + text: 'Loading', + }, }; export function messageTypes([type]) { diff --git a/ui/lib/core/addon/helpers/replication-action-for-mode.js b/ui/lib/core/addon/helpers/replication-action-for-mode.js index 82c198d24f90e..d560871f43a4e 100644 --- a/ui/lib/core/addon/helpers/replication-action-for-mode.js +++ b/ui/lib/core/addon/helpers/replication-action-for-mode.js @@ -8,7 +8,8 @@ const ACTIONS = { }, dr: { primary: ['disable', 'recover', 'reindex', 'demote'], - secondary: ['promote'], + // TODO: add disable, recover, and reindex when API is ready + secondary: ['promote', 'update-primary', 'generate-token'], bootstrapping: ['disable', 'recover', 'reindex'], }, }; diff --git a/ui/lib/core/addon/helpers/replication-mode-description.js b/ui/lib/core/addon/helpers/replication-mode-description.js new file mode 100644 index 0000000000000..014ed5d92bd55 --- /dev/null +++ b/ui/lib/core/addon/helpers/replication-mode-description.js @@ -0,0 +1,14 @@ +import { helper as buildHelper } from '@ember/component/helper'; + +const REPLICATION_MODE_DESCRIPTIONS = { + dr: + 'Disaster Recovery Replication is designed to protect against catastrophic failure of entire clusters. Secondaries do not forward service requests until they are elected and become a new primary.', + performance: + 'Performance Replication scales workloads horizontally across clusters to make requests faster. Local secondaries handle read requests but forward writes to the primary to be handled.', +}; + +export function replicationModeDescription([mode]) { + return REPLICATION_MODE_DESCRIPTIONS[mode]; +} + +export default buildHelper(replicationModeDescription); diff --git a/ui/lib/core/addon/mixins/replication-actions.js b/ui/lib/core/addon/mixins/replication-actions.js index c09a60aa22d24..8e67d15aa2605 100644 --- a/ui/lib/core/addon/mixins/replication-actions.js +++ b/ui/lib/core/addon/mixins/replication-actions.js @@ -10,7 +10,8 @@ export default Mixin.create({ loading: or('save.isRunning', 'submitSuccess.isRunning'), onEnable() {}, onDisable() {}, - submitHandler(action, clusterMode, data, event) { + onPromote() {}, + submitHandler: task(function*(action, clusterMode, data, event) { let replicationMode = (data && data.replicationMode) || this.get('replicationMode'); if (event && event.preventDefault) { event.preventDefault(); @@ -22,15 +23,18 @@ export default Mixin.create({ data = Object.keys(data).reduce((newData, key) => { var val = data[key]; if (isPresent(val)) { - newData[key] = val; + if (key === 'dr_operation_token_primary' || key === 'dr_operation_token_promote') { + newData['dr_operation_token'] = val; + } else { + newData[key] = val; + } } return newData; }, {}); delete data.replicationMode; } - - return this.save.perform(action, replicationMode, clusterMode, data); - }, + return yield this.save.perform(action, replicationMode, clusterMode, data); + }), save: task(function*(action, replicationMode, clusterMode, data) { let resp; @@ -41,7 +45,7 @@ export default Mixin.create({ } catch (e) { return this.submitError(e); } - yield this.submitSuccess.perform(resp, action, clusterMode); + return yield this.submitSuccess.perform(resp, action, clusterMode); }).drop(), submitSuccess: task(function*(resp, action, mode) { @@ -89,12 +93,13 @@ export default Mixin.create({ if (action === 'disable') { yield this.onDisable(); } - if (action === 'enable') { - yield this.onEnable(replicationMode); + if (action === 'promote') { + yield this.onPromote(); } - - if (mode === 'secondary' && replicationMode === 'dr') { - yield this.router.transitionTo('vault.cluster'); + if (action === 'enable') { + /// onEnable is a method available only to route vault.cluster.replication.index + // if action 'enable' is called from vault.cluster.replication.mode.index this method is not called + yield this.onEnable(replicationMode, mode); } }).drop(), diff --git a/ui/lib/core/addon/templates/components/alert-banner.hbs b/ui/lib/core/addon/templates/components/alert-banner.hbs index 9bdbe7a2e43ee..e1ebd35a9cb32 100644 --- a/ui/lib/core/addon/templates/components/alert-banner.hbs +++ b/ui/lib/core/addon/templates/components/alert-banner.hbs @@ -1,12 +1,12 @@ -
+
{{#if @yieldWithoutColumn}} {{yield}} @@ -14,6 +14,21 @@
{{or @title this.alertType.text}} + {{#if @secondIconType}} +
{{#if @message}}

diff --git a/ui/lib/core/addon/templates/components/alert-inline.hbs b/ui/lib/core/addon/templates/components/alert-inline.hbs index 9bfd0f733450f..9736238165793 100644 --- a/ui/lib/core/addon/templates/components/alert-inline.hbs +++ b/ui/lib/core/addon/templates/components/alert-inline.hbs @@ -2,6 +2,6 @@ @glyph={{this.alertType.glyph}} class={{this.alertType.glyphClass}} /> -

+

{{@message}}

diff --git a/ui/lib/core/addon/templates/components/confirmation-modal.hbs b/ui/lib/core/addon/templates/components/confirmation-modal.hbs new file mode 100644 index 0000000000000..9b28de1e78a1a --- /dev/null +++ b/ui/lib/core/addon/templates/components/confirmation-modal.hbs @@ -0,0 +1,44 @@ + + +
+ + +
+
diff --git a/ui/lib/core/addon/templates/components/empty-state.hbs b/ui/lib/core/addon/templates/components/empty-state.hbs index 0fb2a1899263e..eccbf827411e5 100644 --- a/ui/lib/core/addon/templates/components/empty-state.hbs +++ b/ui/lib/core/addon/templates/components/empty-state.hbs @@ -1,8 +1,17 @@
+ {{#if icon}} +
+ +

+ {{title}} +

+
+ {{else}}

{{title}}

+ {{/if}} {{#if message}}

{{message}} diff --git a/ui/lib/core/addon/templates/components/info-table-row.hbs b/ui/lib/core/addon/templates/components/info-table-row.hbs index fecdfe419c1f1..bd7f3afcdb987 100644 --- a/ui/lib/core/addon/templates/components/info-table-row.hbs +++ b/ui/lib/core/addon/templates/components/info-table-row.hbs @@ -1,7 +1,14 @@ {{#if (or alwaysRender value)}} -

- {{label}} -
+ {{#if label}} +
+ {{label}} + {{#if helperText}} +
+ {{helperText}} +
+ {{/if}} +
+ {{/if}}
{{#if (has-block)}} {{yield}} diff --git a/ui/lib/core/addon/templates/components/info-table.hbs b/ui/lib/core/addon/templates/components/info-table.hbs new file mode 100644 index 0000000000000..6586cd1a13033 --- /dev/null +++ b/ui/lib/core/addon/templates/components/info-table.hbs @@ -0,0 +1,23 @@ + diff --git a/ui/app/templates/components/key-value-header.hbs b/ui/lib/core/addon/templates/components/key-value-header.hbs similarity index 100% rename from ui/app/templates/components/key-value-header.hbs rename to ui/lib/core/addon/templates/components/key-value-header.hbs diff --git a/ui/lib/core/addon/templates/components/layout-loading.hbs b/ui/lib/core/addon/templates/components/layout-loading.hbs index b144ff68bf68b..b0e99bd89572a 100644 --- a/ui/lib/core/addon/templates/components/layout-loading.hbs +++ b/ui/lib/core/addon/templates/components/layout-loading.hbs @@ -1,4 +1,4 @@ -
+
diff --git a/ui/app/templates/components/modal.hbs b/ui/lib/core/addon/templates/components/modal.hbs similarity index 54% rename from ui/app/templates/components/modal.hbs rename to ui/lib/core/addon/templates/components/modal.hbs index 30bf5107371b9..f2eb136eaa727 100644 --- a/ui/app/templates/components/modal.hbs +++ b/ui/lib/core/addon/templates/components/modal.hbs @@ -1,9 +1,20 @@ {{#ember-wormhole to="modal-wormhole"}} -