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 @@ {{#if row.model.isCSI}} diff --git a/ui/app/templates/components/plugin-subnav.hbs b/ui/app/templates/components/plugin-subnav.hbs index 2a84d1dfd..05a496480 100644 --- a/ui/app/templates/components/plugin-subnav.hbs +++ b/ui/app/templates/components/plugin-subnav.hbs @@ -5,7 +5,7 @@
    -
  • Overview
  • -
  • Allocations
  • +
  • Overview
  • +
  • Allocations
diff --git a/ui/app/templates/components/storage-subnav.hbs b/ui/app/templates/components/storage-subnav.hbs index e14c97aa6..91fb2eee4 100644 --- a/ui/app/templates/components/storage-subnav.hbs +++ b/ui/app/templates/components/storage-subnav.hbs @@ -5,14 +5,14 @@
    -
  • - - Volumes +
  • + + Overview
  • - - Plugins + + CSI Plugins
diff --git a/ui/app/templates/components/task-row.hbs b/ui/app/templates/components/task-row.hbs index 838297a68..78d863b23 100644 --- a/ui/app/templates/components/task-row.hbs +++ b/ui/app/templates/components/task-row.hbs @@ -47,7 +47,7 @@ {{#if volume.isCSI}} {{/if}} {{/if}} - \ No newline at end of file + diff --git a/ui/app/templates/csi/plugins.hbs b/ui/app/templates/csi/plugins.hbs deleted file mode 100644 index f2c6f7ee9..000000000 --- a/ui/app/templates/csi/plugins.hbs +++ /dev/null @@ -1,6 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - -{{outlet}} \ No newline at end of file diff --git a/ui/app/templates/csi/volumes.hbs b/ui/app/templates/csi/volumes.hbs deleted file mode 100644 index f2c6f7ee9..000000000 --- a/ui/app/templates/csi/volumes.hbs +++ /dev/null @@ -1,6 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - -{{outlet}} \ No newline at end of file diff --git a/ui/app/templates/csi/volumes/index.hbs b/ui/app/templates/csi/volumes/index.hbs deleted file mode 100644 index bb50661d2..000000000 --- a/ui/app/templates/csi/volumes/index.hbs +++ /dev/null @@ -1,183 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - -{{page-title "CSI Volumes"}} - -
-
-
- {{#if this.visibleVolumes.length}} - - {{/if}} -
- {{#if this.system.shouldShowNamespaces}} -
-
- -
-
- {{/if}} -
- {{#if this.isForbidden}} - - {{else if this.sortedVolumes}} - - - - - Name - - {{#if this.system.shouldShowNamespaces}} - - Namespace - - {{/if}} - - Volume Health - - - Controller Health - - - Node Health - - - Provider - - - # Allocs - - - - - - - - {{row.model.name}} - - - - {{#if this.system.shouldShowNamespaces}} - - {{row.model.namespace.name}} - - {{/if}} - - {{if row.model.schedulable "Schedulable" "Unschedulable"}} - - - {{#if row.model.controllerRequired}} - {{if (gt row.model.controllersHealthy 0) "Healthy" "Unhealthy"}} - ( - {{row.model.controllersHealthy}} - / - {{row.model.controllersExpected}} - ) - {{else if (gt row.model.controllersExpected 0)}} - {{if (gt row.model.controllersHealthy 0) "Healthy" "Unhealthy"}} - ( - {{row.model.controllersHealthy}} - / - {{row.model.controllersExpected}} - ) - {{else}} - - Node Only - - {{/if}} - - - {{if (gt row.model.nodesHealthy 0) "Healthy" "Unhealthy"}} - ( - {{row.model.nodesHealthy}} - / - {{row.model.nodesExpected}} - ) - - - {{row.model.provider}} - - - {{row.model.allocationCount}} - - - - -
- - -
-
- {{else}} -
- {{#if (eq this.visibleVolumes.length 0)}} -

- No Volumes -

-

- This namespace currently has no CSI Volumes. -

- {{else if this.searchTerm}} -

- No Matches -

-

- No volumes match the term - - {{this.searchTerm}} - -

- {{/if}} -
- {{/if}} -
diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index 88fdb6e22..4b85aee57 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -332,10 +332,10 @@ {{#if row.model.isCSI}} {{!-- if volume is per_alloc=true, there's no one specific volume. So, link to the volumes index with an active query --}} {{#if row.model.perAlloc}} - {{row.model.name}} + {{row.model.name}} {{else}} {{row.model.name}} diff --git a/ui/app/templates/csi.hbs b/ui/app/templates/storage.hbs similarity index 100% rename from ui/app/templates/csi.hbs rename to ui/app/templates/storage.hbs diff --git a/ui/app/templates/storage/index.hbs b/ui/app/templates/storage/index.hbs new file mode 100644 index 000000000..eba893eff --- /dev/null +++ b/ui/app/templates/storage/index.hbs @@ -0,0 +1,252 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{page-title "Storage"}} + +{{outlet}} + + +
+ + + + {{#if this.system.shouldShowNamespaces}} + + + {{#each this.optionsNamespaces as |option|}} + + {{option.label}} + + {{/each}} + + {{/if}} + + + + {{#if this.isForbidden}} + + {{else}} + +
+

CSI Volumes

+

+ Storage configured by plugins run as Nomad jobs, with advanced features like snapshots and resizing. + Read more +

+ +
+ {{#if this.sortedCSIVolumes.length}} + + <:body as |B|> + + + + {{B.data.plainId}} + + + {{#if this.system.shouldShowNamespaces}} + + {{B.data.namespace.name}} + + {{/if}} + + {{if B.data.schedulable "Schedulable" "Unschedulable"}} + + + {{#if B.data.controllerRequired}} + {{if (gt B.data.controllersHealthy 0) "Healthy" "Unhealthy"}} + ( + {{B.data.controllersHealthy}} + / + {{B.data.controllersExpected}} + ) + {{else if (gt B.data.controllersExpected 0)}} + {{if (gt B.data.controllersHealthy 0) "Healthy" "Unhealthy"}} + ( + {{B.data.controllersHealthy}} + / + {{B.data.controllersExpected}} + ) + {{else}} + + Node Only + + {{/if}} + + + {{if (gt B.data.nodesHealthy 0) "Healthy" "Unhealthy"}} + ( + {{B.data.nodesHealthy}} + / + {{B.data.nodesExpected}} + ) + + + + {{B.data.plugin.plainId}} + + + + {{B.data.allocationCount}} + + + + + {{#if (gt this.sortedCSIVolumes.length this.userSettings.pageSize)}} + + {{/if}} + {{else}} +
+ {{#if this.csiFilter}} +

No CSI volumes match your search for "{{this.csiFilter}}"

+ + {{else}} +

No CSI Volumes found

+ {{/if}} +
+ {{/if}} +
+ + +
+

Dynamic Host Volumes

+

+ Storage provisioned via plugin scripts on a particular client, modifiable without requiring client restart. + Read more +

+ +
+ {{#if this.sortedDynamicHostVolumes.length}} + + <:body as |B|> + + + + {{B.data.plainId}} + + + + {{B.data.name}} + + {{#if this.system.shouldShowNamespaces}} + {{B.data.namespace}} + {{/if}} + + + {{B.data.node.name}} + + + {{B.data.pluginID}} + {{B.data.state}} + + + {{moment-from-now B.data.modifyTime}} + + + + + + {{#if (gt this.sortedDynamicHostVolumes.length this.userSettings.pageSize)}} + + {{/if}} + {{else}} +
+ {{#if this.dhvFilter}} +

No dynamic host volumes match your search for "{{this.dhvFilter}}"

+ + {{else}} +

No Dynamic Host Volumes found

+ {{/if}} +
+ {{/if}} +
+ + +
+

Other Storage Types

+
+ + + Static Host Volumes + + + Defined in the Nomad agent's config file, best for infrequently changing storage + + + + + + Ephemeral Disks + + + Best-effort persistence, ideal for rebuildable data. Stored in the /alloc/data directory in a given allocation. + + + +
+ + {{/if}} +
diff --git a/ui/app/templates/storage/plugins.hbs b/ui/app/templates/storage/plugins.hbs new file mode 100644 index 000000000..01b9ee821 --- /dev/null +++ b/ui/app/templates/storage/plugins.hbs @@ -0,0 +1,6 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{outlet}} diff --git a/ui/app/templates/csi/plugins/index.hbs b/ui/app/templates/storage/plugins/index.hbs similarity index 96% rename from ui/app/templates/csi/plugins/index.hbs rename to ui/app/templates/storage/plugins/index.hbs index 082b41a51..545e91dbb 100644 --- a/ui/app/templates/csi/plugins/index.hbs +++ b/ui/app/templates/storage/plugins/index.hbs @@ -44,7 +44,7 @@ action=(action "gotoPlugin" row.model) }} > - {{row.model.plainId}} + {{row.model.plainId}} {{#if row.model.controllerRequired}} diff --git a/ui/app/templates/csi/plugins/plugin.hbs b/ui/app/templates/storage/plugins/plugin.hbs similarity index 100% rename from ui/app/templates/csi/plugins/plugin.hbs rename to ui/app/templates/storage/plugins/plugin.hbs diff --git a/ui/app/templates/csi/plugins/plugin/allocations.hbs b/ui/app/templates/storage/plugins/plugin/allocations.hbs similarity index 100% rename from ui/app/templates/csi/plugins/plugin/allocations.hbs rename to ui/app/templates/storage/plugins/plugin/allocations.hbs diff --git a/ui/app/templates/csi/plugins/plugin/index.hbs b/ui/app/templates/storage/plugins/plugin/index.hbs similarity index 98% rename from ui/app/templates/csi/plugins/plugin/index.hbs rename to ui/app/templates/storage/plugins/plugin/index.hbs index 1c2b7748e..8b64fab14 100644 --- a/ui/app/templates/csi/plugins/plugin/index.hbs +++ b/ui/app/templates/storage/plugins/plugin/index.hbs @@ -129,7 +129,7 @@

@@ -180,7 +180,7 @@

diff --git a/ui/app/templates/storage/volumes.hbs b/ui/app/templates/storage/volumes.hbs new file mode 100644 index 000000000..01b9ee821 --- /dev/null +++ b/ui/app/templates/storage/volumes.hbs @@ -0,0 +1,6 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{outlet}} diff --git a/ui/app/templates/storage/volumes/dynamic-host-volume.hbs b/ui/app/templates/storage/volumes/dynamic-host-volume.hbs new file mode 100644 index 000000000..baaddcc48 --- /dev/null +++ b/ui/app/templates/storage/volumes/dynamic-host-volume.hbs @@ -0,0 +1,119 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{#each this.breadcrumbs as |crumb|}} + +{{/each}} +{{page-title "Dynamic Host Volume " this.model.name}} +

+

{{this.model.name}}

+ +
+
+ Volume Details + + ID + {{this.model.plainId}} + + {{#if this.system.shouldShowNamespaces}} + + Namespace + {{this.model.namespace}} + + {{/if}} + + Client + {{this.model.node.name}} + + + Plugin + {{this.model.pluginID}} + + + Create Time + + {{moment-from-now this.model.createTime}} + + + + Modify Time + + {{moment-from-now this.model.modifyTime}} + + + {{#if this.model.capacityBytes}} + + Capacity + {{format-bytes this.model.capacityBytes}} + + {{/if}} +
+
+ +
+
+ Allocations +
+
+ {{#if this.sortedAllocations.length}} + + + Driver Health, Scheduling, and Preemption + ID + Created + Modified + Status + Client + Job + Version + CPU + Memory + + + + + + {{else}} +
+

No Allocations

+

No allocations are making use of this volume.

+
+ {{/if}} +
+
+ +
+
+ Capabilities +
+
+ + + + + + + {{#each this.model.capabilities as |capability|}} + + + + + {{/each}} + +
Access ModeAttachment Mode
{{capability.access_mode}}{{capability.attachment_mode}}
+
+
+ +
diff --git a/ui/app/templates/csi/volumes/volume.hbs b/ui/app/templates/storage/volumes/volume.hbs similarity index 96% rename from ui/app/templates/csi/volumes/volume.hbs rename to ui/app/templates/storage/volumes/volume.hbs index 146d78556..73aec6eda 100644 --- a/ui/app/templates/csi/volumes/volume.hbs +++ b/ui/app/templates/storage/volumes/volume.hbs @@ -7,6 +7,7 @@ {{/each}} {{page-title "CSI Volume " this.model.name}} +{{!-- TODO: determine if /volumes/volume will just be CSI or if we ought to generalize it --}}

{{this.model.name}}

@@ -57,7 +58,7 @@
- Constraints + Capabilities
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' + ); + }); +});