diff options
Diffstat (limited to 'src/zen/frontend/html')
| -rw-r--r-- | src/zen/frontend/html/api.js | 8 | ||||
| -rw-r--r-- | src/zen/frontend/html/counters.js | 404 | ||||
| -rw-r--r-- | src/zen/frontend/html/csvstats.js | 5 | ||||
| -rw-r--r-- | src/zen/frontend/html/index.html | 24 | ||||
| -rw-r--r-- | src/zen/frontend/html/logs.js | 11 | ||||
| -rw-r--r-- | src/zen/frontend/html/memory.js | 73 | ||||
| -rw-r--r-- | src/zen/frontend/html/timeline.js | 170 | ||||
| -rw-r--r-- | src/zen/frontend/html/trace.css | 73 | ||||
| -rw-r--r-- | src/zen/frontend/html/trace.js | 40 | ||||
| -rw-r--r-- | src/zen/frontend/html/util.js | 16 |
10 files changed, 704 insertions, 120 deletions
diff --git a/src/zen/frontend/html/api.js b/src/zen/frontend/html/api.js index fbe5304ca..86831220e 100644 --- a/src/zen/frontend/html/api.js +++ b/src/zen/frontend/html/api.js @@ -107,6 +107,14 @@ export function getCsvMetadata() { return getJson("csv-metadata"); } +export function getCounters() { + return getJson("counters"); +} + +export function getCounterSeries(id) { + return getJson(`counter-series?id=${encodeURIComponent(id)}`); +} + export function getAllocSummary() { return getJson("alloc-summary"); } diff --git a/src/zen/frontend/html/counters.js b/src/zen/frontend/html/counters.js new file mode 100644 index 000000000..fe3e4d338 --- /dev/null +++ b/src/zen/frontend/html/counters.js @@ -0,0 +1,404 @@ +// Copyright Epic Games, Inc. All Rights Reserved. +// Counters viewer — list registered TRACE_INT_VALUE / TRACE_FLOAT_VALUE +// counters and chart selected ones. +// +// Mirrors csvstats.js layout (tree on the left, line chart on the right), +// adapted to the simpler counter-id keyed series model. + +import { getCounters, getCounterSeries } from "./api.js"; +import { escapeHtml } from "./util.js"; + +function formatTime(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`; +} + +const BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB"]; +function formatBytes(value) { + let v = value; + let i = 0; + while (Math.abs(v) >= 1024 && i + 1 < BYTE_UNITS.length) { + v /= 1024; + ++i; + } + return `${v.toFixed(i === 0 ? 0 : 2)} ${BYTE_UNITS[i]}`; +} + +function formatCounterValue(value, def) { + if (def && def.display_hint === 1) { + return formatBytes(value); + } + if (def && def.type === 0) { + // Integer — render without fractional digits. + return Number(value).toLocaleString(); + } + return value.toFixed(3); +} + +const LINE_COLORS = [ + "#4fc3f7", "#81c784", "#ffb74d", "#e57373", "#ba68c8", + "#4db6ac", "#fff176", "#f06292", "#7986cb", "#a1887f", +]; + +export class CountersView { + constructor(model, containerEl) { + this.model = model; + this.container = containerEl; + this.loaded = false; + this.defs = []; + this.defById = new Map(); + + this.selected = new Set(); + this.seriesData = new Map(); + this.colorIndex = 0; + this.colorById = new Map(); + + this.viewStartUs = 0; + this.viewEndUs = (model.session && model.session.trace_end_us) || 1; + + this.buildLayout(); + } + + buildLayout() { + this.container.innerHTML = + `<div class="csv-layout">` + + `<div class="csv-tree-panel">` + + `<div class="sidebar-label">Counters</div>` + + `<div class="csv-tree counters-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(".counters-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.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; }); + + 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 }); + } + + async ensureLoaded() { + if (this.loaded) { + this.drawChart(); + return; + } + try { + this.defs = await getCounters(); + for (const d of this.defs) { + this.defById.set(d.id, d); + } + this.renderTree(); + } catch (e) { + this.treeEl.innerHTML = `<div class="csv-empty">Failed to load counters: ${escapeHtml(e.message)}</div>`; + console.error(e); + } + this.loaded = true; + this.drawChart(); + } + + renderTree() { + // Group by leading path component (everything before the first "/"). + const groups = new Map(); + for (const d of this.defs) { + if (!d.sample_count) continue; + const slash = d.name.indexOf("/"); + const group = slash > 0 ? d.name.substring(0, slash) : ""; + if (!groups.has(group)) groups.set(group, []); + groups.get(group).push(d); + } + + if (groups.size === 0) { + this.treeEl.innerHTML = `<div class="csv-empty">No counters in this trace.</div>`; + return; + } + + const groupNames = Array.from(groups.keys()).sort((a, b) => a.localeCompare(b)); + const parts = []; + for (const g of groupNames) { + const list = groups.get(g); + parts.push(`<div class="csv-cat-header">${escapeHtml(g || "(ungrouped)")}</div>`); + for (const d of list) { + parts.push( + `<label class="csv-stat-row">` + + `<input type="checkbox" data-counter-id="${d.id}">` + + `<span class="csv-stat-name"></span>` + + `<span class="thread-count">${Number(d.sample_count).toLocaleString()}</span>` + + `</label>` + ); + } + } + this.treeEl.innerHTML = parts.join(""); + + // Set names via DOM (XSS safe). + const rows = this.treeEl.querySelectorAll(".csv-stat-row"); + let idx = 0; + for (const g of groupNames) { + for (const d of groups.get(g)) { + if (idx < rows.length) { + const slash = d.name.indexOf("/"); + const display = slash > 0 ? d.name.substring(slash + 1) : d.name; + rows[idx].querySelector(".csv-stat-name").textContent = display; + rows[idx].title = d.name; + } + ++idx; + } + } + + // Wire checkboxes. + for (const cb of this.treeEl.querySelectorAll("input[type=checkbox]")) { + cb.addEventListener("change", () => { + const id = Number(cb.dataset.counterId); + if (cb.checked) { + this.selected.add(id); + if (!this.colorById.has(id)) { + this.colorById.set(id, LINE_COLORS[this.colorIndex++ % LINE_COLORS.length]); + } + this.fetchSeries(id); + } else { + this.selected.delete(id); + this.drawChart(); + } + }); + } + } + + async fetchSeries(id) { + if (this.seriesData.has(id)) { + this.drawChart(); + return; + } + try { + const result = await getCounterSeries(id); + const samples = (result.samples || []).map(([t, v]) => ({ timeUs: t, value: v })); + this.seriesData.set(id, samples); + this.drawChart(); + } catch (e) { + console.error(`Failed to fetch counter series for ${id}: ${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.selected.size === 0) { + ctx.fillStyle = fg2; + ctx.font = "12px -apple-system, Segoe UI, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("Select counters from the tree to chart them", W / 2, H / 2); + return; + } + + const PAD_L = 70, 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); + + let minVal = Infinity, maxVal = -Infinity; + for (const id of this.selected) { + const samples = this.seriesData.get(id); + 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; + + 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(); + } + + // Format Y-axis labels using the display hint of the first selected counter. + const firstSelected = this.selected.values().next().value; + const firstDef = this.defById.get(firstSelected); + + 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(formatCounterValue(v, firstDef), PAD_L - 4, y); + } + + 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 step-style lines (counters change at discrete events). + for (const id of this.selected) { + const samples = this.seriesData.get(id); + if (!samples || samples.length === 0) continue; + const color = this.colorById.get(id) || "#fff"; + + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.beginPath(); + let started = false; + let prevY = 0; + 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, prevY); ctx.lineTo(x, y); } + prevY = y; + } + ctx.stroke(); + } + + ctx.strokeStyle = border; + ctx.lineWidth = 1; + ctx.strokeRect(PAD_L, PAD_T, chartW, chartH); + + ctx.font = "10px -apple-system, Segoe UI, sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + let legendX = PAD_L + 6; + for (const id of this.selected) { + const def = this.defById.get(id); + const name = def ? def.name : `counter ${id}`; + const color = this.colorById.get(id) || "#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; + } + + this._chartLayout = { PAD_L, PAD_R, PAD_T, PAD_B, chartW, chartH, startUs, endUs, rangeUs, minVal, maxVal, xAt, yAt, firstDef }; + } + + onChartHover(e) { + if (!this._chartLayout || this.selected.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 id of this.selected) { + const samples = this.seriesData.get(id); + if (!samples || samples.length === 0) continue; + 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.defById.get(id); + const name = def ? def.name : `counter ${id}`; + const color = this.colorById.get(id) || "#fff"; + lines.push(`<span style="color:${color}">${escapeHtml(name)}</span>: ${formatCounterValue(best.value, def)}`); + } + } + 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/csvstats.js b/src/zen/frontend/html/csvstats.js index a50b2f068..fc006acdc 100644 --- a/src/zen/frontend/html/csvstats.js +++ b/src/zen/frontend/html/csvstats.js @@ -2,10 +2,7 @@ // 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])); -} +import { escapeHtml } from "./util.js"; function formatTime(us) { if (us < 1000) return `${us} \u00b5s`; diff --git a/src/zen/frontend/html/index.html b/src/zen/frontend/html/index.html index 5853a80dc..924abadda 100644 --- a/src/zen/frontend/html/index.html +++ b/src/zen/frontend/html/index.html @@ -9,6 +9,15 @@ <body> <noscript>This viewer requires JavaScript.</noscript> <div class="header"> + <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="counters">Counters</button> + <button class="tab" data-tab="session">Session</button> + </nav> <div class="header-title">zen trace viewer</div> <div class="header-file" id="hdr-file"></div> <div class="header-stats" id="hdr-stats"></div> @@ -16,14 +25,6 @@ </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"> @@ -50,6 +51,10 @@ <input type="checkbox" id="lod-toggle" checked> <span>LOD</span> </label> + <label class="toolbar-toggle" title="Compact rows: thinner swimlanes, hides in-bar labels (shortcut: c)"> + <input type="checkbox" id="compact-toggle"> + <span>Compact</span> + </label> <button id="zoom-reset" class="btn">Reset view</button> </div> <div class="timeline-frame"> @@ -84,6 +89,9 @@ <section class="view view-csv" data-view="csv" hidden> <div id="csv-content"></div> </section> + <section class="view view-counters" data-view="counters" hidden> + <div id="counters-content"></div> + </section> <section class="view view-session" data-view="session" hidden> <div id="session-content" class="session-content"></div> </section> diff --git a/src/zen/frontend/html/logs.js b/src/zen/frontend/html/logs.js index d9646ba39..f0498dde2 100644 --- a/src/zen/frontend/html/logs.js +++ b/src/zen/frontend/html/logs.js @@ -2,6 +2,7 @@ // Log viewer: filterable list of captured Logging.LogMessage events. import { getLogs } from "./api.js"; +import { escapeHtml } from "./util.js"; // UE ELogVerbosity::Type values — lower number = more severe. const VERBOSITY_LABELS = [ @@ -16,16 +17,6 @@ const VERBOSITY_LABELS = [ "All", ]; -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ({ - "&": "&", - "<": "<", - ">": ">", - "\"": """, - "'": "'", - }[c])); -} - function verbosityLabel(v) { return VERBOSITY_LABELS[v] || `V${v}`; } diff --git a/src/zen/frontend/html/memory.js b/src/zen/frontend/html/memory.js index 6b9760439..6e4d51061 100644 --- a/src/zen/frontend/html/memory.js +++ b/src/zen/frontend/html/memory.js @@ -1,17 +1,9 @@ // Copyright Epic Games, Inc. All Rights Reserved. -// Interactive memory analysis view: summary cards, memory timeline, leak/churn/hot callsite tables. +// Interactive memory analysis view: summary cards, memory timeline, and a +// tabbed callsite panel (Leaky / Churn / Hot) sharing one slot below the chart. import { getAllocSummary, getMemoryTimeline, getCallstackStats, getChurnStats, getCallstack, getAllocSizeHistogram } from "./api.js"; - -function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }[c])); -} +import { escapeHtml } from "./util.js"; function formatNum(n) { return Number(n || 0).toLocaleString(); @@ -187,10 +179,17 @@ export class MemoryView { churn: { sortKey: "churn_allocs", desc: true, groupMode: "none", filterText: "" }, hot: { sortKey: "total_allocs", desc: true, groupMode: "none", filterText: "" }, }; + this.activeTable = "leaks"; this.loadStateFromUrl(); this.buildLayout(); } + static TAB_DEFS = [ + { name: "leaks", label: "Leaky callsites" }, + { name: "churn", label: "Churn" }, + { name: "hot", label: "Hot callsites" }, + ]; + buildLayout() { this.container.innerHTML = `<div class="memory-view">` + @@ -222,6 +221,12 @@ export class MemoryView { `</div>` + `</div>` + `<div class="memory-grid">` + + `<div class="memory-tabbed">` + + `<div class="memory-tab-bar" role="tablist">` + + MemoryView.TAB_DEFS.map(({ name, label }) => + `<button type="button" class="memory-tab" role="tab" data-mem-tab="${name}" id="memory-tab-${name}" aria-controls="memory-tabpanel-${name}">${escapeHtml(label)}</button>` + ).join("") + + `</div>` + this.buildPanelMarkup("leaks", "Leaky callsites", "Top live allocation stacks", [ ["live_bytes", "Live bytes"], ["live_count", "Live allocs"], @@ -239,6 +244,7 @@ export class MemoryView { ["churn_allocs", "Churn allocs"], ["summary", "Summary"], ]) + + `</div>` + `<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>` + @@ -339,6 +345,17 @@ export class MemoryView { this.updateFilterButton(name); } + this.tabButtons = {}; + this.tabPanels = {}; + for (const { name } of MemoryView.TAB_DEFS) { + const button = this.container.querySelector(`[data-mem-tab="${name}"]`); + const panel = this.container.querySelector(`[data-mem-tabpanel="${name}"]`); + this.tabButtons[name] = button; + this.tabPanels[name] = panel; + button.addEventListener("click", () => this.setActiveTable(name)); + } + this.setActiveTable(this.activeTable, /*save=*/ false); + this.container.addEventListener("keydown", (e) => { if (e.key !== "/" || e.defaultPrevented) { return; @@ -352,20 +369,39 @@ export class MemoryView { if (activeView && activeView.hidden) { return; } - const firstFilter = this.panelRefs.leaks.filter; - if (firstFilter) { - firstFilter.focus(); - firstFilter.select(); + const activeFilter = this.panelRefs[this.activeTable]?.filter; + if (activeFilter) { + activeFilter.focus(); + activeFilter.select(); } }); this.container.tabIndex = -1; this.container.dataset.memoryView = "true"; } + setActiveTable(name, save = true) { + if (!this.tabButtons || !this.tabButtons[name]) { + return; + } + this.activeTable = name; + for (const { name: tabName } of MemoryView.TAB_DEFS) { + const isActive = tabName === name; + const button = this.tabButtons[tabName]; + const panel = this.tabPanels[tabName]; + button.classList.toggle("active", isActive); + button.setAttribute("aria-selected", isActive ? "true" : "false"); + button.tabIndex = isActive ? 0 : -1; + panel.hidden = !isActive; + } + if (save) { + this.saveStateToUrl(); + } + } + 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 memory-tabpanel" data-mem-tabpanel="${name}" id="memory-tabpanel-${name}" role="tabpanel" aria-labelledby="memory-tab-${name}" hidden> <div class="memory-panel-header memory-panel-header-wrap"> <div> <div class="memory-panel-title">${escapeHtml(title)}</div> @@ -393,6 +429,10 @@ export class MemoryView { if (histogramMetric === "count" || histogramMetric === "bytes") { this.histogramMetric = histogramMetric; } + const activeTable = params.get("mem_table"); + if (activeTable && this.tableState[activeTable]) { + this.activeTable = activeTable; + } for (const [name, state] of Object.entries(this.tableState)) { const sortKey = params.get(`mem_${name}_sort`); const groupMode = params.get(`mem_${name}_group`); @@ -419,6 +459,7 @@ export class MemoryView { saveStateToUrl() { const url = new URL(window.location.href); url.searchParams.set("mem_hist_metric", this.histogramMetric); + url.searchParams.set("mem_table", this.activeTable); 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); diff --git a/src/zen/frontend/html/timeline.js b/src/zen/frontend/html/timeline.js index f463a8418..e0fd64181 100644 --- a/src/zen/frontend/html/timeline.js +++ b/src/zen/frontend/html/timeline.js @@ -2,16 +2,35 @@ // 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 +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 REGIONS_GAP = 6; // gap between the region rack and the first thread + +// Per-mode row metrics. Compact mode (toggled with 'c') shrinks every +// vertical dimension so the flame graph fits many more threads/depths in +// the viewport at the cost of in-bar text labels. +const METRICS_NORMAL = { + headerH: 18, // thread name row height + depthH: 16, // scope lane row height + regionLaneH: 18, // region band row height + regionHeaderH: 16, // region category header row height + scopeFontPx: 11, // in-bar scope label font size + headerFontPx: 11, // thread header / region row font size + showLabels: true, +}; + +const METRICS_COMPACT = { + headerH: 12, + depthH: 4, + regionLaneH: 6, + regionHeaderH: 12, + scopeFontPx: 0, // labels too tall to fit; suppressed + headerFontPx: 9, + showLabels: false, +}; // Scope colors: golden-angle hue rotation keyed on NameId so the same scope // always renders in the same color across zoom levels. @@ -69,6 +88,8 @@ export class Timeline { this.bookmarks = (this.model.bookmarks || []).slice().sort((a, b) => a.time_us - b.time_us); this.bookmarksVisible = true; + this.compact = false; + this.metrics = METRICS_NORMAL; this.regionCategories = (this.model.regionCategories || []).filter(c => c.lane_count > 0); // All categories enabled by default; renderRegionCategories() calls // setEnabledRegionCategories() shortly after construction. @@ -144,6 +165,17 @@ export class Timeline { this.requestDraw(); } + setCompact(compact) { + const next = !!compact; + if (next === this.compact) return; + this.compact = next; + this.metrics = next ? METRICS_COMPACT : METRICS_NORMAL; + this.recomputeRegionsBlockH(); + this.requestDraw(); + } + + toggleCompact() { this.setCompact(!this.compact); } + setEnabledRegionCategories(indices) { this.enabledRegionCategories = indices instanceof Set ? indices : new Set(indices); this.recomputeRegionsBlockH(); @@ -152,10 +184,11 @@ export class Timeline { recomputeRegionsBlockH() { this.regionsBlockH = 0; + const M = this.metrics || METRICS_NORMAL; 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; + this.regionsBlockH += M.regionHeaderH + cat.lane_count * M.regionLaneH; } if (this.regionsBlockH > 0) { this.regionsBlockH += REGIONS_GAP; @@ -406,6 +439,7 @@ export class Timeline { if (ma.thread_id !== mb.thread_id) return ma.thread_id - mb.thread_id; return (ma.name || "").localeCompare(mb.name || "", undefined, { numeric: true }); }); + const M = this.metrics; for (const threadId of sorted) { const timeline = this.timelines.get(threadId); const scopes = timeline ? timeline.scopes : []; @@ -414,8 +448,8 @@ export class Timeline { 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 }); + const rowH = M.headerH + (maxDepth + 1) * M.depthH; + rows.push({ threadId, y, headerH: M.headerH, maxDepth, height: rowH }); y += rowH + THREAD_GAP; } // y now points at the bottom of the last row in scrolled coords. @@ -467,6 +501,7 @@ export class Timeline { const fg1 = getComputedStyle(document.body).getPropertyValue("--fg1") || "#c9d1d9"; const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e"; + const M = this.metrics; for (const row of rows) { if (row.y > H) break; if (row.y + row.height < RULER_H) continue; @@ -479,14 +514,14 @@ export class Timeline { 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.font = `${M.headerFontPx}px -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); + ctx.fillRect(0, row.y + row.headerH + d * M.depthH, W, M.depthH); } const timeline = this.timelines.get(row.threadId); @@ -554,6 +589,7 @@ export class Timeline { drawRegions(ctx, W) { if (this.regionCategories.length === 0) return; + const M = this.metrics; const startUs = this.startUs; const endUs = this.endUs; const pxPerUs = this.pxPerUs(); @@ -566,13 +602,15 @@ export class Timeline { 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; + ctx.fillRect(0, catY, W, M.regionHeaderH); + if (M.showLabels) { + 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 + M.regionHeaderH / 2); + } + catY += M.regionHeaderH; // Region bands for this category for (const r of cat.regions) { @@ -585,35 +623,35 @@ export class Timeline { const w = Math.max(MIN_RECT_W, (endRegUs - beginUs) * pxPerUs); if (w < MIN_RECT_W) continue; - const y = catY + r.depth * REGION_LANE_H; + const y = catY + r.depth * M.regionLaneH; ctx.fillStyle = regionFillColor(r.name); - ctx.fillRect(x, y + 1, w, REGION_LANE_H - 2); + ctx.fillRect(x, y + 1, w, M.regionLaneH - 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); + ctx.strokeRect(x + 0.5, y + 1.5, w - 1, M.regionLaneH - 3); const visX = Math.max(x, 0); const visRight = Math.min(x + w, this.width); const visW = visRight - visX; - if (visW > 24 && r.name) { + if (M.showLabels && 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.rect(visX + 3, y, visW - 6, M.regionLaneH); ctx.clip(); - ctx.fillText(r.name, visX + 5, y + REGION_LANE_H / 2); + ctx.fillText(r.name, visX + 5, y + M.regionLaneH / 2); ctx.restore(); } - this.hits.push({ x, y, w, h: REGION_LANE_H - 2, region: r, regionCategory: cat.name }); + this.hits.push({ x, y, w, h: M.regionLaneH - 2, region: r, regionCategory: cat.name }); } - catY += cat.lane_count * REGION_LANE_H; + catY += cat.lane_count * M.regionLaneH; } } @@ -660,6 +698,8 @@ export class Timeline { drawScopes(ctx, timeline, row, pxPerUs, textColor) { const { scopes, perDepth } = timeline; + const M = this.metrics; + const depthH = M.depthH; const startUs = this.startUs; const endUs = this.endUs; @@ -669,7 +709,9 @@ export class Timeline { ctx.textBaseline = "middle"; ctx.textAlign = "left"; - ctx.font = "11px -apple-system, Segoe UI, sans-serif"; + if (M.showLabels) { + ctx.font = `${M.scopeFontPx}px -apple-system, Segoe UI, sans-serif`; + } const rowTop = row.y + row.headerH; const maxDepth = Math.min(row.maxDepth, perDepth.length - 1); @@ -695,7 +737,11 @@ export class Timeline { } } - const y = rowTop + depth * DEPTH_H; + const y = rowTop + depth * depthH; + // Compact mode shrinks the bar to the row height; normal mode + // leaves a 1px gap top and bottom for readability. + const barTop = M.showLabels ? y + 1 : y; + const barH = M.showLabels ? depthH - 2 : depthH; let rendered = 0; for (let j = lo; j < indices.length; j++) { const s = scopes[indices[j]]; @@ -718,39 +764,44 @@ export class Timeline { 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([]); + ctx.fillRect(x, barTop, w, barH); + if (M.showLabels) { + ctx.strokeStyle = "rgba(255,255,255,0.25)"; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.beginPath(); + ctx.moveTo(x, barTop + 0.5); + ctx.lineTo(x + w, barTop + 0.5); + ctx.stroke(); + ctx.setLineDash([]); + } } else { ctx.fillStyle = isHighlighted ? scopeHighlightColor(nameId) : scopeFillColor(nameId); - ctx.fillRect(x, y + 1, w, DEPTH_H - 2); + ctx.fillRect(x, barTop, w, barH); } // 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(); + // so zooming into a long scope still shows its name. Skipped + // in compact mode where the bar is too short for readable text. + if (M.showLabels) { + 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, depthH); + ctx.clip(); + ctx.fillText(shown, visX + 4, y + depthH / 2); + ctx.restore(); + } } - this.hits.push({ x, y, w, h: DEPTH_H - 2, threadId: row.threadId, tuple: s }); + this.hits.push({ x, y, w, h: depthH, threadId: row.threadId, tuple: s }); } } } @@ -764,12 +815,13 @@ export class Timeline { const { rows } = this.layoutThreads(); const row = rows.find((r) => r.threadId === this.selected.threadId); if (!row) return; + const M = this.metrics; const x = this.xAtUs(beginUs); const w = Math.max(MIN_RECT_W, durationUs * this.pxPerUs()); - const y = row.y + row.headerH + depth * DEPTH_H; + const y = row.y + row.headerH + depth * M.depthH; ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1.5; - ctx.strokeRect(x - 0.5, y + 0.5, w + 1, DEPTH_H - 1); + ctx.strokeRect(x - 0.5, y + 0.5, w + 1, M.depthH - 1); } drawViewportInfo() { diff --git a/src/zen/frontend/html/trace.css b/src/zen/frontend/html/trace.css index 2ff324019..5b9bb28c6 100644 --- a/src/zen/frontend/html/trace.css +++ b/src/zen/frontend/html/trace.css @@ -87,18 +87,27 @@ pre, code, .mono { .header { display: flex; - align-items: center; + align-items: stretch; gap: 16px; - padding: 10px 16px; + padding: 0 16px; background: var(--bg1); border-bottom: 1px solid var(--border); flex-shrink: 0; + min-height: 40px; +} + +.header > .header-title, +.header > .header-file, +.header > .header-stats, +.header > .header-btn { + align-self: center; } .header-title { font-weight: 600; color: var(--fg0); font-size: 14px; + white-space: nowrap; } .header-file { @@ -172,16 +181,19 @@ pre, code, .mono { } /* -- tabs ------------------------------------------------------------------ */ +/* Tabs live inside the header bar at the top, alongside the title / file + * path / stats. Each tab is a button that owns its own underline so the + * active state aligns flush with the header's bottom border. */ .tabs { display: flex; - border-bottom: 1px solid var(--border); flex-shrink: 0; + height: 100%; + margin-left: -8px; /* let first tab sit closer to the left edge */ } .tab { - flex: 1; - padding: 10px 8px; + padding: 0 14px; background: transparent; border: none; border-bottom: 2px solid transparent; @@ -191,6 +203,10 @@ pre, code, .mono { cursor: pointer; text-transform: uppercase; letter-spacing: 0.5px; + white-space: nowrap; + display: flex; + align-items: center; + margin-bottom: -1px; /* overlap header's bottom border so the underline sits flush */ } .tab:hover { @@ -1165,7 +1181,7 @@ pre, code, .mono { .memory-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); gap: 16px; } @@ -1173,6 +1189,46 @@ pre, code, .mono { grid-column: 1 / -1; } +.memory-tabbed { + display: flex; + flex-direction: column; + gap: 0; +} + +.memory-tab-bar { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 4px; + border-bottom: 1px solid var(--border-soft); + margin-bottom: -1px; +} + +.memory-tab { + padding: 8px 14px; + 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; + white-space: nowrap; + margin-bottom: -1px; +} + +.memory-tab:hover { + color: var(--fg0); + background: var(--bg2); +} + +.memory-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + .memory-table-wrap { overflow: auto; max-height: 360px; @@ -1305,8 +1361,3 @@ pre, code, .mono { 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 index 2910da15d..2c0b7a3bd 100644 --- a/src/zen/frontend/html/trace.js +++ b/src/zen/frontend/html/trace.js @@ -8,16 +8,8 @@ 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])); -} +import { CountersView } from "./counters.js"; +import { escapeHtml } from "./util.js"; function formatTimeMs(us) { if (us < 1000) return `${us} µs`; @@ -157,10 +149,11 @@ async function main() { 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 countersView = new CountersView(model, document.getElementById("counters-content")); const threadsListApi = renderThreadsList(model, timeline); renderRegionCategories(model, timeline); - setupTabs(memoryView, logsView, csvView); + setupTabs(memoryView, logsView, csvView, countersView); setupSearch(model, timeline, stats); const bookmarksToggle = document.getElementById("bookmarks-toggle"); @@ -173,6 +166,26 @@ async function main() { timeline.setLodEnabled(lodToggle.checked); }); + const compactToggle = document.getElementById("compact-toggle"); + compactToggle.addEventListener("change", () => { + timeline.setCompact(compactToggle.checked); + }); + + // 'c' toggles compact mode while the timeline tab is active. Skipped + // when focus is in a text input so typing 'c' in the search box still + // works normally. + document.addEventListener("keydown", (e) => { + if (e.key !== "c" && e.key !== "C") return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + const target = e.target; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; + const timelineTab = document.querySelector(".tab[data-tab='timeline']"); + if (!timelineTab || !timelineTab.classList.contains("active")) return; + e.preventDefault(); + compactToggle.checked = !compactToggle.checked; + timeline.setCompact(compactToggle.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); @@ -492,7 +505,7 @@ function renderRegionCategories(model, timeline) { timeline.setEnabledRegionCategories(allIndices); } -function setupTabs(memoryView, logsView, csvView) { +function setupTabs(memoryView, logsView, csvView, countersView) { const tabs = document.querySelectorAll(".tab"); const views = document.querySelectorAll(".view"); const validTabs = new Set(Array.from(tabs, (tab) => tab.dataset.tab)); @@ -518,6 +531,9 @@ function setupTabs(memoryView, logsView, csvView) { if (key === "csv" && csvView) { csvView.ensureLoaded(); } + if (key === "counters" && countersView) { + countersView.ensureLoaded(); + } } for (const tab of tabs) { diff --git a/src/zen/frontend/html/util.js b/src/zen/frontend/html/util.js new file mode 100644 index 000000000..34241c9ae --- /dev/null +++ b/src/zen/frontend/html/util.js @@ -0,0 +1,16 @@ +// Copyright Epic Games, Inc. All Rights Reserved. +// Tiny shared helpers used across the trace-viewer modules. + +// Escape characters that have special meaning inside an HTML context (text or +// attribute) so that user-supplied strings can be safely interpolated into +// .innerHTML / template literals. Coerces the input to string first so callers +// don't have to. +export function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + }[c])); +} |