UI: Support for CSI (#7446)
Closes #7197 #7199 Note: Test coverage is limited to adapter and serializer unit tests. All acceptance tests have been stubbed and all features have been manually tested end-to-end. This represents Phase 1 of #6993 which is the core workflow of CSI in the UI. It includes a couple new pages for viewing all external volumes as well as the allocations associated with each. It also updates existing volume related views on job and allocation pages to handle both Host Volumes and CSI Volumes.
This commit is contained in:
parent
0c1dd8a204
commit
1bd6a69067
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import Controller from '@ember/controller';
|
||||
|
||||
export default Controller.extend({
|
||||
queryParams: {
|
||||
volumeNamespace: 'namespace',
|
||||
},
|
||||
|
||||
isForbidden: false,
|
||||
|
||||
volumeNamespace: 'default',
|
||||
});
|
|
@ -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'),
|
||||
}
|
||||
);
|
|
@ -0,0 +1,9 @@
|
|||
import Controller from '@ember/controller';
|
||||
|
||||
export default Controller.extend({
|
||||
actions: {
|
||||
gotoAllocation(allocation) {
|
||||
this.transitionToRoute('allocations.allocation', allocation);
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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: () => [] }),
|
||||
});
|
|
@ -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(),
|
||||
});
|
|
@ -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(),
|
||||
});
|
|
@ -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();
|
||||
|
|
|
@ -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'),
|
||||
});
|
|
@ -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'),
|
||||
|
|
|
@ -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'),
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import Route from '@ember/routing/route';
|
||||
|
||||
export default Route.extend({
|
||||
redirect() {
|
||||
this.transitionTo('csi.volumes');
|
||||
},
|
||||
});
|
|
@ -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));
|
||||
},
|
||||
});
|
|
@ -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'),
|
||||
});
|
|
@ -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'),
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -131,7 +131,15 @@
|
|||
{{/t.head}}
|
||||
{{#t.body as |row|}}
|
||||
<tr data-test-volume>
|
||||
<td data-test-volume-name>{{row.model.volume}}</td>
|
||||
<td data-test-volume-name>
|
||||
{{#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}}
|
||||
</td>
|
||||
<td data-test-volume-destination><code>{{row.model.destination}}</code></td>
|
||||
<td data-test-volume-permissions>{{if row.model.readOnly "Read" "Read/Write"}}</td>
|
||||
<td data-test-volume-client-source>{{row.model.source}}</td>
|
||||
|
|
|
@ -36,10 +36,13 @@
|
|||
<td data-test-client-status class="is-one-line">
|
||||
<span class="color-swatch {{allocation.clientStatus}}" /> {{allocation.clientStatus}}
|
||||
</td>
|
||||
{{#if (eq context "volume")}}
|
||||
<td data-test-client>{{#link-to "clients.client" allocation.node}}{{allocation.node.shortId}}{{/link-to}}</td>
|
||||
{{/if}}
|
||||
{{#if (or (eq context "taskGroup") (eq context "job"))}}
|
||||
<td data-test-job-version>{{allocation.jobVersion}}</td>
|
||||
<td data-test-client>{{#link-to "clients.client" allocation.node}}{{allocation.node.shortId}}{{/link-to}}</td>
|
||||
{{else if (eq context "node")}}
|
||||
{{else if (or (eq context "node") (eq context "volume"))}}
|
||||
<td>
|
||||
{{#if (or allocation.job.isPending allocation.job.isReloading)}}
|
||||
...
|
||||
|
@ -50,7 +53,9 @@
|
|||
</td>
|
||||
<td data-test-job-version class="is-1">{{allocation.jobVersion}}</td>
|
||||
{{/if}}
|
||||
<td data-test-volume>{{if allocation.taskGroup.volumes.length "Yes"}}</td>
|
||||
{{#if (not (eq context "volume"))}}
|
||||
<td data-test-volume>{{if allocation.taskGroup.volumes.length "Yes"}}</td>
|
||||
{{/if}}
|
||||
<td data-test-cpu class="is-1 has-text-centered">
|
||||
{{#if allocation.isRunning}}
|
||||
{{#if (and (not cpu) fetchStats.isRunning)}}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
{{#if system.shouldShowRegions}}
|
||||
<div class="collapsed-only">
|
||||
<p class="menu-label">
|
||||
Region
|
||||
Region {{if system.shouldShowNamespaces "& Namespace"}}
|
||||
</p>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
|
@ -22,12 +22,9 @@
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{{/if}}
|
||||
<p class="menu-label">
|
||||
Workload
|
||||
</p>
|
||||
<ul class="menu-list">
|
||||
{{#if system.shouldShowNamespaces}}
|
||||
{{/if}}
|
||||
{{#if system.shouldShowNamespaces}}
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<div class="menu-item is-wide">
|
||||
{{#power-select
|
||||
|
@ -48,9 +45,20 @@
|
|||
{{/power-select}}
|
||||
</div>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
<p class="menu-label">
|
||||
Workload
|
||||
</p>
|
||||
<ul class="menu-list">
|
||||
<li>{{#link-to "jobs" activeClass="is-active" data-test-gutter-link="jobs"}}Jobs{{/link-to}}</li>
|
||||
</ul>
|
||||
<p class="menu-label is-minor">
|
||||
Integrations
|
||||
</p>
|
||||
<ul class="menu-list">
|
||||
<li>{{#link-to "csi" activeClass="is-active" data-test-gutter-link="csi"}}CSI{{/link-to}}</li>
|
||||
</ul>
|
||||
<p class="menu-label">
|
||||
Cluster
|
||||
</p>
|
||||
|
|
|
@ -27,7 +27,13 @@
|
|||
{{#each task.task.volumeMounts as |volume|}}
|
||||
<li data-test-volume>
|
||||
<strong>{{volume.volume}}:</strong>
|
||||
{{volume.source}}
|
||||
{{#if volume.isCSI}}
|
||||
{{#link-to "csi.volumes.volume" volume.volume (query-params volumeNamespace=volume.namespace.id)}}
|
||||
{{volume.source}}
|
||||
{{/link-to}}
|
||||
{{else}}
|
||||
{{volume.source}}
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{{#page-layout}}
|
||||
{{outlet}}
|
||||
{{/page-layout}}
|
|
@ -0,0 +1 @@
|
|||
{{outlet}}
|
|
@ -0,0 +1,65 @@
|
|||
{{title "CSI Volumes"}}
|
||||
<section class="section">
|
||||
{{#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}}
|
||||
<th># Allocs</th>
|
||||
{{/t.head}}
|
||||
{{#t.body key="model.name" as |row|}}
|
||||
<tr class="is-interactive">
|
||||
<td>
|
||||
{{#link-to "csi.volumes.volume" row.model.plainId class="is-primary"}}{{row.model.name}}{{/link-to}}
|
||||
</td>
|
||||
<td>{{if row.model.schedulable "Schedulable" "Unschedulable"}}</td>
|
||||
<td>
|
||||
{{if (gt row.model.controllersHealthy 0) "Healthy" "Unhealthy"}}
|
||||
({{row.model.controllersHealthy}}/{{row.model.controllersExpected}})
|
||||
</td>
|
||||
<td>
|
||||
{{if (gt row.model.nodesHealthy 0) "Healthy" "Unhealthy"}}
|
||||
({{row.model.nodesHealthy}}/{{row.model.nodesExpected}})
|
||||
</td>
|
||||
<td>{{row.model.provider}}</td>
|
||||
<td>{{row.model.allocations.length}}</td>
|
||||
</tr>
|
||||
{{/t.body}}
|
||||
{{/list-table}}
|
||||
<div class="table-foot">
|
||||
<nav class="pagination">
|
||||
<div class="pagination-numbers">
|
||||
{{p.startsAt}}–{{p.endsAt}} of {{sortedVolumes.length}}
|
||||
</div>
|
||||
{{#p.prev class="pagination-previous"}} < {{/p.prev}}
|
||||
{{#p.next class="pagination-next"}} > {{/p.next}}
|
||||
<ul class="pagination-list"></ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{/list-pagination}}
|
||||
{{else}}
|
||||
<div data-test-empty-jobs-list class="empty-message">
|
||||
{{#if (eq sortedVolumes.length 0)}}
|
||||
<h3 data-test-empty-jobs-list-headline class="empty-message-headline">No Volumes</h3>
|
||||
<p class="empty-message-body">
|
||||
The cluster currently has no CSI volumes.
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</section>
|
|
@ -0,0 +1,102 @@
|
|||
{{title "CSI Volume " model.name}}
|
||||
<section class="section">
|
||||
<h1 class="title">{{model.name}}</h1>
|
||||
|
||||
<div class="boxed-section is-small">
|
||||
<div class="boxed-section-body inline-definitions">
|
||||
<span class="label">Volume Details</span>
|
||||
<span class="pair">
|
||||
<span class="term">health</span>
|
||||
{{if model.schedulable "Schedulable" "Unschedulable"}}
|
||||
</span>
|
||||
<span class="pair">
|
||||
<span class="term">Provider</span>
|
||||
{{model.provider}}
|
||||
</span>
|
||||
<span class="pair">
|
||||
<span class="term">External ID</span>
|
||||
{{model.externalId}}
|
||||
</span>
|
||||
<span class="pair">
|
||||
<span class="term">Namespace</span>
|
||||
{{model.namespace.name}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="boxed-section">
|
||||
<div class="boxed-section-head">
|
||||
Write Allocations
|
||||
</div>
|
||||
<div class="boxed-section-body {{if model.writeAllocations.length "is-full-bleed"}}">
|
||||
{{#if model.writeAllocations.length}}
|
||||
{{#list-table
|
||||
source=model.writeAllocations
|
||||
class="with-foot" as |t|}}
|
||||
{{#t.head}}
|
||||
<th class="is-narrow"></th>
|
||||
<th>ID</th>
|
||||
<th>Modified</th>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
<th>Client</th>
|
||||
<th>Job</th>
|
||||
<th>Version</th>
|
||||
<th>CPU</th>
|
||||
<th>Memory</th>
|
||||
{{/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}}
|
||||
<div class="empty-message" data-test-empty-recent-allocations>
|
||||
<h3 class="empty-message-headline" data-test-empty-recent-allocations-headline>No Write Allocations</h3>
|
||||
<p class="empty-message-body" data-test-empty-recent-allocations-message>No allocations are depending on this volume for read/write access.</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="boxed-section">
|
||||
<div class="boxed-section-head">
|
||||
Read Allocations
|
||||
</div>
|
||||
<div class="boxed-section-body {{if model.readAllocations.length "is-full-bleed"}}">
|
||||
{{#if model.readAllocations.length}}
|
||||
{{#list-table
|
||||
source=model.readAllocations
|
||||
class="with-foot" as |t|}}
|
||||
{{#t.head}}
|
||||
<th class="is-narrow"></th>
|
||||
<th>ID</th>
|
||||
<th>Modified</th>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
<th>Client</th>
|
||||
<th>Job</th>
|
||||
<th>Version</th>
|
||||
<th>CPU</th>
|
||||
<th>Memory</th>
|
||||
{{/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}}
|
||||
<div class="empty-message" data-test-empty-recent-allocations>
|
||||
<h3 class="empty-message-headline" data-test-empty-recent-allocations-headline>No Read Allocations</h3>
|
||||
<p class="empty-message-body" data-test-empty-recent-allocations-message>No allocations are depending on this volume for read-only access.</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
|
@ -126,7 +126,15 @@
|
|||
{{/t.head}}
|
||||
{{#t.body as |row|}}
|
||||
<tr data-test-volume>
|
||||
<td data-test-volume-name>{{row.model.name}}</td>
|
||||
<td data-test-volume-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}}
|
||||
</td>
|
||||
<td data-test-volume-type>{{row.model.type}}</td>
|
||||
<td data-test-volume-source>{{row.model.source}}</td>
|
||||
<td data-test-volume-permissions>{{if row.model.readOnly "Read" "Read/Write"}}</td>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
import { Model, hasMany } from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
nodes: hasMany('storage-node'),
|
||||
controllers: hasMany('storage-controller'),
|
||||
});
|
|
@ -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'),
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
import { Model, belongsTo } from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
job: belongsTo(),
|
||||
node: belongsTo(),
|
||||
allocation: belongsTo(),
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
import { Model, belongsTo } from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
job: belongsTo(),
|
||||
node: belongsTo(),
|
||||
allocation: belongsTo(),
|
||||
});
|
|
@ -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) {
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import ApplicationSerializer from './application';
|
||||
|
||||
export default ApplicationSerializer.extend({
|
||||
embed: true,
|
||||
include: ['nodes', 'controllers'],
|
||||
});
|
|
@ -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');
|
||||
}
|
|
@ -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() {});
|
||||
});
|
|
@ -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() {});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue