From 18782dd07b03b0185b603b024fdc7edd6a315115 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 8 Feb 2018 15:02:48 -0800 Subject: [PATCH 01/24] Refactor job summary to a relationship Now that blocking queries are going to be in play, We can no longer pretend the two requests are one, since they have independent nomad indices. --- ui/app/adapters/job.js | 28 ++++++------------- ui/app/models/job-summary.js | 39 ++++++++++++++++++++++++++ ui/app/models/job.js | 46 +++++++++++-------------------- ui/app/models/task-group.js | 6 ++-- ui/app/serializers/job-summary.js | 32 +++++++++++++++++++++ ui/app/serializers/job.js | 26 ++++------------- 6 files changed, 104 insertions(+), 73 deletions(-) create mode 100644 ui/app/models/job-summary.js create mode 100644 ui/app/serializers/job-summary.js diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index c783c6ff2ff..7f715c25e56 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -1,5 +1,4 @@ import { inject as service } from '@ember/service'; -import RSVP from 'rsvp'; import { assign } from '@ember/polyfills'; import ApplicationAdapter from './application'; @@ -26,28 +25,17 @@ 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. + findRecordSummary(modelName, name, snapshot, namespaceQuery) { + return this.ajax(`${this.buildURL(modelName, name, snapshot, 'findRecord')}/summary`, 'GET', { + data: assign(this.buildQuery() || {}, namespaceQuery), + }); + }, - // URL is the form of /job/:name?namespace=:namespace with arbitrary additional query params + findRecord(store, type, id, snapshot) { 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; - }); + + return this._super(store, type, name, snapshot, namespaceQuery); }, findAllocations(job) { diff --git a/ui/app/models/job-summary.js b/ui/app/models/job-summary.js new file mode 100644 index 00000000000..a9584acede0 --- /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 b77fc7a662f..ac536db0414 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'), - - // 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'), + summary: belongsTo('job-summary'), + + // 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 e5ea67d0e97..9be65bdbac6 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/serializers/job-summary.js b/ui/app/serializers/job-summary.js new file mode 100644 index 00000000000..c3c8f67cd06 --- /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 df77e2f38ab..4ab4af882a6 100644 --- a/ui/app/serializers/job.js +++ b/ui/app/serializers/job.js @@ -34,27 +34,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); }, @@ -68,6 +47,11 @@ export default ApplicationSerializer.extend({ .buildURL(modelName, hash.PlainId, hash, 'findRecord'); return assign(this._super(...arguments), { + summary: { + links: { + related: buildURL(`${jobURL}/summary`, { namespace: namespace }), + }, + }, allocations: { links: { related: buildURL(`${jobURL}/allocations`, { namespace: namespace }), From 66d0eabe34558fe1e610f5535c4295fe93b789a2 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 8 Feb 2018 15:05:19 -0800 Subject: [PATCH 02/24] Re-render chart whenever data changes --- ui/app/components/distribution-bar.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/app/components/distribution-bar.js b/ui/app/components/distribution-bar.js index 46d47d8f4b4..c38a49a97c5 100644 --- a/ui/app/components/distribution-bar.js +++ b/ui/app/components/distribution-bar.js @@ -1,5 +1,5 @@ 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'; @@ -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() { From 9b2080d41ebd491d896dae3c6e8dfa1544b1f65e Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 8 Feb 2018 15:06:10 -0800 Subject: [PATCH 03/24] Prototype watching resources - Service to manage X-Nomad-Index values - Adapter method for reloading relationships with additional params - Pattern for watching models and model relationships using EC --- ui/app/adapters/job.js | 6 ++-- ui/app/adapters/watchable.js | 60 +++++++++++++++++++++++++++++++++++ ui/app/routes/jobs/job.js | 41 ++++++++++++++++++++++++ ui/app/services/watch-list.js | 19 +++++++++++ ui/app/utils/wait.js | 10 ++++++ 5 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 ui/app/adapters/watchable.js create mode 100644 ui/app/services/watch-list.js create mode 100644 ui/app/utils/wait.js diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index 7f715c25e56..eec5483f814 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -1,11 +1,11 @@ import { inject as service } from '@ember/service'; 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, + // shouldReloadAll: () => true, buildQuery() { const namespace = this.get('system.activeNamespace.id'); diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js new file mode 100644 index 00000000000..fb32c082599 --- /dev/null +++ b/ui/app/adapters/watchable.js @@ -0,0 +1,60 @@ +import { get } from '@ember/object'; +import { assign } from '@ember/polyfills'; +import { copy } from '@ember/object/internals'; +import { inject as service } from '@ember/service'; +import queryString from 'npm:query-string'; +import ApplicationAdapter from './application'; + +export default ApplicationAdapter.extend({ + watchList: service(), + store: service(), + + findRecord(store, type, id, snapshot, additionalParams = {}) { + const params = copy(additionalParams, true); + const url = this.buildURL(type.modelName, id, snapshot, 'findRecord'); + + if (get(snapshot, 'adapterOptions.watch')) { + params.index = this.get('watchList').getIndexFor(url); + } + + return this.ajax(url, 'GET', { + data: params, + }); + }, + + 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); + } + + this.ajax(url, 'GET', { + data: params, + }).then(json => { + this.get('store').pushPayload(relationship.type, { + [relationship.type]: relationship.kind === 'hasMany' ? json : [json], + }); + }); + } + }, + + handleResponse(status, headers, payload, requestData) { + const newIndex = headers['x-nomad-index']; + if (newIndex) { + this.get('watchList').setIndexFor(requestData.url, newIndex); + } + return this._super(...arguments); + }, +}); diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js index 558ea55e459..8062d01e5b1 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -2,9 +2,13 @@ import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; import RSVP from 'rsvp'; import notifyError from 'nomad-ui/utils/notify-error'; +import { task } from 'ember-concurrency'; +import wait from 'nomad-ui/utils/wait'; export default Route.extend({ store: service(), + token: service(), + watchList: service(), serialize(model) { return { job_name: model.get('plainId') }; @@ -21,4 +25,41 @@ export default Route.extend({ }) .catch(notifyError(this)); }, + + setupController(controller, model) { + controller.set('modelTask', this.get('watch').perform(model.get('id'))); + controller.set('summaryTask', this.get('watchRelationship').perform(model, 'summary')); + controller.set('evaluationsTask', this.get('watchRelationship').perform(model, 'evaluations')); + controller.set('deploymentsTask', this.get('watchRelationship').perform(model, 'deployments')); + }, + + watch: task(function*(jobId) { + while (true) { + try { + yield RSVP.all([ + this.store.findRecord('job', jobId, { reload: true, adapterOptions: { watch: true } }), + wait(2000), + ]); + } catch (e) { + yield e; + break; + } + } + }), + + watchRelationship: task(function*(job, relationshipName) { + while (true) { + try { + yield RSVP.all([ + this.store + .adapterFor(job.get('modelName')) + .reloadRelationship(job, relationshipName, true), + wait(2000), + ]); + } catch (e) { + yield e; + break; + } + } + }), }); diff --git a/ui/app/services/watch-list.js b/ui/app/services/watch-list.js new file mode 100644 index 00000000000..f22ad34d9f7 --- /dev/null +++ b/ui/app/services/watch-list.js @@ -0,0 +1,19 @@ +import { readOnly } from '@ember/object/computed'; +import { copy } from '@ember/object/internals'; +import Service from '@ember/service'; + +const list = {}; + +export default Service.extend({ + list: readOnly(function() { + return copy(list, true); + }), + + getIndexFor(url) { + return list[url] || 0; + }, + + setIndexFor(url, value) { + list[url] = value; + }, +}); diff --git a/ui/app/utils/wait.js b/ui/app/utils/wait.js new file mode 100644 index 00000000000..3949cf23af5 --- /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); + }); +} From 5bf5448c1f8fef58f40f7048efc34d0f2f82626e Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 8 Feb 2018 15:43:54 -0800 Subject: [PATCH 04/24] Move watch tasks into a utils file --- ui/app/routes/jobs/job.js | 46 ++++++++------------------------ ui/app/utils/properties/watch.js | 39 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 35 deletions(-) create mode 100644 ui/app/utils/properties/watch.js diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js index 8062d01e5b1..3180f700fa2 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -2,8 +2,7 @@ import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; import RSVP from 'rsvp'; import notifyError from 'nomad-ui/utils/notify-error'; -import { task } from 'ember-concurrency'; -import wait from 'nomad-ui/utils/wait'; +import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch'; export default Route.extend({ store: service(), @@ -27,39 +26,16 @@ export default Route.extend({ }, setupController(controller, model) { - controller.set('modelTask', this.get('watch').perform(model.get('id'))); - controller.set('summaryTask', this.get('watchRelationship').perform(model, 'summary')); - controller.set('evaluationsTask', this.get('watchRelationship').perform(model, 'evaluations')); - controller.set('deploymentsTask', this.get('watchRelationship').perform(model, 'deployments')); + 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: task(function*(jobId) { - while (true) { - try { - yield RSVP.all([ - this.store.findRecord('job', jobId, { reload: true, adapterOptions: { watch: true } }), - wait(2000), - ]); - } catch (e) { - yield e; - break; - } - } - }), - - watchRelationship: task(function*(job, relationshipName) { - while (true) { - try { - yield RSVP.all([ - this.store - .adapterFor(job.get('modelName')) - .reloadRelationship(job, relationshipName, true), - wait(2000), - ]); - } catch (e) { - yield e; - break; - } - } - }), + watch: watchRecord('job'), + watchSummary: watchRelationship('summary'), + watchEvaluations: watchRelationship('evaluations'), + watchDeployments: watchRelationship('deployments'), }); diff --git a/ui/app/utils/properties/watch.js b/ui/app/utils/properties/watch.js new file mode 100644 index 00000000000..505f137374b --- /dev/null +++ b/ui/app/utils/properties/watch.js @@ -0,0 +1,39 @@ +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) { + id = get(id, 'id') || id; + while (true) { + try { + yield RSVP.all([ + this.store.findRecord(modelName, id, { reload: true, adapterOptions: { watch: true } }), + wait(2000), + ]); + } catch (e) { + yield e; + break; + } + } + }); +} + +export function watchRelationship(staticRelationshipName) { + return task(function*(model, relationshipName) { + while (true) { + try { + yield RSVP.all([ + this.store + .adapterFor(model.get('modelName')) + .reloadRelationship(model, staticRelationshipName || relationshipName, true), + wait(2000), + ]); + } catch (e) { + yield e; + break; + } + } + }); +} From 27a32a76f150e5f63e2ffd125edfb36a4fbad311 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 12 Feb 2018 15:20:32 -0800 Subject: [PATCH 05/24] Fix distribution-bar bugs found with live data - Key data using datum label (when something goes from number, to zero, to number again) - Retain active and inactive classes across data updates --- ui/app/components/distribution-bar.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ui/app/components/distribution-bar.js b/ui/app/components/distribution-bar.js index c38a49a97c5..3fcdd046615 100644 --- a/ui/app/components/distribution-bar.js +++ b/ui/app/components/distribution-bar.js @@ -77,15 +77,18 @@ 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; + this.set('slices', slices); + slices.exit().remove(); let slicesEnter = slices.enter() .append('g') .on('mouseenter', d => { run(() => { + const slices = this.get('slices'); const slice = slices.filter(datum => datum === d); slices.classed('active', false).classed('inactive', true); slice.classed('active', true).classed('inactive', false); @@ -103,7 +106,13 @@ 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(' '); + }); 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` @@ -121,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)); }); From 6bf66f9ec86de3af66677eeb374e99df7edd1589 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 12 Feb 2018 15:23:07 -0800 Subject: [PATCH 06/24] Add a watchAll computed property macro --- ui/app/utils/properties/watch.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/ui/app/utils/properties/watch.js b/ui/app/utils/properties/watch.js index 505f137374b..297644c41d7 100644 --- a/ui/app/utils/properties/watch.js +++ b/ui/app/utils/properties/watch.js @@ -21,14 +21,30 @@ export function watchRecord(modelName) { } export function watchRelationship(staticRelationshipName) { - return task(function*(model, relationshipName) { + return task(function*(model, throttle = 2000) { while (true) { try { yield RSVP.all([ - this.store - .adapterFor(model.get('modelName')) - .reloadRelationship(model, staticRelationshipName || relationshipName, true), - wait(2000), + this.get('store') + .adapterFor(model.constructor.modelName) + .reloadRelationship(model, staticRelationshipName, true), + wait(throttle), + ]); + } catch (e) { + yield e; + break; + } + } + }); +} + +export function watchAll(modelName) { + return task(function*(throttle = 2000) { + while (true) { + try { + yield RSVP.all([ + this.get('store').findAll(modelName, { reload: true, adapterOptions: { watch: true } }), + wait(throttle), ]); } catch (e) { yield e; From 4dd6972a334f115d37bc95952cd72d10bd8e0027 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 12 Feb 2018 15:23:35 -0800 Subject: [PATCH 07/24] Make the throttle time configurable --- ui/app/utils/properties/watch.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ui/app/utils/properties/watch.js b/ui/app/utils/properties/watch.js index 297644c41d7..b0d1391b31b 100644 --- a/ui/app/utils/properties/watch.js +++ b/ui/app/utils/properties/watch.js @@ -4,13 +4,16 @@ import { task } from 'ember-concurrency'; import wait from 'nomad-ui/utils/wait'; export function watchRecord(modelName) { - return task(function*(id) { + return task(function*(id, throttle = 2000) { id = get(id, 'id') || id; while (true) { try { yield RSVP.all([ - this.store.findRecord(modelName, id, { reload: true, adapterOptions: { watch: true } }), - wait(2000), + this.get('store').findRecord(modelName, id, { + reload: true, + adapterOptions: { watch: true }, + }), + wait(throttle), ]); } catch (e) { yield e; From bc2945756ecfeafc1fb1bb8cd68e1211106515de Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 12 Feb 2018 15:24:15 -0800 Subject: [PATCH 08/24] Blocking query support for findAll requests --- ui/app/adapters/watchable.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js index fb32c082599..003fbbbf707 100644 --- a/ui/app/adapters/watchable.js +++ b/ui/app/adapters/watchable.js @@ -1,6 +1,7 @@ import { get } from '@ember/object'; import { assign } from '@ember/polyfills'; import { copy } from '@ember/object/internals'; +import { makeArray } from '@ember/array'; import { inject as service } from '@ember/service'; import queryString from 'npm:query-string'; import ApplicationAdapter from './application'; @@ -9,6 +10,19 @@ export default ApplicationAdapter.extend({ watchList: service(), store: service(), + findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) { + const params = copy(additionalParams, true); + 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, + }); + }, + findRecord(store, type, id, snapshot, additionalParams = {}) { const params = copy(additionalParams, true); const url = this.buildURL(type.modelName, id, snapshot, 'findRecord'); @@ -40,11 +54,11 @@ export default ApplicationAdapter.extend({ params = assign(queryString.parse(url.split('?')[1]), params); } - this.ajax(url, 'GET', { + return this.ajax(url, 'GET', { data: params, }).then(json => { this.get('store').pushPayload(relationship.type, { - [relationship.type]: relationship.kind === 'hasMany' ? json : [json], + [relationship.type]: makeArray(json), }); }); } From 5e4491ca2cb38b4a5ce7e04b3f5fe6e3a539d83a Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 12 Feb 2018 15:25:21 -0800 Subject: [PATCH 09/24] Remove records from the store when they are no longer in array responses Ember Data doesn't do this by default, instead opting to be as non-destructive as possible. However, this is desired behavior. --- ui/app/serializers/application.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/ui/app/serializers/application.js b/ui/app/serializers/application.js index 3d63f5b6344..55e354df85e 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,8 +37,29 @@ export default JSONSerializer.extend({ documentHash.included.push(...included); } }); + }); + + store.push(documentHash); + }, + + normalizeArrayResponse(store, modelClass) { + const result = this._super(...arguments); + this.cullStore(store, modelClass.modelName, result.data); + return result; + }, - store.push(documentHash); + // 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) { + const newRecords = copy(records).filter(record => get(record, 'id')); + const oldRecords = store.peekAll(type); + oldRecords.filter(record => get(record, 'id')).forEach(old => { + const newRecord = newRecords.find(record => get(record, 'id') === get(old, 'id')); + if (!newRecord) { + store.unloadRecord(old); + } else { + newRecords.removeObject(newRecord); + } }); }, From 702228e878694f4a7793b6cb78b074c5b358e2e7 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 12 Feb 2018 15:26:35 -0800 Subject: [PATCH 10/24] Watch all records on the jobs list page --- ui/app/routes/jobs.js | 5 +++++ ui/app/routes/jobs/job.js | 2 ++ 2 files changed, 7 insertions(+) diff --git a/ui/app/routes/jobs.js b/ui/app/routes/jobs.js index 745e326e2e2..f76d6c61246 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,13 @@ export default Route.extend(WithForbiddenState, { setupController(controller) { this.syncToController(controller); + + controller.set('modelWatch', this.get('watch').perform()); return 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 3180f700fa2..8a7d5400d11 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -32,6 +32,8 @@ export default Route.extend({ evaluations: this.get('watchEvaluations').perform(model), deployments: this.get('watchDeployments').perform(model), }); + + return this._super(...arguments); }, watch: watchRecord('job'), From b675d9740034d41b5f0bf52c65d13567034eecf9 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 12 Feb 2018 15:27:19 -0800 Subject: [PATCH 11/24] Watch for summary changes in job-row --- ui/app/components/job-row.js | 10 +++++++++- ui/app/templates/components/job-row.hbs | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ui/app/components/job-row.js b/ui/app/components/job-row.js index db9e6e3699a..bc8c95c724f 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,11 @@ 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); + }); } }, + + watch: watchRelationship('summary').drop(), }); diff --git a/ui/app/templates/components/job-row.hbs b/ui/app/templates/components/job-row.hbs index f64d058894c..a412279fd2a 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}} From 8a119d99ce6cd1ea37c43a96624808f485f77d8a Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 14 Feb 2018 15:31:27 -0800 Subject: [PATCH 12/24] Remove stale records from the store for findHasMany requests --- ui/app/adapters/application.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index bcf223383f9..ccdfd8aa24c 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. // From ed809fe27ec9b4141978714e92056a2e053a8a3a Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 14 Feb 2018 15:37:51 -0800 Subject: [PATCH 13/24] Track xhrs in the watchable adapter and expose cancellation methods --- ui/app/adapters/job.js | 13 +++++++-- ui/app/adapters/watchable.js | 53 +++++++++++++++++++++++++++++++++++- ui/app/serializers/job.js | 3 +- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index eec5483f814..84afeca35aa 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -32,10 +32,19 @@ export default Watchable.extend({ }, findRecord(store, type, id, snapshot) { - const [name, namespace] = JSON.parse(id); + const [, namespace] = JSON.parse(id); const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {}; - return this._super(store, type, name, snapshot, namespaceQuery); + 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}`; + } + return url; }, findAllocations(job) { diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js index 003fbbbf707..9d58e31cd70 100644 --- a/ui/app/adapters/watchable.js +++ b/ui/app/adapters/watchable.js @@ -1,4 +1,4 @@ -import { get } from '@ember/object'; +import { get, computed } from '@ember/object'; import { assign } from '@ember/polyfills'; import { copy } from '@ember/object/internals'; import { makeArray } from '@ember/array'; @@ -10,6 +10,27 @@ 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 = copy(additionalParams, true); const url = this.urlForFindAll(type.modelName); @@ -71,4 +92,34 @@ export default ApplicationAdapter.extend({ } 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/serializers/job.js b/ui/app/serializers/job.js index 4ab4af882a6..0e6799b531d 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'; @@ -44,7 +43,7 @@ export default ApplicationSerializer.extend({ const jobURL = this.store .adapterFor(modelName) - .buildURL(modelName, hash.PlainId, hash, 'findRecord'); + .buildURL(modelName, hash.ID, hash, 'findRecord'); return assign(this._super(...arguments), { summary: { From b69fe312b815d94ffc3873e5568fef7aa11e174e Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 14 Feb 2018 15:41:07 -0800 Subject: [PATCH 14/24] Watch properties cancel long poll requests --- ui/app/utils/properties/watch.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/ui/app/utils/properties/watch.js b/ui/app/utils/properties/watch.js index b0d1391b31b..3fbedbc5cae 100644 --- a/ui/app/utils/properties/watch.js +++ b/ui/app/utils/properties/watch.js @@ -18,27 +18,35 @@ export function watchRecord(modelName) { } catch (e) { yield e; break; + } finally { + this.get('store') + .adapterFor(modelName) + .cancelFindRecord(modelName, id); } } - }); + }).drop(); } -export function watchRelationship(staticRelationshipName) { +export function watchRelationship(relationshipName) { return task(function*(model, throttle = 2000) { while (true) { try { yield RSVP.all([ this.get('store') .adapterFor(model.constructor.modelName) - .reloadRelationship(model, staticRelationshipName, true), + .reloadRelationship(model, relationshipName, true), wait(throttle), ]); } catch (e) { yield e; break; + } finally { + this.get('store') + .adapterFor(model.constructor.name) + .cancelReloadRelationship(model, relationshipName); } } - }); + }).drop(); } export function watchAll(modelName) { @@ -52,7 +60,11 @@ export function watchAll(modelName) { } catch (e) { yield e; break; + } finally { + this.get('store') + .adapterFor(modelName) + .cancelFindAll(modelName); } } - }); + }).drop(); } From dbc9903ae957aef8e327dc3b92b6e34266f212ac Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 14 Feb 2018 15:42:03 -0800 Subject: [PATCH 15/24] Cancel watch tasks when appropriate --- ui/app/components/job-row.js | 5 +++++ ui/app/routes/jobs.js | 5 +++++ ui/app/routes/jobs/job.js | 10 ++++++++++ 3 files changed, 20 insertions(+) diff --git a/ui/app/components/job-row.js b/ui/app/components/job-row.js index bc8c95c724f..abb4d3295ac 100644 --- a/ui/app/components/job-row.js +++ b/ui/app/components/job-row.js @@ -27,5 +27,10 @@ export default Component.extend({ } }, + willDestroy() { + this.get('watch').cancelAll(); + this._super(...arguments); + }, + watch: watchRelationship('summary').drop(), }); diff --git a/ui/app/routes/jobs.js b/ui/app/routes/jobs.js index f76d6c61246..181bcb1822f 100644 --- a/ui/app/routes/jobs.js +++ b/ui/app/routes/jobs.js @@ -41,6 +41,11 @@ export default Route.extend(WithForbiddenState, { return this._super(...arguments); }, + deactivate() { + this.get('watch').cancelAll(); + this._super(...arguments); + }, + watch: watchAll('job'), actions: { diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js index 8a7d5400d11..5e16030c5e3 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -1,4 +1,5 @@ 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'; @@ -36,8 +37,17 @@ export default Route.extend({ 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'), }); From 60ee8714c1daa0593f4992edada6c32dc5f34786 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 14 Feb 2018 15:42:34 -0800 Subject: [PATCH 16/24] Generalized solution for removing records in the local store When the findAll response from the server no longer has them. --- ui/app/serializers/application.js | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/ui/app/serializers/application.js b/ui/app/serializers/application.js index 55e354df85e..08796baad4a 100644 --- a/ui/app/serializers/application.js +++ b/ui/app/serializers/application.js @@ -42,7 +42,7 @@ export default JSONSerializer.extend({ store.push(documentHash); }, - normalizeArrayResponse(store, modelClass) { + normalizeFindAllResponse(store, modelClass) { const result = this._super(...arguments); this.cullStore(store, modelClass.modelName, result.data); return result; @@ -50,17 +50,20 @@ export default JSONSerializer.extend({ // 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) { + 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')).forEach(old => { - const newRecord = newRecords.find(record => get(record, 'id') === get(old, 'id')); - if (!newRecord) { - store.unloadRecord(old); - } else { - newRecords.removeObject(newRecord); - } - }); + 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) { From 3c2a1f8a4abc63b8a86ffbeb25099d33307bc051 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 15 Feb 2018 18:55:59 -0800 Subject: [PATCH 17/24] Fix existing tests --- ui/app/adapters/application.js | 2 +- ui/app/adapters/job.js | 19 ++- ui/app/adapters/watchable.js | 11 +- ui/app/components/job-row.js | 2 +- ui/app/serializers/job.js | 5 +- ui/app/utils/properties/watch.js | 14 +- ui/mirage/factories/allocation.js | 2 + ui/mirage/factories/evaluation.js | 2 +- ui/mirage/factories/job-summary.js | 1 + ui/mirage/factories/job-version.js | 1 + ui/mirage/factories/job.js | 51 ++++--- ui/mirage/factories/task-group.js | 1 + .../job-page/parts/children-test.js | 2 + .../integration/job-page/periodic-test.js | 6 +- .../integration/placement-failure-test.js | 73 +++++----- ui/tests/unit/adapters/job-test.js | 28 ++-- ui/tests/unit/models/job-test.js | 105 ++++++++++----- ui/tests/unit/serializers/job-test.js | 126 +----------------- 18 files changed, 191 insertions(+), 260 deletions(-) diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index ccdfd8aa24c..4bd664c1c00 100644 --- a/ui/app/adapters/application.js +++ b/ui/app/adapters/application.js @@ -44,7 +44,7 @@ export default RESTAdapter.extend({ // owner type. In the event it isn't, findHasMany should be overridden. store .peekAll(relationshipType) - .filter(record => record.get(`${ownerType}.id` === snapshot.id)) + .filter(record => record.get(`${ownerType}.id`) === snapshot.id) .forEach(record => { store.unloadRecord(record); }); diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index 84afeca35aa..7d90a950c79 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -5,14 +5,13 @@ import Watchable from './watchable'; export default Watchable.extend({ system: service(), - // shouldReloadAll: () => true, - buildQuery() { const namespace = this.get('system.activeNamespace.id'); if (namespace && namespace !== 'default') { return { namespace }; } + return {}; }, findAll() { @@ -42,7 +41,7 @@ export default Watchable.extend({ const [name, namespace] = JSON.parse(id); let url = this._super(name, type, hash); if (namespace && namespace !== 'default') { - url += `?${namespace}`; + url += `?namespace=${namespace}`; } return url; }, @@ -57,19 +56,17 @@ export default Watchable.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 index 9d58e31cd70..29d001630e3 100644 --- a/ui/app/adapters/watchable.js +++ b/ui/app/adapters/watchable.js @@ -1,6 +1,5 @@ import { get, computed } from '@ember/object'; import { assign } from '@ember/polyfills'; -import { copy } from '@ember/object/internals'; import { makeArray } from '@ember/array'; import { inject as service } from '@ember/service'; import queryString from 'npm:query-string'; @@ -32,10 +31,10 @@ export default ApplicationAdapter.extend({ }, findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) { - const params = copy(additionalParams, true); + const params = assign(this.buildQuery(), additionalParams); const url = this.urlForFindAll(type.modelName); - if (get(snapshotRecordArray, 'adapterOptions.watch')) { + if (get(snapshotRecordArray || {}, 'adapterOptions.watch')) { params.index = this.get('watchList').getIndexFor(url); } @@ -45,10 +44,10 @@ export default ApplicationAdapter.extend({ }, findRecord(store, type, id, snapshot, additionalParams = {}) { - const params = copy(additionalParams, true); - const url = this.buildURL(type.modelName, id, snapshot, 'findRecord'); + let [url, params] = this.buildURL(type.modelName, id, snapshot, 'findRecord').split('?'); + params = assign(queryString.parse(params) || {}, this.buildQuery(), additionalParams); - if (get(snapshot, 'adapterOptions.watch')) { + if (get(snapshot || {}, 'adapterOptions.watch')) { params.index = this.get('watchList').getIndexFor(url); } diff --git a/ui/app/components/job-row.js b/ui/app/components/job-row.js index abb4d3295ac..8a09f51c0ec 100644 --- a/ui/app/components/job-row.js +++ b/ui/app/components/job-row.js @@ -32,5 +32,5 @@ export default Component.extend({ this._super(...arguments); }, - watch: watchRelationship('summary').drop(), + watch: watchRelationship('summary'), }); diff --git a/ui/app/serializers/job.js b/ui/app/serializers/job.js index 0e6799b531d..7271f86d861 100644 --- a/ui/app/serializers/job.js +++ b/ui/app/serializers/job.js @@ -41,9 +41,10 @@ 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.ID, hash, 'findRecord'); + .buildURL(modelName, hash.ID, hash, 'findRecord') + .split('?'); return assign(this._super(...arguments), { summary: { diff --git a/ui/app/utils/properties/watch.js b/ui/app/utils/properties/watch.js index 3fbedbc5cae..a5729f2cd38 100644 --- a/ui/app/utils/properties/watch.js +++ b/ui/app/utils/properties/watch.js @@ -1,3 +1,5 @@ +import Ember from 'ember'; +import { typeOf } from '@ember/utils'; import { get } from '@ember/object'; import RSVP from 'rsvp'; import { task } from 'ember-concurrency'; @@ -5,8 +7,10 @@ import wait from 'nomad-ui/utils/wait'; export function watchRecord(modelName) { return task(function*(id, throttle = 2000) { - id = get(id, 'id') || id; - while (true) { + if (typeOf(id) === 'object') { + id = get(id, 'id'); + } + while (!Ember.testing) { try { yield RSVP.all([ this.get('store').findRecord(modelName, id, { @@ -29,7 +33,7 @@ export function watchRecord(modelName) { export function watchRelationship(relationshipName) { return task(function*(model, throttle = 2000) { - while (true) { + while (!Ember.testing) { try { yield RSVP.all([ this.get('store') @@ -42,7 +46,7 @@ export function watchRelationship(relationshipName) { break; } finally { this.get('store') - .adapterFor(model.constructor.name) + .adapterFor(model.constructor.modelName) .cancelReloadRelationship(model, relationshipName); } } @@ -51,7 +55,7 @@ export function watchRelationship(relationshipName) { export function watchAll(modelName) { return task(function*(throttle = 2000) { - while (true) { + while (!Ember.testing) { try { yield RSVP.all([ this.get('store').findAll(modelName, { reload: true, adapterOptions: { watch: true } }), diff --git a/ui/mirage/factories/allocation.js b/ui/mirage/factories/allocation.js index ecadc1d0e42..0a74eb8bebd 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 98cb7c03ef5..00ebff30ffa 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 c76d39e9f4d..76bef8a0204 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 0665bd8dc65..9e545bd30d5 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 fe97f3573eb..62d0d3886a1 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 c77e20cdf09..6a12dc3e899 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/integration/job-page/parts/children-test.js b/ui/tests/integration/job-page/parts/children-test.js index 9b7149d4104..06fb9bcdcb7 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 c259f016782..6ea79bd1876 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 b43a1b5533e..193161e8552 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, - }, - classFiltered: { - filtered: 1, - }, - constraintFiltered: { - 'prop = val': 1, - }, - nodesExhausted: 3, - classExhausted: { - class: 3, - }, - dimensionExhausted: { - iops: 3, - }, - quotaExhausted: { - quota: 'dimension', - }, - scores: { - name: 3, + 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, + }, }, - }, - obj - ) + obj + ), }; } diff --git a/ui/tests/unit/adapters/job-test.js b/ui/tests/unit/adapters/job-test.js index 433ac1f0d42..191d6a96a49 100644 --- a/ui/tests/unit/adapters/job-test.js +++ b/ui/tests/unit/adapters/job-test.js @@ -3,7 +3,13 @@ import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; moduleFor('adapter:job', 'Unit | Adapter | Job', { unit: true, - needs: ['service:token', 'service:system', 'model:namespace', 'adapter:application'], + needs: [ + 'service:token', + 'service:system', + 'model:namespace', + 'adapter:application', + 'service:watchList', + ], beforeEach() { window.sessionStorage.clear(); @@ -27,8 +33,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 +48,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 +65,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'; diff --git a/ui/tests/unit/models/job-test.js b/ui/tests/unit/models/job-test.js index 709f9eb850a..1913a20bf07 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 15bf2b0f2a6..529e1bf162d 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', From 0a0cea0f8776dddfffeb5a2a64ff5624b17b577d Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 16 Feb 2018 18:58:19 -0800 Subject: [PATCH 18/24] Watchable request helper for Mirage --- ui/mirage/config.js | 91 ++++++++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 30 deletions(-) diff --git a/ui/mirage/config.js b/ui/mirage/config.js index c17c7d3e509..adf7f113873 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')); - }); - - 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)) - ); - }); - - return job ? this.serialize(job) : new Response(404, {}, null); - }); - - this.get('/job/:id/summary', function({ jobSummaries }, { params }) { - return this.serialize(jobSummaries.findBy({ jobId: params.id })); - }); + 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); + + // Get and increment the approrpriate index + nomadIndices[url] || (nomadIndices[url] = 1); + const index = nomadIndices[url]; + nomadIndices[url]++; + + // 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( + '/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 })); From 504ff2d4b7327de5e14ba8ede501aff09a618b85 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Fri, 16 Feb 2018 18:59:40 -0800 Subject: [PATCH 19/24] Tests for watching and canceling requests --- ui/app/adapters/watchable.js | 31 ++++- ui/app/services/watch-list.js | 6 +- ui/tests/unit/adapters/job-test.js | 194 +++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 7 deletions(-) diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js index 29d001630e3..09efef4e8cc 100644 --- a/ui/app/adapters/watchable.js +++ b/ui/app/adapters/watchable.js @@ -4,6 +4,7 @@ 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(), @@ -40,6 +41,11 @@ export default ApplicationAdapter.extend({ return this.ajax(url, 'GET', { data: params, + }).catch(error => { + if (error instanceof AbortError) { + return []; + } + throw error; }); }, @@ -53,6 +59,11 @@ export default ApplicationAdapter.extend({ return this.ajax(url, 'GET', { data: params, + }).catch(error => { + if (error instanceof AbortError) { + return {}; + } + throw error; }); }, @@ -76,16 +87,24 @@ export default ApplicationAdapter.extend({ return this.ajax(url, 'GET', { data: params, - }).then(json => { - this.get('store').pushPayload(relationship.type, { - [relationship.type]: makeArray(json), - }); - }); + }).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']; + const newIndex = headers['X-Nomad-Index']; if (newIndex) { this.get('watchList').setIndexFor(requestData.url, newIndex); } diff --git a/ui/app/services/watch-list.js b/ui/app/services/watch-list.js index f22ad34d9f7..7e9277dd834 100644 --- a/ui/app/services/watch-list.js +++ b/ui/app/services/watch-list.js @@ -2,13 +2,17 @@ import { readOnly } from '@ember/object/computed'; import { copy } from '@ember/object/internals'; import Service from '@ember/service'; -const list = {}; +let list = {}; export default Service.extend({ list: readOnly(function() { return copy(list, true); }), + init() { + list = {}; + }, + getIndexFor(url) { return list[url] || 0; }, diff --git a/ui/tests/unit/adapters/job-test.js b/ui/tests/unit/adapters/job-test.js index 191d6a96a49..1240c228190 100644 --- a/ui/tests/unit/adapters/job-test.js +++ b/ui/tests/unit/adapters/job-test.js @@ -1,4 +1,7 @@ +import { run } from '@ember/runloop'; +import { assign } from '@ember/polyfills'; import { test, moduleFor } from 'ember-qunit'; +import wait from 'ember-test-helpers/wait'; import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; moduleFor('adapter:job', 'Unit | Adapter | Job', { @@ -80,3 +83,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 + ); +} From 7c51270876647459cf41c3f885f17e0109b853cb Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 20 Feb 2018 12:04:36 -0800 Subject: [PATCH 20/24] Add tests for findAll store culling --- ui/tests/helpers/module-for-serializer.js | 3 + ui/tests/unit/serializers/node-test.js | 71 +++++++++++++++++++++++ ui/tests/utils/push-payload-to-store.js | 12 ++++ 3 files changed, 86 insertions(+) create mode 100644 ui/tests/unit/serializers/node-test.js create mode 100644 ui/tests/utils/push-payload-to-store.js diff --git a/ui/tests/helpers/module-for-serializer.js b/ui/tests/helpers/module-for-serializer.js index d5cd2fc8bdf..50f6e41e3f6 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/unit/serializers/node-test.js b/ui/tests/unit/serializers/node-test.js new file mode 100644 index 00000000000..f474db9637e --- /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 00000000000..28ff9aafb2e --- /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); + }); +} From 47dad64cabf3c33b62b43741fe8411177710f270 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 20 Feb 2018 12:05:34 -0800 Subject: [PATCH 21/24] Add tests for findHasMany store culling --- ui/tests/helpers/module-for-adapter.js | 33 +++++++ ui/tests/unit/adapters/job-test.js | 7 +- ui/tests/unit/adapters/node-test.js | 122 +++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 ui/tests/helpers/module-for-adapter.js create mode 100644 ui/tests/unit/adapters/node-test.js diff --git a/ui/tests/helpers/module-for-adapter.js b/ui/tests/helpers/module-for-adapter.js new file mode 100644 index 00000000000..c7227601ec8 --- /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/unit/adapters/job-test.js b/ui/tests/unit/adapters/job-test.js index 1240c228190..2b372737177 100644 --- a/ui/tests/unit/adapters/job-test.js +++ b/ui/tests/unit/adapters/job-test.js @@ -1,12 +1,13 @@ import { run } from '@ember/runloop'; import { assign } from '@ember/polyfills'; -import { test, moduleFor } from 'ember-qunit'; +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, +moduleForAdapter('job', 'Unit | Adapter | Job', { needs: [ + 'adapter:job', 'service:token', 'service:system', 'model:namespace', diff --git a/ui/tests/unit/adapters/node-test.js b/ui/tests/unit/adapters/node-test.js new file mode 100644 index 00000000000..28b0dc41833 --- /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(); +} From 6c3a09123ac9ecba817d9746fea484c1950c6ef5 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 20 Feb 2018 15:27:03 -0800 Subject: [PATCH 22/24] Json viewer isn't side effect free, so use a copy --- ui/app/components/json-viewer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/app/components/json-viewer.js b/ui/app/components/json-viewer.js index cf966757d6e..3408de0445b 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', }); }), From 5dd83fd9cdef316dd46e70a135dbdf0d9c95cb9c Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Tue, 20 Feb 2018 15:27:30 -0800 Subject: [PATCH 23/24] Set slices after merging the selection --- ui/app/components/distribution-bar.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/app/components/distribution-bar.js b/ui/app/components/distribution-bar.js index 3fcdd046615..84eb97f6574 100644 --- a/ui/app/components/distribution-bar.js +++ b/ui/app/components/distribution-bar.js @@ -22,7 +22,7 @@ export default Component.extend(WindowResizable, { tooltipStyle: styleStringProperty('tooltipPosition'), maskId: null, - _data: computed('data', function() { + _data: computed('data.@each.{value,label,className,layers}', function() { const data = this.get('data'); const sum = data.mapBy('value').reduce(sumAggregate, 0); @@ -80,8 +80,6 @@ export default Component.extend(WindowResizable, { let slices = chart.select('.bars').selectAll('g').data(filteredData, d => d.label); let sliceCount = filteredData.length; - this.set('slices', slices); - slices.exit().remove(); let slicesEnter = slices.enter() @@ -89,7 +87,7 @@ export default Component.extend(WindowResizable, { .on('mouseenter', d => { run(() => { const slices = this.get('slices'); - const slice = slices.filter(datum => datum === d); + 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); @@ -114,6 +112,8 @@ export default Component.extend(WindowResizable, { 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` From 421f082a9c3929886018ecd5852db0ef71dc6260 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 22 Feb 2018 13:21:32 -0800 Subject: [PATCH 24/24] Address headers and ID bugs --- ui/app/adapters/watchable.js | 3 ++- ui/app/components/distribution-bar.js | 6 +++--- ui/app/utils/properties/watch.js | 3 +-- ui/mirage/config.js | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js index 09efef4e8cc..857b5f87ee7 100644 --- a/ui/app/adapters/watchable.js +++ b/ui/app/adapters/watchable.js @@ -104,10 +104,11 @@ export default ApplicationAdapter.extend({ }, handleResponse(status, headers, payload, requestData) { - const newIndex = headers['X-Nomad-Index']; + const newIndex = headers['x-nomad-index']; if (newIndex) { this.get('watchList').setIndexFor(requestData.url, newIndex); } + return this._super(...arguments); }, diff --git a/ui/app/components/distribution-bar.js b/ui/app/components/distribution-bar.js index 84eb97f6574..50bd6fc6916 100644 --- a/ui/app/components/distribution-bar.js +++ b/ui/app/components/distribution-bar.js @@ -2,7 +2,7 @@ import Component from '@ember/component'; 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'; @@ -22,8 +22,8 @@ export default Component.extend(WindowResizable, { tooltipStyle: styleStringProperty('tooltipPosition'), maskId: null, - _data: computed('data.@each.{value,label,className,layers}', function() { - const data = this.get('data'); + _data: computed('data', function() { + const data = copy(this.get('data'), true); const sum = data.mapBy('value').reduce(sumAggregate, 0); return data.map(({ label, value, className, layers }, index) => ({ diff --git a/ui/app/utils/properties/watch.js b/ui/app/utils/properties/watch.js index a5729f2cd38..a372d293d6d 100644 --- a/ui/app/utils/properties/watch.js +++ b/ui/app/utils/properties/watch.js @@ -1,5 +1,4 @@ import Ember from 'ember'; -import { typeOf } from '@ember/utils'; import { get } from '@ember/object'; import RSVP from 'rsvp'; import { task } from 'ember-concurrency'; @@ -7,7 +6,7 @@ import wait from 'nomad-ui/utils/wait'; export function watchRecord(modelName) { return task(function*(id, throttle = 2000) { - if (typeOf(id) === 'object') { + if (typeof id === 'object') { id = get(id, 'id'); } while (!Ember.testing) { diff --git a/ui/mirage/config.js b/ui/mirage/config.js index adf7f113873..59f833cb27f 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -35,7 +35,7 @@ export default function() { response.headers['X-Nomad-Index'] = index; return response; } - return new Response(200, { 'X-Nomad-Index': index }, response); + return new Response(200, { 'x-nomad-index': index }, response); }; };