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"}} +
+ +
+
+ {{#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}} +
+ {{page-size-select onChange=(action resetPagination)}} + +
+ {{/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"}} +
+ +
{{#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 +
+
+ + + + + + + + + + + + + + + +
SettingValue
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]'), + }, });