diff options
| author | Stefan Boberg <[email protected]> | 2026-04-23 18:16:57 +0200 |
|---|---|---|
| committer | Stefan Boberg <[email protected]> | 2026-04-23 18:16:57 +0200 |
| commit | 0232b991cd7d8e3a2114ea30e4591dd3e7b65c36 (patch) | |
| tree | 94730e7594fd09ae1fa820391ce311f6daf13905 /src/zen/frontend/html/memory.js | |
| parent | Fix forward declaration order for s_GotSigWinch and SigWinchHandler (diff) | |
| parent | trace: declare Region event name fields as AnsiString (#1012) (diff) | |
| download | archived-zen-sb/zen-help.tar.xz archived-zen-sb/zen-help.zip | |
Merge branch 'main' into sb/zen-helpsb/zen-help
- Combine HelpCommand (this branch) with HistoryCommand (main) in zen CLI dispatcher
- Keep filter-aware TuiPickOne rewrite; adopt main's ASCII arrow glyphs in doc comment
Diffstat (limited to 'src/zen/frontend/html/memory.js')
| -rw-r--r-- | src/zen/frontend/html/memory.js | 790 |
1 files changed, 790 insertions, 0 deletions
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>`; + } + } +} |