From ccae1fb1d63c649b7c4743e7c496721acce04e32 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 2 Aug 2018 15:54:57 -0700 Subject: [PATCH 01/32] Bare minimum Mirage support for regions --- ui/mirage/config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 55293e57452..fa7c1f6a1a1 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -222,6 +222,10 @@ export default function() { return new Response(403, {}, null); }); + this.get('/regions', function() { + return JSON.stringify(['global']); + }); + const clientAllocationStatsHandler = function({ clientAllocationStats }, { params }) { return this.serialize(clientAllocationStats.find(params.id)); }; From dc1a031460333a6508d0dbb7438b2325b6bdbc11 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 2 Aug 2018 15:56:11 -0700 Subject: [PATCH 02/32] Add three-way region property (query param, service, localStorage) --- ui/app/controllers/application.js | 20 +++++++++++++++++ ui/app/routes/application.js | 22 +++++++++++++++++++ ui/app/services/system.js | 36 +++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/ui/app/controllers/application.js b/ui/app/controllers/application.js index fc3c5e58e92..69d3bcbff25 100644 --- a/ui/app/controllers/application.js +++ b/ui/app/controllers/application.js @@ -7,6 +7,16 @@ import codesForError from '../utils/codes-for-error'; export default Controller.extend({ config: service(), + system: service(), + + queryParams: { + region: 'region', + }, + + region: 'global', + + syncRegionService: forwardRegion('region', 'system.activeRegion'), + syncRegionParam: forwardRegion('system.activeRegion', 'region'), error: null, @@ -43,3 +53,13 @@ export default Controller.extend({ } }), }); + +function forwardRegion(source, destination) { + return observer(source, function() { + const newRegion = this.get(source); + const currentRegion = this.get(destination); + if (currentRegion !== newRegion) { + this.set(destination, newRegion); + } + }); +} diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 469e76a0af9..c4eea77f0ac 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -1,9 +1,11 @@ import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; +import { next } from '@ember/runloop'; import { AbortError } from 'ember-data/adapters/errors'; export default Route.extend({ config: service(), + system: service(), resetController(controller, isExiting) { if (isExiting) { @@ -11,6 +13,26 @@ export default Route.extend({ } }, + beforeModel() { + return this.get('system.regions'); + }, + + syncToController(controller) { + const region = this.get('system.activeRegion'); + + // The run next is necessary to let the controller figure + // itself out before updating QPs. + // See: https://github.com/emberjs/ember.js/issues/5465 + next(() => { + controller.set('region', region || 'global'); + }); + }, + + setupController(controller) { + this.syncToController(controller); + return this._super(...arguments); + }, + actions: { didTransition() { if (!this.get('config.isTest')) { diff --git a/ui/app/services/system.js b/ui/app/services/system.js index cb0e84b9a58..93a1744f669 100644 --- a/ui/app/services/system.js +++ b/ui/app/services/system.js @@ -1,6 +1,7 @@ import Service, { inject as service } from '@ember/service'; import { computed } from '@ember/object'; import PromiseObject from '../utils/classes/promise-object'; +import PromiseArray from '../utils/classes/promise-array'; import { namespace } from '../adapters/application'; export default Service.extend({ @@ -23,6 +24,41 @@ export default Service.extend({ }); }), + regions: computed(function() { + const token = this.get('token'); + + return PromiseArray.create({ + promise: token.authorizedRequest(`/${namespace}/regions`).then(res => res.json()), + }); + }), + + activeRegion: computed('regions.[]', { + get() { + const regions = this.get('regions'); + const region = window.localStorage.nomadActiveRegion; + + if (regions.includes(region)) { + return region; + } + + // If the region in localStorage is no longer in the cluster, it needs to + // be cleared from localStorage + this.set('activeRegion', null); + return null; + }, + set(key, value) { + if (value == null) { + window.localStorage.removeItem('nomadActiveRegion'); + } else { + // All localStorage values are strings. Stringify first so + // the return value is consistent with what is persisted. + const strValue = value + ''; + window.localStorage.nomadActiveRegion = strValue; + return strValue; + } + }, + }), + namespaces: computed(function() { return this.get('store').findAll('namespace'); }), From 27308a884c362f5e4e74f4cf6f089192fef978cc Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 3 Aug 2018 10:11:47 -0700 Subject: [PATCH 03/32] Styles for the region switcher --- ui/app/styles/app.scss | 5 +-- ui/app/styles/components.scss | 1 + ui/app/styles/components/dropdown.scss | 35 +++++++++++++++++++ ui/app/styles/core/navbar.scss | 9 +++++ ui/app/templates/components/global-header.hbs | 12 ++++++- 5 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 ui/app/styles/components/dropdown.scss diff --git a/ui/app/styles/app.scss b/ui/app/styles/app.scss index 952709aee32..73ece329ef7 100644 --- a/ui/app/styles/app.scss +++ b/ui/app/styles/app.scss @@ -1,8 +1,9 @@ @import './core'; -@import './components'; -@import './charts'; @import 'ember-power-select'; +@import './components'; +@import './charts'; + // Only necessary in dev @import './styleguide.scss'; diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index be9bf7c74c1..93bbf906e0c 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -2,6 +2,7 @@ @import './components/badge'; @import './components/boxed-section'; @import './components/cli-window'; +@import './components/dropdown'; @import './components/ember-power-select'; @import './components/empty-message'; @import './components/error-container'; diff --git a/ui/app/styles/components/dropdown.scss b/ui/app/styles/components/dropdown.scss new file mode 100644 index 00000000000..3b59d6950bf --- /dev/null +++ b/ui/app/styles/components/dropdown.scss @@ -0,0 +1,35 @@ +.ember-power-select-trigger { + padding: 0.3em 16px 0.3em 0.3em; + border-radius: $radius; + box-shadow: $button-box-shadow-standard; + + &.is-outlined { + border-color: rgba($white, 0.5); + color: $white; + background: transparent; + box-shadow: $button-box-shadow-standard, 0 0 2px 2px rgba($black, 0.1); + + .ember-power-select-status-icon { + border-top-color: rgba($white, 0.75); + } + + .ember-power-select-prefix { + color: rgba($white, 0.75); + } + } +} + +.ember-power-select-selected-item { + text-overflow: ellipsis; + white-space: nowrap; +} + +.ember-power-select-prefix { + color: $grey; +} + +.ember-power-select-option { + .ember-power-select-prefix { + display: none; + } +} diff --git a/ui/app/styles/core/navbar.scss b/ui/app/styles/core/navbar.scss index 4ab73cfe8b5..6d1926cde21 100644 --- a/ui/app/styles/core/navbar.scss +++ b/ui/app/styles/core/navbar.scss @@ -75,6 +75,15 @@ &.is-gutter { width: $gutter-width; + display: block; + padding: 0 1rem; + font-size: 1em; + + // Unfortunate necessity to middle align an element larger than + // plain text in the subnav. + > * { + margin: -5px 0; + } @media #{$mq-hidden-gutter} { width: 0; diff --git a/ui/app/templates/components/global-header.hbs b/ui/app/templates/components/global-header.hbs index 50cb7752eef..c371031213d 100644 --- a/ui/app/templates/components/global-header.hbs +++ b/ui/app/templates/components/global-header.hbs @@ -16,7 +16,17 @@ diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index 3985b9f0008..a306f510616 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -25,7 +25,7 @@ Summary {{/t.head}} {{#t.body key="model.id" as |row|}} - {{job-row data-test-job-row job=row.model onClick=(action "gotoJob" row.model)}} + {{job-row data-test-job-row=row.model.plainId job=row.model onClick=(action "gotoJob" row.model)}} {{/t.body}} {{/list-table}}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 9a419ba7157..0311a8d94eb 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -166,8 +166,9 @@ export default function() { }); this.get('/agent/members', function({ agents, regions }) { + const firstRegion = regions.first(); return { - ServerRegion: regions.first().id, + ServerRegion: firstRegion ? firstRegion.id : null, Members: this.serialize(agents.all()), }; }); diff --git a/ui/tests/acceptance/regions-test.js b/ui/tests/acceptance/regions-test.js new file mode 100644 index 00000000000..9fae4d6a3f6 --- /dev/null +++ b/ui/tests/acceptance/regions-test.js @@ -0,0 +1,210 @@ +import { test } from 'qunit'; +import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance'; +import JobsList from 'nomad-ui/tests/pages/jobs/list'; +import ClientsList from 'nomad-ui/tests/pages/clients/list'; +import PageLayout from 'nomad-ui/tests/pages/layout'; +import Allocation from 'nomad-ui/tests/pages/allocations/detail'; + +moduleForAcceptance('Acceptance | regions (only one)', { + beforeEach() { + server.create('agent'); + server.create('node'); + server.createList('job', 5); + }, +}); + +test('when there is only one region, the region switcher is not shown in the nav bar', function(assert) { + server.create('region', { id: 'global' }); + + andThen(() => { + JobsList.visit(); + }); + + andThen(() => { + assert.notOk(PageLayout.navbar.regionSwitcher.isPresent, 'No region switcher'); + }); +}); + +test('when the only region is not named "global", the region switcher still is not shown', function(assert) { + server.create('region', { id: 'some-region' }); + + andThen(() => { + JobsList.visit(); + }); + + andThen(() => { + assert.notOk(PageLayout.navbar.regionSwitcher.isPresent, 'No region switcher'); + }); +}); + +test('pages do not include the region query param', function(assert) { + let jobId; + + server.create('region', { id: 'global' }); + + andThen(() => { + JobsList.visit(); + }); + andThen(() => { + assert.equal(currentURL(), '/jobs', 'No region query param'); + }); + andThen(() => { + jobId = JobsList.jobs.objectAt(0).id; + JobsList.jobs.objectAt(0).clickRow(); + }); + andThen(() => { + assert.equal(currentURL(), `/jobs/${jobId}`, 'No region query param'); + }); + andThen(() => { + ClientsList.visit(); + }); + andThen(() => { + assert.equal(currentURL(), '/clients', 'No region query param'); + }); +}); + +test('api requests do not include the region query param', function(assert) { + server.create('region', { id: 'global' }); + + andThen(() => { + JobsList.visit(); + }); + andThen(() => { + JobsList.jobs.objectAt(0).clickRow(); + }); + andThen(() => { + PageLayout.gutter.visitClients(); + }); + andThen(() => { + PageLayout.gutter.visitServers(); + }); + andThen(() => { + server.pretender.handledRequests.forEach(req => { + assert.notOk(req.url.includes('region='), req.url); + }); + }); +}); + +moduleForAcceptance('Acceptance | regions (many)', { + beforeEach() { + server.create('agent'); + server.create('node'); + server.createList('job', 5); + server.create('region', { id: 'global' }); + server.create('region', { id: 'region-2' }); + }, +}); + +test('the region switcher is rendered in the nav bar', function(assert) { + JobsList.visit(); + + andThen(() => { + assert.ok(PageLayout.navbar.regionSwitcher.isPresent, 'Region switcher is shown'); + }); +}); + +test('when on the default region, pages do not include the region query param', function(assert) { + JobsList.visit(); + + andThen(() => { + assert.equal(currentURL(), '/jobs', 'No region query param'); + assert.equal(window.localStorage.nomadActiveRegion, 'global', 'Region in localStorage'); + }); +}); + +test('switching regions sets localStorage and the region query param', function(assert) { + const newRegion = server.db.regions[1].id; + + JobsList.visit(); + + selectChoose('[data-test-region-switcher]', newRegion); + + andThen(() => { + assert.ok( + currentURL().includes(`region=${newRegion}`), + 'New region is the region query param value' + ); + assert.equal(window.localStorage.nomadActiveRegion, newRegion, 'New region in localStorage'); + }); +}); + +test('switching regions to the default region, unsets the region query param', function(assert) { + const startingRegion = server.db.regions[1].id; + const defaultRegion = server.db.regions[0].id; + + JobsList.visit({ region: startingRegion }); + + selectChoose('[data-test-region-switcher]', defaultRegion); + + andThen(() => { + assert.equal(currentURL(), '/jobs', 'No region query param for the default region'); + assert.equal( + window.localStorage.nomadActiveRegion, + defaultRegion, + 'New region in localStorage' + ); + }); +}); + +test('switching regions on deep pages redirects to the application root', function(assert) { + const newRegion = server.db.regions[1].id; + + Allocation.visit({ id: server.db.allocations[0].id }); + + selectChoose('[data-test-region-switcher]', newRegion); + + andThen(() => { + assert.ok(currentURL().includes('/jobs?'), 'Back at the jobs page'); + }); +}); + +test('navigating directly to a page with the region query param sets the application to that region', function(assert) { + const allocation = server.db.allocations[0]; + const region = server.db.regions[1].id; + Allocation.visit({ id: allocation.id, region }); + + andThen(() => { + assert.equal( + currentURL(), + `/allocations/${allocation.id}?region=${region}`, + 'Region param is persisted when navigating straight to a detail page' + ); + assert.equal( + window.localStorage.nomadActiveRegion, + region, + 'Region is also set in localStorage from a detail page' + ); + }); +}); + +test('when the region is not the default region, all api requests include the region query param', function(assert) { + const region = server.db.regions[1].id; + + JobsList.visit({ region }); + + andThen(() => { + JobsList.jobs.objectAt(0).clickRow(); + }); + andThen(() => { + PageLayout.gutter.visitClients(); + }); + andThen(() => { + PageLayout.gutter.visitServers(); + }); + andThen(() => { + const [regionsRequest, defaultRegionRequest, ...appRequests] = server.pretender.handledRequests; + + assert.notOk( + regionsRequest.url.includes('region='), + 'The regions request is made without a region qp' + ); + assert.notOk( + defaultRegionRequest.url.includes('region='), + 'The default region request is made without a region qp' + ); + + appRequests.forEach(req => { + assert.ok(req.url.includes(`region=${region}`), req.url); + }); + }); +}); diff --git a/ui/tests/helpers/module-for-acceptance.js b/ui/tests/helpers/module-for-acceptance.js index 44fe4e0e2f4..05110ce6d24 100644 --- a/ui/tests/helpers/module-for-acceptance.js +++ b/ui/tests/helpers/module-for-acceptance.js @@ -9,7 +9,7 @@ export default function(name, options = {}) { // Clear session storage (a side effect of token storage) window.sessionStorage.clear(); - // Also clear local storage (a side effect of namespaces) + // Also clear local storage (a side effect of namespaces and regions) window.localStorage.clear(); this.application = startApp(); diff --git a/ui/tests/pages/jobs/list.js b/ui/tests/pages/jobs/list.js index fa05760d93f..cd59ac7ef67 100644 --- a/ui/tests/pages/jobs/list.js +++ b/ui/tests/pages/jobs/list.js @@ -17,6 +17,7 @@ export default create({ search: fillable('[data-test-jobs-search] input'), jobs: collection('[data-test-job-row]', { + id: attribute('data-test-job-row'), name: text('[data-test-job-name]'), link: attribute('href', '[data-test-job-name] a'), status: text('[data-test-job-status]'), diff --git a/ui/tests/pages/layout.js b/ui/tests/pages/layout.js new file mode 100644 index 00000000000..e276310bb14 --- /dev/null +++ b/ui/tests/pages/layout.js @@ -0,0 +1,31 @@ +import { create, clickable, collection, isPresent, text } from 'ember-cli-page-object'; + +export default create({ + navbar: { + scope: '[data-test-global-header]', + + regionSwitcher: { + scope: '[data-test-region-switcher]', + isPresent: isPresent(), + open: clickable('.ember-power-select-trigger'), + options: collection('.ember-power-select-option', { + label: text(), + }), + }, + }, + + gutter: { + scope: '[data-test-gutter-menu]', + namespaceSwitcher: { + scope: '[data-test-namespace-switcher]', + isPresent: isPresent(), + open: clickable('.ember-power-select-trigger'), + options: collection('.ember-power-select-option', { + label: text(), + }), + }, + visitJobs: clickable('[data-test-gutter-link="jobs"]'), + visitClients: clickable('[data-test-gutter-link="clients"]'), + visitServers: clickable('[data-test-gutter-link="servers"]'), + }, +}); From 31922670393abb0334391d99f129edf39a88488e Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 13 Aug 2018 16:18:06 -0700 Subject: [PATCH 31/32] Use the model hook and setupController hook instead of afterModel This is a more idiomatic way to handle the QP resetting in the application controller. --- ui/app/routes/application.js | 15 ++++++++------- ui/tests/acceptance/regions-test.js | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 6119eb4cea4..477d30c49d3 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -46,14 +46,15 @@ export default Route.extend({ ); }, - // setupController doesn't refire when the model hook refires as part of - // a query param change - afterModel(model, transition) { - const queryParam = transition.queryParams.region; - const controller = this.controllerFor('application'); + // Model is being used as a way to transfer the provided region + // query param to update the controller state. + model(params) { + return params.region; + }, + + setupController(controller, model) { + const queryParam = model; - // The default region shouldn't show up as a query param since - // it's superfluous. if (queryParam === this.get('system.defaultRegion.region')) { next(() => { controller.set('region', null); diff --git a/ui/tests/acceptance/regions-test.js b/ui/tests/acceptance/regions-test.js index 9fae4d6a3f6..2a392eed13c 100644 --- a/ui/tests/acceptance/regions-test.js +++ b/ui/tests/acceptance/regions-test.js @@ -137,7 +137,7 @@ test('switching regions to the default region, unsets the region query param', f selectChoose('[data-test-region-switcher]', defaultRegion); andThen(() => { - assert.equal(currentURL(), '/jobs', 'No region query param for the default region'); + assert.notOk(currentURL().includes('region='), 'No region query param for the default region'); assert.equal( window.localStorage.nomadActiveRegion, defaultRegion, From a61ad7ac59a158a7ec7c852b051a931f39d98dd3 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 13 Aug 2018 16:18:53 -0700 Subject: [PATCH 32/32] List the new region mirage env var in the environment file --- ui/config/environment.js | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/config/environment.js b/ui/config/environment.js index 3fafff3245c..2c8f0c688e6 100644 --- a/ui/config/environment.js +++ b/ui/config/environment.js @@ -23,6 +23,7 @@ module.exports = function(environment) { mirageScenario: 'smallCluster', mirageWithNamespaces: false, mirageWithTokens: true, + mirageWithRegions: true, }, };