diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index 6e54c834b..34172dc93 100644 --- a/ui/app/adapters/application.js +++ b/ui/app/adapters/application.js @@ -101,11 +101,13 @@ export default DS.RESTAdapter.extend(AdapterFetch, { return fetch(url, { method: type || 'GET', headers: opts.headers || {}, + body: opts.body, + signal: opts.signal, }).then(response => { if (response.status >= 200 && response.status < 300) { return RSVP.resolve(response); } else { - return RSVP.reject(); + return RSVP.reject(response); } }); }, diff --git a/ui/app/adapters/raft-join.js b/ui/app/adapters/raft-join.js new file mode 100644 index 000000000..f89deb6f2 --- /dev/null +++ b/ui/app/adapters/raft-join.js @@ -0,0 +1,7 @@ +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + urlForCreateRecord() { + return '/v1/sys/storage/raft/join'; + }, +}); diff --git a/ui/app/adapters/server.js b/ui/app/adapters/server.js new file mode 100644 index 000000000..437e7d4da --- /dev/null +++ b/ui/app/adapters/server.js @@ -0,0 +1,15 @@ +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + urlForFindAll() { + return '/v1/sys/storage/raft/configuration'; + }, + urlForDeleteRecord() { + return '/v1/sys/storage/raft/remove-peer'; + }, + deleteRecord(store, type, snapshot) { + let server_id = snapshot.attr('nodeId'); + let url = '/v1/sys/storage/raft/remove-peer'; + return this.ajax(url, 'POST', { data: { server_id } }); + }, +}); diff --git a/ui/app/components/file-to-array-buffer.js b/ui/app/components/file-to-array-buffer.js new file mode 100644 index 000000000..6c67df20d --- /dev/null +++ b/ui/app/components/file-to-array-buffer.js @@ -0,0 +1,59 @@ +import Component from '@ember/component'; +import filesize from 'filesize'; + +/** + * @module FileToArrayBuffer + * `FileToArrayBuffer` is a component that will allow you to pick a file from the local file system. Once + * loaded, this file will be emitted as a JS ArrayBuffer to the passed `onChange` callback. + * + * @example + * ```js + * + * ``` + * @param onChange=null {Function} - The function to call when the file read is complete. This function + * recieves the file as a JS ArrayBuffer + * @param [label=null {String}] - Text to use as the label for the file input + * @param [fileHelpText=null {String} - Text to use as help under the file input + * + */ +export default Component.extend({ + classNames: ['box', 'is-fullwidth', 'is-marginless', 'is-shadowless'], + onChange: () => {}, + label: null, + fileHelpText: null, + + file: null, + fileName: null, + fileSize: null, + fileLastModified: null, + + readFile(file) { + const reader = new FileReader(); + reader.onload = () => this.send('onChange', reader.result, file); + reader.readAsArrayBuffer(file); + }, + + actions: { + pickedFile(e) { + let { files } = e.target; + if (!files.length) { + return; + } + for (let i = 0, len = files.length; i < len; i++) { + this.readFile(files[i]); + } + }, + clearFile() { + this.send('onChange'); + }, + onChange(fileAsBytes, fileMeta) { + let { name, size, lastModifiedDate } = fileMeta || {}; + let fileSize = size ? filesize(size) : null; + this.set('file', fileAsBytes); + this.set('fileName', name); + this.set('fileSize', fileSize); + this.set('fileLastModified', lastModifiedDate); + this.onChange(fileAsBytes, name); + }, + }, +}); diff --git a/ui/app/components/raft-join.js b/ui/app/components/raft-join.js new file mode 100644 index 000000000..41de3921d --- /dev/null +++ b/ui/app/components/raft-join.js @@ -0,0 +1,38 @@ +import { inject as service } from '@ember/service'; + +/** + * @module RaftJoin + * RaftJoin component presents the user with a choice to join an existing raft cluster when a new Vault + * server is brought up + * + * + * @example + * ```js + * + * ``` + * @param {function} onDismiss - This function will be called if the user decides not to join an existing + * raft cluster + * + */ + +import Component from '@ember/component'; + +export default Component.extend({ + classNames: 'raft-join', + store: service(), + onDismiss() {}, + preference: 'join', + showJoinForm: false, + actions: { + advanceFirstScreen() { + if (this.preference !== 'join') { + this.onDismiss(); + return; + } + this.set('showJoinForm', true); + }, + newModel() { + return this.store.createRecord('raft-join'); + }, + }, +}); diff --git a/ui/app/components/raft-storage-overview.js b/ui/app/components/raft-storage-overview.js new file mode 100644 index 000000000..794f99b1a --- /dev/null +++ b/ui/app/components/raft-storage-overview.js @@ -0,0 +1,81 @@ +import Component from '@ember/component'; +import { getOwner } from '@ember/application'; +import config from '../config/environment'; +import { inject as service } from '@ember/service'; + +export default Component.extend({ + flashMessages: service(), + useServiceWorker: null, + + async init() { + this._super(...arguments); + if (this.useServiceWorker === false) { + return; + } + // check to see if we support ServiceWorker + if ('serviceWorker' in navigator) { + // this checks to see if there's an active service worker - if it failed to register + // for any reason, then this would be null + let worker = await navigator.serviceWorker.getRegistration(config.serviceWorkerScope); + if (worker) { + this.set('useServiceWorker', true); + } + } + }, + + actions: { + async removePeer(model) { + let { nodeId } = model; + try { + await model.destroyRecord(); + } catch (e) { + let errString = e.errors ? e.errors.join(' ') : e.message || e; + this.flashMessages.danger(`There was an issue removing the peer ${nodeId}: ${errString}`); + return; + } + this.flashMessages.success(`Successfully removed the peer: ${nodeId}.`); + }, + + downloadViaServiceWorker() { + // the actual download happens when the user clicks the anchor link, and then the ServiceWorker + // intercepts the request and adds auth headers. + // Here we just want to notify users that something is happening before the browser starts the download + this.flashMessages.success('The snapshot download will begin shortly.'); + }, + + async downloadSnapshot() { + // this entire method is the fallback behavior in case the browser either doesn't support ServiceWorker + // or the UI is not being run on https. + // here we're downloading the entire snapshot in memory, creating a dataurl with createObjectURL, and + // then forcing a download by clicking a link that has a download attribute + // + // this is not the default because + let adapter = getOwner(this).lookup('adapter:application'); + + this.flashMessages.success('The snapshot download has begun.'); + let resp, blob; + try { + resp = await adapter.rawRequest('/v1/sys/storage/raft/snapshot', 'GET'); + blob = await resp.blob(); + } catch (e) { + let errString = e.errors ? e.errors.join(' ') : e.message || e; + this.flashMessages.danger(`There was an error trying to download the snapshot: ${errString}`); + } + let filename = 'snapshot.gz'; + let file = new Blob([blob], { type: 'application/x-gzip' }); + file.name = filename; + if ('msSaveOrOpenBlob' in navigator) { + navigator.msSaveOrOpenBlob(file, filename); + return; + } + let a = document.createElement('a'); + let objectURL = window.URL.createObjectURL(file); + a.href = objectURL; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(objectURL); + }, + }, +}); diff --git a/ui/app/components/raft-storage-restore.js b/ui/app/components/raft-storage-restore.js new file mode 100644 index 000000000..726432636 --- /dev/null +++ b/ui/app/components/raft-storage-restore.js @@ -0,0 +1,45 @@ +import Component from '@ember/component'; +import { task } from 'ember-concurrency'; +import { getOwner } from '@ember/application'; +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; +import { AbortController } from 'fetch'; + +export default Component.extend({ + file: null, + errors: null, + forceRestore: false, + flashMessages: service(), + isUploading: alias('restore.isRunning'), + abortController: null, + restore: task(function*() { + this.set('errors', null); + let adapter = getOwner(this).lookup('adapter:application'); + try { + let url = '/v1/sys/storage/raft/snapshot'; + if (this.forceRestore) { + url = `${url}-force`; + } + let file = new Blob([this.file], { type: 'application/gzip' }); + let controller = new AbortController(); + this.set('abortController', controller); + yield adapter.rawRequest(url, 'POST', { body: file, signal: controller.signal }); + this.flashMessages.success('The snapshot was successfully uploaded!'); + } catch (e) { + if (e.name === 'AbortError') { + return; + } + let resp; + if (e.json) { + resp = yield e.json(); + } + let err = resp ? resp.errors : [e]; + this.set('errors', err); + } + }), + actions: { + cancelUpload() { + this.abortController.abort(); + }, + }, +}); diff --git a/ui/app/models/cluster.js b/ui/app/models/cluster.js index 5084d63ed..db24b159e 100644 --- a/ui/app/models/cluster.js +++ b/ui/app/models/cluster.js @@ -1,5 +1,5 @@ import { inject as service } from '@ember/service'; -import { not, gte, alias, and, or } from '@ember/object/computed'; +import { alias, and, equal, gte, not, or } from '@ember/object/computed'; import { get, computed } from '@ember/object'; import DS from 'ember-data'; import { fragment } from 'ember-data-model-fragments/attributes'; @@ -38,7 +38,9 @@ export default DS.Model.extend({ sealThreshold: alias('leaderNode.sealThreshold'), sealProgress: alias('leaderNode.progress'), sealType: alias('leaderNode.type'), + storageType: alias('leaderNode.storageType'), hasProgress: gte('sealProgress', 1), + usingRaft: equal('storageType', 'raft'), //replication mode - will only ever be 'unsupported' //otherwise the particular mode will have the relevant mode attr through replication-attributes diff --git a/ui/app/models/node.js b/ui/app/models/node.js index 0e18d9653..2f8595bec 100644 --- a/ui/app/models/node.js +++ b/ui/app/models/node.js @@ -24,6 +24,7 @@ export default DS.Model.extend({ sealNumShares: alias('n'), version: attr('string'), type: attr('string'), + storageType: attr('string'), //https://www.vaultproject.io/docs/http/sys-leader.html haEnabled: attr('boolean'), diff --git a/ui/app/models/raft-join.js b/ui/app/models/raft-join.js new file mode 100644 index 000000000..df814b594 --- /dev/null +++ b/ui/app/models/raft-join.js @@ -0,0 +1,44 @@ +import DS from 'ember-data'; +import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import { computed } from '@ember/object'; +const { attr } = DS; + +//leader_api_addr (string: ) – Address of the leader node in the Raft cluster to which this node is trying to join. + +//retry (bool: false) - Retry joining the Raft cluster in case of failures. + +//leader_ca_cert (string: "") - CA certificate used to communicate with Raft's leader node. + +//leader_client_cert (string: "") - Client certificate used to communicate with Raft's leader node. + +//leader_client_key (string: "") - Client key used to communicate with Raft's leader node. + +export default DS.Model.extend({ + leaderApiAddr: attr('string', { + label: 'Leader API Address', + }), + retry: attr('boolean', { + label: 'Keep retrying to join in case of failures', + }), + leaderCaCert: attr('string', { + label: 'Leader CA Certificate', + editType: 'file', + }), + leaderClientCert: attr('string', { + label: 'Leader Client Certificate', + editType: 'file', + }), + leaderClientKey: attr('string', { + label: 'Leader Client Key', + editType: 'file', + }), + fields: computed(function() { + return expandAttributeMeta(this, [ + 'leaderApiAddr', + 'leaderCaCert', + 'leaderClientCert', + 'leaderClientKey', + 'retry', + ]); + }), +}); diff --git a/ui/app/models/server.js b/ui/app/models/server.js new file mode 100644 index 000000000..26d7e98aa --- /dev/null +++ b/ui/app/models/server.js @@ -0,0 +1,11 @@ +import DS from 'ember-data'; +const { attr } = DS; + +//{"node_id":"1249bfbc-b234-96f3-0c66-07078ac3e16e","address":"127.0.0.1:8201","leader":true,"protocol_version":"3","voter":true} +export default DS.Model.extend({ + address: attr('string'), + nodeId: attr('string'), + protocolVersion: attr('string'), + voter: attr('boolean'), + leader: attr('boolean'), +}); diff --git a/ui/app/router.js b/ui/app/router.js index 10796047b..9618309e1 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -16,6 +16,8 @@ Router.map(function() { this.mount('open-api-explorer', { path: '/api-explorer' }); this.route('license'); this.route('requests', { path: '/metrics/requests' }); + this.route('storage', { path: '/storage/raft' }); + this.route('storage-restore', { path: '/storage/raft/restore' }); this.route('settings', function() { this.route('index', { path: '/' }); this.route('seal'); diff --git a/ui/app/routes/vault/cluster/storage.js b/ui/app/routes/vault/cluster/storage.js new file mode 100644 index 000000000..f111a000f --- /dev/null +++ b/ui/app/routes/vault/cluster/storage.js @@ -0,0 +1,14 @@ +import Route from '@ember/routing/route'; +import ClusterRoute from 'vault/mixins/cluster-route'; + +export default Route.extend(ClusterRoute, { + model() { + return this.store.findAll('server'); + }, + + actions: { + doRefresh() { + this.refresh(); + }, + }, +}); diff --git a/ui/app/serializers/server.js b/ui/app/serializers/server.js new file mode 100644 index 000000000..cce0ca2f9 --- /dev/null +++ b/ui/app/serializers/server.js @@ -0,0 +1,13 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + primaryKey: 'node_id', + normalizeItems(payload) { + if (payload.data && payload.data.config) { + // rewrite the payload from data.config.servers to data.keys so we can use the application serializer + // on it + return payload.data.config.servers.slice(0); + } + return this._super(payload); + }, +}); diff --git a/ui/app/services/permissions.js b/ui/app/services/permissions.js index 0d05a38eb..ed3ec6c02 100644 --- a/ui/app/services/permissions.js +++ b/ui/app/services/permissions.js @@ -27,6 +27,7 @@ const API_PATHS = { replication: 'sys/replication', license: 'sys/license', seal: 'sys/seal', + raft: 'sys/storage/raft/configuration', }, metrics: { requests: 'sys/internal/counters/requests', diff --git a/ui/app/styles/components/http-requests-table.scss b/ui/app/styles/components/http-requests-table.scss index 76e87f593..6ac07883b 100644 --- a/ui/app/styles/components/http-requests-table.scss +++ b/ui/app/styles/components/http-requests-table.scss @@ -1,31 +1,4 @@ .http-requests-table { - & .is-collapsed { - visibility: collapse; - } - - & th, - td { - padding: $spacing-s; - } - - & th { - color: $grey-dark; - font-weight: 500; - font-size: $size-8; - } - - & tbody th { - font-size: $size-7; - } - - & tr { - border-bottom: 1px solid $grey-light; - } - - & td { - color: $grey-darkest; - } - & .percent-change { font-weight: 500; font-size: $size-7; diff --git a/ui/app/styles/components/raft-join.scss b/ui/app/styles/components/raft-join.scss new file mode 100644 index 000000000..942df4b46 --- /dev/null +++ b/ui/app/styles/components/raft-join.scss @@ -0,0 +1,12 @@ +.raft-join .field { + margin-bottom: 0; +} +.raft-join .box.is-fullwidth { + padding-top: $spacing-s; + padding-bottom: $spacing-s; +} +.raft-join-unseal { + color: $orange; + font-size: $size-6; + display: inline-block; +} diff --git a/ui/app/styles/components/vlt-table.scss b/ui/app/styles/components/vlt-table.scss new file mode 100644 index 000000000..2b61b079f --- /dev/null +++ b/ui/app/styles/components/vlt-table.scss @@ -0,0 +1,37 @@ +.vlt-table { + .is-collapsed { + visibility: collapse; + height: 0; + } + + th, + td { + padding: $spacing-s; + } + + th { + color: $grey-dark; + font-weight: 500; + font-size: $size-8; + } + + tbody th { + font-size: $size-7; + } + + tr { + border-bottom: 1px solid $grey-light; + } + + td { + color: $grey-darkest; + } + + td.middle { + vertical-align: middle; + } + + td.no-padding { + padding: 0; + } +} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index 13ee777f4..e1402d54c 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -55,8 +55,8 @@ @import './components/form-section'; @import './components/global-flash'; @import './components/hover-copy-button'; -@import './components/http-requests-table'; @import './components/http-requests-bar-chart'; +@import './components/http-requests-table'; @import './components/init-illustration'; @import './components/info-table-row'; @import './components/input-hint'; @@ -73,6 +73,7 @@ @import './components/page-header'; @import './components/popup-menu'; @import './components/radial-progress'; +@import './components/raft-join'; @import './components/role-item'; @import './components/search-select'; @import './components/shamir-progress'; @@ -87,6 +88,7 @@ @import './components/ui-wizard'; @import './components/vault-loading'; @import './components/vlt-radio'; +@import './components/vlt-table'; // bulma-free-zone @import './components/hs-icon'; diff --git a/ui/app/styles/core/bulma-radio-checkboxes.scss b/ui/app/styles/core/bulma-radio-checkboxes.scss index 6b92273d0..d41699356 100644 --- a/ui/app/styles/core/bulma-radio-checkboxes.scss +++ b/ui/app/styles/core/bulma-radio-checkboxes.scss @@ -17,3 +17,9 @@ position: absolute; top: 0; } + +.checkbox-help-text { + font-size: $size-7; + color: $ui-gray-700; + padding-left: 28px; +} diff --git a/ui/app/templates/components/file-to-array-buffer.hbs b/ui/app/templates/components/file-to-array-buffer.hbs new file mode 100644 index 000000000..c2d815471 --- /dev/null +++ b/ui/app/templates/components/file-to-array-buffer.hbs @@ -0,0 +1,36 @@ +
+
+ +
+ +
+
+ {{#if this.fileName}} +

+ This file is {{this.fileSize}} and was created on {{date-format this.fileLastModified 'MMM DD, YYYY hh:mm:ss A'}}. +

+ {{/if}} + {{#if @fileHelpText}} +

+ {{@fileHelpText}} +

+ {{/if}} +
diff --git a/ui/app/templates/components/http-requests-table.hbs b/ui/app/templates/components/http-requests-table.hbs index ed3dfb859..4b503f80b 100644 --- a/ui/app/templates/components/http-requests-table.hbs +++ b/ui/app/templates/components/http-requests-table.hbs @@ -1,4 +1,4 @@ -
+
diff --git a/ui/app/templates/components/raft-join.hbs b/ui/app/templates/components/raft-join.hbs new file mode 100644 index 000000000..283a89b50 --- /dev/null +++ b/ui/app/templates/components/raft-join.hbs @@ -0,0 +1,66 @@ + +
+
+ Warning Vault is sealed +
+
+
+{{#if this.showJoinForm}} +
+

+ Join an existing Raft cluster +

+ +
+{{else}} + +
+

+ This server is configured to use Raft Storage. +
+
+ How do you want to get started? +

+ + + + +
+
+ +
+ +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/raft-storage-overview.hbs b/ui/app/templates/components/raft-storage-overview.hbs new file mode 100644 index 000000000..b37c37576 --- /dev/null +++ b/ui/app/templates/components/raft-storage-overview.hbs @@ -0,0 +1,109 @@ + + +

+ Raft Storage +

+
+
+ + + + + Snapshots + + + + + + + + + +
+ + + + + + + + + + {{#each @model as |server|}} + + + + + + {{/each}} + +
AddressVoter
+ {{server.address}} + {{#if server.leader}} + Leader + {{/if}} + + + {{#if server.voter}} + + {{else}} + + {{/if}} + + + + + + +
diff --git a/ui/app/templates/components/raft-storage-restore.hbs b/ui/app/templates/components/raft-storage-restore.hbs new file mode 100644 index 000000000..06817ea8b --- /dev/null +++ b/ui/app/templates/components/raft-storage-restore.hbs @@ -0,0 +1,70 @@ + + + + + +

+ Restore Snapshot +

+
+
+ +
+ + + {{#if this.isUploading}} +
+ +
+
+ +
+ {{else}} +
+ + +
+ + +

+ Bypass checks to ensure the AutoUnseal or Shamir keys are consistent with the snapshot data. +

+
+
+ + {{/if}} + diff --git a/ui/app/templates/partials/status/cluster.hbs b/ui/app/templates/partials/status/cluster.hbs index ed66ab25e..81710fa75 100644 --- a/ui/app/templates/partials/status/cluster.hbs +++ b/ui/app/templates/partials/status/cluster.hbs @@ -62,25 +62,41 @@
{{/if}} {{/if}} - {{#if (has-permission 'status' routeParams='license')}} + {{/unless}} + {{#if (or + (and version.features (has-permission 'status' routeParams='license')) + (and cluster.usingRaft (has-permission 'status' routeParams='raft')) + ) + }}
{{/if}} - {{/unless}}