aboutsummaryrefslogtreecommitdiff
path: root/src/zen/frontend/html/memory.js
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-04-23 18:16:57 +0200
committerStefan Boberg <[email protected]>2026-04-23 18:16:57 +0200
commit0232b991cd7d8e3a2114ea30e4591dd3e7b65c36 (patch)
tree94730e7594fd09ae1fa820391ce311f6daf13905 /src/zen/frontend/html/memory.js
parentFix forward declaration order for s_GotSigWinch and SigWinchHandler (diff)
parenttrace: declare Region event name fields as AnsiString (#1012) (diff)
downloadarchived-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.js790
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) => ({
+ "&": "&amp;",
+ "<": "&lt;",
+ ">": "&gt;",
+ '"': "&quot;",
+ "'": "&#39;",
+ }[c]));
+}
+
+function formatNum(n) {
+ return Number(n || 0).toLocaleString();
+}
+
+function formatBytes(bytes) {
+ const sign = bytes < 0 ? "-" : "";
+ let value = Math.abs(Number(bytes || 0));
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit++;
+ }
+ const decimals = unit === 0 ? 0 : value >= 100 ? 0 : value >= 10 ? 1 : 2;
+ return `${sign}${value.toFixed(decimals)} ${units[unit]}`;
+}
+
+function formatDistance(events) {
+ return `${Math.round(Number(events || 0)).toLocaleString()} ev`;
+}
+
+function formatTimeAxis(us) {
+ const value = Number(us || 0);
+ if (value < 1000) return `${Math.round(value)} µs`;
+ if (value < 1_000_000) return `${Math.round(value / 1000)} ms`;
+ return `${Math.round(value / 1_000_000)} s`;
+}
+
+function chooseNiceTimeStep(spanUs, targetTickCount) {
+ const rawStep = Math.max(1, spanUs / Math.max(1, targetTickCount));
+ const bases = [1, 2, 5];
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
+ for (const scale of [1, 10]) {
+ for (const base of bases) {
+ const step = base * magnitude * scale;
+ if (step >= rawStep) {
+ return step;
+ }
+ }
+ }
+ return 10 * magnitude;
+}
+
+function buildNiceTimeTicks(startUs, endUs, targetTickCount) {
+ const spanUs = Math.max(1, endUs - startUs);
+ const stepUs = chooseNiceTimeStep(spanUs, targetTickCount);
+ const firstTickUs = Math.ceil(startUs / stepUs) * stepUs;
+ const ticks = [];
+ for (let tickUs = firstTickUs; tickUs <= endUs; tickUs += stepUs) {
+ ticks.push(tickUs);
+ }
+ if (ticks.length === 0) {
+ const roundedStart = Math.floor(startUs / stepUs) * stepUs;
+ const roundedEnd = Math.ceil(endUs / stepUs) * stepUs;
+ if (roundedStart >= startUs && roundedStart <= endUs) {
+ ticks.push(roundedStart);
+ }
+ if (roundedEnd >= startUs && roundedEnd <= endUs && roundedEnd !== roundedStart) {
+ ticks.push(roundedEnd);
+ }
+ }
+ return ticks;
+}
+
+function trimPath(path) {
+ if (!path) return "";
+ const parts = String(path).split(/[\\/]/);
+ return parts[parts.length - 1] || path;
+}
+
+function compareValues(a, b, desc) {
+ if (typeof a === "string" || typeof b === "string") {
+ const result = String(a || "").localeCompare(String(b || ""), undefined, { numeric: true, sensitivity: "base" });
+ return desc ? -result : result;
+ }
+ const result = Number(a || 0) - Number(b || 0);
+ return desc ? -result : result;
+}
+
+function escapeRegExp(s) {
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function highlightMatch(text, filterText) {
+ const source = String(text || "");
+ if (!filterText) {
+ return escapeHtml(source);
+ }
+ const regex = new RegExp(`(${escapeRegExp(filterText)})`, "ig");
+ return escapeHtml(source).replace(regex, '<mark class="memory-mark">$1</mark>');
+}
+
+function buildSummaryHtml(row, filterText = "") {
+ const top = highlightMatch(row.top_frame || row.summary || `Callstack ${row.callstack_id}`, filterText);
+ const second = row.secondary_frame ? `<div class="memory-summary-secondary">${highlightMatch(row.secondary_frame, filterText)}</div>` : "";
+ const badges = [];
+ if (row.hidden_prefix_count > 0) {
+ badges.push(`<span class="memory-badge">skip ${escapeHtml(formatNum(row.hidden_prefix_count))}</span>`);
+ }
+ if (row.included_third_party_boundary) {
+ badges.push(`<span class="memory-badge">3p boundary</span>`);
+ }
+ const badgeHtml = badges.length ? `<div class="memory-summary-badges">${badges.join("")}</div>` : "";
+ return `<div class="memory-summary"><div class="memory-summary-top-row"><div class="memory-summary-top">${top}</div>${badgeHtml}</div>${second}</div>`;
+}
+
+function describeBucket(bucket) {
+ const min = Number(bucket.min_size || 0);
+ const max = Number(bucket.max_size || 0);
+ if (min === 0 && max === 0) {
+ return "0 bytes";
+ }
+ if (min === max) {
+ return `${formatBytes(min)}`;
+ }
+ return `${formatBytes(min)} – ${formatBytes(max)}`;
+}
+
+function formatBucketEdge(bucket) {
+ const max = Number(bucket.max_size || 0);
+ if (max === 0) {
+ return "0";
+ }
+ return formatBytes(max);
+}
+
+function sparklinePath(samples, width, height, valueIndex) {
+ if (!samples || samples.length === 0) {
+ return "";
+ }
+ let minValue = Infinity;
+ let maxValue = -Infinity;
+ for (const sample of samples) {
+ const value = Number(sample[valueIndex] || 0);
+ minValue = Math.min(minValue, value);
+ maxValue = Math.max(maxValue, value);
+ }
+ if (!isFinite(minValue) || !isFinite(maxValue)) {
+ return "";
+ }
+ if (minValue === maxValue) {
+ minValue -= 1;
+ maxValue += 1;
+ }
+ const count = samples.length;
+ const points = [];
+ for (let i = 0; i < count; ++i) {
+ const x = count > 1 ? (i / (count - 1)) * width : width * 0.5;
+ const norm = (Number(samples[i][valueIndex] || 0) - minValue) / (maxValue - minValue);
+ const y = height - norm * height;
+ points.push(`${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`);
+ }
+ return points.join(" ");
+}
+
+export class MemoryView {
+ constructor(model, containerEl) {
+ this.model = model;
+ this.container = containerEl;
+ this.loaded = false;
+ this.summary = null;
+ this.memoryTimeline = null;
+ this.leaks = [];
+ this.churn = [];
+ this.hot = [];
+ this.sizeHistogram = null;
+ this.histogramMetric = "count";
+ this.callstackCache = new Map();
+ this.selectedCallstackId = 0;
+ this.tableState = {
+ leaks: { sortKey: "live_bytes", desc: true, groupMode: "none", filterText: "" },
+ churn: { sortKey: "churn_allocs", desc: true, groupMode: "none", filterText: "" },
+ hot: { sortKey: "total_allocs", desc: true, groupMode: "none", filterText: "" },
+ };
+ this.loadStateFromUrl();
+ this.buildLayout();
+ }
+
+ buildLayout() {
+ this.container.innerHTML =
+ `<div class="memory-view">` +
+ `<div class="memory-cards" id="memory-cards"></div>` +
+ `<div class="memory-panel">` +
+ `<div class="memory-panel-header">` +
+ `<div class="memory-panel-title">Memory timeline</div>` +
+ `<div class="memory-panel-subtitle" id="memory-timeline-meta"></div>` +
+ `</div>` +
+ `<div class="memory-chart-wrap" id="memory-chart-wrap">` +
+ `<svg class="memory-chart" id="memory-chart"></svg>` +
+ `</div>` +
+ `</div>` +
+ `<div class="memory-panel">` +
+ `<div class="memory-panel-header memory-panel-header-wrap">` +
+ `<div>` +
+ `<div class="memory-panel-title">Allocation size distribution</div>` +
+ `<div class="memory-panel-subtitle" id="memory-histogram-meta"></div>` +
+ `</div>` +
+ `<div class="memory-controls">` +
+ `<label>Metric <select id="memory-histogram-metric">` +
+ `<option value="count">Alloc count</option>` +
+ `<option value="bytes">Total bytes</option>` +
+ `</select></label>` +
+ `</div>` +
+ `</div>` +
+ `<div class="memory-chart-wrap" id="memory-histogram-wrap">` +
+ `<svg class="memory-chart memory-histogram" id="memory-histogram"></svg>` +
+ `</div>` +
+ `</div>` +
+ `<div class="memory-grid">` +
+ this.buildPanelMarkup("leaks", "Leaky callsites", "Top live allocation stacks", [
+ ["live_bytes", "Live bytes"],
+ ["live_count", "Live allocs"],
+ ["summary", "Summary"],
+ ]) +
+ this.buildPanelMarkup("churn", "Churn", "Short-lived allocation sites", [
+ ["churn_allocs", "Short-lived allocs"],
+ ["churn_bytes", "Churn bytes"],
+ ["mean_distance", "Avg distance"],
+ ["summary", "Summary"],
+ ]) +
+ this.buildPanelMarkup("hot", "Hot callsites", "Highest total allocation activity", [
+ ["total_allocs", "Total allocs"],
+ ["total_bytes", "Total bytes"],
+ ["churn_allocs", "Churn allocs"],
+ ["summary", "Summary"],
+ ]) +
+ `<div class="memory-panel memory-callstack-panel">` +
+ `<div class="memory-panel-header"><div class="memory-panel-title">Callstack details</div><div class="memory-panel-subtitle" id="memory-callstack-meta">Select a row to inspect its frames</div></div>` +
+ `<div class="memory-callstack-body" id="memory-callstack-body"><div class="memory-empty">No callstack selected.</div></div>` +
+ `</div>` +
+ `</div>` +
+ `</div>`;
+
+ this.cardsEl = this.container.querySelector("#memory-cards");
+ this.chartWrapEl = this.container.querySelector("#memory-chart-wrap");
+ this.chartEl = this.container.querySelector("#memory-chart");
+ this.chartMetaEl = this.container.querySelector("#memory-timeline-meta");
+ this.histogramWrapEl = this.container.querySelector("#memory-histogram-wrap");
+ this.histogramEl = this.container.querySelector("#memory-histogram");
+ this.histogramMetaEl = this.container.querySelector("#memory-histogram-meta");
+ this.histogramMetricEl = this.container.querySelector("#memory-histogram-metric");
+ this.callstackMetaEl = this.container.querySelector("#memory-callstack-meta");
+ this.callstackBodyEl = this.container.querySelector("#memory-callstack-body");
+ this.panelRefs = {
+ leaks: {
+ tbody: this.container.querySelector("#memory-leaks-body"),
+ sort: this.container.querySelector("#memory-leaks-sort"),
+ filter: this.container.querySelector("#memory-leaks-filter"),
+ clear: this.container.querySelector("#memory-leaks-clear"),
+ direction: this.container.querySelector("#memory-leaks-direction"),
+ group: this.container.querySelector("#memory-leaks-group"),
+ },
+ churn: {
+ tbody: this.container.querySelector("#memory-churn-body"),
+ sort: this.container.querySelector("#memory-churn-sort"),
+ filter: this.container.querySelector("#memory-churn-filter"),
+ clear: this.container.querySelector("#memory-churn-clear"),
+ direction: this.container.querySelector("#memory-churn-direction"),
+ group: this.container.querySelector("#memory-churn-group"),
+ },
+ hot: {
+ tbody: this.container.querySelector("#memory-hot-body"),
+ sort: this.container.querySelector("#memory-hot-sort"),
+ filter: this.container.querySelector("#memory-hot-filter"),
+ clear: this.container.querySelector("#memory-hot-clear"),
+ direction: this.container.querySelector("#memory-hot-direction"),
+ group: this.container.querySelector("#memory-hot-group"),
+ },
+ };
+
+ this.resizeObserver = new ResizeObserver(() => {
+ if (this.loaded) {
+ this.renderTimeline();
+ this.renderSizeHistogram();
+ }
+ });
+ this.resizeObserver.observe(this.chartWrapEl);
+ this.resizeObserver.observe(this.histogramWrapEl);
+
+ this.histogramMetricEl.value = this.histogramMetric;
+ this.histogramMetricEl.addEventListener("change", () => {
+ this.histogramMetric = this.histogramMetricEl.value;
+ this.saveStateToUrl();
+ this.renderSizeHistogram();
+ });
+
+ for (const [name, refs] of Object.entries(this.panelRefs)) {
+ refs.sort.value = this.tableState[name].sortKey;
+ refs.group.value = this.tableState[name].groupMode;
+ refs.filter.value = this.tableState[name].filterText;
+ refs.sort.addEventListener("change", () => {
+ this.tableState[name].sortKey = refs.sort.value;
+ this.tableState[name].desc = refs.sort.value !== "mean_distance";
+ this.updateDirectionButton(name);
+ this.saveStateToUrl();
+ this.renderTableByName(name);
+ });
+ refs.direction.addEventListener("click", () => {
+ this.tableState[name].desc = !this.tableState[name].desc;
+ this.updateDirectionButton(name);
+ this.saveStateToUrl();
+ this.renderTableByName(name);
+ });
+ refs.filter.addEventListener("input", () => {
+ this.tableState[name].filterText = refs.filter.value;
+ this.updateFilterButton(name);
+ this.saveStateToUrl();
+ this.renderTableByName(name);
+ });
+ refs.clear.addEventListener("click", () => {
+ refs.filter.value = "";
+ this.tableState[name].filterText = "";
+ this.updateFilterButton(name);
+ this.saveStateToUrl();
+ this.renderTableByName(name);
+ refs.filter.focus();
+ });
+ refs.group.addEventListener("change", () => {
+ this.tableState[name].groupMode = refs.group.value;
+ this.saveStateToUrl();
+ this.renderTableByName(name);
+ });
+ this.updateDirectionButton(name);
+ this.updateFilterButton(name);
+ }
+
+ this.container.addEventListener("keydown", (e) => {
+ if (e.key !== "/" || e.defaultPrevented) {
+ return;
+ }
+ const target = e.target;
+ if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable)) {
+ return;
+ }
+ e.preventDefault();
+ const activeView = this.container.closest(".view");
+ if (activeView && activeView.hidden) {
+ return;
+ }
+ const firstFilter = this.panelRefs.leaks.filter;
+ if (firstFilter) {
+ firstFilter.focus();
+ firstFilter.select();
+ }
+ });
+ this.container.tabIndex = -1;
+ this.container.dataset.memoryView = "true";
+ }
+
+ buildPanelMarkup(name, title, subtitle, sortOptions) {
+ const sortHtml = sortOptions.map(([value, label]) => `<option value="${value}">${escapeHtml(label)}</option>`).join("");
+ return `
+ <div class="memory-panel">
+ <div class="memory-panel-header memory-panel-header-wrap">
+ <div>
+ <div class="memory-panel-title">${escapeHtml(title)}</div>
+ <div class="memory-panel-subtitle">${escapeHtml(subtitle)}</div>
+ </div>
+ <div class="memory-controls">
+ <label>Filter <input type="text" id="memory-${name}-filter" class="memory-filter-input" placeholder="filter entries..."></label>
+ <button type="button" class="memory-clear-btn" id="memory-${name}-clear">Clear</button>
+ <label>Sort <select id="memory-${name}-sort">${sortHtml}</select></label>
+ <button type="button" class="memory-direction-btn" id="memory-${name}-direction"></button>
+ <label>Group <select id="memory-${name}-group">
+ <option value="none">None</option>
+ <option value="top_frame">Top frame</option>
+ <option value="prefix">Trimmed prefix</option>
+ </select></label>
+ </div>
+ </div>
+ <div class="memory-table-wrap"><table class="memory-table"><tbody id="memory-${name}-body"></tbody></table></div>
+ </div>`;
+ }
+
+ loadStateFromUrl() {
+ const params = new URLSearchParams(window.location.search);
+ const histogramMetric = params.get("mem_hist_metric");
+ if (histogramMetric === "count" || histogramMetric === "bytes") {
+ this.histogramMetric = histogramMetric;
+ }
+ for (const [name, state] of Object.entries(this.tableState)) {
+ const sortKey = params.get(`mem_${name}_sort`);
+ const groupMode = params.get(`mem_${name}_group`);
+ const dir = params.get(`mem_${name}_dir`);
+ const filterText = params.get(`mem_${name}_filter`);
+ if (sortKey) {
+ state.sortKey = sortKey;
+ }
+ if (groupMode) {
+ state.groupMode = groupMode;
+ }
+ if (filterText) {
+ state.filterText = filterText;
+ }
+ if (dir === "asc") {
+ state.desc = false;
+ }
+ else if (dir === "desc") {
+ state.desc = true;
+ }
+ }
+ }
+
+ saveStateToUrl() {
+ const url = new URL(window.location.href);
+ url.searchParams.set("mem_hist_metric", this.histogramMetric);
+ for (const [name, state] of Object.entries(this.tableState)) {
+ url.searchParams.set(`mem_${name}_sort`, state.sortKey);
+ url.searchParams.set(`mem_${name}_group`, state.groupMode);
+ url.searchParams.set(`mem_${name}_dir`, state.desc ? "desc" : "asc");
+ if (state.filterText) {
+ url.searchParams.set(`mem_${name}_filter`, state.filterText);
+ }
+ else {
+ url.searchParams.delete(`mem_${name}_filter`);
+ }
+ }
+ window.history.replaceState({}, "", url);
+ }
+
+ updateFilterButton(name) {
+ const refs = this.panelRefs[name];
+ const hasText = !!this.tableState[name].filterText;
+ refs.clear.disabled = !hasText;
+ refs.clear.title = hasText ? "Clear filter" : "Filter is empty";
+ }
+
+ updateDirectionButton(name) {
+ const refs = this.panelRefs[name];
+ const desc = this.tableState[name].desc;
+ refs.direction.textContent = desc ? "↓ Desc" : "↑ Asc";
+ refs.direction.setAttribute("aria-label", desc ? "Sort descending" : "Sort ascending");
+ refs.direction.title = desc ? "Sorting descending" : "Sorting ascending";
+ }
+
+ async ensureLoaded() {
+ if (this.loaded) return;
+ this.loaded = true;
+ await this.refresh();
+ }
+
+ async refresh() {
+ this.renderLoading();
+ try {
+ const [summary, memoryTimeline, leakResponse, churnResponse, sizeHistogram] = await Promise.all([
+ getAllocSummary(),
+ getMemoryTimeline({ maxSamples: 1200 }),
+ getCallstackStats(100),
+ getChurnStats(200),
+ getAllocSizeHistogram(),
+ ]);
+ this.summary = summary;
+ this.memoryTimeline = memoryTimeline;
+ this.leaks = (leakResponse && leakResponse.stats) || [];
+ this.churn = (churnResponse && churnResponse.stats) || [];
+ this.sizeHistogram = sizeHistogram;
+ this.hot = this.churn.slice().sort((a, b) => {
+ if (b.total_allocs !== a.total_allocs) return b.total_allocs - a.total_allocs;
+ return b.total_bytes - a.total_bytes;
+ }).slice(0, 100);
+ this.render();
+ } catch (e) {
+ this.cardsEl.innerHTML = "";
+ this.chartEl.innerHTML = "";
+ this.chartMetaEl.textContent = "";
+ this.histogramEl.innerHTML = "";
+ this.histogramMetaEl.textContent = "";
+ for (const refs of Object.values(this.panelRefs)) {
+ refs.tbody.innerHTML = `<tr><td class="memory-empty">Failed to load memory data: ${escapeHtml(e.message)}</td></tr>`;
+ }
+ this.callstackBodyEl.innerHTML = `<div class="memory-empty">Failed to load memory data.</div>`;
+ }
+ }
+
+ renderLoading() {
+ this.cardsEl.innerHTML = `<div class="memory-card"><div class="memory-card-label">Loading</div><div class="memory-card-value">Memory analysis…</div></div>`;
+ this.chartEl.innerHTML = "";
+ this.chartMetaEl.textContent = "";
+ this.histogramEl.innerHTML = "";
+ this.histogramMetaEl.textContent = "Loading…";
+ for (const refs of Object.values(this.panelRefs)) {
+ refs.tbody.innerHTML = `<tr><td class="memory-empty">Loading…</td></tr>`;
+ }
+ }
+
+ render() {
+ this.renderCards();
+ this.renderTimeline();
+ this.renderSizeHistogram();
+ this.renderTableByName("leaks");
+ this.renderTableByName("churn");
+ this.renderTableByName("hot");
+ }
+
+ renderCards() {
+ const s = this.summary || {};
+ this.cardsEl.innerHTML = [
+ this.formatCard("Peak memory", formatBytes(s.peak_bytes)),
+ this.formatCard("End memory", formatBytes(s.end_bytes)),
+ this.formatCard("Live allocations", formatNum(s.live_allocations)),
+ this.formatCard("Total allocs", formatNum(s.total_allocs)),
+ this.formatCard("Total frees", formatNum(s.total_frees)),
+ this.formatCard("Reallocs", formatNum((s.total_realloc_allocs || 0) + (s.total_realloc_frees || 0))),
+ ].join("");
+ }
+
+ formatCard(label, value) {
+ return `<div class="memory-card"><div class="memory-card-label">${escapeHtml(label)}</div><div class="memory-card-value">${escapeHtml(value)}</div></div>`;
+ }
+
+ renderTimeline() {
+ const samples = (this.memoryTimeline && this.memoryTimeline.samples) || [];
+ if (samples.length === 0) {
+ this.chartMetaEl.textContent = "No memory timeline samples";
+ this.chartEl.innerHTML = `<text x="500" y="110" text-anchor="middle" class="memory-chart-text">No memory timeline samples</text>`;
+ return;
+ }
+
+ const width = Math.max(320, Math.floor(this.chartWrapEl.getBoundingClientRect().width) - 24);
+ const height = 220;
+ const padLeft = 56;
+ const padRight = 12;
+ const padTop = 12;
+ const padBottom = 22;
+ const chartWidth = width - padLeft - padRight;
+ const chartHeight = height - padTop - padBottom;
+ let minValue = Infinity;
+ let maxValue = -Infinity;
+ for (const sample of samples) {
+ minValue = Math.min(minValue, Number(sample[1] || 0));
+ maxValue = Math.max(maxValue, Number(sample[1] || 0));
+ }
+ if (minValue === maxValue) {
+ minValue -= 1;
+ maxValue += 1;
+ }
+
+ const xAt = (index) => samples.length > 1 ? (padLeft + (index / (samples.length - 1)) * chartWidth) : (padLeft + chartWidth * 0.5);
+ const yAt = (value) => {
+ const norm = (Number(value || 0) - minValue) / Math.max(1, maxValue - minValue);
+ return padTop + chartHeight - norm * chartHeight;
+ };
+ const path = samples.map((sample, index) => `${index === 0 ? "M" : "L"}${xAt(index).toFixed(1)} ${yAt(sample[1]).toFixed(1)}`).join(" ");
+
+ const grid = [];
+ for (let i = 0; i <= 4; ++i) {
+ const y = padTop + chartHeight * i / 4;
+ const value = maxValue + (minValue - maxValue) * i / 4;
+ grid.push(`<line x1="${padLeft}" y1="${y}" x2="${padLeft + chartWidth}" y2="${y}" class="memory-chart-grid"/>`);
+ grid.push(`<text x="${padLeft - 6}" y="${y + 4}" text-anchor="end" class="memory-chart-axis">${escapeHtml(formatBytes(value))}</text>`);
+ }
+
+ const startUs = Number(samples[0][0] || 0);
+ const endUs = Number(samples[samples.length - 1][0] || 0);
+ const spanUs = Math.max(1, endUs - startUs);
+ const targetTickCount = Math.max(3, Math.min(8, Math.floor(chartWidth / 110)));
+ const ticks = buildNiceTimeTicks(startUs, endUs, targetTickCount);
+ for (const timeUs of ticks) {
+ const t = spanUs > 0 ? ((timeUs - startUs) / spanUs) : 0;
+ const x = padLeft + chartWidth * t;
+ grid.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${padTop + chartHeight}" class="memory-chart-grid memory-chart-grid-vert"/>`);
+ grid.push(`<text x="${x}" y="${height - 4}" text-anchor="middle" class="memory-chart-axis">${escapeHtml(formatTimeAxis(timeUs))}</text>`);
+ }
+
+ const durationUs = (this.model.session.trace_end_us || 0) - (this.model.session.trace_start_us || 0);
+ this.chartMetaEl.textContent = `${formatNum(samples.length)} samples across ${(durationUs / 1_000_000).toFixed(2)} s`;
+ this.chartEl.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ this.chartEl.setAttribute("preserveAspectRatio", "xMinYMin meet");
+ this.chartEl.innerHTML =
+ `<rect x="0" y="0" width="${width}" height="${height}" class="memory-chart-bg"/>` +
+ grid.join("") +
+ `<path d="${path}" class="memory-chart-line"/>`;
+ }
+
+ renderSizeHistogram() {
+ const buckets = (this.sizeHistogram && this.sizeHistogram.buckets) || [];
+ if (buckets.length === 0) {
+ this.histogramMetaEl.textContent = "No allocations recorded";
+ this.histogramEl.innerHTML = `<text x="500" y="110" text-anchor="middle" class="memory-chart-text">No allocations recorded</text>`;
+ return;
+ }
+
+ const metric = this.histogramMetric === "bytes" ? "bytes" : "count";
+ const metricLabel = metric === "bytes" ? "Total bytes" : "Alloc count";
+ const valueFor = (b) => Number((metric === "bytes" ? b.bytes : b.count) || 0);
+ const formatValue = metric === "bytes" ? formatBytes : formatNum;
+
+ let maxValue = 0;
+ let totalValue = 0;
+ for (const bucket of buckets) {
+ const v = valueFor(bucket);
+ if (v > maxValue) maxValue = v;
+ totalValue += v;
+ }
+ if (maxValue === 0) {
+ maxValue = 1;
+ }
+
+ const width = Math.max(320, Math.floor(this.histogramWrapEl.getBoundingClientRect().width) - 24);
+ const height = 240;
+ const padLeft = 64;
+ const padRight = 12;
+ const padTop = 12;
+ const padBottom = 42;
+ const chartWidth = width - padLeft - padRight;
+ const chartHeight = height - padTop - padBottom;
+
+ const bucketCount = buckets.length;
+ const slotWidth = chartWidth / bucketCount;
+ const barGap = Math.max(1, Math.min(4, slotWidth * 0.15));
+ const barWidth = Math.max(1, slotWidth - barGap);
+
+ const parts = [];
+ parts.push(`<rect x="0" y="0" width="${width}" height="${height}" class="memory-chart-bg"/>`);
+
+ // Horizontal grid + y-axis labels at 0, 25, 50, 75, 100% of max.
+ for (let i = 0; i <= 4; ++i) {
+ const y = padTop + chartHeight * i / 4;
+ const value = maxValue * (1 - i / 4);
+ parts.push(`<line x1="${padLeft}" y1="${y}" x2="${padLeft + chartWidth}" y2="${y}" class="memory-chart-grid"/>`);
+ parts.push(`<text x="${padLeft - 6}" y="${y + 4}" text-anchor="end" class="memory-chart-axis">${escapeHtml(formatValue(value))}</text>`);
+ }
+
+ // Bars. X-axis labels are drawn for a subset of buckets to avoid overlap.
+ const labelStride = Math.max(1, Math.ceil(bucketCount / Math.max(3, Math.floor(chartWidth / 64))));
+ for (let i = 0; i < bucketCount; ++i) {
+ const bucket = buckets[i];
+ const value = valueFor(bucket);
+ const barHeight = (value / maxValue) * chartHeight;
+ const x = padLeft + i * slotWidth + barGap / 2;
+ const y = padTop + chartHeight - barHeight;
+ const label = describeBucket(bucket);
+ const tooltip = `${label}\n${metricLabel}: ${formatValue(value)}\nAlloc count: ${formatNum(bucket.count)}\nTotal bytes: ${formatBytes(bucket.bytes)}`;
+ parts.push(
+ `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barWidth.toFixed(1)}" height="${Math.max(0, barHeight).toFixed(1)}" class="memory-histogram-bar"><title>${escapeHtml(tooltip)}</title></rect>`
+ );
+ if (i % labelStride === 0 || i === bucketCount - 1) {
+ const tickX = padLeft + i * slotWidth + slotWidth / 2;
+ parts.push(
+ `<text x="${tickX.toFixed(1)}" y="${(padTop + chartHeight + 14).toFixed(1)}" text-anchor="middle" class="memory-chart-axis">${escapeHtml(formatBucketEdge(bucket))}</text>`
+ );
+ }
+ }
+
+ // Axis title for x.
+ parts.push(
+ `<text x="${(padLeft + chartWidth / 2).toFixed(1)}" y="${(height - 4).toFixed(1)}" text-anchor="middle" class="memory-chart-axis">Allocation size (power-of-two buckets)</text>`
+ );
+
+ const summaryTotalCount = Number((this.sizeHistogram && this.sizeHistogram.total_count) || 0);
+ const summaryTotalBytes = Number((this.sizeHistogram && this.sizeHistogram.total_bytes) || 0);
+ this.histogramMetaEl.textContent = `${formatNum(summaryTotalCount)} allocations, ${formatBytes(summaryTotalBytes)} total across ${bucketCount} bucket${bucketCount === 1 ? "" : "s"}`;
+
+ this.histogramEl.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ this.histogramEl.setAttribute("preserveAspectRatio", "xMinYMin meet");
+ this.histogramEl.innerHTML = parts.join("");
+ }
+
+ getRowsForTable(name) {
+ if (name === "leaks") return this.leaks.slice(0, 100);
+ if (name === "churn") return this.churn.slice(0, 100);
+ return this.hot.slice(0, 100);
+ }
+
+ renderTableByName(name) {
+ const refs = this.panelRefs[name];
+ const state = this.tableState[name];
+ let rows = this.getRowsForTable(name).slice();
+ const filterText = state.filterText.trim().toLowerCase();
+ if (filterText) {
+ rows = rows.filter((row) => {
+ const haystack = [
+ row.summary,
+ row.top_frame,
+ row.secondary_frame,
+ row.group_key,
+ `callstack ${row.callstack_id}`,
+ ].join("\n").toLowerCase();
+ return haystack.includes(filterText);
+ });
+ }
+ rows.sort((a, b) => compareValues(a[state.sortKey], b[state.sortKey], state.desc));
+
+ if (!rows.length) {
+ refs.tbody.innerHTML = `<tr><td class="memory-empty">No data available.</td></tr>`;
+ return;
+ }
+
+ const parts = [];
+ let currentGroup = null;
+ for (let index = 0; index < rows.length; ++index) {
+ const row = rows[index];
+ const groupKey = state.groupMode === "top_frame" ? (row.top_frame || "(unknown)") :
+ (state.groupMode === "prefix" ? (row.group_key || row.top_frame || "(unknown)") : null);
+ if (groupKey !== null && groupKey !== currentGroup) {
+ currentGroup = groupKey;
+ parts.push(`<tr class="memory-group-row"><td colspan="5">${escapeHtml(groupKey)}</td></tr>`);
+ }
+ parts.push(this.renderDataRow(name, row, index));
+ }
+ refs.tbody.innerHTML = parts.join("");
+ for (const tr of refs.tbody.querySelectorAll("tr[data-callstack-id]")) {
+ tr.addEventListener("click", () => {
+ const callstackId = Number(tr.dataset.callstackId);
+ this.selectCallstack(callstackId);
+ for (const rowEl of this.container.querySelectorAll("tr[data-callstack-id]")) {
+ rowEl.classList.toggle("selected", Number(rowEl.dataset.callstackId) === callstackId);
+ }
+ });
+ }
+ }
+
+ renderDataRow(name, row, index) {
+ if (name === "leaks") {
+ return `<tr data-callstack-id="${row.callstack_id}">`
+ + `<td class="num">${index + 1}</td>`
+ + `<td class="num">${escapeHtml(formatBytes(row.live_bytes))}</td>`
+ + `<td class="num">${escapeHtml(formatNum(row.live_count))}</td>`
+ + `<td>${buildSummaryHtml(row)}</td>`
+ + `</tr>`;
+ }
+ if (name === "churn") {
+ return `<tr data-callstack-id="${row.callstack_id}">`
+ + `<td class="num">${index + 1}</td>`
+ + `<td class="num">${escapeHtml(formatNum(row.churn_allocs))}</td>`
+ + `<td class="num">${escapeHtml(formatBytes(row.churn_bytes))}</td>`
+ + `<td class="num">${escapeHtml(formatDistance(row.mean_distance))}</td>`
+ + `<td>${buildSummaryHtml(row)}</td>`
+ + `</tr>`;
+ }
+ return `<tr data-callstack-id="${row.callstack_id}">`
+ + `<td class="num">${index + 1}</td>`
+ + `<td class="num">${escapeHtml(formatNum(row.total_allocs))}</td>`
+ + `<td class="num">${escapeHtml(formatBytes(row.total_bytes))}</td>`
+ + `<td class="num">${escapeHtml(formatNum(row.churn_allocs))}</td>`
+ + `<td>${buildSummaryHtml(row)}</td>`
+ + `</tr>`;
+ }
+
+ async selectCallstack(callstackId) {
+ this.selectedCallstackId = callstackId;
+ this.callstackMetaEl.textContent = `Callstack ${callstackId}`;
+ this.callstackBodyEl.innerHTML = `<div class="memory-empty">Loading callstack ${callstackId}…</div>`;
+ try {
+ let callstack = this.callstackCache.get(callstackId);
+ if (!callstack) {
+ callstack = await getCallstack(callstackId);
+ this.callstackCache.set(callstackId, callstack);
+ }
+ const frames = callstack.frames || [];
+ if (!frames.length) {
+ this.callstackBodyEl.innerHTML = `<div class="memory-empty">No frames recorded for this callstack.</div>`;
+ return;
+ }
+ const notes = [];
+ if (callstack.hidden_prefix_count > 0) {
+ let note = `Skipped ${formatNum(callstack.hidden_prefix_count)} leading frame(s)`;
+ if (callstack.included_third_party_boundary) {
+ note += "; kept boundary third-party callsite";
+ }
+ notes.push(`<div class="memory-empty">${escapeHtml(note)}.</div>`);
+ }
+ const items = [];
+ for (let i = 0; i < frames.length; ++i) {
+ const frame = frames[i];
+ const display = frame.display || frame.address || "(unknown frame)";
+ const extra = frame.module_path ? ` <span class="memory-frame-path">${escapeHtml(trimPath(frame.module_path))}</span>` : "";
+ items.push(`<li><span class="memory-frame-index">#${frame.index ?? i}</span> <span class="memory-frame-display">${escapeHtml(display)}</span>${extra}</li>`);
+ }
+ this.callstackBodyEl.innerHTML = `${notes.join("")}<ol class="memory-callstack-list">${items.join("")}</ol>`;
+ } catch (e) {
+ this.callstackBodyEl.innerHTML = `<div class="memory-empty">Failed to load callstack ${callstackId}: ${escapeHtml(e.message)}</div>`;
+ }
+ }
+}