Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

[web-ui] Updating graph visualizations. Changelog below.

CHANGELOG
=========
* Added `GraphboxView`, which is an extension of `Backpack.Lightbox`,
  but only concerned with rendering different, registered types of
  graph views.
* Added `GraphVizView`, which is a new graph view that uses
  [viz.js](https://github.com/mdaines/viz.js) to convert DOT files (from
  the API) into SVGs.
* Added `FilterableView`, which holds the common functionality of
  filtering for a graph view. `FilterableView` also handles the tracking
  of selections.
* Added `Filterable` component, which at the moment is entirely coupled
  to `FilterableView`. This will ideally be merely an augmentation on
  top of `ParentView` at some point.
* Added `JobDetailStatsView`, which displays 50th, 75th, 95th and 99th
  percentiles for each job's run time.
* Updated `docs/FAQ.md` with a solution to a problem that can be
  encountered when running jobs locally, with mesosMaster and mesosSlave
  started.
  • Loading branch information...
commit e3b8f8100d5c68cdc7fa79d81355e0495f308c38 1 parent 3fc47ed
@andykram andykram authored
Showing with 9,210 additions and 966 deletions.
  1. +13 −1 docs/FAQ.md
  2. +1 −1  src/main/resources/assets/Makefile
  3. +1 −31 src/main/resources/assets/app/index.html
  4. +5 −0 src/main/resources/assets/app/scripts/collections/base_jobs.js
  5. +1 −5 src/main/resources/assets/app/scripts/collections/results.js
  6. +47 −5 src/main/resources/assets/app/scripts/components/configured_rivets.js
  7. +0 −7 src/main/resources/assets/app/scripts/components/date_node.js
  8. +1 −0  src/main/resources/assets/app/scripts/components/fuzzy_select2.js
  9. +1 −1  src/main/resources/assets/app/scripts/components/parent_view.js
  10. +0 −2  src/main/resources/assets/app/scripts/components/validators/base_job.js
  11. +8 −10 src/main/resources/assets/app/scripts/main.js
  12. +37 −2 src/main/resources/assets/app/scripts/models/base_job.js
  13. +4 −0 src/main/resources/assets/app/scripts/models/job.js
  14. +15 −30 src/main/resources/assets/app/scripts/routers/application.js
  15. +1 −0  src/main/resources/assets/app/scripts/styles.js
  16. +48 −0 src/main/resources/assets/app/scripts/styles/chronos.less
  17. +11 −4 src/main/resources/assets/app/scripts/styles/chronos_select2.less
  18. +4 −0 src/main/resources/assets/app/scripts/styles/core.less
  19. +1 −1  src/main/resources/assets/app/scripts/styles/forms.less
  20. +108 −34 src/main/resources/assets/app/scripts/styles/graph.less
  21. +1 −5 src/main/resources/assets/app/scripts/styles/timeline.css
  22. +321 −0 src/main/resources/assets/app/scripts/styles/vendor/rickshaw/rickshaw.css
  23. +4 −0 src/main/resources/assets/app/scripts/templates/filterable_view.hbs
  24. +7 −14 src/main/resources/assets/app/scripts/templates/graph_view.hbs
  25. +23 −0 src/main/resources/assets/app/scripts/templates/graph_viz_view.hbs
  26. +13 −0 src/main/resources/assets/app/scripts/templates/job_detail_stats.hbs
  27. +37 −34 src/main/resources/assets/app/scripts/templates/job_detail_view.hbs
  28. +1 −0  src/main/resources/assets/app/scripts/templates/job_item_view.hbs
  29. +32 −0 src/main/resources/assets/app/scripts/templates/main_menu.hbs
  30. +578 −505 src/main/resources/assets/app/scripts/vendor/backbone.js
  31. +12 −5 src/main/resources/assets/app/scripts/vendor/backpack.js
  32. +1 −1  src/main/resources/assets/app/scripts/vendor/d3.v3.js
  33. +325 −0 src/main/resources/assets/app/scripts/vendor/d3/box_plot.js
  34. +419 −0 src/main/resources/assets/app/scripts/vendor/dotgraph/dotgraph.coffee
  35. +2,631 −0 src/main/resources/assets/app/scripts/vendor/dotgraph/dotparser.js
  36. 0  src/main/resources/assets/app/scripts/vendor/{ → jquery}/jquery.fastLiveFilter.js
  37. +3,047 −0 src/main/resources/assets/app/scripts/vendor/jquery/jquery.sparkline.js
  38. +24 −2 src/main/resources/assets/app/scripts/vendor/lodash.js
  39. +384 −0 src/main/resources/assets/app/scripts/vendor/raphael/eve.js
  40. +9 −0 src/main/resources/assets/app/scripts/vendor/viz.js
  41. +6 −22 src/main/resources/assets/app/scripts/views/application_view.js
  42. +201 −0 src/main/resources/assets/app/scripts/views/filterable_view.js
  43. +9 −22 src/main/resources/assets/app/scripts/views/graph.js
  44. +20 −217 src/main/resources/assets/app/scripts/views/graph_view.js
  45. +230 −0 src/main/resources/assets/app/scripts/views/graph_viz_view.js
  46. +106 −0 src/main/resources/assets/app/scripts/views/graphbox_view.js
  47. +2 −0  src/main/resources/assets/app/scripts/views/job_detail_collection_view.js
  48. +1 −1  src/main/resources/assets/app/scripts/views/job_detail_header_view.js
  49. +67 −0 src/main/resources/assets/app/scripts/views/job_detail_stats.js
  50. +19 −0 src/main/resources/assets/app/scripts/views/job_detail_view.js
  51. +48 −0 src/main/resources/assets/app/scripts/views/main_menu.js
  52. +316 −0 src/main/resources/assets/app/stubs/dot.dot
  53. +7 −3 src/main/resources/assets/package.json
  54. +1 −0  src/main/scala/com/airbnb/scheduler/api/DescriptiveStatisticsSerializer.scala
  55. +1 −1  src/main/scala/com/airbnb/scheduler/jobs/JobUtils.scala
