08b7427e29
- Sort by labels if no category is selected - Support page separators in simple txt files. - Fix un-toggling subcategories - Prompt for labels when loading multiple files - Improve parsing performance by using raw for-loops and fixing regexps Change-Id: Ibd388e4134b4c0722a7f44d7eb4c5c56748e5175 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/2759511 Commit-Queue: Camillo Bruni <cbruni@chromium.org> Reviewed-by: Patrick Thier <pthier@chromium.org> Cr-Commit-Position: refs/heads/master@{#73459}
2700 lines
87 KiB
HTML
2700 lines
87 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<!--
|
|
Copyright 2016 the V8 project authors. All rights reserved. Use of this source
|
|
code is governed by a BSD-style license that can be found in the LICENSE file.
|
|
-->
|
|
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>V8 Runtime Call Stats Komparator</title>
|
|
<link rel="stylesheet" type="text/css" href="system-analyzer/index.css">
|
|
<style>
|
|
body {
|
|
font-family: arial;
|
|
}
|
|
|
|
.panel {
|
|
display: none;
|
|
}
|
|
|
|
.loaded .panel {
|
|
display: block;
|
|
}
|
|
|
|
.panel.alwaysVisible {
|
|
display: inherit !important;
|
|
}
|
|
|
|
.error #inputs {
|
|
background-color: var(--error-color);
|
|
}
|
|
|
|
table {
|
|
display: table;
|
|
border-spacing: 0px;
|
|
}
|
|
|
|
tr {
|
|
border-spacing: 0px;
|
|
padding: 10px;
|
|
}
|
|
|
|
td,
|
|
th {
|
|
padding: 3px 10px 3px 5px;
|
|
}
|
|
|
|
.inline {
|
|
display: inline-block;
|
|
vertical-align: middle;
|
|
margin-right: 10px;
|
|
}
|
|
|
|
.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.view {
|
|
display: table;
|
|
}
|
|
|
|
.panel-group {
|
|
display: grid;
|
|
align-content: center;
|
|
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
|
|
grid-auto-flow: row dense;
|
|
grid-gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.column {
|
|
display: table-cell;
|
|
border-right: 1px black dotted;
|
|
min-width: 200px;
|
|
}
|
|
|
|
.column .header {
|
|
padding: 0 10px 0 10px
|
|
}
|
|
|
|
#column {
|
|
display: none;
|
|
}
|
|
|
|
.list {
|
|
width: 100%;
|
|
}
|
|
|
|
select {
|
|
width: 100%
|
|
}
|
|
|
|
.list tbody {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.list tr:nth-child(even) {
|
|
background-color: rgba(0.5, 0.5, 0.5, 0.1);
|
|
}
|
|
|
|
.list tr.child {
|
|
display: none;
|
|
}
|
|
|
|
.list tr.child.visible {
|
|
display: table-row;
|
|
}
|
|
|
|
.list .child .name {
|
|
padding-left: 20px;
|
|
}
|
|
|
|
.list .parent td {
|
|
border-top: 1px solid #AAA;
|
|
}
|
|
|
|
.list .total {
|
|
font-weight: bold
|
|
}
|
|
|
|
.list tr.parent.selected,
|
|
.list tr:nth-child(even).selected,
|
|
tr.selected {
|
|
background-color: rgba(0.5, 0.5, 0.5, 0.1);
|
|
}
|
|
|
|
.codeSearch {
|
|
display: block-inline;
|
|
float: right;
|
|
border-radius: 5px;
|
|
background-color: #333;
|
|
width: 1em;
|
|
text-align: center;
|
|
}
|
|
|
|
.list .position {
|
|
text-align: right;
|
|
display: none;
|
|
}
|
|
|
|
.list div.toggle {
|
|
cursor: pointer;
|
|
}
|
|
|
|
#column_0 .position {
|
|
display: table-cell;
|
|
}
|
|
|
|
#column_0 .name {
|
|
display: table-cell;
|
|
}
|
|
|
|
.list .name {
|
|
display: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.value {
|
|
text-align: right;
|
|
}
|
|
|
|
.selectedVersion {
|
|
font-weight: bold;
|
|
}
|
|
|
|
#baseline {
|
|
width: auto;
|
|
}
|
|
|
|
.pageDetailTable tbody {
|
|
cursor: pointer
|
|
}
|
|
|
|
.pageDetailTable tfoot td {
|
|
border-top: 1px grey solid;
|
|
}
|
|
|
|
#popover {
|
|
position: absolute;
|
|
transform: translateY(-50%) translateX(40px);
|
|
box-shadow: -2px 10px 44px -10px #000;
|
|
border-radius: 5px;
|
|
z-index: 1;
|
|
background-color: var(--surface-color);
|
|
display: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
#popover table {
|
|
position: relative;
|
|
z-index: 1;
|
|
text-align: right;
|
|
margin: 10px;
|
|
}
|
|
|
|
#popover td {
|
|
padding: 3px 0px 3px 5px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.popoverArrow {
|
|
background-color: var(--surface-color);
|
|
position: absolute;
|
|
width: 30px;
|
|
height: 30px;
|
|
transform: translateY(-50%)rotate(45deg);
|
|
top: 50%;
|
|
left: -10px;
|
|
z-index: 0;
|
|
}
|
|
|
|
#popover .name {
|
|
padding: 5px;
|
|
font-weight: bold;
|
|
text-align: center;
|
|
}
|
|
|
|
#popover table .compare {
|
|
display: none
|
|
}
|
|
|
|
#popover table.compare .compare {
|
|
display: table-cell;
|
|
}
|
|
|
|
#popover .compare .time,
|
|
#popover .compare .version {
|
|
padding-left: 10px;
|
|
}
|
|
|
|
.diff .hideDiff {
|
|
display: none;
|
|
}
|
|
|
|
.noDiff .hideNoDiff {
|
|
display: none;
|
|
}
|
|
</style>
|
|
<script src="https://www.gstatic.com/charts/loader.js"></script>
|
|
<script>
|
|
"use strict"
|
|
google.charts.load('current', {
|
|
packages: ['corechart']
|
|
});
|
|
|
|
// Did anybody say monkeypatching?
|
|
if (!NodeList.prototype.forEach) {
|
|
NodeList.prototype.forEach = function (func) {
|
|
for (let i = 0; i < this.length; i++) {
|
|
func(this[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
let versions;
|
|
let pages;
|
|
let selectedPage;
|
|
let baselineVersion;
|
|
let selectedEntry;
|
|
let sortByLabel = false;
|
|
|
|
// Marker to programatically replace the defaultData.
|
|
let defaultData = /*default-data-start*/ undefined /*default-data-end*/;
|
|
|
|
function initialize() {
|
|
// Initialize the stats table and toggle lists.
|
|
let original = $("column");
|
|
let viewBody = $("view").querySelector('.panelBody');
|
|
removeAllChildren(viewBody);
|
|
let i = 0;
|
|
versions.forEach((version) => {
|
|
if (!version.enabled) return;
|
|
// add column
|
|
let column = original.cloneNode(true);
|
|
column.id = "column_" + i;
|
|
// Fill in all versions
|
|
let select = column.querySelector(".version");
|
|
select.id = "selectVersion_" + i;
|
|
// add all select options
|
|
versions.forEach((version) => {
|
|
if (!version.enabled) return;
|
|
let option = document.createElement("option");
|
|
option.textContent = version.name;
|
|
option.version = version;
|
|
select.appendChild(option);
|
|
});
|
|
// Fill in all page versions
|
|
select = column.querySelector(".pageVersion");
|
|
select.id = "select_" + i;
|
|
// add all pages
|
|
versions.forEach((version) => {
|
|
if (!version.enabled) return;
|
|
let optgroup = document.createElement("optgroup");
|
|
optgroup.label = version.name;
|
|
optgroup.version = version;
|
|
version.forEachPage((page) => {
|
|
let option = document.createElement("option");
|
|
option.textContent = page.name;
|
|
option.page = page;
|
|
optgroup.appendChild(option);
|
|
});
|
|
select.appendChild(optgroup);
|
|
});
|
|
viewBody.appendChild(column);
|
|
i++;
|
|
});
|
|
|
|
let select = $('baseline');
|
|
removeAllChildren(select);
|
|
select.appendChild(document.createElement('option'));
|
|
versions.forEach((version) => {
|
|
let option = document.createElement("option");
|
|
option.textContent = version.name;
|
|
option.version = version;
|
|
select.appendChild(option);
|
|
});
|
|
initializeToggleList(versions.versions, $('versionSelector'));
|
|
initializeToggleList(pages.values(), $('pageSelector'));
|
|
initializeToggleList(Group.groups.values(), $('groupSelector'));
|
|
}
|
|
|
|
function initializeToggleList(items, node) {
|
|
let list = node.querySelector('ul');
|
|
removeAllChildren(list);
|
|
items = Array.from(items);
|
|
items.sort(NameComparator);
|
|
items.forEach((item) => {
|
|
let li = document.createElement('li');
|
|
let checkbox = document.createElement('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.checked = item.enabled;
|
|
checkbox.item = item;
|
|
checkbox.addEventListener('click', handleToggleVersionOrPageEnable);
|
|
li.appendChild(checkbox);
|
|
li.appendChild(document.createTextNode(item.name));
|
|
list.appendChild(li);
|
|
});
|
|
}
|
|
|
|
window.addEventListener('popstate', (event) => {
|
|
popHistoryState(event.state);
|
|
});
|
|
|
|
function popHistoryState(state) {
|
|
if (!state.version) return false;
|
|
if (!versions) return false;
|
|
let version = versions.getByName(state.version);
|
|
if (!version) return false;
|
|
let page = version.get(state.page);
|
|
if (!page) return false;
|
|
if (!state.entry) {
|
|
showEntry(page.total);
|
|
} else {
|
|
let entry = page.get(state.entry);
|
|
if (!entry) {
|
|
showEntry(page.total);
|
|
} else {
|
|
showEntry(entry);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function pushHistoryState() {
|
|
let selection = selectedEntry ? selectedEntry : selectedPage;
|
|
if (!selection) return;
|
|
let state = selection.urlParams();
|
|
// Don't push a history state if it didn't change.
|
|
if (JSON.stringify(window.history.state) === JSON.stringify(state)) return;
|
|
let params = "?";
|
|
for (let pairs of Object.entries(state)) {
|
|
params += encodeURIComponent(pairs[0]) + "=" +
|
|
encodeURIComponent(pairs[1]) + "&";
|
|
}
|
|
window.history.pushState(state, selection.toString(), params);
|
|
}
|
|
|
|
function showSelectedEntryInPage(page) {
|
|
if (!selectedEntry) return showPage(page);
|
|
let entry = page.get(selectedEntry.name);
|
|
if (!entry) return showPage(page);
|
|
selectEntry(entry);
|
|
}
|
|
|
|
function showPage(firstPage) {
|
|
let changeSelectedEntry = selectedEntry !== undefined &&
|
|
selectedEntry.page === selectedPage;
|
|
selectedPage = firstPage;
|
|
selectedPage.sort();
|
|
showPageInColumn(firstPage, 0);
|
|
// Show the other versions of this page in the following columns.
|
|
let pageVersions = versions.getPageVersions(firstPage);
|
|
let index = 1;
|
|
pageVersions.forEach((page) => {
|
|
if (page !== firstPage) {
|
|
showPageInColumn(page, index);
|
|
index++;
|
|
}
|
|
});
|
|
if (changeSelectedEntry) {
|
|
showEntryDetail(selectedPage.getEntry(selectedEntry));
|
|
}
|
|
showImpactList(selectedPage);
|
|
pushHistoryState();
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
if (value < min) return min;
|
|
if (value > max) return max;
|
|
return value;
|
|
}
|
|
|
|
function diffColorFromRatio(ratio) {
|
|
if (ratio > 1) {
|
|
// ratio > 1: #FFFFFF => #00FF00
|
|
const red = clamp(((ratio - 1) * 255 * 10) | 0, 0, 255);
|
|
const other = (255 - red).toString(16).padStart(2, '0');
|
|
return `#ff${other}${other}`;
|
|
}
|
|
// ratio < 1: #FF0000 => #FFFFFF
|
|
const green = clamp(((1 - ratio) * 255 * 10) | 0, 0, 255);
|
|
const other = (255 - green).toString(16).padStart(2, '0');
|
|
return `#${other}ff${other}`;
|
|
}
|
|
|
|
function showPageInColumn(page, columnIndex) {
|
|
page.sort();
|
|
let showDiff = columnIndex !== 0;
|
|
if (baselineVersion) showDiff = page.version !== baselineVersion;
|
|
let diffColor = (td, a, b) => { };
|
|
if (showDiff) {
|
|
if (baselineVersion) {
|
|
diffColor = (td, diff, baseline) => {
|
|
if (diff == 0) return;
|
|
const ratio = (baseline + diff) / baseline;
|
|
td.style.color = diffColorFromRatio(ratio);
|
|
};
|
|
} else {
|
|
diffColor = (td, value, reference) => {
|
|
if (value == reference) return;
|
|
const ratio = value / reference;
|
|
td.style.color = diffColorFromRatio(ratio);
|
|
}
|
|
}
|
|
}
|
|
|
|
let column = $('column_' + columnIndex);
|
|
let select = $('select_' + columnIndex);
|
|
// Find the matching option
|
|
selectOption(select, (i, option) => {
|
|
return option.page == page
|
|
});
|
|
let table = column.querySelector("table");
|
|
let oldTbody = table.querySelector('tbody');
|
|
let tbody = document.createElement('tbody');
|
|
let referencePage = selectedPage;
|
|
page.forEachSorted(selectedPage, (parentEntry, entry, referenceEntry) => {
|
|
let tr = document.createElement('tr');
|
|
tbody.appendChild(tr);
|
|
tr.entry = entry;
|
|
tr.parentEntry = parentEntry;
|
|
tr.className = parentEntry === undefined ? 'parent' : 'child';
|
|
// Don't show entries that do not exist on the current page or if we
|
|
// compare against the current page
|
|
if (entry !== undefined && page.version !== baselineVersion) {
|
|
// If we show a diff, use the baselineVersion as the referenceEntry
|
|
if (baselineVersion !== undefined) {
|
|
let baselineEntry = baselineVersion.getEntry(entry);
|
|
if (baselineEntry !== undefined) referenceEntry = baselineEntry
|
|
}
|
|
if (!parentEntry) {
|
|
let node = td(tr, '<div class="toggle">►</div>', 'position');
|
|
node.firstChild.addEventListener('click', handleToggleGroup);
|
|
} else {
|
|
td(tr, entry.position == 0 ? '' : entry.position, 'position');
|
|
}
|
|
addCodeSearchButton(entry,
|
|
td(tr, entry.name, 'name ' + entry.cssClass()));
|
|
|
|
diffColor(
|
|
td(tr, ms(entry.time), 'value time'),
|
|
entry.time, referenceEntry.time);
|
|
diffColor(
|
|
td(tr, percent(entry.timePercent), 'value time'),
|
|
entry.time, referenceEntry.time);
|
|
diffColor(
|
|
td(tr, count(entry.count), 'value count'),
|
|
entry.count, referenceEntry.count);
|
|
} else if (baselineVersion !== undefined && referenceEntry &&
|
|
page.version !== baselineVersion) {
|
|
// Show comparison of entry that does not exist on the current page.
|
|
tr.entry = new Entry(0, referenceEntry.name);
|
|
tr.entry.page = page;
|
|
td(tr, '-', 'position');
|
|
td(tr, referenceEntry.name, 'name');
|
|
diffColor(
|
|
td(tr, ms(referenceEntry.time), 'value time'),
|
|
referenceEntry.time, 0);
|
|
diffColor(
|
|
td(tr, percent(referenceEntry.timePercent), 'value time'),
|
|
referenceEntry.timePercent, 0);
|
|
diffColor(
|
|
td(tr, count(referenceEntry.count), 'value count'),
|
|
referenceEntry.count, 0);
|
|
} else {
|
|
// Display empty entry / baseline entry
|
|
let showBaselineEntry = entry !== undefined;
|
|
if (showBaselineEntry) {
|
|
if (!parentEntry) {
|
|
let node = td(tr, '<div class="toggle">►</div>', 'position');
|
|
node.firstChild.addEventListener('click', handleToggleGroup);
|
|
} else {
|
|
td(tr, entry.position == 0 ? '' : entry.position, 'position');
|
|
}
|
|
td(tr, entry.name, 'name');
|
|
td(tr, ms(entry.time, false), 'value time');
|
|
td(tr, percent(entry.timePercent, false), 'value time');
|
|
td(tr, count(entry.count, false), 'value count');
|
|
} else {
|
|
td(tr, '-', 'position');
|
|
td(tr, referenceEntry.name, 'name');
|
|
td(tr, '-', 'value time');
|
|
td(tr, '-', 'value time');
|
|
td(tr, '-', 'value count');
|
|
}
|
|
}
|
|
});
|
|
table.replaceChild(tbody, oldTbody);
|
|
let versionSelect = column.querySelector('select.version');
|
|
selectOption(versionSelect, (index, option) => {
|
|
return option.version == page.version
|
|
});
|
|
}
|
|
|
|
function showEntry(entry) {
|
|
selectEntry(entry, true);
|
|
}
|
|
|
|
function selectEntry(entry, updateSelectedPage) {
|
|
let needsPageSwitch = true;
|
|
if (updateSelectedPage && selectedPage) {
|
|
entry = selectedPage.version.getEntry(entry);
|
|
needsPageSwitch = updateSelectedPage && entry.page != selectedPage;
|
|
}
|
|
let rowIndex = 0;
|
|
// If clicked in the detail row change the first column to that page.
|
|
if (needsPageSwitch) showPage(entry.page);
|
|
let childNodes = $('column_0').querySelector('.list tbody').childNodes;
|
|
for (let i = 0; i < childNodes.length; i++) {
|
|
if (childNodes[i].entry !== undefined &&
|
|
childNodes[i].entry.name == entry.name) {
|
|
rowIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
let firstEntry = childNodes[rowIndex].entry;
|
|
if (rowIndex) {
|
|
if (firstEntry.parent) showGroup(firstEntry.parent);
|
|
}
|
|
// Deselect all
|
|
$('view').querySelectorAll('.list tbody tr').forEach((tr) => {
|
|
toggleCssClass(tr, 'selected', false);
|
|
});
|
|
// Select the entry row
|
|
$('view').querySelectorAll("tbody").forEach((body) => {
|
|
let row = body.childNodes[rowIndex];
|
|
if (!row) return;
|
|
toggleCssClass(row, 'selected', row.entry && row.entry.name ==
|
|
firstEntry.name);
|
|
});
|
|
if (updateSelectedPage && selectedEntry) {
|
|
entry = selectedEntry.page.version.getEntry(entry);
|
|
}
|
|
if (entry !== selectedEntry) {
|
|
selectedEntry = entry;
|
|
showEntryDetail(entry);
|
|
}
|
|
}
|
|
|
|
function showEntryDetail(entry) {
|
|
showVersionDetails(entry);
|
|
showPageDetails(entry);
|
|
showImpactList(entry.page);
|
|
showGraphs(entry.page);
|
|
pushHistoryState();
|
|
}
|
|
|
|
function showVersionDetails(entry) {
|
|
let table, tbody, entries;
|
|
table = $('versionDetails').querySelector('.versionDetailTable');
|
|
tbody = document.createElement('tbody');
|
|
if (entry !== undefined) {
|
|
$('versionDetails').querySelector('h2 span').textContent =
|
|
entry.name + ' in ' + entry.page.name;
|
|
entries = versions.getPageVersions(entry.page).map(
|
|
(page) => {
|
|
return page.get(entry.name)
|
|
});
|
|
entries.sort((a, b) => {
|
|
return a.time - b.time
|
|
});
|
|
entries.forEach((pageEntry) => {
|
|
if (pageEntry === undefined) return;
|
|
let tr = document.createElement('tr');
|
|
if (pageEntry == entry) tr.className += 'selected';
|
|
tr.entry = pageEntry;
|
|
let isBaselineEntry = pageEntry.page.version == baselineVersion;
|
|
td(tr, pageEntry.page.version.name, 'version');
|
|
td(tr, ms(pageEntry.time, !isBaselineEntry), 'value time');
|
|
td(tr, percent(pageEntry.timePercent, !isBaselineEntry), 'value time');
|
|
td(tr, count(pageEntry.count, !isBaselineEntry), 'value count');
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
table.replaceChild(tbody, table.querySelector('tbody'));
|
|
}
|
|
|
|
function showPageDetails(entry) {
|
|
let table, tbody, entries;
|
|
table = $('pageDetail').querySelector('.pageDetailTable');
|
|
tbody = document.createElement('tbody');
|
|
if (entry === undefined) {
|
|
table.replaceChild(tbody, table.querySelector('tbody'));
|
|
return;
|
|
}
|
|
let version = entry.page.version;
|
|
let showDiff = version !== baselineVersion;
|
|
$('pageDetail').querySelector('h2 span').textContent =
|
|
version.name;
|
|
entries = version.pages.map((page) => {
|
|
if (!page.enabled) return;
|
|
return page.get(entry.name)
|
|
});
|
|
entries.sort((a, b) => {
|
|
let cmp = b.timePercent - a.timePercent;
|
|
if (cmp.toFixed(1) == 0) return b.time - a.time;
|
|
return cmp
|
|
});
|
|
entries.forEach((pageEntry) => {
|
|
if (pageEntry === undefined) return;
|
|
let tr = document.createElement('tr');
|
|
if (pageEntry === entry) tr.className += 'selected';
|
|
tr.entry = pageEntry;
|
|
td(tr, pageEntry.page.name, 'name');
|
|
td(tr, ms(pageEntry.time, showDiff), 'value time');
|
|
td(tr, percent(pageEntry.timePercent, showDiff), 'value time');
|
|
td(tr, percent(pageEntry.timePercentPerEntry, showDiff),
|
|
'value time hideNoDiff');
|
|
td(tr, count(pageEntry.count, showDiff), 'value count');
|
|
tbody.appendChild(tr);
|
|
});
|
|
// show the total for all pages
|
|
let tds = table.querySelectorAll('tfoot td');
|
|
tds[1].textContent = ms(entry.getTimeImpact(), showDiff);
|
|
// Only show the percentage total if we are in diff mode:
|
|
tds[2].textContent = percent(entry.getTimePercentImpact(), showDiff);
|
|
tds[3].textContent = '';
|
|
tds[4].textContent = count(entry.getCountImpact(), showDiff);
|
|
table.replaceChild(tbody, table.querySelector('tbody'));
|
|
}
|
|
|
|
function showImpactList(page) {
|
|
let impactView = $('impactView');
|
|
impactView.querySelector('h2 span').textContent = page.version.name;
|
|
|
|
let table = impactView.querySelector('table');
|
|
let tbody = document.createElement('tbody');
|
|
let version = page.version;
|
|
let entries = version.allEntries();
|
|
if (selectedEntry !== undefined && selectedEntry.isGroup) {
|
|
impactView.querySelector('h2 span').textContent += " " + selectedEntry.name;
|
|
entries = entries.filter((entry) => {
|
|
return entry.name == selectedEntry.name ||
|
|
(entry.parent && entry.parent.name == selectedEntry.name)
|
|
});
|
|
}
|
|
let isCompareView = baselineVersion !== undefined;
|
|
entries = entries.filter((entry) => {
|
|
if (isCompareView) {
|
|
let impact = entry.getTimeImpact();
|
|
return impact < -1 || 1 < impact
|
|
}
|
|
return entry.getTimePercentImpact() > 0.01;
|
|
});
|
|
entries = entries.slice(0, 50);
|
|
entries.sort((a, b) => {
|
|
let cmp = b.getTimePercentImpact() - a.getTimePercentImpact();
|
|
if (isCompareView || cmp.toFixed(1) == 0) {
|
|
return b.getTimeImpact() - a.getTimeImpact();
|
|
}
|
|
return cmp
|
|
});
|
|
entries.forEach((entry) => {
|
|
let tr = document.createElement('tr');
|
|
tr.entry = entry;
|
|
td(tr, entry.name, 'name');
|
|
td(tr, ms(entry.getTimeImpact()), 'value time');
|
|
let percentImpact = entry.getTimePercentImpact();
|
|
td(tr, percentImpact > 1000 ? '-' : percent(percentImpact), 'value time');
|
|
let topPages = entry.getPagesByPercentImpact().slice(0, 3)
|
|
.map((each) => {
|
|
return each.name + ' (' + percent(each.getEntry(entry).timePercent) +
|
|
')'
|
|
});
|
|
td(tr, topPages.join(', '), 'name');
|
|
tbody.appendChild(tr);
|
|
});
|
|
table.replaceChild(tbody, table.querySelector('tbody'));
|
|
}
|
|
|
|
function showGraphs(page) {
|
|
let groups = page.groups.filter(each => each.enabled && !each.isTotal);
|
|
// Sort groups by the biggest impact
|
|
groups.sort((a, b) => b.getTimeImpact() - a.getTimeImpact());
|
|
if (selectedGroup == undefined) {
|
|
selectedGroup = groups[0];
|
|
} else {
|
|
groups = groups.filter(each => each.name != selectedGroup.name);
|
|
if (!selectedGroup.isTotal && selectedGroup.enabled) {
|
|
groups.unshift(selectedGroup);
|
|
}
|
|
}
|
|
// Display graphs delayed for a snappier UI.
|
|
setTimeout(() => {
|
|
showPageVersionGraph(groups, page);
|
|
showPageGraph(groups, page);
|
|
showVersionGraph(groups, page);
|
|
}, 10);
|
|
}
|
|
|
|
function getGraphDataTable(groups, page) {
|
|
let dataTable = new google.visualization.DataTable();
|
|
dataTable.addColumn('string', 'Name');
|
|
groups.forEach(group => {
|
|
let column = dataTable.addColumn('number', group.name.substring(6));
|
|
dataTable.setColumnProperty(column, 'group', group);
|
|
column = dataTable.addColumn({
|
|
role: "annotation"
|
|
});
|
|
dataTable.setColumnProperty(column, 'group', group);
|
|
});
|
|
let column = dataTable.addColumn('number', 'Chart Total');
|
|
dataTable.setColumnProperty(column, 'group', page.total);
|
|
column = dataTable.addColumn({
|
|
role: "annotation"
|
|
});
|
|
dataTable.setColumnProperty(column, 'group', page.total);
|
|
return dataTable;
|
|
}
|
|
|
|
let selectedGroup;
|
|
|
|
class ChartRow {
|
|
static kSortFirstValueRelative(chartRow) {
|
|
if (selectedGroup?.isTotal) return chartRow.total;
|
|
return chartRow.data[0] / chartRow.total;
|
|
}
|
|
|
|
static kSortByFirstValue(chartRow) {
|
|
if (selectedGroup?.isTotal) return chartRow.total;
|
|
return chartRow.data[0];
|
|
}
|
|
|
|
constructor(linkedPage, label, sortValue_fn, data,
|
|
excludeFromAverage = false) {
|
|
this.linkedPage = linkedPage;
|
|
this.label = label;
|
|
if (!Array.isArray(data)) {
|
|
throw new Error("Provide an Array for data");
|
|
}
|
|
this.data = data;
|
|
this.total = 0;
|
|
for (let i = 0; i < data.length; i++) this.total += data[i];
|
|
this.sortValue = sortValue_fn(this);
|
|
this.excludeFromAverage = excludeFromAverage;
|
|
}
|
|
|
|
forDataTable(maxRowsTotal) {
|
|
// row = [label, entry1, annotation1, entry2, annotation2, ...]
|
|
const rowData = [this.label];
|
|
const kShowLabelLimit = 0.1;
|
|
const kMinLabelWidth = 80;
|
|
const chartWidth = window.innerWidth - 400;
|
|
// Add value,label pairs
|
|
for (let i = 0; i < this.data.length; i++) {
|
|
const value = this.data[i];
|
|
let label = '';
|
|
// Only show labels for entries that are large enough..
|
|
if (Math.abs(value / maxRowsTotal) * chartWidth > kMinLabelWidth) {
|
|
label = ms(value);
|
|
}
|
|
rowData.push(value, label);
|
|
}
|
|
// Add the total row, with very small negative dummy entry for correct
|
|
// placement of labels in diff view.
|
|
rowData.push(this.total >= 0 ? 0 : -0.000000001, ms(this.total));
|
|
return rowData;
|
|
}
|
|
}
|
|
const collator = new Intl.Collator('en-UK');
|
|
|
|
function setDataTableRows(dataTable, rows) {
|
|
let skippedRows = 0;
|
|
// Always sort by the selected entry (first column after the label)
|
|
if (sortByLabel) {
|
|
rows.sort((a, b) => collator.compare(a.label, b.label));
|
|
} else {
|
|
rows.sort((a, b) => b.sortValue - a.sortValue);
|
|
}
|
|
// Aggregate row data for Average/SUM chart entry:
|
|
const aggregateData = rows[0].data.slice().fill(0);
|
|
let maxTotal = 0;
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows[i];
|
|
let total = Math.abs(row.total);
|
|
if (total > maxTotal) maxTotal = total;
|
|
if (row.excludeFromAverage) {
|
|
skippedRows++;
|
|
continue
|
|
}
|
|
const chartRowData = row.data;
|
|
for (let j = 0; j < chartRowData.length; j++) {
|
|
aggregateData[j] += chartRowData[j];
|
|
}
|
|
}
|
|
const length = rows.length - skippedRows;
|
|
for (let i = 0; i < aggregateData.length; i++) {
|
|
aggregateData[i] /= rows.length;
|
|
}
|
|
const averageRow = new ChartRow(undefined, 'Average',
|
|
ChartRow.kSortByFirstValue, aggregateData);
|
|
dataTable.addRow(averageRow.forDataTable());
|
|
|
|
rows.forEach(chartRow => {
|
|
let rowIndex = dataTable.addRow(chartRow.forDataTable(maxTotal));
|
|
dataTable.setRowProperty(rowIndex, 'page', chartRow.linkedPage);
|
|
});
|
|
}
|
|
|
|
function showPageVersionGraph(groups, page) {
|
|
let dataTable = getGraphDataTable(groups, page);
|
|
let vs = versions.getPageVersions(page);
|
|
// Calculate the entries for the versions
|
|
const rows = vs.map(page => new ChartRow(
|
|
page, page.version.name, ChartRow.kSortByFirstValue,
|
|
groups.map(group => page.getEntry(group).time),
|
|
page.version === baselineVersion));
|
|
renderGraph(`Versions for ${page.name}`, groups, dataTable, rows,
|
|
'pageVersionGraph', true);
|
|
}
|
|
|
|
function showPageGraph(groups, page) {
|
|
let isDiffView = baselineVersion !== undefined;
|
|
let dataTable = getGraphDataTable(groups, page);
|
|
// Calculate the average row
|
|
// Sort the pages by the selected group.
|
|
let pages = page.version.pages.filter(page => page.enabled);
|
|
// Calculate the entries for the pages
|
|
const rows = pages.map(page => new ChartRow(
|
|
page, page.name,
|
|
isDiffView ?
|
|
ChartRow.kSortByFirstValue : ChartRow.kSortFirstValueRelative,
|
|
groups.map(group => page.getEntry(group).time)));
|
|
renderGraph(`Pages for ${page.version.name}`, groups, dataTable, rows,
|
|
'pageGraph', isDiffView ? true : 'percent');
|
|
}
|
|
|
|
function showVersionGraph(groups, page) {
|
|
let dataTable = getGraphDataTable(groups, page);
|
|
let vs = versions.versions.filter(version => version.enabled);
|
|
// Calculate the entries for the versions
|
|
const rows = vs.map((version) => new ChartRow(
|
|
version.get(page), version.name, ChartRow.kSortByFirstValue,
|
|
groups.map(group => version.getEntry(group).getTimeImpact()),
|
|
version === baselineVersion));
|
|
renderGraph('Versions Total Time over all Pages', groups, dataTable, rows,
|
|
'versionGraph', true);
|
|
}
|
|
|
|
function renderGraph(title, groups, dataTable, rows, id, isStacked) {
|
|
let isDiffView = baselineVersion !== undefined;
|
|
setDataTableRows(dataTable, rows);
|
|
let formatter = new google.visualization.NumberFormat({
|
|
suffix: (isDiffView ? 'msΔ' : 'ms'),
|
|
negativeColor: 'red',
|
|
groupingSymbol: "'"
|
|
});
|
|
for (let i = 1; i < dataTable.getNumberOfColumns(); i++) {
|
|
formatter.format(dataTable, i);
|
|
}
|
|
let height = 85 + 28 * dataTable.getNumberOfRows();
|
|
let options = {
|
|
isStacked: isStacked,
|
|
height: height,
|
|
hAxis: {
|
|
minValue: 0,
|
|
textStyle: {
|
|
fontSize: 14
|
|
}
|
|
},
|
|
vAxis: {
|
|
textStyle: {
|
|
fontSize: 14
|
|
}
|
|
},
|
|
tooltip: {
|
|
textStyle: {
|
|
fontSize: 14
|
|
}
|
|
},
|
|
annotations: {
|
|
textStyle: {
|
|
fontSize: 8
|
|
}
|
|
},
|
|
explorer: {
|
|
actions: ['dragToZoom', 'rightClickToReset'],
|
|
maxZoomIn: 0.01
|
|
},
|
|
legend: {
|
|
position: 'top',
|
|
maxLines: 3,
|
|
textStyle: {
|
|
fontSize: 12
|
|
}
|
|
},
|
|
chartArea: {
|
|
left: 200,
|
|
top: 50
|
|
},
|
|
colors: [
|
|
...groups.map(each => each.color),
|
|
/* Chart Total */
|
|
"#000000",
|
|
]
|
|
};
|
|
let parentNode = $(id);
|
|
parentNode.querySelector('h2>span, h3>span').textContent = title;
|
|
let graphNode = parentNode.querySelector('.panelBody');
|
|
|
|
let chart = graphNode.chart;
|
|
if (chart === undefined) {
|
|
chart = graphNode.chart = new google.visualization.BarChart(graphNode);
|
|
} else {
|
|
google.visualization.events.removeAllListeners(chart);
|
|
}
|
|
google.visualization.events.addListener(chart, 'select', selectHandler);
|
|
|
|
function getChartEntry(selection) {
|
|
if (!selection) return undefined;
|
|
let column = selection.column;
|
|
if (column == undefined) return undefined;
|
|
let selectedGroup = dataTable.getColumnProperty(column, 'group');
|
|
let row = selection.row;
|
|
if (row == null) return selectedGroup;
|
|
let page = dataTable.getRowProperty(row, 'page');
|
|
if (!page) return selectedGroup;
|
|
return page.getEntry(selectedGroup);
|
|
}
|
|
|
|
function selectHandler(e) {
|
|
const newSelectedGroup = getChartEntry(chart.getSelection()[0]);
|
|
if (newSelectedGroup == selectedGroup) {
|
|
sortByLabel = !sortByLabel;
|
|
} else if (newSelectedGroup === undefined && selectedPage) {
|
|
sortByLabel = true;
|
|
return showGraphs(selectedPage);
|
|
} else {
|
|
sortByLabel = false;
|
|
}
|
|
selectedGroup = newSelectedGroup;
|
|
selectEntry(selectedGroup, true);
|
|
}
|
|
|
|
// Make our global tooltips work
|
|
google.visualization.events.addListener(chart, 'onmouseover', mouseOverHandler);
|
|
|
|
function mouseOverHandler(selection) {
|
|
const selectedGroup = getChartEntry(selection);
|
|
graphNode.entry = selectedGroup;
|
|
}
|
|
chart.draw(dataTable, options);
|
|
}
|
|
|
|
function showGroup(entry) {
|
|
toggleGroup(entry, true);
|
|
}
|
|
|
|
function toggleGroup(group, show) {
|
|
$('view').querySelectorAll(".child").forEach((tr) => {
|
|
let entry = tr.parentEntry;
|
|
if (!entry) return;
|
|
if (entry.name !== group.name) return;
|
|
toggleCssClass(tr, 'visible', show);
|
|
});
|
|
}
|
|
|
|
function showPopover(entry) {
|
|
let popover = $('popover');
|
|
popover.querySelector('td.name').textContent = entry.name;
|
|
popover.querySelector('td.page').textContent = entry.page.name;
|
|
setPopoverDetail(popover, entry, '');
|
|
popover.querySelector('table').className = "";
|
|
if (baselineVersion !== undefined) {
|
|
entry = baselineVersion.getEntry(entry);
|
|
setPopoverDetail(popover, entry, '.compare');
|
|
popover.querySelector('table').className = "compare";
|
|
}
|
|
}
|
|
|
|
function setPopoverDetail(popover, entry, prefix) {
|
|
let node = (name) => popover.querySelector(prefix + name);
|
|
if (entry == undefined) {
|
|
node('.version').textContent = baselineVersion.name;
|
|
node('.time').textContent = '-';
|
|
node('.timeVariance').textContent = '-';
|
|
node('.percent').textContent = '-';
|
|
node('.percentPerEntry').textContent = '-';
|
|
node('.percentVariance').textContent = '-';
|
|
node('.count').textContent = '-';
|
|
node('.countVariance').textContent = '-';
|
|
node('.timeImpact').textContent = '-';
|
|
node('.timePercentImpact').textContent = '-';
|
|
} else {
|
|
node('.version').textContent = entry.page.version.name;
|
|
node('.time').textContent = ms(entry._time, false);
|
|
node('.timeVariance').textContent = percent(entry.timeVariancePercent, false);
|
|
node('.percent').textContent = percent(entry.timePercent, false);
|
|
node('.percentPerEntry').textContent = percent(entry.timePercentPerEntry, false);
|
|
node('.percentVariance').textContent = percent(entry.timePercentVariancePercent, false);
|
|
node('.count').textContent = count(entry._count, false);
|
|
node('.countVariance').textContent = percent(entry.timeVariancePercent, false);
|
|
node('.timeImpact').textContent = ms(entry.getTimeImpact(false), false);
|
|
node('.timePercentImpact').textContent = percent(entry.getTimeImpactVariancePercent(false), false);
|
|
}
|
|
}
|
|
</script>
|
|
<script>
|
|
"use strict"
|
|
// =========================================================================
|
|
// Helpers
|
|
function $(id) {
|
|
return document.getElementById(id)
|
|
}
|
|
|
|
function removeAllChildren(node) {
|
|
while (node.firstChild) {
|
|
node.removeChild(node.firstChild);
|
|
}
|
|
}
|
|
|
|
function selectOption(select, match) {
|
|
let options = select.options;
|
|
for (let i = 0; i < options.length; i++) {
|
|
if (match(i, options[i])) {
|
|
select.selectedIndex = i;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function addCodeSearchButton(entry, node) {
|
|
if (entry.isGroup) return;
|
|
let button = document.createElement("div");
|
|
button.textContent = '?'
|
|
button.className = "codeSearch"
|
|
button.addEventListener('click', handleCodeSearch);
|
|
node.appendChild(button);
|
|
return node;
|
|
}
|
|
|
|
function td(tr, content, className) {
|
|
let td = document.createElement("td");
|
|
if (content[0] == '<') {
|
|
td.innerHTML = content;
|
|
} else {
|
|
td.textContent = content;
|
|
}
|
|
td.className = className
|
|
tr.appendChild(td);
|
|
return td
|
|
}
|
|
|
|
function nodeIndex(node) {
|
|
let children = node.parentNode.childNodes,
|
|
i = 0;
|
|
for (; i < children.length; i++) {
|
|
if (children[i] == node) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function toggleCssClass(node, cssClass, toggleState = true) {
|
|
let index = -1;
|
|
let classes;
|
|
if (node.className != undefined) {
|
|
classes = node.className.split(' ');
|
|
index = classes.indexOf(cssClass);
|
|
}
|
|
if (index == -1) {
|
|
if (toggleState === false) return;
|
|
node.className += ' ' + cssClass;
|
|
return;
|
|
}
|
|
if (toggleState === true) return;
|
|
classes.splice(index, 1);
|
|
node.className = classes.join(' ');
|
|
}
|
|
|
|
function NameComparator(a, b) {
|
|
if (a.name > b.name) return 1;
|
|
if (a.name < b.name) return -1;
|
|
return 0
|
|
}
|
|
|
|
function diffSign(value, digits, unit, showDiff) {
|
|
if (showDiff === false || baselineVersion == undefined) {
|
|
if (value === undefined) return '';
|
|
return value.toFixed(digits) + unit;
|
|
}
|
|
return (value >= 0 ? '+' : '') + value.toFixed(digits) + unit + 'Δ';
|
|
}
|
|
|
|
function ms(value, showDiff) {
|
|
return diffSign(value, 1, 'ms', showDiff);
|
|
}
|
|
|
|
function count(value, showDiff) {
|
|
return diffSign(value, 0, '#', showDiff);
|
|
}
|
|
|
|
function percent(value, showDiff) {
|
|
return diffSign(value, 1, '%', showDiff);
|
|
}
|
|
</script>
|
|
<script>
|
|
"use strict"
|
|
// =========================================================================
|
|
// EventHandlers
|
|
async function handleBodyLoad() {
|
|
$('uploadInput').focus();
|
|
if (tryLoadDefaultData() || await tryLoadFromURLParams() ||
|
|
await tryLoadDefaultResults()) {
|
|
displayResultsAfterLoading();
|
|
}
|
|
}
|
|
|
|
function tryLoadDefaultData() {
|
|
if (!defaultData) return false;
|
|
handleLoadJSON(defaultData);
|
|
return true;
|
|
}
|
|
|
|
async function tryLoadFromURLParams() {
|
|
let params = new URLSearchParams(document.location.search);
|
|
let hasFile = false;
|
|
params.forEach(async (value, key) => {
|
|
if (key !== 'file') return;
|
|
hasFile ||= await tryLoadFile(value, true);
|
|
});
|
|
return hasFile;
|
|
}
|
|
|
|
async function tryLoadDefaultResults() {
|
|
if (window.location.protocol === 'file:') return false;
|
|
// Try to load a results.json file adjacent to this day.
|
|
// The markers on the following line can be used to replace the url easily
|
|
// with scripts.
|
|
const url = /*results-url-start*/ 'results.json' /*results-url-end*/;
|
|
return tryLoadFile(url);
|
|
}
|
|
|
|
async function tryLoadFile(url, append = false) {
|
|
if (!url.startsWith('http')) {
|
|
// hack to get relative urls
|
|
let location = window.location;
|
|
let parts = location.pathname.split("/").slice(0, -1);
|
|
url = location.origin + parts.join('/') + '/' + url;
|
|
}
|
|
let response = await fetch(url);
|
|
if (!response.ok) return false;
|
|
let filename = url.split('/');
|
|
filename = filename[filename.length - 1];
|
|
handleLoadText(await response.text(), append, filename);
|
|
return true;
|
|
}
|
|
|
|
function handleAppendFiles() {
|
|
let files = document.getElementById("appendInput").files;
|
|
loadFiles(files, true);
|
|
}
|
|
|
|
function handleLoadFiles() {
|
|
let files = document.getElementById("uploadInput").files;
|
|
loadFiles(files, false)
|
|
}
|
|
|
|
async function loadFiles(files, append) {
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
console.log(file.name);
|
|
let text = await new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result)
|
|
reader.readAsText(file);
|
|
});
|
|
handleLoadText(text, append, file.name);
|
|
// Only the first file might clear existing data, all sequent files
|
|
// are always append.
|
|
append = true;
|
|
}
|
|
displayResultsAfterLoading();
|
|
toggleCssClass(document.body, "loaded");
|
|
}
|
|
|
|
function handleLoadText(text, append, fileName) {
|
|
if (fileName.endsWith('.json')) {
|
|
handleLoadJSON(JSON.parse(text), append, fileName);
|
|
} else if (fileName.endsWith('.csv') ||
|
|
fileName.endsWith('.output') || fileName.endsWith('.output.txt')) {
|
|
handleLoadCSV(text, append, fileName);
|
|
} else if (fileName.endsWith('.txt')) {
|
|
handleLoadTXT(text, append, fileName);
|
|
} else {
|
|
alert(`Unsupported file extension: "${fileName}"`);
|
|
}
|
|
}
|
|
|
|
function getStateFromParams() {
|
|
let query = window.location.search.substr(1);
|
|
let result = {};
|
|
query.split("&").forEach((part) => {
|
|
let item = part.split("=");
|
|
let key = decodeURIComponent(item[0])
|
|
result[key] = decodeURIComponent(item[1]);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function handleLoadJSON(json, append, fileName) {
|
|
json = fixClusterTelemetryResults(json);
|
|
json = fixTraceImportJSON(json);
|
|
json = fixSingleVersionJSON(json, fileName);
|
|
let isFirstLoad = pages === undefined;
|
|
if (append && !isFirstLoad) {
|
|
json = createUniqueVersions(json);
|
|
}
|
|
if (!append || isFirstLoad) {
|
|
pages = new Pages();
|
|
versions = Versions.fromJSON(json);
|
|
} else {
|
|
Versions.fromJSON(json).forEach(e => versions.add(e))
|
|
}
|
|
}
|
|
|
|
function handleLoadCSV(csv, append, fileName) {
|
|
let isFirstLoad = pages === undefined;
|
|
if (!append || isFirstLoad) {
|
|
pages = new Pages();
|
|
versions = new Versions();
|
|
}
|
|
const lines = csv.split(/\r?\n/);
|
|
// The first line contains only the field names.
|
|
const fields = new Map();
|
|
csvSplit(lines[0]).forEach((name, index) => {
|
|
fields.set(name, index);
|
|
});
|
|
if (fields.has('displayLabel') && fields.has('stories')) {
|
|
handleLoadResultCSV(fields, lines);
|
|
} else if (fields.has('page_name')) {
|
|
handleLoadClusterTelemetryCSV(fields, lines, fileName);
|
|
} else {
|
|
return alert("Unknown CSV format");
|
|
}
|
|
}
|
|
|
|
function csvSplit(line) {
|
|
let fields = [];
|
|
let index = 0;
|
|
while (index < line.length) {
|
|
let lastIndex = index;
|
|
if (line[lastIndex] == '"') {
|
|
index = line.indexOf('"', lastIndex + 1);
|
|
if (index < 0) index = line.length;
|
|
fields.push(line.substring(lastIndex + 1, index));
|
|
// Consume ','
|
|
index++;
|
|
} else {
|
|
index = line.indexOf(',', lastIndex);
|
|
if (index === -1) index = line.length;
|
|
fields.push(line.substring(lastIndex, index))
|
|
}
|
|
// Consume ','
|
|
index++;
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
// Ignore the following categories as they are aggregated values and are
|
|
// created by callstats.html on the fly.
|
|
const import_skip_categories = new Set([
|
|
'V8-Only', 'V8-Only-Main-Thread', 'Total-Main-Thread', 'Blink_Total'
|
|
])
|
|
|
|
function handleLoadClusterTelemetryCSV(fields, lines, fileName) {
|
|
const rscFields = Array.from(fields.keys())
|
|
.filter(field => {
|
|
return field.endsWith(':duration (ms)') &&
|
|
!import_skip_categories.has(field.split(':')[0])
|
|
})
|
|
.map(field => {
|
|
let name = field.split(':')[0];
|
|
return [name, fields.get(field), fields.get(`${name}:count`)];
|
|
})
|
|
const page_name_i = fields.get('page_name');
|
|
const version = versions.getOrCreate(fileName);
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = csvSplit(lines[i]);
|
|
if (line.length == 0) continue;
|
|
let page_name = line[page_name_i];
|
|
if (page_name === undefined) continue;
|
|
page_name = page_name.split(' ')[0];
|
|
const pageVersion = version.getOrCreate(page_name);
|
|
for (let [fieldName, duration_i, count_i] of rscFields) {
|
|
const duration = Number.parseFloat(line[duration_i]);
|
|
const count = Number.parseFloat(line[count_i]);
|
|
// Skip over entries without metrics (most likely crashes)
|
|
if (Number.isNaN(count) || Number.isNaN(duration)) {
|
|
console.warn(`BROKEN ${page_name}`, lines[i])
|
|
break;
|
|
}
|
|
pageVersion.add(new Entry(0, fieldName, duration, 0, 0, count, 0, 0))
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleLoadResultCSV(fields, lines) {
|
|
const version_i = fields.get('displayLabel');
|
|
const page_i = fields.get('stories');
|
|
const category_i = fields.get('name');
|
|
const value_i = fields.get('avg');
|
|
const tempEntriesCache = new Map();
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = csvSplit(lines[i]);
|
|
if (line.length == 0) continue;
|
|
const raw_category = line[category_i];
|
|
if (!raw_category.endsWith(':duration') &&
|
|
!raw_category.endsWith(':count')) {
|
|
continue;
|
|
}
|
|
let [category, type] = raw_category.split(':');
|
|
if (import_skip_categories.has(category)) continue;
|
|
const version = versions.getOrCreate(line[version_i]);
|
|
const pageVersion = version.getOrCreate(line[page_i]);
|
|
const value = Number.parseFloat(line[value_i]);
|
|
const entry = TempEntry.get(tempEntriesCache, pageVersion, category);
|
|
if (type == 'duration') {
|
|
entry.durations.push(value)
|
|
} else {
|
|
entry.counts.push(value)
|
|
}
|
|
}
|
|
|
|
tempEntriesCache.forEach((tempEntries, pageVersion) => {
|
|
tempEntries.forEach(tmpEntry => {
|
|
pageVersion.add(tmpEntry.toEntry())
|
|
})
|
|
});
|
|
}
|
|
|
|
class TempEntry {
|
|
constructor(category) {
|
|
this.category = category;
|
|
this.durations = [];
|
|
this.counts = [];
|
|
}
|
|
|
|
static get(cache, pageVersion, category) {
|
|
let tempEntries = cache.get(pageVersion);
|
|
if (tempEntries === undefined) {
|
|
tempEntries = new Map();
|
|
cache.set(pageVersion, tempEntries);
|
|
}
|
|
let tempEntry = tempEntries.get(category);
|
|
if (tempEntry === undefined) {
|
|
tempEntry = new TempEntry(category);
|
|
tempEntries.set(category, tempEntry);
|
|
}
|
|
return tempEntry;
|
|
}
|
|
|
|
toEntry() {
|
|
const [duration, durationStddev] = this.stats(this.durations);
|
|
const [count, countStddev] = this.stats(this.durations);
|
|
return new Entry(0, this.category,
|
|
duration, durationStddev, 0, count, countStddev, 0)
|
|
}
|
|
|
|
stats(values) {
|
|
let sum = 0;
|
|
for (let i = 0; i < values.length; i++) {
|
|
sum += values[i];
|
|
}
|
|
const avg = sum / values.length;
|
|
let stddevSquared = 0;
|
|
for (let i = 0; i < values.length; i++) {
|
|
const delta = values[i] - avg;
|
|
stddevSquared += delta * delta;
|
|
}
|
|
const stddev = Math.sqrt(stddevSquared / values.length);
|
|
return [avg, stddev];
|
|
}
|
|
}
|
|
|
|
function handleLoadTXT(txt, append, fileName) {
|
|
fileName = window.prompt('Version name:', fileName);
|
|
let isFirstLoad = pages === undefined;
|
|
// Load raw RCS output which contains a single page
|
|
if (!append || isFirstLoad) {
|
|
pages = new Pages();
|
|
versions = new Versions()
|
|
}
|
|
versions.add(Version.fromTXT(fileName, txt));
|
|
|
|
}
|
|
|
|
function displayResultsAfterLoading() {
|
|
const isFirstLoad = pages === undefined;
|
|
let state = getStateFromParams();
|
|
initialize()
|
|
if (isFirstLoad && !popHistoryState(state) && selectedPage) {
|
|
showEntry(selectedPage.total);
|
|
return;
|
|
}
|
|
const page = versions.versions[0].pages[0]
|
|
if (page == undefined) return;
|
|
showPage(page);
|
|
showEntry(page.total);
|
|
}
|
|
|
|
function fixClusterTelemetryResults(json) {
|
|
// Convert CT results to callstats compatible JSON
|
|
// Input:
|
|
// { VERSION_NAME: { PAGE: { METRIC: { "count": {XX}, "duration": {XX} }.. }}.. }
|
|
let firstEntry;
|
|
for (let key in json) {
|
|
firstEntry = json[key];
|
|
break;
|
|
}
|
|
// Return the original JSON if it is not a CT result.
|
|
if (firstEntry.pairs === undefined) return json;
|
|
// The results include already the group totals, remove them by filtering.
|
|
let groupNames = new Set(Array.from(Group.groups.values()).map(e => e.name));
|
|
let result = Object.create(null);
|
|
for (let file_name in json) {
|
|
let entries = [];
|
|
let file_data = json[file_name].pairs;
|
|
for (let name in file_data) {
|
|
if (name != "Total" && groupNames.has(name)) continue;
|
|
let entry = file_data[name];
|
|
let count = entry.count;
|
|
let time = entry.time;
|
|
entries.push([name, time, 0, 0, count, 0, 0]);
|
|
}
|
|
let domain = file_name.split("/").slice(-1)[0];
|
|
result[domain] = entries;
|
|
}
|
|
return {
|
|
__proto__: null,
|
|
ClusterTelemetry: result
|
|
};
|
|
}
|
|
|
|
function fixTraceImportJSON(json) {
|
|
// Fix json file that was created by converting a trace json output
|
|
if (!('telemetry-results' in json)) return json;
|
|
// { telemetry-results: { PAGE:[ { METRIC: [ COUNT TIME ], ... }, ... ]}}
|
|
let version_data = {
|
|
__proto__: null
|
|
};
|
|
json = json["telemetry-results"];
|
|
for (let page_name in json) {
|
|
if (page_name == "placeholder") continue;
|
|
let page_data = {
|
|
__proto__: null,
|
|
Total: {
|
|
duration: {
|
|
average: 0,
|
|
stddev: 0
|
|
},
|
|
count: {
|
|
average: 0,
|
|
stddev: 0
|
|
}
|
|
}
|
|
};
|
|
let page = json[page_name];
|
|
for (let slice of page) {
|
|
for (let metric_name in slice) {
|
|
if (metric_name == "Blink_V8") continue;
|
|
// sum up entries
|
|
if (!(metric_name in page_data)) {
|
|
page_data[metric_name] = {
|
|
duration: {
|
|
average: 0,
|
|
stddev: 0
|
|
},
|
|
count: {
|
|
average: 0,
|
|
stddev: 0
|
|
}
|
|
}
|
|
}
|
|
let [metric_count, metric_duration] = slice[metric_name]
|
|
let metric = page_data[metric_name];
|
|
const kMicroToMilli = 1 / 1000;
|
|
metric.duration.average += metric_duration * kMicroToMilli;
|
|
metric.count.average += metric_count;
|
|
|
|
if (metric_name.startsWith('Blink_')) continue;
|
|
let total = page_data['Total'];
|
|
total.duration.average += metric_duration * kMicroToMilli;
|
|
total.count.average += metric_count;
|
|
}
|
|
}
|
|
version_data[page_name] = page_data;
|
|
}
|
|
return version_data;
|
|
}
|
|
|
|
function fixSingleVersionJSON(json, name) {
|
|
// Try to detect the single-version case, where we're missing the toplevel
|
|
// version object. The incoming JSON is of the form:
|
|
// { PAGE: ... , PAGE_2: }
|
|
// Instead of the default multi-page JSON:
|
|
// {"Version 1": { "Page 1": ..., ...}, "Version 2": {...}, ...}
|
|
// In this case insert a single "Default" version as top-level entry.
|
|
let firstProperty = (object) => {
|
|
for (let key in object) return object[key];
|
|
};
|
|
let maybePage = firstProperty(json);
|
|
let maybeMetrics = firstProperty(maybePage);
|
|
let tempName = name ? name : new Date().toISOString();
|
|
tempName = window.prompt('Enter a name for the loaded file:', tempName);
|
|
if ('count' in maybeMetrics && 'duration' in maybeMetrics) {
|
|
return {
|
|
[tempName]: json
|
|
}
|
|
}
|
|
// Legacy fallback where the metrics are encoded as arrays:
|
|
// { PAGE: [[metric_name, ...], [...], ]}
|
|
if (Array.isArray(maybeMetrics)) {
|
|
return {
|
|
[tempName]: json
|
|
}
|
|
}
|
|
return json
|
|
}
|
|
|
|
let appendIndex = 0;
|
|
|
|
function createUniqueVersions(json) {
|
|
// Make sure all toplevel entries are unique names and added properly
|
|
appendIndex++;
|
|
let result = {
|
|
__proto__: null
|
|
}
|
|
for (let key in json) {
|
|
result[key + "_" + appendIndex] = json[key];
|
|
}
|
|
return result
|
|
}
|
|
|
|
function handleCopyToClipboard(event) {
|
|
const names = ["Group", ...versions.versions.map(e => e.name)];
|
|
let result = [names.join("\t")];
|
|
let groups = Array.from(Group.groups.values());
|
|
// Move the total group to the end.
|
|
groups.push(groups.shift())
|
|
groups.forEach(group => {
|
|
let row = [group.name];
|
|
versions.forEach(v => {
|
|
const time = v.pages[0].get("Group-" + group.name)?._time ?? 0;
|
|
row.push(time);
|
|
})
|
|
result.push(row.join("\t"));
|
|
});
|
|
result = result.join("\n");
|
|
navigator.clipboard.writeText(result);
|
|
}
|
|
|
|
function handleToggleGroup(event) {
|
|
let group = event.target.parentNode.parentNode.entry;
|
|
toggleGroup(selectedPage.get(group.name), 'toggle');
|
|
}
|
|
|
|
function handleSelectPage(select, event) {
|
|
let option = select.options[select.selectedIndex];
|
|
if (select.id == "select_0") {
|
|
showSelectedEntryInPage(option.page);
|
|
} else {
|
|
let columnIndex = select.id.split('_')[1];
|
|
showPageInColumn(option.page, columnIndex);
|
|
}
|
|
}
|
|
|
|
function handleSelectVersion(select, event) {
|
|
let option = select.options[select.selectedIndex];
|
|
let version = option.version;
|
|
if (select.id == "selectVersion_0") {
|
|
let page = version.get(selectedPage.name);
|
|
showSelectedEntryInPage(page);
|
|
} else {
|
|
let columnIndex = select.id.split('_')[1];
|
|
let pageSelect = $('select_' + columnIndex);
|
|
let page = pageSelect.options[pageSelect.selectedIndex].page;
|
|
page = version.get(page.name);
|
|
showPageInColumn(page, columnIndex);
|
|
}
|
|
}
|
|
|
|
function handleSelectDetailRow(table, event) {
|
|
if (event.target.tagName != 'TD') return;
|
|
let tr = event.target.parentNode;
|
|
if (tr.tagName != 'TR') return;
|
|
if (tr.entry === undefined) return;
|
|
selectEntry(tr.entry, true);
|
|
}
|
|
|
|
function handleSelectRow(table, event, fromDetail) {
|
|
if (event.target.tagName != 'TD') return;
|
|
let tr = event.target.parentNode;
|
|
if (tr.tagName != 'TR') return;
|
|
if (tr.entry === undefined) return;
|
|
selectEntry(tr.entry, false);
|
|
}
|
|
|
|
function handleSelectBaseline(select, event) {
|
|
let option = select.options[select.selectedIndex];
|
|
baselineVersion = option.version;
|
|
let showingDiff = baselineVersion !== undefined;
|
|
let body = $('body');
|
|
toggleCssClass(body, 'diff', showingDiff);
|
|
toggleCssClass(body, 'noDiff', !showingDiff);
|
|
showPage(selectedPage);
|
|
if (selectedEntry === undefined) return;
|
|
selectEntry(selectedEntry, true);
|
|
}
|
|
|
|
function findEntry(event) {
|
|
let target = event.target;
|
|
while (target.entry === undefined) {
|
|
target = target.parentNode;
|
|
if (!target) return undefined;
|
|
}
|
|
return target.entry;
|
|
}
|
|
|
|
function handleUpdatePopover(event) {
|
|
let popover = $('popover');
|
|
popover.style.left = event.pageX + 'px';
|
|
popover.style.top = event.pageY + 'px';
|
|
popover.style.display = 'none';
|
|
popover.style.display = event.shiftKey ? 'block' : 'none';
|
|
let entry = findEntry(event);
|
|
if (entry === undefined) return;
|
|
showPopover(entry);
|
|
}
|
|
|
|
function handleToggleVersionOrPageEnable(event) {
|
|
let item = this.item;
|
|
if (item === undefined) return;
|
|
item.enabled = this.checked;
|
|
initialize();
|
|
let page = selectedPage;
|
|
if (page === undefined || !page.version.enabled) {
|
|
page = versions.getEnabledPage(page.name);
|
|
}
|
|
if (!page.enabled) {
|
|
page = page.getNextPage();
|
|
}
|
|
showPage(page);
|
|
}
|
|
|
|
function handleCodeSearch(event) {
|
|
let entry = findEntry(event);
|
|
if (entry === undefined) return;
|
|
let url = "https://cs.chromium.org/search/?sq=package:chromium&type=cs&q=";
|
|
name = entry.name;
|
|
if (name.startsWith("API_")) {
|
|
name = name.substring(4);
|
|
}
|
|
url += encodeURIComponent(name) + "+file:src/v8/src";
|
|
window.open(url, '_blank');
|
|
}
|
|
</script>
|
|
<script>
|
|
"use strict"
|
|
// =========================================================================
|
|
class Versions {
|
|
constructor() {
|
|
this.versions = [];
|
|
}
|
|
add(version) {
|
|
this.versions.push(version);
|
|
return version;
|
|
}
|
|
getPageVersions(page) {
|
|
let result = [];
|
|
this.versions.forEach((version) => {
|
|
if (!version.enabled) return;
|
|
let versionPage = version.get(page.name);
|
|
if (versionPage !== undefined) result.push(versionPage);
|
|
});
|
|
return result;
|
|
}
|
|
get length() {
|
|
return this.versions.length
|
|
}
|
|
get(index) {
|
|
return this.versions[index]
|
|
}
|
|
getByName(name) {
|
|
return this.versions.find((each) => each.name == name);
|
|
}
|
|
getOrCreate(name) {
|
|
return this.getByName(name) ?? this.add(new Version(name));
|
|
}
|
|
forEach(f) {
|
|
this.versions.forEach(f);
|
|
}
|
|
sort() {
|
|
this.versions.sort(NameComparator);
|
|
}
|
|
getEnabledPage(name) {
|
|
for (let i = 0; i < this.versions.length; i++) {
|
|
let version = this.versions[i];
|
|
if (!version.enabled) continue;
|
|
let page = version.get(name);
|
|
if (page !== undefined) return page;
|
|
}
|
|
}
|
|
|
|
static fromJSON(json) {
|
|
let versions = new Versions();
|
|
for (let version in json) {
|
|
versions.add(Version.fromJSON(version, json[version]));
|
|
}
|
|
versions.sort();
|
|
return versions;
|
|
}
|
|
}
|
|
|
|
class Version {
|
|
constructor(name) {
|
|
this.name = name;
|
|
this.enabled = true;
|
|
this.pages = [];
|
|
}
|
|
add(page) {
|
|
this.pages.push(page);
|
|
return page;
|
|
}
|
|
indexOf(name) {
|
|
for (let i = 0; i < this.pages.length; i++) {
|
|
if (this.pages[i].name == name) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
getNextPage(page) {
|
|
if (this.length == 0) return undefined;
|
|
return this.pages[(this.indexOf(page.name) + 1) % this.length];
|
|
}
|
|
get(name) {
|
|
let index = this.indexOf(name);
|
|
if (0 <= index) return this.pages[index];
|
|
return undefined;
|
|
}
|
|
getOrCreate(name) {
|
|
return this.get(name) ??
|
|
this.add(new PageVersion(this, pages.getOrCreate(name)));
|
|
}
|
|
get length() {
|
|
return this.pages.length;
|
|
}
|
|
getEntry(entry) {
|
|
if (entry === undefined) return undefined;
|
|
let page = this.get(entry.page.name);
|
|
if (page === undefined) return undefined;
|
|
return page.get(entry.name);
|
|
}
|
|
forEachEntry(fun) {
|
|
this.forEachPage((page) => {
|
|
page.forEach(fun);
|
|
});
|
|
}
|
|
forEachPage(fun) {
|
|
this.pages.forEach((page) => {
|
|
if (!page.enabled) return;
|
|
fun(page);
|
|
})
|
|
}
|
|
allEntries() {
|
|
let map = new Map();
|
|
this.forEachEntry((group, entry) => {
|
|
if (!map.has(entry.name)) map.set(entry.name, entry);
|
|
});
|
|
return Array.from(map.values());
|
|
}
|
|
getTotalValue(name, property) {
|
|
if (name === undefined) name = this.pages[0].total.name;
|
|
let sum = 0;
|
|
this.forEachPage((page) => {
|
|
let entry = page.get(name);
|
|
if (entry !== undefined) sum += entry[property];
|
|
});
|
|
return sum;
|
|
}
|
|
getTotalTime(name, showDiff) {
|
|
return this.getTotalValue(name, showDiff === false ? '_time' : 'time');
|
|
}
|
|
getTotalTimePercent(name, showDiff) {
|
|
if (baselineVersion === undefined || showDiff === false) {
|
|
// Return the overall average percent of the given entry name.
|
|
return this.getTotalValue(name, 'time') /
|
|
this.getTotalTime('Group-Total') * 100;
|
|
}
|
|
// Otherwise return the difference to the sum of the baseline version.
|
|
let baselineValue = baselineVersion.getTotalTime(name, false);
|
|
let total = this.getTotalValue(name, '_time');
|
|
return (total / baselineValue - 1) * 100;
|
|
}
|
|
getTotalTimeVariance(name, showDiff) {
|
|
// Calculate the overall error for a given entry name
|
|
let sum = 0;
|
|
this.forEachPage((page) => {
|
|
let entry = page.get(name);
|
|
if (entry === undefined) return;
|
|
sum += entry.timeVariance * entry.timeVariance;
|
|
});
|
|
return Math.sqrt(sum);
|
|
}
|
|
getTotalTimeVariancePercent(name, showDiff) {
|
|
return this.getTotalTimeVariance(name, showDiff) /
|
|
this.getTotalTime(name, showDiff) * 100;
|
|
}
|
|
getTotalCount(name, showDiff) {
|
|
return this.getTotalValue(name, showDiff === false ? '_count' : 'count');
|
|
}
|
|
getAverageTimeImpact(name, showDiff) {
|
|
return this.getTotalTime(name, showDiff) / this.pages.length;
|
|
}
|
|
getPagesByPercentImpact(name) {
|
|
let sortedPages =
|
|
this.pages.filter((each) => {
|
|
return each.get(name) !== undefined
|
|
});
|
|
sortedPages.sort((a, b) => {
|
|
return b.get(name).timePercent - a.get(name).timePercent;
|
|
});
|
|
return sortedPages;
|
|
}
|
|
sort() {
|
|
this.pages.sort(NameComparator)
|
|
}
|
|
|
|
static fromJSON(name, data) {
|
|
let version = new Version(name);
|
|
for (let pageName in data) {
|
|
version.add(PageVersion.fromJSON(version, pageName, data[pageName]));
|
|
}
|
|
version.sort();
|
|
return version;
|
|
}
|
|
|
|
static fromTXT(name, txt) {
|
|
let version = new Version(name);
|
|
let defaultName = "RAW DATA";
|
|
PageVersion.fromTXT(version, defaultName, txt)
|
|
.forEach(each => version.add(each));
|
|
return version;
|
|
}
|
|
}
|
|
|
|
class Pages extends Map {
|
|
get(name) {
|
|
if (name.indexOf('www.') == 0) {
|
|
name = name.substring(4);
|
|
}
|
|
if (!this.has(name)) {
|
|
this.set(name, new Page(name));
|
|
}
|
|
return super.get(name);
|
|
}
|
|
getOrCreate(name) {
|
|
return this.get(name);
|
|
}
|
|
}
|
|
|
|
class Page {
|
|
constructor(name) {
|
|
this.name = name;
|
|
this.enabled = true;
|
|
this.versions = [];
|
|
}
|
|
add(pageVersion) {
|
|
this.versions.push(pageVersion);
|
|
return pageVersion;
|
|
}
|
|
}
|
|
|
|
class PageVersion {
|
|
constructor(version, page) {
|
|
this.page = page;
|
|
this.page.add(this);
|
|
this.total = Group.groups.get('total').entry();
|
|
this.total.isTotal = true;
|
|
this.unclassified = new UnclassifiedEntry(this)
|
|
this.groups = [
|
|
this.total,
|
|
Group.groups.get('ic').entry(),
|
|
Group.groups.get('optimize-background').entry(),
|
|
Group.groups.get('optimize').entry(),
|
|
Group.groups.get('compile-background').entry(),
|
|
Group.groups.get('compile').entry(),
|
|
Group.groups.get('parse-background').entry(),
|
|
Group.groups.get('parse').entry(),
|
|
Group.groups.get('blink').entry(),
|
|
Group.groups.get('callback').entry(),
|
|
Group.groups.get('api').entry(),
|
|
Group.groups.get('gc-custom').entry(),
|
|
Group.groups.get('gc-background').entry(),
|
|
Group.groups.get('gc').entry(),
|
|
Group.groups.get('javascript').entry(),
|
|
Group.groups.get('runtime').entry(),
|
|
this.unclassified
|
|
];
|
|
this.entryDict = new Map();
|
|
this.groups.forEach((entry) => {
|
|
entry.page = this;
|
|
this.entryDict.set(entry.name, entry);
|
|
});
|
|
this.version = version;
|
|
}
|
|
toString() {
|
|
return this.version.name + ": " + this.name;
|
|
}
|
|
urlParams() {
|
|
return {
|
|
version: this.version.name,
|
|
page: this.name
|
|
};
|
|
}
|
|
add(entry) {
|
|
let existingEntry = this.entryDict.get(entry.name);
|
|
if (existingEntry !== undefined) {
|
|
// Duplicate entries happen when multiple runs are combined into a
|
|
// single file.
|
|
existingEntry.add(entry);
|
|
for (let i = 0; i < this.groups.length; i++) {
|
|
const group = this.groups[i];
|
|
if (group.addTimeAndCount(entry)) return;
|
|
}
|
|
} else {
|
|
// Ignore accidentally added Group entries.
|
|
if (entry.name.startsWith(GroupedEntry.prefix)) {
|
|
console.warn("Skipping accidentally added Group entry:", entry, this);
|
|
return;
|
|
}
|
|
entry.page = this;
|
|
this.entryDict.set(entry.name, entry);
|
|
for (let group of this.groups) {
|
|
if (group.add(entry)) return;
|
|
}
|
|
}
|
|
console.error("Should not get here", entry);
|
|
}
|
|
get(name) {
|
|
return this.entryDict.get(name)
|
|
}
|
|
getEntry(entry) {
|
|
if (entry === undefined) return undefined;
|
|
return this.get(entry.name);
|
|
}
|
|
get length() {
|
|
return this.versions.length
|
|
}
|
|
get name() {
|
|
return this.page.name
|
|
}
|
|
get enabled() {
|
|
return this.page.enabled
|
|
}
|
|
forEachSorted(referencePage, func) {
|
|
// Iterate over all the entries in the order they appear on the
|
|
// reference page.
|
|
referencePage.forEach((parent, referenceEntry) => {
|
|
let entry;
|
|
if (parent) parent = this.entryDict.get(parent.name);
|
|
if (referenceEntry) entry = this.entryDict.get(referenceEntry.name);
|
|
func(parent, entry, referenceEntry);
|
|
});
|
|
}
|
|
forEach(fun) {
|
|
this.forEachGroup((group) => {
|
|
fun(undefined, group);
|
|
group.forEach((entry) => {
|
|
fun(group, entry)
|
|
});
|
|
});
|
|
}
|
|
forEachGroup(fun) {
|
|
this.groups.forEach(fun)
|
|
}
|
|
sort() {
|
|
this.groups.sort((a, b) => {
|
|
return b.time - a.time;
|
|
});
|
|
this.groups.forEach((group) => {
|
|
group.sort()
|
|
});
|
|
}
|
|
distanceFromTotalPercent() {
|
|
let sum = 0;
|
|
this.groups.forEach(group => {
|
|
if (group == this.total) return;
|
|
let value = group.getTimePercentImpact() -
|
|
this.getEntry(group).timePercent;
|
|
sum += value * value;
|
|
});
|
|
return sum;
|
|
}
|
|
getNextPage() {
|
|
return this.version.getNextPage(this);
|
|
}
|
|
|
|
static fromJSON(version, name, data) {
|
|
let page = new PageVersion(version, pages.get(name));
|
|
// Distinguish between the legacy format which just uses Arrays,
|
|
// or the new object style.
|
|
if (Array.isArray(data)) {
|
|
for (let i = 0; i < data.length; i++) {
|
|
page.add(Entry.fromLegacyJSON(i, data[data.length - i - 1]));
|
|
}
|
|
} else {
|
|
let position = 0;
|
|
for (let metric_name in data) {
|
|
page.add(Entry.fromJSON(position, metric_name, data[metric_name]));
|
|
position++;
|
|
}
|
|
}
|
|
page.sort();
|
|
return page
|
|
}
|
|
|
|
static fromTXT(version, defaultName, txt) {
|
|
const kPageNameIdentifier = "== Page:";
|
|
const kCommentStart = "=="
|
|
const lines = txt.split('\n');
|
|
const split = / +/g
|
|
const result = [];
|
|
let pageVersion = undefined;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
// Skip header separators
|
|
if (line.startsWith(kCommentStart)) {
|
|
// Check for page names
|
|
if (line.startsWith(kPageNameIdentifier)) {
|
|
const name = line.split(kPageNameIdentifier)[1];
|
|
pageVersion = new PageVersion(version, pages.get(name));
|
|
result.push(pageVersion);
|
|
}
|
|
}
|
|
// Skip header lines.
|
|
if (lines[i + 1]?.startsWith(kCommentStart)) continue;
|
|
const split_line = line.trim().split(split)
|
|
if (split_line.length != 5) continue;
|
|
if (pageVersion === undefined) {
|
|
pageVersion = new PageVersion(version, pages.get(defaultName));
|
|
result.push(pageVersion);
|
|
}
|
|
const position = i - 2;
|
|
pageVersion.add(Entry.fromTXT(position, split_line));
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
|
|
class Entry {
|
|
constructor(position, name, time, timeVariance, timeVariancePercent,
|
|
count, countVariance, countVariancePercent) {
|
|
this.position = position;
|
|
this.name = name;
|
|
this._time = time;
|
|
this._timeVariance = timeVariance;
|
|
this._timeVariancePercent =
|
|
this._variancePercent(time, timeVariance, timeVariancePercent);
|
|
this._count = count;
|
|
this.countVariance = countVariance;
|
|
this.countVariancePercent =
|
|
this._variancePercent(count, countVariance, countVariancePercent);
|
|
this.page = undefined;
|
|
this.parent = undefined;
|
|
this.isTotal = false;
|
|
}
|
|
_variancePercent(value, valueVariance, valueVariancePercent) {
|
|
if (valueVariancePercent) return valueVariancePercent;
|
|
if (!valueVariance) return 0;
|
|
return valueVariance / value * 100;
|
|
}
|
|
|
|
add(entry) {
|
|
if (this.name !== entry.name) {
|
|
console.error("Should not combine entries with different names");
|
|
return;
|
|
}
|
|
this._time += entry._time;
|
|
this._count += entry._count;
|
|
}
|
|
urlParams() {
|
|
let params = this.page.urlParams();
|
|
params.entry = this.name;
|
|
return params;
|
|
}
|
|
getCompareWithBaseline(value, property) {
|
|
if (baselineVersion == undefined) return value;
|
|
let baselineEntry = baselineVersion.getEntry(this);
|
|
if (!baselineEntry) return value;
|
|
if (baselineVersion === this.page.version) return value;
|
|
return value - baselineEntry[property];
|
|
}
|
|
cssClass() {
|
|
return ''
|
|
}
|
|
get time() {
|
|
return this.getCompareWithBaseline(this._time, '_time');
|
|
}
|
|
get count() {
|
|
return this.getCompareWithBaseline(this._count, '_count');
|
|
}
|
|
get timePercent() {
|
|
let value = this._time / this.page.total._time * 100;
|
|
if (baselineVersion == undefined) return value;
|
|
let baselineEntry = baselineVersion.getEntry(this);
|
|
if (!baselineEntry) return value;
|
|
if (baselineVersion === this.page.version) return value;
|
|
return (this._time - baselineEntry._time) / this.page.total._time *
|
|
100;
|
|
}
|
|
get timePercentPerEntry() {
|
|
let value = this._time / this.page.total._time * 100;
|
|
if (baselineVersion == undefined) return value;
|
|
let baselineEntry = baselineVersion.getEntry(this);
|
|
if (!baselineEntry) return value;
|
|
if (baselineVersion === this.page.version) return value;
|
|
return (this._time / baselineEntry._time - 1) * 100;
|
|
}
|
|
get timePercentVariancePercent() {
|
|
// Get the absolute values for the percentages
|
|
return this.timeVariance / this.page.total._time * 100;
|
|
}
|
|
getTimeImpact(showDiff) {
|
|
return this.page.version.getTotalTime(this.name, showDiff);
|
|
}
|
|
getTimeImpactVariancePercent(showDiff) {
|
|
return this.page.version.getTotalTimeVariancePercent(this.name, showDiff);
|
|
}
|
|
getTimePercentImpact(showDiff) {
|
|
return this.page.version.getTotalTimePercent(this.name, showDiff);
|
|
}
|
|
getCountImpact(showDiff) {
|
|
return this.page.version.getTotalCount(this.name, showDiff);
|
|
}
|
|
getAverageTimeImpact(showDiff) {
|
|
return this.page.version.getAverageTimeImpact(this.name, showDiff);
|
|
}
|
|
getPagesByPercentImpact() {
|
|
return this.page.version.getPagesByPercentImpact(this.name);
|
|
}
|
|
get isGroup() {
|
|
return false;
|
|
}
|
|
get timeVariance() {
|
|
return this._timeVariance;
|
|
}
|
|
get timeVariancePercent() {
|
|
return this._timeVariancePercent;
|
|
}
|
|
|
|
static fromLegacyJSON(position, data) {
|
|
return new Entry(position, ...data);
|
|
}
|
|
|
|
static fromJSON(position, name, data) {
|
|
let time = data.duration;
|
|
let count = data.count;
|
|
return new Entry(position, name, time.average, time.stddev, 0,
|
|
count.average, count.stddev, 0);
|
|
}
|
|
|
|
static fromTXT(position, splitLine) {
|
|
const name = splitLine[0];
|
|
let time = splitLine[1];
|
|
const msIndex = time.indexOf('m');
|
|
if (msIndex > 0) time = time.substring(0, msIndex);
|
|
const timePercent = splitLine[2];
|
|
const count = splitLine[3];
|
|
const countPercent = splitLine[4];
|
|
const timeDeviation = 0;
|
|
const countDeviation = 0;
|
|
const timeDeviationPercent = 0;
|
|
const countDeviationPercent = 0
|
|
return new Entry(position, name,
|
|
Number.parseFloat(time), timeDeviation, timeDeviationPercent,
|
|
Number.parseInt(count), countDeviation, countDeviationPercent)
|
|
}
|
|
}
|
|
|
|
class Group {
|
|
constructor(name, regexp, color, enabled = true, addsToTotal = true) {
|
|
this.name = name;
|
|
this.regexp = regexp;
|
|
this.color = color;
|
|
this.enabled = enabled;
|
|
this.addsToTotal = addsToTotal;
|
|
}
|
|
entry() {
|
|
return new GroupedEntry(this);
|
|
}
|
|
}
|
|
Group.groups = new Map();
|
|
Group.add = function (name, group) {
|
|
this.groups.set(name, group);
|
|
return group;
|
|
}
|
|
Group.add('total', new Group('Total', /.*Total.*/, '#BBB', true, false));
|
|
Group.add('ic', new Group('IC', /(.*IC_.*)|IC/, "#3366CC"));
|
|
Group.add('optimize-background', new Group('Optimize-Background',
|
|
/.*Optimize(d?-?)(Background|Concurrent).*/, "#702000"));
|
|
Group.add('optimize', new Group('Optimize',
|
|
/(StackGuard|Optimize|Deoptimize|Recompile).*/, "#DC3912"));
|
|
Group.add('compile-background', new Group('Compile-Background',
|
|
/(.*Compile-?Background.*)/, "#b08000"));
|
|
Group.add('compile', new Group('Compile',
|
|
/(^Compile.*)|(.*_Compile.*)/, "#FFAA00"));
|
|
Group.add('parse-background',
|
|
new Group('Parse-Background', /.*Parse-?Background.*/, "#c05000"));
|
|
Group.add('parse', new Group('Parse', /.*Parse.*/, "#FF6600"));
|
|
Group.add('callback',
|
|
new Group('Blink C++', /.*(Callback)|(Blink C\+\+).*/, "#109618"));
|
|
Group.add('api', new Group('API', /.*API.*/, "#990099"));
|
|
Group.add('gc-custom', new Group('GC-Custom', /GC_Custom_.*/, "#0099C6"));
|
|
Group.add('gc-background',
|
|
new Group(
|
|
'GC-Background', /.*GC.*(BACKGROUND|Background).*/, "#00597c"));
|
|
Group.add('gc',
|
|
new Group('GC', /GC_.*|AllocateInTargetSpace|GC/, "#00799c"));
|
|
Group.add('javascript',
|
|
new Group('JavaScript', /JS_Execution|JavaScript/, "#DD4477"));
|
|
Group.add('runtime', new Group('V8 C++', /.*/, "#88BB00"));
|
|
Group.add('blink',
|
|
new Group('Blink RCS', /.*Blink_.*/, "#006600", false, false));
|
|
Group.add('unclassified', new Group('Unclassified', /.*/, "#000", false));
|
|
|
|
class GroupedEntry extends Entry {
|
|
constructor(group) {
|
|
super(0, GroupedEntry.prefix + group.name, 0, 0, 0, 0, 0, 0);
|
|
this.group = group;
|
|
this.entries = [];
|
|
this.missingEntries = null;
|
|
this.addsToTotal = group.addsToTotal;
|
|
}
|
|
get regexp() {
|
|
return this.group.regexp;
|
|
}
|
|
get color() {
|
|
return this.group.color;
|
|
}
|
|
get enabled() {
|
|
return this.group.enabled;
|
|
}
|
|
add(entry) {
|
|
if (!this.addTimeAndCount(entry)) return;
|
|
// TODO: sum up variance
|
|
this.entries.push(entry);
|
|
entry.parent = this;
|
|
return true;
|
|
}
|
|
addTimeAndCount(entry) {
|
|
if (!this.regexp.test(entry.name)) return false;
|
|
this._time += entry.time;
|
|
this._count += entry.count;
|
|
return true;
|
|
}
|
|
_initializeMissingEntries() {
|
|
let dummyEntryNames = new Set();
|
|
versions.forEach((version) => {
|
|
let page = version.getOrCreate(this.page.name);
|
|
let groupEntry = page.get(this.name);
|
|
if (groupEntry != this) {
|
|
for (let entry of groupEntry.entries) {
|
|
if (this.page.get(entry.name) == undefined) {
|
|
dummyEntryNames.add(entry.name);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
this.missingEntries = [];
|
|
for (let name of dummyEntryNames) {
|
|
let tmpEntry = new Entry(0, name, 0, 0, 0, 0, 0, 0);
|
|
tmpEntry.page = this.page;
|
|
this.missingEntries.push(tmpEntry);
|
|
};
|
|
}
|
|
forEach(fun) {
|
|
// Show also all entries which are in at least one version.
|
|
// Concatenate our real entries.
|
|
if (this.missingEntries == null) {
|
|
this._initializeMissingEntries();
|
|
}
|
|
let tmpEntries = this.missingEntries.concat(this.entries);
|
|
|
|
// The compared entries are sorted by absolute impact.
|
|
tmpEntries.sort((a, b) => {
|
|
return b.time - a.time
|
|
});
|
|
tmpEntries.forEach(fun);
|
|
}
|
|
sort() {
|
|
this.entries.sort((a, b) => {
|
|
return b.time - a.time;
|
|
});
|
|
}
|
|
cssClass() {
|
|
if (this.page.total == this) return 'total';
|
|
return '';
|
|
}
|
|
get isGroup() {
|
|
return true
|
|
}
|
|
getVarianceForProperty(property) {
|
|
let sum = 0;
|
|
const key = property + 'Variance';
|
|
this.entries.forEach((entry) => {
|
|
const value = entry[key];
|
|
sum += value * value;
|
|
});
|
|
return Math.sqrt(sum);
|
|
}
|
|
get timeVariancePercent() {
|
|
if (this._time == 0) return 0;
|
|
return this.getVarianceForProperty('time') / this._time * 100
|
|
}
|
|
get timeVariance() {
|
|
return this.getVarianceForProperty('time')
|
|
}
|
|
}
|
|
GroupedEntry.prefix = 'Group-';
|
|
|
|
class UnclassifiedEntry extends GroupedEntry {
|
|
constructor(page) {
|
|
super(Group.groups.get('unclassified'));
|
|
this.page = page;
|
|
this._time = undefined;
|
|
this._count = undefined;
|
|
}
|
|
add(entry) {
|
|
console.log("Adding unclassified:", entry);
|
|
this.entries.push(entry);
|
|
entry.parent = this;
|
|
return true;
|
|
}
|
|
forEachPageGroup(fun) {
|
|
this.page.forEachGroup((group) => {
|
|
if (group == this) return;
|
|
if (group == this.page.total) return;
|
|
fun(group);
|
|
});
|
|
}
|
|
get time() {
|
|
if (this._time === undefined) {
|
|
this._time = this.page.total._time;
|
|
this.forEachPageGroup((group) => {
|
|
if (group.addsToTotal) this._time -= group._time;
|
|
});
|
|
}
|
|
return this.getCompareWithBaseline(this._time, '_time');
|
|
}
|
|
get count() {
|
|
if (this._count === undefined) {
|
|
this._count = this.page.total._count;
|
|
this.forEachPageGroup((group) => {
|
|
this._count -= group._count;
|
|
});
|
|
}
|
|
return this.getCompareWithBaseline(this._count, '_count');
|
|
}
|
|
}
|
|
</script>
|
|
</head>
|
|
|
|
<body id="body" onmousemove="handleUpdatePopover(event)" onload="handleBodyLoad()" class="noDiff">
|
|
<h1>Runtime Stats Komparator</h1>
|
|
|
|
<section id="inputs" class="panel alwaysVisible">
|
|
<input type="checkbox" id="inputsCheckbox" class="panelCloserInput">
|
|
<label class="panelCloserLabel" for="inputsCheckbox">▼</label>
|
|
<h2>Input/Output</h2>
|
|
<div class="panelBody">
|
|
<form name="fileForm" class="inline">
|
|
<p class="inline">
|
|
<label for="uploadInput">Load Files:</label>
|
|
<input id="uploadInput" type="file" name="files" onchange="handleLoadFiles();" multiple
|
|
accept=".json,.txt,.csv,.output">
|
|
</p>
|
|
<p class="inline">
|
|
<label for="appendInput">Append Files:</label>
|
|
<input id="appendInput" type="file" name="files" onchange="handleAppendFiles();" multiple
|
|
accept=".json,.txt,.csv,.output">
|
|
</p>
|
|
</form>
|
|
<p class="inline">
|
|
<button onclick="handleCopyToClipboard()">Copy Table to Clipboard</button>
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>Baseline Selector</h2>
|
|
<div class="panel-body">
|
|
Compare against baseline: <select id="baseline" onchange="handleSelectBaseline(this, event)"></select><br />
|
|
<span style="color: #060">Green</span> a selected version performs
|
|
better than the baseline.
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel-group">
|
|
<div id="versionSelector" class="panel">
|
|
<input type="checkbox" checked id="versionSelectorCheckbox" class="panelCloserInput">
|
|
<label class="panelCloserLabel" for="versionSelectorCheckbox">▼</label>
|
|
<h2>Selected Versions</h2>
|
|
<div class="panelBody">
|
|
<ul></ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="pageSelector" class="panel">
|
|
<input type="checkbox" checked id="pageSelectorCheckbox" class="panelCloserInput">
|
|
<label class="panelCloserLabel" for="pageSelectorCheckbox">▼</label>
|
|
<h2>Selected Pages</h2>
|
|
<div class="panelBody">
|
|
<ul></ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="groupSelector" class="panel">
|
|
<input type="checkbox" checked id="groupSelectorCheckbox" class="panelCloserInput">
|
|
<label class="panelCloserLabel" for="groupSelectorCheckbox">▼</label>
|
|
<h2>Selected RCS Groups</h2>
|
|
<div class="panelBody">
|
|
<ul></ul>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="view" class="panel">
|
|
<input type="checkbox" id="tableViewCheckbox" class="panelCloserInput">
|
|
<label class="panelCloserLabel" for="tableViewCheckbox">▼</label>
|
|
<h2>RCS Table</h2>
|
|
<div class="panelBody"></div>
|
|
</section>
|
|
|
|
<section class="panel-group">
|
|
<div id="versionDetails" class="panel">
|
|
<input type="checkbox" checked id="versionDetailCheckbox" class="panelCloserInput">
|
|
<label class="panelCloserLabel" for="versionDetailCheckbox">▼</label>
|
|
<h2><span>Compare Page Versions</span></h2>
|
|
<div class="conten panelBody">
|
|
<table class="versionDetailTable" onclick="handleSelectDetailRow(this, event);">
|
|
<thead>
|
|
<tr>
|
|
<th class="version">Version </th>
|
|
<th class="position">Pos. </th>
|
|
<th class="value time">Time▴ </th>
|
|
<th class="value time">Percent </th>
|
|
<th class="value count">Count </th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="pageDetail" class="panel">
|
|
<input type="checkbox" checked id="pageDetailCheckbox" class="panelCloserInput">
|
|
<label class="panelCloserLabel" for="pageDetailCheckbox">▼</label>
|
|
<h2>Page Comparison for <span></span></h2>
|
|
<div class="panelBody">
|
|
<table class="pageDetailTable" onclick="handleSelectDetailRow(this, event);">
|
|
<thead>
|
|
<tr>
|
|
<th class="page">Page </th>
|
|
<th class="value time">Time </th>
|
|
<th class="value time">Percent▾ </th>
|
|
<th class="value time hideNoDiff">%/Entry </th>
|
|
<th class="value count">Count </th>
|
|
</tr>
|
|
</thead>
|
|
<tfoot>
|
|
<tr>
|
|
<td class="page">Total:</td>
|
|
<td class="value time"></td>
|
|
<td class="value time"></td>
|
|
<td class="value time hideNoDiff"></td>
|
|
<td class="value count"></td>
|
|
</tr>
|
|
</tfoot>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="impactView" class="panel">
|
|
<input type="checkbox" checked id="impactViewCheckbox" class="panelCloserInput">
|
|
<label class="panelCloserLabel" for="impactViewCheckbox">▼</label>
|
|
<h2>Impact list for <span></span></h2>
|
|
<div class="panelBody">
|
|
<table class="pageDetailTable" onclick="handleSelectDetailRow(this, event);">
|
|
<thead>
|
|
<tr>
|
|
<th class="page">Name </th>
|
|
<th class="value time">Time </th>
|
|
<th class="value time">Percent▾ </th>
|
|
<th class="">Top Pages</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="pageVersionGraph" class="panel">
|
|
<input type="checkbox" id="pageVersionGraphCheckbox" class="panelCloserInput">
|
|
<label class="panelCloserLabel" for="pageVersionGraphCheckbox">▼</label>
|
|
<h2><span></span></h2>
|
|
<div class="panelBody"></div>
|
|
</section>
|
|
|
|
<section id="pageGraph" class="panel">
|
|
<input type="checkbox" id="pageGraphCheckbox" class="panelCloserInput">
|
|
<label class="panelCloserLabel" for="pageGraphCheckbox">▼</label>
|
|
<h2><span></span></h2>
|
|
<div class="panelBody"></div>
|
|
</section>
|
|
|
|
<section id="versionGraph" class="panel">
|
|
<input type="checkbox" id="versionGraphCheckbox" class="panelCloserInput">
|
|
<label class="panelCloserLabel" for="versionGraphCheckbox">▼</label>
|
|
<h2><span></span></h2>
|
|
<div class="panelBody"></div>
|
|
</section>
|
|
|
|
<div id="column" class="column">
|
|
<div class="header">
|
|
<select class="version" onchange="handleSelectVersion(this, event);"></select>
|
|
<select class="pageVersion" onchange="handleSelectPage(this, event);"></select>
|
|
</div>
|
|
<table class="list" onclick="handleSelectRow(this, event);">
|
|
<thead>
|
|
<tr>
|
|
<th class="position">Pos. </th>
|
|
<th class="name">Name </th>
|
|
<th class="value time">Time </th>
|
|
<th class="value time">Percent </th>
|
|
<th class="value count">Count </th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<section class="panel alwaysVisible">
|
|
<h2>Instructions</h2>
|
|
<div class="panelBody">
|
|
<ol>
|
|
<li>Build chrome.</li>
|
|
</ol>
|
|
<h3>Telemetry benchmark</h3>
|
|
<ol>
|
|
<li>Run <code>v8.browsing</code> benchmarks:
|
|
<pre>$CHROMIUM_DIR/tools/perf/run_benchmark run v8.browsing_desktop \
|
|
--browser=exact --browser-executable=$CHROMIUM_DIR/out/release/chrome \
|
|
--story-filter='.*2020 ' \
|
|
--also-run-disabled-tests
|
|
</pre>
|
|
</li>
|
|
<li>Install <a href="https://stedolan.github.io/jq/">jq</a>.</li>
|
|
<li>Convert the telemetry JSON files to callstats JSON file:
|
|
<pre>
|
|
$V8_DIR/tools/callstats-from-telemetry.sh $CHROMIUM_DIR/tools/perf/artifacts/run_XXXX
|
|
</pre>
|
|
</li>
|
|
<li>Load the generated <code>out.json</code></li>
|
|
</ol>
|
|
<h3>Merged CSV from results.html</h3>
|
|
<ol>
|
|
<li>Open a results.html page for RCS-enabled benchmarks</li>
|
|
<li>Select "Export merged CSV" in the toolbar</li>
|
|
<li>Load the downloading .csv file normally in callstats.html</li>
|
|
</ol>
|
|
<h3>Aggregated raw txt output</h3>
|
|
<ol>
|
|
<li>Install scipy, e.g. <code>sudo aptitude install python-scipy</code>
|
|
<li>Check out a known working version of webpagereply:
|
|
<pre>git -C $CHROME_DIR/third_party/webpagereplay checkout 7dbd94752d1cde5536ffc623a9e10a51721eff1d</pre>
|
|
</li>
|
|
<li>Run <code>callstats.py</code> with a web-page-replay archive:
|
|
<pre>$V8_DIR/tools/callstats.py run \
|
|
--replay-bin=$CHROME_SRC/third_party/webpagereplay/replay.py \
|
|
--replay-wpr=$INPUT_DIR/top25.wpr \
|
|
--js-flags="" \
|
|
--with-chrome=$CHROME_SRC/out/Release/chrome \
|
|
--sites-file=$INPUT_DIR/top25.json</pre>
|
|
</li>
|
|
<li>Move results file to a subdirectory: <code>mkdir $VERSION_DIR; mv *.txt $VERSION_DIR</code></li>
|
|
<li>Repeat from step 1 with a different configuration (e.g. <code>--js-flags="--nolazy"</code>).</li>
|
|
<li>Create the final results file: <code>./callstats.py json $VERSION_DIR1 $VERSION_DIR2 > result.json</code>
|
|
</li>
|
|
<li>Use <code>results.json</code> on this site.</code>
|
|
</ol>
|
|
</div>
|
|
</section>
|
|
|
|
<div id="popover">
|
|
<div class="popoverArrow"></div>
|
|
<table>
|
|
<tr>
|
|
<td class="name" colspan="6"></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Page:</td>
|
|
<td class="page name" colspan="6"></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Version:</td>
|
|
<td class="version name" colspan="3"></td>
|
|
<td class="compare version name" colspan="3"></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Time:</td>
|
|
<td class="time"></td>
|
|
<td>±</td>
|
|
<td class="timeVariance"></td>
|
|
<td class="compare time"></td>
|
|
<td class="compare"> ± </td>
|
|
<td class="compare timeVariance"></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Percent:</td>
|
|
<td class="percent"></td>
|
|
<td>±</td>
|
|
<td class="percentVariance"></td>
|
|
<td class="compare percent"></td>
|
|
<td class="compare"> ± </td>
|
|
<td class="compare percentVariance"></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Percent per Entry:</td>
|
|
<td class="percentPerEntry"></td>
|
|
<td colspan=2></td>
|
|
<td class="compare percentPerEntry"></td>
|
|
<td colspan=2></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Count:</td>
|
|
<td class="count"></td>
|
|
<td>±</td>
|
|
<td class="countVariance"></td>
|
|
<td class="compare count"></td>
|
|
<td class="compare"> ± </td>
|
|
<td class="compare countVariance"></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Overall Impact:</td>
|
|
<td class="timeImpact"></td>
|
|
<td>±</td>
|
|
<td class="timePercentImpact"></td>
|
|
<td class="compare timeImpact"></td>
|
|
<td class="compare"> ± </td>
|
|
<td class="compare timePercentImpact"></td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
</body>
|
|
|
|
</html> |