Skip to content
Browse files

Merge pull request #22 from airbnb/stats-graph

[web-ui] Adding ability to few runtime stats in graph.
  • Loading branch information...
2 parents 61cbee8 + b064677 commit 1e4174fdd2edd36c3e9ee3017232608ba00f62a2 @andykram andykram committed
View
17 src/main/resources/assets/app/scripts/components/duration_humanizer.js
@@ -2,15 +2,18 @@ define([
'underscore'
],
function(_) {
+ 'use strict';
var msInSecond = 1000,
secondsInMinute = 60,
minutesInHour = 60,
hoursInDay = 24;
- function formatMS(ms) {
+ function formatMS(ms, shortenUnit) {
var results = [],
+ shortenUnit = shortenUnit || false,
x, s, m, h, days;
+
if (!_.isNumber(ms)) { return '0'; }
x = ms / msInSecond;
@@ -25,20 +28,22 @@ function(_) {
days = x;
if (days > 1.0) {
- results.push({days: days}, {hours: h});
+ results.push({days: days});
} else if (h > 1.0) {
- results.push({hours: h}, {minutes: m});
+ results.push({hours: h});
} else if (m > 1.0) {
- results.push({minutes: m}, {seconds: s});
+ results.push({minutes: m});
} else {
results.push({seconds: s});
}
var fmt = _.map(results, function(val, i) {
var k = _.chain(val).keys().first().value(),
- v = val[k];
+ v = val[k],
+ result = [parseFloat(v).toFixed(2)];
- return [parseFloat(v).toFixed(2), k].join(' ');
+ result.push((shortenUnit ? k.substring(0, 1) : k));
+ return result.join(' ');
});
return fmt.join(', ');
View
63 src/main/resources/assets/app/scripts/components/job_stats_aggregator.js
@@ -0,0 +1,63 @@
+define([
+ 'underscore'
+],
+function(_) {
+ 'use strict';
+
+ /**
+ * JobStatsAggregator takes a Backbone.Collection of models with
+ * fields 'stats' and 'parents'. JobStatsAggregator will aggregate
+ * the time spent in the specified percentile
+ */
+ function JobStatsAggregator(collection, statsKey, idKey) {
+ this.statsKey = statsKey || '95thPercentile';
+ this.idKey = idKey || 'name';
+ this.processCollection(collection);
+ }
+
+ _.extend(JobStatsAggregator.prototype, {
+ processCollection: function(coll) {
+ if (!!coll) { this.collection = coll; }
+ else if (!this.collection) { return this; }
+
+ this.aggregates = {};
+ this.collection.forEach(function(model) {
+ var id = model.get(this.idKey);
+ this.aggregates[id] = this.processTimeFor(model);
+ }, this);
+
+ },
+
+ processTimeFor: function(model) {
+ var stats = model.get('stats'),
+ id = model.get(this.idKey),
+ parents = model.get('parents'),
+ baseTime = stats ? stats[this.statsKey] : null,
+ otherTime = 0;
+
+ if (parents && parents.length) {
+ otherTime = _(parents).reduce(function(maxTime, parentName) {
+ var parent = this.collection.get(parentName),
+ t = this.processTimeFor(parent);
+ return (t > maxTime) ? t : maxTime;
+ }, 0, this);
+ }
+
+ return baseTime + otherTime;
+ },
+
+ getAggregateFor: function(id) {
+ var agg = _(this.aggregates).has(id) ? this.aggregates[id] : null,
+ model = this.collection.get(id),
+ stats = (model && model.get('stats')) ? model.get('stats') : {},
+ own = stats[this.statsKey] ? stats[this.statsKey] : null;
+
+ return {
+ own: own,
+ total: agg
+ };
+ }
+ });
+
+ return JobStatsAggregator;
+});
View
5 src/main/resources/assets/app/scripts/components/pollable_collection.js
@@ -2,7 +2,8 @@ define([
'underscore'
],
function(_) {
- var namespace = "_pollable";
+ var namespace = "_pollable",
+ defaultInterval = 10000;
function get(t, k) {
if (!(t[namespace] && t[namespace][k])) { return null; }
@@ -24,7 +25,7 @@ function(_) {
},
poll: function(interval) {
- interval || (interval = 5000);
+ interval || (interval = defaultInterval);
var coll = this;
set(this, 'lastTimeout', setTimeout(function() {
View
35 src/main/resources/assets/app/scripts/models/base_job.js
@@ -211,26 +211,10 @@ function(Backbone, _, moment, BaseJobValidations) {
});
},
- updateLeaf: function() {
- return true;
- this.set('leaf', !this.get('parents').length);
- return this;
- },
-
getWhitelist: function() {
return [];
},
- nextDate: function() {
- var duration = this.get('duration'),
- currentDate = this.get('currentDate'),
- nextDate = xsdurationjs.add(duration, currentDate);
-
- this.set('currentDate', nextDate);
-
- return nextDate;
- },
-
parentsList: function(parents) {
return this.get('parents').join(', ');
},
@@ -249,25 +233,6 @@ function(Backbone, _, moment, BaseJobValidations) {
parseSchedule: function() {
return this;
- /*
- var schedule = this.get('schedule'),
- parts;
-
- if (!schedule) {
- return this;
- }
-
- parts = schedule.split('/');
-
- this.set({
- repeats: this.parseRepeats(parts[0]),
- startDate: this.parseStartDate(parts[1]),
- startTime: this.parseStartTime(parts[1]),
- duration: this.parseDuration(parts[2])
- }, {silent: true});
-
- return this;
- */
}
}, {
getWhitelist: function() {
View
55 src/main/resources/assets/app/scripts/styles/pickadate.classic.css
@@ -1,13 +1,3 @@
-
-
-/**
- * Classic styling for pickadate.js
- * Demo: http://amsul.github.com/pickadate.js/themes.htm#classic
- */
-
-/**
- * The picker holder
- */
.pickadate__holder {
/* The base font-size */
@@ -24,9 +14,6 @@
}
-/**
- * The frame that bounds the calendar
- */
.pickadate__frame {
position: relative;
max-width: 420px;
@@ -39,10 +26,6 @@
transition: all .15s ease-out;
}
-
-/**
- * When the calendar opens
- */
.pickadate__holder--opened .pickadate__frame {
top: .5em;
max-height: 25em;
@@ -52,11 +35,6 @@
box-shadow: 0 6px 18px 1px rgba(0,0,0,.12);
}
-
-/**
- * The calendar itself
- */
-
.pickadate__calendar {
color: #000;
background: #fff;
@@ -72,11 +50,6 @@
border-radius: 6px;
}
-
-
-/**
- * The calendar table of dates
- */
.pickadate__table {
text-align: center;
border-collapse: collapse;
@@ -87,17 +60,11 @@
margin-top: .75em;
}
-/* Remove browser stylings on a table cell */
.pickadate__table td {
margin: 0;
padding: 0;
}
-
-
-/**
- * The header containing the month and year tags/selectors
- */
.pickadate__header {
text-align: center;
position: relative;
@@ -156,11 +123,6 @@
}
-
-
-/**
- * The weekday labels
- */
.pickadate__weekday {
width: 14.285714286%; /* 100/7 */
font-size: .75em;
@@ -170,9 +132,6 @@
}
-/**
- * The days on the calendar
- */
.pickadate__day {
padding: .33em 0 .25em;
font-weight: 100;
@@ -180,9 +139,6 @@
margin-bottom: 1px;
}
-/**
- * The various states of a day
- */
.pickadate__day--today {
color: #0089ec;
position: relative;
@@ -216,11 +172,6 @@
opacity: .75;
}
-
-
-/**
- * The footer containing the "today" and "clear" buttons
- */
.pickadate__footer {
text-align: center;
margin: .5em 0 -.5em;
@@ -267,10 +218,6 @@
-
-/**
- * The hover effect on any buttons
- */
.pickadate__day--infocus:hover,
.pickadate__day--outfocus:hover,
.pickadate__nav--prev:hover,
@@ -292,4 +239,4 @@
.pickadate__holder--focused .pickadate__day--highlighted {
background: #0089ec;
color: #fff;
-}
+}
View
23 src/main/resources/assets/app/scripts/templates/graph_view.hbs
@@ -16,9 +16,28 @@
</div>
<div class="btn-group graph-toggle" data-toggle="buttons-radio">
- <button type="button" class="btn active"
+ <button type="button"
+ class="btn {{active currentView 'dynamic'}}"
data-job-type="dynamic">Dynamic</button>
- <button type="button" class="btn"
+ <button type="button"
+ class="btn {{active currentView 'static'}}"
data-job-type="static">Static</button>
+ <button type="button"
+ class="btn {{active currentView 'static-stats'}}"
+ data-job-type="static"
+ data-job-options='{"decorator": "stats"}'>Stats</button>
+ <!--
+ <button class="btn dropdown-toggle {{active currentView 'static'}}"
+ data-toggle="dropdown">
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <li class="" data-job-type="dynamic">
+ <a href="#">Stats View</a>
+ </li>
+ <li class="" data-job-type="dynamic">
+ <a href="#">Time View</a>
+ </li>
+ </ul> //-->
</div>
</div>
View
23 src/main/resources/assets/app/scripts/templates/graph_viz_view.hbs
@@ -1,23 +0,0 @@
-<ul class="nav job-detail-nav">
- <li class="pull-left nav-item title">
- Dependency Graph
- </li>
- <li class="pull-right nav-item"
- data-lightbox-close="true">✕</li>
-</ul>
-
-<div class="row-fluid">
- <div class="span3 graph-filter-container">
- </div>
-
- <div class="span12 graph-area-container">
- <div id="graph-area"></div>
- </div>
-
- <div class="btn-group graph-toggle" data-toggle="buttons-radio">
- <button type="button" class="btn"
- data-job-type="dynamic">Dynamic</button>
- <button type="button" class="btn active"
- data-job-type="static">Static</button>
- </div>
-</div>
View
11 src/main/resources/assets/app/scripts/templates/helpers/active.js
@@ -0,0 +1,11 @@
+define(['handlebars'], function (Handlebars) {
+ function active(context, sep) {
+ if (context && context === sep) {
+ return 'active';
+ } else {
+ return '';
+ }
+ }
+ Handlebars.registerHelper('active', active);
+ return active;
+});
View
2 src/main/resources/assets/app/scripts/vendor/viz.js
1 addition, 1 deletion not shown because the diff is too large. Please use a local Git client to view these changes.
View
32 src/main/resources/assets/app/scripts/views/graph_view.js
@@ -9,7 +9,8 @@ define([
'components/mixable_view',
'components/filterable',
'hbs!templates/graph_view',
- 'bootstrap/tooltip'
+ 'bootstrap/tooltip',
+ 'bootstrap/dropdown'
],
function(Backbone,
_,
@@ -20,26 +21,6 @@ function(Backbone,
var GraphView;
- /*
- * RenderChildren renders related elements into select2 results.
- * this should be bound to a Backbone view.
- *
- * @params {Integer} resultId The ID (name) of a query result.
- * @params {Array} related A list of related jobs.
- *
- * @returns this
- */
- function RenderChildren(resultId, related) {
- var sel = ['[data-select2-id="', resultId, '"]'].join(''),
- relatedNames = _.chain(related).keys().without(resultId).value();
-
- this.$(sel).find('ul.children').html(Select2ChildTpl({
- children: relatedNames
- }));
-
- return this;
- }
-
GraphView = MixableView.extend({
mixins: {
filterable: Filterable.InstanceMethods
@@ -59,11 +40,18 @@ function(Backbone,
});
},
+ getTemplateData: function() {
+ return {
+ currentView: 'dynamic'
+ };
+ },
+
render: function() {
- var html = this.template();
+ var html = this.template(this.getTemplateData());
this.$el.html(html);
this.trigger('render');
+ this.$('[data-toggle="dropdown"]').dropdown();
return this;
},
View
195 src/main/resources/assets/app/scripts/views/graph_viz_view.js
@@ -2,31 +2,40 @@ define([
'jquery',
'backbone',
'underscore',
- 'components/mixable_view',
+ 'components/duration_humanizer',
+ 'components/d3_shadowed_text',
'components/filterable',
+ 'components/job_stats_aggregator',
+ 'components/mixable_view',
'd3',
'vendor/viz',
- 'hbs!templates/graph_viz_view',
+ 'hbs!templates/graph_view',
'moment',
- 'components/d3_shadowed_text',
'cs!vendor/dotgraph/dotgraph',
- 'vendor/dotgraph/dotparser'
+ 'vendor/dotgraph/dotparser',
+ 'bootstrap/tooltip',
+ 'bootstrap/dropdown'
],
function($,
Backbone,
_,
- MixableView,
+ FormatMS,
+ shadowedText,
Filterable,
+ JobStatsAggregator,
+ MixableView,
d3,
Viz,
GraphViewTpl,
moment,
- shadowedText,
DotGraph,
DotParser) {
+ 'use strict';
var DOT_PATH = "/scheduler/graph/dot",
- GraphVizView;
+ GraphVizView,
+ StandardGraphDecorator,
+ StatsGraphDecorator;
function rightClick(data, i) {
_(['stopImmediatePropagation', 'preventDefault']).each(function(name) {
@@ -51,6 +60,103 @@ function($,
}
}
+ function GraphDecorator() {}
+ _.extend(GraphDecorator, {extend: Backbone.Model.extend});
+ _.extend(GraphDecorator.prototype, Backbone.Events, {
+ 'decorateGraph': function() {
+ throw new Error('Decorate graph must be defined by a subclass.');
+ }
+ });
+
+ StandardGraphDecorator = GraphDecorator.extend({
+ decorateGraph: function(svg) {
+ svg.selectAll('.node').on('mouseover', function() {
+ var jId = $(this).data('job-id'),
+ job = app.jobsCollection.get(jId),
+ lastRunTime = job.get('lastRunTime'),
+ d3_textNode = d3.select(this).select('text'),
+ textNode = d3_textNode.node(),
+ offset = (textNode.getBBox().height + 5),
+ text = [],
+ newTextNode;
+
+ if (!lastRunTime) {
+ text.push('Job has not run yet');
+ } else {
+ if (job.get('lastRunError')) {
+ text.push('Last error: ');
+ } else if (job.get('lastRunSuccess')) {
+ text.push('Last success: ');
+ }
+ text.push(moment(lastRunTime).fromNow());
+ }
+
+ shadowedText(d3.select(this), {
+ text: text.join(''),
+ attributes: {
+ 'text-anchor': 'middle',
+ x: d3_textNode.attr('x'),
+ y: (parseInt(d3_textNode.attr('y')) + offset),
+ 'class': [
+ 'last-run-time',
+ (job.get('lastRunError') ? 'failure' : ''),
+ (job.get('lastRunSuccess') ? 'success' : '')
+ ].join(' ')
+ }
+ });
+ }).
+ on('mouseout', function() {
+ d3.select(this).selectAll('.last-run-time').remove();
+ });
+ }
+ });
+
+ StatsGraphDecorator = GraphDecorator.extend({
+ decorateGraph: function(svg) {
+ app.jobsCollection.forEach(function(job) {
+ if (!job.get('stats')) { job.fetchStats(); }
+ });
+
+ this.listenTo(app.jobsCollection, 'change:stats',
+ _.bind(this.addText, this, svg));
+
+ this.addText(svg);
+ },
+
+ addText: function(svg) {
+ var timeSums = {},
+ aggregator = new JobStatsAggregator(app.jobsCollection);
+
+ svg.selectAll('.last-run-time').remove();
+ svg.selectAll('.node').each(function() {
+ var jId = $(this).data('job-id'),
+ aggStats = aggregator.getAggregateFor(jId),
+ d3_textNode = d3.select(this).select('text'),
+ textNode = d3_textNode.node(),
+ offset = (textNode.getBBox().height + 5),
+ text = '',
+ aggregateTime;
+
+ if (aggStats && aggStats.own && aggStats.total) {
+ text = [
+ FormatMS(aggStats.own, true),
+ FormatMS(aggStats.total, true)
+ ].join(' / ');
+ }
+
+ shadowedText(d3.select(this), {
+ text: text,
+ attributes: {
+ x: d3_textNode.attr('x'),
+ y: (parseInt(d3_textNode.attr('y')) + offset),
+ 'text-anchor': 'middle',
+ 'class': 'last-run-time'
+ }
+ });
+ });
+ }
+ });
+
GraphVizView = MixableView.extend({
mixins: {
filterable: Filterable.InstanceMethods
@@ -61,13 +167,29 @@ function($,
template: GraphViewTpl,
initialize: function(options) {
+ var useStatsDecorator = (this.options.decorator === 'stats');
this.initFilterableView();
+ this.decorators = {
+ 'graph': (useStatsDecorator ? StatsGraphDecorator : StandardGraphDecorator)
+ };
this.listenTo(this, 'selections:updated', this.renderDotFile);
},
+ getTemplateData: function() {
+ var viewType = 'static';
+ if (this.options.decorator === 'stats') {
+ viewType = 'static-stats';
+ }
+
+ return {
+ currentView: viewType
+ };
+ },
+
render: function() {
- this.$el.html(this.template());
+ this.$el.html(this.template(this.getTemplateData()));
this.trigger('render');
+ this.$('[data-toggle="dropdown"]').dropdown();
this.renderDotFile();
return this;
@@ -136,7 +258,6 @@ function($,
attr('width', width).
attr('height', height).
attr('viewBox', null);
- //attr('preserveAspectRatio', 'xMidYMid slice');
graph = $svg.select('.graph');
graph.select('polygon').
@@ -162,7 +283,21 @@ function($,
this.decorateDot($svg);
},
+ getDecorator: function() {
+ if (this.options.decorator === 'stats') {
+ return new StatsGraphDecorator;
+ } else {
+ return new StandardGraphDecorator;
+ }
+ },
+
+ getDecorators: function() {
+ return [this.getDecorator()];
+ },
+
decorateDot: function(svg) {
+ var decorators = this.getDecorators();
+
svg.selectAll('title').remove();
svg.selectAll('.node').each(function() {
var node = d3.select(this),
@@ -179,50 +314,16 @@ function($,
});
svg.selectAll('.node').
- on('mouseover', function() {
- var jId = $(this).data('job-id'),
- job = app.jobsCollection.get(jId),
- lastRunTime = job.get('lastRunTime'),
- d3_textNode = d3.select(this).select('text'),
- textNode = d3_textNode.node(),
- offset = (textNode.getBBox().height + 5),
- text = [],
- newTextNode;
-
- if (!lastRunTime) {
- text.push('Job has not run yet');
- } else {
- if (job.get('lastRunError')) {
- text.push('Last error: ');
- } else if (job.get('lastRunSuccess')) {
- text.push('Last success: ');
- }
- text.push(moment(lastRunTime).fromNow());
- }
-
- shadowedText(d3.select(this), {
- text: text.join(''),
- attributes: {
- 'text-anchor': 'middle',
- x: d3_textNode.attr('x'),
- y: (parseInt(d3_textNode.attr('y')) + offset),
- 'class': [
- 'last-run-time',
- (job.get('lastRunError') ? 'failure' : ''),
- (job.get('lastRunSuccess') ? 'success' : '')
- ].join(' ')
- }
- });
- }).
- on('mouseout', function() {
- d3.select(this).selectAll('.last-run-time').remove();
- }).
on('click', function() {
var jId = $(this).data('job-id');
app.lightbox.close();
app.router.navigateJob(jId);
});
+
+ _.each(decorators, function(decorator) {
+ decorator.decorateGraph(svg);
+ });
}
});
View
20 src/main/resources/assets/app/scripts/views/graphbox_view.js
@@ -11,11 +11,13 @@ define([
Backpack,
GraphView,
GraphVizView) {
+ 'use strict';
var GraphboxView,
lbInitialize,
lbEvents,
lbRender,
+ lbClose,
slice = Array.prototype.slice;
lbInitialize = Backpack.Lightbox.prototype.initialize;
@@ -55,19 +57,23 @@ define([
if (view) { view.trigger('close'); }
},
- showGraphView: function(graphType, targetId) {
+ showGraphView: function(graphType, targetId, options) {
var model = this.model,
oldView = model ? model.get('content') : null,
newViewClass = this.getRegisteredGraphType(graphType),
isOpen = model && model.get('open'),
+ options = (_(options).isObject() ? options : {}),
+ transferSelection = (oldView && isOpen && !targetId),
newView,
collection;
- collection = (oldView && isOpen && !targetId) ? oldView.getSelections() : null;
+ collection = transferSelection ? oldView.getSelections() : null;
this.closeGraphView();
if (!!newViewClass) {
- newView = new newViewClass({selections: collection});
+ newView = new newViewClass(_.extend({}, {
+ selections: collection
+ }, options));
if (collection) { collection.trigger('reset', collection, {}); }
else if (!!targetId) { newView.setTarget(targetId); }
@@ -79,10 +85,12 @@ define([
},
toggleGraph: function(e) {
- var $target = $(e.currentTarget);
- e.preventDefault();
+ var $target = $(e.currentTarget),
+ jobType = $target.data('job-type'),
+ options = $target.data('job-options');
- this.showGraphView($target.data('job-type'));
+ e.preventDefault();
+ this.showGraphView(jobType, null, options);
},
getRegisteredGraphType: function(name) {

0 comments on commit 1e4174f

Please sign in to comment.
Something went wrong with that request. Please try again.