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: Stats trackers #4635

Merged
merged 9 commits into from
Sep 13, 2018
17 changes: 17 additions & 0 deletions ui/app/controllers/allocations/allocation/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import Ember from 'ember';
import { alias } from '@ember/object/computed';
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { task, timeout } from 'ember-concurrency';
import Sortable from 'nomad-ui/mixins/sortable';
import { lazyClick } from 'nomad-ui/helpers/lazy-click';
import { stats } from 'nomad-ui/utils/classes/allocation-stats-tracker';

export default Controller.extend(Sortable, {
token: service(),

queryParams: {
sortProperty: 'sort',
sortDescending: 'desc',
Expand All @@ -15,6 +21,17 @@ export default Controller.extend(Sortable, {
listToSort: alias('model.states'),
sortedStates: alias('listSorted'),

stats: stats('model', function statsFetch() {
return url => this.get('token').authorizedRequest(url);
}),

pollStats: task(function*() {
do {
yield this.get('stats').poll();
yield timeout(1000);
} while (!Ember.testing);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏

}),

actions: {
gotoTask(allocation, task) {
this.transitionToRoute('allocations.allocation.task', task);
Expand Down
14 changes: 14 additions & 0 deletions ui/app/controllers/clients/client.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import Ember from 'ember';
import { alias } from '@ember/object/computed';
import Controller from '@ember/controller';
import { computed } from '@ember/object';
import { task, timeout } from 'ember-concurrency';
import Sortable from 'nomad-ui/mixins/sortable';
import Searchable from 'nomad-ui/mixins/searchable';
import { stats } from 'nomad-ui/utils/classes/node-stats-tracker';

export default Controller.extend(Sortable, Searchable, {
queryParams: {
Expand Down Expand Up @@ -34,6 +37,17 @@ export default Controller.extend(Sortable, Searchable, {
return this.get('model.drivers').sortBy('name');
}),

stats: stats('model', function statsFetch() {
return url => this.get('token').authorizedRequest(url);
}),

pollStats: task(function*() {
do {
yield this.get('stats').poll();
yield timeout(1000);
} while (!Ember.testing);
}),

actions: {
gotoAllocation(allocation) {
this.transitionToRoute('allocations.allocation', allocation);
Expand Down
12 changes: 12 additions & 0 deletions ui/app/routes/allocations/allocation/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Route from '@ember/routing/route';

export default Route.extend({
setupController(controller) {
this._super(...arguments);
controller.get('pollStats').perform();
},

resetController(controller) {
controller.get('pollStats').cancelAll();
},
});
11 changes: 11 additions & 0 deletions ui/app/routes/clients/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,15 @@ export default Route.extend(WithWatchers, {
watchAllocations: watchRelationship('allocations'),

watchers: collect('watch', 'watchAllocations'),

setupController(controller, model) {
this._super(...arguments);
if (model) {
controller.get('pollStats').perform();
}
},

resetController(controller) {
controller.get('pollStats').cancelAll();
},
});
27 changes: 27 additions & 0 deletions ui/app/utils/classes/abstract-stats-tracker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Mixin from '@ember/object/mixin';
import { assert } from '@ember/debug';

export default Mixin.create({
url: '',

fetch() {
assert('StatsTrackers need a fetch method, which should have an interface like window.fetch');
},

append(/* frame */) {
assert(
'StatsTrackers need an append method, which takes the JSON response from a request to url as an argument'
);
},

poll() {
const url = this.get('url');
assert('Url must be defined', url);

return this.get('fetch')(url)
.then(res => {
return res.json();
})
.then(frame => this.append(frame));
},
});
101 changes: 101 additions & 0 deletions ui/app/utils/classes/allocation-stats-tracker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import EmberObject, { computed, get } from '@ember/object';
import { alias } from '@ember/object/computed';
import RollingArray from 'nomad-ui/utils/classes/rolling-array';
import AbstractStatsTracker from 'nomad-ui/utils/classes/abstract-stats-tracker';

const percent = (numerator, denominator) => {
if (!numerator || !denominator) {
return 0;
}
return numerator / denominator;
};

const AllocationStatsTracker = EmberObject.extend(AbstractStatsTracker, {
// Set via the stats computed property macro
allocation: null,

bufferSize: 100,

url: computed('allocation', function() {
return `/v1/client/allocation/${this.get('allocation.id')}/stats`;
}),

append(frame) {
const cpuUsed = Math.floor(frame.ResourceUsage.CpuStats.TotalTicks) || 0;
this.get('cpu').push({
timestamp: frame.Timestamp,
used: cpuUsed,
percent: percent(cpuUsed, this.get('reservedCPU')),
});

const memoryUsed = frame.ResourceUsage.MemoryStats.RSS;
this.get('memory').push({
timestamp: frame.Timestamp,
used: memoryUsed,
percent: percent(memoryUsed / 1024 / 1024, this.get('reservedMemory')),
});

for (var taskName in frame.Tasks) {
const taskFrame = frame.Tasks[taskName];
const stats = this.get('tasks').findBy('task', taskName);

// If for whatever reason there is a task in the frame data that isn't in the
// allocation, don't attempt to append data for the task.
if (!stats) continue;

const taskCpuUsed = Math.floor(taskFrame.ResourceUsage.CpuStats.TotalTicks) || 0;
stats.cpu.push({
timestamp: taskFrame.Timestamp,
used: taskCpuUsed,
percent: percent(taskCpuUsed, stats.reservedCPU),
});

const taskMemoryUsed = taskFrame.ResourceUsage.MemoryStats.RSS;
stats.memory.push({
timestamp: taskFrame.Timestamp,
used: taskMemoryUsed,
percent: percent(taskMemoryUsed / 1024 / 1024, stats.reservedMemory),
});
}
},

// Static figures, denominators for stats
reservedCPU: alias('allocation.taskGroup.reservedCPU'),
reservedMemory: alias('allocation.taskGroup.reservedMemory'),

// Dynamic figures, collected over time
// []{ timestamp: Date, used: Number, percent: Number }
cpu: computed('allocation', function() {
return RollingArray(this.get('bufferSize'));
}),
memory: computed('allocation', function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the difference between these and the cpu + memory that are returned on the hash from the tasks computed prop?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are aggregates for the whole allocation. In the case where there is only one task, they're in theory the same value.

return RollingArray(this.get('bufferSize'));
}),

tasks: computed('allocation', function() {
const bufferSize = this.get('bufferSize');
return this.get('allocation.taskGroup.tasks').map(task => ({
task: get(task, 'name'),

// Static figures, denominators for stats
reservedCPU: get(task, 'reservedCPU'),
reservedMemory: get(task, 'reservedMemory'),

// Dynamic figures, collected over time
// []{ timestamp: Date, used: Number, percent: Number }
cpu: RollingArray(bufferSize),
memory: RollingArray(bufferSize),
}));
}),
});

export default AllocationStatsTracker;

export function stats(allocationProp, fetch) {
return computed(allocationProp, function() {
return AllocationStatsTracker.create({
fetch: fetch.call(this),
allocation: this.get(allocationProp),
});
});
}
62 changes: 62 additions & 0 deletions ui/app/utils/classes/node-stats-tracker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import EmberObject, { computed } from '@ember/object';
import { alias } from '@ember/object/computed';
import RollingArray from 'nomad-ui/utils/classes/rolling-array';
import AbstractStatsTracker from 'nomad-ui/utils/classes/abstract-stats-tracker';

const percent = (numerator, denominator) => {
if (!numerator || !denominator) {
return 0;
}
return numerator / denominator;
};

const NodeStatsTracker = EmberObject.extend(AbstractStatsTracker, {
// Set via the stats computed property macro
node: null,

bufferSize: 100,

url: computed('node', function() {
return `/v1/client/stats?node_id=${this.get('node.id')}`;
}),

append(frame) {
const cpuUsed = Math.floor(frame.CPUTicksConsumed) || 0;
this.get('cpu').push({
timestamp: frame.Timestamp,
used: cpuUsed,
percent: percent(cpuUsed, this.get('reservedCPU')),
});

const memoryUsed = frame.Memory.Used;
this.get('memory').push({
timestamp: frame.Timestamp,
used: memoryUsed,
percent: percent(memoryUsed / 1024 / 1024, this.get('reservedMemory')),
});
},

// Static figures, denominators for stats
reservedCPU: alias('node.resources.cpu'),
reservedMemory: alias('node.resources.memory'),

// Dynamic figures, collected over time
// []{ timestamp: Date, used: Number, percent: Number }
cpu: computed('node', function() {
return RollingArray(this.get('bufferSize'));
}),
memory: computed('node', function() {
return RollingArray(this.get('bufferSize'));
}),
});

export default NodeStatsTracker;

export function stats(nodeProp, fetch) {
return computed(nodeProp, function() {
return NodeStatsTracker.create({
fetch: fetch.call(this),
node: this.get(nodeProp),
});
});
}
45 changes: 45 additions & 0 deletions ui/app/utils/classes/rolling-array.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// An array with a max length.
//
// When max length is surpassed, items are removed from
// the front of the array.

// Using Classes to extend Array is unsupported in Babel so this less
// ideal approach is taken: https://babeljs.io/docs/en/caveats#classes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any additional implications since ember has prototype extensions on? Seems like you would still be able to call ArrayProxy methods on the returned array, is that right? And is it a concern? (tried to think of a way to prevent that but nothing's coming to mind)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are, which I have addressed in a future branch. Mostly addObject needs to behave like push does.

export default function RollingArray(maxLength, ...items) {
const array = new Array(...items);
array.maxLength = maxLength;

// Capture the originals of each array method, but
// associate them with the array to prevent closures.
array._push = array.push;
array._splice = array.splice;
array._unshift = array.unshift;

array.push = function(...items) {
const returnValue = this._push(...items);

const surplus = this.length - this.maxLength;
if (surplus > 0) {
this.splice(0, surplus);
}

return Math.min(returnValue, this.maxLength);
};

array.splice = function(...args) {
const returnValue = this._splice(...args);

const surplus = this.length - this.maxLength;
if (surplus > 0) {
this._splice(0, surplus);
}

return returnValue;
};

array.unshift = function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not a use case for it, but could be cool to support push and unshift (and probably not splice? or just push out both sides with splice?) and then you remove the surplus from the opposite side so that it's a SlideArray.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, that could be cool. I want to keep this as specific as possible though. The next thing I'm likely to address (probably in a different array type) is some sort of rolling aggregation. So there is still fine-grained second-by-second resolution for recent figures, but on the early end of the array, the resolution would be something like one point per 10 seconds, or one point per minute.

Still a nebulous brain noodle for now, but I may end up dabbling with it after I finish this first pass at everything.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, sorry bit late here, if you are avoiding closures, would Array.prototype.push.apply(this, arguments) help here? It would mean you wouldn't have to add your _push etc (polluting the array)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally I hesitate to do that because there is no guarantee that array.push === Array.prototype.push, but in this case, the Array constructor is being invoked directly, so you're totally right that I can just use the methods on the prototype.

Good call, I'll make this change in my current branch 👍

throw new Error('Cannot unshift onto a RollingArray');
};

return array;
}
2 changes: 1 addition & 1 deletion ui/mirage/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ export default function() {
this.get('/client/allocation/:id/stats', clientAllocationStatsHandler);
this.get('/client/fs/logs/:allocation_id', clientAllocationLog);

this.get('/client/v1/client/stats', function({ clientStats }, { queryParams }) {
this.get('/client/stats', function({ clientStats }, { queryParams }) {
return this.serialize(clientStats.find(queryParams.node_id));
});

Expand Down
4 changes: 4 additions & 0 deletions ui/mirage/factories/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ export default Factory.extend({
node.update({
eventIds: events.mapBy('id'),
});

server.create('client-stats', {
id: node.id,
});
},
});

Expand Down
Loading