Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Live updating views #3936

Merged
merged 48 commits into from
Mar 8, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
18782dd
Refactor job summary to a relationship
DingoEatingFuzz Feb 8, 2018
66d0eab
Re-render chart whenever data changes
DingoEatingFuzz Feb 8, 2018
9b2080d
Prototype watching resources
DingoEatingFuzz Feb 8, 2018
5bf5448
Move watch tasks into a utils file
DingoEatingFuzz Feb 8, 2018
27a32a7
Fix distribution-bar bugs found with live data
DingoEatingFuzz Feb 12, 2018
6bf66f9
Add a watchAll computed property macro
DingoEatingFuzz Feb 12, 2018
4dd6972
Make the throttle time configurable
DingoEatingFuzz Feb 12, 2018
bc29457
Blocking query support for findAll requests
DingoEatingFuzz Feb 12, 2018
5e4491c
Remove records from the store when they are no longer in array responses
DingoEatingFuzz Feb 12, 2018
702228e
Watch all records on the jobs list page
DingoEatingFuzz Feb 12, 2018
b675d97
Watch for summary changes in job-row
DingoEatingFuzz Feb 12, 2018
8a119d9
Remove stale records from the store for findHasMany requests
DingoEatingFuzz Feb 14, 2018
ed809fe
Track xhrs in the watchable adapter and expose cancellation methods
DingoEatingFuzz Feb 14, 2018
b69fe31
Watch properties cancel long poll requests
DingoEatingFuzz Feb 14, 2018
dbc9903
Cancel watch tasks when appropriate
DingoEatingFuzz Feb 14, 2018
60ee871
Generalized solution for removing records in the local store
DingoEatingFuzz Feb 14, 2018
3c2a1f8
Fix existing tests
DingoEatingFuzz Feb 16, 2018
0a0cea0
Watchable request helper for Mirage
DingoEatingFuzz Feb 17, 2018
504ff2d
Tests for watching and canceling requests
DingoEatingFuzz Feb 17, 2018
7c51270
Add tests for findAll store culling
DingoEatingFuzz Feb 20, 2018
47dad64
Add tests for findHasMany store culling
DingoEatingFuzz Feb 20, 2018
6c3a091
Json viewer isn't side effect free, so use a copy
DingoEatingFuzz Feb 20, 2018
5dd83fd
Set slices after merging the selection
DingoEatingFuzz Feb 20, 2018
421f082
Address headers and ID bugs
DingoEatingFuzz Feb 22, 2018
2f93dea
Merge pull request #3893 from hashicorp/f-ui-polling-foundation
DingoEatingFuzz Mar 4, 2018
a2976da
Watch nodes and allocs on the nodes list page
DingoEatingFuzz Feb 16, 2018
594b991
Go through the expected normalization paths when watching relationships
DingoEatingFuzz Feb 27, 2018
88df7fe
Fix preexisting bugs that only surfaced once live updating started
DingoEatingFuzz Feb 27, 2018
34c712d
Watch job versions
DingoEatingFuzz Feb 27, 2018
9658268
Cancel a watch request before making an identical one
DingoEatingFuzz Feb 28, 2018
e1c5a5d
Move job watchers to job.index
DingoEatingFuzz Feb 28, 2018
9cd5632
Add polling to the deployments page
DingoEatingFuzz Feb 28, 2018
093bd41
Move jobs polling from jobs to jobs.index
DingoEatingFuzz Feb 28, 2018
5307803
Watch job, job-summary, and job-allocs on the task group page
DingoEatingFuzz Feb 28, 2018
e7a26ce
Watch the allocation on the allocation and task pages
DingoEatingFuzz Feb 28, 2018
9390e09
Move node watching to the index page
DingoEatingFuzz Feb 28, 2018
8fe4f76
Watch node and related allocations on the client detail page
DingoEatingFuzz Feb 28, 2018
0cb4855
Use willTransition instead of deactivate to cancel requests
DingoEatingFuzz Mar 1, 2018
40977d8
Patch tests
DingoEatingFuzz Mar 1, 2018
4b3b49b
Merge pull request #3938 from hashicorp/f-ui-polling-everywhere
DingoEatingFuzz Mar 8, 2018
c864697
New mixins for managing tab visibility effects
DingoEatingFuzz Mar 6, 2018
a6319f3
Wire up the job summary
DingoEatingFuzz Mar 6, 2018
01de2ea
Toggle polling in components when switching away from the tab
DingoEatingFuzz Mar 6, 2018
d29942f
Change from the setupController hook to the new startWatchers hook
DingoEatingFuzz Mar 6, 2018
4e0489a
Don't let aborted requests redirect to error
DingoEatingFuzz Mar 8, 2018
df54b6b
Fix a bug where namespace filter is incorrect for the jobs list
DingoEatingFuzz Mar 8, 2018
27ec2c2
Better define mixin contracts
DingoEatingFuzz Mar 8, 2018
73ca228
Merge pull request #3953 from hashicorp/f-ui-polling-disconnect
DingoEatingFuzz Mar 8, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ui/app/adapters/allocation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Watchable from './watchable';

export default Watchable.extend();
18 changes: 18 additions & 0 deletions ui/app/adapters/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
60 changes: 27 additions & 33 deletions ui/app/adapters/job.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -26,28 +24,26 @@ export default ApplicationAdapter.extend({
});
},

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

// URL is the form of /job/:name?namespace=:namespace with arbitrary additional query params
const [name, namespace] = JSON.parse(id);
findRecord(store, type, id, snapshot) {
const [, namespace] = JSON.parse(id);
const namespaceQuery = namespace && namespace !== 'default' ? { namespace } : {};
return RSVP.hash({
job: this.ajax(this.buildURL(modelName, name, snapshot, 'findRecord'), 'GET', {
data: assign(this.buildQuery() || {}, namespaceQuery),
}),
summary: this.ajax(
`${this.buildURL(modelName, name, snapshot, 'findRecord')}/summary`,
'GET',
{
data: assign(this.buildQuery() || {}, namespaceQuery),
}
),
}).then(({ job, summary }) => {
job.JobSummary = summary;
return job;
});

return this._super(store, type, id, snapshot, namespaceQuery);
},

urlForFindRecord(id, type, hash) {
const [name, namespace] = JSON.parse(id);
let url = this._super(name, type, hash);
if (namespace && namespace !== 'default') {
url += `?namespace=${namespace}`;
}
return url;
},

findAllocations(job) {
Expand All @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions ui/app/adapters/node.js
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down
147 changes: 147 additions & 0 deletions ui/app/adapters/watchable.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
},
});
29 changes: 27 additions & 2 deletions ui/app/components/client-node-row.js
Original file line number Diff line number Diff line change
@@ -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'],

Expand All @@ -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'),
});
26 changes: 19 additions & 7 deletions ui/app/components/distribution-bar.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) => ({
Expand Down Expand Up @@ -66,14 +66,18 @@ export default Component.extend(WindowResizable, {
this.renderChart();
},

updateChart: observer('_data.@each.{value,label,className}', function() {
this.renderChart();
}),

// prettier-ignore
/* eslint-disable */
renderChart() {
const { chart, _data, isNarrow } = this.getProperties('chart', '_data', 'isNarrow');
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();
Expand All @@ -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);
Expand All @@ -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`
Expand All @@ -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));
});
Expand Down
Loading