diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js
index bcf223383..4bd664c1c 100644
--- a/ui/app/adapters/application.js
+++ b/ui/app/adapters/application.js
@@ -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.
//
diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js
index c783c6ff2..7d90a950c 100644
--- a/ui/app/adapters/job.js
+++ b/ui/app/adapters/job.js
@@ -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');
diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js
new file mode 100644
index 000000000..857b5f87e
--- /dev/null
+++ b/ui/app/adapters/watchable.js
@@ -0,0 +1,144 @@
+import { get, computed } from '@ember/object';
+import { assign } from '@ember/polyfills';
+import { makeArray } from '@ember/array';
+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);
+ }
+
+ return this.ajax(url, 'GET', {
+ data: params,
+ }).catch(error => {
+ if (error instanceof AbortError) {
+ return [];
+ }
+ throw error;
+ });
+ },
+
+ 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);
+ }
+
+ 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);
+ }
+
+ if (url.includes('?')) {
+ params = assign(queryString.parse(url.split('?')[1]), params);
+ }
+
+ return this.ajax(url, 'GET', {
+ data: params,
+ }).then(
+ json => {
+ this.get('store').pushPayload(relationship.type, {
+ [relationship.type]: makeArray(json),
+ });
+ },
+ 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();
+ }
+ }
+ },
+});
diff --git a/ui/app/components/distribution-bar.js b/ui/app/components/distribution-bar.js
index 46d47d8f4..50bd6fc69 100644
--- a/ui/app/components/distribution-bar.js
+++ b/ui/app/components/distribution-bar.js
@@ -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));
});
diff --git a/ui/app/components/job-row.js b/ui/app/components/job-row.js
index db9e6e369..8a09f51c0 100644
--- a/ui/app/components/job-row.js
+++ b/ui/app/components/job-row.js
@@ -1,7 +1,11 @@
+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';
export default Component.extend({
+ store: service(),
+
tagName: 'tr',
classNames: ['job-row', 'is-interactive'],
@@ -17,7 +21,16 @@ 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);
+ });
}
},
+
+ willDestroy() {
+ this.get('watch').cancelAll();
+ this._super(...arguments);
+ },
+
+ watch: watchRelationship('summary'),
});
diff --git a/ui/app/components/json-viewer.js b/ui/app/components/json-viewer.js
index cf966757d..3408de044 100644
--- a/ui/app/components/json-viewer.js
+++ b/ui/app/components/json-viewer.js
@@ -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',
});
}),
diff --git a/ui/app/models/job-summary.js b/ui/app/models/job-summary.js
new file mode 100644
index 000000000..a9584aced
--- /dev/null
+++ b/ui/app/models/job-summary.js
@@ -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'),
+});
diff --git a/ui/app/models/job.js b/ui/app/models/job.js
index b77fc7a66..ac536db04 100644
--- a/ui/app/models/job.js
+++ b/ui/app/models/job.js
@@ -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'),
diff --git a/ui/app/models/task-group.js b/ui/app/models/task-group.js
index e5ea67d0e..9be65bdba 100644
--- a/ui/app/models/task-group.js
+++ b/ui/app/models/task-group.js
@@ -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'));
}),
});
diff --git a/ui/app/routes/jobs.js b/ui/app/routes/jobs.js
index 745e326e2..181bcb182 100644
--- a/ui/app/routes/jobs.js
+++ b/ui/app/routes/jobs.js
@@ -3,6 +3,7 @@ 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';
+import { watchAll } from 'nomad-ui/utils/properties/watch';
export default Route.extend(WithForbiddenState, {
system: service(),
@@ -35,9 +36,18 @@ export default Route.extend(WithForbiddenState, {
setupController(controller) {
this.syncToController(controller);
+
+ controller.set('modelWatch', this.get('watch').perform());
return this._super(...arguments);
},
+ deactivate() {
+ this.get('watch').cancelAll();
+ this._super(...arguments);
+ },
+
+ watch: watchAll('job'),
+
actions: {
refreshRoute() {
this.refresh();
diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js
index 558ea55e4..5e16030c5 100644
--- a/ui/app/routes/jobs/job.js
+++ b/ui/app/routes/jobs/job.js
@@ -1,10 +1,14 @@
import { inject as service } from '@ember/service';
+import { collect } from '@ember/object/computed';
import Route from '@ember/routing/route';
import RSVP from 'rsvp';
import notifyError from 'nomad-ui/utils/notify-error';
+import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch';
export default Route.extend({
store: service(),
+ token: service(),
+ watchList: service(),
serialize(model) {
return { job_name: model.get('plainId') };
@@ -21,4 +25,29 @@ export default Route.extend({
})
.catch(notifyError(this));
},
+
+ setupController(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),
+ });
+
+ return this._super(...arguments);
+ },
+
+ deactivate() {
+ this.get('allWatchers').forEach(watcher => {
+ watcher.cancelAll();
+ });
+ this._super(...arguments);
+ },
+
+ watch: watchRecord('job'),
+ watchSummary: watchRelationship('summary'),
+ watchEvaluations: watchRelationship('evaluations'),
+ watchDeployments: watchRelationship('deployments'),
+
+ allWatchers: collect('watch', 'watchSummary', 'watchEvaluations', 'watchDeployments'),
});
diff --git a/ui/app/serializers/application.js b/ui/app/serializers/application.js
index 3d63f5b63..08796baad 100644
--- a/ui/app/serializers/application.js
+++ b/ui/app/serializers/application.js
@@ -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) {
diff --git a/ui/app/serializers/job-summary.js b/ui/app/serializers/job-summary.js
new file mode 100644
index 000000000..c3c8f67cd
--- /dev/null
+++ b/ui/app/serializers/job-summary.js
@@ -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);
+ },
+});
diff --git a/ui/app/serializers/job.js b/ui/app/serializers/job.js
index df77e2f38..7271f86d8 100644
--- a/ui/app/serializers/job.js
+++ b/ui/app/serializers/job.js
@@ -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 }),
diff --git a/ui/app/services/watch-list.js b/ui/app/services/watch-list.js
new file mode 100644
index 000000000..7e9277dd8
--- /dev/null
+++ b/ui/app/services/watch-list.js
@@ -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;
+ },
+});
diff --git a/ui/app/templates/components/job-row.hbs b/ui/app/templates/components/job-row.hbs
index f64d05889..a412279fd 100644
--- a/ui/app/templates/components/job-row.hbs
+++ b/ui/app/templates/components/job-row.hbs
@@ -5,10 +5,10 @@
{{job.displayType}} |
{{job.priority}} |
- {{#if job.isReloading}}
- ...
- {{else}}
+ {{#if job.taskGroups.length}}
{{job.taskGroups.length}}
+ {{else}}
+ --
{{/if}}
|
diff --git a/ui/app/utils/properties/watch.js b/ui/app/utils/properties/watch.js
new file mode 100644
index 000000000..a372d293d
--- /dev/null
+++ b/ui/app/utils/properties/watch.js
@@ -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();
+}
diff --git a/ui/app/utils/wait.js b/ui/app/utils/wait.js
new file mode 100644
index 000000000..3949cf23a
--- /dev/null
+++ b/ui/app/utils/wait.js
@@ -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);
+ });
+}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js
index c17c7d3e5..59f833cb2 100644
--- a/ui/mirage/config.js
+++ b/ui/mirage/config.js
@@ -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 }));
diff --git a/ui/mirage/factories/allocation.js b/ui/mirage/factories/allocation.js
index ecadc1d0e..0a74eb8be 100644
--- a/ui/mirage/factories/allocation.js
+++ b/ui/mirage/factories/allocation.js
@@ -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),
diff --git a/ui/mirage/factories/evaluation.js b/ui/mirage/factories/evaluation.js
index 98cb7c03e..00ebff30f 100644
--- a/ui/mirage/factories/evaluation.js
+++ b/ui/mirage/factories/evaluation.js
@@ -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)
);
}
diff --git a/ui/mirage/factories/job-summary.js b/ui/mirage/factories/job-summary.js
index c76d39e9f..76bef8a02 100644
--- a/ui/mirage/factories/job-summary.js
+++ b/ui/mirage/factories/job-summary.js
@@ -5,6 +5,7 @@ export default Factory.extend({
groupNames: [],
JobID: '',
+ namespace: null,
withSummary: trait({
Summary: function() {
diff --git a/ui/mirage/factories/job-version.js b/ui/mirage/factories/job-version.js
index 0665bd8dc..9e545bd30 100644
--- a/ui/mirage/factories/job-version.js
+++ b/ui/mirage/factories/job-version.js
@@ -25,6 +25,7 @@ export default Factory.extend({
version.activeDeployment && 'active',
{
jobId: version.jobId,
+ namespace: version.job.namespace,
versionNumber: version.version,
},
].compact();
diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js
index fe97f3573..62d0d3886 100644
--- a/ui/mirage/factories/job.js
+++ b/ui/mirage/factories/job.js
@@ -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,21 @@ 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,
+ namespace: job.namespace,
});
job.update({
@@ -124,22 +126,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) {
diff --git a/ui/mirage/factories/task-group.js b/ui/mirage/factories/task-group.js
index c77e20cdf..6a12dc3e8 100644
--- a/ui/mirage/factories/task-group.js
+++ b/ui/mirage/factories/task-group.js
@@ -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}]`,
});
diff --git a/ui/tests/helpers/module-for-adapter.js b/ui/tests/helpers/module-for-adapter.js
new file mode 100644
index 000000000..c7227601e
--- /dev/null
+++ b/ui/tests/helpers/module-for-adapter.js
@@ -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.beforeEach) {
+ options.beforeEach.apply(this, arguments);
+ }
+ },
+ });
+}
diff --git a/ui/tests/helpers/module-for-serializer.js b/ui/tests/helpers/module-for-serializer.js
index d5cd2fc8b..50f6e41e3 100644
--- a/ui/tests/helpers/module-for-serializer.js
+++ b/ui/tests/helpers/module-for-serializer.js
@@ -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);
}
diff --git a/ui/tests/integration/job-page/parts/children-test.js b/ui/tests/integration/job-page/parts/children-test.js
index 9b7149d41..06fb9bcdc 100644
--- a/ui/tests/integration/job-page/parts/children-test.js
+++ b/ui/tests/integration/job-page/parts/children-test.js
@@ -159,6 +159,8 @@ test('is sorted based on the sortProperty and sortDescending properties', functi
`Child ${index} is ${child.get('name')}`
);
});
+
+ return wait();
});
});
});
diff --git a/ui/tests/integration/job-page/periodic-test.js b/ui/tests/integration/job-page/periodic-test.js
index c259f0167..6ea79bd18 100644
--- a/ui/tests/integration/job-page/periodic-test.js
+++ b/ui/tests/integration/job-page/periodic-test.js
@@ -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'
);
diff --git a/ui/tests/integration/placement-failure-test.js b/ui/tests/integration/placement-failure-test.js
index b43a1b553..193161e85 100644
--- a/ui/tests/integration/placement-failure-test.js
+++ b/ui/tests/integration/placement-failure-test.js
@@ -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
+ ),
};
}
diff --git a/ui/tests/unit/adapters/job-test.js b/ui/tests/unit/adapters/job-test.js
index 433ac1f0d..2b3727371 100644
--- a/ui/tests/unit/adapters/job-test.js
+++ b/ui/tests/unit/adapters/job-test.js
@@ -1,9 +1,19 @@
-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',
+ 'adapter:application',
+ 'service:watchList',
+ ],
beforeEach() {
window.sessionStorage.clear();
@@ -27,8 +37,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 +52,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 +69,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 +84,194 @@ 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 },
+ });
+
+ 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
+ );
+}
diff --git a/ui/tests/unit/adapters/node-test.js b/ui/tests/unit/adapters/node-test.js
new file mode 100644
index 000000000..28b0dc418
--- /dev/null
+++ b/ui/tests/unit/adapters/node-test.js
@@ -0,0 +1,122 @@
+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',
+ '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();
+}
diff --git a/ui/tests/unit/models/job-test.js b/ui/tests/unit/models/job-test.js
index 709f9eb85..1913a20bf 100644
--- a/ui/tests/unit/models/job-test.js
+++ b/ui/tests/unit/models/job-test.js
@@ -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'
);
});
diff --git a/ui/tests/unit/serializers/job-test.js b/ui/tests/unit/serializers/job-test.js
index 15bf2b0f2..529e1bf16 100644
--- a/ui/tests/unit/serializers/job-test.js
+++ b/ui/tests/unit/serializers/job-test.js
@@ -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',
diff --git a/ui/tests/unit/serializers/node-test.js b/ui/tests/unit/serializers/node-test.js
new file mode 100644
index 000000000..f474db963
--- /dev/null
+++ b/ui/tests/unit/serializers/node-test.js
@@ -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 };
+}
diff --git a/ui/tests/utils/push-payload-to-store.js b/ui/tests/utils/push-payload-to-store.js
new file mode 100644
index 000000000..28ff9aafb
--- /dev/null
+++ b/ui/tests/utils/push-payload-to-store.js
@@ -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);
+ });
+}
|