From 7b2a56edbefe09d1aa646ce528191a71a4e08b37 Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Tue, 17 Mar 2026 14:49:35 +0530 Subject: [PATCH 01/12] Allow region to be passed in for token requests --- ui/app/adapters/token.js | 9 +++++++-- ui/app/services/token.js | 8 ++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/ui/app/adapters/token.js b/ui/app/adapters/token.js index 4b10a670f07..e4830fa52cc 100644 --- a/ui/app/adapters/token.js +++ b/ui/app/adapters/token.js @@ -47,13 +47,18 @@ export default class TokenAdapter extends ApplicationAdapter { return `${this.buildURL()}/${singularize(modelName)}/${identifier}`; } - async findSelf() { + async findSelf(regionOverride = null) { // the application adapter automatically adds the region parameter to all requests, // but only if the /regions endpoint has been resolved first. Since this request is async, // we can ensure that the regions are loaded before making the token/self request. await this.system.regions; - const response = await this.ajax(`${this.buildURL()}/token/self`, 'GET'); + const options = regionOverride ? { regionOverride } : {}; + const response = await this.ajax( + `${this.buildURL()}/token/self`, + 'GET', + options + ); const normalized = this.store.normalize('token', response); const tokenRecord = this.store.push(normalized); return tokenRecord; diff --git a/ui/app/services/token.js b/ui/app/services/token.js index 531736494cc..984f269b624 100644 --- a/ui/app/services/token.js +++ b/ui/app/services/token.js @@ -40,10 +40,10 @@ export default class TokenService extends Service { } } - @task(function* () { + @task(function* (regionOverride = null) { const TokenAdapter = getOwner(this).lookup('adapter:token'); try { - var token = yield TokenAdapter.findSelf(); + var token = yield TokenAdapter.findSelf(regionOverride); if (token.accessor === 'acls-disabled') { this.set('aclEnabled', false); return null; @@ -102,8 +102,8 @@ export default class TokenService extends Service { @alias('fetchSelfTokenPolicies.lastSuccessful.value') selfTokenPolicies; - @task(function* () { - yield this.fetchSelfToken.perform(); + @task(function* (regionOverride = null) { + yield this.fetchSelfToken.perform(regionOverride); this.kickoffTokenTTLMonitoring(); if (this.aclEnabled) { yield this.fetchSelfTokenPolicies.perform(); From da955e2a3433ab73691a1f8d9827f4fb4236d98e Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Tue, 17 Mar 2026 14:52:24 +0530 Subject: [PATCH 02/12] Do not update active region prematurely and pass the changed region in token requests --- ui/app/components/region-switcher.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ui/app/components/region-switcher.js b/ui/app/components/region-switcher.js index 3a5098676c9..0f6a5eb1aea 100644 --- a/ui/app/components/region-switcher.js +++ b/ui/app/components/region-switcher.js @@ -21,10 +21,8 @@ export default class RegionSwitcher extends Component { } async gotoRegion(region) { - // Note: redundant but as long as we're using PowerSelect, the implicit set('activeRegion') - // is not something we can await, so we do it explicitly here. - this.system.set('activeRegion', region); - await this.get('token.fetchSelfTokenAndPolicies').perform().catch(); + // Fetch token for the new region before transitioning + await this.get('token.fetchSelfTokenAndPolicies').perform(region).catch(); this.router.transitionTo({ queryParams: { region } }); } From 94b2fdb480752ec202c0b666c0b7a9a479418e4d Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Tue, 17 Mar 2026 16:03:50 +0530 Subject: [PATCH 03/12] Fix - When region changes, properly reload data by cancelling watchers, clearing store and refreshing routes --- ui/app/routes/application.js | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index e32615277e0..5d3b903d513 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -92,16 +92,35 @@ export default class ApplicationRoute extends Route { const defaultRegion = this.get('system.defaultRegion.region'); const currentRegion = this.get('system.activeRegion') || defaultRegion; - // Only reset the store if the region actually changed - if ( + // Check if region actually changed + const regionChanged = (queryParam && queryParam !== currentRegion) || - (!queryParam && currentRegion !== defaultRegion) - ) { - this.store.unloadAll(); - } + (!queryParam && currentRegion !== defaultRegion); + // Update the active region - the adapter will use this for all API requests this.set('system.activeRegion', queryParam || defaultRegion); + // If region changed, cancel watchers, clear store, and refresh + if (regionChanged) { + later(() => { + // Cancel all watchers on the current route to prevent polling old region + const currentRoute = this.router.currentRoute; + if ( + currentRoute && + currentRoute.handler && + currentRoute.handler.cancelAllWatchers + ) { + currentRoute.handler.cancelAllWatchers(); + } + + // Clear the store to remove stale data + this.store.unloadAll(); + + // Refresh to reload with new region data + this.refresh(); + }, 0); + } + return promises; } From e5045dcd24a65906fe65efcde18083e83c2714a4 Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Mon, 23 Mar 2026 09:58:20 +0530 Subject: [PATCH 04/12] Added a method to cancel all stats trackers --- ui/app/services/stats-trackers-registry.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ui/app/services/stats-trackers-registry.js b/ui/app/services/stats-trackers-registry.js index 1d4f810ecfa..3d8f6155fc7 100644 --- a/ui/app/services/stats-trackers-registry.js +++ b/ui/app/services/stats-trackers-registry.js @@ -67,4 +67,21 @@ export default class StatsTrackersRegistryService extends Service { return tracker; } + + // utility method to cancel all active stats polling tasks across all trackers in the registry. + cancelAll() { + registry.forEach((tracker) => { + // Cancel the poll task if it exists and is running + if (tracker.poll && typeof tracker.poll.cancelAll === 'function') { + tracker.poll.cancelAll(); + } + // Cancel the signalPause task if it exists + if ( + tracker.signalPause && + typeof tracker.signalPause.cancelAll === 'function' + ) { + tracker.signalPause.cancelAll(); + } + }); + } } From cb8e57849ef4b274a4a2761c2f38356718049b4d Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Mon, 23 Mar 2026 09:58:39 +0530 Subject: [PATCH 05/12] Clear all stats trackers as well on region change --- ui/app/routes/application.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 5d3b903d513..0663f70a7ca 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -111,6 +111,9 @@ export default class ApplicationRoute extends Route { currentRoute.handler.cancelAllWatchers ) { currentRoute.handler.cancelAllWatchers(); + + // Cancel all stats trackers as well + this.get('stats-trackers-registry').cancelAll(); } // Clear the store to remove stale data From a82d6c27f6db68ff3769eef2b9215af47734358e Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Tue, 24 Mar 2026 00:03:14 +0530 Subject: [PATCH 06/12] Removed store unload and unncessary cancelling of watchers to refresh the routes only if region changed and is query param only transition --- ui/app/routes/application.js | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 0663f70a7ca..ec54c83f248 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -19,7 +19,6 @@ import { handleRouteRedirects } from '../utils/route-redirector'; export default class ApplicationRoute extends Route { @service config; @service system; - @service store; @service token; @service router; @@ -91,35 +90,15 @@ export default class ApplicationRoute extends Route { const queryParam = transition.to.queryParams.region; const defaultRegion = this.get('system.defaultRegion.region'); const currentRegion = this.get('system.activeRegion') || defaultRegion; - - // Check if region actually changed - const regionChanged = - (queryParam && queryParam !== currentRegion) || - (!queryParam && currentRegion !== defaultRegion); + const nextRegion = queryParam || defaultRegion; + const regionChanged = nextRegion !== currentRegion; // Update the active region - the adapter will use this for all API requests - this.set('system.activeRegion', queryParam || defaultRegion); + this.set('system.activeRegion', nextRegion); - // If region changed, cancel watchers, clear store, and refresh - if (regionChanged) { + // Refresh for child routes if region changes and only if query-param-only transitions + if (regionChanged && transition.queryParamsOnly) { later(() => { - // Cancel all watchers on the current route to prevent polling old region - const currentRoute = this.router.currentRoute; - if ( - currentRoute && - currentRoute.handler && - currentRoute.handler.cancelAllWatchers - ) { - currentRoute.handler.cancelAllWatchers(); - - // Cancel all stats trackers as well - this.get('stats-trackers-registry').cancelAll(); - } - - // Clear the store to remove stale data - this.store.unloadAll(); - - // Refresh to reload with new region data this.refresh(); }, 0); } From 81b51bc94babf78b60b9cb3bb56a93de5f10f858 Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Tue, 24 Mar 2026 00:09:41 +0530 Subject: [PATCH 07/12] Reload allocations and evaluations data to avoid possible stale data during region switch --- ui/app/routes/jobs/job.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js index de6f17cbcd5..e5e36288b91 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -42,8 +42,8 @@ export default class JobRoute extends Route.extend(WithWatchers) { .findRecord('job', fullId, { reload: true }) .then((job) => { const relatedModelsQueries = [ - job.get('allocations'), - job.get('evaluations'), + job.hasMany('allocations').reload(), + job.hasMany('evaluations').reload(), this.store.findAll('namespace'), ]; From 32bac188089c1b4f36c42f27eea3ee8e6ae50959 Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Tue, 24 Mar 2026 00:10:44 +0530 Subject: [PATCH 08/12] Reload parent job data when loading an allocation to prevent stale data from region switch --- ui/app/routes/allocations/allocation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/app/routes/allocations/allocation.js b/ui/app/routes/allocations/allocation.js index bce60374747..99e2cb42a26 100644 --- a/ui/app/routes/allocations/allocation.js +++ b/ui/app/routes/allocations/allocation.js @@ -54,7 +54,7 @@ export default class AllocationRoute extends Route.extend(WithWatchers) { await allocation.reload(); } const jobId = allocation.belongsTo('job').id(); - await this.store.findRecord('job', jobId); + await this.store.findRecord('job', jobId, { reload: true }); return allocation; } catch (e) { const [allocId, transition] = arguments; From f3b3a1950c459d2743c4d0a28336f7033df5ea83 Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Thu, 30 Apr 2026 09:48:44 +0530 Subject: [PATCH 09/12] Fix failing variables-link test caused by explicit job reload by ensuring task-linked variables are loaded and the link rerenders correctly. --- ui/app/models/task.js | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/ui/app/models/task.js b/ui/app/models/task.js index 4697a35851e..d99ff727bfe 100644 --- a/ui/app/models/task.js +++ b/ui/app/models/task.js @@ -10,7 +10,7 @@ import { fragmentArray, fragmentOwner, } from 'ember-data-model-fragments/attributes'; -import { computed } from '@ember/object'; +import { computed, notifyPropertyChange } from '@ember/object'; export default class Task extends Fragment { @fragmentOwner() taskGroup; @@ -61,6 +61,19 @@ export default class Task extends Fragment { @fragmentArray('volume-mount', { defaultValue: () => [] }) volumeMounts; + _pathLinkedVariableJobID() { + let jobID = this._job.plainId; + const parentID = this._job.belongsTo('parent').id() + ? JSON.parse(this._job.belongsTo('parent').id())[0] + : null; + + if (parentID) { + jobID = parentID; + } + + return jobID; + } + async _fetchParentJob() { let job = this.store.peekRecord('job', this.taskGroup.job.id); if (!job) { @@ -69,6 +82,9 @@ export default class Task extends Fragment { }); } this._job = job; + await this._job.variables; + // pathLinkedVariable is consumed by a sync template condition, so notify after async load. + notifyPropertyChange(this, 'pathLinkedVariable'); } get pathLinkedVariable() { @@ -76,10 +92,7 @@ export default class Task extends Fragment { this._fetchParentJob(); return null; } else { - let jobID = this._job.plainId; - if (this._job.parent.get('plainId')) { - jobID = this._job.parent.get('plainId'); - } + let jobID = this._pathLinkedVariableJobID(); return this._job.variables?.findBy( 'path', `nomad/jobs/${jobID}/${this.taskGroup.name}/${this.name}` @@ -93,14 +106,7 @@ export default class Task extends Fragment { await this._fetchParentJob(); } await this._job.variables; - let jobID = this._job.plainId; - // not getting plainID because we dont know the resolution status of the task's job's parent yet - let parentID = this._job.belongsTo('parent').id() - ? JSON.parse(this._job.belongsTo('parent').id())[0] - : null; - if (parentID) { - jobID = parentID; - } + let jobID = this._pathLinkedVariableJobID(); return await this._job.variables?.findBy( 'path', `nomad/jobs/${jobID}/${this.taskGroup.name}/${this.name}` From 0d7a37a568faa20837cb3267bbbb9d3ba640fd33 Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Thu, 30 Apr 2026 15:14:46 +0530 Subject: [PATCH 10/12] Updated the test to stop asserting against generated JOB_JSON and instead assert against fields from the Mirage-created job loaded by the test. --- ui/tests/acceptance/job-definition-test.js | 34 ++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/ui/tests/acceptance/job-definition-test.js b/ui/tests/acceptance/job-definition-test.js index bf0281b4ae0..68b4a491359 100644 --- a/ui/tests/acceptance/job-definition-test.js +++ b/ui/tests/acceptance/job-definition-test.js @@ -12,7 +12,6 @@ import { setupMirage } from 'ember-cli-mirage/test-support'; import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; import setupCodeMirror from 'nomad-ui/tests/helpers/codemirror'; import Definition from 'nomad-ui/tests/pages/jobs/job/definition'; -import { JOB_JSON } from 'nomad-ui/tests/utils/generate-raw-json-job'; let job; @@ -154,7 +153,7 @@ module('Acceptance | job definition | full specification', function (hooks) { }); test('it allows users to select between full specification and JSON definition', async function (assert) { - assert.expect(3); + assert.expect(7); const specification_response = { Format: 'hcl2', JobID: 'example', @@ -166,7 +165,7 @@ module('Acceptance | job definition | full specification', function (hooks) { Variables: '', Version: 0, }; - server.get('/job/:id', () => JOB_JSON); + server.get('/job/:id/submission', () => specification_response); await Definition.visit({ id: job.id }); @@ -174,7 +173,7 @@ module('Acceptance | job definition | full specification', function (hooks) { assert .dom('[data-test-select="job-spec"]') - .exists('A select button exists and defaults to full definition'); + .exists('A select button exists and defaults to job spec'); let codeMirror = getCodeMirrorInstance('[data-test-editor]'); assert.equal( codeMirror.getValue(), @@ -184,6 +183,31 @@ module('Acceptance | job definition | full specification', function (hooks) { await click('[data-test-select-full]'); codeMirror = getCodeMirrorInstance('[data-test-editor]'); - assert.propContains(JSON.parse(codeMirror.getValue()), JOB_JSON); + const fullDefinition = JSON.parse(codeMirror.getValue()); + assert + .dom('[data-test-select="full-definition"]') + .exists('View switches to full definition mode'); + assert.equal( + fullDefinition.ID, + job.id, + 'Full definition shows the correct job ID' + ); + assert.equal( + fullDefinition.Name, + job.name, + 'Full definition shows the correct job name' + ); + assert.ok( + fullDefinition.TaskGroups, + 'Full definition includes task groups' + ); + + await click('[data-test-select="full-definition"] button:first-child'); + codeMirror = getCodeMirrorInstance('[data-test-editor]'); + assert.equal( + codeMirror.getValue(), + specification_response.Source, + 'Switching back to job spec restores the original specification source' + ); }); }); From 26d75e34836cb99d96577b93c822928968fd8217 Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Sun, 3 May 2026 16:13:58 +0530 Subject: [PATCH 11/12] Added acceptance coverage to verify region switches reload jobs and job-detail data with the selected region. --- ui/tests/acceptance/regions-test.js | 109 ++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/ui/tests/acceptance/regions-test.js b/ui/tests/acceptance/regions-test.js index 06ebdf57b0e..8115456d040 100644 --- a/ui/tests/acceptance/regions-test.js +++ b/ui/tests/acceptance/regions-test.js @@ -22,6 +22,7 @@ module('Acceptance | regions (only one)', function (hooks) { setupMirage(hooks); hooks.beforeEach(function () { + window.localStorage.clear(); server.create('agent'); server.create('node-pool'); server.create('node'); @@ -106,7 +107,11 @@ module('Acceptance | regions (many)', function (hooks) { }); test('the region switcher is rendered in the nav bar and the region is in the page title', async function (assert) { + let managementToken = server.create('token'); + window.localStorage.nomadTokenSecret = managementToken.secretId; + await JobsList.visit(); + await settled(); assert.ok( Layout.navbar.regionSwitcher.isPresent, @@ -147,6 +152,110 @@ module('Acceptance | regions (many)', function (hooks) { ); }); + test('switching regions on a query-param-only transition refreshes the active route model', async function (assert) { + const newRegion = server.db.regions[1].id; + + await JobsList.visit(); + + const jobsRequestsBeforeSwitch = server.pretender.handledRequests.filter( + (request) => request.url.includes('/v1/jobs') + ).length; + + await selectChoose('[data-test-region-switcher-parent]', newRegion); + await settled(); + + const jobsRequestsAfterSwitch = server.pretender.handledRequests.filter( + (request) => request.url.includes('/v1/jobs') + ); + + assert.ok( + jobsRequestsAfterSwitch.length > jobsRequestsBeforeSwitch, + 'Jobs model request is issued again after region query-param switch' + ); + + assert.ok( + jobsRequestsAfterSwitch + .slice(jobsRequestsBeforeSwitch) + .some((request) => request.url.includes(`region=${newRegion}`)), + 'Refreshed jobs request uses the selected region' + ); + }); + + test('switching regions on job detail reloads job, allocations, and evaluations', async function (assert) { + const newRegion = server.db.regions[1].id; + + await JobsList.visit(); + const jobId = JobsList.jobs.objectAt(0).id; + await JobsList.jobs.objectAt(0).clickRow(); + await settled(); + + const isForJobPath = (request, path) => { + const url = new URL(request.url, window.location.origin); + return url.pathname === `/v1/job/${jobId}${path}`; + }; + + const jobRequestsBeforeSwitch = server.pretender.handledRequests.filter( + (request) => isForJobPath(request, '') + ).length; + + const allocationRequestsBeforeSwitch = + server.pretender.handledRequests.filter((request) => + isForJobPath(request, '/allocations') + ).length; + + const evaluationRequestsBeforeSwitch = + server.pretender.handledRequests.filter((request) => + isForJobPath(request, '/evaluations') + ).length; + + await selectChoose('[data-test-region-switcher-parent]', newRegion); + await settled(); + + const jobRequestsAfterSwitch = server.pretender.handledRequests.filter( + (request) => isForJobPath(request, '') + ); + const allocationRequestsAfterSwitch = + server.pretender.handledRequests.filter((request) => + isForJobPath(request, '/allocations') + ); + const evaluationRequestsAfterSwitch = + server.pretender.handledRequests.filter((request) => + isForJobPath(request, '/evaluations') + ); + + assert.ok( + jobRequestsAfterSwitch.length > jobRequestsBeforeSwitch, + 'Job record is fetched again after switching regions on job detail' + ); + assert.ok( + allocationRequestsAfterSwitch.length > allocationRequestsBeforeSwitch, + 'Job allocations are fetched again after switching regions on job detail' + ); + assert.ok( + evaluationRequestsAfterSwitch.length > evaluationRequestsBeforeSwitch, + 'Job evaluations are fetched again after switching regions on job detail' + ); + + assert.ok( + jobRequestsAfterSwitch + .slice(jobRequestsBeforeSwitch) + .some((request) => request.url.includes(`region=${newRegion}`)), + 'Refetched job request includes selected region' + ); + assert.ok( + allocationRequestsAfterSwitch + .slice(allocationRequestsBeforeSwitch) + .some((request) => request.url.includes(`region=${newRegion}`)), + 'Refetched allocations request includes selected region' + ); + assert.ok( + evaluationRequestsAfterSwitch + .slice(evaluationRequestsBeforeSwitch) + .some((request) => request.url.includes(`region=${newRegion}`)), + 'Refetched evaluations request includes selected region' + ); + }); + test('switching regions to the default region, unsets the region query param', async function (assert) { let managementToken = server.create('token'); window.localStorage.nomadTokenSecret = managementToken.secretId; From db3a659092570e97330ecd93cd5cc8ffb1a0741b Mon Sep 17 00:00:00 2001 From: Arun Stanislavose Date: Sun, 3 May 2026 16:22:59 +0530 Subject: [PATCH 12/12] remove unused method --- ui/app/services/stats-trackers-registry.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/ui/app/services/stats-trackers-registry.js b/ui/app/services/stats-trackers-registry.js index 3d8f6155fc7..1d4f810ecfa 100644 --- a/ui/app/services/stats-trackers-registry.js +++ b/ui/app/services/stats-trackers-registry.js @@ -67,21 +67,4 @@ export default class StatsTrackersRegistryService extends Service { return tracker; } - - // utility method to cancel all active stats polling tasks across all trackers in the registry. - cancelAll() { - registry.forEach((tracker) => { - // Cancel the poll task if it exists and is running - if (tracker.poll && typeof tracker.poll.cancelAll === 'function') { - tracker.poll.cancelAll(); - } - // Cancel the signalPause task if it exists - if ( - tracker.signalPause && - typeof tracker.signalPause.cancelAll === 'function' - ) { - tracker.signalPause.cancelAll(); - } - }); - } }