Merge pull request #4572 from hashicorp/f-ui-region-switcher

UI: Region Switcher
This commit is contained in:
Michael Lange
2018-08-13 16:33:08 -07:00
committed by GitHub
39 changed files with 846 additions and 178 deletions

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import Component from '@ember/component';
export default Component.extend({
'data-test-global-header': true,
onHamburgerClick() {},
});

View File

@@ -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 },
});
},
});

View File

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

View File

@@ -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');
});
}
});
}

View File

@@ -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';
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'));
}
),
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'),

View File

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

View File

@@ -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')) {

View File

@@ -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,8 +14,19 @@ export default Route.extend(WithForbiddenState, {
},
],
beforeModel() {
return this.get('system.namespaces');
queryParams: {
jobNamespace: {
refreshModel: true,
},
},
beforeModel(transition) {
return this.get('system.namespaces').then(namespaces => {
const queryParam = transition.queryParams.namespace;
this.set('system.activeNamespace', queryParam || 'default');
return namespaces;
});
},
model() {
@@ -25,26 +35,6 @@ export default Route.extend(WithForbiddenState, {
.catch(notifyForbidden(this));
},
syncToController(controller) {
const namespace = this.get('system.activeNamespace.id');
// 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');
}
});
},
setupController(controller) {
this.syncToController(controller);
return this._super(...arguments);
},
actions: {
refreshRoute() {
this.refresh();

View File

@@ -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);
},
});

View File

@@ -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}`;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
.breadcrumb {
margin: 0 1.5rem;
a {
text-decoration: none;
opacity: 0.7;

View File

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

View File

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

View File

@@ -16,7 +16,9 @@
</div>
</nav>
<div class="navbar is-secondary">
<div class="navbar-item is-gutter"></div>
<div class="navbar-item is-gutter">
{{region-switcher decoration="is-outlined"}}
</div>
<nav class="breadcrumb is-large">
<ul>
{{yield}}

View File

@@ -9,13 +9,27 @@
</span>
</header>
<aside class="menu">
{{#if system.shouldShowRegions}}
<div class="collapsed-only">
<p class="menu-label">
Region
</p>
<ul class="menu-list">
<li>
<div class="menu-item is-wide">
{{region-switcher}}
</div>
</li>
</ul>
</div>
{{/if}}
<p class="menu-label">
Workload
</p>
<ul class="menu-list">
{{#if system.shouldShowNamespaces}}
<li>
<div class="menu-item">
<div class="menu-item is-wide">
{{#power-select
data-test-namespace-switcher
options=sortedNamespaces
@@ -35,14 +49,14 @@
</div>
</li>
{{/if}}
<li>{{#link-to "jobs" activeClass="is-active"}}Jobs{{/link-to}}</li>
<li>{{#link-to "jobs" activeClass="is-active" data-test-gutter-link="jobs"}}Jobs{{/link-to}}</li>
</ul>
<p class="menu-label">
Cluster
</p>
<ul class="menu-list">
<li>{{#link-to "clients" activeClass="is-active"}}Clients{{/link-to}}</li>
<li>{{#link-to "servers" activeClass="is-active"}}Servers{{/link-to}}</li>
<li>{{#link-to "clients" activeClass="is-active" data-test-gutter-link="clients"}}Clients{{/link-to}}</li>
<li>{{#link-to "servers" activeClass="is-active" data-test-gutter-link="servers"}}Servers{{/link-to}}</li>
</ul>
</aside>
</div>

View File

@@ -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|}}
<span class="ember-power-select-prefix">Region: </span>{{region}}
{{/power-select}}
{{/if}}

View File

@@ -25,7 +25,7 @@
<th class="is-3">Summary</th>
{{/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}}
<div class="table-foot">

View File

@@ -1,71 +1,68 @@
{{#gutter-menu class="page-body" onNamespaceChange=(action "gotoJobs")}}
{{partial "jobs/job/subnav"}}
<section class="section">
{{#if allocations.length}}
<div class="content">
<div>
{{search-box
data-test-allocations-search
searchTerm=(mut searchTerm)
placeholder="Search allocations..."}}
</div>
{{partial "jobs/job/subnav"}}
<section class="section">
{{#if allocations.length}}
<div class="content">
<div>
{{search-box
data-test-allocations-search
searchTerm=(mut searchTerm)
placeholder="Search allocations..."}}
</div>
{{#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}}
<th class="is-narrow"></th>
{{#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}}
<th>CPU</th>
<th>Memory</th>
{{/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}}
<div class="table-foot">
<nav class="pagination">
<div class="pagination-numbers">
{{p.startsAt}}&ndash;{{p.endsAt}} of {{sortedAllocations.length}}
</div>
{{#p.prev class="pagination-previous"}} &lt; {{/p.prev}}
{{#p.next class="pagination-next"}} &gt; {{/p.next}}
<ul class="pagination-list"></ul>
</nav>
</div>
{{else}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-allocations-list>
<h3 class="empty-message-headline" data-test-empty-allocations-list-headline>No Matches</h3>
<p class="empty-message-body">No allocations match the term <strong>{{searchTerm}}</strong></p>
</div>
{{#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}}
<th class="is-narrow"></th>
{{#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}}
<th>CPU</th>
<th>Memory</th>
{{/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}}
<div class="table-foot">
<nav class="pagination">
<div class="pagination-numbers">
{{p.startsAt}}&ndash;{{p.endsAt}} of {{sortedAllocations.length}}
</div>
</div>
{{/list-pagination}}
{{#p.prev class="pagination-previous"}} &lt; {{/p.prev}}
{{#p.next class="pagination-next"}} &gt; {{/p.next}}
<ul class="pagination-list"></ul>
</nav>
</div>
{{else}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-allocations-list>
<h3 class="empty-message-headline" data-test-empty-allocations-list-headline>No Allocations</h3>
<p class="empty-message-body">No allocations have been placed.</p>
<h3 class="empty-message-headline" data-test-empty-allocations-list-headline>No Matches</h3>
<p class="empty-message-body">No allocations match the term <strong>{{searchTerm}}</strong></p>
</div>
</div>
{{/if}}
</section>
{{/gutter-menu}}
{{/list-pagination}}
{{else}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-allocations-list>
<h3 class="empty-message-headline" data-test-empty-allocations-list-headline>No Allocations</h3>
<p class="empty-message-body">No allocations have been placed.</p>
</div>
</div>
{{/if}}
</section>

View File

@@ -23,6 +23,7 @@ module.exports = function(environment) {
mirageScenario: 'smallCluster',
mirageWithNamespaces: false,
mirageWithTokens: true,
mirageWithRegions: true,
},
};

View File

@@ -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));
};

View File

@@ -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!');
},
});

View File

@@ -0,0 +1,3 @@
import { Model } from 'ember-cli-mirage';
export default Model.extend();

View File

@@ -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:');

View File

@@ -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');
},
});

View File

@@ -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);
});
});
});

View File

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

View File

@@ -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();

View File

@@ -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() {

View File

@@ -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]'),

31
ui/tests/pages/layout.js Normal file
View File

@@ -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"]'),
},
});

View File

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

View File

@@ -15,6 +15,7 @@ moduleForAdapter('node', 'Unit | Adapter | Node', {
'model:job',
'serializer:application',
'serializer:node',
'service:system',
'service:token',
'service:config',
'service:watchList',

View File

@@ -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'
);
});