Latest job detail page changes.

This commit is contained in:
Chris Houseknecht 2014-04-22 00:15:25 -04:00
parent be3996b9be
commit 0b0207e20e
7 changed files with 279 additions and 113 deletions

View File

@ -8,77 +8,162 @@
'use strict';
function JobDetailController ($scope, $compile, $routeParams, ClearScope, Breadcrumbs, LoadBreadCrumbs, GetBasePath, Wait, Rest, ProcessErrors, DigestEvents,
SelectPlay, SelectTask) {
SelectPlay, SelectTask, Socket, GetElapsed) {
ClearScope();
var job_id = $routeParams.id,
job;
event_socket, job,
event_queue = [],
processed_events = [],
scope = $scope,
api_complete = false;
scope.plays = [];
scope.tasks = [];
scope.hosts = [];
scope.hostResults = [];
scope.job_status = {};
scope.job_id = job_id;
event_socket = Socket({
scope: scope,
endpoint: "job_events"
});
event_socket.init();
/*LoadBreadCrumbs();
e = angular.element(document.getElementById('breadcrumbs'));
e.html(Breadcrumbs({ list: { editTitle: 'Jobs' } , mode: 'edit' }));
$compile(e)($scope);
*/
$scope.plays = [];
$scope.tasks = [];
$scope.hosts = [];
$scope.hostResults = [];
$scope.job_status = {};
$scope.job_id = job_id;
// Apply each event to the view
if ($scope.removeEventsReady) {
$scope.removeEventsReady();
// Evaluate elements of an array, returning the set of elements that
// match a condition as expressed in a function
//
// matches = myarray.find(function(x) { return x.id === 5 });
//
Array.prototype.find = function(parameterFunction) {
var results = [];
this.forEach(function(row) {
if (parameterFunction(row)) {
results.push(row);
}
});
return results;
}
$scope.removeEventsReady = $scope.$on('EventsReady', function(e, events) {
// Reduce an array of objects down to just the bits we want from each object by
// passing in a function that returns just those parts.
//
// new_array = myarray.reduce(function(x) { return { blah: x.blah, foo: x.foo } });
//
Array.prototype.reduce = function(parameterFunction) {
var results= [];
this.forEach(function(row) {
results.push(parameterFunction(row));
});
return results;
}
// Apply each event to the view
if (scope.removeEventsReady) {
scope.removeEventsReady();
}
scope.removeEventsReady = scope.$on('EventsReady', function(e, events) {
console.log('Inside EventsReady!');
console.log(events);
DigestEvents({
scope: $scope,
scope: scope,
events: events
});
});
// Get events, page size 50
if ($scope.removeJobReady) {
$scope.removeJobReady();
}
$scope.removeJobReady = $scope.$on('JobReady', function(e, next) {
if (next) {
Rest.setUrl(next);
Rest.get()
.success(function(data) {
$scope.$emit('EventsReady', data.results);
if (data.next) {
$scope.$emit('JobReady', data.next);
}
else {
Wait('stop');
}
})
.error(function(data, status) {
ProcessErrors($scope, data, status, null, { hdr: 'Error!',
msg: 'Failed to retrieve job events: ' + next + ' GET returned: ' + status });
});
event_socket.on("job_events-" + job_id, function(data) {
var matches;
data.id = data.event_id;
console.log(data);
if (api_complete) {
matches = processed_events.find(function(x) { return x === data.id });
if (matches.length === 0) {
// event not processed
console.log('process event: ' + data.id);
scope.$emit('EventsReady', [ data ]);
}
}
else {
console.log('queue event: ' + data.id);
event_queue.push(data);
}
});
if ($scope.removeGetCredentialNames) {
$scope.removeGetCredentialNames();
//
if (scope.removeAPIComplete) {
scope.removeAPIComplete();
}
$scope.removeGetCredentialNames = $scope.$on('GetCredentialNames', function(e, data) {
scope.removeAPIComplete = scope.$on('APIComplete', function() {
var events;
if (event_queue.length > 0) {
// Events arrived while we were processing API results
events = event_queue.find(function(event) {
var matched = false;
processed_events.every(function(event_id) {
if (event_id === event.id) {
matched = true;
return false;
}
return true;
});
return (!matched); //return true when event.id not in the list of processed_events
});
console.log('processing queued events: ');
console.log(events.reduce(function(x) { return x.id }));
if (events.length > 0) {
scope.$emit('EventsReady', events);
api_complete = true;
}
}
else {
api_complete = true;
}
});
// Get events, 50 at a time. When done, emit APIComplete
if (scope.removeJobReady) {
scope.removeJobReady();
}
scope.removeJobReady = scope.$on('JobReady', function(e, next) {
Rest.setUrl(next);
Rest.get()
.success(function(data) {
processed_events = processed_events.concat( data.results.reduce(function(x) { return x.id }) );
scope.$emit('EventsReady', data.results);
if (data.next) {
scope.$emit('JobReady', data.next);
}
else {
Wait('stop');
scope.$emit('APIComplete');
}
})
.error(function(data, status) {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Failed to retrieve job events: ' + next + ' GET returned: ' + status });
});
});
if (scope.removeGetCredentialNames) {
scope.removeGetCredentialNames();
}
scope.removeGetCredentialNames = scope.$on('GetCredentialNames', function(e, data) {
var url;
if (data.credential) {
url = GetBasePath('credentials') + data.credential + '/';
Rest.setUrl(url);
Rest.get()
.success( function(data) {
$scope.credential_name = data.name;
scope.credential_name = data.name;
})
.error( function(data, status) {
$scope.credential_name = '';
ProcessErrors($scope, data, status, null, { hdr: 'Error!',
scope.credential_name = '';
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + '. GET returned: ' + status });
});
}
@ -87,11 +172,11 @@ function JobDetailController ($scope, $compile, $routeParams, ClearScope, Breadc
Rest.setUrl(url);
Rest.get()
.success( function(data) {
$scope.cloud_credential_name = data.name;
scope.cloud_credential_name = data.name;
})
.error( function(data, status) {
$scope.credential_name = '';
ProcessErrors($scope, data, status, null, { hdr: 'Error!',
scope.credential_name = '';
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + '. GET returned: ' + status });
});
}
@ -104,42 +189,56 @@ function JobDetailController ($scope, $compile, $routeParams, ClearScope, Breadc
Rest.get()
.success(function(data) {
job = data;
$scope.job_template_name = data.name;
$scope.project_name = (data.summary_fields.project) ? data.summary_fields.project.name : '';
$scope.inventory_name = (data.summary_fields.inventory) ? data.summary_fields.inventory.name : '';
$scope.job_template_url = '/#/job_templates/' + data.unified_job_template;
$scope.inventory_url = ($scope.inventory_name && data.inventory) ? '/#/inventories/' + data.inventory : '';
$scope.project_url = ($scope.project_name && data.project) ? '/#/projects/' + data.project : '';
$scope.job_type = data.job_type;
$scope.playbook = data.playbook;
$scope.credential = data.credential;
$scope.cloud_credential = data.cloud_credential;
$scope.forks = data.forks;
$scope.limit = data.limit;
$scope.verbosity = data.verbosity;
$scope.job_tags = data.job_tags;
//$scope.started = data.started;
//$scope.finished = data.finished;
//$scope.elapsed = data.elapsed;
//$scope.job_status = data.status;
$scope.$emit('JobReady', data.related.job_events + '?page_size=50&order_by=id');
$scope.$emit('GetCredentialNames', data);
scope.job_template_name = data.name;
scope.project_name = (data.summary_fields.project) ? data.summary_fields.project.name : '';
scope.inventory_name = (data.summary_fields.inventory) ? data.summary_fields.inventory.name : '';
scope.job_template_url = '/#/job_templates/' + data.unified_job_template;
scope.inventory_url = (scope.inventory_name && data.inventory) ? '/#/inventories/' + data.inventory : '';
scope.project_url = (scope.project_name && data.project) ? '/#/projects/' + data.project : '';
scope.job_type = data.job_type;
scope.playbook = data.playbook;
scope.credential = data.credential;
scope.cloud_credential = data.cloud_credential;
scope.forks = data.forks;
scope.limit = data.limit;
scope.verbosity = data.verbosity;
scope.job_tags = data.job_tags;
// In the case that the job is already completed, or an error already happened,
// populate scope.job_status info
scope.job_status.status = data.status;
scope.job_status.started = data.started;
scope.job_status.status_class = ((data.status === 'error' || data.status === 'failed') && data.job_explanation) ? "alert alert-danger" : "";
scope.job_status.finished = data.finished;
scope.job_status.explanation = data.job_explanation;
if (data.started && data.finished) {
scope.job_status.elapsed = GetElapsed({
start: data.started,
end: data.finished
});
}
else {
scope.job_status.elapsed = '00:00:00';
}
scope.$emit('JobReady', data.related.job_events + '?page_size=50&order_by=id');
scope.$emit('GetCredentialNames', data);
})
.error(function(data, status) {
ProcessErrors($scope, data, status, null, { hdr: 'Error!',
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Failed to retrieve job: ' + $routeParams.id + '. GET returned: ' + status });
});
$scope.selectPlay = function(id) {
scope.selectPlay = function(id) {
SelectPlay({
scope: $scope,
scope: scope,
id: id
});
};
$scope.selectTask = function(id) {
scope.selectTask = function(id) {
SelectTask({
scope: $scope,
scope: scope,
id: id
});
};
@ -157,5 +256,5 @@ function JobDetailController ($scope, $compile, $routeParams, ClearScope, Breadc
}
JobDetailController.$inject = [ '$scope', '$compile', '$routeParams', 'ClearScope', 'Breadcrumbs', 'LoadBreadCrumbs', 'GetBasePath', 'Wait',
'Rest', 'ProcessErrors', 'DigestEvents', 'SelectPlay', 'SelectTask'
'Rest', 'ProcessErrors', 'DigestEvents', 'SelectPlay', 'SelectTask', 'Socket', 'GetElapsed'
];

View File

@ -40,19 +40,22 @@
angular.module('JobDetailHelper', ['Utilities', 'RestServices'])
.factory('DigestEvents', ['UpdatePlayStatus', 'UpdatePlayNoHostsMatched', 'UpdateHostStatus', 'UpdatePlayChild', 'AddHostResult', 'SelectPlay', 'SelectTask',
'GetHostCount', 'GetElapsed',
function(UpdatePlayStatus, UpdatePlayNoHostsMatched, UpdateHostStatus, UpdatePlayChild, AddHostResult, SelectPlay, SelectTask, GetHostCount, GetElapsed) {
'GetHostCount', 'GetElapsed', 'UpdateJobStatus',
function(UpdatePlayStatus, UpdatePlayNoHostsMatched, UpdateHostStatus, UpdatePlayChild, AddHostResult, SelectPlay, SelectTask, GetHostCount, GetElapsed,
UpdateJobStatus) {
return function(params) {
var scope = params.scope,
events = params.events;
events.forEach(function(event) {
var hostCount;
if (event.event === 'playbook_on_start') {
scope.job_status.started = event.created;
scope.job_status.status = 'running';
if (scope.job_status.status!== 'failed' && scope.job_status.status !== 'canceled' &&
scope.job_status.status !== 'error') {
scope.job_status.started = event.created;
scope.job_status.status = 'running';
}
}
if (event.event === 'playbook_on_play_start') {
@ -60,7 +63,7 @@ function(UpdatePlayStatus, UpdatePlayNoHostsMatched, UpdateHostStatus, UpdatePla
id: event.id,
name: event.play,
created: event.created,
status: (event.changed) ? 'changed' : (event.failed) ? 'failed' : 'none',
status: (event.failed) ? 'failed' : (event.changed) ? 'changed' : 'none',
children: []
});
SelectPlay({
@ -77,15 +80,19 @@ function(UpdatePlayStatus, UpdatePlayNoHostsMatched, UpdateHostStatus, UpdatePla
id: event.id,
name: event.event_display,
play_id: event.parent,
status: (event.failed) ? 'failed' : 'successful',
status: ( (event.failed) ? 'failed' : (event.changed) ? 'changed' : 'successful' ),
created: event.created,
modified: event.modified,
hostCount: hostCount,
reportedHosts: 0,
successfulCount: 0,
failedCount: 0,
changedCount: 0,
successfulCount: 0,
skippedCount: 0,
reportedHosts: 0
successfulStyle: { display: 'none'},
failedStyle: { display: 'none' },
changedStyle: { display: 'none' },
skippedStyle: { display: 'none' }
});
UpdatePlayStatus({
scope: scope,
@ -108,16 +115,20 @@ function(UpdatePlayStatus, UpdatePlayNoHostsMatched, UpdateHostStatus, UpdatePla
id: event.id,
name: event.task,
play_id: event.parent,
status: ( (event.changed) ? 'changed' : (event.failed) ? 'failed' : 'successful' ),
status: ( (event.failed) ? 'failed' : (event.changed) ? 'changed' : 'successful' ),
role: event.role,
created: event.created,
modified: event.modified,
hostCount: hostCount,
reportedHosts: 0,
successfulCount: 0,
failedCount: 0,
changedCount: 0,
successfulCount: 0,
skippedCount: 0,
reportedHosts: 0
successfulStyle: { display: 'none'},
failedStyle: { display: 'none' },
changedStyle: { display: 'none' },
skippedStyle: { display: 'none' }
});
if (event.role) {
scope.hasRoles = true;
@ -181,7 +192,7 @@ function(UpdatePlayStatus, UpdatePlayNoHostsMatched, UpdateHostStatus, UpdatePla
name: event.event_data.host,
host_id: event.host,
task_id: event.parent,
status: ( (event.changed) ? 'changed' : (event.failed) ? 'failed' : 'successful' ),
status: ( (event.failed) ? 'failed' : (event.changed) ? 'changed' : 'successful' ),
event_id: event.id,
created: event.created,
modified: event.modified
@ -194,6 +205,7 @@ function(UpdatePlayStatus, UpdatePlayNoHostsMatched, UpdateHostStatus, UpdatePla
end: scope.job_status.finished
});
scope.job_status.status = (event.failed) ? 'error' : 'successful';
scope.job_status.status_class = "";
}
});
};
@ -322,8 +334,37 @@ function(UpdatePlayStatus, UpdatePlayNoHostsMatched, UpdateHostStatus, UpdatePla
};
}])
.factory('UpdateJobStatus', ['GetElapsed', 'Empty', function(GetElapsed, Empty) {
return function(params) {
var scope = params.scope,
failed = params.failed,
modified = params.modified;
started = params.started;
if (failed && scope.job_status.status !== 'failed' && scope.job_status.status !== 'error'
&& scope.job_status.status !== 'canceled') {
scope.job_status.status = 'error';
}
if (!Empty(modified)) {
scope.job_status.finished = modified;
}
if (!Empty(started) && Empty(scope.job_status.started)) {
scope.job_status.started = started;
}
if (!Empty(scope.job_status.finished) && !Empty(scope.job_status.started)) {
console.log('scope.job_status.started: ' + scope.job_status.started);
console.log('scope.job_status.finished: ' + scope.job_status.finished);
scope.job_status.elapsed = GetElapsed({
start: scope.job_status.started,
end: scope.job_status.finished
});
console.log('elapsed: ' + scope.job_status.elapsed);
}
};
}])
// Update the status of a play
.factory('UpdatePlayStatus', ['GetElapsed', function(GetElapsed) {
.factory('UpdatePlayStatus', ['GetElapsed', 'UpdateJobStatus', function(GetElapsed, UpdateJobStatus) {
return function(params) {
var scope = params.scope,
failed = params.failed,
@ -332,7 +373,10 @@ function(UpdatePlayStatus, UpdatePlayNoHostsMatched, UpdateHostStatus, UpdatePla
modified = params.modified;
scope.plays.every(function(play,idx) {
if (play.id === id) {
if (play.status !== 'changed' && play.status !== 'failed') {
if (failed) {
scope.plays[idx].status = 'failed';
}
else if (play.status !== 'changed' && play.status !== 'failed') {
// once the status becomes 'changed' or 'failed' don't modify it
scope.plays[idx].status = (changed) ? 'changed' : (failed) ? 'failed' : 'successful';
}
@ -341,6 +385,11 @@ function(UpdatePlayStatus, UpdatePlayNoHostsMatched, UpdateHostStatus, UpdatePla
start: play.created,
end: modified
});
/*UpdateJobStatus({
scope: scope,
failed: failed,
modified: modified
});*/
return false;
}
return true;
@ -357,9 +406,12 @@ function(UpdatePlayStatus, UpdatePlayNoHostsMatched, UpdateHostStatus, UpdatePla
modified = params.modified;
scope.tasks.every(function (task, i) {
if (task.id === id) {
if (task.status !== 'changed' && task.status !== 'failed') {
if (failed) {
scope.tasks[i].status = 'failed';
}
else if (task.status !== 'changed' && task.status !== 'failed') {
// once the status becomes 'changed' or 'failed' don't modify it
scope.tasks[i].status = (changed) ? 'changed' : (failed) ? 'failed' : 'successful';
scope.tasks[i].status = (failed) ? 'failed' : (changed) ? 'changed' : 'successful';
}
scope.tasks[i].finished = params.modified;
scope.tasks[i].elapsed = GetElapsed({

View File

@ -26,7 +26,7 @@ angular.module('RefreshHelper', ['RestServices', 'Utilities', 'PaginationHelpers
iterator = params.iterator,
url = params.url;
scope[iterator + "HidePaginator"] = true;
//scope[iterator + "HidePaginator"] = true;
//scope[iterator + 'Loading'] = true;
scope.current_url = url;

View File

@ -950,6 +950,13 @@ input[type="checkbox"].checkbox-no-label {
border-top: none;
}
/* Less padding on .table-condensed */
.table-condensed>tbody>tr>td,
.table-condensed>thead>tr>th {
padding-top: 3px;
padding-bottom: 3px;
}
/* Table info rows */
.loading-info {

View File

@ -28,12 +28,15 @@
}
li {
display: inline-block;
margin-right: 15px;
margin-right: 20px;
}
i {
font-size: 12px;
}
.label {
display: inline-block;
text-align: left;
width: 50px;
font-size: 12px;
color: @black;
padding-left: 0;
@ -177,6 +180,8 @@
padding: 5px;
height: 150px;
background-color: @white;
overflow-y: hide;
overflow-x: auto;
}
#host-details {

View File

@ -5,9 +5,8 @@
<div class="col-md-12">
<div class="nav-path">
<ul class="breadcrumb" id="breadcrumb-list">
<li><strong>{{ job_id }}</strong> - <a href="{{ job_template_url }}">{{ job_template_name }}</a></li>
<li><a href="{{ project_url }}">{{ project_name }}</a></li>
<li><a href="{{ inventory_url }}">{{ inventory_name }}</a></li>
<li><a href="/#/jobs">Jobs</a></li>
<li><strong>{{ job_id }}</strong> - <a href="{{ job_template_url }}">{{ job_template_name }}</a> - <a href="{{ project_url }}">{{ project_name }}</a> - <a href="{{ inventory_url }}">{{ inventory_name }}</a></li>
</ul>
</div>
</div>
@ -21,11 +20,14 @@
<div id="job-status">
<ul>
<li><span class="label">Status</span> <i class="fa icon-job-{{ job_status.status }}"></i> {{ job_status.status }}</li>
<li><span class="label">Start</span> {{ job_status.started | date:'MM/dd/yy HH:mm:ss' }}</li>
<li><span class="label">Finish</span> {{ job_status.finished | date:'MM/dd/yy HH:mm:ss' }}<li>
<li><span class="label">Elapsed</span> {{ job_status.elapsed }}</li>
<li ng-show="job_status.explanation" style="display: block; margin-bottom: 10px;"><div ng-class="job_status.status_class"><i class="fa icon-job-{{ job_status.status }}"></i> {{ job_status.status }} &nbsp;-{{ job_status.explanation }}</div></li>
<li ng-show="!job_status.explanation"><div class="label">Status</div> <i class="fa icon-job-{{ job_status.status }}"></i> {{ job_status.status }}</li>
<li ng-show="job_status.started"><div class="label">Start</div> {{ job_status.started | date:'MM/dd/yy HH:mm:ss' }}</li>
<li ng-show="!job_status.started"><div class="label">Start</div> Not Started</li>
<li ng-show="job_status.finished"><div class="label">Finish</div> {{ job_status.finished | date:'MM/dd/yy HH:mm:ss' }}<li>
<li><div class="label">Elapsed</div> {{ job_status.elapsed }}</li>
</ul>
</div>
<div class="job-detail-tables">
@ -34,17 +36,17 @@
<table class="table job-detail-table">
<thead>
<tr>
<th class="col-lg-1 col-md-1 col-sm-1 hidden-xs">Started</th>
<th class="col-lg-1 col-md-1 col-sm-2 hidden-xs">Started</th>
<th class="col-lg-10 col-md-10 col-sm-10 col-xs-12">Name</th>
<th class="col-lg-1 col-md-1 col-sm-1 hidden-xs">Elapsed</th>
<th class="col-lg-1 col-md-1 hidden-sm hidden-xs">Elapsed</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="play in plays" ng-class="play.playActiveClass" ng-click="selectPlay(play.id)" class="cursor-pointer">
<td class="col-lg-1 col-md-1 col-sm-1 hidden-xs">{{ play.created | date: 'HH:mm:ss' }}</td>
<td class="col-lg-1 col-md-1 col-sm-2 hidden-xs">{{ play.created | date: 'HH:mm:ss' }}</td>
<td class="col-lg-10 col-md-9 col-sm-10 col-xs-12 status-column">
<i class="fa icon-job-{{ play.status }}"></i> {{ play.name }}</span></td>
<td class="col-lg-1 col-md-1 col-sm-1 hidden-xs" aw-tool-tip="Completed at {{ play.finished | date:'HH:mm:ss' }}"
<td class="col-lg-1 col-md-1 col-hidden-sm hidden-xs" aw-tool-tip="Completed at {{ play.finished | date:'HH:mm:ss' }}"
data-placement="top">{{ play.elapsed }}</td>
</tr>
</tbody>
@ -56,15 +58,15 @@
<table class="table job-detail-table">
<thead>
<tr>
<th class="col-lg-1 col-md-1 col-sm-1 hidden-xs">Started</th>
<th class="col-lg-1 col-md-1 col-sm-2 hidden-xs">Started</th>
<th class="col-lg-6 col-md-6 col-sm-6 col-xs-7">Name</th>
<th class="col-lg-4 col-md-4 col-sm-4 col-xs-5">Host Status</th>
<th class="col-lg-1 col-md-1 col-sm-1 hidden-xs">Elapsed</th>
<th class="col-lg-1 col-md-1 hidden-sm hidden-xs">Elapsed</th>
</tr>
</thead>
<tbody id="task-table-body">
<tr ng-repeat="task in tasks | filter:{ play_id: activePlay }" ng-class="task.taskActiveClass" ng-click="selectTask(task.id)" class="cursor-pointer">
<td class="col-lg-1 col-md-1 col-sm-1 hidden-xs">{{ task.created | date: 'HH:mm:ss' }}</td>
<td class="col-lg-1 col-md-1 col-sm-2 hidden-xs">{{ task.created | date: 'HH:mm:ss' }}</td>
<td class="col-lg-6 col-md-6 col-sm-6 col-xs-7 status-column">
<i class="fa icon-job-{{ task.status }}"></i><span ng-show="hasRoles"> {{ task.role }} </span>{{ task.name }}
</td>
@ -76,7 +78,7 @@
<div class="failed-hosts inner-bar" aw-tool-tip="{{ task.failedCount}} hosts failed" aw-tip-watch="task.failedCount" data-placement="top" ng-style="{{ task.failedStyle }}">{{ task.failedCount }}</div>
</div>
</td>
<td class="col-lg-1 col-md-1 col-sm-1 hidden-xs" aw-tool-tip="Completed at {{ task.finished | date:'HH:mm:ss' }}"
<td class="col-lg-1 col-md-1 hidden-sm hidden-xs" aw-tool-tip="Completed at {{ task.finished | date:'HH:mm:ss' }}"
data-placement="top">{{ task.elapsed }}</td>
</tr>
</tbody>

View File

@ -399,7 +399,8 @@
<script src="{{ STATIC_URL }}lib/codemirror/addon/selection/active-line.js"></script>
<script src="{{ STATIC_URL }}lib/scrollto/lib/jquery-scrollto.js"></script>
<script src="{{ STATIC_URL }}lib/socket.io-client/dist/socket.io.min.js"></script>
<script src="{{ STATIC_URL }}lib/lib/d3js/build/d3.v3.min.js"></script>
<script src="{{ STATIC_URL }}lib/d3js/build/d3.v3.min.js"></script>
<script>
!function(){
var Donut3D={};