Skip to content

Commit

Permalink
Merge pull request #3936 from hashicorp/f-ui-polling
Browse files Browse the repository at this point in the history
UI: Live updating views
  • Loading branch information
DingoEatingFuzz committed Mar 8, 2018
2 parents 5dea799 + 73ca228 commit 220790f
Show file tree
Hide file tree
Showing 56 changed files with 1,381 additions and 385 deletions.
3 changes: 3 additions & 0 deletions ui/app/adapters/allocation.js
@@ -0,0 +1,3 @@
import Watchable from './watchable';

export default Watchable.extend();
18 changes: 18 additions & 0 deletions ui/app/adapters/application.js
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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

0 comments on commit 220790f

Please sign in to comment.