mirror of
https://github.com/kemko/nomad.git
synced 2026-01-01 16:05:42 +03:00
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:
3
.changelog/25224.txt
Normal file
3
.changelog/25224.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
```release-note:improvement
|
||||
ui: Added Dynamic Host Volumes to the web UI
|
||||
```
|
||||
24
ui/app/adapters/dynamic-host-volume.js
Normal file
24
ui/app/adapters/dynamic-host-volume.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
241
ui/app/controllers/storage/index.js
Normal file
241
ui/app/controllers/storage/index.js
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
];
|
||||
}
|
||||
53
ui/app/controllers/storage/volumes/dynamic-host-volume.js
Normal file
53
ui/app/controllers/storage/volumes/dynamic-host-volume.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
39
ui/app/models/dynamic-host-volume.js
Normal file
39
ui/app/models/dynamic-host-volume.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
30
ui/app/routes/storage/volumes/dynamic-host-volume.js
Normal file
30
ui/app/routes/storage/volumes/dynamic-host-volume.js
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
79
ui/app/serializers/dynamic-host-volume.js
Normal file
79
ui/app/serializers/dynamic-host-volume.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -61,3 +61,4 @@
|
||||
@import './components/access-control';
|
||||
@import './components/actions';
|
||||
@import './components/jobs-list';
|
||||
@import './components/storage';
|
||||
|
||||
65
ui/app/styles/components/storage.scss
Normal file
65
ui/app/styles/components/storage.scss
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<Breadcrumb @crumb={{hash label="Storage" args=(array "csi.index")}} />{{outlet}}
|
||||
@@ -1,6 +0,0 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
~}}
|
||||
|
||||
<Breadcrumb @crumb={{hash label="Storage" args=(array "csi.index")}} />{{outlet}}
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
|
||||
252
ui/app/templates/storage/index.hbs
Normal file
252
ui/app/templates/storage/index.hbs
Normal 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>
|
||||
6
ui/app/templates/storage/plugins.hbs
Normal file
6
ui/app/templates/storage/plugins.hbs
Normal 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}}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
6
ui/app/templates/storage/volumes.hbs
Normal file
6
ui/app/templates/storage/volumes.hbs
Normal 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}}
|
||||
119
ui/app/templates/storage/volumes/dynamic-host-volume.hbs
Normal file
119
ui/app/templates/storage/volumes/dynamic-host-volume.hbs
Normal 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>
|
||||
@@ -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">
|
||||
43
ui/app/utils/route-redirector.js
Normal file
43
ui/app/utils/route-redirector.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
ui/app/utils/route-redirects.js
Normal file
36
ui/app/utils/route-redirects.js
Normal 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',
|
||||
},
|
||||
];
|
||||
@@ -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, {}, '[]');
|
||||
|
||||
64
ui/mirage/factories/dynamic-host-volume.js
Normal file
64
ui/mirage/factories/dynamic-host-volume.js
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
24
ui/mirage/serializers/dynamic-host-volume.js
Normal file
24
ui/mirage/serializers/dynamic-host-volume.js
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
236
ui/tests/acceptance/dynamic-host-volume-detail-test.js
Normal file
236
ui/tests/acceptance/dynamic-host-volume-detail-test.js
Normal 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')
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'])}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
40
ui/tests/pages/storage/dynamic-host-volumes/detail.js
Normal file
40
ui/tests/pages/storage/dynamic-host-volumes/detail.js
Normal 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]'),
|
||||
}),
|
||||
});
|
||||
60
ui/tests/pages/storage/list.js
Normal file
60
ui/tests/pages/storage/list.js
Normal 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]'),
|
||||
},
|
||||
});
|
||||
@@ -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]'),
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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"]'),
|
||||
|
||||
@@ -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]'),
|
||||
|
||||
|
||||
@@ -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]'),
|
||||
},
|
||||
});
|
||||
116
ui/tests/unit/utils/route-redirector-test.js
Normal file
116
ui/tests/unit/utils/route-redirector-test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user