Skip to content

Commit 220790f

Browse files
Merge pull request #3936 from hashicorp/f-ui-polling
UI: Live updating views
2 parents 5dea799 + 73ca228 commit 220790f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+1381
-385
lines changed

ui/app/adapters/allocation.js

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import Watchable from './watchable';
2+
3+
export default Watchable.extend();

ui/app/adapters/application.js

+18
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ export default RESTAdapter.extend({
3434
});
3535
},
3636

37+
// In order to remove stale records from the store, findHasMany has to unload
38+
// all records related to the request in question.
39+
findHasMany(store, snapshot, link, relationship) {
40+
return this._super(...arguments).then(payload => {
41+
const ownerType = snapshot.modelName;
42+
const relationshipType = relationship.type;
43+
// Naively assume that the inverse relationship is named the same as the
44+
// owner type. In the event it isn't, findHasMany should be overridden.
45+
store
46+
.peekAll(relationshipType)
47+
.filter(record => record.get(`${ownerType}.id`) === snapshot.id)
48+
.forEach(record => {
49+
store.unloadRecord(record);
50+
});
51+
return payload;
52+
});
53+
},
54+
3755
// Single record requests deviate from REST practice by using
3856
// the singular form of the resource name.
3957
//

ui/app/adapters/job.js

+27-33
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
import { inject as service } from '@ember/service';
2-
import RSVP from 'rsvp';
32
import { assign } from '@ember/polyfills';
4-
import ApplicationAdapter from './application';
3+
import Watchable from './watchable';
54

