diff --git a/ui/app/components/allocation-row.js b/ui/app/components/allocation-row.js
index b102f79d96e..3e00ea64538 100644
--- a/ui/app/components/allocation-row.js
+++ b/ui/app/components/allocation-row.js
@@ -71,24 +71,22 @@ export default Component.extend({
}).drop(),
});
-function qualifyAllocation() {
+async function qualifyAllocation() {
const allocation = this.allocation;
// Make sure the allocation is a complete record and not a partial so we
// can show information such as preemptions and rescheduled allocation.
- return allocation.reload().then(() => {
- this.fetchStats.perform();
+ await allocation.reload();
+ if (allocation.get('job.isPending')) {
+ // Make sure the job is loaded before starting the stats tracker
+ await allocation.get('job');
+ } else if (!allocation.get('taskGroup')) {
// Make sure that the job record in the store for this allocation
// is complete and not a partial from the list endpoint
- if (
- allocation &&
- allocation.get('job') &&
- !allocation.get('job.isPending') &&
- !allocation.get('taskGroup')
- ) {
- const job = allocation.get('job.content');
- job && job.reload();
- }
- });
+ const job = allocation.get('job.content');
+ if (job) await job.reload();
+ }
+
+ this.fetchStats.perform();
}
diff --git a/ui/app/components/allocation-stat.js b/ui/app/components/allocation-stat.js
new file mode 100644
index 00000000000..c8f16a7a42e
--- /dev/null
+++ b/ui/app/components/allocation-stat.js
@@ -0,0 +1,44 @@
+import Component from '@ember/component';
+import { computed } from '@ember/object';
+import { alias } from '@ember/object/computed';
+import { formatBytes } from 'nomad-ui/helpers/format-bytes';
+
+export default Component.extend({
+ tagName: '',
+
+ allocation: null,
+ statsTracker: null,
+ isLoading: false,
+ error: null,
+ metric: 'memory', // Either memory or cpu
+
+ statClass: computed('metric', function() {
+ return this.metric === 'cpu' ? 'is-info' : 'is-danger';
+ }),
+
+ cpu: alias('statsTracker.cpu.lastObject'),
+ memory: alias('statsTracker.memory.lastObject'),
+
+ stat: computed('metric', 'cpu', 'memory', function() {
+ const { metric } = this;
+ if (metric === 'cpu' || metric === 'memory') {
+ return this[this.metric];
+ }
+ }),
+
+ formattedStat: computed('metric', 'stat.used', function() {
+ if (!this.stat) return;
+ if (this.metric === 'memory') return formatBytes([this.stat.used]);
+ return this.stat.used;
+ }),
+
+ formattedReserved: computed(
+ 'metric',
+ 'statsTracker.reservedMemory',
+ 'statsTracker.reservedCPU',
+ function() {
+ if (this.metric === 'memory') return `${this.statsTracker.reservedMemory} MiB`;
+ if (this.metric === 'cpu') return `${this.statsTracker.reservedCPU} MHz`;
+ }
+ ),
+});
diff --git a/ui/app/components/plugin-allocation-row.js b/ui/app/components/plugin-allocation-row.js
new file mode 100644
index 00000000000..f3fef64d826
--- /dev/null
+++ b/ui/app/components/plugin-allocation-row.js
@@ -0,0 +1,7 @@
+import { alias } from '@ember/object/computed';
+import AllocationRow from 'nomad-ui/components/allocation-row';
+
+export default AllocationRow.extend({
+ pluginAllocation: null,
+ allocation: alias('pluginAllocation.allocation'),
+});
diff --git a/ui/app/controllers/csi/plugins.js b/ui/app/controllers/csi/plugins.js
new file mode 100644
index 00000000000..f4d0631dccf
--- /dev/null
+++ b/ui/app/controllers/csi/plugins.js
@@ -0,0 +1,5 @@
+import Controller from '@ember/controller';
+
+export default Controller.extend({
+ isForbidden: false,
+});
diff --git a/ui/app/controllers/csi/plugins/index.js b/ui/app/controllers/csi/plugins/index.js
new file mode 100644
index 00000000000..51f6c5a61fe
--- /dev/null
+++ b/ui/app/controllers/csi/plugins/index.js
@@ -0,0 +1,40 @@
+import { inject as service } from '@ember/service';
+import { alias, readOnly } from '@ember/object/computed';
+import Controller, { inject as controller } from '@ember/controller';
+import SortableFactory from 'nomad-ui/mixins/sortable-factory';
+import { lazyClick } from 'nomad-ui/helpers/lazy-click';
+
+export default Controller.extend(SortableFactory([]), {
+ userSettings: service(),
+ pluginsController: controller('csi/plugins'),
+
+ isForbidden: alias('pluginsController.isForbidden'),
+
+ queryParams: {
+ currentPage: 'page',
+ sortProperty: 'sort',
+ sortDescending: 'desc',
+ },
+
+ currentPage: 1,
+ pageSize: readOnly('userSettings.pageSize'),
+
+ sortProperty: 'id',
+ sortDescending: false,
+
+ listToSort: alias('model'),
+ sortedPlugins: alias('listSorted'),
+
+ // TODO: Remove once this page gets search capability
+ resetPagination() {
+ if (this.currentPage != null) {
+ this.set('currentPage', 1);
+ }
+ },
+
+ actions: {
+ gotoPlugin(plugin, event) {
+ lazyClick([() => this.transitionToRoute('csi.plugins.plugin', plugin.plainId), event]);
+ },
+ },
+});
diff --git a/ui/app/controllers/csi/plugins/plugin.js b/ui/app/controllers/csi/plugins/plugin.js
new file mode 100644
index 00000000000..fcd6977e611
--- /dev/null
+++ b/ui/app/controllers/csi/plugins/plugin.js
@@ -0,0 +1,18 @@
+import Controller from '@ember/controller';
+import { computed } from '@ember/object';
+
+export default Controller.extend({
+ sortedControllers: computed('model.controllers.@each.updateTime', function() {
+ return this.model.controllers.sortBy('updateTime').reverse();
+ }),
+
+ sortedNodes: computed('model.nodes.@each.updateTime', function() {
+ return this.model.nodes.sortBy('updateTime').reverse();
+ }),
+
+ actions: {
+ gotoAllocation(allocation) {
+ this.transitionToRoute('allocations.allocation', allocation);
+ },
+ },
+});
diff --git a/ui/app/controllers/csi/volumes/index.js b/ui/app/controllers/csi/volumes/index.js
index 195be87ec9d..aa63bb51b69 100644
--- a/ui/app/controllers/csi/volumes/index.js
+++ b/ui/app/controllers/csi/volumes/index.js
@@ -3,6 +3,7 @@ import { computed } from '@ember/object';
import { alias, readOnly } from '@ember/object/computed';
import Controller, { inject as controller } from '@ember/controller';
import SortableFactory from 'nomad-ui/mixins/sortable-factory';
+import { lazyClick } from 'nomad-ui/helpers/lazy-click';
export default Controller.extend(
SortableFactory([
@@ -58,8 +59,11 @@ export default Controller.extend(
},
actions: {
- gotoVolume(volume) {
- this.transitionToRoute('csi.volumes.volume', volume.get('plainId'));
+ gotoVolume(volume, event) {
+ lazyClick([
+ () => this.transitionToRoute('csi.volumes.volume', volume.get('plainId')),
+ event,
+ ]);
},
},
}
diff --git a/ui/app/controllers/csi/volumes/volume.js b/ui/app/controllers/csi/volumes/volume.js
index 3d633bdc2e9..5e43260b4f6 100644
--- a/ui/app/controllers/csi/volumes/volume.js
+++ b/ui/app/controllers/csi/volumes/volume.js
@@ -3,6 +3,7 @@ import { inject as service } from '@ember/service';
import { computed } from '@ember/object';
export default Controller.extend({
+ // Used in the template
system: service(),
sortedReadAllocations: computed('model.readAllocations.@each.modifyIndex', function() {
diff --git a/ui/app/helpers/format-month-ts.js b/ui/app/helpers/format-month-ts.js
index a787d6b5e9d..a9b96ad75a7 100644
--- a/ui/app/helpers/format-month-ts.js
+++ b/ui/app/helpers/format-month-ts.js
@@ -1,8 +1,8 @@
import moment from 'moment';
import Helper from '@ember/component/helper';
-export function formatMonthTs([date]) {
- const format = 'MMM DD HH:mm:ss ZZ';
+export function formatMonthTs([date], options = {}) {
+ const format = options.short ? 'MMM D' : 'MMM DD HH:mm:ss ZZ';
return moment(date).format(format);
}
diff --git a/ui/app/helpers/format-ts.js b/ui/app/helpers/format-ts.js
index 10e1bb809fb..1169efe0f61 100644
--- a/ui/app/helpers/format-ts.js
+++ b/ui/app/helpers/format-ts.js
@@ -1,8 +1,8 @@
import moment from 'moment';
import Helper from '@ember/component/helper';
-export function formatTs([date]) {
- const format = "MMM DD, 'YY HH:mm:ss ZZ";
+export function formatTs([date], options = {}) {
+ const format = options.short ? 'MMM D' : "MMM DD, 'YY HH:mm:ss ZZ";
return moment(date).format(format);
}
diff --git a/ui/app/models/plugin.js b/ui/app/models/plugin.js
index 152427a0595..6042b2f680e 100644
--- a/ui/app/models/plugin.js
+++ b/ui/app/models/plugin.js
@@ -1,13 +1,30 @@
+import { computed } from '@ember/object';
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
-// import { fragmentArray } from 'ember-data-model-fragments/attributes';
+import { fragmentArray } from 'ember-data-model-fragments/attributes';
export default Model.extend({
+ plainId: attr('string'),
+
topologies: attr(),
provider: attr('string'),
version: attr('string'),
+
+ controllers: fragmentArray('storage-controller', { defaultValue: () => [] }),
+ nodes: fragmentArray('storage-node', { defaultValue: () => [] }),
+
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'),
- // controllers: fragmentArray('storage-controller', { defaultValue: () => [] }),
- // nodes: fragmentArray('storage-node', { defaultValue: () => [] }),
+ nodesHealthyProportion: computed('nodesHealthy', 'nodesExpected', function() {
+ return this.nodesHealthy / this.nodesExpected;
+ }),
});
diff --git a/ui/app/models/storage-controller.js b/ui/app/models/storage-controller.js
index 3ad5009f4ab..e7b3aeea7b8 100644
--- a/ui/app/models/storage-controller.js
+++ b/ui/app/models/storage-controller.js
@@ -1,13 +1,25 @@
+import { computed } from '@ember/object';
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';
+import PromiseObject from 'nomad-ui/utils/classes/promise-object';
export default Fragment.extend({
plugin: fragmentOwner(),
node: belongsTo('node'),
- allocation: belongsTo('allocation'),
+ allocID: attr('string'),
+
+ // Model fragments don't support relationships, but with an allocation ID
+ // a "belongsTo" can be sufficiently mocked.
+ allocation: computed('allocID', function() {
+ if (!this.allocID) return null;
+ return PromiseObject.create({
+ promise: this.store.findRecord('allocation', this.allocID),
+ reload: () => this.store.findRecord('allocation', this.allocID),
+ });
+ }),
provider: attr('string'),
version: attr('string'),
diff --git a/ui/app/models/storage-node.js b/ui/app/models/storage-node.js
index de76d4a8ece..819279717f3 100644
--- a/ui/app/models/storage-node.js
+++ b/ui/app/models/storage-node.js
@@ -1,13 +1,25 @@
+import { computed } from '@ember/object';
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';
+import PromiseObject from 'nomad-ui/utils/classes/promise-object';
export default Fragment.extend({
plugin: fragmentOwner(),
node: belongsTo('node'),
- allocation: belongsTo('allocation'),
+ allocID: attr('string'),
+
+ // Model fragments don't support relationships, but with an allocation ID
+ // a "belongsTo" can be sufficiently mocked.
+ allocation: computed('allocID', function() {
+ if (!this.allocID) return null;
+ return PromiseObject.create({
+ promise: this.store.findRecord('allocation', this.allocID),
+ reload: () => this.store.findRecord('allocation', this.allocID),
+ });
+ }),
provider: attr('string'),
version: attr('string'),
diff --git a/ui/app/router.js b/ui/app/router.js
index 241e5023149..2b5d1d974a3 100644
--- a/ui/app/router.js
+++ b/ui/app/router.js
@@ -37,6 +37,10 @@ Router.map(function() {
this.route('volumes', function() {
this.route('volume', { path: '/:volume_name' });
});
+
+ this.route('plugins', function() {
+ this.route('plugin', { path: '/:plugin_name' });
+ });
});
this.route('allocations', function() {
diff --git a/ui/app/routes/csi/plugins.js b/ui/app/routes/csi/plugins.js
new file mode 100644
index 00000000000..9aaf2d516af
--- /dev/null
+++ b/ui/app/routes/csi/plugins.js
@@ -0,0 +1,19 @@
+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, {
+ store: service(),
+
+ breadcrumbs: [
+ {
+ label: 'Storage',
+ args: ['csi.index'],
+ },
+ ],
+
+ model() {
+ return this.store.query('plugin', { type: 'csi' }).catch(notifyForbidden(this));
+ },
+});
diff --git a/ui/app/routes/csi/plugins/index.js b/ui/app/routes/csi/plugins/index.js
new file mode 100644
index 00000000000..2adff9d30f8
--- /dev/null
+++ b/ui/app/routes/csi/plugins/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('plugin'),
+ watchers: collect('watch'),
+});
diff --git a/ui/app/routes/csi/plugins/plugin.js b/ui/app/routes/csi/plugins/plugin.js
new file mode 100644
index 00000000000..f3630e0acbe
--- /dev/null
+++ b/ui/app/routes/csi/plugins/plugin.js
@@ -0,0 +1,41 @@
+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 { 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: plugin => [
+ {
+ label: 'Plugins',
+ args: ['csi.plugins'],
+ },
+ {
+ label: plugin.plainId,
+ args: ['csi.plugins.plugin', plugin.plainId],
+ },
+ ],
+
+ startWatchers(controller, model) {
+ if (!model) return;
+
+ controller.set('watchers', {
+ model: this.watch.perform(model),
+ });
+ },
+
+ serialize(model) {
+ return { plugin_name: model.get('plainId') };
+ },
+
+ model(params) {
+ return this.store.findRecord('plugin', `csi/${params.plugin_name}`).catch(notifyError(this));
+ },
+
+ watch: watchRecord('plugin'),
+ watchers: collect('watch'),
+});
diff --git a/ui/app/serializers/plugin.js b/ui/app/serializers/plugin.js
index 779238a265b..6f87159d746 100644
--- a/ui/app/serializers/plugin.js
+++ b/ui/app/serializers/plugin.js
@@ -1,8 +1,20 @@
import ApplicationSerializer from './application';
+// Convert a map[string]interface{} into an array of objects
+// where the key becomes a property at propKey.
+// This is destructive. The original object is mutated to avoid
+// excessive copies of the originals which would otherwise just
+// be garbage collected.
+const unmap = (hash, propKey) =>
+ Object.keys(hash).map(key => {
+ const record = hash[key];
+ record[propKey] = key;
+ return record;
+ });
+
export default ApplicationSerializer.extend({
normalize(typeHash, hash) {
- hash.PlainID = hash.ID;
+ 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
@@ -10,8 +22,11 @@ export default ApplicationSerializer.extend({
// this identifier.
hash.ID = `csi/${hash.ID}`;
- hash.Nodes = hash.Nodes || [];
- hash.Controllers = hash.Controllers || [];
+ const nodes = hash.Nodes || {};
+ const controllers = hash.Controllers || {};
+
+ hash.Nodes = unmap(nodes, 'NodeID');
+ hash.Controllers = unmap(controllers, 'NodeID');
return this._super(typeHash, hash);
},
diff --git a/ui/app/styles/core/icon.scss b/ui/app/styles/core/icon.scss
index 3a49731257c..d2b499d50bf 100644
--- a/ui/app/styles/core/icon.scss
+++ b/ui/app/styles/core/icon.scss
@@ -42,6 +42,7 @@ $icon-dimensions-large: 2rem;
&.is-#{$name} {
fill: $color;
+ color: $color;
}
}
}
diff --git a/ui/app/templates/components/allocation-row.hbs b/ui/app/templates/components/allocation-row.hbs
index ed3fa0fbe87..b9f7fb5bbe4 100644
--- a/ui/app/templates/components/allocation-row.hbs
+++ b/ui/app/templates/components/allocation-row.hbs
@@ -57,42 +57,18 @@
{{if allocation.taskGroup.volumes.length "Yes"}} |
{{/if}}
- {{#if allocation.isRunning}}
- {{#if (and (not cpu) fetchStats.isRunning)}}
- ...
- {{else if statsError}}
-
- {{x-icon "warning" class="is-warning"}}
-
- {{else}}
-
- {{/if}}
- {{/if}}
+ {{allocation-stat
+ metric="cpu"
+ allocation=allocation
+ statsTracker=stats
+ isLoading=fetchStats.isRunning
+ error=statsError}}
|
- {{#if allocation.isRunning}}
- {{#if (and (not memory) fetchStats.isRunning)}}
- ...
- {{else if statsError}}
-
- {{x-icon "warning" class="is-warning"}}
-
- {{else}}
-
- {{/if}}
- {{/if}}
+ {{allocation-stat
+ metric="memory"
+ allocation=allocation
+ statsTracker=stats
+ isLoading=fetchStats.isRunning
+ error=statsError}}
|
diff --git a/ui/app/templates/components/allocation-stat.hbs b/ui/app/templates/components/allocation-stat.hbs
new file mode 100644
index 00000000000..8dabc3422bf
--- /dev/null
+++ b/ui/app/templates/components/allocation-stat.hbs
@@ -0,0 +1,18 @@
+{{#if allocation.isRunning}}
+ {{#if (and (not stat) isLoading)}}
+ …
+ {{else if error}}
+
+ {{x-icon "warning" class="is-warning"}}
+
+ {{else}}
+
+ {{/if}}
+{{/if}}
diff --git a/ui/app/templates/components/plugin-allocation-row.hbs b/ui/app/templates/components/plugin-allocation-row.hbs
new file mode 100644
index 00000000000..2bc1a3f04e7
--- /dev/null
+++ b/ui/app/templates/components/plugin-allocation-row.hbs
@@ -0,0 +1,77 @@
+{{#if allocation}}
+
+ {{#if allocation.unhealthyDrivers.length}}
+
+ {{x-icon "warning" class="is-warning"}}
+
+ {{/if}}
+ {{#if allocation.nextAllocation}}
+
+ {{x-icon "history" class="is-faded"}}
+
+ {{/if}}
+ {{#if allocation.wasPreempted}}
+
+ {{x-icon "boot" class="is-faded"}}
+
+ {{/if}}
+ |
+
+
+ {{#link-to "allocations.allocation" allocation class="is-primary"}}
+ {{allocation.shortId}}
+ {{/link-to}}
+ |
+
+
+
+ {{format-month-ts allocation.createTime short=true}}
+
+ |
+
+
+
+ {{moment-from-now allocation.modifyTime}}
+
+ |
+
+
+
+ {{x-icon
+ (if pluginAllocation.healthy "check-circle-outline" "minus-circle-outline")
+ class=(if pluginAllocation.healthy "is-success" "is-danger")}}
+ {{if pluginAllocation.healthy "Healthy" "Unhealthy"}}
+
+ |
+
+ {{#link-to "clients.client" allocation.node}}{{allocation.node.shortId}}{{/link-to}} |
+
+ {{#if (or allocation.job.isPending allocation.job.isReloading)}}
+ ...
+ {{else}}
+ {{#link-to "jobs.job" allocation.job (query-params jobNamespace=allocation.job.namespace.id) data-test-job}}{{allocation.job.name}}{{/link-to}}
+ / {{allocation.taskGroup.name}}
+ {{/if}}
+ |
+ {{allocation.jobVersion}} |
+ {{if allocation.taskGroup.volumes.length "Yes"}} |
+
+
+ {{allocation-stat
+ metric="cpu"
+ allocation=allocation
+ statsTracker=stats
+ isLoading=fetchStats.isRunning
+ error=statsError}}
+ |
+
+ {{allocation-stat
+ metric="memory"
+ allocation=allocation
+ statsTracker=stats
+ isLoading=fetchStats.isRunning
+ error=statsError}}
+ |
+{{else}}
+ … |
+{{/if}}
diff --git a/ui/app/templates/csi/plugins.hbs b/ui/app/templates/csi/plugins.hbs
new file mode 100644
index 00000000000..c24cd68950a
--- /dev/null
+++ b/ui/app/templates/csi/plugins.hbs
@@ -0,0 +1 @@
+{{outlet}}
diff --git a/ui/app/templates/csi/plugins/index.hbs b/ui/app/templates/csi/plugins/index.hbs
new file mode 100644
index 00000000000..43f7eb8a72f
--- /dev/null
+++ b/ui/app/templates/csi/plugins/index.hbs
@@ -0,0 +1,66 @@
+{{title "CSI Plugins"}}
+
+
+ - {{#link-to "csi.volumes.index" activeClass="is-active"}}Volumes{{/link-to}}
+ - {{#link-to "csi.plugins.index" activeClass="is-active"}}Plugins{{/link-to}}
+
+
+
+ {{#if isForbidden}}
+ {{partial "partials/forbidden-message"}}
+ {{else}}
+ {{#if sortedPlugins}}
+ {{#list-pagination
+ source=sortedPlugins
+ 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="plainId"}}ID{{/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}}
+ {{/t.head}}
+ {{#t.body key="model.id" as |row|}}
+
+
+ {{#link-to "csi.plugins.plugin" row.model.plainId class="is-primary"}}{{row.model.plainId}}{{/link-to}}
+ |
+
+ {{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}} |
+
+ {{/t.body}}
+ {{/list-table}}
+
+ {{/list-pagination}}
+ {{else}}
+
+
No Plugins
+
+ The cluster currently has no registered CSI Plugins.
+
+
+ {{/if}}
+ {{/if}}
+
diff --git a/ui/app/templates/csi/plugins/plugin.hbs b/ui/app/templates/csi/plugins/plugin.hbs
new file mode 100644
index 00000000000..e777a11e983
--- /dev/null
+++ b/ui/app/templates/csi/plugins/plugin.hbs
@@ -0,0 +1,98 @@
+{{title "CSI Plugin " model.plainId}}
+
+ {{model.plainId}}
+
+
+
+ Plugin Details
+
+ Controller Health
+ {{format-percentage model.controllersHealthy total=model.controllersExpected}}
+ ({{model.controllersHealthy}}/{{model.controllersExpected}})
+
+
+ Node Health
+ {{format-percentage model.nodesHealthy total=model.nodesExpected}}
+ ({{model.nodesHealthy}}/{{model.nodesExpected}})
+
+
+ Provider
+ {{model.provider}}
+
+
+
+
+
+
+ Controller Allocations
+
+
+ {{#if model.controllers}}
+ {{#list-table
+ source=sortedControllers
+ class="with-foot" as |t|}}
+ {{#t.head}}
+
|
+
ID |
+
Created |
+
Modified |
+
Health |
+
Client |
+
Job |
+
Version |
+
Volumes |
+
CPU |
+
Memory |
+ {{/t.head}}
+ {{#t.body as |row|}}
+ {{plugin-allocation-row
+ data-test-controller-allocation=row.model.allocID
+ pluginAllocation=row.model}}
+ {{/t.body}}
+ {{/list-table}}
+ {{else}}
+
+
No Controller Plugin Allocations
+
No allocations are providing controller plugin service.
+
+ {{/if}}
+
+
+
+
+
+ Node Allocations
+
+
+ {{#if model.nodes}}
+ {{#list-table
+ source=sortedNodes
+ class="with-foot" as |t|}}
+ {{#t.head}}
+
|
+
ID |
+
Created |
+
Modified |
+
Health |
+
Client |
+
Job |
+
Version |
+
Volumes |
+
CPU |
+
Memory |
+ {{/t.head}}
+ {{#t.body as |row|}}
+ {{plugin-allocation-row
+ data-test-node-allocation=row.model.allocID
+ pluginAllocation=row.model}}
+ {{/t.body}}
+ {{/list-table}}
+ {{else}}
+
+
No Node Plugin Allocations
+
No allocations are providing node plugin service.
+
+ {{/if}}
+
+
+
diff --git a/ui/app/templates/csi/volumes/index.hbs b/ui/app/templates/csi/volumes/index.hbs
index e30e139ae9b..6b4b41fafb8 100644
--- a/ui/app/templates/csi/volumes/index.hbs
+++ b/ui/app/templates/csi/volumes/index.hbs
@@ -1,4 +1,10 @@
{{title "CSI Volumes"}}
+
+
+ - {{#link-to "csi.volumes.index" activeClass="is-active"}}Volumes{{/link-to}}
+ - {{#link-to "csi.plugins.index" activeClass="is-active"}}Plugins{{/link-to}}
+
+
{{#if isForbidden}}
{{partial "partials/forbidden-message"}}
@@ -54,12 +60,10 @@
{{/list-pagination}}
{{else}}
- {{#if (eq sortedVolumes.length 0)}}
-
No Volumes
-
- The cluster currently has no CSI Volumes.
-
- {{/if}}
+
No Volumes
+
+ The cluster currently has no CSI Volumes.
+
{{/if}}
{{/if}}
diff --git a/ui/app/templates/csi/volumes/volume.hbs b/ui/app/templates/csi/volumes/volume.hbs
index 945f5fda838..55b8b48a4b6 100644
--- a/ui/app/templates/csi/volumes/volume.hbs
+++ b/ui/app/templates/csi/volumes/volume.hbs
@@ -1,12 +1,12 @@
{{title "CSI Volume " model.name}}
-
-{{model.name}}
+
+ {{model.name}}
Volume Details
- health
+ Health
{{if model.schedulable "Schedulable" "Unschedulable"}}
@@ -38,8 +38,8 @@
{{#t.head}}
|
ID |
- Modified |
Created |
+ Modified |
Status |
Client |
Job |
@@ -101,4 +101,28 @@
{{/if}}
+
+
+
+ Constraints
+
+
+
+
+ Setting |
+ Value |
+
+
+
+ Access Mode |
+ {{model.accessMode}} |
+
+
+ Attachment Mode |
+ {{model.attachmentMode}} |
+
+
+
+
+
diff --git a/ui/mirage/factories/csi-plugin.js b/ui/mirage/factories/csi-plugin.js
index 592e50dab2d..e57aea40ea7 100644
--- a/ui/mirage/factories/csi-plugin.js
+++ b/ui/mirage/factories/csi-plugin.js
@@ -12,9 +12,15 @@ export default Factory.extend({
provider: faker.helpers.randomize(STORAGE_PROVIDERS),
version: '1.0.1',
controllerRequired: faker.random.boolean,
- controllersHealthy: () => faker.random.number(10),
+ controllersHealthy: () => faker.random.number(3),
+ controllersExpected() {
+ return this.controllersHealthy + faker.random.number({ min: 1, max: 2 });
+ },
- nodesHealthy: () => faker.random.number(10),
+ nodesHealthy: () => faker.random.number(3),
+ nodesExpected() {
+ return this.nodesHealthy + faker.random.number({ min: 1, max: 2 });
+ },
// Internal property to determine whether or not this plugin
// Should create one or two Jobs to represent Node and
@@ -30,20 +36,18 @@ export default Factory.extend({
if (plugin.isMonolith) {
const pluginJob = server.create('job', { type: 'service', createAllocations: false });
- const count = faker.random.number({ min: 1, max: 5 });
+ const count = plugin.nodesExpected;
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 }), {
+ storageNodes = server.createList('storage-node', plugin.nodesExpected, {
job: nodeJob,
});
- storageControllers = server.createList(
- 'storage-controller',
- faker.random.number({ min: 1, max: 5 }),
- { job: controllerJob }
- );
+ storageControllers = server.createList('storage-controller', plugin.controllersExpected, {
+ job: controllerJob,
+ });
}
plugin.update({
@@ -52,7 +56,7 @@ export default Factory.extend({
});
if (plugin.createVolumes) {
- server.createList('csi-volume', faker.random.number(5), {
+ server.createList('csi-volume', faker.random.number({ min: 1, max: 5 }), {
plugin,
provider: plugin.provider,
});
diff --git a/ui/mirage/factories/storage-controller.js b/ui/mirage/factories/storage-controller.js
index bade4f1b613..3557116692a 100644
--- a/ui/mirage/factories/storage-controller.js
+++ b/ui/mirage/factories/storage-controller.js
@@ -12,7 +12,7 @@ export default Factory.extend({
this.healthy ? 'healthy' : 'unhealthy';
},
- updateTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000,
+ updateTime: () => faker.date.past(2 / 365, REF_TIME),
requiresControllerPlugin: true,
requiresTopologies: true,
@@ -27,11 +27,12 @@ export default Factory.extend({
afterCreate(storageController, server) {
const alloc = server.create('allocation', {
jobId: storageController.job.id,
+ forceRunningClientStatus: true,
+ modifyTime: storageController.updateTime * 1000000,
});
storageController.update({
- allocation: alloc,
- allocId: alloc.id,
+ allocID: alloc.id,
nodeId: alloc.nodeId,
});
},
diff --git a/ui/mirage/factories/storage-node.js b/ui/mirage/factories/storage-node.js
index bc3788b4d55..88a70020d4d 100644
--- a/ui/mirage/factories/storage-node.js
+++ b/ui/mirage/factories/storage-node.js
@@ -12,7 +12,7 @@ export default Factory.extend({
this.healthy ? 'healthy' : 'unhealthy';
},
- updateTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000,
+ updateTime: () => faker.date.past(2 / 365, REF_TIME),
requiresControllerPlugin: true,
requiresTopologies: true,
@@ -28,10 +28,11 @@ export default Factory.extend({
afterCreate(storageNode, server) {
const alloc = server.create('allocation', {
jobId: storageNode.job.id,
+ modifyTime: storageNode.updateTime * 1000000,
});
storageNode.update({
- allocId: alloc.id,
+ allocID: alloc.id,
nodeId: alloc.nodeId,
});
},
diff --git a/ui/mirage/models/storage-controller.js b/ui/mirage/models/storage-controller.js
index c845b480788..cfce7071b99 100644
--- a/ui/mirage/models/storage-controller.js
+++ b/ui/mirage/models/storage-controller.js
@@ -3,5 +3,4 @@ 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
index c845b480788..cfce7071b99 100644
--- a/ui/mirage/models/storage-node.js
+++ b/ui/mirage/models/storage-node.js
@@ -3,5 +3,4 @@ import { Model, belongsTo } from 'ember-cli-mirage';
export default Model.extend({
job: belongsTo(),
node: belongsTo(),
- allocation: belongsTo(),
});
diff --git a/ui/tests/acceptance/plugin-detail-test.js b/ui/tests/acceptance/plugin-detail-test.js
new file mode 100644
index 00000000000..b97cdfb8a9b
--- /dev/null
+++ b/ui/tests/acceptance/plugin-detail-test.js
@@ -0,0 +1,164 @@
+import { module, test } from 'qunit';
+import { currentURL } from '@ember/test-helpers';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import moment from 'moment';
+import { formatBytes } from 'nomad-ui/helpers/format-bytes';
+import PluginDetail from 'nomad-ui/tests/pages/storage/plugins/detail';
+
+module('Acceptance | plugin detail', function(hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ let plugin;
+
+ hooks.beforeEach(function() {
+ server.create('node');
+ plugin = server.create('csi-plugin');
+ });
+
+ test('/csi/plugins/:id should have a breadcrumb trail linking back to Plugins and Storage', async function(assert) {
+ await PluginDetail.visit({ id: plugin.id });
+
+ assert.equal(PluginDetail.breadcrumbFor('csi.index').text, 'Storage');
+ assert.equal(PluginDetail.breadcrumbFor('csi.plugins').text, 'Plugins');
+ assert.equal(PluginDetail.breadcrumbFor('csi.plugins.plugin').text, plugin.id);
+ });
+
+ test('/csi/plugins/:id should show the plugin name in the title', async function(assert) {
+ await PluginDetail.visit({ id: plugin.id });
+
+ assert.equal(document.title, `CSI Plugin ${plugin.id} - Nomad`);
+ assert.equal(PluginDetail.title, plugin.id);
+ });
+
+ test('/csi/plugins/:id should list additional details for the plugin below the title', async function(assert) {
+ await PluginDetail.visit({ id: plugin.id });
+
+ assert.ok(
+ PluginDetail.controllerHealth.includes(
+ `${Math.round((plugin.controllersHealthy / plugin.controllersExpected) * 100)}%`
+ )
+ );
+ assert.ok(
+ PluginDetail.controllerHealth.includes(
+ `${plugin.controllersHealthy}/${plugin.controllersExpected}`
+ )
+ );
+ assert.ok(
+ PluginDetail.nodeHealth.includes(
+ `${Math.round((plugin.nodesHealthy / plugin.nodesExpected) * 100)}%`
+ )
+ );
+ assert.ok(PluginDetail.nodeHealth.includes(`${plugin.nodesHealthy}/${plugin.nodesExpected}`));
+ assert.ok(PluginDetail.provider.includes(plugin.provider));
+ });
+
+ test('/csi/plugins/:id should list all the controller plugin allocations for the plugin', async function(assert) {
+ await PluginDetail.visit({ id: plugin.id });
+
+ assert.equal(PluginDetail.controllerAllocations.length, plugin.controllers.length);
+ plugin.controllers.models
+ .sortBy('updateTime')
+ .reverse()
+ .forEach((allocation, idx) => {
+ assert.equal(PluginDetail.controllerAllocations.objectAt(idx).id, allocation.allocID);
+ });
+ });
+
+ test('/csi/plugins/:id should list all the node plugin allocations for the plugin', async function(assert) {
+ await PluginDetail.visit({ id: plugin.id });
+
+ assert.equal(PluginDetail.nodeAllocations.length, plugin.nodes.length);
+ plugin.nodes.models
+ .sortBy('updateTime')
+ .reverse()
+ .forEach((allocation, idx) => {
+ assert.equal(PluginDetail.nodeAllocations.objectAt(idx).id, allocation.allocID);
+ });
+ });
+
+ test('each allocation should have high-level details for the allocation', async function(assert) {
+ const controller = plugin.controllers.models.sortBy('updateTime').reverse()[0];
+ const allocation = server.db.allocations.find(controller.allocID);
+ const allocStats = server.db.clientAllocationStats.find(allocation.id);
+ const taskGroup = server.db.taskGroups.findBy({
+ name: allocation.taskGroup,
+ jobId: allocation.jobId,
+ });
+
+ const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
+ const cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0);
+ const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0);
+
+ await PluginDetail.visit({ id: plugin.id });
+
+ PluginDetail.controllerAllocations.objectAt(0).as(allocationRow => {
+ assert.equal(allocationRow.shortId, allocation.id.split('-')[0], 'Allocation short ID');
+ assert.equal(
+ allocationRow.createTime,
+ moment(allocation.createTime / 1000000).format('MMM D')
+ );
+ assert.equal(
+ allocationRow.createTooltip,
+ moment(allocation.createTime / 1000000).format('MMM DD HH:mm:ss ZZ')
+ );
+ assert.equal(allocationRow.modifyTime, moment(allocation.modifyTime / 1000000).fromNow());
+ assert.equal(allocationRow.health, controller.healthy ? 'Healthy' : 'Unhealthy');
+ assert.equal(
+ allocationRow.client,
+ server.db.nodes.find(allocation.nodeId).id.split('-')[0],
+ 'Node ID'
+ );
+ assert.equal(allocationRow.job, server.db.jobs.find(allocation.jobId).name, 'Job name');
+ assert.ok(allocationRow.taskGroup, 'Task group name');
+ assert.ok(allocationRow.jobVersion, 'Job Version');
+ assert.equal(
+ allocationRow.cpu,
+ Math.floor(allocStats.resourceUsage.CpuStats.TotalTicks) / cpuUsed,
+ 'CPU %'
+ );
+ assert.equal(
+ allocationRow.cpuTooltip,
+ `${Math.floor(allocStats.resourceUsage.CpuStats.TotalTicks)} / ${cpuUsed} MHz`,
+ 'Detailed CPU information is in a tooltip'
+ );
+ assert.equal(
+ allocationRow.mem,
+ allocStats.resourceUsage.MemoryStats.RSS / 1024 / 1024 / memoryUsed,
+ 'Memory used'
+ );
+ assert.equal(
+ allocationRow.memTooltip,
+ `${formatBytes([allocStats.resourceUsage.MemoryStats.RSS])} / ${memoryUsed} MiB`,
+ 'Detailed memory information is in a tooltip'
+ );
+ });
+ });
+
+ test('each allocation should link to the allocation detail page', async function(assert) {
+ const controller = plugin.controllers.models.sortBy('updateTime').reverse()[0];
+
+ await PluginDetail.visit({ id: plugin.id });
+ await PluginDetail.controllerAllocations.objectAt(0).visit();
+
+ assert.equal(currentURL(), `/allocations/${controller.allocID}`);
+ });
+
+ test('when there are no plugin allocations, the tables present empty states', async function(assert) {
+ const emptyPlugin = server.create('csi-plugin', {
+ controllersHealthy: 0,
+ controllersExpected: 0,
+ nodesHealthy: 0,
+ nodesExpected: 0,
+ });
+
+ await PluginDetail.visit({ id: emptyPlugin.id });
+
+ assert.ok(PluginDetail.controllerTableIsEmpty);
+ assert.equal(PluginDetail.controllerEmptyState.headline, 'No Controller Plugin Allocations');
+
+ assert.ok(PluginDetail.nodeTableIsEmpty);
+ assert.equal(PluginDetail.nodeEmptyState.headline, 'No Node Plugin Allocations');
+ });
+});
diff --git a/ui/tests/acceptance/plugins-list-test.js b/ui/tests/acceptance/plugins-list-test.js
new file mode 100644
index 00000000000..3b66a31c0c4
--- /dev/null
+++ b/ui/tests/acceptance/plugins-list-test.js
@@ -0,0 +1,99 @@
+import { currentURL } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import pageSizeSelect from './behaviors/page-size-select';
+import PluginsList from 'nomad-ui/tests/pages/storage/plugins/list';
+
+module('Acceptance | plugins list', function(hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function() {
+ server.create('node');
+ window.localStorage.clear();
+ });
+
+ test('visiting /csi/plugins', async function(assert) {
+ await PluginsList.visit();
+
+ assert.equal(currentURL(), '/csi/plugins');
+ assert.equal(document.title, 'CSI Plugins - Nomad');
+ });
+
+ test('/csi/plugins should list the first page of plugins sorted by id', async function(assert) {
+ const pluginCount = PluginsList.pageSize + 1;
+ server.createList('csi-plugin', pluginCount);
+
+ await PluginsList.visit();
+
+ const sortedPlugins = server.db.csiPlugins.sortBy('id');
+ assert.equal(PluginsList.plugins.length, PluginsList.pageSize);
+ PluginsList.plugins.forEach((plugin, index) => {
+ assert.equal(plugin.id, sortedPlugins[index].id, 'Plugins are ordered');
+ });
+ });
+
+ test('each plugin row should contain information about the plugin', async function(assert) {
+ const plugin = server.create('csi-plugin');
+
+ await PluginsList.visit();
+
+ const pluginRow = PluginsList.plugins.objectAt(0);
+ const controllerHealthStr = plugin.controllersHealthy > 0 ? 'Healthy' : 'Unhealthy';
+ const nodeHealthStr = plugin.nodesHealthy > 0 ? 'Healthy' : 'Unhealthy';
+
+ assert.equal(pluginRow.id, plugin.id);
+ assert.equal(
+ pluginRow.controllerHealth,
+ `${controllerHealthStr} (${plugin.controllersHealthy}/${plugin.controllersExpected})`
+ );
+ assert.equal(
+ pluginRow.nodeHealth,
+ `${nodeHealthStr} (${plugin.nodesHealthy}/${plugin.nodesExpected})`
+ );
+ assert.equal(pluginRow.provider, plugin.provider);
+ });
+
+ test('each plugin row should link to the corresponding plugin', async function(assert) {
+ const plugin = server.create('csi-plugin');
+
+ await PluginsList.visit();
+
+ await PluginsList.plugins.objectAt(0).clickName();
+ assert.equal(currentURL(), `/csi/plugins/${plugin.id}`);
+
+ await PluginsList.visit();
+ assert.equal(currentURL(), '/csi/plugins');
+
+ await PluginsList.plugins.objectAt(0).clickRow();
+ assert.equal(currentURL(), `/csi/plugins/${plugin.id}`);
+ });
+
+ test('when there are no plugins, there is an empty message', async function(assert) {
+ await PluginsList.visit();
+
+ assert.ok(PluginsList.isEmpty);
+ assert.equal(PluginsList.emptyState.headline, 'No Plugins');
+ });
+
+ test('when accessing plugins is forbidden, a message is shown with a link to the tokens page', async function(assert) {
+ server.pretender.get('/v1/plugins', () => [403, {}, null]);
+
+ await PluginsList.visit();
+ assert.equal(PluginsList.error.title, 'Not Authorized');
+
+ await PluginsList.error.seekHelp();
+ assert.equal(currentURL(), '/settings/tokens');
+ });
+
+ pageSizeSelect({
+ resourceName: 'plugin',
+ pageObject: PluginsList,
+ pageObjectList: PluginsList.plugins,
+ async setup() {
+ server.createList('csi-plugin', PluginsList.pageSize);
+ await PluginsList.visit();
+ },
+ });
+});
diff --git a/ui/tests/acceptance/volume-detail-test.js b/ui/tests/acceptance/volume-detail-test.js
index 10cefdd28e0..01139293030 100644
--- a/ui/tests/acceptance/volume-detail-test.js
+++ b/ui/tests/acceptance/volume-detail-test.js
@@ -28,7 +28,7 @@ module('Acceptance | volume detail', function(hooks) {
volume = server.create('csi-volume');
});
- test('/csi/volumes/:id should have a breadcrumb trail linking back to Volumes and CSI', async function(assert) {
+ test('/csi/volumes/:id should have a breadcrumb trail linking back to Volumes and Storage', async function(assert) {
await VolumeDetail.visit({ id: volume.id });
assert.equal(VolumeDetail.breadcrumbFor('csi.index').text, 'Storage');
@@ -172,6 +172,13 @@ module('Acceptance | volume detail', function(hooks) {
assert.ok(VolumeDetail.readTableIsEmpty);
assert.equal(VolumeDetail.readEmptyState.headline, 'No Read Allocations');
});
+
+ test('the constraints table shows access mode and attachment mode', async function(assert) {
+ await VolumeDetail.visit({ id: volume.id });
+
+ assert.equal(VolumeDetail.constraints.accessMode, volume.accessMode);
+ assert.equal(VolumeDetail.constraints.attachmentMode, volume.attachmentMode);
+ });
});
// Namespace test: details shows the namespace
diff --git a/ui/tests/pages/components/allocations.js b/ui/tests/pages/components/allocations.js
index b19f82f70a0..020603cd75d 100644
--- a/ui/tests/pages/components/allocations.js
+++ b/ui/tests/pages/components/allocations.js
@@ -11,7 +11,9 @@ export default function(selector = '[data-test-allocation]', propKey = 'allocati
id: attribute(attr),
shortId: text('[data-test-short-id]'),
createTime: text('[data-test-create-time]'),
+ createTooltip: attribute('aria-label', '[data-test-create-time] .tooltip'),
modifyTime: text('[data-test-modify-time]'),
+ health: text('[data-test-health]'),
status: text('[data-test-client-status]'),
job: text('[data-test-job]'),
taskGroup: text('[data-test-task-group]'),
diff --git a/ui/tests/pages/storage/plugins/detail.js b/ui/tests/pages/storage/plugins/detail.js
new file mode 100644
index 00000000000..2c148d0e696
--- /dev/null
+++ b/ui/tests/pages/storage/plugins/detail.js
@@ -0,0 +1,44 @@
+import {
+ attribute,
+ clickable,
+ collection,
+ create,
+ isPresent,
+ text,
+ visitable,
+} from 'ember-cli-page-object';
+
+import allocations from 'nomad-ui/tests/pages/components/allocations';
+
+export default create({
+ visit: visitable('/csi/plugins/:id'),
+
+ title: text('[data-test-title]'),
+
+ controllerHealth: text('[data-test-plugin-controller-health]'),
+ nodeHealth: text('[data-test-plugin-node-health]'),
+ provider: text('[data-test-plugin-provider]'),
+
+ breadcrumbs: collection('[data-test-breadcrumb]', {
+ id: attribute('data-test-breadcrumb'),
+ text: text(),
+ visit: clickable(),
+ }),
+
+ breadcrumbFor(id) {
+ return this.breadcrumbs.toArray().find(crumb => crumb.id === id);
+ },
+
+ ...allocations('[data-test-controller-allocation]', 'controllerAllocations'),
+ ...allocations('[data-test-node-allocation]', 'nodeAllocations'),
+
+ controllerTableIsEmpty: isPresent('[data-test-empty-controller-allocations]'),
+ controllerEmptyState: {
+ headline: text('[data-test-empty-controller-allocations-headline]'),
+ },
+
+ nodeTableIsEmpty: isPresent('[data-test-empty-node-allocations]'),
+ nodeEmptyState: {
+ headline: text('[data-test-empty-node-allocations-headline]'),
+ },
+});
diff --git a/ui/tests/pages/storage/plugins/list.js b/ui/tests/pages/storage/plugins/list.js
new file mode 100644
index 00000000000..e46afa44820
--- /dev/null
+++ b/ui/tests/pages/storage/plugins/list.js
@@ -0,0 +1,31 @@
+import { clickable, collection, create, isPresent, text, visitable } from 'ember-cli-page-object';
+
+import error from 'nomad-ui/tests/pages/components/error';
+import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
+
+export default create({
+ pageSize: 25,
+
+ visit: visitable('/csi/plugins'),
+
+ plugins: collection('[data-test-plugin-row]', {
+ id: text('[data-test-plugin-id]'),
+ controllerHealth: text('[data-test-plugin-controller-health]'),
+ nodeHealth: text('[data-test-plugin-node-health]'),
+ provider: text('[data-test-plugin-provider]'),
+
+ clickRow: clickable(),
+ clickName: clickable('[data-test-plugin-id] a'),
+ }),
+
+ nextPage: clickable('[data-test-pager="next"]'),
+ prevPage: clickable('[data-test-pager="prev"]'),
+
+ isEmpty: isPresent('[data-test-empty-plugins-list]'),
+ emptyState: {
+ headline: text('[data-test-empty-plugins-list-headline]'),
+ },
+
+ error: error(),
+ pageSizeSelect: pageSizeSelect(),
+});
diff --git a/ui/tests/pages/storage/volumes/detail.js b/ui/tests/pages/storage/volumes/detail.js
index e340145988d..a0b0ee71639 100644
--- a/ui/tests/pages/storage/volumes/detail.js
+++ b/ui/tests/pages/storage/volumes/detail.js
@@ -43,4 +43,9 @@ export default create({
readEmptyState: {
headline: text('[data-test-empty-read-allocations-headline]'),
},
+
+ constraints: {
+ accessMode: text('[data-test-access-mode]'),
+ attachmentMode: text('[data-test-attachment-mode]'),
+ },
});