// Copyright Epic Games, Inc. All Rights Reserved.
// 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";
import { escapeHtml } from "./util.js";
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, '$1');
}
function buildSummaryHtml(row, filterText = "") {
const top = highlightMatch(row.top_frame || row.summary || `Callstack ${row.callstack_id}`, filterText);
const second = row.secondary_frame ? `
${highlightMatch(row.secondary_frame, filterText)}
` : "";
const badges = [];
if (row.hidden_prefix_count > 0) {
badges.push(`skip ${escapeHtml(formatNum(row.hidden_prefix_count))}`);
}
if (row.included_third_party_boundary) {
badges.push(`3p boundary`);
}
const badgeHtml = badges.length ? `${badges.join("")}
` : "";
return ``;
}
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.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 =
`` +
`
` +
`
` +
`` +
`
` +
`` +
`
` +
`
` +
`
` +
`` +
`
` +
`` +
`
` +
`
` +
`
` +
`
` +
`
` +
MemoryView.TAB_DEFS.map(({ name, label }) =>
``
).join("") +
`
` +
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"],
]) +
`
` +
`
` +
`
` +
`
`;
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.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;
}
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 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]) => ``).join("");
return `
`;
}
loadStateFromUrl() {
const params = new URLSearchParams(window.location.search);
const histogramMetric = params.get("mem_hist_metric");
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`);
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);
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);
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 = `| Failed to load memory data: ${escapeHtml(e.message)} |
`;
}
this.callstackBodyEl.innerHTML = `Failed to load memory data.
`;
}
}
renderLoading() {
this.cardsEl.innerHTML = ``;
this.chartEl.innerHTML = "";
this.chartMetaEl.textContent = "";
this.histogramEl.innerHTML = "";
this.histogramMetaEl.textContent = "Loading…";
for (const refs of Object.values(this.panelRefs)) {
refs.tbody.innerHTML = `| Loading… |
`;
}
}
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 `${escapeHtml(label)}
${escapeHtml(value)}
`;
}
renderTimeline() {
const samples = (this.memoryTimeline && this.memoryTimeline.samples) || [];
if (samples.length === 0) {
this.chartMetaEl.textContent = "No memory timeline samples";
this.chartEl.innerHTML = `No memory timeline samples`;
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(``);
grid.push(`${escapeHtml(formatBytes(value))}`);
}
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(``);
grid.push(`${escapeHtml(formatTimeAxis(timeUs))}`);
}
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 =
`` +
grid.join("") +
``;
}
renderSizeHistogram() {
const buckets = (this.sizeHistogram && this.sizeHistogram.buckets) || [];
if (buckets.length === 0) {
this.histogramMetaEl.textContent = "No allocations recorded";
this.histogramEl.innerHTML = `No allocations recorded`;
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(``);
// 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(``);
parts.push(`${escapeHtml(formatValue(value))}`);
}
// 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(
`${escapeHtml(tooltip)}`
);
if (i % labelStride === 0 || i === bucketCount - 1) {
const tickX = padLeft + i * slotWidth + slotWidth / 2;
parts.push(
`${escapeHtml(formatBucketEdge(bucket))}`
);
}
}
// Axis title for x.
parts.push(
`Allocation size (power-of-two buckets)`
);
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 = `| No data available. |
`;
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(`| ${escapeHtml(groupKey)} |
`);
}
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 ``
+ `| ${index + 1} | `
+ `${escapeHtml(formatBytes(row.live_bytes))} | `
+ `${escapeHtml(formatNum(row.live_count))} | `
+ `${buildSummaryHtml(row)} | `
+ `
`;
}
if (name === "churn") {
return ``
+ `| ${index + 1} | `
+ `${escapeHtml(formatNum(row.churn_allocs))} | `
+ `${escapeHtml(formatBytes(row.churn_bytes))} | `
+ `${escapeHtml(formatDistance(row.mean_distance))} | `
+ `${buildSummaryHtml(row)} | `
+ `
`;
}
return ``
+ `| ${index + 1} | `
+ `${escapeHtml(formatNum(row.total_allocs))} | `
+ `${escapeHtml(formatBytes(row.total_bytes))} | `
+ `${escapeHtml(formatNum(row.churn_allocs))} | `
+ `${buildSummaryHtml(row)} | `
+ `
`;
}
async selectCallstack(callstackId) {
this.selectedCallstackId = callstackId;
this.callstackMetaEl.textContent = `Callstack ${callstackId}`;
this.callstackBodyEl.innerHTML = `Loading callstack ${callstackId}…
`;
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 = `No frames recorded for this callstack.
`;
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(`${escapeHtml(note)}.
`);
}
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 ? ` ${escapeHtml(trimPath(frame.module_path))}` : "";
items.push(`#${frame.index ?? i} ${escapeHtml(display)}${extra}`);
}
this.callstackBodyEl.innerHTML = `${notes.join("")}${items.join("")}
`;
} catch (e) {
this.callstackBodyEl.innerHTML = `Failed to load callstack ${callstackId}: ${escapeHtml(e.message)}
`;
}
}
}