View
14 docs/FAQ.md
@@ -2,6 +2,7 @@
## Table of Contents
1. [[osx] making mesos fails on `warning: 'JNI_CreateJavaVM' is deprecated`](#osx-making-mesos-fails-on-deprecated-warning)
2. [My Web UI is not showing up!](#my-web-ui-is-not-showing-up)
+3. [When running jobs locally I get an error like `Failed to execute 'chown -R'`](#when-running-jobs-locally-i-get-an-error-like-failed-to-execute-chown--r)
## [osx] Making mesos fails on deprecated header warning
@@ -25,4 +26,15 @@ This error is the result of OSX shipping with an outdated version of the JDK and
`JAVA_CPPFLAGS='-I/Library/Java/JavaVirtualMachines/jdk1.7.0_12.jdk/Contents/Home/include/ -I/Library/Java/JavaVirtualMachines/jdk1.7.0_12.jdk/Contents/Home/include/darwin/' ../configure`
## My Web UI is not showing up!
-See
+See [docs/WEBUI.md](/airbnb/chronos/blob/master/docs/WEBUI.md).
+
+## When running jobs locally I get an error like `Failed to execute 'chown -R'`
+
+If you get an error such as:
+
+ Failed to execute 'chown -R 0:0 '/tmp/mesos/slaves/executors/...' ... Undefined error: 0
+ Failed to launch executor`
+
+You can try starting your mesos slaves with switch users disabled. To do this, start your slaves in the following manner:
+
+ MESOS_SWITCH_USER=0 bin/mesos-slave.sh --master=zk://localhost:2181/mesos --resources="cpus:8,mem:68551;disk:803394"
View
2  src/main/resources/assets/Makefile
@@ -6,7 +6,7 @@ CSS_VENDOR=app/scripts/styles/vendor/tests
JS_MODULE=node_modules
REQUIREJS=app/scripts/require.js
PARSER_PATH=app/scripts/parsers
-LODASH_OPTIONS_PLUS=random template uniq times object omit range memoize flatten merge compact isNull isUndefined isNumber pluck
+LODASH_OPTIONS_PLUS=random template uniq times object omit range memoize flatten merge compact isNull isUndefined isNumber pluck invert
LODASH_OPTIONS_SETTINGS='{interpolate : /{{([\s\S]+?)}}/g}'
SPACE :=
SPACE +=
View
32 src/main/resources/assets/app/index.html
@@ -16,37 +16,7 @@
<div class="app row-fluid">
- <div class="span2 menu">
- <a class="brand" href="/">
- <h1 id="logo">Chronos</h1>
- </a>
-
- <div class="search-wrapper width">
- <form id="search-form">
- <i class="icon-search"></i>
- <input type="text" class="span12" id="search-filter" placeholder="Search" />
- </form>
- </div>
-
- <ul class="nav nav-list width stat-menu">
- <li class="no-select total-jobs">
- <div class="stat-count all-jobs-count">0</div>
- <div class="stat-label">Total Jobs</div>
- </li>
-
- <li class="no-select total-jobs">
- <div class="stat-count failed-jobs-count">0</div>
- <div class="stat-label">Failed Jobs</div>
- </li>
- </ul>
-
- <div>
- <button class="btn width clear-btn pull-right view-graph">
- <i class="icon-retweet"></i> Dependency Graph
- </button>
- <button class="btn width clear-btn pull-right new-job">✚ New Job</button>
- </div>
- </div>
+ <div class="span2 menu" id="main-menu"></div>
<div class="span5 results">
View
5 src/main/resources/assets/app/scripts/collections/base_jobs.js
@@ -83,6 +83,11 @@ define([
this.countSuccessDirection = reverse ? 'down' : 'up';
},
+ errCount: function() {
+ var ec = this.where({'lastRunStatus': 'failure'}).length;
+ return ec;
+ },
+
toggleCountError: function() {
var reverse = this.countErrorDirection === 'up'
this.setComparator(this.makeComparatorByAttribute('errorCount', reverse));
View
6 src/main/resources/assets/app/scripts/collections/results.js
@@ -5,7 +5,7 @@
define([
'backbone',
'underscore',
- 'collections/jobs'
+ 'collections/base_jobs'
], function(Backbone, _, JobsCollection) {
var ResultsCollection;
@@ -28,10 +28,6 @@ define([
var count = 0,
errors = this.where({'lastRunStatus': 'failure'});
- // _.each(errors, function(d) {
- // count = count + d;
- // });
-
this.errorCount = errors.length;
}
View
52 src/main/resources/assets/app/scripts/components/configured_rivets.js
@@ -1,24 +1,49 @@
define([
'jquery',
+ 'backbone',
'underscore',
'cs!vendor/rivets'
-], function($, _, rivets) {
+], function($, Backbone, _, rivets) {
+
+ var collectionEvents = 'add remove reset';
+
+ function isColl(o) {
+ return o instanceof Backbone.Collection;
+ }
rivets.configure({
preloadData: false,
prefix: 'rv',
adapter: {
subscribe: function(obj, keypath, callback) {
- obj.on('change:' + keypath, callback)
+ if (isColl(obj)) {
+ obj.on(collectionEvents, callback).
+ on('change:' + keypath, callback);
+ } else {
+ obj.on('change:' + keypath, callback)
+ }
},
unsubscribe: function(obj, keypath, callback) {
- obj.off('change:' + keypath, callback)
+ if (isColl(obj)) {
+ obj.off(collectionEvents, callback).
+ off('change:' + keypath, callback);
+ } else {
+ obj.off('change:' + keypath, callback)
+ }
},
read: function(obj, keypath) {
- return obj.get(keypath)
+ if (isColl(obj)) {
+ return obj[keypath] || obj;
+ } else {
+ return obj.get(keypath);
+ };
},
publish: function(obj, keypath, value) {
- obj.set(keypath, value)
+ if (isColl(obj)) {
+ obj[keypath] = value;
+ } else {
+ obj.set(keypath, value);
+ };
}
}
});
@@ -58,6 +83,23 @@ define([
read: function(val) {
return !val ? 'none' : val;
}
+ },
+
+ filterBy: function(c, prop) {
+ return c.filter(function(m) {
+ var propVal = rivets.config.adapter.read(m, prop);
+ return !!propVal;
+ });
+ },
+
+ mapToList: function(map) {
+ var list = _.reduce(map, function(memo, v, k) {
+ return memo.concat({
+ key: k,
+ value: v
+ });
+ }, []);
+ return list;
}
});
View
7 src/main/resources/assets/app/scripts/components/date_node.js
@@ -214,7 +214,6 @@ function(_, Backbone, moment) {
var moments = this.mapParts(function(v) {
return v.toMoment();
});
- //debugger
},
add: function(val) {
@@ -230,8 +229,6 @@ function(_, Backbone, moment) {
return memo.add(partName, part);
}, m);
- //debugger
-
return new DateTime({
date: null,
time: null
@@ -253,8 +250,6 @@ function(_, Backbone, moment) {
return memo.subtract(partName, part);
}, m);
- //debugger
-
return new DateTime({
date: null,
time: null
@@ -323,7 +318,6 @@ function(_, Backbone, moment) {
var strValues = _.chain(this.getToStrFields()).compact().map(function(field) {
return field.toString();
}).flatten().value();
- //debugger;
return strValues.join(this.delimeter);
},
getToStrFields: function() {
@@ -378,7 +372,6 @@ function(_, Backbone, moment) {
}
}
- //debugger
return vals;
}
});
View
1  src/main/resources/assets/app/scripts/components/fuzzy_select2.js
@@ -12,6 +12,7 @@ function($,
FuzzyJobMatcher,
Select2ChoiceTpl,
PresenceMap) {
+ 'use strict';
var methods = {},
nsKey = 'airbnb',
View
2  src/main/resources/assets/app/scripts/components/parent_view.js
@@ -61,7 +61,7 @@ function(_) {
function RemoveOne(model) {
var _views = get(this, 'views'),
- view = views[model.cid],
+ view = _views[model.cid],
views;
if (!view) { return; }
View
2  src/main/resources/assets/app/scripts/components/validators/base_job.js
@@ -15,8 +15,6 @@ function(Backbone, _, BaseJob) {
return defaults.name !== name;
});
- debugger
-
this.should("have unique name", function() {
return this.withCollection(function(collection) {
return !collection.some(function(model) {
View
18 src/main/resources/assets/app/scripts/main.js
@@ -14,14 +14,15 @@ require.config({
'bootstrap/button' : 'vendor/bootstrap/js/bootstrap-button',
'bootstrap/dropdown' : 'vendor/bootstrap/js/bootstrap-dropdown',
'bootstrap/timepicker' : 'vendor/bootstrap-timepicker/js/bootstrap-timepicker',
- 'jquery/autotype' : 'vendor/bootstrap-timepicker/js/jquery.autotype',
'd3' : 'vendor/d3.v3',
'underscore' : 'vendor/lodash',
'moment' : 'vendor/moment',
'backpack' : 'vendor/backpack',
+ 'jquery/autotype' : 'vendor/bootstrap-timepicker/js/jquery.autotype',
'jquery/select2' : 'vendor/select2',
'jquery/pickadate' : 'vendor/pickadate',
- 'jquery/fastLiveFilter' : 'vendor/jquery.fastLiveFilter',
+ 'jquery/visibility' : 'vendor/jquery/jquery.visibility',
+ 'jquery/fastLiveFilter' : 'vendor/jquery/jquery.fastLiveFilter',
'coffee-script' : 'vendor/coffee-script',
'cs' : 'vendor/cs',
'propertyParser' : 'vendor/requirejs-plugins/src/propertyParser',
@@ -73,6 +74,10 @@ require.config({
deps: ['jquery'],
exports: 'jQuery.fn.pickadate'
},
+ 'jquery/visibility': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn._pageVisibility'
+ },
'bootstrap/tooltip': {
deps: ['jquery'],
exports: 'jQuery.fn.tooltip'
@@ -181,12 +186,8 @@ function($,
init: function() {
window.app || (window.app = {});
- var jobsCollection;
+ var jobsCollection = new JobsCollection();
- jobsCollection = new JobsCollection();
- jobsCollection.on('all', function() {
- //console.log.apply(console, ['jobsCollection event'].concat(arguments).concat(this));
- });
jobsCollection.fetch().done(function() {
jobsCollection.each(function(job) {
job.set({persisted: true}, {silent: true});
@@ -196,9 +197,6 @@ function($,
window.app.resultsCollection = new ResultsCollection(jobsCollection.models)
window.app.jobsGraphCollection = new JobGraphCollection();
- window.app.jobsGraphCollection.on('all', function() {
- //console.log('jobsGraphCollection event', arguments, this);
- });
window.app.jobsGraphCollection.registerAccessoryCollection(
jobsCollection).fetch();
View
39 src/main/resources/assets/app/scripts/models/base_job.js
@@ -10,7 +10,18 @@ define([
],
function(Backbone, _, moment, BaseJobValidations) {
- var BaseWhiteList, BaseJobModel;
+ var slice = Array.prototype.slice,
+ BaseWhiteList,
+ BaseJobModel;
+
+ function Route() {
+ var args = slice.call(arguments),
+ encoded;
+
+ encoded = _.map(args, function(arg) { return encodeURIComponent(arg); });
+ encoded.unshift('');
+ return encoded.join('/');
+ }
BaseWhiteList = [
'name', 'command', 'owner', 'async', 'epsilon', 'executor'
@@ -44,7 +55,7 @@ function(Backbone, _, moment, BaseJobValidations) {
url: function(action) {
if (action === 'put') {
- return '/scheduler/job/' + encodeURIComponent(this.get('name'));
+ return Route('scheduler', 'job', this.get('name'));
}
},
@@ -69,6 +80,30 @@ function(Backbone, _, moment, BaseJobValidations) {
});
},
+ fetchStats: function() {
+ var url = Route('scheduler', 'job', 'stat', this.get('name')),
+ model = this;
+
+ var formatStats = function(stats) {
+ return _.reduce(stats, function(memo, v, k) {
+ var key = k;
+ /*
+ if (k.toLocaleLowerCase().indexOf('percentile') >= 0) {
+ key = [
+ k.split('th')[0], 'th', ' Percentile'
+ ].join('');
+ }
+ */
+ memo[key] = v;
+ return memo;
+ }, {});
+ };
+ $.getJSON(url, function(data) {
+ if (!data || !data.count) { return null; }
+ model.set({stats: formatStats(data)});
+ });
+ },
+
hasSchedule: function() {
return true;
},
View
4 src/main/resources/assets/app/scripts/models/job.js
@@ -20,6 +20,10 @@ define(['backbone', 'underscore', 'models/base_job'],
hasSchedule: function() {
return true;
+ },
+
+ fetchStats: function() {
+ return false;
}
});
View
45 src/main/resources/assets/app/scripts/routers/application.js
@@ -9,8 +9,8 @@ define([
'views/application_view',
'views/jobs_collection_view',
'views/job_detail_collection_view',
- 'views/graph_view',
- 'backpack'
+ 'views/main_menu',
+ 'views/graphbox_view'
],
function($,
Backbone,
@@ -18,8 +18,8 @@ define([
ApplicationView,
JobsCollectionView,
JobDetailCollectionView,
- GraphView,
- Backpack) {
+ MainMenuView,
+ GraphboxView) {
var ApplicationRouter;
@@ -35,7 +35,7 @@ define([
_.extend(window.app, {
applicationView: new ApplicationView({
collection: window.app.jobsCollection
- }),
+ }).render(),
jobsCollectionView: new JobsCollectionView({
collection: window.app.resultsCollection
@@ -43,22 +43,19 @@ define([
detailsCollectionView: new JobDetailCollectionView({
collection: window.app.detailsCollection
- })
- });
-
- window.app.resultsCollection.on('change', function() {
- $('.failed-jobs-count').html(this.errorCount);
- $('.fresh-jobs-count').html(this.freshCount);
- }, window.app.resultsCollection).trigger('reset')
+ }),
- window.app.lightbox = new Backpack.Lightbox();
+ mainMenuView: new MainMenuView({
+ collection: window.app.jobsCollection
+ }).render()
+ });
- $('.all-jobs-count').html(window.app.resultsCollection.size());
+ window.app.lightbox = new GraphboxView();
+ window.app.resultsCollection.trigger('reset');
+ },
- $('#search-form').on('submit', function(event){
- event.preventDefault();
- return false;
- });
+ navigateJob: function(jobName) {
+ this.navigate('jobs/' + jobName, {trigger: true});
},
index: function() {
@@ -66,18 +63,6 @@ define([
app.resultsCollection.reset(app.jobsCollection.models);
},
- graph: function() {
- console.log('graph')
- var graphView = new GraphView();
-
- app.lightbox
- .addClass('graph-wrapper')
- .content(graphView)
- .open();
-
- app.graph.init();
- },
-
showJob: function(path) {
app.detailsCollection.deserialize(path);
}
View
1  src/main/resources/assets/app/scripts/styles.js
@@ -2,6 +2,7 @@ define([
'css!styles/lightbox.css',
'css!styles/timeline.css',
'css!styles/pickadate.classic.css',
+ 'css!styles/vendor/rickshaw/rickshaw.css',
'less!styles/select2.less',
'less!styles/chronos.less'
], function() {
View
48 src/main/resources/assets/app/scripts/styles/chronos.less
@@ -40,12 +40,14 @@ html > body {
}
.new-job,
+.view-alt-graph,
.view-graph {
margin-top: 12px;
position: relative;
left: 10px;
}
+.nav-item.view-alt-graph,
.nav-item.view-graph {
margin-top: 0;
position: static;
@@ -129,3 +131,49 @@ html > body .tooltip,
#job-form label.radio input {
width: auto;
}
+
+// Stats graph styles
+@statsYAxisWidth: 35px;
+.graph-container {
+ position: relative;
+
+ .graph-stats {
+ position: relative;
+ left: @statsYAxisWidth;
+ }
+
+ line.median {
+ stroke-width: 3px;
+ stroke: #000;
+ }
+
+ &.stats-graph {
+ div svg path {
+ &, &.domain {
+ stroke: #fff;
+ opacity: 0.0;
+ }
+ }
+ div svg text {
+ opacity: 1.0;
+ fill: #fff;
+ }
+ }
+
+ .graph-y-axis {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: @statsYAxisWidth;
+ }
+ .graph-x-axis {
+ position: relative;
+ left: @statsYAxisWidth;
+ height: @statsYAxisWidth;
+ }
+}
+
+.badge-percentile {
+ background: rgba(0,0,0,0.4);
+ color: rgba(255,255,255,0.4);
+}
View
15 src/main/resources/assets/app/scripts/styles/chronos_select2.less
@@ -5,6 +5,7 @@
.select2-container-multi .select2-choices .select2-search-field input {
outline: 0;
.box-shadow(none);
+ border-width: 1px;
}
.details-wrapper .select2-container .select2-search-field {
@@ -96,7 +97,7 @@
@graphColor3: darken(@graphColor03, @darkenAmount);
@graphColor4: darken(@graphColor04, @darkenAmount);
-.graph-area .select2-container-multi .select2-choices .select2-search-choice {
+.lb-graph .select2-container-multi .select2-choices .select2-search-choice {
background-color: rgba(255, 255, 255, 0.15);
color: #fff;
.border-radius(4px);
@@ -138,8 +139,14 @@
}
}
-#filter-graph .select2-search-field input {
- border: 1px solid rgba(255,255,255,0.25);
+#filter-graph .select2-search-field {
+ input,
+ .select2-input,
+ .select2-focused {
+ &, &:focus {
+ border: 1px solid rgba(255,255,255,0.5);
+ }
+ }
}
.fuzzy-select2-dropdown,
@@ -154,7 +161,7 @@
}
}
-.graph-area {
+.lb-graph {
.fuzzy-select2-dropdown,
.select2-drop-multi.fuzzy-select2-dropdown {
background-color: transparent;
View
4 src/main/resources/assets/app/scripts/styles/core.less
@@ -906,3 +906,7 @@ margin-top: -70px;
white-space: normal;
word-wrap: break-word;
}
+
+.hidden {
+ display: none;
+}
View
2  src/main/resources/assets/app/scripts/styles/forms.less
@@ -77,7 +77,7 @@
right: 8px;
top: 6px;
opacity: 0.3;
- margin-top: 8px;
+ margin-top: 2px;
background-image: @iconWhiteSpritePath;
}
View
142 src/main/resources/assets/app/scripts/styles/graph.less
@@ -38,9 +38,13 @@
z-index: 1;
}
+.span3.graph-filter-container,
+.graph-toggle {
+ z-index: 2;
+}
+
.span3.graph-filter-container {
position: fixed;
- z-index: 2;
}
/**
@@ -62,59 +66,129 @@
opacity: 0.0;
}
-.node text {
- pointer-events: none;
- font: 18px @sansFontFamily;
- fill: #FFFFFF;
-}
+.graph-area {
+ .node text {
+ pointer-events: none;
+ font: 18px @sansFontFamily;
+ fill: #FFFFFF;
+ }
-.node text.subtitle {
- display: none;
-}
-.node text.inv-subtitle {
- display: block;
-}
+ .node text.subtitle {
+ display: none;
+ }
+ .node text.inv-subtitle {
+ display: block;
+ }
-.node text.inv-subtitle,
-.node text.subtitle {
- font-size: 13px;
-}
+ .node text.inv-subtitle,
+ .node text.subtitle {
+ font-size: 13px;
+ }
+
+ .node.hover {
+ text.subtitle {
+ display: block;
+ }
-.node.hover {
- text.subtitle {
+ text.inv-subtitle {
+ display: none;
+ }
+ }
+
+ .node.hover text.shadow {
display: block;
}
- text.inv-subtitle {
- display: none;
+ .node circle {
+ fill: #24343F;
}
+ .node .fail {
+ fill: red;
+ }
+ .node:hover circle {
+ border: #000;
+ border-width: 2px;
+ }
}
-.node text.shadow {
- stroke: #333;
- stroke-width: 4px;
- stroke-opacity: 0.6;
+svg path.area {
+ opacity: 0.4
}
-.node.hover text.shadow {
- display: block;
-}
+.graph-viz-view {
+
+ text {
+ font-family: @sansFontFamily;
+ font-size: 14px;
+ }
-.node circle {
- fill: #24343F;
+ .node {
+ cursor: pointer;
+ }
+
+ text.last-run-time,
+ .node ellipse {
+ fill: #fff;
+ }
+
+ .node.success {
+ ellipse {
+ fill: green;
+ }
+ }
+
+ .node.fail {
+ ellipse {
+ fill: red;
+ }
+
+ text {
+ }
+ }
+
+ @myWhite: rgba(255, 255, 255, 0.6);
+ g.edge {
+ path,
+ polygon {
+ stroke: @myWhite;
+ }
+
+ polygon {
+ fill: @myWhite;
+ }
+ }
}
-.node .fail {
- fill: red;
+.graph-area,
+.graph-viz-view {
+ .node text.shadow {
+ stroke: #333;
+ stroke-width: 4px;
+ stroke-opacity: 0.6;
+ }
}
-.node:hover circle {
- border: #000;
- border-width: 2px;
+.graph-viz-view .node text.shadow {
+ stroke: #000;
+ stroke-opacity: 0.8;
}
.graph-wrapper .search-wrapper {
.makeColumn(2);
}
+
+svg.box {
+ height: 50px;
+
+ line,
+ rect {
+ fill: none;
+ stroke: #000;
+ }
+
+ text {
+ fill: #fff;
+ }
+}
View
6 src/main/resources/assets/app/scripts/styles/timeline.css
@@ -2,10 +2,6 @@ svg {
font: 10px sans-serif;
}
-path {
- fill: steelblue;
-}
-
.axis path,
.axis line {
fill: none;
@@ -17,4 +13,4 @@ path {
stroke: #fff;
fill-opacity: .125;
shape-rendering: crispEdges;
-}
+}
View
321 src/main/resources/assets/app/scripts/styles/vendor/rickshaw/rickshaw.css
@@ -0,0 +1,321 @@
+.rickshaw_graph .detail {
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ z-index: 2;
+ background: rgba(0, 0, 0, 0.1);
+ bottom: 0;
+ width: 1px;
+ transition: opacity 0.25s linear;
+ -moz-transition: opacity 0.25s linear;
+ -o-transition: opacity 0.25s linear;
+ -webkit-transition: opacity 0.25s linear;
+}
+.rickshaw_graph .detail.inactive {
+ opacity: 0;
+}
+.rickshaw_graph .detail .item.active {
+ opacity: 1;
+}
+.rickshaw_graph .detail .x_label {
+ font-family: Arial, sans-serif;
+ border-radius: 3px;
+ padding: 6px;
+ opacity: 0.5;
+ border: 1px solid #e0e0e0;
+ font-size: 12px;
+ position: absolute;
+ background: white;
+ white-space: nowrap;
+}
+.rickshaw_graph .detail .item {
+ position: absolute;
+ z-index: 2;
+ border-radius: 3px;
+ padding: 0.25em;
+ font-size: 12px;
+ font-family: Arial, sans-serif;
+ opacity: 0;
+ background: rgba(0, 0, 0, 0.4);
+ color: white;
+ border: 1px solid rgba(0, 0, 0, 0.4);
+ margin-left: 1em;
+ margin-top: -1em;
+ white-space: nowrap;
+}
+.rickshaw_graph .detail .item.active {
+ opacity: 1;
+ background: rgba(0, 0, 0, 0.8);
+}
+.rickshaw_graph .detail .item:before {
+ content: "\25c2";
+ position: absolute;
+ left: -0.5em;
+ color: rgba(0, 0, 0, 0.7);
+ width: 0;
+}
+.rickshaw_graph .detail .dot {
+ width: 4px;
+ height: 4px;
+ margin-left: -4px;
+ margin-top: -3px;
+ border-radius: 5px;
+ position: absolute;
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
+ background: white;
+ border-width: 2px;
+ border-style: solid;
+ display: none;
+ background-clip: padding-box;
+}
+.rickshaw_graph .detail .dot.active {
+ display: block;
+}
+/* graph */
+
+.rickshaw_graph {
+ position: relative;
+}
+.rickshaw_graph svg {
+ display: block;
+ overflow: hidden;
+}
+
+/* ticks */
+
+.rickshaw_graph .x_tick {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 0px;
+ border-left: 1px dotted rgba(0, 0, 0, 0.2);
+ pointer-events: none;
+}
+.rickshaw_graph .x_tick .title {
+ position: absolute;
+ font-size: 12px;
+ font-family: Arial, sans-serif;
+ opacity: 0.5;
+ white-space: nowrap;
+ margin-left: 3px;
+ bottom: 1px;
+}
+
+/* annotations */
+
+.rickshaw_annotation_timeline {
+ height: 1px;
+ border-top: 1px solid #e0e0e0;
+ margin-top: 10px;
+ position: relative;
+}
+.rickshaw_annotation_timeline .annotation {
+ position: absolute;
+ height: 6px;
+ width: 6px;
+ margin-left: -2px;
+ top: -3px;
+ border-radius: 5px;
+ background-color: rgba(0, 0, 0, 0.25);
+}
+.rickshaw_graph .annotation_line {
+ position: absolute;
+ top: 0;
+ bottom: -6px;
+ width: 0px;
+ border-left: 2px solid rgba(0, 0, 0, 0.3);
+ display: none;
+}
+.rickshaw_graph .annotation_line.active {
+ display: block;
+}
+
+.rickshaw_graph .annotation_range {
+ background: rgba(0, 0, 0, 0.1);
+ display: none;
+ position: absolute;
+ top: 0;
+ bottom: -6px;
+ z-index: -10;
+}
+.rickshaw_graph .annotation_range.active {
+ display: block;
+}
+.rickshaw_graph .annotation_range.active.offscreen {
+ display: none;
+}
+
+.rickshaw_annotation_timeline .annotation .content {
+ background: white;
+ color: black;
+ opacity: 0.9;
+ padding: 5px 5px;
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
+ border-radius: 3px;
+ position: relative;
+ z-index: 20;
+ font-size: 12px;
+ padding: 6px 8px 8px;
+ top: 18px;
+ left: -11px;
+ width: 160px;
+ display: none;
+ cursor: pointer;
+}
+.rickshaw_annotation_timeline .annotation .content:before {
+ content: "\25b2";
+ position: absolute;
+ top: -11px;
+ color: white;
+ text-shadow: 0 -1px 1px rgba(0, 0, 0, 0.8);
+}
+.rickshaw_annotation_timeline .annotation.active,
+.rickshaw_annotation_timeline .annotation:hover {
+ background-color: rgba(0, 0, 0, 0.8);
+ cursor: none;
+}
+.rickshaw_annotation_timeline .annotation .content:hover {
+ z-index: 50;
+}
+.rickshaw_annotation_timeline .annotation.active .content {
+ display: block;
+}
+.rickshaw_annotation_timeline .annotation:hover .content {
+ display: block;
+ z-index: 50;
+}
+.rickshaw_graph .y_axis,
+.rickshaw_graph .x_axis_d3 {
+ fill: none;
+}
+.rickshaw_graph .y_ticks .tick,
+.rickshaw_graph .x_ticks_d3 .tick {
+ stroke: rgba(0, 0, 0, 0.16);
+ stroke-width: 2px;
+ shape-rendering: crisp-edges;
+ pointer-events: none;
+}
+.rickshaw_graph .y_grid .tick,
+.rickshaw_graph .x_grid_d3 .tick {
+ z-index: -1;
+ stroke: rgba(0, 0, 0, 0.20);
+ stroke-width: 1px;
+ stroke-dasharray: 1 1;
+}
+.rickshaw_graph .y_grid path,
+.rickshaw_graph .x_grid_d3 path {
+ fill: none;
+ stroke: none;
+}
+.rickshaw_graph .y_ticks path,
+.rickshaw_graph .x_ticks_d3 path {
+ fill: none;
+ stroke: #808080;
+}
+.rickshaw_graph .y_ticks text,
+.rickshaw_graph .x_ticks_d3 text {
+ opacity: 0.5;
+ font-size: 12px;
+ pointer-events: none;
+}
+.rickshaw_graph .x_tick.glow .title,
+.rickshaw_graph .y_ticks.glow text {
+ fill: black;
+ color: black;
+ text-shadow:
+ -1px 1px 0 rgba(255, 255, 255, 0.1),
+ 1px -1px 0 rgba(255, 255, 255, 0.1),
+ 1px 1px 0 rgba(255, 255, 255, 0.1),
+ 0px 1px 0 rgba(255, 255, 255, 0.1),
+ 0px -1px 0 rgba(255, 255, 255, 0.1),
+ 1px 0px 0 rgba(255, 255, 255, 0.1),
+ -1px 0px 0 rgba(255, 255, 255, 0.1),
+ -1px -1px 0 rgba(255, 255, 255, 0.1);
+}
+.rickshaw_graph .x_tick.inverse .title,
+.rickshaw_graph .y_ticks.inverse text {
+ fill: white;
+ color: white;
+ text-shadow:
+ -1px 1px 0 rgba(0, 0, 0, 0.8),
+ 1px -1px 0 rgba(0, 0, 0, 0.8),
+ 1px 1px 0 rgba(0, 0, 0, 0.8),
+ 0px 1px 0 rgba(0, 0, 0, 0.8),
+ 0px -1px 0 rgba(0, 0, 0, 0.8),
+ 1px 0px 0 rgba(0, 0, 0, 0.8),
+ -1px 0px 0 rgba(0, 0, 0, 0.8),
+ -1px -1px 0 rgba(0, 0, 0, 0.8);
+}
+.rickshaw_legend {
+ font-family: Arial;
+ font-size: 12px;
+ color: white;
+ background: #404040;
+ display: inline-block;
+ padding: 12px 5px;
+ border-radius: 2px;
+ position: relative;
+}
+.rickshaw_legend:hover {
+ z-index: 10;
+}
+.rickshaw_legend .swatch {
+ width: 10px;
+ height: 10px;
+ border: 1px solid rgba(0, 0, 0, 0.2);
+}
+.rickshaw_legend .line {
+ clear: both;
+ line-height: 140%;
+ padding-right: 15px;
+}
+.rickshaw_legend .line .swatch {
+ display: inline-block;
+ margin-right: 3px;
+ border-radius: 2px;
+}
+.rickshaw_legend .label {
+ margin: 0;
+ white-space: nowrap;
+ display: inline;
+ font-size: inherit;
+ background-color: transparent;
+ color: inherit;
+ font-weight: normal;
+ line-height: normal;
+ padding: 0px;
+ text-shadow: none;
+}
+.rickshaw_legend .action:hover {
+ opacity: 0.6;
+}
+.rickshaw_legend .action {
+ margin-right: 0.2em;
+ font-size: 10px;
+ opacity: 0.2;
+ cursor: pointer;
+ font-size: 14px;
+}
+.rickshaw_legend .line.disabled {
+ opacity: 0.4;
+}
+.rickshaw_legend ul {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ margin: 2px;
+ cursor: pointer;
+}
+.rickshaw_legend li {
+ padding: 0 0 0 2px;
+ min-width: 80px;
+ white-space: nowrap;
+}
+.rickshaw_legend li:hover {
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 3px;
+}
+.rickshaw_legend li:active {
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 3px;
+}
View
4 src/main/resources/assets/app/scripts/templates/filterable_view.hbs
@@ -0,0 +1,4 @@
+<form id="filter-graph">
+ <i class="icon-search"></i>
+ <input type="text" class="graph-filter" id="graph-filter" data-placeholder="Filter" placeholder="Filter" />
+</form>
View
21 src/main/resources/assets/app/scripts/templates/graph_view.hbs
@@ -3,29 +3,22 @@
Dependency Graph
</li>
- <!--
- <li class="pull-left nav-item">
- <i class="icon icon-play"></i>
- </li>
-
- <li class="pull-left nav-item">
- <i class="icon icon-pause"></i>
- </li>
- //-->
-
<li class="pull-right nav-item"
data-lightbox-close="true">✕</li>
</ul>
<div class="row-fluid">
<div class="span3 graph-filter-container">
- <form id="filter-graph">
- <i class="icon-search"></i>
- <input type="text" id="graph-filter" data-placeholder="Filter" placeholder="Filter" />
- </form>
</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 active"
+ data-job-type="dynamic">Dynamic</button>
+ <button type="button" class="btn"
+ data-job-type="static">Static</button>
+ </div>
</div>
View
23 src/main/resources/assets/app/scripts/templates/graph_viz_view.hbs
@@ -0,0 +1,23 @@
+<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
13 src/main/resources/assets/app/scripts/templates/job_detail_stats.hbs
@@ -0,0 +1,13 @@
+{{#if percentiles}}
+<div class="detail">Job runtime (percentiles)</div>
+<ul class="inline stats-view">
+{{#each percentiles}}
+ <li>
+ <span class="badge badge-success badge-percentile">{{percentile}}</span>
+ <span>{{value}}</span>
+ </li>
+{{/each}}
+</ul>
+{{else}}
+<div class="detail">No stats available for job.</div>
+{{/if}}
View
71 src/main/resources/assets/app/scripts/templates/job_detail_view.hbs
@@ -58,19 +58,38 @@
</div>
<div class="row-fluid job-detail-row">
- <div class="span6 control-group job-detail job-detail-tooltip"
- data-toggle="tooltip"
- data-title="Owner's email address<br>The owner will be emailed in the event of a job failure or disruption."
- data-html="true"
- data-placement="bottom">
- <div class="detail">Owner</div>
- <div class="display hightlight line-height job-name owner"
- data-rv-text="job.owner">{{owner}}</div>
- <div class="field cmd">
- <input class="job-input" value="{{owner}}" name="owner" tabindex="4"
- data-rv-value="job.owner">
+ <div class="span6">
+ <div class="control-group job-detail job-detail-tooltip"
+ data-toggle="tooltip"
+ data-title="Owner's email address<br>The owner will be emailed in the event of a job failure or disruption."
+ data-html="true"
+ data-placement="bottom">
+ <div class="detail">Owner</div>
+ <div class="display hightlight line-height job-name owner"
+ data-rv-text="job.owner">{{owner}}</div>
+ <div class="field cmd">
+ <input class="job-input" value="{{owner}}" name="owner" tabindex="4"
+ data-rv-value="job.owner">
+ </div>
+ <span class="help-inline"></span>
+ </div>
+
+ <div class="control-group job-detail job-detail-tooltip"
+ data-title="This is the mesos executor to use. Note, this can be a path that needs to be available on the mesos slave or it can be left blank in which case the shell-executor is assumed. The executor will receive the string specified in the command field."
+ data-toggle="tooltip"
+ data-placement="bottom">
+ <div class="detail">Executor</div>
+ <div class="display hightlight cmd executor"
+ data-rv-text="job.executor | orNone">{{orNone executor}}</div>
+ <div class="field cmd">
+ <input class="job-input"
+ data-rv-value="job.executor"
+ value="{{executor}}"
+ name="executor"
+ placeholder="/custom/executor"
+ tabindex="5">
+ </div>
</div>
- <span class="help-inline"></span>
</div>
{{#persisted}}
@@ -128,7 +147,7 @@
data-rv-text="job.schedule">{{schedule}}</div>
<div class="field cmd small-input repeats control-group">
<label for="repeats" class="control-label">R</label>
- <input class="job-input" value="{{repeats}}" name="repeats" tabindex="5"
+ <input class="job-input" value="{{repeats}}" name="repeats" tabindex="6"
data-rv-value="job.repeats"
placeholder="∞" data-placeholder="∞"
data-title="Number of Repetitions<br>Blank for Infinite (∞), 0 for none."
@@ -141,7 +160,7 @@
<div class="field cmd small-input control-group">
<input class="job-input datepicker" value="{{startDate}}"
data-rv-value="job.startDate"
- name="startDate" tabindex="6"
+ name="startDate" tabindex="7"
data-title="Date to start the job."
data-toggle="tooltip">
</div>
@@ -149,7 +168,7 @@
<div class="small-input break">T</div>
<div class="field cmd small-input bootstrap-timepicker control-group">
- <input class="job-input timepicker" value="{{startTime}}" name="startTime" tabindex="7"
+ <input class="job-input timepicker" value="{{startTime}}" name="startTime" tabindex="8"
data-rv-value="job.startTime"
data-title="Time to start the job."
data-toggle="tooltip"
@@ -163,7 +182,7 @@
<div class="field cmd small-input period control-group">
<label for="duration" class="control-label">P</label>
- <input class="job-input" value="{{duration}}" name="duration" tabindex="8"
+ <input class="job-input" value="{{duration}}" name="duration" tabindex="9"
data-rv-value="job.duration"
data-title="Duration<br>The job will execute every after every passing of duration from start time."
data-html="true"
@@ -178,7 +197,7 @@
<div class="detail">Epsilon</div>
<div class="display highlight cmd epsilon">{{epsilon}}</div>
<div class="field cmd epsilon control-group">
- <input class="job-input" value="{{epsilon}}" name="epsilon" tabindex="9"
+ <input class="job-input" value="{{epsilon}}" name="epsilon" tabindex="10"
data-rv-value="job.epsilon"
data-title="Epsilon<br>Amount of time after a failure within which to retry the job."
data-html="true"
@@ -216,23 +235,7 @@
</div>
</div>
- <div class="row-fluid job-detail-row job-detail job-detail-tooltip bottom-row"
- data-title="This is the mesos executor to use. Note, this can be a path that needs to be available on the mesos slave or it can be left blank in which case the shell-executor is assumed. The executor will receive the string specified in the command field."
- data-toggle="tooltip"
- data-placement="bottom">
- <div class="detail">Executor</div>
- <div class="display hightlight cmd executor"
- data-rv-text="job.executor | orNone">{{orNone executor}}</div>
- <div class="field cmd">
- <div class="input-append">
- <input class="job-input span8"
- data-rv-value="job.executor"
- value="{{executor}}"
- name="executor"
- placeholder="/custom/executor"
- tabindex="11">
- </div>
- </div>
+ <div class="row-fluid job-detail-row job-detail job-detail-tooltip bottom-row stats-row">
</div>
</div>
</form>
View
1  src/main/resources/assets/app/scripts/templates/job_item_view.hbs
@@ -1,4 +1,5 @@
<div class="span9 job-name">{{name}}</div>
+<div class="hidden job-owner" data-rv-text="job.owner">{{owner}}</div>
<div class="span1 ignore">
<a class="view-graph ignore" href="#"
data-job-id="{{name}}"
View
32 src/main/resources/assets/app/scripts/templates/main_menu.hbs
@@ -0,0 +1,32 @@
+<a class="brand" href="/">
+ <h1 id="logo">Chronos</h1>
+</a>
+
+<div class="search-wrapper width">
+ <form id="search-form">
+ <i class="icon-search"></i>
+ <input type="text" class="span12" id="search-filter" placeholder="Search by job name, status or owner" />
+ </form>
+</div>
+
+<ul class="nav nav-list width stat-menu">
+ <li class="no-select total-jobs">
+ <div class="stat-count all-jobs-count"
+ data-rv-text="jobs.length">0</div>
+ <div class="stat-label">Total Jobs</div>
+ </li>
+
+ <li class="no-select total-jobs">
+ <div class="stat-count failed-jobs-count"
+ data-rv-text="jobs:errCount < .lastRunStatus">0</div>
+ <div class="stat-label">Failed Jobs</div>
+ </li>
+</ul>
+
+<div>
+ <button class="btn width clear-btn pull-right view-graph">
+ <i class="icon-retweet"></i> Dependency Graph
+ </button>
+
+ <button class="btn width clear-btn pull-right new-job">✚ New Job</button>
+</div>
View
1,083 src/main/resources/assets/app/scripts/vendor/backbone.js
@@ -1,9 +1,9 @@
//Wrapped in an outer function to preserve global this
(function (root) { var amdExports; define(['jquery','underscore'], function () { (function () {
-// Backbone.js 0.9.10
+// Backbone.js 1.0.0
-// (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc.
+// (c) 2010-2013 Jeremy Ashkenas, DocumentCloud Inc.
// Backbone may be freely distributed under the MIT license.
// For all details and documentation:
// http://backbonejs.org
@@ -21,14 +21,14 @@
// restored later on, if `noConflict` is used.
var previousBackbone = root.Backbone;
- // Create a local reference to array methods.
+ // Create local references to array methods we'll want to use later.
var array = [];
var push = array.push;
var slice = array.slice;
var splice = array.splice;
// The top-level namespace. All public Backbone classes and modules will
- // be attached to this. Exported for both CommonJS and the browser.
+ // be attached to this. Exported for both the browser and the server.
var Backbone;
if (typeof exports !== 'undefined') {
Backbone = exports;
@@ -37,14 +37,15 @@
}
// Current version of the library. Keep in sync with `package.json`.
- Backbone.VERSION = '0.9.10';
+ Backbone.VERSION = '1.0.0';
// Require Underscore, if we're on the server, and it's not already present.
var _ = root._;
if (!_ && (typeof require !== 'undefined')) _ = require('underscore');
- // For Backbone's purposes, jQuery, Zepto, or Ender owns the `$` variable.
- Backbone.$ = root.jQuery || root.Zepto || root.ender;
+ // For Backbone's purposes, jQuery, Zepto, Ender, or My Library (kidding) owns
+ // the `$` variable.
+ Backbone.$ = root.jQuery || root.Zepto || root.ender || root.$;
// Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable
// to its previous owner. Returns a reference to this Backbone object.
@@ -67,45 +68,6 @@
// Backbone.Events
// ---------------
- // Regular expression used to split event strings.
- var eventSplitter = /\s+/;
-
- // Implement fancy features of the Events API such as multiple event
- // names `"change blur"` and jQuery-style event maps `{change: action}`
- // in terms of the existing API.
- var eventsApi = function(obj, action, name, rest) {
- if (!name) return true;
- if (typeof name === 'object') {
- for (var key in name) {
- obj[action].apply(obj, [key, name[key]].concat(rest));
- }
- } else if (eventSplitter.test(name)) {
- var names = name.split(eventSplitter);
- for (var i = 0, l = names.length; i < l; i++) {
- obj[action].apply(obj, [names[i]].concat(rest));
- }
- } else {
- return true;
- }
- };
-
- // Optimized internal dispatch function for triggering events. Tries to
- // keep the usual cases speedy (most Backbone events have 3 arguments).
- var triggerEvents = function(events, args) {
- var ev, i = -1, l = events.length;
- switch (args.length) {
- case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx);
- return;
- case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, args[0]);
- return;
- case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, args[0], args[1]);
- return;
- case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, args[0], args[1], args[2]);
- return;
- default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args);
- }
- };
-
// A module that can be mixed in to *any object* in order to provide it with
// custom events. You may bind with `on` or remove with `off` callback
// functions to an event; `trigger`-ing an event fires all callbacks in
@@ -118,29 +80,27 @@
//
var Events = Backbone.Events = {
- // Bind one or more space separated events, or an events map,
- // to a `callback` function. Passing `"all"` will bind the callback to
- // all events fired.
+ // Bind an event to a `callback` function. Passing `"all"` will bind
+ // the callback to all events fired.
on: function(name, callback, context) {
- if (!(eventsApi(this, 'on', name, [callback, context]) && callback)) return this;
+ if (!eventsApi(this, 'on', name, [callback, context]) || !callback) return this;
this._events || (this._events = {});
- var list = this._events[name] || (this._events[name] = []);
- list.push({callback: callback, context: context, ctx: context || this});
+ var events = this._events[name] || (this._events[name] = []);
+ events.push({callback: callback, context: context, ctx: context || this});
return this;
},
- // Bind events to only be triggered a single time. After the first time
+ // Bind an event to only be triggered a single time. After the first time
// the callback is invoked, it will be removed.
once: function(name, callback, context) {
- if (!(eventsApi(this, 'once', name, [callback, context]) && callback)) return this;
+ if (!eventsApi(this, 'once', name, [callback, context]) || !callback) return this;
var self = this;
var once = _.once(function() {
self.off(name, once);
callback.apply(this, arguments);
});
once._callback = callback;
- this.on(name, once, context);
- return this;
+ return this.on(name, once, context);
},
// Remove one or many callbacks. If `context` is null, removes all
@@ -148,7 +108,7 @@
// callbacks for the event. If `name` is null, removes all bound
// callbacks for all events.
off: function(name, callback, context) {
- var list, ev, events, names, i, l, j, k;
+ var retain, ev, events, names, i, l, j, k;
if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return this;
if (!name && !callback && !context) {
this._events = {};
@@ -158,19 +118,18 @@
names = name ? [name] : _.keys(this._events);
for (i = 0, l = names.length; i < l; i++) {
name = names[i];
- if (list = this._events[name]) {
- events = [];
+ if (events = this._events[name]) {
+ this._events[name] = retain = [];
if (callback || context) {
- for (j = 0, k = list.length; j < k; j++) {
- ev = list[j];
- if ((callback && callback !== ev.callback &&
- callback !== ev.callback._callback) ||
+ for (j = 0, k = events.length; j < k; j++) {
+ ev = events[j];
+ if ((callback && callback !== ev.callback && callback !== ev.callback._callback) ||
(context && context !== ev.context)) {
- events.push(ev);
+ retain.push(ev);
}
}
}
- this._events[name] = events;
+ if (!retain.length) delete this._events[name];
}
}
@@ -192,35 +151,82 @@
return this;
},
- // An inversion-of-control version of `on`. Tell *this* object to listen to
- // an event in another object ... keeping track of what it's listening to.
- listenTo: function(obj, name, callback) {
- var listeners = this._listeners || (this._listeners = {});
- var id = obj._listenerId || (obj._listenerId = _.uniqueId('l'));
- listeners[id] = obj;
- obj.on(name, typeof name === 'object' ? this : callback, this);
- return this;
- },
-
// Tell this object to stop listening to either specific events ... or
// to every object it's currently listening to.
stopListening: function(obj, name, callback) {
var listeners = this._listeners;
- if (!listeners) return;
- if (obj) {
- obj.off(name, typeof name === 'object' ? this : callback, this);
- if (!name && !callback) delete listeners[obj._listenerId];
- } else {
- if (typeof name === 'object') callback = this;
- for (var id in listeners) {
- listeners[id].off(name, callback, this);
- }
- this._listeners = {};
+ if (!listeners) return this;
+ var deleteListener = !name && !callback;
+ if (typeof name === 'object') callback = this;
+ if (obj) (listeners = {})[obj._listenerId] = obj;
+ for (var id in listeners) {
+ listeners[id].off(name, callback, this);
+ if (deleteListener) delete this._listeners[id];
}
return this;
}
+
+ };
+
+ // Regular expression used to split event strings.
+ var eventSplitter = /\s+/;
+
+ // Implement fancy features of the Events API such as multiple event
+ // names `"change blur"` and jQuery-style event maps `{change: action}`
+ // in terms of the existing API.
+ var eventsApi = function(obj, action, name, rest) {
+ if (!name) return true;
+
+ // Handle event maps.
+ if (typeof name === 'object') {
+ for (var key in name) {
+ obj[action].apply(obj, [key, name[key]].concat(rest));
+ }
+ return false;
+ }
+
+ // Handle space separated event names.
+ if (eventSplitter.test(name)) {
+ var names = name.split(eventSplitter);
+ for (var i = 0, l = names.length; i < l; i++) {
+ obj[action].apply(obj, [names[i]].concat(rest));
+ }
+ return false;
+ }
+
+ return true;
+ };
+
+ // A difficult-to-believe, but optimized internal dispatch function for
+ // triggering events. Tries to keep the usual cases speedy (most internal
+ // Backbone events have 3 arguments).
+ var triggerEvents = function(events, args) {
+ var ev, i = -1, l = events.length, a1 = args[0], a2 = args[1], a3 = args[2];
+ switch (args.length) {
+ case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx); return;
+ case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1); return;
+ case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2); return;
+ case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2, a3); return;
+ default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args);
+ }
};
+ var listenMethods = {listenTo: 'on', listenToOnce: 'once'};
+
+ // Inversion-of-control versions of `on` and `once`. Tell *this* object to
+ // listen to an event in another object ... keeping track of what it's
+ // listening to.
+ _.each(listenMethods, function(implementation, method) {
+ Events[method] = function(obj, name, callback) {
+ var listeners = this._listeners || (this._listeners = {});
+ var id = obj._listenerId || (obj._listenerId = _.uniqueId('l'));
+ listeners[id] = obj;
+ if (typeof name === 'object') callback = this;
+ obj[implementation](name, callback, this);
+ return this;
+ };
+ });
+
// Aliases for backwards compatibility.
Events.bind = Events.on;
Events.unbind = Events.off;
@@ -232,15 +238,21 @@
// Backbone.Model
// --------------
- // Create a new model, with defined attributes. A client id (`cid`)
+ // Backbone **Models** are the basic data object in the framework --
+ // frequently representing a row in a table in a database on your server.
+ // A discrete chunk of data and a bunch of useful, related methods for
+ // performing computations and transformations on that data.
+
+ // Create a new model with the specified attributes. A client id (`cid`)
// is automatically generated and assigned for you.
var Model = Backbone.Model = function(attributes, options) {
var defaults;
var attrs = attributes || {};
+ options || (options = {});
this.cid = _.uniqueId('c');
this.attributes = {};
- if (options && options.collection) this.collection = options.collection;
- if (options && options.parse) attrs = this.parse(attrs, options) || {};
+ _.extend(this, _.pick(options, modelOptions));
+ if (options.parse) attrs = this.parse(attrs, options) || {};
if (defaults = _.result(this, 'defaults')) {
attrs = _.defaults({}, attrs, defaults);
}
@@ -249,12 +261,18 @@
this.initialize.apply(this, arguments);
};
+ // A list of options to be attached directly to the model, if provided.
+ var modelOptions = ['url', 'urlRoot', 'collection'];
+
// Attach all inheritable methods to the Model prototype.
_.extend(Model.prototype, Events, {
// A hash of attributes whose current and previous value differ.
changed: null,
+ // The value returned during the last failed validation.
+ validationError: null,
+
// The default name for the JSON `id` attribute is `"id"`. MongoDB and
// CouchDB users may want to set this to `"_id"`.
idAttribute: 'id',
@@ -268,7 +286,8 @@
return _.clone(this.attributes);
},
- // Proxy `Backbone.sync` by default.
+ // Proxy `Backbone.sync` by default -- but override this if you need
+ // custom syncing semantics for *this* particular model.
sync: function() {
return Backbone.sync.apply(this, arguments);
},
@@ -289,10 +308,9 @@
return this.get(attr) != null;
},
- // ----------------------------------------------------------------------
-
- // Set a hash of model attributes on the object, firing `"change"` unless
- // you choose to silence it.
+ // Set a hash of model attributes on the object, firing `"change"`. This is
+ // the core primitive operation of a model, updating the data and notifying
+ // anyone who needs to know about the change in state. The heart of the beast.
set: function(key, val, options) {
var attr, attrs, unset, changes, silent, changing, prev, current;
if (key == null) return this;
@@ -346,6 +364,8 @@
}
}
+ // You might be wondering why there's a `while` loop here. Changes can
+ // be recursively nested within `"change"` events.
if (changing) return this;
if (!silent) {
while (this._pending) {
@@ -358,14 +378,13 @@
return this;
},
- // Remove an attribute from the model, firing `"change"` unless you choose
- // to silence it. `unset` is a noop if the attribute doesn't exist.
+ // Remove an attribute from the model, firing `"change"`. `unset` is a noop
+ // if the attribute doesn't exist.
unset: function(attr, options) {
return this.set(attr, void 0, _.extend({}, options, {unset: true}));
},
- // Clear all attributes on the model, firing `"change"` unless you choose
- // to silence it.
+ // Clear all attributes on the model, firing `"change"`.
clear: function(options) {
var attrs = {};
for (var key in this.attributes) attrs[key] = void 0;
@@ -409,19 +428,20 @@
return _.clone(this._previousAttributes);
},
- // ---------------------------------------------------------------------
-
// Fetch the model from the server. If the server's representation of the
- // model differs from its current attributes, they will be overriden,
+ // model differs from its current attributes, they will be overridden,
// triggering a `"change"` event.
fetch: function(options) {
options = options ? _.clone(options) : {};
if (options.parse === void 0) options.parse = true;
+ var model = this;
var success = options.success;
- options.success = function(model, resp, options) {
+ options.success = function(resp) {
if (!model.set(model.parse(resp, options), options)) return false;
if (success) success(model, resp, options);
+ model.trigger('sync', model, resp, options);
};
+ wrapError(this, options);
return this.sync('read', this, options);
},
@@ -429,7 +449,7 @@
// If the server returns an attributes hash that differs, the model's
// state will be `set` again.
save: function(key, val, options) {
- var attrs, success, method, xhr, attributes = this.attributes;
+ var attrs, method, xhr, attributes = this.attributes;
// Handle both `"key", value` and `{key: value}` -style arguments.
if (key == null || typeof key === 'object') {
@@ -455,8 +475,9 @@
// After a successful server-side save, the client is (optionally)
// updated with the server-side state.
if (options.parse === void 0) options.parse = true;
- success = options.success;
- options.success = function(model, resp, options) {
+ var model = this;
+ var success = options.success;
+ options.success = function(resp) {
// Ensure attributes are restored during synchronous saves.
model.attributes = attributes;
var serverAttrs = model.parse(resp, options);
@@ -465,9 +486,10 @@
return false;
}
if (success) success(model, resp, options);
+ model.trigger('sync', model, resp, options);
};
+ wrapError(this, options);
- // Finish configuring and sending the Ajax request.
method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update');
if (method === 'patch') options.attrs = attrs;
xhr = this.sync(method, this, options);
@@ -490,15 +512,17 @@
model.trigger('destroy', model, model.collection, options);
};
- options.success = function(model, resp, options) {
+ options.success = function(resp) {
if (options.wait || model.isNew()) destroy();
if (success) success(model, resp, options);
+ if (!model.isNew()) model.trigger('sync', model, resp, options);
};
if (this.isNew()) {
- options.success(this, null, options);
+ options.success();
return false;
}
+ wrapError(this, options);
var xhr = this.sync('delete', this, options);
if (!options.wait) destroy();
@@ -532,39 +556,61 @@
// Check if the model is currently in a valid state.
isValid: function(options) {
- return !this.validate || !this.validate(this.attributes, options);
+ return this._validate({}, _.extend(options || {}, { validate: true }));
},
// Run validation against the next complete set of model attributes,
- // returning `true` if all is well. Otherwise, fire a general
- // `"error"` event and call the error callback, if specified.
+ // returning `true` if all is well. Otherwise, fire an `"invalid"` event.
_validate: function(attrs, options) {
if (!options.validate || !this.validate) return true;
attrs = _.extend({}, this.attributes, attrs);
var error = this.validationError = this.validate(attrs, options) || null;
if (!error) return true;
- this.trigger('invalid', this, error, options || {});
+ this.trigger('invalid', this, error, _.extend(options || {}, {validationError: error}));
return false;
}
});
+ // Underscore methods that we want to implement on the Model.
+ var modelMethods = ['keys', 'values', 'pairs', 'invert', 'pick', 'omit'];
+
+ // Mix in each Underscore method as a proxy to `Model#attributes`.
+ _.each(modelMethods, function(method) {
+ Model.prototype[method] = function() {
+ var args = slice.call(arguments);
+ args.unshift(this.attributes);
+ return _[method].apply(_, args);
+ };
+ });
+
// Backbone.Collection
// -------------------
- // Provides a standard collection class for our sets of models, ordered
- // or unordered. If a `comparator` is specified, the Collection will maintain
+ // If models tend to represent a single row of data, a Backbone Collection is
+ // more analagous to a table full of data ... or a small slice or page of that
+ // table, or a collection of rows that belong together for a particular reason
+ // -- all of the messages in this particular folder, all of the documents
+ // belonging to this particular author, and so on. Collections maintain
+ // indexes of their models, both in order, and for lookup by `id`.
+
+ // Create a new **Collection**, perhaps to contain a specific type of `model`.
+ // If a `comparator` is specified, the Collection will maintain
// its models in sort order, as they're added and removed.
var Collection = Backbone.Collection = function(models, options) {
options || (options = {});
+ if (options.url) this.url = options.url;
if (options.model) this.model = options.model;
if (options.comparator !== void 0) this.comparator = options.comparator;
- this.models = [];
this._reset();
this.initialize.apply(this, arguments);
if (models) this.reset(models, _.extend({silent: true}, options));
};
+ // Default options for `Collection#set`.
+ var setOptions = {add: true, remove: true, merge: true};
+ var addOptions = {add: true, merge: false, remove: false};
+
// Define the Collection's inheritable methods.
_.extend(Collection.prototype, Events, {
@@ -589,88 +635,118 @@
// Add a model, or list of models to the set.
add: function(models, options) {
+ return this.set(models, _.defaults(options || {}, addOptions));
+ },
+
+ // Remove a model, or a list of models from the set.
+ remove: function(models, options) {
models = _.isArray(models) ? models.slice() : [models];
options || (options = {});
- var i, l, model, attrs, existing, doSort, add, at, sort, sortAttr;
- add = [];
- at = options.at;
- sort = this.comparator && (at == null) && options.sort != false;
- sortAttr = _.isString(this.comparator) ? this.comparator : null;
+ var i, l, index, model;
+ for (i = 0, l = models.length; i < l; i++) {
+ model = this.get(models[i]);
+ if (!model) continue;
+ delete this._byId[model.id];
+ delete this._byId[model.cid];
+ index = this.indexOf(model);
+ this.models.splice(index, 1);
+ this.length--;
+ if (!options.silent) {
+ options.index = index;
+ model.trigger('remove', model, this, options);
+ }
+ this._removeReference(model);
+ }
+ return this;
+ },
+
+ // Update a collection by `set`-ing a new list of models, adding new ones,
+ // removing models that are no longer present, and merging models that
+ // already exist in the collection, as necessary. Similar to **Model#set**,
+ // the core operation for updating the data contained by the collection.
+ set: function(models, options) {
+ options = _.defaults(options || {}, setOptions);
+ if (options.parse) models = this.parse(models, options);
+ if (!_.isArray(models)) models = models ? [models] : [];
+ var i, l, model, attrs, existing, sort;
+ var at = options.at;
+ var sortable = this.comparator && (at == null) && options.sort !== false;
+ var sortAttr = _.isString(this.comparator) ? this.comparator : null;
+ var toAdd = [], toRemove = [], modelMap = {};
// Turn bare objects into model references, and prevent invalid models
// from being added.
for (i = 0, l = models.length; i < l; i++) {
- if (!(model = this._prepareModel(attrs = models[i], options))) {
- this.trigger('invalid', this, attrs, options);
- continue;
- }
+ if (!(model = this._prepareModel(models[i], options))) continue;
// If a duplicate is found, prevent it from being added and
// optionally merge it into the existing model.
if (existing = this.get(model)) {
+ if (options.remove) modelMap[existing.cid] = true;
if (options.merge) {
- existing.set(attrs === model ? model.attributes : attrs, options);
- if (sort && !doSort && existing.hasChanged(sortAttr)) doSort = true;
+ existing.set(model.attributes, options);
+ if (sortable && !sort && existing.hasChanged(sortAttr)) sort = true;
}
- continue;
- }
- // This is a new model, push it to the `add` list.
- add.push(model);
+ // This is a new model, push it to the `toAdd` list.
+ } else if (options.add) {
+ toAdd.push(model);
- // Listen to added models' events, and index models for lookup by
- // `id` and by `cid`.
- model.on('all', this._onModelEvent, this);
- this._byId[model.cid] = model;
- if (model.id != null) this._byId[model.id] = model;
+ // Listen to added models' events, and index models for lookup by
+ // `id` and by `cid`.
+ model.on('all', this._onModelEvent, this);
+ this._byId[model.cid] = model;
+ if (model.id != null) this._byId[model.id] = model;
+ }
+ }
+
+ // Remove nonexistent models if appropriate.
+ if (options.remove) {
+ for (i = 0, l = this.length; i < l; ++i) {
+ if (!modelMap[(model = this.models[i]).cid]) toRemove.push(model);
+ }
+ if (toRemove.length) this.remove(toRemove, options);
}
// See if sorting is needed, update `length` and splice in new models.
- if (add.length) {
- if (sort) doSort = true;
- this.length += add.length;
+ if (toAdd.length) {
+ if (sortable) sort = true;
+ this.length += toAdd.length;
if (at != null) {
- splice.apply(this.models, [at, 0].concat(add));
+ splice.apply(this.models, [at, 0].concat(toAdd));
} else {
- push.apply(this.models, add);
+ push.apply(this.models, toAdd);
}
}
// Silently sort the collection if appropriate.
- if (doSort) this.sort({silent: true});
+ if (sort) this.sort({silent: true});
if (options.silent) return this;
// Trigger `add` events.
- for (i = 0, l = add.length; i < l; i++) {
- (model = add[i]).trigger('add', model, this, options);
+ for (i = 0, l = toAdd.length; i < l; i++) {
+ (model = toAdd[i]).trigger('add', model, this, options);
}
// Trigger `sort` if the collection was sorted.
- if (doSort) this.trigger('sort', this, options);
-
+ if (sort) this.trigger('sort', this, options);
return this;
},
- // Remove a model, or a list of models from the set.
- remove: function(models, options) {
- models = _.isArray(models) ? models.slice() : [models];
+ // When you have more items than you want to add or remove individually,
+ // you can reset the entire set with a new list of models, without firing
+ // any granular `add` or `remove` events. Fires `reset` when finished.
+ // Useful for bulk operations and optimizations.
+ reset: function(models, options) {
options || (options = {});
- var i, l, index, model;
- for (i = 0, l = models.length; i < l; i++) {
- model = this.get(models[i]);
- if (!model) continue;
- delete this._byId[model.id];