From 0343b634129e2be0e262cc35cdfbaf364867b831 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Mon, 12 Oct 2015 17:04:38 -0600 Subject: [PATCH] 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 --- app/js/controllers/main.js | 9 +- app/js/controllers/timeline.js | 24 + app/js/directives/timeline-details.js | 44 ++ app/js/directives/timeline.js | 597 +++++++++++++++++++ app/js/filters/_index.js | 8 + app/js/filters/list-filters.js | 47 ++ app/js/main.js | 2 + app/js/on_config.js | 7 + app/js/util/array-util.js | 58 ++ app/js/util/dstat-parse.js | 91 +++ app/styles/directives/_timeline-details.scss | 8 + app/styles/main.scss | 2 + app/views/directives/tempest-summary.html | 3 +- app/views/directives/timeline-details.html | 31 + app/views/timeline.html | 42 ++ 15 files changed, 969 insertions(+), 4 deletions(-) create mode 100644 app/js/controllers/timeline.js create mode 100644 app/js/directives/timeline-details.js create mode 100644 app/js/directives/timeline.js create mode 100644 app/js/filters/_index.js create mode 100644 app/js/filters/list-filters.js create mode 100644 app/js/util/array-util.js create mode 100644 app/js/util/dstat-parse.js create mode 100644 app/styles/directives/_timeline-details.scss create mode 100644 app/views/directives/timeline-details.html create mode 100644 app/views/timeline.html diff --git a/app/js/controllers/main.js b/app/js/controllers/main.js index d34bb57..5b983c0 100644 --- a/app/js/controllers/main.js +++ b/app/js/controllers/main.js @@ -2,8 +2,13 @@ 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); diff --git a/app/js/controllers/timeline.js b/app/js/controllers/timeline.js new file mode 100644 index 0000000..752c842 --- /dev/null +++ b/app/js/controllers/timeline.js @@ -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); diff --git a/app/js/directives/timeline-details.js b/app/js/directives/timeline-details.js new file mode 100644 index 0000000..0b86524 --- /dev/null +++ b/app/js/directives/timeline-details.js @@ -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); diff --git a/app/js/directives/timeline.js b/app/js/directives/timeline.js new file mode 100644 index 0000000..dad8a59 --- /dev/null +++ b/app/js/directives/timeline.js @@ -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); diff --git a/app/js/filters/_index.js b/app/js/filters/_index.js new file mode 100644 index 0000000..22d7614 --- /dev/null +++ b/app/js/filters/_index.js @@ -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']); diff --git a/app/js/filters/list-filters.js b/app/js/filters/list-filters.js new file mode 100644 index 0000000..28db718 --- /dev/null +++ b/app/js/filters/list-filters.js @@ -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; }); diff --git a/app/js/main.js b/app/js/main.js index 0cbc6fa..16efaba 100644 --- a/app/js/main.js +++ b/app/js/main.js @@ -10,6 +10,7 @@ require('./templates'); require('./controllers/_index'); require('./services/_index'); require('./directives/_index'); +require('./filters/_index'); var bootstrap = function() { @@ -20,6 +21,7 @@ var bootstrap = function() { 'app.controllers', 'app.services', 'app.directives', + 'app.filters', 'picardy.fontawesome' ]; diff --git a/app/js/on_config.js b/app/js/on_config.js index b5068fa..8f0e020 100644 --- a/app/js/on_config.js +++ b/app/js/on_config.js @@ -14,6 +14,13 @@ function OnConfig($stateProvider, $locationProvider, $urlRouterProvider) { title: 'Home' }); + $stateProvider.state('timeline', { + url: '/timeline/{datasetId:int}', + controller: 'TimelineCtrl as timeline', + templateUrl: 'timeline.html', + title: 'Timeline' + }); + $urlRouterProvider.otherwise('/'); } diff --git a/app/js/util/array-util.js b/app/js/util/array-util.js new file mode 100644 index 0000000..71c0e08 --- /dev/null +++ b/app/js/util/array-util.js @@ -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 +}; diff --git a/app/js/util/dstat-parse.js b/app/js/util/dstat-parse.js new file mode 100644 index 0000000..03eec12 --- /dev/null +++ b/app/js/util/dstat-parse.js @@ -0,0 +1,91 @@ +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 (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; diff --git a/app/styles/directives/_timeline-details.scss b/app/styles/directives/_timeline-details.scss new file mode 100644 index 0000000..7e15ce7 --- /dev/null +++ b/app/styles/directives/_timeline-details.scss @@ -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%; +} diff --git a/app/styles/main.scss b/app/styles/main.scss index 2d8b4b4..03765b3 100644 --- a/app/styles/main.scss +++ b/app/styles/main.scss @@ -3,3 +3,5 @@ @import 'bootstrap'; @import 'font-awesome/font-awesome'; @import 'sb-admin-2'; + +@import 'directives/_timeline-details.scss'; diff --git a/app/views/directives/tempest-summary.html b/app/views/directives/tempest-summary.html index 7afb4a7..242dd01 100644 --- a/app/views/directives/tempest-summary.html +++ b/app/views/directives/tempest-summary.html @@ -11,8 +11,7 @@ class="btn btn-default" ui-sref="timeline({datasetId: dataset.id})">Timeline Sunburst + class="btn btn-default">Sunburst diff --git a/app/views/directives/timeline-details.html b/app/views/directives/timeline-details.html new file mode 100644 index 0000000..2cd4e48 --- /dev/null +++ b/app/views/directives/timeline-details.html @@ -0,0 +1,31 @@ +
+ Hover over a timeline item for details. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
Name{{item.name | split:'.' | pickRight:1}}
Class{{item.name | split:'.' | pickRight:2}}
Module{{item.name | split:'.' | slice:0:-2 | join:'.'}}
Status{{item.status}}
Tags{{item.tags | join:', '}}
Duration{{item.duration}} seconds
+
diff --git a/app/views/timeline.html b/app/views/timeline.html new file mode 100644 index 0000000..a28cc9a --- /dev/null +++ b/app/views/timeline.html @@ -0,0 +1,42 @@ +
+
+

+ Timeline: {{ timeline.dataset.name }} + #{{ timeline.dataset.id }} +

+
+
+ +
+
+
+
+ {{ timeline.error }} +
+
+
+
+
+
+
+

Timeline

+
+ + +
+
+
+
+
+

Details

+
+ + +
+
+
+