From 508404ccba419e6159e88b90d259a5a352a8a02f Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 25 Mar 2020 05:51:26 -0700 Subject: [PATCH] UI: Support for CSI (#7446) Closes #7197 #7199 Note: Test coverage is limited to adapter and serializer unit tests. All acceptance tests have been stubbed and all features have been manually tested end-to-end. This represents Phase 1 of #6993 which is the core workflow of CSI in the UI. It includes a couple new pages for viewing all external volumes as well as the allocations associated with each. It also updates existing volume related views on job and allocation pages to handle both Host Volumes and CSI Volumes. --- ui/app/adapters/job.js | 54 +-- ui/app/adapters/volume.js | 9 + ui/app/adapters/watchable.js | 84 ++++- ui/app/controllers/csi.js | 11 + ui/app/controllers/csi/volumes/index.js | 35 ++ ui/app/controllers/csi/volumes/volume.js | 9 + ui/app/mixins/with-namespace-ids.js | 60 +++ ui/app/models/plugin.js | 13 + ui/app/models/storage-controller.js | 21 ++ ui/app/models/storage-node.js | 21 ++ ui/app/models/task-group.js | 2 +- ui/app/models/volume-definition.js | 17 + ui/app/models/volume-mount.js | 13 +- ui/app/models/volume.js | 46 ++- ui/app/router.js | 6 + ui/app/routes/csi/index.js | 7 + ui/app/routes/csi/volumes.js | 41 ++ ui/app/routes/csi/volumes/index.js | 13 + ui/app/routes/csi/volumes/volume.js | 55 +++ ui/app/serializers/plugin.js | 18 + ui/app/serializers/volume.js | 96 +++++ ui/app/services/watch-list.js | 7 +- ui/app/styles/core/menu.scss | 11 + .../allocations/allocation/task/index.hbs | 10 +- .../templates/components/allocation-row.hbs | 9 +- ui/app/templates/components/gutter-menu.hbs | 24 +- ui/app/templates/components/task-row.hbs | 8 +- ui/app/templates/csi.hbs | 3 + ui/app/templates/csi/volumes.hbs | 1 + ui/app/templates/csi/volumes/index.hbs | 65 ++++ ui/app/templates/csi/volumes/volume.hbs | 102 +++++ ui/app/templates/jobs/job/task-group.hbs | 10 +- ui/app/utils/properties/watch.js | 22 ++ ui/mirage/common.js | 2 + ui/mirage/config.js | 52 +++ ui/mirage/factories/csi-plugin.js | 56 +++ ui/mirage/factories/csi-volume.js | 61 +++ ui/mirage/factories/storage-controller.js | 38 ++ ui/mirage/factories/storage-node.js | 38 ++ ui/mirage/models/csi-plugin.js | 6 + ui/mirage/models/csi-volume.js | 7 + ui/mirage/models/storage-controller.js | 7 + ui/mirage/models/storage-node.js | 7 + ui/mirage/scenarios/default.js | 11 + ui/mirage/serializers/csi-plugin.js | 6 + ui/mirage/serializers/csi-volume.js | 28 ++ ui/tests/acceptance/volume-detail-test.js | 28 ++ ui/tests/acceptance/volumes-list-test.js | 26 ++ ui/tests/unit/adapters/volume-test.js | 167 +++++++++ ui/tests/unit/serializers/volume-test.js | 349 ++++++++++++++++++ 50 files changed, 1714 insertions(+), 78 deletions(-) create mode 100644 ui/app/adapters/volume.js create mode 100644 ui/app/controllers/csi.js create mode 100644 ui/app/controllers/csi/volumes/index.js create mode 100644 ui/app/controllers/csi/volumes/volume.js create mode 100644 ui/app/mixins/with-namespace-ids.js create mode 100644 ui/app/models/plugin.js create mode 100644 ui/app/models/storage-controller.js create mode 100644 ui/app/models/storage-node.js create mode 100644 ui/app/models/volume-definition.js create mode 100644 ui/app/routes/csi/index.js create mode 100644 ui/app/routes/csi/volumes.js create mode 100644 ui/app/routes/csi/volumes/index.js create mode 100644 ui/app/routes/csi/volumes/volume.js create mode 100644 ui/app/serializers/plugin.js create mode 100644 ui/app/serializers/volume.js create mode 100644 ui/app/templates/csi.hbs create mode 100644 ui/app/templates/csi/volumes.hbs create mode 100644 ui/app/templates/csi/volumes/index.hbs create mode 100644 ui/app/templates/csi/volumes/volume.hbs create mode 100644 ui/mirage/factories/csi-plugin.js create mode 100644 ui/mirage/factories/csi-volume.js create mode 100644 ui/mirage/factories/storage-controller.js create mode 100644 ui/mirage/factories/storage-node.js create mode 100644 ui/mirage/models/csi-plugin.js create mode 100644 ui/mirage/models/csi-volume.js create mode 100644 ui/mirage/models/storage-controller.js create mode 100644 ui/mirage/models/storage-node.js create mode 100644 ui/mirage/serializers/csi-plugin.js create mode 100644 ui/mirage/serializers/csi-volume.js create mode 100644 ui/tests/acceptance/volume-detail-test.js create mode 100644 ui/tests/acceptance/volumes-list-test.js create mode 100644 ui/tests/unit/adapters/volume-test.js create mode 100644 ui/tests/unit/serializers/volume-test.js diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index 0f4d64845..93ff7a9fb 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -1,51 +1,8 @@ -import { inject as service } from '@ember/service'; import Watchable from './watchable'; import addToPath from 'nomad-ui/utils/add-to-path'; +import WithNamespaceIDs from 'nomad-ui/mixins/with-namespace-ids'; -export default Watchable.extend({ - system: service(), - - findAll() { - const namespace = this.get('system.activeNamespace'); - return this._super(...arguments).then(data => { - data.forEach(job => { - job.Namespace = namespace ? namespace.get('id') : 'default'; - }); - return data; - }); - }, - - findRecord(store, type, id, snapshot) { - const [, namespace] = JSON.parse(id); - const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {}; - - return this._super(store, type, id, snapshot, namespaceQuery); - }, - - urlForFindAll() { - const url = this._super(...arguments); - const namespace = this.get('system.activeNamespace.id'); - return associateNamespace(url, namespace); - }, - - urlForFindRecord(id, type, hash) { - const [name, namespace] = JSON.parse(id); - let url = this._super(name, type, hash); - return associateNamespace(url, namespace); - }, - - urlForUpdateRecord(id, type, hash) { - const [name, namespace] = JSON.parse(id); - let url = this._super(name, type, hash); - return associateNamespace(url, namespace); - }, - - xhrKey(url, method, options = {}) { - const plainKey = this._super(...arguments); - const namespace = options.data && options.data.namespace; - return associateNamespace(plainKey, namespace); - }, - +export default Watchable.extend(WithNamespaceIDs, { relationshipFallbackLinks: { summary: '/summary', }, @@ -113,10 +70,3 @@ export default Watchable.extend({ }); }, }); - -function associateNamespace(url, namespace) { - if (namespace && namespace !== 'default') { - url += `?namespace=${namespace}`; - } - return url; -} diff --git a/ui/app/adapters/volume.js b/ui/app/adapters/volume.js new file mode 100644 index 000000000..551e49126 --- /dev/null +++ b/ui/app/adapters/volume.js @@ -0,0 +1,9 @@ +import Watchable from './watchable'; +import WithNamespaceIDs from 'nomad-ui/mixins/with-namespace-ids'; + +export default Watchable.extend(WithNamespaceIDs, { + queryParamsToAttrs: { + type: 'type', + plugin_id: 'plugin.id', + }, +}); diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js index b6a37b025..e069ea37e 100644 --- a/ui/app/adapters/watchable.js +++ b/ui/app/adapters/watchable.js @@ -1,16 +1,28 @@ import { get } from '@ember/object'; import { assign } from '@ember/polyfills'; import { inject as service } from '@ember/service'; +import { AbortError } from '@ember-data/adapter/error'; import queryString from 'query-string'; import ApplicationAdapter from './application'; -import { AbortError } from '@ember-data/adapter/error'; +import removeRecord from '../utils/remove-record'; export default ApplicationAdapter.extend({ watchList: service(), store: service(), ajaxOptions(url, type, options) { - const ajaxOptions = this._super(...arguments); + const ajaxOptions = this._super(url, type, options); + + // Since ajax has been changed to include query params in the URL, + // we have to remove query params that are in the URL from the data + // object so they don't get passed along twice. + const [newUrl, params] = ajaxOptions.url.split('?'); + const queryParams = queryString.parse(params); + ajaxOptions.url = !params ? newUrl : `${newUrl}?${queryString.stringify(queryParams)}`; + Object.keys(queryParams).forEach(key => { + delete ajaxOptions.data[key]; + }); + const abortToken = (options || {}).abortToken; if (abortToken) { delete options.abortToken; @@ -27,6 +39,23 @@ export default ApplicationAdapter.extend({ return ajaxOptions; }, + // Overriding ajax is not advised, but this is a minimal modification + // that sets off a series of events that results in query params being + // available in handleResponse below. Unfortunately, this is the only + // place where what becomes requestData can be modified. + // + // It's either this weird side-effecting thing that also requires a change + // to ajaxOptions or overriding ajax completely. + ajax(url, type, options) { + const hasParams = hasNonBlockingQueryParams(options); + if (!hasParams || type !== 'GET') return this._super(url, type, options); + + const params = { ...options.data }; + delete params.index; + + return this._super(`${url}?${queryString.stringify(params)}`, type, options); + }, + findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) { const params = assign(this.buildQuery(), additionalParams); const url = this.urlForFindAll(type.modelName); @@ -62,6 +91,48 @@ export default ApplicationAdapter.extend({ }); }, + query(store, type, query, snapshotRecordArray, options, additionalParams = {}) { + const url = this.buildURL(type.modelName, null, null, 'query', query); + let [, params] = url.split('?'); + params = assign(queryString.parse(params) || {}, this.buildQuery(), additionalParams, query); + + if (get(options, 'adapterOptions.watch')) { + // The intended query without additional blocking query params is used + // to track the appropriate query index. + params.index = this.watchList.getIndexFor(`${url}?${queryString.stringify(query)}`); + } + + const abortToken = get(options, 'adapterOptions.abortToken'); + return this.ajax(url, 'GET', { + abortToken, + data: params, + }).then(payload => { + const adapter = store.adapterFor(type.modelName); + + // Query params may not necessarily map one-to-one to attribute names. + // Adapters are responsible for declaring param mappings. + const queryParamsToAttrs = Object.keys(adapter.queryParamsToAttrs || {}).map(key => ({ + queryParam: key, + attr: adapter.queryParamsToAttrs[key], + })); + + // Remove existing records that match this query. This way if server-side + // deletes have occurred, the store won't have stale records. + store + .peekAll(type.modelName) + .filter(record => + queryParamsToAttrs.some( + mapping => get(record, mapping.attr) === query[mapping.queryParam] + ) + ) + .forEach(record => { + removeRecord(store, record); + }); + + return payload; + }); + }, + reloadRelationship(model, relationshipName, options = { watch: false, abortToken: null }) { const { watch, abortToken } = options; const relationship = model.relationshipFor(relationshipName); @@ -122,3 +193,12 @@ export default ApplicationAdapter.extend({ return this._super(...arguments); }, }); + +function hasNonBlockingQueryParams(options) { + if (!options || !options.data) return false; + const keys = Object.keys(options.data); + if (!keys.length) return false; + if (keys.length === 1 && keys[0] === 'index') return false; + + return true; +} diff --git a/ui/app/controllers/csi.js b/ui/app/controllers/csi.js new file mode 100644 index 000000000..bd4bec291 --- /dev/null +++ b/ui/app/controllers/csi.js @@ -0,0 +1,11 @@ +import Controller from '@ember/controller'; + +export default Controller.extend({ + queryParams: { + volumeNamespace: 'namespace', + }, + + isForbidden: false, + + volumeNamespace: 'default', +}); diff --git a/ui/app/controllers/csi/volumes/index.js b/ui/app/controllers/csi/volumes/index.js new file mode 100644 index 000000000..3eb926864 --- /dev/null +++ b/ui/app/controllers/csi/volumes/index.js @@ -0,0 +1,35 @@ +import { inject as service } from '@ember/service'; +import { alias } from '@ember/object/computed'; +import Controller, { inject as controller } from '@ember/controller'; +import SortableFactory from 'nomad-ui/mixins/sortable-factory'; + +export default Controller.extend( + SortableFactory([ + 'id', + 'schedulable', + 'controllersHealthyProportion', + 'nodesHealthyProportion', + 'provider', + ]), + { + system: service(), + csiController: controller('csi'), + + isForbidden: alias('csiController.isForbidden'), + + queryParams: { + currentPage: 'page', + sortProperty: 'sort', + sortDescending: 'desc', + }, + + currentPage: 1, + pageSize: 10, + + sortProperty: 'id', + sortDescending: true, + + listToSort: alias('model'), + sortedVolumes: alias('listSorted'), + } +); diff --git a/ui/app/controllers/csi/volumes/volume.js b/ui/app/controllers/csi/volumes/volume.js new file mode 100644 index 000000000..bff70d6ec --- /dev/null +++ b/ui/app/controllers/csi/volumes/volume.js @@ -0,0 +1,9 @@ +import Controller from '@ember/controller'; + +export default Controller.extend({ + actions: { + gotoAllocation(allocation) { + this.transitionToRoute('allocations.allocation', allocation); + }, + }, +}); diff --git a/ui/app/mixins/with-namespace-ids.js b/ui/app/mixins/with-namespace-ids.js new file mode 100644 index 000000000..3ba221751 --- /dev/null +++ b/ui/app/mixins/with-namespace-ids.js @@ -0,0 +1,60 @@ +import { inject as service } from '@ember/service'; +import Mixin from '@ember/object/mixin'; + +export default Mixin.create({ + system: service(), + + findAll() { + const namespace = this.get('system.activeNamespace'); + return this._super(...arguments).then(data => { + data.forEach(record => { + record.Namespace = namespace ? namespace.get('id') : 'default'; + }); + return data; + }); + }, + + findRecord(store, type, id, snapshot) { + const [, namespace] = JSON.parse(id); + const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {}; + + return this._super(store, type, id, snapshot, namespaceQuery); + }, + + urlForFindAll() { + const url = this._super(...arguments); + const namespace = this.get('system.activeNamespace.id'); + return associateNamespace(url, namespace); + }, + + urlForQuery() { + const url = this._super(...arguments); + const namespace = this.get('system.activeNamespace.id'); + return associateNamespace(url, namespace); + }, + + urlForFindRecord(id, type, hash) { + const [name, namespace] = JSON.parse(id); + let url = this._super(name, type, hash); + return associateNamespace(url, namespace); + }, + + urlForUpdateRecord(id, type, hash) { + const [name, namespace] = JSON.parse(id); + let url = this._super(name, type, hash); + return associateNamespace(url, namespace); + }, + + xhrKey(url, method, options = {}) { + const plainKey = this._super(...arguments); + const namespace = options.data && options.data.namespace; + return associateNamespace(plainKey, namespace); + }, +}); + +function associateNamespace(url, namespace) { + if (namespace && namespace !== 'default') { + url += `?namespace=${namespace}`; + } + return url; +} diff --git a/ui/app/models/plugin.js b/ui/app/models/plugin.js new file mode 100644 index 000000000..152427a05 --- /dev/null +++ b/ui/app/models/plugin.js @@ -0,0 +1,13 @@ +import Model from 'ember-data/model'; +import attr from 'ember-data/attr'; +// import { fragmentArray } from 'ember-data-model-fragments/attributes'; + +export default Model.extend({ + topologies: attr(), + provider: attr('string'), + version: attr('string'), + controllerRequired: attr('boolean'), + + // controllers: fragmentArray('storage-controller', { defaultValue: () => [] }), + // nodes: fragmentArray('storage-node', { defaultValue: () => [] }), +}); diff --git a/ui/app/models/storage-controller.js b/ui/app/models/storage-controller.js new file mode 100644 index 000000000..3ad5009f4 --- /dev/null +++ b/ui/app/models/storage-controller.js @@ -0,0 +1,21 @@ +import attr from 'ember-data/attr'; +import { belongsTo } from 'ember-data/relationships'; +import Fragment from 'ember-data-model-fragments/fragment'; +import { fragmentOwner } from 'ember-data-model-fragments/attributes'; + +export default Fragment.extend({ + plugin: fragmentOwner(), + + node: belongsTo('node'), + allocation: belongsTo('allocation'), + + provider: attr('string'), + version: attr('string'), + healthy: attr('boolean'), + healthDescription: attr('string'), + updateTime: attr('date'), + requiresControllerPlugin: attr('boolean'), + requiresTopologies: attr('boolean'), + + controllerInfo: attr(), +}); diff --git a/ui/app/models/storage-node.js b/ui/app/models/storage-node.js new file mode 100644 index 000000000..de76d4a8e --- /dev/null +++ b/ui/app/models/storage-node.js @@ -0,0 +1,21 @@ +import attr from 'ember-data/attr'; +import { belongsTo } from 'ember-data/relationships'; +import Fragment from 'ember-data-model-fragments/fragment'; +import { fragmentOwner } from 'ember-data-model-fragments/attributes'; + +export default Fragment.extend({ + plugin: fragmentOwner(), + + node: belongsTo('node'), + allocation: belongsTo('allocation'), + + provider: attr('string'), + version: attr('string'), + healthy: attr('boolean'), + healthDescription: attr('string'), + updateTime: attr('date'), + requiresControllerPlugin: attr('boolean'), + requiresTopologies: attr('boolean'), + + nodeInfo: attr(), +}); diff --git a/ui/app/models/task-group.js b/ui/app/models/task-group.js index 8ad57ccfd..361e45f7e 100644 --- a/ui/app/models/task-group.js +++ b/ui/app/models/task-group.js @@ -16,7 +16,7 @@ export default Fragment.extend({ services: fragmentArray('service'), - volumes: fragmentArray('volume'), + volumes: fragmentArray('volume-definition'), drivers: computed('tasks.@each.driver', function() { return this.tasks.mapBy('driver').uniq(); diff --git a/ui/app/models/volume-definition.js b/ui/app/models/volume-definition.js new file mode 100644 index 000000000..b682b4c3a --- /dev/null +++ b/ui/app/models/volume-definition.js @@ -0,0 +1,17 @@ +import { alias, equal } from '@ember/object/computed'; +import attr from 'ember-data/attr'; +import Fragment from 'ember-data-model-fragments/fragment'; +import { fragmentOwner } from 'ember-data-model-fragments/attributes'; + +export default Fragment.extend({ + taskGroup: fragmentOwner(), + + name: attr('string'), + + source: attr('string'), + type: attr('string'), + readOnly: attr('boolean'), + + isCSI: equal('type', 'csi'), + namespace: alias('taskGroup.job.namespace'), +}); diff --git a/ui/app/models/volume-mount.js b/ui/app/models/volume-mount.js index 402f00d03..107c093f5 100644 --- a/ui/app/models/volume-mount.js +++ b/ui/app/models/volume-mount.js @@ -1,4 +1,5 @@ import { computed } from '@ember/object'; +import { alias, equal } from '@ember/object/computed'; import attr from 'ember-data/attr'; import Fragment from 'ember-data-model-fragments/fragment'; import { fragmentOwner } from 'ember-data-model-fragments/attributes'; @@ -7,10 +8,18 @@ export default Fragment.extend({ task: fragmentOwner(), volume: attr('string'), - source: computed('volume', 'task.taskGroup.volumes.@each.{name,source}', function() { - return this.task.taskGroup.volumes.findBy('name', this.volume).source; + + volumeDeclaration: computed('task.taskGroup.volumes.@each.name', function() { + return this.task.taskGroup.volumes.findBy('name', this.volume); }), + isCSI: equal('volumeDeclaration.type', 'csi'), + source: alias('volumeDeclaration.source'), + + // Since CSI volumes are namespaced, the link intent of a volume mount will + // be to the CSI volume with a namespace that matches this task's job's namespace. + namespace: alias('task.taskGroup.job.namespace'), + destination: attr('string'), propagationMode: attr('string'), readOnly: attr('boolean'), diff --git a/ui/app/models/volume.js b/ui/app/models/volume.js index f4cfe60eb..167fc6502 100644 --- a/ui/app/models/volume.js +++ b/ui/app/models/volume.js @@ -1,10 +1,46 @@ +import { computed } from '@ember/object'; +import Model from 'ember-data/model'; import attr from 'ember-data/attr'; -import Fragment from 'ember-data-model-fragments/fragment'; +import { belongsTo, hasMany } from 'ember-data/relationships'; -export default Fragment.extend({ +export default Model.extend({ + plainId: attr('string'), name: attr('string'), - source: attr('string'), - type: attr('string'), - readOnly: attr('boolean'), + namespace: belongsTo('namespace'), + plugin: belongsTo('plugin'), + + writeAllocations: hasMany('allocation'), + readAllocations: hasMany('allocation'), + + allocations: computed('writeAllocations.[]', 'readAllocations.[]', function() { + return [...this.writeAllocations.toArray(), ...this.readAllocations.toArray()]; + }), + + externalId: attr('string'), + topologies: attr(), + accessMode: attr('string'), + attachmentMode: attr('string'), + schedulable: attr('boolean'), + provider: attr('string'), + version: attr('string'), + + controllerRequired: attr('boolean'), + controllersHealthy: attr('number'), + controllersExpected: attr('number'), + + controllersHealthyProportion: computed('controllersHealthy', 'controllersExpected', function() { + return this.controllersHealthy / this.controllersExpected; + }), + + nodesHealthy: attr('number'), + nodesExpected: attr('number'), + + nodesHealthyProportion: computed('nodesHealthy', 'nodesExpected', function() { + return this.nodesHealthy / this.nodesExpected; + }), + + resourceExhausted: attr('number'), + createIndex: attr('number'), + modifyIndex: attr('number'), }); diff --git a/ui/app/router.js b/ui/app/router.js index f1c94fe41..241e50231 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -33,6 +33,12 @@ Router.map(function() { this.route('server', { path: '/:agent_id' }); }); + this.route('csi', function() { + this.route('volumes', function() { + this.route('volume', { path: '/:volume_name' }); + }); + }); + this.route('allocations', function() { this.route('allocation', { path: '/:allocation_id' }, function() { this.route('task', { path: '/:name' }, function() { diff --git a/ui/app/routes/csi/index.js b/ui/app/routes/csi/index.js new file mode 100644 index 000000000..f66008d19 --- /dev/null +++ b/ui/app/routes/csi/index.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default Route.extend({ + redirect() { + this.transitionTo('csi.volumes'); + }, +}); diff --git a/ui/app/routes/csi/volumes.js b/ui/app/routes/csi/volumes.js new file mode 100644 index 000000000..9bc9d71eb --- /dev/null +++ b/ui/app/routes/csi/volumes.js @@ -0,0 +1,41 @@ +import { inject as service } from '@ember/service'; +import Route from '@ember/routing/route'; +import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; +import notifyForbidden from 'nomad-ui/utils/notify-forbidden'; + +export default Route.extend(WithForbiddenState, { + system: service(), + store: service(), + + breadcrumbs: [ + { + label: 'CSI', + args: ['csi.volumes.index'], + }, + ], + + queryParams: { + volumeNamespace: { + refreshModel: true, + }, + }, + + beforeModel(transition) { + return this.get('system.namespaces').then(namespaces => { + const queryParam = transition.to.queryParams.namespace; + this.set('system.activeNamespace', queryParam || 'default'); + + return namespaces; + }); + }, + + model() { + return this.store + .query('volume', { type: 'csi' }) + .then(volumes => { + volumes.forEach(volume => volume.plugin); + return volumes; + }) + .catch(notifyForbidden(this)); + }, +}); diff --git a/ui/app/routes/csi/volumes/index.js b/ui/app/routes/csi/volumes/index.js new file mode 100644 index 000000000..40aa58f81 --- /dev/null +++ b/ui/app/routes/csi/volumes/index.js @@ -0,0 +1,13 @@ +import Route from '@ember/routing/route'; +import { collect } from '@ember/object/computed'; +import { watchQuery } from 'nomad-ui/utils/properties/watch'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; + +export default Route.extend(WithWatchers, { + startWatchers(controller) { + controller.set('modelWatch', this.watch.perform({ type: 'csi' })); + }, + + watch: watchQuery('volume'), + watchers: collect('watch'), +}); diff --git a/ui/app/routes/csi/volumes/volume.js b/ui/app/routes/csi/volumes/volume.js new file mode 100644 index 000000000..c4d324fe2 --- /dev/null +++ b/ui/app/routes/csi/volumes/volume.js @@ -0,0 +1,55 @@ +import { inject as service } from '@ember/service'; +import Route from '@ember/routing/route'; +import { collect } from '@ember/object/computed'; +import notifyError from 'nomad-ui/utils/notify-error'; +import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; +import { watchRecord } from 'nomad-ui/utils/properties/watch'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; + +export default Route.extend(WithWatchers, { + store: service(), + system: service(), + + breadcrumbs: volume => [ + { + label: 'Volumes', + args: [ + 'csi.volumes', + qpBuilder({ volumeNamespace: volume.get('namespace.name') || 'default' }), + ], + }, + { + label: volume.name, + args: [ + 'csi.volumes.volume', + volume.plainId, + qpBuilder({ volumeNamespace: volume.get('namespace.name') || 'default' }), + ], + }, + ], + + startWatchers(controller, model) { + if (!model) return; + + controller.set('watchers', { + model: this.watch.perform(model), + }); + }, + + serialize(model) { + return { volume_name: model.get('plainId') }; + }, + + model(params, transition) { + const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id'); + const name = params.volume_name; + const fullId = JSON.stringify([`csi/${name}`, namespace || 'default']); + return this.store.findRecord('volume', fullId, { reload: true }).catch(notifyError(this)); + }, + + // Since volume includes embedded records for allocations, + // it's possible that allocations that are server-side deleted may + // not be removed from the UI while sitting on the volume detail page. + watch: watchRecord('volume'), + watchers: collect('watch'), +}); diff --git a/ui/app/serializers/plugin.js b/ui/app/serializers/plugin.js new file mode 100644 index 000000000..779238a26 --- /dev/null +++ b/ui/app/serializers/plugin.js @@ -0,0 +1,18 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + normalize(typeHash, hash) { + hash.PlainID = hash.ID; + + // TODO This shouldn't hardcode `csi/` as part of the ID, + // but it is necessary to make the correct find request and the + // payload does not contain the required information to derive + // this identifier. + hash.ID = `csi/${hash.ID}`; + + hash.Nodes = hash.Nodes || []; + hash.Controllers = hash.Controllers || []; + + return this._super(typeHash, hash); + }, +}); diff --git a/ui/app/serializers/volume.js b/ui/app/serializers/volume.js new file mode 100644 index 000000000..e545d42ac --- /dev/null +++ b/ui/app/serializers/volume.js @@ -0,0 +1,96 @@ +import { set, get } from '@ember/object'; +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + attrs: { + externalId: 'ExternalID', + }, + + embeddedRelationships: ['writeAllocations', 'readAllocations'], + + // Volumes treat Allocations as embedded records. Ember has an + // EmbeddedRecords mixin, but it assumes an application is using + // the REST serializer and Nomad does not. + normalize(typeHash, hash) { + hash.NamespaceID = hash.Namespace; + + hash.PlainId = hash.ID; + + // TODO These shouldn't hardcode `csi/` as part of the IDs, + // but it is necessary to make the correct find requests and the + // payload does not contain the required information to derive + // this identifier. + hash.ID = JSON.stringify([`csi/${hash.ID}`, hash.NamespaceID || 'default']); + hash.PluginID = `csi/${hash.PluginID}`; + + // Convert hash-based allocation embeds to lists + const readAllocs = hash.ReadAllocs || {}; + const writeAllocs = hash.WriteAllocs || {}; + const bindIDToAlloc = hash => id => { + const alloc = hash[id]; + alloc.ID = id; + return alloc; + }; + + hash.ReadAllocations = Object.keys(readAllocs).map(bindIDToAlloc(readAllocs)); + hash.WriteAllocations = Object.keys(writeAllocs).map(bindIDToAlloc(writeAllocs)); + + const normalizedHash = this._super(typeHash, hash); + return this.extractEmbeddedRecords(this, this.store, typeHash, normalizedHash); + }, + + keyForRelationship(attr, relationshipType) { + //Embedded relationship attributes don't end in IDs + if (this.embeddedRelationships.includes(attr)) return attr.capitalize(); + return this._super(attr, relationshipType); + }, + + // Convert the embedded relationship arrays into JSONAPI included records + extractEmbeddedRecords(serializer, store, typeHash, partial) { + partial.included = partial.included || []; + + this.embeddedRelationships.forEach(embed => { + const relationshipMeta = typeHash.relationshipsByName.get(embed); + const relationship = get(partial, `data.relationships.${embed}.data`); + + if (!relationship) return; + + // Create a sidecar relationships array + const hasMany = new Array(relationship.length); + + // For each embedded allocation, normalize the allocation JSON according + // to the allocation serializer. + relationship.forEach((alloc, idx) => { + const { data, included } = this.normalizeEmbeddedRelationship( + store, + relationshipMeta, + alloc + ); + + // In JSONAPI, embedded records go in the included array. + partial.included.push(data); + if (included) { + partial.included.push(...included); + } + + // In JSONAPI, the main payload value is an array of IDs that + // map onto the objects in the included array. + hasMany[idx] = { id: data.id, type: data.type }; + }); + + // Set the JSONAPI relationship value to the sidecar. + const relationshipJson = { data: hasMany }; + set(partial, `data.relationships.${embed}`, relationshipJson); + }); + + return partial; + }, + + normalizeEmbeddedRelationship(store, relationshipMeta, relationshipHash) { + const modelName = relationshipMeta.type; + const modelClass = store.modelFor(modelName); + const serializer = store.serializerFor(modelName); + + return serializer.normalize(modelClass, relationshipHash, null); + }, +}); diff --git a/ui/app/services/watch-list.js b/ui/app/services/watch-list.js index f75a8bdb8..964736c8b 100644 --- a/ui/app/services/watch-list.js +++ b/ui/app/services/watch-list.js @@ -1,3 +1,4 @@ +import { computed } from '@ember/object'; import { readOnly } from '@ember/object/computed'; import { copy } from 'ember-copy'; import Service from '@ember/service'; @@ -5,9 +6,9 @@ import Service from '@ember/service'; let list = {}; export default Service.extend({ - list: readOnly(function() { - return copy(list, true); - }), + _list: computed(() => copy(list, true)), + + list: readOnly('_list'), init() { this._super(...arguments); diff --git a/ui/app/styles/core/menu.scss b/ui/app/styles/core/menu.scss index d5f8110c9..6a5ca163a 100644 --- a/ui/app/styles/core/menu.scss +++ b/ui/app/styles/core/menu.scss @@ -1,5 +1,7 @@ .menu { .menu-list { + margin-top: 1rem; + a { font-weight: $weight-semibold; padding: 0.5rem 1.5rem; @@ -48,6 +50,15 @@ &:not(:first-child) { border-top: 1px solid $grey-blue; } + + &.is-minor { + border-top: none; + margin-top: 0; + } + + + .menu-list { + margin-top: 0.5rem; + } } .collapsed-only + .menu-label { diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index 65800fde0..f04b6841d 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -131,7 +131,15 @@ {{/t.head}} {{#t.body as |row|}} - {{row.model.volume}} + + {{#if row.model.isCSI}} + {{#link-to "csi.volumes.volume" row.model.volume (query-params volumeNamespace=row.model.namespace.id)}} + {{row.model.volume}} + {{/link-to}} + {{else}} + {{row.model.volume}} + {{/if}} + {{row.model.destination}} {{if row.model.readOnly "Read" "Read/Write"}} {{row.model.source}} diff --git a/ui/app/templates/components/allocation-row.hbs b/ui/app/templates/components/allocation-row.hbs index 2c23595a5..ed3fa0fbe 100644 --- a/ui/app/templates/components/allocation-row.hbs +++ b/ui/app/templates/components/allocation-row.hbs @@ -36,10 +36,13 @@ {{allocation.clientStatus}} +{{#if (eq context "volume")}} + {{#link-to "clients.client" allocation.node}}{{allocation.node.shortId}}{{/link-to}} +{{/if}} {{#if (or (eq context "taskGroup") (eq context "job"))}} {{allocation.jobVersion}} {{#link-to "clients.client" allocation.node}}{{allocation.node.shortId}}{{/link-to}} -{{else if (eq context "node")}} +{{else if (or (eq context "node") (eq context "volume"))}} {{#if (or allocation.job.isPending allocation.job.isReloading)}} ... @@ -50,7 +53,9 @@ {{allocation.jobVersion}} {{/if}} -{{if allocation.taskGroup.volumes.length "Yes"}} +{{#if (not (eq context "volume"))}} + {{if allocation.taskGroup.volumes.length "Yes"}} +{{/if}} {{#if allocation.isRunning}} {{#if (and (not cpu) fetchStats.isRunning)}} diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs index a4ba2aa23..4cc8a6172 100644 --- a/ui/app/templates/components/gutter-menu.hbs +++ b/ui/app/templates/components/gutter-menu.hbs @@ -12,7 +12,7 @@ {{#if system.shouldShowRegions}}
- {{/if}} - - diff --git a/ui/app/templates/csi.hbs b/ui/app/templates/csi.hbs new file mode 100644 index 000000000..87dfce6a7 --- /dev/null +++ b/ui/app/templates/csi.hbs @@ -0,0 +1,3 @@ +{{#page-layout}} + {{outlet}} +{{/page-layout}} diff --git a/ui/app/templates/csi/volumes.hbs b/ui/app/templates/csi/volumes.hbs new file mode 100644 index 000000000..c24cd6895 --- /dev/null +++ b/ui/app/templates/csi/volumes.hbs @@ -0,0 +1 @@ +{{outlet}} diff --git a/ui/app/templates/csi/volumes/index.hbs b/ui/app/templates/csi/volumes/index.hbs new file mode 100644 index 000000000..45a9cfa94 --- /dev/null +++ b/ui/app/templates/csi/volumes/index.hbs @@ -0,0 +1,65 @@ +{{title "CSI Volumes"}} +
+ {{#if isForbidden}} + {{partial "partials/forbidden-message"}} + {{else}} + {{#if sortedVolumes}} + {{#list-pagination + source=sortedVolumes + size=pageSize + page=currentPage as |p|}} + {{#list-table + source=p.list + sortProperty=sortProperty + sortDescending=sortDescending + class="with-foot" as |t|}} + {{#t.head}} + {{#t.sort-by prop="name"}}Name{{/t.sort-by}} + {{#t.sort-by prop="schedulable"}}Volume Health{{/t.sort-by}} + {{#t.sort-by prop="controllersHealthyProportion"}}Controller Health{{/t.sort-by}} + {{#t.sort-by prop="nodesHealthyProportion"}}Node Health{{/t.sort-by}} + {{#t.sort-by prop="provider"}}Provider{{/t.sort-by}} + # Allocs + {{/t.head}} + {{#t.body key="model.name" as |row|}} + + + {{#link-to "csi.volumes.volume" row.model.plainId class="is-primary"}}{{row.model.name}}{{/link-to}} + + {{if row.model.schedulable "Schedulable" "Unschedulable"}} + + {{if (gt row.model.controllersHealthy 0) "Healthy" "Unhealthy"}} + ({{row.model.controllersHealthy}}/{{row.model.controllersExpected}}) + + + {{if (gt row.model.nodesHealthy 0) "Healthy" "Unhealthy"}} + ({{row.model.nodesHealthy}}/{{row.model.nodesExpected}}) + + {{row.model.provider}} + {{row.model.allocations.length}} + + {{/t.body}} + {{/list-table}} +
+ +
+ {{/list-pagination}} + {{else}} +
+ {{#if (eq sortedVolumes.length 0)}} +

No Volumes

+

+ The cluster currently has no CSI volumes. +

+ {{/if}} +
+ {{/if}} + {{/if}} +
diff --git a/ui/app/templates/csi/volumes/volume.hbs b/ui/app/templates/csi/volumes/volume.hbs new file mode 100644 index 000000000..d2885ff0e --- /dev/null +++ b/ui/app/templates/csi/volumes/volume.hbs @@ -0,0 +1,102 @@ +{{title "CSI Volume " model.name}} +
+

{{model.name}}

+ +
+
+ Volume Details + + health + {{if model.schedulable "Schedulable" "Unschedulable"}} + + + Provider + {{model.provider}} + + + External ID + {{model.externalId}} + + + Namespace + {{model.namespace.name}} + +
+
+ +
+
+ Write Allocations +
+
+ {{#if model.writeAllocations.length}} + {{#list-table + source=model.writeAllocations + class="with-foot" as |t|}} + {{#t.head}} + + ID + Modified + Created + Status + Client + Job + Version + CPU + Memory + {{/t.head}} + {{#t.body as |row|}} + {{allocation-row + data-test-allocation=row.model.id + allocation=row.model + context="volume" + onClick=(action "gotoAllocation" row.model)}} + {{/t.body}} + {{/list-table}} + {{else}} +
+

No Write Allocations

+

No allocations are depending on this volume for read/write access.

+
+ {{/if}} +
+
+ +
+
+ Read Allocations +
+
+ {{#if model.readAllocations.length}} + {{#list-table + source=model.readAllocations + class="with-foot" as |t|}} + {{#t.head}} + + ID + Modified + Created + Status + Client + Job + Version + CPU + Memory + {{/t.head}} + {{#t.body as |row|}} + {{allocation-row + data-test-allocation=row.model.id + allocation=row.model + context="volume" + onClick=(action "gotoAllocation" row.model)}} + {{/t.body}} + {{/list-table}} + {{else}} +
+

No Read Allocations

+

No allocations are depending on this volume for read-only access.

+
+ {{/if}} +
+
+
diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index 77a705d68..fdfa5d001 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -126,7 +126,15 @@ {{/t.head}} {{#t.body as |row|}} - {{row.model.name}} + + {{#if row.model.isCSI}} + {{#link-to "csi.volumes.volume" row.model.name (query-params volumeNamespace=row.model.namespace.id)}} + {{row.model.name}} + {{/link-to}} + {{else}} + {{row.model.name}} + {{/if}} + {{row.model.type}} {{row.model.source}} {{if row.model.readOnly "Read" "Read/Write"}} diff --git a/ui/app/utils/properties/watch.js b/ui/app/utils/properties/watch.js index ee6f5f9ec..f447144ca 100644 --- a/ui/app/utils/properties/watch.js +++ b/ui/app/utils/properties/watch.js @@ -75,3 +75,25 @@ export function watchAll(modelName) { } }).drop(); } + +export function watchQuery(modelName) { + return task(function*(params, throttle = 10000) { + const token = new XHRToken(); + while (isEnabled && !Ember.testing) { + try { + yield RSVP.all([ + this.store.query(modelName, params, { + reload: true, + adapterOptions: { watch: true, abortToken: token }, + }), + wait(throttle), + ]); + } catch (e) { + yield e; + break; + } finally { + token.abort(); + } + } + }).drop(); +} diff --git a/ui/mirage/common.js b/ui/mirage/common.js index 48d8fc977..a7eaae454 100644 --- a/ui/mirage/common.js +++ b/ui/mirage/common.js @@ -23,6 +23,8 @@ export const HOSTS = provide(100, () => { return `${ip}:${faker.random.number({ min: 4000, max: 4999 })}`; }); +export const STORAGE_PROVIDERS = ['ebs', 'zfs', 'nfs', 'cow', 'moo']; + export function generateResources(options = {}) { return { CPU: faker.helpers.randomize(CPU_RESERVATIONS), diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 07ff38b40..3978c9c5e 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -227,6 +227,58 @@ export default function() { return new Response(204, {}, ''); }); + this.get( + '/volumes', + withBlockingSupport(function({ csiVolumes }, { queryParams }) { + if (queryParams.type !== 'csi') { + return new Response(200, {}, '[]'); + } + + return this.serialize(csiVolumes.all()); + }) + ); + + this.get( + '/volume/:id', + withBlockingSupport(function({ csiVolumes }, { params }) { + if (!params.id.startsWith('csi/')) { + return new Response(404, {}, null); + } + + const id = params.id.replace(/^csi\//, ''); + const volume = csiVolumes.find(id); + + if (!volume) { + return new Response(404, {}, null); + } + + return this.serialize(volume); + }) + ); + + this.get('/plugins', function({ csiPlugins }, { queryParams }) { + if (queryParams.type !== 'csi') { + return new Response(200, {}, '[]'); + } + + return this.serialize(csiPlugins.all()); + }); + + this.get('/plugin/:id', function({ csiPlugins }, { params }) { + if (!params.id.startsWith('csi/')) { + return new Response(404, {}, null); + } + + const id = params.id.replace(/^csi\//, ''); + const volume = csiPlugins.find(id); + + if (!volume) { + return new Response(404, {}, null); + } + + return this.serialize(volume); + }); + this.get('/namespaces', function({ namespaces }) { const records = namespaces.all(); diff --git a/ui/mirage/factories/csi-plugin.js b/ui/mirage/factories/csi-plugin.js new file mode 100644 index 000000000..6e35d393c --- /dev/null +++ b/ui/mirage/factories/csi-plugin.js @@ -0,0 +1,56 @@ +import { Factory } from 'ember-cli-mirage'; +import faker from 'nomad-ui/mirage/faker'; +import { STORAGE_PROVIDERS } from '../common'; + +export default Factory.extend({ + id: () => faker.random.uuid(), + + // Topologies is currently unused by the UI. This should + // eventually become dynamic. + topologies: () => [{ foo: 'bar' }], + + provider: faker.helpers.randomize(STORAGE_PROVIDERS), + version: '1.0.1', + controllerRequired: faker.random.boolean, + controllersHealthy: () => faker.random.number(10), + + nodesHealthy: () => faker.random.number(10), + + // Internal property to determine whether or not this plugin + // Should create one or two Jobs to represent Node and + // Controller plugins. + isMonolith: faker.random.boolean, + + afterCreate(plugin, server) { + let storageNodes; + let storageControllers; + + if (plugin.isMonolith) { + const pluginJob = server.create('job', { type: 'service', createAllocations: false }); + const count = faker.random.number({ min: 1, max: 5 }); + storageNodes = server.createList('storage-node', count, { job: pluginJob }); + storageControllers = server.createList('storage-controller', count, { job: pluginJob }); + } else { + const controllerJob = server.create('job', { type: 'service', createAllocations: false }); + const nodeJob = server.create('job', { type: 'service', createAllocations: false }); + storageNodes = server.createList('storage-node', faker.random.number({ min: 1, max: 5 }), { + job: nodeJob, + }); + storageControllers = server.createList( + 'storage-controller', + faker.random.number({ min: 1, max: 5 }), + { job: controllerJob } + ); + } + + plugin.update({ + controllers: storageControllers, + nodes: storageNodes, + }); + + server.createList('csi-volume', faker.random.number(5), { + plugin, + provider: plugin.provider, + }); + }, +}); diff --git a/ui/mirage/factories/csi-volume.js b/ui/mirage/factories/csi-volume.js new file mode 100644 index 000000000..5a595b030 --- /dev/null +++ b/ui/mirage/factories/csi-volume.js @@ -0,0 +1,61 @@ +import { Factory } from 'ember-cli-mirage'; +import faker from 'nomad-ui/mirage/faker'; +import { pickOne } from '../utils'; +import { STORAGE_PROVIDERS } from '../common'; + +const ACCESS_MODES = ['multi-node-single-writer']; +const ATTACHMENT_MODES = ['file-system']; + +export default Factory.extend({ + id: i => `${faker.hacker.noun().dasherize()}-${i}`.toLowerCase(), + name() { + return this.id; + }, + + externalId: () => `vol-${faker.random.uuid().split('-')[0]}`, + + // Topologies is currently unused by the UI. This should + // eventually become dynamic. + topologies: () => [{ foo: 'bar' }], + + accessMode: faker.helpers.randomize(ACCESS_MODES), + attachmentMode: faker.helpers.randomize(ATTACHMENT_MODES), + + schedulable: faker.random.boolean, + provider: faker.helpers.randomize(STORAGE_PROVIDERS), + version: '1.0.1', + controllerRequired: faker.random.boolean, + controllersHealthy: () => faker.random.number(10), + controllersExpected() { + return this.controllersHealthy + faker.random.number(10); + }, + nodesHealthy: () => faker.random.number(10), + nodesExpected() { + return this.nodesHealthy + faker.random.number(10); + }, + + afterCreate(volume, server) { + if (!volume.namespaceId) { + const namespace = server.db.namespaces.length ? pickOne(server.db.namespaces).id : null; + volume.update({ + namespace, + namespaceId: namespace, + }); + } else { + volume.update({ + namespace: volume.namespaceId, + }); + } + + if (!volume.plugin) { + const plugin = server.db.csiPlugins.length ? pickOne(server.db.csiPlugins) : null; + volume.update({ + PluginId: plugin && plugin.id, + }); + } else { + volume.update({ + PluginId: volume.plugin.id, + }); + } + }, +}); diff --git a/ui/mirage/factories/storage-controller.js b/ui/mirage/factories/storage-controller.js new file mode 100644 index 000000000..bade4f1b6 --- /dev/null +++ b/ui/mirage/factories/storage-controller.js @@ -0,0 +1,38 @@ +import { Factory } from 'ember-cli-mirage'; +import faker from 'nomad-ui/mirage/faker'; +import { STORAGE_PROVIDERS } from '../common'; +const REF_TIME = new Date(); + +export default Factory.extend({ + provider: faker.helpers.randomize(STORAGE_PROVIDERS), + providerVersion: '1.0.1', + + healthy: faker.random.boolean, + healthDescription() { + this.healthy ? 'healthy' : 'unhealthy'; + }, + + updateTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000, + + requiresControllerPlugin: true, + requiresTopologies: true, + + controllerInfo: () => ({ + SupportsReadOnlyAttach: true, + SupportsAttachDetach: true, + SupportsListVolumes: true, + SupportsListVolumesAttachedNodes: false, + }), + + afterCreate(storageController, server) { + const alloc = server.create('allocation', { + jobId: storageController.job.id, + }); + + storageController.update({ + allocation: alloc, + allocId: alloc.id, + nodeId: alloc.nodeId, + }); + }, +}); diff --git a/ui/mirage/factories/storage-node.js b/ui/mirage/factories/storage-node.js new file mode 100644 index 000000000..bc3788b4d --- /dev/null +++ b/ui/mirage/factories/storage-node.js @@ -0,0 +1,38 @@ +import { Factory } from 'ember-cli-mirage'; +import faker from 'nomad-ui/mirage/faker'; +import { STORAGE_PROVIDERS } from '../common'; +const REF_TIME = new Date(); + +export default Factory.extend({ + provider: faker.helpers.randomize(STORAGE_PROVIDERS), + providerVersion: '1.0.1', + + healthy: faker.random.boolean, + healthDescription() { + this.healthy ? 'healthy' : 'unhealthy'; + }, + + updateTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000, + + requiresControllerPlugin: true, + requiresTopologies: true, + + nodeInfo: () => ({ + MaxVolumes: 51, + AccessibleTopology: { + key: 'value', + }, + RequiresNodeStageVolume: true, + }), + + afterCreate(storageNode, server) { + const alloc = server.create('allocation', { + jobId: storageNode.job.id, + }); + + storageNode.update({ + allocId: alloc.id, + nodeId: alloc.nodeId, + }); + }, +}); diff --git a/ui/mirage/models/csi-plugin.js b/ui/mirage/models/csi-plugin.js new file mode 100644 index 000000000..fe3962e12 --- /dev/null +++ b/ui/mirage/models/csi-plugin.js @@ -0,0 +1,6 @@ +import { Model, hasMany } from 'ember-cli-mirage'; + +export default Model.extend({ + nodes: hasMany('storage-node'), + controllers: hasMany('storage-controller'), +}); diff --git a/ui/mirage/models/csi-volume.js b/ui/mirage/models/csi-volume.js new file mode 100644 index 000000000..256bb03c6 --- /dev/null +++ b/ui/mirage/models/csi-volume.js @@ -0,0 +1,7 @@ +import { Model, belongsTo, hasMany } from 'ember-cli-mirage'; + +export default Model.extend({ + plugin: belongsTo('csi-plugin'), + writeAllocs: hasMany('allocation'), + readAllocs: hasMany('allocation'), +}); diff --git a/ui/mirage/models/storage-controller.js b/ui/mirage/models/storage-controller.js new file mode 100644 index 000000000..c845b4807 --- /dev/null +++ b/ui/mirage/models/storage-controller.js @@ -0,0 +1,7 @@ +import { Model, belongsTo } from 'ember-cli-mirage'; + +export default Model.extend({ + job: belongsTo(), + node: belongsTo(), + allocation: belongsTo(), +}); diff --git a/ui/mirage/models/storage-node.js b/ui/mirage/models/storage-node.js new file mode 100644 index 000000000..c845b4807 --- /dev/null +++ b/ui/mirage/models/storage-node.js @@ -0,0 +1,7 @@ +import { Model, belongsTo } from 'ember-cli-mirage'; + +export default Model.extend({ + job: belongsTo(), + node: belongsTo(), + allocation: belongsTo(), +}); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 0a7269415..fadd4f734 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -1,4 +1,5 @@ import config from 'nomad-ui/config/environment'; +import { pickOne } from '../utils'; const withNamespaces = getConfigValue('mirageWithNamespaces', false); const withTokens = getConfigValue('mirageWithTokens', true); @@ -41,6 +42,16 @@ function smallCluster(server) { server.createList('job', 5); server.createList('allocFile', 5); server.create('allocFile', 'dir', { depth: 2 }); + server.createList('csi-plugin', 2); + + const csiAllocations = server.createList('allocation', 5); + const volumes = server.schema.csiVolumes.all().models; + csiAllocations.forEach(alloc => { + const volume = pickOne(volumes); + volume.writeAllocs.add(alloc); + volume.readAllocs.add(alloc); + volume.save(); + }); } function mediumCluster(server) { diff --git a/ui/mirage/serializers/csi-plugin.js b/ui/mirage/serializers/csi-plugin.js new file mode 100644 index 000000000..8fd785df6 --- /dev/null +++ b/ui/mirage/serializers/csi-plugin.js @@ -0,0 +1,6 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + embed: true, + include: ['nodes', 'controllers'], +}); diff --git a/ui/mirage/serializers/csi-volume.js b/ui/mirage/serializers/csi-volume.js new file mode 100644 index 000000000..9cc22c10d --- /dev/null +++ b/ui/mirage/serializers/csi-volume.js @@ -0,0 +1,28 @@ +import ApplicationSerializer from './application'; + +const groupBy = (list, attr) => { + return list.reduce((group, item) => { + group[item[attr]] = item; + return group; + }, {}); +}; + +export default ApplicationSerializer.extend({ + embed: true, + include: ['writeAllocs', 'readAllocs'], + + serialize() { + var json = ApplicationSerializer.prototype.serialize.apply(this, arguments); + if (json instanceof Array) { + json.forEach(serializeVolume); + } else { + serializeVolume(json); + } + return json; + }, +}); + +function serializeVolume(volume) { + volume.WriteAllocs = groupBy(volume.WriteAllocs, 'ID'); + volume.ReadAllocs = groupBy(volume.ReadAllocs, 'ID'); +} diff --git a/ui/tests/acceptance/volume-detail-test.js b/ui/tests/acceptance/volume-detail-test.js new file mode 100644 index 000000000..f0b7bac78 --- /dev/null +++ b/ui/tests/acceptance/volume-detail-test.js @@ -0,0 +1,28 @@ +import { module, skip } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; + +module('Acceptance | volume detail', function(hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function() {}); + + skip('/csi/volums/:id should have a breadcrumb trail linking back to Volumes and CSI', async function() {}); + + skip('/csi/volumes/:id should show the volume name in the title', async function() {}); + + skip('/csi/volumes/:id should list additional details for the volume below the title', async function() {}); + + skip('/csi/volumes/:id should list all write allocations the volume is attached to', async function() {}); + + skip('/csi/volumes/:id should list all read allocations the volume is attached to', async function() {}); + + skip('each allocation should have high-level details forthe allocation', async function() {}); + + skip('each allocation should link to the allocation detail page', async function() {}); + + skip('when there are no write allocations, the table presents an empty state', async function() {}); + + skip('when there are no read allocations, the table presents an empty state', async function() {}); +}); diff --git a/ui/tests/acceptance/volumes-list-test.js b/ui/tests/acceptance/volumes-list-test.js new file mode 100644 index 000000000..ed9f3c345 --- /dev/null +++ b/ui/tests/acceptance/volumes-list-test.js @@ -0,0 +1,26 @@ +import { module, skip } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; + +module('Acceptance | volumes list', function(hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + skip('visiting /csi', async function() { + // redirects to /csi/volumes + }); + + skip('visiting /csi/volumes', async function() {}); + + skip('/csi/volumes should list the first page of volumes sorted by name', async function() {}); + + skip('each volume row should contain information about the volume', async function() {}); + + skip('each volume row should link to the corresponding volume', async function() {}); + + skip('when there are no volumes, there is an empty message', async function() {}); + + skip('when the namespace query param is set, only matching volumes are shown and the namespace value is forwarded to app state', async function() {}); + + skip('when accessing volumes is forbidden, a message is shown with a link to the tokens page', async function() {}); +}); diff --git a/ui/tests/unit/adapters/volume-test.js b/ui/tests/unit/adapters/volume-test.js new file mode 100644 index 000000000..fcb3d04fa --- /dev/null +++ b/ui/tests/unit/adapters/volume-test.js @@ -0,0 +1,167 @@ +import { run } from '@ember/runloop'; +import { settled } from '@ember/test-helpers'; +import { setupTest } from 'ember-qunit'; +import { module, test } from 'qunit'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import XHRToken from 'nomad-ui/utils/classes/xhr-token'; + +module('Unit | Adapter | Volume', function(hooks) { + setupTest(hooks); + + hooks.beforeEach(async function() { + this.store = this.owner.lookup('service:store'); + this.subject = () => this.store.adapterFor('volume'); + + window.sessionStorage.clear(); + window.localStorage.clear(); + + this.server = startMirage(); + + this.initializeUI = async () => { + this.server.create('namespace'); + this.server.create('namespace', { id: 'some-namespace' }); + this.server.create('node'); + this.server.create('job', { id: 'job-1', namespaceId: 'default' }); + this.server.create('csi-plugin', 2); + this.server.create('csi-volume', { id: 'volume-1', namespaceId: 'some-namespace' }); + + this.server.create('region', { id: 'region-1' }); + this.server.create('region', { id: 'region-2' }); + + this.system = this.owner.lookup('service:system'); + + // Namespace, default region, and all regions are requests that all + // job requests depend on. Fetching them ahead of time means testing + // job adapter behavior in isolation. + await this.system.get('namespaces'); + this.system.get('shouldIncludeRegion'); + await this.system.get('defaultRegion'); + + // Reset the handledRequests array to avoid accounting for this + // namespaces request everywhere. + this.server.pretender.handledRequests.length = 0; + }; + }); + + hooks.afterEach(function() { + this.server.shutdown(); + }); + + test('The volume endpoint can be queried by type', async function(assert) { + const { pretender } = this.server; + + await this.initializeUI(); + + this.subject().query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, {}); + await settled(); + + assert.deepEqual(pretender.handledRequests.mapBy('url'), ['/v1/volumes?type=csi']); + }); + + test('When a namespace is set in localStorage and the volume endpoint is queried, the namespace is in the query string', async function(assert) { + const { pretender } = this.server; + + window.localStorage.nomadActiveNamespace = 'some-namespace'; + await this.initializeUI(); + + this.subject().query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, {}); + await settled(); + + assert.deepEqual(pretender.handledRequests.mapBy('url'), [ + '/v1/volumes?namespace=some-namespace&type=csi', + ]); + }); + + test('When the volume has a namespace other than default, it is in the URL', async function(assert) { + const { pretender } = this.server; + const volumeName = 'csi/volume-1'; + const volumeNamespace = 'some-namespace'; + const volumeId = JSON.stringify([volumeName, volumeNamespace]); + + await this.initializeUI(); + + this.subject().findRecord(this.store, { modelName: 'volume' }, volumeId); + await settled(); + + assert.deepEqual(pretender.handledRequests.mapBy('url'), [ + `/v1/volume/${encodeURIComponent(volumeName)}?namespace=${volumeNamespace}`, + ]); + }); + + test('query can be watched', async function(assert) { + await this.initializeUI(); + + const { pretender } = this.server; + + const request = () => + this.subject().query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, { + reload: true, + adapterOptions: { watch: true }, + }); + + request(); + assert.equal(pretender.handledRequests[0].url, '/v1/volumes?type=csi&index=1'); + + await settled(); + request(); + assert.equal(pretender.handledRequests[1].url, '/v1/volumes?type=csi&index=2'); + + await settled(); + }); + + test('query can be canceled', async function(assert) { + await this.initializeUI(); + + const { pretender } = this.server; + const token = new XHRToken(); + + pretender.get('/v1/volumes', () => [200, {}, '[]'], true); + + this.subject() + .query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, { + reload: true, + adapterOptions: { watch: true, abortToken: token }, + }) + .catch(() => {}); + + const { request: xhr } = pretender.requestReferences[0]; + assert.equal(xhr.status, 0, 'Request is still pending'); + + // Schedule the cancelation before waiting + run.next(() => { + token.abort(); + }); + + await settled(); + assert.ok(xhr.aborted, 'Request was aborted'); + }); + + test('query and findAll have distinct watchList entries', async function(assert) { + await this.initializeUI(); + + const { pretender } = this.server; + + const request = () => + this.subject().query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, { + reload: true, + adapterOptions: { watch: true }, + }); + + const findAllRequest = () => + this.subject().findAll(null, { modelName: 'volume' }, null, { + reload: true, + adapterOptions: { watch: true }, + }); + + request(); + assert.equal(pretender.handledRequests[0].url, '/v1/volumes?type=csi&index=1'); + + await settled(); + request(); + assert.equal(pretender.handledRequests[1].url, '/v1/volumes?type=csi&index=2'); + + await settled(); + findAllRequest(); + assert.equal(pretender.handledRequests[2].url, '/v1/volumes?index=1'); + }); +}); diff --git a/ui/tests/unit/serializers/volume-test.js b/ui/tests/unit/serializers/volume-test.js new file mode 100644 index 000000000..0f5989095 --- /dev/null +++ b/ui/tests/unit/serializers/volume-test.js @@ -0,0 +1,349 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import VolumeModel from 'nomad-ui/models/volume'; + +module('Unit | Serializer | Volume', function(hooks) { + setupTest(hooks); + hooks.beforeEach(function() { + this.store = this.owner.lookup('service:store'); + this.subject = () => this.store.serializerFor('volume'); + }); + + const REF_DATE = new Date(); + + const normalizationTestCases = [ + { + name: + '`default` is used as the namespace in the volume ID when there is no namespace in the payload', + in: { + ID: 'volume-id', + Name: 'volume-id', + PluginID: 'plugin-1', + ExternalID: 'external-uuid', + Topologies: {}, + AccessMode: 'access-this-way', + AttachmentMode: 'attach-this-way', + Schedulable: true, + Provider: 'abc.123', + Version: '1.0.29', + ControllerRequired: true, + ControllersHealthy: 1, + ControllersExpected: 1, + NodesHealthy: 1, + NodesExpected: 2, + CreateIndex: 1, + ModifyIndex: 38, + WriteAllocs: {}, + ReadAllocs: {}, + }, + out: { + data: { + id: '["csi/volume-id","default"]', + type: 'volume', + attributes: { + plainId: 'volume-id', + name: 'volume-id', + externalId: 'external-uuid', + topologies: {}, + accessMode: 'access-this-way', + attachmentMode: 'attach-this-way', + schedulable: true, + provider: 'abc.123', + version: '1.0.29', + controllerRequired: true, + controllersHealthy: 1, + controllersExpected: 1, + nodesHealthy: 1, + nodesExpected: 2, + createIndex: 1, + modifyIndex: 38, + }, + relationships: { + plugin: { + data: { + id: 'csi/plugin-1', + type: 'plugin', + }, + }, + readAllocations: { + data: [], + }, + writeAllocations: { + data: [], + }, + }, + }, + included: [], + }, + }, + + { + name: 'The ID of the record is a composite of both the name and the namespace', + in: { + ID: 'volume-id', + Name: 'volume-id', + Namespace: 'namespace-2', + PluginID: 'plugin-1', + ExternalID: 'external-uuid', + Topologies: {}, + AccessMode: 'access-this-way', + AttachmentMode: 'attach-this-way', + Schedulable: true, + Provider: 'abc.123', + Version: '1.0.29', + ControllerRequired: true, + ControllersHealthy: 1, + ControllersExpected: 1, + NodesHealthy: 1, + NodesExpected: 2, + CreateIndex: 1, + ModifyIndex: 38, + WriteAllocs: {}, + ReadAllocs: {}, + }, + out: { + data: { + id: '["csi/volume-id","namespace-2"]', + type: 'volume', + attributes: { + plainId: 'volume-id', + name: 'volume-id', + externalId: 'external-uuid', + topologies: {}, + accessMode: 'access-this-way', + attachmentMode: 'attach-this-way', + schedulable: true, + provider: 'abc.123', + version: '1.0.29', + controllerRequired: true, + controllersHealthy: 1, + controllersExpected: 1, + nodesHealthy: 1, + nodesExpected: 2, + createIndex: 1, + modifyIndex: 38, + }, + relationships: { + plugin: { + data: { + id: 'csi/plugin-1', + type: 'plugin', + }, + }, + namespace: { + data: { + id: 'namespace-2', + type: 'namespace', + }, + }, + readAllocations: { + data: [], + }, + writeAllocations: { + data: [], + }, + }, + }, + included: [], + }, + }, + + { + name: + 'Allocations are interpreted as embedded records and are properly normalized into included resources in a JSON API shape', + in: { + ID: 'volume-id', + Name: 'volume-id', + Namespace: 'namespace-2', + PluginID: 'plugin-1', + ExternalID: 'external-uuid', + Topologies: {}, + AccessMode: 'access-this-way', + AttachmentMode: 'attach-this-way', + Schedulable: true, + Provider: 'abc.123', + Version: '1.0.29', + ControllerRequired: true, + ControllersHealthy: 1, + ControllersExpected: 1, + NodesHealthy: 1, + NodesExpected: 2, + CreateIndex: 1, + ModifyIndex: 38, + WriteAllocs: { + 'alloc-id-1': { + TaskGroup: 'foobar', + CreateTime: +REF_DATE * 1000000, + ModifyTime: +REF_DATE * 1000000, + JobID: 'the-job', + Namespace: 'namespace-2', + }, + 'alloc-id-2': { + TaskGroup: 'write-here', + CreateTime: +REF_DATE * 1000000, + ModifyTime: +REF_DATE * 1000000, + JobID: 'the-job', + Namespace: 'namespace-2', + }, + }, + ReadAllocs: { + 'alloc-id-3': { + TaskGroup: 'look-if-you-must', + CreateTime: +REF_DATE * 1000000, + ModifyTime: +REF_DATE * 1000000, + JobID: 'the-job', + Namespace: 'namespace-2', + }, + }, + }, + out: { + data: { + id: '["csi/volume-id","namespace-2"]', + type: 'volume', + attributes: { + plainId: 'volume-id', + name: 'volume-id', + externalId: 'external-uuid', + topologies: {}, + accessMode: 'access-this-way', + attachmentMode: 'attach-this-way', + schedulable: true, + provider: 'abc.123', + version: '1.0.29', + controllerRequired: true, + controllersHealthy: 1, + controllersExpected: 1, + nodesHealthy: 1, + nodesExpected: 2, + createIndex: 1, + modifyIndex: 38, + }, + relationships: { + plugin: { + data: { + id: 'csi/plugin-1', + type: 'plugin', + }, + }, + namespace: { + data: { + id: 'namespace-2', + type: 'namespace', + }, + }, + readAllocations: { + data: [{ type: 'allocation', id: 'alloc-id-3' }], + }, + writeAllocations: { + data: [ + { type: 'allocation', id: 'alloc-id-1' }, + { type: 'allocation', id: 'alloc-id-2' }, + ], + }, + }, + }, + included: [ + { + id: 'alloc-id-1', + type: 'allocation', + attributes: { + createTime: REF_DATE, + modifyTime: REF_DATE, + taskGroupName: 'foobar', + wasPreempted: false, + states: [], + }, + relationships: { + followUpEvaluation: { + data: null, + }, + job: { + data: { type: 'job', id: '["the-job","namespace-2"]' }, + }, + nextAllocation: { + data: null, + }, + previousAllocation: { + data: null, + }, + preemptedAllocations: { + data: [], + }, + preemptedByAllocation: { + data: null, + }, + }, + }, + { + id: 'alloc-id-2', + type: 'allocation', + attributes: { + createTime: REF_DATE, + modifyTime: REF_DATE, + taskGroupName: 'write-here', + wasPreempted: false, + states: [], + }, + relationships: { + followUpEvaluation: { + data: null, + }, + job: { + data: { type: 'job', id: '["the-job","namespace-2"]' }, + }, + nextAllocation: { + data: null, + }, + previousAllocation: { + data: null, + }, + preemptedAllocations: { + data: [], + }, + preemptedByAllocation: { + data: null, + }, + }, + }, + { + id: 'alloc-id-3', + type: 'allocation', + attributes: { + createTime: REF_DATE, + modifyTime: REF_DATE, + taskGroupName: 'look-if-you-must', + wasPreempted: false, + states: [], + }, + relationships: { + followUpEvaluation: { + data: null, + }, + job: { + data: { type: 'job', id: '["the-job","namespace-2"]' }, + }, + nextAllocation: { + data: null, + }, + previousAllocation: { + data: null, + }, + preemptedAllocations: { + data: [], + }, + preemptedByAllocation: { + data: null, + }, + }, + }, + ], + }, + }, + ]; + + normalizationTestCases.forEach(testCase => { + test(`normalization: ${testCase.name}`, async function(assert) { + assert.deepEqual(this.subject().normalize(VolumeModel, testCase.in), testCase.out); + }); + }); +});