aboutsummaryrefslogtreecommitdiff
path: root/src/zen/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'src/zen/frontend')
-rw-r--r--src/zen/frontend/html/api.js137
-rw-r--r--src/zen/frontend/html/csvstats.js383
-rw-r--r--src/zen/frontend/html/index.html95
-rw-r--r--src/zen/frontend/html/logs.js237
-rw-r--r--src/zen/frontend/html/memory.js790
-rw-r--r--src/zen/frontend/html/stats.js95
-rw-r--r--src/zen/frontend/html/timeline.js973
-rw-r--r--src/zen/frontend/html/trace.css1312
-rw-r--r--src/zen/frontend/html/trace.js577
9 files changed, 4599 insertions, 0 deletions
diff --git a/src/zen/frontend/html/api.js b/src/zen/frontend/html/api.js
new file mode 100644
index 000000000..fbe5304ca
--- /dev/null
+++ b/src/zen/frontend/html/api.js
@@ -0,0 +1,137 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+// Thin wrappers around the /api/* endpoints exposed by TraceViewerService.
+
+const API = "api/";
+
+const JSON_HEADERS = { Accept: "application/json" };
+
+async function getJson(path) {
+ const response = await fetch(API + path, { headers: JSON_HEADERS });
+ if (!response.ok) {
+ throw new Error(`${path}: HTTP ${response.status}`);
+ }
+ return response.json();
+}
+
+export function getSession() {
+ return getJson("session");
+}
+
+export function getThreads() {
+ return getJson("threads");
+}
+
+export function getChannels() {
+ return getJson("channels");
+}
+
+export function getScopeStats() {
+ return getJson("scope-stats");
+}
+
+export function getScopeNames() {
+ return getJson("scope-names");
+}
+
+export async function getTimeline(threadId, startUs, endUs, minDurUs = 0, resolution = 0, { signal } = {}) {
+ const params = new URLSearchParams({
+ thread: String(threadId),
+ start: String(startUs),
+ end: String(endUs),
+ });
+ if (minDurUs > 0) params.set("mindur", String(minDurUs));
+ if (resolution > 0) params.set("resolution", String(resolution));
+ const response = await fetch(API + "timeline?" + params.toString(), { signal, headers: JSON_HEADERS });
+ if (!response.ok) {
+ throw new Error(`timeline: HTTP ${response.status}`);
+ }
+ return response.json();
+}
+
+export async function getTimelineBatch(threadIds, startUs, endUs, minDurUs = 0, resolution = 0, { signal } = {}) {
+ let url = `${API}timeline-batch?threads=${threadIds.join(",")}&start=${startUs}&end=${endUs}`;
+ if (minDurUs > 0) url += `&mindur=${minDurUs}`;
+ if (resolution > 0) url += `&resolution=${resolution}`;
+ const response = await fetch(url, { signal, headers: JSON_HEADERS });
+ if (!response.ok) {
+ throw new Error(`timeline-batch: HTTP ${response.status}`);
+ }
+ return response.json();
+}
+
+export function getLogCategories() {
+ return getJson("log-categories");
+}
+
+export function getLogs({ startUs = 0, endUs = 0xffffffff, minVerbosity = 0, category = null, limit = 5000 } = {}) {
+ const params = new URLSearchParams({
+ start: String(startUs),
+ end: String(endUs),
+ min_verbosity: String(minVerbosity),
+ limit: String(limit),
+ });
+ if (category !== null && category !== undefined) {
+ params.set("category", String(category));
+ }
+ return getJson("logs?" + params.toString());
+}
+
+export function getBookmarks() {
+ return getJson("bookmarks");
+}
+
+export function getRegions() {
+ return getJson("regions");
+}
+
+export function getCsvCategories() {
+ return getJson("csv-categories");
+}
+
+export function getCsvStats() {
+ return getJson("csv-stats");
+}
+
+export function getCsvSeries(statId, threadId) {
+ let url = "csv-series?";
+ if (statId != null) url += `stat=${statId}&`;
+ if (threadId != null) url += `thread=${threadId}&`;
+ return getJson(url);
+}
+
+export function getCsvEvents() {
+ return getJson("csv-events");
+}
+
+export function getCsvMetadata() {
+ return getJson("csv-metadata");
+}
+
+export function getAllocSummary() {
+ return getJson("alloc-summary");
+}
+
+export function getMemoryTimeline({ startUs = 0, endUs = 0xffffffff, maxSamples = 2000 } = {}) {
+ const params = new URLSearchParams({
+ start: String(startUs),
+ end: String(endUs),
+ max_samples: String(maxSamples),
+ });
+ return getJson("memory-timeline?" + params.toString());
+}
+
+export function getCallstackStats(limit = 100) {
+ return getJson("callstack-stats?limit=" + encodeURIComponent(limit));
+}
+
+export function getChurnStats(limit = 100) {
+ return getJson("churn-stats?limit=" + encodeURIComponent(limit));
+}
+
+export function getCallstack(callstackId) {
+ return getJson("callstacks?id=" + encodeURIComponent(callstackId));
+}
+
+export function getAllocSizeHistogram() {
+ return getJson("alloc-size-histogram");
+}
diff --git a/src/zen/frontend/html/csvstats.js b/src/zen/frontend/html/csvstats.js
new file mode 100644
index 000000000..a50b2f068
--- /dev/null
+++ b/src/zen/frontend/html/csvstats.js
@@ -0,0 +1,383 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+// CSV Profiler stats viewer — category/stat tree with line-chart visualization.
+
+import { getCsvSeries } from "./api.js";
+
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]));
+}
+
+function formatTime(us) {
+ if (us < 1000) return `${us} \u00b5s`;
+ if (us < 1_000_000) return `${(us / 1000).toFixed(2)} ms`;
+ return `${(us / 1_000_000).toFixed(2)} s`;
+}
+
+// Palette for chart lines — distinct hues.
+const LINE_COLORS = [
+ "#4fc3f7", "#81c784", "#ffb74d", "#e57373", "#ba68c8",
+ "#4db6ac", "#fff176", "#f06292", "#7986cb", "#a1887f",
+];
+
+export class CsvStatsView {
+ constructor(model, containerEl) {
+ this.model = model;
+ this.container = containerEl;
+ this.loaded = false;
+
+ this.categories = model.csvCategories || [];
+ this.statDefs = model.csvStats || [];
+
+ // Group stats by category index.
+ this.catMap = new Map();
+ for (const cat of this.categories) {
+ this.catMap.set(cat.index, cat.name);
+ }
+ this.statsByCategory = new Map();
+ for (const s of this.statDefs) {
+ const catIdx = s.category_index;
+ if (!this.statsByCategory.has(catIdx)) this.statsByCategory.set(catIdx, []);
+ this.statsByCategory.get(catIdx).push(s);
+ }
+
+ // Selected series: Set of stat_id values to chart.
+ this.selectedStats = new Set();
+ this.seriesData = new Map(); // stat_id → [{time_us, value}, ...]
+ this.colorIndex = 0;
+ this.statColors = new Map(); // stat_id → color
+
+ this.buildLayout();
+ }
+
+ buildLayout() {
+ this.container.innerHTML =
+ `<div class="csv-layout">` +
+ `<div class="csv-tree-panel">` +
+ `<div class="sidebar-label">Stats</div>` +
+ `<div class="csv-tree"></div>` +
+ `</div>` +
+ `<div class="csv-chart-panel">` +
+ `<canvas class="csv-chart-canvas"></canvas>` +
+ `<div class="csv-chart-tooltip" hidden></div>` +
+ `</div>` +
+ `</div>`;
+
+ this.treeEl = this.container.querySelector(".csv-tree");
+ this.canvas = this.container.querySelector(".csv-chart-canvas");
+ this.tooltipEl = this.container.querySelector(".csv-chart-tooltip");
+ this.ctx = this.canvas.getContext("2d");
+ this.dpr = Math.max(1, window.devicePixelRatio || 1);
+
+ this.renderTree();
+
+ // Chart interaction
+ this.resizeObserver = new ResizeObserver(() => this.drawChart());
+ this.resizeObserver.observe(this.canvas);
+ this.canvas.addEventListener("mousemove", (e) => this.onChartHover(e));
+ this.canvas.addEventListener("mouseleave", () => { this.tooltipEl.hidden = true; });
+
+ // Pan + zoom state
+ this.viewStartUs = 0;
+ this.viewEndUs = this.model.session.trace_end_us || 1;
+ this.panning = false;
+ this.canvas.addEventListener("mousedown", (e) => this.onPanStart(e));
+ window.addEventListener("mousemove", (e) => this.onPanMove(e));
+ window.addEventListener("mouseup", () => this.onPanEnd());
+ this.canvas.addEventListener("wheel", (e) => this.onWheel(e), { passive: false });
+ }
+
+ renderTree() {
+ const parts = [];
+ // Sort categories by index.
+ const catIndices = Array.from(this.statsByCategory.keys()).sort((a, b) => a - b);
+ for (const catIdx of catIndices) {
+ const catName = this.catMap.get(catIdx) || `Category ${catIdx}`;
+ const stats = this.statsByCategory.get(catIdx);
+ parts.push(`<div class="csv-cat-header">${escapeHtml(catName)}</div>`);
+ for (const s of stats) {
+ const id = s.stat_id;
+ parts.push(
+ `<label class="csv-stat-row">` +
+ `<input type="checkbox" data-stat-id="${id}">` +
+ `<span class="csv-stat-name"></span>` +
+ `</label>`
+ );
+ }
+ }
+ if (parts.length === 0) {
+ parts.push(`<div class="csv-empty">No CSV profiler data in this trace.</div>`);
+ }
+ this.treeEl.innerHTML = parts.join("");
+
+ // Set names via DOM (XSS safe).
+ let statIdx = 0;
+ for (const catIdx of catIndices) {
+ const stats = this.statsByCategory.get(catIdx);
+ for (const s of stats) {
+ const rows = this.treeEl.querySelectorAll(".csv-stat-row");
+ if (statIdx < rows.length) {
+ rows[statIdx].querySelector(".csv-stat-name").textContent = s.name;
+ }
+ statIdx++;
+ }
+ }
+
+ // Wire checkboxes.
+ for (const cb of this.treeEl.querySelectorAll("input[type=checkbox]")) {
+ cb.addEventListener("change", () => {
+ const statId = Number(cb.dataset.statId);
+ if (cb.checked) {
+ this.selectedStats.add(statId);
+ if (!this.statColors.has(statId)) {
+ this.statColors.set(statId, LINE_COLORS[this.colorIndex++ % LINE_COLORS.length]);
+ }
+ this.fetchSeries(statId);
+ } else {
+ this.selectedStats.delete(statId);
+ this.drawChart();
+ }
+ });
+ }
+ }
+
+ async ensureLoaded() {
+ // Tree is built in constructor; nothing extra to lazy-load.
+ this.loaded = true;
+ this.drawChart();
+ }
+
+ async fetchSeries(statId) {
+ if (this.seriesData.has(statId)) {
+ this.drawChart();
+ return;
+ }
+ try {
+ const result = await getCsvSeries(statId);
+ // Merge all threads' samples for this stat into one combined array for now.
+ const allSamples = [];
+ for (const series of result) {
+ for (const [timeUs, value] of series.samples) {
+ allSamples.push({ timeUs, value });
+ }
+ }
+ allSamples.sort((a, b) => a.timeUs - b.timeUs);
+ this.seriesData.set(statId, allSamples);
+ this.drawChart();
+ } catch (e) {
+ console.error(`Failed to fetch CSV series for stat ${statId}: ${e.message}`);
+ }
+ }
+
+ resizeCanvas() {
+ const rect = this.canvas.getBoundingClientRect();
+ this.width = Math.floor(rect.width);
+ this.height = Math.floor(rect.height);
+ const bw = Math.floor(rect.width * this.dpr);
+ const bh = Math.floor(rect.height * this.dpr);
+ if (this.canvas.width !== bw || this.canvas.height !== bh) {
+ this.canvas.width = bw;
+ this.canvas.height = bh;
+ }
+ this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+ }
+
+ drawChart() {
+ this.resizeCanvas();
+ const ctx = this.ctx;
+ const W = this.width;
+ const H = this.height;
+
+ const bg = getComputedStyle(document.body).getPropertyValue("--bg0") || "#0d1117";
+ const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e";
+ const border = getComputedStyle(document.body).getPropertyValue("--border") || "#30363d";
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, W, H);
+
+ if (this.selectedStats.size === 0) {
+ ctx.fillStyle = fg2;
+ ctx.font = "12px -apple-system, Segoe UI, sans-serif";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText("Select stats from the tree to chart them", W / 2, H / 2);
+ return;
+ }
+
+ const PAD_L = 60, PAD_R = 12, PAD_T = 12, PAD_B = 28;
+ const chartW = W - PAD_L - PAD_R;
+ const chartH = H - PAD_T - PAD_B;
+ if (chartW <= 0 || chartH <= 0) return;
+
+ const startUs = this.viewStartUs;
+ const endUs = this.viewEndUs;
+ const rangeUs = Math.max(1, endUs - startUs);
+
+ // Compute value range across all visible selected series.
+ let minVal = Infinity, maxVal = -Infinity;
+ for (const statId of this.selectedStats) {
+ const samples = this.seriesData.get(statId);
+ if (!samples) continue;
+ for (const s of samples) {
+ if (s.timeUs < startUs || s.timeUs > endUs) continue;
+ if (s.value < minVal) minVal = s.value;
+ if (s.value > maxVal) maxVal = s.value;
+ }
+ }
+ if (!isFinite(minVal)) { minVal = 0; maxVal = 1; }
+ if (minVal === maxVal) { minVal -= 0.5; maxVal += 0.5; }
+ const valRange = maxVal - minVal;
+ const valPad = valRange * 0.05;
+ minVal -= valPad;
+ maxVal += valPad;
+
+ const xAt = (us) => PAD_L + (us - startUs) / rangeUs * chartW;
+ const yAt = (v) => PAD_T + (1 - (v - minVal) / (maxVal - minVal)) * chartH;
+
+ // Grid lines.
+ ctx.strokeStyle = border;
+ ctx.lineWidth = 0.5;
+ for (let i = 0; i <= 4; i++) {
+ const y = PAD_T + chartH * i / 4;
+ ctx.beginPath(); ctx.moveTo(PAD_L, y); ctx.lineTo(PAD_L + chartW, y); ctx.stroke();
+ }
+
+ // Y axis labels.
+ ctx.fillStyle = fg2;
+ ctx.font = "10px -apple-system, Segoe UI, sans-serif";
+ ctx.textAlign = "right";
+ ctx.textBaseline = "middle";
+ for (let i = 0; i <= 4; i++) {
+ const v = minVal + (maxVal - minVal) * (1 - i / 4);
+ const y = PAD_T + chartH * i / 4;
+ ctx.fillText(v.toFixed(2), PAD_L - 4, y);
+ }
+
+ // X axis labels.
+ ctx.textAlign = "center";
+ ctx.textBaseline = "top";
+ const tickCount = Math.max(2, Math.min(8, Math.floor(chartW / 80)));
+ for (let i = 0; i <= tickCount; i++) {
+ const us = startUs + rangeUs * i / tickCount;
+ const x = xAt(us);
+ ctx.fillText(formatTime(us), x, PAD_T + chartH + 4);
+ }
+
+ // Draw lines.
+ for (const statId of this.selectedStats) {
+ const samples = this.seriesData.get(statId);
+ if (!samples || samples.length === 0) continue;
+ const color = this.statColors.get(statId) || "#fff";
+
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ let started = false;
+ for (const s of samples) {
+ if (s.timeUs < startUs || s.timeUs > endUs) continue;
+ const x = xAt(s.timeUs);
+ const y = yAt(s.value);
+ if (!started) { ctx.moveTo(x, y); started = true; }
+ else { ctx.lineTo(x, y); }
+ }
+ ctx.stroke();
+ }
+
+ // Chart border.
+ ctx.strokeStyle = border;
+ ctx.lineWidth = 1;
+ ctx.strokeRect(PAD_L, PAD_T, chartW, chartH);
+
+ // Legend.
+ ctx.font = "10px -apple-system, Segoe UI, sans-serif";
+ ctx.textAlign = "left";
+ ctx.textBaseline = "top";
+ let legendX = PAD_L + 6;
+ for (const statId of this.selectedStats) {
+ const def = this.statDefs.find(d => d.stat_id === statId);
+ const name = def ? def.name : `stat ${statId}`;
+ const color = this.statColors.get(statId) || "#fff";
+ ctx.fillStyle = color;
+ ctx.fillRect(legendX, PAD_T + 4, 10, 10);
+ ctx.fillStyle = "#ccc";
+ ctx.fillText(name, legendX + 14, PAD_T + 4);
+ legendX += ctx.measureText(name).width + 24;
+ }
+
+ // Store layout for hover.
+ this._chartLayout = { PAD_L, PAD_R, PAD_T, PAD_B, chartW, chartH, startUs, endUs, rangeUs, minVal, maxVal, xAt, yAt };
+ }
+
+ onChartHover(e) {
+ if (!this._chartLayout || this.selectedStats.size === 0) {
+ this.tooltipEl.hidden = true;
+ return;
+ }
+ const rect = this.canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ const { PAD_L, PAD_T, chartW, chartH, startUs, rangeUs } = this._chartLayout;
+
+ if (mx < PAD_L || mx > PAD_L + chartW || my < PAD_T || my > PAD_T + chartH) {
+ this.tooltipEl.hidden = true;
+ return;
+ }
+
+ const cursorUs = startUs + (mx - PAD_L) / chartW * rangeUs;
+ const lines = [];
+ for (const statId of this.selectedStats) {
+ const samples = this.seriesData.get(statId);
+ if (!samples || samples.length === 0) continue;
+ // Find nearest sample.
+ let best = null, bestDist = Infinity;
+ for (const s of samples) {
+ const d = Math.abs(s.timeUs - cursorUs);
+ if (d < bestDist) { bestDist = d; best = s; }
+ }
+ if (best) {
+ const def = this.statDefs.find(d => d.stat_id === statId);
+ const name = def ? def.name : `stat ${statId}`;
+ const color = this.statColors.get(statId) || "#fff";
+ lines.push(`<span style="color:${color}">${escapeHtml(name)}</span>: ${best.value.toFixed(3)}`);
+ }
+ }
+ if (lines.length === 0) { this.tooltipEl.hidden = true; return; }
+ this.tooltipEl.innerHTML = `<div style="margin-bottom:2px">${formatTime(cursorUs)}</div>` + lines.join("<br>");
+ this.tooltipEl.style.left = `${mx + 12}px`;
+ this.tooltipEl.style.top = `${my + 12}px`;
+ this.tooltipEl.hidden = false;
+ }
+
+ onPanStart(e) {
+ if (e.button !== 0) return;
+ this.panning = true;
+ this.panStartX = e.clientX;
+ this.panStartViewStart = this.viewStartUs;
+ this.panStartViewEnd = this.viewEndUs;
+ }
+
+ onPanMove(e) {
+ if (!this.panning || !this._chartLayout) return;
+ const dx = e.clientX - this.panStartX;
+ const usPerPx = (this.panStartViewEnd - this.panStartViewStart) / this._chartLayout.chartW;
+ const shift = -dx * usPerPx;
+ this.viewStartUs = this.panStartViewStart + shift;
+ this.viewEndUs = this.panStartViewEnd + shift;
+ this.drawChart();
+ }
+
+ onPanEnd() { this.panning = false; }
+
+ onWheel(e) {
+ e.preventDefault();
+ if (!this._chartLayout) return;
+ const rect = this.canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const { PAD_L, chartW, startUs, rangeUs } = this._chartLayout;
+ const cursorUs = startUs + (mx - PAD_L) / chartW * rangeUs;
+ const factor = e.deltaY > 0 ? 1.25 : 0.8;
+ const newRange = Math.max(10, (this.viewEndUs - this.viewStartUs) * factor);
+ const ratio = (mx - PAD_L) / chartW;
+ this.viewStartUs = cursorUs - ratio * newRange;
+ this.viewEndUs = this.viewStartUs + newRange;
+ this.drawChart();
+ }
+}
diff --git a/src/zen/frontend/html/index.html b/src/zen/frontend/html/index.html
new file mode 100644
index 000000000..5853a80dc
--- /dev/null
+++ b/src/zen/frontend/html/index.html
@@ -0,0 +1,95 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>zen trace viewer</title>
+ <link rel="stylesheet" href="trace.css">
+</head>
+<body>
+ <noscript>This viewer requires JavaScript.</noscript>
+ <div class="header">
+ <div class="header-title">zen trace viewer</div>
+ <div class="header-file" id="hdr-file"></div>
+ <div class="header-stats" id="hdr-stats"></div>
+ <button id="theme-toggle" class="header-btn" type="button" title="Toggle dark/light mode">Theme</button>
+ </div>
+ <div class="layout">
+ <aside class="sidebar">
+ <nav class="tabs">
+ <button class="tab active" data-tab="timeline">Timeline</button>
+ <button class="tab" data-tab="stats">Stats</button>
+ <button class="tab" data-tab="memory">Memory</button>
+ <button class="tab" data-tab="logs">Logs</button>
+ <button class="tab" data-tab="csv">CSV</button>
+ <button class="tab" data-tab="session">Session</button>
+ </nav>
+ <div class="sidebar-section">
+ <div class="sidebar-label">Search scopes</div>
+ <input id="search-input" type="text" placeholder="filter scopes..." autocomplete="off" spellcheck="false">
+ <div id="search-results" class="search-results"></div>
+ </div>
+ <div class="sidebar-section" id="regions-panel" hidden>
+ <div class="sidebar-label">Regions <button id="regions-toggle-all" class="sidebar-action">deselect all</button></div>
+ <div id="regions-list" class="regions-list"></div>
+ </div>
+ <div class="sidebar-section" id="threads-panel">
+ <div class="sidebar-label">Threads <button id="threads-toggle-all" class="sidebar-action">deselect all</button></div>
+ <div id="threads-list" class="threads-list"></div>
+ </div>
+ </aside>
+ <main class="content">
+ <section class="view view-timeline" data-view="timeline">
+ <div class="timeline-toolbar">
+ <div id="viewport-info" class="viewport-info"></div>
+ <label class="toolbar-toggle" title="Show or hide bookmark markers">
+ <input type="checkbox" id="bookmarks-toggle" checked>
+ <span>Bookmarks</span>
+ </label>
+ <label class="toolbar-toggle" title="Disable LOD to always fetch full-resolution scopes (slower but useful for validating LOD correctness)">
+ <input type="checkbox" id="lod-toggle" checked>
+ <span>LOD</span>
+ </label>
+ <button id="zoom-reset" class="btn">Reset view</button>
+ </div>
+ <div class="timeline-frame">
+ <canvas id="timeline-canvas"></canvas>
+ <div id="tooltip" class="tooltip" hidden></div>
+ </div>
+ <div id="selection-panel" class="selection-panel">
+ <div class="selection-hint">Click a scope to see details. Drag to pan, wheel to zoom.</div>
+ </div>
+ </section>
+ <section class="view view-stats" data-view="stats" hidden>
+ <table class="stats-table">
+ <thead>
+ <tr>
+ <th data-sort="name">Scope</th>
+ <th data-sort="count" class="num">Count</th>
+ <th data-sort="min_us" class="num">Min (ms)</th>
+ <th data-sort="mean_us" class="num">Mean (ms)</th>
+ <th data-sort="max_us" class="num">Max (ms)</th>
+ <th data-sort="stdev_us" class="num">σ (ms)</th>
+ </tr>
+ </thead>
+ <tbody id="stats-tbody"></tbody>
+ </table>
+ </section>
+ <section class="view view-memory" data-view="memory" hidden>
+ <div id="memory-content"></div>
+ </section>
+ <section class="view view-logs" data-view="logs" hidden>
+ <div id="logs-content"></div>
+ </section>
+ <section class="view view-csv" data-view="csv" hidden>
+ <div id="csv-content"></div>
+ </section>
+ <section class="view view-session" data-view="session" hidden>
+ <div id="session-content" class="session-content"></div>
+ </section>
+ </main>
+ </div>
+ <div id="loading" class="loading">Loading trace…</div>
+ <script type="module" src="trace.js"></script>
+</body>
+</html>
diff --git a/src/zen/frontend/html/logs.js b/src/zen/frontend/html/logs.js
new file mode 100644
index 000000000..d9646ba39
--- /dev/null
+++ b/src/zen/frontend/html/logs.js
@@ -0,0 +1,237 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+// Log viewer: filterable list of captured Logging.LogMessage events.
+
+import { getLogs } from "./api.js";
+
+// UE ELogVerbosity::Type values — lower number = more severe.
+const VERBOSITY_LABELS = [
+ "NoLogging",
+ "Fatal",
+ "Error",
+ "Warning",
+ "Display",
+ "Log",
+ "Verbose",
+ "VeryVerbose",
+ "All",
+];
+
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, (c) => ({
+ "&": "&amp;",
+ "<": "&lt;",
+ ">": "&gt;",
+ "\"": "&quot;",
+ "'": "&#39;",
+ }[c]));
+}
+
+function verbosityLabel(v) {
+ return VERBOSITY_LABELS[v] || `V${v}`;
+}
+
+function verbosityClass(v) {
+ switch (v) {
+ case 1: return "vb-fatal";
+ case 2: return "vb-error";
+ case 3: return "vb-warn";
+ case 4: return "vb-display";
+ case 5: return "vb-log";
+ case 6: case 7: return "vb-verbose";
+ default: return "vb-other";
+ }
+}
+
+function formatTime(us) {
+ const totalMs = Math.floor(us / 1000);
+ const ms = totalMs % 1000;
+ const totalS = Math.floor(totalMs / 1000);
+ const s = totalS % 60;
+ const totalM = Math.floor(totalS / 60);
+ const m = totalM % 60;
+ const h = Math.floor(totalM / 60);
+ if (h > 0) {
+ return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(ms).padStart(3, "0")}`;
+ }
+ return `${m}:${String(s).padStart(2, "0")}.${String(ms).padStart(3, "0")}`;
+}
+
+export class LogsView {
+ constructor(model, containerEl) {
+ this.model = model;
+ this.container = containerEl;
+ this.minVerbosity = 0;
+ this.category = "";
+ this.textFilter = "";
+ this.loaded = false;
+ this.rendering = null;
+
+ this.container.innerHTML =
+ `<div class="logs-toolbar">
+ <div class="logs-filter">
+ <span class="logs-filter-label">Verbosity</span>
+ <select id="log-verbosity">
+ <option value="0">All</option>
+ <option value="6">Verbose</option>
+ <option value="5">Log</option>
+ <option value="4">Display</option>
+ <option value="3">Warning</option>
+ <option value="2">Error</option>
+ <option value="1">Fatal</option>
+ </select>
+ </div>
+ <div class="logs-filter">
+ <span class="logs-filter-label">Category</span>
+ <select id="log-category">
+ <option value="">All</option>
+ </select>
+ </div>
+ <div class="logs-filter logs-filter-grow">
+ <span class="logs-filter-label">Search</span>
+ <input id="log-search" type="text" placeholder="filter messages..." autocomplete="off" spellcheck="false">
+ </div>
+ <div id="log-count" class="logs-count"></div>
+ </div>
+ <div class="logs-list-wrap">
+ <table class="logs-table">
+ <thead><tr>
+ <th class="col-time">Time</th>
+ <th class="col-verb">Verbosity</th>
+ <th class="col-cat">Category</th>
+ <th class="col-msg">Message</th>
+ <th class="col-loc">Source</th>
+ </tr></thead>
+ <tbody id="logs-tbody"></tbody>
+ </table>
+ </div>`;
+
+ const catSelect = this.container.querySelector("#log-category");
+ if ((this.model.bookmarks || []).length > 0) {
+ const bmOpt = document.createElement("option");
+ bmOpt.value = "bookmarks";
+ bmOpt.textContent = "(bookmarks only)";
+ catSelect.appendChild(bmOpt);
+ }
+ for (let i = 0; i < this.model.logCategories.length; i++) {
+ const c = this.model.logCategories[i];
+ const opt = document.createElement("option");
+ opt.value = String(i);
+ opt.textContent = c.name || `category ${i}`;
+ catSelect.appendChild(opt);
+ }
+
+ this.container.querySelector("#log-verbosity").addEventListener("change", (e) => {
+ this.minVerbosity = Number(e.target.value) || 0;
+ this.refresh();
+ });
+ this.container.querySelector("#log-category").addEventListener("change", (e) => {
+ this.category = e.target.value;
+ this.refresh();
+ });
+ this.container.querySelector("#log-search").addEventListener("input", (e) => {
+ this.textFilter = e.target.value.toLowerCase();
+ this.renderFiltered();
+ });
+ }
+
+ async ensureLoaded() {
+ if (this.loaded) return;
+ this.loaded = true;
+ await this.refresh();
+ }
+
+ async refresh() {
+ // "(bookmarks only)" is a synthetic category that displays just the
+ // bookmark rows without hitting the /api/logs endpoint.
+ if (this.category === "bookmarks") {
+ this.result = { entries: [], total: 0, returned: 0 };
+ this.renderFiltered();
+ return;
+ }
+
+ const opts = {
+ minVerbosity: this.minVerbosity,
+ limit: 5000,
+ };
+ if (this.category !== "") {
+ opts.category = Number(this.category);
+ }
+ try {
+ this.result = await getLogs(opts);
+ } catch (e) {
+ this.container.querySelector("#logs-tbody").innerHTML =
+ `<tr><td colspan="5" class="logs-error">Failed to load logs: ${escapeHtml(e.message)}</td></tr>`;
+ return;
+ }
+ this.renderFiltered();
+ }
+
+ renderFiltered() {
+ if (!this.result) return;
+ const entries = this.result.entries || [];
+ const filter = this.textFilter;
+ const tbody = this.container.querySelector("#logs-tbody");
+ const count = this.container.querySelector("#log-count");
+
+ // Bookmarks are interleaved into the display list when the category
+ // filter is "All" or "(bookmarks only)". Any other category is log-
+ // specific so bookmarks are hidden to avoid confusion.
+ const showBookmarks = (this.category === "" || this.category === "bookmarks");
+ const bookmarks = showBookmarks ? (this.model.bookmarks || []) : [];
+
+ // Build a combined, time-sorted row list. Each item keeps its kind
+ // so we can render log and bookmark rows differently.
+ const items = [];
+ for (const e of entries) {
+ items.push({ kind: "log", time: e.time_us, entry: e });
+ }
+ for (const b of bookmarks) {
+ items.push({ kind: "bookmark", time: b.time_us, entry: b });
+ }
+ items.sort((a, b) => a.time - b.time);
+
+ const rows = [];
+ let shown = 0;
+ for (const it of items) {
+ if (it.kind === "log") {
+ const e = it.entry;
+ if (filter && !e.message.toLowerCase().includes(filter)) continue;
+ const cat = this.model.logCategories[e.category_index] || { name: "(unknown)" };
+ const file = e.file ? String(e.file).split(/[\\/]/).pop() : "";
+ rows.push(
+ `<tr class="${verbosityClass(e.verbosity)}">` +
+ `<td class="col-time mono">${formatTime(e.time_us)}</td>` +
+ `<td class="col-verb">${escapeHtml(verbosityLabel(e.verbosity))}</td>` +
+ `<td class="col-cat">${escapeHtml(cat.name)}</td>` +
+ `<td class="col-msg">${escapeHtml(e.message)}</td>` +
+ `<td class="col-loc mono">${escapeHtml(file)}${e.line ? ":" + e.line : ""}</td>` +
+ `</tr>`,
+ );
+ } else {
+ const b = it.entry;
+ if (filter && !b.text.toLowerCase().includes(filter)) continue;
+ const file = b.file ? String(b.file).split(/[\\/]/).pop() : "";
+ rows.push(
+ `<tr class="bm-row">` +
+ `<td class="col-time mono">${formatTime(b.time_us)}</td>` +
+ `<td class="col-verb">BOOKMARK</td>` +
+ `<td class="col-cat">—</td>` +
+ `<td class="col-msg">${escapeHtml(b.text)}</td>` +
+ `<td class="col-loc mono">${escapeHtml(file)}${b.line ? ":" + b.line : ""}</td>` +
+ `</tr>`,
+ );
+ }
+ shown++;
+ }
+ tbody.innerHTML = rows.join("") ||
+ `<tr><td colspan="5" class="logs-empty">No entries match the current filter.</td></tr>`;
+
+ const total = (this.result.total || 0) + bookmarks.length;
+ const returned = (this.result.returned || 0) + bookmarks.length;
+ if (total > returned) {
+ count.textContent = `${shown.toLocaleString()} shown · ${returned.toLocaleString()} of ${total.toLocaleString()} loaded`;
+ } else {
+ count.textContent = `${shown.toLocaleString()} of ${total.toLocaleString()}`;
+ }
+ }
+}
diff --git a/src/zen/frontend/html/memory.js b/src/zen/frontend/html/memory.js
new file mode 100644
index 000000000..6b9760439
--- /dev/null
+++ b/src/zen/frontend/html/memory.js
@@ -0,0 +1,790 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+// Interactive memory analysis view: summary cards, memory timeline, leak/churn/hot callsite tables.
+
+import { getAllocSummary, getMemoryTimeline, getCallstackStats, getChurnStats, getCallstack, getAllocSizeHistogram } from "./api.js";
+
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, (c) => ({
+ "&": "&amp;",
+ "<": "&lt;",
+ ">": "&gt;",
+ '"': "&quot;",
+ "'": "&#39;",
+ }[c]));
+}
+
+function formatNum(n) {
+ return Number(n || 0).toLocaleString();
+}
+
+function formatBytes(bytes) {
+ const sign = bytes < 0 ? "-" : "";
+ let value = Math.abs(Number(bytes || 0));
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit++;
+ }
+ const decimals = unit === 0 ? 0 : value >= 100 ? 0 : value >= 10 ? 1 : 2;
+ return `${sign}${value.toFixed(decimals)} ${units[unit]}`;
+}
+
+function formatDistance(events) {
+ return `${Math.round(Number(events || 0)).toLocaleString()} ev`;
+}
+
+function formatTimeAxis(us) {
+ const value = Number(us || 0);
+ if (value < 1000) return `${Math.round(value)} µs`;
+ if (value < 1_000_000) return `${Math.round(value / 1000)} ms`;
+ return `${Math.round(value / 1_000_000)} s`;
+}
+
+function chooseNiceTimeStep(spanUs, targetTickCount) {
+ const rawStep = Math.max(1, spanUs / Math.max(1, targetTickCount));
+ const bases = [1, 2, 5];
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
+ for (const scale of [1, 10]) {
+ for (const base of bases) {
+ const step = base * magnitude * scale;
+ if (step >= rawStep) {
+ return step;
+ }
+ }
+ }
+ return 10 * magnitude;
+}
+
+function buildNiceTimeTicks(startUs, endUs, targetTickCount) {
+ const spanUs = Math.max(1, endUs - startUs);
+ const stepUs = chooseNiceTimeStep(spanUs, targetTickCount);
+ const firstTickUs = Math.ceil(startUs / stepUs) * stepUs;
+ const ticks = [];
+ for (let tickUs = firstTickUs; tickUs <= endUs; tickUs += stepUs) {
+ ticks.push(tickUs);
+ }
+ if (ticks.length === 0) {
+ const roundedStart = Math.floor(startUs / stepUs) * stepUs;
+ const roundedEnd = Math.ceil(endUs / stepUs) * stepUs;
+ if (roundedStart >= startUs && roundedStart <= endUs) {
+ ticks.push(roundedStart);
+ }
+ if (roundedEnd >= startUs && roundedEnd <= endUs && roundedEnd !== roundedStart) {
+ ticks.push(roundedEnd);
+ }
+ }
+ return ticks;
+}
+
+function trimPath(path) {
+ if (!path) return "";
+ const parts = String(path).split(/[\\/]/);
+ return parts[parts.length - 1] || path;
+}
+
+function compareValues(a, b, desc) {
+ if (typeof a === "string" || typeof b === "string") {
+ const result = String(a || "").localeCompare(String(b || ""), undefined, { numeric: true, sensitivity: "base" });
+ return desc ? -result : result;
+ }
+ const result = Number(a || 0) - Number(b || 0);
+ return desc ? -result : result;
+}
+
+function escapeRegExp(s) {
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function highlightMatch(text, filterText) {
+ const source = String(text || "");
+ if (!filterText) {
+ return escapeHtml(source);
+ }
+ const regex = new RegExp(`(${escapeRegExp(filterText)})`, "ig");
+ return escapeHtml(source).replace(regex, '<mark class="memory-mark">$1</mark>');
+}
+
+function buildSummaryHtml(row, filterText = "") {
+ const top = highlightMatch(row.top_frame || row.summary || `Callstack ${row.callstack_id}`, filterText);
+ const second = row.secondary_frame ? `<div class="memory-summary-secondary">${highlightMatch(row.secondary_frame, filterText)}</div>` : "";
+ const badges = [];
+ if (row.hidden_prefix_count > 0) {
+ badges.push(`<span class="memory-badge">skip ${escapeHtml(formatNum(row.hidden_prefix_count))}</span>`);
+ }
+ if (row.included_third_party_boundary) {
+ badges.push(`<span class="memory-badge">3p boundary</span>`);
+ }
+ const badgeHtml = badges.length ? `<div class="memory-summary-badges">${badges.join("")}</div>` : "";
+ return `<div class="memory-summary"><div class="memory-summary-top-row"><div class="memory-summary-top">${top}</div>${badgeHtml}</div>${second}</div>`;
+}
+
+function describeBucket(bucket) {
+ const min = Number(bucket.min_size || 0);
+ const max = Number(bucket.max_size || 0);
+ if (min === 0 && max === 0) {
+ return "0 bytes";
+ }
+ if (min === max) {
+ return `${formatBytes(min)}`;
+ }
+ return `${formatBytes(min)} – ${formatBytes(max)}`;
+}
+
+function formatBucketEdge(bucket) {
+ const max = Number(bucket.max_size || 0);
+ if (max === 0) {
+ return "0";
+ }
+ return formatBytes(max);
+}
+
+function sparklinePath(samples, width, height, valueIndex) {
+ if (!samples || samples.length === 0) {
+ return "";
+ }
+ let minValue = Infinity;
+ let maxValue = -Infinity;
+ for (const sample of samples) {
+ const value = Number(sample[valueIndex] || 0);
+ minValue = Math.min(minValue, value);
+ maxValue = Math.max(maxValue, value);
+ }
+ if (!isFinite(minValue) || !isFinite(maxValue)) {
+ return "";
+ }
+ if (minValue === maxValue) {
+ minValue -= 1;
+ maxValue += 1;
+ }
+ const count = samples.length;
+ const points = [];
+ for (let i = 0; i < count; ++i) {
+ const x = count > 1 ? (i / (count - 1)) * width : width * 0.5;
+ const norm = (Number(samples[i][valueIndex] || 0) - minValue) / (maxValue - minValue);
+ const y = height - norm * height;
+ points.push(`${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`);
+ }
+ return points.join(" ");
+}
+
+export class MemoryView {
+ constructor(model, containerEl) {
+ this.model = model;
+ this.container = containerEl;
+ this.loaded = false;
+ this.summary = null;
+ this.memoryTimeline = null;
+ this.leaks = [];
+ this.churn = [];
+ this.hot = [];
+ this.sizeHistogram = null;
+ this.histogramMetric = "count";
+ this.callstackCache = new Map();
+ this.selectedCallstackId = 0;
+ this.tableState = {
+ leaks: { sortKey: "live_bytes", desc: true, groupMode: "none", filterText: "" },
+ churn: { sortKey: "churn_allocs", desc: true, groupMode: "none", filterText: "" },
+ hot: { sortKey: "total_allocs", desc: true, groupMode: "none", filterText: "" },
+ };
+ this.loadStateFromUrl();
+ this.buildLayout();
+ }
+
+ buildLayout() {
+ this.container.innerHTML =
+ `<div class="memory-view">` +
+ `<div class="memory-cards" id="memory-cards"></div>` +
+ `<div class="memory-panel">` +
+ `<div class="memory-panel-header">` +
+ `<div class="memory-panel-title">Memory timeline</div>` +
+ `<div class="memory-panel-subtitle" id="memory-timeline-meta"></div>` +
+ `</div>` +
+ `<div class="memory-chart-wrap" id="memory-chart-wrap">` +
+ `<svg class="memory-chart" id="memory-chart"></svg>` +
+ `</div>` +
+ `</div>` +
+ `<div class="memory-panel">` +
+ `<div class="memory-panel-header memory-panel-header-wrap">` +
+ `<div>` +
+ `<div class="memory-panel-title">Allocation size distribution</div>` +
+ `<div class="memory-panel-subtitle" id="memory-histogram-meta"></div>` +
+ `</div>` +
+ `<div class="memory-controls">` +
+ `<label>Metric <select id="memory-histogram-metric">` +
+ `<option value="count">Alloc count</option>` +
+ `<option value="bytes">Total bytes</option>` +
+ `</select></label>` +
+ `</div>` +
+ `</div>` +
+ `<div class="memory-chart-wrap" id="memory-histogram-wrap">` +
+ `<svg class="memory-chart memory-histogram" id="memory-histogram"></svg>` +
+ `</div>` +
+ `</div>` +
+ `<div class="memory-grid">` +
+ this.buildPanelMarkup("leaks", "Leaky callsites", "Top live allocation stacks", [
+ ["live_bytes", "Live bytes"],
+ ["live_count", "Live allocs"],
+ ["summary", "Summary"],
+ ]) +
+ this.buildPanelMarkup("churn", "Churn", "Short-lived allocation sites", [
+ ["churn_allocs", "Short-lived allocs"],
+ ["churn_bytes", "Churn bytes"],
+ ["mean_distance", "Avg distance"],
+ ["summary", "Summary"],
+ ]) +
+ this.buildPanelMarkup("hot", "Hot callsites", "Highest total allocation activity", [
+ ["total_allocs", "Total allocs"],
+ ["total_bytes", "Total bytes"],
+ ["churn_allocs", "Churn allocs"],
+ ["summary", "Summary"],
+ ]) +
+ `<div class="memory-panel memory-callstack-panel">` +
+ `<div class="memory-panel-header"><div class="memory-panel-title">Callstack details</div><div class="memory-panel-subtitle" id="memory-callstack-meta">Select a row to inspect its frames</div></div>` +
+ `<div class="memory-callstack-body" id="memory-callstack-body"><div class="memory-empty">No callstack selected.</div></div>` +
+ `</div>` +
+ `</div>` +
+ `</div>`;
+
+ this.cardsEl = this.container.querySelector("#memory-cards");
+ this.chartWrapEl = this.container.querySelector("#memory-chart-wrap");
+ this.chartEl = this.container.querySelector("#memory-chart");
+ this.chartMetaEl = this.container.querySelector("#memory-timeline-meta");
+ this.histogramWrapEl = this.container.querySelector("#memory-histogram-wrap");
+ this.histogramEl = this.container.querySelector("#memory-histogram");
+ this.histogramMetaEl = this.container.querySelector("#memory-histogram-meta");
+ this.histogramMetricEl = this.container.querySelector("#memory-histogram-metric");
+ this.callstackMetaEl = this.container.querySelector("#memory-callstack-meta");
+ this.callstackBodyEl = this.container.querySelector("#memory-callstack-body");
+ this.panelRefs = {
+ leaks: {
+ tbody: this.container.querySelector("#memory-leaks-body"),
+ sort: this.container.querySelector("#memory-leaks-sort"),
+ filter: this.container.querySelector("#memory-leaks-filter"),
+ clear: this.container.querySelector("#memory-leaks-clear"),
+ direction: this.container.querySelector("#memory-leaks-direction"),
+ group: this.container.querySelector("#memory-leaks-group"),
+ },
+ churn: {
+ tbody: this.container.querySelector("#memory-churn-body"),
+ sort: this.container.querySelector("#memory-churn-sort"),
+ filter: this.container.querySelector("#memory-churn-filter"),
+ clear: this.container.querySelector("#memory-churn-clear"),
+ direction: this.container.querySelector("#memory-churn-direction"),
+ group: this.container.querySelector("#memory-churn-group"),
+ },
+ hot: {
+ tbody: this.container.querySelector("#memory-hot-body"),
+ sort: this.container.querySelector("#memory-hot-sort"),
+ filter: this.container.querySelector("#memory-hot-filter"),
+ clear: this.container.querySelector("#memory-hot-clear"),
+ direction: this.container.querySelector("#memory-hot-direction"),
+ group: this.container.querySelector("#memory-hot-group"),
+ },
+ };
+
+ this.resizeObserver = new ResizeObserver(() => {
+ if (this.loaded) {
+ this.renderTimeline();
+ this.renderSizeHistogram();
+ }
+ });
+ this.resizeObserver.observe(this.chartWrapEl);
+ this.resizeObserver.observe(this.histogramWrapEl);
+
+ this.histogramMetricEl.value = this.histogramMetric;
+ this.histogramMetricEl.addEventListener("change", () => {
+ this.histogramMetric = this.histogramMetricEl.value;
+ this.saveStateToUrl();
+ this.renderSizeHistogram();
+ });
+
+ for (const [name, refs] of Object.entries(this.panelRefs)) {
+ refs.sort.value = this.tableState[name].sortKey;
+ refs.group.value = this.tableState[name].groupMode;
+ refs.filter.value = this.tableState[name].filterText;
+ refs.sort.addEventListener("change", () => {
+ this.tableState[name].sortKey = refs.sort.value;
+ this.tableState[name].desc = refs.sort.value !== "mean_distance";
+ this.updateDirectionButton(name);
+ this.saveStateToUrl();
+ this.renderTableByName(name);
+ });
+ refs.direction.addEventListener("click", () => {
+ this.tableState[name].desc = !this.tableState[name].desc;
+ this.updateDirectionButton(name);
+ this.saveStateToUrl();
+ this.renderTableByName(name);
+ });
+ refs.filter.addEventListener("input", () => {
+ this.tableState[name].filterText = refs.filter.value;
+ this.updateFilterButton(name);
+ this.saveStateToUrl();
+ this.renderTableByName(name);
+ });
+ refs.clear.addEventListener("click", () => {
+ refs.filter.value = "";
+ this.tableState[name].filterText = "";
+ this.updateFilterButton(name);
+ this.saveStateToUrl();
+ this.renderTableByName(name);
+ refs.filter.focus();
+ });
+ refs.group.addEventListener("change", () => {
+ this.tableState[name].groupMode = refs.group.value;
+ this.saveStateToUrl();
+ this.renderTableByName(name);
+ });
+ this.updateDirectionButton(name);
+ this.updateFilterButton(name);
+ }
+
+ this.container.addEventListener("keydown", (e) => {
+ if (e.key !== "/" || e.defaultPrevented) {
+ return;
+ }
+ const target = e.target;
+ if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable)) {
+ return;
+ }
+ e.preventDefault();
+ const activeView = this.container.closest(".view");
+ if (activeView && activeView.hidden) {
+ return;
+ }
+ const firstFilter = this.panelRefs.leaks.filter;
+ if (firstFilter) {
+ firstFilter.focus();
+ firstFilter.select();
+ }
+ });
+ this.container.tabIndex = -1;
+ this.container.dataset.memoryView = "true";
+ }
+
+ buildPanelMarkup(name, title, subtitle, sortOptions) {
+ const sortHtml = sortOptions.map(([value, label]) => `<option value="${value}">${escapeHtml(label)}</option>`).join("");
+ return `
+ <div class="memory-panel">
+ <div class="memory-panel-header memory-panel-header-wrap">
+ <div>
+ <div class="memory-panel-title">${escapeHtml(title)}</div>
+ <div class="memory-panel-subtitle">${escapeHtml(subtitle)}</div>
+ </div>
+ <div class="memory-controls">
+ <label>Filter <input type="text" id="memory-${name}-filter" class="memory-filter-input" placeholder="filter entries..."></label>
+ <button type="button" class="memory-clear-btn" id="memory-${name}-clear">Clear</button>
+ <label>Sort <select id="memory-${name}-sort">${sortHtml}</select></label>
+ <button type="button" class="memory-direction-btn" id="memory-${name}-direction"></button>
+ <label>Group <select id="memory-${name}-group">
+ <option value="none">None</option>
+ <option value="top_frame">Top frame</option>
+ <option value="prefix">Trimmed prefix</option>
+ </select></label>
+ </div>
+ </div>
+ <div class="memory-table-wrap"><table class="memory-table"><tbody id="memory-${name}-body"></tbody></table></div>
+ </div>`;
+ }
+
+ loadStateFromUrl() {
+ const params = new URLSearchParams(window.location.search);
+ const histogramMetric = params.get("mem_hist_metric");
+ if (histogramMetric === "count" || histogramMetric === "bytes") {
+ this.histogramMetric = histogramMetric;
+ }
+ for (const [name, state] of Object.entries(this.tableState)) {
+ const sortKey = params.get(`mem_${name}_sort`);
+ const groupMode = params.get(`mem_${name}_group`);
+ const dir = params.get(`mem_${name}_dir`);
+ const filterText = params.get(`mem_${name}_filter`);
+ if (sortKey) {
+ state.sortKey = sortKey;
+ }
+ if (groupMode) {
+ state.groupMode = groupMode;
+ }
+ if (filterText) {
+ state.filterText = filterText;
+ }
+ if (dir === "asc") {
+ state.desc = false;
+ }
+ else if (dir === "desc") {
+ state.desc = true;
+ }
+ }
+ }
+
+ saveStateToUrl() {
+ const url = new URL(window.location.href);
+ url.searchParams.set("mem_hist_metric", this.histogramMetric);
+ for (const [name, state] of Object.entries(this.tableState)) {
+ url.searchParams.set(`mem_${name}_sort`, state.sortKey);
+ url.searchParams.set(`mem_${name}_group`, state.groupMode);
+ url.searchParams.set(`mem_${name}_dir`, state.desc ? "desc" : "asc");
+ if (state.filterText) {
+ url.searchParams.set(`mem_${name}_filter`, state.filterText);
+ }
+ else {
+ url.searchParams.delete(`mem_${name}_filter`);
+ }
+ }
+ window.history.replaceState({}, "", url);
+ }
+
+ updateFilterButton(name) {
+ const refs = this.panelRefs[name];
+ const hasText = !!this.tableState[name].filterText;
+ refs.clear.disabled = !hasText;
+ refs.clear.title = hasText ? "Clear filter" : "Filter is empty";
+ }
+
+ updateDirectionButton(name) {
+ const refs = this.panelRefs[name];
+ const desc = this.tableState[name].desc;
+ refs.direction.textContent = desc ? "↓ Desc" : "↑ Asc";
+ refs.direction.setAttribute("aria-label", desc ? "Sort descending" : "Sort ascending");
+ refs.direction.title = desc ? "Sorting descending" : "Sorting ascending";
+ }
+
+ async ensureLoaded() {
+ if (this.loaded) return;
+ this.loaded = true;
+ await this.refresh();
+ }
+
+ async refresh() {
+ this.renderLoading();
+ try {
+ const [summary, memoryTimeline, leakResponse, churnResponse, sizeHistogram] = await Promise.all([
+ getAllocSummary(),
+ getMemoryTimeline({ maxSamples: 1200 }),
+ getCallstackStats(100),
+ getChurnStats(200),
+ getAllocSizeHistogram(),
+ ]);
+ this.summary = summary;
+ this.memoryTimeline = memoryTimeline;
+ this.leaks = (leakResponse && leakResponse.stats) || [];
+ this.churn = (churnResponse && churnResponse.stats) || [];
+ this.sizeHistogram = sizeHistogram;
+ this.hot = this.churn.slice().sort((a, b) => {
+ if (b.total_allocs !== a.total_allocs) return b.total_allocs - a.total_allocs;
+ return b.total_bytes - a.total_bytes;
+ }).slice(0, 100);
+ this.render();
+ } catch (e) {
+ this.cardsEl.innerHTML = "";
+ this.chartEl.innerHTML = "";
+ this.chartMetaEl.textContent = "";
+ this.histogramEl.innerHTML = "";
+ this.histogramMetaEl.textContent = "";
+ for (const refs of Object.values(this.panelRefs)) {
+ refs.tbody.innerHTML = `<tr><td class="memory-empty">Failed to load memory data: ${escapeHtml(e.message)}</td></tr>`;
+ }
+ this.callstackBodyEl.innerHTML = `<div class="memory-empty">Failed to load memory data.</div>`;
+ }
+ }
+
+ renderLoading() {
+ this.cardsEl.innerHTML = `<div class="memory-card"><div class="memory-card-label">Loading</div><div class="memory-card-value">Memory analysis…</div></div>`;
+ this.chartEl.innerHTML = "";
+ this.chartMetaEl.textContent = "";
+ this.histogramEl.innerHTML = "";
+ this.histogramMetaEl.textContent = "Loading…";
+ for (const refs of Object.values(this.panelRefs)) {
+ refs.tbody.innerHTML = `<tr><td class="memory-empty">Loading…</td></tr>`;
+ }
+ }
+
+ render() {
+ this.renderCards();
+ this.renderTimeline();
+ this.renderSizeHistogram();
+ this.renderTableByName("leaks");
+ this.renderTableByName("churn");
+ this.renderTableByName("hot");
+ }
+
+ renderCards() {
+ const s = this.summary || {};
+ this.cardsEl.innerHTML = [
+ this.formatCard("Peak memory", formatBytes(s.peak_bytes)),
+ this.formatCard("End memory", formatBytes(s.end_bytes)),
+ this.formatCard("Live allocations", formatNum(s.live_allocations)),
+ this.formatCard("Total allocs", formatNum(s.total_allocs)),
+ this.formatCard("Total frees", formatNum(s.total_frees)),
+ this.formatCard("Reallocs", formatNum((s.total_realloc_allocs || 0) + (s.total_realloc_frees || 0))),
+ ].join("");
+ }
+
+ formatCard(label, value) {
+ return `<div class="memory-card"><div class="memory-card-label">${escapeHtml(label)}</div><div class="memory-card-value">${escapeHtml(value)}</div></div>`;
+ }
+
+ renderTimeline() {
+ const samples = (this.memoryTimeline && this.memoryTimeline.samples) || [];
+ if (samples.length === 0) {
+ this.chartMetaEl.textContent = "No memory timeline samples";
+ this.chartEl.innerHTML = `<text x="500" y="110" text-anchor="middle" class="memory-chart-text">No memory timeline samples</text>`;
+ return;
+ }
+
+ const width = Math.max(320, Math.floor(this.chartWrapEl.getBoundingClientRect().width) - 24);
+ const height = 220;
+ const padLeft = 56;
+ const padRight = 12;
+ const padTop = 12;
+ const padBottom = 22;
+ const chartWidth = width - padLeft - padRight;
+ const chartHeight = height - padTop - padBottom;
+ let minValue = Infinity;
+ let maxValue = -Infinity;
+ for (const sample of samples) {
+ minValue = Math.min(minValue, Number(sample[1] || 0));
+ maxValue = Math.max(maxValue, Number(sample[1] || 0));
+ }
+ if (minValue === maxValue) {
+ minValue -= 1;
+ maxValue += 1;
+ }
+
+ const xAt = (index) => samples.length > 1 ? (padLeft + (index / (samples.length - 1)) * chartWidth) : (padLeft + chartWidth * 0.5);
+ const yAt = (value) => {
+ const norm = (Number(value || 0) - minValue) / Math.max(1, maxValue - minValue);
+ return padTop + chartHeight - norm * chartHeight;
+ };
+ const path = samples.map((sample, index) => `${index === 0 ? "M" : "L"}${xAt(index).toFixed(1)} ${yAt(sample[1]).toFixed(1)}`).join(" ");
+
+ const grid = [];
+ for (let i = 0; i <= 4; ++i) {
+ const y = padTop + chartHeight * i / 4;
+ const value = maxValue + (minValue - maxValue) * i / 4;
+ grid.push(`<line x1="${padLeft}" y1="${y}" x2="${padLeft + chartWidth}" y2="${y}" class="memory-chart-grid"/>`);
+ grid.push(`<text x="${padLeft - 6}" y="${y + 4}" text-anchor="end" class="memory-chart-axis">${escapeHtml(formatBytes(value))}</text>`);
+ }
+
+ const startUs = Number(samples[0][0] || 0);
+ const endUs = Number(samples[samples.length - 1][0] || 0);
+ const spanUs = Math.max(1, endUs - startUs);
+ const targetTickCount = Math.max(3, Math.min(8, Math.floor(chartWidth / 110)));
+ const ticks = buildNiceTimeTicks(startUs, endUs, targetTickCount);
+ for (const timeUs of ticks) {
+ const t = spanUs > 0 ? ((timeUs - startUs) / spanUs) : 0;
+ const x = padLeft + chartWidth * t;
+ grid.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${padTop + chartHeight}" class="memory-chart-grid memory-chart-grid-vert"/>`);
+ grid.push(`<text x="${x}" y="${height - 4}" text-anchor="middle" class="memory-chart-axis">${escapeHtml(formatTimeAxis(timeUs))}</text>`);
+ }
+
+ const durationUs = (this.model.session.trace_end_us || 0) - (this.model.session.trace_start_us || 0);
+ this.chartMetaEl.textContent = `${formatNum(samples.length)} samples across ${(durationUs / 1_000_000).toFixed(2)} s`;
+ this.chartEl.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ this.chartEl.setAttribute("preserveAspectRatio", "xMinYMin meet");
+ this.chartEl.innerHTML =
+ `<rect x="0" y="0" width="${width}" height="${height}" class="memory-chart-bg"/>` +
+ grid.join("") +
+ `<path d="${path}" class="memory-chart-line"/>`;
+ }
+
+ renderSizeHistogram() {
+ const buckets = (this.sizeHistogram && this.sizeHistogram.buckets) || [];
+ if (buckets.length === 0) {
+ this.histogramMetaEl.textContent = "No allocations recorded";
+ this.histogramEl.innerHTML = `<text x="500" y="110" text-anchor="middle" class="memory-chart-text">No allocations recorded</text>`;
+ return;
+ }
+
+ const metric = this.histogramMetric === "bytes" ? "bytes" : "count";
+ const metricLabel = metric === "bytes" ? "Total bytes" : "Alloc count";
+ const valueFor = (b) => Number((metric === "bytes" ? b.bytes : b.count) || 0);
+ const formatValue = metric === "bytes" ? formatBytes : formatNum;
+
+ let maxValue = 0;
+ let totalValue = 0;
+ for (const bucket of buckets) {
+ const v = valueFor(bucket);
+ if (v > maxValue) maxValue = v;
+ totalValue += v;
+ }
+ if (maxValue === 0) {
+ maxValue = 1;
+ }
+
+ const width = Math.max(320, Math.floor(this.histogramWrapEl.getBoundingClientRect().width) - 24);
+ const height = 240;
+ const padLeft = 64;
+ const padRight = 12;
+ const padTop = 12;
+ const padBottom = 42;
+ const chartWidth = width - padLeft - padRight;
+ const chartHeight = height - padTop - padBottom;
+
+ const bucketCount = buckets.length;
+ const slotWidth = chartWidth / bucketCount;
+ const barGap = Math.max(1, Math.min(4, slotWidth * 0.15));
+ const barWidth = Math.max(1, slotWidth - barGap);
+
+ const parts = [];
+ parts.push(`<rect x="0" y="0" width="${width}" height="${height}" class="memory-chart-bg"/>`);
+
+ // Horizontal grid + y-axis labels at 0, 25, 50, 75, 100% of max.
+ for (let i = 0; i <= 4; ++i) {
+ const y = padTop + chartHeight * i / 4;
+ const value = maxValue * (1 - i / 4);
+ parts.push(`<line x1="${padLeft}" y1="${y}" x2="${padLeft + chartWidth}" y2="${y}" class="memory-chart-grid"/>`);
+ parts.push(`<text x="${padLeft - 6}" y="${y + 4}" text-anchor="end" class="memory-chart-axis">${escapeHtml(formatValue(value))}</text>`);
+ }
+
+ // Bars. X-axis labels are drawn for a subset of buckets to avoid overlap.
+ const labelStride = Math.max(1, Math.ceil(bucketCount / Math.max(3, Math.floor(chartWidth / 64))));
+ for (let i = 0; i < bucketCount; ++i) {
+ const bucket = buckets[i];
+ const value = valueFor(bucket);
+ const barHeight = (value / maxValue) * chartHeight;
+ const x = padLeft + i * slotWidth + barGap / 2;
+ const y = padTop + chartHeight - barHeight;
+ const label = describeBucket(bucket);
+ const tooltip = `${label}\n${metricLabel}: ${formatValue(value)}\nAlloc count: ${formatNum(bucket.count)}\nTotal bytes: ${formatBytes(bucket.bytes)}`;
+ parts.push(
+ `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barWidth.toFixed(1)}" height="${Math.max(0, barHeight).toFixed(1)}" class="memory-histogram-bar"><title>${escapeHtml(tooltip)}</title></rect>`
+ );
+ if (i % labelStride === 0 || i === bucketCount - 1) {
+ const tickX = padLeft + i * slotWidth + slotWidth / 2;
+ parts.push(
+ `<text x="${tickX.toFixed(1)}" y="${(padTop + chartHeight + 14).toFixed(1)}" text-anchor="middle" class="memory-chart-axis">${escapeHtml(formatBucketEdge(bucket))}</text>`
+ );
+ }
+ }
+
+ // Axis title for x.
+ parts.push(
+ `<text x="${(padLeft + chartWidth / 2).toFixed(1)}" y="${(height - 4).toFixed(1)}" text-anchor="middle" class="memory-chart-axis">Allocation size (power-of-two buckets)</text>`
+ );
+
+ const summaryTotalCount = Number((this.sizeHistogram && this.sizeHistogram.total_count) || 0);
+ const summaryTotalBytes = Number((this.sizeHistogram && this.sizeHistogram.total_bytes) || 0);
+ this.histogramMetaEl.textContent = `${formatNum(summaryTotalCount)} allocations, ${formatBytes(summaryTotalBytes)} total across ${bucketCount} bucket${bucketCount === 1 ? "" : "s"}`;
+
+ this.histogramEl.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ this.histogramEl.setAttribute("preserveAspectRatio", "xMinYMin meet");
+ this.histogramEl.innerHTML = parts.join("");
+ }
+
+ getRowsForTable(name) {
+ if (name === "leaks") return this.leaks.slice(0, 100);
+ if (name === "churn") return this.churn.slice(0, 100);
+ return this.hot.slice(0, 100);
+ }
+
+ renderTableByName(name) {
+ const refs = this.panelRefs[name];
+ const state = this.tableState[name];
+ let rows = this.getRowsForTable(name).slice();
+ const filterText = state.filterText.trim().toLowerCase();
+ if (filterText) {
+ rows = rows.filter((row) => {
+ const haystack = [
+ row.summary,
+ row.top_frame,
+ row.secondary_frame,
+ row.group_key,
+ `callstack ${row.callstack_id}`,
+ ].join("\n").toLowerCase();
+ return haystack.includes(filterText);
+ });
+ }
+ rows.sort((a, b) => compareValues(a[state.sortKey], b[state.sortKey], state.desc));
+
+ if (!rows.length) {
+ refs.tbody.innerHTML = `<tr><td class="memory-empty">No data available.</td></tr>`;
+ return;
+ }
+
+ const parts = [];
+ let currentGroup = null;
+ for (let index = 0; index < rows.length; ++index) {
+ const row = rows[index];
+ const groupKey = state.groupMode === "top_frame" ? (row.top_frame || "(unknown)") :
+ (state.groupMode === "prefix" ? (row.group_key || row.top_frame || "(unknown)") : null);
+ if (groupKey !== null && groupKey !== currentGroup) {
+ currentGroup = groupKey;
+ parts.push(`<tr class="memory-group-row"><td colspan="5">${escapeHtml(groupKey)}</td></tr>`);
+ }
+ parts.push(this.renderDataRow(name, row, index));
+ }
+ refs.tbody.innerHTML = parts.join("");
+ for (const tr of refs.tbody.querySelectorAll("tr[data-callstack-id]")) {
+ tr.addEventListener("click", () => {
+ const callstackId = Number(tr.dataset.callstackId);
+ this.selectCallstack(callstackId);
+ for (const rowEl of this.container.querySelectorAll("tr[data-callstack-id]")) {
+ rowEl.classList.toggle("selected", Number(rowEl.dataset.callstackId) === callstackId);
+ }
+ });
+ }
+ }
+
+ renderDataRow(name, row, index) {
+ if (name === "leaks") {
+ return `<tr data-callstack-id="${row.callstack_id}">`
+ + `<td class="num">${index + 1}</td>`
+ + `<td class="num">${escapeHtml(formatBytes(row.live_bytes))}</td>`
+ + `<td class="num">${escapeHtml(formatNum(row.live_count))}</td>`
+ + `<td>${buildSummaryHtml(row)}</td>`
+ + `</tr>`;
+ }
+ if (name === "churn") {
+ return `<tr data-callstack-id="${row.callstack_id}">`
+ + `<td class="num">${index + 1}</td>`
+ + `<td class="num">${escapeHtml(formatNum(row.churn_allocs))}</td>`
+ + `<td class="num">${escapeHtml(formatBytes(row.churn_bytes))}</td>`
+ + `<td class="num">${escapeHtml(formatDistance(row.mean_distance))}</td>`
+ + `<td>${buildSummaryHtml(row)}</td>`
+ + `</tr>`;
+ }
+ return `<tr data-callstack-id="${row.callstack_id}">`
+ + `<td class="num">${index + 1}</td>`
+ + `<td class="num">${escapeHtml(formatNum(row.total_allocs))}</td>`
+ + `<td class="num">${escapeHtml(formatBytes(row.total_bytes))}</td>`
+ + `<td class="num">${escapeHtml(formatNum(row.churn_allocs))}</td>`
+ + `<td>${buildSummaryHtml(row)}</td>`
+ + `</tr>`;
+ }
+
+ async selectCallstack(callstackId) {
+ this.selectedCallstackId = callstackId;
+ this.callstackMetaEl.textContent = `Callstack ${callstackId}`;
+ this.callstackBodyEl.innerHTML = `<div class="memory-empty">Loading callstack ${callstackId}…</div>`;
+ try {
+ let callstack = this.callstackCache.get(callstackId);
+ if (!callstack) {
+ callstack = await getCallstack(callstackId);
+ this.callstackCache.set(callstackId, callstack);
+ }
+ const frames = callstack.frames || [];
+ if (!frames.length) {
+ this.callstackBodyEl.innerHTML = `<div class="memory-empty">No frames recorded for this callstack.</div>`;
+ return;
+ }
+ const notes = [];
+ if (callstack.hidden_prefix_count > 0) {
+ let note = `Skipped ${formatNum(callstack.hidden_prefix_count)} leading frame(s)`;
+ if (callstack.included_third_party_boundary) {
+ note += "; kept boundary third-party callsite";
+ }
+ notes.push(`<div class="memory-empty">${escapeHtml(note)}.</div>`);
+ }
+ const items = [];
+ for (let i = 0; i < frames.length; ++i) {
+ const frame = frames[i];
+ const display = frame.display || frame.address || "(unknown frame)";
+ const extra = frame.module_path ? ` <span class="memory-frame-path">${escapeHtml(trimPath(frame.module_path))}</span>` : "";
+ items.push(`<li><span class="memory-frame-index">#${frame.index ?? i}</span> <span class="memory-frame-display">${escapeHtml(display)}</span>${extra}</li>`);
+ }
+ this.callstackBodyEl.innerHTML = `${notes.join("")}<ol class="memory-callstack-list">${items.join("")}</ol>`;
+ } catch (e) {
+ this.callstackBodyEl.innerHTML = `<div class="memory-empty">Failed to load callstack ${callstackId}: ${escapeHtml(e.message)}</div>`;
+ }
+ }
+}
diff --git a/src/zen/frontend/html/stats.js b/src/zen/frontend/html/stats.js
new file mode 100644
index 000000000..741ad7ef9
--- /dev/null
+++ b/src/zen/frontend/html/stats.js
@@ -0,0 +1,95 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+// Sortable stats table view.
+
+const US_PER_MS = 1000;
+
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, (c) => ({
+ "&": "&amp;",
+ "<": "&lt;",
+ ">": "&gt;",
+ "\"": "&quot;",
+ "'": "&#39;",
+ }[c]));
+}
+
+export class StatsView {
+ constructor(tbody, headerRow, model, onSelect) {
+ this.tbody = tbody;
+ this.headerRow = headerRow;
+ this.stats = model.scopeStats.slice();
+ this.onSelect = onSelect;
+ this.sortKey = "count";
+ this.sortAsc = false;
+ this.selectedName = null;
+
+ for (const th of headerRow.querySelectorAll("th[data-sort]")) {
+ th.addEventListener("click", () => this.handleSort(th.dataset.sort));
+ }
+ this.render();
+ }
+
+ handleSort(key) {
+ if (this.sortKey === key) {
+ this.sortAsc = !this.sortAsc;
+ } else {
+ this.sortKey = key;
+ this.sortAsc = key === "name";
+ }
+ this.render();
+ }
+
+ selectByName(name) {
+ this.selectedName = name;
+ for (const tr of this.tbody.querySelectorAll("tr")) {
+ tr.classList.toggle("selected", tr.dataset.name === name);
+ if (tr.dataset.name === name) {
+ tr.scrollIntoView({ block: "nearest" });
+ }
+ }
+ }
+
+ render() {
+ const key = this.sortKey;
+ const asc = this.sortAsc;
+ this.stats.sort((a, b) => {
+ const av = a[key];
+ const bv = b[key];
+ if (typeof av === "string") {
+ return asc ? av.localeCompare(bv) : bv.localeCompare(av);
+ }
+ return asc ? av - bv : bv - av;
+ });
+
+ for (const th of this.headerRow.querySelectorAll("th[data-sort]")) {
+ th.classList.toggle("sorted", th.dataset.sort === key);
+ th.classList.toggle("asc", th.dataset.sort === key && asc);
+ }
+
+ const rows = [];
+ for (const stat of this.stats) {
+ const selected = stat.name === this.selectedName ? " class=\"selected\"" : "";
+ rows.push(
+ `<tr data-name="${escapeHtml(stat.name)}"${selected}>` +
+ `<td>${escapeHtml(stat.name)}</td>` +
+ `<td class="num">${stat.count.toLocaleString()}</td>` +
+ `<td class="num">${(stat.min_us / US_PER_MS).toFixed(3)}</td>` +
+ `<td class="num">${(stat.mean_us / US_PER_MS).toFixed(3)}</td>` +
+ `<td class="num">${(stat.max_us / US_PER_MS).toFixed(3)}</td>` +
+ `<td class="num">${(stat.stdev_us / US_PER_MS).toFixed(3)}</td>` +
+ `</tr>`,
+ );
+ }
+ this.tbody.innerHTML = rows.join("");
+
+ for (const tr of this.tbody.querySelectorAll("tr")) {
+ tr.addEventListener("click", () => {
+ const name = tr.dataset.name;
+ this.selectByName(name);
+ if (this.onSelect) {
+ this.onSelect(name);
+ }
+ });
+ }
+ }
+}
diff --git a/src/zen/frontend/html/timeline.js b/src/zen/frontend/html/timeline.js
new file mode 100644
index 000000000..f463a8418
--- /dev/null
+++ b/src/zen/frontend/html/timeline.js
@@ -0,0 +1,973 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+// Canvas-drawn flame graph with per-thread swimlanes, pan+zoom, hover
+// tooltip, click-to-select and scope-name highlighting.
+
+const HEADER_H = 18; // thread name row height
+const DEPTH_H = 16; // scope lane row height
+const MAX_DRAWN_DEPTH = 32;
+const MIN_RECT_W = 1.5; // don't draw narrower than this (px)
+const RULER_H = 20;
+const THREAD_GAP = 6;
+const PADDING_X = 0;
+const REGION_LANE_H = 18; // region band row height
+const REGION_HEADER_H = 16; // category header row height
+const REGIONS_GAP = 6; // gap between the region rack and the first thread
+
+// Scope colors: golden-angle hue rotation keyed on NameId so the same scope
+// always renders in the same color across zoom levels.
+function scopeFillColor(nameId) {
+ const hue = ((nameId * 137.508) % 360 + 360) % 360;
+ return `hsl(${hue.toFixed(0)}, 55%, 42%)`;
+}
+
+function scopeHighlightColor(nameId) {
+ const hue = ((nameId * 137.508) % 360 + 360) % 360;
+ return `hsl(${hue.toFixed(0)}, 80%, 60%)`;
+}
+
+function stringHash(s) {
+ let h = 0;
+ for (let i = 0; i < s.length; i++) {
+ h = ((h << 5) - h + s.charCodeAt(i)) | 0;
+ }
+ return h >>> 0;
+}
+
+// Desaturated palette for regions so they don't compete visually with the
+// colourful CPU scopes below them.
+function regionFillColor(name) {
+ const hue = (stringHash(name || "region") * 2.3) % 360;
+ return `hsla(${hue.toFixed(0)}, 35%, 55%, 0.55)`;
+}
+
+function formatTime(us) {
+ if (us < 1000) {
+ return `${us} µs`;
+ }
+ if (us < 1_000_000) {
+ return `${(us / 1000).toFixed(3)} ms`;
+ }
+ return `${(us / 1_000_000).toFixed(3)} s`;
+}
+
+function formatRange(startUs, endUs) {
+ return `${formatTime(startUs)} → ${formatTime(endUs)} (${formatTime(endUs - startUs)})`;
+}
+
+export class Timeline {
+ constructor(opts) {
+ this.canvas = opts.canvas;
+ this.tooltip = opts.tooltip;
+ this.selectionEl = opts.selectionEl;
+ this.viewportInfoEl = opts.viewportInfoEl;
+ this.zoomResetBtn = opts.zoomResetBtn;
+ this.model = opts.model;
+ this.onScopeSelect = opts.onScopeSelect || (() => {});
+
+ this.ctx = this.canvas.getContext("2d");
+ this.dpr = Math.max(1, window.devicePixelRatio || 1);
+
+ this.bookmarks = (this.model.bookmarks || []).slice().sort((a, b) => a.time_us - b.time_us);
+ this.bookmarksVisible = true;
+ this.regionCategories = (this.model.regionCategories || []).filter(c => c.lane_count > 0);
+ // All categories enabled by default; renderRegionCategories() calls
+ // setEnabledRegionCategories() shortly after construction.
+ this.enabledRegionCategories = new Set(this.regionCategories.map((_, i) => i));
+ this.recomputeRegionsBlockH();
+
+ // Per-thread timelines keyed by threadId; each entry is an object
+ // { scopes, perDepth } where scopes is an array of tuples
+ // [beginUs, durationUs, nameId, depth, mergeCount?].
+ this.timelines = new Map();
+ // Set of threadIds the user wants visible.
+ this.enabledThreads = new Set();
+
+ // Viewport-driven fetch state.
+ this.lodEnabled = true; // when false, always request LOD 0 (raw)
+ this.fetchThrottled = false;
+ this.fetchPending = false;
+ this.fetchThrottleTimer = null;
+ this.abortControllers = new Map(); // threadId → AbortController
+ this.fetchSeq = new Map(); // threadId → monotonic fetch sequence id
+ this.cachedRanges = new Map(); // threadId → { startUs, endUs, resolution }
+ // Lookup helpers.
+ this.threadMeta = new Map(); // threadId → { name, sortHint, scopeCount }
+ for (const t of this.model.threads) {
+ this.threadMeta.set(t.thread_id, t);
+ }
+
+ // Viewport state — time units throughout are microseconds from trace start.
+ this.traceStart = 0;
+ this.traceEnd = Math.max(1, this.model.session.trace_end_us || 0);
+ if (this.traceEnd <= this.traceStart) {
+ this.traceEnd = this.traceStart + 1000;
+ }
+ this.startUs = this.traceStart;
+ const maxInitialUs = 60_000_000; // cap initial view to 60 seconds
+ this.endUs = (this.traceEnd - this.traceStart > maxInitialUs)
+ ? this.traceStart + maxInitialUs
+ : this.traceEnd;
+
+ // Vertical scroll offset in canvas pixels (0 = first thread flush
+ // against the ruler). Updated by both drag-pan and shift-wheel.
+ this.scrollY = 0;
+ this.maxScrollY = 0;
+
+ // Hit-test rects computed during the last draw.
+ this.hits = [];
+ this.selectedId = null;
+ this.highlightName = null;
+
+ // Pan state
+ this.panStartX = 0;
+ this.panStartY = 0;
+ this.panStartUs = 0;
+ this.panStartScrollY = 0;
+ this.panning = false;
+ this.panMoved = false;
+
+ this.resizeObserver = new ResizeObserver(() => this.requestDraw());
+ this.resizeObserver.observe(this.canvas);
+
+ this.canvas.addEventListener("mousedown", (e) => this.onMouseDown(e));
+ window.addEventListener("mousemove", (e) => this.onMouseMove(e));
+ window.addEventListener("mouseup", (e) => this.onMouseUp(e));
+ this.canvas.addEventListener("wheel", (e) => this.onWheel(e), { passive: false });
+ this.canvas.addEventListener("mouseleave", () => this.hideTooltip());
+ this.zoomResetBtn.addEventListener("click", () => this.resetView());
+
+ this.drawPending = false;
+ }
+
+ setBookmarksVisible(visible) {
+ this.bookmarksVisible = visible;
+ this.requestDraw();
+ }
+
+ setEnabledRegionCategories(indices) {
+ this.enabledRegionCategories = indices instanceof Set ? indices : new Set(indices);
+ this.recomputeRegionsBlockH();
+ this.requestDraw();
+ }
+
+ recomputeRegionsBlockH() {
+ this.regionsBlockH = 0;
+ for (let i = 0; i < this.regionCategories.length; i++) {
+ if (!this.enabledRegionCategories || !this.enabledRegionCategories.has(i)) continue;
+ const cat = this.regionCategories[i];
+ this.regionsBlockH += REGION_HEADER_H + cat.lane_count * REGION_LANE_H;
+ }
+ if (this.regionsBlockH > 0) {
+ this.regionsBlockH += REGIONS_GAP;
+ }
+ }
+
+ setLodEnabled(enabled) {
+ this.lodEnabled = enabled;
+ // Invalidate all caches so the next fetch uses the new setting.
+ this.cachedRanges.clear();
+ this.scheduleFetch();
+ }
+
+ setEnabledThreads(ids) {
+ this.enabledThreads = new Set(ids);
+ this.scheduleFetch();
+ this.requestDraw();
+ }
+
+ setHighlightName(name) {
+ this.highlightName = name || null;
+ this.requestDraw();
+ }
+
+ jumpToScopeName(name) {
+ // Find the first scope with the given name and frame it.
+ this.setHighlightName(name);
+ for (const threadId of this.enabledThreads) {
+ const timeline = this.timelines.get(threadId);
+ if (!timeline) continue;
+ for (const s of timeline.scopes) {
+ const nameId = s[2];
+ if (this.model.scopeNames[nameId] !== name) continue;
+ const beginUs = s[0];
+ const durationUs = s[1];
+ const pad = Math.max(durationUs * 3, 500);
+ this.startUs = Math.max(0, beginUs - pad);
+ this.endUs = beginUs + durationUs + pad;
+ this.selectScope({ threadId, tuple: s });
+ this.scheduleFetch();
+ this.requestDraw();
+ return;
+ }
+ }
+ }
+
+ resetView() {
+ this.startUs = this.traceStart;
+ this.endUs = this.traceEnd;
+ this.scrollY = 0;
+ this.scheduleFetch();
+ this.requestDraw();
+ }
+
+ // ── Viewport-driven fetch engine ──────────────────────────────────
+
+ computeResolution() {
+ const w = this.width || this.canvas.getBoundingClientRect().width || 1;
+ // The resolution tells the server the minimum renderable scope duration.
+ // A scope must be at least MIN_RECT_W pixels wide to be drawn, so the
+ // threshold is usPerPixel * MIN_RECT_W, not just usPerPixel. This
+ // selects a coarser LOD that merges across gaps smaller than one
+ // renderable unit, preventing empty holes in the timeline.
+ return Math.ceil((this.endUs - this.startUs) / w * MIN_RECT_W);
+ }
+
+ computeFetchWindow() {
+ const range = this.endUs - this.startUs;
+ const margin = range * 0.5;
+ return {
+ startUs: Math.max(0, Math.floor(this.startUs - margin)),
+ endUs: Math.ceil(this.endUs + margin),
+ };
+ }
+
+ // Map a resolution to the LOD index the server would select.
+ // Mirrors the server's selection: finest LOD where ResolutionUs >= res.
+ // Returns -1 for LOD 0 (raw), 0–4 for LOD 1–5.
+ lodForResolution(res) {
+ if (!this.lodEnabled || res <= 0) return -1;
+ const levels = [100, 1000, 8000, 40000, 200000];
+ for (let i = 0; i < levels.length; i++) {
+ if (levels[i] >= res) return i;
+ }
+ return levels.length - 1; // coarsest
+ }
+
+ needsRefetch(threadId) {
+ const cached = this.cachedRanges.get(threadId);
+ if (!cached) return true;
+ const currentRes = this.computeResolution();
+ // Re-fetch when the LOD level would change — this catches the exact
+ // boundary crossing and prevents jarring LOD transitions during pan.
+ if (this.lodForResolution(cached.resolution) !== this.lodForResolution(currentRes)) return true;
+ // Re-fetch when the viewport nears the edge of the cached range.
+ const margin = (cached.endUs - cached.startUs) * 0.25;
+ if (this.startUs < cached.startUs + margin) return true;
+ if (this.endUs > cached.endUs - margin) return true;
+ return false;
+ }
+
+ checkViewportFetch() {
+ for (const id of this.enabledThreads) {
+ if (this.needsRefetch(id)) {
+ this.scheduleFetch();
+ return;
+ }
+ }
+ }
+
+ scheduleFetch() {
+ // Leading+trailing throttle: fires immediately on the first call,
+ // then suppresses further calls for 150ms. If any calls arrived
+ // during the suppression window, one trailing fetch fires at the end.
+ // This keeps data flowing during continuous pan/zoom without flooding.
+ this.fetchPending = true;
+ if (this.fetchThrottled) return;
+ this.fetchThrottled = true;
+ this.fetchPending = false;
+ this.fetchViewport();
+ this.fetchThrottleTimer = setTimeout(() => {
+ this.fetchThrottled = false;
+ if (this.fetchPending) {
+ this.fetchPending = false;
+ this.scheduleFetch();
+ }
+ }, 150);
+ }
+
+ async fetchViewport() {
+ const { startUs, endUs } = this.computeFetchWindow();
+ const currentRes = this.lodEnabled ? this.computeResolution() : 0;
+
+ const threadIds = [];
+ let resolution = currentRes;
+ for (const threadId of this.enabledThreads) {
+ if (!this.needsRefetch(threadId)) continue;
+
+ // If the LOD level hasn't changed, reuse the cached resolution so
+ // the server selects the same LOD. This prevents a pan-triggered
+ // refetch from accidentally switching LOD levels due to minor
+ // resolution drift within the same LOD band.
+ const cached = this.cachedRanges.get(threadId);
+ if (cached && currentRes > 0 &&
+ this.lodForResolution(cached.resolution) === this.lodForResolution(currentRes)) {
+ resolution = cached.resolution;
+ }
+
+ threadIds.push(threadId);
+ }
+ if (threadIds.length === 0) return;
+
+ // Cancel any in-flight batch request.
+ if (this.batchAbort) this.batchAbort.abort();
+ const controller = new AbortController();
+ this.batchAbort = controller;
+
+ const seq = (this.batchSeq || 0) + 1;
+ this.batchSeq = seq;
+
+ try {
+ const { getTimelineBatch } = await import("./api.js");
+ const result = await getTimelineBatch(threadIds, startUs, endUs, 0, resolution, { signal: controller.signal });
+ // Discard stale responses.
+ if (this.batchSeq !== seq) return;
+
+ for (const threadId of threadIds) {
+ const entry = result[String(threadId)];
+ const scopes = entry ? (entry.scopes || []) : [];
+
+ const perDepth = [];
+ for (let i = 0; i < scopes.length; i++) {
+ const d = scopes[i][3];
+ while (perDepth.length <= d) perDepth.push([]);
+ perDepth[d].push(i);
+ }
+
+ this.timelines.set(threadId, { scopes, perDepth });
+ this.cachedRanges.set(threadId, { startUs, endUs, resolution });
+ }
+
+ this.batchAbort = null;
+ this.requestDraw();
+ } catch (e) {
+ if (e.name === "AbortError") return;
+ console.error(`failed to load timeline batch: ${e.message}`);
+ this.batchAbort = null;
+ }
+ }
+
+ requestDraw() {
+ if (this.drawPending) return;
+ this.drawPending = true;
+ requestAnimationFrame(() => {
+ this.drawPending = false;
+ this.draw();
+ });
+ }
+
+ resizeBackingStore() {
+ const rect = this.canvas.getBoundingClientRect();
+ this.width = Math.floor(rect.width);
+ this.height = Math.floor(rect.height);
+ const bw = Math.floor(rect.width * this.dpr);
+ const bh = Math.floor(rect.height * this.dpr);
+ if (this.canvas.width !== bw || this.canvas.height !== bh) {
+ this.canvas.width = bw;
+ this.canvas.height = bh;
+ }
+ this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+ }
+
+ pxPerUs() {
+ return (this.width - PADDING_X * 2) / Math.max(1, this.endUs - this.startUs);
+ }
+
+ usAtX(x) {
+ return this.startUs + (x - PADDING_X) / this.pxPerUs();
+ }
+
+ xAtUs(us) {
+ return PADDING_X + (us - this.startUs) * this.pxPerUs();
+ }
+
+ layoutThreads() {
+ // Returns { rows: [{threadId, y, maxDepth}], totalH }. The y values
+ // are in canvas coordinates with the current scrollY already applied.
+ const rows = [];
+ let y = RULER_H + this.regionsBlockH - this.scrollY;
+ const sorted = Array.from(this.enabledThreads)
+ .filter((id) => this.threadMeta.has(id))
+ .sort((a, b) => {
+ const ma = this.threadMeta.get(a);
+ const mb = this.threadMeta.get(b);
+ // OS threads first, then lanes
+ if (ma.is_lane !== mb.is_lane) return ma.is_lane ? 1 : -1;
+ // Group by group name (ungrouped first)
+ const ga = ma.group || "";
+ const gb = mb.group || "";
+ if (ga !== gb) {
+ if (!ga) return -1;
+ if (!gb) return 1;
+ return ga.localeCompare(gb, undefined, { numeric: true });
+ }
+ // Match sidebar sort: sort_hint → scopes-first → thread_id → name
+ if (ma.sort_hint !== mb.sort_hint) return ma.sort_hint - mb.sort_hint;
+ if ((ma.scope_count > 0) !== (mb.scope_count > 0)) return mb.scope_count - ma.scope_count;
+ if (ma.thread_id !== mb.thread_id) return ma.thread_id - mb.thread_id;
+ return (ma.name || "").localeCompare(mb.name || "", undefined, { numeric: true });
+ });
+ for (const threadId of sorted) {
+ const timeline = this.timelines.get(threadId);
+ const scopes = timeline ? timeline.scopes : [];
+ let maxDepth = 0;
+ for (const s of scopes) {
+ if (s[3] > maxDepth) maxDepth = s[3];
+ }
+ if (maxDepth > MAX_DRAWN_DEPTH) maxDepth = MAX_DRAWN_DEPTH;
+ const rowH = HEADER_H + (maxDepth + 1) * DEPTH_H;
+ rows.push({ threadId, y, headerH: HEADER_H, maxDepth, height: rowH });
+ y += rowH + THREAD_GAP;
+ }
+ // y now points at the bottom of the last row in scrolled coords.
+ // Recover the unscrolled total content height for scroll clamping.
+ const totalContentH = y + this.scrollY;
+ return { rows, totalH: totalContentH };
+ }
+
+ draw() {
+ this.resizeBackingStore();
+ const ctx = this.ctx;
+ const W = this.width;
+ const H = this.height;
+
+ ctx.fillStyle = getComputedStyle(document.body).getPropertyValue("--bg0") || "#0d1117";
+ ctx.fillRect(0, 0, W, H);
+
+ this.hits = [];
+
+ // First pass: lay out threads to discover total content height and
+ // clamp scrollY. We may re-layout after clamping so coordinates
+ // are accurate for the real draw.
+ let layout = this.layoutThreads();
+ const visibleH = Math.max(0, H - RULER_H);
+ this.maxScrollY = Math.max(0, layout.totalH - RULER_H - visibleH);
+ if (this.scrollY > this.maxScrollY)
+ {
+ this.scrollY = this.maxScrollY;
+ layout = this.layoutThreads();
+ }
+ if (this.scrollY < 0)
+ {
+ this.scrollY = 0;
+ layout = this.layoutThreads();
+ }
+ const { rows } = layout;
+
+ // Clip thread rendering to below the ruler strip so scrolled-up
+ // content never bleeds over it. The ruler is drawn after restoring.
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(0, RULER_H, W, H - RULER_H);
+ ctx.clip();
+
+ this.drawRegions(ctx, W);
+
+ const pxPerUs = this.pxPerUs();
+ const bg1 = getComputedStyle(document.body).getPropertyValue("--bg1") || "#161b22";
+ const fg1 = getComputedStyle(document.body).getPropertyValue("--fg1") || "#c9d1d9";
+ const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e";
+
+ for (const row of rows) {
+ if (row.y > H) break;
+ if (row.y + row.height < RULER_H) continue;
+
+ // Thread header strip
+ const meta = this.threadMeta.get(row.threadId);
+ const isLane = meta && meta.is_lane;
+ ctx.fillStyle = isLane ? "rgba(130, 80, 220, 0.12)" : bg1;
+ ctx.fillRect(0, row.y, W, row.headerH);
+ const prefix = isLane ? "⬦ " : "";
+ const label = `${prefix}${(meta && meta.name) || `tid ${row.threadId}`} · ${row.threadId}`;
+ ctx.fillStyle = isLane ? "rgba(180, 140, 255, 0.8)" : fg2;
+ ctx.font = "11px -apple-system, Segoe UI, sans-serif";
+ ctx.textBaseline = "middle";
+ ctx.fillText(label, 6, row.y + row.headerH / 2);
+
+ // Swimlane backgrounds
+ for (let d = 0; d <= row.maxDepth; d++) {
+ ctx.fillStyle = d % 2 === 0 ? "rgba(255,255,255,0.015)" : "rgba(255,255,255,0.00)";
+ ctx.fillRect(0, row.y + row.headerH + d * DEPTH_H, W, DEPTH_H);
+ }
+
+ const timeline = this.timelines.get(row.threadId);
+ if (timeline) {
+ this.drawScopes(ctx, timeline, row, pxPerUs, fg1);
+ }
+ }
+
+ this.drawSelectionOutline(ctx);
+
+ ctx.restore();
+
+ // Ruler is drawn last so it always overlays the thread region
+ // regardless of how far the content has scrolled.
+ this.drawRuler(ctx, W);
+
+ // Bookmark lines span the whole content area, drawn after the ruler
+ // so the little diamond markers sit inside the ruler strip.
+ this.drawBookmarks(ctx, W, H);
+
+ this.drawViewportInfo();
+ }
+
+ drawRuler(ctx, W) {
+ const bg1 = getComputedStyle(document.body).getPropertyValue("--bg1") || "#161b22";
+ const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e";
+ const border = getComputedStyle(document.body).getPropertyValue("--border") || "#30363d";
+
+ ctx.fillStyle = bg1;
+ ctx.fillRect(0, 0, W, RULER_H);
+ ctx.strokeStyle = border;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(0, RULER_H - 0.5);
+ ctx.lineTo(W, RULER_H - 0.5);
+ ctx.stroke();
+
+ // Pick a tick interval that yields 6–12 ticks across the visible range.
+ const rangeUs = this.endUs - this.startUs;
+ const targetTicks = Math.max(4, Math.min(12, Math.floor(W / 100)));
+ const roughInterval = rangeUs / targetTicks;
+ const pow10 = Math.pow(10, Math.floor(Math.log10(roughInterval)));
+ let interval = pow10;
+ if (roughInterval / pow10 > 5) interval = 10 * pow10;
+ else if (roughInterval / pow10 > 2) interval = 5 * pow10;
+ else if (roughInterval / pow10 > 1) interval = 2 * pow10;
+
+ ctx.fillStyle = fg2;
+ ctx.font = "10px -apple-system, Segoe UI, sans-serif";
+ ctx.textBaseline = "middle";
+ ctx.textAlign = "left";
+
+ const firstTick = Math.ceil(this.startUs / interval) * interval;
+ for (let t = firstTick; t <= this.endUs; t += interval) {
+ const x = this.xAtUs(t);
+ ctx.strokeStyle = border;
+ ctx.beginPath();
+ ctx.moveTo(x + 0.5, 0);
+ ctx.lineTo(x + 0.5, RULER_H);
+ ctx.stroke();
+ ctx.fillText(formatTime(t), x + 4, RULER_H / 2);
+ }
+ }
+
+ drawRegions(ctx, W) {
+ if (this.regionCategories.length === 0) return;
+
+ const startUs = this.startUs;
+ const endUs = this.endUs;
+ const pxPerUs = this.pxPerUs();
+ const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e";
+ const bg1 = getComputedStyle(document.body).getPropertyValue("--bg1") || "#161b22";
+ let catY = RULER_H - this.scrollY;
+
+ for (let ci = 0; ci < this.regionCategories.length; ci++) {
+ if (!this.enabledRegionCategories.has(ci)) continue;
+ const cat = this.regionCategories[ci];
+ // Category header
+ ctx.fillStyle = bg1;
+ ctx.fillRect(0, catY, W, REGION_HEADER_H);
+ ctx.fillStyle = fg2;
+ ctx.font = "10px -apple-system, Segoe UI, sans-serif";
+ ctx.textBaseline = "middle";
+ ctx.textAlign = "left";
+ ctx.fillText(`Timing Regions \u2013 ${cat.name}`, 6, catY + REGION_HEADER_H / 2);
+ catY += REGION_HEADER_H;
+
+ // Region bands for this category
+ for (const r of cat.regions) {
+ const beginUs = r.begin_us;
+ const endRegUs = r.end_us;
+ if (endRegUs < startUs) continue;
+ if (beginUs > endUs) continue;
+
+ const x = this.xAtUs(beginUs);
+ const w = Math.max(MIN_RECT_W, (endRegUs - beginUs) * pxPerUs);
+ if (w < MIN_RECT_W) continue;
+
+ const y = catY + r.depth * REGION_LANE_H;
+
+ ctx.fillStyle = regionFillColor(r.name);
+ ctx.fillRect(x, y + 1, w, REGION_LANE_H - 2);
+
+ ctx.strokeStyle = "rgba(255,255,255,0.2)";
+ ctx.lineWidth = 1;
+ ctx.strokeRect(x + 0.5, y + 1.5, w - 1, REGION_LANE_H - 3);
+
+ const visX = Math.max(x, 0);
+ const visRight = Math.min(x + w, this.width);
+ const visW = visRight - visX;
+ if (visW > 24 && r.name) {
+ ctx.fillStyle = "rgba(255,255,255,0.95)";
+ ctx.font = "11px -apple-system, Segoe UI, sans-serif";
+ ctx.textBaseline = "middle";
+ ctx.textAlign = "left";
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(visX + 3, y, visW - 6, REGION_LANE_H);
+ ctx.clip();
+ ctx.fillText(r.name, visX + 5, y + REGION_LANE_H / 2);
+ ctx.restore();
+ }
+
+ this.hits.push({ x, y, w, h: REGION_LANE_H - 2, region: r, regionCategory: cat.name });
+ }
+
+ catY += cat.lane_count * REGION_LANE_H;
+ }
+ }
+
+ drawBookmarks(ctx, W, H) {
+ if (!this.bookmarksVisible || !this.bookmarks || this.bookmarks.length === 0) return;
+
+ const startUs = this.startUs;
+ const endUs = this.endUs;
+
+ ctx.save();
+ ctx.strokeStyle = "rgba(227, 179, 65, 0.85)";
+ ctx.fillStyle = "rgba(227, 179, 65, 0.95)";
+ ctx.lineWidth = 1;
+
+ for (const b of this.bookmarks) {
+ if (b.time_us < startUs) continue;
+ if (b.time_us > endUs) break;
+
+ const x = this.xAtUs(b.time_us);
+ if (x < -2 || x > W + 2) continue;
+
+ // Dashed vertical line spanning the whole content area.
+ ctx.setLineDash([3, 3]);
+ ctx.beginPath();
+ ctx.moveTo(x + 0.5, RULER_H);
+ ctx.lineTo(x + 0.5, H);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ // Diamond marker inside the ruler strip.
+ const cy = RULER_H - 6;
+ ctx.beginPath();
+ ctx.moveTo(x, cy - 4);
+ ctx.lineTo(x + 4, cy);
+ ctx.lineTo(x, cy + 4);
+ ctx.lineTo(x - 4, cy);
+ ctx.closePath();
+ ctx.fill();
+
+ this.hits.push({ x: x - 4, y: 0, w: 9, h: H, bookmark: b });
+ }
+ ctx.restore();
+ }
+
+ drawScopes(ctx, timeline, row, pxPerUs, textColor) {
+ const { scopes, perDepth } = timeline;
+ const startUs = this.startUs;
+ const endUs = this.endUs;
+
+ const highlightNameId = this.highlightName
+ ? this.model.scopeNameIds.get(this.highlightName)
+ : undefined;
+
+ ctx.textBaseline = "middle";
+ ctx.textAlign = "left";
+ ctx.font = "11px -apple-system, Segoe UI, sans-serif";
+
+ const rowTop = row.y + row.headerH;
+ const maxDepth = Math.min(row.maxDepth, perDepth.length - 1);
+
+ for (let depth = 0; depth <= maxDepth; depth++) {
+ const indices = perDepth[depth];
+ if (!indices || indices.length === 0) continue;
+
+ // Sibling scopes at the same depth never overlap, so their end
+ // times are monotonic in begin order — a standard lower_bound
+ // on (end >= startUs) correctly finds the first visible scope,
+ // including outer-depth scopes whose begin is far before
+ // the viewport start.
+ let lo = 0;
+ let hi = indices.length;
+ while (lo < hi) {
+ const mid = (lo + hi) >>> 1;
+ const s = scopes[indices[mid]];
+ if (s[0] + s[1] < startUs) {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+
+ const y = rowTop + depth * DEPTH_H;
+ let rendered = 0;
+ for (let j = lo; j < indices.length; j++) {
+ const s = scopes[indices[j]];
+ if (s[0] > endUs) break;
+
+ const beginUs = s[0];
+ const durationUs = s[1];
+ const nameId = s[2];
+ const mergeCount = s[4] || 0;
+
+ ++rendered;
+ const x = this.xAtUs(beginUs);
+ const w = Math.max(MIN_RECT_W, durationUs * pxPerUs);
+
+ const hue = ((nameId * 137.508) % 360 + 360) % 360;
+ const isHighlighted = highlightNameId !== undefined && nameId === highlightNameId;
+
+ if (mergeCount > 1) {
+ // Merged scope — desaturated fill with dashed top indicator.
+ ctx.fillStyle = isHighlighted
+ ? `hsl(${hue.toFixed(0)}, 50%, 50%)`
+ : `hsl(${hue.toFixed(0)}, 30%, 35%)`;
+ ctx.fillRect(x, y + 1, w, DEPTH_H - 2);
+ ctx.strokeStyle = "rgba(255,255,255,0.25)";
+ ctx.lineWidth = 1;
+ ctx.setLineDash([2, 2]);
+ ctx.beginPath();
+ ctx.moveTo(x, y + 1.5);
+ ctx.lineTo(x + w, y + 1.5);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ } else {
+ ctx.fillStyle = isHighlighted ? scopeHighlightColor(nameId) : scopeFillColor(nameId);
+ ctx.fillRect(x, y + 1, w, DEPTH_H - 2);
+ }
+
+ // Draw the label pinned to the visible portion of the rect
+ // so zooming into a long scope still shows its name.
+ const visX = Math.max(x, 0);
+ const visRight = Math.min(x + w, this.width);
+ const visW = visRight - visX;
+ if (visW > 30) {
+ const name = this.model.scopeNames[nameId] || "?";
+ const maxChars = Math.floor((visW - 6) / 6);
+ const shown = name.length > maxChars ? name.slice(0, Math.max(0, maxChars - 1)) + "…" : name;
+ ctx.fillStyle = "rgba(255,255,255,0.95)";
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(visX + 3, y, visW - 6, DEPTH_H);
+ ctx.clip();
+ ctx.fillText(shown, visX + 4, y + DEPTH_H / 2);
+ ctx.restore();
+ }
+
+ this.hits.push({ x, y, w, h: DEPTH_H - 2, threadId: row.threadId, tuple: s });
+ }
+ }
+ }
+
+ drawSelectionOutline(ctx) {
+ if (!this.selected || !this.selected.tuple) return;
+ const s = this.selected.tuple;
+ const beginUs = s[0];
+ const durationUs = s[1];
+ const depth = s[3];
+ const { rows } = this.layoutThreads();
+ const row = rows.find((r) => r.threadId === this.selected.threadId);
+ if (!row) return;
+ const x = this.xAtUs(beginUs);
+ const w = Math.max(MIN_RECT_W, durationUs * this.pxPerUs());
+ const y = row.y + row.headerH + depth * DEPTH_H;
+ ctx.strokeStyle = "#ffffff";
+ ctx.lineWidth = 1.5;
+ ctx.strokeRect(x - 0.5, y + 0.5, w + 1, DEPTH_H - 1);
+ }
+
+ drawViewportInfo() {
+ const text = `${formatRange(this.startUs, this.endUs)} · ${(this.pxPerUs() * 1000).toFixed(2)} px/ms`;
+ this.viewportInfoEl.textContent = text;
+ }
+
+ hitTest(clientX, clientY) {
+ const rect = this.canvas.getBoundingClientRect();
+ const x = clientX - rect.left;
+ const y = clientY - rect.top;
+ for (let i = this.hits.length - 1; i >= 0; i--) {
+ const h = this.hits[i];
+ if (x >= h.x && x < h.x + h.w && y >= h.y && y < h.y + h.h) {
+ return h;
+ }
+ }
+ return null;
+ }
+
+ onMouseDown(e) {
+ if (e.button !== 0) return;
+ this.panning = true;
+ this.panMoved = false;
+ this.panStartX = e.clientX;
+ this.panStartY = e.clientY;
+ this.panStartUs = this.startUs;
+ this.panStartScrollY = this.scrollY;
+ this.panRangeUs = this.endUs - this.startUs;
+ }
+
+ onMouseMove(e) {
+ if (this.panning) {
+ const dx = e.clientX - this.panStartX;
+ const dy = e.clientY - this.panStartY;
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) this.panMoved = true;
+ const deltaUs = -dx / this.pxPerUs();
+ this.startUs = this.panStartUs + deltaUs;
+ this.endUs = this.startUs + this.panRangeUs;
+ this.scrollY = this.panStartScrollY - dy;
+ this.checkViewportFetch();
+ this.requestDraw();
+ this.hideTooltip();
+ return;
+ }
+ const hit = this.hitTest(e.clientX, e.clientY);
+ if (hit) {
+ this.showTooltip(hit, e.clientX, e.clientY);
+ } else {
+ this.hideTooltip();
+ }
+ }
+
+ onMouseUp(e) {
+ if (!this.panning) return;
+ this.panning = false;
+ if (!this.panMoved) {
+ const hit = this.hitTest(e.clientX, e.clientY);
+ if (hit) {
+ this.selectScope(hit);
+ }
+ }
+ }
+
+ onWheel(e) {
+ e.preventDefault();
+
+ // Shift+wheel (or horizontal wheel delta from a trackpad) scrolls
+ // vertically without changing the zoom.
+ if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
+ const step = e.deltaX !== 0 ? e.deltaX : e.deltaY;
+ this.scrollY += step;
+ this.requestDraw();
+ return;
+ }
+
+ const rect = this.canvas.getBoundingClientRect();
+ const cursorX = e.clientX - rect.left;
+ const cursorUs = this.usAtX(cursorX);
+ const factor = e.deltaY > 0 ? 1.25 : 0.8;
+ const newRange = Math.max(10, (this.endUs - this.startUs) * factor);
+ this.startUs = cursorUs - (cursorX - PADDING_X) * (newRange / (this.width - PADDING_X * 2));
+ this.endUs = this.startUs + newRange;
+ this.checkViewportFetch();
+ this.requestDraw();
+ }
+
+ showTooltip(hit, clientX, clientY) {
+ let title = "";
+ let meta = "";
+
+ if (hit.bookmark) {
+ const b = hit.bookmark;
+ title = b.text || "(unnamed bookmark)";
+ const loc = b.file ? `${b.file.split(/[\\/]/).pop()}:${b.line}` : "";
+ meta = `bookmark · ${formatTime(b.time_us)}${loc ? " · " + loc : ""}`;
+ }
+ else if (hit.region) {
+ const r = hit.region;
+ const dur = r.end_us - r.begin_us;
+ title = r.name || "(unnamed region)";
+ meta = `region · ${formatTime(dur)} · start ${formatTime(r.begin_us)}${hit.regionCategory ? " · " + hit.regionCategory : ""}`;
+ }
+ else {
+ const s = hit.tuple;
+ title = this.model.scopeNames[s[2]] || "?";
+ const tm = this.threadMeta.get(hit.threadId);
+ const threadName = (tm && tm.name) || `tid ${hit.threadId}`;
+ meta = `${formatTime(s[1])} · depth ${s[3]} · ${threadName} · start ${formatTime(s[0])}`;
+ if (s[4] > 1) {
+ meta += ` · ${s[4]} merged`;
+ }
+ }
+
+ this.tooltip.innerHTML =
+ `<div class="tt-name"></div>` +
+ `<div class="tt-meta"></div>`;
+ this.tooltip.querySelector(".tt-name").textContent = title;
+ this.tooltip.querySelector(".tt-meta").textContent = meta;
+
+ const rect = this.canvas.getBoundingClientRect();
+ const tx = clientX - rect.left + 12;
+ const ty = clientY - rect.top + 12;
+ this.tooltip.style.left = `${tx}px`;
+ this.tooltip.style.top = `${ty}px`;
+ this.tooltip.hidden = false;
+ }
+
+ hideTooltip() {
+ this.tooltip.hidden = true;
+ }
+
+ selectScope(hit) {
+ this.selected = hit;
+
+ if (hit.bookmark) {
+ const b = hit.bookmark;
+ this.selectionEl.innerHTML =
+ `<div class="selection-title"></div>` +
+ `<div class="selection-meta">` +
+ `<div><span class="k">Kind:</span> <span class="v">bookmark</span></div>` +
+ `<div><span class="k">Time:</span> <span class="v" data-k="time"></span></div>` +
+ `<div><span class="k">Source:</span> <span class="v" data-k="src"></span></div>` +
+ `</div>`;
+ this.selectionEl.querySelector(".selection-title").textContent = b.text || "(unnamed bookmark)";
+ this.selectionEl.querySelector("[data-k=time]").textContent = formatTime(b.time_us);
+ this.selectionEl.querySelector("[data-k=src]").textContent = b.file ? `${b.file}:${b.line}` : "";
+ this.requestDraw();
+ return;
+ }
+
+ if (hit.region) {
+ const r = hit.region;
+ this.selectionEl.innerHTML =
+ `<div class="selection-title"></div>` +
+ `<div class="selection-meta">` +
+ `<div><span class="k">Kind:</span> <span class="v">region</span></div>` +
+ `<div><span class="k">Duration:</span> <span class="v" data-k="dur"></span></div>` +
+ `<div><span class="k">Begin:</span> <span class="v" data-k="begin"></span></div>` +
+ `<div><span class="k">End:</span> <span class="v" data-k="end"></span></div>` +
+ `<div><span class="k">Category:</span> <span class="v" data-k="cat"></span></div>` +
+ `</div>`;
+ this.selectionEl.querySelector(".selection-title").textContent = r.name || "(unnamed region)";
+ this.selectionEl.querySelector("[data-k=dur]").textContent = formatTime(r.end_us - r.begin_us);
+ this.selectionEl.querySelector("[data-k=begin]").textContent = formatTime(r.begin_us);
+ this.selectionEl.querySelector("[data-k=end]").textContent = formatTime(r.end_us);
+ this.selectionEl.querySelector("[data-k=cat]").textContent = hit.regionCategory || "\u2014";
+ this.requestDraw();
+ return;
+ }
+
+ const s = hit.tuple;
+ const name = this.model.scopeNames[s[2]] || "?";
+ const meta = this.threadMeta.get(hit.threadId);
+ const threadName = (meta && meta.name) || `tid ${hit.threadId}`;
+ const mergedRow = s[4] > 1
+ ? `<div><span class="k">Merged:</span> <span class="v" data-k="merged"></span></div>`
+ : "";
+ this.selectionEl.innerHTML =
+ `<div class="selection-title"></div>` +
+ `<div class="selection-meta">` +
+ `<div><span class="k">Duration:</span> <span class="v" data-k="dur"></span></div>` +
+ `<div><span class="k">Begin:</span> <span class="v" data-k="begin"></span></div>` +
+ `<div><span class="k">End:</span> <span class="v" data-k="end"></span></div>` +
+ `<div><span class="k">Depth:</span> <span class="v" data-k="depth"></span></div>` +
+ `<div><span class="k">Thread:</span> <span class="v" data-k="thread"></span></div>` +
+ mergedRow +
+ `</div>`;
+ this.selectionEl.querySelector(".selection-title").textContent = name;
+ this.selectionEl.querySelector("[data-k=dur]").textContent = formatTime(s[1]);
+ this.selectionEl.querySelector("[data-k=begin]").textContent = formatTime(s[0]);
+ this.selectionEl.querySelector("[data-k=end]").textContent = formatTime(s[0] + s[1]);
+ this.selectionEl.querySelector("[data-k=depth]").textContent = String(s[3]);
+ this.selectionEl.querySelector("[data-k=thread]").textContent = `${threadName} (${hit.threadId})`;
+ if (s[4] > 1) {
+ this.selectionEl.querySelector("[data-k=merged]").textContent = `${s[4]} scopes`;
+ }
+ this.requestDraw();
+ this.onScopeSelect(name);
+ }
+}
diff --git a/src/zen/frontend/html/trace.css b/src/zen/frontend/html/trace.css
new file mode 100644
index 000000000..2ff324019
--- /dev/null
+++ b/src/zen/frontend/html/trace.css
@@ -0,0 +1,1312 @@
+/* Copyright Epic Games, Inc. All Rights Reserved. */
+
+:root,
+:root[data-theme="dark"] {
+ --bg0: #0d1117;
+ --bg1: #161b22;
+ --bg2: #1c2128;
+ --bg3: #21262d;
+ --border: #30363d;
+ --border-soft: #21262d;
+ --fg0: #f0f6fc;
+ --fg1: #c9d1d9;
+ --fg2: #8b949e;
+ --accent: #58a6ff;
+ --accent-soft: #1c2128;
+ --warn: #d29922;
+ --ok: #3fb950;
+ --fail: #f85149;
+ --highlight: #e3b34166;
+}
+
+@media (prefers-color-scheme: light) {
+ :root:not([data-theme]),
+ :root[data-theme="system"] {
+ --bg0: #ffffff;
+ --bg1: #f6f8fa;
+ --bg2: #ffffff;
+ --bg3: #eaeef2;
+ --border: #d0d7de;
+ --border-soft: #d8dee4;
+ --fg0: #1f2328;
+ --fg1: #24292f;
+ --fg2: #656d76;
+ --accent: #0969da;
+ --accent-soft: #ddf4ff;
+ --warn: #9a6700;
+ --ok: #1a7f37;
+ --fail: #cf222e;
+ --highlight: #b8860b44;
+ }
+}
+
+:root[data-theme="light"] {
+ --bg0: #ffffff;
+ --bg1: #f6f8fa;
+ --bg2: #ffffff;
+ --bg3: #eaeef2;
+ --border: #d0d7de;
+ --border-soft: #d8dee4;
+ --fg0: #1f2328;
+ --fg1: #24292f;
+ --fg2: #656d76;
+ --accent: #0969da;
+ --accent-soft: #ddf4ff;
+ --warn: #9a6700;
+ --ok: #1a7f37;
+ --fail: #cf222e;
+ --highlight: #b8860b44;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ background: var(--bg0);
+ color: var(--fg1);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ font-size: 13px;
+ overflow: hidden;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+}
+
+pre, code, .mono {
+ font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace;
+ font-size: 12px;
+}
+
+/* -- header ---------------------------------------------------------------- */
+
+.header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 10px 16px;
+ background: var(--bg1);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.header-title {
+ font-weight: 600;
+ color: var(--fg0);
+ font-size: 14px;
+}
+
+.header-file {
+ color: var(--fg2);
+ font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace;
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ min-width: 0;
+}
+
+.header-stats {
+ color: var(--fg2);
+ font-size: 12px;
+ display: flex;
+ gap: 16px;
+}
+
+.header-stats .k {
+ color: var(--fg2);
+ margin-right: 4px;
+}
+
+.header-stats .v {
+ color: var(--fg0);
+ font-weight: 500;
+}
+
+.header-btn {
+ background: var(--bg2);
+ color: var(--fg1);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 10px;
+ font-size: 12px;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.header-btn:hover {
+ background: var(--bg3);
+ color: var(--fg0);
+}
+
+/* -- layout ---------------------------------------------------------------- */
+
+.layout {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+}
+
+.sidebar {
+ width: 260px;
+ flex-shrink: 0;
+ background: var(--bg1);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.content {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg0);
+}
+
+/* -- tabs ------------------------------------------------------------------ */
+
+.tabs {
+ display: flex;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.tab {
+ flex: 1;
+ padding: 10px 8px;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--fg2);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.tab:hover {
+ color: var(--fg0);
+ background: var(--bg2);
+}
+
+.tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+/* -- sidebar sections ------------------------------------------------------ */
+
+.sidebar-section {
+ padding: 12px 12px;
+ border-bottom: 1px solid var(--border-soft);
+ flex-shrink: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.sidebar-section:last-child {
+ flex: 1;
+ overflow-y: auto;
+}
+
+.sidebar-label {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ font-size: 10px;
+ color: var(--fg2);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 6px;
+ font-weight: 600;
+}
+
+.sidebar-action {
+ font-size: 9px;
+ color: var(--fg2);
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ text-transform: lowercase;
+ letter-spacing: 0;
+ font-weight: 400;
+ opacity: 0.7;
+}
+
+.sidebar-action:hover {
+ color: var(--fg0);
+ opacity: 1;
+}
+
+#search-input {
+ width: 100%;
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ color: var(--fg0);
+ padding: 5px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+}
+
+#search-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.search-results {
+ margin-top: 6px;
+ max-height: 180px;
+ overflow-y: auto;
+ font-size: 12px;
+}
+
+.search-results .hit {
+ padding: 3px 6px;
+ border-radius: 3px;
+ cursor: pointer;
+ color: var(--fg1);
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+}
+
+.search-results .hit:hover {
+ background: var(--accent-soft);
+ color: var(--fg0);
+}
+
+.search-results .hit-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.search-results .hit-count {
+ color: var(--fg2);
+ font-size: 11px;
+ flex-shrink: 0;
+}
+
+/* -- threads list ---------------------------------------------------------- */
+
+.threads-list, .regions-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ overflow-y: auto;
+}
+
+.thread-group-header {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--fg2);
+ padding: 6px 4px 2px;
+ user-select: none;
+}
+
+.thread-group-header[data-group] {
+ cursor: pointer;
+ border-radius: 3px;
+}
+
+.thread-group-header[data-group]:hover {
+ color: var(--fg1);
+ background: var(--bg2);
+}
+
+.thread-group-header:first-child {
+ padding-top: 0;
+}
+
+.group-checkbox {
+ margin: 0 2px 0 0;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.group-chevron {
+ display: inline-block;
+ margin-right: 2px;
+ transition: transform 0.15s;
+}
+
+.thread-group-header.collapsed .group-chevron {
+ transform: rotate(-90deg);
+}
+
+.thread-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 4px;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--fg1);
+}
+
+.thread-row.lane .thread-name {
+ font-style: italic;
+}
+
+.thread-row:hover {
+ background: var(--bg2);
+}
+
+.thread-row.empty {
+ color: var(--fg2);
+ opacity: 0.6;
+}
+
+.thread-row input[type=checkbox] {
+ margin: 0;
+ accent-color: var(--accent);
+}
+
+.thread-row .thread-name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.thread-row .thread-count {
+ color: var(--fg2);
+ font-size: 11px;
+ flex-shrink: 0;
+}
+
+/* -- views ----------------------------------------------------------------- */
+
+.view {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.view[hidden] {
+ display: none;
+}
+
+/* -- timeline -------------------------------------------------------------- */
+
+.view-timeline {
+ position: relative;
+}
+
+.timeline-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--border-soft);
+ background: var(--bg1);
+ flex-shrink: 0;
+}
+
+.viewport-info {
+ color: var(--fg2);
+ font-size: 11px;
+ flex: 1;
+ font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace;
+}
+
+.toolbar-toggle {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ color: var(--fg2);
+ cursor: pointer;
+ user-select: none;
+}
+
+.toolbar-toggle input[type="checkbox"] {
+ margin: 0;
+}
+
+.btn {
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ color: var(--fg1);
+ padding: 3px 10px;
+ border-radius: 4px;
+ font-size: 11px;
+ cursor: pointer;
+}
+
+.btn:hover {
+ background: var(--bg3);
+ color: var(--fg0);
+}
+
+.timeline-frame {
+ flex: 1;
+ position: relative;
+ min-height: 0;
+ overflow: hidden;
+}
+
+#timeline-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ cursor: grab;
+}
+
+#timeline-canvas:active {
+ cursor: grabbing;
+}
+
+.tooltip {
+ position: absolute;
+ background: var(--bg1);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 6px 10px;
+ font-size: 11px;
+ color: var(--fg0);
+ pointer-events: none;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+ max-width: 360px;
+ z-index: 10;
+}
+
+.tooltip .tt-name {
+ font-weight: 600;
+ margin-bottom: 2px;
+}
+
+.tooltip .tt-meta {
+ color: var(--fg2);
+ font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace;
+ font-size: 10px;
+}
+
+.selection-panel {
+ background: var(--bg1);
+ border-top: 1px solid var(--border-soft);
+ padding: 10px 14px;
+ flex-shrink: 0;
+ min-height: 56px;
+ max-height: 140px;
+ overflow-y: auto;
+}
+
+.selection-hint {
+ color: var(--fg2);
+ font-size: 11px;
+ font-style: italic;
+}
+
+.selection-title {
+ color: var(--fg0);
+ font-weight: 600;
+ font-size: 13px;
+ margin-bottom: 4px;
+ word-break: break-all;
+}
+
+.selection-meta {
+ color: var(--fg2);
+ font-size: 11px;
+ font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 4px 16px;
+}
+
+.selection-meta .k {
+ color: var(--fg2);
+}
+
+.selection-meta .v {
+ color: var(--fg1);
+}
+
+/* -- stats table ----------------------------------------------------------- */
+
+.view-stats {
+ overflow-y: auto;
+ padding: 12px;
+}
+
+.stats-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.stats-table th {
+ text-align: left;
+ padding: 8px 10px;
+ background: var(--bg1);
+ color: var(--fg2);
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.5px;
+ border-bottom: 1px solid var(--border);
+ cursor: pointer;
+ user-select: none;
+ position: sticky;
+ top: 0;
+}
+
+.stats-table th.num {
+ text-align: right;
+}
+
+.stats-table th:hover {
+ color: var(--fg0);
+}
+
+.stats-table th.sorted::after {
+ content: ' ▾';
+ color: var(--accent);
+}
+
+.stats-table th.sorted.asc::after {
+ content: ' ▴';
+}
+
+.stats-table td {
+ padding: 5px 10px;
+ border-bottom: 1px solid var(--border-soft);
+ color: var(--fg1);
+}
+
+.stats-table td.num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ color: var(--fg0);
+}
+
+.stats-table tbody tr {
+ cursor: pointer;
+}
+
+.stats-table tbody tr:hover {
+ background: var(--bg1);
+}
+
+.stats-table tbody tr.selected {
+ background: var(--accent-soft);
+}
+
+/* -- session view ---------------------------------------------------------- */
+
+.view-session {
+ overflow-y: auto;
+ padding: 20px 24px;
+}
+
+.session-content h2 {
+ font-size: 14px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--fg2);
+ margin: 24px 0 10px;
+ border-bottom: 1px solid var(--border-soft);
+ padding-bottom: 4px;
+}
+
+.session-content h2:first-child {
+ margin-top: 0;
+}
+
+.session-content dl {
+ display: grid;
+ grid-template-columns: 150px 1fr;
+ gap: 6px 16px;
+ margin: 0 0 12px;
+ font-size: 12px;
+}
+
+.session-content dt {
+ color: var(--fg2);
+}
+
+.session-content dd {
+ margin: 0;
+ color: var(--fg1);
+ font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace;
+ word-break: break-all;
+}
+
+.session-content table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.session-content table th {
+ text-align: left;
+ padding: 6px 10px;
+ color: var(--fg2);
+ font-weight: 600;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ border-bottom: 1px solid var(--border-soft);
+}
+
+.session-content table th.num {
+ text-align: right;
+}
+
+.session-content table td {
+ padding: 4px 10px;
+ border-bottom: 1px solid var(--border-soft);
+ color: var(--fg1);
+}
+
+.session-content table td.num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.chan-enabled {
+ color: var(--ok);
+}
+
+.chan-disabled {
+ color: var(--fg2);
+}
+
+.chan-readonly {
+ color: var(--warn);
+ font-size: 10px;
+ margin-left: 8px;
+}
+
+/* -- logs view ------------------------------------------------------------- */
+
+.view-logs {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+#logs-content {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.logs-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 8px 12px;
+ background: var(--bg1);
+ border-bottom: 1px solid var(--border-soft);
+ flex-shrink: 0;
+}
+
+.logs-filter {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.logs-filter-grow {
+ flex: 1;
+}
+
+.logs-filter-label {
+ color: var(--fg2);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+}
+
+.logs-toolbar select,
+.logs-toolbar input {
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ color: var(--fg0);
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+}
+
+.logs-toolbar select:focus,
+.logs-toolbar input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.logs-toolbar input {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.logs-count {
+ color: var(--fg2);
+ font-size: 11px;
+ font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace;
+ white-space: nowrap;
+}
+
+.logs-list-wrap {
+ flex: 1;
+ overflow: auto;
+ min-height: 0;
+}
+
+.logs-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.logs-table th {
+ text-align: left;
+ padding: 6px 10px;
+ background: var(--bg1);
+ color: var(--fg2);
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.5px;
+ border-bottom: 1px solid var(--border);
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.logs-table td {
+ padding: 4px 10px;
+ border-bottom: 1px solid var(--border-soft);
+ vertical-align: top;
+}
+
+.logs-table .col-time {
+ white-space: nowrap;
+ color: var(--fg2);
+ width: 1%;
+}
+
+.logs-table .col-verb {
+ white-space: nowrap;
+ width: 1%;
+ font-weight: 500;
+}
+
+.logs-table .col-cat {
+ white-space: nowrap;
+ width: 1%;
+ color: var(--fg1);
+}
+
+.logs-table .col-msg {
+ color: var(--fg0);
+ word-break: break-word;
+}
+
+.logs-table .col-loc {
+ white-space: nowrap;
+ color: var(--fg2);
+ width: 1%;
+ font-size: 11px;
+}
+
+.logs-table tr.vb-fatal td,
+.logs-table tr.vb-error td {
+ color: var(--fail);
+}
+
+.logs-table tr.vb-error .col-msg {
+ color: var(--fail);
+}
+
+.logs-table tr.vb-warn .col-verb,
+.logs-table tr.vb-warn .col-msg {
+ color: var(--warn);
+}
+
+.logs-table tr.vb-display .col-verb {
+ color: var(--accent);
+}
+
+.logs-table tr.vb-verbose .col-verb,
+.logs-table tr.vb-verbose .col-msg {
+ color: var(--fg2);
+}
+
+.logs-table tr.bm-row .col-verb {
+ color: #e3b341;
+ font-weight: 600;
+}
+
+.logs-table tr.bm-row .col-msg {
+ color: #f0d078;
+}
+
+.logs-table tr.bm-row .col-time {
+ color: #e3b341;
+}
+
+.logs-empty, .logs-error {
+ padding: 20px;
+ text-align: center;
+ color: var(--fg2);
+}
+
+.logs-error {
+ color: var(--fail);
+}
+
+/* -- loading overlay ------------------------------------------------------- */
+
+.loading {
+ position: fixed;
+ inset: 0;
+ background: var(--bg0);
+ color: var(--fg2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ z-index: 100;
+}
+
+.loading.hidden {
+ display: none;
+}
+
+/* ── CSV Stats view ───────────────────────────────────────────────── */
+
+.view-csv {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+#csv-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.csv-layout {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+}
+
+.csv-tree-panel {
+ width: 240px;
+ flex-shrink: 0;
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ padding: 8px;
+}
+
+.csv-chart-panel {
+ flex: 1;
+ position: relative;
+ min-width: 0;
+}
+
+.csv-chart-canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+.csv-chart-tooltip {
+ position: absolute;
+ background: var(--bg1);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 6px 8px;
+ font-size: 11px;
+ color: var(--fg1);
+ pointer-events: none;
+ z-index: 10;
+ white-space: nowrap;
+}
+
+.csv-cat-header {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--fg2);
+ padding: 8px 4px 2px;
+}
+
+.csv-stat-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 2px 4px;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--fg1);
+}
+
+.csv-stat-row:hover {
+ background: var(--bg2);
+}
+
+.csv-stat-row input[type=checkbox] {
+ margin: 0;
+ accent-color: var(--accent);
+}
+
+.csv-stat-name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.csv-empty {
+ color: var(--fg2);
+ font-size: 12px;
+ padding: 12px 4px;
+}
+
+/* -- memory view ---------------------------------------------------------- */
+
+.view-memory {
+ overflow: auto;
+ padding: 16px;
+}
+
+.memory-view {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.memory-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 12px;
+}
+
+.memory-card,
+.memory-panel {
+ background: var(--bg1);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.memory-card {
+ padding: 12px 14px;
+}
+
+.memory-card-label,
+.memory-panel-subtitle,
+.memory-empty,
+.memory-frame-path {
+ color: var(--fg2);
+}
+
+.memory-chart-axis,
+.memory-chart-text {
+ fill: var(--fg2);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ font-size: 11px;
+}
+
+.memory-card-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ margin-bottom: 6px;
+}
+
+.memory-card-value {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--fg0);
+}
+
+.memory-panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 12px;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border-soft);
+}
+
+.memory-panel-header-wrap {
+ flex-wrap: wrap;
+}
+
+.memory-panel-title {
+ font-weight: 600;
+ color: var(--fg0);
+}
+
+.memory-controls {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ flex-wrap: wrap;
+ color: var(--fg2);
+ font-size: 12px;
+}
+
+.memory-controls label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.memory-filter-input,
+.memory-controls select {
+ background: var(--bg2);
+ color: var(--fg1);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 4px 6px;
+ font-size: 12px;
+}
+
+.memory-filter-input {
+ min-width: 180px;
+}
+
+.memory-direction-btn,
+.memory-clear-btn {
+ background: var(--bg2);
+ color: var(--fg1);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.memory-direction-btn:hover,
+.memory-clear-btn:hover:not(:disabled) {
+ background: var(--bg3);
+}
+
+.memory-clear-btn:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.memory-chart-wrap {
+ padding: 10px 12px 12px;
+}
+
+.memory-chart {
+ display: block;
+ width: 100%;
+ height: 220px;
+}
+
+.memory-chart-bg {
+ fill: var(--bg1);
+}
+
+.memory-chart-grid {
+ stroke: var(--border-soft);
+ stroke-width: 1;
+}
+
+.memory-chart-grid-vert {
+ stroke-opacity: 0.45;
+}
+
+.memory-chart-line {
+ fill: none;
+ stroke: var(--accent);
+ stroke-width: 2;
+ stroke-linejoin: round;
+ stroke-linecap: round;
+}
+
+.memory-histogram {
+ height: 260px;
+}
+
+.memory-histogram-bar {
+ fill: var(--accent);
+ fill-opacity: 0.78;
+}
+
+.memory-histogram-bar:hover {
+ fill-opacity: 1;
+}
+
+.memory-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.memory-callstack-panel {
+ grid-column: 1 / -1;
+}
+
+.memory-table-wrap {
+ overflow: auto;
+ max-height: 360px;
+}
+
+.memory-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.memory-table th,
+.memory-table td {
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--border-soft);
+ text-align: left;
+ vertical-align: top;
+}
+
+.memory-table th {
+ position: sticky;
+ top: 0;
+ background: var(--bg1);
+ z-index: 1;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: var(--fg2);
+}
+
+.memory-table .num {
+ text-align: right;
+ white-space: nowrap;
+}
+
+.memory-table tbody tr {
+ cursor: pointer;
+}
+
+.memory-table tbody tr:hover {
+ background: var(--bg2);
+}
+
+.memory-table tbody tr.selected {
+ background: var(--accent-soft);
+}
+
+.memory-group-row td {
+ background: var(--bg2);
+ color: var(--fg2);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ font-weight: 600;
+}
+
+.memory-summary-top-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.memory-summary-top {
+ color: var(--fg0);
+ font-weight: 500;
+ word-break: break-word;
+ min-width: 0;
+ flex: 1;
+}
+
+.memory-summary-secondary {
+ margin-top: 3px;
+ color: var(--fg2);
+ font-size: 12px;
+ word-break: break-word;
+}
+
+.memory-summary-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ justify-content: flex-end;
+ flex-shrink: 0;
+}
+
+.memory-badge {
+ display: inline-block;
+ padding: 1px 6px;
+ border-radius: 999px;
+ background: var(--accent-soft);
+ color: var(--fg2);
+ font-size: 11px;
+}
+
+.memory-mark {
+ background: color-mix(in srgb, var(--accent) 28%, transparent);
+ color: inherit;
+ padding: 0 1px;
+ border-radius: 2px;
+}
+
+.memory-callstack-body {
+ padding: 12px 14px;
+ max-height: 320px;
+ overflow: auto;
+}
+
+.memory-callstack-list {
+ margin: 0;
+ padding-left: 22px;
+}
+
+.memory-callstack-list li {
+ margin: 0 0 8px;
+ word-break: break-word;
+}
+
+.memory-frame-index {
+ color: var(--fg2);
+ margin-right: 8px;
+}
+
+.memory-frame-display {
+ font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace;
+ color: var(--fg0);
+}
+
+.memory-frame-path {
+ margin-left: 8px;
+ font-size: 12px;
+}
+
+@media (max-width: 1200px) {
+ .memory-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/zen/frontend/html/trace.js b/src/zen/frontend/html/trace.js
new file mode 100644
index 000000000..2910da15d
--- /dev/null
+++ b/src/zen/frontend/html/trace.js
@@ -0,0 +1,577 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+// Entry point: boots the viewer, owns the model, wires tabs / sidebar /
+// search / threads list / session panel.
+
+import { getSession, getThreads, getChannels, getScopeStats, getScopeNames, getLogCategories, getBookmarks, getRegions, getCsvCategories, getCsvStats } from "./api.js";
+import { Timeline } from "./timeline.js";
+import { StatsView } from "./stats.js";
+import { MemoryView } from "./memory.js";
+import { LogsView } from "./logs.js";
+import { CsvStatsView } from "./csvstats.js";
+
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, (c) => ({
+ "&": "&amp;",
+ "<": "&lt;",
+ ">": "&gt;",
+ "\"": "&quot;",
+ "'": "&#39;",
+ }[c]));
+}
+
+function formatTimeMs(us) {
+ if (us < 1000) return `${us} µs`;
+ if (us < 1_000_000) return `${(us / 1000).toFixed(2)} ms`;
+ return `${(us / 1_000_000).toFixed(2)} s`;
+}
+
+function formatNum(n) {
+ return Number(n).toLocaleString();
+}
+
+function stripNul(s) {
+ return (s || "").replace(/\u0000/g, "");
+}
+
+function getThemePreference() {
+ const params = new URLSearchParams(window.location.search);
+ const theme = params.get("theme");
+ if (theme === "dark" || theme === "light" || theme === "system") {
+ return theme;
+ }
+ const stored = window.localStorage.getItem("zen-trace-theme");
+ if (stored === "dark" || stored === "light" || stored === "system") {
+ return stored;
+ }
+ return "system";
+}
+
+function applyTheme(theme) {
+ document.documentElement.setAttribute("data-theme", theme);
+ window.localStorage.setItem("zen-trace-theme", theme);
+ const url = new URL(window.location.href);
+ if (theme === "system") {
+ url.searchParams.delete("theme");
+ } else {
+ url.searchParams.set("theme", theme);
+ }
+ window.history.replaceState({}, "", url);
+ const btn = document.getElementById("theme-toggle");
+ if (btn) {
+ btn.textContent = theme === "dark" ? "Dark" : theme === "light" ? "Light" : "System";
+ btn.title = `Theme: ${theme}. Click to cycle.`;
+ }
+}
+
+function setupThemeToggle() {
+ const btn = document.getElementById("theme-toggle");
+ if (!btn) {
+ return;
+ }
+ const themes = ["system", "dark", "light"];
+ let theme = getThemePreference();
+ applyTheme(theme);
+ btn.addEventListener("click", () => {
+ const index = themes.indexOf(theme);
+ theme = themes[(index + 1) % themes.length];
+ applyTheme(theme);
+ });
+}
+
+async function main() {
+ setupThemeToggle();
+ const loadingEl = document.getElementById("loading");
+ try {
+ const [session, threads, channels, scopeStats, scopeNames, logCategories, bookmarks, regionsResponse, csvCategories, csvStats] = await Promise.all([
+ getSession(),
+ getThreads(),
+ getChannels(),
+ getScopeStats(),
+ getScopeNames(),
+ getLogCategories(),
+ getBookmarks(),
+ getRegions(),
+ getCsvCategories(),
+ getCsvStats(),
+ ]);
+
+ // Normalize strings (tourist sometimes leaves trailing NULs in FieldStr).
+ for (const t of threads) t.name = stripNul(t.name);
+ session.app_name = stripNul(session.app_name);
+ session.project_name = stripNul(session.project_name);
+ session.branch = stripNul(session.branch);
+ session.build_version = stripNul(session.build_version);
+ session.platform = stripNul(session.platform);
+ session.command_line = stripNul(session.command_line);
+ for (const s of scopeStats) s.name = stripNul(s.name);
+ for (let i = 0; i < scopeNames.length; i++) scopeNames[i] = stripNul(scopeNames[i]);
+ for (const c of logCategories) c.name = stripNul(c.name);
+ for (const b of bookmarks) {
+ b.text = stripNul(b.text);
+ b.file = stripNul(b.file);
+ }
+ const regionCategories = regionsResponse && regionsResponse.categories ? regionsResponse.categories : [];
+ for (const cat of regionCategories) {
+ cat.name = stripNul(cat.name);
+ for (const r of cat.regions) {
+ r.name = stripNul(r.name);
+ }
+ }
+ for (const cat of csvCategories) cat.name = stripNul(cat.name);
+ for (const s of csvStats) s.name = stripNul(s.name);
+
+ // Precompute name → id for highlight lookups.
+ const scopeNameIds = new Map();
+ for (let i = 0; i < scopeNames.length; i++) {
+ scopeNameIds.set(scopeNames[i], i);
+ }
+
+ const model = { session, threads, channels, scopeStats, scopeNames, scopeNameIds, logCategories, bookmarks, regionCategories, csvCategories, csvStats };
+
+ renderHeader(model);
+ renderSessionView(model);
+
+ const timeline = new Timeline({
+ canvas: document.getElementById("timeline-canvas"),
+ tooltip: document.getElementById("tooltip"),
+ selectionEl: document.getElementById("selection-panel"),
+ viewportInfoEl: document.getElementById("viewport-info"),
+ zoomResetBtn: document.getElementById("zoom-reset"),
+ model,
+ onScopeSelect: (name) => {
+ timeline.setHighlightName(name);
+ stats.selectByName(name);
+ },
+ });
+
+ const stats = new StatsView(
+ document.getElementById("stats-tbody"),
+ document.querySelector(".stats-table thead tr"),
+ model,
+ (name) => {
+ timeline.setHighlightName(name);
+ timeline.jumpToScopeName(name);
+ },
+ );
+
+ const memoryView = new MemoryView(model, document.getElementById("memory-content"));
+ const logsView = new LogsView(model, document.getElementById("logs-content"));
+ const csvView = new CsvStatsView(model, document.getElementById("csv-content"));
+
+ const threadsListApi = renderThreadsList(model, timeline);
+ renderRegionCategories(model, timeline);
+ setupTabs(memoryView, logsView, csvView);
+ setupSearch(model, timeline, stats);
+
+ const bookmarksToggle = document.getElementById("bookmarks-toggle");
+ bookmarksToggle.addEventListener("change", () => {
+ timeline.setBookmarksVisible(bookmarksToggle.checked);
+ });
+
+ const lodToggle = document.getElementById("lod-toggle");
+ lodToggle.addEventListener("change", () => {
+ timeline.setLodEnabled(lodToggle.checked);
+ });
+
+ // Enable all threads that actually have captured scopes by default; if
+ // none do, enable every thread so the swimlanes still show up empty.
+ const withScopes = model.threads.filter((t) => t.scope_count > 0).map((t) => t.thread_id);
+ const initialEnabled = withScopes.length > 0 ? withScopes : model.threads.map((t) => t.thread_id);
+ for (const id of initialEnabled) {
+ const cb = document.querySelector(`.thread-row input[data-tid="${id}"]`);
+ if (cb) cb.checked = true;
+ }
+ threadsListApi.syncAllGroupCheckboxes();
+ timeline.setEnabledThreads(initialEnabled);
+
+ // "deselect all / select all" toggle for the Threads panel
+ const toggleAllBtn = document.getElementById("threads-toggle-all");
+ const threadsList = document.getElementById("threads-list");
+ toggleAllBtn.addEventListener("click", () => {
+ const allBoxes = threadsList.querySelectorAll(".thread-row input[type=checkbox]");
+ const anyChecked = Array.from(allBoxes).some((cb) => cb.checked);
+ const newState = !anyChecked;
+ for (const cb of allBoxes) {
+ cb.checked = newState;
+ }
+ // Sync group checkboxes
+ for (const gcb of threadsList.querySelectorAll(".group-checkbox")) {
+ gcb.checked = newState;
+ gcb.indeterminate = false;
+ }
+ toggleAllBtn.textContent = newState ? "deselect all" : "select all";
+ const enabled = [];
+ if (newState) {
+ for (const cb of allBoxes) {
+ enabled.push(Number(cb.dataset.tid));
+ }
+ }
+ timeline.setEnabledThreads(enabled);
+ });
+
+ loadingEl.classList.add("hidden");
+ } catch (e) {
+ loadingEl.textContent = `Failed to load trace: ${e.message}`;
+ console.error(e);
+ }
+}
+
+function renderHeader(model) {
+ const { session } = model;
+ const hdrFile = document.getElementById("hdr-file");
+ hdrFile.textContent = session.file_path || "";
+ hdrFile.title = session.file_path || "";
+
+ const stats = document.getElementById("hdr-stats");
+ stats.innerHTML =
+ `<span><span class="k">events:</span><span class="v"></span></span>` +
+ `<span><span class="k">threads:</span><span class="v"></span></span>` +
+ `<span><span class="k">duration:</span><span class="v"></span></span>` +
+ `<span><span class="k">parse:</span><span class="v"></span></span>`;
+ const vs = stats.querySelectorAll(".v");
+ vs[0].textContent = formatNum(session.total_events || 0);
+ vs[1].textContent = formatNum(model.threads.length);
+ vs[2].textContent = formatTimeMs((session.trace_end_us || 0) - (session.trace_start_us || 0));
+ vs[3].textContent = `${session.parse_time_ms} ms`;
+}
+
+function renderSessionView(model) {
+ const { session, threads, channels } = model;
+ const el = document.getElementById("session-content");
+
+ const rows = [];
+ rows.push("<h2>Session</h2>");
+ rows.push("<dl>");
+ const row = (k, v) => v && rows.push(`<dt>${k}</dt><dd>${escapeHtml(v)}</dd>`);
+ row("File", session.file_path);
+ row("Size", `${formatNum(session.file_size)} bytes`);
+ row("Events", formatNum(session.total_events));
+ row("Parse time", `${session.parse_time_ms} ms`);
+ row("Platform", session.platform);
+ row("App", session.app_name);
+ row("Project", session.project_name);
+ row("Branch", session.branch);
+ row("Build", session.build_version);
+ if (session.changelist) row("Changelist", String(session.changelist));
+ row("Command line", session.command_line);
+ rows.push("</dl>");
+
+ rows.push("<h2>Threads</h2>");
+ rows.push("<table><thead><tr>");
+ rows.push(`<th>Name</th><th class="num">TID</th><th class="num">System ID</th><th class="num">Scopes</th>`);
+ rows.push("</tr></thead><tbody>");
+ for (const t of threads) {
+ rows.push(
+ `<tr><td>${escapeHtml(t.name || "")}</td>` +
+ `<td class="num">${t.thread_id}</td>` +
+ `<td class="num">${t.system_id}</td>` +
+ `<td class="num">${formatNum(t.scope_count || 0)}</td></tr>`,
+ );
+ }
+ rows.push("</tbody></table>");
+
+ if (channels && channels.length) {
+ rows.push("<h2>Trace channels</h2>");
+ rows.push("<table><thead><tr><th>Name</th><th>State</th></tr></thead><tbody>");
+ for (const c of channels) {
+ const cls = c.enabled ? "chan-enabled" : "chan-disabled";
+ const ro = c.readonly ? `<span class="chan-readonly">read-only</span>` : "";
+ rows.push(`<tr><td>${escapeHtml(c.name || "")}</td><td class="${cls}">${c.enabled ? "enabled" : "disabled"}${ro}</td></tr>`);
+ }
+ rows.push("</tbody></table>");
+ }
+
+ el.innerHTML = rows.join("");
+}
+
+function renderThreadsList(model, timeline) {
+ const list = document.getElementById("threads-list");
+ const cmp = (a, b) => {
+ // SortHint first (UE sets low values for important threads like GameThread)
+ if (a.sort_hint !== b.sort_hint) return a.sort_hint - b.sort_hint;
+ // Then threads with scopes before empty ones
+ if ((a.scope_count > 0) !== (b.scope_count > 0)) return b.scope_count - a.scope_count;
+ // Then by thread ID (lower = created earlier, main thread is typically first)
+ if (a.thread_id !== b.thread_id) return a.thread_id - b.thread_id;
+ return (a.name || "").localeCompare(b.name || "", undefined, { numeric: true });
+ };
+
+ // Build groups: ungrouped threads, named groups, and lanes
+ const lanes = model.threads.filter(t => t.is_lane).sort(cmp);
+ const grouped = new Map(); // groupName → [threads]
+ const ungrouped = [];
+ for (const t of model.threads) {
+ if (t.is_lane) continue;
+ const g = t.group || "";
+ if (g) {
+ if (!grouped.has(g)) grouped.set(g, []);
+ grouped.get(g).push(t);
+ } else {
+ ungrouped.push(t);
+ }
+ }
+ ungrouped.sort(cmp);
+ for (const [, threads] of grouped) threads.sort(cmp);
+
+ // Sort group names naturally
+ const groupNames = Array.from(grouped.keys()).sort((a, b) =>
+ a.localeCompare(b, undefined, { numeric: true }));
+
+ const collapsed = new Set();
+ const parts = [];
+
+ function renderGroup(label, threads, collapsible) {
+ if (threads.length === 0) return;
+ const collapseAttr = collapsible ? ` data-group="${label}"` : "";
+ const chevron = collapsible ? `<span class="group-chevron">&#x25BE;</span>` : "";
+ const groupCb = collapsible ? `<input type="checkbox" class="group-checkbox" data-group-toggle="${label}">` : "";
+ parts.push(`<div class="thread-group-header"${collapseAttr}>${groupCb}${chevron}${label} (${threads.length})</div>`);
+ for (const t of threads) {
+ const emptyCls = t.scope_count === 0 ? " empty" : "";
+ const laneCls = t.is_lane ? " lane" : "";
+ const groupAttr = collapsible ? ` data-group-member="${label}"` : "";
+ parts.push(
+ `<label class="thread-row${emptyCls}${laneCls}"${groupAttr}>` +
+ `<input type="checkbox" data-tid="${t.thread_id}">` +
+ `<span class="thread-name"></span>` +
+ `<span class="thread-count">${formatNum(t.scope_count || 0)}</span>` +
+ `</label>`,
+ );
+ }
+ }
+
+ // Ungrouped threads first, then named groups, then lanes
+ if (ungrouped.length > 0 && (grouped.size > 0 || lanes.length > 0)) {
+ renderGroup("Threads", ungrouped, false);
+ } else {
+ // No groups at all — render without a header
+ for (const t of ungrouped) {
+ const emptyCls = t.scope_count === 0 ? " empty" : "";
+ parts.push(
+ `<label class="thread-row${emptyCls}">` +
+ `<input type="checkbox" data-tid="${t.thread_id}">` +
+ `<span class="thread-name"></span>` +
+ `<span class="thread-count">${formatNum(t.scope_count || 0)}</span>` +
+ `</label>`,
+ );
+ }
+ }
+ for (const name of groupNames) renderGroup(name, grouped.get(name), true);
+ renderGroup("Lanes", lanes, lanes.length > 0);
+
+ list.innerHTML = parts.join("");
+
+ function syncTimeline() {
+ const enabled = new Set();
+ for (const box of list.querySelectorAll(".thread-row input[type=checkbox]")) {
+ if (box.checked) enabled.add(Number(box.dataset.tid));
+ }
+ timeline.setEnabledThreads(Array.from(enabled));
+ }
+
+ function syncGroupCheckbox(groupName) {
+ const members = list.querySelectorAll(`[data-group-member="${groupName}"] input[type=checkbox]`);
+ const gcb = list.querySelector(`.group-checkbox[data-group-toggle="${groupName}"]`);
+ if (!gcb || members.length === 0) return;
+ const checkedCount = Array.from(members).filter((c) => c.checked).length;
+ gcb.checked = checkedCount === members.length;
+ gcb.indeterminate = checkedCount > 0 && checkedCount < members.length;
+ }
+
+ function syncAllGroupCheckboxes() {
+ for (const gcb of list.querySelectorAll(".group-checkbox")) {
+ syncGroupCheckbox(gcb.dataset.groupToggle);
+ }
+ }
+
+ // Wire up collapsible group headers (click on the label area, not checkbox)
+ for (const hdr of list.querySelectorAll(".thread-group-header[data-group]")) {
+ hdr.addEventListener("click", (e) => {
+ // Don't collapse when clicking the group checkbox
+ if (e.target.classList.contains("group-checkbox")) return;
+ const group = hdr.dataset.group;
+ const isCollapsed = collapsed.has(group);
+ if (isCollapsed) {
+ collapsed.delete(group);
+ hdr.classList.remove("collapsed");
+ } else {
+ collapsed.add(group);
+ hdr.classList.add("collapsed");
+ }
+ for (const row of list.querySelectorAll(`[data-group-member="${group}"]`)) {
+ row.style.display = isCollapsed ? "" : "none";
+ }
+ });
+ }
+
+ // Wire up group checkboxes — toggle all children
+ for (const gcb of list.querySelectorAll(".group-checkbox")) {
+ gcb.addEventListener("change", () => {
+ const group = gcb.dataset.groupToggle;
+ const checked = gcb.checked;
+ for (const row of list.querySelectorAll(`[data-group-member="${group}"]`)) {
+ const cb = row.querySelector("input[type=checkbox]");
+ if (cb) cb.checked = checked;
+ }
+ syncTimeline();
+ });
+ }
+
+ // Wire up thread checkboxes
+ for (const row of list.querySelectorAll(".thread-row")) {
+ const cb = row.querySelector("input");
+ const nameEl = row.querySelector(".thread-name");
+ const tid = Number(cb.dataset.tid);
+ const thread = model.threads.find((t) => t.thread_id === tid);
+ nameEl.textContent = (thread && thread.name) || `tid ${tid}`;
+ cb.addEventListener("change", () => {
+ const groupMember = row.dataset.groupMember;
+ if (groupMember) syncGroupCheckbox(groupMember);
+ syncTimeline();
+ });
+ }
+
+ return { syncAllGroupCheckboxes };
+}
+
+function renderRegionCategories(model, timeline) {
+ const categories = model.regionCategories || [];
+ if (categories.length === 0) return;
+
+ const panel = document.getElementById("regions-panel");
+ panel.hidden = false;
+
+ const list = document.getElementById("regions-list");
+ const parts = [];
+ for (let i = 0; i < categories.length; i++) {
+ const cat = categories[i];
+ const count = cat.regions ? cat.regions.length : 0;
+ parts.push(
+ `<label class="thread-row">` +
+ `<input type="checkbox" data-cat-idx="${i}" checked>` +
+ `<span class="thread-name"></span>` +
+ `<span class="thread-count">${formatNum(count)}</span>` +
+ `</label>`,
+ );
+ }
+ list.innerHTML = parts.join("");
+
+ // Set label text via DOM to avoid XSS
+ for (const row of list.querySelectorAll(".thread-row")) {
+ const cb = row.querySelector("input");
+ const nameEl = row.querySelector(".thread-name");
+ const idx = Number(cb.dataset.catIdx);
+ nameEl.textContent = categories[idx].name || "Uncategorized";
+ }
+
+ function syncTimeline() {
+ const enabled = new Set();
+ for (const cb of list.querySelectorAll("input[type=checkbox]")) {
+ if (cb.checked) enabled.add(Number(cb.dataset.catIdx));
+ }
+ timeline.setEnabledRegionCategories(enabled);
+ }
+
+ for (const cb of list.querySelectorAll("input[type=checkbox]")) {
+ cb.addEventListener("change", syncTimeline);
+ }
+
+ // "deselect all / select all" toggle
+ const toggleBtn = document.getElementById("regions-toggle-all");
+ toggleBtn.addEventListener("click", () => {
+ const allBoxes = list.querySelectorAll("input[type=checkbox]");
+ const anyChecked = Array.from(allBoxes).some((cb) => cb.checked);
+ const newState = !anyChecked;
+ for (const cb of allBoxes) cb.checked = newState;
+ toggleBtn.textContent = newState ? "deselect all" : "select all";
+ syncTimeline();
+ });
+
+ // Initial state: all enabled
+ const allIndices = new Set(categories.map((_, i) => i));
+ timeline.setEnabledRegionCategories(allIndices);
+}
+
+function setupTabs(memoryView, logsView, csvView) {
+ const tabs = document.querySelectorAll(".tab");
+ const views = document.querySelectorAll(".view");
+ const validTabs = new Set(Array.from(tabs, (tab) => tab.dataset.tab));
+
+ function activateTab(key, updateUrl = true) {
+ for (const tab of tabs) {
+ tab.classList.toggle("active", tab.dataset.tab === key);
+ }
+ for (const view of views) {
+ view.hidden = view.dataset.view !== key;
+ }
+ if (updateUrl) {
+ const url = new URL(window.location.href);
+ url.searchParams.set("tab", key);
+ window.history.replaceState({}, "", url);
+ }
+ if (key === "memory" && memoryView) {
+ memoryView.ensureLoaded();
+ }
+ if (key === "logs" && logsView) {
+ logsView.ensureLoaded();
+ }
+ if (key === "csv" && csvView) {
+ csvView.ensureLoaded();
+ }
+ }
+
+ for (const tab of tabs) {
+ tab.addEventListener("click", () => activateTab(tab.dataset.tab));
+ }
+
+ const initialTab = new URLSearchParams(window.location.search).get("tab");
+ if (initialTab && validTabs.has(initialTab)) {
+ activateTab(initialTab, false);
+ }
+}
+
+function setupSearch(model, timeline, stats) {
+ const input = document.getElementById("search-input");
+ const results = document.getElementById("search-results");
+
+ function render() {
+ const q = input.value.trim().toLowerCase();
+ if (!q) {
+ results.innerHTML = "";
+ timeline.setHighlightName(null);
+ return;
+ }
+ const matches = [];
+ for (const s of model.scopeStats) {
+ if (s.name.toLowerCase().includes(q)) {
+ matches.push(s);
+ if (matches.length >= 50) break;
+ }
+ }
+ const parts = [];
+ for (const m of matches) {
+ parts.push(
+ `<div class="hit" data-name="${escapeHtml(m.name)}">` +
+ `<span class="hit-name">${escapeHtml(m.name)}</span>` +
+ `<span class="hit-count">${formatNum(m.count)}</span>` +
+ `</div>`,
+ );
+ }
+ results.innerHTML = parts.join("");
+ for (const hit of results.querySelectorAll(".hit")) {
+ hit.addEventListener("click", () => {
+ const name = hit.dataset.name;
+ timeline.setHighlightName(name);
+ timeline.jumpToScopeName(name);
+ stats.selectByName(name);
+ });
+ }
+ if (matches.length > 0) {
+ timeline.setHighlightName(matches[0].name);
+ }
+ }
+
+ input.addEventListener("input", render);
+}
+
+main();