[tools][system-analyzer] Add ToolTip API

Enable more complex tooltips with clickable links and references.

- Use short filename for Script.name if they are unique
- Use shared App.isClickable method
- Remove various toStringLong methods
- Rename CodeLogEntry.disassemble to .code
- Add DOM.button helper

Bug: v8:10644
Change-Id: I5d46ffd560b37278dc46b8347cb9ff0a7fdfa2ef
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/2916373
Reviewed-by: Victor Gomes <victorgomes@chromium.org>
Commit-Queue: Camillo Bruni <cbruni@chromium.org>
Cr-Commit-Position: refs/heads/master@{#74746}
This commit is contained in:
Camillo Bruni 2021-05-25 10:49:11 +02:00 committed by V8 LUCI CQ
parent 42c77e9a83
commit a6c474fecc
17 changed files with 270 additions and 85 deletions

View File

@ -45,10 +45,6 @@ export class SourcePosition {
toString() { toString() {
return `${this.script.name}:${this.line}:${this.column}`; return `${this.script.name}:${this.line}:${this.column}`;
} }
toStringLong() {
return this.toString();
}
} }
export class Script { export class Script {
@ -63,8 +59,9 @@ export class Script {
this.sourcePositions = []; this.sourcePositions = [];
} }
update(name, source) { update(url, source) {
this.name = name; this.url = url;
this.name = Script.getShortestUniqueName(url, this);
this.source = source; this.source = source;
} }
@ -103,8 +100,33 @@ export class Script {
return `Script(${this.id}): ${this.name}`; return `Script(${this.id}): ${this.name}`;
} }
toStringLong() { get toolTipDict() {
return this.source; return {
title: this.toString(),
__this__: this,
id: this.id,
url: this.url,
source: this.source,
sourcePositions: this.sourcePositions.length
}
}
static getShortestUniqueName(url, script) {
const parts = url.split('/');
const filename = parts[parts.length -1];
const dict = this._dict ?? (this._dict = new Map());
const matchingScripts = dict.get(filename);
if (matchingScripts == undefined) {
dict.set(filename, [script]);
return filename;
}
// TODO: find shortest unique substring
// Update all matching scripts to have a unique filename again.
for (let matchingScript of matchingScripts) {
matchingScript.name = script.url
}
matchingScripts.push(script);
return url;
} }
} }

View File

@ -179,7 +179,7 @@ found in the LICENSE file. -->
</a> </a>
</dt> </dt>
<dd> <dd>
Log<a href="https://v8.dev/blog/fast-properties">Maps</a> Log <a href="https://v8.dev/blog/fast-properties">Maps</a>
</dd> </dd>
<dt> <dt>
<a href="https://source.chromium.org/search?q=FLAG_trace_ic"> <a href="https://source.chromium.org/search?q=FLAG_trace_ic">

View File

