[tools][system-analyzer]

improve logEntry hit testing performance
fixing flame graph rendering
adding some comments
adding flamechart highlighting

Bug: v8:10644, v8:11835
Change-Id: I2ab2f63b9e8339c6c25bb7023772fc97dfc56c2e
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/2959615
Commit-Queue: Camillo Bruni <cbruni@chromium.org>
Reviewed-by: Patrick Thier <pthier@chromium.org>
Cr-Commit-Position: refs/heads/master@{#75130}
This commit is contained in:
Camillo Bruni 2021-06-14 14:44:00 +02:00 committed by V8 LUCI CQ
parent 1c249d33b5
commit 1837c6f983
12 changed files with 293 additions and 101 deletions

View File

@ -267,6 +267,28 @@ export class Profile {
throw new Error(`unknown code state: ${state}`);
}
static vmStateString(state) {
switch (state) {
case this.VMState.JS:
return 'JS';
case this.VMState.GC:
return 'GC';
case this.VMState.PARSER:
return 'Parse';
case this.VMState.BYTECODE_COMPILER:
return 'Compile Bytecode';
case this.VMState.COMPILER:
return 'Compile';
case this.VMState.OTHER:
return 'Other';
case this.VMState.EXTERNAL:
return 'External';
case this.VMState.IDLE:
return 'Idle';
}
return 'unknown';
}
/**
* Called whenever the specified operation has failed finding a function
* containing the specified address. Should be overriden by subclasses.

View File

@ -1,6 +1,7 @@
:root {
--background-color: #000000;
--surface-color: #121212;
--surface-color-rgb: 18, 18, 18;
--surface-color: rgb(var(--surface-color-rgb));
--primary-color: #bb86fc;
--secondary-color: #03dac6;
--on-surface-color: #ffffff;

View File

@ -7,17 +7,31 @@ import {LogEntry} from './log.mjs';
export class TickLogEntry extends LogEntry {
constructor(time, vmState, processedStack) {
super(TickLogEntry.extractType(processedStack), time);
super(TickLogEntry.extractType(vmState, processedStack), time);
this.state = vmState;
this.stack = processedStack;
}
static extractType(processedStack) {
if (processedStack.length == 0) return 'idle';
const topOfStack = processedStack[processedStack.length - 1];
if (topOfStack?.state) {
return Profile.getKindFromState(topOfStack.state);
static extractType(vmState, processedStack) {
if (processedStack.length == 0 || vmState == Profile.VMState.IDLE) {
return 'Idle';
}
return 'native';
const topOfStack = processedStack[0];
if (typeof topOfStack === 'number') {
// TODO(cbruni): Handle VmStack and native ticks better.
return 'Other';
}
if (vmState != Profile.VMState.JS) {
topOfStack.vmState = vmState;
}
return this.extractCodeEntryType(topOfStack);
}
static extractCodeEntryType(entry) {
if (entry?.state !== undefined) {
return 'JS ' + Profile.getKindFromState(entry.state);
}
if (entry?.vmState) return Profile.vmStateString(entry.vmState);
return 'Other';
}
}

View File

@ -83,7 +83,7 @@ export class CSSColor {
return this.list[index % this.list.length];
}
static darken(hexColorString, amount = -40) {
static darken(hexColorString, amount = -50) {
if (hexColorString[0] !== '#') {
throw new Error(`Unsupported color: ${hexColorString}`);
}

View File

@ -14,15 +14,14 @@ found in the LICENSE file. -->
transition: all 0.5s ease-in-out;
background-color: var(--surface-color);
}
#fileReader:hover {
background-color: var(--primary-color);
color: var(--on-primary-color);
}
.done #fileReader{
height: 20px;
line-height: 20px;
display: none;
}
.fail #fileReader {
@ -51,6 +50,7 @@ found in the LICENSE file. -->
height: 100%;
background-color: var(--file-reader-background-color);
}
#spinner {
position: absolute;
width: 100px;

View File

@ -18,10 +18,26 @@ found in the LICENSE file. -->
width: 30px;
background-color: var(--border-color);
}
.titleButtons {
float: right;
}
.titleButtons button {
border-radius: 20px;
width: 20px;
height: 20px;
font-weight: bold;
line-height: 11px;
}
</style>
</head>
<div class="panel">
<h2>Timeline Panel</h2>
<h2>
Timeline Panel
<div class="titleButtons">
<button id="zoomIn" title="Increase resolution">+</button>
<button id="zoomOut" title="Decrease resolution"></button>
</div>
</h2>
<div class="titleBackground"></div>
<div>
<slot></slot>

View File

@ -18,6 +18,8 @@ DOM.defineCustomElement(
this.addEventListener(
SynchronizeSelectionEvent.name,
e => this.handleSelectionSyncronization(e));
this.$('#zoomIn').onclick = () => this.nofChunks *= 1.5;
this.$('#zoomOut').onclick = () => this.nofChunks /= 1.5;
}
set nofChunks(count) {

View File

@ -7,6 +7,8 @@ import {kChunkHeight, kChunkWidth} from '../../log/map.mjs';
import {SelectionEvent, SelectTimeEvent, SynchronizeSelectionEvent, ToolTipEvent,} from '../events.mjs';
import {CSSColor, DOM, SVG, V8CustomElement} from '../helper.mjs';
export const kTimelineHeight = 200;
export class TimelineTrackBase extends V8CustomElement {
_timeline;
_nofChunks = 500;
@ -17,6 +19,9 @@ export class TimelineTrackBase extends V8CustomElement {
_legend;
_lastContentWidth = 0;
_cachedTimelineBoundingClientRect;
_cachedTimelineScrollLeft;
constructor(templateText) {
super(templateText);
this._selectionHandler = new SelectionHandler(this);
@ -24,9 +29,10 @@ export class TimelineTrackBase extends V8CustomElement {
this._legend.onFilter = (type) => this._handleFilterTimeline();
this.timelineNode.addEventListener(
'scroll', e => this._handleTimelineScroll(e));
this.timelineNode.onclick = (e) => this._handleClick(e);
this.timelineNode.ondblclick = (e) => this._handleDoubleClick(e);
this.timelineChunks.onmousemove = (e) => this._handleMouseMove(e);
this.hitPanelNode.onclick = this._handleClick.bind(this);
this.hitPanelNode.ondblclick = this._handleDoubleClick.bind(this);
this.hitPanelNode.onmousemove = this._handleMouseMove.bind(this);
window.addEventListener('resize', () => this._resetCachedDimensions());
this.isLocked = false;
}
@ -61,10 +67,30 @@ export class TimelineTrackBase extends V8CustomElement {
this._legend.update();
}
// Maps the clicked x position to the x position on timeline canvas
get _timelineBoundingClientRect() {
if (this._cachedTimelineBoundingClientRect === undefined) {
this._cachedTimelineBoundingClientRect =
this.timelineNode.getBoundingClientRect();
}
return this._cachedTimelineBoundingClientRect;
}
get _timelineScrollLeft() {
if (this._cachedTimelineScrollLeft === undefined) {
this._cachedTimelineScrollLeft = this.timelineNode.scrollLeft;
}
return this._cachedTimelineScrollLeft;
}
_resetCachedDimensions() {
this._cachedTimelineBoundingClientRect = undefined;
this._cachedTimelineScrollLeft = undefined;
}
// Maps the clicked x position to the x position on timeline
positionOnTimeline(pagePosX) {
let rect = this.timelineNode.getBoundingClientRect();
let posClickedX = pagePosX - rect.left + this.timelineNode.scrollLeft;
let rect = this._timelineBoundingClientRect;
let posClickedX = pagePosX - rect.left + this._timelineScrollLeft;
return posClickedX;
}
@ -74,7 +100,7 @@ export class TimelineTrackBase extends V8CustomElement {
relativePositionToTime(timelineRelativeX) {
const timelineAbsoluteX = timelineRelativeX + this._timeStartPixelOffset;
return timelineAbsoluteX / this._timeToPixel;
return (timelineAbsoluteX / this._timeToPixel) | 0;
}
timeToPosition(time) {
@ -83,8 +109,8 @@ export class TimelineTrackBase extends V8CustomElement {
return relativePosX;
}
get timelineCanvas() {
return this.$('#timelineCanvas');
get toolTipTargetNode() {
return this.$('#toolTipTarget');
}
get timelineChunks() {
@ -108,6 +134,10 @@ export class TimelineTrackBase extends V8CustomElement {
return this._timelineNode;
}
get hitPanelNode() {
return this.$('#hitPanel');
}
get timelineAnnotationsNode() {
return this.$('#timelineAnnotations');
}
@ -150,6 +180,7 @@ export class TimelineTrackBase extends V8CustomElement {
set scrollLeft(offset) {
this.timelineNode.scrollLeft = offset;
this._cachedTimelineScrollLeft = offset;
}
handleEntryTypeDoubleClick(e) {
@ -158,19 +189,20 @@ export class TimelineTrackBase extends V8CustomElement {
timelineIndicatorMove(offset) {
this.timelineNode.scrollLeft += offset;
this._cachedTimelineScrollLeft = undefined;
}
_handleTimelineScroll(e) {
let horizontal = e.currentTarget.scrollLeft;
let scrollLeft = e.currentTarget.scrollLeft;
this._cachedTimelineScrollLeft = scrollLeft;
this.dispatchEvent(new CustomEvent(
'scrolltrack', {bubbles: true, composed: true, detail: horizontal}));
'scrolltrack', {bubbles: true, composed: true, detail: scrollLeft}));
}
_updateDimensions() {
const centerOffset = this.timelineNode.getBoundingClientRect().width / 2;
const time = this.relativePositionToTime(
this.timelineNode.scrollLeft + centerOffset);
const centerOffset = this._timelineBoundingClientRect.width / 2;
const time =
this.relativePositionToTime(this._timelineScrollLeft + centerOffset);
const start = this._timeline.startTime;
const width = this._nofChunks * kChunkWidth;
this._lastContentWidth = parseInt(this.timelineMarkersNode.style.width);
@ -179,10 +211,12 @@ export class TimelineTrackBase extends V8CustomElement {
this.timelineChunks.style.width = `${width}px`;
this.timelineMarkersNode.style.width = `${width}px`;
this.timelineAnnotationsNode.style.width = `${width}px`;
this.hitPanelNode.style.width = `${width}px`;
this._drawMarkers();
this._selectionHandler.update();
this._scaleContent(width);
this.timelineNode.scrollLeft = this.timeToPosition(time) - centerOffset;
this._cachedTimelineScrollLeft = this.timelineNode.scrollLeft =
this.timeToPosition(time) - centerOffset;
}
_scaleContent(currentWidth) {
@ -194,12 +228,15 @@ export class TimelineTrackBase extends V8CustomElement {
_adjustHeight(height) {
this.querySelectorAll('.dataSized')
.forEach(node => {node.style.height = height + 'px'});
this.timelineNode.style.overflowY =
(height > kTimelineHeight) ? 'scroll' : 'hidden';
}
_update() {
this._legend.update();
this._drawContent();
this._drawAnnotations(this.selectedEntry);
this._resetCachedDimensions();
}
async _drawContent() {
@ -230,7 +267,7 @@ export class TimelineTrackBase extends V8CustomElement {
const groups = chunk.getBreakdown(event => event.type);
let buffer = '';
const kHeight = chunk.height;
let lastHeight = 200;
let lastHeight = kTimelineHeight;
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
if (group.count == 0) break;
@ -281,7 +318,8 @@ export class TimelineTrackBase extends V8CustomElement {
_handleDoubleClick(event) {
this._selectionHandler.clearSelection();
const chunk = event.target.chunk;
const time = this.positionToTime(event.pageX);
const chunk = this._getChunkForEvent(event)
if (!chunk) return;
event.stopImmediatePropagation();
this.dispatchEvent(new SelectTimeEvent(chunk.start, chunk.end));
@ -291,28 +329,33 @@ export class TimelineTrackBase extends V8CustomElement {
_handleMouseMove(event) {
if (this.isLocked) return false;
if (this._selectionHandler.isSelecting) return false;
const {logEntry, target} = this._getEntryForEvent(event);
const logEntry = this._getEntryForEvent(event);
if (!logEntry) return false;
this.dispatchEvent(new ToolTipEvent(logEntry, target));
this.dispatchEvent(new ToolTipEvent(logEntry, this.toolTipTargetNode));
const time = this.positionToTime(event.pageX);
this._drawAnnotations(logEntry, time);
}
_getEntryForEvent(event) {
let target = event.target;
let logEntry = false;
if (target === this.timelineChunks) return {logEntry, target};
target = target.parentNode;
_getChunkForEvent(event) {
const time = this.positionToTime(event.pageX);
const chunkIndex = (time - this._timeline.startTime) /
this._timeline.duration() * this._nofChunks;
const chunk = this.chunks[chunkIndex | 0];
if (!chunk?.isEmpty()) {
const relativeIndex =
Math.round((200 - event.layerY) / chunk.height * (chunk.size() - 1));
if (relativeIndex < chunk.size()) logEntry = chunk.at(relativeIndex);
}
return {logEntry, target};
const chunkIndex = ((time - this._timeline.startTime) /
this._timeline.duration() * this._nofChunks) |
0;
return this.chunks[chunkIndex];
}
_getEntryForEvent(event) {
const chunk = this._getChunkForEvent(event);
if (chunk?.isEmpty() ?? true) return false;
const relativeIndex = Math.round(
(kTimelineHeight - event.layerY) / chunk.height * (chunk.size() - 1));
if (relativeIndex > chunk.size()) return false;
const logEntry = chunk.at(relativeIndex);
const style = this.toolTipTargetNode.style;
style.left = `${chunk.index * kChunkWidth}px`;
style.top = `${kTimelineHeight - chunk.height}px`;
style.height = `${chunk.height}px`;
return logEntry;
}
};
@ -475,7 +518,12 @@ class Legend {
}
colorForType(type) {
return this._colors.get(type);
let color = this._colors.get(type);
if (color === undefined) {
color = CSSColor.at(this._colors.size);
this._colors.set(type, color);
}
return color;
}
filter(logEntry) {

View File

@ -29,15 +29,20 @@ found in the LICENSE file. -->
}
#timelineSamples, #timelineChunks,
#timelineMarkers, #timelineAnnotations {
#timelineMarkers, #timelineAnnotations, #hitPanel {
top: 0px;
position: absolute;
margin-right: 100px;
}
#timelineMarkers, #timelineAnnotations {
#timelineMarkers, #timelineAnnotations,
.noPointerEvents, .noPointerEvents * {
pointer-events: none;
}
#toolTipTarget {
position: absolute;
}
.title {
position: relative;
float: left;
@ -105,6 +110,9 @@ found in the LICENSE file. -->
#selection {
display: none;
top: 0px;
left: 0px;
position: absolute;
}
#rightHandle,
@ -121,6 +129,7 @@ found in the LICENSE file. -->
}
#rightHandle {
border-right: 1px solid var(--on-surface-color);
margin-left: -5px;
}
#selectionBackground {
@ -147,7 +156,9 @@ found in the LICENSE file. -->
.legend {
flex: initial;
}
</style>
<style>
/* SVG styles */
.txt {
font: 8px monospace;
}
@ -179,8 +190,10 @@ found in the LICENSE file. -->
vector-effect: non-scaling-stroke;
}
.flameSelected {
fill: none;
fill: var(--on-background-color);
fill-opacity: 0.1;
stroke: var(--on-background-color);
stroke-opacity: 0.8;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
@ -204,9 +217,11 @@ found in the LICENSE file. -->
<svg id="timelineChunks" xmlns="http://www.w3.org/2000/svg" class="dataSized">
<g id="scalableContent"></g>
</svg>
<svg id="timelineAnnotations" xmlns="http://www.w3.org/2000/svg" class="dataSized"></svg>
<svg id="timelineMarkers" xmlns="http://www.w3.org/2000/svg" class="dataSized"></svg>
<canvas id="timelineCanvas"></canvas>
<svg id="timelineAnnotations" xmlns="http://www.w3.org/2000/svg" class="dataSized noPointerEvents"></svg>
<svg id="timelineMarkers" xmlns="http://www.w3.org/2000/svg" class="dataSized noPointerEvents"></svg>
<div id="toolTipTarget"></div>
<!-- Use a div element covering all complex items to prevent slow hit test-->
<div id="hitPanel" class="dataSized"></div>
</div>
<div class="timelineLegend">

View File

@ -2,13 +2,16 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {Profile} from '../../../profile.mjs'
import {delay} from '../../helper.mjs';
import {TickLogEntry} from '../../log/tick.mjs';
import {Timeline} from '../../timeline.mjs';
import {CSSColor, DOM, SVG, V8CustomElement} from '../helper.mjs';
import {SelectTimeEvent} from '../events.mjs';
import {DOM, SVG} from '../helper.mjs';
import {TimelineTrackBase} from './timeline-track-base.mjs'
const kFlameHeight = 10;
class Flame {
constructor(time, entry, depth, id) {
this.time = time;
@ -16,13 +19,39 @@ class Flame {
this.depth = depth;
this.id = id;
this.duration = -1;
this.parent = undefined;
this.children = [];
}
static add(time, entry, stack, flames) {
const depth = stack.length;
const id = flames.length;
const newFlame = new Flame(time, entry, depth, id)
if (depth > 0) {
const parent = stack[depth - 1];
newFlame.parent = parent;
parent.children.push(newFlame);
}
flames.push(newFlame);
stack.push(newFlame);
}
stop(time) {
this.duration = time - this.time
}
}
const kFlameHeight = 10;
get start() {
return this.time;
}
get end() {
return this.time + this.duration;
}
get type() {
return TickLogEntry.extractCodeEntryType(this.entry);
}
}
DOM.defineCustomElement('view/timeline/timeline-track', 'timeline-track-tick',
(templateText) =>
@ -33,7 +62,6 @@ DOM.defineCustomElement('view/timeline/timeline-track', 'timeline-track-tick',
constructor() {
super(templateText);
this._annotations = new Annotations(this);
this.timelineNode.style.overflowY = 'scroll';
}
_updateChunks() {
@ -48,17 +76,48 @@ DOM.defineCustomElement('view/timeline/timeline-track', 'timeline-track-tick',
this._updateFlames();
}
_getEntryForEvent(event) {
let logEntry = false;
const target = event.target;
const id = event.target.getAttribute('data-id');
if (id) {
const codeEntry = this._flames.at(id).entry;
if (codeEntry.logEntry) {
logEntry = codeEntry.logEntry;
}
_handleDoubleClick(event) {
this._selectionHandler.clearSelection();
const flame = this._getFlameForEvent(event);
if (flame === undefined) return;
event.stopImmediatePropagation();
this.dispatchEvent(new SelectTimeEvent(flame.start, flame.end));
return false;
}
_getFlameDepthForEvent(event) {
return Math.floor(event.layerY / kFlameHeight) - 1;
}
_getFlameForEvent(event) {
const depth = this._getFlameDepthForEvent(event);
const time = this.positionToTime(event.pageX);
const index = this._flames.find(time);
for (let i = index - 1; i > 0; i--) {
const flame = this._flames.at(i);
if (flame.depth != depth) continue;
if (flame.end < time) continue;
return flame;
}
return {logEntry, target};
return undefined;
}
_getEntryForEvent(event) {
const depth = this._getFlameDepthForEvent(event);
const time = this.positionToTime(event.pageX);
const index = this._timeline.find(time);
const tick = this._timeline.at(index);
let stack = tick.stack;
if (index > 0 && tick.time > time) {
stack = this._timeline.at(index - 1).stack;
}
// tick.stack = [top, ...., bottom];
const logEntry = stack[stack.length - depth - 1]?.logEntry ?? false;
// Filter out raw pc entries.
if (typeof logEntry == 'number' || logEntry === false) return false;
this.toolTipTargetNode.style.left = `${event.layerX}px`;
this.toolTipTargetNode.style.top = `${(depth + 2) * kFlameHeight}px`;
return logEntry;
}
_updateFlames() {
@ -83,10 +142,7 @@ DOM.defineCustomElement('view/timeline/timeline-track', 'timeline-track-tick',
}
flameStack.length = flameStackIndex;
}
const newFlame =
new Flame(tick.time, entry, flameStack.length, tmpFlames.length);
tmpFlames.push(newFlame);
flameStack.push(newFlame);
Flame.add(tick.time, entry, flameStack, tmpFlames);
}
if (tick.stack.length < flameStack.length) {
for (let k = tick.stack.length; k < flameStack.length; k++) {
@ -140,26 +196,17 @@ DOM.defineCustomElement('view/timeline/timeline-track', 'timeline-track-tick',
const x = this.timeToPosition(flame.time);
const y = (flame.depth + 1) * kFlameHeight;
let width = flame.duration * this._timeToPixel;
if (outline) {
return `<rect x=${x} y=${y} width=${width} height=${
kFlameHeight} class=flameSelected />`;
}
let type = 'native';
if (flame.entry?.state) {
type = Profile.getKindFromState(flame.entry.state);
}
const color = this._legend.colorForType(type);
const color = this._legend.colorForType(flame.type);
return `<rect x=${x} y=${y} width=${width} height=${kFlameHeight} fill=${
color} data-id=${flame.id} class=flame />`;
color} class=flame />`;
}
drawFlameText(flame) {
let type = 'native';
if (flame.entry?.state) {
type = Profile.getKindFromState(flame.entry.state);
}
let type = flame.type;
const kHeight = 9;
const x = this.timeToPosition(flame.time);
const y = flame.depth * (kHeight + 1);
@ -167,7 +214,7 @@ DOM.defineCustomElement('view/timeline/timeline-track', 'timeline-track-tick',
width -= width * 0.1;
let buffer = '';
if (width < 15 || type == 'native') return buffer;
if (width < 15 || type == 'Other') return buffer;
const rawName = flame.entry.getRawName();
if (rawName.length == 0) return buffer;
const kChartWidth = 5;
@ -179,7 +226,7 @@ DOM.defineCustomElement('view/timeline/timeline-track', 'timeline-track-tick',
_drawAnnotations(logEntry, time) {
if (time === undefined) {
time = this.relativePositionToTime(this.timelineNode.scrollLeft);
time = this.relativePositionToTime(this._timelineScrollLeft);
}
this._annotations.update(logEntry, time);
}
@ -226,9 +273,11 @@ class Annotations {
if (start < 0) start = 0;
if (end > rawFlames.length) end = rawFlames.length;
const code = this._logEntry.entry;
// Also compare against the function
const func = code.func ?? 0;
for (let i = start; i < end; i++) {
const flame = rawFlames[i];
if (flame.entry != code) continue;
if (flame.entry !== code && flame.entry?.func !== func) continue;
this._buffer += this._track.drawFlame(flame, true);
}
this._drawBuffer();

View File

@ -11,14 +11,17 @@ found in the LICENSE file. -->
}
#content {
background-color: var(--surface-color);
background-color: rgba(var(--surface-color-rgb), 0.8);
border: 3px var(--primary-color) solid;
border-radius: 10px;
min-width: 100px;
min-height: 100px;
padding: 10px;
box-shadow: 0px 0px 10px rgba(0,0,0,0.5);
width: auto;
min-width: 100px;
max-width: 400px;
min-height: 100px;
max-height: 400px;
overflow: auto;
box-shadow: 0px 0px 10px rgba(0,0,0,0.5);
}
#content > h3 {
@ -39,7 +42,7 @@ found in the LICENSE file. -->
z-index: 99999;
--tip-offset: 10px;
--tip-width: 10px;
--tip-height: 15px;
--tip-height: 40px;
}
#body.top {
@ -55,29 +58,51 @@ found in the LICENSE file. -->
left: calc(var(--tip-offset) * -1 - var(--tip-width));
}
.tip {
.tip, .tipThin {
width: 0;
height: 0;
border-style: solid;
position: absolute;
border-width: var(--tip-height) var(--tip-width) 0 var(--tip-width);
border-color: var(--primary-color) transparent transparent transparent;
pointer-events: none;
}
.tip {
border-width: var(--tip-width) var(--tip-width) 0 var(--tip-width);
}
.tipThin {
border-width: var(--tip-height) 4px 2px 4px;
bottom: -30px;
left: -4px;
}
/* Tip positioning modifiers */
.top > .tip {
bottom: calc(var(--tip-width) * -1);
}
.top > .tipThin {
bottom: calc(var(--tip-height) * -1);
}
.bottom > .tip {
top: calc(var(--tip-width) * -1);
transform: scaleY(-1);
}
.bottom > .tipThin {
top: calc(var(--tip-height) * -1);
transform: scaleY(-1);
}
.left > .tip {
right: var(--tip-offset);
}
.left > .tipThin {
right: var(--tip-offset);
}
.right > .tip {
left: var(--tip-offset);
}
.right > .tipThin {
left: var(--tip-offset);
}
.properties td {
vertical-align: top;
}
@ -93,5 +118,7 @@ found in the LICENSE file. -->
<div id="body">
<div id="content">
</div>
<div class="tip"></div>
<div class="tip">
<div class="tipThin"></div>
</div>
</div>

View File

@ -155,13 +155,11 @@ class TableBuilder {
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());
}
new ExpandableText(cell, value.toString());
}
_addTitle(value) {