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 @@