6-
export default ApplicationAdapter.extend({
5+
export default Watchable.extend({
76
system: service(),
87

9-
shouldReloadAll: () => true,
10-
118
buildQuery() {
129
const namespace = this.get('system.activeNamespace.id');
1310

1411
if (namespace && namespace !== 'default') {
1512
return { namespace };
1613
}
14+
return {};
1715
},
1816

1917
findAll() {
@@ -26,28 +24,26 @@ export default ApplicationAdapter.extend({
2624
});
2725
},
2826

29-
findRecord(store, { modelName }, id, snapshot) {
30-
// To make a findRecord response reflect the findMany response, the JobSummary
31-
// from /summary needs to be stitched into the response.
27+
findRecordSummary(modelName, name, snapshot, namespaceQuery) {
28+
return this.ajax(`${this.buildURL(modelName, name, snapshot, 'findRecord')}/summary`, 'GET', {
29+
data: assign(this.buildQuery() || {}, namespaceQuery),
30+
});
31+
},
3232

33-
// URL is the form of /job/:name?namespace=:namespace with arbitrary additional query params
34-
const [name, namespace] = JSON.parse(id);
33+
findRecord(store, type, id, snapshot) {
34+
const [, namespace] = JSON.parse(id);
3535
const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {};
36-
return RSVP.hash({
37-
job: this.ajax(this.buildURL(modelName, name, snapshot, 'findRecord'), 'GET', {
38-
data: assign(this.buildQuery() || {}, namespaceQuery),
39-
}),
40-
summary: this.ajax(
41-
`${this.buildURL(modelName, name, snapshot, 'findRecord')}/summary`,
42-
'GET',
43-
{
44-
data: assign(this.buildQuery() || {}, namespaceQuery),
45-
}
46-
),
47-
}).then(({ job, summary }) => {
48-
job.JobSummary = summary;
49-
return job;
50-
});
36+
37+
return this._super(store, type, id, snapshot, namespaceQuery);
38+
},
39+
40+
urlForFindRecord(id, type, hash) {
41+
const [name, namespace] = JSON.parse(id);
42+
let url = this._super(name, type, hash);
43+
if (namespace && namespace !== 'default') {
44+
url += `?namespace=${namespace}`;
45+
}
46+
return url;
5147
},
5248

5349
findAllocations(job) {
@@ -60,19 +56,17 @@ export default ApplicationAdapter.extend({
6056
},
6157

6258
fetchRawDefinition(job) {
63-
const [name, namespace] = JSON.parse(job.get('id'));
64-
const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {};
65-
const url = this.buildURL('job', name, job, 'findRecord');
66-
return this.ajax(url, 'GET', { data: assign(this.buildQuery() || {}, namespaceQuery) });
59+
const url = this.buildURL('job', job.get('id'), job, 'findRecord');
60+
return this.ajax(url, 'GET', { data: this.buildQuery() });
6761
},
6862

6963
forcePeriodic(job) {
7064
if (job.get('periodic')) {
71-
const [name, namespace] = JSON.parse(job.get('id'));
72-
let url = `${this.buildURL('job', name, job, 'findRecord')}/periodic/force`;
65+
const [path, params] = this.buildURL('job', job.get('id'), job, 'findRecord').split('?');
66+
let url = `${path}/periodic/force`;
7367

74-
if (namespace) {
75-
url += `?namespace=${namespace}`;
68+
if (params) {
69+
url += `?${params}`;
7670
}
7771

7872
return this.ajax(url, 'POST');

ui/app/adapters/node.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import ApplicationAdapter from './application';
1+
import Watchable from './watchable';
22

3-
export default ApplicationAdapter.extend({
3+
export default Watchable.extend({
44
findAllocations(node) {
55
const url = `${this.buildURL('node', node.get('id'), node, 'findRecord')}/allocations`;
66
return this.ajax(url, 'GET').then(allocs => {

ui/app/adapters/watchable.js

+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { get, computed } from '@ember/object';
2+
import { assign } from '@ember/polyfills';
3+
import { inject as service } from '@ember/service';
4+
import queryString from 'npm:query-string';
5+
import ApplicationAdapter from './application';
6+
import { AbortError } from 'ember-data/adapters/errors';
7+
8+
export default ApplicationAdapter.extend({
9+
watchList: service(),
10+
store: service(),
11+
12+
xhrs: computed(function() {
13+
return {};
14+
}),
15+
16+
ajaxOptions(url) {
17+
const ajaxOptions = this._super(...arguments);
18+
19+
const previousBeforeSend = ajaxOptions.beforeSend;
20+
ajaxOptions.beforeSend = function(jqXHR) {
21+
if (previousBeforeSend) {
22+
previousBeforeSend(...arguments);
23+
}
24+
this.get('xhrs')[url] = jqXHR;
25+
jqXHR.always(() => {
26+
delete this.get('xhrs')[url];
27+
});
28+
};
29+
30+
return ajaxOptions;
31+
},
32+
33+
findAll(store, type, sinceToken, snapshotRecordArray, additionalParams = {}) {
34+
const params = assign(this.buildQuery(), additionalParams);
35+
const url = this.urlForFindAll(type.modelName);
36+
37+
if (get(snapshotRecordArray || {}, 'adapterOptions.watch')) {
38+
params.index = this.get('watchList').getIndexFor(url);
39+
this.cancelFindAll(type.modelName);
40+
}
41+
42+
return this.ajax(url, 'GET', {
43+
data: params,
44+
});
45+
},
46+
47+
findRecord(store, type, id, snapshot, additionalParams = {}) {
48+
let [url, params] = this.buildURL(type.modelName, id, snapshot, 'findRecord').split('?');
49+
params = assign(queryString.parse(params) || {}, this.buildQuery(), additionalParams);
50+
51+
if (get(snapshot || {}, 'adapterOptions.watch')) {
52+
params.index = this.get('watchList').getIndexFor(url);
53+
this.cancelFindRecord(type.modelName, id);
54+
}
55+
56+
return this.ajax(url, 'GET', {
57+
data: params,
58+
}).catch(error => {
59+
if (error instanceof AbortError) {
60+
return {};
61+
}
62+
throw error;
63+
});
64+
},
65+
66+
reloadRelationship(model, relationshipName, watch = false) {
67+
const relationship = model.relationshipFor(relationshipName);
68+
if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') {
69+
throw new Error(
70+
`${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}`
71+
);
72+
} else {
73+
const url = model[relationship.kind](relationship.key).link();
74+
let params = {};
75+
76+
if (watch) {
77+
params.index = this.get('watchList').getIndexFor(url);
78+
this.cancelReloadRelationship(model, relationshipName);
79+
}
80+
81+
if (url.includes('?')) {
82+
params = assign(queryString.parse(url.split('?')[1]), params);
83+
}
84+
85+
return this.ajax(url, 'GET', {
86+
data: params,
87+
}).then(
88+
json => {
89+
const store = this.get('store');
90+
const normalizeMethod =
91+
relationship.kind === 'belongsTo'
92+
? 'normalizeFindBelongsToResponse'
93+
: 'normalizeFindHasManyResponse';
94+
const serializer = store.serializerFor(relationship.type);
95+
const modelClass = store.modelFor(relationship.type);
96+
const normalizedData = serializer[normalizeMethod](store, modelClass, json);
97+
store.push(normalizedData);
98+
},
99+
error => {
100+
if (error instanceof AbortError) {
101+
return relationship.kind === 'belongsTo' ? {} : [];
102+
}
103+
throw error;
104+
}
105+
);
106+
}
107+
},
108+
109+
handleResponse(status, headers, payload, requestData) {
110+
const newIndex = headers['x-nomad-index'];
111+
if (newIndex) {
112+
this.get('watchList').setIndexFor(requestData.url, newIndex);
113+
}
114+
115+
return this._super(...arguments);
116+
},
117+
118+
cancelFindRecord(modelName, id) {
119+
const url = this.urlForFindRecord(id, modelName);
120+
const xhr = this.get('xhrs')[url];
121+
if (xhr) {
122+
xhr.abort();
123+
}
124+
},
125+
126+
cancelFindAll(modelName) {
127+
const xhr = this.get('xhrs')[this.urlForFindAll(modelName)];
128+
if (xhr) {
129+
xhr.abort();
130+
}
131+
},
132+
133+
cancelReloadRelationship(model, relationshipName) {
134+
const relationship = model.relationshipFor(relationshipName);
135+
if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') {
136+
throw new Error(
137+
`${relationship.key} must be a belongsTo or hasMany, instead it was ${relationship.kind}`
138+
);
139+
} else {
140+
const url = model[relationship.kind](relationship.key).link();
141+
const xhr = this.get('xhrs')[url];
142+
if (xhr) {
143+
xhr.abort();
144+
}
145+
}
146+
},
147+
});

ui/app/components/client-node-row.js

+27-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import { inject as service } from '@ember/service';
12
import Component from '@ember/component';
23
import { lazyClick } from '../helpers/lazy-click';
4+
import { watchRelationship } from 'nomad-ui/utils/properties/watch';
5+
import WithVisibilityDetection from 'nomad-ui/mixins/with-component-visibility-detection';
6+
7+
export default Component.extend(WithVisibilityDetection, {
8+
store: service(),
39

4-
export default Component.extend({
510
tagName: 'tr',
611
classNames: ['client-node-row', 'is-interactive'],
712

@@ -17,7 +22,27 @@ export default Component.extend({
1722
// Reload the node in order to get detail information
1823
const node = this.get('node');
1924
if (node) {
20-
node.reload();
25+
node.reload().then(() => {
26+
this.get('watch').perform(node, 100);
27+
});
28+
}
29+
},
30+
31+
visibilityHandler() {
32+
if (document.hidden) {
33+
this.get('watch').cancelAll();
34+
} else {
35+
const node = this.get('node');
36+
if (node) {
37+
this.get('watch').perform(node, 100);
38+
}
2139
}
2240
},
41+
42+
willDestroy() {
43+
this.get('watch').cancelAll();
44+
this._super(...arguments);
45+
},
46+
47+
watch: watchRelationship('allocations'),
2348
});

ui/app/components/distribution-bar.js

+19-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import Component from '@ember/component';
2-
import { computed } from '@ember/object';
2+
import { computed, observer } from '@ember/object';
33
import { run } from '@ember/runloop';
44
import { assign } from '@ember/polyfills';
5-
import { guidFor } from '@ember/object/internals';
5+
import { guidFor, copy } from '@ember/object/internals';
66
import d3 from 'npm:d3-selection';
77
import 'npm:d3-transition';
88
import WindowResizable from '../mixins/window-resizable';
@@ -23,7 +23,7 @@ export default Component.extend(WindowResizable, {
2323
maskId: null,
2424

2525
_data: computed('data', function() {
26-
const data = this.get('data');
26+
const data = copy(this.get('data'), true);
2727
const sum = data.mapBy('value').reduce(sumAggregate, 0);
2828

2929
return data.map(({ label, value, className, layers }, index) => ({
@@ -66,14 +66,18 @@ export default Component.extend(WindowResizable, {
6666
this.renderChart();
6767
},
6868

69+
updateChart: observer('_data.@each.{value,label,className}', function() {
70+
this.renderChart();
71+
}),
72+
6973
// prettier-ignore
7074
/* eslint-disable */
7175
renderChart() {
7276
const { chart, _data, isNarrow } = this.getProperties('chart', '_data', 'isNarrow');
7377
const width = this.$('svg').width();
7478
const filteredData = _data.filter(d => d.value > 0);
7579

76-
let slices = chart.select('.bars').selectAll('g').data(filteredData);
80+
let slices = chart.select('.bars').selectAll('g').data(filteredData, d => d.label);
7781
let sliceCount = filteredData.length;
7882

7983
slices.exit().remove();
@@ -82,7 +86,8 @@ export default Component.extend(WindowResizable, {
8286
.append('g')
8387
.on('mouseenter', d => {
8488
run(() => {
85-
const slice = slices.filter(datum => datum === d);
89+
const slices = this.get('slices');
90+
const slice = slices.filter(datum => datum.label === d.label);
8691
slices.classed('active', false).classed('inactive', true);
8792
slice.classed('active', true).classed('inactive', false);
8893
this.set('activeDatum', d);
@@ -99,7 +104,15 @@ export default Component.extend(WindowResizable, {
99104
});
100105

101106
slices = slices.merge(slicesEnter);
102-
slices.attr('class', d => d.className || `slice-${_data.indexOf(d)}`);
107+
slices.attr('class', d => {
108+
const className = d.className || `slice-${_data.indexOf(d)}`
109+
const activeDatum = this.get('activeDatum');
110+
const isActive = activeDatum && activeDatum.label === d.label;
111+
const isInactive = activeDatum && activeDatum.label !== d.label;
112+
return [ className, isActive && 'active', isInactive && 'inactive' ].compact().join(' ');
113+
});
114+
115+
this.set('slices', slices);
103116

104117
const setWidth = d => `${width * d.percent - (d.index === sliceCount - 1 || d.index === 0 ? 1 : 2)}px`
105118
const setOffset = d => `${width * d.offset + (d.index === 0 ? 0 : 1)}px`
@@ -117,7 +130,6 @@ export default Component.extend(WindowResizable, {
117130
.attr('width', setWidth)
118131
.attr('x', setOffset)
119132

120-
121133
let layers = slices.selectAll('.bar').data((d, i) => {
122134
return new Array(d.layers || 1).fill(assign({ index: i }, d));
123135
});

0 commit comments

Comments
 (0)