diff options
Diffstat (limited to 'src/zen/frontend')
| -rw-r--r-- | src/zen/frontend/html/api.js | 137 | ||||
| -rw-r--r-- | src/zen/frontend/html/csvstats.js | 383 | ||||
| -rw-r--r-- | src/zen/frontend/html/index.html | 95 | ||||
| -rw-r--r-- | src/zen/frontend/html/logs.js | 237 | ||||
| -rw-r--r-- | src/zen/frontend/html/memory.js | 790 | ||||
| -rw-r--r-- | src/zen/frontend/html/stats.js | 95 | ||||
| -rw-r--r-- | src/zen/frontend/html/timeline.js | 973 | ||||
| -rw-r--r-- | src/zen/frontend/html/trace.css | 1312 | ||||
| -rw-r--r-- | src/zen/frontend/html/trace.js | 577 |
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) => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[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) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + }[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) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[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) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + }[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) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + }[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">▾</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(); |