[ui] Custom watchQuery equivalent on the storage index (#25374)

* Custom watchQuery equivalent on the storage index

* Tests for live updates to the storage page

* Deconditionalizing the pagination on storage, and fixing a bug where I was looking at filtered but not paginated DHV

* Test for pagination with live-updates
This commit is contained in:
Phil Renaud
2025-03-19 11:38:01 -04:00
committed by GitHub
parent 13b95b7685
commit 3370d9cb96
5 changed files with 367 additions and 47 deletions

View File

@@ -8,6 +8,10 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import Controller from '@ember/controller';
import { scheduleOnce } from '@ember/runloop';
import { restartableTask, timeout } from 'ember-concurrency';
import Ember from 'ember';
const TASK_THROTTLE = 1000;
export default class IndexController extends Controller {
@service router;
@@ -144,11 +148,12 @@ export default class IndexController extends Controller {
// Filter, then Sort, then Paginate
// all handled client-side
@tracked csiVolumes = this.model.csiVolumes;
get filteredCSIVolumes() {
if (!this.csiFilter) {
return this.model.csiVolumes;
return this.csiVolumes;
} else {
return this.model.csiVolumes.filter((volume) => {
return this.csiVolumes.filter((volume) => {
return (
volume.plainId.toLowerCase().includes(this.csiFilter.toLowerCase()) ||
volume.name.toLowerCase().includes(this.csiFilter.toLowerCase())
@@ -172,11 +177,12 @@ export default class IndexController extends Controller {
);
}
@tracked dynamicHostVolumes = this.model.dynamicHostVolumes;
get filteredDynamicHostVolumes() {
if (!this.dhvFilter) {
return this.model.dynamicHostVolumes;
return this.dynamicHostVolumes;
} else {
return this.model.dynamicHostVolumes.filter((volume) => {
return this.dynamicHostVolumes.filter((volume) => {
return (
volume.plainId.toLowerCase().includes(this.dhvFilter.toLowerCase()) ||
volume.name.toLowerCase().includes(this.dhvFilter.toLowerCase())
@@ -238,4 +244,79 @@ export default class IndexController extends Controller {
dhv.idWithNamespace
);
}
@restartableTask *watchDHV(
params,
throttle = Ember.testing ? 0 : TASK_THROTTLE
) {
while (true) {
const abortController = new AbortController();
try {
const result = yield this.store.query('dynamic-host-volume', params, {
reload: true,
adapterOptions: {
watch: true,
abortController: abortController,
},
});
this.dynamicHostVolumes = result;
} catch (e) {
console.error('Error fetching dynamic host volumes:', e);
yield timeout(throttle);
} finally {
abortController.abort();
}
yield timeout(throttle);
if (Ember.testing) {
break;
}
}
}
@restartableTask *watchCSI(
params,
throttle = Ember.testing ? 0 : TASK_THROTTLE
) {
while (true) {
const abortController = new AbortController();
try {
const result = yield this.store.query('volume', params, {
reload: true,
adapterOptions: {
watch: true,
abortController: abortController,
},
});
this.csiVolumes = result;
} catch (e) {
console.error('Error fetching CSI volumes:', e);
yield timeout(throttle);
} finally {
abortController.abort();
}
yield timeout(throttle);
if (Ember.testing) {
break;
}
}
}
@action
cancelQueryWatch() {
this.watchDHV.cancelAll();
this.watchCSI.cancelAll();
}
// (called from route)
@action
startQueryWatch(dhvQuery, csiQuery) {
this.watchDHV.perform(dhvQuery.queryParams);
this.watchCSI.perform(csiQuery.queryParams);
}
}

View File

@@ -9,10 +9,11 @@ import { inject as service } from '@ember/service';
import RSVP from 'rsvp';
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
import { watchQuery, watchAll } from 'nomad-ui/utils/properties/watch';
import { watchAll } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
import { action } from '@ember/object';
export default class IndexRoute extends Route.extend(
WithWatchers,
@@ -43,25 +44,32 @@ export default class IndexRoute extends Route.extend(
startWatchers(controller) {
controller.set('namespacesWatch', this.watchNamespaces.perform());
controller.set(
'modelWatch',
this.watchVolumes.perform({
type: 'csi',
namespace: controller.qpNamespace,
})
);
controller.set(
'modelWatch',
this.watchDynamicHostVolumes.perform({
type: 'host',
namespace: controller.qpNamespace,
})
controller.startQueryWatch(
{
queryType: 'dynamic-host-volume',
queryParams: {
type: 'host',
namespace: controller.qpNamespace,
},
},
{
queryType: 'volume',
queryParams: {
type: 'csi',
namespace: controller.qpNamespace,
},
}
);
}
@watchQuery('volume') watchVolumes;
@watchQuery('dynamic-host-volume') watchDynamicHostVolumes;
@action
willTransition() {
// eslint-disable-next-line
this.controller.cancelQueryWatch();
this.cancelAllWatchers();
}
@watchAll('namespace') watchNamespaces;
@collect('watchVolumes', 'watchNamespaces', 'watchDynamicHostVolumes')
@collect('watchNamespaces')
watchers;
}

View File

@@ -32,7 +32,7 @@
{{#if this.isForbidden}}
<ForbiddenMessage />
{{else}}
<Hds::Card::Container @level="base" @hasBorder={{false}} class="storage-index-table-card">
<Hds::Card::Container @level="base" @hasBorder={{false}} class="storage-index-table-card" data-test-csi-volumes-card>
<header aria-label="CSI Volumes">
<h3>CSI Volumes</h3>
<p class="intro">
@@ -121,16 +121,14 @@
</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}}
<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)}}
/>
{{else}}
<div class="empty-message" data-test-empty-csi-volumes-list-headline>
{{#if this.csiFilter}}
@@ -143,7 +141,7 @@
{{/if}}
</Hds::Card::Container>
<Hds::Card::Container @level="base" @hasBorder={{false}} class="storage-index-table-card">
<Hds::Card::Container @level="base" @hasBorder={{false}} class="storage-index-table-card" data-test-dynamic-host-volumes-card>
<header aria-label="Dynamic Host Volumes">
<h3>Dynamic Host Volumes</h3>
<p class="intro">
@@ -162,7 +160,7 @@
</header>
{{#if this.sortedDynamicHostVolumes.length}}
<Hds::Table @caption="Dynamic Host Volumes"
@model={{this.sortedDynamicHostVolumes}}
@model={{this.paginatedDynamicHostVolumes}}
@columns={{this.dhvColumns}}
@sortBy={{this.dhvSortProperty}}
@sortOrder={{if this.dhvSortDescending "desc" "asc"}}
@@ -202,18 +200,16 @@
</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}}
<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)}}
/>
{{else}}
<div class="empty-message">
<div class="empty-message" data-test-empty-dhv-list-headline>
{{#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))}} />

View File

@@ -277,5 +277,226 @@ module('Acceptance | storage list', function (hooks) {
'URL has the correct query param key and value'
);
});
module('Live updates are reflected in the list', function () {
test('When you visit the storage list page, the watch process is kicked off', async function (assert) {
await StorageList.visit();
const requests = server.pretender.handledRequests;
const dhvRequests = requests.filter((request) =>
request.url.startsWith('/v1/volumes?namespace=%2A&type=host')
);
const csiRequests = requests.filter((request) =>
request.url.startsWith('/v1/volumes?namespace=%2A&type=csi')
);
assert.equal(dhvRequests.length, 2, '2 DHV requests were made');
assert.equal(csiRequests.length, 2, '2 CSI requests were made');
});
test('When a new dynamic host volume is created, the page should reflect the changes', async function (assert) {
server.create('dynamic-host-volume', {
name: 'initial-volume',
});
const controller = this.owner.lookup('controller:storage.index');
await visit('/storage');
// Check pretender to see 2 requests related to DHV: the initial one, and another one with an index on it
const requests = server.pretender.handledRequests;
// Should be 2 DHV requests made: the initial one, and the watcher
let dhvRequests = requests.filter((request) =>
request.url.startsWith('/v1/volumes?namespace=%2A&type=host')
);
assert.equal(dhvRequests.length, 2, '2 DHV requests were made');
assert.dom('[data-test-dhv-row]').exists({ count: 1 });
assert.dom('[data-test-dhv-row]').containsText('initial-volume');
server.create('dynamic-host-volume', {
name: 'new-volume',
});
await controller.watchDHV.perform({
type: 'host',
namespace: controller.qpNamespace,
});
// Now there should be a third DHV request
dhvRequests = requests.filter((request) =>
request.url.startsWith('/v1/volumes?namespace=%2A&type=host')
);
assert.equal(dhvRequests.length, 3, '3 DHV requests were made');
// and a second row
assert.dom('[data-test-dhv-row]').exists({ count: 2 });
});
test('When a new csi volume is created, the page should reflect the changes', async function (assert) {
server.create('csi-volume', {
id: 'initial-volume',
});
const controller = this.owner.lookup('controller:storage.index');
await visit('/storage');
// Check pretender to see 2 requests related to DHV: the initial one, and another one with an index on it
const requests = server.pretender.handledRequests;
// Should be 2 DHV requests made: the initial one, and the watcher
let csiRequests = requests.filter((request) =>
request.url.startsWith('/v1/volumes?namespace=%2A&type=csi')
);
assert.equal(csiRequests.length, 2, '2 CSI requests were made');
assert.dom('[data-test-csi-volume-row]').exists({ count: 1 });
assert.dom('[data-test-csi-volume-row]').containsText('initial-volume');
server.create('csi-volume', {
id: 'new-volume',
});
await controller.watchCSI.perform({
type: 'csi',
namespace: controller.qpNamespace,
});
// Now there should be a third DHV request
csiRequests = requests.filter((request) =>
request.url.startsWith('/v1/volumes?namespace=%2A&type=csi')
);
assert.equal(csiRequests.length, 3, '3 CSI requests were made');
// and a second row
assert.dom('[data-test-csi-volume-row]').exists({ count: 2 });
});
test('When a dynamic host volume is updated, the page should reflect the changes', async function (assert) {
const dhv = server.create('dynamic-host-volume', {
name: 'initial-volume',
});
const controller = this.owner.lookup('controller:storage.index');
await visit('/storage');
// Check pretender to see 2 requests related to DHV: the initial one, and another one with an index on it
const requests = server.pretender.handledRequests;
// Should be 2 DHV requests made: the initial one, and the watcher
let dhvRequests = requests.filter((request) =>
request.url.startsWith('/v1/volumes?namespace=%2A&type=host')
);
assert.equal(dhvRequests.length, 2, '2 DHV requests were made');
assert.dom('[data-test-dhv-row]').exists({ count: 1 });
assert.dom('[data-test-dhv-row]').containsText('initial-volume');
dhv.update('name', 'updated-volume');
await controller.watchDHV.perform({
type: 'host',
namespace: controller.qpNamespace,
});
dhvRequests = requests.filter((request) =>
request.url.startsWith('/v1/volumes?namespace=%2A&type=host')
);
assert.equal(dhvRequests.length, 3, '3 DHV requests were made');
// Still just one row
assert.dom('[data-test-dhv-row]').exists({ count: 1 });
assert.dom('[data-test-dhv-row]').containsText('updated-volume');
});
test('When a dynamic host volume is deleted, the page should reflect the changes', async function (assert) {
const dhv = server.create('dynamic-host-volume', {
name: 'initial-volume',
});
const controller = this.owner.lookup('controller:storage.index');
await visit('/storage');
// Check pretender to see 2 requests related to DHV: the initial one, and another one with an index on it
const requests = server.pretender.handledRequests;
// Should be 2 DHV requests made: the initial one, and the watcher
let dhvRequests = requests.filter((request) =>
request.url.startsWith('/v1/volumes?namespace=%2A&type=host')
);
assert.equal(dhvRequests.length, 2, '2 DHV requests were made');
assert.dom('[data-test-dhv-row]').exists({ count: 1 });
assert.dom('[data-test-dhv-row]').containsText('initial-volume');
dhv.destroy();
await controller.watchDHV.perform({
type: 'host',
namespace: controller.qpNamespace,
});
dhvRequests = requests.filter((request) =>
request.url.startsWith('/v1/volumes?namespace=%2A&type=host')
);
assert.equal(dhvRequests.length, 3, '3 DHV requests were made');
assert.dom('[data-test-dhv-row]').exists({ count: 0 });
assert.ok(StorageList.dhvIsEmpty);
});
});
test('Pagination is adhered to when live updates happen', async function (assert) {
localStorage.setItem('nomadPageSize', 10);
server.createList('dynamic-host-volume', 9);
const controller = this.owner.lookup('controller:storage.index');
await StorageList.visit();
// 9 rows should be present
assert.dom('[data-test-dhv-row]').exists({ count: 9 });
server.create('dynamic-host-volume', { name: 'tenth-volume' });
await controller.watchDHV.perform({
type: 'host',
namespace: controller.qpNamespace,
});
// 10 rows should be present
assert.dom('[data-test-dhv-row]').exists({ count: 10 });
// Newest (sorted by modified date by default) should show up first
assert
.dom('[data-test-dhv-row]:first-child')
.containsText('tenth-volume');
// There should still only be 1 page of pagination
assert.dom('.hds-pagination-nav__number').exists({ count: 1 });
// 10 rows should be present
assert.dom('[data-test-dhv-row]').exists({ count: 10 });
// Create one more
server.create('dynamic-host-volume', { name: 'eleventh-volume' });
await controller.watchDHV.perform({
type: 'host',
namespace: controller.qpNamespace,
});
// 10 rows still present
assert.dom('[data-test-dhv-row]').exists({ count: 10 });
// Newest should show up first
assert
.dom('[data-test-dhv-row]:first-child')
.containsText('eleventh-volume');
// There should now be 2 pages of pagination
assert.dom('.hds-pagination-nav__number').exists({ count: 2 });
// Clicking through to the second page changes the URL and only shows 1 row
await StorageList.dhvNextPage();
assert.equal(currentURL(), '/storage?dhvPage=2');
// 1 row should be present
assert.dom('[data-test-dhv-row]').exists({ count: 1 });
// cleanup
localStorage.removeItem('nomadPageSize');
});
}
});

View File

@@ -48,8 +48,22 @@ export default create({
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'),
dhvIsEmpty: isPresent('[data-test-empty-dhv-list-headline]'),
dhvEmptyState: text('[data-test-empty-dhv-list-headline]'),
csiNextPage: clickable(
'[data-test-csi-volumes-card] .hds-pagination-nav__arrow--direction-next'
),
csiPrevPage: clickable(
'[data-test-csi-volumes-card] .hds-pagination-nav__arrow--direction-prev'
),
dhvNextPage: clickable(
'[data-test-dynamic-host-volumes-card] .hds-pagination-nav__arrow--direction-next'
),
dhvPrevPage: clickable(
'[data-test-dynamic-host-volumes-card] .hds-pagination-nav__arrow--direction-prev'
),
error: error(),
pageSizeSelect: pageSizeSelect(),