blob: 4d77aa0e7e6a225c9a4f2f64bda0a5257a880240 [file] [log] [blame]
/* Copyright (c) 2012 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file. */
cr.define('performance_monitor', function() {
'use strict';
/**
* Map of available time resolutions.
* @type {Object.<string, PerformanceMonitor.TimeResolution>}
* @private
*/
var TimeResolutions_ = {
// Prior 15 min, resolution of 15 seconds.
minutes: {id: 0, i18nKey: 'timeLastFifteenMinutes', timeSpan: 900 * 1000,
pointResolution: 1000 * 15},
// Prior hour, resolution of 1 minute.
// Labels at 5 point (5 min) intervals.
hour: {id: 1, i18nKey: 'timeLastHour', timeSpan: 3600 * 1000,
pointResolution: 1000 * 60},
// Prior day, resolution of 24 min.
// Labels at 5 point (2 hour) intervals.
day: {id: 2, i18nKey: 'timeLastDay', timeSpan: 24 * 3600 * 1000,
pointResolution: 1000 * 60 * 24},
// Prior week, resolution of 2.8 hours (168 min).
// Labels at ~8.5 point (daily) intervals.
week: {id: 3, i18nKey: 'timeLastWeek', timeSpan: 7 * 24 * 3600 * 1000,
pointResolution: 1000 * 60 * 168},
// Prior month (30 days), resolution of 12 hours.
// Labels at 14 point (weekly) intervals.
month: {id: 4, i18nKey: 'timeLastMonth', timeSpan: 30 * 24 * 3600 * 1000,
pointResolution: 1000 * 3600 * 12},
// Prior quarter (90 days), resolution of 36 hours.
// Labels at ~9.3 point (fortnightly) intervals.
quarter: {id: 5, i18nKey: 'timeLastQuarter',
timeSpan: 90 * 24 * 3600 * 1000,
pointResolution: 1000 * 3600 * 36},
};
/**
* Map of available date formats in Flot-style format strings.
* @type {Object.<string, string>}
* @private
*/
var TimeFormats_ = {
time: '%h:%M %p',
monthDayTime: '%b %d<br/>%h:%M %p',
monthDay: '%b %d',
yearMonthDay: '%y %b %d',
};
/*
* Table of colors to use for metrics and events. Basically boxing the
* colorwheel, but leaving out yellows and fully saturated colors.
* @type {Array.<string>}
* @private
*/
var ColorTable_ = [
'rgb(255, 128, 128)', 'rgb(128, 255, 128)', 'rgb(128, 128, 255)',
'rgb(128, 255, 255)', 'rgb(255, 128, 255)', // No bright yellow
'rgb(255, 64, 64)', 'rgb( 64, 255, 64)', 'rgb( 64, 64, 255)',
'rgb( 64, 255, 255)', 'rgb(255, 64, 255)', // No medium yellow either
'rgb(128, 64, 64)', 'rgb( 64, 128, 64)', 'rgb( 64, 64, 128)',
'rgb( 64, 128, 128)', 'rgb(128, 64, 128)', 'rgb(128, 128, 64)'
];
/*
* Offset, in ms, by which to subtract to convert GMT to local time.
* @type {number}
* @private
*/
var timezoneOffset_ = new Date().getTimezoneOffset() * 60000;
/*
* Additional range multiplier to ensure that points don't hit the top of
* the graph.
* @type {number}
* @private
*/
var yAxisMargin_ = 1.05;
/*
* Number of time resolution periods to wait between automated update of
* graphs.
* @type {number}
* @private
*/
var intervalMultiple_ = 2;
/*
* Number of milliseconds to wait before deciding that the most recent
* resize event is not going to be followed immediately by another, and
* thus needs handling.
* @type {number}
* @private
*/
var resizeDelay_ = 500;
/*
* The value of the 'No Aggregation' option enum (AGGREGATION_METHOD_NONE) on
* the C++ side. We use this to warn the user that selecting this aggregation
* option will be slow.
*/
var aggregationMethodNone = 0;
/*
* The value of the default aggregation option, 'Median Aggregation'
* (AGGREGATION_METHOD_MEDIAN), on the C++ side.
*/
var aggregationMethodMedian = 1;
/** @constructor */
function PerformanceMonitor() {
this.__proto__ = PerformanceMonitor.prototype;
/** Information regarding a certain time resolution option, including an
* enumerative id, a readable name, the timespan in milliseconds prior to
* |now|, data point resolution in milliseconds, and time-label frequency
* in data points per label.
* @typedef {{
* id: number,
* name: string,
* timeSpan: number,
* pointResolution: number,
* labelEvery: number,
* }}
*/
PerformanceMonitor.TimeResolution;
/**
* Detailed information on a metric in the UI. |metricId| is a unique
* identifying number for the metric, provided by the webui, and assumed to
* be densely populated. |description| is a localized string description
* suitable for mouseover on the metric. |category| corresponds to a
* category object to which the metric belongs (see |metricCategoryMap_|).
* |color| is the color in which the metric is displayed on the graphs.
* |maxValue| is a value by which to scale the y-axis, in order to avoid
* constant resizing to fit the present data. |checkbox| is the HTML element
* for the checkbox which toggles the metric's display. |enabled| indicates
* whether or not the metric is being actively displayed. |data| is the
* collection of data for the metric.
*
* For |data|, the inner-most array represents a point in a pair of numbers,
* representing time and value (this will always be of length 2). The
* array above is the collection of points within a series, which is an
* interval for which PerformanceMonitor was active. The outer-most array
* is the collection of these series.
*
* @typedef {{
* metricId: number,
* description: string,
* category: !Object,
* color: string,
* maxValue: number,
* checkbox: HTMLElement,
* enabled: boolean,
* data: ?Array.<Array<Array<number> > >
* }}
*/
PerformanceMonitor.MetricDetails;
/**
* Similar data for events as for metrics, though no y-axis info is needed
* since events are simply labeled markers at X locations.
*
* The |data| field follows a special rule not describable in
* JSDoc: Aside from the |time| key, each event type has varying other
* properties, with unknown key names, which properties must still be
* displayed. Such properties always have value of form
* {label: 'some label', value: 'some value'}, with label and value
* internationalized.
*
* @typedef {{
* eventId: number,
* name: string,
* popupTitle: string,
* description: string,
* color: string,
* checkbox: HTMLElement,
* enabled: boolean
* data: ?Array.<{time: number}>
* }}
*/
PerformanceMonitor.EventDetails;
/**
* The collection of divs that compose a chart on the UI, plus the metricIds
* of any metric which should be shown on the chart (whether the metric is
* enabled or not). The |mainDiv| is the full element, under which all other
* divs are nested. The |grid| is the div into which the |plot| (which is
* the core of the graph, including the axis, gridlines, dataseries, etc)
* goes. The |yaxisLabel| is nested under the mainDiv, and shows the units
* for the chart.
*
* @typedef {{
* mainDiv: HTMLDivElement,
* grid: HTMLDivElement,
* plot: HTMLDivElement,
* yaxisLabel: HTMLDivElement,
* metricIds: ?Array.<number>
*/
PerformanceMonitor.Chart;
/**
* The time range which we are currently viewing, with the start and end of
* the range, the TimeResolution, and an appropriate for display (this
* format is the string structure which Flot expects for its setting).
* @typedef {{
* @type {{
* start: number,
* end: number,
* resolution: PerformanceMonitor.TimeResolution
* format: string
* }}
* @private
*/
this.range_ = { 'start': 0, 'end': 0, 'resolution': undefined };
/**
* The map containing the available TimeResolutions and the radio button to
* which each corresponds. The key is the id field from the TimeResolution
* object.
* @type {Object.<string, {
* option: PerformanceMonitor.TimeResolution,
* element: HTMLElement
* }>}
* @private
*/
this.timeResolutionRadioMap_ = {};
/**
* The map containing the available Aggregation Methods and the radio button
* to which each corresponds. The different methods are retrieved from the
* WebUI, and the information about the method is stored in the 'option'
* field. The key to the map is the id of the aggregation method.
*
* @type {Object.<string, {
* option: {
* id: number,
* name: string,
* description: string,
* },
* element: HTMLElement
* }>}
* @private
*/
this.aggregationRadioMap_ = {};
/**
* Metrics fall into categories that have common units and thus may
* share a common graph, or share y-axes within a multi-y-axis graph.
* Each category has a unique identifying metricCategoryId; a localized
* name, mouseover description, and unit; and an array of all the metrics
* which are in this category. The key is |metricCategoryId|.
*
* @type {Object.<string, {
* metricCategoryId: number,
* name: string,
* description: string,
* unit: string,
* details: Array.<{!PerformanceMonitor.MetricDetails}>,
* }>}
* @private
*/
this.metricCategoryMap_ = {};
/**
* Comprehensive map from metricId to MetricDetails.
* @type {Object.<string, {PerformanceMonitor.MetricDetails}>}
* @private
*/
this.metricDetailsMap_ = {};
/**
* Events fall into categories just like metrics, above. This category
* grouping is not as important as that for metrics, since events
* needn't share maxima, y-axes, nor units, and since events appear on
* all charts. But grouping of event categories in the event-selection
* UI is still useful. The key is the id of the event category.
*
* @type {Object.<string, {
* eventCategoryId: number,
* name: string,
* description: string,
* details: !Array.<!PerformanceMonitor.EventDetails>,
* }>}
* @private
*/
this.eventCategoryMap_ = {};
/**
* Comprehensive map from eventId to EventDetails.
* @type {Object.<string, {PerformanceMonitor.EventDetails}>}
* @private
*/
this.eventDetailsMap_ = {};
/**
* Time periods in which the browser was active and collecting metrics
* and events.
* @type {!Array.<{start: number, end: number}>}
* @private
*/
this.intervals_ = [];
/**
* The record of all the warnings which are currently active (or empty if no
* warnings are being displayed).
* @type {!Array.<string>}
* @private
*/
this.activeWarnings_ = [];
/**
* Handle of timer interval function used to update charts
* @type {Object}
* @private
*/
this.updateTimer_ = null;
/**
* Handle of timer interval function used to check for resizes. Nonnull
* only when resize events are coming steadily.
* @type {Object}
* @private
*/
this.resizeTimer_ = null;
/**
* The status of all calls for data, stored in order to keep track of the
* internal state. This stores an attribute for each type of repeated data
* call (for now, only metrics and events), which will be true if we are
* awaiting data and false otherwise.
* @type {Object.<string, boolean>}
* @private
*/
this.awaitingDataCalls_ = {};
/**
* The progress into the initialization process. This must be stored, since
* certain tasks must be performed in a specific order which cannot be
* statically determined. Mainly, we must not request any data until the
* metrics, events, aggregation method, and time range have all been set.
* This object contains an attribute for each stage of the initialization
* process, which is set to true if the stage has been completed.
* @type {Object.<string, boolean>}
* @private
*/
this.initProgress_ = { 'aggregation': false,
'events': false,
'metrics': false,
'timeRange': false };
/**
* All PerformanceMonitor.Chart objects available in the display, whether
* hidden or visible.
* @type {Array.<PerformanceMonitor.Chart>}
* @private
*/
this.charts_ = [];
this.setupStaticControlPanelFeatures_();
chrome.send('getFlagEnabled');
chrome.send('getAggregationTypes');
chrome.send('getEventTypes');
chrome.send('getMetricTypes');
}
PerformanceMonitor.prototype = {
/**
* Display the appropriate warning at the top of the page.
* @param {string} warningId the id of the HTML element with the warning
* to display; this does not include the '#'.
*/
showWarning: function(warningId) {
if (this.activeWarnings_.indexOf(warningId) != -1)
return;
if (this.activeWarnings_.length == 0)
$('#warnings-box')[0].style.display = 'block';
$('#' + warningId)[0].style.display = 'block';
this.activeWarnings_.push(warningId);
},
/**
* Hide the warning, and, if that was the only warning showing, the entire
* warnings box.
* @param {string} warningId the id of the HTML element with the warning
* to display; this does not include the '#'.
*/
hideWarning: function(warningId) {
var index = this.activeWarnings_.indexOf(warningId);
if (index == -1)
return;
$('#' + warningId)[0].style.display = 'none';
this.activeWarnings_.splice(index, 1);
if (this.activeWarnings_.length == 0)
$('#warnings-box')[0].style.display = 'none';
},
/**
* Receive an indication of whether or not the kPerformanceMonitorGathering
* flag has been enabled and, if not, warn the user of such.
* @param {boolean} flagEnabled indicates whether or not the flag has been
* enabled.
*/
getFlagEnabledCallback: function(flagEnabled) {
if (!flagEnabled)
this.showWarning('flag-not-enabled-warning');
},
/**
* Return true if we are not awaiting any returning data calls, and false
* otherwise.
* @return {boolean} The value indicating whether or not we are actively
* fetching data.
*/
fetchingData_: function() {
return this.awaitingDataCalls_.metrics == true ||
this.awaitingDataCalls_.events == true;
},
/**
* Return true if the main steps of initialization prior to the first draw
* are complete, and false otherwise.
* @return {boolean} The value indicating whether or not the initialization
* process has finished.
*/
isInitialized_: function() {
return this.initProgress_.aggregation == true &&
this.initProgress_.events == true &&
this.initProgress_.metrics == true &&
this.initProgress_.timeRange == true;
},
/**
* Refresh all data areas.
*/
refreshAll: function() {
this.refreshMetrics();
this.refreshEvents();
},
/**
* Receive a list of all the aggregation methods. Populate
* |this.aggregationRadioMap_| to reflect said list. Create the section of
* radio buttons for the aggregation methods, and choose the first method
* by default.
* @param {Array<{
* id: number,
* name: string,
* description: string
* }>} methods All aggregation methods needing radio buttons.
*/
getAggregationTypesCallback: function(methods) {
methods.forEach(function(method) {
this.aggregationRadioMap_[method.id] = { 'option': method };
}, this);
this.setupRadioButtons_($('#choose-aggregation')[0],
this.aggregationRadioMap_,
this.setAggregationMethod,
aggregationMethodMedian,
'aggregation-methods');
this.setAggregationMethod(aggregationMethodMedian);
this.initProgress_.aggregation = true;
if (this.isInitialized_())
this.refreshAll();
},
/**
* Receive a list of all metric categories, each with its corresponding
* list of metric details. Populate |this.metricCategoryMap_| and
* |this.metricDetailsMap_| to reflect said list. Reconfigure the
* checkbox set for metric selection.
* @param {Array.<{
* metricCategoryId: number,
* name: string,
* unit: string,
* description: string,
* details: Array.<{
* metricId: number,
* name: string,
* description: string
* }>
* }>} categories All metric categories needing charts and checkboxes.
*/
getMetricTypesCallback: function(categories) {
categories.forEach(function(category) {
this.addCategoryChart_(category);
this.metricCategoryMap_[category.metricCategoryId] = category;
category.details.forEach(function(metric) {
metric.color = ColorTable_[metric.metricId % ColorTable_.length];
metric.maxValue = 1;
metric.divs = [];
metric.data = null;
metric.category = category;
this.metricDetailsMap_[metric.metricId] = metric;
}, this);
}, this);
this.setupCheckboxes_($('#choose-metrics')[0],
this.metricCategoryMap_, 'metricId', this.addMetric, this.dropMetric);
for (var metric in this.metricDetailsMap_) {
this.metricDetailsMap_[metric].checkbox.checked = true;
this.metricDetailsMap_[metric].enabled = true;
}
this.initProgress_.metrics = true;
if (this.isInitialized_())
this.refreshAll();
},
/**
* Receive a list of all event categories, each with its correspoinding
* list of event details. Populate |this.eventCategoryMap_| and
* |this.eventDetailsMap| to reflect said list. Reconfigure the
* checkbox set for event selection.
* @param {Array.<{
* eventCategoryId: number,
* name: string,
* description: string,
* details: Array.<{
* eventId: number,
* name: string,
* description: string
* }>
* }>} categories All event categories needing charts and checkboxes.
*/
getEventTypesCallback: function(categories) {
categories.forEach(function(category) {
this.eventCategoryMap_[category.eventCategoryId] = category;
category.details.forEach(function(event) {
event.color = ColorTable_[event.eventId % ColorTable_.length];
event.divs = [];
event.data = null;
this.eventDetailsMap_[event.eventId] = event;
}, this);
}, this);
this.setupCheckboxes_($('#choose-events')[0], this.eventCategoryMap_,
'eventId', this.addEventType, this.dropEventType);
this.initProgress_.events = true;
if (this.isInitialized_())
this.refreshAll();
},
/**
* Set up the aspects of the control panel which are not dependent upon the
* information retrieved from PerformanceMonitor's database; this includes
* the Time Resolutions and Aggregation Methods radio sections.
* @private
*/
setupStaticControlPanelFeatures_: function() {
// Initialize the options in the |timeResolutionRadioMap_| and set the
// localized names for the time resolutions.
for (var key in TimeResolutions_) {
var resolution = TimeResolutions_[key];
this.timeResolutionRadioMap_[resolution.id] = { 'option': resolution };
resolution.name = loadTimeData.getString(resolution.i18nKey);
}
// Setup the Time Resolution radio buttons, and select the default option
// of minutes (finer resolution in order to ensure that the user sees
// something at startup).
this.setupRadioButtons_($('#choose-time-range')[0],
this.timeResolutionRadioMap_,
this.changeTimeResolution_,
TimeResolutions_.minutes.id,
'time-resolutions');
// Set the default selection to 'Minutes' and set the time range.
this.setTimeRange(TimeResolutions_.minutes,
Date.now(),
true); // Auto-refresh the chart.
var forwardButton = $('#forward-time')[0];
forwardButton.addEventListener('click', this.forwardTime.bind(this));
var backButton = $('#back-time')[0];
backButton.addEventListener('click', this.backTime.bind(this));
this.initProgress_.timeRange = true;
if (this.isInitialized_())
this.refreshAll();
},
/**
* Change the current time resolution. The visible range will stay centered
* around the current center unless the latest edge crosses now(), in which
* case it will be pinned there and start auto-updating.
* @param {number} mapId the index into the |timeResolutionRadioMap_| of the
* selected resolution.
*/
changeTimeResolution_: function(mapId) {
var newEnd;
var now = Date.now();
var newResolution = this.timeResolutionRadioMap_[mapId].option;
// If we are updating the timer, then we know that we are already ending
// at the perceived current time (which may be different than the actual
// current time, since we don't update continuously).
newEnd = this.updateTimer_ ? now :
Math.min(now, this.range_.end + (newResolution.timeSpan -
this.range_.resolution.timeSpan) / 2);
this.setTimeRange(newResolution, newEnd, newEnd == now);
},
/**
* Generalized function to create checkboxes for either events
* or metrics, given a |div| into which to put the checkboxes, and a
* |optionCategoryMap| describing the checkbox structure.
*
* For instance, |optionCategoryMap| might be metricCategoryMap_, with
* contents thus:
*
* optionCategoryMap : {
* 1: {
* name: 'CPU',
* details: [
* {
* metricId: 1,
* name: 'CPU Usage',
* description:
* 'The combined CPU usage of all processes related to Chrome',
* color: 'rgb(255, 128, 128)'
* }
* ],
* 2: {
* name : 'Memory',
* details: [
* {
* metricId: 2,
* name: 'Private Memory Usage',
* description:
* 'The combined private memory usage of all processes related
* to Chrome',
* color: 'rgb(128, 255, 128)'
* },
* {
* metricId: 3,
* name: 'Shared Memory Usage',
* description:
* 'The combined shared memory usage of all processes related
* to Chrome',
* color: 'rgb(128, 128, 255)'
* }
* ]
* }
*
* and we would call setupCheckboxes_ thus:
*
* this.setupCheckboxes_(<parent div>, this.metricCategoryMap_, 'metricId',
* this.addMetric, this.dropMetric);
*
* MetricCategoryMap_'s values each have a |name| and |details| property.
* SetupCheckboxes_ creates one major header for each such value, with title
* given by the |name| field. Under each major header are checkboxes,
* one for each element in the |details| property. The checkbox titles
* come from the |name| property of each |details| object,
* and they each have an associated colored icon matching the |color|
* property of the details object.
*
* So, for the example given, the generated HTML looks thus:
*
* <div>
* <h3 class="category-heading">CPU</h3>
* <div class="checkbox-group">
* <div>
* <label class="input-label" title=
* "The combined CPU usage of all processes related to Chrome">
* <input type="checkbox">
* <span>CPU</span>
* </label>
* </div>
* </div>
* </div>
* <div>
* <h3 class="category-heading">Memory</h3>
* <div class="checkbox-group">
* <div>
* <label class="input-label" title= "The combined private memory \
* usage of all processes related to Chrome">
* <input type="checkbox">
* <span>Private Memory</span>
* </label>
* </div>
* <div>
* <label class="input-label" title= "The combined shared memory \
* usage of all processes related to Chrome">
* <input type="checkbox">
* <span>Shared Memory</span>
* </label>
* </div>
* </div>
* </div>
*
* The checkboxes for each details object call addMetric or
* dropMetric as they are checked and unchecked, passing the relevant
* |metricId| value. Parameter 'metricId' identifies key |metricId| as the
* identifying property to pass to the methods. So, for instance, checking
* the CPU Usage box results in a call to this.addMetric(1), since
* metricCategoryMap_[1].details[0].metricId == 1.
*
* In general, |optionCategoryMap| must have values that each include
* a property |name|, and a property |details|. The |details| value must
* be an array of objects that in turn each have an identifying property
* with key given by parameter |idKey|, plus a property |name| and a
* property |color|.
*
* @param {!HTMLDivElement} div A <div> into which to put checkboxes.
* @param {!Object} optionCategoryMap A map of metric/event categories.
* @param {string} idKey The key of the id property.
* @param {!function(this:Controller, Object)} check
* The function to select an entry (metric or event).
* @param {!function(this:Controller, Object)} uncheck
* The function to deselect an entry (metric or event).
* @private
*/
setupCheckboxes_: function(div, optionCategoryMap, idKey, check, uncheck) {
var categoryTemplate = $('#category-template')[0];
var checkboxTemplate = $('#checkbox-template')[0];
for (var c in optionCategoryMap) {
var category = optionCategoryMap[c];
var template = categoryTemplate.cloneNode(true);
template.id = '';
var heading = template.querySelector('.category-heading');
heading.innerText = category.name;
heading.title = category.description;
var checkboxGroup = template.querySelector('.checkbox-group');
category.details.forEach(function(details) {
var checkbox = checkboxTemplate.cloneNode(true);
checkbox.id = '';
var input = checkbox.querySelector('input');
details.checkbox = input;
input.checked = false;
input.option = details[idKey];
input.addEventListener('change', function(e) {
(e.target.checked ? check : uncheck).call(this, e.target.option);
}.bind(this));
checkbox.querySelector('span').innerText = details.name;
checkbox.querySelector('.input-label').title = details.description;
checkboxGroup.appendChild(checkbox);
}, this);
div.appendChild(template);
}
},
/**
* Generalized function to create radio buttons in a collection of
* |collectionName|, given a |div| into which the radio buttons are placed
* and a |optionMap| describing the radio buttons' options.
*
* optionMaps have two guaranteed fields - 'option' and 'element'. The
* 'option' field corresponds to the item which the radio button will be
* representing (e.g., a particular aggregation method).
* - Each 'option' is guaranteed to have a 'value', a 'name', and a
* 'description'. 'Value' holds the id of the option, while 'name' and
* 'description' are internationalized strings for the radio button's
* content.
* - 'Element' is the field devoted to the HTMLElement for the radio
* button corresponding to that entry; this will be set in this
* function.
*
* Assume that |optionMap| is |aggregationRadioMap_|, as follows:
* optionMap: {
* 0: {
* option: {
* id: 0
* name: 'Median'
* description: 'Aggregate using median calculations to reduce
* noisiness in reporting'
* },
* element: null
* },
* 1: {
* option: {
* id: 1
* name: 'Mean'
* description: 'Aggregate using mean calculations for the most
* accurate average in reporting'
* },
* element: null
* }
* }
*
* and we would call setupRadioButtons_ with:
* this.setupRadioButtons_(<parent_div>, this.aggregationRadioMap_,
* this.setAggregationMethod, 0, 'aggregation-methods');
*
* The resultant HTML would be:
* <div class="radio">
* <label class="input-label" title="Aggregate using median \
* calculations to reduce noisiness in reporting">
* <input type="radio" name="aggregation-methods" value=0>
* <span>Median</span>
* </label>
* </div>
* <div class="radio">
* <label class="input-label" title="Aggregate using mean \
* calculations for the most accurate average in reporting">
* <input type="radio" name="aggregation-methods" value=1>
* <span>Mean</span>
* </label>
* </div>
*
* If a radio button is selected, |onSelect| is called with the radio
* button's value. The |defaultKey| is used to choose which radio button
* to select at startup; the |onSelect| method is not called on this
* selection.
*
* @param {!HTMLDivElement} div A <div> into which we place the radios.
* @param {!Object} optionMap A map containing the radio button information.
* @param {!function(this:Controller, Object)} onSelect
* The function called when a radio is selected.
* @param {string} defaultKey The key to the radio which should be selected
* initially.
* @param {string} collectionName The name of the radio button collection.
* @private
*/
setupRadioButtons_: function(div,
optionMap,
onSelect,
defaultKey,
collectionName) {
var radioTemplate = $('#radio-template')[0];
for (var key in optionMap) {
var entry = optionMap[key];
var radio = radioTemplate.cloneNode(true);
radio.id = '';
var input = radio.querySelector('input');
input.name = collectionName;
input.enumerator = entry.option.id;
input.option = entry;
radio.querySelector('span').innerText = entry.option.name;
if (entry.option.description != undefined)
radio.querySelector('.input-label').title = entry.option.description;
div.appendChild(radio);
entry.element = input;
}
optionMap[defaultKey].element.click();
div.addEventListener('click', function(e) {
if (!e.target.webkitMatchesSelector('input[type="radio"]'))
return;
onSelect.call(this, e.target.enumerator);
}.bind(this));
},
/**
* Add a new chart for |category|, making it initially hidden,
* with no metrics displayed in it.
* @param {!Object} category The metric category for which to create
* the chart. Category is a value from metricCategoryMap_.
* @private
*/
addCategoryChart_: function(category) {
var chartParent = $('#charts')[0];
var mainDiv = $('#chart-template')[0].cloneNode(true);
mainDiv.id = '';
var yaxisLabel = mainDiv.querySelector('h4');
yaxisLabel.innerText = category.unit;
// Rotation is weird in html. The length of the text affects the x-axis
// placement of the label. We shift it back appropriately.
var width = -1 * (yaxisLabel.offsetWidth / 2) + 20;
var widthString = width.toString() + 'px';
yaxisLabel.style.webkitMarginStart = widthString;
var grid = mainDiv.querySelector('.grid');
mainDiv.hidden = true;
chartParent.appendChild(mainDiv);
grid.hovers = [];
// Set the various fields for the PerformanceMonitor.Chart object, and
// add the new object to |charts_|.
var chart = {};
chart.mainDiv = mainDiv;
chart.yaxisLabel = yaxisLabel;
chart.grid = grid;
chart.metricIds = [];
category.details.forEach(function(details) {
chart.metricIds.push(details.metricId);
});
this.charts_.push(chart);
// Receive hover events from Flot.
// Attached to chart will be properties 'hovers', a list of {x, div}
// pairs. As pos events arrive, check each hover to see if it should
// be hidden or made visible.
$(grid).bind('plothover', function(event, pos, item) {
var tolerance = this.range_.resolution.pointResolution;
grid.hovers.forEach(function(hover) {
hover.div.hidden = hover.x < pos.x - tolerance ||
hover.x > pos.x + tolerance;
});
}.bind(this));
$(window).resize(function() {
if (this.resizeTimer_ != null)
clearTimeout(this.resizeTimer_);
this.resizeTimer_ = setTimeout(this.checkResize_.bind(this),
resizeDelay_);
}.bind(this));
},
/**
* |resizeDelay_| ms have elapsed since the last resize event, and the timer
* for redrawing has triggered. Clear it, and redraw all the charts.
* @private
*/
checkResize_: function() {
clearTimeout(this.resizeTimer_);
this.resizeTimer_ = null;
this.drawCharts();
},
/**
* Set the time range for which to display metrics and events. For
* now, the time range always ends at 'now', but future implementations
* may allow time ranges not so anchored. Also set the format string for
* Flot.
*
* @param {TimeResolution} resolution
* The time resolution at which to display the data.
* @param {number} end Ending time, in ms since epoch, to which to
* set the new time range.
* @param {boolean} autoRefresh Indicates whether we should restart the
* range-update timer.
*/
setTimeRange: function(resolution, end, autoRefresh) {
// If we have a timer and we are no longer updating, or if we need a timer
// for a different resolution, disable the current timer.
if (this.updateTimer_ &&
(this.range_.resolution != resolution || !autoRefresh)) {
clearInterval(this.updateTimer_);
this.updateTimer_ = null;
}
if (autoRefresh && !this.updateTimer_) {
this.updateTimer_ = setInterval(
this.forwardTime.bind(this),
intervalMultiple_ * resolution.pointResolution);
}
this.range_.resolution = resolution;
this.range_.end = Math.floor(end / resolution.pointResolution) *
resolution.pointResolution;
this.range_.start = this.range_.end - resolution.timeSpan;
this.setTimeFormat_();
if (this.isInitialized_())
this.refreshAll();
},
/**
* Set the format string for Flot. For time formats, we display the time
* if we are showing data only for the current day; we display the month,
* day, and time if we are showing data for multiple days at a fine
* resolution; we display the month and day if we are showing data for
* multiple days within the same year at course resolution; and we display
* the year, month, and day if we are showing data for multiple years.
* @private
*/
setTimeFormat_: function() {
// If the range is set to a week or less, then we will need to show times.
if (this.range_.resolution.id <= TimeResolutions_['week'].id) {
var dayStart = new Date();
dayStart.setHours(0);
dayStart.setMinutes(0);
if (this.range_.start >= dayStart.getTime())
this.range_.format = TimeFormats_['time'];
else
this.range_.format = TimeFormats_['monthDayTime'];
} else {
var yearStart = new Date();
yearStart.setMonth(0);
yearStart.setDate(0);
if (this.range_.start >= yearStart.getTime())
this.range_.format = TimeFormats_['monthDay'];
else
this.range_.format = TimeFormats_['yearMonthDay'];
}
},
/**
* Back up the time range by 1/2 of its current span, and cause chart
* redraws.
*/
backTime: function() {
this.setTimeRange(this.range_.resolution,
this.range_.end - this.range_.resolution.timeSpan / 2,
false);
},
/**
* Advance the time range by 1/2 of its current span, or up to the point
* where it ends at the present time, whichever is less.
*/
forwardTime: function() {
var now = Date.now();
var newEnd =
Math.min(now, this.range_.end + this.range_.resolution.timeSpan / 2);
this.setTimeRange(this.range_.resolution, newEnd, newEnd == now);
},
/**
* Set the aggregation method.
* @param {number} methodId The id of the aggregation method.
*/
setAggregationMethod: function(methodId) {
if (methodId != aggregationMethodNone)
this.hideWarning('no-aggregation-warning');
else
this.showWarning('no-aggregation-warning');
this.aggregationMethod = methodId;
if (this.isInitialized_())
this.refreshMetrics();
},
/**
* Add a new metric to the display, fetching its data and triggering a
* chart redraw.
* @param {number} metricId The id of the metric to start displaying.
*/
addMetric: function(metricId) {
var metric = this.metricDetailsMap_[metricId];
metric.enabled = true;
this.refreshMetrics();
},
/**
* Remove a metric from its homechart, triggering a chart redraw.
* @param {number} metricId The metric to stop displaying.
*/
dropMetric: function(metricId) {
var metric = this.metricDetailsMap_[metricId];
metric.enabled = false;
this.drawCharts();
},
/**
* Refresh all metrics which are active on the graph in one call to the
* webui. Results will be returned in getMetricsCallback().
*/
refreshMetrics: function() {
var metrics = [];
for (var metric in this.metricDetailsMap_) {
if (this.metricDetailsMap_[metric].enabled)
metrics.push(this.metricDetailsMap_[metric].metricId);
}
if (!metrics.length)
return;
this.awaitingDataCalls_.metrics = true;
chrome.send('getMetrics',
[metrics,
this.range_.start, this.range_.end,
this.range_.resolution.pointResolution,
this.aggregationMethod]);
},
/**
* The callback from refreshing the metrics. The resulting metrics will be
* returned in a list, containing for each active metric a list of data
* point series, representing the time periods for which PerformanceMonitor
* was active. These data will be in sorted order, and will be aggregated
* according to |aggregationMethod_|. These data are put into a Flot-style
* series, with each point stored in an array of length 2, comprised of the
* time and the value of the point.
* @param Array<{
* metricId: number,
* data: Array<{time: number, value: number}>,
* maxValue: number
* }> results The data for the requested metrics.
*/
getMetricsCallback: function(results) {
results.forEach(function(metric) {
var metricDetails = this.metricDetailsMap_[metric.metricId];
metricDetails.data = [];
// Each data series sent back represents a interval for which
// PerformanceMonitor was active. Iterate through the points of each
// series, converting them to Flot standard (an array of time, value
// pairs).
metric.metrics.forEach(function(series) {
var seriesData = [];
series.forEach(function(point) {
seriesData.push([point.time - timezoneOffset_, point.value]);
});
metricDetails.data.push(seriesData);
});
metricDetails.maxValue = Math.max(metricDetails.maxValue,
metric.maxValue);
}, this);
this.awaitingDataCalls_.metrics = false;
this.drawCharts();
},
/**
* Add a new event to the display, fetching its data and triggering a
* redraw.
* @param {number} eventType The type of event to start displaying.
*/
addEventType: function(eventId) {
this.eventDetailsMap_[eventId].enabled = true;
this.refreshEvents();
},
/*
* Remove an event from the display, triggering a redraw.
* @param {number} eventId The type of event to stop displaying.
*/
dropEventType: function(eventId) {
this.eventDetailsMap_[eventId].enabled = false;
this.drawCharts();
},
/**
* Refresh all events which are active on the graph in one call to the
* webui. Results will be returned in getEventsCallback().
*/
refreshEvents: function() {
var events = [];
for (var eventType in this.eventDetailsMap_) {
if (this.eventDetailsMap_[eventType].enabled)
events.push(this.eventDetailsMap_[eventType].eventId);
}
if (!events.length)
return;
this.awaitingDataCalls_.events = true;
chrome.send('getEvents', [events, this.range_.start, this.range_.end]);
},
/**
* The callback from refreshing events. Resulting events are stored in a
* list object, which contains for each event type requested a series
* of event points. Each event point contains a time and an arbitrary list
* of additional properties to be displayed as a tooltip message for the
* event.
* @param Array.<{
* eventId: number,
* Array.<{time: number}>
* }> results The collection of events for the requested types.
*/
getEventsCallback: function(results) {
results.forEach(function(eventSet) {
var eventType = this.eventDetailsMap_[eventSet.eventId];
eventSet.events.forEach(function(eventData) {
eventData.time -= timezoneOffset_;
});
eventType.data = eventSet.events;
}, this);
this.awaitingDataCalls_.events = false;
this.drawCharts();
},
/**
* Create and return an array of 'markings' (per Flot), representing
* vertical lines at the event time, in the event's color. Also add
* (not per Flot) a |popupTitle| property to each, to be used for
* labeling description popups.
* @return {!Array.<{
* color: string,
* popupContent: string,
* xaxis: {from: number, to: number}
* }>} A marks data structure for Flot to use.
* @private
*/
getEventMarks_: function() {
var enabledEvents = [];
var markings = [];
var explanation;
var date;
for (var eventType in this.eventDetailsMap_) {
if (this.eventDetailsMap_[eventType].enabled)
enabledEvents.push(this.eventDetailsMap_[eventType]);
}
enabledEvents.forEach(function(eventValue) {
eventValue.data.forEach(function(point) {
if (point.time >= this.range_.start - timezoneOffset_ &&
point.time <= this.range_.end - timezoneOffset_) {
date = new Date(point.time + timezoneOffset_);
explanation = '<b>' + eventValue.popupTitle + '<br/>' +
date.toLocaleString() + '</b><br/>';
for (var key in point) {
if (key != 'time') {
var datum = point[key];
// We display all fields with a label-value pair.
if ('label' in datum && 'value' in datum) {
explanation = explanation + '<b>' + datum.label + ': </b>' +
datum.value + ' <br/>';
}
}
}
markings.push({
color: eventValue.color,
popupContent: explanation,
xaxis: { from: point.time, to: point.time }
});
} else {
console.log('Event out of time range ' + this.range_.start +
' -> ' + this.range_.end + ' at: ' + point.time);
}
}, this);
}, this);
return markings;
},
/**
* Return an object containing an array of series for Flot to chart, as well
* as a series of axes (currently this will only be one axis).
* @param {Array.<PerformanceMonitor.MetricDetails>} activeMetrics
* The metrics for which we are generating series.
* @return {!{
* series: !Array.<{
* color: string,
* data: !Array<{time: number, value: number},
* yaxis: {min: number, max: number, labelWidth: number}
* },
* yaxes: !Array.<{min: number, max: number, labelWidth: number}>
* }}
* @private
*/
getChartSeriesAndAxes_: function(activeMetrics) {
var seriesList = [];
var axisList = [];
var axisMap = {};
activeMetrics.forEach(function(metric) {
var categoryId = metric.category.metricCategoryId;
var yaxisNumber = axisMap[categoryId];
// Add a new y-axis if we are encountering this category of metric
// for the first time. Otherwise, update the existing y-axis with
// a new max value if needed. (Presently, we expect only one category
// of metric per chart, but this design permits more in the future.)
if (yaxisNumber === undefined) {
axisList.push({min: 0,
max: metric.maxValue * yAxisMargin_,
labelWidth: 60});
axisMap[categoryId] = yaxisNumber = axisList.length;
} else {
axisList[yaxisNumber - 1].max =
Math.max(axisList[yaxisNumber - 1].max,
metric.maxValue * yAxisMargin_);
}
// Create a Flot-style series for each data series in the metric.
for (var i = 0; i < metric.data.length; ++i) {
seriesList.push({
color: metric.color,
data: metric.data[i],
label: i == 0 ? metric.name : null,
yaxis: yaxisNumber
});
}
}, this);
return { series: seriesList, yaxes: axisList };
},
/**
* Draw each chart which has at least one enabled metric, along with all
* the event markers, if and only if we do not have outstanding calls for
* data.
*/
drawCharts: function() {
// If we are currently waiting for data, do nothing - the callbacks will
// re-call drawCharts when they are done. This way, we can avoid any
// conflicts.
if (this.fetchingData_())
return;
// All charts will share the same xaxis and events.
var eventMarks = this.getEventMarks_();
var xaxis = {
mode: 'time',
timeformat: this.range_.format,
min: this.range_.start - timezoneOffset_,
max: this.range_.end - timezoneOffset_
};
this.charts_.forEach(function(chart) {
var activeMetrics = [];
chart.metricIds.forEach(function(id) {
if (this.metricDetailsMap_[id].enabled)
activeMetrics.push(this.metricDetailsMap_[id]);
}, this);
if (!activeMetrics.length) {
chart.hidden = true;
return;
}
chart.mainDiv.hidden = false;
var chartData = this.getChartSeriesAndAxes_(activeMetrics);
// There is the possibility that we have no data for this particular
// time window and metric, but Flot will not draw the grid without at
// least one data point (regardless of whether that datapoint is
// displayed). Thus, we will add the point (-1, -1) (which is guaranteed
// not to show with our axis bounds), and force Flot to show the chart.
if (chartData.series.length == 0)
chartData.series = [[-1, -1]];
chart.plot = $.plot(chart.grid, chartData.series, {
yaxes: chartData.yaxes,
xaxis: xaxis,
points: { show: true, radius: 1},
lines: { show: true},
grid: {
markings: eventMarks,
hoverable: true,
autoHighlight: true,
backgroundColor: { colors: ['#fff', '#f0f6fc'] },
},
});
// For each event in |eventMarks|, create also a label div, with left
// edge colinear with the event vertical line. Top of label is
// presently a hack-in, putting labels in three tiers of 25px height
// each to avoid overlap. Will need something better.
var labelTemplate = $('#label-template')[0];
for (var i = 0; i < eventMarks.length; i++) {
var mark = eventMarks[i];
var point = chart.plot.pointOffset(
{x: mark.xaxis.to, y: chartData.yaxes[0].max, yaxis: 1});
var labelDiv = labelTemplate.cloneNode(true);
labelDiv.innerHTML = mark.popupContent;
labelDiv.style.left = point.left + 'px';
labelDiv.style.top = (point.top + 100 * (i % 3)) + 'px';
chart.grid.appendChild(labelDiv);
labelDiv.hidden = true;
chart.grid.hovers.push({x: mark.xaxis.to, div: labelDiv});
}
}, this);
},
};
return {
PerformanceMonitor: PerformanceMonitor
};
});
var PerformanceMonitor = new performance_monitor.PerformanceMonitor();