// 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 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; })();