mirror of
https://github.com/kemko/nomad.git
synced 2026-01-08 19:35:41 +03:00
Merge pull request #3936 from hashicorp/f-ui-polling
UI: Live updating views
This commit is contained in:
3
ui/app/adapters/allocation.js
Normal file
3
ui/app/adapters/allocation.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import Watchable from './watchable';
|
||||
|
||||
export default Watchable.extend();
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { inject as service } from '@ember/service';
|
||||
import RSVP from 'rsvp';
|
||||
import { assign } from '@ember/polyfills';
|
||||
import ApplicationAdapter from './application';
|
||||
import Watchable from './watchable';
|
||||
|
||||
export default ApplicationAdapter.extend({
|
||||
export default Watchable.extend({
|
||||
system: service(),
|
||||
|
||||
shouldReloadAll: () => true,
|
||||
|
||||
buildQuery() {
|
||||
const namespace = this.get('system.activeNamespace.id');
|
||||
|
||||
if (namespace && namespace !== 'default') {
|
||||
return { namespace };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
findAll() {
|
||||
@@ -26,30 +24,28 @@ export default ApplicationAdapter.extend({
|
||||
});
|
||||
},
|
||||
|
||||
findRecord(store, { modelName }, id, snapshot) {
|
||||
// To make a findRecord response reflect the findMany response, the JobSummary
|
||||
// from /summary needs to be stitched into the response.
|
||||
|
||||
// URL is the form of /job/:name?namespace=:namespace with arbitrary additional query params
|
||||
const [name, namespace] = JSON.parse(id);
|
||||
const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {};
|
||||
return RSVP.hash({
|
||||
job: this.ajax(this.buildURL(modelName, name, snapshot, 'findRecord'), 'GET', {
|
||||
data: assign(this.buildQuery() || {}, namespaceQuery),
|
||||
}),
|
||||
summary: this.ajax(
|
||||
`${this.buildURL(modelName, name, snapshot, 'findRecord')}/summary`,
|
||||
'GET',
|
||||
{
|
||||
data: assign(this.buildQuery() || {}, namespaceQuery),
|
||||
}
|
||||
),
|
||||
}).then(({ job, summary }) => {
|
||||
job.JobSummary = summary;
|
||||
return job;
|
||||
findRecordSummary(modelName, name, snapshot, namespaceQuery) {
|
||||
return this.ajax(`${this.buildURL(modelName, name, snapshot, 'findRecord')}/summary`, 'GET', {
|
||||
data: assign(this.buildQuery() || {}, namespaceQuery),
|
||||
});
|
||||
},
|
||||
|
||||
findRecord(store, type, id, snapshot) {
|
||||
const [, namespace] = JSON.parse(id);
|
||||
const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {};
|
||||
|
||||
return this._super(store, type, id, snapshot, namespaceQuery);
|
||||
},
|
||||
|
||||
urlForFindRecord(id, type, hash) {
|
||||
const [name, namespace] = JSON.parse(id);
|
||||
let url = this._super(name, type, hash);
|
||||
if (namespace && namespace !== 'default') {
|
||||
url += `?namespace=${namespace}`;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
|
||||
findAllocations(job) {
|
||||
const url = `${this.buildURL('job', job.get('id'), job, 'findRecord')}/allocations`;
|
||||
return this.ajax(url, 'GET', { data: this.buildQuery() }).then(allocs => {
|
||||
@@ -60,19 +56,17 @@ export default ApplicationAdapter.extend({
|
||||
},
|
||||
|
||||
fetchRawDefinition(job) {
|
||||
const [name, namespace] = JSON.parse(job.get('id'));
|
||||
const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {};
|
||||
const url = this.buildURL('job', name, job, 'findRecord');
|
||||
return this.ajax(url, 'GET', { data: assign(this.buildQuery() || {}, namespaceQuery) });
|
||||
const url = this.buildURL('job', job.get('id'), job, 'findRecord');
|
||||
return this.ajax(url, 'GET', { data: this.buildQuery() });
|
||||
},
|
||||
|
||||
forcePeriodic(job) {
|
||||
if (job.get('periodic')) {
|
||||
const [name, namespace] = JSON.parse(job.get('id'));
|
||||
let url = `${this.buildURL('job', name, job, 'findRecord')}/periodic/force`;
|
||||
const [path, params] = this.buildURL('job', job.get('id'), job, 'findRecord').split('?');
|
||||
let url = `${path}/periodic/force`;
|
||||
|
||||
if (namespace) {
|
||||
url += `?namespace=${namespace}`;
|
||||
if (params) {
|
||||
url += `?${params}`;
|
||||
}
|
||||
|
||||
return this.ajax(url, 'POST');
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
147
ui/app/adapters/watchable.js
Normal file
147
ui/app/adapters/watchable.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { get, computed } from '@ember/object';
|
||||
import { assign } from '@ember/polyfills';
|
||||
import { inject as service } from '@ember/service';
|
||||
import queryString from 'npm:query-string';
|
||||
import ApplicationAdapter from './application';
|
||||
import { AbortError } from 'ember-data/adapters/errors';
|
||||
|
||||
export default ApplicationAdapter.extend({
|
||||
watchList: service(),
|
||||
store: service(),
|
||||
|
||||
xhrs: computed(function() {
|
||||
return {};
|
||||
}),
|
||||
|
||||
ajaxOptions(url) {
|
||||
const ajaxOptions = this._super(...arguments);
|
||||
|
||||
const previousBeforeSend = ajaxOptions.beforeSend;
|
||||
ajaxOptions.beforeSend = function(jqXHR) {
|
||||
if (previousBeforeSend) {
|
||||
previousBeforeSend(...arguments);
|
||||
}
|
||||
this.get('xhrs')[url] = jqXHR;
|
||||
jqXHR.always(() => {
|
||||
delete this.get('xhrs')[url];
|
||||
});
|
||||
};
|
||||
|
||||
return ajaxOptions;
|
||||
},
|
||||
|
||||
findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) {
|
||||
const params = assign(this.buildQuery(), additionalParams);
|
||||
const url = this.urlForFindAll(type.modelName);
|
||||
|
||||
if (get(snapshotRecordArray || {}, 'adapterOptions.watch')) {
|
||||
params.index = this.get('watchList').getIndexFor(url);
|
||||
this.cancelFindAll(type.modelName);
|
||||
}
|
||||
|
||||
return this.ajax(url, 'GET', {
|
||||
data: params,
|
||||
});
|
||||
},
|
||||
|
||||
findRecord(store, type, id, snapshot, additionalParams = {}) {
|
||||
let [url, params] = this.buildURL(type.modelName, id, snapshot, 'findRecord').split('?');
|
||||
params = assign(queryString.parse(params) || {}, this.buildQuery(), additionalParams);
|
||||
|
||||
if (get(snapshot || {}, 'adapterOptions.watch')) {
|
||||
params.index = this.get('watchList').getIndexFor(url);
|
||||
this.cancelFindRecord(type.modelName, id);
|
||||
}
|
||||
|
||||
return this.ajax(url, 'GET', {
|
||||
data: params,
|
||||
}).catch(error => {
|
||||
if (error instanceof AbortError) {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
|
||||
reloadRelationship(model, relationshipName, watch = false) {
|
||||
const relationship = model.relationshipFor(relationshipName);
|
||||
if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') {
|
||||
throw new Error(
|
||||
`${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}`
|
||||
);
|
||||
} else {
|
||||
const url = model[relationship.kind](relationship.key).link();
|
||||
let params = {};
|
||||
|
||||
if (watch) {
|
||||
params.index = this.get('watchList').getIndexFor(url);
|
||||
this.cancelReloadRelationship(model, relationshipName);
|
||||
}
|
||||
|
||||
if (url.includes('?')) {
|
||||
params = assign(queryString.parse(url.split('?')[1]), params);
|
||||
}
|
||||
|
||||
return this.ajax(url, 'GET', {
|
||||
data: params,
|
||||
}).then(
|
||||
json => {
|
||||
const store = this.get('store');
|
||||
const normalizeMethod =
|
||||
relationship.kind === 'belongsTo'
|
||||
? 'normalizeFindBelongsToResponse'
|
||||
: 'normalizeFindHasManyResponse';
|
||||
const serializer = store.serializerFor(relationship.type);
|
||||
const modelClass = store.modelFor(relationship.type);
|
||||
const normalizedData = serializer[normalizeMethod](store, modelClass, json);
|
||||
store.push(normalizedData);
|
||||
},
|
||||
error => {
|
||||
if (error instanceof AbortError) {
|
||||
return relationship.kind === 'belongsTo' ? {} : [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
handleResponse(status, headers, payload, requestData) {
|
||||
const newIndex = headers['x-nomad-index'];
|
||||
if (newIndex) {
|
||||
this.get('watchList').setIndexFor(requestData.url, newIndex);
|
||||
}
|
||||
|
||||
return this._super(...arguments);
|
||||
},
|
||||
|
||||
cancelFindRecord(modelName, id) {
|
||||
const url = this.urlForFindRecord(id, modelName);
|
||||
const xhr = this.get('xhrs')[url];
|
||||
if (xhr) {
|
||||
xhr.abort();
|
||||
}
|
||||
},
|
||||
|
||||
cancelFindAll(modelName) {
|
||||
const xhr = this.get('xhrs')[this.urlForFindAll(modelName)];
|
||||
if (xhr) {
|
||||
xhr.abort();
|
||||
}
|
||||
},
|
||||
|
||||
cancelReloadRelationship(model, relationshipName) {
|
||||
const relationship = model.relationshipFor(relationshipName);
|
||||
if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') {
|
||||
throw new Error(
|
||||
`${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}`
|
||||
);
|
||||
} else {
|
||||
const url = model[relationship.kind](relationship.key).link();
|
||||
const xhr = this.get('xhrs')[url];
|
||||
if (xhr) {
|
||||
xhr.abort();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
17
ui/app/mixins/with-component-visibility-detection.js
Normal file
17
ui/app/mixins/with-component-visibility-detection.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import Mixin from '@ember/object/mixin';
|
||||
import { assert } from '@ember/debug';
|
||||
|
||||
export default Mixin.create({
|
||||
visibilityHandler() {
|
||||
assert('visibilityHandler needs to be overridden in the Component', false);
|
||||
},
|
||||
|
||||
setupDocumentVisibility: function() {
|
||||
this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
|
||||
document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
|
||||
}.on('init'),
|
||||
|
||||
removeDocumentVisibility: function() {
|
||||
document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
|
||||
}.on('willDestroy'),
|
||||
});
|
||||
17
ui/app/mixins/with-route-visibility-detection.js
Normal file
17
ui/app/mixins/with-route-visibility-detection.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import Mixin from '@ember/object/mixin';
|
||||
import { assert } from '@ember/debug';
|
||||
|
||||
export default Mixin.create({
|
||||
visibilityHandler() {
|
||||
assert('visibilityHandler needs to be overridden in the Route', false);
|
||||
},
|
||||
|
||||
setupDocumentVisibility: function() {
|
||||
this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
|
||||
document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
|
||||
}.on('activate'),
|
||||
|
||||
removeDocumentVisibility: function() {
|
||||
document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
|
||||
}.on('deactivate'),
|
||||
});
|
||||
38
ui/app/mixins/with-watchers.js
Normal file
38
ui/app/mixins/with-watchers.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import Mixin from '@ember/object/mixin';
|
||||
import { computed } from '@ember/object';
|
||||
import { assert } from '@ember/debug';
|
||||
import WithVisibilityDetection from './with-route-visibility-detection';
|
||||
|
||||
export default Mixin.create(WithVisibilityDetection, {
|
||||
watchers: computed(() => []),
|
||||
|
||||
cancelAllWatchers() {
|
||||
this.get('watchers').forEach(watcher => {
|
||||
assert('Watchers must be Ember Concurrency Tasks.', !!watcher.cancelAll);
|
||||
watcher.cancelAll();
|
||||
});
|
||||
},
|
||||
|
||||
startWatchers() {
|
||||
assert('startWatchers needs to be overridden in the Route', false);
|
||||
},
|
||||
|
||||
setupController() {
|
||||
this.startWatchers(...arguments);
|
||||
return this._super(...arguments);
|
||||
},
|
||||
|
||||
visibilityHandler() {
|
||||
if (document.hidden) {
|
||||
this.cancelAllWatchers();
|
||||
} else {
|
||||
this.startWatchers(this.controller, this.controller.get('model'));
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
willTransition() {
|
||||
this.cancelAllWatchers();
|
||||
},
|
||||
},
|
||||
});
|
||||
39
ui/app/models/job-summary.js
Normal file
39
ui/app/models/job-summary.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { collect, sum } from '@ember/object/computed';
|
||||
import Model from 'ember-data/model';
|
||||
import attr from 'ember-data/attr';
|
||||
import { belongsTo } from 'ember-data/relationships';
|
||||
import { fragmentArray } from 'ember-data-model-fragments/attributes';
|
||||
import sumAggregation from '../utils/properties/sum-aggregation';
|
||||
|
||||
export default Model.extend({
|
||||
job: belongsTo('job'),
|
||||
|
||||
taskGroupSummaries: fragmentArray('task-group-summary'),
|
||||
|
||||
// Aggregate allocation counts across all summaries
|
||||
queuedAllocs: sumAggregation('taskGroupSummaries', 'queuedAllocs'),
|
||||
startingAllocs: sumAggregation('taskGroupSummaries', 'startingAllocs'),
|
||||
runningAllocs: sumAggregation('taskGroupSummaries', 'runningAllocs'),
|
||||
completeAllocs: sumAggregation('taskGroupSummaries', 'completeAllocs'),
|
||||
failedAllocs: sumAggregation('taskGroupSummaries', 'failedAllocs'),
|
||||
lostAllocs: sumAggregation('taskGroupSummaries', 'lostAllocs'),
|
||||
|
||||
allocsList: collect(
|
||||
'queuedAllocs',
|
||||
'startingAllocs',
|
||||
'runningAllocs',
|
||||
'completeAllocs',
|
||||
'failedAllocs',
|
||||
'lostAllocs'
|
||||
),
|
||||
|
||||
totalAllocs: sum('allocsList'),
|
||||
|
||||
pendingChildren: attr('number'),
|
||||
runningChildren: attr('number'),
|
||||
deadChildren: attr('number'),
|
||||
|
||||
childrenList: collect('pendingChildren', 'runningChildren', 'deadChildren'),
|
||||
|
||||
totalChildren: sum('childrenList'),
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
import { collect, sum, bool, equal, or } from '@ember/object/computed';
|
||||
import { alias, bool, equal, or } from '@ember/object/computed';
|
||||
import { computed } from '@ember/object';
|
||||
import Model from 'ember-data/model';
|
||||
import attr from 'ember-data/attr';
|
||||
import { belongsTo, hasMany } from 'ember-data/relationships';
|
||||
import { fragmentArray } from 'ember-data-model-fragments/attributes';
|
||||
import sumAggregation from '../utils/properties/sum-aggregation';
|
||||
|
||||
const JOB_TYPES = ['service', 'batch', 'system'];
|
||||
|
||||
@@ -83,34 +82,21 @@ export default Model.extend({
|
||||
|
||||
datacenters: attr(),
|
||||
taskGroups: fragmentArray('task-group', { defaultValue: () => [] }),
|
||||
taskGroupSummaries: fragmentArray('task-group-summary'),
|
||||
summary: belongsTo('job-summary'),
|
||||
|
||||
// Aggregate allocation counts across all summaries
|
||||
queuedAllocs: sumAggregation('taskGroupSummaries', 'queuedAllocs'),
|
||||
startingAllocs: sumAggregation('taskGroupSummaries', 'startingAllocs'),
|
||||
runningAllocs: sumAggregation('taskGroupSummaries', 'runningAllocs'),
|
||||
completeAllocs: sumAggregation('taskGroupSummaries', 'completeAllocs'),
|
||||
failedAllocs: sumAggregation('taskGroupSummaries', 'failedAllocs'),
|
||||
lostAllocs: sumAggregation('taskGroupSummaries', 'lostAllocs'),
|
||||
|
||||
allocsList: collect(
|
||||
'queuedAllocs',
|
||||
'startingAllocs',
|
||||
'runningAllocs',
|
||||
'completeAllocs',
|
||||
'failedAllocs',
|
||||
'lostAllocs'
|
||||
),
|
||||
|
||||
totalAllocs: sum('allocsList'),
|
||||
|
||||
pendingChildren: attr('number'),
|
||||
runningChildren: attr('number'),
|
||||
deadChildren: attr('number'),
|
||||
|
||||
childrenList: collect('pendingChildren', 'runningChildren', 'deadChildren'),
|
||||
|
||||
totalChildren: sum('childrenList'),
|
||||
// Alias through to the summary, as if there was no relationship
|
||||
taskGroupSummaries: alias('summary.taskGroupSummaries'),
|
||||
queuedAllocs: alias('summary.queuedAllocs'),
|
||||
startingAllocs: alias('summary.startingAllocs'),
|
||||
runningAllocs: alias('summary.runningAllocs'),
|
||||
completeAllocs: alias('summary.completeAllocs'),
|
||||
failedAllocs: alias('summary.failedAllocs'),
|
||||
lostAllocs: alias('summary.lostAllocs'),
|
||||
totalAllocs: alias('summary.totalAllocs'),
|
||||
pendingChildren: alias('summary.pendingChildren'),
|
||||
runningChildren: alias('summary.runningChildren'),
|
||||
deadChildren: alias('summary.deadChildren'),
|
||||
totalChildren: alias('summary.childrenList'),
|
||||
|
||||
version: attr('number'),
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
13
ui/app/routes/clients/index.js
Normal file
13
ui/app/routes/clients/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { collect } from '@ember/object/computed';
|
||||
import { watchAll } from 'nomad-ui/utils/properties/watch';
|
||||
import WithWatchers from 'nomad-ui/mixins/with-watchers';
|
||||
|
||||
export default Route.extend(WithWatchers, {
|
||||
startWatchers(controller) {
|
||||
controller.set('watcher', this.get('watch').perform());
|
||||
},
|
||||
|
||||
watch: watchAll('node'),
|
||||
watchers: collect('watch'),
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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') };
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
22
ui/app/routes/jobs/job/index.js
Normal file
22
ui/app/routes/jobs/job/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { collect } from '@ember/object/computed';
|
||||
import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch';
|
||||
import WithWatchers from 'nomad-ui/mixins/with-watchers';
|
||||
|
||||
export default Route.extend(WithWatchers, {
|
||||
startWatchers(controller, model) {
|
||||
controller.set('watchers', {
|
||||
model: this.get('watch').perform(model),
|
||||
summary: this.get('watchSummary').perform(model),
|
||||
evaluations: this.get('watchEvaluations').perform(model),
|
||||
deployments: this.get('watchDeployments').perform(model),
|
||||
});
|
||||
},
|
||||
|
||||
watch: watchRecord('job'),
|
||||
watchSummary: watchRelationship('summary'),
|
||||
watchEvaluations: watchRelationship('evaluations'),
|
||||
watchDeployments: watchRelationship('deployments'),
|
||||
|
||||
watchers: collect('watch', 'watchSummary', 'watchEvaluations', 'watchDeployments'),
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
32
ui/app/serializers/job-summary.js
Normal file
32
ui/app/serializers/job-summary.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { get } from '@ember/object';
|
||||
import ApplicationSerializer from './application';
|
||||
|
||||
export default ApplicationSerializer.extend({
|
||||
normalize(modelClass, hash) {
|
||||
// Transform the map-based Summary object into an array-based
|
||||
// TaskGroupSummary fragment list
|
||||
hash.PlainJobId = hash.JobID;
|
||||
hash.ID = JSON.stringify([hash.JobID, hash.Namespace || 'default']);
|
||||
|
||||
hash.TaskGroupSummaries = Object.keys(get(hash, 'Summary') || {}).map(key => {
|
||||
const allocStats = get(hash, `Summary.${key}`) || {};
|
||||
const summary = { Name: key };
|
||||
|
||||
Object.keys(allocStats).forEach(
|
||||
allocKey => (summary[`${allocKey}Allocs`] = allocStats[allocKey])
|
||||
);
|
||||
|
||||
return summary;
|
||||
});
|
||||
|
||||
// Lift the children stats out of the Children object
|
||||
const childrenStats = get(hash, 'Children');
|
||||
if (childrenStats) {
|
||||
Object.keys(childrenStats).forEach(
|
||||
childrenKey => (hash[`${childrenKey}Children`] = childrenStats[childrenKey])
|
||||
);
|
||||
}
|
||||
|
||||
return this._super(modelClass, hash);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
23
ui/app/services/watch-list.js
Normal file
23
ui/app/services/watch-list.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { readOnly } from '@ember/object/computed';
|
||||
import { copy } from '@ember/object/internals';
|
||||
import Service from '@ember/service';
|
||||
|
||||
let list = {};
|
||||
|
||||
export default Service.extend({
|
||||
list: readOnly(function() {
|
||||
return copy(list, true);
|
||||
}),
|
||||
|
||||
init() {
|
||||
list = {};
|
||||
},
|
||||
|
||||
getIndexFor(url) {
|
||||
return list[url] || 0;
|
||||
},
|
||||
|
||||
setIndexFor(url, value) {
|
||||
list[url] = value;
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
{{#each annotatedDeployments as |record|}}
|
||||
{{#each annotatedDeployments key="deployment.id" as |record|}}
|
||||
{{#if record.meta.showDate}}
|
||||
<li data-test-deployment-time class="timeline-note">
|
||||
{{#if record.deployment.version.submitTime}}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<td data-test-job-type>{{job.displayType}}</td>
|
||||
<td data-test-job-priority>{{job.priority}}</td>
|
||||
<td data-test-job-task-groups>
|
||||
{{#if job.isReloading}}
|
||||
...
|
||||
{{else}}
|
||||
{{#if job.taskGroups.length}}
|
||||
{{job.taskGroups.length}}
|
||||
{{else}}
|
||||
--
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-test-job-allocations>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{#each annotatedVersions as |record|}}
|
||||
{{#each annotatedVersions key="version.id" as |record|}}
|
||||
{{#if record.meta.showDate}}
|
||||
<li data-test-version-time class="timeline-note">
|
||||
{{moment-format record.version.submitTime "MMMM D, YYYY"}}
|
||||
|
||||
73
ui/app/utils/properties/watch.js
Normal file
73
ui/app/utils/properties/watch.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import Ember from 'ember';
|
||||
import { get } from '@ember/object';
|
||||
import RSVP from 'rsvp';
|
||||
import { task } from 'ember-concurrency';
|
||||
import wait from 'nomad-ui/utils/wait';
|
||||
|
||||
export function watchRecord(modelName) {
|
||||
return task(function*(id, throttle = 2000) {
|
||||
if (typeof id === 'object') {
|
||||
id = get(id, 'id');
|
||||
}
|
||||
while (!Ember.testing) {
|
||||
try {
|
||||
yield RSVP.all([
|
||||
this.get('store').findRecord(modelName, id, {
|
||||
reload: true,
|
||||
adapterOptions: { watch: true },
|
||||
}),
|
||||
wait(throttle),
|
||||
]);
|
||||
} catch (e) {
|
||||
yield e;
|
||||
break;
|
||||
} finally {
|
||||
this.get('store')
|
||||
.adapterFor(modelName)
|
||||
.cancelFindRecord(modelName, id);
|
||||
}
|
||||
}
|
||||
}).drop();
|
||||
}
|
||||
|
||||
export function watchRelationship(relationshipName) {
|
||||
return task(function*(model, throttle = 2000) {
|
||||
while (!Ember.testing) {
|
||||
try {
|
||||
yield RSVP.all([
|
||||
this.get('store')
|
||||
.adapterFor(model.constructor.modelName)
|
||||
.reloadRelationship(model, relationshipName, true),
|
||||
wait(throttle),
|
||||
]);
|
||||
} catch (e) {
|
||||
yield e;
|
||||
break;
|
||||
} finally {
|
||||
this.get('store')
|
||||
.adapterFor(model.constructor.modelName)
|
||||
.cancelReloadRelationship(model, relationshipName);
|
||||
}
|
||||
}
|
||||
}).drop();
|
||||
}
|
||||
|
||||
export function watchAll(modelName) {
|
||||
return task(function*(throttle = 2000) {
|
||||
while (!Ember.testing) {
|
||||
try {
|
||||
yield RSVP.all([
|
||||
this.get('store').findAll(modelName, { reload: true, adapterOptions: { watch: true } }),
|
||||
wait(throttle),
|
||||
]);
|
||||
} catch (e) {
|
||||
yield e;
|
||||
break;
|
||||
} finally {
|
||||
this.get('store')
|
||||
.adapterFor(modelName)
|
||||
.cancelFindAll(modelName);
|
||||
}
|
||||
}
|
||||
}).drop();
|
||||
}
|
||||
10
ui/app/utils/wait.js
Normal file
10
ui/app/utils/wait.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import RSVP from 'rsvp';
|
||||
|
||||
// An always passing promise used to throttle other promises
|
||||
export default function wait(duration) {
|
||||
return new RSVP.Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve(`Waited ${duration}ms`);
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
@@ -11,41 +11,72 @@ export function findLeader(schema) {
|
||||
}
|
||||
|
||||
export default function() {
|
||||
const server = this;
|
||||
this.timing = 0; // delay for each request, automatically set to 0 during testing
|
||||
|
||||
this.namespace = 'v1';
|
||||
this.trackRequests = Ember.testing;
|
||||
|
||||
this.get('/jobs', function({ jobs }, { queryParams }) {
|
||||
const json = this.serialize(jobs.all());
|
||||
const namespace = queryParams.namespace || 'default';
|
||||
return json
|
||||
.filter(
|
||||
job =>
|
||||
namespace === 'default'
|
||||
? !job.NamespaceID || job.NamespaceID === namespace
|
||||
: job.NamespaceID === namespace
|
||||
)
|
||||
.map(job => filterKeys(job, 'TaskGroups', 'NamespaceID'));
|
||||
});
|
||||
const nomadIndices = {}; // used for tracking blocking queries
|
||||
const server = this;
|
||||
const withBlockingSupport = function(fn) {
|
||||
return function(schema, request) {
|
||||
// Get the original response
|
||||
let { url } = request;
|
||||
url = url.replace(/index=\d+[&;]?/, '');
|
||||
const response = fn.apply(this, arguments);
|
||||
|
||||
this.get('/job/:id', function({ jobs }, { params, queryParams }) {
|
||||
const job = jobs.all().models.find(job => {
|
||||
const jobIsDefault = !job.namespaceId || job.namespaceId === 'default';
|
||||
const qpIsDefault = !queryParams.namespace || queryParams.namespace === 'default';
|
||||
return (
|
||||
job.id === params.id &&
|
||||
(job.namespaceId === queryParams.namespace || (jobIsDefault && qpIsDefault))
|
||||
);
|
||||
});
|
||||
// Get and increment the approrpriate index
|
||||
nomadIndices[url] || (nomadIndices[url] = 1);
|
||||
const index = nomadIndices[url];
|
||||
nomadIndices[url]++;
|
||||
|
||||
return job ? this.serialize(job) : new Response(404, {}, null);
|
||||
});
|
||||
// Annotate the response with the index
|
||||
if (response instanceof Response) {
|
||||
response.headers['X-Nomad-Index'] = index;
|
||||
return response;
|
||||
}
|
||||
return new Response(200, { 'x-nomad-index': index }, response);
|
||||
};
|
||||
};
|
||||
|
||||
this.get('/job/:id/summary', function({ jobSummaries }, { params }) {
|
||||
return this.serialize(jobSummaries.findBy({ jobId: params.id }));
|
||||
});
|
||||
this.get(
|
||||
'/jobs',
|
||||
withBlockingSupport(function({ jobs }, { queryParams }) {
|
||||
const json = this.serialize(jobs.all());
|
||||
const namespace = queryParams.namespace || 'default';
|
||||
return json
|
||||
.filter(
|
||||
job =>
|
||||
namespace === 'default'
|
||||
? !job.NamespaceID || job.NamespaceID === namespace
|
||||
: job.NamespaceID === namespace
|
||||
)
|
||||
.map(job => filterKeys(job, 'TaskGroups', 'NamespaceID'));
|
||||
})
|
||||
);
|
||||
|
||||
this.get(
|
||||
'/job/:id',
|
||||
withBlockingSupport(function({ jobs }, { params, queryParams }) {
|
||||
const job = jobs.all().models.find(job => {
|
||||
const jobIsDefault = !job.namespaceId || job.namespaceId === 'default';
|
||||
const qpIsDefault = !queryParams.namespace || queryParams.namespace === 'default';
|
||||
return (
|
||||
job.id === params.id &&
|
||||
(job.namespaceId === queryParams.namespace || (jobIsDefault && qpIsDefault))
|
||||
);
|
||||
});
|
||||
|
||||
return job ? this.serialize(job) : new Response(404, {}, null);
|
||||
})
|
||||
);
|
||||
|
||||
this.get(
|
||||
'/job/:id/summary',
|
||||
withBlockingSupport(function({ jobSummaries }, { params }) {
|
||||
return this.serialize(jobSummaries.findBy({ jobId: params.id }));
|
||||
})
|
||||
);
|
||||
|
||||
this.get('/job/:id/allocations', function({ allocations }, { params }) {
|
||||
return this.serialize(allocations.where({ jobId: params.id }));
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export default Factory.extend({
|
||||
groupNames: [],
|
||||
|
||||
JobID: '',
|
||||
namespace: null,
|
||||
|
||||
withSummary: trait({
|
||||
Summary: function() {
|
||||
|
||||
@@ -25,6 +25,7 @@ export default Factory.extend({
|
||||
version.activeDeployment && 'active',
|
||||
{
|
||||
jobId: version.jobId,
|
||||
namespace: version.job.namespace,
|
||||
versionNumber: version.version,
|
||||
},
|
||||
].compact();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}]`,
|
||||
});
|
||||
|
||||
33
ui/tests/helpers/module-for-adapter.js
Normal file
33
ui/tests/helpers/module-for-adapter.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getOwner } from '@ember/application';
|
||||
import { moduleForModel } from 'ember-qunit';
|
||||
import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
|
||||
|
||||
export default function(modelName, description, options = { needs: [] }) {
|
||||
// moduleForModel correctly creates the store service
|
||||
// but moduleFor does not.
|
||||
moduleForModel(modelName, description, {
|
||||
unit: true,
|
||||
needs: options.needs,
|
||||
beforeEach() {
|
||||
const model = this.subject();
|
||||
|
||||
// Initializers don't run automatically in unit tests
|
||||
fragmentSerializerInitializer(getOwner(model));
|
||||
|
||||
// Reassign the subject to provide the adapter
|
||||
this.subject = () => model.store.adapterFor(modelName);
|
||||
|
||||
// Expose the store as well, since it is a parameter for many adapter methods
|
||||
this.store = model.store;
|
||||
|
||||
if (options.beforeEach) {
|
||||
options.beforeEach.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
afterEach() {
|
||||
if (options.afterEach) {
|
||||
options.afterEach.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -159,6 +159,8 @@ test('is sorted based on the sortProperty and sortDescending properties', functi
|
||||
`Child ${index} is ${child.get('name')}`
|
||||
);
|
||||
});
|
||||
|
||||
return wait();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ test('should render the placement failure (basic render)', function(assert) {
|
||||
'taskGroup',
|
||||
createFixture(
|
||||
{
|
||||
coalescedFailures: failures - 1
|
||||
coalescedFailures: failures - 1,
|
||||
},
|
||||
name
|
||||
)
|
||||
@@ -77,22 +77,16 @@ test('should render the placement failure (basic render)', function(assert) {
|
||||
1,
|
||||
'Quota exhausted message shown'
|
||||
);
|
||||
assert.equal(
|
||||
findAll('[data-test-placement-failure-scores]').length,
|
||||
1,
|
||||
'Scores message shown'
|
||||
);
|
||||
assert.equal(findAll('[data-test-placement-failure-scores]').length, 1, 'Scores message shown');
|
||||
});
|
||||
|
||||
test('should render correctly when a node is not evaluated', function(assert) {
|
||||
this.set(
|
||||
'taskGroup',
|
||||
createFixture(
|
||||
{
|
||||
nodesEvaluated: 1,
|
||||
nodesExhausted: 0
|
||||
}
|
||||
)
|
||||
createFixture({
|
||||
nodesEvaluated: 1,
|
||||
nodesExhausted: 0,
|
||||
})
|
||||
);
|
||||
|
||||
this.render(commonTemplate);
|
||||
@@ -112,33 +106,34 @@ test('should render correctly when a node is not evaluated', function(assert) {
|
||||
function createFixture(obj = {}, name = 'Placement Failure') {
|
||||
return {
|
||||
name: name,
|
||||
placementFailures: assign({
|
||||
coalescedFailures: 10,
|
||||
nodesEvaluated: 0,
|
||||
nodesAvailable: {
|
||||
datacenter: 0,
|
||||
placementFailures: assign(
|
||||
{
|
||||
coalescedFailures: 10,
|
||||
nodesEvaluated: 0,
|
||||
nodesAvailable: {
|
||||
datacenter: 0,
|
||||
},
|
||||
classFiltered: {
|
||||
filtered: 1,
|
||||
},
|
||||
constraintFiltered: {
|
||||
'prop = val': 1,
|
||||
},
|
||||
nodesExhausted: 3,
|
||||
classExhausted: {
|
||||
class: 3,
|
||||
},
|
||||
dimensionExhausted: {
|
||||
iops: 3,
|
||||
},
|
||||
quotaExhausted: {
|
||||
quota: 'dimension',
|
||||
},
|
||||
scores: {
|
||||
name: 3,
|
||||
},
|
||||
},
|
||||
classFiltered: {
|
||||
filtered: 1,
|
||||
},
|
||||
constraintFiltered: {
|
||||
'prop = val': 1,
|
||||
},
|
||||
nodesExhausted: 3,
|
||||
classExhausted: {
|
||||
class: 3,
|
||||
},
|
||||
dimensionExhausted: {
|
||||
iops: 3,
|
||||
},
|
||||
quotaExhausted: {
|
||||
quota: 'dimension',
|
||||
},
|
||||
scores: {
|
||||
name: 3,
|
||||
},
|
||||
},
|
||||
obj
|
||||
)
|
||||
obj
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
123
ui/tests/unit/adapters/node-test.js
Normal file
123
ui/tests/unit/adapters/node-test.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import { run } from '@ember/runloop';
|
||||
import { test } from 'ember-qunit';
|
||||
import wait from 'ember-test-helpers/wait';
|
||||
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
|
||||
import moduleForAdapter from '../../helpers/module-for-adapter';
|
||||
|
||||
moduleForAdapter('node', 'Unit | Adapter | Node', {
|
||||
needs: [
|
||||
'adapter:node',
|
||||
'model:node-attributes',
|
||||
'model:allocation',
|
||||
'model:job',
|
||||
'serializer:application',
|
||||
'serializer:node',
|
||||
'service:token',
|
||||
'service:config',
|
||||
'service:watchList',
|
||||
'transform:fragment',
|
||||
],
|
||||
beforeEach() {
|
||||
this.server = startMirage();
|
||||
this.server.create('node', { id: 'node-1' });
|
||||
this.server.create('node', { id: 'node-2' });
|
||||
this.server.create('job', { id: 'job-1', createAllocations: false });
|
||||
|
||||
this.server.create('allocation', { id: 'node-1-1', nodeId: 'node-1' });
|
||||
this.server.create('allocation', { id: 'node-1-2', nodeId: 'node-1' });
|
||||
this.server.create('allocation', { id: 'node-2-1', nodeId: 'node-2' });
|
||||
this.server.create('allocation', { id: 'node-2-2', nodeId: 'node-2' });
|
||||
},
|
||||
afterEach() {
|
||||
this.server.shutdown();
|
||||
},
|
||||
});
|
||||
|
||||
test('findHasMany removes old related models from the store', function(assert) {
|
||||
let node;
|
||||
run(() => {
|
||||
// Fetch the model
|
||||
this.store.findRecord('node', 'node-1').then(model => {
|
||||
node = model;
|
||||
|
||||
// Fetch the related allocations
|
||||
return findHasMany(model, 'allocations').then(allocations => {
|
||||
assert.equal(
|
||||
allocations.get('length'),
|
||||
this.server.db.allocations.where({ nodeId: node.get('id') }).length,
|
||||
'Allocations returned from the findHasMany matches the db state'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return wait().then(() => {
|
||||
server.db.allocations.remove('node-1-1');
|
||||
|
||||
run(() => {
|
||||
// Reload the related allocations now that one was removed server-side
|
||||
return findHasMany(node, 'allocations').then(allocations => {
|
||||
const dbAllocations = this.server.db.allocations.where({ nodeId: node.get('id') });
|
||||
assert.equal(
|
||||
allocations.get('length'),
|
||||
dbAllocations.length,
|
||||
'Allocations returned from the findHasMany matches the db state'
|
||||
);
|
||||
assert.equal(
|
||||
this.store.peekAll('allocation').get('length'),
|
||||
dbAllocations.length,
|
||||
'Server-side deleted allocation was removed from the store'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('findHasMany does not remove old unrelated models from the store', function(assert) {
|
||||
let node;
|
||||
|
||||
run(() => {
|
||||
// Fetch the first node and related allocations
|
||||
this.store.findRecord('node', 'node-1').then(model => {
|
||||
node = model;
|
||||
return findHasMany(model, 'allocations');
|
||||
});
|
||||
|
||||
// Also fetch the second node and related allocations;
|
||||
this.store.findRecord('node', 'node-2').then(model => findHasMany(model, 'allocations'));
|
||||
});
|
||||
|
||||
return wait().then(() => {
|
||||
assert.deepEqual(
|
||||
this.store
|
||||
.peekAll('allocation')
|
||||
.mapBy('id')
|
||||
.sort(),
|
||||
['node-1-1', 'node-1-2', 'node-2-1', 'node-2-2'],
|
||||
'All allocations for the first and second node are in the store'
|
||||
);
|
||||
|
||||
server.db.allocations.remove('node-1-1');
|
||||
|
||||
run(() => {
|
||||
// Reload the related allocations now that one was removed server-side
|
||||
return findHasMany(node, 'allocations').then(() => {
|
||||
assert.deepEqual(
|
||||
this.store
|
||||
.peekAll('allocation')
|
||||
.mapBy('id')
|
||||
.sort(),
|
||||
['node-1-2', 'node-2-1', 'node-2-2'],
|
||||
'The deleted allocation is removed from the store and the allocations associated with the other node are untouched'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Using fetchLink on a model's hasMany relationship exercises the adapter's
|
||||
// findHasMany method as well normalizing the response and pushing it to the store
|
||||
function findHasMany(model, relationshipName) {
|
||||
const relationship = model.relationshipFor(relationshipName);
|
||||
return model.hasMany(relationship.key).hasManyRelationship.fetchLink();
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
71
ui/tests/unit/serializers/node-test.js
Normal file
71
ui/tests/unit/serializers/node-test.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { run } from '@ember/runloop';
|
||||
import { test } from 'ember-qunit';
|
||||
import wait from 'ember-test-helpers/wait';
|
||||
import NodeModel from 'nomad-ui/models/node';
|
||||
import moduleForSerializer from '../../helpers/module-for-serializer';
|
||||
import pushPayloadToStore from '../../utils/push-payload-to-store';
|
||||
|
||||
moduleForSerializer('node', 'Unit | Serializer | Node', {
|
||||
needs: ['serializer:node', 'service:config', 'transform:fragment', 'model:allocation'],
|
||||
});
|
||||
|
||||
test('local store is culled to reflect the state of findAll requests', function(assert) {
|
||||
const findAllResponse = [
|
||||
makeNode('1', 'One', '127.0.0.1:4646'),
|
||||
makeNode('2', 'Two', '127.0.0.2:4646'),
|
||||
makeNode('3', 'Three', '127.0.0.3:4646'),
|
||||
];
|
||||
|
||||
const payload = this.subject().normalizeFindAllResponse(this.store, NodeModel, findAllResponse);
|
||||
pushPayloadToStore(this.store, payload, NodeModel.modelName);
|
||||
|
||||
assert.equal(
|
||||
payload.data.length,
|
||||
findAllResponse.length,
|
||||
'Each original record is returned in the response'
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
this.store
|
||||
.peekAll('node')
|
||||
.filterBy('id')
|
||||
.get('length'),
|
||||
findAllResponse.length,
|
||||
'Each original record is now in the store'
|
||||
);
|
||||
|
||||
const newFindAllResponse = [
|
||||
makeNode('2', 'Two', '127.0.0.2:4646'),
|
||||
makeNode('3', 'Three', '127.0.0.3:4646'),
|
||||
makeNode('4', 'Four', '127.0.0.4:4646'),
|
||||
];
|
||||
|
||||
let newPayload;
|
||||
run(() => {
|
||||
newPayload = this.subject().normalizeFindAllResponse(this.store, NodeModel, newFindAllResponse);
|
||||
});
|
||||
pushPayloadToStore(this.store, newPayload, NodeModel.modelName);
|
||||
|
||||
return wait().then(() => {
|
||||
assert.equal(
|
||||
newPayload.data.length,
|
||||
newFindAllResponse.length,
|
||||
'Each new record is returned in the response'
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
this.store
|
||||
.peekAll('node')
|
||||
.filterBy('id')
|
||||
.get('length'),
|
||||
newFindAllResponse.length,
|
||||
'The node length in the store reflects the new response'
|
||||
);
|
||||
|
||||
assert.notOk(this.store.peekAll('node').findBy('id', '1'), 'Record One is no longer found');
|
||||
});
|
||||
});
|
||||
|
||||
function makeNode(id, name, ip) {
|
||||
return { ID: id, Name: name, HTTPAddr: ip };
|
||||
}
|
||||
12
ui/tests/utils/push-payload-to-store.js
Normal file
12
ui/tests/utils/push-payload-to-store.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { run } from '@ember/runloop';
|
||||
|
||||
// These are private store methods called by store "finder" methods.
|
||||
// Useful in unit tests when there is store interaction, since calling
|
||||
// adapter and serializer methods directly will never insert data into
|
||||
// the store.
|
||||
export default function pushPayloadToStore(store, payload, modelName) {
|
||||
run(() => {
|
||||
store._push(payload);
|
||||
store._didUpdateAll(modelName);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user