blob: 3e4794369be2dbb14619ac066c67721393fcd455 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* Typescript for actor_internals.html, served from chrome://actor-internals/
* This is used to debug actor events recording. It displays a live
* stream of all actor events that occur in chromium while the
* chrome://actor-internals/ page is open.
*/
import {getRequiredElement} from '//resources/js/util.js';
import type {JournalEntry} from './actor_internals.mojom-webui.js';
import {BrowserProxy} from './browser_proxy.js';
/**
* Manages the state and rendering of the actor event log table.
* Encapsulates all state (active spans, collapse state, indentation)
* instead of using global variables.
*/
class ActorEventLog {
private table: HTMLTableElement;
private template: HTMLTemplateElement;
private spanIdCounter: number = 0;
private activeSpans: Array<{id: number, track: string, event: string}> = [];
private spanState =
new Map<number, {collapsed: boolean, parentIds: number[]}>();
private indentationByTrack = new Map<string, number>();
constructor() {
this.table = getRequiredElement<HTMLTableElement>('actor-events-table');
this.template = getRequiredElement<HTMLTemplateElement>('actor-events-row');
}
// --- State Helper Methods ---
private getState(row: HTMLTableRowElement) {
const rowSpanId = Number(row.dataset['spanId']);
return this.spanState.get(rowSpanId);
}
private getParentIds(row: HTMLTableRowElement): number[] {
const state = this.getState(row);
return state ? state.parentIds : [];
}
isCollapsed(row: HTMLTableRowElement): boolean {
const state = this.getState(row);
return state ? state.collapsed : false;
}
/** Checks if any parent of a given row is currently collapsed. */
private isAnyParentCollapsed(row: HTMLTableRowElement): boolean {
for (const parentId of this.getParentIds(row)) {
const parentState = this.spanState.get(parentId);
if (parentState && parentState.collapsed) {
return true;
}
}
return false;
}
private getIndentation(track: string): number {
return this.indentationByTrack.get(track) || 0;
}
private incrementIndentation(track: string): void {
this.indentationByTrack.set(track, this.getIndentation(track) + 1);
}
private decrementIndentation(track: string): void {
const newLevel = Math.max(0, this.getIndentation(track) - 1);
this.indentationByTrack.set(track, newLevel);
}
/**
* Main entry point for adding a new journal entry to the table.
*/
addEntry(entry: JournalEntry) {
const {row, cells} = this.createRow(entry);
const typeCell = cells[3]!;
const detailsCell = cells[4]!;
if (entry.track === 'FrontEnd') {
typeCell.classList.add('frontend-track');
}
this.formatTypeCell(entry, row, typeCell);
this.formatDetailsCell(entry, detailsCell);
this.insertRow(row, entry.timestamp.getTime());
}
/**
* Creates the <tr> element and all <td> cells from the template.
*/
private createRow(entry: JournalEntry) {
const clone = (this.template.content.cloneNode(true) as DocumentFragment);
const row = clone.children[0] as HTMLTableRowElement;
row.dataset['timestamp'] = entry.timestamp.getTime().toString();
row.dataset['taskId'] = entry.taskId.toString();
row.dataset['type'] = entry.type.toString();
row.dataset['track'] = entry.track.toString();
const cells = clone.querySelectorAll('td');
cells[0]!.textContent = entry.taskId === 0 ? '' : entry.taskId.toString();
cells[1]!.textContent = entry.url;
cells[2]!.textContent = entry.event;
cells[5]!.textContent =
new Date(entry.timestamp).toLocaleTimeString(undefined, {
hour12: false,
timeZoneName: 'short',
});
return {row, cells};
}
private formatMapDetails(detailsMap: Map<string, string>): string {
return [...detailsMap]
.filter(([_, value]) => value)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
}
private formatDetailsCell(entry: JournalEntry,
detailsCell: HTMLTableCellElement ) {
if (entry.event === 'GlicPerformActions' && entry.details['proto']) {
const protobytes = entry.details['proto'];
const link = document.createElement('a');
link.textContent = 'Actions Proto';
link.href = `https://protoshop.corp.google.com/embed?tabs=viewer,editor&type=chrome_intelligence_proto_features.Actions&protobytes=${
protobytes}`;
link.target = '_blank';
detailsCell.appendChild(link);
} else if (entry.screenshot) {
const byteArray = new Uint8Array(entry.screenshot);
const blob = new Blob([byteArray], { type: 'image/jpeg' });
const blobUrl = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobUrl; // Use the Blob URL for the small image
img.style.width = '200px';
img.style.height = '100px';
img.style.cursor = 'pointer'; // Indicate it's clickable
img.onclick = () => {
const newTab = window.open(blobUrl, '_blank');
if (newTab) {
newTab.focus();
}
};
detailsCell.appendChild(img);
} else {
detailsCell.textContent = this.formatMapDetails(new Map(Object.entries(entry.details)));
}
detailsCell.classList.add('whitespace-pre');
if ('error' in entry.details) {
detailsCell.classList.add('error-cell');
}
}
private formatTypeCell(
entry: JournalEntry, row: HTMLTableRowElement,
typeCell: HTMLTableCellElement) {
const typeSpan = document.createElement('span');
typeCell.classList.add('type-cell');
const newSpanId = this.spanIdCounter++;
row.dataset['spanId'] = newSpanId.toString();
let indentLevel = 0;
let parentIds: number[] = [];
if (entry.type === 'Begin') {
// 1. Get the current indentation level for this track (e.g., N)
indentLevel = this.getIndentation(entry.track);
parentIds = this.activeSpans.map(s => s.id);
// 2. Render this 'Begin' span at level N
typeCell.textContent = ' '.repeat(indentLevel * 2) + '+ ';
typeCell.onclick = () => this.toggleSpan(row);
this.spanState.set(newSpanId, {collapsed: false, parentIds});
// 3. Add to stack and increment the map *for its future children* (to
// N+1)
this.activeSpans.push({id: newSpanId, track: entry.track, event: entry.event});
this.incrementIndentation(entry.track);
typeSpan.className = 'type-b';
} else if (entry.type === 'End') {
// This 'End' event closes the current span. Decrement the indent
// level for this track *first* (from N+1 back to N).
this.decrementIndentation(entry.track);
// Get the new, correct indentation level (N)
indentLevel = this.getIndentation(entry.track);
// Get parent list *before* removing its own Begin span from the stack.
// This correctly makes the End row a child of its Begin row.
parentIds = this.activeSpans.map(s => s.id);
// Find and remove the last active span *matching this track*.
// Do NOT just pop(), as the top of stack might be from another track.
let endedSpanIndex = -1;
for (let i = this.activeSpans.length - 1; i >= 0; i--) {
if (this.activeSpans[i]!.track === entry.track &&
this.activeSpans[i]!.event.startsWith(entry.event)) {
endedSpanIndex = i;
break;
}
}
if (endedSpanIndex > -1) {
this.activeSpans.splice(endedSpanIndex, 1);
}
// Render this 'End' span at level N
typeCell.textContent = ' '.repeat(indentLevel * 2) + ' ';
this.spanState.set(newSpanId, {collapsed: false, parentIds});
typeSpan.className = 'type-e';
} else {
indentLevel = this.getIndentation(entry.track);
parentIds = this.activeSpans.map(s => s.id);
typeCell.textContent = ' '.repeat(indentLevel * 2) + ' ';
this.spanState.set(newSpanId, {collapsed: false, parentIds});
}
typeSpan.textContent = entry.type;
typeCell.appendChild(typeSpan);
typeCell.classList.add('whitespace-pre');
}
/**
* Inserts the new row into the table, sorted by timestamp.
*/
private insertRow(row: HTMLTableRowElement, newTimestamp: number) {
const rows = this.table.rows;
for (let i = rows.length - 1; i > 0; i--) {
const existingTimestamp = Number(rows[i]!.dataset['timestamp']);
if (newTimestamp >= existingTimestamp) {
// Insert after this row
rows[i]!.after(row);
return;
}
}
// If we reached the top or table is empty, append after header
rows[0]!.after(row);
}
/**
* Toggles the collapsed state of a span.
*/
toggleSpan(row: HTMLTableRowElement) {
const toggledSpanId = Number(row.dataset['spanId']);
const state = this.spanState.get(toggledSpanId);
if (!state) {
return;
}
const toCollapse = !state.collapsed;
state.collapsed = toCollapse;
// Update the +/- icon on the clicked row.
const typeCell = row.querySelector<HTMLTableCellElement>('.type-cell');
const textNode = typeCell!.childNodes[0];
textNode!.nodeValue = toCollapse ? textNode!.nodeValue!.replace('+', '-') :
textNode!.nodeValue!.replace('-', '+');
// Update visibility for all subsequent rows that are descendants.
for (let i = row.rowIndex + 1; i < this.table.rows.length; i++) {
const childRow = this.table.rows[i]!;
const parentSpans = this.getParentIds(childRow);
// If the row is not a descendant, we can stop iterating.
if (!parentSpans.includes(toggledSpanId)) {
break;
}
// If the child row is not on the same track, do not change its
// visibility.
if (childRow.dataset['track'] !== row.dataset['track']) {
continue;
}
// This row is a descendant. It should be visible ONLY if *none* of its
// parents (including the one just toggled) are collapsed.
const isHidden = this.isAnyParentCollapsed(childRow);
childRow.style.display = isHidden ? 'none' : '';
// Add animation class if we are un-collapsing
if (!isHidden && !toCollapse) {
childRow.classList.add('newly-uncollapsed');
setTimeout(() => {
childRow.classList.remove('newly-uncollapsed');
}, 2000);
}
}
}
/**
* Displays all rows, respecting the current collapsed state of parent spans.
*/
clearFilter() {
getRequiredElement<HTMLInputElement>('task-id-filter').value = '';
const rows = Array.from(this.table.rows);
for (let i = 1; i < rows.length; i++) {
const row = rows[i]!;
// Show row only if none of its parents are collapsed
row.style.display = this.isAnyParentCollapsed(row) ? 'none' : '';
}
}
/**
* Displays only rows matching a task ID, respecting collapsed state.
*/
filterByTaskId() {
const taskId =
getRequiredElement<HTMLInputElement>('task-id-filter').value.trim();
if (!taskId) {
this.clearFilter(); // No filter text, just run a clear.
return;
}
const rows = Array.from(this.table.rows);
let firstIndex = -1;
let lastIndex = -1;
for (let i = 1; i < rows.length; i++) {
if (rows[i]!.dataset['taskId'] === taskId) {
if (firstIndex === -1) {
firstIndex = i;
}
lastIndex = i;
}
}
if (firstIndex === -1) {
// No matches, hide everything
for (let i = 1; i < rows.length; i++) {
rows[i]!.style.display = 'none';
}
return;
}
// Show/hide all rows based on filter range AND collapse state
for (let i = 1; i < rows.length; i++) {
const row = rows[i]!;
const inRange = (i >= firstIndex && i <= lastIndex);
if (!inRange) {
row.style.display = 'none';
} else {
// It's in the task range. Show it, but only if its parents aren't
// collapsed.
row.style.display = this.isAnyParentCollapsed(row) ? 'none' : '';
}
}
}
}
function startLogging() {
BrowserProxy.getInstance().handler.startLogging();
getRequiredElement('start-logging').style.display = 'none';
getRequiredElement('stop-logging').style.display = 'inline-block';
}
function stopLogging() {
BrowserProxy.getInstance().handler.stopLogging();
getRequiredElement('start-logging').style.display = 'inline-block';
getRequiredElement('stop-logging').style.display = 'none';
}
window.onload = function() {
const proxy = BrowserProxy.getInstance();
const log = new ActorEventLog();
// Route journal entries to the class instance
proxy.callbackRouter.journalEntryAdded.addListener(
(entry: JournalEntry) => log.addEntry(entry));
// Hook up buttons
getRequiredElement('start-logging').onclick = startLogging;
getRequiredElement('stop-logging').onclick = stopLogging;
getRequiredElement('filter-by-task-id').onclick = () => log.filterByTaskId();
getRequiredElement('clear-filter').onclick = () => log.clearFilter();
};