// 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.
var g_browserBridge;
var g_mainView;
// TODO(eroman): The handling of "max" across snapshots is not correct.
// For starters the browser needs to be aware to generate new maximums.
// Secondly, we need to take into account the "max" of intermediary snapshots,
// not just the terminal ones.
/**
* Main entry point called once the page has loaded.
*/
function onLoad() {
g_browserBridge = new BrowserBridge();
g_mainView = new MainView();
}
document.addEventListener('DOMContentLoaded', onLoad);
/**
* This class provides a "bridge" for communicating between the javascript and
* the browser. Used as a singleton.
*/
var BrowserBridge = (function() {
'use strict';
/**
* @constructor
*/
function BrowserBridge() {
}
BrowserBridge.prototype = {
//--------------------------------------------------------------------------
// Messages sent to the browser
//--------------------------------------------------------------------------
sendGetData: function() {
chrome.send('getData');
},
sendResetData: function() {
chrome.send('resetData');
},
//--------------------------------------------------------------------------
// Messages received from the browser.
//--------------------------------------------------------------------------
receivedData: function(data) {
// TODO(eroman): The browser should give an indication of which snapshot
// this data belongs to. For now we always assume it is for the latest.
g_mainView.addDataToSnapshot(data);
},
};
return BrowserBridge;
})();
/**
* This class handles the presentation of our profiler view. Used as a
* singleton.
*/
var MainView = (function() {
'use strict';
// --------------------------------------------------------------------------
// Important IDs in the HTML document
// --------------------------------------------------------------------------
// The search box to filter results.
var FILTER_SEARCH_ID = 'filter-search';
// The container node to put all the "Group by" dropdowns into.
var GROUP_BY_CONTAINER_ID = 'group-by-container';
// The container node to put all the "Sort by" dropdowns into.
var SORT_BY_CONTAINER_ID = 'sort-by-container';
// The DIV to put all the tables into.
var RESULTS_DIV_ID = 'results-div';
// The container node to put all the column (visibility) checkboxes into.
var COLUMN_TOGGLES_CONTAINER_ID = 'column-toggles-container';
// The container node to put all the column (merge) checkboxes into.
var COLUMN_MERGE_TOGGLES_CONTAINER_ID = 'column-merge-toggles-container';
// The anchor which toggles visibility of column checkboxes.
var EDIT_COLUMNS_LINK_ID = 'edit-columns-link';
// The container node to show/hide when toggling the column checkboxes.
var EDIT_COLUMNS_ROW = 'edit-columns-row';
// The checkbox which controls whether things like "Worker Threads" and
// "PAC threads" will be merged together.
var MERGE_SIMILAR_THREADS_CHECKBOX_ID = 'merge-similar-threads-checkbox';
var RESET_DATA_LINK_ID = 'reset-data-link';
var TOGGLE_SNAPSHOTS_LINK_ID = 'snapshots-link';
var SNAPSHOTS_ROW = 'snapshots-row';
var SNAPSHOT_SELECTION_SUMMARY_ID = 'snapshot-selection-summary';
var TAKE_SNAPSHOT_BUTTON_ID = 'take-snapshot-button';
var SAVE_SNAPSHOTS_BUTTON_ID = 'save-snapshots-button';
var SNAPSHOT_FILE_LOADER_ID = 'snapshot-file-loader';
var LOAD_ERROR_ID = 'file-load-error';
var DOWNLOAD_ANCHOR_ID = 'download-anchor';
// --------------------------------------------------------------------------
// Row keys
// --------------------------------------------------------------------------
// Each row of our data is an array of values rather than a dictionary. This
// avoids some overhead from repeating the key string multiple times, and
// speeds up the property accesses a bit. The following keys are well-known
// indexes into the array for various properties.
//
// Note that the declaration order will also define the default display order.
var BEGIN_KEY = 1; // Start at 1 rather than 0 to simplify sorting code.
var END_KEY = BEGIN_KEY;
var KEY_COUNT = END_KEY++;
var KEY_RUN_TIME = END_KEY++;
var KEY_AVG_RUN_TIME = END_KEY++;
var KEY_MAX_RUN_TIME = END_KEY++;
var KEY_QUEUE_TIME = END_KEY++;
var KEY_AVG_QUEUE_TIME = END_KEY++;
var KEY_MAX_QUEUE_TIME = END_KEY++;
var KEY_BIRTH_THREAD = END_KEY++;
var KEY_DEATH_THREAD = END_KEY++;
var KEY_PROCESS_TYPE = END_KEY++;
var KEY_PROCESS_ID = END_KEY++;
var KEY_FUNCTION_NAME = END_KEY++;
var KEY_SOURCE_LOCATION = END_KEY++;
var KEY_FILE_NAME = END_KEY++;
var KEY_LINE_NUMBER = END_KEY++;
var NUM_KEYS = END_KEY - BEGIN_KEY;
// --------------------------------------------------------------------------
// Aggregators
// --------------------------------------------------------------------------
// To generalize computing/displaying the aggregate "counts" for each column,
// we specify an optional "Aggregator" class to use with each property.
// The following are actually "Aggregator factories". They create an
// aggregator instance by calling 'create()'. The instance is then fed
// each row one at a time via the 'consume()' method. After all rows have
// been consumed, the 'getValueAsText()' method will return the aggregated
// value.
/**
* This aggregator counts the number of unique values that were fed to it.
*/
var UniquifyAggregator = (function() {
function Aggregator(key) {
this.key_ = key;
this.valuesSet_ = {};
}
Aggregator.prototype = {
consume: function(e) {
this.valuesSet_[e[this.key_]] = true;
},
getValueAsText: function() {
return getDictionaryKeys(this.valuesSet_).length + ' unique';
},
};
return {
create: function(key) { return new Aggregator(key); }
};
})();
/**
* This aggregator sums a numeric field.
*/
var SumAggregator = (function() {
function Aggregator(key) {
this.key_ = key;
this.sum_ = 0;
}
Aggregator.prototype = {
consume: function(e) {
this.sum_ += e[this.key_];
},
getValue: function() {
return this.sum_;
},
getValueAsText: function() {
return formatNumberAsText(this.getValue());
},
};
return {
create: function(key) { return new Aggregator(key); }
};
})();
/**
* This aggregator computes an average by summing two
* numeric fields, and then dividing the totals.
*/
var AvgAggregator = (function() {
function Aggregator(numeratorKey, divisorKey) {
this.numeratorKey_ = numeratorKey;
this.divisorKey_ = divisorKey;
this.numeratorSum_ = 0;
this.divisorSum_ = 0;
}
Aggregator.prototype = {
consume: function(e) {
this.numeratorSum_ += e[this.numeratorKey_];
this.divisorSum_ += e[this.divisorKey_];
},
getValue: function() {
return this.numeratorSum_ / this.divisorSum_;
},
getValueAsText: function() {
return formatNumberAsText(this.getValue());
},
};
return {
create: function(numeratorKey, divisorKey) {
return {
create: function(key) {
return new Aggregator(numeratorKey, divisorKey);
},
};
}
};
})();
/**
* This aggregator finds the maximum for a numeric field.
*/
var MaxAggregator = (function() {
function Aggregator(key) {
this.key_ = key;
this.max_ = -Infinity;
}
Aggregator.prototype = {
consume: function(e) {
this.max_ = Math.max(this.max_, e[this.key_]);
},
getValue: function() {
return this.max_;
},
getValueAsText: function() {
return formatNumberAsText(this.getValue());
},
};
return {
create: function(key) { return new Aggregator(key); }
};
})();
// --------------------------------------------------------------------------
// Key properties
// --------------------------------------------------------------------------
// Custom comparator for thread names (sorts main thread and IO thread
// higher than would happen lexicographically.)
var threadNameComparator =
createLexicographicComparatorWithExceptions([
'CrBrowserMain',
'Chrome_IOThread',
'Chrome_FileThread',
'Chrome_HistoryThread',
'Chrome_DBThread',
'Still_Alive',
]);
function diffFuncForCount(a, b) {
return b - a;
}
function diffFuncForMax(a, b) {
return b;
}
/**
* Enumerates information about various keys. Such as whether their data is
* expected to be numeric or is a string, a descriptive name (title) for the
* property, and what function should be used to aggregate the property when
* displayed in a column.
*
* --------------------------------------
* The following properties are required:
* --------------------------------------
*
* [name]: This is displayed as the column's label.
* [aggregator]: Aggregator factory that is used to compute an aggregate
* value for this column.
*
* --------------------------------------
* The following properties are optional:
* --------------------------------------
*
* [inputJsonKey]: The corresponding key for this property in the original
* JSON dictionary received from the browser. If this is
* present, values for this key will be automatically
* populated during import.
* [comparator]: A comparator function for sorting this column.
* [textPrinter]: A function that transforms values into the user-displayed
* text shown in the UI. If unspecified, will default to the
* "toString()" function.
* [cellAlignment]: The horizonal alignment to use for columns of this
* property (for instance 'right'). If unspecified will
* default to left alignment.
* [sortDescending]: When first clicking on this column, we will default to
* sorting by |comparator| in ascending order. If this
* property is true, we will reverse that to descending.
* [diff]: Function to call to compute a "difference" value between
* parameters (a, b). This is used when calculating the difference
* between two snapshots. Diffing numeric quantities generally
* involves subtracting, but some fields like max may need to do
* something different.
*/
var KEY_PROPERTIES = [];
KEY_PROPERTIES[KEY_PROCESS_ID] = {
name: 'PID',
cellAlignment: 'right',
aggregator: UniquifyAggregator,
};
KEY_PROPERTIES[KEY_PROCESS_TYPE] = {
name: 'Process type',
aggregator: UniquifyAggregator,
};
KEY_PROPERTIES[KEY_BIRTH_THREAD] = {
name: 'Birth thread',
inputJsonKey: 'birth_thread',
aggregator: UniquifyAggregator,
comparator: threadNameComparator,
};
KEY_PROPERTIES[KEY_DEATH_THREAD] = {
name: 'Exec thread',
inputJsonKey: 'death_thread',
aggregator: UniquifyAggregator,
comparator: threadNameComparator,
};
KEY_PROPERTIES[KEY_FUNCTION_NAME] = {
name: 'Function name',
inputJsonKey: 'birth_location.function_name',
aggregator: UniquifyAggregator,
};
KEY_PROPERTIES[KEY_FILE_NAME] = {
name: 'File name',
inputJsonKey: 'birth_location.file_name',
aggregator: UniquifyAggregator,
};
KEY_PROPERTIES[KEY_LINE_NUMBER] = {
name: 'Line number',
cellAlignment: 'right',
inputJsonKey: 'birth_location.line_number',
aggregator: UniquifyAggregator,
};
KEY_PROPERTIES[KEY_COUNT] = {
name: 'Count',
cellAlignment: 'right',
sortDescending: true,
textPrinter: formatNumberAsText,
inputJsonKey: 'death_data.count',
aggregator: SumAggregator,
diff: diffFuncForCount,
};
KEY_PROPERTIES[KEY_QUEUE_TIME] = {
name: 'Total queue time',
cellAlignment: 'right',
sortDescending: true,
textPrinter: formatNumberAsText,
inputJsonKey: 'death_data.queue_ms',
aggregator: SumAggregator,
diff: diffFuncForCount,
};
KEY_PROPERTIES[KEY_MAX_QUEUE_TIME] = {
name: 'Max queue time',
cellAlignment: 'right',
sortDescending: true,
textPrinter: formatNumberAsText,
inputJsonKey: 'death_data.queue_ms_max',
aggregator: MaxAggregator,
diff: diffFuncForMax,
};
KEY_PROPERTIES[KEY_RUN_TIME] = {
name: 'Total run time',
cellAlignment: 'right',
sortDescending: true,
textPrinter: formatNumberAsText,
inputJsonKey: 'death_data.run_ms',
aggregator: SumAggregator,
diff: diffFuncForCount,
};
KEY_PROPERTIES[KEY_AVG_RUN_TIME] = {
name: 'Avg run time',
cellAlignment: 'right',
sortDescending: true,
textPrinter: formatNumberAsText,
aggregator: AvgAggregator.create(KEY_RUN_TIME, KEY_COUNT),
};
KEY_PROPERTIES[KEY_MAX_RUN_TIME] = {
name: 'Max run time',
cellAlignment: 'right',
sortDescending: true,
textPrinter: formatNumberAsText,
inputJsonKey: 'death_data.run_ms_max',
aggregator: MaxAggregator,
diff: diffFuncForMax,
};
KEY_PROPERTIES[KEY_AVG_QUEUE_TIME] = {
name: 'Avg queue time',
cellAlignment: 'right',
sortDescending: true,
textPrinter: formatNumberAsText,
aggregator: AvgAggregator.create(KEY_QUEUE_TIME, KEY_COUNT),
};
KEY_PROPERTIES[KEY_SOURCE_LOCATION] = {
name: 'Source location',
type: 'string',
aggregator: UniquifyAggregator,
};
/**
* Returns the string name for |key|.
*/
function getNameForKey(key) {
var props = KEY_PROPERTIES[key];
if (props == undefined)
throw 'Did not define properties for key: ' + key;
return props.name;
}
/**
* Ordered list of all keys. This is the order we generally want
* to display the properties in. Default to declaration order.
*/
var ALL_KEYS = [];
for (var k = BEGIN_KEY; k < END_KEY; ++k)
ALL_KEYS.push(k);
// --------------------------------------------------------------------------
// Default settings
// --------------------------------------------------------------------------
/**
* List of keys for those properties which we want to initially omit
* from the table. (They can be re-enabled by clicking [Edit columns]).
*/
var INITIALLY_HIDDEN_KEYS = [
KEY_FILE_NAME,
KEY_LINE_NUMBER,
KEY_QUEUE_TIME,
];
/**
* The ordered list of grouping choices to expose in the "Group by"
* dropdowns. We don't include the numeric properties, since they
* leads to awkward bucketing.
*/
var GROUPING_DROPDOWN_CHOICES = [
KEY_PROCESS_TYPE,
KEY_PROCESS_ID,
KEY_BIRTH_THREAD,
KEY_DEATH_THREAD,
KEY_FUNCTION_NAME,
KEY_SOURCE_LOCATION,
KEY_FILE_NAME,
KEY_LINE_NUMBER,
];
/**
* The ordered list of sorting choices to expose in the "Sort by"
* dropdowns.
*/
var SORT_DROPDOWN_CHOICES = ALL_KEYS;
/**
* The ordered list of all columns that can be displayed in the tables (not
* including whatever has been hidden via [Edit Columns]).
*/
var ALL_TABLE_COLUMNS = ALL_KEYS;
/**
* The initial keys to sort by when loading the page (can be changed later).
*/
var INITIAL_SORT_KEYS = [-KEY_COUNT];
/**
* The default sort keys to use when nothing has been specified.
*/
var DEFAULT_SORT_KEYS = [-KEY_COUNT];
/**
* The initial keys to group by when loading the page (can be changed later).
*/
var INITIAL_GROUP_KEYS = [];
/**
* The columns to give the option to merge on.
*/
var MERGEABLE_KEYS = [
KEY_PROCESS_ID,
KEY_PROCESS_TYPE,
KEY_BIRTH_THREAD,
KEY_DEATH_THREAD,
];
/**
* The columns to merge by default.
*/
var INITIALLY_MERGED_KEYS = [];
/**
* The full set of columns which define the "identity" for a row. A row is
* considered equivalent to another row if it matches on all of these
* fields. This list is used when merging the data, to determine which rows
* should be merged together. The remaining columns not listed in
* IDENTITY_KEYS will be aggregated.
*/
var IDENTITY_KEYS = [
KEY_BIRTH_THREAD,
KEY_DEATH_THREAD,
KEY_PROCESS_TYPE,
KEY_PROCESS_ID,
KEY_FUNCTION_NAME,
KEY_SOURCE_LOCATION,
KEY_FILE_NAME,
KEY_LINE_NUMBER,
];
/**
* The time (in milliseconds) to wait after receiving new data before
* re-drawing it to the screen. The reason we wait a bit is to avoid
* repainting repeatedly during the loading phase (which can slow things
* down). Note that this only slows down the addition of new data. It does
* not impact the latency of user-initiated operations like sorting or
* merging.
*/
var PROCESS_DATA_DELAY_MS = 500;
/**
* The initial number of rows to display (the rest are hidden) when no
* grouping is selected. We use a higher limit than when grouping is used
* since there is a lot of vertical real estate.
*/
var INITIAL_UNGROUPED_ROW_LIMIT = 30;
/**
* The initial number of rows to display (rest are hidden) for each group.
*/
var INITIAL_GROUP_ROW_LIMIT = 10;
/**
* The number of extra rows to show/hide when clicking the "Show more" or
* "Show less" buttons.
*/
var LIMIT_INCREMENT = 10;
// --------------------------------------------------------------------------
// General utility functions
// --------------------------------------------------------------------------
/**
* Returns a list of all the keys in |dict|.
*/
function getDictionaryKeys(dict) {
var keys = [];
for (var key in dict) {
keys.push(key);
}
return keys;
}
/**
* Formats the number |x| as a decimal integer. Strips off any decimal parts,
* and comma separates the number every 3 characters.
*/
function formatNumberAsText(x) {
var orig = x.toFixed(0);
var parts = [];
for (var end = orig.length; end > 0; ) {
var chunk = Math.min(end, 3);
parts.push(orig.substr(end - chunk, chunk));
end -= chunk;
}
return parts.reverse().join(',');
}
/**
* Simple comparator function which works for both strings and numbers.
*/
function simpleCompare(a, b) {
if (a == b)
return 0;
if (a < b)
return -1;
return 1;
}
/**
* Returns a comparator function that compares values lexicographically,
* but special-cases the values in |orderedList| to have a higher
* rank.
*/
function createLexicographicComparatorWithExceptions(orderedList) {
var valueToRankMap = {};
for (var i = 0; i < orderedList.length; ++i)
valueToRankMap[orderedList[i]] = i;
function getCustomRank(x) {
var rank = valueToRankMap[x];
if (rank == undefined)
rank = Infinity; // Unmatched.
return rank;
}
return function(a, b) {
var aRank = getCustomRank(a);
var bRank = getCustomRank(b);
// Not matched by any of our exceptions.
if (aRank == bRank)
return simpleCompare(a, b);
if (aRank < bRank)
return -1;
return 1;
};
}
/**
* Returns dict[key]. Note that if |key| contains periods (.), they will be
* interpreted as meaning a sub-property.
*/
function getPropertyByPath(dict, key) {
var cur = dict;
var parts = key.split('.');
for (var i = 0; i < parts.length; ++i) {
if (cur == undefined)
return undefined;
cur = cur[parts[i]];
}
return cur;
}
/**
* Creates and appends a DOM node of type |tagName| to |parent|. Optionally,
* sets the new node's text to |opt_text|. Returns the newly created node.
*/
function addNode(parent, tagName, opt_text) {
var n = parent.ownerDocument.createElement(tagName);
parent.appendChild(n);
if (opt_text != undefined) {
addText(n, opt_text);
}
return n;
}
/**
* Adds |text| to |parent|.
*/
function addText(parent, text) {
var textNode = parent.ownerDocument.createTextNode(text);
parent.appendChild(textNode);
return textNode;
}
/**
* Deletes all the strings in |array| which appear in |valuesToDelete|.
*/
function deleteValuesFromArray(array, valuesToDelete) {
var valueSet = arrayToSet(valuesToDelete);
for (var i = 0; i < array.length; ) {
if (valueSet[array[i]]) {
array.splice(i, 1);
} else {
i++;
}
}
}
/**
* Deletes all the repeated ocurrences of strings in |array|.
*/
function deleteDuplicateStringsFromArray(array) {
// Build up set of each entry in array.
var seenSoFar = {};
for (var i = 0; i < array.length; ) {
var value = array[i];
if (seenSoFar[value]) {
array.splice(i, 1);
} else {
seenSoFar[value] = true;
i++;
}
}
}
/**
* Builds a map out of the array |list|.
*/
function arrayToSet(list) {
var set = {};
for (var i = 0; i < list.length; ++i)
set[list[i]] = true;
return set;
}
function trimWhitespace(text) {
var m = /^\s*(.*)\s*$/.exec(text);
return m[1];
}
/**
* Selects the option in |select| which has a value of |value|.
*/
function setSelectedOptionByValue(select, value) {
for (var i = 0; i < select.options.length; ++i) {
if (select.options[i].value == value) {
select.options[i].selected = true;
return true;
}
}
return false;
}
/**
* Adds a checkbox to |parent|. The checkbox will have a label on its right
* with text |label|. Returns the checkbox input node.
*/
function addLabeledCheckbox(parent, label) {
var labelNode = addNode(parent, 'label');
var checkbox = addNode(labelNode, 'input');
checkbox.type = 'checkbox';
addText(labelNode, label);
return checkbox;
}
/**
* Return the last component in a path which is separated by either forward
* slashes or backslashes.
*/
function getFilenameFromPath(path) {
var lastSlash = Math.max(path.lastIndexOf('/'),
path.lastIndexOf('\\'));
if (lastSlash == -1)
return path;
return path.substr(lastSlash + 1);
}
/**
* Returns the current time in milliseconds since unix epoch.
*/
function getTimeMillis() {
return (new Date()).getTime();
}
/**
* Toggle a node between hidden/invisible.
*/
function toggleNodeDisplay(n) {
if (n.style.display == '') {
n.style.display = 'none';
} else {
n.style.display = '';
}
}
/**
* Set the visibility state of a node.
*/
function setNodeDisplay(n, visible) {
if (visible) {
n.style.display = '';
} else {
n.style.display = 'none';
}
}
// --------------------------------------------------------------------------
// Functions that augment, bucket, and compute aggregates for the input data.
// --------------------------------------------------------------------------
/**
* Adds new derived properties to row. Mutates the provided dictionary |e|.
*/
function augmentDataRow(e) {
computeDataRowAverages(e);
e[KEY_SOURCE_LOCATION] = e[KEY_FILE_NAME] + ' [' + e[KEY_LINE_NUMBER] + ']';
}
function computeDataRowAverages(e) {
e[KEY_AVG_QUEUE_TIME] = e[KEY_QUEUE_TIME] / e[KEY_COUNT];
e[KEY_AVG_RUN_TIME] = e[KEY_RUN_TIME] / e[KEY_COUNT];
}
/**
* Creates and initializes an aggregator object for each key in |columns|.
* Returns an array whose keys are values from |columns|, and whose
* values are Aggregator instances.
*/
function initializeAggregates(columns) {
var aggregates = [];
for (var i = 0; i < columns.length; ++i) {
var key = columns[i];
var aggregatorFactory = KEY_PROPERTIES[key].aggregator;
aggregates[key] = aggregatorFactory.create(key);
}
return aggregates;
}
function consumeAggregates(aggregates, row) {
for (var key in aggregates)
aggregates[key].consume(row);
}
function bucketIdenticalRows(rows, identityKeys, propertyGetterFunc) {
var identicalRows = {};
for (var i = 0; i < rows.length; ++i) {
var r = rows[i];
var rowIdentity = [];
for (var j = 0; j < identityKeys.length; ++j)
rowIdentity.push(propertyGetterFunc(r, identityKeys[j]));
rowIdentity = rowIdentity.join('\n');
var l = identicalRows[rowIdentity];
if (!l) {
l = [];
identicalRows[rowIdentity] = l;
}
l.push(r);
}
return identicalRows;
}
/**
* Merges the rows in |origRows|, by collapsing the columns listed in
* |mergeKeys|. Returns an array with the merged rows (in no particular
* order).
*
* If |mergeSimilarThreads| is true, then threads with a similar name will be
* considered equivalent. For instance, "WorkerThread-1" and "WorkerThread-2"
* will be remapped to "WorkerThread-*".
*
* If |outputAsDictionary| is false then the merged rows will be returned as a
* flat list. Otherwise the result will be a dictionary, where each row
* has a unique key.
*/
function mergeRows(origRows, mergeKeys, mergeSimilarThreads,
outputAsDictionary) {
// Define a translation function for each property. Normally we copy over
// properties as-is, but if we have been asked to "merge similar threads" we
// we will remap the thread names that end in a numeric suffix.
var propertyGetterFunc;
if (mergeSimilarThreads) {
propertyGetterFunc = function(row, key) {
var value = row[key];
// If the property is a thread name, try to remap it.
if (key == KEY_BIRTH_THREAD || key == KEY_DEATH_THREAD) {
var m = /^(.*[^\d])(\d+)$/.exec(value);
if (m)
value = m[1] + '*';
}
return value;
}
} else {
propertyGetterFunc = function(row, key) { return row[key]; };
}
// Determine which sets of properties a row needs to match on to be
// considered identical to another row.
var identityKeys = IDENTITY_KEYS.slice(0);
deleteValuesFromArray(identityKeys, mergeKeys);
// Set |aggregateKeys| to everything else, since we will be aggregating
// their value as part of the merge.
var aggregateKeys = ALL_KEYS.slice(0);
deleteValuesFromArray(aggregateKeys, IDENTITY_KEYS);
deleteValuesFromArray(aggregateKeys, mergeKeys);
// Group all the identical rows together, bucketed into |identicalRows|.
var identicalRows =
bucketIdenticalRows(origRows, identityKeys, propertyGetterFunc);
var mergedRows = outputAsDictionary ? {} : [];
// Merge the rows and save the results to |mergedRows|.
for (var k in identicalRows) {
// We need to smash the list |l| down to a single row...
var l = identicalRows[k];
var newRow = [];
if (outputAsDictionary) {
mergedRows[k] = newRow;
} else {
mergedRows.push(newRow);
}
// Copy over all the identity columns to the new row (since they
// were the same for each row matched).
for (var i = 0; i < identityKeys.length; ++i)
newRow[identityKeys[i]] = propertyGetterFunc(l[0], identityKeys[i]);
// Compute aggregates for the other columns.
var aggregates = initializeAggregates(aggregateKeys);
// Feed the rows to the aggregators.
for (var i = 0; i < l.length; ++i)
consumeAggregates(aggregates, l[i]);
// Suck out the data generated by the aggregators.
for (var aggregateKey in aggregates)
newRow[aggregateKey] = aggregates[aggregateKey].getValue();
}
return mergedRows;
}
/**
* Takes two dictionaries data1 and data2, and returns a new flat list which
* represents the difference between them. The exact meaning of "difference"
* is column specific, but for most numeric fields (like the count, or total
* time), it is found by subtracting.
*
* Rows in data1 and data2 are expected to use the same scheme for the keys.
* In other words, data1[k] is considered the analagous row to data2[k].
*/
function subtractSnapshots(data1, data2, columnsToExclude) {
// These columns are computed from the other columns. We won't bother
// diffing/aggregating these, but rather will derive them again from the
// final row.
var COMPUTED_AGGREGATE_KEYS = [KEY_AVG_QUEUE_TIME, KEY_AVG_RUN_TIME];
// These are the keys which determine row equality. Since we are not doing
// any merging yet at this point, it is simply the list of all identity
// columns.
var identityKeys = IDENTITY_KEYS.slice(0);
deleteValuesFromArray(identityKeys, columnsToExclude);
// The columns to compute via aggregation is everything else.
var aggregateKeys = ALL_KEYS.slice(0);
deleteValuesFromArray(aggregateKeys, IDENTITY_KEYS);
deleteValuesFromArray(aggregateKeys, COMPUTED_AGGREGATE_KEYS);
deleteValuesFromArray(aggregateKeys, columnsToExclude);
var diffedRows = [];
for (var rowId in data2) {
var row1 = data1[rowId];
var row2 = data2[rowId];
var newRow = [];
// Copy over all the identity columns to the new row (since they
// were the same for each row matched).
for (var i = 0; i < identityKeys.length; ++i)
newRow[identityKeys[i]] = row2[identityKeys[i]];
// Diff the two rows.
if (row1) {
for (var i = 0; i < aggregateKeys.length; ++i) {
var aggregateKey = aggregateKeys[i];
var a = row1[aggregateKey];
var b = row2[aggregateKey];
var diffFunc = KEY_PROPERTIES[aggregateKey].diff;
newRow[aggregateKey] = diffFunc(a, b);
}
} else {
// If the the row doesn't appear in snapshot1, then there is nothing to
// diff, so just copy row2 as is.
for (var i = 0; i < aggregateKeys.length; ++i) {
var aggregateKey = aggregateKeys[i];
newRow[aggregateKey] = row2[aggregateKey];
}
}
if (newRow[KEY_COUNT] == 0) {
// If a row's count has gone to zero, it means there were no new
// occurrences of it in the second snapshot, so remove it.
continue;
}
// Since we excluded the averages during the diffing phase, re-compute
// them using the diffed totals.
computeDataRowAverages(newRow);
diffedRows.push(newRow);
}
return diffedRows;
}
// --------------------------------------------------------------------------
// HTML drawing code
// --------------------------------------------------------------------------
function getTextValueForProperty(key, value) {
if (value == undefined) {
// A value may be undefined as a result of having merging rows. We
// won't actually draw it, but this might be called by the filter.
return '';
}
var textPrinter = KEY_PROPERTIES[key].textPrinter;
if (textPrinter)
return textPrinter(value);
return value.toString();
}
/**
* Renders the property value |value| into cell |td|. The name of this
* property is |key|.
*/
function drawValueToCell(td, key, value) {
// Get a text representation of the value.
var text = getTextValueForProperty(key, value);
// Apply the desired cell alignment.
var cellAlignment = KEY_PROPERTIES[key].cellAlignment;
if (cellAlignment)
td.align = cellAlignment;
if (key == KEY_SOURCE_LOCATION) {
// Linkify the source column so it jumps to the source code. This doesn't
// take into account the particular code this build was compiled from, or
// local edits to source. It should however work correctly for top of tree
// builds.
var m = /^(.*) \[(\d+)\]$/.exec(text);
if (m) {
var filepath = m[1];
var filename = getFilenameFromPath(filepath);
var linenumber = m[2];
var link = addNode(td, 'a', filename + ' [' + linenumber + ']');
// http://chromesrc.appspot.com is a server I wrote specifically for
// this task. It redirects to the appropriate source file; the file
// paths given by the compiler can be pretty crazy and different
// between platforms.
link.href = 'http://chromesrc.appspot.com/?path=' +
encodeURIComponent(filepath) + '&line=' + linenumber;
link.target = '_blank';
return;
}
}
// String values can get pretty long. If the string contains no spaces, then
// CSS fails to wrap it, and it overflows the cell causing the table to get
// really big. We solve this using a hack: insert a <wbr> element after
// every single character. This will allow the rendering engine to wrap the
// value, and hence avoid it overflowing!
var kMinLengthBeforeWrap = 20;
addText(td, text.substr(0, kMinLengthBeforeWrap));
for (var i = kMinLengthBeforeWrap; i < text.length; ++i) {
addNode(td, 'wbr');
addText(td, text.substr(i, 1));
}
}
// --------------------------------------------------------------------------
// Helper code for handling the sort and grouping dropdowns.
// --------------------------------------------------------------------------
function addOptionsForGroupingSelect(select) {
// Add "no group" choice.
addNode(select, 'option', '---').value = '';
for (var i = 0; i < GROUPING_DROPDOWN_CHOICES.length; ++i) {
var key = GROUPING_DROPDOWN_CHOICES[i];
var option = addNode(select, 'option', getNameForKey(key));
option.value = key;
}
}
function addOptionsForSortingSelect(select) {
// Add "no sort" choice.
addNode(select, 'option', '---').value = '';
// Add a divider.
addNode(select, 'optgroup').label = '';
for (var i = 0; i < SORT_DROPDOWN_CHOICES.length; ++i) {
var key = SORT_DROPDOWN_CHOICES[i];
addNode(select, 'option', getNameForKey(key)).value = key;
}
// Add a divider.
addNode(select, 'optgroup').label = '';
// Add the same options, but for descending.
for (var i = 0; i < SORT_DROPDOWN_CHOICES.length; ++i) {
var key = SORT_DROPDOWN_CHOICES[i];
var n = addNode(select, 'option', getNameForKey(key) + ' (DESC)');
n.value = reverseSortKey(key);
}
}
/**
* Helper function used to update the sorting and grouping lists after a
* dropdown changes.
*/
function updateKeyListFromDropdown(list, i, select) {
// Update the list.
if (i < list.length) {
list[i] = select.value;
} else {
list.push(select.value);
}
// Normalize the list, so setting 'none' as primary zeros out everything
// else.
for (var i = 0; i < list.length; ++i) {
if (list[i] == '') {
list.splice(i, list.length - i);
break;
}
}
}
/**
* Comparator for property |key|, having values |value1| and |value2|.
* If the key has defined a custom comparator use it. Otherwise use a
* default "less than" comparison.
*/
function compareValuesForKey(key, value1, value2) {
var comparator = KEY_PROPERTIES[key].comparator;
if (comparator)
return comparator(value1, value2);
return simpleCompare(value1, value2);
}
function reverseSortKey(key) {
return -key;
}
function sortKeyIsReversed(key) {
return key < 0;
}
function sortKeysMatch(key1, key2) {
return Math.abs(key1) == Math.abs(key2);
}
function getKeysForCheckedBoxes(checkboxes) {
var keys = [];
for (var k in checkboxes) {
if (checkboxes[k].checked)
keys.push(k);
}
return keys;
}
// --------------------------------------------------------------------------
/**
* @constructor
*/
function MainView() {
// Make sure we have a definition for each key.
for (var k = BEGIN_KEY; k < END_KEY; ++k) {
if (!KEY_PROPERTIES[k])
throw 'KEY_PROPERTIES[] not defined for key: ' + k;
}
this.init_();
}
MainView.prototype = {
addDataToSnapshot: function(data) {
// TODO(eroman): We need to know which snapshot this data belongs to!
// For now we assume it is the most recent snapshot.
var snapshotIndex = this.snapshots_.length - 1;
var snapshot = this.snapshots_[snapshotIndex];
var pid = data.process_id;
var ptype = data.process_type;
// Save the browser's representation of the data
snapshot.origData.push(data);
// Augment each data row with the process information.
var rows = data.list;
for (var i = 0; i < rows.length; ++i) {
// Transform the data from a dictionary to an array. This internal
// representation is more compact and faster to access.
var origRow = rows[i];
var newRow = [];
newRow[KEY_PROCESS_ID] = pid;
newRow[KEY_PROCESS_TYPE] = ptype;
// Copy over the known properties which have a 1:1 mapping with JSON.
for (var k = BEGIN_KEY; k < END_KEY; ++k) {
var inputJsonKey = KEY_PROPERTIES[k].inputJsonKey;
if (inputJsonKey != undefined) {
newRow[k] = getPropertyByPath(origRow, inputJsonKey);
}
}
if (newRow[KEY_COUNT] == 0) {
// When resetting the data, it is possible for the backend to give us
// counts of "0". There is no point adding these rows (in fact they
// will cause us to do divide by zeros when calculating averages and
// stuff), so we skip past them.
continue;
}
// Add our computed properties.
augmentDataRow(newRow);
snapshot.flatData.push(newRow);
}
if (!arrayToSet(this.getSelectedSnapshotIndexes_())[snapshotIndex]) {
// Optimization: If this snapshot is not a data dependency for the
// current display, then don't bother updating anything.
return;
}
// We may end up calling addDataToSnapshot_() repeatedly (once for each
// process). To avoid this from slowing us down we do bulk updates on a
// timer.
this.updateMergedDataSoon_();
},
updateMergedDataSoon_: function() {
if (this.updateMergedDataPending_) {
// If a delayed task has already been posted to re-merge the data,
// then we don't need to do anything extra.
return;
}
// Otherwise schedule updateMergedData_() to be called later. We want it
// to be called no more than once every PROCESS_DATA_DELAY_MS
// milliseconds.
if (this.lastUpdateMergedDataTime_ == undefined)
this.lastUpdateMergedDataTime_ = 0;
var timeSinceLastMerge = getTimeMillis() - this.lastUpdateMergedDataTime_;
var timeToWait = Math.max(0, PROCESS_DATA_DELAY_MS - timeSinceLastMerge);
var functionToRun = function() {
// Do the actual update.
this.updateMergedData_();
// Keep track of when we last ran.
this.lastUpdateMergedDataTime_ = getTimeMillis();
this.updateMergedDataPending_ = false;
}.bind(this);
this.updateMergedDataPending_ = true;
window.setTimeout(functionToRun, timeToWait);
},
/**
* Returns a list of the currently selected snapshots. This list is
* guaranteed to be of length 1 or 2.
*/
getSelectedSnapshotIndexes_: function() {
var indexes = this.getSelectedSnapshotBoxes_();
for (var i = 0; i < indexes.length; ++i)
indexes[i] = indexes[i].__index;
return indexes;
},
/**
* Same as getSelectedSnapshotIndexes_(), only it returns the actual
* checkbox input DOM nodes rather than the snapshot ID.
*/
getSelectedSnapshotBoxes_: function() {
// Figure out which snaphots to use for our data.
var boxes = [];
for (var i = 0; i < this.snapshots_.length; ++i) {
var box = this.getSnapshotCheckbox_(i);
if (box.checked)
boxes.push(box);
}
return boxes;
},
/**
* Re-draw the description that explains which snapshots are currently
* selected (if two snapshots were selected we explain that the *difference*
* between them is being displayed).
*/
updateSnapshotSelectionSummaryDiv_: function() {
var summaryDiv = $(SNAPSHOT_SELECTION_SUMMARY_ID);
var selectedSnapshots = this.getSelectedSnapshotIndexes_();
if (selectedSnapshots.length == 0) {
// This can occur during an attempt to load a file or following file
// load failure. We just ignore it and move on.
} else if (selectedSnapshots.length == 1) {
// If only one snapshot is chosen then we will display that snapshot's
// data in its entirety.
this.flatData_ = this.snapshots_[selectedSnapshots[0]].flatData;
// Don't bother displaying any text when just 1 snapshot is selected,
// since it is obvious what this should do.
summaryDiv.innerText = '';
} else if (selectedSnapshots.length == 2) {
// Otherwise if two snapshots were chosen, show the difference between
// them.
var snapshot1 = this.snapshots_[selectedSnapshots[0]];
var snapshot2 = this.snapshots_[selectedSnapshots[1]];
var timeDeltaInSeconds =
((snapshot2.time - snapshot1.time) / 1000).toFixed(0);
// Explain that what is being shown is the difference between two
// snapshots.
summaryDiv.innerText =
'Showing the difference between snapshots #' +
selectedSnapshots[0] + ' and #' +
selectedSnapshots[1] + ' (' + timeDeltaInSeconds +
' seconds worth of data)';
} else {
// This shouldn't be possible...
throw 'Unexpected number of selected snapshots';
}
},
updateMergedData_: function() {
// Retrieve the merge options.
var mergeColumns = this.getMergeColumns_();
var shouldMergeSimilarThreads = this.shouldMergeSimilarThreads_();
var selectedSnapshots = this.getSelectedSnapshotIndexes_();
// We do merges a bit differently depending if we are displaying the diffs
// between two snapshots, or just displaying a single snapshot.
if (selectedSnapshots.length == 1) {
var snapshot = this.snapshots_[selectedSnapshots[0]];
this.mergedData_ = mergeRows(snapshot.flatData,
mergeColumns,
shouldMergeSimilarThreads,
false);
} else if (selectedSnapshots.length == 2) {
var snapshot1 = this.snapshots_[selectedSnapshots[0]];
var snapshot2 = this.snapshots_[selectedSnapshots[1]];
// Merge the data for snapshot1.
var mergedRows1 = mergeRows(snapshot1.flatData,
mergeColumns,
shouldMergeSimilarThreads,
true);
// Merge the data for snapshot2.
var mergedRows2 = mergeRows(snapshot2.flatData,
mergeColumns,
shouldMergeSimilarThreads,
true);
// Do a diff between the two snapshots.
this.mergedData_ = subtractSnapshots(mergedRows1,
mergedRows2,
mergeColumns);
} else {
throw 'Unexpected number of selected snapshots';
}
// Recompute filteredData_ (since it is derived from mergedData_)
this.updateFilteredData_();
},
updateFilteredData_: function() {
// Recompute filteredData_.
this.filteredData_ = [];
var filterFunc = this.getFilterFunction_();
for (var i = 0; i < this.mergedData_.length; ++i) {
var r = this.mergedData_[i];
if (!filterFunc(r)) {
// Not matched by our filter, discard.
continue;
}
this.filteredData_.push(r);
}
// Recompute groupedData_ (since it is derived from filteredData_)
this.updateGroupedData_();
},
updateGroupedData_: function() {
// Recompute groupedData_.
var groupKeyToData = {};
var entryToGroupKeyFunc = this.getGroupingFunction_();
for (var i = 0; i < this.filteredData_.length; ++i) {
var r = this.filteredData_[i];
var groupKey = entryToGroupKeyFunc(r);
var groupData = groupKeyToData[groupKey];
if (!groupData) {
groupData = {
key: JSON.parse(groupKey),
aggregates: initializeAggregates(ALL_KEYS),
rows: [],
};
groupKeyToData[groupKey] = groupData;
}
// Add the row to our list.
groupData.rows.push(r);
// Update aggregates for each column.
consumeAggregates(groupData.aggregates, r);
}
this.groupedData_ = groupKeyToData;
// Figure out a display order for the groups themselves.
this.sortedGroupKeys_ = getDictionaryKeys(groupKeyToData);
this.sortedGroupKeys_.sort(this.getGroupSortingFunction_());
// Sort the group data.
this.sortGroupedData_();
},
sortGroupedData_: function() {
var sortingFunc = this.getSortingFunction_();
for (var k in this.groupedData_)
this.groupedData_[k].rows.sort(sortingFunc);
// Every cached data dependency is now up to date, all that is left is
// to actually draw the result.
this.redrawData_();
},
getVisibleColumnKeys_: function() {
// Figure out what columns to include, based on the selected checkboxes.
var columns = this.getSelectionColumns_();
columns = columns.slice(0);
// Eliminate columns which we are merging on.
deleteValuesFromArray(columns, this.getMergeColumns_());
// Eliminate columns which we are grouped on.
if (this.sortedGroupKeys_.length > 0) {
// The grouping will be the the same for each so just pick the first.
var randomGroupKey = this.groupedData_[this.sortedGroupKeys_[0]].key;
// The grouped properties are going to be the same for each row in our,
// table, so avoid drawing them in our table!
var keysToExclude = [];
for (var i = 0; i < randomGroupKey.length; ++i)
keysToExclude.push(randomGroupKey[i].key);
deleteValuesFromArray(columns, keysToExclude);
}
// If we are currently showing a "diff", hide the max columns, since we
// are not populating it correctly. See the TODO at the top of this file.
if (this.getSelectedSnapshotIndexes_().length > 1)
deleteValuesFromArray(columns, [KEY_MAX_RUN_TIME, KEY_MAX_QUEUE_TIME]);
return columns;
},
redrawData_: function() {
// Clear the results div, sine we may be overwriting older data.
var parent = $(RESULTS_DIV_ID);
parent.innerHTML = '';
var columns = this.getVisibleColumnKeys_();
// Draw each group.
for (var i = 0; i < this.sortedGroupKeys_.length; ++i) {
var k = this.sortedGroupKeys_[i];
this.drawGroup_(parent, k, columns);
}
},
/**
* Renders the information for a particular group.
*/
drawGroup_: function(parent, groupKey, columns) {
var groupData = this.groupedData_[groupKey];
var div = addNode(parent, 'div');
div.className = 'group-container';
this.drawGroupTitle_(div, groupData.key);
var table = addNode(div, 'table');
this.drawDataTable_(table, groupData, columns, groupKey);
},
/**
* Draws a title into |parent| that describes |groupKey|.
*/
drawGroupTitle_: function(parent, groupKey) {
if (groupKey.length == 0) {
// Empty group key means there was no grouping.
return;
}
var parent = addNode(parent, 'div');
parent.className = 'group-title-container';
// Each component of the group key represents the "key=value" constraint
// for this group. Show these as an AND separated list.
for (var i = 0; i < groupKey.length; ++i) {
if (i > 0)
addNode(parent, 'i', ' and ');
var e = groupKey[i];
addNode(parent, 'b', getNameForKey(e.key) + ' = ');
addNode(parent, 'span', e.value);
}
},
/**
* Renders a table which summarizes all |column| fields for |data|.
*/
drawDataTable_: function(table, data, columns, groupKey) {
table.className = 'results-table';
var thead = addNode(table, 'thead');
var tbody = addNode(table, 'tbody');
var displaySettings = this.getGroupDisplaySettings_(groupKey);
var limit = displaySettings.limit;
this.drawAggregateRow_(thead, data.aggregates, columns);
this.drawTableHeader_(thead, columns);
this.drawTableBody_(tbody, data.rows, columns, limit);
this.drawTruncationRow_(tbody, data.rows.length, limit, columns.length,
groupKey);
},
drawTableHeader_: function(thead, columns) {
var tr = addNode(thead, 'tr');
for (var i = 0; i < columns.length; ++i) {
var key = columns[i];
var th = addNode(tr, 'th', getNameForKey(key));
th.onclick = this.onClickColumn_.bind(this, key);
// Draw an indicator if we are currently sorted on this column.
// TODO(eroman): Should use an icon instead of asterisk!
for (var j = 0; j < this.currentSortKeys_.length; ++j) {
if (sortKeysMatch(this.currentSortKeys_[j], key)) {
var sortIndicator = addNode(th, 'span', '*');
sortIndicator.style.color = 'red';
if (sortKeyIsReversed(this.currentSortKeys_[j])) {
// Use double-asterisk for descending columns.
addText(sortIndicator, '*');
}
break;
}
}
}
},
drawTableBody_: function(tbody, rows, columns, limit) {
for (var i = 0; i < rows.length && i < limit; ++i) {
var e = rows[i];
var tr = addNode(tbody, 'tr');
for (var c = 0; c < columns.length; ++c) {
var key = columns[c];
var value = e[key];
var td = addNode(tr, 'td');
drawValueToCell(td, key, value);
}
}
},
/**
* Renders a row that describes all the aggregate values for |columns|.
*/
drawAggregateRow_: function(tbody, aggregates, columns) {
var tr = addNode(tbody, 'tr');
tr.className = 'aggregator-row';
for (var i = 0; i < columns.length; ++i) {
var key = columns[i];
var td = addNode(tr, 'td');
// Most of our outputs are numeric, so we want to align them to the
// right. However for the unique counts we will center.
if (KEY_PROPERTIES[key].aggregator == UniquifyAggregator) {
td.align = 'center';
} else {
td.align = 'right';
}
var aggregator = aggregates[key];
if (aggregator)
td.innerText = aggregator.getValueAsText();
}
},
/**
* Renders a row which describes how many rows the table has, how many are
* currently hidden, and a set of buttons to show more.
*/
drawTruncationRow_: function(tbody, numRows, limit, numColumns, groupKey) {
var numHiddenRows = Math.max(numRows - limit, 0);
var numVisibleRows = numRows - numHiddenRows;
var tr = addNode(tbody, 'tr');
tr.className = 'truncation-row';
var td = addNode(tr, 'td');
td.colSpan = numColumns;
addText(td, numRows + ' rows');
if (numHiddenRows > 0) {
var s = addNode(td, 'span', ' (' + numHiddenRows + ' hidden) ');
s.style.color = 'red';
}
if (numVisibleRows > LIMIT_INCREMENT) {
addNode(td, 'button', 'Show less').onclick =
this.changeGroupDisplayLimit_.bind(
this, groupKey, -LIMIT_INCREMENT);
}
if (numVisibleRows > 0) {
addNode(td, 'button', 'Show none').onclick =
this.changeGroupDisplayLimit_.bind(this, groupKey, -Infinity);
}
if (numHiddenRows > 0) {
addNode(td, 'button', 'Show more').onclick =
this.changeGroupDisplayLimit_.bind(this, groupKey, LIMIT_INCREMENT);
addNode(td, 'button', 'Show all').onclick =
this.changeGroupDisplayLimit_.bind(this, groupKey, Infinity);
}
},
/**
* Adjusts the row limit for group |groupKey| by |delta|.
*/
changeGroupDisplayLimit_: function(groupKey, delta) {
// Get the current settings for this group.
var settings = this.getGroupDisplaySettings_(groupKey, true);
// Compute the adjusted limit.
var newLimit = settings.limit;
var totalNumRows = this.groupedData_[groupKey].rows.length;
newLimit = Math.min(totalNumRows, newLimit);
newLimit += delta;
newLimit = Math.max(0, newLimit);
// Update the settings with the new limit.
settings.limit = newLimit;
// TODO(eroman): It isn't necessary to redraw *all* the data. Really we
// just need to insert the missing rows (everything else stays the same)!
this.redrawData_();
},
/**
* Returns the rendering settings for group |groupKey|. This includes things
* like how many rows to display in the table.
*/
getGroupDisplaySettings_: function(groupKey, opt_create) {
var settings = this.groupDisplaySettings_[groupKey];
if (!settings) {
// If we don't have any settings for this group yet, create some
// default ones.
if (groupKey == '[]') {
// (groupKey of '[]' is what we use for ungrouped data).
settings = {limit: INITIAL_UNGROUPED_ROW_LIMIT};
} else {
settings = {limit: INITIAL_GROUP_ROW_LIMIT};
}
if (opt_create)
this.groupDisplaySettings_[groupKey] = settings;
}
return settings;
},
init_: function() {
this.snapshots_ = [];
// Start fetching the data from the browser; this will be our snapshot #0.
this.takeSnapshot_();
// Data goes through the following pipeline:
// (1) Raw data received from browser, and transformed into our own
// internal row format (where properties are indexed by KEY_*
// constants.)
// (2) We "augment" each row by adding some extra computed columns
// (like averages).
// (3) The rows are merged using current merge settings.
// (4) The rows that don't match current search expression are
// tossed out.
// (5) The rows are organized into "groups" based on current settings,
// and aggregate values are computed for each resulting group.
// (6) The rows within each group are sorted using current settings.
// (7) The grouped rows are drawn to the screen.
this.mergedData_ = [];
this.filteredData_ = [];
this.groupedData_ = {};
this.sortedGroupKeys_ = [];
this.groupDisplaySettings_ = {};
this.fillSelectionCheckboxes_($(COLUMN_TOGGLES_CONTAINER_ID));
this.fillMergeCheckboxes_($(COLUMN_MERGE_TOGGLES_CONTAINER_ID));
$(FILTER_SEARCH_ID).onsearch = this.onChangedFilter_.bind(this);
this.currentSortKeys_ = INITIAL_SORT_KEYS.slice(0);
this.currentGroupingKeys_ = INITIAL_GROUP_KEYS.slice(0);
this.fillGroupingDropdowns_();
this.fillSortingDropdowns_();
$(EDIT_COLUMNS_LINK_ID).onclick =
toggleNodeDisplay.bind(null, $(EDIT_COLUMNS_ROW));
$(TOGGLE_SNAPSHOTS_LINK_ID).onclick =
toggleNodeDisplay.bind(null, $(SNAPSHOTS_ROW));
$(MERGE_SIMILAR_THREADS_CHECKBOX_ID).onchange =
this.onMergeSimilarThreadsCheckboxChanged_.bind(this);
$(RESET_DATA_LINK_ID).onclick =
g_browserBridge.sendResetData.bind(g_browserBridge);
$(TAKE_SNAPSHOT_BUTTON_ID).onclick = this.takeSnapshot_.bind(this);
$(SAVE_SNAPSHOTS_BUTTON_ID).onclick = this.saveSnapshots_.bind(this);
$(SNAPSHOT_FILE_LOADER_ID).onchange = this.loadFileChanged_.bind(this);
},
takeSnapshot_: function() {
// Start a new empty snapshot. Make note of the current time, so we know
// when the snaphot was taken.
this.snapshots_.push({flatData: [], origData: [], time: getTimeMillis()});
// Update the UI to reflect the new snapshot.
this.addSnapshotToList_(this.snapshots_.length - 1);
// Ask the browser for the profiling data. We will receive the data
// later through a callback to addDataToSnapshot_().
g_browserBridge.sendGetData();
},
saveSnapshots_: function() {
var snapshots = [];
for (var i = 0; i < this.snapshots_.length; ++i) {
snapshots.push({ data: this.snapshots_[i].origData,
timestamp: Math.floor(
this.snapshots_[i].time / 1000) });
}
var dump = {
'userAgent': navigator.userAgent,
'version': 1,
'snapshots': snapshots
};
var dumpText = JSON.stringify(dump, null, ' ');
var textBlob = new Blob([dumpText],
{ type: 'octet/stream', endings: 'native' });
var blobUrl = window.webkitURL.createObjectURL(textBlob);
$(DOWNLOAD_ANCHOR_ID).href = blobUrl;
$(DOWNLOAD_ANCHOR_ID).click();
},
loadFileChanged_: function() {
this.loadSnapshots_($(SNAPSHOT_FILE_LOADER_ID).files[0]);
},
loadSnapshots_: function(file) {
if (file) {
var fileReader = new FileReader();
fileReader.onload = this.onLoadSnapshotsFile_.bind(this, file);
fileReader.onerror = this.onLoadSnapshotsFileError_.bind(this, file);
fileReader.readAsText(file);
}
},
onLoadSnapshotsFile_: function(file, event) {
try {
var parsed = null;
parsed = JSON.parse(event.target.result);
if (parsed.version != 1) {
throw new Error('Unrecognized version: ' + parsed.version);
}
if (parsed.snapshots.length < 1) {
throw new Error('File contains no data');
}
this.displayLoadedFile_(file, parsed);
this.hideFileLoadError_();
} catch (error) {
this.displayFileLoadError_('File load failure: ' + error.message);
}
},
clearExistingSnapshots_: function() {
var tbody = $('snapshots-tbody');
this.snapshots_ = [];
tbody.innerHTML = '';
this.updateMergedDataSoon_();
},
displayLoadedFile_: function(file, content) {
this.clearExistingSnapshots_();
$(TAKE_SNAPSHOT_BUTTON_ID).disabled = true;
$(SAVE_SNAPSHOTS_BUTTON_ID).disabled = true;
if (content.snapshots.length > 1) {
setNodeDisplay($(SNAPSHOTS_ROW), true);
}
for (var i = 0; i < content.snapshots.length; ++i) {
var snapshot = content.snapshots[i];
this.snapshots_.push({flatData: [], origData: [],
time: snapshot.timestamp * 1000});
this.addSnapshotToList_(this.snapshots_.length - 1);
var snapshotData = snapshot.data;
for (var j = 0; j < snapshotData.length; ++j) {
this.addDataToSnapshot(snapshotData[j]);
}
}
this.redrawData_();
},
onLoadSnapshotsFileError_: function(file, filedata) {
this.displayFileLoadError_('Error loading ' + file.name);
},
displayFileLoadError_: function(message) {
$(LOAD_ERROR_ID).textContent = message;
$(LOAD_ERROR_ID).hidden = false;
},
hideFileLoadError_: function() {
$(LOAD_ERROR_ID).textContent = '';
$(LOAD_ERROR_ID).hidden = true;
},
getSnapshotCheckbox_: function(i) {
return $(this.getSnapshotCheckboxId_(i));
},
getSnapshotCheckboxId_: function(i) {
return 'snapshotCheckbox-' + i;
},
addSnapshotToList_: function(i) {
var tbody = $('snapshots-tbody');
var tr = addNode(tbody, 'tr');
var id = this.getSnapshotCheckboxId_(i);
var checkboxCell = addNode(tr, 'td');
var checkbox = addNode(checkboxCell, 'input');
checkbox.type = 'checkbox';
checkbox.id = id;
checkbox.__index = i;
checkbox.onclick = this.onSnapshotCheckboxChanged_.bind(this);
addNode(tr, 'td', '#' + i);
var labelCell = addNode(tr, 'td');
var l = addNode(labelCell, 'label');
var dateString = new Date(this.snapshots_[i].time).toLocaleString();
addText(l, dateString);
l.htmlFor = id;
// If we are on snapshot 0, make it the default.
if (i == 0) {
checkbox.checked = true;
checkbox.__time = getTimeMillis();
this.updateSnapshotCheckboxStyling_();
}
},
updateSnapshotCheckboxStyling_: function() {
for (var i = 0; i < this.snapshots_.length; ++i) {
var checkbox = this.getSnapshotCheckbox_(i);
checkbox.parentNode.parentNode.className =
checkbox.checked ? 'selected_snapshot' : '';
}
},
onSnapshotCheckboxChanged_: function(event) {
// Keep track of when we clicked this box (for when we need to uncheck
// older boxes).
event.target.__time = getTimeMillis();
// Find all the checked boxes. Either 1 or 2 can be checked. If a third
// was just checked, then uncheck one of the earlier ones so we only have
// 2.
var checked = this.getSelectedSnapshotBoxes_();
checked.sort(function(a, b) { return b.__time - a.__time; });
if (checked.length > 2) {
for (var i = 2; i < checked.length; ++i)
checked[i].checked = false;
checked.length = 2;
}
// We should always have at least 1 selection. Prevent the user from
// unselecting the final box.
if (checked.length == 0)
event.target.checked = true;
this.updateSnapshotCheckboxStyling_();
this.updateSnapshotSelectionSummaryDiv_();
// Recompute mergedData_ (since it is derived from selected snapshots).
this.updateMergedData_();
},
fillSelectionCheckboxes_: function(parent) {
this.selectionCheckboxes_ = {};
var onChangeFunc = this.onSelectCheckboxChanged_.bind(this);
for (var i = 0; i < ALL_TABLE_COLUMNS.length; ++i) {
var key = ALL_TABLE_COLUMNS[i];
var checkbox = addLabeledCheckbox(parent, getNameForKey(key));
checkbox.checked = true;
checkbox.onchange = onChangeFunc;
addText(parent, ' ');
this.selectionCheckboxes_[key] = checkbox;
}
for (var i = 0; i < INITIALLY_HIDDEN_KEYS.length; ++i) {
this.selectionCheckboxes_[INITIALLY_HIDDEN_KEYS[i]].checked = false;
}
},
getSelectionColumns_: function() {
return getKeysForCheckedBoxes(this.selectionCheckboxes_);
},
getMergeColumns_: function() {
return getKeysForCheckedBoxes(this.mergeCheckboxes_);
},
shouldMergeSimilarThreads_: function() {
return $(MERGE_SIMILAR_THREADS_CHECKBOX_ID).checked;
},
fillMergeCheckboxes_: function(parent) {
this.mergeCheckboxes_ = {};
var onChangeFunc = this.onMergeCheckboxChanged_.bind(this);
for (var i = 0; i < MERGEABLE_KEYS.length; ++i) {
var key = MERGEABLE_KEYS[i];
var checkbox = addLabeledCheckbox(parent, getNameForKey(key));
checkbox.onchange = onChangeFunc;
addText(parent, ' ');
this.mergeCheckboxes_[key] = checkbox;
}
for (var i = 0; i < INITIALLY_MERGED_KEYS.length; ++i) {
this.mergeCheckboxes_[INITIALLY_MERGED_KEYS[i]].checked = true;
}
},
fillGroupingDropdowns_: function() {
var parent = $(GROUP_BY_CONTAINER_ID);
parent.innerHTML = '';
for (var i = 0; i <= this.currentGroupingKeys_.length; ++i) {
// Add a dropdown.
var select = addNode(parent, 'select');
select.onchange = this.onChangedGrouping_.bind(this, select, i);
addOptionsForGroupingSelect(select);
if (i < this.currentGroupingKeys_.length) {
var key = this.currentGroupingKeys_[i];
setSelectedOptionByValue(select, key);
}
}
},
fillSortingDropdowns_: function() {
var parent = $(SORT_BY_CONTAINER_ID);
parent.innerHTML = '';
for (var i = 0; i <= this.currentSortKeys_.length; ++i) {
// Add a dropdown.
var select = addNode(parent, 'select');
select.onchange = this.onChangedSorting_.bind(this, select, i);
addOptionsForSortingSelect(select);
if (i < this.currentSortKeys_.length) {
var key = this.currentSortKeys_[i];
setSelectedOptionByValue(select, key);
}
}
},
onChangedGrouping_: function(select, i) {
updateKeyListFromDropdown(this.currentGroupingKeys_, i, select);
this.fillGroupingDropdowns_();
this.updateGroupedData_();
},
onChangedSorting_: function(select, i) {
updateKeyListFromDropdown(this.currentSortKeys_, i, select);
this.fillSortingDropdowns_();
this.sortGroupedData_();
},
onSelectCheckboxChanged_: function() {
this.redrawData_();
},
onMergeCheckboxChanged_: function() {
this.updateMergedData_();
},
onMergeSimilarThreadsCheckboxChanged_: function() {
this.updateMergedData_();
},
onChangedFilter_: function() {
this.updateFilteredData_();
},
/**
* When left-clicking a column, change the primary sort order to that
* column. If we were already sorted on that column then reverse the order.
*
* When alt-clicking, add a secondary sort column. Similarly, if
* alt-clicking a column which was already being sorted on, reverse its
* order.
*/
onClickColumn_: function(key, event) {
// If this property wants to start off in descending order rather then
// ascending, flip it.
if (KEY_PROPERTIES[key].sortDescending)
key = reverseSortKey(key);
// Scan through our sort order and see if we are already sorted on this
// key. If so, reverse that sort ordering.
var foundIndex = -1;
for (var i = 0; i < this.currentSortKeys_.length; ++i) {
var curKey = this.currentSortKeys_[i];
if (sortKeysMatch(curKey, key)) {
this.currentSortKeys_[i] = reverseSortKey(curKey);
foundIndex = i;
break;
}
}
if (event.altKey) {
if (foundIndex == -1) {
// If we weren't already sorted on the column that was alt-clicked,
// then add it to our sort.
this.currentSortKeys_.push(key);
}
} else {
if (foundIndex != 0 ||
!sortKeysMatch(this.currentSortKeys_[foundIndex], key)) {
// If the column we left-clicked wasn't already our primary column,
// make it so.
this.currentSortKeys_ = [key];
} else {
// If the column we left-clicked was already our primary column (and
// we just reversed it), remove any secondary sorts.
this.currentSortKeys_.length = 1;
}
}
this.fillSortingDropdowns_();
this.sortGroupedData_();
},
getSortingFunction_: function() {
var sortKeys = this.currentSortKeys_.slice(0);
// Eliminate the empty string keys (which means they were unspecified).
deleteValuesFromArray(sortKeys, ['']);
// If no sort is specified, use our default sort.
if (sortKeys.length == 0)
sortKeys = [DEFAULT_SORT_KEYS];
return function(a, b) {
for (var i = 0; i < sortKeys.length; ++i) {
var key = Math.abs(sortKeys[i]);
var factor = sortKeys[i] < 0 ? -1 : 1;
var propA = a[key];
var propB = b[key];
var comparison = compareValuesForKey(key, propA, propB);
comparison *= factor; // Possibly reverse the ordering.
if (comparison != 0)
return comparison;
}
// Tie breaker.
return simpleCompare(JSON.stringify(a), JSON.stringify(b));
};
},
getGroupSortingFunction_: function() {
return function(a, b) {
var groupKey1 = JSON.parse(a);
var groupKey2 = JSON.parse(b);
for (var i = 0; i < groupKey1.length; ++i) {
var comparison = compareValuesForKey(
groupKey1[i].key,
groupKey1[i].value,
groupKey2[i].value);
if (comparison != 0)
return comparison;
}
// Tie breaker.
return simpleCompare(a, b);
};
},
getFilterFunction_: function() {
var searchStr = $(FILTER_SEARCH_ID).value;
// Normalize the search expression.
searchStr = trimWhitespace(searchStr);
searchStr = searchStr.toLowerCase();
return function(x) {
// Match everything when there was no filter.
if (searchStr == '')
return true;
// Treat the search text as a LOWERCASE substring search.
for (var k = BEGIN_KEY; k < END_KEY; ++k) {
var propertyText = getTextValueForProperty(k, x[k]);
if (propertyText.toLowerCase().indexOf(searchStr) != -1)
return true;
}
return false;
};
},
getGroupingFunction_: function() {
var groupings = this.currentGroupingKeys_.slice(0);
// Eliminate the empty string groupings (which means they were
// unspecified).
deleteValuesFromArray(groupings, ['']);
// Eliminate duplicate primary/secondary group by directives, since they
// are redundant.
deleteDuplicateStringsFromArray(groupings);
return function(e) {
var groupKey = [];
for (var i = 0; i < groupings.length; ++i) {
var entry = {key: groupings[i],
value: e[groupings[i]]};
groupKey.push(entry);
}
return JSON.stringify(groupKey);
};
},
};
return MainView;
})();