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