diff --git a/.changelog/25224.txt b/.changelog/25224.txt new file mode 100644 index 000000000..7880a1a5f --- /dev/null +++ b/.changelog/25224.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Added Dynamic Host Volumes to the web UI +``` diff --git a/ui/app/adapters/dynamic-host-volume.js b/ui/app/adapters/dynamic-host-volume.js new file mode 100644 index 000000000..1ad1f09c8 --- /dev/null +++ b/ui/app/adapters/dynamic-host-volume.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import WatchableNamespaceIDs from './watchable-namespace-ids'; +import classic from 'ember-classic-decorator'; + +@classic +export default class DynamicHostVolumeAdapter extends WatchableNamespaceIDs { + pathForType = () => 'volumes'; + + urlForFindRecord(fullID) { + const [id, namespace] = JSON.parse(fullID); + + let url = `/${this.namespace}/volume/host/${id}`; + + if (namespace && namespace !== 'default') { + url += `?namespace=${namespace}`; + } + + return url; + } +} diff --git a/ui/app/components/global-search/control.js b/ui/app/components/global-search/control.js index f39b98cf2..dfafceb7e 100644 --- a/ui/app/components/global-search/control.js +++ b/ui/app/components/global-search/control.js @@ -202,7 +202,7 @@ export default class GlobalSearchControl extends Component { ); }); } else if (model.type === 'plugin') { - this.router.transitionTo('csi.plugins.plugin', model.id); + this.router.transitionTo('storage.plugins.plugin', model.id); } else if (model.type === 'allocation') { this.router.transitionTo('allocations.allocation', model.id); } diff --git a/ui/app/controllers/csi/volumes/index.js b/ui/app/controllers/csi/volumes/index.js deleted file mode 100644 index 92750aaf8..000000000 --- a/ui/app/controllers/csi/volumes/index.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { inject as service } from '@ember/service'; -import { action, computed } from '@ember/object'; -import { alias, readOnly } from '@ember/object/computed'; -import { scheduleOnce } from '@ember/runloop'; -import Controller, { inject as controller } from '@ember/controller'; -import SortableFactory from 'nomad-ui/mixins/sortable-factory'; -import Searchable from 'nomad-ui/mixins/searchable'; -import { lazyClick } from 'nomad-ui/helpers/lazy-click'; -import { serialize } from 'nomad-ui/utils/qp-serialize'; -import classic from 'ember-classic-decorator'; - -@classic -export default class IndexController extends Controller.extend( - SortableFactory([ - 'id', - 'schedulable', - 'controllersHealthyProportion', - 'nodesHealthyProportion', - 'provider', - ]), - Searchable -) { - @service system; - @service userSettings; - @service keyboard; - @controller('csi/volumes') volumesController; - - @alias('volumesController.isForbidden') - isForbidden; - - queryParams = [ - { - currentPage: 'page', - }, - { - searchTerm: 'search', - }, - { - sortProperty: 'sort', - }, - { - sortDescending: 'desc', - }, - { - qpNamespace: 'namespace', - }, - ]; - - currentPage = 1; - @readOnly('userSettings.pageSize') pageSize; - - sortProperty = 'id'; - sortDescending = false; - - @computed - get searchProps() { - return ['name']; - } - - @computed - get fuzzySearchProps() { - return ['name']; - } - - fuzzySearchEnabled = true; - - @computed('qpNamespace', 'model.namespaces.[]') - get optionsNamespaces() { - const availableNamespaces = this.model.namespaces.map((namespace) => ({ - key: namespace.name, - label: namespace.name, - })); - - availableNamespaces.unshift({ - key: '*', - label: 'All (*)', - }); - - // Unset the namespace selection if it was server-side deleted - if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) { - // eslint-disable-next-line ember/no-incorrect-calls-with-inline-anonymous-functions - scheduleOnce('actions', () => { - // eslint-disable-next-line ember/no-side-effects - this.set('qpNamespace', '*'); - }); - } - - return availableNamespaces; - } - - /** - Visible volumes are those that match the selected namespace - */ - @computed('model.volumes.@each.parent', 'system.{namespaces.length}') - get visibleVolumes() { - if (!this.model.volumes) return []; - return this.model.volumes.compact(); - } - - @alias('visibleVolumes') listToSort; - @alias('listSorted') listToSearch; - @alias('listSearched') sortedVolumes; - - setFacetQueryParam(queryParam, selection) { - this.set(queryParam, serialize(selection)); - } - - @action - gotoVolume(volume, event) { - lazyClick([ - () => - this.transitionToRoute( - 'csi.volumes.volume', - volume.get('idWithNamespace') - ), - event, - ]); - } -} diff --git a/ui/app/controllers/storage/index.js b/ui/app/controllers/storage/index.js new file mode 100644 index 000000000..20f2e9463 --- /dev/null +++ b/ui/app/controllers/storage/index.js @@ -0,0 +1,241 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import Controller from '@ember/controller'; +import { scheduleOnce } from '@ember/runloop'; + +export default class IndexController extends Controller { + @service router; + @service userSettings; + @service system; + @service keyboard; + + queryParams = [ + { qpNamespace: 'namespace' }, + 'dhvPage', + 'csiPage', + 'dhvFilter', + 'csiFilter', + 'dhvSortProperty', + 'csiSortProperty', + 'dhvSortDescending', + 'csiSortDescending', + ]; + + @tracked qpNamespace = '*'; + + pageSizes = [10, 25, 50]; + + get optionsNamespaces() { + const availableNamespaces = this.model.namespaces.map((namespace) => ({ + key: namespace.name, + label: namespace.name, + })); + + availableNamespaces.unshift({ + key: '*', + label: 'All (*)', + }); + + // Unset the namespace selection if it was server-side deleted + if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) { + // eslint-disable-next-line ember/no-incorrect-calls-with-inline-anonymous-functions + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.qpNamespace = '*'; + }); + } + + return availableNamespaces; + } + + get dhvColumns() { + return [ + { + key: 'plainId', + label: 'ID', + isSortable: true, + }, + { + key: 'name', + label: 'Name', + isSortable: true, + }, + ...(this.system.shouldShowNamespaces + ? [ + { + key: 'namespace', + label: 'Namespace', + isSortable: true, + }, + ] + : []), + { + key: 'node.name', + label: 'Node', + isSortable: true, + }, + { + key: 'pluginID', + label: 'Plugin ID', + isSortable: true, + }, + { + key: 'state', + label: 'State', + isSortable: true, + }, + { + key: 'modifyTime', + label: 'Last Modified', + isSortable: true, + }, + ]; + } + + get csiColumns() { + let cols = [ + { + key: 'plainId', + label: 'ID', + isSortable: true, + }, + ...(this.system.shouldShowNamespaces + ? [ + { + key: 'namespace', + label: 'Namespace', + isSortable: true, + }, + ] + : []), + { + key: 'schedulable', + label: 'Volume Health', + isSortable: true, + }, + { + key: 'controllersHealthyProportion', + label: 'Controller Health', + }, + { + key: 'nodesHealthyProportion', + label: 'Node Health', + }, + { + key: 'plugin.plainId', + label: 'Plugin', + }, + { + key: 'allocationCount', + label: '# Allocs', + isSortable: true, + }, + ].filter(Boolean); + return cols; + } + + // For all volume types: + // Filter, then Sort, then Paginate + // all handled client-side + + get filteredCSIVolumes() { + if (!this.csiFilter) { + return this.model.csiVolumes; + } else { + return this.model.csiVolumes.filter((volume) => { + return ( + volume.plainId.toLowerCase().includes(this.csiFilter.toLowerCase()) || + volume.name.toLowerCase().includes(this.csiFilter.toLowerCase()) + ); + }); + } + } + + get sortedCSIVolumes() { + let sorted = this.filteredCSIVolumes.sortBy(this.csiSortProperty); + if (this.csiSortDescending) { + sorted.reverse(); + } + return sorted; + } + + get paginatedCSIVolumes() { + return this.sortedCSIVolumes.slice( + (this.csiPage - 1) * this.userSettings.pageSize, + this.csiPage * this.userSettings.pageSize + ); + } + + get filteredDynamicHostVolumes() { + if (!this.dhvFilter) { + return this.model.dynamicHostVolumes; + } else { + return this.model.dynamicHostVolumes.filter((volume) => { + return ( + volume.plainId.toLowerCase().includes(this.dhvFilter.toLowerCase()) || + volume.name.toLowerCase().includes(this.dhvFilter.toLowerCase()) + ); + }); + } + } + + get sortedDynamicHostVolumes() { + let sorted = this.filteredDynamicHostVolumes.sortBy(this.dhvSortProperty); + if (this.dhvSortDescending) { + sorted.reverse(); + } + return sorted; + } + + get paginatedDynamicHostVolumes() { + return this.sortedDynamicHostVolumes.slice( + (this.dhvPage - 1) * this.userSettings.pageSize, + this.dhvPage * this.userSettings.pageSize + ); + } + + @tracked csiSortProperty = 'id'; + @tracked csiSortDescending = false; + @tracked csiPage = 1; + @tracked csiFilter = ''; + + @tracked dhvSortProperty = 'modifyTime'; + @tracked dhvSortDescending = true; + @tracked dhvPage = 1; + @tracked dhvFilter = ''; + + @action handlePageChange(type, page) { + if (type === 'csi') { + this.csiPage = page; + } else if (type === 'dhv') { + this.dhvPage = page; + } + } + + @action handleSort(type, sortBy, sortOrder) { + this[`${type}SortProperty`] = sortBy; + this[`${type}SortDescending`] = sortOrder === 'desc'; + } + + @action applyFilter(type, event) { + this[`${type}Filter`] = event.target.value; + this[`${type}Page`] = 1; + } + + @action openCSI(csi) { + this.router.transitionTo('storage.volumes.volume', csi.idWithNamespace); + } + + @action openDHV(dhv) { + this.router.transitionTo( + 'storage.volumes.dynamic-host-volume', + dhv.idWithNamespace + ); + } +} diff --git a/ui/app/controllers/csi/plugins.js b/ui/app/controllers/storage/plugins.js similarity index 100% rename from ui/app/controllers/csi/plugins.js rename to ui/app/controllers/storage/plugins.js diff --git a/ui/app/controllers/csi/plugins/index.js b/ui/app/controllers/storage/plugins/index.js similarity index 91% rename from ui/app/controllers/csi/plugins/index.js rename to ui/app/controllers/storage/plugins/index.js index 5afa2f231..2fb73db9a 100644 --- a/ui/app/controllers/csi/plugins/index.js +++ b/ui/app/controllers/storage/plugins/index.js @@ -23,7 +23,7 @@ export default class IndexController extends Controller.extend( Searchable ) { @service userSettings; - @controller('csi/plugins') pluginsController; + @controller('storage/plugins') pluginsController; @alias('pluginsController.isForbidden') isForbidden; @@ -65,7 +65,7 @@ export default class IndexController extends Controller.extend( @action gotoPlugin(plugin, event) { lazyClick([ - () => this.transitionToRoute('csi.plugins.plugin', plugin.plainId), + () => this.transitionToRoute('storage.plugins.plugin', plugin.plainId), event, ]); } diff --git a/ui/app/controllers/csi/plugins/plugin.js b/ui/app/controllers/storage/plugins/plugin.js similarity index 68% rename from ui/app/controllers/csi/plugins/plugin.js rename to ui/app/controllers/storage/plugins/plugin.js index 632d48841..53d191ccd 100644 --- a/ui/app/controllers/csi/plugins/plugin.js +++ b/ui/app/controllers/storage/plugins/plugin.js @@ -5,7 +5,7 @@ import Controller from '@ember/controller'; -export default class CsiPluginsPluginController extends Controller { +export default class StoragePluginsPluginController extends Controller { get plugin() { return this.model; } @@ -15,11 +15,11 @@ export default class CsiPluginsPluginController extends Controller { return [ { label: 'Plugins', - args: ['csi.plugins'], + args: ['storage.plugins'], }, { label: plainId, - args: ['csi.plugins.plugin', plainId], + args: ['storage.plugins.plugin', plainId], }, ]; } diff --git a/ui/app/controllers/csi/plugins/plugin/allocations.js b/ui/app/controllers/storage/plugins/plugin/allocations.js similarity index 100% rename from ui/app/controllers/csi/plugins/plugin/allocations.js rename to ui/app/controllers/storage/plugins/plugin/allocations.js diff --git a/ui/app/controllers/csi/plugins/plugin/index.js b/ui/app/controllers/storage/plugins/plugin/index.js similarity index 100% rename from ui/app/controllers/csi/plugins/plugin/index.js rename to ui/app/controllers/storage/plugins/plugin/index.js diff --git a/ui/app/controllers/csi/volumes.js b/ui/app/controllers/storage/volumes.js similarity index 100% rename from ui/app/controllers/csi/volumes.js rename to ui/app/controllers/storage/volumes.js diff --git a/ui/app/controllers/storage/volumes/dynamic-host-volume.js b/ui/app/controllers/storage/volumes/dynamic-host-volume.js new file mode 100644 index 000000000..18b5d1aa6 --- /dev/null +++ b/ui/app/controllers/storage/volumes/dynamic-host-volume.js @@ -0,0 +1,53 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action, computed } from '@ember/object'; +import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; + +export default class DynamicHostVolumeController extends Controller { + // Used in the template + @service system; + + queryParams = [ + { + volumeNamespace: 'namespace', + }, + ]; + volumeNamespace = 'default'; + + get volume() { + return this.model; + } + + get breadcrumbs() { + const volume = this.volume; + if (!volume) { + return []; + } + return [ + { + label: volume.name, + args: [ + 'storage.volumes.dynamic-host-volume', + volume.plainId, + qpBuilder({ + volumeNamespace: volume.get('namespace.name') || 'default', + }), + ], + }, + ]; + } + + @computed('model.allocations.@each.modifyIndex') + get sortedAllocations() { + return this.model.allocations.sortBy('modifyIndex').reverse(); + } + + @action gotoAllocation(allocation) { + this.transitionToRoute('allocations.allocation', allocation.id); + } +} diff --git a/ui/app/controllers/csi/volumes/volume.js b/ui/app/controllers/storage/volumes/volume.js similarity index 92% rename from ui/app/controllers/csi/volumes/volume.js rename to ui/app/controllers/storage/volumes/volume.js index 74fb18ed9..9a04bfb3c 100644 --- a/ui/app/controllers/csi/volumes/volume.js +++ b/ui/app/controllers/storage/volumes/volume.js @@ -29,14 +29,10 @@ export default class VolumeController extends Controller { return []; } return [ - { - label: 'Volumes', - args: ['csi.volumes'], - }, { label: volume.name, args: [ - 'csi.volumes.volume', + 'storage.volumes.volume', volume.plainId, qpBuilder({ volumeNamespace: volume.get('namespace.name') || 'default', diff --git a/ui/app/models/dynamic-host-volume.js b/ui/app/models/dynamic-host-volume.js new file mode 100644 index 000000000..822722122 --- /dev/null +++ b/ui/app/models/dynamic-host-volume.js @@ -0,0 +1,39 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Model from '@ember-data/model'; +import { attr, belongsTo, hasMany } from '@ember-data/model'; +export default class DynamicHostVolumeModel extends Model { + @attr('string') plainId; + @attr('string') name; + @attr('string') path; + @attr('string') namespace; + @attr('string') state; + @belongsTo('node') node; + @attr('string') pluginID; + @attr() constraints; + @attr('date') createTime; + @attr('date') modifyTime; + @hasMany('allocation', { async: false }) allocations; + @attr() requestedCapabilities; + @attr('number') capacityBytes; + + get idWithNamespace() { + return `${this.plainId}@${this.namespace}`; + } + + get capabilities() { + let capabilities = []; + if (this.requestedCapabilities) { + this.requestedCapabilities.forEach((capability) => { + capabilities.push({ + access_mode: capability.AccessMode, + attachment_mode: capability.AttachmentMode, + }); + }); + } + return capabilities; + } +} diff --git a/ui/app/models/host-volume.js b/ui/app/models/host-volume.js index f31f3fe50..e1b509f41 100644 --- a/ui/app/models/host-volume.js +++ b/ui/app/models/host-volume.js @@ -10,6 +10,8 @@ import { fragmentOwner } from 'ember-data-model-fragments/attributes'; export default class HostVolume extends Fragment { @fragmentOwner() node; + @attr('string') volumeID; + @attr('string') name; @attr('string') path; @attr('boolean') readOnly; diff --git a/ui/app/router.js b/ui/app/router.js index 174c5c3c4..8b666d839 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -60,9 +60,13 @@ Router.map(function () { this.route('topology'); - this.route('csi', function () { + // Only serves as a redirect to storage + this.route('csi'); + + this.route('storage', function () { this.route('volumes', function () { - this.route('volume', { path: '/:volume_name' }); + this.route('volume', { path: '/csi/:volume_name' }); + this.route('dynamic-host-volume', { path: '/dynamic/:id' }); }); this.route('plugins', function () { diff --git a/ui/app/routes/administration/policies/new.js b/ui/app/routes/administration/policies/new.js index c965bf3f8..5535a01a2 100644 --- a/ui/app/routes/administration/policies/new.js +++ b/ui/app/routes/administration/policies/new.js @@ -67,6 +67,11 @@ operator { # * alloc-lifecycle # * csi-write-volume # * csi-mount-volume +# * host-volume-create +# * host-volume-register +# * host-volume-read +# * host-volume-write +# * host-volume-delete # * list-scaling-policies # * read-scaling-policy # * read-job-scaling diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index d45418bc1..b749de20f 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -3,6 +3,8 @@ * SPDX-License-Identifier: BUSL-1.1 */ +// @ts-check + /* eslint-disable ember/no-controller-access-in-routes */ import { inject as service } from '@ember/service'; import { later, next } from '@ember/runloop'; @@ -11,6 +13,7 @@ import { AbortError } from '@ember-data/adapter/error'; import RSVP from 'rsvp'; import { action } from '@ember/object'; import classic from 'ember-classic-decorator'; +import { handleRouteRedirects } from '../utils/route-redirector'; @classic export default class ApplicationRoute extends Route { @@ -33,6 +36,10 @@ export default class ApplicationRoute extends Route { } async beforeModel(transition) { + if (handleRouteRedirects(transition, this.router)) { + return; + } + let promises; // service:router#transitionTo can cause this to rerun because of refreshModel on diff --git a/ui/app/routes/csi/volumes/index.js b/ui/app/routes/storage/index.js similarity index 69% rename from ui/app/routes/csi/volumes/index.js rename to ui/app/routes/storage/index.js index dd5f70ea6..88a17fb8f 100644 --- a/ui/app/routes/csi/volumes/index.js +++ b/ui/app/routes/storage/index.js @@ -3,6 +3,8 @@ * SPDX-License-Identifier: BUSL-1.1 */ +// @ts-check + import { inject as service } from '@ember/service'; import RSVP from 'rsvp'; import Route from '@ember/routing/route'; @@ -26,10 +28,16 @@ export default class IndexRoute extends Route.extend( model(params) { return RSVP.hash({ - volumes: this.store + csiVolumes: this.store .query('volume', { type: 'csi', namespace: params.qpNamespace }) .catch(notifyForbidden(this)), namespaces: this.store.findAll('namespace'), + dynamicHostVolumes: this.store + .query('dynamic-host-volume', { + type: 'host', + namespace: params.qpNamespace, + }) + .catch(notifyForbidden(this)), }); } @@ -42,9 +50,18 @@ export default class IndexRoute extends Route.extend( namespace: controller.qpNamespace, }) ); + controller.set( + 'modelWatch', + this.watchDynamicHostVolumes.perform({ + type: 'host', + namespace: controller.qpNamespace, + }) + ); } @watchQuery('volume') watchVolumes; + @watchQuery('dynamic-host-volume') watchDynamicHostVolumes; @watchAll('namespace') watchNamespaces; - @collect('watchVolumes', 'watchNamespaces') watchers; + @collect('watchVolumes', 'watchNamespaces', 'watchDynamicHostVolumes') + watchers; } diff --git a/ui/app/routes/csi/plugins.js b/ui/app/routes/storage/plugins.js similarity index 100% rename from ui/app/routes/csi/plugins.js rename to ui/app/routes/storage/plugins.js diff --git a/ui/app/routes/csi/plugins/index.js b/ui/app/routes/storage/plugins/index.js similarity index 88% rename from ui/app/routes/csi/plugins/index.js rename to ui/app/routes/storage/plugins/index.js index 742ec0605..c9e829ba8 100644 --- a/ui/app/routes/csi/plugins/index.js +++ b/ui/app/routes/storage/plugins/index.js @@ -13,7 +13,7 @@ export default class IndexRoute extends Route.extend(WithWatchers) { @service store; startWatchers(controller) { - controller.set('modelWatch', this.watch.perform({ type: 'csi' })); + controller.set('modelWatch', this.watch.perform({ type: 'host' })); } @watchQuery('plugin') watch; diff --git a/ui/app/routes/csi/plugins/plugin.js b/ui/app/routes/storage/plugins/plugin.js similarity index 100% rename from ui/app/routes/csi/plugins/plugin.js rename to ui/app/routes/storage/plugins/plugin.js diff --git a/ui/app/routes/csi/plugins/plugin/allocations.js b/ui/app/routes/storage/plugins/plugin/allocations.js similarity index 100% rename from ui/app/routes/csi/plugins/plugin/allocations.js rename to ui/app/routes/storage/plugins/plugin/allocations.js diff --git a/ui/app/routes/csi/plugins/plugin/index.js b/ui/app/routes/storage/plugins/plugin/index.js similarity index 100% rename from ui/app/routes/csi/plugins/plugin/index.js rename to ui/app/routes/storage/plugins/plugin/index.js diff --git a/ui/app/routes/csi/volumes.js b/ui/app/routes/storage/volumes.js similarity index 100% rename from ui/app/routes/csi/volumes.js rename to ui/app/routes/storage/volumes.js diff --git a/ui/app/routes/storage/volumes/dynamic-host-volume.js b/ui/app/routes/storage/volumes/dynamic-host-volume.js new file mode 100644 index 000000000..45da02051 --- /dev/null +++ b/ui/app/routes/storage/volumes/dynamic-host-volume.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check + +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; +import notifyError from 'nomad-ui/utils/notify-error'; +import { inject as service } from '@ember/service'; + +export default class StorageVolumesDynamicHostVolumeRoute extends Route { + @service store; + @service system; + + model(params) { + const [id, namespace] = params.id.split('@'); + const fullId = JSON.stringify([`${id}`, namespace || 'default']); + + return RSVP.hash({ + volume: this.store.findRecord('dynamic-host-volume', fullId, { + reload: true, + }), + namespaces: this.store.findAll('namespace'), + }) + .then((hash) => hash.volume) + .catch(notifyError(this)); + } +} diff --git a/ui/app/routes/csi/index.js b/ui/app/routes/storage/volumes/index.js similarity index 57% rename from ui/app/routes/csi/index.js rename to ui/app/routes/storage/volumes/index.js index 23b74f6f0..b1f45293e 100644 --- a/ui/app/routes/csi/index.js +++ b/ui/app/routes/storage/volumes/index.js @@ -3,10 +3,13 @@ * SPDX-License-Identifier: BUSL-1.1 */ +import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; export default class IndexRoute extends Route { - redirect() { - this.transitionTo('csi.volumes'); + @service router; + + beforeModel() { + this.router.transitionTo('storage'); } } diff --git a/ui/app/routes/csi/volumes/volume.js b/ui/app/routes/storage/volumes/volume.js similarity index 100% rename from ui/app/routes/csi/volumes/volume.js rename to ui/app/routes/storage/volumes/volume.js diff --git a/ui/app/serializers/dynamic-host-volume.js b/ui/app/serializers/dynamic-host-volume.js new file mode 100644 index 000000000..321601434 --- /dev/null +++ b/ui/app/serializers/dynamic-host-volume.js @@ -0,0 +1,79 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import ApplicationSerializer from './application'; +import { get, set } from '@ember/object'; +import { capitalize } from '@ember/string'; +import classic from 'ember-classic-decorator'; + +@classic +export default class DynamicHostVolumeSerializer extends ApplicationSerializer { + embeddedRelationships = ['allocations']; + separateNanos = ['CreateTime', 'ModifyTime']; + + // 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.PlainId = hash.ID; + hash.ID = JSON.stringify([hash.ID, hash.Namespace || 'default']); + const normalizedHash = super.normalize(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 capitalize(attr); + return super.keyForRelationship(attr, relationshipType); + } + + 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; + + const hasMany = new Array(relationship.length); + + relationship.forEach((alloc, idx) => { + const { data, included } = this.normalizeEmbeddedRelationship( + store, + relationshipMeta, + alloc + ); + + 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 }; + }); + + const relationshipJson = { data: hasMany }; + set(partial, `data.relationships.${embed}`, relationshipJson); + }); + + return partial; + } + + normalizeEmbeddedRelationship(store, relationshipMeta, relationshipHash) { + const modelName = relationshipMeta.type; + const modelClass = store.modelFor(modelName); + const serializer = store.serializerFor(modelName); + + return serializer.normalize(modelClass, relationshipHash, null); + } +} diff --git a/ui/app/serializers/node.js b/ui/app/serializers/node.js index 87c8aaf1b..14f4ebea9 100644 --- a/ui/app/serializers/node.js +++ b/ui/app/serializers/node.js @@ -18,6 +18,15 @@ export default class NodeSerializer extends ApplicationSerializer { mapToArray = ['Drivers', 'HostVolumes']; + normalize(modelClass, hash) { + if (hash.HostVolumes) { + Object.entries(hash.HostVolumes).forEach(([key, value]) => { + hash.HostVolumes[key].VolumeID = value.ID || undefined; + }); + } + return super.normalize(...arguments); + } + extractRelationships(modelClass, hash) { const { modelName } = modelClass; const nodeURL = this.store diff --git a/ui/app/services/keyboard.js b/ui/app/services/keyboard.js index 3ed4f78fc..1fe2924e5 100644 --- a/ui/app/services/keyboard.js +++ b/ui/app/services/keyboard.js @@ -105,7 +105,7 @@ export default class KeyboardService extends Service { }, { label: 'Go to Storage', - action: () => this.router.transitionTo('csi.volumes'), + action: () => this.router.transitionTo('storage'), rebindable: true, }, { @@ -294,7 +294,7 @@ export default class KeyboardService extends Service { // If no activeLink, means we're nested within a primary section. // Luckily, Ember's RouteInfo.find() gives us access to parents and connected leaves of a route. - // So, if we're on /csi/volumes but the nav link is to /csi, we'll .find() it. + // So, if we're on /storage/volumes but the nav link is to /storage, we'll .find() it. // Similarly, /job/:job/taskgroupid/index will find /job. if (!activeLink) { activeLink = links.find((link) => { diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index a1cb9dcdf..3ffdbaaeb 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -61,3 +61,4 @@ @import './components/access-control'; @import './components/actions'; @import './components/jobs-list'; +@import './components/storage'; diff --git a/ui/app/styles/components/storage.scss b/ui/app/styles/components/storage.scss new file mode 100644 index 000000000..9c7dfe94c --- /dev/null +++ b/ui/app/styles/components/storage.scss @@ -0,0 +1,65 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +.storage-index { + .storage-index-table-card { + margin-bottom: 2rem; + padding-bottom: 2rem; + border-bottom: 1px solid #eee; + &:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } + header { + display: grid; + gap: 0.5rem; + grid-template-areas: + 'title actions' + 'intro search'; + grid-template-columns: 2fr 1fr; + h3 { + font-size: 1.5rem; + font-weight: $weight-bold; + grid-area: title; + } + .actions { + display: grid; + gap: 1rem; + grid-auto-flow: column; + grid-area: actions; + justify-content: end; + } + .intro { + grid-area: intro; + } + .search { + grid-area: search; + } + } + table { + margin-top: 1rem; + } + .empty-message { + margin-top: 1rem; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + } + } + + .info-panels { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + header { + grid-column: -1 / 1; + grid-template-areas: "title"; + } + } +} diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index 578827da5..9d6598f52 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -220,7 +220,7 @@
+ Storage configured by plugins run as Nomad jobs, with advanced features like snapshots and resizing.
+
+ Storage provisioned via plugin scripts on a particular client, modifiable without requiring client restart.
+
/alloc/data directory in a given allocation.
+
{{this.model.name}}
+
+ Driver Health, Scheduling, and Preemption
+ ID
+ Created
+ Modified
+ Status
+ Client
+ Job
+ Version
+ CPU
+ Memory
+
+
+
+ Access Mode
+ Attachment Mode
+
+
+ {{#each this.model.capabilities as |capability|}}
+
+
+ {{/each}}
+
+ {{capability.access_mode}}
+ {{capability.attachment_mode}}
+ {{this.model.name}}
@@ -57,7 +58,7 @@
diff --git a/ui/app/utils/route-redirector.js b/ui/app/utils/route-redirector.js
new file mode 100644
index 000000000..f8eac3784
--- /dev/null
+++ b/ui/app/utils/route-redirector.js
@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import routeRedirects from './route-redirects';
+
+export function handleRouteRedirects(transition, router) {
+ const currentPath = transition.intent.url || transition.targetName;
+
+ for (const redirect of routeRedirects) {
+ let shouldRedirect = false;
+ let targetPath =
+ typeof redirect.to === 'function'
+ ? redirect.to(currentPath)
+ : redirect.to;
+
+ switch (redirect.method) {
+ case 'startsWith':
+ shouldRedirect = currentPath.startsWith(redirect.from);
+ break;
+ case 'exact':
+ shouldRedirect = currentPath === redirect.from;
+ break;
+ case 'pattern':
+ if (redirect.pattern && redirect.pattern.test(currentPath)) {
+ shouldRedirect = true;
+ }
+ break;
+ }
+
+ if (shouldRedirect) {
+ console.warn(
+ `This URL has changed. Please update your bookmark from ${currentPath} to ${targetPath}`
+ );
+
+ router.replaceWith(targetPath, {
+ queryParams: transition.to.queryParams,
+ });
+ return true;
+ }
+ }
+}
diff --git a/ui/app/utils/route-redirects.js b/ui/app/utils/route-redirects.js
new file mode 100644
index 000000000..338aa389d
--- /dev/null
+++ b/ui/app/utils/route-redirects.js
@@ -0,0 +1,36 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+// This serves as a lit of routes in the UI that we change over time,
+// but still want to respect users' bookmarks and habits.
+
+/**
+ * @typedef {Object} RouteRedirect
+ * @property {string} from - The path to match against
+ * @property {(string|function(string): string)} to - Either a static path or a function to compute the new path
+ * @property {'startsWith'|'exact'|'pattern'} method - The matching strategy to use
+ * @property {RegExp} [pattern] - Optional regex pattern if method is 'pattern'
+ */
+export default [
+ {
+ from: '/csi/volumes/',
+ to: (path) => {
+ const volumeName = path.split('/csi/volumes/')[1];
+ return `/storage/volumes/csi/${volumeName}`;
+ },
+ method: 'pattern',
+ pattern: /^\/csi\/volumes\/(.+)$/,
+ },
+ {
+ from: '/csi/volumes',
+ to: '/storage/volumes',
+ method: 'exact',
+ },
+ {
+ from: '/csi',
+ to: '/storage',
+ method: 'startsWith',
+ },
+];
diff --git a/ui/mirage/config.js b/ui/mirage/config.js
index d97ef3580..b304ae237 100644
--- a/ui/mirage/config.js
+++ b/ui/mirage/config.js
@@ -656,19 +656,33 @@ export default function () {
this.get(
'/volumes',
- withBlockingSupport(function ({ csiVolumes }, { queryParams }) {
- if (queryParams.type !== 'csi') {
+ withBlockingSupport(function (
+ { csiVolumes, dynamicHostVolumes },
+ { queryParams }
+ ) {
+ if (queryParams.type !== 'csi' && queryParams.type !== 'host') {
return new Response(200, {}, '[]');
}
- const json = this.serialize(csiVolumes.all());
- const namespace = queryParams.namespace || 'default';
- return json.filter((volume) => {
- if (namespace === '*') return true;
- return namespace === 'default'
- ? !volume.NamespaceID || volume.NamespaceID === namespace
- : volume.NamespaceID === namespace;
- });
+ if (queryParams.type === 'host') {
+ const json = this.serialize(dynamicHostVolumes.all());
+ const namespace = queryParams.namespace || 'default';
+ return json.filter((volume) => {
+ if (namespace === '*') return true;
+ return namespace === 'default'
+ ? !volume.NamespaceID || volume.NamespaceID === namespace
+ : volume.NamespaceID === namespace;
+ });
+ } else {
+ const json = this.serialize(csiVolumes.all());
+ const namespace = queryParams.namespace || 'default';
+ return json.filter((volume) => {
+ if (namespace === '*') return true;
+ return namespace === 'default'
+ ? !volume.NamespaceID || volume.NamespaceID === namespace
+ : volume.NamespaceID === namespace;
+ });
+ }
})
);
@@ -692,6 +706,26 @@ export default function () {
})
);
+ this.get(
+ '/volume/host/:id',
+ withBlockingSupport(function ({ dynamicHostVolumes }, { params, queryParams }) {
+ const { id } = params;
+ const volume = dynamicHostVolumes.all().models.find((volume) => {
+ const volumeIsDefault =
+ !volume.namespaceId || volume.namespaceId === 'default';
+ const qpIsDefault =
+ !queryParams.namespace || queryParams.namespace === 'default';
+ return (
+ volume.id === id &&
+ (volume.namespaceId === queryParams.namespace ||
+ (volumeIsDefault && qpIsDefault))
+ );
+ });
+
+ return volume ? this.serialize(volume) : new Response(404, {}, null);
+ })
+ );
+
this.get('/plugins', function ({ csiPlugins }, { queryParams }) {
if (queryParams.type !== 'csi') {
return new Response(200, {}, '[]');
diff --git a/ui/mirage/factories/dynamic-host-volume.js b/ui/mirage/factories/dynamic-host-volume.js
new file mode 100644
index 000000000..5d705076d
--- /dev/null
+++ b/ui/mirage/factories/dynamic-host-volume.js
@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { Factory } from 'ember-cli-mirage';
+import faker from 'nomad-ui/mirage/faker';
+import { pickOne } from '../utils';
+
+export default Factory.extend({
+ id: () => `${faker.random.uuid()}`,
+ name() {
+ return faker.hacker.noun();
+ },
+
+ pluginID() {
+ return faker.hacker.noun();
+ },
+
+ state() {
+ return 'ready';
+ },
+
+ capacityBytes() {
+ return 10000000;
+ },
+
+ requestedCapabilities() {
+ return [
+ {
+ AccessMode: 'single-node-writer',
+ AttachmentMode: 'file-system',
+ },
+ {
+ AccessMode: 'single-node-reader-only',
+ AttachmentMode: 'block-device',
+ },
+ ];
+ },
+
+ path: () => faker.system.filePath(),
+
+ 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.nodeId) {
+ const node = server.db.nodes.length ? pickOne(server.db.nodes) : null;
+ volume.update({
+ nodeId: node.id,
+ });
+ }
+ },
+});
diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js
index ae5059d59..4d03a6d31 100644
--- a/ui/mirage/scenarios/default.js
+++ b/ui/mirage/scenarios/default.js
@@ -575,6 +575,14 @@ function smallCluster(server) {
volume.save();
});
+ server.create('dynamic-host-volume', {
+ name: 'dynamic-host-volume',
+ namespaceId: 'default',
+ createTime: new Date().getTime() * 1000000,
+ modifyTime: new Date().getTime() * 1000000,
+ allocations: csiAllocations,
+ });
+
server.create('auth-method', { name: 'vault' });
server.create('auth-method', { name: 'auth0' });
server.create('auth-method', { name: 'cognito' });
diff --git a/ui/mirage/serializers/dynamic-host-volume.js b/ui/mirage/serializers/dynamic-host-volume.js
new file mode 100644
index 000000000..b9d3d76ae
--- /dev/null
+++ b/ui/mirage/serializers/dynamic-host-volume.js
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import ApplicationSerializer from './application';
+
+export default ApplicationSerializer.extend({
+ embed: true,
+ include: ['allocations', 'node'],
+
+ serialize() {
+ var json = ApplicationSerializer.prototype.serialize.apply(this, arguments);
+ if (!Array.isArray(json)) {
+ serializeVolume(json);
+ }
+ return json;
+ },
+});
+
+function serializeVolume(volume) {
+ volume.NodeID = volume.Node.ID;
+ delete volume.Node;
+}
diff --git a/ui/tests/acceptance/actions-test.js b/ui/tests/acceptance/actions-test.js
index 212ebb6bf..6b812c7ff 100644
--- a/ui/tests/acceptance/actions-test.js
+++ b/ui/tests/acceptance/actions-test.js
@@ -11,12 +11,13 @@ import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import percySnapshot from '@percy/ember';
import Actions from 'nomad-ui/tests/pages/jobs/job/actions';
import { triggerEvent, visit, click } from '@ember/test-helpers';
-
+import faker from 'nomad-ui/mirage/faker';
module('Acceptance | actions', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
+ faker.seed(1);
window.localStorage.clear();
server.create('agent');
server.create('node-pool');
@@ -46,7 +47,7 @@ module('Acceptance | actions', function (hooks) {
const actionsGroup = server.create('task-group', {
jobId: actionsJob.id,
name: 'actionable-group',
- count: 1,
+ taskCount: 1,
});
// make sure the allocation generated by that group is running
diff --git a/ui/tests/acceptance/dynamic-host-volume-detail-test.js b/ui/tests/acceptance/dynamic-host-volume-detail-test.js
new file mode 100644
index 000000000..d3fc45138
--- /dev/null
+++ b/ui/tests/acceptance/dynamic-host-volume-detail-test.js
@@ -0,0 +1,236 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+/* eslint-disable qunit/require-expect */
+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 a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
+import moment from 'moment';
+import { formatBytes, formatHertz } from 'nomad-ui/utils/units';
+import VolumeDetail from 'nomad-ui/tests/pages/storage/dynamic-host-volumes/detail';
+import Layout from 'nomad-ui/tests/pages/layout';
+import percySnapshot from '@percy/ember';
+
+const assignAlloc = (volume, alloc) => {
+ volume.allocations.add(alloc);
+ volume.save();
+};
+
+module('Acceptance | dynamic host volume detail', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ let volume;
+
+ hooks.beforeEach(function () {
+ server.create('node-pool');
+ server.create('node');
+ server.create('job', {
+ name: 'dhv-job',
+ });
+ volume = server.create('dynamic-host-volume', {
+ nodeId: server.db.nodes[0].id,
+ });
+ });
+
+ test('it passes an accessibility audit', async function (assert) {
+ await VolumeDetail.visit({ id: `${volume.id}@default` });
+ await a11yAudit(assert);
+ });
+
+ test('/storage/volumes/:id should have a breadcrumb trail linking back to Volumes and Storage', async function (assert) {
+ await VolumeDetail.visit({ id: `${volume.id}@default` });
+
+ assert.equal(Layout.breadcrumbFor('storage.index').text, 'Storage');
+ assert.equal(
+ Layout.breadcrumbFor('storage.volumes.dynamic-host-volume').text,
+ volume.name
+ );
+ });
+
+ test('/storage/volumes/:id should show the volume name in the title', async function (assert) {
+ await VolumeDetail.visit({ id: `${volume.id}@default` });
+
+ assert.equal(document.title, `Dynamic Host Volume ${volume.name} - Nomad`);
+ assert.equal(VolumeDetail.title, volume.name);
+ });
+
+ test('/storage/volumes/:id should list additional details for the volume below the title', async function (assert) {
+ await VolumeDetail.visit({ id: `${volume.id}@default` });
+ assert.ok(VolumeDetail.node.includes(volume.node.name));
+ assert.ok(VolumeDetail.plugin.includes(volume.pluginID));
+ assert.notOk(
+ VolumeDetail.hasNamespace,
+ 'Namespace is omitted when there is only one namespace'
+ );
+ assert.equal(VolumeDetail.capacity, 'Capacity 9.54 MiB');
+ });
+
+ test('/storage/volumes/:id should list all allocations the volume is attached to', async function (assert) {
+ const allocations = server.createList('allocation', 3);
+ allocations.forEach((alloc) => assignAlloc(volume, alloc));
+
+ await VolumeDetail.visit({ id: `${volume.id}@default` });
+
+ assert.equal(VolumeDetail.allocations.length, allocations.length);
+ allocations
+ .sortBy('modifyIndex')
+ .reverse()
+ .forEach((allocation, idx) => {
+ assert.equal(allocation.id, VolumeDetail.allocations.objectAt(idx).id);
+ });
+ await percySnapshot(assert);
+ });
+
+ test('each allocation should have high-level details for the allocation', async function (assert) {
+ const allocation = server.create('allocation', { clientStatus: 'running' });
+ assignAlloc(volume, allocation);
+
+ 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 VolumeDetail.visit({ id: `${volume.id}@default` });
+ VolumeDetail.allocations.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 DD HH:mm:ss ZZ'),
+ 'Allocation create time'
+ );
+ assert.equal(
+ allocationRow.modifyTime,
+ moment(allocation.modifyTime / 1000000).fromNow(),
+ 'Allocation modify time'
+ );
+ assert.equal(
+ allocationRow.status,
+ allocation.clientStatus,
+ 'Client status'
+ );
+ 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.client,
+ server.db.nodes.find(allocation.nodeId).id.split('-')[0],
+ 'Node ID'
+ );
+ assert.equal(
+ allocationRow.clientTooltip.substr(0, 15),
+ server.db.nodes.find(allocation.nodeId).name.substr(0, 15),
+ 'Node Name'
+ );
+ assert.equal(
+ allocationRow.cpu,
+ Math.floor(allocStats.resourceUsage.CpuStats.TotalTicks) / cpuUsed,
+ 'CPU %'
+ );
+ const roundedTicks = Math.floor(
+ allocStats.resourceUsage.CpuStats.TotalTicks
+ );
+ assert.equal(
+ allocationRow.cpuTooltip,
+ `${formatHertz(roundedTicks, 'MHz')} / ${formatHertz(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
+ )} / ${formatBytes(memoryUsed, 'MiB')}`,
+ 'Detailed memory information is in a tooltip'
+ );
+ });
+ });
+
+ test('each allocation should link to the allocation detail page', async function (assert) {
+ const allocation = server.create('allocation');
+ assignAlloc(volume, allocation);
+
+ await VolumeDetail.visit({ id: `${volume.id}@default` });
+ await VolumeDetail.allocations.objectAt(0).visit();
+
+ assert.equal(currentURL(), `/allocations/${allocation.id}`);
+ });
+
+ test('when there are no allocations, the table presents an empty state', async function (assert) {
+ await VolumeDetail.visit({ id: `${volume.id}@default` });
+
+ assert.ok(VolumeDetail.allocationsTableIsEmpty);
+ assert.equal(VolumeDetail.allocationsEmptyState.headline, 'No Allocations');
+ });
+
+ test('Capabilities table shows access mode and attachment mode', async function (assert) {
+ await VolumeDetail.visit({ id: `${volume.id}@default` });
+ assert.equal(
+ VolumeDetail.capabilities.objectAt(0).accessMode,
+ 'single-node-writer'
+ );
+ assert.equal(
+ VolumeDetail.capabilities.objectAt(0).attachmentMode,
+ 'file-system'
+ );
+ assert.equal(
+ VolumeDetail.capabilities.objectAt(1).accessMode,
+ 'single-node-reader-only'
+ );
+ assert.equal(
+ VolumeDetail.capabilities.objectAt(1).attachmentMode,
+ 'block-device'
+ );
+ });
+});
+
+// Namespace test: details shows the namespace
+module(
+ 'Acceptance | dynamic volume detail (with namespaces)',
+ function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ let volume;
+
+ hooks.beforeEach(function () {
+ server.createList('namespace', 2);
+ server.create('node-pool');
+ server.create('node');
+ volume = server.create('dynamic-host-volume');
+ });
+
+ test('/storage/volumes/:id detail ribbon includes the namespace of the volume', async function (assert) {
+ await VolumeDetail.visit({ id: `${volume.id}@${volume.namespaceId}` });
+
+ assert.ok(VolumeDetail.hasNamespace);
+ assert.ok(
+ VolumeDetail.namespace.includes(volume.namespaceId || 'default')
+ );
+ });
+ }
+);
diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js
index 727e390be..c044af8dd 100644
--- a/ui/tests/acceptance/job-detail-test.js
+++ b/ui/tests/acceptance/job-detail-test.js
@@ -274,6 +274,7 @@ moduleForJob('Acceptance | job detail (periodic child)', 'allocations', () => {
running: 1,
},
noActiveDeployment: true,
+ withPreviousStableVersion: true,
});
return server.db.jobs.where({ parentId: parent.id })[0];
});
@@ -292,6 +293,7 @@ moduleForJob(
},
// Child's gotta be non-queued to be able to run
status: 'running', // TODO: TEMP
+ withPreviousStableVersion: true,
});
return server.db.jobs.where({ parentId: parent.id })[0];
}
diff --git a/ui/tests/acceptance/plugin-allocations-test.js b/ui/tests/acceptance/plugin-allocations-test.js
index efaec6f16..7321c81f1 100644
--- a/ui/tests/acceptance/plugin-allocations-test.js
+++ b/ui/tests/acceptance/plugin-allocations-test.js
@@ -36,7 +36,7 @@ module('Acceptance | plugin allocations', function (hooks) {
await a11yAudit(assert);
});
- test('/csi/plugins/:id/allocations shows all allocations in a single table', async function (assert) {
+ test('/storage/plugins/:id/allocations shows all allocations in a single table', async function (assert) {
plugin = server.create('csi-plugin', {
shallow: true,
controllerRequired: true,
@@ -172,7 +172,7 @@ module('Acceptance | plugin allocations', function (hooks) {
assert.equal(
currentURL(),
- `/csi/plugins/${plugin.id}/allocations?${queryString}`
+ `/storage/plugins/${plugin.id}/allocations?${queryString}`
);
});
}
diff --git a/ui/tests/acceptance/plugin-detail-test.js b/ui/tests/acceptance/plugin-detail-test.js
index 77c9f3166..f3fed9c2e 100644
--- a/ui/tests/acceptance/plugin-detail-test.js
+++ b/ui/tests/acceptance/plugin-detail-test.js
@@ -31,22 +31,25 @@ module('Acceptance | plugin detail', function (hooks) {
await a11yAudit(assert);
});
- test('/csi/plugins/:id should have a breadcrumb trail linking back to Plugins and Storage', async function (assert) {
+ test('/storage/plugins/:id should have a breadcrumb trail linking back to Plugins and Storage', async function (assert) {
await PluginDetail.visit({ id: plugin.id });
- assert.equal(Layout.breadcrumbFor('csi.index').text, 'Storage');
- assert.equal(Layout.breadcrumbFor('csi.plugins').text, 'Plugins');
- assert.equal(Layout.breadcrumbFor('csi.plugins.plugin').text, plugin.id);
+ assert.equal(Layout.breadcrumbFor('storage.index').text, 'Storage');
+ assert.equal(Layout.breadcrumbFor('storage.plugins').text, 'Plugins');
+ assert.equal(
+ Layout.breadcrumbFor('storage.plugins.plugin').text,
+ plugin.id
+ );
});
- test('/csi/plugins/:id should show the plugin name in the title', async function (assert) {
+ test('/storage/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) {
+ test('/storage/plugins/:id should list additional details for the plugin below the title', async function (assert) {
await PluginDetail.visit({ id: plugin.id });
assert.ok(
@@ -74,7 +77,7 @@ module('Acceptance | plugin detail', function (hooks) {
assert.ok(PluginDetail.provider.includes(plugin.provider));
});
- test('/csi/plugins/:id should list all the controller plugin allocations for the plugin', async function (assert) {
+ test('/storage/plugins/:id should list all the controller plugin allocations for the plugin', async function (assert) {
await PluginDetail.visit({ id: plugin.id });
assert.equal(
@@ -92,7 +95,7 @@ module('Acceptance | plugin detail', function (hooks) {
});
});
- test('/csi/plugins/:id should list all the node plugin allocations for the plugin', async function (assert) {
+ test('/storage/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);
@@ -267,7 +270,9 @@ module('Acceptance | plugin detail', function (hooks) {
await PluginDetail.goToControllerAllocations();
assert.equal(
currentURL(),
- `/csi/plugins/${plugin.id}/allocations?type=${serialize(['controller'])}`
+ `/storage/plugins/${plugin.id}/allocations?type=${serialize([
+ 'controller',
+ ])}`
);
await PluginDetail.visit({ id: plugin.id });
@@ -277,7 +282,7 @@ module('Acceptance | plugin detail', function (hooks) {
await PluginDetail.goToNodeAllocations();
assert.equal(
currentURL(),
- `/csi/plugins/${plugin.id}/allocations?type=${serialize(['node'])}`
+ `/storage/plugins/${plugin.id}/allocations?type=${serialize(['node'])}`
);
});
});
diff --git a/ui/tests/acceptance/plugins-list-test.js b/ui/tests/acceptance/plugins-list-test.js
index 4dc89fd38..1567737eb 100644
--- a/ui/tests/acceptance/plugins-list-test.js
+++ b/ui/tests/acceptance/plugins-list-test.js
@@ -27,14 +27,14 @@ module('Acceptance | plugins list', function (hooks) {
await a11yAudit(assert);
});
- test('visiting /csi/plugins', async function (assert) {
+ test('visiting /storage/plugins', async function (assert) {
await PluginsList.visit();
- assert.equal(currentURL(), '/csi/plugins');
+ assert.equal(currentURL(), '/storage/plugins');
assert.equal(document.title, 'CSI Plugins - Nomad');
});
- test('/csi/plugins should list the first page of plugins sorted by id', async function (assert) {
+ test('/storage/plugins should list the first page of plugins sorted by id', async function (assert) {
const pluginCount = PluginsList.pageSize + 1;
server.createList('csi-plugin', pluginCount, { shallow: true });
@@ -98,13 +98,13 @@ module('Acceptance | plugins list', function (hooks) {
await PluginsList.visit();
await PluginsList.plugins.objectAt(0).clickName();
- assert.equal(currentURL(), `/csi/plugins/${plugin.id}`);
+ assert.equal(currentURL(), `/storage/plugins/${plugin.id}`);
await PluginsList.visit();
- assert.equal(currentURL(), '/csi/plugins');
+ assert.equal(currentURL(), '/storage/plugins');
await PluginsList.plugins.objectAt(0).clickRow();
- assert.equal(currentURL(), `/csi/plugins/${plugin.id}`);
+ assert.equal(currentURL(), `/storage/plugins/${plugin.id}`);
});
test('when there are no plugins, there is an empty message', async function (assert) {
@@ -133,11 +133,11 @@ module('Acceptance | plugins list', function (hooks) {
await PluginsList.visit();
await PluginsList.nextPage();
- assert.equal(currentURL(), '/csi/plugins?page=2');
+ assert.equal(currentURL(), '/storage/plugins?page=2');
await PluginsList.search('foobar');
- assert.equal(currentURL(), '/csi/plugins?search=foobar');
+ assert.equal(currentURL(), '/storage/plugins?search=foobar');
});
test('when accessing plugins is forbidden, a message is shown with a link to the tokens page', async function (assert) {
diff --git a/ui/tests/acceptance/search-test.js b/ui/tests/acceptance/search-test.js
index ef10a0d1f..ed4726720 100644
--- a/ui/tests/acceptance/search-test.js
+++ b/ui/tests/acceptance/search-test.js
@@ -134,7 +134,7 @@ module('Acceptance | search', function (hooks) {
await selectSearch(Layout.navbar.search.scope, 'xy');
await Layout.navbar.search.groups[4].options[0].click();
- assert.equal(currentURL(), '/csi/plugins/xyz-plugin');
+ assert.equal(currentURL(), '/storage/plugins/xyz-plugin');
const fuzzySearchQueries = server.pretender.handledRequests.filterBy(
'url',
diff --git a/ui/tests/acceptance/volumes-list-test.js b/ui/tests/acceptance/storage-list-test.js
similarity index 66%
rename from ui/tests/acceptance/volumes-list-test.js
rename to ui/tests/acceptance/storage-list-test.js
index 033decdad..48b502974 100644
--- a/ui/tests/acceptance/volumes-list-test.js
+++ b/ui/tests/acceptance/storage-list-test.js
@@ -9,8 +9,7 @@ import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
-import pageSizeSelect from './behaviors/page-size-select';
-import VolumesList from 'nomad-ui/tests/pages/storage/volumes/list';
+import StorageList from 'nomad-ui/tests/pages/storage/list';
import percySnapshot from '@percy/ember';
import faker from 'nomad-ui/mirage/faker';
@@ -26,7 +25,7 @@ const assignReadAlloc = (volume, alloc) => {
volume.save();
};
-module('Acceptance | volumes list', function (hooks) {
+module('Acceptance | storage list', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
@@ -39,34 +38,35 @@ module('Acceptance | volumes list', function (hooks) {
});
test('it passes an accessibility audit', async function (assert) {
- await VolumesList.visit();
+ await StorageList.visit();
await a11yAudit(assert);
});
- test('visiting /csi redirects to /csi/volumes', async function (assert) {
+ test('visiting the now-deprecated /csi redirects to /storage', async function (assert) {
await visit('/csi');
- assert.equal(currentURL(), '/csi/volumes');
+ assert.equal(currentURL(), '/storage');
});
- test('visiting /csi/volumes', async function (assert) {
- await VolumesList.visit();
+ test('visiting /storage', async function (assert) {
+ await StorageList.visit();
- assert.equal(currentURL(), '/csi/volumes');
- assert.equal(document.title, 'CSI Volumes - Nomad');
+ assert.equal(currentURL(), '/storage');
+ assert.equal(document.title, 'Storage - Nomad');
});
- test('/csi/volumes should list the first page of volumes sorted by name', async function (assert) {
- const volumeCount = VolumesList.pageSize + 1;
+ test('/storage/volumes should list the first page of volumes sorted by name', async function (assert) {
+ const volumeCount = StorageList.pageSize + 1;
server.createList('csi-volume', volumeCount);
- await VolumesList.visit();
+ await StorageList.visit();
await percySnapshot(assert);
const sortedVolumes = server.db.csiVolumes.sortBy('id');
- assert.equal(VolumesList.volumes.length, VolumesList.pageSize);
- VolumesList.volumes.forEach((volume, index) => {
+
+ assert.equal(StorageList.csiVolumes.length, StorageList.pageSize);
+ StorageList.csiVolumes.forEach((volume, index) => {
assert.equal(volume.name, sortedVolumes[index].id, 'Volumes are ordered');
});
});
@@ -78,9 +78,9 @@ module('Acceptance | volumes list', function (hooks) {
readAllocs.forEach((alloc) => assignReadAlloc(volume, alloc));
writeAllocs.forEach((alloc) => assignWriteAlloc(volume, alloc));
- await VolumesList.visit();
+ await StorageList.visit();
- const volumeRow = VolumesList.volumes.objectAt(0);
+ const volumeRow = StorageList.csiVolumes.objectAt(0);
let controllerHealthStr = 'Node Only';
if (volume.controllerRequired || volume.controllersExpected > 0) {
@@ -105,7 +105,7 @@ module('Acceptance | volumes list', function (hooks) {
volumeRow.nodeHealth,
`${nodeHealthStr} ( ${volume.nodesHealthy} / ${volume.nodesExpected} )`
);
- assert.equal(volumeRow.provider, volume.provider);
+ assert.equal(volumeRow.plugin, volume.PluginId);
assert.equal(volumeRow.allocations, readAllocs.length + writeAllocs.length);
});
@@ -115,64 +115,58 @@ module('Acceptance | volumes list', function (hooks) {
namespaceId: secondNamespace.id,
});
- await VolumesList.visit({ namespace: '*' });
-
- await VolumesList.volumes.objectAt(0).clickName();
+ await StorageList.visit({ namespace: '*' });
+ await StorageList.csiVolumes.objectAt(0).clickName();
assert.equal(
currentURL(),
- `/csi/volumes/${volume.id}@${secondNamespace.id}`
+ `/storage/volumes/csi/${volume.id}@${secondNamespace.id}`
);
- await VolumesList.visit({ namespace: '*' });
- assert.equal(currentURL(), '/csi/volumes?namespace=*');
-
- await VolumesList.volumes.objectAt(0).clickRow();
- assert.equal(
- currentURL(),
- `/csi/volumes/${volume.id}@${secondNamespace.id}`
- );
+ await StorageList.visit({ namespace: '*' });
+ assert.equal(currentURL(), '/storage');
});
- test('when there are no volumes, there is an empty message', async function (assert) {
- await VolumesList.visit();
+ test('when there are no csi volumes, there is an empty message', async function (assert) {
+ await StorageList.visit();
await percySnapshot(assert);
- assert.ok(VolumesList.isEmpty);
- assert.equal(VolumesList.emptyState.headline, 'No Volumes');
+ assert.ok(StorageList.csiIsEmpty);
+ assert.equal(StorageList.csiEmptyState, 'No CSI Volumes found');
});
test('when there are volumes, but no matches for a search, there is an empty message', async function (assert) {
server.create('csi-volume', { id: 'cat 1' });
server.create('csi-volume', { id: 'cat 2' });
- await VolumesList.visit();
-
- await VolumesList.search('dog');
- assert.ok(VolumesList.isEmpty);
- assert.equal(VolumesList.emptyState.headline, 'No Matches');
+ await StorageList.visit();
+ await StorageList.csiSearch('dog');
+ assert.ok(StorageList.csiIsEmpty);
+ assert.ok(
+ StorageList.csiEmptyState.includes('No CSI volumes match your search')
+ );
});
test('searching resets the current page', async function (assert) {
- server.createList('csi-volume', VolumesList.pageSize + 1);
+ server.createList('csi-volume', StorageList.pageSize + 1);
- await VolumesList.visit();
- await VolumesList.nextPage();
+ await StorageList.visit();
+ await StorageList.csiNextPage();
- assert.equal(currentURL(), '/csi/volumes?page=2');
+ assert.equal(currentURL(), '/storage?csiPage=2');
- await VolumesList.search('foobar');
+ await StorageList.csiSearch('foobar');
- assert.equal(currentURL(), '/csi/volumes?search=foobar');
+ assert.equal(currentURL(), '/storage?csiFilter=foobar');
});
test('when the cluster has namespaces, each volume row includes the volume namespace', async function (assert) {
server.createList('namespace', 2);
const volume = server.create('csi-volume');
- await VolumesList.visit({ namespace: '*' });
+ await StorageList.visit({ namespace: '*' });
- const volumeRow = VolumesList.volumes.objectAt(0);
+ const volumeRow = StorageList.csiVolumes.objectAt(0);
assert.equal(volumeRow.namespace, volume.namespaceId);
});
@@ -185,43 +179,33 @@ module('Acceptance | volumes list', function (hooks) {
namespaceId: server.db.namespaces[1].id,
});
- await VolumesList.visit();
- assert.equal(VolumesList.volumes.length, 2);
+ await StorageList.visit();
+ assert.equal(StorageList.csiVolumes.length, 2);
const firstNamespace = server.db.namespaces[0];
- await VolumesList.visit({ namespace: firstNamespace.id });
- assert.equal(VolumesList.volumes.length, 1);
- assert.equal(VolumesList.volumes.objectAt(0).name, volume1.id);
+ await StorageList.visit({ namespace: firstNamespace.id });
+ assert.equal(StorageList.csiVolumes.length, 1);
+ assert.equal(StorageList.csiVolumes.objectAt(0).name, volume1.id);
const secondNamespace = server.db.namespaces[1];
- await VolumesList.visit({ namespace: secondNamespace.id });
+ await StorageList.visit({ namespace: secondNamespace.id });
- assert.equal(VolumesList.volumes.length, 1);
- assert.equal(VolumesList.volumes.objectAt(0).name, volume2.id);
+ assert.equal(StorageList.csiVolumes.length, 1);
+ assert.equal(StorageList.csiVolumes.objectAt(0).name, volume2.id);
});
test('when accessing volumes is forbidden, a message is shown with a link to the tokens page', async function (assert) {
server.pretender.get('/v1/volumes', () => [403, {}, null]);
- await VolumesList.visit();
- assert.equal(VolumesList.error.title, 'Not Authorized');
+ await StorageList.visit();
+ assert.equal(StorageList.error.title, 'Not Authorized');
- await VolumesList.error.seekHelp();
+ await StorageList.error.seekHelp();
assert.equal(currentURL(), '/settings/tokens');
});
- pageSizeSelect({
- resourceName: 'volume',
- pageObject: VolumesList,
- pageObjectList: VolumesList.volumes,
- async setup() {
- server.createList('csi-volume', VolumesList.pageSize);
- await VolumesList.visit();
- },
- });
-
testSingleSelectFacet('Namespace', {
- facet: VolumesList.facets.namespace,
+ facet: StorageList.facets.namespace,
paramName: 'namespace',
expectedOptions: ['All (*)', 'default', 'namespace-2'],
optionToSelect: 'namespace-2',
@@ -230,7 +214,7 @@ module('Acceptance | volumes list', function (hooks) {
server.create('namespace', { id: 'namespace-2' });
server.createList('csi-volume', 2, { namespaceId: 'default' });
server.createList('csi-volume', 2, { namespaceId: 'namespace-2' });
- await VolumesList.visit();
+ await StorageList.visit();
},
filter(volume, selection) {
return volume.namespaceId === selection;
@@ -264,14 +248,14 @@ module('Acceptance | volumes list', function (hooks) {
await facet.toggle();
const option = facet.options.findOneBy('label', optionToSelect);
- const selection = option.key;
- await option.select();
+ const selection = option.label;
+ await option.toggle();
const expectedVolumes = server.db.csiVolumes
.filter((volume) => filter(volume, selection))
.sortBy('id');
- VolumesList.volumes.forEach((volume, index) => {
+ StorageList.csiVolumes.forEach((volume, index) => {
assert.equal(
volume.name,
expectedVolumes[index].name,
@@ -285,11 +269,11 @@ module('Acceptance | volumes list', function (hooks) {
await facet.toggle();
const option = facet.options.objectAt(1);
- const selection = option.key;
- await option.select();
+ const label = option.label;
+ await option.toggle();
assert.ok(
- currentURL().includes(`${paramName}=${selection}`),
+ currentURL().includes(`${paramName}=${label}`),
'URL has the correct query param key and value'
);
});
diff --git a/ui/tests/acceptance/task-group-detail-test.js b/ui/tests/acceptance/task-group-detail-test.js
index c53de086b..b74806f09 100644
--- a/ui/tests/acceptance/task-group-detail-test.js
+++ b/ui/tests/acceptance/task-group-detail-test.js
@@ -698,7 +698,11 @@ module('Acceptance | task group detail', function (hooks) {
async beforeEach() {
['pending', 'running', 'complete', 'failed', 'lost', 'unknown'].forEach(
(s) => {
- server.createList('allocation', 5, { clientStatus: s });
+ server.createList('allocation', 5, {
+ jobId: job.id,
+ taskGroup: taskGroup.name,
+ clientStatus: s,
+ });
}
);
await TaskGroup.visit({ id: job.id, name: taskGroup.name });
@@ -767,9 +771,7 @@ function testFacet(
test(`facet ${label} | the ${label} facet filters the allocations list by ${label}`, async function (assert) {
let option;
-
await beforeEach();
-
await facet.toggle();
option = facet.options.objectAt(0);
await option.toggle();
diff --git a/ui/tests/acceptance/volume-detail-test.js b/ui/tests/acceptance/volume-detail-test.js
index bf79a871b..7f6e803ac 100644
--- a/ui/tests/acceptance/volume-detail-test.js
+++ b/ui/tests/acceptance/volume-detail-test.js
@@ -44,22 +44,24 @@ module('Acceptance | volume detail', function (hooks) {
await a11yAudit(assert);
});
- test('/csi/volumes/:id should have a breadcrumb trail linking back to Volumes and Storage', async function (assert) {
+ test('/storage/volumes/:id should have a breadcrumb trail linking back to Volumes and Storage', async function (assert) {
await VolumeDetail.visit({ id: `${volume.id}@default` });
- assert.equal(Layout.breadcrumbFor('csi.index').text, 'Storage');
- assert.equal(Layout.breadcrumbFor('csi.volumes').text, 'Volumes');
- assert.equal(Layout.breadcrumbFor('csi.volumes.volume').text, volume.name);
+ assert.equal(Layout.breadcrumbFor('storage.index').text, 'Storage');
+ assert.equal(
+ Layout.breadcrumbFor('storage.volumes.volume').text,
+ volume.name
+ );
});
- test('/csi/volumes/:id should show the volume name in the title', async function (assert) {
+ test('/storage/volumes/:id should show the volume name in the title', async function (assert) {
await VolumeDetail.visit({ id: `${volume.id}@default` });
assert.equal(document.title, `CSI Volume ${volume.name} - Nomad`);
assert.equal(VolumeDetail.title, volume.name);
});
- test('/csi/volumes/:id should list additional details for the volume below the title', async function (assert) {
+ test('/storage/volumes/:id should list additional details for the volume below the title', async function (assert) {
await VolumeDetail.visit({ id: `${volume.id}@default` });
assert.ok(
@@ -75,7 +77,7 @@ module('Acceptance | volume detail', function (hooks) {
);
});
- test('/csi/volumes/:id should list all write allocations the volume is attached to', async function (assert) {
+ test('/storage/volumes/:id should list all write allocations the volume is attached to', async function (assert) {
const writeAllocations = server.createList('allocation', 2);
const readAllocations = server.createList('allocation', 3);
writeAllocations.forEach((alloc) => assignWriteAlloc(volume, alloc));
@@ -95,7 +97,7 @@ module('Acceptance | volume detail', function (hooks) {
});
});
- test('/csi/volumes/:id should list all read allocations the volume is attached to', async function (assert) {
+ test('/storage/volumes/:id should list all read allocations the volume is attached to', async function (assert) {
const writeAllocations = server.createList('allocation', 2);
const readAllocations = server.createList('allocation', 3);
writeAllocations.forEach((alloc) => assignWriteAlloc(volume, alloc));
@@ -250,7 +252,7 @@ module('Acceptance | volume detail (with namespaces)', function (hooks) {
volume = server.create('csi-volume');
});
- test('/csi/volumes/:id detail ribbon includes the namespace of the volume', async function (assert) {
+ test('/storage/volumes/:id detail ribbon includes the namespace of the volume', async function (assert) {
await VolumeDetail.visit({ id: `${volume.id}@${volume.namespaceId}` });
assert.ok(VolumeDetail.hasNamespace);
diff --git a/ui/tests/pages/storage/dynamic-host-volumes/detail.js b/ui/tests/pages/storage/dynamic-host-volumes/detail.js
new file mode 100644
index 000000000..692db3132
--- /dev/null
+++ b/ui/tests/pages/storage/dynamic-host-volumes/detail.js
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import {
+ create,
+ isPresent,
+ text,
+ visitable,
+ collection,
+} from 'ember-cli-page-object';
+
+import allocations from 'nomad-ui/tests/pages/components/allocations';
+
+export default create({
+ visit: visitable('/storage/volumes/dynamic/:id'),
+
+ title: text('[data-test-title]'),
+
+ // health: text('[data-test-volume-health]'),
+ // provider: text('[data-test-volume-provider]'),
+ node: text('[data-test-volume-node]'),
+ plugin: text('[data-test-volume-plugin]'),
+ hasNamespace: isPresent('[data-test-volume-namespace]'),
+ namespace: text('[data-test-volume-namespace]'),
+ capacity: text('[data-test-volume-capacity]'),
+
+ ...allocations('[data-test-allocation]', 'allocations'),
+
+ allocationsTableIsEmpty: isPresent('[data-test-empty-allocations]'),
+ allocationsEmptyState: {
+ headline: text('[data-test-empty-allocations-headline]'),
+ },
+
+ capabilities: collection('[data-test-capability-row]', {
+ accessMode: text('[data-test-capability-access-mode]'),
+ attachmentMode: text('[data-test-capability-attachment-mode]'),
+ }),
+});
diff --git a/ui/tests/pages/storage/list.js b/ui/tests/pages/storage/list.js
new file mode 100644
index 000000000..59f3ea29b
--- /dev/null
+++ b/ui/tests/pages/storage/list.js
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import {
+ clickable,
+ collection,
+ create,
+ fillable,
+ isPresent,
+ text,
+ visitable,
+} from 'ember-cli-page-object';
+
+import error from 'nomad-ui/tests/pages/components/error';
+import { hdsFacet } from 'nomad-ui/tests/pages/components/facet';
+import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
+
+export default create({
+ pageSize: 25,
+
+ visit: visitable('/storage'),
+
+ csiSearch: fillable('[data-test-csi-volumes-search]'),
+ dynamicHostVolumesSearch: fillable(
+ '[data-test-dynamic-host-volumes-search] input'
+ ),
+ staticHostVolumesSearch: fillable(
+ '[data-test-static-host-volumes-search] input'
+ ),
+ ephemeralDisksSearch: fillable('[data-test-ephemeral-disks-search] input'),
+
+ csiVolumes: collection('[data-test-csi-volume-row]', {
+ name: text('[data-test-csi-volume-name]'),
+ namespace: text('[data-test-csi-volume-namespace]'),
+ schedulable: text('[data-test-csi-volume-schedulable]'),
+ controllerHealth: text('[data-test-csi-volume-controller-health]'),
+ nodeHealth: text('[data-test-csi-volume-node-health]'),
+ plugin: text('[data-test-csi-volume-plugin]'),
+ allocations: text('[data-test-csi-volume-allocations]'),
+
+ hasNamespace: isPresent('[data-test-csi-volume-namespace]'),
+ clickRow: clickable(),
+ clickName: clickable('[data-test-csi-volume-name] a'),
+ }),
+
+ csiIsEmpty: isPresent('[data-test-empty-csi-volumes-list-headline]'),
+ csiEmptyState: text('[data-test-empty-csi-volumes-list-headline]'),
+
+ csiNextPage: clickable('.hds-pagination-nav__arrow--direction-next'),
+ csiPrevPage: clickable('.hds-pagination-nav__arrow--direction-prev'),
+
+ error: error(),
+ pageSizeSelect: pageSizeSelect(),
+
+ facets: {
+ namespace: hdsFacet('[data-test-namespace-facet]'),
+ },
+});
diff --git a/ui/tests/pages/storage/plugins/detail.js b/ui/tests/pages/storage/plugins/detail.js
index ffa2dab65..6794327e6 100644
--- a/ui/tests/pages/storage/plugins/detail.js
+++ b/ui/tests/pages/storage/plugins/detail.js
@@ -14,7 +14,7 @@ import {
import allocations from 'nomad-ui/tests/pages/components/allocations';
export default create({
- visit: visitable('/csi/plugins/:id'),
+ visit: visitable('/storage/plugins/:id'),
title: text('[data-test-title]'),
diff --git a/ui/tests/pages/storage/plugins/list.js b/ui/tests/pages/storage/plugins/list.js
index 899d99e56..8117bec1d 100644
--- a/ui/tests/pages/storage/plugins/list.js
+++ b/ui/tests/pages/storage/plugins/list.js
@@ -19,7 +19,7 @@ import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
export default create({
pageSize: 25,
- visit: visitable('/csi/plugins'),
+ visit: visitable('/storage/plugins'),
search: fillable('[data-test-plugins-search] input'),
diff --git a/ui/tests/pages/storage/plugins/plugin/allocations.js b/ui/tests/pages/storage/plugins/plugin/allocations.js
index c05546c27..c56b9241a 100644
--- a/ui/tests/pages/storage/plugins/plugin/allocations.js
+++ b/ui/tests/pages/storage/plugins/plugin/allocations.js
@@ -18,7 +18,7 @@ import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
export default create({
pageSize: 25,
- visit: visitable('/csi/plugins/:id/allocations'),
+ visit: visitable('/storage/plugins/:id/allocations'),
nextPage: clickable('[data-test-pager="next"]'),
prevPage: clickable('[data-test-pager="prev"]'),
diff --git a/ui/tests/pages/storage/volumes/detail.js b/ui/tests/pages/storage/volumes/detail.js
index 645a7ed64..12254446c 100644
--- a/ui/tests/pages/storage/volumes/detail.js
+++ b/ui/tests/pages/storage/volumes/detail.js
@@ -8,7 +8,7 @@ import { create, isPresent, text, visitable } from 'ember-cli-page-object';
import allocations from 'nomad-ui/tests/pages/components/allocations';
export default create({
- visit: visitable('/csi/volumes/:id'),
+ visit: visitable('/storage/volumes/csi/:id'),
title: text('[data-test-title]'),
diff --git a/ui/tests/pages/storage/volumes/list.js b/ui/tests/pages/storage/volumes/list.js
deleted file mode 100644
index 2e8102ed9..000000000
--- a/ui/tests/pages/storage/volumes/list.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * Copyright (c) HashiCorp, Inc.
- * SPDX-License-Identifier: BUSL-1.1
- */
-
-import {
- clickable,
- collection,
- create,
- fillable,
- isPresent,
- text,
- visitable,
-} from 'ember-cli-page-object';
-
-import error from 'nomad-ui/tests/pages/components/error';
-import { singleFacet } from 'nomad-ui/tests/pages/components/facet';
-import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
-
-export default create({
- pageSize: 25,
-
- visit: visitable('/csi/volumes'),
-
- search: fillable('[data-test-volumes-search] input'),
-
- volumes: collection('[data-test-volume-row]', {
- name: text('[data-test-volume-name]'),
- namespace: text('[data-test-volume-namespace]'),
- schedulable: text('[data-test-volume-schedulable]'),
- controllerHealth: text('[data-test-volume-controller-health]'),
- nodeHealth: text('[data-test-volume-node-health]'),
- provider: text('[data-test-volume-provider]'),
- allocations: text('[data-test-volume-allocations]'),
-
- hasNamespace: isPresent('[data-test-volume-namespace]'),
- clickRow: clickable(),
- clickName: clickable('[data-test-volume-name] a'),
- }),
-
- nextPage: clickable('[data-test-pager="next"]'),
- prevPage: clickable('[data-test-pager="prev"]'),
-
- isEmpty: isPresent('[data-test-empty-volumes-list]'),
- emptyState: {
- headline: text('[data-test-empty-volumes-list-headline]'),
- },
-
- error: error(),
- pageSizeSelect: pageSizeSelect(),
-
- facets: {
- namespace: singleFacet('[data-test-namespace-facet]'),
- },
-});
diff --git a/ui/tests/unit/utils/route-redirector-test.js b/ui/tests/unit/utils/route-redirector-test.js
new file mode 100644
index 000000000..3a042df14
--- /dev/null
+++ b/ui/tests/unit/utils/route-redirector-test.js
@@ -0,0 +1,116 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { handleRouteRedirects } from 'nomad-ui/utils/route-redirector';
+import sinon from 'sinon';
+
+module('Unit | Utility | handle-route-redirects', function () {
+ test('it handles different types of redirects correctly', function (assert) {
+ assert.expect(7);
+
+ const router = {
+ replaceWith: sinon.spy(),
+ };
+
+ const testCases = [
+ {
+ name: 'exact match redirect',
+ transition: {
+ intent: { url: '/csi/volumes' },
+ to: { queryParams: { region: 'global' } },
+ },
+ expectedPath: '/storage/volumes',
+ expectedQueryParams: { region: 'global' },
+ },
+ {
+ name: 'pattern match redirect',
+ transition: {
+ intent: { url: '/csi/volumes/my-volume' },
+ to: { queryParams: { region: 'us-east' } },
+ },
+ expectedPath: '/storage/volumes/csi/my-volume',
+ expectedQueryParams: { region: 'us-east' },
+ },
+ {
+ name: 'startsWith redirect',
+ transition: {
+ intent: { url: '/csi' },
+ to: { queryParams: {} },
+ },
+ expectedPath: '/storage',
+ expectedQueryParams: {},
+ },
+ {
+ name: 'no redirect needed',
+ transition: {
+ intent: { url: '/jobs' },
+ to: { queryParams: {} },
+ },
+ expectedCalls: 0,
+ },
+ ];
+
+ testCases
+ .filter((testCase) => testCase.expectedCalls !== 0)
+ .forEach((testCase) => {
+ router.replaceWith.resetHistory();
+
+ handleRouteRedirects(testCase.transition, router);
+ assert.ok(
+ router.replaceWith.calledOnce,
+ `${testCase.name}: redirect occurred`
+ );
+ assert.ok(
+ router.replaceWith.calledWith(testCase.expectedPath, {
+ queryParams: testCase.expectedQueryParams,
+ }),
+ `${testCase.name}: redirected to correct path with query params`
+ );
+ });
+
+ testCases
+ .filter((testCase) => testCase.expectedCalls === 0)
+ .forEach((testCase) => {
+ router.replaceWith.resetHistory();
+
+ handleRouteRedirects(testCase.transition, router);
+ assert.notOk(
+ router.replaceWith.called,
+ `${testCase.name}: no redirect occurred`
+ );
+ });
+ });
+
+ test('it preserves query parameters during redirects', function (assert) {
+ const router = {
+ replaceWith: sinon.spy(),
+ };
+
+ const transition = {
+ intent: { url: '/csi/volumes' },
+ to: {
+ queryParams: {
+ region: 'global',
+ namespace: 'default',
+ foo: 'bar',
+ },
+ },
+ };
+
+ handleRouteRedirects(transition, router);
+
+ assert.ok(
+ router.replaceWith.calledWith('/storage/volumes', {
+ queryParams: {
+ region: 'global',
+ namespace: 'default',
+ foo: 'bar',
+ },
+ }),
+ 'All query parameters were preserved in the redirect'
+ );
+ });
+});