Feature: Dynamic Host Volumes in the UI (#25224)

* DHV UI init

* /csi routes to /storage routes and a routeRedirector util (#25163)

* /csi routes to /storage routes and a routeRedirector util

* Tests and routes move csi/ to storage/

* Changelog added

* [ui] Storage UI overhaul + Dynamic Host Volumes UI (#25226)

* Storage index page and DHV model properties

* Naive version of a storage overview page

* Experimental fetch of alloc data dirs

* Fetch ephemeral disks and static host volumes as an ember concurrency task and nice table stylings

* Playing nice with section header labels to make eslint happy even though wcag was already cool with it

* inlined the storage type explainers and reordered things, plus tooltips and keynav

* Bones of a dynamic host volume individual page

* Woooo dynamic host volume model, adapter, and serializer with embedded alloc relationships

* Couple test fixes

* async:false relationship for dhv.hasMany('alloc') to prevent a ton of xhr requests

* DHV request type at index routemodel and better serialization

* Pagination and searching and query params oh my

* Test retrofits for csi volumes

* Really fantastic flake gets fixed

* DHV detail page acceptance test and a bunch of mirage hooks

* Seed so that the actions test has a guaranteed task

* removed ephemeral disk and static host volume manual scanning

* CapacityBytes and capabilities table added to DHV detail page

* Debugging actions flyout test

* was becoming clear that faker.seed editing was causing havoc elsewhere so might as well not boil the ocean and just tell this test to do what I want it to

* Post-create job gets taskCount instead of count

* CSI volumes now get /csi route prefix at detail level

* lazyclick method for unused keynav removed

* keyboard nav and table-watcher for DHV added

* Addressed PR comments, changed up capabilities table and id references, etc.

* Capabilities table for DHV and ID in details header

* Testfixes for pluginID and capabilities table on DHV page
This commit is contained in:
Phil Renaud
2025-03-10 14:46:02 -04:00
committed by GitHub
parent 8e56805fea
commit 1976202cd6
76 changed files with 1783 additions and 538 deletions

3
.changelog/25224.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: Added Dynamic Host Volumes to the web UI
```

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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,
]);
}
}

View File

@@ -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
);
}
}

View File

@@ -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,
]);
}

View File

@@ -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],
},
];
}

View File

@@ -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);
}
}

View File

@@ -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',

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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 () {

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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));
}
}

View File

@@ -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');
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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) => {

View File

@@ -61,3 +61,4 @@
@import './components/access-control';
@import './components/actions';
@import './components/jobs-list';
@import './components/storage';

View File

@@ -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";
}
}
}

View File

@@ -220,7 +220,7 @@
<td data-test-volume-client-source>
{{#if row.model.isCSI}}
<LinkTo
@route="csi.volumes.volume"
@route="storage.volumes.volume"
@model={{concat
(format-volume-name
source=row.model.source

View File

@@ -68,7 +68,7 @@
{{/if}}
<li {{keyboard-shortcut menuLevel=true pattern=(array "g" "r") }}>
<LinkTo
@route="csi"
@route="storage"
@activeClass="is-active"
data-test-gutter-link="storage"
>

View File

@@ -5,7 +5,7 @@
<div data-test-subnav="plugins" class="tabs is-subnav" {{did-insert this.keyboard.registerNav type="subnav"}} {{will-destroy this.keyboard.unregisterSubnav}}>
<ul>
<li data-test-tab="overview"><LinkTo @route="csi.plugins.plugin.index" @model={{@plugin}} @activeClass="is-active">Overview</LinkTo></li>
<li data-test-tab="allocations"><LinkTo @route="csi.plugins.plugin.allocations" @model={{@plugin}} @activeClass="is-active">Allocations</LinkTo></li>
<li data-test-tab="overview"><LinkTo @route="storage.plugins.plugin.index" @model={{@plugin}} @activeClass="is-active">Overview</LinkTo></li>
<li data-test-tab="allocations"><LinkTo @route="storage.plugins.plugin.allocations" @model={{@plugin}} @activeClass="is-active">Allocations</LinkTo></li>
</ul>
</div>

View File

@@ -5,14 +5,14 @@
<div class="tabs is-subnav" {{did-insert this.keyboard.registerNav type="subnav"}} {{will-destroy this.keyboard.unregisterSubnav}}>
<ul>
<li data-test-tab="volumes">
<LinkTo @route="csi.volumes.index" @activeClass="is-active">
Volumes
<li data-test-tab="overview">
<LinkTo @route="storage.index" @activeClass="is-active">
Overview
</LinkTo>
</li>
<li data-test-tab="plugins">
<LinkTo @route="csi.plugins.index" @activeClass="is-active">
Plugins
<LinkTo @route="storage.plugins.index" @activeClass="is-active">
CSI Plugins
</LinkTo>
</li>
</ul>

View File

@@ -47,7 +47,7 @@
</strong>
{{#if volume.isCSI}}
<LinkTo
@route="csi.volumes.volume"
@route="storage.volumes.volume"
@model={{concat
(format-volume-name
source=volume.source

View File

@@ -1,6 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label="Storage" args=(array "csi.index")}} />{{outlet}}

View File

@@ -1,6 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label="Storage" args=(array "csi.index")}} />{{outlet}}

View File

@@ -1,183 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{page-title "CSI Volumes"}}
<StorageSubnav />
<section class="section">
<div class="toolbar">
<div class="toolbar-item">
{{#if this.visibleVolumes.length}}
<SearchBox
data-test-volumes-search
@searchTerm={{mut this.searchTerm}}
@onChange={{action this.resetPagination}}
@placeholder="Search volumes..."
/>
{{/if}}
</div>
{{#if this.system.shouldShowNamespaces}}
<div class="toolbar-item is-right-aligned is-mobile-full-width">
<div class="button-bar">
<SingleSelectDropdown
data-test-namespace-facet
@label="Namespace"
@options={{this.optionsNamespaces}}
@selection={{this.qpNamespace}}
@onSelect={{action this.setFacetQueryParam "qpNamespace"}}
/>
</div>
</div>
{{/if}}
</div>
{{#if this.isForbidden}}
<ForbiddenMessage />
{{else if this.sortedVolumes}}
<ListPagination
@source={{this.sortedVolumes}}
@size={{this.pageSize}}
@page={{this.currentPage}} as |p|
>
<ListTable
@source={{p.list}}
@sortProperty={{this.sortProperty}}
@sortDescending={{this.sortDescending}}
@class="with-foot" as |t|
>
<t.head>
<t.sort-by @prop="name">
Name
</t.sort-by>
{{#if this.system.shouldShowNamespaces}}
<t.sort-by @prop="namespace.name">
Namespace
</t.sort-by>
{{/if}}
<t.sort-by @prop="schedulable">
Volume Health
</t.sort-by>
<t.sort-by @prop="controllersHealthyProportion">
Controller Health
</t.sort-by>
<t.sort-by @prop="nodesHealthyProportion">
Node Health
</t.sort-by>
<t.sort-by @prop="provider">
Provider
</t.sort-by>
<th>
# Allocs
</th>
</t.head>
<t.body @key="model.name" as |row|>
<tr
class="is-interactive"
data-test-volume-row
{{on "click" (action "gotoVolume" row.model)}}
>
<td data-test-volume-name
{{keyboard-shortcut
enumerated=true
action=(action "gotoVolume" row.model)
}}
>
<Tooltip @text={{row.model.plainId}}>
<LinkTo
@route="csi.volumes.volume"
@model={{row.model.idWithNamespace}}
class="is-primary"
>
{{row.model.name}}
</LinkTo>
</Tooltip>
</td>
{{#if this.system.shouldShowNamespaces}}
<td data-test-volume-namespace>
{{row.model.namespace.name}}
</td>
{{/if}}
<td data-test-volume-schedulable>
{{if row.model.schedulable "Schedulable" "Unschedulable"}}
</td>
<td data-test-volume-controller-health>
{{#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}}
<em class="is-faded">
Node Only
</em>
{{/if}}
</td>
<td data-test-volume-node-health>
{{if (gt row.model.nodesHealthy 0) "Healthy" "Unhealthy"}}
(
{{row.model.nodesHealthy}}
/
{{row.model.nodesExpected}}
)
</td>
<td data-test-volume-provider>
{{row.model.provider}}
</td>
<td data-test-volume-allocations>
{{row.model.allocationCount}}
</td>
</tr>
</t.body>
</ListTable>
<div class="table-foot">
<PageSizeSelect @onChange={{action this.resetPagination}} />
<nav class="pagination">
<div class="pagination-numbers">
{{p.startsAt}}
{{p.endsAt}}
of
{{this.sortedVolumes.length}}
</div>
<p.prev @class="pagination-previous">
{{x-icon "chevron-left"}}
</p.prev>
<p.next @class="pagination-next">
{{x-icon "chevron-right"}}
</p.next>
<ul class="pagination-list"></ul>
</nav>
</div>
</ListPagination>
{{else}}
<div data-test-empty-volumes-list class="empty-message">
{{#if (eq this.visibleVolumes.length 0)}}
<h3 data-test-empty-volumes-list-headline class="empty-message-headline">
No Volumes
</h3>
<p class="empty-message-body">
This namespace currently has no CSI Volumes.
</p>
{{else if this.searchTerm}}
<h3 data-test-empty-volumes-list-headline class="empty-message-headline">
No Matches
</h3>
<p class="empty-message-body">
No volumes match the term
<strong>
{{this.searchTerm}}
</strong>
</p>
{{/if}}
</div>
{{/if}}
</section>

View File

@@ -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}}
<LinkTo @route="csi.volumes.index" @query={{hash search=row.model.source}}>{{row.model.name}}</LinkTo>
<LinkTo @route="storage.volumes.index" @query={{hash search=row.model.source}}>{{row.model.name}}</LinkTo>
{{else}}
<LinkTo
@route="csi.volumes.volume"
@route="storage.volumes.volume"
@model={{concat row.model.source "@" row.model.namespace.id}}
>
{{row.model.name}}

View File

@@ -0,0 +1,252 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{page-title "Storage"}}
<Breadcrumb @crumb={{hash label="Storage" args=(array "storage.index")}} />{{outlet}}
<StorageSubnav />
<section class="section storage-index">
<Hds::PageHeader @title="Storage" as |PH|>
<PH.Actions>
{{#if this.system.shouldShowNamespaces}}
<Hds::Dropdown data-test-namespace-facet as |dd|>
<dd.ToggleButton @text="Namespace ({{this.qpNamespace}})" @color="secondary" />
{{#each this.optionsNamespaces as |option|}}
<dd.Radio
name={{option.key}}
{{on "change" (action (mut this.qpNamespace) option.key)}}
checked={{eq this.qpNamespace option.key}}
>
{{option.label}}
</dd.Radio>
{{/each}}
</Hds::Dropdown>
{{/if}}
</PH.Actions>
</Hds::PageHeader>
{{#if this.isForbidden}}
<ForbiddenMessage />
{{else}}
<Hds::Card::Container @level="base" @hasBorder={{false}} class="storage-index-table-card">
<header aria-label="CSI Volumes">
<h3>CSI Volumes</h3>
<p class="intro">
Storage configured by plugins run as Nomad jobs, with advanced features like snapshots and resizing.
<Hds::Link::Inline @href="https://developer.hashicorp.com/nomad/docs/other-specifications/volume/csi" @icon="docs-link" @iconPosition="trailing">Read more</Hds::Link::Inline>
</p>
<div class="search">
<Hds::Form::TextInput::Field
data-test-csi-volumes-search
@type="search"
@value={{this.csiFilter}}
placeholder="Search CSI Volumes"
{{on "input" (action this.applyFilter "csi")}}
/>
</div>
</header>
{{#if this.sortedCSIVolumes.length}}
<Hds::Table @caption="CSI Volumes"
@model={{this.paginatedCSIVolumes}}
@columns={{this.csiColumns}}
@sortBy={{this.csiSortProperty}}
@sortOrder={{if this.csiSortDescending "desc" "asc"}}
@onSort={{action this.handleSort "csi"}}
>
<:body as |B|>
<B.Tr data-test-csi-volume-row
{{keyboard-shortcut
enumerated=true
action=(action this.openCSI B.data)
}}
>
<B.Td data-test-csi-volume-name>
<LinkTo
@route="storage.volumes.volume"
@model={{B.data.idWithNamespace}}
class="is-primary"
>
{{B.data.plainId}}
</LinkTo>
</B.Td>
{{#if this.system.shouldShowNamespaces}}
<B.Td data-test-csi-volume-namespace>
{{B.data.namespace.name}}
</B.Td>
{{/if}}
<B.Td data-test-csi-volume-schedulable>
{{if B.data.schedulable "Schedulable" "Unschedulable"}}
</B.Td>
<B.Td data-test-csi-volume-controller-health>
{{#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}}
<em class="is-faded">
Node Only
</em>
{{/if}}
</B.Td>
<B.Td data-test-csi-volume-node-health>
{{if (gt B.data.nodesHealthy 0) "Healthy" "Unhealthy"}}
(
{{B.data.nodesHealthy}}
/
{{B.data.nodesExpected}}
)
</B.Td>
<B.Td data-test-csi-volume-plugin>
<LinkTo @route="storage.plugins.plugin" @model={{B.data.plugin.plainId}}>
{{B.data.plugin.plainId}}
</LinkTo>
</B.Td>
<B.Td data-test-csi-volume-allocations>
{{B.data.allocationCount}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
{{#if (gt this.sortedCSIVolumes.length this.userSettings.pageSize)}}
<Hds::Pagination::Numbered
@totalItems={{this.filteredCSIVolumes.length}}
@currentPage={{this.csiPage}}
@pageSizes={{this.pageSizes}}
@currentPageSize={{this.userSettings.pageSize}}
@onPageChange={{action this.handlePageChange "csi"}}
@onPageSizeChange={{action (mut this.userSettings.pageSize)}}
/>
{{/if}}
{{else}}
<div class="empty-message" data-test-empty-csi-volumes-list-headline>
{{#if this.csiFilter}}
<p>No CSI volumes match your search for "{{this.csiFilter}}"</p>
<Hds::Button @text="Clear search" @color="secondary" {{on "click" (queue (action (mut this.csiFilter) "") (action this.handlePageChange "csi" 1))}} />
{{else}}
<p>No CSI Volumes found</p>
{{/if}}
</div>
{{/if}}
</Hds::Card::Container>
<Hds::Card::Container @level="base" @hasBorder={{false}} class="storage-index-table-card">
<header aria-label="Dynamic Host Volumes">
<h3>Dynamic Host Volumes</h3>
<p class="intro">
Storage provisioned via plugin scripts on a particular client, modifiable without requiring client restart.
<Hds::Link::Inline @href="https://developer.hashicorp.com/nomad/docs/other-specifications/volume/host" @icon="docs-link" @iconPosition="trailing">Read more</Hds::Link::Inline>
</p>
<div class="search">
<Hds::Form::TextInput::Field
data-test-dynamic-host-volumes-search
@type="search"
@value={{this.dhvFilter}}
placeholder="Search Dynamic Host Volumes"
{{on "input" (action this.applyFilter "dhv")}}
/>
</div>
</header>
{{#if this.sortedDynamicHostVolumes.length}}
<Hds::Table @caption="Dynamic Host Volumes"
@model={{this.sortedDynamicHostVolumes}}
@columns={{this.dhvColumns}}
@sortBy={{this.dhvSortProperty}}
@sortOrder={{if this.dhvSortDescending "desc" "asc"}}
@onSort={{action this.handleSort "dhv"}}
>
<:body as |B|>
<B.Tr data-test-dhv-row
{{keyboard-shortcut
enumerated=true
action=(action this.openDHV B.data)
}}
>
<B.Td>
<LinkTo data-test-dhv-name={{B.data.name}} @route="storage.volumes.dynamic-host-volume"
@model={{B.data.idWithNamespace}}>
{{B.data.plainId}}
</LinkTo>
</B.Td>
<B.Td>
{{B.data.name}}
</B.Td>
{{#if this.system.shouldShowNamespaces}}
<B.Td>{{B.data.namespace}}</B.Td>
{{/if}}
<B.Td>
<LinkTo @route="clients.client" @model={{B.data.node.id}}>
{{B.data.node.name}}
</LinkTo>
</B.Td>
<B.Td>{{B.data.pluginID}}</B.Td>
<B.Td>{{B.data.state}}</B.Td>
<B.Td>
<span class="tooltip" aria-label="{{format-month-ts B.data.modifyTime}}">
{{moment-from-now B.data.modifyTime}}
</span>
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
{{#if (gt this.sortedDynamicHostVolumes.length this.userSettings.pageSize)}}
<Hds::Pagination::Numbered
@totalItems={{this.filteredDynamicHostVolumes.length}}
@currentPage={{this.dhvPage}}
@pageSizes={{this.pageSizes}}
@currentPageSize={{this.userSettings.pageSize}}
@onPageChange={{action this.handlePageChange "dhv"}}
@onPageSizeChange={{action (mut this.userSettings.pageSize)}}
/>
{{/if}}
{{else}}
<div class="empty-message">
{{#if this.dhvFilter}}
<p>No dynamic host volumes match your search for "{{this.dhvFilter}}"</p>
<Hds::Button @text="Clear search" @color="secondary" {{on "click" (queue (action (mut this.dhvFilter) "") (action this.handlePageChange "dhv" 1))}} />
{{else}}
<p>No Dynamic Host Volumes found</p>
{{/if}}
</div>
{{/if}}
</Hds::Card::Container>
<Hds::Card::Container @level="base" @hasBorder={{false}} class="info-panels storage-index-table-card">
<header aria-label="Other Storage Types">
<h3>Other Storage Types</h3>
</header>
<Hds::Alert @type="inline" @color="highlight" @icon="hard-drive" as |A|>
<A.Title>
Static Host Volumes
</A.Title>
<A.Description>
Defined in the Nomad agent's config file, best for infrequently changing storage
</A.Description>
<A.Button @color="secondary" @icon="arrow-right" @iconPosition="trailing" @text="Learn more" @href="https://developer.hashicorp.com/nomad/tutorials/stateful-workloads/stateful-workloads-host-volumes" />
</Hds::Alert>
<Hds::Alert @type="inline" @color="highlight" @icon="hard-drive" as |A|>
<A.Title>
Ephemeral Disks
</A.Title>
<A.Description>
Best-effort persistence, ideal for rebuildable data. Stored in the <code>/alloc/data</code> directory in a given allocation.
</A.Description>
<A.Button @color="secondary" @icon="arrow-right" @iconPosition="trailing" @text="Learn more" @href="https://developer.hashicorp.com/nomad/docs/operations/stateful-workloads#ephemeral-disks" />
</Hds::Alert>
</Hds::Card::Container>
{{/if}}
</section>

View File

@@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label="Storage" args=(array "storage.index")}} />{{outlet}}

View File

@@ -44,7 +44,7 @@
action=(action "gotoPlugin" row.model)
}}
>
<LinkTo @route="csi.plugins.plugin" @model={{row.model.plainId}} class="is-primary">{{row.model.plainId}}</LinkTo>
<LinkTo @route="storage.plugins.plugin" @model={{row.model.plainId}} class="is-primary">{{row.model.plainId}}</LinkTo>
</td>
<td data-test-plugin-controller-health>
{{#if row.model.controllerRequired}}

View File

@@ -129,7 +129,7 @@
<div class="boxed-section-foot">
<p class="pull-right">
<LinkTo
@route="csi.plugins.plugin.allocations"
@route="storage.plugins.plugin.allocations"
@model={{this.model}}
@query={{hash qpType=(qp-serialize (array "controller"))}}
data-test-go-to-controller-allocations>
@@ -180,7 +180,7 @@
<div class="boxed-section-foot">
<p class="pull-right">
<LinkTo
@route="csi.plugins.plugin.allocations"
@route="storage.plugins.plugin.allocations"
@model={{this.model}}
@query={{hash qpType=(qp-serialize (array "node"))}}
data-test-go-to-node-allocations>

View File

@@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Breadcrumb @crumb={{hash label="Storage" args=(array "storage.index")}} />{{outlet}}

View File

@@ -0,0 +1,119 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#each this.breadcrumbs as |crumb|}}
<Breadcrumb @crumb={{crumb}} />
{{/each}}
{{page-title "Dynamic Host Volume " this.model.name}}
<section class="section with-headspace">
<h1 class="title" data-test-title>{{this.model.name}}</h1>
<div class="boxed-section is-small">
<div class="boxed-section-body inline-definitions">
<span class="label">Volume Details</span>
<span class="pair" data-test-volume-id>
<span class="term">ID</span>
{{this.model.plainId}}
</span>
{{#if this.system.shouldShowNamespaces}}
<span class="pair" data-test-volume-namespace>
<span class="term">Namespace</span>
{{this.model.namespace}}
</span>
{{/if}}
<span class="pair" data-test-volume-node>
<span class="term">Client</span>
{{this.model.node.name}}
</span>
<span class="pair" data-test-volume-plugin>
<span class="term">Plugin</span>
{{this.model.pluginID}}
</span>
<span class="pair" data-test-volume-create-time>
<span class="term">Create Time</span>
<span class="tooltip" aria-label="{{format-month-ts this.model.createTime}}">
{{moment-from-now this.model.createTime}}
</span>
</span>
<span class="pair" data-test-volume-modify-time>
<span class="term">Modify Time</span>
<span class="tooltip" aria-label="{{format-month-ts this.model.modifyTime}}">
{{moment-from-now this.model.modifyTime}}
</span>
</span>
{{#if this.model.capacityBytes}}
<span class="pair" data-test-volume-capacity>
<span class="term">Capacity</span>
{{format-bytes this.model.capacityBytes}}
</span>
{{/if}}
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
Allocations
</div>
<div class="boxed-section-body {{if this.model.allocations.length "is-full-bleed"}}">
{{#if this.sortedAllocations.length}}
<ListTable
@source={{this.sortedAllocations}}
@class="with-foot" as |t|>
<t.head>
<th class="is-narrow"><span class="visually-hidden">Driver Health, Scheduling, and Preemption</span></th>
<th>ID</th>
<th>Created</th>
<th>Modified</th>
<th>Status</th>
<th>Client</th>
<th>Job</th>
<th>Version</th>
<th>CPU</th>
<th>Memory</th>
</t.head>
<t.body as |row|>
<AllocationRow
{{keyboard-shortcut
enumerated=true
action=(action "gotoAllocation" row.model)
}}
@data-test-allocation={{row.model.id}}
@allocation={{row.model}}
@context="volume"
@onClick={{action "gotoAllocation" row.model}} />
</t.body>
</ListTable>
{{else}}
<div class="empty-message" data-test-empty-allocations>
<h3 class="empty-message-headline" data-test-empty-allocations-headline>No Allocations</h3>
<p class="empty-message-body" data-test-empty-allocations-message>No allocations are making use of this volume.</p>
</div>
{{/if}}
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
Capabilities
</div>
<div class="boxed-section-body is-full-bleed">
<table class="table">
<thead>
<th>Access Mode</th>
<th>Attachment Mode</th>
</thead>
<tbody>
{{#each this.model.capabilities as |capability|}}
<tr data-test-capability-row>
<td data-test-capability-access-mode>{{capability.access_mode}}</td>
<td data-test-capability-attachment-mode>{{capability.attachment_mode}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</div>
</section>

View File

@@ -7,6 +7,7 @@
<Breadcrumb @crumb={{crumb}} />
{{/each}}
{{page-title "CSI Volume " this.model.name}}
{{!-- TODO: determine if /volumes/volume will just be CSI or if we ought to generalize it --}}
<section class="section with-headspace">
<h1 class="title" data-test-title>{{this.model.name}}</h1>
@@ -120,7 +121,7 @@
<div class="boxed-section">
<div class="boxed-section-head">
Constraints
Capabilities
</div>
<div class="boxed-section-body is-full-bleed">
<table class="table">

View File

@@ -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;
}
}
}

View File

@@ -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',
},
];

View File

@@ -656,11 +656,24 @@ 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, {}, '[]');
}
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) => {
@@ -669,6 +682,7 @@ export default function () {
? !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, {}, '[]');

View File

@@ -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,
});
}
},
});

View File

@@ -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' });

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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')
);
});
}
);

View File

@@ -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];
}

View File

@@ -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}`
);
});
}

View File

@@ -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'])}`
);
});
});

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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'
);
});

View File

@@ -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();

View File

@@ -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);

View File

@@ -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]'),
}),
});

View File

@@ -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]'),
},
});

View File

@@ -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]'),

View File

@@ -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'),

View File

@@ -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"]'),

View File

@@ -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]'),

View File

@@ -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]'),
},
});

View File

@@ -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'
);
});
});