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