Merge pull request #3936 from hashicorp/f-ui-polling

UI: Live updating views
This commit is contained in:
Michael Lange
2018-03-08 13:27:02 -08:00
committed by GitHub
56 changed files with 1379 additions and 383 deletions

View File

@@ -0,0 +1,3 @@
import Watchable from './watchable';
export default Watchable.extend();

View File

@@ -34,6 +34,24 @@ export default RESTAdapter.extend({
});
},
// 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) {
return this._super(...arguments).then(payload => {
const ownerType = snapshot.modelName;
const relationshipType = relationship.type;
// Naively assume that the inverse relationship is named the same as the
// owner type. In the event it isn't, findHasMany should be overridden.
store
.peekAll(relationshipType)
.filter(record => record.get(`${ownerType}.id`) === snapshot.id)
.forEach(record => {
store.unloadRecord(record);
});
return payload;
});
},
// Single record requests deviate from REST practice by using
// the singular form of the resource name.
//

View File

@@ -1,19 +1,17 @@
import { inject as service } from '@ember/service';
import RSVP from 'rsvp';
import { assign } from '@ember/polyfills';
import ApplicationAdapter from './application';
import Watchable from './watchable';
export default ApplicationAdapter.extend({
export default Watchable.extend({
system: service(),
shouldReloadAll: () => true,
buildQuery() {
const namespace = this.get('system.activeNamespace.id');
if (namespace && namespace !== 'default') {
return { namespace };
}
return {};
},
findAll() {
@@ -26,30 +24,28 @@ export default ApplicationAdapter.extend({
});
},
findRecord(store, { modelName }, id, snapshot) {
// To make a findRecord response reflect the findMany response, the JobSummary
// from /summary needs to be stitched into the response.
// URL is the form of /job/:name?namespace=:namespace with arbitrary additional query params
const [name, namespace] = JSON.parse(id);
const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {};
return RSVP.hash({
job: this.ajax(this.buildURL(modelName, name, snapshot, 'findRecord'), 'GET', {
data: assign(this.buildQuery() || {}, namespaceQuery),
}),
summary: this.ajax(
`${this.buildURL(modelName, name, snapshot, 'findRecord')}/summary`,
'GET',
{
data: assign(this.buildQuery() || {}, namespaceQuery),
}
),
}).then(({ job, summary }) => {
job.JobSummary = summary;
return job;
findRecordSummary(modelName, name, snapshot, namespaceQuery) {
return this.ajax(`${this.buildURL(modelName, name, snapshot, 'findRecord')}/summary`, 'GET', {
data: assign(this.buildQuery() || {}, namespaceQuery),
});
},
findRecord(store, type, id, snapshot) {
const [, namespace] = JSON.parse(id);
const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {};
return this._super(store, type, id, snapshot, namespaceQuery);
},
urlForFindRecord(id, type, hash) {
const [name, namespace] = JSON.parse(id);
let url = this._super(name, type, hash);
if (namespace && namespace !== 'default') {
url += `?namespace=${namespace}`;
}
return url;
},
findAllocations(job) {
const url = `${this.buildURL('job', job.get('id'), job, 'findRecord')}/allocations`;
return this.ajax(url, 'GET', { data: this.buildQuery() }).then(allocs => {
@@ -60,19 +56,17 @@ export default ApplicationAdapter.extend({
},
fetchRawDefinition(job) {
const [name, namespace] = JSON.parse(job.get('id'));
const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {};
const url = this.buildURL('job', name, job, 'findRecord');
return this.ajax(url, 'GET', { data: assign(this.buildQuery() || {}, namespaceQuery) });
const url = this.buildURL('job', job.get('id'), job, 'findRecord');
return this.ajax(url, 'GET', { data: this.buildQuery() });
},
forcePeriodic(job) {
if (job.get('periodic')) {
const [name, namespace] = JSON.parse(job.get('id'));
let url = `${this.buildURL('job', name, job, 'findRecord')}/periodic/force`;
const [path, params] = this.buildURL('job', job.get('id'), job, 'findRecord').split('?');
let url = `${path}/periodic/force`;
if (namespace) {
url += `?namespace=${namespace}`;
if (params) {
url += `?${params}`;
}
return this.ajax(url, 'POST');

View File

@@ -1,6 +1,6 @@
import ApplicationAdapter from './application';
import Watchable from './watchable';
export default ApplicationAdapter.extend({
export default Watchable.extend({
findAllocations(node) {
const url = `${this.buildURL('node', node.get('id'), node, 'findRecord')}/allocations`;
return this.ajax(url, 'GET').then(allocs => {

View File

@@ -0,0 +1,147 @@
import { get, computed } from '@ember/object';
import { assign } from '@ember/polyfills';
import { inject as service } from '@ember/service';
import queryString from 'npm:query-string';
import ApplicationAdapter from './application';
import { AbortError } from 'ember-data/adapters/errors';
export default ApplicationAdapter.extend({
watchList: service(),
store: service(),
xhrs: computed(function() {
return {};
}),
ajaxOptions(url) {
const ajaxOptions = this._super(...arguments);
const previousBeforeSend = ajaxOptions.beforeSend;
ajaxOptions.beforeSend = function(jqXHR) {
if (previousBeforeSend) {
previousBeforeSend(...arguments);
}
this.get('xhrs')[url] = jqXHR;
jqXHR.always(() => {
delete this.get('xhrs')[url];
});
};
return ajaxOptions;
},
findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) {
const params = assign(this.buildQuery(), additionalParams);
const url = this.urlForFindAll(type.modelName);
if (get(snapshotRecordArray || {}, 'adapterOptions.watch')) {
params.index = this.get('watchList').getIndexFor(url);
this.cancelFindAll(type.modelName);
}
return this.ajax(url, 'GET', {
data: params,
});
},
findRecord(store, type, id, snapshot, additionalParams = {}) {
let [url, params] = this.buildURL(type.modelName, id, snapshot, 'findRecord').split('?');
params = assign(queryString.parse(params) || {}, this.buildQuery(), additionalParams);
if (get(snapshot || {}, 'adapterOptions.watch')) {
params.index = this.get('watchList').getIndexFor(url);
this.cancelFindRecord(type.modelName, id);
}
return this.ajax(url, 'GET', {
data: params,
}).catch(error => {
if (error instanceof AbortError) {
return {};
}
throw error;
});
},
reloadRelationship(model, relationshipName, watch = false) {
const relationship = model.relationshipFor(relationshipName);
if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') {
throw new Error(
`${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}`
);
} else {
const url = model[relationship.kind](relationship.key).link();
let params = {};
if (watch) {
params.index = this.get('watchList').getIndexFor(url);
this.cancelReloadRelationship(model, relationshipName);
}
if (url.includes('?')) {
params = assign(queryString.parse(url.split('?')[1]), params);
}
return this.ajax(url, 'GET', {
data: params,
}).then(
json => {
const store = this.get('store');
const normalizeMethod =
relationship.kind === 'belongsTo'
? 'normalizeFindBelongsToResponse'
: 'normalizeFindHasManyResponse';
const serializer = store.serializerFor(relationship.type);
const modelClass = store.modelFor(relationship.type);
const normalizedData = serializer[normalizeMethod](store, modelClass, json);
store.push(normalizedData);
},
error => {
if (error instanceof AbortError) {
return relationship.kind === 'belongsTo' ? {} : [];
}
throw error;
}
);
}
},
handleResponse(status, headers, payload, requestData) {
const newIndex = headers['x-nomad-index'];
if (newIndex) {
this.get('watchList').setIndexFor(requestData.url, newIndex);
}
return this._super(...arguments);
},
cancelFindRecord(modelName, id) {
const url = this.urlForFindRecord(id, modelName);
const xhr = this.get('xhrs')[url];
if (xhr) {
xhr.abort();
}
},
cancelFindAll(modelName) {
const xhr = this.get('xhrs')[this.urlForFindAll(modelName)];
if (xhr) {
xhr.abort();
}
},
cancelReloadRelationship(model, relationshipName) {
const relationship = model.relationshipFor(relationshipName);
if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') {
throw new Error(
`${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}`
);
} else {
const url = model[relationship.kind](relationship.key).link();
const xhr = this.get('xhrs')[url];
if (xhr) {
xhr.abort();
}
}
},
});

View File

@@ -1,7 +1,12 @@
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import { lazyClick } from '../helpers/lazy-click';
import { watchRelationship } from 'nomad-ui/utils/properties/watch';
import WithVisibilityDetection from 'nomad-ui/mixins/with-component-visibility-detection';
export default Component.extend(WithVisibilityDetection, {
store: service(),
export default Component.extend({
tagName: 'tr',
classNames: ['client-node-row', 'is-interactive'],
@@ -17,7 +22,27 @@ export default Component.extend({
// Reload the node in order to get detail information
const node = this.get('node');
if (node) {
node.reload();
node.reload().then(() => {
this.get('watch').perform(node, 100);
});
}
},
visibilityHandler() {
if (document.hidden) {
this.get('watch').cancelAll();
} else {
const node = this.get('node');
if (node) {
this.get('watch').perform(node, 100);
}
}
},
willDestroy() {
this.get('watch').cancelAll();
this._super(...arguments);
},
watch: watchRelationship('allocations'),
});

View File

@@ -1,8 +1,8 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { computed, observer } from '@ember/object';
import { run } from '@ember/runloop';
import { assign } from '@ember/polyfills';
import { guidFor } from '@ember/object/internals';
import { guidFor, copy } from '@ember/object/internals';
import d3 from 'npm:d3-selection';
import 'npm:d3-transition';
import WindowResizable from '../mixins/window-resizable';
@@ -23,7 +23,7 @@ export default Component.extend(WindowResizable, {
maskId: null,
_data: computed('data', function() {
const data = this.get('data');
const data = copy(this.get('data'), true);
const sum = data.mapBy('value').reduce(sumAggregate, 0);
return data.map(({ label, value, className, layers }, index) => ({
@@ -66,6 +66,10 @@ export default Component.extend(WindowResizable, {
this.renderChart();
},
updateChart: observer('_data.@each.{value,label,className}', function() {
this.renderChart();
}),
// prettier-ignore
/* eslint-disable */
renderChart() {
@@ -73,7 +77,7 @@ export default Component.extend(WindowResizable, {
const width = this.$('svg').width();
const filteredData = _data.filter(d => d.value > 0);
let slices = chart.select('.bars').selectAll('g').data(filteredData);
let slices = chart.select('.bars').selectAll('g').data(filteredData, d => d.label);
let sliceCount = filteredData.length;
slices.exit().remove();
@@ -82,7 +86,8 @@ export default Component.extend(WindowResizable, {
.append('g')
.on('mouseenter', d => {
run(() => {
const slice = slices.filter(datum => datum === d);
const slices = this.get('slices');
const slice = slices.filter(datum => datum.label === d.label);
slices.classed('active', false).classed('inactive', true);
slice.classed('active', true).classed('inactive', false);
this.set('activeDatum', d);
@@ -99,7 +104,15 @@ export default Component.extend(WindowResizable, {
});
slices = slices.merge(slicesEnter);
slices.attr('class', d => d.className || `slice-${_data.indexOf(d)}`);
slices.attr('class', d => {
const className = d.className || `slice-${_data.indexOf(d)}`
const activeDatum = this.get('activeDatum');
const isActive = activeDatum && activeDatum.label === d.label;
const isInactive = activeDatum && activeDatum.label !== d.label;
return [ className, isActive && 'active', isInactive && 'inactive' ].compact().join(' ');
});
this.set('slices', slices);
const setWidth = d => `${width * d.percent - (d.index === sliceCount - 1 || d.index === 0 ? 1 : 2)}px`
const setOffset = d => `${width * d.offset + (d.index === 0 ? 0 : 1)}px`
@@ -117,7 +130,6 @@ export default Component.extend(WindowResizable, {
.attr('width', setWidth)
.attr('x', setOffset)
let layers = slices.selectAll('.bar').data((d, i) => {
return new Array(d.layers || 1).fill(assign({ index: i }, d));
});

View File

@@ -1,7 +1,12 @@
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import { lazyClick } from '../helpers/lazy-click';
import { watchRelationship } from 'nomad-ui/utils/properties/watch';
import WithVisibilityDetection from 'nomad-ui/mixins/with-component-visibility-detection';
export default Component.extend(WithVisibilityDetection, {
store: service(),
export default Component.extend({
tagName: 'tr',
classNames: ['job-row', 'is-interactive'],
@@ -17,7 +22,27 @@ export default Component.extend({
// Reload the job in order to get detail information
const job = this.get('job');
if (job && !job.get('isLoading')) {
job.reload();
job.reload().then(() => {
this.get('watch').perform(job, 100);
});
}
},
visibilityHandler() {
if (document.hidden) {
this.get('watch').cancelAll();
} else {
const job = this.get('job');
if (job && !job.get('isLoading')) {
this.get('watch').perform(job, 100);
}
}
},
willDestroy() {
this.get('watch').cancelAll();
this._super(...arguments);
},
watch: watchRelationship('summary'),
});

View File

@@ -12,7 +12,9 @@ export default Component.extend({
verbose: true,
annotatedVersions: computed('versions.[]', function() {
const versions = this.get('versions');
const versions = this.get('versions')
.sortBy('submitTime')
.reverse();
return versions.map((version, index) => {
const meta = {};

View File

@@ -1,6 +1,7 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { run } from '@ember/runloop';
import { copy } from '@ember/object/internals';
import JSONFormatterPkg from 'npm:json-formatter-js';
// json-formatter-js is packaged in a funny way that ember-cli-browserify
@@ -14,7 +15,7 @@ export default Component.extend({
expandDepth: Infinity,
formatter: computed('json', 'expandDepth', function() {
return new JSONFormatter(this.get('json'), this.get('expandDepth'), {
return new JSONFormatter(copy(this.get('json'), true), this.get('expandDepth'), {
theme: 'nomad',
});
}),

View File

@@ -37,7 +37,7 @@ export default Controller.extend(Sortable, Searchable, {
'system.namespaces.length',
function() {
const hasNamespaces = this.get('system.namespaces.length');
const activeNamespace = this.get('system.activeNamespace.id');
const activeNamespace = this.get('system.activeNamespace.id') || 'default';
return this.get('model')
.filter(job => !hasNamespaces || job.get('namespace.id') === activeNamespace)

View File

@@ -1,8 +1,13 @@
import Mixin from '@ember/object/mixin';
import { run } from '@ember/runloop';
import { assert } from '@ember/debug';
import $ from 'jquery';
export default Mixin.create({
windowResizeHandler() {
assert('windowResizeHandler needs to be overridden in the Component', false);
},
setupWindowResize: function() {
run.scheduleOnce('afterRender', this, () => {
this.set('_windowResizeHandler', this.get('windowResizeHandler').bind(this));

View File

@@ -0,0 +1,17 @@
import Mixin from '@ember/object/mixin';
import { assert } from '@ember/debug';
export default Mixin.create({
visibilityHandler() {
assert('visibilityHandler needs to be overridden in the Component', false);
},
setupDocumentVisibility: function() {
this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
}.on('init'),
removeDocumentVisibility: function() {
document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
}.on('willDestroy'),
});

View File

@@ -0,0 +1,17 @@
import Mixin from '@ember/object/mixin';
import { assert } from '@ember/debug';
export default Mixin.create({
visibilityHandler() {
assert('visibilityHandler needs to be overridden in the Route', false);
},
setupDocumentVisibility: function() {
this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
}.on('activate'),
removeDocumentVisibility: function() {
document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
}.on('deactivate'),
});

View File

@@ -0,0 +1,38 @@
import Mixin from '@ember/object/mixin';
import { computed } from '@ember/object';
import { assert } from '@ember/debug';
import WithVisibilityDetection from './with-route-visibility-detection';
export default Mixin.create(WithVisibilityDetection, {
watchers: computed(() => []),
cancelAllWatchers() {
this.get('watchers').forEach(watcher => {
assert('Watchers must be Ember Concurrency Tasks.', !!watcher.cancelAll);
watcher.cancelAll();
});
},
startWatchers() {
assert('startWatchers needs to be overridden in the Route', false);
},
setupController() {
this.startWatchers(...arguments);
return this._super(...arguments);
},
visibilityHandler() {
if (document.hidden) {
this.cancelAllWatchers();
} else {
this.startWatchers(this.controller, this.controller.get('model'));
}
},
actions: {
willTransition() {
this.cancelAllWatchers();
},
},
});

View File

@@ -0,0 +1,39 @@
import { collect, sum } from '@ember/object/computed';
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { belongsTo } from 'ember-data/relationships';
import { fragmentArray } from 'ember-data-model-fragments/attributes';
import sumAggregation from '../utils/properties/sum-aggregation';
export default Model.extend({
job: belongsTo('job'),
taskGroupSummaries: fragmentArray('task-group-summary'),
// Aggregate allocation counts across all summaries
queuedAllocs: sumAggregation('taskGroupSummaries', 'queuedAllocs'),
startingAllocs: sumAggregation('taskGroupSummaries', 'startingAllocs'),
runningAllocs: sumAggregation('taskGroupSummaries', 'runningAllocs'),
completeAllocs: sumAggregation('taskGroupSummaries', 'completeAllocs'),
failedAllocs: sumAggregation('taskGroupSummaries', 'failedAllocs'),
lostAllocs: sumAggregation('taskGroupSummaries', 'lostAllocs'),
allocsList: collect(
'queuedAllocs',
'startingAllocs',
'runningAllocs',
'completeAllocs',
'failedAllocs',
'lostAllocs'
),
totalAllocs: sum('allocsList'),
pendingChildren: attr('number'),
runningChildren: attr('number'),
deadChildren: attr('number'),
childrenList: collect('pendingChildren', 'runningChildren', 'deadChildren'),
totalChildren: sum('childrenList'),
});

View File

@@ -1,10 +1,9 @@
import { collect, sum, bool, equal, or } from '@ember/object/computed';
import { alias, bool, equal, or } from '@ember/object/computed';
import { computed } from '@ember/object';
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { belongsTo, hasMany } from 'ember-data/relationships';
import { fragmentArray } from 'ember-data-model-fragments/attributes';
import sumAggregation from '../utils/properties/sum-aggregation';
const JOB_TYPES = ['service', 'batch', 'system'];
@@ -83,34 +82,21 @@ export default Model.extend({
datacenters: attr(),
taskGroups: fragmentArray('task-group', { defaultValue: () => [] }),
taskGroupSummaries: fragmentArray('task-group-summary'),
summary: belongsTo('job-summary'),
// Aggregate allocation counts across all summaries
queuedAllocs: sumAggregation('taskGroupSummaries', 'queuedAllocs'),
startingAllocs: sumAggregation('taskGroupSummaries', 'startingAllocs'),
runningAllocs: sumAggregation('taskGroupSummaries', 'runningAllocs'),
completeAllocs: sumAggregation('taskGroupSummaries', 'completeAllocs'),
failedAllocs: sumAggregation('taskGroupSummaries', 'failedAllocs'),
lostAllocs: sumAggregation('taskGroupSummaries', 'lostAllocs'),
allocsList: collect(
'queuedAllocs',
'startingAllocs',
'runningAllocs',
'completeAllocs',
'failedAllocs',
'lostAllocs'
),
totalAllocs: sum('allocsList'),
pendingChildren: attr('number'),
runningChildren: attr('number'),
deadChildren: attr('number'),
childrenList: collect('pendingChildren', 'runningChildren', 'deadChildren'),
totalChildren: sum('childrenList'),
// Alias through to the summary, as if there was no relationship
taskGroupSummaries: alias('summary.taskGroupSummaries'),
queuedAllocs: alias('summary.queuedAllocs'),
startingAllocs: alias('summary.startingAllocs'),
runningAllocs: alias('summary.runningAllocs'),
completeAllocs: alias('summary.completeAllocs'),
failedAllocs: alias('summary.failedAllocs'),
lostAllocs: alias('summary.lostAllocs'),
totalAllocs: alias('summary.totalAllocs'),
pendingChildren: alias('summary.pendingChildren'),
runningChildren: alias('summary.runningChildren'),
deadChildren: alias('summary.deadChildren'),
totalChildren: alias('summary.childrenList'),
version: attr('number'),

View File

@@ -4,6 +4,8 @@ import attr from 'ember-data/attr';
import { fragmentOwner, fragmentArray } from 'ember-data-model-fragments/attributes';
import sumAggregation from '../utils/properties/sum-aggregation';
const maybe = arr => arr || [];
export default Fragment.extend({
job: fragmentOwner(),
@@ -13,7 +15,7 @@ export default Fragment.extend({
tasks: fragmentArray('task'),
allocations: computed('job.allocations.@each.taskGroup', function() {
return this.get('job.allocations').filterBy('taskGroupName', this.get('name'));
return maybe(this.get('job.allocations')).filterBy('taskGroupName', this.get('name'));
}),
reservedCPU: sumAggregation('tasks', 'reservedCPU'),
@@ -32,6 +34,6 @@ export default Fragment.extend({
}),
summary: computed('job.taskGroupSummaries.[]', function() {
return this.get('job.taskGroupSummaries').findBy('name', this.get('name'));
return maybe(this.get('job.taskGroupSummaries')).findBy('name', this.get('name'));
}),
});

View File

@@ -1,4 +1,15 @@
import Route from '@ember/routing/route';
import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling';
import { collect } from '@ember/object/computed';
import { watchRecord } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default Route.extend(WithModelErrorHandling);
export default Route.extend(WithModelErrorHandling, WithWatchers, {
startWatchers(controller, model) {
controller.set('watcher', this.get('watch').perform(model));
},
watch: watchRecord('allocation'),
watchers: collect('watch'),
});

View File

@@ -1,5 +1,6 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import { AbortError } from 'ember-data/adapters/errors';
export default Route.extend({
config: service(),
@@ -22,7 +23,9 @@ export default Route.extend({
},
error(error) {
this.controllerFor('application').set('error', error);
if (!(error instanceof AbortError)) {
this.controllerFor('application').set('error', error);
}
},
},
});

View File

@@ -1,8 +1,11 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
import notifyError from 'nomad-ui/utils/notify-error';
import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default Route.extend({
export default Route.extend(WithWatchers, {
store: service(),
model() {
@@ -15,4 +18,14 @@ export default Route.extend({
}
return model && model.get('allocations');
},
startWatchers(controller, model) {
controller.set('watchModel', this.get('watch').perform(model));
controller.set('watchAllocations', this.get('watchAllocations').perform(model));
},
watch: watchRecord('node'),
watchAllocations: watchRelationship('allocations'),
watchers: collect('watch', 'watchAllocations'),
});

View File

@@ -0,0 +1,13 @@
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
import { watchAll } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default Route.extend(WithWatchers, {
startWatchers(controller) {
controller.set('watcher', this.get('watch').perform());
},
watch: watchAll('node'),
watchers: collect('watch'),
});

View File

@@ -1,6 +1,16 @@
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
import { watchAll } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default Route.extend(WithWatchers, {
startWatchers(controller) {
controller.set('modelWatch', this.get('watch').perform());
},
watch: watchAll('job'),
watchers: collect('watch'),
export default Route.extend({
actions: {
refreshRoute() {
return true;

View File

@@ -5,6 +5,7 @@ import notifyError from 'nomad-ui/utils/notify-error';
export default Route.extend({
store: service(),
token: service(),
serialize(model) {
return { job_name: model.get('plainId') };

View File

@@ -1,9 +1,22 @@
import Route from '@ember/routing/route';
import RSVP from 'rsvp';
import { collect } from '@ember/object/computed';
import { watchRelationship } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default Route.extend({
export default Route.extend(WithWatchers, {
model() {
const job = this.modelFor('jobs.job');
return RSVP.all([job.get('deployments'), job.get('versions')]).then(() => job);
},
startWatchers(controller, model) {
controller.set('watchDeployments', this.get('watchDeployments').perform(model));
controller.set('watchVersions', this.get('watchVersions').perform(model));
},
watchDeployments: watchRelationship('deployments'),
watchVersions: watchRelationship('versions'),
watchers: collect('watchDeployments', 'watchVersions'),
});

View File

@@ -0,0 +1,22 @@
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default Route.extend(WithWatchers, {
startWatchers(controller, model) {
controller.set('watchers', {
model: this.get('watch').perform(model),
summary: this.get('watchSummary').perform(model),
evaluations: this.get('watchEvaluations').perform(model),
deployments: this.get('watchDeployments').perform(model),
});
},
watch: watchRecord('job'),
watchSummary: watchRelationship('summary'),
watchEvaluations: watchRelationship('evaluations'),
watchDeployments: watchRelationship('deployments'),
watchers: collect('watch', 'watchSummary', 'watchEvaluations', 'watchDeployments'),
});

View File

@@ -1,6 +1,9 @@
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default Route.extend({
export default Route.extend(WithWatchers, {
model({ name }) {
// If the job is a partial (from the list request) it won't have task
// groups. Reload the job to ensure task groups are present.
@@ -15,4 +18,19 @@ export default Route.extend({
});
});
},
startWatchers(controller, model) {
const job = model.get('job');
controller.set('watchers', {
job: this.get('watchJob').perform(job),
summary: this.get('watchSummary').perform(job),
allocations: this.get('watchAllocations').perform(job),
});
},
watchJob: watchRecord('job'),
watchSummary: watchRelationship('summary'),
watchAllocations: watchRelationship('allocations'),
watchers: collect('watchJob', 'watchSummary', 'watchAllocations'),
});

View File

@@ -1,8 +1,18 @@
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
import { watchRelationship } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default Route.extend({
export default Route.extend(WithWatchers, {
model() {
const job = this.modelFor('jobs.job');
return job.get('versions').then(() => job);
},
startWatchers(controller, model) {
controller.set('watcher', this.get('watchVersions').perform(model));
},
watchVersions: watchRelationship('versions'),
watchers: collect('watchVersions'),
});

View File

@@ -1,3 +1,5 @@
import { copy } from '@ember/object/internals';
import { get } from '@ember/object';
import { makeArray } from '@ember/array';
import JSONSerializer from 'ember-data/serializers/json';
@@ -35,9 +37,33 @@ export default JSONSerializer.extend({
documentHash.included.push(...included);
}
});
store.push(documentHash);
});
store.push(documentHash);
},
normalizeFindAllResponse(store, modelClass) {
const result = this._super(...arguments);
this.cullStore(store, modelClass.modelName, result.data);
return result;
},
// When records are removed server-side, and therefore don't show up in requests,
// the local copies of those records need to be unloaded from the store.
cullStore(store, type, records, storeFilter = () => true) {
const newRecords = copy(records).filter(record => get(record, 'id'));
const oldRecords = store.peekAll(type);
oldRecords
.filter(record => get(record, 'id'))
.filter(storeFilter)
.forEach(old => {
const newRecord = newRecords.find(record => get(record, 'id') === get(old, 'id'));
if (!newRecord) {
store.unloadRecord(old);
} else {
newRecords.removeObject(newRecord);
}
});
},
modelNameFromPayloadKey(key) {

View File

@@ -0,0 +1,32 @@
import { get } from '@ember/object';
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
normalize(modelClass, hash) {
// Transform the map-based Summary object into an array-based
// TaskGroupSummary fragment list
hash.PlainJobId = hash.JobID;
hash.ID = JSON.stringify([hash.JobID, hash.Namespace || 'default']);
hash.TaskGroupSummaries = Object.keys(get(hash, 'Summary') || {}).map(key => {
const allocStats = get(hash, `Summary.${key}`) || {};
const summary = { Name: key };
Object.keys(allocStats).forEach(
allocKey => (summary[`${allocKey}Allocs`] = allocStats[allocKey])
);
return summary;
});
// Lift the children stats out of the Children object
const childrenStats = get(hash, 'Children');
if (childrenStats) {
Object.keys(childrenStats).forEach(
childrenKey => (hash[`${childrenKey}Children`] = childrenStats[childrenKey])
);
}
return this._super(modelClass, hash);
},
});

View File

@@ -11,6 +11,7 @@ export default ApplicationSerializer.extend({
assign({}, version, {
Diff: hash.Diffs && hash.Diffs[index],
ID: `${version.ID}-${version.Version}`,
JobID: JSON.stringify([version.ID, version.Namespace || 'default']),
SubmitTime: Math.floor(version.SubmitTime / 1000000),
SubmitTimeNanos: version.SubmitTime % 1000000,
})

View File

@@ -1,4 +1,3 @@
import { get } from '@ember/object';
import { assign } from '@ember/polyfills';
import ApplicationSerializer from './application';
import queryString from 'npm:query-string';
@@ -34,27 +33,6 @@ export default ApplicationSerializer.extend({
hash.ParameterizedJob = true;
}
// Transform the map-based JobSummary object into an array-based
// JobSummary fragment list
hash.TaskGroupSummaries = Object.keys(get(hash, 'JobSummary.Summary') || {}).map(key => {
const allocStats = get(hash, `JobSummary.Summary.${key}`) || {};
const summary = { Name: key };
Object.keys(allocStats).forEach(
allocKey => (summary[`${allocKey}Allocs`] = allocStats[allocKey])
);
return summary;
});
// Lift the children stats out of the JobSummary object
const childrenStats = get(hash, 'JobSummary.Children');
if (childrenStats) {
Object.keys(childrenStats).forEach(
childrenKey => (hash[`${childrenKey}Children`] = childrenStats[childrenKey])
);
}
return this._super(typeHash, hash);
},
@@ -63,11 +41,17 @@ export default ApplicationSerializer.extend({
!hash.NamespaceID || hash.NamespaceID === 'default' ? undefined : hash.NamespaceID;
const { modelName } = modelClass;
const jobURL = this.store
const [jobURL] = this.store
.adapterFor(modelName)
.buildURL(modelName, hash.PlainId, hash, 'findRecord');
.buildURL(modelName, hash.ID, hash, 'findRecord')
.split('?');
return assign(this._super(...arguments), {
summary: {
links: {
related: buildURL(`${jobURL}/summary`, { namespace: namespace }),
},
},
allocations: {
links: {
related: buildURL(`${jobURL}/allocations`, { namespace: namespace }),

View File

@@ -0,0 +1,23 @@
import { readOnly } from '@ember/object/computed';
import { copy } from '@ember/object/internals';
import Service from '@ember/service';
let list = {};
export default Service.extend({
list: readOnly(function() {
return copy(list, true);
}),
init() {
list = {};
},
getIndexFor(url) {
return list[url] || 0;
},
setIndexFor(url, value) {
list[url] = value;
},
});

View File

@@ -1,4 +1,4 @@
{{#each annotatedDeployments as |record|}}
{{#each annotatedDeployments key="deployment.id" as |record|}}
{{#if record.meta.showDate}}
<li data-test-deployment-time class="timeline-note">
{{#if record.deployment.version.submitTime}}

View File

@@ -5,10 +5,10 @@
<td data-test-job-type>{{job.displayType}}</td>
<td data-test-job-priority>{{job.priority}}</td>
<td data-test-job-task-groups>
{{#if job.isReloading}}
...
{{else}}
{{#if job.taskGroups.length}}
{{job.taskGroups.length}}
{{else}}
--
{{/if}}
</td>
<td data-test-job-allocations>

View File

@@ -1,4 +1,4 @@
{{#each annotatedVersions as |record|}}
{{#each annotatedVersions key="version.id" as |record|}}
{{#if record.meta.showDate}}
<li data-test-version-time class="timeline-note">
{{moment-format record.version.submitTime "MMMM D, YYYY"}}

View File

@@ -0,0 +1,73 @@
import Ember from 'ember';
import { get } from '@ember/object';
import RSVP from 'rsvp';
import { task } from 'ember-concurrency';
import wait from 'nomad-ui/utils/wait';
export function watchRecord(modelName) {
return task(function*(id, throttle = 2000) {
if (typeof id === 'object') {
id = get(id, 'id');
}
while (!Ember.testing) {
try {
yield RSVP.all([
this.get('store').findRecord(modelName, id, {
reload: true,
adapterOptions: { watch: true },
}),
wait(throttle),
]);
} catch (e) {
yield e;
break;
} finally {
this.get('store')
.adapterFor(modelName)
.cancelFindRecord(modelName, id);
}
}
}).drop();
}
export function watchRelationship(relationshipName) {
return task(function*(model, throttle = 2000) {
while (!Ember.testing) {
try {
yield RSVP.all([
this.get('store')
.adapterFor(model.constructor.modelName)
.reloadRelationship(model, relationshipName, true),
wait(throttle),
]);
} catch (e) {
yield e;
break;
} finally {
this.get('store')
.adapterFor(model.constructor.modelName)
.cancelReloadRelationship(model, relationshipName);
}
}
}).drop();
}
export function watchAll(modelName) {
return task(function*(throttle = 2000) {
while (!Ember.testing) {
try {
yield RSVP.all([
this.get('store').findAll(modelName, { reload: true, adapterOptions: { watch: true } }),
wait(throttle),
]);
} catch (e) {
yield e;
break;
} finally {
this.get('store')
.adapterFor(modelName)
.cancelFindAll(modelName);
}
}
}).drop();
}

10
ui/app/utils/wait.js Normal file
View File

@@ -0,0 +1,10 @@
import RSVP from 'rsvp';
// An always passing promise used to throttle other promises
export default function wait(duration) {
return new RSVP.Promise(resolve => {
setTimeout(() => {
resolve(`Waited ${duration}ms`);
}, duration);
});
}

View File

@@ -11,41 +11,72 @@ export function findLeader(schema) {
}
export default function() {
const server = this;
this.timing = 0; // delay for each request, automatically set to 0 during testing
this.namespace = 'v1';
this.trackRequests = Ember.testing;
this.get('/jobs', function({ jobs }, { queryParams }) {
const json = this.serialize(jobs.all());
const namespace = queryParams.namespace || 'default';
return json
.filter(
job =>
namespace === 'default'
? !job.NamespaceID || job.NamespaceID === namespace
: job.NamespaceID === namespace
)
.map(job => filterKeys(job, 'TaskGroups', 'NamespaceID'));
});
const nomadIndices = {}; // used for tracking blocking queries
const server = this;
const withBlockingSupport = function(fn) {
return function(schema, request) {
// Get the original response
let { url } = request;
url = url.replace(/index=\d+[&;]?/, '');
const response = fn.apply(this, arguments);
this.get('/job/:id', function({ jobs }, { params, queryParams }) {
const job = jobs.all().models.find(job => {
const jobIsDefault = !job.namespaceId || job.namespaceId === 'default';
const qpIsDefault = !queryParams.namespace || queryParams.namespace === 'default';
return (
job.id === params.id &&
(job.namespaceId === queryParams.namespace || (jobIsDefault && qpIsDefault))
);
});
// Get and increment the approrpriate index
nomadIndices[url] || (nomadIndices[url] = 1);
const index = nomadIndices[url];
nomadIndices[url]++;
return job ? this.serialize(job) : new Response(404, {}, null);
});
// Annotate the response with the index
if (response instanceof Response) {
response.headers['X-Nomad-Index'] = index;
return response;
}
return new Response(200, { 'x-nomad-index': index }, response);
};
};
this.get('/job/:id/summary', function({ jobSummaries }, { params }) {
return this.serialize(jobSummaries.findBy({ jobId: params.id }));
});
this.get(
'/jobs',
withBlockingSupport(function({ jobs }, { queryParams }) {
const json = this.serialize(jobs.all());
const namespace = queryParams.namespace || 'default';
return json
.filter(
job =>
namespace === 'default'
? !job.NamespaceID || job.NamespaceID === namespace
: job.NamespaceID === namespace
)
.map(job => filterKeys(job, 'TaskGroups', 'NamespaceID'));
})
);
this.get(
'/job/:id',
withBlockingSupport(function({ jobs }, { params, queryParams }) {
const job = jobs.all().models.find(job => {
const jobIsDefault = !job.namespaceId || job.namespaceId === 'default';
const qpIsDefault = !queryParams.namespace || queryParams.namespace === 'default';
return (
job.id === params.id &&
(job.namespaceId === queryParams.namespace || (jobIsDefault && qpIsDefault))
);
});
return job ? this.serialize(job) : new Response(404, {}, null);
})
);
this.get(
'/job/:id/summary',
withBlockingSupport(function({ jobSummaries }, { params }) {
return this.serialize(jobSummaries.findBy({ jobId: params.id }));
})
);
this.get('/job/:id/allocations', function({ allocations }, { params }) {
return this.serialize(allocations.where({ jobId: params.id }));

View File

@@ -15,6 +15,8 @@ export default Factory.extend({
modifyTime: () => faker.date.past(2 / 365, REF_TIME) * 1000000,
namespace: null,
clientStatus: faker.list.random(...CLIENT_STATUSES),
desiredStatus: faker.list.random(...DESIRED_STATUSES),

View File

@@ -65,7 +65,7 @@ export default Factory.extend({
const failedTaskGroupNames = [];
for (let i = 0; i < failedTaskGroupsCount; i++) {
failedTaskGroupNames.push(
...taskGroupNames.splice(faker.random.number(taskGroupNames.length), 1)
...taskGroupNames.splice(faker.random.number(taskGroupNames.length - 1), 1)
);
}

View File

@@ -5,6 +5,7 @@ export default Factory.extend({
groupNames: [],
JobID: '',
namespace: null,
withSummary: trait({
Summary: function() {

View File

@@ -25,6 +25,7 @@ export default Factory.extend({
version.activeDeployment && 'active',
{
jobId: version.jobId,
namespace: version.job.namespace,
versionNumber: version.version,
},
].compact();

View File

@@ -1,3 +1,4 @@
import { assign } from '@ember/polyfills';
import { Factory, faker, trait } from 'ember-cli-mirage';
import { provide, provider, pickOne } from '../utils';
import { DATACENTERS } from '../common';
@@ -86,16 +87,6 @@ export default Factory.extend({
noFailedPlacements: false,
afterCreate(job, server) {
const groups = server.createList('task-group', job.groupsCount, {
job,
createAllocations: job.createAllocations,
});
job.update({
taskGroupIds: groups.mapBy('id'),
task_group_ids: groups.mapBy('id'),
});
if (!job.namespaceId) {
const namespace = server.db.namespaces.length ? pickOne(server.db.namespaces).id : null;
job.update({
@@ -108,10 +99,22 @@ export default Factory.extend({
});
}
const groups = server.createList('task-group', job.groupsCount, {
job,
createAllocations: job.createAllocations,
});
job.update({
taskGroupIds: groups.mapBy('id'),
task_group_ids: groups.mapBy('id'),
});
const hasChildren = job.periodic || job.parameterized;
const jobSummary = server.create('job-summary', hasChildren ? 'withChildren' : 'withSummary', {
groupNames: groups.mapBy('name'),
job,
job_id: job.id,
namespace: job.namespace,
});
job.update({
@@ -124,22 +127,39 @@ export default Factory.extend({
.map((_, index) => {
return server.create('job-version', {
job,
namespace: job.namespace,
version: index,
noActiveDeployment: job.noActiveDeployment,
activeDeployment: job.activeDeployment,
});
});
server.createList('evaluation', faker.random.number({ min: 1, max: 5 }), { job });
const knownEvaluationProperties = {
job,
namespace: job.namespace,
};
server.createList(
'evaluation',
faker.random.number({ min: 1, max: 5 }),
knownEvaluationProperties
);
if (!job.noFailedPlacements) {
server.createList('evaluation', faker.random.number(3), 'withPlacementFailures', { job });
server.createList(
'evaluation',
faker.random.number(3),
'withPlacementFailures',
knownEvaluationProperties
);
}
if (job.failedPlacements) {
server.create('evaluation', 'withPlacementFailures', {
job,
modifyIndex: 4000,
});
server.create(
'evaluation',
'withPlacementFailures',
assign(knownEvaluationProperties, {
modifyIndex: 4000,
})
);
}
if (job.periodic) {

View File

@@ -32,6 +32,7 @@ export default Factory.extend({
.forEach((_, i) => {
server.create('allocation', {
jobId: group.job.id,
namespace: group.job.namespace,
taskGroup: group.name,
name: `${group.name}.[${i}]`,
});

View File

@@ -0,0 +1,33 @@
import { getOwner } from '@ember/application';
import { moduleForModel } from 'ember-qunit';
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
export default function(modelName, description, options = { needs: [] }) {
// moduleForModel correctly creates the store service
// but moduleFor does not.
moduleForModel(modelName, description, {
unit: true,
needs: options.needs,
beforeEach() {
const model = this.subject();
// Initializers don't run automatically in unit tests
fragmentSerializerInitializer(getOwner(model));
// Reassign the subject to provide the adapter
this.subject = () => model.store.adapterFor(modelName);
// Expose the store as well, since it is a parameter for many adapter methods
this.store = model.store;
if (options.beforeEach) {
options.beforeEach.apply(this, arguments);
}
},
afterEach() {
if (options.afterEach) {
options.afterEach.apply(this, arguments);
}
},
});
}

View File

@@ -17,6 +17,9 @@ export default function(modelName, description, options = { needs: [] }) {
// Reassign the subject to provide the serializer
this.subject = () => model.store.serializerFor(modelName);
// Expose the store as well, since it is a parameter for many serializer methods
this.store = model.store;
if (options.beforeEach) {
options.beforeEach.apply(this, arguments);
}

View File

@@ -159,6 +159,8 @@ test('is sorted based on the sortProperty and sortDescending properties', functi
`Child ${index} is ${child.get('name')}`
);
});
return wait();
});
});
});

View File

@@ -63,11 +63,15 @@ test('Clicking Force Launch launches a new periodic child job', function(assert)
return wait().then(() => {
const id = job.get('plainId');
const namespace = job.get('namespace.name') || 'default';
let expectedURL = `/v1/job/${id}/periodic/force`;
if (namespace !== 'default') {
expectedURL += `?namespace=${namespace}`;
}
assert.ok(
server.pretender.handledRequests
.filterBy('method', 'POST')
.find(req => req.url === `/v1/job/${id}/periodic/force?namespace=${namespace}`),
.find(req => req.url === expectedURL),
'POST URL was correct'
);

View File

@@ -19,7 +19,7 @@ test('should render the placement failure (basic render)', function(assert) {
'taskGroup',
createFixture(
{
coalescedFailures: failures - 1
coalescedFailures: failures - 1,
},
name
)
@@ -77,22 +77,16 @@ test('should render the placement failure (basic render)', function(assert) {
1,
'Quota exhausted message shown'
);
assert.equal(
findAll('[data-test-placement-failure-scores]').length,
1,
'Scores message shown'
);
assert.equal(findAll('[data-test-placement-failure-scores]').length, 1, 'Scores message shown');
});
test('should render correctly when a node is not evaluated', function(assert) {
this.set(
'taskGroup',
createFixture(
{
nodesEvaluated: 1,
nodesExhausted: 0
}
)
createFixture({
nodesEvaluated: 1,
nodesExhausted: 0,
})
);
this.render(commonTemplate);
@@ -112,33 +106,34 @@ test('should render correctly when a node is not evaluated', function(assert) {
function createFixture(obj = {}, name = 'Placement Failure') {
return {
name: name,
placementFailures: assign({
coalescedFailures: 10,
nodesEvaluated: 0,
nodesAvailable: {
datacenter: 0,
placementFailures: assign(
{
coalescedFailures: 10,
nodesEvaluated: 0,
nodesAvailable: {
datacenter: 0,
},
classFiltered: {
filtered: 1,
},
constraintFiltered: {
'prop = val': 1,
},
nodesExhausted: 3,
classExhausted: {
class: 3,
},
dimensionExhausted: {
iops: 3,
},
quotaExhausted: {
quota: 'dimension',
},
scores: {
name: 3,
},
},
classFiltered: {
filtered: 1,
},
constraintFiltered: {
'prop = val': 1,
},
nodesExhausted: 3,
classExhausted: {
class: 3,
},
dimensionExhausted: {
iops: 3,
},
quotaExhausted: {
quota: 'dimension',
},
scores: {
name: 3,
},
},
obj
)
obj
),
};
}

View File

@@ -1,9 +1,20 @@
import { test, moduleFor } from 'ember-qunit';
import { run } from '@ember/runloop';
import { assign } from '@ember/polyfills';
import { test } from 'ember-qunit';
import wait from 'ember-test-helpers/wait';
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
import moduleForAdapter from '../../helpers/module-for-adapter';
moduleFor('adapter:job', 'Unit | Adapter | Job', {
unit: true,
needs: ['service:token', 'service:system', 'model:namespace', 'adapter:application'],
moduleForAdapter('job', 'Unit | Adapter | Job', {
needs: [
'adapter:job',
'service:token',
'service:system',
'model:namespace',
'model:job-summary',
'adapter:application',
'service:watchList',
],
beforeEach() {
window.sessionStorage.clear();
@@ -27,8 +38,8 @@ test('The job summary is stitched into the job request', function(assert) {
assert.deepEqual(
pretender.handledRequests.mapBy('url'),
['/v1/namespaces', `/v1/job/${jobName}`, `/v1/job/${jobName}/summary`],
'The three requests made are /namespaces, /job/:id, and /job/:id/summary'
['/v1/namespaces', `/v1/job/${jobName}`],
'The two requests made are /namespaces and /job/:id'
);
});
@@ -42,18 +53,12 @@ test('When the job has a namespace other than default, it is in the URL', functi
assert.deepEqual(
pretender.handledRequests.mapBy('url'),
[
'/v1/namespaces',
`/v1/job/${jobName}?namespace=${jobNamespace}`,
`/v1/job/${jobName}/summary?namespace=${jobNamespace}`,
],
'The three requests made are /namespaces, /job/:id?namespace=:namespace, and /job/:id/summary?namespace=:namespace'
['/v1/namespaces', `/v1/job/${jobName}?namespace=${jobNamespace}`],
'The two requests made are /namespaces and /job/:id?namespace=:namespace'
);
});
test('When there is no token set in the token service, no x-nomad-token header is set', function(
assert
) {
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']);
@@ -65,9 +70,7 @@ test('When there is no token set in the token service, no x-nomad-token header i
);
});
test('When a token is set in the token service, then x-nomad-token header is set', function(
assert
) {
test('When a token is set in the token service, then x-nomad-token header is set', function(assert) {
const { pretender } = this.server;
const jobId = JSON.stringify(['job-1', 'default']);
const secret = 'here is the secret';
@@ -82,3 +85,196 @@ test('When a token is set in the token service, then x-nomad-token header is set
'The token header is present on both job requests'
);
});
test('findAll can be watched', function(assert) {
const { pretender } = this.server;
const request = () =>
this.subject().findAll(null, { modelName: 'job' }, null, {
reload: true,
adapterOptions: { watch: true },
});
request();
assert.equal(
pretender.handledRequests[0].url,
'/v1/namespaces',
'First request is for namespaces'
);
assert.equal(
pretender.handledRequests[1].url,
'/v1/jobs?index=0',
'Second request is a blocking request for jobs'
);
return wait().then(() => {
request();
assert.equal(
pretender.handledRequests[2].url,
'/v1/jobs?index=1',
'Third request is a blocking request with an incremented index param'
);
return wait();
});
});
test('findRecord can be watched', function(assert) {
const jobId = JSON.stringify(['job-1', 'default']);
const { pretender } = this.server;
const request = () =>
this.subject().findRecord(null, { modelName: 'job' }, jobId, {
reload: true,
adapterOptions: { watch: true },
});
request();
assert.equal(
pretender.handledRequests[0].url,
'/v1/namespaces',
'First request is for namespaces'
);
assert.equal(
pretender.handledRequests[1].url,
'/v1/job/job-1?index=0',
'Second request is a blocking request for job-1'
);
return wait().then(() => {
request();
assert.equal(
pretender.handledRequests[2].url,
'/v1/job/job-1?index=1',
'Third request is a blocking request with an incremented index param'
);
return wait();
});
});
test('relationships can be reloaded', function(assert) {
const { pretender } = this.server;
const plainId = 'job-1';
const mockModel = makeMockModel(plainId);
this.subject().reloadRelationship(mockModel, 'summary');
assert.equal(
pretender.handledRequests[0].url,
`/v1/job/${plainId}/summary`,
'Relationship was reloaded'
);
});
test('relationship reloads can be watched', function(assert) {
const { pretender } = this.server;
const plainId = 'job-1';
const mockModel = makeMockModel(plainId);
this.subject().reloadRelationship(mockModel, 'summary', true);
assert.equal(
pretender.handledRequests[0].url,
'/v1/job/job-1/summary?index=0',
'First request is a blocking request for job-1 summary relationship'
);
return wait().then(() => {
this.subject().reloadRelationship(mockModel, 'summary', true);
assert.equal(
pretender.handledRequests[1].url,
'/v1/job/job-1/summary?index=1',
'Second request is a blocking request with an incremented index param'
);
});
});
test('findAll can be canceled', function(assert) {
const { pretender } = this.server;
pretender.get('/v1/jobs', () => [200, {}, '[]'], true);
this.subject()
.findAll(null, { modelName: 'job' }, null, {
reload: true,
adapterOptions: { watch: true },
})
.catch(() => {});
const { request: xhr } = pretender.requestReferences[0];
assert.equal(xhr.status, 0, 'Request is still pending');
// Schedule the cancelation before waiting
run.next(() => {
this.subject().cancelFindAll('job');
});
return wait().then(() => {
assert.ok(xhr.aborted, 'Request was aborted');
});
});
test('findRecord can be canceled', function(assert) {
const { pretender } = this.server;
const jobId = JSON.stringify(['job-1', 'default']);
pretender.get('/v1/job/:id', () => [200, {}, '{}'], true);
this.subject().findRecord(null, { modelName: 'job' }, jobId, {
reload: true,
adapterOptions: { watch: true },
});
const { request: xhr } = pretender.requestReferences[0];
assert.equal(xhr.status, 0, 'Request is still pending');
// Schedule the cancelation before waiting
run.next(() => {
this.subject().cancelFindRecord('job', jobId);
});
return wait().then(() => {
assert.ok(xhr.aborted, 'Request was aborted');
});
});
test('relationship reloads can be canceled', function(assert) {
const { pretender } = this.server;
const plainId = 'job-1';
const mockModel = makeMockModel(plainId);
pretender.get('/v1/job/:id/summary', () => [200, {}, '{}'], true);
this.subject().reloadRelationship(mockModel, 'summary', true);
const { request: xhr } = pretender.requestReferences[0];
assert.equal(xhr.status, 0, 'Request is still pending');
// Schedule the cancelation before waiting
run.next(() => {
this.subject().cancelReloadRelationship(mockModel, 'summary');
});
return wait().then(() => {
assert.ok(xhr.aborted, 'Request was aborted');
});
});
function makeMockModel(id, options) {
return assign(
{
relationshipFor(name) {
return {
kind: 'belongsTo',
type: 'job-summary',
key: name,
};
},
belongsTo(name) {
return {
link() {
return `/v1/job/${id}/${name}`;
},
};
},
},
options
);
}

View File

@@ -0,0 +1,123 @@
import { run } from '@ember/runloop';
import { test } from 'ember-qunit';
import wait from 'ember-test-helpers/wait';
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
import moduleForAdapter from '../../helpers/module-for-adapter';
moduleForAdapter('node', 'Unit | Adapter | Node', {
needs: [
'adapter:node',
'model:node-attributes',
'model:allocation',
'model:job',
'serializer:application',
'serializer:node',
'service:token',
'service:config',
'service:watchList',
'transform:fragment',
],
beforeEach() {
this.server = startMirage();
this.server.create('node', { id: 'node-1' });
this.server.create('node', { id: 'node-2' });
this.server.create('job', { id: 'job-1', createAllocations: false });
this.server.create('allocation', { id: 'node-1-1', nodeId: 'node-1' });
this.server.create('allocation', { id: 'node-1-2', nodeId: 'node-1' });
this.server.create('allocation', { id: 'node-2-1', nodeId: 'node-2' });
this.server.create('allocation', { id: 'node-2-2', nodeId: 'node-2' });
},
afterEach() {
this.server.shutdown();
},
});
test('findHasMany removes old related models from the store', function(assert) {
let node;
run(() => {
// Fetch the model
this.store.findRecord('node', 'node-1').then(model => {
node = model;
// Fetch the related allocations
return findHasMany(model, 'allocations').then(allocations => {
assert.equal(
allocations.get('length'),
this.server.db.allocations.where({ nodeId: node.get('id') }).length,
'Allocations returned from the findHasMany matches the db state'
);
});
});
});
return wait().then(() => {
server.db.allocations.remove('node-1-1');
run(() => {
// Reload the related allocations now that one was removed server-side
return findHasMany(node, 'allocations').then(allocations => {
const dbAllocations = this.server.db.allocations.where({ nodeId: node.get('id') });
assert.equal(
allocations.get('length'),
dbAllocations.length,
'Allocations returned from the findHasMany matches the db state'
);
assert.equal(
this.store.peekAll('allocation').get('length'),
dbAllocations.length,
'Server-side deleted allocation was removed from the store'
);
});
});
});
});
test('findHasMany does not remove old unrelated models from the store', function(assert) {
let node;
run(() => {
// Fetch the first node and related allocations
this.store.findRecord('node', 'node-1').then(model => {
node = model;
return findHasMany(model, 'allocations');
});
// Also fetch the second node and related allocations;
this.store.findRecord('node', 'node-2').then(model => findHasMany(model, 'allocations'));
});
return wait().then(() => {
assert.deepEqual(
this.store
.peekAll('allocation')
.mapBy('id')
.sort(),
['node-1-1', 'node-1-2', 'node-2-1', 'node-2-2'],
'All allocations for the first and second node are in the store'
);
server.db.allocations.remove('node-1-1');
run(() => {
// Reload the related allocations now that one was removed server-side
return findHasMany(node, 'allocations').then(() => {
assert.deepEqual(
this.store
.peekAll('allocation')
.mapBy('id')
.sort(),
['node-1-2', 'node-2-1', 'node-2-2'],
'The deleted allocation is removed from the store and the allocations associated with the other node are untouched'
);
});
});
});
});
// Using fetchLink on a model's hasMany relationship exercises the adapter's
// findHasMany method as well normalizing the response and pushing it to the store
function findHasMany(model, relationshipName) {
const relationship = model.relationshipFor(relationshipName);
return model.hasMany(relationship.key).hasManyRelationship.fetchLink();
}

View File

@@ -1,11 +1,50 @@
import { getOwner } from '@ember/application';
import { run } from '@ember/runloop';
import { moduleForModel, test } from 'ember-qunit';
moduleForModel('job', 'Unit | Model | job', {
needs: ['model:task-group', 'model:task', 'model:task-group-summary'],
needs: ['model:job-summary', 'model:task-group', 'model:task', 'model:task-group-summary'],
});
test('should expose aggregate allocations derived from task groups', function(assert) {
const store = getOwner(this).lookup('service:store');
let summary;
run(() => {
summary = store.createRecord('job-summary', {
taskGroupSummaries: [
{
name: 'one',
queuedAllocs: 1,
startingAllocs: 2,
runningAllocs: 3,
completeAllocs: 4,
failedAllocs: 5,
lostAllocs: 6,
},
{
name: 'two',
queuedAllocs: 2,
startingAllocs: 4,
runningAllocs: 6,
completeAllocs: 8,
failedAllocs: 10,
lostAllocs: 12,
},
{
name: 'three',
queuedAllocs: 3,
startingAllocs: 6,
runningAllocs: 9,
completeAllocs: 12,
failedAllocs: 15,
lostAllocs: 18,
},
],
});
});
const job = this.subject({
summary,
name: 'example',
taskGroups: [
{
@@ -24,76 +63,68 @@ test('should expose aggregate allocations derived from task groups', function(as
tasks: [],
},
],
taskGroupSummaries: [
{
name: 'one',
queuedAllocs: 1,
startingAllocs: 2,
runningAllocs: 3,
completeAllocs: 4,
failedAllocs: 5,
lostAllocs: 6,
},
{
name: 'two',
queuedAllocs: 2,
startingAllocs: 4,
runningAllocs: 6,
completeAllocs: 8,
failedAllocs: 10,
lostAllocs: 12,
},
{
name: 'three',
queuedAllocs: 3,
startingAllocs: 6,
runningAllocs: 9,
completeAllocs: 12,
failedAllocs: 15,
lostAllocs: 18,
},
],
});
assert.equal(
job.get('totalAllocs'),
job.get('taskGroups').mapBy('summary.totalAllocs').reduce((sum, allocs) => sum + allocs, 0),
job
.get('taskGroups')
.mapBy('summary.totalAllocs')
.reduce((sum, allocs) => sum + allocs, 0),
'totalAllocs is the sum of all group totalAllocs'
);
assert.equal(
job.get('queuedAllocs'),
job.get('taskGroups').mapBy('summary.queuedAllocs').reduce((sum, allocs) => sum + allocs, 0),
job
.get('taskGroups')
.mapBy('summary.queuedAllocs')
.reduce((sum, allocs) => sum + allocs, 0),
'queuedAllocs is the sum of all group queuedAllocs'
);
assert.equal(
job.get('startingAllocs'),
job.get('taskGroups').mapBy('summary.startingAllocs').reduce((sum, allocs) => sum + allocs, 0),
job
.get('taskGroups')
.mapBy('summary.startingAllocs')
.reduce((sum, allocs) => sum + allocs, 0),
'startingAllocs is the sum of all group startingAllocs'
);
assert.equal(
job.get('runningAllocs'),
job.get('taskGroups').mapBy('summary.runningAllocs').reduce((sum, allocs) => sum + allocs, 0),
job
.get('taskGroups')
.mapBy('summary.runningAllocs')
.reduce((sum, allocs) => sum + allocs, 0),
'runningAllocs is the sum of all group runningAllocs'
);
assert.equal(
job.get('completeAllocs'),
job.get('taskGroups').mapBy('summary.completeAllocs').reduce((sum, allocs) => sum + allocs, 0),
job
.get('taskGroups')
.mapBy('summary.completeAllocs')
.reduce((sum, allocs) => sum + allocs, 0),
'completeAllocs is the sum of all group completeAllocs'
);
assert.equal(
job.get('failedAllocs'),
job.get('taskGroups').mapBy('summary.failedAllocs').reduce((sum, allocs) => sum + allocs, 0),
job
.get('taskGroups')
.mapBy('summary.failedAllocs')
.reduce((sum, allocs) => sum + allocs, 0),
'failedAllocs is the sum of all group failedAllocs'
);
assert.equal(
job.get('lostAllocs'),
job.get('taskGroups').mapBy('summary.lostAllocs').reduce((sum, allocs) => sum + allocs, 0),
job
.get('taskGroups')
.mapBy('summary.lostAllocs')
.reduce((sum, allocs) => sum + allocs, 0),
'lostAllocs is the sum of all group lostAllocs'
);
});

View File

@@ -11,131 +11,7 @@ moduleForSerializer('job', 'Unit | Serializer | Job', {
],
});
test('The JobSummary object is transformed from a map to a list', function(assert) {
const original = {
ID: 'example',
ParentID: '',
Name: 'example',
Type: 'service',
Priority: 50,
Periodic: false,
ParameterizedJob: false,
Stop: false,
Status: 'running',
StatusDescription: '',
JobSummary: {
JobID: 'example',
Summary: {
cache: {
Queued: 0,
Complete: 0,
Failed: 0,
Running: 1,
Starting: 0,
Lost: 0,
},
something_else: {
Queued: 0,
Complete: 0,
Failed: 0,
Running: 2,
Starting: 0,
Lost: 0,
},
},
CreateIndex: 7,
ModifyIndex: 13,
},
CreateIndex: 7,
ModifyIndex: 9,
JobModifyIndex: 7,
};
const { data } = this.subject().normalize(JobModel, original);
assert.deepEqual(data.attributes, {
name: 'example',
plainId: 'example',
type: 'service',
priority: 50,
periodic: false,
parameterized: false,
status: 'running',
statusDescription: '',
taskGroupSummaries: [
{
name: 'cache',
queuedAllocs: 0,
completeAllocs: 0,
failedAllocs: 0,
runningAllocs: 1,
startingAllocs: 0,
lostAllocs: 0,
},
{
name: 'something_else',
queuedAllocs: 0,
completeAllocs: 0,
failedAllocs: 0,
runningAllocs: 2,
startingAllocs: 0,
lostAllocs: 0,
},
],
createIndex: 7,
modifyIndex: 9,
});
});
test('The children stats are lifted out of the JobSummary object', function(assert) {
const original = {
ID: 'example',
ParentID: '',
Name: 'example',
Type: 'service',
Priority: 50,
Periodic: false,
ParameterizedJob: false,
Stop: false,
Status: 'running',
StatusDescription: '',
JobSummary: {
JobID: 'example',
Summary: {},
Children: {
Pending: 1,
Running: 2,
Dead: 3,
},
},
CreateIndex: 7,
ModifyIndex: 9,
JobModifyIndex: 7,
};
const normalized = this.subject().normalize(JobModel, original);
assert.deepEqual(normalized.data.attributes, {
name: 'example',
plainId: 'example',
type: 'service',
priority: 50,
periodic: false,
parameterized: false,
status: 'running',
statusDescription: '',
taskGroupSummaries: [],
pendingChildren: 1,
runningChildren: 2,
deadChildren: 3,
createIndex: 7,
modifyIndex: 9,
});
});
test('`default` is used as the namespace in the job ID when there is no namespace in the payload', function(
assert
) {
test('`default` is used as the namespace in the job ID when there is no namespace in the payload', function(assert) {
const original = {
ID: 'example',
Name: 'example',

View File

@@ -0,0 +1,71 @@
import { run } from '@ember/runloop';
import { test } from 'ember-qunit';
import wait from 'ember-test-helpers/wait';
import NodeModel from 'nomad-ui/models/node';
import moduleForSerializer from '../../helpers/module-for-serializer';
import pushPayloadToStore from '../../utils/push-payload-to-store';
moduleForSerializer('node', 'Unit | Serializer | Node', {
needs: ['serializer:node', 'service:config', 'transform:fragment', 'model:allocation'],
});
test('local store is culled to reflect the state of findAll requests', function(assert) {
const findAllResponse = [
makeNode('1', 'One', '127.0.0.1:4646'),
makeNode('2', 'Two', '127.0.0.2:4646'),
makeNode('3', 'Three', '127.0.0.3:4646'),
];
const payload = this.subject().normalizeFindAllResponse(this.store, NodeModel, findAllResponse);
pushPayloadToStore(this.store, payload, NodeModel.modelName);
assert.equal(
payload.data.length,
findAllResponse.length,
'Each original record is returned in the response'
);
assert.equal(
this.store
.peekAll('node')
.filterBy('id')
.get('length'),
findAllResponse.length,
'Each original record is now in the store'
);
const newFindAllResponse = [
makeNode('2', 'Two', '127.0.0.2:4646'),
makeNode('3', 'Three', '127.0.0.3:4646'),
makeNode('4', 'Four', '127.0.0.4:4646'),
];
let newPayload;
run(() => {
newPayload = this.subject().normalizeFindAllResponse(this.store, NodeModel, newFindAllResponse);
});
pushPayloadToStore(this.store, newPayload, NodeModel.modelName);
return wait().then(() => {
assert.equal(
newPayload.data.length,
newFindAllResponse.length,
'Each new record is returned in the response'
);
assert.equal(
this.store
.peekAll('node')
.filterBy('id')
.get('length'),
newFindAllResponse.length,
'The node length in the store reflects the new response'
);
assert.notOk(this.store.peekAll('node').findBy('id', '1'), 'Record One is no longer found');
});
});
function makeNode(id, name, ip) {
return { ID: id, Name: name, HTTPAddr: ip };
}

View File

@@ -0,0 +1,12 @@
import { run } from '@ember/runloop';
// These are private store methods called by store "finder" methods.
// Useful in unit tests when there is store interaction, since calling
// adapter and serializer methods directly will never insert data into
// the store.
export default function pushPayloadToStore(store, payload, modelName) {
run(() => {
store._push(payload);
store._didUpdateAll(modelName);
});
}