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); + }); + }); +});