@ -9,6 +9,7 @@ import {ApiLogEntry} from './log/api.mjs';
import {DeoptLogEntry} from './log/code.mjs'; import {DeoptLogEntry} from './log/code.mjs';
import {CodeLogEntry} from './log/code.mjs'; import {CodeLogEntry} from './log/code.mjs';
import {IcLogEntry} from './log/ic.mjs'; import {IcLogEntry} from './log/ic.mjs';
import {LogEntry} from './log/log.mjs';
import {MapLogEntry} from './log/map.mjs'; import {MapLogEntry} from './log/map.mjs';
import {Processor} from './processor.mjs'; import {Processor} from './processor.mjs';
import {FocusEvent, SelectionEvent, SelectRelatedEvent, SelectTimeEvent, ToolTipEvent,} from './view/events.mjs'; import {FocusEvent, SelectionEvent, SelectRelatedEvent, SelectTimeEvent, ToolTipEvent,} from './view/events.mjs';
@ -119,6 +120,14 @@ class App {
this.selectEntries(entries); this.selectEntries(entries);
} }
static isClickable(object) {
if (typeof object !== 'object') return false;
if (object instanceof LogEntry) return true;
if (object instanceof SourcePosition) return true;
if (object instanceof Script) return true;
return false;
}
handleSelectEntries(e) { handleSelectEntries(e) {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
this.showEntries(e.entries); this.showEntries(e.entries);
@ -232,6 +241,7 @@ class App {
this._state.map = entry; this._state.map = entry;
this._view.mapTrack.focusedEntry = entry; this._view.mapTrack.focusedEntry = entry;
this._view.mapPanel.map = entry; this._view.mapPanel.map = entry;
this._view.mapPanel.show();
} }
focusIcLogEntry(entry) { focusIcLogEntry(entry) {
@ -241,10 +251,11 @@ class App {
focusCodeLogEntry(entry) { focusCodeLogEntry(entry) {
this._state.code = entry; this._state.code = entry;
this._view.codePanel.entry = entry; this._view.codePanel.entry = entry;
this._view.codePanel.show();
} }
focusDeoptLogEntry(entry) { focusDeoptLogEntry(entry) {
this._view.deoptList.focusedLogEntry = entry; this._state.DeoptLogEntry = entry;
} }
focusApiLogEntry(entry) { focusApiLogEntry(entry) {
@ -255,11 +266,33 @@ class App {
focusSourcePosition(sourcePosition) { focusSourcePosition(sourcePosition) {
if (!sourcePosition) return; if (!sourcePosition) return;
this._view.scriptPanel.focusedSourcePositions = [sourcePosition]; this._view.scriptPanel.focusedSourcePositions = [sourcePosition];
this._view.scriptPanel.show();
} }
handleToolTip(event) { handleToolTip(event) {
this._view.toolTip.positionOrTargetNode = event.positionOrTargetNode; let content = event.content;
this._view.toolTip.content = event.content; switch (content.constructor) {
case String:
break;
case Script:
case SourcePosition:
case MapLogEntry:
case IcLogEntry:
case ApiLogEntry:
case CodeLogEntry:
case DeoptLogEntry:
content = content.toolTipDict;
break;
default:
throw new Error(
`Unknown tooltip content type: ${entry.constructor?.name}`);
}
this.setToolTip(content, event.positionOrTargetNode);
}
setToolTip(content, positionOrTargetNode) {
this._view.toolTip.positionOrTargetNode = positionOrTargetNode;
this._view.toolTip.content = content;
} }
handleFileUploadStart(e) { handleFileUploadStart(e) {

View File

@ -18,14 +18,6 @@ export class ApiLogEntry extends LogEntry {
return this._argument; return this._argument;
} }
toString() {
return `Api(${this.type})`;
}
toStringLong() {
return `Api(${this.type}): ${this._name}`;
}
static get propertyNames() { static get propertyNames() {
return ['type', 'name', 'argument']; return ['type', 'name', 'argument'];
} }

View File

@ -1,6 +1,8 @@
// Copyright 2020 the V8 project authors. All rights reserved. // Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import {formatBytes} from '../helper.mjs';
import {LogEntry} from './log.mjs'; import {LogEntry} from './log.mjs';
export class DeoptLogEntry extends LogEntry { export class DeoptLogEntry extends LogEntry {
@ -34,14 +36,6 @@ export class DeoptLogEntry extends LogEntry {
return this._entry.functionName; return this._entry.functionName;
} }
toString() {
return `Deopt(${this.type})`;
}
toStringLong() {
return `Deopt(${this.type})${this._reason}: ${this._location}`;
}
static get propertyNames() { static get propertyNames() {
return [ return [
'type', 'reason', 'functionName', 'sourcePosition', 'type', 'reason', 'functionName', 'sourcePosition',
@ -51,9 +45,10 @@ export class DeoptLogEntry extends LogEntry {
} }
export class CodeLogEntry extends LogEntry { export class CodeLogEntry extends LogEntry {
constructor(type, time, kind, entry) { constructor(type, time, kindName, kind, entry) {
super(type, time); super(type, time);
this._kind = kind; this._kind = kind;
this._kindName = kindName;
this._entry = entry; this._entry = entry;
} }
@ -61,6 +56,10 @@ export class CodeLogEntry extends LogEntry {
return this._kind; return this._kind;
} }
get kindName() {
return this._kindName;
}
get entry() { get entry() {
return this._entry; return this._entry;
} }
@ -77,7 +76,7 @@ export class CodeLogEntry extends LogEntry {
return this._entry?.getSourceCode() ?? ''; return this._entry?.getSourceCode() ?? '';
} }
get disassemble() { get code() {
return this._entry?.source?.disassemble; return this._entry?.source?.disassemble;
} }
@ -85,11 +84,16 @@ export class CodeLogEntry extends LogEntry {
return `Code(${this.type})`; return `Code(${this.type})`;
} }
toStringLong() { get toolTipDict() {
return `Code(${this.type}): ${this._entry.toString()}`; const dict = super.toolTipDict;
dict.size = formatBytes(dict.size);
return dict;
} }
static get propertyNames() { static get propertyNames() {
return ['type', 'kind', 'functionName', 'sourcePosition', 'script']; return [
'type', 'kind', 'kindName', 'size', 'functionName', 'sourcePosition',
'script', 'source', 'code'
];
} }
} }

View File

@ -28,14 +28,6 @@ export class IcLogEntry extends LogEntry {
this.modifier = modifier; this.modifier = modifier;
} }
toString() {
return `IC(${this.type})`;
}
toStringLong() {
return `IC(${this.type}):\n${this.state}`;
}
parseMapProperties(parts, offset) { parseMapProperties(parts, offset) {
let next = parts[++offset]; let next = parts[++offset];
if (!next.startsWith('dict')) return offset; if (!next.startsWith('dict')) return offset;
@ -65,7 +57,7 @@ export class IcLogEntry extends LogEntry {
static get propertyNames() { static get propertyNames() {
return [ return [
'type', 'category', 'functionName', 'script', 'sourcePosition', 'state', 'type', 'category', 'functionName', 'script', 'sourcePosition', 'state',
'key', 'map', 'reason', 'file' 'key', 'map', 'reason'
]; ];
} }
} }

View File

@ -22,15 +22,34 @@ export class LogEntry {
} }
toString() { toString() {
return `${this.constructor.name}(${this._type})`; let name = this.constructor.name;
const index = name.lastIndexOf('LogEntry');
if (index > 0) {
name = name.substr(0, index);
}
return `${name}(${this._type})`;
} }
toStringLong() { get toolTipDict() {
return this.toString(); const toolTipDescription = {
__proto__: null,
__this__: this,
title: this.toString()
};
for (let key of this.constructor.propertyNames) {
toolTipDescription[key] = this[key];
}
return toolTipDescription;
} }
// Returns an Array of all possible #type values. // Returns an Array of all possible #type values.
static get allTypes() { static get allTypes() {
throw new Error('Not implemented.'); throw new Error('Not implemented.');
} }
// Returns an array of public property names.
static get propertyNames() {
throw new Error('Not implemented.');
}
} }

View File

@ -60,10 +60,6 @@ class MapLogEntry extends LogEntry {
return `Map(${this.id})`; return `Map(${this.id})`;
} }
toStringLong() {
return `Map(${this.id}):\n${this.description}`;
}
finalizeRootMap(id) { finalizeRootMap(id) {
let stack = [this]; let stack = [this];
while (stack.length > 0) { while (stack.length > 0) {
@ -194,7 +190,7 @@ class MapLogEntry extends LogEntry {
static get propertyNames() { static get propertyNames() {
return [ return [
'type', 'reason', 'property', 'functionName', 'sourcePosition', 'script', 'type', 'reason', 'property', 'functionName', 'sourcePosition', 'script',
'id' 'id', 'parent', 'description'
]; ];
} }
} }

View File

@ -213,8 +213,9 @@ export class Processor extends LogReader {
} else { } else {
entry = this._profile.addCode(type, name, timestamp, start, size); entry = this._profile.addCode(type, name, timestamp, start, size);
} }
this._lastCodeLogEntry = this._lastCodeLogEntry = new CodeLogEntry(
new CodeLogEntry(type + stateName, timestamp, kind, entry); type + stateName, timestamp,
Profile.getKindFromState(Profile.parseState(stateName)), kind, entry);
this._codeTimeline.push(this._lastCodeLogEntry); this._codeTimeline.push(this._lastCodeLogEntry);
} }

View File

@ -1,11 +1,8 @@
// Copyright 2020 the V8 project authors. All rights reserved. // Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import {IcLogEntry} from '../log/ic.mjs'; import {SelectRelatedEvent} from './events.mjs';
import {MapLogEntry} from '../log/map.mjs'; import {CollapsableElement, DOM, formatBytes, formatMicroSeconds} from './helper.mjs';
import {FocusEvent, SelectionEvent, ToolTipEvent} from './events.mjs';
import {CollapsableElement, delay, DOM, formatBytes, formatMicroSeconds} from './helper.mjs';
DOM.defineCustomElement('view/code-panel', DOM.defineCustomElement('view/code-panel',
(templateText) => (templateText) =>
@ -51,7 +48,7 @@ DOM.defineCustomElement('view/code-panel',
_update() { _update() {
this._updateSelect(); this._updateSelect();
this._disassemblyNode.innerText = this._entry?.disassemble ?? ''; this._disassemblyNode.innerText = this._entry?.code ?? '';
this._sourceNode.innerText = this._entry?.source ?? ''; this._sourceNode.innerText = this._entry?.source ?? '';
} }

View File

@ -76,10 +76,10 @@ export class ToolTipEvent extends AppEvent {
constructor(content, positionOrTargetNode) { constructor(content, positionOrTargetNode) {
super(ToolTipEvent.name); super(ToolTipEvent.name);
this._content = content; if (!positionOrTargetNode) {
if (!positionOrTargetNode && !node) {
throw Error('Either provide a valid position or targetNode'); throw Error('Either provide a valid position or targetNode');
} }
this._content = content;
this._positionOrTargetNode = positionOrTargetNode; this._positionOrTargetNode = positionOrTargetNode;
} }

View File

@ -137,6 +137,13 @@ export class DOM {
return document.createTextNode(string); return document.createTextNode(string);
} }
static button(label, clickHandler) {
const button = DOM.element('button');
button.innerText = label;
button.onclick = clickHandler;
return button;
}
static div(classes) { static div(classes) {
return this.element('div', classes); return this.element('div', classes);
} }
@ -236,11 +243,19 @@ export class CollapsableElement extends V8CustomElement {
this._closer.onclick = _ => this.tryUpdateOnVisibilityChange(); this._closer.onclick = _ => this.tryUpdateOnVisibilityChange();
} }
hide() {
if (this._contentIsVisible) this._closer.click();
}
show() {
if (!this._contentIsVisible) this._closer.click();
}
get _closer() { get _closer() {
return this.$('#closer'); return this.$('#closer');
} }
_contentIsVisible() { get _contentIsVisible() {
return !this._closer.checked; return !this._closer.checked;
} }
@ -257,7 +272,7 @@ export class CollapsableElement extends V8CustomElement {
} }
requestUpdateIfVisible(useAnimation) { requestUpdateIfVisible(useAnimation) {
if (!this._contentIsVisible()) return; if (!this._contentIsVisible) return;
return super.requestUpdate(useAnimation); return super.requestUpdate(useAnimation);
} }
@ -267,6 +282,43 @@ export class CollapsableElement extends V8CustomElement {
} }
} }
export class ExpandableText {
constructor(node, string, limit = 200) {
this._node = node;
this._string = string;
this._delta = limit / 2;
this._start = 0;
this._end = string.length;
this._button = this._createExpandButton();
this.expand();
}
_createExpandButton() {
const button = DOM.element('button');
button.innerText = '...';
button.onclick = (e) => {
e.stopImmediatePropagation();
this.expand()
};
return button;
}
expand() {
DOM.removeAllChildren(this._node);
this._start = this._start + this._delta;
this._end = this._end - this._delta;
if (this._start >= this._end) {
this._node.innerText = this._string;
this._button.onclick = undefined;
return;
}
this._node.appendChild(DOM.text(this._string.substring(0, this._start)));
this._node.appendChild(this._button);
this._node.appendChild(
DOM.text(this._string.substring(this._end, this._string.length)));
}
}
export class Chunked { export class Chunked {
constructor(iterable, limit) { constructor(iterable, limit) {
this._iterator = iterable[Symbol.iterator](); this._iterator = iterable[Symbol.iterator]();

View File

@ -2,8 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import {Script, SourcePosition} from '../../profile.mjs'; import {App} from '../index.mjs'
import {LogEntry} from '../log/log.mjs';
import {FocusEvent, ToolTipEvent} from './events.mjs'; import {FocusEvent, ToolTipEvent} from './events.mjs';
import {groupBy, LazyTable} from './helper.mjs'; import {groupBy, LazyTable} from './helper.mjs';
@ -128,8 +127,7 @@ DOM.defineCustomElement('view/list-panel',
_logEntryMouseOverHandler(e) { _logEntryMouseOverHandler(e) {
const group = e.currentTarget.group; const group = e.currentTarget.group;
this.dispatchEvent( this.dispatchEvent(new ToolTipEvent(group.key, e.currentTarget));
new ToolTipEvent(group.key.toStringLong(), e.currentTarget));
} }
_handleDetailsClick(event) { _handleDetailsClick(event) {
@ -188,8 +186,8 @@ DOM.defineCustomElement('view/list-panel',
details.onclick = this._detailsClickHandler; details.onclick = this._detailsClickHandler;
tr.appendChild(DOM.td(`${group.percent.toFixed(2)}%`, 'percentage')); tr.appendChild(DOM.td(`${group.percent.toFixed(2)}%`, 'percentage'));
tr.appendChild(DOM.td(group.count, 'count')); tr.appendChild(DOM.td(group.count, 'count'));
const valueTd = tr.appendChild(DOM.td(`${group.key}`, 'key')); const valueTd = tr.appendChild(DOM.td(group.key.toString(), 'key'));
if (this._isClickable(group.key)) { if (App.isClickable(group.key)) {
tr.onclick = this._logEntryClickHandler; tr.onclick = this._logEntryClickHandler;
tr.onmouseover = this._logEntryMouseOverHandler; tr.onmouseover = this._logEntryMouseOverHandler;
valueTd.classList.add('clickable'); valueTd.classList.add('clickable');
@ -197,12 +195,4 @@ DOM.defineCustomElement('view/list-panel',
return tr; return tr;
}, 10); }, 10);
} }
_isClickable(object) {
if (typeof object !== 'object') return false;
if (object instanceof LogEntry) return true;
if (object instanceof SourcePosition) return true;
if (object instanceof Script) return true;
return false;
}
}); });

View File

@ -148,8 +148,8 @@ DOM.defineCustomElement(
} }
_handleMouseoverMap(event) { _handleMouseoverMap(event) {
this.dispatchEvent(new ToolTipEvent( this.dispatchEvent(
event.currentTarget.map.toStringLong(), event.currentTarget)); new ToolTipEvent(event.currentTarget.map, event.currentTarget));
} }
_handleToggleSubtree(event) { _handleToggleSubtree(event) {

View File

@ -232,8 +232,7 @@ DOM.defineCustomElement('view/timeline/timeline-track',
let relativeIndex = Math.round( let relativeIndex = Math.round(
event.layerY / event.target.offsetHeight * (chunk.size() - 1)); event.layerY / event.target.offsetHeight * (chunk.size() - 1));
let logEntry = chunk.at(relativeIndex); let logEntry = chunk.at(relativeIndex);
this.dispatchEvent(new FocusEvent(logEntry)); this.dispatchEvent(new ToolTipEvent(logEntry, event.target));
this.dispatchEvent(new ToolTipEvent(logEntry.toStringLong(), event.target));
} }
_handleChunkClick(event) { _handleChunkClick(event) {

View File

@ -21,6 +21,10 @@ found in the LICENSE file. -->
width: auto; width: auto;
} }
#content > h3 {
margin-top: 0;
}
.textContent { .textContent {
font-family: monospace; font-family: monospace;
white-space: pre; white-space: pre;
@ -73,6 +77,13 @@ found in the LICENSE file. -->
.right > .tip { .right > .tip {
left: var(--tip-offset); left: var(--tip-offset);
} }
.properties td {
vertical-align: top;
}
.properties > tbody > tr > td:nth-child(2n+1):after {
content: ':';
}
</style> </style>
<div id="body"> <div id="body">

View File

@ -2,13 +2,19 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import {DOM, V8CustomElement} from './helper.mjs'; import {App} from '../index.mjs'
import {FocusEvent} from './events.mjs';
import {DOM, ExpandableText, V8CustomElement} from './helper.mjs';
DOM.defineCustomElement( DOM.defineCustomElement(
'view/tool-tip', (templateText) => class Tooltip extends V8CustomElement { 'view/tool-tip', (templateText) => class Tooltip extends V8CustomElement {
_targetNode; _targetNode;
_content; _content;
_isHidden = true; _isHidden = true;
_logEntryClickHandler = this._handleLogEntryClick.bind(this);
_logEntryRelatedHandler = this._handleLogEntryRelated.bind(this);
constructor() { constructor() {
super(templateText); super(templateText);
this._intersectionObserver = new IntersectionObserver((entries) => { this._intersectionObserver = new IntersectionObserver((entries) => {
@ -82,14 +88,28 @@ DOM.defineCustomElement(
if (typeof content === 'string') { if (typeof content === 'string') {
this.contentNode.innerHTML = content; this.contentNode.innerHTML = content;
this.contentNode.className = 'textContent'; this.contentNode.className = 'textContent';
} else if (content?.nodeType && nodeType?.nodeName) {
this._setContentNode(content);
} else { } else {
const newContent = DOM.div(); this._setContentNode(new TableBuilder(this, content).fragment);
newContent.appendChild(content);
this.contentNode.replaceWith(newContent);
newContent.id = 'content';
} }
} }
_setContentNode(content) {
const newContent = DOM.div();
newContent.appendChild(content);
this.contentNode.replaceWith(newContent);
newContent.id = 'content';
}
_handleLogEntryClick(e) {
this.dispatchEvent(new FocusEvent(e.currentTarget.data));
}
_handleLogEntryRelated(e) {
this.dispatchEvent(new SelectRelatedEvent(e.currentTarget.data));
}
hide() { hide() {
this._isHidden = true; this._isHidden = true;
this.bodyNode.style.display = 'none'; this.bodyNode.style.display = 'none';
@ -109,3 +129,60 @@ DOM.defineCustomElement(
return this.$('#content'); return this.$('#content');
} }
}); });
class TableBuilder {
_instance;
constructor(tooltip, descriptor) {
this._fragment = new DocumentFragment();
this._table = DOM.table('properties');
this._tooltip = tooltip;
for (let key in descriptor) {
const value = descriptor[key];
this._addKeyValue(key, value);
}
this._addFooter();
this._fragment.appendChild(this._table);
}
_addKeyValue(key, value) {
if (key == 'title') return this._addTitle(value);
if (key == '__this__') {
this._instance = value;
return;
}
const row = this._table.insertRow();
row.insertCell().innerText = key;
const cell = row.insertCell();
if (value == undefined) return;
if (App.isClickable(value)) {
cell.innerText = value.toString();
cell.className = 'clickable';
cell.onclick = this._logEntryClickHandler;
cell.data = value;
} else {
new ExpandableText(cell, value.toString());
}
}
_addTitle(value) {
const title = DOM.element('h3');
title.innerText = value;
this._fragment.appendChild(title);
}
_addFooter() {
if (this._instance === undefined) return;
const td = this._table.createTFoot().insertRow().insertCell();
let button =
td.appendChild(DOM.button('Show', this._tooltip._logEntryClickHandler));
button.data = this._instance;
button = td.appendChild(
DOM.button('Show Related', this._tooltip._logEntryRelatedClickHandler));
button.data = this._instance;
}
get fragment() {
return this._fragment;
}
}