diff --git a/ui/app/adapters/allocation.js b/ui/app/adapters/allocation.js new file mode 100644 index 00000000000..8aa4b662fd5 --- /dev/null +++ b/ui/app/adapters/allocation.js @@ -0,0 +1,3 @@ +import Watchable from './watchable'; + +export default Watchable.extend(); diff --git a/ui/app/adapters/application.js b/ui/app/adapters/application.js index bcf223383f9..4bd664c1c00 100644 --- a/ui/app/adapters/application.js +++ b/ui/app/adapters/application.js @@ -34,6 +34,24 @@ export default RESTAdapter.extend({ }); }, + // In order to remove stale records from the store, findHasMany has to unload + // all records related to the request in question. + findHasMany(store, snapshot, link, relationship) { + return this._super(...arguments).then(payload => { + const ownerType = snapshot.modelName; + const relationshipType = relationship.type; + // Naively assume that the inverse relationship is named the same as the + // owner type. In the event it isn't, findHasMany should be overridden. + store + .peekAll(relationshipType) + .filter(record => record.get(`${ownerType}.id`) === snapshot.id) + .forEach(record => { + store.unloadRecord(record); + }); + return payload; + }); + }, + // Single record requests deviate from REST practice by using // the singular form of the resource name. // diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index c783c6ff2ff..7d90a950c79 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -1,19 +1,17 @@ import { inject as service } from '@ember/service'; -import RSVP from 'rsvp'; import { assign } from '@ember/polyfills'; -import ApplicationAdapter from './application'; +import Watchable from './watchable'; -export default ApplicationAdapter.extend({ +export default Watchable.extend({ system: service(), - shouldReloadAll: () => true, - buildQuery() { const namespace = this.get('system.activeNamespace.id'); if (namespace && namespace !== 'default') { return { namespace }; } + return {}; }, findAll() { @@ -26,28 +24,26 @@ 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 - const [name, namespace] = JSON.parse(id); + findRecord(store, type, id, snapshot) { + const [, 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, id, snapshot, namespaceQuery); + }, + + urlForFindRecord(id, type, hash) { + const [name, namespace] = JSON.parse(id); + let url = this._super(name, type, hash); + if (namespace && namespace !== 'default') { + url += `?namespace=${namespace}`; + } + return url; }, findAllocations(job) { @@ -60,19 +56,17 @@ export default ApplicationAdapter.extend({ }, fetchRawDefinition(job) { - const [name, namespace] = JSON.parse(job.get('id')); - const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {}; - const url = this.buildURL('job', name, job, 'findRecord'); - return this.ajax(url, 'GET', { data: assign(this.buildQuery() || {}, namespaceQuery) }); + const url = this.buildURL('job', job.get('id'), job, 'findRecord'); + return this.ajax(url, 'GET', { data: this.buildQuery() }); }, forcePeriodic(job) { if (job.get('periodic')) { - const [name, namespace] = JSON.parse(job.get('id')); - let url = `${this.buildURL('job', name, job, 'findRecord')}/periodic/force`; + const [path, params] = this.buildURL('job', job.get('id'), job, 'findRecord').split('?'); + let url = `${path}/periodic/force`; - if (namespace) { - url += `?namespace=${namespace}`; + if (params) { + url += `?${params}`; } return this.ajax(url, 'POST'); diff --git a/ui/app/adapters/node.js b/ui/app/adapters/node.js index 349e44b821a..5d77bebd268 100644 --- a/ui/app/adapters/node.js +++ b/ui/app/adapters/node.js @@ -1,6 +1,6 @@ -import ApplicationAdapter from './application'; +import Watchable from './watchable'; -export default ApplicationAdapter.extend({ +export default Watchable.extend({ findAllocations(node) { const url = `${this.buildURL('node', node.get('id'), node, 'findRecord')}/allocations`; return this.ajax(url, 'GET').then(allocs => { diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js new file mode 100644 index 00000000000..5b71a5e3f09 --- /dev/null +++ b/ui/app/adapters/watchable.js @@ -0,0 +1,147 @@ +import { get, computed } from '@ember/object'; +import { assign } from '@ember/polyfills'; +import { inject as service } from '@ember/service'; +import queryString from 'npm:query-string'; +import ApplicationAdapter from './application'; +import { AbortError } from 'ember-data/adapters/errors'; + +export default ApplicationAdapter.extend({ + watchList: service(), + store: service(), + + xhrs: computed(function() { + return {}; + }), + + ajaxOptions(url) { + const ajaxOptions = this._super(...arguments); + + const previousBeforeSend = ajaxOptions.beforeSend; + ajaxOptions.beforeSend = function(jqXHR) { + if (previousBeforeSend) { + previousBeforeSend(...arguments); + } + this.get('xhrs')[url] = jqXHR; + jqXHR.always(() => { + delete this.get('xhrs')[url]; + }); + }; + + return ajaxOptions; + }, + + findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) { + const params = assign(this.buildQuery(), additionalParams); + const url = this.urlForFindAll(type.modelName); + + if (get(snapshotRecordArray || {}, 'adapterOptions.watch')) { + params.index = this.get('watchList').getIndexFor(url); + this.cancelFindAll(type.modelName); + } + + return this.ajax(url, 'GET', { + data: params, + }); + }, + + findRecord(store, type, id, snapshot, additionalParams = {}) { + let [url, params] = this.buildURL(type.modelName, id, snapshot, 'findRecord').split('?'); + params = assign(queryString.parse(params) || {}, this.buildQuery(), additionalParams); + + if (get(snapshot || {}, 'adapterOptions.watch')) { + params.index = this.get('watchList').getIndexFor(url); + this.cancelFindRecord(type.modelName, id); + } + + return this.ajax(url, 'GET', { + data: params, + }).catch(error => { + if (error instanceof AbortError) { + return {}; + } + throw error; + }); + }, + + reloadRelationship(model, relationshipName, watch = false) { + const relationship = model.relationshipFor(relationshipName); + if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') { + throw new Error( + `${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}` + ); + } else { + const url = model[relationship.kind](relationship.key).link(); + let params = {}; + + if (watch) { + params.index = this.get('watchList').getIndexFor(url); + this.cancelReloadRelationship(model, relationshipName); + } + + if (url.includes('?')) { + params = assign(queryString.parse(url.split('?')[1]), params); + } + + return this.ajax(url, 'GET', { + data: params, + }).then( + json => { + const store = this.get('store'); + const normalizeMethod = + relationship.kind === 'belongsTo' + ? 'normalizeFindBelongsToResponse' + : 'normalizeFindHasManyResponse'; + const serializer = store.serializerFor(relationship.type); + const modelClass = store.modelFor(relationship.type); + const normalizedData = serializer[normalizeMethod](store, modelClass, json); + store.push(normalizedData); + }, + error => { + if (error instanceof AbortError) { + return relationship.kind === 'belongsTo' ? {} : []; + } + throw error; + } + ); + } + }, + + handleResponse(status, headers, payload, requestData) { + const newIndex = headers['x-nomad-index']; + if (newIndex) { + this.get('watchList').setIndexFor(requestData.url, newIndex); + } + + return this._super(...arguments); + }, + + cancelFindRecord(modelName, id) { + const url = this.urlForFindRecord(id, modelName); + const xhr = this.get('xhrs')[url]; + if (xhr) { + xhr.abort(); + } + }, + + cancelFindAll(modelName) { + const xhr = this.get('xhrs')[this.urlForFindAll(modelName)]; + if (xhr) { + xhr.abort(); + } + }, + + cancelReloadRelationship(model, relationshipName) { + const relationship = model.relationshipFor(relationshipName); + if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') { + throw new Error( + `${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}` + ); + } else { + const url = model[relationship.kind](relationship.key).link(); + const xhr = this.get('xhrs')[url]; + if (xhr) { + xhr.abort(); + } + } + }, +}); diff --git a/ui/app/components/client-node-row.js b/ui/app/components/client-node-row.js index 2775ed58fbb..2e465ce3fc6 100644 --- a/ui/app/components/client-node-row.js +++ b/ui/app/components/client-node-row.js @@ -1,7 +1,12 @@ +import { inject as service } from '@ember/service'; import Component from '@ember/component'; import { lazyClick } from '../helpers/lazy-click'; +import { watchRelationship } from 'nomad-ui/utils/properties/watch'; +import WithVisibilityDetection from 'nomad-ui/mixins/with-component-visibility-detection'; + +export default Component.extend(WithVisibilityDetection, { + store: service(), -export default Component.extend({ tagName: 'tr', classNames: ['client-node-row', 'is-interactive'], @@ -17,7 +22,27 @@ export default Component.extend({ // Reload the node in order to get detail information const node = this.get('node'); if (node) { - node.reload(); + node.reload().then(() => { + this.get('watch').perform(node, 100); + }); + } + }, + + visibilityHandler() { + if (document.hidden) { + this.get('watch').cancelAll(); + } else { + const node = this.get('node'); + if (node) { + this.get('watch').perform(node, 100); + } } }, + + willDestroy() { + this.get('watch').cancelAll(); + this._super(...arguments); + }, + + watch: watchRelationship('allocations'), }); diff --git a/ui/app/components/distribution-bar.js b/ui/app/components/distribution-bar.js index 46d47d8f4b4..50bd6fc6916 100644 --- a/ui/app/components/distribution-bar.js +++ b/ui/app/components/distribution-bar.js @@ -1,8 +1,8 @@ import Component from '@ember/component'; -import { computed } from '@ember/object'; +import { computed, observer } from '@ember/object'; import { run } from '@ember/runloop'; import { assign } from '@ember/polyfills'; -import { guidFor } from '@ember/object/internals'; +import { guidFor, copy } from '@ember/object/internals'; import d3 from 'npm:d3-selection'; import 'npm:d3-transition'; import WindowResizable from '../mixins/window-resizable'; @@ -23,7 +23,7 @@ export default Component.extend(WindowResizable, { maskId: null, _data: computed('data', function() { - const data = this.get('data'); + const data = copy(this.get('data'), true); const sum = data.mapBy('value').reduce(sumAggregate, 0); return data.map(({ label, value, className, layers }, index) => ({ @@ -66,6 +66,10 @@ export default Component.extend(WindowResizable, { this.renderChart(); }, + updateChart: observer('_data.@each.{value,label,className}', function() { + this.renderChart(); + }), + // prettier-ignore /* eslint-disable */ renderChart() { @@ -73,7 +77,7 @@ export default Component.extend(WindowResizable, { const width = this.$('svg').width(); const filteredData = _data.filter(d => d.value > 0); - let slices = chart.select('.bars').selectAll('g').data(filteredData); + let slices = chart.select('.bars').selectAll('g').data(filteredData, d => d.label); let sliceCount = filteredData.length; slices.exit().remove(); @@ -82,7 +86,8 @@ export default Component.extend(WindowResizable, { .append('g') .on('mouseenter', d => { run(() => { - const slice = slices.filter(datum => datum === d); + const slices = this.get('slices'); + const slice = slices.filter(datum => datum.label === d.label); slices.classed('active', false).classed('inactive', true); slice.classed('active', true).classed('inactive', false); this.set('activeDatum', d); @@ -99,7 +104,15 @@ export default Component.extend(WindowResizable, { }); slices = slices.merge(slicesEnter); - slices.attr('class', d => d.className || `slice-${_data.indexOf(d)}`); + slices.attr('class', d => { + const className = d.className || `slice-${_data.indexOf(d)}` + const activeDatum = this.get('activeDatum'); + const isActive = activeDatum && activeDatum.label === d.label; + const isInactive = activeDatum && activeDatum.label !== d.label; + return [ className, isActive && 'active', isInactive && 'inactive' ].compact().join(' '); + }); + + this.set('slices', slices); const setWidth = d => `${width * d.percent - (d.index === sliceCount - 1 || d.index === 0 ? 1 : 2)}px` const setOffset = d => `${width * d.offset + (d.index === 0 ? 0 : 1)}px` @@ -117,7 +130,6 @@ export default Component.extend(WindowResizable, { .attr('width', setWidth) .attr('x', setOffset) - let layers = slices.selectAll('.bar').data((d, i) => { return new Array(d.layers || 1).fill(assign({ index: i }, d)); }); diff --git a/ui/app/components/job-row.js b/ui/app/components/job-row.js index db9e6e3699a..b48913dfc9a 100644 --- a/ui/app/components/job-row.js +++ b/ui/app/components/job-row.js @@ -1,7 +1,12 @@ +import { inject as service } from '@ember/service'; import Component from '@ember/component'; import { lazyClick } from '../helpers/lazy-click'; +import { watchRelationship } from 'nomad-ui/utils/properties/watch'; +import WithVisibilityDetection from 'nomad-ui/mixins/with-component-visibility-detection'; + +export default Component.extend(WithVisibilityDetection, { + store: service(), -export default Component.extend({ tagName: 'tr', classNames: ['job-row', 'is-interactive'], @@ -17,7 +22,27 @@ export default Component.extend({ // Reload the job in order to get detail information const job = this.get('job'); if (job && !job.get('isLoading')) { - job.reload(); + job.reload().then(() => { + this.get('watch').perform(job, 100); + }); + } + }, + + visibilityHandler() { + if (document.hidden) { + this.get('watch').cancelAll(); + } else { + const job = this.get('job'); + if (job && !job.get('isLoading')) { + this.get('watch').perform(job, 100); + } } }, + + willDestroy() { + this.get('watch').cancelAll(); + this._super(...arguments); + }, + + watch: watchRelationship('summary'), }); diff --git a/ui/app/components/job-versions-stream.js b/ui/app/components/job-versions-stream.js index b2a92d71a8b..460d0b9a244 100644 --- a/ui/app/components/job-versions-stream.js +++ b/ui/app/components/job-versions-stream.js @@ -12,7 +12,9 @@ export default Component.extend({ verbose: true, annotatedVersions: computed('versions.[]', function() { - const versions = this.get('versions'); + const versions = this.get('versions') + .sortBy('submitTime') + .reverse(); return versions.map((version, index) => { const meta = {}; 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', }); }), diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index db1a561c049..2f52e1a0897 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -37,7 +37,7 @@ export default Controller.extend(Sortable, Searchable, { 'system.namespaces.length', function() { const hasNamespaces = this.get('system.namespaces.length'); - const activeNamespace = this.get('system.activeNamespace.id'); + const activeNamespace = this.get('system.activeNamespace.id') || 'default'; return this.get('model') .filter(job => !hasNamespaces || job.get('namespace.id') === activeNamespace) diff --git a/ui/app/mixins/window-resizable.js b/ui/app/mixins/window-resizable.js index 6a7055bdf40..4799bddafaf 100644 --- a/ui/app/mixins/window-resizable.js +++ b/ui/app/mixins/window-resizable.js @@ -1,8 +1,13 @@ import Mixin from '@ember/object/mixin'; import { run } from '@ember/runloop'; +import { assert } from '@ember/debug'; import $ from 'jquery'; export default Mixin.create({ + windowResizeHandler() { + assert('windowResizeHandler needs to be overridden in the Component', false); + }, + setupWindowResize: function() { run.scheduleOnce('afterRender', this, () => { this.set('_windowResizeHandler', this.get('windowResizeHandler').bind(this)); diff --git a/ui/app/mixins/with-component-visibility-detection.js b/ui/app/mixins/with-component-visibility-detection.js new file mode 100644 index 00000000000..66d1097ee4e --- /dev/null +++ b/ui/app/mixins/with-component-visibility-detection.js @@ -0,0 +1,17 @@ +import Mixin from '@ember/object/mixin'; +import { assert } from '@ember/debug'; + +export default Mixin.create({ + visibilityHandler() { + assert('visibilityHandler needs to be overridden in the Component', false); + }, + + setupDocumentVisibility: function() { + this.set('_visibilityHandler', this.get('visibilityHandler').bind(this)); + document.addEventListener('visibilitychange', this.get('_visibilityHandler')); + }.on('init'), + + removeDocumentVisibility: function() { + document.removeEventListener('visibilitychange', this.get('_visibilityHandler')); + }.on('willDestroy'), +}); diff --git a/ui/app/mixins/with-route-visibility-detection.js b/ui/app/mixins/with-route-visibility-detection.js new file mode 100644 index 00000000000..1df6e5f4580 --- /dev/null +++ b/ui/app/mixins/with-route-visibility-detection.js @@ -0,0 +1,17 @@ +import Mixin from '@ember/object/mixin'; +import { assert } from '@ember/debug'; + +export default Mixin.create({ + visibilityHandler() { + assert('visibilityHandler needs to be overridden in the Route', false); + }, + + setupDocumentVisibility: function() { + this.set('_visibilityHandler', this.get('visibilityHandler').bind(this)); + document.addEventListener('visibilitychange', this.get('_visibilityHandler')); + }.on('activate'), + + removeDocumentVisibility: function() { + document.removeEventListener('visibilitychange', this.get('_visibilityHandler')); + }.on('deactivate'), +}); diff --git a/ui/app/mixins/with-watchers.js b/ui/app/mixins/with-watchers.js new file mode 100644 index 00000000000..c9690dbdf02 --- /dev/null +++ b/ui/app/mixins/with-watchers.js @@ -0,0 +1,38 @@ +import Mixin from '@ember/object/mixin'; +import { computed } from '@ember/object'; +import { assert } from '@ember/debug'; +import WithVisibilityDetection from './with-route-visibility-detection'; + +export default Mixin.create(WithVisibilityDetection, { + watchers: computed(() => []), + + cancelAllWatchers() { + this.get('watchers').forEach(watcher => { + assert('Watchers must be Ember Concurrency Tasks.', !!watcher.cancelAll); + watcher.cancelAll(); + }); + }, + + startWatchers() { + assert('startWatchers needs to be overridden in the Route', false); + }, + + setupController() { + this.startWatchers(...arguments); + return this._super(...arguments); + }, + + visibilityHandler() { + if (document.hidden) { + this.cancelAllWatchers(); + } else { + this.startWatchers(this.controller, this.controller.get('model')); + } + }, + + actions: { + willTransition() { + this.cancelAllWatchers(); + }, + }, +}); 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/routes/allocations/allocation.js b/ui/app/routes/allocations/allocation.js index 17aa8b10c92..3f46faa2f7c 100644 --- a/ui/app/routes/allocations/allocation.js +++ b/ui/app/routes/allocations/allocation.js @@ -1,4 +1,15 @@ import Route from '@ember/routing/route'; import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling'; +import { collect } from '@ember/object/computed'; +import { watchRecord } from 'nomad-ui/utils/properties/watch'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; -export default Route.extend(WithModelErrorHandling); +export default Route.extend(WithModelErrorHandling, WithWatchers, { + startWatchers(controller, model) { + controller.set('watcher', this.get('watch').perform(model)); + }, + + watch: watchRecord('allocation'), + + watchers: collect('watch'), +}); diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index f7437a18c15..469e76a0af9 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -1,5 +1,6 @@ import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; +import { AbortError } from 'ember-data/adapters/errors'; export default Route.extend({ config: service(), @@ -22,7 +23,9 @@ export default Route.extend({ }, error(error) { - this.controllerFor('application').set('error', error); + if (!(error instanceof AbortError)) { + this.controllerFor('application').set('error', error); + } }, }, }); diff --git a/ui/app/routes/clients/client.js b/ui/app/routes/clients/client.js index 1be621e4749..f049d1efe2f 100644 --- a/ui/app/routes/clients/client.js +++ b/ui/app/routes/clients/client.js @@ -1,8 +1,11 @@ import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; +import { collect } from '@ember/object/computed'; import notifyError from 'nomad-ui/utils/notify-error'; +import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; -export default Route.extend({ +export default Route.extend(WithWatchers, { store: service(), model() { @@ -15,4 +18,14 @@ export default Route.extend({ } return model && model.get('allocations'); }, + + startWatchers(controller, model) { + controller.set('watchModel', this.get('watch').perform(model)); + controller.set('watchAllocations', this.get('watchAllocations').perform(model)); + }, + + watch: watchRecord('node'), + watchAllocations: watchRelationship('allocations'), + + watchers: collect('watch', 'watchAllocations'), }); diff --git a/ui/app/routes/clients/index.js b/ui/app/routes/clients/index.js new file mode 100644 index 00000000000..0d9770e677e --- /dev/null +++ b/ui/app/routes/clients/index.js @@ -0,0 +1,13 @@ +import Route from '@ember/routing/route'; +import { collect } from '@ember/object/computed'; +import { watchAll } from 'nomad-ui/utils/properties/watch'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; + +export default Route.extend(WithWatchers, { + startWatchers(controller) { + controller.set('watcher', this.get('watch').perform()); + }, + + watch: watchAll('node'), + watchers: collect('watch'), +}); diff --git a/ui/app/routes/jobs/index.js b/ui/app/routes/jobs/index.js index 0a8317feac4..bb3c0b6a325 100644 --- a/ui/app/routes/jobs/index.js +++ b/ui/app/routes/jobs/index.js @@ -1,6 +1,16 @@ import Route from '@ember/routing/route'; +import { collect } from '@ember/object/computed'; +import { watchAll } from 'nomad-ui/utils/properties/watch'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; + +export default Route.extend(WithWatchers, { + startWatchers(controller) { + controller.set('modelWatch', this.get('watch').perform()); + }, + + watch: watchAll('job'), + watchers: collect('watch'), -export default Route.extend({ actions: { refreshRoute() { return true; diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js index 558ea55e459..86b784508ce 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -5,6 +5,7 @@ import notifyError from 'nomad-ui/utils/notify-error'; export default Route.extend({ store: service(), + token: service(), serialize(model) { return { job_name: model.get('plainId') }; diff --git a/ui/app/routes/jobs/job/deployments.js b/ui/app/routes/jobs/job/deployments.js index 41363eff69b..9f4e974106a 100644 --- a/ui/app/routes/jobs/job/deployments.js +++ b/ui/app/routes/jobs/job/deployments.js @@ -1,9 +1,22 @@ import Route from '@ember/routing/route'; import RSVP from 'rsvp'; +import { collect } from '@ember/object/computed'; +import { watchRelationship } from 'nomad-ui/utils/properties/watch'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; -export default Route.extend({ +export default Route.extend(WithWatchers, { model() { const job = this.modelFor('jobs.job'); return RSVP.all([job.get('deployments'), job.get('versions')]).then(() => job); }, + + startWatchers(controller, model) { + controller.set('watchDeployments', this.get('watchDeployments').perform(model)); + controller.set('watchVersions', this.get('watchVersions').perform(model)); + }, + + watchDeployments: watchRelationship('deployments'), + watchVersions: watchRelationship('versions'), + + watchers: collect('watchDeployments', 'watchVersions'), }); diff --git a/ui/app/routes/jobs/job/index.js b/ui/app/routes/jobs/job/index.js new file mode 100644 index 00000000000..6f0b17858db --- /dev/null +++ b/ui/app/routes/jobs/job/index.js @@ -0,0 +1,22 @@ +import Route from '@ember/routing/route'; +import { collect } from '@ember/object/computed'; +import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; + +export default Route.extend(WithWatchers, { + startWatchers(controller, model) { + controller.set('watchers', { + model: this.get('watch').perform(model), + summary: this.get('watchSummary').perform(model), + evaluations: this.get('watchEvaluations').perform(model), + deployments: this.get('watchDeployments').perform(model), + }); + }, + + watch: watchRecord('job'), + watchSummary: watchRelationship('summary'), + watchEvaluations: watchRelationship('evaluations'), + watchDeployments: watchRelationship('deployments'), + + watchers: collect('watch', 'watchSummary', 'watchEvaluations', 'watchDeployments'), +}); diff --git a/ui/app/routes/jobs/job/task-group.js b/ui/app/routes/jobs/job/task-group.js index def6e57eaac..6a8b42480e9 100644 --- a/ui/app/routes/jobs/job/task-group.js +++ b/ui/app/routes/jobs/job/task-group.js @@ -1,6 +1,9 @@ import Route from '@ember/routing/route'; +import { collect } from '@ember/object/computed'; +import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; -export default Route.extend({ +export default Route.extend(WithWatchers, { model({ name }) { // If the job is a partial (from the list request) it won't have task // groups. Reload the job to ensure task groups are present. @@ -15,4 +18,19 @@ export default Route.extend({ }); }); }, + + startWatchers(controller, model) { + const job = model.get('job'); + controller.set('watchers', { + job: this.get('watchJob').perform(job), + summary: this.get('watchSummary').perform(job), + allocations: this.get('watchAllocations').perform(job), + }); + }, + + watchJob: watchRecord('job'), + watchSummary: watchRelationship('summary'), + watchAllocations: watchRelationship('allocations'), + + watchers: collect('watchJob', 'watchSummary', 'watchAllocations'), }); diff --git a/ui/app/routes/jobs/job/versions.js b/ui/app/routes/jobs/job/versions.js index 6debc85db39..4e24e6a7ec9 100644 --- a/ui/app/routes/jobs/job/versions.js +++ b/ui/app/routes/jobs/job/versions.js @@ -1,8 +1,18 @@ import Route from '@ember/routing/route'; +import { collect } from '@ember/object/computed'; +import { watchRelationship } from 'nomad-ui/utils/properties/watch'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; -export default Route.extend({ +export default Route.extend(WithWatchers, { model() { const job = this.modelFor('jobs.job'); return job.get('versions').then(() => job); }, + + startWatchers(controller, model) { + controller.set('watcher', this.get('watchVersions').perform(model)); + }, + + watchVersions: watchRelationship('versions'), + watchers: collect('watchVersions'), }); diff --git a/ui/app/serializers/application.js b/ui/app/serializers/application.js index 3d63f5b6344..08796baad4a 100644 --- a/ui/app/serializers/application.js +++ b/ui/app/serializers/application.js @@ -1,3 +1,5 @@ +import { copy } from '@ember/object/internals'; +import { get } from '@ember/object'; import { makeArray } from '@ember/array'; import JSONSerializer from 'ember-data/serializers/json'; @@ -35,9 +37,33 @@ export default JSONSerializer.extend({ documentHash.included.push(...included); } }); - - store.push(documentHash); }); + + store.push(documentHash); + }, + + normalizeFindAllResponse(store, modelClass) { + const result = this._super(...arguments); + this.cullStore(store, modelClass.modelName, result.data); + return result; + }, + + // When records are removed server-side, and therefore don't show up in requests, + // the local copies of those records need to be unloaded from the store. + cullStore(store, type, records, storeFilter = () => true) { + const newRecords = copy(records).filter(record => get(record, 'id')); + const oldRecords = store.peekAll(type); + oldRecords + .filter(record => get(record, 'id')) + .filter(storeFilter) + .forEach(old => { + const newRecord = newRecords.find(record => get(record, 'id') === get(old, 'id')); + if (!newRecord) { + store.unloadRecord(old); + } else { + newRecords.removeObject(newRecord); + } + }); }, modelNameFromPayloadKey(key) { diff --git a/ui/app/serializers/job-summary.js b/ui/app/serializers/job-summary.js new file mode 100644 index 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-version.js b/ui/app/serializers/job-version.js index f05809b3f24..4e250d5d8d5 100644 --- a/ui/app/serializers/job-version.js +++ b/ui/app/serializers/job-version.js @@ -11,6 +11,7 @@ export default ApplicationSerializer.extend({ assign({}, version, { Diff: hash.Diffs && hash.Diffs[index], ID: `${version.ID}-${version.Version}`, + JobID: JSON.stringify([version.ID, version.Namespace || 'default']), SubmitTime: Math.floor(version.SubmitTime / 1000000), SubmitTimeNanos: version.SubmitTime % 1000000, }) diff --git a/ui/app/serializers/job.js b/ui/app/serializers/job.js index df77e2f38ab..7271f86d861 100644 --- a/ui/app/serializers/job.js +++ b/ui/app/serializers/job.js @@ -1,4 +1,3 @@ -import { get } from '@ember/object'; import { assign } from '@ember/polyfills'; import ApplicationSerializer from './application'; import queryString from 'npm:query-string'; @@ -34,27 +33,6 @@ export default ApplicationSerializer.extend({ hash.ParameterizedJob = true; } - // Transform the map-based JobSummary object into an array-based - // JobSummary fragment list - hash.TaskGroupSummaries = Object.keys(get(hash, 'JobSummary.Summary') || {}).map(key => { - const allocStats = get(hash, `JobSummary.Summary.${key}`) || {}; - const summary = { Name: key }; - - Object.keys(allocStats).forEach( - allocKey => (summary[`${allocKey}Allocs`] = allocStats[allocKey]) - ); - - return summary; - }); - - // Lift the children stats out of the JobSummary object - const childrenStats = get(hash, 'JobSummary.Children'); - if (childrenStats) { - Object.keys(childrenStats).forEach( - childrenKey => (hash[`${childrenKey}Children`] = childrenStats[childrenKey]) - ); - } - return this._super(typeHash, hash); }, @@ -63,11 +41,17 @@ export default ApplicationSerializer.extend({ !hash.NamespaceID || hash.NamespaceID === 'default' ? undefined : hash.NamespaceID; const { modelName } = modelClass; - const jobURL = this.store + const [jobURL] = this.store .adapterFor(modelName) - .buildURL(modelName, hash.PlainId, hash, 'findRecord'); + .buildURL(modelName, hash.ID, hash, 'findRecord') + .split('?'); return assign(this._super(...arguments), { + summary: { + links: { + related: buildURL(`${jobURL}/summary`, { namespace: namespace }), + }, + }, allocations: { links: { related: buildURL(`${jobURL}/allocations`, { namespace: namespace }), diff --git a/ui/app/services/watch-list.js b/ui/app/services/watch-list.js new file mode 100644 index 00000000000..7e9277dd834 --- /dev/null +++ b/ui/app/services/watch-list.js @@ -0,0 +1,23 @@ +import { readOnly } from '@ember/object/computed'; +import { copy } from '@ember/object/internals'; +import Service from '@ember/service'; + +let list = {}; + +export default Service.extend({ + list: readOnly(function() { + return copy(list, true); + }), + + init() { + list = {}; + }, + + getIndexFor(url) { + return list[url] || 0; + }, + + setIndexFor(url, value) { + list[url] = value; + }, +}); diff --git a/ui/app/templates/components/job-deployments-stream.hbs b/ui/app/templates/components/job-deployments-stream.hbs index cf89df6150d..3479877873a 100644 --- a/ui/app/templates/components/job-deployments-stream.hbs +++ b/ui/app/templates/components/job-deployments-stream.hbs @@ -1,4 +1,4 @@ -{{#each annotatedDeployments as |record|}} +{{#each annotatedDeployments key="deployment.id" as |record|}} {{#if record.meta.showDate}}
  • {{#if record.deployment.version.submitTime}} 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}} diff --git a/ui/app/templates/components/job-versions-stream.hbs b/ui/app/templates/components/job-versions-stream.hbs index 30f3dabdc36..052b6a30b68 100644 --- a/ui/app/templates/components/job-versions-stream.hbs +++ b/ui/app/templates/components/job-versions-stream.hbs @@ -1,4 +1,4 @@ -{{#each annotatedVersions as |record|}} +{{#each annotatedVersions key="version.id" as |record|}} {{#if record.meta.showDate}}
  • {{moment-format record.version.submitTime "MMMM D, YYYY"}} diff --git a/ui/app/utils/properties/watch.js b/ui/app/utils/properties/watch.js new file mode 100644 index 00000000000..a372d293d6d --- /dev/null +++ b/ui/app/utils/properties/watch.js @@ -0,0 +1,73 @@ +import Ember from 'ember'; +import { get } from '@ember/object'; +import RSVP from 'rsvp'; +import { task } from 'ember-concurrency'; +import wait from 'nomad-ui/utils/wait'; + +export function watchRecord(modelName) { + return task(function*(id, throttle = 2000) { + if (typeof id === 'object') { + id = get(id, 'id'); + } + while (!Ember.testing) { + try { + yield RSVP.all([ + this.get('store').findRecord(modelName, id, { + reload: true, + adapterOptions: { watch: true }, + }), + wait(throttle), + ]); + } catch (e) { + yield e; + break; + } finally { + this.get('store') + .adapterFor(modelName) + .cancelFindRecord(modelName, id); + } + } + }).drop(); +} + +export function watchRelationship(relationshipName) { + return task(function*(model, throttle = 2000) { + while (!Ember.testing) { + try { + yield RSVP.all([ + this.get('store') + .adapterFor(model.constructor.modelName) + .reloadRelationship(model, relationshipName, true), + wait(throttle), + ]); + } catch (e) { + yield e; + break; + } finally { + this.get('store') + .adapterFor(model.constructor.modelName) + .cancelReloadRelationship(model, relationshipName); + } + } + }).drop(); +} + +export function watchAll(modelName) { + return task(function*(throttle = 2000) { + while (!Ember.testing) { + try { + yield RSVP.all([ + this.get('store').findAll(modelName, { reload: true, adapterOptions: { watch: true } }), + wait(throttle), + ]); + } catch (e) { + yield e; + break; + } finally { + this.get('store') + .adapterFor(modelName) + .cancelFindAll(modelName); + } + } + }).drop(); +} diff --git a/ui/app/utils/wait.js b/ui/app/utils/wait.js new file mode 100644 index 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); + }); +} diff --git a/ui/mirage/config.js b/ui/mirage/config.js index b760fb67308..ac4020d1623 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); - }); + 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('/job/:id/summary', function({ jobSummaries }, { params }) { - return this.serialize(jobSummaries.findBy({ jobId: params.id })); - }); + this.get( + '/jobs', + withBlockingSupport(function({ jobs }, { queryParams }) { + const json = this.serialize(jobs.all()); + const namespace = queryParams.namespace || 'default'; + return json + .filter( + job => + namespace === 'default' + ? !job.NamespaceID || job.NamespaceID === namespace + : job.NamespaceID === namespace + ) + .map(job => filterKeys(job, 'TaskGroups', 'NamespaceID')); + }) + ); + + this.get( + '/job/:id', + withBlockingSupport(function({ jobs }, { params, queryParams }) { + const job = jobs.all().models.find(job => { + const jobIsDefault = !job.namespaceId || job.namespaceId === 'default'; + const qpIsDefault = !queryParams.namespace || queryParams.namespace === 'default'; + return ( + job.id === params.id && + (job.namespaceId === queryParams.namespace || (jobIsDefault && qpIsDefault)) + ); + }); + + return job ? this.serialize(job) : new Response(404, {}, null); + }) + ); + + this.get( + '/job/:id/summary', + withBlockingSupport(function({ jobSummaries }, { params }) { + return this.serialize(jobSummaries.findBy({ jobId: params.id })); + }) + ); this.get('/job/:id/allocations', function({ allocations }, { params }) { return this.serialize(allocations.where({ jobId: params.id })); diff --git a/ui/mirage/factories/allocation.js b/ui/mirage/factories/allocation.js index 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..f2cfa9fadfc 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,22 @@ export default Factory.extend({ }); } + const groups = server.createList('task-group', job.groupsCount, { + job, + createAllocations: job.createAllocations, + }); + + job.update({ + taskGroupIds: groups.mapBy('id'), + task_group_ids: groups.mapBy('id'), + }); + const hasChildren = job.periodic || job.parameterized; const jobSummary = server.create('job-summary', hasChildren ? 'withChildren' : 'withSummary', { groupNames: groups.mapBy('name'), job, + job_id: job.id, + namespace: job.namespace, }); job.update({ @@ -124,22 +127,39 @@ export default Factory.extend({ .map((_, index) => { return server.create('job-version', { job, + namespace: job.namespace, version: index, noActiveDeployment: job.noActiveDeployment, activeDeployment: job.activeDeployment, }); }); - server.createList('evaluation', faker.random.number({ min: 1, max: 5 }), { job }); + const knownEvaluationProperties = { + job, + namespace: job.namespace, + }; + server.createList( + 'evaluation', + faker.random.number({ min: 1, max: 5 }), + knownEvaluationProperties + ); if (!job.noFailedPlacements) { - server.createList('evaluation', faker.random.number(3), 'withPlacementFailures', { job }); + server.createList( + 'evaluation', + faker.random.number(3), + 'withPlacementFailures', + knownEvaluationProperties + ); } if (job.failedPlacements) { - server.create('evaluation', 'withPlacementFailures', { - job, - modifyIndex: 4000, - }); + server.create( + 'evaluation', + 'withPlacementFailures', + assign(knownEvaluationProperties, { + modifyIndex: 4000, + }) + ); } if (job.periodic) { 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/helpers/module-for-adapter.js b/ui/tests/helpers/module-for-adapter.js new file mode 100644 index 00000000000..9601b1d0acd --- /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.afterEach) { + options.afterEach.apply(this, arguments); + } + }, + }); +} 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/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..b2e38840a00 100644 --- a/ui/tests/unit/adapters/job-test.js +++ b/ui/tests/unit/adapters/job-test.js @@ -1,9 +1,20 @@ -import { test, moduleFor } from 'ember-qunit'; +import { run } from '@ember/runloop'; +import { assign } from '@ember/polyfills'; +import { test } from 'ember-qunit'; +import wait from 'ember-test-helpers/wait'; import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import moduleForAdapter from '../../helpers/module-for-adapter'; -moduleFor('adapter:job', 'Unit | Adapter | Job', { - unit: true, - needs: ['service:token', 'service:system', 'model:namespace', 'adapter:application'], +moduleForAdapter('job', 'Unit | Adapter | Job', { + needs: [ + 'adapter:job', + 'service:token', + 'service:system', + 'model:namespace', + 'model:job-summary', + 'adapter:application', + 'service:watchList', + ], beforeEach() { window.sessionStorage.clear(); @@ -27,8 +38,8 @@ test('The job summary is stitched into the job request', function(assert) { assert.deepEqual( pretender.handledRequests.mapBy('url'), - ['/v1/namespaces', `/v1/job/${jobName}`, `/v1/job/${jobName}/summary`], - 'The three requests made are /namespaces, /job/:id, and /job/:id/summary' + ['/v1/namespaces', `/v1/job/${jobName}`], + 'The two requests made are /namespaces and /job/:id' ); }); @@ -42,18 +53,12 @@ test('When the job has a namespace other than default, it is in the URL', functi assert.deepEqual( pretender.handledRequests.mapBy('url'), - [ - '/v1/namespaces', - `/v1/job/${jobName}?namespace=${jobNamespace}`, - `/v1/job/${jobName}/summary?namespace=${jobNamespace}`, - ], - 'The three requests made are /namespaces, /job/:id?namespace=:namespace, and /job/:id/summary?namespace=:namespace' + ['/v1/namespaces', `/v1/job/${jobName}?namespace=${jobNamespace}`], + 'The two requests made are /namespaces and /job/:id?namespace=:namespace' ); }); -test('When there is no token set in the token service, no x-nomad-token header is set', function( - assert -) { +test('When there is no token set in the token service, no x-nomad-token header is set', function(assert) { const { pretender } = this.server; const jobId = JSON.stringify(['job-1', 'default']); @@ -65,9 +70,7 @@ test('When there is no token set in the token service, no x-nomad-token header i ); }); -test('When a token is set in the token service, then x-nomad-token header is set', function( - assert -) { +test('When a token is set in the token service, then x-nomad-token header is set', function(assert) { const { pretender } = this.server; const jobId = JSON.stringify(['job-1', 'default']); const secret = 'here is the secret'; @@ -82,3 +85,196 @@ test('When a token is set in the token service, then x-nomad-token header is set 'The token header is present on both job requests' ); }); + +test('findAll can be watched', function(assert) { + const { pretender } = this.server; + + const request = () => + this.subject().findAll(null, { modelName: 'job' }, null, { + reload: true, + adapterOptions: { watch: true }, + }); + + request(); + assert.equal( + pretender.handledRequests[0].url, + '/v1/namespaces', + 'First request is for namespaces' + ); + assert.equal( + pretender.handledRequests[1].url, + '/v1/jobs?index=0', + 'Second request is a blocking request for jobs' + ); + + return wait().then(() => { + request(); + assert.equal( + pretender.handledRequests[2].url, + '/v1/jobs?index=1', + 'Third request is a blocking request with an incremented index param' + ); + + return wait(); + }); +}); + +test('findRecord can be watched', function(assert) { + const jobId = JSON.stringify(['job-1', 'default']); + const { pretender } = this.server; + + const request = () => + this.subject().findRecord(null, { modelName: 'job' }, jobId, { + reload: true, + adapterOptions: { watch: true }, + }); + + request(); + assert.equal( + pretender.handledRequests[0].url, + '/v1/namespaces', + 'First request is for namespaces' + ); + assert.equal( + pretender.handledRequests[1].url, + '/v1/job/job-1?index=0', + 'Second request is a blocking request for job-1' + ); + + return wait().then(() => { + request(); + assert.equal( + pretender.handledRequests[2].url, + '/v1/job/job-1?index=1', + 'Third request is a blocking request with an incremented index param' + ); + + return wait(); + }); +}); + +test('relationships can be reloaded', function(assert) { + const { pretender } = this.server; + const plainId = 'job-1'; + const mockModel = makeMockModel(plainId); + + this.subject().reloadRelationship(mockModel, 'summary'); + assert.equal( + pretender.handledRequests[0].url, + `/v1/job/${plainId}/summary`, + 'Relationship was reloaded' + ); +}); + +test('relationship reloads can be watched', function(assert) { + const { pretender } = this.server; + const plainId = 'job-1'; + const mockModel = makeMockModel(plainId); + + this.subject().reloadRelationship(mockModel, 'summary', true); + assert.equal( + pretender.handledRequests[0].url, + '/v1/job/job-1/summary?index=0', + 'First request is a blocking request for job-1 summary relationship' + ); + + return wait().then(() => { + this.subject().reloadRelationship(mockModel, 'summary', true); + assert.equal( + pretender.handledRequests[1].url, + '/v1/job/job-1/summary?index=1', + 'Second request is a blocking request with an incremented index param' + ); + }); +}); + +test('findAll can be canceled', function(assert) { + const { pretender } = this.server; + pretender.get('/v1/jobs', () => [200, {}, '[]'], true); + + this.subject() + .findAll(null, { modelName: 'job' }, null, { + reload: true, + adapterOptions: { watch: true }, + }) + .catch(() => {}); + + const { request: xhr } = pretender.requestReferences[0]; + assert.equal(xhr.status, 0, 'Request is still pending'); + + // Schedule the cancelation before waiting + run.next(() => { + this.subject().cancelFindAll('job'); + }); + + return wait().then(() => { + assert.ok(xhr.aborted, 'Request was aborted'); + }); +}); + +test('findRecord can be canceled', function(assert) { + const { pretender } = this.server; + const jobId = JSON.stringify(['job-1', 'default']); + + pretender.get('/v1/job/:id', () => [200, {}, '{}'], true); + + this.subject().findRecord(null, { modelName: 'job' }, jobId, { + reload: true, + adapterOptions: { watch: true }, + }); + + const { request: xhr } = pretender.requestReferences[0]; + assert.equal(xhr.status, 0, 'Request is still pending'); + + // Schedule the cancelation before waiting + run.next(() => { + this.subject().cancelFindRecord('job', jobId); + }); + + return wait().then(() => { + assert.ok(xhr.aborted, 'Request was aborted'); + }); +}); + +test('relationship reloads can be canceled', function(assert) { + const { pretender } = this.server; + const plainId = 'job-1'; + const mockModel = makeMockModel(plainId); + pretender.get('/v1/job/:id/summary', () => [200, {}, '{}'], true); + + this.subject().reloadRelationship(mockModel, 'summary', true); + + const { request: xhr } = pretender.requestReferences[0]; + assert.equal(xhr.status, 0, 'Request is still pending'); + + // Schedule the cancelation before waiting + run.next(() => { + this.subject().cancelReloadRelationship(mockModel, 'summary'); + }); + + return wait().then(() => { + assert.ok(xhr.aborted, 'Request was aborted'); + }); +}); + +function makeMockModel(id, options) { + return assign( + { + relationshipFor(name) { + return { + kind: 'belongsTo', + type: 'job-summary', + key: name, + }; + }, + belongsTo(name) { + return { + link() { + return `/v1/job/${id}/${name}`; + }, + }; + }, + }, + options + ); +} diff --git a/ui/tests/unit/adapters/node-test.js b/ui/tests/unit/adapters/node-test.js new file mode 100644 index 00000000000..fde514eddf8 --- /dev/null +++ b/ui/tests/unit/adapters/node-test.js @@ -0,0 +1,123 @@ +import { run } from '@ember/runloop'; +import { test } from 'ember-qunit'; +import wait from 'ember-test-helpers/wait'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import moduleForAdapter from '../../helpers/module-for-adapter'; + +moduleForAdapter('node', 'Unit | Adapter | Node', { + needs: [ + 'adapter:node', + 'model:node-attributes', + 'model:allocation', + 'model:job', + 'serializer:application', + 'serializer:node', + 'service:token', + 'service:config', + 'service:watchList', + 'transform:fragment', + ], + beforeEach() { + this.server = startMirage(); + this.server.create('node', { id: 'node-1' }); + this.server.create('node', { id: 'node-2' }); + this.server.create('job', { id: 'job-1', createAllocations: false }); + + this.server.create('allocation', { id: 'node-1-1', nodeId: 'node-1' }); + this.server.create('allocation', { id: 'node-1-2', nodeId: 'node-1' }); + this.server.create('allocation', { id: 'node-2-1', nodeId: 'node-2' }); + this.server.create('allocation', { id: 'node-2-2', nodeId: 'node-2' }); + }, + afterEach() { + this.server.shutdown(); + }, +}); + +test('findHasMany removes old related models from the store', function(assert) { + let node; + run(() => { + // Fetch the model + this.store.findRecord('node', 'node-1').then(model => { + node = model; + + // Fetch the related allocations + return findHasMany(model, 'allocations').then(allocations => { + assert.equal( + allocations.get('length'), + this.server.db.allocations.where({ nodeId: node.get('id') }).length, + 'Allocations returned from the findHasMany matches the db state' + ); + }); + }); + }); + + return wait().then(() => { + server.db.allocations.remove('node-1-1'); + + run(() => { + // Reload the related allocations now that one was removed server-side + return findHasMany(node, 'allocations').then(allocations => { + const dbAllocations = this.server.db.allocations.where({ nodeId: node.get('id') }); + assert.equal( + allocations.get('length'), + dbAllocations.length, + 'Allocations returned from the findHasMany matches the db state' + ); + assert.equal( + this.store.peekAll('allocation').get('length'), + dbAllocations.length, + 'Server-side deleted allocation was removed from the store' + ); + }); + }); + }); +}); + +test('findHasMany does not remove old unrelated models from the store', function(assert) { + let node; + + run(() => { + // Fetch the first node and related allocations + this.store.findRecord('node', 'node-1').then(model => { + node = model; + return findHasMany(model, 'allocations'); + }); + + // Also fetch the second node and related allocations; + this.store.findRecord('node', 'node-2').then(model => findHasMany(model, 'allocations')); + }); + + return wait().then(() => { + assert.deepEqual( + this.store + .peekAll('allocation') + .mapBy('id') + .sort(), + ['node-1-1', 'node-1-2', 'node-2-1', 'node-2-2'], + 'All allocations for the first and second node are in the store' + ); + + server.db.allocations.remove('node-1-1'); + + run(() => { + // Reload the related allocations now that one was removed server-side + return findHasMany(node, 'allocations').then(() => { + assert.deepEqual( + this.store + .peekAll('allocation') + .mapBy('id') + .sort(), + ['node-1-2', 'node-2-1', 'node-2-2'], + 'The deleted allocation is removed from the store and the allocations associated with the other node are untouched' + ); + }); + }); + }); +}); + +// Using fetchLink on a model's hasMany relationship exercises the adapter's +// findHasMany method as well normalizing the response and pushing it to the store +function findHasMany(model, relationshipName) { + const relationship = model.relationshipFor(relationshipName); + return model.hasMany(relationship.key).hasManyRelationship.fetchLink(); +} 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', 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); + }); +}