Add timeline view with Angular support.
This re-adds the Tempest timeline view as a set of Angular directives. This includes related functionality, such as the Dstat parser and some array utilities. The timeline view consists of a timeline component, which includes the d3 chart, and a separate details component, which shows additional information for tests when selected in the timeline. Change-Id: Ifaaeda91b0617e8cf7a60d30728005f5c8d00546
This commit is contained in:
parent
b73595b3aa
commit
d84e984fb7
@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
var controllersModule = require('./_index');
|
var controllersModule = require('./_index');
|
||||||
|
|
||||||
function MainCtrl() {
|
/**
|
||||||
var vm = this;
|
* @ngInject
|
||||||
|
*/
|
||||||
|
function MainCtrl($window, $scope) {
|
||||||
|
$window.addEventListener('resize', function () {
|
||||||
|
$scope.$broadcast('windowResize');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
controllersModule.controller('MainCtrl', MainCtrl);
|
controllersModule.controller('MainCtrl', MainCtrl);
|
||||||
|
24
app/js/controllers/timeline.js
Normal file
24
app/js/controllers/timeline.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
var controllersModule = require('./_index');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ngInject
|
||||||
|
*/
|
||||||
|
function TimelineCtrl($stateParams, datasetService) {
|
||||||
|
|
||||||
|
// ViewModel
|
||||||
|
var vm = this;
|
||||||
|
|
||||||
|
datasetService.get($stateParams.datasetId).then(function(dataset) {
|
||||||
|
vm.dataset = dataset;
|
||||||
|
}, function(reason) {
|
||||||
|
vm.error = "Unable to load dataset: " + reason;
|
||||||
|
});
|
||||||
|
|
||||||
|
vm.hoveredItem = null;
|
||||||
|
vm.selectedItem = null;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
controllersModule.controller('TimelineCtrl', TimelineCtrl);
|
44
app/js/directives/timeline-details.js
Normal file
44
app/js/directives/timeline-details.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
var directivesModule = require('./_index.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ngInject
|
||||||
|
*/
|
||||||
|
function timelineDetails() {
|
||||||
|
var controller = function($scope) {
|
||||||
|
$scope.item = null;
|
||||||
|
|
||||||
|
$scope.$watch('hoveredItem', function(value) {
|
||||||
|
if (value && !$scope.selectedItem) {
|
||||||
|
$scope.item = value;
|
||||||
|
} else if (!value && !$scope.selectedItem) {
|
||||||
|
$scope.item = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.$watch('selectedItem', function(value) {
|
||||||
|
if (value) {
|
||||||
|
$scope.item = value;
|
||||||
|
} else {
|
||||||
|
if ($scope.hoveredItem) {
|
||||||
|
$scope.item = $scope.hoveredItem;
|
||||||
|
} else {
|
||||||
|
$scope.item = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
restrict: 'EA',
|
||||||
|
scope: {
|
||||||
|
'hoveredItem': '=',
|
||||||
|
'selectedItem': '='
|
||||||
|
},
|
||||||
|
controller: controller,
|
||||||
|
templateUrl: 'directives/timeline-details.html'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
directivesModule.directive('timelineDetails', timelineDetails);
|
597
app/js/directives/timeline.js
Normal file
597
app/js/directives/timeline.js
Normal file
@ -0,0 +1,597 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
var directivesModule = require('./_index.js');
|
||||||
|
|
||||||
|
var arrayUtil = require('../util/array-util');
|
||||||
|
var parseDstat = require('../util/dstat-parse');
|
||||||
|
var d3 = require('d3');
|
||||||
|
|
||||||
|
var statusColorMap = {
|
||||||
|
"success": "LightGreen",
|
||||||
|
"fail": "Crimson",
|
||||||
|
"skip": "DodgerBlue"
|
||||||
|
};
|
||||||
|
|
||||||
|
var parseWorker = function(tags) {
|
||||||
|
for (var i = 0; i < tags.length; i++) {
|
||||||
|
if (!tags[i].startsWith("worker")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseInt(tags[i].split("-")[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
var getDstatLanes = function(data, mins, maxes) {
|
||||||
|
if (!data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = data[0];
|
||||||
|
var lanes = [];
|
||||||
|
|
||||||
|
if ('total_cpu_usage_usr' in row && 'total_cpu_usage_sys' in row) {
|
||||||
|
lanes.push([{
|
||||||
|
scale: d3.scale.linear().domain([0, 100]),
|
||||||
|
value: function(d) {
|
||||||
|
return d.total_cpu_usage_wai;
|
||||||
|
},
|
||||||
|
color: "rgba(224, 188, 188, 1)",
|
||||||
|
text: "CPU wait"
|
||||||
|
}, {
|
||||||
|
scale: d3.scale.linear().domain([0, 100]),
|
||||||
|
value: function(d) {
|
||||||
|
return d.total_cpu_usage_usr + d.total_cpu_usage_sys;
|
||||||
|
},
|
||||||
|
color: "rgba(102, 140, 178, 0.75)",
|
||||||
|
text: "CPU (user+sys)"
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('memory_usage_used' in row) {
|
||||||
|
lanes.push([{
|
||||||
|
scale: d3.scale.linear().domain([0, maxes.memory_usage_used]),
|
||||||
|
value: function(d) { return d.memory_usage_used; },
|
||||||
|
color: "rgba(102, 140, 178, 0.75)",
|
||||||
|
text: "Memory"
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('net_total_recv' in row && 'net_total_send' in row) {
|
||||||
|
lanes.push([{
|
||||||
|
scale: d3.scale.linear().domain([0, maxes.net_total_recv]),
|
||||||
|
value: function(d) { return d.net_total_recv; },
|
||||||
|
color: "rgba(224, 188, 188, 1)",
|
||||||
|
text: "Net Down"
|
||||||
|
}, {
|
||||||
|
scale: d3.scale.linear().domain([0, maxes.net_total_send]),
|
||||||
|
value: function(d) { return d.net_total_send; },
|
||||||
|
color: "rgba(102, 140, 178, 0.75)",
|
||||||
|
text: "Net Up",
|
||||||
|
type: "line"
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('dsk_total_read' in row && 'dsk_total_writ' in row) {
|
||||||
|
lanes.push([{
|
||||||
|
scale: d3.scale.linear().domain([0, maxes.dsk_total_read]),
|
||||||
|
value: function(d) { return d.dsk_total_read; },
|
||||||
|
color: "rgba(224, 188, 188, 1)",
|
||||||
|
text: "Disk Read",
|
||||||
|
type: "line"
|
||||||
|
}, {
|
||||||
|
scale: d3.scale.linear().domain([0, maxes.dsk_total_writ]),
|
||||||
|
value: function(d) { return d.dsk_total_writ; },
|
||||||
|
color: "rgba(102, 140, 178, 0.75)",
|
||||||
|
text: "Disk Write",
|
||||||
|
type: "line"
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lanes;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ngInject
|
||||||
|
*/
|
||||||
|
function timeline(datasetService) {
|
||||||
|
var link = function(scope, el, attrs) {
|
||||||
|
var data = [];
|
||||||
|
var dstat = {};
|
||||||
|
var timeExtents = [];
|
||||||
|
|
||||||
|
var margin = { top: 20, right: 10, bottom: 10, left: 80 };
|
||||||
|
var width = el.parent()[0].clientWidth - margin.left - margin.right;
|
||||||
|
var height = 550 - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
var miniHeight = 0;
|
||||||
|
var dstatHeight = 0;
|
||||||
|
var mainHeight = 0;
|
||||||
|
|
||||||
|
// primary x axis, maps time -> screen x
|
||||||
|
var x = d3.time.scale().range([0, width]);
|
||||||
|
|
||||||
|
// secondary x axis, maps time (in selected range) -> screen x
|
||||||
|
var xSelected = d3.scale.linear().range([0, width]);
|
||||||
|
|
||||||
|
// y axis for lane positions within main
|
||||||
|
var yMain = d3.scale.linear();
|
||||||
|
|
||||||
|
// y axis for dstat lane positions
|
||||||
|
var yDstat = d3.scale.linear();
|
||||||
|
|
||||||
|
// y axis for lane positions within mini
|
||||||
|
var yMini = d3.scale.linear();
|
||||||
|
|
||||||
|
var chart = d3.select(el[0])
|
||||||
|
.append('svg')
|
||||||
|
.attr('width', '100%')
|
||||||
|
.attr('height', height + margin.top + margin.bottom);
|
||||||
|
|
||||||
|
var defs = chart.append('defs')
|
||||||
|
.append('clipPath')
|
||||||
|
.attr('id', 'clip')
|
||||||
|
.append('rect')
|
||||||
|
.attr('width', width); // TODO: set height later
|
||||||
|
|
||||||
|
var main = chart.append('g')
|
||||||
|
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
|
||||||
|
.attr('width', width); // TODO: set height later
|
||||||
|
|
||||||
|
var laneLines = main.append('g');
|
||||||
|
var laneLabels = main.append('g');
|
||||||
|
|
||||||
|
var itemGroups = main.append('g');
|
||||||
|
|
||||||
|
var dstatLanes = [];
|
||||||
|
var dstatGroup = chart.append('g').attr('width', width);
|
||||||
|
|
||||||
|
var mini = chart.append('g').attr('width', width);
|
||||||
|
var miniGroups = mini.append('g');
|
||||||
|
|
||||||
|
// delay init of the brush until we know the extents, otherwise it won't
|
||||||
|
// init properly
|
||||||
|
var brush = null;
|
||||||
|
|
||||||
|
var selectedRect = null;
|
||||||
|
|
||||||
|
var cursorGroup = main.append('g')
|
||||||
|
.style('opacity', 0)
|
||||||
|
.style('pointer-events', 'none');
|
||||||
|
|
||||||
|
var cursor = cursorGroup.append('line')
|
||||||
|
.attr('x1', 0)
|
||||||
|
.attr('x2', 0)
|
||||||
|
.attr('stroke', 'blue');
|
||||||
|
|
||||||
|
var cursorText = cursorGroup.append('text')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', -10)
|
||||||
|
.attr('dy', '-.5ex')
|
||||||
|
.style('text-anchor', 'middle')
|
||||||
|
.style('font', '9px sans-serif');
|
||||||
|
|
||||||
|
var updateLanes = function() {
|
||||||
|
var lines = laneLines.selectAll('.laneLine')
|
||||||
|
.data(data, function(d) { return d.key; });
|
||||||
|
|
||||||
|
lines.enter().append('line')
|
||||||
|
.attr('x1', 0)
|
||||||
|
.attr('x2', width)
|
||||||
|
.attr('stroke', 'lightgray')
|
||||||
|
.attr('class', 'laneLine');
|
||||||
|
|
||||||
|
lines.attr('y1', function(d, i) { return yMain(i - 0.1); })
|
||||||
|
.attr('y2', function(d, i) { return yMain(i - 0.1); });
|
||||||
|
|
||||||
|
lines.exit().remove();
|
||||||
|
|
||||||
|
var labels = laneLabels.selectAll('.laneLabel')
|
||||||
|
.data(data, function(d) { return d.key; });
|
||||||
|
|
||||||
|
labels.enter().append('text')
|
||||||
|
.text(function(d) { return 'Worker #' + d.key; })
|
||||||
|
.attr('x', -margin.right)
|
||||||
|
.attr('dy', '.5ex')
|
||||||
|
.attr('text-anchor', 'end')
|
||||||
|
.attr('class', 'laneLabel');
|
||||||
|
|
||||||
|
labels.attr('y', function(d, i) { return yMain(i + 0.5); });
|
||||||
|
labels.exit().remove();
|
||||||
|
|
||||||
|
cursor.attr('y2', yMain(data.length - 0.1));
|
||||||
|
};
|
||||||
|
|
||||||
|
var updateItems = function() {
|
||||||
|
var minExtent = brush.extent()[0];
|
||||||
|
var maxExtent = brush.extent()[1];
|
||||||
|
|
||||||
|
// filter visible items to include only those within the current extent
|
||||||
|
// additionally prune extremely small values to improve performance
|
||||||
|
var visibleItems = data.map(function(group) {
|
||||||
|
return {
|
||||||
|
key: group.key,
|
||||||
|
values: group.values.filter(function(e) {
|
||||||
|
if (xSelected(e.endDate) - xSelected(e.startDate) < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.startDate > maxExtent || e.endDate < minExtent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
var groups = itemGroups.selectAll("g")
|
||||||
|
.data(visibleItems, function(d) { return d.key; });
|
||||||
|
|
||||||
|
groups.enter().append("g");
|
||||||
|
|
||||||
|
var rects = groups.selectAll("rect")
|
||||||
|
.data(function(d) { return d.values; }, function(d) { return d.name; });
|
||||||
|
|
||||||
|
rects.enter().append("rect")
|
||||||
|
.attr('y', function(d) { return yMain(parseWorker(d.tags)); })
|
||||||
|
.attr('height', 0.8 * yMain(1))
|
||||||
|
.attr('stroke', 'rgba(100, 100, 100, 0.25)')
|
||||||
|
.attr('clip-path', 'url(#clip)');
|
||||||
|
|
||||||
|
rects
|
||||||
|
.attr('x', function(d) {
|
||||||
|
return xSelected(d.startDate);
|
||||||
|
})
|
||||||
|
.attr('width', function(d) {
|
||||||
|
return xSelected(d.endDate) - xSelected(d.startDate);
|
||||||
|
})
|
||||||
|
.attr('fill', function(d) { return statusColorMap[d.status]; })
|
||||||
|
.on("mouseover", function(d) {
|
||||||
|
if (selectedRect !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.hoveredItem = d;
|
||||||
|
scope.$apply();
|
||||||
|
|
||||||
|
var self = d3.select(this);
|
||||||
|
if (!self.attr('data-old-fill')) {
|
||||||
|
self.attr('data-old-fill', self.attr('fill'));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.attr('fill', 'darkturquoise');
|
||||||
|
})
|
||||||
|
.on('mouseout', function(d) {
|
||||||
|
if (selectedRect !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.hoveredItem = null;
|
||||||
|
scope.$apply();
|
||||||
|
|
||||||
|
var self = d3.select(this);
|
||||||
|
if (self.attr('data-old-fill')) {
|
||||||
|
self.attr('fill', self.attr('data-old-fill'));
|
||||||
|
self.attr('data-old-fill', null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('click', function(d) {
|
||||||
|
var self = d3.select(this);
|
||||||
|
if (selectedRect) {
|
||||||
|
if (selectedRect.attr('data-old-fill')) {
|
||||||
|
selectedRect.attr('fill', selectedRect.attr('data-old-fill'));
|
||||||
|
selectedRect.attr('data-old-fill', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope.selectedItem.name === d.name) {
|
||||||
|
scope.selectedItem = null;
|
||||||
|
scope.$apply();
|
||||||
|
|
||||||
|
selectedRect = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.selectedItem = d;
|
||||||
|
scope.$apply();
|
||||||
|
|
||||||
|
selectedRect = self;
|
||||||
|
|
||||||
|
if (!self.attr('data-old-fill')) {
|
||||||
|
self.attr('data-old-fill', self.attr('fill'));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.attr('fill', 'goldenrod');
|
||||||
|
});
|
||||||
|
|
||||||
|
rects.exit().remove();
|
||||||
|
groups.exit().remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
var updateDstat = function() {
|
||||||
|
if (dstatLanes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var minExtent = brush.extent()[0];
|
||||||
|
var maxExtent = brush.extent()[1];
|
||||||
|
|
||||||
|
var timeFunc = function(d) { return d.system_time; };
|
||||||
|
|
||||||
|
var visibleEntries = dstat.entries.slice(
|
||||||
|
arrayUtil.binaryMinIndex(minExtent, dstat.entries, timeFunc),
|
||||||
|
arrayUtil.binaryMaxIndex(maxExtent, dstat.entries, timeFunc)
|
||||||
|
);
|
||||||
|
|
||||||
|
// apply the current dataset (visibleEntries) to each dstat path
|
||||||
|
dstatLanes.forEach(function(lane) {
|
||||||
|
lane.forEach(function(pathDef) {
|
||||||
|
pathDef.path
|
||||||
|
.datum(visibleEntries)
|
||||||
|
.attr("d", pathDef.area);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var update = function() {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
xSelected.domain(brush.extent());
|
||||||
|
|
||||||
|
updateLanes();
|
||||||
|
updateItems();
|
||||||
|
updateDstat();
|
||||||
|
};
|
||||||
|
|
||||||
|
var updateMiniItems = function() {
|
||||||
|
var groups = miniGroups.selectAll("g")
|
||||||
|
.data(data, function(d) { return d.key; });
|
||||||
|
|
||||||
|
groups.enter().append("g");
|
||||||
|
|
||||||
|
var rects = groups.selectAll("rect").data(
|
||||||
|
function(d) { return d.values; },
|
||||||
|
function(d) { return d.name; });
|
||||||
|
|
||||||
|
rects.enter().append("rect")
|
||||||
|
.attr("y", function(d) { return yMini(parseWorker(d.tags) + 0.5) - 5; })
|
||||||
|
.attr("height", 10);
|
||||||
|
|
||||||
|
rects.attr("x", function(d) { return x(d.startDate); })
|
||||||
|
.attr("width", function(d) { return x(d.endDate) - x(d.startDate); })
|
||||||
|
.attr("stroke", 'rgba(100, 100, 100, 0.25)')
|
||||||
|
.attr("fill", function(d) { return statusColorMap[d.status]; });
|
||||||
|
|
||||||
|
rects.exit().remove();
|
||||||
|
groups.exit().remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
var initChart = function() {
|
||||||
|
// determine lanes available based on current data
|
||||||
|
dstatLanes = getDstatLanes(dstat.entries, dstat.minimums, dstat.maximums);
|
||||||
|
|
||||||
|
// determine region sizes that depend on available datasets
|
||||||
|
miniHeight = data.length * 12 + 30;
|
||||||
|
dstatHeight = dstatLanes.length * 30 + 30;
|
||||||
|
mainHeight = height - miniHeight - dstatHeight - 10;
|
||||||
|
|
||||||
|
// update scales based on data and calculated heights
|
||||||
|
x.domain(timeExtents);
|
||||||
|
yMain.domain([0, data.length]).range([0, mainHeight]);
|
||||||
|
yDstat.domain([0, dstatLanes.length]).range([0, dstatHeight]);
|
||||||
|
yMini.domain([0, data.length]).range([0, miniHeight]);
|
||||||
|
|
||||||
|
// apply calculated heights to group sizes and transforms
|
||||||
|
defs.attr('height', mainHeight);
|
||||||
|
main.attr('height', mainHeight);
|
||||||
|
cursor.attr('y1', yMain(-0.1));
|
||||||
|
|
||||||
|
var dstatOffset = margin.top + mainHeight;
|
||||||
|
dstatGroup
|
||||||
|
.attr('height', dstatHeight)
|
||||||
|
.attr('transform', 'translate(' + margin.left + ',' + dstatOffset + ')');
|
||||||
|
|
||||||
|
var miniOffset = margin.top + mainHeight + dstatHeight;
|
||||||
|
mini.attr('height', mainHeight)
|
||||||
|
.attr('transform', 'translate(' + margin.left + ',' + miniOffset + ')');
|
||||||
|
|
||||||
|
// set initial selection extents to 1/8 the total size
|
||||||
|
// this helps with performance issues in some browsers when displaying
|
||||||
|
// large datasets (e.g. recent Firefox on Linux)
|
||||||
|
var start = timeExtents[0];
|
||||||
|
var end = timeExtents[1];
|
||||||
|
var reducedEnd = new Date(start.getTime() + (end - start) / 8);
|
||||||
|
|
||||||
|
brush = d3.svg.brush()
|
||||||
|
.x(x)
|
||||||
|
.extent([start, reducedEnd])
|
||||||
|
.on('brush', update);
|
||||||
|
|
||||||
|
var brushElement = mini.append('g')
|
||||||
|
.call(brush)
|
||||||
|
.selectAll('rect')
|
||||||
|
.attr('y', 1)
|
||||||
|
.attr('fill', 'dodgerblue')
|
||||||
|
.attr('fill-opacity', 0.365);
|
||||||
|
|
||||||
|
brushElement.attr('height', miniHeight - 1);
|
||||||
|
|
||||||
|
// init dstat lanes
|
||||||
|
dstatLanes.forEach(function(lane, i) {
|
||||||
|
var laneGroup = dstatGroup.append('g');
|
||||||
|
|
||||||
|
var text = laneGroup.append('text')
|
||||||
|
.attr('y', yDstat(i + 0.5))
|
||||||
|
.attr('dy', '0.5ex')
|
||||||
|
.attr('text-anchor', 'end')
|
||||||
|
.style('font', '10px sans-serif');
|
||||||
|
|
||||||
|
var dy = 0;
|
||||||
|
|
||||||
|
lane.forEach(function(pathDef) {
|
||||||
|
var laneHeight = 0.8 * yDstat(1);
|
||||||
|
pathDef.scale.range([laneHeight, 0]);
|
||||||
|
|
||||||
|
if ('text' in pathDef) {
|
||||||
|
text.append('tspan')
|
||||||
|
.attr('x', -margin.right)
|
||||||
|
.attr('dy', dy)
|
||||||
|
.text(pathDef.text)
|
||||||
|
.attr('fill', pathDef.color);
|
||||||
|
|
||||||
|
dy += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
pathDef.path = laneGroup.append('path');
|
||||||
|
if (pathDef.type === 'line') {
|
||||||
|
pathDef.area = d3.svg.line()
|
||||||
|
.x(function(d) { return xSelected(d.system_time); })
|
||||||
|
.y(function(d) {
|
||||||
|
return yDstat(i) + pathDef.scale(pathDef.value(d));
|
||||||
|
});
|
||||||
|
|
||||||
|
pathDef.path
|
||||||
|
.style('stroke', pathDef.color)
|
||||||
|
.style('stroke-width', '1.5px')
|
||||||
|
.style('fill', 'none');
|
||||||
|
} else {
|
||||||
|
pathDef.area = d3.svg.area()
|
||||||
|
.x(function(d) { return xSelected(d.system_time); })
|
||||||
|
.y0(yDstat(i) + laneHeight)
|
||||||
|
.y1(function(d) {
|
||||||
|
return yDstat(i) + pathDef.scale(pathDef.value(d));
|
||||||
|
});
|
||||||
|
|
||||||
|
pathDef.path.style('fill', pathDef.color);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// finalize chart init
|
||||||
|
updateMiniItems();
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
|
||||||
|
var initData = function(raw, dstatRaw) {
|
||||||
|
// find data extents
|
||||||
|
var minStart = null;
|
||||||
|
var maxEnd = null;
|
||||||
|
|
||||||
|
raw.forEach(function(d) {
|
||||||
|
d.startDate = new Date(d.timestamps[0]);
|
||||||
|
if (minStart === null || d.startDate < minStart) {
|
||||||
|
minStart = d.startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
d.endDate = new Date(d.timestamps[1]);
|
||||||
|
if (maxEnd === null || d.endDate > maxEnd) {
|
||||||
|
maxEnd = d.endDate;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// define a nested data structure with groups by worker, and fill using
|
||||||
|
// entries w/ duration > 0
|
||||||
|
data = d3.nest()
|
||||||
|
.key(function(d) { return parseWorker(d.tags); })
|
||||||
|
.sortKeys(d3.ascending)
|
||||||
|
.entries(raw.filter(function(d) { return d.duration > 0; }));
|
||||||
|
|
||||||
|
var accessor = function(d) { return d.system_time; };
|
||||||
|
var minIndex = arrayUtil.binaryMinIndex(minStart, dstatRaw.entries, accessor);
|
||||||
|
var maxIndex = arrayUtil.binaryMaxIndex(maxEnd, dstatRaw.entries, accessor);
|
||||||
|
|
||||||
|
dstat = {
|
||||||
|
entries: dstatRaw.entries.slice(minIndex, maxIndex),
|
||||||
|
minimums: dstatRaw.minimums,
|
||||||
|
maximums: dstatRaw.maximums
|
||||||
|
};
|
||||||
|
timeExtents = [ minStart, maxEnd ];
|
||||||
|
|
||||||
|
initChart();
|
||||||
|
};
|
||||||
|
|
||||||
|
chart.on('mouseout', function() {
|
||||||
|
cursorGroup.style('opacity', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
chart.on('mousemove', function() {
|
||||||
|
var pos = d3.mouse(this);
|
||||||
|
var px = pos[0];
|
||||||
|
var py = pos[1];
|
||||||
|
|
||||||
|
if (px >= margin.left && px < (width + margin.left) &&
|
||||||
|
py > margin.top && py < (mainHeight + margin.top)) {
|
||||||
|
var relX = px - margin.left;
|
||||||
|
var currentTime = new Date(xSelected.invert(relX));
|
||||||
|
|
||||||
|
cursorGroup
|
||||||
|
.style('opacity', '0.5')
|
||||||
|
.attr('transform', 'translate(' + relX + ', 0)');
|
||||||
|
|
||||||
|
cursorText.text(d3.time.format('%X')(currentTime));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.$on('windowResize', function() {
|
||||||
|
var extent = brush.extent();
|
||||||
|
|
||||||
|
width = el.parent()[0].clientWidth - margin.left - margin.right;
|
||||||
|
x.range([0, width]);
|
||||||
|
xSelected.range([0, width]);
|
||||||
|
|
||||||
|
chart.attr('width', el.parent()[0].clientWidth);
|
||||||
|
defs.attr('width', width);
|
||||||
|
main.attr('width', width);
|
||||||
|
mini.attr('width', width);
|
||||||
|
// TODO: dstat?
|
||||||
|
|
||||||
|
laneLines.selectAll('.laneLine').attr('x2', width);
|
||||||
|
|
||||||
|
brush.extent(extent);
|
||||||
|
|
||||||
|
updateMiniItems();
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.$watch('dataset', function(dataset) {
|
||||||
|
if (!dataset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = null;
|
||||||
|
var dstat = null;
|
||||||
|
|
||||||
|
// load both datasets
|
||||||
|
datasetService.raw(dataset).then(function(response) {
|
||||||
|
raw = response.data;
|
||||||
|
return datasetService.dstat(dataset);
|
||||||
|
}).then(function(response) {
|
||||||
|
var firstDate = new Date(raw[0].timestamps[0]);
|
||||||
|
dstat = parseDstat(response.data, firstDate.getYear());
|
||||||
|
}).finally(function() {
|
||||||
|
// display as much as we were able to load
|
||||||
|
// (dstat may not exist, but that's okay)
|
||||||
|
initData(raw, dstat);
|
||||||
|
}).catch(function(ex) {
|
||||||
|
console.error(ex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
restrict: 'EA',
|
||||||
|
scope: {
|
||||||
|
'dataset': '=',
|
||||||
|
'hoveredItem': '=',
|
||||||
|
'selectedItem': '='
|
||||||
|
},
|
||||||
|
link: link
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
directivesModule.directive('timeline', timeline);
|
8
app/js/filters/_index.js
Normal file
8
app/js/filters/_index.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
var angular = require('angular');
|
||||||
|
var bulk = require('bulk-require');
|
||||||
|
|
||||||
|
module.exports = angular.module('app.filters', []);
|
||||||
|
|
||||||
|
bulk(__dirname, ['./**/!(*_index|*.spec).js']);
|
47
app/js/filters/list-filters.js
Normal file
47
app/js/filters/list-filters.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
var filtersModule = require('./_index.js');
|
||||||
|
|
||||||
|
var split = function(input, delim) {
|
||||||
|
delim = delim || ',';
|
||||||
|
|
||||||
|
return input.split(delim);
|
||||||
|
};
|
||||||
|
|
||||||
|
var join = function(input, delim) {
|
||||||
|
delim = delim || ', ';
|
||||||
|
|
||||||
|
return input.join(delim);
|
||||||
|
};
|
||||||
|
|
||||||
|
var pick = function(input, index) {
|
||||||
|
return input[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
var pickRight = function(input, index) {
|
||||||
|
return input[input.length - index];
|
||||||
|
};
|
||||||
|
|
||||||
|
var slice = function(input, begin, end) {
|
||||||
|
return input.slice(begin, end);
|
||||||
|
};
|
||||||
|
|
||||||
|
var first = function(input, length) {
|
||||||
|
length = length || 1;
|
||||||
|
|
||||||
|
return input.slice(0, input.length - length);
|
||||||
|
};
|
||||||
|
|
||||||
|
var last = function(input, length) {
|
||||||
|
length = length || 1;
|
||||||
|
|
||||||
|
return input.slice(input.length - length, input.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
filtersModule.filter('split', function() { return split; });
|
||||||
|
filtersModule.filter('join', function() { return join; });
|
||||||
|
filtersModule.filter('pick', function() { return pick; });
|
||||||
|
filtersModule.filter('pickRight', function() { return pickRight; });
|
||||||
|
filtersModule.filter('slice', function() { return slice; });
|
||||||
|
filtersModule.filter('first', function() { return first; });
|
||||||
|
filtersModule.filter('last', function() { return last; });
|
@ -10,6 +10,7 @@ require('./templates');
|
|||||||
require('./controllers/_index');
|
require('./controllers/_index');
|
||||||
require('./services/_index');
|
require('./services/_index');
|
||||||
require('./directives/_index');
|
require('./directives/_index');
|
||||||
|
require('./filters/_index');
|
||||||
|
|
||||||
var bootstrap = function() {
|
var bootstrap = function() {
|
||||||
|
|
||||||
@ -20,6 +21,7 @@ var bootstrap = function() {
|
|||||||
'app.controllers',
|
'app.controllers',
|
||||||
'app.services',
|
'app.services',
|
||||||
'app.directives',
|
'app.directives',
|
||||||
|
'app.filters',
|
||||||
'picardy.fontawesome'
|
'picardy.fontawesome'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -14,6 +14,13 @@ function OnConfig($stateProvider, $locationProvider, $urlRouterProvider) {
|
|||||||
title: 'Home'
|
title: 'Home'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$stateProvider.state('timeline', {
|
||||||
|
url: '/timeline/{datasetId:int}',
|
||||||
|
controller: 'TimelineCtrl as timeline',
|
||||||
|
templateUrl: 'timeline.html',
|
||||||
|
title: 'Timeline'
|
||||||
|
});
|
||||||
|
|
||||||
$urlRouterProvider.otherwise('/');
|
$urlRouterProvider.otherwise('/');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
58
app/js/util/array-util.js
Normal file
58
app/js/util/array-util.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
var binaryMinIndex = function(min, array, func) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var left = 0;
|
||||||
|
var right = array.length - 1;
|
||||||
|
|
||||||
|
while (left < right) {
|
||||||
|
var mid = Math.floor((left + right) / 2);
|
||||||
|
|
||||||
|
if (min < func(array[mid])) {
|
||||||
|
right = mid - 1;
|
||||||
|
} else if (min > func(array[mid])) {
|
||||||
|
left = mid + 1;
|
||||||
|
} else {
|
||||||
|
right = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left >= array.length) {
|
||||||
|
return array.length - 1;
|
||||||
|
} else if (func(array[left]) <= min) {
|
||||||
|
return left;
|
||||||
|
} else {
|
||||||
|
return left - 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var binaryMaxIndex = function(max, array, func) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var left = 0;
|
||||||
|
var right = array.length - 1;
|
||||||
|
|
||||||
|
while (left < right) {
|
||||||
|
var mid = Math.floor((left + right) / 2);
|
||||||
|
|
||||||
|
if (max < func(array[mid])) {
|
||||||
|
right = mid - 1;
|
||||||
|
} else if (max > func(array[mid])) {
|
||||||
|
left = mid + 1;
|
||||||
|
} else {
|
||||||
|
right = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (right < 0) {
|
||||||
|
return 0;
|
||||||
|
} else if (func(array[right]) <= max) {
|
||||||
|
return right + 1; // exclusive index
|
||||||
|
} else {
|
||||||
|
return right;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
binaryMinIndex: binaryMinIndex,
|
||||||
|
binaryMaxIndex: binaryMaxIndex
|
||||||
|
};
|
92
app/js/util/dstat-parse.js
Normal file
92
app/js/util/dstat-parse.js
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
var d3 = require("d3");
|
||||||
|
|
||||||
|
var fillArrayRight = function(array) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// "fill" the array to the right, overwriting empty values with the next
|
||||||
|
// non-empty value to the left
|
||||||
|
// only false values will be overwritten (e.g. "", null, etc)
|
||||||
|
for (var i = 0; i < array.length - 1; i++) {
|
||||||
|
if (!array[i + 1]) {
|
||||||
|
array[i + 1] = array[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var mergeNames = function(primary, secondary) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// "zip" together strings in the same position in each array, and do some
|
||||||
|
// basic cleanup of results
|
||||||
|
var ret = [];
|
||||||
|
for (var i = 0; i < primary.length; i++) {
|
||||||
|
ret.push((primary[i] + '_' + secondary[i]).replace(/[ /]/g, '_'));
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
|
||||||
|
var parseDstat = function(data, year) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var primaryNames = null;
|
||||||
|
var secondaryNames = null;
|
||||||
|
var names = null;
|
||||||
|
|
||||||
|
var minimums = {};
|
||||||
|
var maximums = {};
|
||||||
|
|
||||||
|
// assume UTC - may not necessarily be the case?
|
||||||
|
// dstat doesn't include the year in its logs, so we'll need to copy it
|
||||||
|
// from the subunit logs
|
||||||
|
var dateFormat = d3.time.format.utc("%d-%m %H:%M:%S");
|
||||||
|
|
||||||
|
var parsed = d3.csv.parseRows(data, function(row, i) {
|
||||||
|
if (i <= 4) { // header rows - ignore
|
||||||
|
return null;
|
||||||
|
} else if (i === 5) { // primary
|
||||||
|
primaryNames = row;
|
||||||
|
fillArrayRight(primaryNames);
|
||||||
|
return null;
|
||||||
|
} else if (i === 6) { // secondary
|
||||||
|
secondaryNames = row;
|
||||||
|
|
||||||
|
names = mergeNames(primaryNames, secondaryNames);
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
var ret = {};
|
||||||
|
|
||||||
|
for (var col = 0; col < row.length; col++) {
|
||||||
|
var name = names[col];
|
||||||
|
var value = row[col];
|
||||||
|
if (value && name) {
|
||||||
|
if (name === "system_time") {
|
||||||
|
value = dateFormat.parse(value);
|
||||||
|
value.setFullYear(1900 + year);
|
||||||
|
} else {
|
||||||
|
value = parseFloat(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(name in minimums) || value < minimums[name]) {
|
||||||
|
minimums[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(name in maximums) || value > maximums[name]) {
|
||||||
|
maximums[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
ret[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
minimums: minimums,
|
||||||
|
maximums: maximums,
|
||||||
|
entries: parsed
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = parseDstat;
|
8
app/styles/directives/_timeline-details.scss
Normal file
8
app/styles/directives/_timeline-details.scss
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
timeline-details table {
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
word-wrap: break-word
|
||||||
|
}
|
||||||
|
timeline-details table td:nth-child(1) {
|
||||||
|
width: 25%;
|
||||||
|
}
|
@ -3,3 +3,5 @@
|
|||||||
@import 'bootstrap';
|
@import 'bootstrap';
|
||||||
@import 'font-awesome/font-awesome';
|
@import 'font-awesome/font-awesome';
|
||||||
@import 'sb-admin-2';
|
@import 'sb-admin-2';
|
||||||
|
|
||||||
|
@import 'directives/_timeline-details.scss';
|
||||||
|
@ -11,8 +11,7 @@
|
|||||||
class="btn btn-default"
|
class="btn btn-default"
|
||||||
ui-sref="timeline({datasetId: dataset.id})">Timeline</a>
|
ui-sref="timeline({datasetId: dataset.id})">Timeline</a>
|
||||||
<a type="button"
|
<a type="button"
|
||||||
class="btn btn-default"
|
class="btn btn-default">Sunburst</a>
|
||||||
ui-sref="sunburst({datasetId: dataset.id})">Sunburst</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
31
app/views/directives/timeline-details.html
Normal file
31
app/views/directives/timeline-details.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<div class="panel-body" ng-if="!item">
|
||||||
|
<i>Hover over a timeline item for details.</i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body" ng-if="!!item">
|
||||||
|
<table class="table table-bordered table-hover table-striped">
|
||||||
|
<tr>
|
||||||
|
<td>Name</td>
|
||||||
|
<td>{{item.name | split:'.' | pickRight:1}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Class</td>
|
||||||
|
<td>{{item.name | split:'.' | pickRight:2}}</td>
|
||||||
|
<tr>
|
||||||
|
<td>Module</td>
|
||||||
|
<td>{{item.name | split:'.' | slice:0:-2 | join:'.'}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Status</td>
|
||||||
|
<td>{{item.status}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Tags</td>
|
||||||
|
<td>{{item.tags | join:', '}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Duration</td>
|
||||||
|
<td>{{item.duration}} seconds</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
42
app/views/timeline.html
Normal file
42
app/views/timeline.html
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<header class="bs-header">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="page-header">
|
||||||
|
Timeline: {{ timeline.dataset.name }}
|
||||||
|
<small>#{{ timeline.dataset.id }}</small>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-12">
|
||||||
|
<div class="alert alert-danger" ng-if="!!timeline.error">
|
||||||
|
{{ timeline.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" ng-if="!timeline.error">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title"><fa name="clock-o" fw></fa> Timeline</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<timeline class="panel-body"
|
||||||
|
dataset="timeline.dataset"
|
||||||
|
hovered-item="timeline.hoveredItem"
|
||||||
|
selected-item="timeline.selectedItem"></timeline>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title"><fa name="info" fw></fa> Details</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<timeline-details hovered-item="timeline.hoveredItem"
|
||||||
|
selected-item="timeline.selectedItem"></timeline-details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
Loading…
Reference in New Issue
Block a user