[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() {
return `${this.script.name}:${this.line}:${this.column}`;
}
toStringLong() {
return this.toString();
}
}
export class Script {
@ -63,8 +59,9 @@ export class Script {
this.sourcePositions = [];
}
update(name, source) {
this.name = name;
update(url, source) {
this.url = url;
this.name = Script.getShortestUniqueName(url, this);
this.source = source;
}
@ -103,8 +100,33 @@ export class Script {
return `Script(${this.id}): ${this.name}`;
}
toStringLong() {
return this.source;
get toolTipDict() {
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>
</dt>
<dd>
Log<a href="https://v8.dev/blog/fast-properties">Maps</a>
Log <a href="https://v8.dev/blog/fast-properties">Maps</a>
</dd>
<dt>
<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 {CodeLogEntry} from './log/code.mjs';
import {IcLogEntry} from './log/ic.mjs';
import {LogEntry} from './log/log.mjs';
import {MapLogEntry} from './log/map.mjs';
import {Processor} from './processor.mjs';
import {FocusEvent, SelectionEvent, SelectRelatedEvent, SelectTimeEvent, ToolTipEvent,} from './view/events.mjs';
@ -119,6 +120,14 @@ class App {
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) {
e.stopImmediatePropagation();
this.showEntries(e.entries);
@ -232,6 +241,7 @@ class App {
this._state.map = entry;
this._view.mapTrack.focusedEntry = entry;
this._view.mapPanel.map = entry;
this._view.mapPanel.show();
}
focusIcLogEntry(entry) {
@ -241,10 +251,11 @@ class App {
focusCodeLogEntry(entry) {
this._state.code = entry;
this._view.codePanel.entry = entry;
this._view.codePanel.show();
}
focusDeoptLogEntry(entry) {
this._view.deoptList.focusedLogEntry = entry;
this._state.DeoptLogEntry = entry;
}
focusApiLogEntry(entry) {
@ -255,11 +266,33 @@ class App {
focusSourcePosition(sourcePosition) {
if (!sourcePosition) return;
this._view.scriptPanel.focusedSourcePositions = [sourcePosition];
this._view.scriptPanel.show();
}
handleToolTip(event) {
this._view.toolTip.positionOrTargetNode = event.positionOrTargetNode;
this._view.toolTip.content = event.content;
let 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) {

View File

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

View File

@ -1,6 +1,8 @@
// Copyright 2020 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.
import {formatBytes} from '../helper.mjs';
import {LogEntry} from './log.mjs';
export class DeoptLogEntry extends LogEntry {
@ -34,14 +36,6 @@ export class DeoptLogEntry extends LogEntry {
return this._entry.functionName;
}
toString() {
return `Deopt(${this.type})`;
}
toStringLong() {
return `Deopt(${this.type})${this._reason}: ${this._location}`;
}
static get propertyNames() {
return [
'type', 'reason', 'functionName', 'sourcePosition',
@ -51,9 +45,10 @@ export class DeoptLogEntry extends LogEntry {
}
export class CodeLogEntry extends LogEntry {
constructor(type, time, kind, entry) {
constructor(type, time, kindName, kind, entry) {
super(type, time);
this._kind = kind;
this._kindName = kindName;
this._entry = entry;
}
@ -61,6 +56,10 @@ export class CodeLogEntry extends LogEntry {
return this._kind;
}
get kindName() {
return this._kindName;
}
get entry() {
return this._entry;
}
@ -77,7 +76,7 @@ export class CodeLogEntry extends LogEntry {
return this._entry?.getSourceCode() ?? '';
}
get disassemble() {
get code() {
return this._entry?.source?.disassemble;
}
@ -85,11 +84,16 @@ export class CodeLogEntry extends LogEntry {
return `Code(${this.type})`;
}
toStringLong() {
return `Code(${this.type}): ${this._entry.toString()}`;
get toolTipDict() {
const dict = super.toolTipDict;
dict.size = formatBytes(dict.size);
return dict;
}
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;
}
toString() {
return `IC(${this.type})`;
}
toStringLong() {
return `IC(${this.type}):\n${this.state}`;
}
parseMapProperties(parts, offset) {
let next = parts[++offset];
if (!next.startsWith('dict')) return offset;
@ -65,7 +57,7 @@ export class IcLogEntry extends LogEntry {
static get propertyNames() {
return [
'type', 'category', 'functionName', 'script', 'sourcePosition', 'state',
'key', 'map', 'reason', 'file'
'key', 'map', 'reason'
];
}
}

View File

@ -22,15 +22,34 @@ export class LogEntry {
}
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() {
return this.toString();
get toolTipDict() {
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.
static get allTypes() {
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})`;
}
toStringLong() {
return `Map(${this.id}):\n${this.description}`;
}
finalizeRootMap(id) {
let stack = [this];
while (stack.length > 0) {
@ -194,7 +190,7 @@ class MapLogEntry extends LogEntry {
static get propertyNames() {
return [
'type', 'reason', 'property', 'functionName', 'sourcePosition', 'script',
'id'
'id', 'parent', 'description'
];
}
}

View File

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

View File

@ -1,11 +1,8 @@
// Copyright 2020 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.
import {IcLogEntry} from '../log/ic.mjs';
import {MapLogEntry} from '../log/map.mjs';
import {FocusEvent, SelectionEvent, ToolTipEvent} from './events.mjs';
import {CollapsableElement, delay, DOM, formatBytes, formatMicroSeconds} from './helper.mjs';
import {SelectRelatedEvent} from './events.mjs';
import {CollapsableElement, DOM, formatBytes, formatMicroSeconds} from './helper.mjs';
DOM.defineCustomElement('view/code-panel',
(templateText) =>
@ -51,7 +48,7 @@ DOM.defineCustomElement('view/code-panel',
_update() {
this._updateSelect();
this._disassemblyNode.innerText = this._entry?.disassemble ?? '';
this._disassemblyNode.innerText = this._entry?.code ?? '';
this._sourceNode.innerText = this._entry?.source ?? '';
}

View File

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

View File

@ -137,6 +137,13 @@ export class DOM {
return document.createTextNode(string);
}
static button(label, clickHandler) {
const button = DOM.element('button');
button.innerText = label;
button.onclick = clickHandler;
return button;
}
static div(classes) {
return this.element('div', classes);
}
@ -236,11 +243,19 @@ export class CollapsableElement extends V8CustomElement {
this._closer.onclick = _ => this.tryUpdateOnVisibilityChange();
}
hide() {
if (this._contentIsVisible) this._closer.click();
}
show() {
if (!this._contentIsVisible) this._closer.click();
}
get _closer() {
return this.$('#closer');
}
_contentIsVisible() {
get _contentIsVisible() {
return !this._closer.checked;
}
@ -257,7 +272,7 @@ export class CollapsableElement extends V8CustomElement {
}
requestUpdateIfVisible(useAnimation) {
if (!this._contentIsVisible()) return;
if (!this._contentIsVisible) return;
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 {
constructor(iterable, limit) {
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
// found in the LICENSE file.
import {Script, SourcePosition} from '../../profile.mjs';
import {LogEntry} from '../log/log.mjs';
import {App} from '../index.mjs'
import {FocusEvent, ToolTipEvent} from './events.mjs';
import {groupBy, LazyTable} from './helper.mjs';
@ -128,8 +127,7 @@ DOM.defineCustomElement('view/list-panel',
_logEntryMouseOverHandler(e) {
const group = e.currentTarget.group;
this.dispatchEvent(
new ToolTipEvent(group.key.toStringLong(), e.currentTarget));
this.dispatchEvent(new ToolTipEvent(group.key, e.currentTarget));
}
_handleDetailsClick(event) {
@ -188,8 +186,8 @@ DOM.defineCustomElement('view/list-panel',
details.onclick = this._detailsClickHandler;
tr.appendChild(DOM.td(`${group.percent.toFixed(2)}%`, 'percentage'));
tr.appendChild(DOM.td(group.count, 'count'));
const valueTd = tr.appendChild(DOM.td(`${group.key}`, 'key'));
if (this._isClickable(group.key)) {
const valueTd = tr.appendChild(DOM.td(group.key.toString(), 'key'));
if (App.isClickable(group.key)) {
tr.onclick = this._logEntryClickHandler;
tr.onmouseover = this._logEntryMouseOverHandler;
valueTd.classList.add('clickable');
@ -197,12 +195,4 @@ DOM.defineCustomElement('view/list-panel',
return tr;
}, 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) {
this.dispatchEvent(new ToolTipEvent(
event.currentTarget.map.toStringLong(), event.currentTarget));
this.dispatchEvent(
new ToolTipEvent(event.currentTarget.map, event.currentTarget));
}
_handleToggleSubtree(event) {

View File

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

View File

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

View File

@ -2,13 +2,19 @@
// Use of this source code is governed by a BSD-style license that can be
// 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(
'view/tool-tip', (templateText) => class Tooltip extends V8CustomElement {
_targetNode;
_content;
_isHidden = true;
_logEntryClickHandler = this._handleLogEntryClick.bind(this);
_logEntryRelatedHandler = this._handleLogEntryRelated.bind(this);
constructor() {
super(templateText);
this._intersectionObserver = new IntersectionObserver((entries) => {
@ -82,14 +88,28 @@ DOM.defineCustomElement(
if (typeof content === 'string') {
this.contentNode.innerHTML = content;
this.contentNode.className = 'textContent';
} else if (content?.nodeType && nodeType?.nodeName) {
this._setContentNode(content);
} else {
const newContent = DOM.div();
newContent.appendChild(content);
this.contentNode.replaceWith(newContent);
newContent.id = 'content';
this._setContentNode(new TableBuilder(this, content).fragment);
}
}
_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() {
this._isHidden = true;
this.bodyNode.style.display = 'none';
@ -109,3 +129,60 @@ DOM.defineCustomElement(
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;
}
}