diff --git a/ui/app/controllers/storage/index.js b/ui/app/controllers/storage/index.js index 20f2e9463..e01cf386a 100644 --- a/ui/app/controllers/storage/index.js +++ b/ui/app/controllers/storage/index.js @@ -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); + } } diff --git a/ui/app/routes/storage/index.js b/ui/app/routes/storage/index.js index 88a17fb8f..d5775d543 100644 --- a/ui/app/routes/storage/index.js +++ b/ui/app/routes/storage/index.js @@ -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; } diff --git a/ui/app/templates/storage/index.hbs b/ui/app/templates/storage/index.hbs index eba893eff..ac5141ddb 100644 --- a/ui/app/templates/storage/index.hbs +++ b/ui/app/templates/storage/index.hbs @@ -32,7 +32,7 @@ {{#if this.isForbidden}} {{else}} - +

CSI Volumes

@@ -121,16 +121,14 @@ - {{#if (gt this.sortedCSIVolumes.length this.userSettings.pageSize)}} - - {{/if}} + {{else}}

{{#if this.csiFilter}} @@ -143,7 +141,7 @@ {{/if}} - +

Dynamic Host Volumes

@@ -162,7 +160,7 @@

{{#if this.sortedDynamicHostVolumes.length}} - {{#if (gt this.sortedDynamicHostVolumes.length this.userSettings.pageSize)}} - - {{/if}} + {{else}} -
+
{{#if this.dhvFilter}}

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

diff --git a/ui/tests/acceptance/storage-list-test.js b/ui/tests/acceptance/storage-list-test.js index 48b502974..857b2e53c 100644 --- a/ui/tests/acceptance/storage-list-test.js +++ b/ui/tests/acceptance/storage-list-test.js @@ -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'); + }); } }); diff --git a/ui/tests/pages/storage/list.js b/ui/tests/pages/storage/list.js index 59f3ea29b..58da1c292 100644 --- a/ui/tests/pages/storage/list.js +++ b/ui/tests/pages/storage/list.js @@ -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(),