diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index 410d4e6ccf4..b1504ff035d 100644 --- a/ui/app/adapters/application.js +++ b/ui/app/adapters/application.js @@ -9,6 +9,7 @@ export const namespace = 'v1'; export default RESTAdapter.extend({ namespace, + system: service(), token: service(), headers: computed('token.secret', function() { @@ -35,6 +36,17 @@ export default RESTAdapter.extend({ }); }, + ajaxOptions(url, type, options = {}) { + options.data || (options.data = {}); + if (this.get('system.shouldIncludeRegion')) { + const region = this.get('system.activeRegion'); + if (region) { + options.data.region = region; + } + } + return this._super(url, type, options); + }, + // In order to remove stale records from the store, findHasMany has to unload // all records related to the request in question. findHasMany(store, snapshot, link, relationship) { diff --git a/ui/app/adapters/token.js b/ui/app/adapters/token.js index 32315037ae7..8b8a8b25072 100644 --- a/ui/app/adapters/token.js +++ b/ui/app/adapters/token.js @@ -7,7 +7,7 @@ export default ApplicationAdapter.extend({ namespace: namespace + '/acl', findSelf() { - return this.ajax(`${this.buildURL()}/token/self`).then(token => { + return this.ajax(`${this.buildURL()}/token/self`, 'GET').then(token => { const store = this.get('store'); store.pushPayload('token', { tokens: [token], diff --git a/ui/app/components/global-header.js b/ui/app/components/global-header.js index 6ee147b08e7..c90e6457dfd 100644 --- a/ui/app/components/global-header.js +++ b/ui/app/components/global-header.js @@ -1,5 +1,7 @@ import Component from '@ember/component'; export default Component.extend({ + 'data-test-global-header': true, + onHamburgerClick() {}, }); diff --git a/ui/app/components/region-switcher.js b/ui/app/components/region-switcher.js new file mode 100644 index 00000000000..41574a908af --- /dev/null +++ b/ui/app/components/region-switcher.js @@ -0,0 +1,21 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default Component.extend({ + system: service(), + router: service(), + store: service(), + + sortedRegions: computed('system.regions', function() { + return this.get('system.regions') + .toArray() + .sort(); + }), + + gotoRegion(region) { + this.get('router').transitionTo('jobs', { + queryParams: { region }, + }); + }, +}); diff --git a/ui/app/controllers/application.js b/ui/app/controllers/application.js index fc3c5e58e92..15902ebd80b 100644 --- a/ui/app/controllers/application.js +++ b/ui/app/controllers/application.js @@ -7,6 +7,13 @@ import codesForError from '../utils/codes-for-error'; export default Controller.extend({ config: service(), + system: service(), + + queryParams: { + region: 'region', + }, + + region: null, error: null, diff --git a/ui/app/controllers/jobs.js b/ui/app/controllers/jobs.js index c8fd48cf5ef..b38d2b11b12 100644 --- a/ui/app/controllers/jobs.js +++ b/ui/app/controllers/jobs.js @@ -1,7 +1,5 @@ import { inject as service } from '@ember/service'; import Controller from '@ember/controller'; -import { observer } from '@ember/object'; -import { run } from '@ember/runloop'; export default Controller.extend({ system: service(), @@ -13,26 +11,4 @@ export default Controller.extend({ isForbidden: false, jobNamespace: 'default', - - // The namespace query param should act as an alias to the system active namespace. - // But query param defaults can't be CPs: https://github.com/emberjs/ember.js/issues/9819 - syncNamespaceService: forwardNamespace('jobNamespace', 'system.activeNamespace'), - syncNamespaceParam: forwardNamespace('system.activeNamespace', 'jobNamespace'), }); - -function forwardNamespace(source, destination) { - return observer(source, `${source}.id`, function() { - const newNamespace = this.get(`${source}.id`) || this.get(source); - const currentNamespace = this.get(`${destination}.id`) || this.get(destination); - const bothAreDefault = - (currentNamespace == undefined || currentNamespace === 'default') && - (newNamespace == undefined || newNamespace === 'default'); - - if (currentNamespace !== newNamespace && !bothAreDefault) { - this.set(destination, newNamespace); - run.next(() => { - this.send('refreshRoute'); - }); - } - }); -} diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index 0dd0eb5a812..e2bdb4a0106 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -32,21 +32,17 @@ export default Controller.extend(Sortable, Searchable, { Filtered jobs are those that match the selected namespace and aren't children of periodic or parameterized jobs. */ - filteredJobs: computed( - 'model.[]', - 'model.@each.parent', - 'system.activeNamespace', - 'system.namespaces.length', - function() { - const hasNamespaces = this.get('system.namespaces.length'); - const activeNamespace = this.get('system.activeNamespace.id') || 'default'; - - return this.get('model') - .compact() - .filter(job => !hasNamespaces || job.get('namespace.id') === activeNamespace) - .filter(job => !job.get('parent.content')); - } - ), + filteredJobs: computed('model.[]', 'model.@each.parent', function() { + // Namespace related properties are ommitted from the dependent keys + // due to a prop invalidation bug caused by region switching. + const hasNamespaces = this.get('system.namespaces.length'); + const activeNamespace = this.get('system.activeNamespace.id') || 'default'; + + return this.get('model') + .compact() + .filter(job => !hasNamespaces || job.get('namespace.id') === activeNamespace) + .filter(job => !job.get('parent.content')); + }), listToSort: alias('filteredJobs'), listToSearch: alias('listSorted'), diff --git a/ui/app/controllers/settings/tokens.js b/ui/app/controllers/settings/tokens.js index be1db757d24..b226b61dcc6 100644 --- a/ui/app/controllers/settings/tokens.js +++ b/ui/app/controllers/settings/tokens.js @@ -5,6 +5,7 @@ import { getOwner } from '@ember/application'; export default Controller.extend({ token: service(), + system: service(), store: service(), secret: reads('token.secret'), @@ -43,6 +44,7 @@ export default Controller.extend({ // Clear out all data to ensure only data the new token is privileged to // see is shown + this.get('system').reset(); this.resetStore(); // Immediately refetch the token now that the store is empty diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index 469e76a0af9..477d30c49d3 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -1,9 +1,19 @@ import { inject as service } from '@ember/service'; +import { next } from '@ember/runloop'; import Route from '@ember/routing/route'; import { AbortError } from 'ember-data/adapters/errors'; +import RSVP from 'rsvp'; export default Route.extend({ config: service(), + system: service(), + store: service(), + + queryParams: { + region: { + refreshModel: true, + }, + }, resetController(controller, isExiting) { if (isExiting) { @@ -11,6 +21,49 @@ export default Route.extend({ } }, + beforeModel(transition) { + return RSVP.all([this.get('system.regions'), this.get('system.defaultRegion')]).then( + promises => { + if (!this.get('system.shouldShowRegions')) return promises; + + const queryParam = transition.queryParams.region; + const defaultRegion = this.get('system.defaultRegion.region'); + const currentRegion = this.get('system.activeRegion') || defaultRegion; + + // Only reset the store if the region actually changed + if ( + (queryParam && queryParam !== currentRegion) || + (!queryParam && currentRegion !== defaultRegion) + ) { + this.get('system').reset(); + this.get('store').unloadAll(); + } + + this.set('system.activeRegion', queryParam || defaultRegion); + + return promises; + } + ); + }, + + // 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; + + if (queryParam === this.get('system.defaultRegion.region')) { + next(() => { + controller.set('region', null); + }); + } + + return this._super(...arguments); + }, + actions: { didTransition() { if (!this.get('config.isTest')) { diff --git a/ui/app/routes/jobs.js b/ui/app/routes/jobs.js index 759027c2103..49564910684 100644 --- a/ui/app/routes/jobs.js +++ b/ui/app/routes/jobs.js @@ -1,6 +1,5 @@ import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; -import { run } from '@ember/runloop'; import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; import notifyForbidden from 'nomad-ui/utils/notify-forbidden'; @@ -15,34 +14,25 @@ export default Route.extend(WithForbiddenState, { }, ], - beforeModel() { - return this.get('system.namespaces'); - }, - - model() { - return this.get('store') - .findAll('job', { reload: true }) - .catch(notifyForbidden(this)); + queryParams: { + jobNamespace: { + refreshModel: true, + }, }, - syncToController(controller) { - const namespace = this.get('system.activeNamespace.id'); + beforeModel(transition) { + return this.get('system.namespaces').then(namespaces => { + const queryParam = transition.queryParams.namespace; + this.set('system.activeNamespace', queryParam || 'default'); - // The run next is necessary to let the controller figure - // itself out before updating QPs. - // See: https://github.com/emberjs/ember.js/issues/5465 - run.next(() => { - if (namespace && namespace !== 'default') { - controller.set('jobNamespace', namespace); - } else { - controller.set('jobNamespace', 'default'); - } + return namespaces; }); }, - setupController(controller) { - this.syncToController(controller); - return this._super(...arguments); + model() { + return this.get('store') + .findAll('job', { reload: true }) + .catch(notifyForbidden(this)); }, actions: { diff --git a/ui/app/services/system.js b/ui/app/services/system.js index cb0e84b9a58..7d6d2179243 100644 --- a/ui/app/services/system.js +++ b/ui/app/services/system.js @@ -1,13 +1,19 @@ import Service, { inject as service } from '@ember/service'; import { computed } from '@ember/object'; +import { copy } from '@ember/object/internals'; import PromiseObject from '../utils/classes/promise-object'; +import PromiseArray from '../utils/classes/promise-array'; import { namespace } from '../adapters/application'; +// When the request isn't ok (e.g., forbidden) handle gracefully +const jsonWithDefault = defaultResponse => res => + res.ok ? res.json() : copy(defaultResponse, true); + export default Service.extend({ token: service(), store: service(), - leader: computed(function() { + leader: computed('activeRegion', function() { const token = this.get('token'); return PromiseObject.create({ @@ -23,8 +29,72 @@ export default Service.extend({ }); }), - namespaces: computed(function() { - return this.get('store').findAll('namespace'); + defaultRegion: computed(function() { + const token = this.get('token'); + return PromiseObject.create({ + promise: token + .authorizedRawRequest(`/${namespace}/agent/members`) + .then(jsonWithDefault({})) + .then(json => { + return { region: json.ServerRegion }; + }), + }); + }), + + regions: computed(function() { + const token = this.get('token'); + + return PromiseArray.create({ + promise: token.authorizedRawRequest(`/${namespace}/regions`).then(jsonWithDefault([])), + }); + }), + + activeRegion: computed('regions.[]', { + get() { + const regions = this.get('regions'); + const region = window.localStorage.nomadActiveRegion; + + if (regions.includes(region)) { + return region; + } + + 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; + } + }, + }), + + shouldShowRegions: computed('regions.[]', function() { + return this.get('regions.length') > 1; + }), + + shouldIncludeRegion: computed( + 'activeRegion', + 'defaultRegion.region', + 'shouldShowRegions', + function() { + return ( + this.get('shouldShowRegions') && + this.get('activeRegion') !== this.get('defaultRegion.region') + ); + } + ), + + namespaces: computed('activeRegion', function() { + return PromiseArray.create({ + promise: this.get('store') + .findAll('namespace') + .then(namespaces => namespaces.compact()), + }); }), shouldShowNamespaces: computed('namespaces.[]', function() { @@ -41,7 +111,7 @@ export default Service.extend({ return namespace; } - // If the namespace is localStorage is no longer in the cluster, it needs to + // If the namespace in localStorage is no longer in the cluster, it needs to // be cleared from localStorage this.set('activeNamespace', null); return this.get('namespaces').findBy('id', 'default'); @@ -58,4 +128,8 @@ export default Service.extend({ } }, }), + + reset() { + this.set('activeNamespace', null); + }, }); diff --git a/ui/app/services/token.js b/ui/app/services/token.js index 4ce27d9c354..28083a0500c 100644 --- a/ui/app/services/token.js +++ b/ui/app/services/token.js @@ -1,9 +1,12 @@ -import Service from '@ember/service'; +import Service, { inject as service } from '@ember/service'; import { computed } from '@ember/object'; import { assign } from '@ember/polyfills'; +import queryString from 'query-string'; import fetch from 'nomad-ui/utils/fetch'; export default Service.extend({ + system: service(), + secret: computed({ get() { return window.sessionStorage.nomadTokenSecret; @@ -19,7 +22,12 @@ export default Service.extend({ }, }), - authorizedRequest(url, options = { credentials: 'include' }) { + // All non Ember Data requests should go through authorizedRequest. + // However, the request that gets regions falls into that category. + // This authorizedRawRequest is necessary in order to fetch data + // with the guarantee of a token but without the automatic region + // param since the region cannot be known at this point. + authorizedRawRequest(url, options = { credentials: 'include' }) { const headers = {}; const token = this.get('secret'); @@ -29,4 +37,21 @@ export default Service.extend({ return fetch(url, assign(options, { headers })); }, + + authorizedRequest(url, options) { + if (this.get('system.shouldIncludeRegion')) { + const region = this.get('system.activeRegion'); + if (region) { + url = addParams(url, { region }); + } + } + + return this.authorizedRawRequest(url, options); + }, }); + +function addParams(url, params) { + const paramsStr = queryString.stringify(params); + const delimiter = url.includes('?') ? '&' : '?'; + return `${url}${delimiter}${paramsStr}`; +} 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..0be310dd37b --- /dev/null +++ b/ui/app/styles/components/dropdown.scss @@ -0,0 +1,36 @@ +.ember-power-select-trigger { + padding: 0.3em 16px 0.3em 0.3em; + border-radius: $radius; + box-shadow: $button-box-shadow-standard; + background: $white-bis; + + &.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/components/gutter.scss b/ui/app/styles/components/gutter.scss index e2f6396c2c5..f2214c36eaf 100644 --- a/ui/app/styles/components/gutter.scss +++ b/ui/app/styles/components/gutter.scss @@ -3,12 +3,20 @@ border-right: 1px solid $grey-blue; overflow: hidden; + .collapsed-only { + display: none; + } + @media #{$mq-hidden-gutter} { border-right: none; &.is-open { box-shadow: 0 0 30px darken($nomad-green-darker, 20%); } + + .collapsed-only { + display: inherit; + } } .collapsed-menu { diff --git a/ui/app/styles/core/breadcrumb.scss b/ui/app/styles/core/breadcrumb.scss index 182decee3c5..092bb022149 100644 --- a/ui/app/styles/core/breadcrumb.scss +++ b/ui/app/styles/core/breadcrumb.scss @@ -1,4 +1,6 @@ .breadcrumb { + margin: 0 1.5rem; + a { text-decoration: none; opacity: 0.7; diff --git a/ui/app/styles/core/menu.scss b/ui/app/styles/core/menu.scss index 0811337afd9..d5f8110c9ae 100644 --- a/ui/app/styles/core/menu.scss +++ b/ui/app/styles/core/menu.scss @@ -27,6 +27,10 @@ .menu-item { margin: 0.5rem 1.5rem; + + &.is-wide { + margin: 0.5rem 1rem; + } } } @@ -45,4 +49,12 @@ border-top: 1px solid $grey-blue; } } + + .collapsed-only + .menu-label { + border-top: none; + + @media #{$mq-hidden-gutter} { + border-top: 1px solid $grey-blue; + } + } } diff --git a/ui/app/styles/core/navbar.scss b/ui/app/styles/core/navbar.scss index 4ab73cfe8b5..cc881cfea21 100644 --- a/ui/app/styles/core/navbar.scss +++ b/ui/app/styles/core/navbar.scss @@ -75,9 +75,18 @@ &.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; + display: none; } } } diff --git a/ui/app/templates/components/global-header.hbs b/ui/app/templates/components/global-header.hbs index 50cb7752eef..10ff5d8ca7b 100644 --- a/ui/app/templates/components/global-header.hbs +++ b/ui/app/templates/components/global-header.hbs @@ -16,7 +16,9 @@ diff --git a/ui/app/templates/components/region-switcher.hbs b/ui/app/templates/components/region-switcher.hbs new file mode 100644 index 00000000000..df2da187093 --- /dev/null +++ b/ui/app/templates/components/region-switcher.hbs @@ -0,0 +1,12 @@ +{{#if system.shouldShowRegions}} + {{#power-select + data-test-region-switcher + tagName="div" + triggerClass=decoration + options=sortedRegions + selected=system.activeRegion + searchEnabled=false + onchange=(action gotoRegion) as |region|}} + Region: {{region}} + {{/power-select}} +{{/if}} 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/app/templates/jobs/job/allocations.hbs b/ui/app/templates/jobs/job/allocations.hbs index 080919b6a65..e8f4c07d345 100644 --- a/ui/app/templates/jobs/job/allocations.hbs +++ b/ui/app/templates/jobs/job/allocations.hbs @@ -1,71 +1,68 @@ -{{#gutter-menu class="page-body" onNamespaceChange=(action "gotoJobs")}} - {{partial "jobs/job/subnav"}} -
- {{#if allocations.length}} -
-
- {{search-box - data-test-allocations-search - searchTerm=(mut searchTerm) - placeholder="Search allocations..."}} -
+{{partial "jobs/job/subnav"}} +
+ {{#if allocations.length}} +
+
+ {{search-box + data-test-allocations-search + searchTerm=(mut searchTerm) + placeholder="Search allocations..."}}
- {{#list-pagination - source=sortedAllocations - size=pageSize - page=currentPage - class="allocations" as |p|}} - {{#list-table - source=p.list - sortProperty=sortProperty - sortDescending=sortDescending - class="with-foot" as |t|}} - {{#t.head}} - - {{#t.sort-by prop="shortId"}}ID{{/t.sort-by}} - {{#t.sort-by prop="taskGroupName"}}Task Group{{/t.sort-by}} - {{#t.sort-by prop="createIndex" title="Create Index"}}Created{{/t.sort-by}} - {{#t.sort-by prop="modifyIndex" title="Modify Index"}}Modified{{/t.sort-by}} - {{#t.sort-by prop="statusIndex"}}Status{{/t.sort-by}} - {{#t.sort-by prop="jobVersion"}}Version{{/t.sort-by}} - {{#t.sort-by prop="node.shortId"}}Client{{/t.sort-by}} - CPU - Memory - {{/t.head}} - {{#t.body as |row|}} - {{allocation-row - data-test-allocation=row.model.id - allocation=row.model - context="job" - onClick=(action "gotoAllocation" row.model)}} - {{/t.body}} - {{/list-table}} -
- -
- {{else}} -
-
-

No Matches

-

No allocations match the term {{searchTerm}}

+
+ {{#list-pagination + source=sortedAllocations + size=pageSize + page=currentPage + class="allocations" as |p|}} + {{#list-table + source=p.list + sortProperty=sortProperty + sortDescending=sortDescending + class="with-foot" as |t|}} + {{#t.head}} + + {{#t.sort-by prop="shortId"}}ID{{/t.sort-by}} + {{#t.sort-by prop="taskGroupName"}}Task Group{{/t.sort-by}} + {{#t.sort-by prop="createIndex" title="Create Index"}}Created{{/t.sort-by}} + {{#t.sort-by prop="modifyIndex" title="Modify Index"}}Modified{{/t.sort-by}} + {{#t.sort-by prop="statusIndex"}}Status{{/t.sort-by}} + {{#t.sort-by prop="jobVersion"}}Version{{/t.sort-by}} + {{#t.sort-by prop="node.shortId"}}Client{{/t.sort-by}} + CPU + Memory + {{/t.head}} + {{#t.body as |row|}} + {{allocation-row + data-test-allocation=row.model.id + allocation=row.model + context="job" + onClick=(action "gotoAllocation" row.model)}} + {{/t.body}} + {{/list-table}} +
+
- {{/list-pagination}} + {{#p.prev class="pagination-previous"}} < {{/p.prev}} + {{#p.next class="pagination-next"}} > {{/p.next}} +
    + +
    {{else}}
    -

    No Allocations

    -

    No allocations have been placed.

    +

    No Matches

    +

    No allocations match the term {{searchTerm}}

    - {{/if}} -
    -{{/gutter-menu}} - + {{/list-pagination}} + {{else}} +
    +
    +

    No Allocations

    +

    No allocations have been placed.

    +
    +
    + {{/if}} +
    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, }, }; diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 55293e57452..0311a8d94eb 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -165,8 +165,10 @@ export default function() { return new Response(501, {}, null); }); - this.get('/agent/members', function({ agents }) { + this.get('/agent/members', function({ agents, regions }) { + const firstRegion = regions.first(); return { + ServerRegion: firstRegion ? firstRegion.id : null, Members: this.serialize(agents.all()), }; }); @@ -222,6 +224,10 @@ export default function() { return new Response(403, {}, null); }); + this.get('/regions', function({ regions }) { + return this.serialize(regions.all()); + }); + const clientAllocationStatsHandler = function({ clientAllocationStats }, { params }) { return this.serialize(clientAllocationStats.find(params.id)); }; diff --git a/ui/mirage/factories/region.js b/ui/mirage/factories/region.js new file mode 100644 index 00000000000..2802d161036 --- /dev/null +++ b/ui/mirage/factories/region.js @@ -0,0 +1,7 @@ +import { Factory } from 'ember-cli-mirage'; + +export default Factory.extend({ + id: () => { + throw new Error('The region factory will not generate IDs!'); + }, +}); diff --git a/ui/mirage/models/region.js b/ui/mirage/models/region.js new file mode 100644 index 00000000000..ddb04151dfb --- /dev/null +++ b/ui/mirage/models/region.js @@ -0,0 +1,3 @@ +import { Model } from 'ember-cli-mirage'; + +export default Model.extend(); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index e40dee2cb3d..d80b5d276ea 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -2,6 +2,7 @@ import config from 'nomad-ui/config/environment'; const withNamespaces = getConfigValue('mirageWithNamespaces', false); const withTokens = getConfigValue('mirageWithTokens', true); +const withRegions = getConfigValue('mirageWithRegions', false); const allScenarios = { smallCluster, @@ -27,6 +28,7 @@ export default function(server) { if (withNamespaces) createNamespaces(server); if (withTokens) createTokens(server); + if (withRegions) createRegions(server); activeScenario(server); } @@ -98,6 +100,12 @@ function createNamespaces(server) { server.createList('namespace', 3); } +function createRegions(server) { + ['americas', 'europe', 'asia', 'some-long-name-just-to-test'].forEach(id => { + server.create('region', { id }); + }); +} + /* eslint-disable */ function logTokens(server) { console.log('TOKENS:'); diff --git a/ui/mirage/serializers/region.js b/ui/mirage/serializers/region.js new file mode 100644 index 00000000000..f38a82674c4 --- /dev/null +++ b/ui/mirage/serializers/region.js @@ -0,0 +1,8 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + serialize() { + var json = ApplicationSerializer.prototype.serialize.apply(this, arguments); + return [].concat(json).mapBy('ID'); + }, +}); diff --git a/ui/tests/acceptance/regions-test.js b/ui/tests/acceptance/regions-test.js new file mode 100644 index 00000000000..2a392eed13c --- /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.notOk(currentURL().includes('region='), '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/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js index c4e43c56c6d..cc30097f708 100644 --- a/ui/tests/acceptance/task-detail-test.js +++ b/ui/tests/acceptance/task-detail-test.js @@ -166,9 +166,11 @@ test('when the allocation is found but the task is not, the application errors', Task.visit({ id: allocation.id, name: 'not-a-real-task-name' }); andThen(() => { - assert.equal( - server.pretender.handledRequests.findBy('status', 200).url, - `/v1/allocation/${allocation.id}`, + assert.ok( + server.pretender.handledRequests + .filterBy('status', 200) + .mapBy('url') + .includes(`/v1/allocation/${allocation.id}`), 'A request to the allocation is made successfully' ); assert.equal( 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/integration/task-log-test.js b/ui/tests/integration/task-log-test.js index 5b2ddab2502..563b0d6323c 100644 --- a/ui/tests/integration/task-log-test.js +++ b/ui/tests/integration/task-log-test.js @@ -56,6 +56,7 @@ moduleForComponent('task-log', 'Integration | Component | task log', { this.server = new Pretender(function() { this.get(`http://${HOST}/v1/client/fs/logs/:allocation_id`, handler); this.get('/v1/client/fs/logs/:allocation_id', handler); + this.get('/v1/regions', () => [200, {}, '[]']); }); }, afterEach() { 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"]'), + }, +}); diff --git a/ui/tests/unit/adapters/job-test.js b/ui/tests/unit/adapters/job-test.js index 1eca17e933a..0a1fb9f657d 100644 --- a/ui/tests/unit/adapters/job-test.js +++ b/ui/tests/unit/adapters/job-test.js @@ -40,8 +40,17 @@ moduleForAdapter('job', 'Unit | Adapter | Job', { this.server.create('job', { id: 'job-1', namespaceId: 'default' }); this.server.create('job', { id: 'job-2', namespaceId: 'some-namespace' }); + this.server.create('region', { id: 'region-1' }); + this.server.create('region', { id: 'region-2' }); + this.system = getOwner(this).lookup('service:system'); + + // Namespace, default region, and all regions are requests that all + // job requests depend on. Fetching them ahead of time means testing + // job adapter behavior in isolation. this.system.get('namespaces'); + this.system.get('shouldIncludeRegion'); + this.system.get('defaultRegion'); // Reset the handledRequests array to avoid accounting for this // namespaces request everywhere. @@ -58,13 +67,15 @@ test('The job endpoint is the only required endpoint for fetching a job', functi const jobNamespace = 'default'; const jobId = JSON.stringify([jobName, jobNamespace]); - this.subject().findRecord(null, { modelName: 'job' }, jobId); + return wait().then(() => { + this.subject().findRecord(null, { modelName: 'job' }, jobId); - assert.deepEqual( - pretender.handledRequests.mapBy('url'), - [`/v1/job/${jobName}`], - 'The only request made is /job/:id' - ); + assert.deepEqual( + pretender.handledRequests.mapBy('url'), + [`/v1/job/${jobName}`], + 'The only request made is /job/:id' + ); + }); }); test('When a namespace is set in localStorage but a job in the default namespace is requested, the namespace query param is not present', function(assert) { @@ -82,7 +93,7 @@ test('When a namespace is set in localStorage but a job in the default namespace assert.deepEqual( pretender.handledRequests.mapBy('url'), [`/v1/job/${jobName}`], - 'The one request made is /job/:id with no namespace query param' + 'The only request made is /job/:id with no namespace query param' ); }); }); @@ -95,13 +106,15 @@ test('When a namespace is in localStorage and the requested job is in the defaul const jobNamespace = 'default'; const jobId = JSON.stringify([jobName, jobNamespace]); - this.subject().findRecord(null, { modelName: 'job' }, jobId); + return wait().then(() => { + this.subject().findRecord(null, { modelName: 'job' }, jobId); - assert.deepEqual( - pretender.handledRequests.mapBy('url'), - [`/v1/job/${jobName}`], - 'The request made is /job/:id with no namespace query param' - ); + assert.deepEqual( + pretender.handledRequests.mapBy('url'), + [`/v1/job/${jobName}`], + 'The request made is /job/:id with no namespace query param' + ); + }); }); test('When the job has a namespace other than default, it is in the URL', function(assert) { @@ -110,25 +123,29 @@ test('When the job has a namespace other than default, it is in the URL', functi const jobNamespace = 'some-namespace'; const jobId = JSON.stringify([jobName, jobNamespace]); - this.subject().findRecord(null, { modelName: 'job' }, jobId); + return wait().then(() => { + this.subject().findRecord(null, { modelName: 'job' }, jobId); - assert.deepEqual( - pretender.handledRequests.mapBy('url'), - [`/v1/job/${jobName}?namespace=${jobNamespace}`], - 'The only request made is /job/:id?namespace=:namespace' - ); + assert.deepEqual( + pretender.handledRequests.mapBy('url'), + [`/v1/job/${jobName}?namespace=${jobNamespace}`], + 'The only request made is /job/:id?namespace=:namespace' + ); + }); }); test('When there is no token set in the token service, no x-nomad-token header is set', function(assert) { const { pretender } = this.server; const jobId = JSON.stringify(['job-1', 'default']); - this.subject().findRecord(null, { modelName: 'job' }, jobId); + return wait().then(() => { + this.subject().findRecord(null, { modelName: 'job' }, jobId); - assert.notOk( - pretender.handledRequests.mapBy('requestHeaders').some(headers => headers['X-Nomad-Token']), - 'No token header present on either job request' - ); + assert.notOk( + pretender.handledRequests.mapBy('requestHeaders').some(headers => headers['X-Nomad-Token']), + 'No token header present on either job request' + ); + }); }); test('When a token is set in the token service, then x-nomad-token header is set', function(assert) { @@ -136,15 +153,17 @@ test('When a token is set in the token service, then x-nomad-token header is set const jobId = JSON.stringify(['job-1', 'default']); const secret = 'here is the secret'; - this.subject().set('token.secret', secret); - this.subject().findRecord(null, { modelName: 'job' }, jobId); + return wait().then(() => { + this.subject().set('token.secret', secret); + this.subject().findRecord(null, { modelName: 'job' }, jobId); - assert.ok( - pretender.handledRequests - .mapBy('requestHeaders') - .every(headers => headers['X-Nomad-Token'] === secret), - 'The token header is present on both job requests' - ); + assert.ok( + pretender.handledRequests + .mapBy('requestHeaders') + .every(headers => headers['X-Nomad-Token'] === secret), + 'The token header is present on both job requests' + ); + }); }); test('findAll can be watched', function(assert) { @@ -375,6 +394,65 @@ test('canceling a find record request will never cancel a request with the same }); }); +test('when there is no region set, requests are made without the region query param', function(assert) { + const { pretender } = this.server; + const jobName = 'job-1'; + const jobNamespace = 'default'; + const jobId = JSON.stringify([jobName, jobNamespace]); + + return wait().then(() => { + this.subject().findRecord(null, { modelName: 'job' }, jobId); + this.subject().findAll(null, { modelName: 'job' }, null); + + assert.deepEqual( + pretender.handledRequests.mapBy('url'), + [`/v1/job/${jobName}`, '/v1/jobs'], + 'No requests include the region query param' + ); + }); +}); + +test('when there is a region set, requests are made with the region query param', function(assert) { + const region = 'region-2'; + window.localStorage.nomadActiveRegion = region; + + const { pretender } = this.server; + const jobName = 'job-1'; + const jobNamespace = 'default'; + const jobId = JSON.stringify([jobName, jobNamespace]); + + return wait().then(() => { + this.subject().findRecord(null, { modelName: 'job' }, jobId); + this.subject().findAll(null, { modelName: 'job' }, null); + + assert.deepEqual( + pretender.handledRequests.mapBy('url'), + [`/v1/job/${jobName}?region=${region}`, `/v1/jobs?region=${region}`], + 'Requests include the region query param' + ); + }); +}); + +test('when the region is set to the default region, requests are made without the region query param', function(assert) { + window.localStorage.nomadActiveRegion = 'region-1'; + + const { pretender } = this.server; + const jobName = 'job-1'; + const jobNamespace = 'default'; + const jobId = JSON.stringify([jobName, jobNamespace]); + + return wait().then(() => { + this.subject().findRecord(null, { modelName: 'job' }, jobId); + this.subject().findAll(null, { modelName: 'job' }, null); + + assert.deepEqual( + pretender.handledRequests.mapBy('url'), + [`/v1/job/${jobName}`, '/v1/jobs'], + 'No requests include the region query param' + ); + }); +}); + function makeMockModel(id, options) { return assign( { diff --git a/ui/tests/unit/adapters/node-test.js b/ui/tests/unit/adapters/node-test.js index 048f8d3bd86..67e0e8c30d3 100644 --- a/ui/tests/unit/adapters/node-test.js +++ b/ui/tests/unit/adapters/node-test.js @@ -15,6 +15,7 @@ moduleForAdapter('node', 'Unit | Adapter | Node', { 'model:job', 'serializer:application', 'serializer:node', + 'service:system', 'service:token', 'service:config', 'service:watchList', diff --git a/ui/tests/unit/services/token-test.js b/ui/tests/unit/services/token-test.js new file mode 100644 index 00000000000..ed9d6f2e97c --- /dev/null +++ b/ui/tests/unit/services/token-test.js @@ -0,0 +1,59 @@ +import { getOwner } from '@ember/application'; +import Service from '@ember/service'; +import { moduleFor, test } from 'ember-qunit'; +import Pretender from 'pretender'; + +moduleFor('service:token', 'Unit | Service | Token', { + beforeEach() { + const mockSystem = Service.extend({ + activeRegion: 'region-1', + shouldIncludeRegion: true, + }); + + this.register('service:system', mockSystem); + this.system = getOwner(this).lookup('service:system'); + + this.server = new Pretender(function() { + this.get('/path', () => [200, {}, null]); + }); + }, + afterEach() { + this.server.shutdown(); + }, + subject() { + return getOwner(this) + .factoryFor('service:token') + .create(); + }, +}); + +test('authorizedRequest includes the region param when the system service says to', function(assert) { + const token = this.subject(); + + token.authorizedRequest('/path'); + assert.equal( + this.server.handledRequests.pop().url, + `/path?region=${this.system.get('activeRegion')}`, + 'The region param is included when the system service shouldIncludeRegion property is true' + ); + + this.system.set('shouldIncludeRegion', false); + + token.authorizedRequest('/path'); + assert.equal( + this.server.handledRequests.pop().url, + '/path', + 'The region param is not included when the system service shouldIncludeRegion property is false' + ); +}); + +test('authorizedRawRequest bypasses adding the region param', function(assert) { + const token = this.subject(); + + token.authorizedRawRequest('/path'); + assert.equal( + this.server.handledRequests.pop().url, + '/path', + 'The region param is ommitted when making a raw request' + ); +});