Files
datastore/programs/server/jemalloc.html
T
Antonio AndelicandClaude Opus 4.6 9b905689c8 Set max_block_size to 500 for collapsed jemalloc profile output
Minimize memory usage when querying `system.jemalloc_profile_text` in
collapsed format by streaming results in small blocks instead of
buffering up to 65K rows.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-28 23:48:19 +01:00

3311 lines
158 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ClickHouse Jemalloc Stats</title>
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDExIDEwIj48c3R5bGU+LmJne2ZpbGw6IzAwMH0ub3tmaWxsOiNmZjB9PC9zdHlsZT48cmVjdCBjbGFzcz0iYmciIHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48cGF0aCBkPSJNMSwxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik0zLDEgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTUsMSBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNNywxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik05LDQuMjUgaDEgdjEuNSBoLTEgeiIgY2xhc3M9Im8iLz48L3N2Zz4">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/uPlot.min.css">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/uPlot.iife.min.js"></script>
<style>
:root {
--color: black;
--background: #f5f5f5;
--card-background: white;
--border-color: #ddd;
--header-background: #FCFF74;
--header-text: black;
--shadow-color: rgba(0, 0, 0, 0.1);
--metric-value-color: black;
--error-color: #dc3545;
--success-color: #28a745;
--warning-color: #b58900;
--table-header-bg: #f8f9fa;
--table-hover-bg: #f1f1f1;
--btn-primary-bg: black;
--btn-primary-color: white;
}
[data-theme="dark"] {
--color: white;
--background: #151C2C;
--card-background: #1b2834;
--border-color: #444;
--header-background: #2a2a2a;
--header-text: #FCFF74;
--shadow-color: rgba(0, 0, 0, 0.3);
--metric-value-color: #FCFF74;
--error-color: #F66;
--success-color: #4ade80;
--warning-color: #facc15;
--table-header-bg: #2a3f5f;
--table-hover-bg: #2a3f5f;
--btn-primary-bg: #FCFF74;
--btn-primary-color: black;
}
* {
box-sizing: border-box;
}
body {
font-family: Liberation Sans, DejaVu Sans, sans-serif;
margin: 0;
padding: 0;
background: var(--background);
color: var(--color);
}
.header {
background: var(--header-background);
color: var(--header-text);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px var(--shadow-color);
position: sticky;
top: 0;
z-index: 100;
}
.header h1 {
margin: 0;
font-size: 1.5rem;
}
.controls {
display: flex;
gap: 1rem;
align-items: center;
}
.connection-params {
display: flex;
gap: 0.5rem;
align-items: center;
}
.connection-params input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-background);
color: var(--color);
font-size: 0.85rem;
font-family: inherit;
box-shadow: 1px 1px 0 var(--shadow-color);
transition: border-color 0.2s;
}
.connection-params input:focus {
outline: none;
border-color: var(--btn-primary-bg);
}
#url {
width: 350px;
}
#user, #password {
width: 120px;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.8;
}
.btn-primary {
background: var(--btn-primary-bg);
color: var(--btn-primary-color);
}
.btn-secondary {
background: var(--border-color);
color: var(--color);
}
.container {
max-width: 90vw;
margin: 0 auto;
padding: 2rem;
}
.error {
background: var(--error-color);
color: white;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.loading {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.metric-group {
display: contents;
}
.metric-group-divider {
grid-column: 1 / -1;
height: 1px;
background: var(--border-color);
margin: 0.5rem 0;
}
.metric-card {
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px var(--shadow-color);
}
.metric-label {
font-size: 0.85rem;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 0.5rem;
}
.metric-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--metric-value-color);
}
.metric-unit {
font-size: 0.9rem;
opacity: 0.7;
margin-left: 0.25rem;
}
.section {
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px var(--shadow-color);
}
.section-title {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border-color);
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
th {
background: var(--table-header-bg);
font-weight: bold;
position: sticky;
top: 0;
}
tbody tr:hover {
background: var(--table-hover-bg);
}
tbody tr:hover .sticky-cell-100,
tbody tr:hover .sticky-cell-80 {
background: var(--table-hover-bg);
}
.number {
text-align: right;
font-family: Monaco, Consolas, monospace;
}
.bar-container {
width: 100%;
height: 20px;
background: var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.bar {
height: 100%;
background: var(--metric-value-color);
transition: width 0.3s;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 2px solid var(--border-color);
}
.tab {
padding: 0.75rem 1.5rem;
cursor: pointer;
border: none;
background: none;
color: var(--color);
font-size: 1rem;
border-bottom: 3px solid transparent;
transition: border-color 0.2s;
}
.tab:hover {
border-bottom-color: var(--color);
}
.tab.active {
border-bottom-color: var(--color);
font-weight: bold;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
#profile-result, #query-flamegraph-result {
/* CSS containment to isolate rendering and prevent affecting other tabs */
contain: layout style paint;
/* Allow browser to skip rendering when not in viewport */
content-visibility: auto;
}
#profile-result pre, #query-flamegraph-result pre {
/* Additional containment for large text content */
contain: layout style paint;
}
pre {
background: var(--table-header-bg);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
overflow-y: auto;
max-height: 600px;
font-size: 0.85rem;
line-height: 1.5;
white-space: pre;
}
.theme-toggle {
background: none;
border: 1px solid white;
color: white;
padding: 0.5rem;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
}
.timestamp {
font-size: 0.85rem;
opacity: 0.7;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 2.4rem;
height: 1.35rem;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
inset: 0;
background: var(--border-color);
border-radius: 1.35rem;
cursor: pointer;
transition: background 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 1rem;
height: 1rem;
left: 0.18rem;
top: 0.18rem;
background: white;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.toggle-switch input:checked + .toggle-slider {
background: var(--btn-primary-bg);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(1.05rem);
}
.heatmap-split-view {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
@media (max-width: 1200px) {
.heatmap-split-view {
flex-direction: column;
}
}
.heatmap-container {
flex: 1;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
}
.heatmap-container.standalone {
margin-bottom: 2rem;
}
.heatmap-scrollable {
max-height: 450px; /* ~15 rows at ~30px per row */
overflow-y: auto;
overflow-x: hidden;
}
.heatmap-title {
font-size: 1rem;
font-weight: bold;
margin-bottom: 1rem;
color: var(--color);
}
.heatmap-bar {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.heatmap-label {
min-width: 80px;
font-size: 0.85rem;
font-family: Monaco, Consolas, monospace;
text-align: right;
}
.heatmap-bar-bg {
flex: 1;
height: 24px;
background: var(--border-color);
border-radius: 4px;
position: relative;
overflow: hidden;
}
.heatmap-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
display: flex;
align-items: center;
padding-left: 0.5rem;
font-size: 0.75rem;
color: white;
font-weight: bold;
}
.heatmap-bar-fill.hot {
background: linear-gradient(90deg, #dc3545, #ff6b6b);
}
.heatmap-bar-fill.warm {
background: linear-gradient(90deg, #ffc107, #ffdc73);
color: black;
}
.heatmap-bar-fill.cool {
background: linear-gradient(90deg, #28a745, #5fdb7c);
}
.sticky-header-100 {
position: sticky;
left: 0;
background: var(--table-header-bg);
z-index: 11;
min-width: 100px;
}
.sticky-header-80 {
position: sticky;
left: 0;
background: var(--table-header-bg);
z-index: 11;
min-width: 80px;
}
.sticky-cell-100 {
position: sticky;
left: 0;
background: var(--card-background);
z-index: 1;
min-width: 100px;
}
.sticky-cell-80 {
position: sticky;
left: 0;
background: var(--card-background);
z-index: 1;
min-width: 80px;
}
.arena-header {
background: var(--table-header-bg);
min-width: 120px;
}
.arena-first-header {
position: sticky;
left: 0;
top: 0;
background: var(--table-header-bg);
z-index: 12;
min-width: 100px;
}
</style>
</head>
<body>
<div class="header">
<h1>ClickHouse Jemalloc Statistics</h1>
<div class="controls">
<div class="connection-params">
<input spellcheck="false" id="url" type="text" value="" placeholder="URL" />
<input spellcheck="false" id="user" type="text" value="" placeholder="user" />
<input spellcheck="false" id="password" type="password" placeholder="password" />
</div>
<span class="timestamp" id="timestamp"></span>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<label for="refresh-interval" style="font-size: 0.9rem;" title="Set to 0 to disable auto-refresh">Auto-refresh (s):</label>
<input type="number" id="refresh-interval" min="0" value="60" title="Set to 0 to disable auto-refresh" style="width: 60px; padding: 0.5rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--card-background); color: var(--color);" oninput="debouncedUpdateRefreshInterval()" onchange="updateRefreshInterval()">
</div>
<button class="btn btn-primary" onclick="event.preventDefault(); loadStats(true); return false;">Refresh</button>
<button class="theme-toggle" onclick="toggleTheme()">
<span id="theme-icon">🌙</span>
</button>
</div>
</div>
<div class="container">
<div id="error" class="error" style="display: none;"></div>
<div id="loading" class="loading">Loading jemalloc statistics...</div>
<div id="content" style="display: none;">
<div class="metrics-grid" id="overview-metrics"></div>
<div class="tabs">
<button class="tab active" onclick="switchTab('summary', event)">Summary</button>
<button class="tab" onclick="switchTab('bins', event)">Allocations</button>
<button class="tab" onclick="switchTab('arenas', event)">Arenas</button>
<button class="tab" onclick="switchTab('operations', event)">Operations</button>
<button class="tab" onclick="switchTab('profile', event)">Global Profiler</button>
<button class="tab" onclick="switchTab('query-profile', event)">Query Profiler</button>
<button class="tab" onclick="switchTab('raw', event)">Raw Output</button>
</div>
<div id="tab-summary" class="tab-content active">
<div class="section">
<div class="section-title">Memory Overview</div>
<div id="memory-overview"></div>
</div>
</div>
<div id="tab-bins" class="tab-content">
<div class="section">
<div class="section-title">Allocations</div>
<div id="bin-stats"></div>
</div>
</div>
<div id="tab-arenas" class="tab-content">
<div class="section">
<div class="section-title">Arena Statistics</div>
<div id="arena-stats"></div>
</div>
</div>
<div id="tab-operations" class="tab-content">
<div class="section">
<div class="section-title">Background Thread Operations</div>
<div id="operations-stats"></div>
</div>
</div>
<div id="tab-profile" class="tab-content">
<div class="section">
<div class="section-title">Global Profiler</div>
<div id="profile-stats"></div>
</div>
</div>
<div id="tab-query-profile" class="tab-content">
<div class="section">
<div class="section-title">Query Profiler</div>
<div id="query-profile-stats"></div>
</div>
</div>
<div id="tab-raw" class="tab-content">
<div class="section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<div class="section-title" style="margin-bottom: 0;">Raw Statistics</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" onclick="copyRawStats()">Copy All</button>
<button class="btn btn-secondary" onclick="saveRawStats()">💾 Save</button>
</div>
</div>
<pre id="raw-stats"></pre>
</div>
</div>
</div>
</div>
<script>
let host = location.protocol != 'file:' ? location.origin : 'http://localhost:8123/';
let user = 'default';
let password = '';
let add_http_cors_header = (location.protocol != 'file:');
const current_url = new URL(window.location);
/// Substitute user name if it's specified in the query string
const user_from_url = current_url.searchParams.get('user');
if (user_from_url) {
user = user_from_url;
}
/// Substitute password if it's specified in the query string
const password_from_url = current_url.searchParams.get('password');
if (password_from_url) {
password = password_from_url;
}
/// Read connection parameters from the input fields
function readConnectionParams() {
host = document.getElementById('url').value || host;
user = document.getElementById('user').value || user;
password = document.getElementById('password').value || password;
}
/// Build a query URL with authentication and CORS parameters
function buildUrl(query, format = 'TabSeparated') {
let url = `${host}?default_format=${format}&enable_http_compression=1&query=${encodeURIComponent(query)}`;
if (add_http_cors_header) {
url += '&add_http_cors_header=1';
}
if (user) {
url += `&user=${encodeURIComponent(user)}`;
}
if (password) {
url += `&password=${encodeURIComponent(password)}`;
}
return url;
}
const fetchOptions = { method: 'POST', headers: { 'Authorization': 'never' } };
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function saveScrollPositions(container, sections) {
const positions = {};
sections.forEach(section => {
positions[section.name] = { x: 0, y: 0 };
});
const scrollElements = container.querySelectorAll('div[style*="max-height"]');
sections.forEach((section, index) => {
if (scrollElements[index]) {
positions[section.name] = {
x: scrollElements[index].scrollLeft,
y: scrollElements[index].scrollTop
};
}
});
return positions;
}
function restoreScrollPositions(container, positions, sections) {
setTimeout(() => {
const scrollElements = container.querySelectorAll('div[style*="max-height"]');
sections.forEach((section, index) => {
if (scrollElements[index] && positions[section.name]) {
scrollElements[index].scrollLeft = positions[section.name].x;
scrollElements[index].scrollTop = positions[section.name].y;
}
});
}, 0);
}
const _tableSortState = {};
const _tableData = {};
const _tableRenderFns = {};
function makeSortableTh(label, col, tableId, extraClass = 'number', extraStyle = '') {
const state = _tableSortState[tableId];
const isActive = state && state.col === col;
const ind = isActive ? (state.dir > 0 ? ' ▲' : ' ▼') : ' ⇅';
const opacity = isActive ? '1' : '0.3';
return `<th class="${extraClass}" data-sort-col="${col}" style="cursor:pointer;white-space:nowrap;user-select:none;${extraStyle}" onclick="sortTableBy('${tableId}','${col}')">${label}<span class="sort-ind" style="opacity:${opacity};font-size:0.7em;margin-left:0.2em">${ind}</span></th>`;
}
function sortTableBy(tableId, col) {
if (!_tableSortState[tableId]) _tableSortState[tableId] = { col: null, dir: 1 };
const state = _tableSortState[tableId];
if (state.col === col) state.dir *= -1;
else { state.col = col; state.dir = 1; }
const data = _tableData[tableId];
if (!data) return;
const table = document.getElementById(tableId);
if (!table) return;
const tbody = table.querySelector('tbody');
if (!tbody) return;
// Sort index array — avoids any HTML re-generation
const order = data.map((item, i) => i).sort((a, b) => {
const av = data[a][col] ?? 0, bv = data[b][col] ?? 0;
return (av < bv ? -1 : av > bv ? 1 : 0) * state.dir;
});
// Build map from data-idx → existing DOM row
const rowMap = {};
Array.from(tbody.querySelectorAll('tr')).forEach(row => { rowMap[row.dataset.idx] = row; });
// Hide during reorder so the browser does one layout pass, not N
tbody.style.display = 'none';
const frag = document.createDocumentFragment();
order.forEach(i => { if (rowMap[i]) frag.appendChild(rowMap[i]); });
tbody.appendChild(frag);
tbody.style.display = '';
// Update header indicators
table.querySelectorAll('th[data-sort-col]').forEach(th => {
const span = th.querySelector('.sort-ind');
if (!span) return;
if (th.dataset.sortCol === col) {
span.style.opacity = '1';
span.textContent = state.dir > 0 ? ' ▲' : ' ▼';
} else {
span.style.opacity = '0.3';
span.textContent = ' ⇅';
}
});
}
function applySortToData(tableId, data) {
const state = _tableSortState[tableId];
if (!state || !state.col) return data;
return [...data].sort((a, b) => {
const av = a[state.col] ?? 0, bv = b[state.col] ?? 0;
return (av < bv ? -1 : av > bv ? 1 : 0) * state.dir;
});
}
function getAllocationsTableHeader() {
return `
<thead style="position: sticky; top: 0; background: var(--table-header-bg); z-index: 10;">
<tr>
<th class="sticky-header-80">Type</th>
<th class="number">Allocated</th>
<th class="number">Nmalloc</th>
<th class="number">Nmalloc/s</th>
<th class="number">Ndalloc</th>
<th class="number">Ndalloc/s</th>
<th class="number">Nrequests</th>
<th class="number">Nrequests/s</th>
<th class="number">Nfill</th>
<th class="number">Nfill/s</th>
<th class="number">Nflush</th>
<th class="number">Nflush/s</th>
</tr>
</thead>`;
}
function renderAllocationRow(type, alloc) {
if (!alloc) return '';
return `
<tr>
<td class="sticky-cell-80">${type}</td>
<td class="number">${formatBytes(alloc.allocated)}</td>
<td class="number">${formatNumber(alloc.nmalloc)}</td>
<td class="number">${formatNumber(alloc.nmalloc_ps)}</td>
<td class="number">${formatNumber(alloc.ndalloc)}</td>
<td class="number">${formatNumber(alloc.ndalloc_ps)}</td>
<td class="number">${formatNumber(alloc.nrequests)}</td>
<td class="number">${formatNumber(alloc.nrequests_ps)}</td>
<td class="number">${formatNumber(alloc.nfill)}</td>
<td class="number">${formatNumber(alloc.nfill_ps)}</td>
<td class="number">${formatNumber(alloc.nflush)}</td>
<td class="number">${formatNumber(alloc.nflush_ps)}</td>
</tr>`;
}
function getBinsTableHeader(tableId) {
return `
<thead style="position: sticky; top: 0; background: var(--table-header-bg); z-index: 10;">
<tr>
${makeSortableTh('Size', 'size', tableId, 'number sticky-header-100')}
${makeSortableTh('Index', 'ind', tableId)}
${makeSortableTh('Allocated', 'allocated', tableId)}
${makeSortableTh('Nmalloc', 'nmalloc', tableId)}
${makeSortableTh('Nmalloc/s', 'nmalloc_ps', tableId)}
${makeSortableTh('Ndalloc', 'ndalloc', tableId)}
${makeSortableTh('Ndalloc/s', 'ndalloc_ps', tableId)}
${makeSortableTh('Nrequests', 'nrequests', tableId)}
${makeSortableTh('Nrequests/s', 'nrequests_ps', tableId)}
${makeSortableTh('Nshards', 'nshards', tableId)}
${makeSortableTh('Curregs', 'curregs', tableId)}
${makeSortableTh('Curslabs', 'curslabs', tableId)}
${makeSortableTh('Nonfull Slabs', 'nonfull_slabs', tableId)}
${makeSortableTh('Regs', 'regs', tableId)}
${makeSortableTh('Pgs', 'pgs', tableId)}
${makeSortableTh('Util', 'util', tableId)}
${makeSortableTh('Nfills', 'nfills', tableId)}
${makeSortableTh('Nfills/s', 'nfills_ps', tableId)}
${makeSortableTh('Nflushes', 'nflushes', tableId)}
${makeSortableTh('Nflushes/s', 'nflushes_ps', tableId)}
${makeSortableTh('Nslabs', 'nslabs', tableId)}
${makeSortableTh('Nreslabs', 'nreslabs', tableId)}
${makeSortableTh('Nreslabs/s', 'nreslabs_ps', tableId)}
${makeSortableTh('Pops', 'pops', tableId)}
${makeSortableTh('Pops/s', 'pops_ps', tableId)}
${makeSortableTh('Failed Push', 'failed_push', tableId)}
${makeSortableTh('Failed Push/s', 'failed_push_ps', tableId)}
${makeSortableTh('Push', 'push', tableId)}
${makeSortableTh('Push/s', 'push_ps', tableId)}
${makeSortableTh('Push Elem', 'push_elem', tableId)}
${makeSortableTh('Push Elem/s', 'push_elem_ps', tableId)}
${makeSortableTh('Lock Ops', 'n_lock_ops', tableId)}
${makeSortableTh('Lock Ops/s', 'n_lock_ops_ps', tableId)}
${makeSortableTh('Waiting', 'n_waiting', tableId)}
${makeSortableTh('Waiting/s', 'n_waiting_ps', tableId)}
${makeSortableTh('Spin Acq', 'n_spin_acq', tableId)}
${makeSortableTh('Spin Acq/s', 'n_spin_acq_ps', tableId)}
${makeSortableTh('Owner Switch', 'n_owner_switch', tableId)}
${makeSortableTh('Owner Switch/s', 'n_owner_switch_ps', tableId)}
${makeSortableTh('Total Wait (ns)', 'total_wait_ns', tableId)}
${makeSortableTh('Total Wait/s', 'total_wait_ns_ps', tableId)}
${makeSortableTh('Max Wait (ns)', 'max_wait_ns', tableId)}
${makeSortableTh('Max Threads', 'max_n_thds', tableId)}
</tr>
</thead>`;
}
function renderBinRow(bin) {
return `
<tr data-idx="${bin._idx}">
<td class="number sticky-cell-100">${formatBytes(bin.size)}</td>
<td class="number">${bin.ind}</td>
<td class="number">${formatBytes(bin.allocated)}</td>
<td class="number">${formatNumber(bin.nmalloc)}</td>
<td class="number">${bin.nmalloc_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.ndalloc)}</td>
<td class="number">${bin.ndalloc_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.nrequests)}</td>
<td class="number">${bin.nrequests_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.nshards)}</td>
<td class="number">${formatNumber(bin.curregs)}</td>
<td class="number">${formatNumber(bin.curslabs)}</td>
<td class="number">${formatNumber(bin.nonfull_slabs)}</td>
<td class="number">${formatNumber(bin.regs)}</td>
<td class="number">${formatNumber(bin.pgs)}</td>
<td class="number">${(bin.util * 100).toFixed(2)}%</td>
<td class="number">${formatNumber(bin.nfills)}</td>
<td class="number">${bin.nfills_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.nflushes)}</td>
<td class="number">${bin.nflushes_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.nslabs)}</td>
<td class="number">${formatNumber(bin.nreslabs)}</td>
<td class="number">${bin.nreslabs_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.pops)}</td>
<td class="number">${bin.pops_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.failed_push)}</td>
<td class="number">${bin.failed_push_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.push)}</td>
<td class="number">${bin.push_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.push_elem)}</td>
<td class="number">${bin.push_elem_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.n_lock_ops)}</td>
<td class="number">${bin.n_lock_ops_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.n_waiting)}</td>
<td class="number">${bin.n_waiting_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.n_spin_acq)}</td>
<td class="number">${bin.n_spin_acq_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.n_owner_switch)}</td>
<td class="number">${bin.n_owner_switch_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.total_wait_ns)}</td>
<td class="number">${bin.total_wait_ns_ps.toFixed(2)}</td>
<td class="number">${formatNumber(bin.max_wait_ns)}</td>
<td class="number">${formatNumber(bin.max_n_thds)}</td>
</tr>`;
}
function getLargeTableHeader(tableId) {
return `
<thead style="position: sticky; top: 0; background: var(--table-header-bg); z-index: 10;">
<tr>
${makeSortableTh('Size', 'size', tableId, 'number sticky-header-100')}
${makeSortableTh('Index', 'ind', tableId)}
${makeSortableTh('Allocated', 'allocated', tableId)}
${makeSortableTh('Nmalloc', 'nmalloc', tableId)}
${makeSortableTh('Nmalloc/s', 'nmalloc_ps', tableId)}
${makeSortableTh('Ndalloc', 'ndalloc', tableId)}
${makeSortableTh('Ndalloc/s', 'ndalloc_ps', tableId)}
${makeSortableTh('Nrequests', 'nrequests', tableId)}
${makeSortableTh('Nrequests/s', 'nrequests_ps', tableId)}
${makeSortableTh('Curlextents', 'curlextents', tableId)}
</tr>
</thead>`;
}
function renderLargeRow(item) {
return `
<tr data-idx="${item._idx}">
<td class="number sticky-cell-100">${formatBytes(item.size)}</td>
<td class="number">${item.ind}</td>
<td class="number">${formatBytes(item.allocated)}</td>
<td class="number">${formatNumber(item.nmalloc)}</td>
<td class="number">${item.nmalloc_ps.toFixed(2)}</td>
<td class="number">${formatNumber(item.ndalloc)}</td>
<td class="number">${item.ndalloc_ps.toFixed(2)}</td>
<td class="number">${formatNumber(item.nrequests)}</td>
<td class="number">${item.nrequests_ps.toFixed(2)}</td>
<td class="number">${formatNumber(item.curlextents)}</td>
</tr>`;
}
function getExtentsTableHeader(tableId) {
return `
<thead style="position: sticky; top: 0; background: var(--table-header-bg); z-index: 10;">
<tr>
${makeSortableTh('Size', 'size', tableId, 'number sticky-header-100')}
${makeSortableTh('Index', 'ind', tableId)}
${makeSortableTh('Ndirty', 'ndirty', tableId)}
${makeSortableTh('Dirty', 'dirty', tableId)}
${makeSortableTh('Nmuzzy', 'nmuzzy', tableId)}
${makeSortableTh('Muzzy', 'muzzy', tableId)}
${makeSortableTh('Nretained', 'nretained', tableId)}
${makeSortableTh('Retained', 'retained', tableId)}
${makeSortableTh('Ntotal', 'ntotal', tableId)}
${makeSortableTh('Total', 'total', tableId)}
</tr>
</thead>`;
}
function renderExtentRow(item) {
return `
<tr data-idx="${item._idx}">
<td class="number sticky-cell-100">${formatBytes(item.size)}</td>
<td class="number">${item.ind}</td>
<td class="number">${formatNumber(item.ndirty)}</td>
<td class="number">${formatBytes(item.dirty)}</td>
<td class="number">${formatNumber(item.nmuzzy)}</td>
<td class="number">${formatBytes(item.muzzy)}</td>
<td class="number">${formatNumber(item.nretained)}</td>
<td class="number">${formatBytes(item.retained)}</td>
<td class="number">${formatNumber(item.ntotal)}</td>
<td class="number">${formatBytes(item.total)}</td>
</tr>`;
}
function renderHeatmapBar(label, colorClass, width, content, title = '') {
const titleAttr = title ? ` title="${title}"` : '';
return `
<div class="heatmap-bar">
<div class="heatmap-label">${label}</div>
<div class="heatmap-bar-bg">
<div class="heatmap-bar-fill ${colorClass}" style="width: ${width}%"${titleAttr}>
${content}
</div>
</div>
</div>`;
}
function renderBinHeatmapSplitView(bins) {
return `
<div class="heatmap-split-view">
${renderBinHeatmap(bins)}
${renderBinUtilization(bins)}
</div>`;
}
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
document.getElementById('theme-icon').textContent = newTheme === 'dark' ? '☀️' : '🌙';
localStorage.setItem('theme', newTheme);
}
function switchTab(tabName, evt) {
// Use passed event or fall back to global event
const target = evt ? evt.target : event.target;
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
target.classList.add('active');
document.getElementById(`tab-${tabName}`).classList.add('active');
}
function copyRawStats() {
const rawStatsEl = document.getElementById('raw-stats');
const text = rawStatsEl.textContent;
const button = event.target;
const originalText = button.textContent;
// Create a temporary textarea
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-999999px';
textarea.style.top = '-999999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
button.textContent = 'Copied!';
button.style.background = 'var(--success-color)';
button.style.color = 'white';
setTimeout(() => {
button.textContent = originalText;
button.style.background = '';
button.style.color = '';
}, 2000);
} else {
throw new Error('Copy command failed');
}
} catch (error) {
console.error('Failed to copy:', error);
alert('Failed to copy to clipboard. Please try selecting the text manually.');
} finally {
document.body.removeChild(textarea);
}
}
function saveRawStats() {
const rawStatsEl = document.getElementById('raw-stats');
const text = rawStatsEl.textContent;
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `jemalloc-raw-stats-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Shared profile output helpers parameterized by result div ID
function getFullProfileText(resultDivId) {
const resultDiv = document.getElementById(resultDivId);
const pre = resultDiv ? resultDiv.querySelector('pre') : null;
if (!pre) return null;
if (resultDiv.dataset.allLines) {
return JSON.parse(resultDiv.dataset.allLines).join('\n');
}
return pre.textContent;
}
function copyProfileOutput(resultDivId) {
const text = getFullProfileText(resultDivId);
if (!text) {
alert('No profile output to copy');
return;
}
const button = event.target;
const originalText = button.textContent;
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-999999px';
textarea.style.top = '-999999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
button.textContent = 'Copied!';
button.style.background = 'var(--success-color)';
button.style.color = 'white';
setTimeout(() => {
button.textContent = originalText;
button.style.background = '';
button.style.color = '';
}, 2000);
} else {
throw new Error('Copy command failed');
}
} catch (error) {
console.error('Failed to copy:', error);
alert('Failed to copy to clipboard. Please try selecting the text manually.');
} finally {
document.body.removeChild(textarea);
}
}
function saveProfileOutput(resultDivId, filenamePrefix) {
const resultDiv = document.getElementById(resultDivId);
const pre = resultDiv ? resultDiv.querySelector('pre') : null;
const text = getFullProfileText(resultDivId);
if (!text) {
alert('No profile output to save');
return;
}
const format = pre.dataset.format || 'Raw';
let extension;
if (format === 'Raw') {
extension = '.heap';
} else if (format === 'Symbolized') {
extension = '.heap.symbolized';
} else if (format === 'Collapsed') {
extension = '.collapsed';
} else {
extension = '.txt';
}
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filenamePrefix}-${new Date().toISOString().replace(/[:.]/g, '-')}${extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function closeProfileOutput(resultDivId) {
const resultDiv = document.getElementById(resultDivId);
resultDiv.innerHTML = '';
}
function showMoreProfileLines(resultDivId) {
const resultDiv = document.getElementById(resultDivId);
const preElement = resultDiv.querySelector('pre');
const allLines = JSON.parse(resultDiv.dataset.allLines);
const currentLine = parseInt(resultDiv.dataset.currentLine);
const batchSize = 1000;
const nextLine = Math.min(currentLine + batchSize, allLines.length);
const newLines = allLines.slice(0, nextLine);
const format = preElement.dataset.format;
const maxLineLength = 500;
let displayText;
if (format === 'Collapsed') {
const truncatedLines = newLines.map(line => {
if (line.length > maxLineLength) {
return line.substring(0, maxLineLength) + '...';
}
return line;
});
displayText = truncatedLines.join('\n');
} else {
displayText = newLines.join('\n');
}
preElement.textContent = displayText;
resultDiv.dataset.currentLine = nextLine.toString();
const infoDiv = resultDiv.querySelector('.metric-label').parentElement.nextElementSibling;
const totalLines = allLines.length;
if (nextLine >= totalLines) {
infoDiv.textContent = infoDiv.textContent.replace(/Showing.*lines/, `${totalLines} lines`);
const buttonDiv = resultDiv.querySelector('div[style*="text-align: center"]');
if (buttonDiv) buttonDiv.remove();
} else {
infoDiv.textContent = infoDiv.textContent.replace(/Showing \d+/, `Showing ${nextLine}`);
}
}
function downloadFlameGraphFromProfile(resultDivId, filenamePrefix, countname) {
const text = getFullProfileText(resultDivId);
if (!text || text === 'No profile data') {
alert('No profile data available. Please get a profile first.');
return;
}
const svg = generateFlameGraphSVG(text, window.innerWidth, countname || 'bytes');
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filenamePrefix}-${new Date().toISOString().replace(/[:.]/g, '-')}.svg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function renderProfileOutputCard(resultDivId, label, lines, format, flameGraphOnclick, filenamePrefix) {
const resultDiv = document.getElementById(resultDivId);
const timestamp = new Date().toLocaleString();
const totalLines = lines.length;
const initialLines = 1000;
const hasMore = totalLines > initialLines;
const visualizeButton = flameGraphOnclick
? `<button class="btn btn-primary" onclick="${flameGraphOnclick}" style="padding: 0.4rem 1rem; font-size: 0.85rem;">🔥 Generate Flame Graph</button>`
: '';
resultDiv.innerHTML = `
<div class="metric-card" style="margin-top: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<div class="metric-label">${label}</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
${visualizeButton}
<button class="btn btn-secondary" onclick="copyProfileOutput('${resultDivId}')" style="padding: 0.4rem 1rem; font-size: 0.85rem;">Copy All</button>
<button class="btn btn-secondary" onclick="saveProfileOutput('${resultDivId}', '${filenamePrefix}')" style="padding: 0.4rem 1rem; font-size: 0.85rem;">💾 Save</button>
<button class="btn btn-secondary" onclick="closeProfileOutput('${resultDivId}')" style="padding: 0.4rem 1rem; font-size: 0.85rem;">✕ Close</button>
</div>
</div>
<div style="margin-top: 0.5rem; font-size: 0.85rem; opacity: 0.7;">
Generated at: ${timestamp}${hasMore ? ` | Showing ${initialLines} of ${totalLines} lines` : ` | ${totalLines} lines`}
</div>
<pre data-format="${format}" style="margin-top: 0.5rem; background: var(--table-header-bg); padding: 1rem; border-radius: 4px; overflow-x: auto; max-height: 600px; font-size: 0.85rem; line-height: 1.5; white-space: pre;"></pre>
${hasMore ? `<div style="margin-top: 0.5rem; text-align: center;"><button class="btn btn-primary" onclick="showMoreProfileLines('${resultDivId}')" style="padding: 0.4rem 1rem; font-size: 0.85rem;">Show More Lines</button></div>` : ''}
</div>
`;
const preElement = resultDiv.querySelector('pre');
const displayLines = hasMore ? lines.slice(0, initialLines) : lines;
const maxLineLength = 500;
let displayText;
if (format === 'Collapsed') {
const truncatedLines = displayLines.map(line =>
line.length > maxLineLength ? line.substring(0, maxLineLength) + '...' : line
);
displayText = truncatedLines.join('\n');
} else {
displayText = displayLines.join('\n');
}
preElement.textContent = displayText || 'No profile data';
resultDiv.dataset.allLines = JSON.stringify(lines);
if (hasMore) {
resultDiv.dataset.currentLine = initialLines.toString();
}
}
function parseJemallocStats(statsText) {
const stats = {
overview: {},
arenas: [],
bins: {},
raw: statsText
};
// Convert escaped newlines to actual newlines
const normalizedText = statsText.replace(/\\n/g, '\n');
const lines = normalizedText.split('\n');
let currentArena = null;
let inBinSection = false;
for (const line of lines) {
// Parse page size
if (line.includes('Page size:')) {
const pageSizeMatch = line.match(/Page size:\s*(\d+)/);
if (pageSizeMatch) stats.overview.pageSize = parseInt(pageSizeMatch[1]);
}
// Parse the main stats line: "Allocated: 499296096, active: 546709504, ..."
if (line.includes('Allocated:') && line.includes('active:')) {
// Extract all key-value pairs from this line
const allocatedMatch = line.match(/Allocated:\s*(\d+)/);
const activeMatch = line.match(/active:\s*(\d+)/);
const metadataMatch = line.match(/metadata:\s*(\d+)/);
const mappedMatch = line.match(/mapped:\s*(\d+)/);
const retainedMatch = line.match(/retained:\s*(\d+)/);
const residentMatch = line.match(/resident:\s*(\d+)/);
if (allocatedMatch) stats.overview.allocated = parseInt(allocatedMatch[1]);
if (activeMatch) stats.overview.active = parseInt(activeMatch[1]);
if (metadataMatch) stats.overview.metadata = parseInt(metadataMatch[1]);
if (mappedMatch) stats.overview.mapped = parseInt(mappedMatch[1]);
if (retainedMatch) stats.overview.retained = parseInt(retainedMatch[1]);
if (residentMatch) stats.overview.resident = parseInt(residentMatch[1]);
}
// Parse dirty pages: " dirty: N/A 14223 2669 27202 1389554"
// Format: dirty: time npages sweeps madvises purged
if (line.match(/^\s+dirty:/)) {
const dirtyMatch = line.match(/^\s+dirty:\s+\S+\s+(\d+)/);
if (dirtyMatch && stats.overview.pageSize) {
const npages = parseInt(dirtyMatch[1]);
stats.overview.dirty = npages * stats.overview.pageSize;
}
}
// Parse muzzy pages
if (line.match(/^\s+muzzy:/)) {
const muzzyMatch = line.match(/^\s+muzzy:\s+\S+\s+(\d+)/);
if (muzzyMatch && stats.overview.pageSize) {
const npages = parseInt(muzzyMatch[1]);
stats.overview.muzzy = npages * stats.overview.pageSize;
}
}
// Parse arena information
if (line.match(/^arenas\[(\d+)\]:/)) {
const arenaMatch = line.match(/^arenas\[(\d+)\]:/);
if (arenaMatch) {
currentArena = {
id: parseInt(arenaMatch[1]),
stats: {}
};
stats.arenas.push(currentArena);
}
}
// Parse assigned threads
if (currentArena && line.match(/^\s*assigned threads:/)) {
const threadsMatch = line.match(/^\s*assigned threads:\s+(\d+)/);
if (threadsMatch) {
currentArena.stats.assigned_threads = parseInt(threadsMatch[1]);
}
}
// Parse arena total allocated (from total: line)
if (currentArena && line.match(/^\s*total:/)) {
const totalMatch = line.match(/^\s*total:\s+(\d+)/);
if (totalMatch) {
currentArena.stats.allocated = parseInt(totalMatch[1]);
}
}
// Parse arena-specific stats
if (currentArena && line.match(/^\s*(active|mapped|retained|resident):/)) {
const statMatch = line.match(/^\s*(active|mapped|retained|resident):\s+(\d+)/);
if (statMatch) {
currentArena.stats[statMatch[1]] = parseInt(statMatch[2]);
}
}
// Parse bin information
if (line.includes('bins:')) {
inBinSection = true;
} else if (inBinSection && line.includes('size')) {
const binMatch = line.match(/size\s+(\d+)/);
if (binMatch) {
const size = parseInt(binMatch[1]);
if (!stats.bins[size]) {
stats.bins[size] = { size, allocations: 0, current: 0 };
}
}
}
}
return stats;
}
function renderOverviewMetrics(stats) {
const container = document.getElementById('overview-metrics');
// Calculate fragmentation metrics
const allocated = stats.allocated || 0;
const active = stats.active || 0;
const fragmentation = active > 0 ? ((active - allocated) / allocated * 100) : 0;
const efficiency = active > 0 ? (allocated / active * 100) : 0;
const metrics = [
{ label: 'Allocated', value: stats.allocated || 0, unit: '', isBytes: true, tooltip: 'Total bytes allocated by the application' },
{ label: 'Active', value: stats.active || 0, unit: '', isBytes: true, tooltip: 'Total bytes in active pages' },
{ label: 'Fragmentation', value: fragmentation, unit: '%', isBytes: false, tooltip: 'Formula: (Active - Allocated) / Allocated × 100%' },
{ label: 'Efficiency', value: efficiency, unit: '%', isBytes: false, tooltip: 'Formula: Allocated / Active × 100%' },
{ label: 'Mapped', value: stats.mapped || 0, unit: '', isBytes: true, tooltip: 'Total bytes in mapped extents', divider: true },
{ label: 'Retained', value: stats.retained || 0, unit: '', isBytes: true, tooltip: 'Total bytes in retained virtual memory' },
{ label: 'Resident', value: stats.resident || 0, unit: '', isBytes: true, tooltip: 'Total bytes in physical memory' },
{ label: 'Metadata', value: stats.metadata || 0, unit: '', isBytes: true, tooltip: 'Total bytes dedicated to metadata' },
{ label: 'Dirty', value: stats.dirty || 0, unit: '', isBytes: true, tooltip: 'Total bytes in dirty pages' }
];
// Check if cards already exist
const existingCards = container.querySelectorAll('.metric-card');
if (existingCards.length === metrics.length) {
// Update existing values only
existingCards.forEach((card, idx) => {
const valueEl = card.querySelector('.metric-value');
if (valueEl) {
const metric = metrics[idx];
const displayValue = metric.isBytes ? formatBytes(metric.value) : metric.value.toFixed(2);
valueEl.innerHTML = `${displayValue}<span class="metric-unit">${metric.unit}</span>`;
}
});
} else {
// Initial render
container.innerHTML = metrics.map((m, idx) => {
const displayValue = m.isBytes ? formatBytes(m.value) : m.value.toFixed(2);
let cardStyle = '';
// Add color coding for fragmentation
if (m.label === 'Fragmentation') {
if (m.value < 10) {
cardStyle = 'border-left: 4px solid var(--success-color);';
} else if (m.value < 25) {
cardStyle = 'border-left: 4px solid var(--warning-color);';
} else {
cardStyle = 'border-left: 4px solid var(--error-color);';
}
} else if (m.label === 'Efficiency') {
if (m.value > 90) {
cardStyle = 'border-left: 4px solid var(--success-color);';
} else if (m.value > 75) {
cardStyle = 'border-left: 4px solid var(--warning-color);';
} else {
cardStyle = 'border-left: 4px solid var(--error-color);';
}
}
const divider = m.divider ? '<div class="metric-group-divider"></div>' : '';
const tooltip = m.tooltip ? `title="${m.tooltip}"` : '';
return `
${divider}
<div class="metric-card" style="${cardStyle}" ${tooltip}>
<div class="metric-label">${m.label}</div>
<div class="metric-value">
${displayValue}
<span class="metric-unit">${m.unit}</span>
</div>
</div>
`;
}).join('');
}
}
function renderMemoryOverview(stats) {
const container = document.getElementById('memory-overview');
const total = stats.resident || 1;
const items = [
{ label: 'Allocated', value: stats.allocated || 0, color: '#FCFF74' },
{ label: 'Active', value: stats.active || 0, color: '#ffd700' },
{ label: 'Dirty', value: stats.dirty || 0, color: '#ff9800' },
{ label: 'Metadata', value: stats.metadata || 0, color: '#9c27b0' }
];
// Check if table already exists
const existingTable = container.querySelector('tbody');
if (existingTable) {
// Update existing values only
const rows = existingTable.querySelectorAll('tr');
rows.forEach((row, idx) => {
if (idx < items.length) {
const item = items[idx];
const cells = row.querySelectorAll('td');
if (cells.length >= 4) {
cells[1].textContent = formatBytes(item.value);
cells[2].textContent = `${((item.value / total) * 100).toFixed(2)}%`;
const bar = cells[3].querySelector('.bar');
if (bar) {
bar.style.width = `${(item.value / total) * 100}%`;
}
}
}
});
} else {
// Initial render
container.innerHTML = `
<table>
<thead>
<tr>
<th>Category</th>
<th class="number">Size</th>
<th class="number">% of Resident</th>
<th style="width: 30%;">Usage</th>
</tr>
</thead>
<tbody>
${items.map(item => `
<tr>
<td>${item.label}</td>
<td class="number">${formatBytes(item.value)}</td>
<td class="number">${((item.value / total) * 100).toFixed(2)}%</td>
<td>
<div class="bar-container">
<div class="bar" style="width: ${(item.value / total) * 100}%; background: ${item.color};"></div>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
}
function renderArenaComparisonRow(arena) {
const fragColor = arena._fragmentation < 10 ? 'var(--success-color)' :
arena._fragmentation < 25 ? 'var(--warning-color)' : 'var(--error-color)';
return `
<tr data-idx="${arena._idx}">
<td class="sticky-cell-100">Arena ${arena.id}</td>
<td class="number">${formatBytes(arena._allocated)}</td>
<td class="number">${formatBytes(arena._active)}</td>
<td class="number">${formatBytes(arena._resident)}</td>
<td class="number">${formatBytes(arena._mapped)}</td>
<td class="number">${formatBytes(arena._retained)}</td>
<td class="number" style="color:${fragColor}" title="Formula: (Active - Allocated) / Allocated × 100%">${arena._fragmentation.toFixed(2)}%</td>
</tr>`;
}
function enrichArenas(arenas) {
return arenas.map((a, i) => {
const allocated = a.stats.allocated || 0;
const active = a.stats.active || 0;
return {
...a,
_idx: i,
_allocated: allocated,
_active: active,
_resident: a.stats.resident || 0,
_mapped: a.stats.mapped || 0,
_retained: a.stats.retained || 0,
_fragmentation: allocated > 0 ? ((active - allocated) / allocated * 100) : 0,
};
});
}
let selectedArena = 0;
function renderArenaStats(arenas, rawStats) {
const container = document.getElementById('arena-stats');
if (!arenas || arenas.length === 0) {
container.innerHTML = '<p>No arena statistics available</p>';
return;
}
// Save scroll positions before re-rendering
const detailsDiv = container.querySelector('#arena-details');
const scrollPositions = detailsDiv
? saveScrollPositions(detailsDiv, [{ name: 'allocations' }, { name: 'bins' }, { name: 'large' }, { name: 'extents' }])
: { allocations: { x: 0, y: 0 }, bins: { x: 0, y: 0 }, large: { x: 0, y: 0 }, extents: { x: 0, y: 0 } };
// Parse arena allocations, bins, large, and extents from raw stats
const arenaAllocations = parseArenaAllocations(rawStats);
const arenaBins = parseArenaBins(rawStats);
const arenaLarge = parseArenaLarge(rawStats);
const arenaExtents = parseArenaExtents(rawStats);
// Store and sort arena comparison data
const enriched = enrichArenas(arenas);
_tableData['arena-comparison-table'] = enriched;
_tableRenderFns['arena-comparison-table'] = renderArenaComparisonRow;
const sortedArenaRows = applySortToData('arena-comparison-table', enriched);
// Check if comparison table and dropdown already exist
let comparisonTable = container.querySelector('#arena-comparison');
if (!comparisonTable) {
// Initial render with comparison table and dropdown
container.innerHTML = `
<div id="arena-load-balancing"></div>
<div id="arena-comparison" style="margin-bottom: 1.5rem;">
<h3 style="margin: 0.5rem 0;">Arena Comparison</h3>
<div style="max-height: 400px; overflow-x: auto; overflow-y: auto;">
<table id="arena-comparison-table">
<thead style="position: sticky; top: 0; z-index: 10;">
<tr>
${makeSortableTh('Arena', 'id', 'arena-comparison-table', 'arena-first-header')}
${makeSortableTh('Allocated', '_allocated', 'arena-comparison-table', 'number arena-header')}
${makeSortableTh('Active', '_active', 'arena-comparison-table', 'number arena-header')}
${makeSortableTh('Resident', '_resident', 'arena-comparison-table', 'number arena-header')}
${makeSortableTh('Mapped', '_mapped', 'arena-comparison-table', 'number arena-header')}
${makeSortableTh('Retained', '_retained', 'arena-comparison-table', 'number arena-header')}
${makeSortableTh('Fragmentation', '_fragmentation', 'arena-comparison-table', 'number arena-header')}
</tr>
</thead>
<tbody>
${sortedArenaRows.map(renderArenaComparisonRow).join('')}
</tbody>
</table>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label for="arena-select" style="margin-right: 0.5rem;">Select Arena for Details:</label>
<select id="arena-select" class="btn btn-secondary" onchange="selectedArena = parseInt(this.value); renderArenaStats(window.currentArenas, window.currentRawStats);" style="padding: 0.5rem;">
${arenas.map(arena => `<option value="${arena.id}">Arena ${arena.id}</option>`).join('')}
</select>
</div>
<div id="arena-details"></div>
`;
} else {
// Update comparison table tbody with current sort applied
const tbody = comparisonTable.querySelector('tbody');
if (tbody) tbody.innerHTML = sortedArenaRows.map(renderArenaComparisonRow).join('');
}
// Always update the load balancing and thread distribution heatmaps
const loadBalancingDiv = container.querySelector('#arena-load-balancing');
if (loadBalancingDiv) {
loadBalancingDiv.innerHTML = `
<div class="heatmap-split-view">
${renderArenaThreadDistribution(arenas)}
${renderArenaLoadBalancing(arenas, arenaAllocations)}
</div>
`;
}
// Update selected arena in dropdown
const select = container.querySelector('select');
if (select) select.value = selectedArena;
// Render details for selected arena
const arena = arenas.find(a => a.id === selectedArena) || arenas[0];
const allocations = arenaAllocations[selectedArena] || { small: null, large: null, total: null };
const bins = arenaBins[selectedArena] || [];
const large = arenaLarge[selectedArena] || [];
const extents = arenaExtents[selectedArena] || [];
// Store data for sortable tables (arena details), tagging each item with _idx
bins.forEach((b, i) => { b._idx = i; });
large.forEach((l, i) => { l._idx = i; });
extents.forEach((e, i) => { e._idx = i; });
_tableData['bins-table-arena'] = bins; _tableRenderFns['bins-table-arena'] = renderBinRow;
_tableData['large-table-arena'] = large; _tableRenderFns['large-table-arena'] = renderLargeRow;
_tableData['extents-table-arena'] = extents; _tableRenderFns['extents-table-arena'] = renderExtentRow;
const targetDiv = container.querySelector('#arena-details') || container;
targetDiv.innerHTML = `
${renderBinHeatmapSplitView(bins)}
<h3 style="margin: 1rem 0 0.5rem 0;">Allocations</h3>
<div style="max-height: 300px; overflow-x: auto; overflow-y: auto;">
<table>
${getAllocationsTableHeader()}
<tbody>
${renderAllocationRow('small', allocations.small)}
${renderAllocationRow('large', allocations.large)}
${renderAllocationRow('total', allocations.total)}
</tbody>
</table>
</div>
${bins.length > 0 ? `
<h3 style="margin: 1rem 0 0.5rem 0;">Bins</h3>
<div style="max-height: 400px; overflow-x: auto; overflow-y: auto;">
<table id="bins-table-arena">
${getBinsTableHeader('bins-table-arena')}
<tbody>
${applySortToData('bins-table-arena', bins).map(renderBinRow).join('')}
</tbody>
</table>
</div>
` : '<p>No bin data available</p>'}
${large.length > 0 ? `
<h3 style="margin: 1rem 0 0.5rem 0;">Large Allocations</h3>
<div style="max-height: 400px; overflow-x: auto; overflow-y: auto;">
<table id="large-table-arena">
${getLargeTableHeader('large-table-arena')}
<tbody>
${applySortToData('large-table-arena', large).map(renderLargeRow).join('')}
</tbody>
</table>
</div>
` : ''}
${extents.length > 0 ? `
<h3 style="margin: 1rem 0 0.5rem 0;">Extents</h3>
<div style="max-height: 400px; overflow-x: auto; overflow-y: auto;">
<table id="extents-table-arena">
${getExtentsTableHeader('extents-table-arena')}
<tbody>
${applySortToData('extents-table-arena', extents).map(renderExtentRow).join('')}
</tbody>
</table>
</div>
` : ''}
`;
// Restore scroll positions after re-rendering
const newDetailsDiv = container.querySelector('#arena-details');
if (newDetailsDiv) {
restoreScrollPositions(newDetailsDiv, scrollPositions, [{ name: 'allocations' }, { name: 'bins' }, { name: 'large' }, { name: 'extents' }]);
}
// Store for access in onchange handler
window.currentArenas = arenas;
window.currentRawStats = rawStats;
}
function parseArenaAllocations(rawStats) {
const arenaAllocations = {};
const lines = rawStats.replace(/\\n/g, '\n').split('\n');
let currentArena = null;
let inArenaSection = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Detect arena start
const arenaMatch = line.match(/^arenas\[(\d+)\]:/);
if (arenaMatch) {
currentArena = parseInt(arenaMatch[1]);
arenaAllocations[currentArena] = { small: null, large: null, total: null };
inArenaSection = true;
continue;
}
// Parse small/large/total allocation lines
const allocMatch = line.match(/^\s*(small|large|total):\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
if (allocMatch) {
const data = {
type: allocMatch[1],
allocated: parseInt(allocMatch[2]),
nmalloc: parseInt(allocMatch[3]),
nmalloc_ps: parseInt(allocMatch[4]),
ndalloc: parseInt(allocMatch[5]),
ndalloc_ps: parseInt(allocMatch[6]),
nrequests: parseInt(allocMatch[7]),
nrequests_ps: parseInt(allocMatch[8]),
nfill: parseInt(allocMatch[9]),
nfill_ps: parseInt(allocMatch[10]),
nflush: parseInt(allocMatch[11]),
nflush_ps: parseInt(allocMatch[12])
};
if (currentArena !== null && inArenaSection) {
arenaAllocations[currentArena][allocMatch[1]] = data;
} else if (!inArenaSection) {
// Global data before any arena
if (!arenaAllocations.global) {
arenaAllocations.global = { small: null, large: null, total: null };
}
arenaAllocations.global[allocMatch[1]] = data;
}
}
}
return arenaAllocations;
}
function parseArenaBins(rawStats) {
const arenaBins = {};
const lines = rawStats.replace(/\\n/g, '\n').split('\n');
let currentArena = null;
let inBins = false;
let inArenaSection = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Detect arena start
const arenaMatch = line.match(/^arenas\[(\d+)\]:/);
if (arenaMatch) {
currentArena = parseInt(arenaMatch[1]);
arenaBins[currentArena] = [];
inBins = false;
inArenaSection = true;
continue;
}
// Detect bins section
if (line.includes('bins:') && line.includes('size ind')) {
inBins = true;
continue;
}
// Parse bin data
if (inBins && line.trim()) {
// Check if we've reached the end of bins section
if (line.includes('large:') || line.includes('extents:') || line.match(/^[a-z]/)) {
inBins = false;
continue;
}
// bins format: size ind allocated nmalloc (#/sec) ndalloc (#/sec) nrequests (#/sec) nshards curregs curslabs nonfull_slabs regs pgs util nfills (#/sec) nflushes (#/sec) nslabs nreslabs (#/sec) pops (#/sec) failed_push (#/sec) push (#/sec) push_elem (#/sec) n_lock_ops (#/sec) n_waiting (#/sec) n_spin_acq (#/sec) n_owner_switch (#/sec) total_wait_ns (#/sec) max_wait_ns max_n_thds
const binMatch = line.match(/^\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
if (binMatch) {
const binData = {
size: parseInt(binMatch[1]),
ind: parseInt(binMatch[2]),
allocated: parseInt(binMatch[3]),
nmalloc: parseInt(binMatch[4]),
nmalloc_ps: parseInt(binMatch[5]),
ndalloc: parseInt(binMatch[6]),
ndalloc_ps: parseInt(binMatch[7]),
nrequests: parseInt(binMatch[8]),
nrequests_ps: parseInt(binMatch[9]),
nshards: parseInt(binMatch[10]),
curregs: parseInt(binMatch[11]),
curslabs: parseInt(binMatch[12]),
nonfull_slabs: parseInt(binMatch[13]),
regs: parseInt(binMatch[14]),
pgs: parseInt(binMatch[15]),
util: parseFloat(binMatch[16]),
nfills: parseInt(binMatch[17]),
nfills_ps: parseInt(binMatch[18]),
nflushes: parseInt(binMatch[19]),
nflushes_ps: parseInt(binMatch[20]),
nslabs: parseInt(binMatch[21]),
nreslabs: parseInt(binMatch[22]),
nreslabs_ps: parseInt(binMatch[23]),
pops: parseInt(binMatch[24]),
pops_ps: parseInt(binMatch[25]),
failed_push: parseInt(binMatch[26]),
failed_push_ps: parseInt(binMatch[27]),
push: parseInt(binMatch[28]),
push_ps: parseInt(binMatch[29]),
push_elem: parseInt(binMatch[30]),
push_elem_ps: parseInt(binMatch[31]),
n_lock_ops: parseInt(binMatch[32]),
n_lock_ops_ps: parseInt(binMatch[33]),
n_waiting: parseInt(binMatch[34]),
n_waiting_ps: parseInt(binMatch[35]),
n_spin_acq: parseInt(binMatch[36]),
n_spin_acq_ps: parseInt(binMatch[37]),
n_owner_switch: parseInt(binMatch[38]),
n_owner_switch_ps: parseInt(binMatch[39]),
total_wait_ns: parseInt(binMatch[40]),
total_wait_ns_ps: parseInt(binMatch[41]),
max_wait_ns: parseInt(binMatch[42]),
max_n_thds: parseInt(binMatch[43])
};
if (currentArena !== null && inArenaSection) {
arenaBins[currentArena].push(binData);
} else if (!inArenaSection) {
// Global data before any arena
if (!arenaBins.global) {
arenaBins.global = [];
}
arenaBins.global.push(binData);
}
}
// If line doesn't match, just skip it (don't exit bins section)
}
}
return arenaBins;
}
function parseArenaLarge(rawStats) {
const arenaLarge = {};
const lines = rawStats.replace(/\\n/g, '\n').split('\n');
let currentArena = null;
let inLarge = false;
let inArenaSection = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Detect arena start
const arenaMatch = line.match(/^arenas\[(\d+)\]:/);
if (arenaMatch) {
currentArena = parseInt(arenaMatch[1]);
arenaLarge[currentArena] = [];
inLarge = false;
inArenaSection = true;
continue;
}
// Detect large section
if (line.includes('large:') && line.includes('size ind')) {
inLarge = true;
continue;
}
// Parse large data
if (inLarge && line.trim()) {
// Check if we've reached the end of large section
if (line.includes('extents:') || line.match(/^[a-z]/)) {
inLarge = false;
continue;
}
// large format: size ind allocated nmalloc (#/sec) ndalloc (#/sec) nrequests (#/sec) curlextents
const largeMatch = line.match(/^\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
if (largeMatch) {
const largeData = {
size: parseInt(largeMatch[1]),
ind: parseInt(largeMatch[2]),
allocated: parseInt(largeMatch[3]),
nmalloc: parseInt(largeMatch[4]),
nmalloc_ps: parseInt(largeMatch[5]),
ndalloc: parseInt(largeMatch[6]),
ndalloc_ps: parseInt(largeMatch[7]),
nrequests: parseInt(largeMatch[8]),
nrequests_ps: parseInt(largeMatch[9]),
curlextents: parseInt(largeMatch[10])
};
if (currentArena !== null && inArenaSection) {
arenaLarge[currentArena].push(largeData);
} else if (!inArenaSection) {
// Global data before any arena
if (!arenaLarge.global) {
arenaLarge.global = [];
}
arenaLarge.global.push(largeData);
}
}
// If line doesn't match, just skip it (don't exit large section)
}
}
return arenaLarge;
}
function parseArenaExtents(rawStats) {
const arenaExtents = {};
const lines = rawStats.replace(/\\n/g, '\n').split('\n');
let currentArena = null;
let inExtents = false;
let inArenaSection = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Detect arena start
const arenaMatch = line.match(/^arenas\[(\d+)\]:/);
if (arenaMatch) {
currentArena = parseInt(arenaMatch[1]);
arenaExtents[currentArena] = [];
inExtents = false;
inArenaSection = true;
continue;
}
// Detect extents section
if (line.includes('extents:') && line.includes('size ind')) {
inExtents = true;
continue;
}
// Parse extents data
if (inExtents && line.trim()) {
// Check if we've reached the end of extents section
if (line.match(/^[a-z]/) || line.match(/^arenas\[/)) {
inExtents = false;
continue;
}
// extents format: size ind ndirty dirty nmuzzy muzzy nretained retained ntotal total
const extentsMatch = line.match(/^\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
if (extentsMatch) {
const extentsData = {
size: parseInt(extentsMatch[1]),
ind: parseInt(extentsMatch[2]),
ndirty: parseInt(extentsMatch[3]),
dirty: parseInt(extentsMatch[4]),
nmuzzy: parseInt(extentsMatch[5]),
muzzy: parseInt(extentsMatch[6]),
nretained: parseInt(extentsMatch[7]),
retained: parseInt(extentsMatch[8]),
ntotal: parseInt(extentsMatch[9]),
total: parseInt(extentsMatch[10])
};
if (currentArena !== null && inArenaSection) {
arenaExtents[currentArena].push(extentsData);
} else if (!inArenaSection) {
// Global data before any arena
if (!arenaExtents.global) {
arenaExtents.global = [];
}
arenaExtents.global.push(extentsData);
}
}
// If line doesn't match, just skip it (don't exit extents section)
}
}
return arenaExtents;
}
function renderArenaThreadDistribution(arenas) {
if (!arenas || arenas.length === 0) {
return '';
}
// Calculate thread distribution for each arena
const arenaThreads = arenas.map(arena => {
return {
id: arena.id,
assigned_threads: arena.stats.assigned_threads || 0
};
});
// Sort by assigned threads descending
const sortedArenas = [...arenaThreads].sort((a, b) => b.assigned_threads - a.assigned_threads);
const maxThreads = sortedArenas[0]?.assigned_threads || 1;
const totalThreads = arenaThreads.reduce((sum, arena) => sum + arena.assigned_threads, 0);
return `
<div class="heatmap-container">
<div class="heatmap-title">Arena Thread Distribution</div>
<div class="heatmap-scrollable">
${sortedArenas.map(arena => {
const percentage = totalThreads > 0 ? (arena.assigned_threads / totalThreads * 100).toFixed(1) : 0;
const width = maxThreads > 0 ? Math.max((arena.assigned_threads / maxThreads * 100), 5) : 5;
let colorClass = 'cool';
if (percentage > 66 / arenas.length) colorClass = 'hot';
else if (percentage > 33 / arenas.length) colorClass = 'warm';
return renderHeatmapBar(
`Arena ${arena.id}`,
colorClass,
width,
`${arena.assigned_threads} (${percentage}%)`,
`${arena.assigned_threads} threads assigned`
);
}).join('')}
</div>
<div style="margin-top: 1rem; font-size: 0.85rem; opacity: 0.8;">
💡 Total threads: ${totalThreads} · Balanced distribution: ${(100 / arenas.length).toFixed(1)}% per arena
</div>
</div>
`;
}
function renderArenaLoadBalancing(arenas, arenaAllocations) {
if (!arenas || arenas.length === 0) {
return '';
}
// Calculate load metrics for each arena
const arenaLoads = arenas.map(arena => {
const allocations = arenaAllocations[arena.id];
const totalRequests = allocations?.total?.nrequests_ps || 0;
const allocated = arena.stats.allocated || 0;
const active = arena.stats.active || 0;
const fragmentation = allocated > 0 ? ((active - allocated) / allocated * 100) : 0;
return {
id: arena.id,
allocated,
totalRequests,
fragmentation,
score: totalRequests // Use request rate as primary load indicator
};
});
// Sort by load (request rate) descending
const sortedArenas = [...arenaLoads].sort((a, b) => b.score - a.score);
const maxLoad = sortedArenas[0]?.score || 1;
return `
<div class="heatmap-container">
<div class="heatmap-title">Arena Load Balancing (by Request Rate)</div>
<div class="heatmap-scrollable">
${sortedArenas.map(arena => {
const loadPercentage = (arena.score / maxLoad * 100).toFixed(1);
const width = Math.max(loadPercentage, 5);
let colorClass = 'cool';
if (loadPercentage > 66) colorClass = 'hot';
else if (loadPercentage > 33) colorClass = 'warm';
const fragWarning = arena.fragmentation > 25 ? ' ⚠️' : '';
return renderHeatmapBar(
`Arena ${arena.id}`,
colorClass,
width,
`${formatNumber(arena.totalRequests)} req/s${fragWarning}`,
`Allocated: ${formatBytes(arena.allocated)}, Fragmentation: ${arena.fragmentation.toFixed(2)}%`
);
}).join('')}
</div>
<div style="margin-top: 1rem; font-size: 0.85rem; opacity: 0.8;">
💡 Tip: Balanced arenas should have similar request rates. ⚠️ indicates high fragmentation (&gt;25%)
</div>
</div>
`;
}
function renderBinHeatmap(bins) {
if (!bins || bins.length === 0) {
return '';
}
// Sort bins by nrequests_ps (descending) and take top 15
const sortedBins = [...bins]
.filter(bin => bin.nrequests_ps > 0)
.sort((a, b) => b.nrequests_ps - a.nrequests_ps)
.slice(0, 15);
if (sortedBins.length === 0) {
return '';
}
const maxRate = sortedBins[0].nrequests_ps;
return `
<div class="heatmap-container">
<div class="heatmap-title">Hot Bins (Top 15 by Requests/s)</div>
${sortedBins.map(bin => {
const percentage = (bin.nrequests_ps / maxRate * 100).toFixed(1);
const width = Math.max(percentage, 5);
let colorClass = 'cool';
if (percentage > 66) colorClass = 'hot';
else if (percentage > 33) colorClass = 'warm';
return renderHeatmapBar(
formatBytes(bin.size),
colorClass,
width,
`${formatNumber(bin.nrequests_ps)} req/s`
);
}).join('')}
</div>
`;
}
function renderBinUtilization(bins) {
if (!bins || bins.length === 0) {
return '';
}
// Sort bins by utilization (worst first) and take bottom 15
const sortedBins = [...bins]
.filter(bin => bin.allocated > 0)
.map(bin => ({
...bin,
waste: bin.allocated * (1 - bin.util) // Calculate wasted bytes
}))
.sort((a, b) => a.util - b.util) // Worst utilization first
.slice(0, 15);
if (sortedBins.length === 0) {
return '';
}
// Calculate total waste
const totalWaste = sortedBins.reduce((sum, bin) => sum + bin.waste, 0);
// Find max waste for scaling bars
const maxWaste = sortedBins[0]?.waste || 1;
return `
<div class="heatmap-container">
<div class="heatmap-title">Bin Utilization Efficiency (Top 15 by Waste)</div>
${sortedBins.map(bin => {
const utilPercent = (bin.util * 100).toFixed(1);
const wastePercent = (bin.waste / maxWaste * 100).toFixed(1);
const width = Math.max(wastePercent, 5);
let colorClass = 'cool';
if (bin.util < 0.5) colorClass = 'hot';
else if (bin.util < 0.7) colorClass = 'warm';
const wasteWarning = bin.util < 0.5 ? ' ⚠️' : '';
return renderHeatmapBar(
formatBytes(bin.size),
colorClass,
width,
`${utilPercent}% util (${formatBytes(bin.waste)} waste)${wasteWarning}`,
`Allocated: ${formatBytes(bin.allocated)}, Used: ${utilPercent}%, Waste: ${formatBytes(bin.waste)}`
);
}).join('')}
<div style="margin-top: 1rem; font-size: 0.85rem; opacity: 0.8;">
💡 Total waste: ${formatBytes(totalWaste)} · ⚠️ indicates severe waste (&lt;50% utilization)
</div>
</div>
`;
}
function renderBinStats(rawStats, arenas) {
const container = document.getElementById('bin-stats');
if (!arenas || arenas.length === 0) {
container.innerHTML = '<p>No statistics available</p>';
return;
}
// Save scroll positions before re-rendering
const scrollPositions = saveScrollPositions(container, [
{ name: 'allocations' },
{ name: 'bins' },
{ name: 'large' },
{ name: 'extents' }
]);
// Parse allocations, bins, large, and extents from raw stats
const arenaAllocations = parseArenaAllocations(rawStats);
const arenaBins = parseArenaBins(rawStats);
const arenaLarge = parseArenaLarge(rawStats);
const arenaExtents = parseArenaExtents(rawStats);
// Use global data (before any arena)
const allocations = arenaAllocations.global || { small: null, large: null, total: null };
const bins = arenaBins.global || [];
const large = arenaLarge.global || [];
const extents = arenaExtents.global || [];
// Store data for sortable tables (global / Allocations tab), tagging each item with _idx
bins.forEach((b, i) => { b._idx = i; });
large.forEach((l, i) => { l._idx = i; });
extents.forEach((e, i) => { e._idx = i; });
_tableData['bins-table-alloc'] = bins; _tableRenderFns['bins-table-alloc'] = renderBinRow;
_tableData['large-table-alloc'] = large; _tableRenderFns['large-table-alloc'] = renderLargeRow;
_tableData['extents-table-alloc'] = extents; _tableRenderFns['extents-table-alloc'] = renderExtentRow;
container.innerHTML = `
${renderBinHeatmapSplitView(bins)}
<h3 style="margin: 1rem 0 0.5rem 0;">Allocations</h3>
<div style="max-height: 300px; overflow-x: auto; overflow-y: auto;">
<table>
${getAllocationsTableHeader()}
<tbody>
${renderAllocationRow('small', allocations.small)}
${renderAllocationRow('large', allocations.large)}
${renderAllocationRow('total', allocations.total)}
</tbody>
</table>
</div>
${bins.length > 0 ? `
<h3 style="margin: 1rem 0 0.5rem 0;">Bins</h3>
<div style="max-height: 400px; overflow-x: auto; overflow-y: auto;">
<table id="bins-table-alloc">
${getBinsTableHeader('bins-table-alloc')}
<tbody>
${applySortToData('bins-table-alloc', bins).map(renderBinRow).join('')}
</tbody>
</table>
</div>
` : '<p>No bin data available</p>'}
${large.length > 0 ? `
<h3 style="margin: 1rem 0 0.5rem 0;">Large Allocations</h3>
<div style="max-height: 400px; overflow-x: auto; overflow-y: auto;">
<table id="large-table-alloc">
${getLargeTableHeader('large-table-alloc')}
<tbody>
${applySortToData('large-table-alloc', large).map(renderLargeRow).join('')}
</tbody>
</table>
</div>
` : '<p>No large allocation data available</p>'}
${extents.length > 0 ? `
<h3 style="margin: 1rem 0 0.5rem 0;">Extents</h3>
<div style="max-height: 400px; overflow-x: auto; overflow-y: auto;">
<table id="extents-table-alloc">
${getExtentsTableHeader('extents-table-alloc')}
<tbody>
${applySortToData('extents-table-alloc', extents).map(renderExtentRow).join('')}
</tbody>
</table>
</div>
` : '<p>No extents data available</p>'}
`;
// Restore scroll positions after re-rendering
restoreScrollPositions(container, scrollPositions, [
{ name: 'allocations' },
{ name: 'bins' },
{ name: 'large' },
{ name: 'extents' }
]);
}
function parseOperationsStats(rawStats) {
const operations = {
backgroundThreads: null,
numRuns: null,
runInterval: null,
mutexStats: []
};
const lines = rawStats.replace(/\\n/g, '\n').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Parse background threads info
if (line.includes('Background threads:')) {
const match = line.match(/Background threads:\s*(\d+),\s*num_runs:\s*(\d+),\s*run_interval:\s*(\d+)\s*ns/);
if (match) {
operations.backgroundThreads = parseInt(match[1]);
operations.numRuns = parseInt(match[2]);
operations.runInterval = parseInt(match[3]);
}
}
// Parse mutex statistics
// Format: mutex_name n_lock_ops (#/sec) n_waiting (#/sec) n_spin_acq (#/sec) n_owner_switch (#/sec) total_wait_ns (#/sec) max_wait_ns max_n_thds
const mutexMatch = line.match(/^\s*(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
if (mutexMatch && !line.includes('n_lock_ops') && !line.includes('bins:')) {
const name = mutexMatch[1];
// Filter known mutex names
if (['background_thread', 'max_per_bg_thd', 'ctl', 'prof', 'prof_thds_data', 'prof_dump', 'prof_recent_alloc', 'prof_recent_dump', 'prof_stats'].includes(name)) {
operations.mutexStats.push({
name: name,
n_lock_ops: parseInt(mutexMatch[2]),
n_lock_ops_ps: parseInt(mutexMatch[3]),
n_waiting: parseInt(mutexMatch[4]),
n_waiting_ps: parseInt(mutexMatch[5]),
n_spin_acq: parseInt(mutexMatch[6]),
n_spin_acq_ps: parseInt(mutexMatch[7]),
n_owner_switch: parseInt(mutexMatch[8]),
n_owner_switch_ps: parseInt(mutexMatch[9]),
total_wait_ns: parseInt(mutexMatch[10]),
total_wait_ns_ps: parseInt(mutexMatch[11]),
max_wait_ns: parseInt(mutexMatch[12]),
max_n_thds: parseInt(mutexMatch[13])
});
}
}
}
return operations;
}
function renderOperationsStats(rawStats) {
const container = document.getElementById('operations-stats');
const operations = parseOperationsStats(rawStats);
if (!operations.backgroundThreads && operations.mutexStats.length === 0) {
container.innerHTML = '<p>No operations statistics available</p>';
return;
}
// Background thread info section
let backgroundInfo = '';
if (operations.backgroundThreads !== null) {
backgroundInfo = `
<div style="margin-bottom: 2rem;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div class="metric-card">
<div class="metric-label">Background Threads</div>
<div class="metric-value">${operations.backgroundThreads}</div>
</div>
<div class="metric-card">
<div class="metric-label">Num Runs</div>
<div class="metric-value">${formatNumber(operations.numRuns)}</div>
</div>
<div class="metric-card">
<div class="metric-label">Run Interval</div>
<div class="metric-value">${formatNumber(operations.runInterval)}<span class="metric-unit">ns</span></div>
</div>
</div>
</div>
`;
}
// Mutex statistics table
let mutexTable = '';
if (operations.mutexStats.length > 0) {
mutexTable = `
<h3 style="margin: 1rem 0 0.5rem 0;">Mutex Statistics</h3>
<div style="max-height: 500px; overflow-x: auto; overflow-y: auto;">
<table>
<thead style="position: sticky; top: 0; background: var(--table-header-bg); z-index: 10;">
<tr>
<th class="sticky-header-100">Mutex</th>
<th class="number">Lock Ops</th>
<th class="number">Lock Ops/s</th>
<th class="number">Waiting</th>
<th class="number">Waiting/s</th>
<th class="number">Spin Acq</th>
<th class="number">Spin Acq/s</th>
<th class="number">Owner Switch</th>
<th class="number">Owner Switch/s</th>
<th class="number">Total Wait (ns)</th>
<th class="number">Total Wait/s</th>
<th class="number">Max Wait (ns)</th>
<th class="number">Max Threads</th>
</tr>
</thead>
<tbody>
${operations.mutexStats.map(mutex => `
<tr>
<td class="sticky-cell-100">${mutex.name}</td>
<td class="number">${formatNumber(mutex.n_lock_ops)}</td>
<td class="number">${formatNumber(mutex.n_lock_ops_ps)}</td>
<td class="number">${formatNumber(mutex.n_waiting)}</td>
<td class="number">${formatNumber(mutex.n_waiting_ps)}</td>
<td class="number">${formatNumber(mutex.n_spin_acq)}</td>
<td class="number">${formatNumber(mutex.n_spin_acq_ps)}</td>
<td class="number">${formatNumber(mutex.n_owner_switch)}</td>
<td class="number">${formatNumber(mutex.n_owner_switch_ps)}</td>
<td class="number">${formatNumber(mutex.total_wait_ns)}</td>
<td class="number">${formatNumber(mutex.total_wait_ns_ps)}</td>
<td class="number">${formatNumber(mutex.max_wait_ns)}</td>
<td class="number">${formatNumber(mutex.max_n_thds)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
container.innerHTML = backgroundInfo + mutexTable;
}
async function checkProfilingEnabled() {
readConnectionParams();
try {
const query = "SELECT value FROM system.asynchronous_metrics WHERE metric = 'jemalloc.prof.thread_active_init'";
const response = await fetch(buildUrl(query), fetchOptions);
if (!response.ok) {
return null;
}
const text = await response.text();
return text.trim() === '1';
} catch (error) {
console.error('Error checking profiling status:', error);
return null;
}
}
function updateSymbolizationVisibility() {
const outputFormat = document.getElementById('profile-output-format').value;
const symbolizationContainer = document.getElementById('symbolization-mode-container');
const useCountContainer = document.getElementById('use-count-container');
if (outputFormat === 'Symbolized' || outputFormat === 'Collapsed') {
symbolizationContainer.style.display = 'flex';
} else {
symbolizationContainer.style.display = 'none';
}
useCountContainer.style.display = outputFormat === 'Collapsed' ? 'flex' : 'none';
}
let profileAbortController = null;
async function getProfile() {
readConnectionParams();
const button = document.getElementById('get-profile-btn');
const resultDiv = document.getElementById('profile-result');
// If already running, cancel it
if (profileAbortController) {
profileAbortController.abort();
profileAbortController = null;
button.textContent = 'Get Profile';
button.disabled = false;
resultDiv.innerHTML = '<p>Profile request cancelled.</p>';
return;
}
const outputFormat = document.getElementById('profile-output-format').value;
const symbolizeWithInline = document.getElementById('profile-symbolize-with-inline').checked;
button.disabled = false;
button.textContent = 'Cancel';
resultDiv.innerHTML = '<p>Fetching profile...</p>';
// Create abort controller for this request
profileAbortController = new AbortController();
try {
// Build settings based on selection (values must be lowercase)
const outputFormatLower = outputFormat.toLowerCase();
const useCount = document.getElementById('profile-collapsed-use-count').checked;
let settings = `jemalloc_profile_text_output_format = '${outputFormatLower}'`;
if (outputFormat === 'Symbolized' || outputFormat === 'Collapsed') {
settings += `, jemalloc_profile_text_symbolize_with_inline = ${symbolizeWithInline ? 1 : 0}`;
}
if (outputFormat === 'Collapsed') {
settings += `, jemalloc_profile_text_collapsed_use_count = ${useCount ? 1 : 0}`;
settings += `, max_block_size = 500`;
}
const query = `SELECT * FROM system.jemalloc_profile_text SETTINGS ${settings}`;
const response = await fetch(buildUrl(query), {
...fetchOptions,
signal: profileAbortController.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.text();
const lines = result ? result.split('\n') : [];
const label = `Profile Output (${outputFormat}${(outputFormat === 'Symbolized' || outputFormat === 'Collapsed') ? (symbolizeWithInline ? ' - with inline' : ' - no inline') : ''}${outputFormat === 'Collapsed' && useCount ? ' - count' : ''})`;
const flameGraphOnclick = outputFormat === 'Collapsed'
? `downloadFlameGraphFromProfile('profile-result', 'flamegraph', document.getElementById('profile-collapsed-use-count').checked ? 'allocations' : 'bytes')`
: null;
renderProfileOutputCard('profile-result', label, lines, outputFormat, flameGraphOnclick, 'jemalloc-profile');
} catch (error) {
console.error('Error getting profile:', error);
if (error.name === 'AbortError') {
resultDiv.innerHTML = `<p>Profile request cancelled.</p>`;
} else {
resultDiv.innerHTML = `<div class="error">Error: ${error.message}</div>`;
}
} finally {
profileAbortController = null;
button.disabled = false;
button.textContent = 'Get Profile';
}
}
async function renderProfileStats() {
const container = document.getElementById('profile-stats');
container.innerHTML = '<p>Checking profiling status...</p>';
const isEnabled = await checkProfilingEnabled();
if (isEnabled === null) {
container.innerHTML = `
<div class="metric-card" style="border-left: 4px solid var(--warning-color);">
<div class="metric-label">Profiling Status</div>
<div class="metric-value">Unknown</div>
<p style="margin-top: 1rem; font-size: 0.9rem;">
Unable to determine profiling status. Make sure the query has access to system.asynchronous_metrics.
</p>
</div>
`;
return;
}
const statusColor = isEnabled ? 'var(--success-color)' : 'var(--error-color)';
const statusText = isEnabled ? 'Enabled' : 'Disabled';
const statusIcon = isEnabled ? '✓' : '✗';
container.innerHTML = `
<div class="metric-card" style="border-left: 4px solid ${statusColor};">
<div class="metric-label">Global Profiler Status</div>
<div class="metric-value">${statusIcon} ${statusText}</div>
${isEnabled ? '<p style="margin-top: 1rem; font-size: 0.9rem;">Global heap profiling is active. Note that per-query profiling can still be enabled independently.</p>' : '<p style="margin-top: 1rem; font-size: 0.9rem;">Global heap profiling is not enabled. To enable it, set <code style="background: var(--table-header-bg); padding: 0.2rem 0.4rem; border-radius: 3px;">jemalloc_enable_global_profiler</code> in server config and restart the instance. Note that per-query profiling can still be used independently.</p>'}
</div>
<div style="margin-top: 2rem;">
<div style="display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: 0.5rem;">
<div style="display: flex; flex-direction: column; gap: 0.3rem;">
<label for="profile-output-format" style="font-size: 0.85rem; font-weight: 600; color: var(--color);">Output Format</label>
<select id="profile-output-format" onchange="updateSymbolizationVisibility()" style="padding: 0.5rem 0.75rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--card-background); color: var(--color); font-size: 0.9rem; cursor: pointer; min-width: 140px;">
<option value="Raw">Raw</option>
<option value="Symbolized">Symbolized</option>
<option value="Collapsed" selected>Collapsed</option>
</select>
</div>
<div id="symbolization-mode-container" style="display: flex; flex-direction: column; gap: 0.3rem;">
<label style="display: flex; align-items: center; gap: 0.6rem; cursor: pointer; font-size: 0.9rem; user-select: none; height: 38px;">
<span class="toggle-switch">
<input type="checkbox" id="profile-symbolize-with-inline" checked onchange="document.getElementById('inline-warning').style.display = this.checked ? 'none' : ''">
<span class="toggle-slider"></span>
</span>
Symbolize with inline
</label>
</div>
<div id="use-count-container" style="display: flex; flex-direction: column; gap: 0.3rem;">
<label style="display: flex; align-items: center; gap: 0.6rem; cursor: pointer; font-size: 0.9rem; user-select: none; height: 38px;">
<span class="toggle-switch">
<input type="checkbox" id="profile-collapsed-use-count">
<span class="toggle-slider"></span>
</span>
Count allocations (not bytes)
</label>
</div>
<button id="get-profile-btn" class="btn btn-primary" onclick="getProfile()" style="padding: 0.5rem 1.5rem; height: 38px;">Get Profile</button>
</div>
<div id="inline-warning" style="display: none; margin-bottom: 0.5rem; font-size: 0.82rem; color: var(--warning-color);">&#9888; Inline frames are disabled. The profile will be produced faster but may lose precision.</div>
<p style="margin-top: 0.5rem; font-size: 0.85rem; opacity: 0.8;">
Retrieves the jemalloc heap profile with the selected output format. Works with both global and per-query profiling.
</p>
<div style="margin-top: 0.75rem; padding: 0.6rem 1rem; border-left: 4px solid var(--success-color); background: var(--table-header-bg); border-radius: 0 4px 4px 0; font-size: 0.85rem; line-height: 1.6;">
The <strong>Collapsed</strong> format is best for analysis with AI tools.
For help analyzing it with Claude, you can fetch the
<a href="https://github.com/ClickHouse/ClickHouse/blob/master/.claude/skills/alloc-profile/SKILL.md" target="_blank" style="color: var(--link-color);">alloc-profile skill</a>
into your Claude Code project and use it with <code style="background: var(--card-background); padding: 0.1rem 0.3rem; border-radius: 3px;">/alloc-profile</code>.
</div>
</div>
<div id="profile-result"></div>
`;
}
async function renderQueryProfileStats() {
const container = document.getElementById('query-profile-stats');
container.innerHTML = `
<div class="metric-card">
<div class="metric-label">Query Profiler</div>
<p style="margin-top: 1rem; font-size: 0.9rem; line-height: 1.6;">
This can be used to profile queries that were run with the following settings:
</p>
<pre style="margin-top: 0.5rem; background: var(--table-header-bg); padding: 1rem; border-radius: 4px; overflow-x: auto; font-size: 0.85rem;">SETTINGS jemalloc_enable_profiler = 1,
jemalloc_collect_profile_samples_in_trace_log = 1</pre>
<p style="margin-top: 1rem; font-size: 0.9rem; line-height: 1.6;">
Example query:
</p>
<pre style="margin-top: 0.5rem; background: var(--table-header-bg); padding: 1rem; border-radius: 4px; overflow-x: auto; font-size: 0.85rem;">SELECT count() FROM system.numbers LIMIT 1000000
SETTINGS jemalloc_enable_profiler = 1,
jemalloc_collect_profile_samples_in_trace_log = 1</pre>
</div>
<div style="margin-top: 2rem;">
<div style="display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: 1rem;">
<div style="display: flex; flex-direction: column; gap: 0.3rem; flex: 1; min-width: 300px;">
<label for="query-profile-id" style="font-size: 0.85rem; font-weight: 600; color: var(--color);">Query ID</label>
<input type="text" id="query-profile-id" placeholder="Enter query_id..." style="padding: 0.5rem 0.75rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--card-background); color: var(--color); font-size: 0.9rem; font-family: monospace;">
</div>
<button id="query-profile-btn" class="btn btn-primary" onclick="getQueryProfile()" style="padding: 0.5rem 1.5rem; height: 38px;">Profile</button>
</div>
</div>
<div id="query-profile-result"></div>
`;
}
async function getQueryProfile() {
readConnectionParams();
const button = document.getElementById('query-profile-btn');
const resultDiv = document.getElementById('query-profile-result');
const queryIdInput = document.getElementById('query-profile-id');
const queryId = queryIdInput.value.trim();
if (!queryId) {
alert('Please enter a query ID');
return;
}
button.disabled = true;
button.textContent = 'Getting profile...';
resultDiv.innerHTML = '<p>Fetching profile data...</p>';
try {
// First, flush the logs to ensure all data is written
const flushQuery = 'SYSTEM FLUSH LOGS';
const flushResponse = await fetch(buildUrl(flushQuery), fetchOptions);
const flushFailed = !flushResponse.ok;
if (flushFailed) {
resultDiv.innerHTML = '<p style="color: var(--warning-color);">⚠ Could not flush logs. There may be a delay before recent queries appear in trace_log.</p>';
}
// Query to get min and max event_time_microseconds for JemallocSample events
const query = `SELECT toUnixTimestamp64Micro(min(event_time_microseconds)) AS min_time, toUnixTimestamp64Micro(max(event_time_microseconds)) AS max_time FROM system.trace_log WHERE query_id = '${queryId}' AND trace_type = 'JemallocSample'`;
const response = await fetch(buildUrl(query), fetchOptions);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.text();
const lines = result.trim().split('\n');
const [minTime, maxTime] = (lines[0] || '').split('\t');
if (!minTime || !maxTime || (minTime === '0' && maxTime === '0')) {
const msg = flushFailed
? '<p style="color: var(--warning-color);">⚠ Could not flush logs. There may be a delay before recent queries appear in trace_log.</p>'
: '';
resultDiv.innerHTML = msg + `<div class="error">No JemallocSample events found for query_id: ${queryId}</div>`;
return;
}
// Now get cumulative memory usage over time with at most 10000 points
const maxPoints = 10000;
const timeRange = BigInt(maxTime) - BigInt(minTime);
// Use Math.ceil to round up, ensuring we don't exceed maxPoints
const bucketSize = timeRange > BigInt(0) ? Math.max(1, Math.ceil(Number(timeRange) / maxPoints)) : 1;
const cumulativeQuery = `WITH per_bucket AS (SELECT intDiv(toUnixTimestamp64Micro(event_time_microseconds) - ${minTime}, ${bucketSize}) * ${bucketSize} + ${minTime} AS bucket_time, sum(size) AS bucket_sum FROM system.trace_log WHERE trace_type = 'JemallocSample' AND query_id = '${queryId}' GROUP BY bucket_time) SELECT bucket_time, sum(bucket_sum) OVER (ORDER BY bucket_time ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_size FROM per_bucket ORDER BY bucket_time`;
const cumulativeResponse = await fetch(buildUrl(cumulativeQuery), fetchOptions);
if (!cumulativeResponse.ok) {
throw new Error(`HTTP ${cumulativeResponse.status}: ${cumulativeResponse.statusText}`);
}
const cumulativeResult = await cumulativeResponse.text();
const cumulativeLines = cumulativeResult.trim().split('\n');
// Parse data for plotting
const dataPoints = cumulativeLines.map(line => {
const [time, size] = line.split('\t');
return { time: Number(time), size: Number(size) };
});
// Get max memory usage and its timepoint
const maxMemQuery = `SELECT argMax(bucket_time, cumulative_size), max(cumulative_size) FROM (WITH per_bucket AS (SELECT event_time_microseconds AS bucket_time, sum(size) AS bucket_sum FROM system.trace_log WHERE trace_type = 'JemallocSample' AND query_id = '${queryId}' GROUP BY bucket_time) SELECT bucket_time, sum(bucket_sum) OVER (ORDER BY bucket_time ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_size, formatReadableSize(cumulative_size) AS cumulative_size_readable FROM per_bucket ORDER BY bucket_time)`;
const maxMemResponse = await fetch(buildUrl(maxMemQuery), fetchOptions);
if (!maxMemResponse.ok) {
throw new Error(`HTTP ${maxMemResponse.status}: ${maxMemResponse.statusText}`);
}
const maxMemResult = await maxMemResponse.text();
const [maxMemTime, maxMemSize] = maxMemResult.trim().split('\t');
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
}
function formatTime(timestamp) {
// If it's already a DateTime64 string, use it as-is
if (typeof timestamp === 'string' && timestamp.includes('-')) {
return timestamp;
}
// Otherwise convert from microsecond timestamp
const date = new Date(Number(timestamp) / 1000);
return date.toISOString().replace('T', ' ').replace('Z', '');
}
resultDiv.innerHTML = `
<div class="metric-card" style="margin-top: 1rem;">
<div class="metric-label">Overview</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-top: 1rem;">
<div>
<div style="font-size: 0.75rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.5px;">First Sample</div>
<div style="font-family: monospace; font-size: 0.85rem; margin-top: 0.25rem;">${formatTime(minTime)}</div>
</div>
<div>
<div style="font-size: 0.75rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.5px;">Last Sample</div>
<div style="font-family: monospace; font-size: 0.85rem; margin-top: 0.25rem;">${formatTime(maxTime)}</div>
</div>
<div>
<div style="font-size: 0.75rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.5px;">Peak Memory</div>
<div style="font-family: monospace; font-size: 0.85rem; margin-top: 0.25rem;">${formatTime(maxMemTime)}</div>
<div style="font-size: 0.85rem; margin-top: 0.25rem;">${formatBytes(maxMemSize)}</div>
</div>
</div>
</div>
<div class="metric-card" style="margin-top: 1rem;">
<div class="metric-label">Memory Usage Over Time</div>
<div id="memory-chart" style="margin-top: 1rem; height: 400px;"></div>
</div>
<div class="metric-card" style="margin-top: 1rem;">
<div class="metric-label">Flame Graph</div>
<div style="margin-top: 1rem; display: flex; gap: 1rem; align-items: flex-end;">
<div style="flex: 1;">
<label for="flamegraph-time" style="display: block; font-size: 0.75rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 0.5rem;">Time</label>
<input type="text" id="flamegraph-time" value="${formatTime(maxMemTime)}" style="width: 100%; padding: 0.5rem; font-family: monospace; font-size: 0.85rem; background: var(--input-background); color: var(--color); border: 1px solid var(--border-color); border-radius: 4px;">
</div>
<button id="generate-query-flamegraph-btn" onclick="getQueryCollapsedProfile()" class="btn btn-primary" style="padding: 0.5rem 1rem; white-space: nowrap;">Get Profile</button>
</div>
<div id="query-flamegraph-result"></div>
</div>
`;
// Prepare data for uPlot
const times = dataPoints.map(p => p.time / 1000000); // Convert microseconds to seconds
const sizes = dataPoints.map(p => p.size);
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
}
// Create uPlot chart
const chartEl = document.getElementById('memory-chart');
const opts = {
width: chartEl.clientWidth,
height: 400,
padding: [null, 60, null, null],
scales: {
x: { time: true },
y: { range: (u, dataMin, dataMax) => [0, dataMax * 1.1] }
},
axes: [
{
stroke: getComputedStyle(document.body).getPropertyValue('--color'),
grid: { stroke: getComputedStyle(document.body).getPropertyValue('--border-color'), width: 1 / devicePixelRatio },
splits: (u, axisIdx, scaleMin, scaleMax, foundIncr, foundSpace, forceMin) => [scaleMin, scaleMax],
values: (u, splits) => splits.map(v => new Date(v * 1000).toISOString().substring(0, 19).replace('T', ' '))
},
{
stroke: getComputedStyle(document.body).getPropertyValue('--color'),
values: (u, vals) => vals.map(v => formatBytes(v)),
grid: { stroke: getComputedStyle(document.body).getPropertyValue('--border-color'), width: 1 / devicePixelRatio },
size: 80
}
],
series: [
{
label: 'Time',
value: (u, v) => v == null ? '-' : new Date(v * 1000).toISOString().replace('T', ' ').replace('Z', ' UTC')
},
{
label: 'Cumulative Memory',
stroke: 'rgb(0, 200, 100)',
width: 2,
points: { show: false },
value: (u, v) => v == null ? '-' : formatBytes(v)
}
]
};
new uPlot(opts, [times, sizes], chartEl);
} catch (error) {
console.error('Error getting query profile:', error);
resultDiv.innerHTML = `<div class="error">Error: ${error.message}</div>`;
} finally {
button.disabled = false;
button.textContent = 'Profile';
}
}
async function getQueryCollapsedProfile() {
readConnectionParams();
const button = document.getElementById('generate-query-flamegraph-btn');
const queryIdInput = document.getElementById('query-profile-id');
const timeInput = document.getElementById('flamegraph-time');
const queryId = queryIdInput.value.trim();
const time = timeInput.value.trim();
const resultDiv = document.getElementById('query-flamegraph-result');
if (!queryId) {
alert('Please enter a query ID first and click Profile to load the data.');
return;
}
if (!time) {
alert('Please enter a time.');
return;
}
button.disabled = true;
button.textContent = 'Getting profile...';
try {
// Query to get collapsed flame graph data
const query = `SELECT arrayJoin(flameGraph(arrayReverse(trace), size, ptr)) FROM system.trace_log WHERE trace_type = 'JemallocSample' AND query_id = '${queryId}' AND event_time_microseconds <= '${time}' SETTINGS allow_introspection_functions = 1`;
const response = await fetch(buildUrl(query), fetchOptions);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const collapsedData = await response.text();
if (!collapsedData || collapsedData.trim() === '') {
resultDiv.innerHTML = `<div class="error" style="margin-top: 1rem;">No profile data found for the specified query ID and time.</div>`;
return;
}
const lines = collapsedData.split('\n');
const flameGraphOnclick = `downloadFlameGraphFromProfile('query-flamegraph-result', 'query-flamegraph-' + document.getElementById('query-profile-id').value.trim())`;
renderProfileOutputCard('query-flamegraph-result', 'Profile Output (Collapsed)', lines, 'Collapsed', flameGraphOnclick, 'query-profile-' + queryId);
} catch (error) {
console.error('Error getting query profile:', error);
resultDiv.innerHTML = `<div class="error" style="margin-top: 1rem;">Error: ${error.message}</div>`;
} finally {
button.disabled = false;
button.textContent = 'Get Profile';
}
}
async function loadStats(isManualRefresh = false) {
readConnectionParams();
const loading = document.getElementById('loading');
const content = document.getElementById('content');
const errorDiv = document.getElementById('error');
// Only show loading on first load
const isFirstLoad = content.style.display === 'none';
if (isFirstLoad) {
loading.style.display = 'block';
content.style.display = 'none';
}
errorDiv.style.display = 'none';
try {
const query = 'SELECT * FROM system.jemalloc_stats';
const response = await fetch(buildUrl(query), fetchOptions);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const statsText = await response.text();
const stats = parseJemallocStats(statsText);
// Update timestamp
document.getElementById('timestamp').textContent = new Date().toLocaleString();
// Render all sections (now optimized to only update values)
renderOverviewMetrics(stats.overview);
renderMemoryOverview(stats.overview);
renderBinStats(stats.raw, stats.arenas);
renderArenaStats(stats.arenas, stats.raw);
renderOperationsStats(stats.raw);
// Only check profile status on first load or manual refresh
if (isFirstLoad || isManualRefresh) {
renderProfileStats();
renderQueryProfileStats();
}
// Only update raw stats on manual refresh or first load
const rawStatsEl = document.getElementById('raw-stats');
rawStatsEl.textContent = stats.raw.replace(/\\n/g, '\n');
if (isFirstLoad) {
loading.style.display = 'none';
content.style.display = 'block';
}
} catch (error) {
console.error('Error loading stats:', error);
errorDiv.textContent = `Error: ${error.message}`;
errorDiv.style.display = 'block';
loading.style.display = 'none';
}
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
document.getElementById('theme-icon').textContent = savedTheme === 'dark' ? '☀️' : '🌙';
// Auto-refresh interval management
let refreshIntervalId = null;
let debounceTimer = null;
function updateRefreshInterval() {
const intervalInput = document.getElementById('refresh-interval');
const seconds = parseInt(intervalInput.value) || 0;
// Clear existing interval
if (refreshIntervalId) {
clearInterval(refreshIntervalId);
refreshIntervalId = null;
}
// Set new interval if seconds > 0
if (seconds > 0) {
refreshIntervalId = setInterval(loadStats, seconds * 1000);
}
}
function debouncedUpdateRefreshInterval() {
// Clear existing debounce timer
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Set new debounce timer
debounceTimer = setTimeout(() => {
updateRefreshInterval();
}, 500);
}
// Flame Graph Generation - generates and downloads SVG file directly
function generateFlameGraphSVG(collapsedData, imagewidth = 1200, countname = 'bytes') {
// Configuration
const fonttype = "Verdana";
const frameheight = 16;
const fontsize = 12;
const fontwidth = 0.59;
const minwidth = 0.1;
const nametype = "Function:";
const xpad = 10;
const framepad = 1;
const ypad1 = fontsize * 3;
const ypad2 = fontsize * 2 + 10;
const titletext = "Heap Profile Flame Graph";
const searchcolor = "rgb(230,0,230)";
// Parse input data
const lines = collapsedData.trim().split('\n').filter(line => line.trim());
const Node = {};
const Tmp = {};
let time = 0;
let depthmax = 0;
// Sort the data
lines.sort();
// Process and merge frames
let last = [];
for (const line of lines) {
const match = line.match(/^(.*)\s+(\d+(?:\.\d*)?)$/);
if (!match) continue;
const [, stack, samplesStr] = match;
const samples = parseFloat(samplesStr);
const current = ['', ...stack.split(';')];
// Merge frames
flow(last, current, time, Node, Tmp);
time += samples;
last = current;
}
flow(last, [], time, Node, Tmp);
if (time === 0) {
return '<p>No valid stack data found</p>';
}
const timemax = time;
const widthpertime = (imagewidth - 2 * xpad) / timemax;
const minwidth_time = minwidth / widthpertime;
// Prune narrow blocks and find max depth
for (const id in Node) {
const [func, depth, etime] = id.split(';');
const node = Node[id];
const stime = node.stime;
if ((etime - stime) < minwidth_time) {
delete Node[id];
continue;
}
depthmax = Math.max(depthmax, parseInt(depth));
}
// Generate SVG
const imageheight = ((depthmax + 1) * frameheight) + ypad1 + ypad2;
const titlesize = fontsize + 5;
let svg = '';
svg += `<?xml version="1.0" standalone="no"?>\n`;
svg += `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n`;
svg += `<svg version="1.1" width="${imagewidth}" height="${imageheight}" onload="init(evt)" viewBox="0 0 ${imagewidth} ${imageheight}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">\n`;
svg += `<!-- Flame graph stack visualization. See https://github.com/brendangregg/FlameGraph for latest version, and http://www.brendangregg.com/flamegraphs.html for examples. -->\n`;
svg += `<!-- NOTES: -->\n`;
// Background gradient (before styles)
svg += `<defs>\n`;
svg += `\t<linearGradient id="background" y1="0" y2="1" x1="0" x2="0" >\n`;
svg += `\t\t<stop stop-color="#eef2ee" offset="5%" />\n`;
svg += `\t\t<stop stop-color="#e0ffe0" offset="95%" />\n`;
svg += `\t</linearGradient>\n`;
svg += `</defs>\n`;
// Add styles and JavaScript
svg += generateFlameGraphStyles(fonttype, fontsize, titlesize, searchcolor);
svg += generateFlameGraphScript(nametype, fontsize, fontwidth, xpad, searchcolor);
svg += `<rect x="0" y="0" width="${imagewidth}" height="${imageheight}" fill="url(#background)" />\n`;
svg += `<text id="title" x="${imagewidth / 2}" y="${fontsize * 2}" text-anchor="middle" font-size="${titlesize}px">${titletext}</text>\n`;
svg += `<text id="details" x="${xpad}" y="${imageheight - (ypad2 / 2)}"> </text>\n`;
svg += `<text id="unzoom" class="hide" x="${xpad}" y="${fontsize * 2}">Reset Zoom</text>\n`;
svg += `<text id="search" x="${imagewidth - xpad - 100}" y="${fontsize * 2}">Search</text>\n`;
svg += `<text id="ignorecase" x="${imagewidth - xpad - 16}" y="${fontsize * 2}">ic</text>\n`;
svg += `<text id="matched" class="hide" x="${imagewidth - xpad - 100}" y="${imageheight - (ypad2 / 2)}"> </text>\n`;
// Draw frames
svg += `<g id="frames">\n`;
for (const id in Node) {
const [func, depth, etime] = id.split(';');
const node = Node[id];
const stime = node.stime;
const etimeVal = func === '' && depth === '0' ? timemax : parseFloat(etime);
const depthVal = parseInt(depth);
const x1 = xpad + stime * widthpertime;
const x2 = xpad + etimeVal * widthpertime;
const y1 = imageheight - ypad2 - (depthVal + 1) * frameheight + framepad;
const y2 = imageheight - ypad2 - depthVal * frameheight;
const samples = Math.round((etimeVal - stime));
const samplesText = samples.toLocaleString();
let info;
if (func === '' && depthVal === 0) {
info = `all (${samplesText} ${countname}, 100%)`;
} else {
const pct = ((100 * samples) / timemax).toFixed(2);
const escapedFunc = escapeHtml(func);
info = `${escapedFunc} (${samplesText} ${countname}, ${pct}%)`;
}
const color = getMemColor(func);
const width = x2 - x1;
const chars = Math.floor(width / (fontsize * fontwidth));
let text = '';
if (chars >= 3) {
text = func.substring(0, chars);
if (chars < func.length) {
text = text.substring(0, chars - 2) + '..';
}
text = escapeHtml(text);
}
svg += `<g >\n`;
svg += `<title>${info}</title><rect x="${x1.toFixed(1)}" y="${y1.toFixed(1)}" width="${width.toFixed(1)}" height="${(y2 - y1).toFixed(1)}" fill="${color}" rx="2" ry="2" />\n`;
svg += `<text x="${(x1 + 3).toFixed(2)}" y="${(3 + (y1 + y2) / 2).toFixed(1)}" >${text}</text>\n`;
svg += `</g>\n`;
}
svg += `</g>\n`;
svg += `</svg>`;
return svg;
}
function flow(last, current, time, Node, Tmp) {
const len_a = last.length - 1;
const len_b = current.length - 1;
let i = 0;
for (; i <= len_a; i++) {
if (i > len_b) break;
if (last[i] !== current[i]) break;
}
const len_same = i;
// Close frames from previous stack
for (i = len_a; i >= len_same; i--) {
const k = `${last[i]};${i}`;
const key = `${k};${time}`;
if (Tmp[k]) {
Node[key] = { stime: Tmp[k].stime };
delete Tmp[k];
}
}
// Open frames for current stack
for (i = len_same; i <= len_b; i++) {
const k = `${current[i]};${i}`;
Tmp[k] = { stime: time };
}
}
function getMemColor(name) {
if (name === '--') return 'rgb(160,160,160)';
if (name === '-') return 'rgb(200,200,200)';
const v1 = namehash(name);
const v2 = namehash(name.split('').reverse().join(''));
const r = 0;
const g = 190 + Math.floor(50 * v2);
const b = 0 + Math.floor(210 * v1);
return `rgb(${r},${g},${b})`;
}
function namehash(name) {
let vector = 0;
let weight = 1;
let max = 1;
let mod = 10;
// If module name present, truncate to first char
name = name.replace(/^.+?`/, '');
for (let j = 0; j < name.length && mod <= 12; j++) {
const i = name.charCodeAt(j) % mod;
vector += (i / (mod - 1)) * weight;
max += 1 * weight;
weight *= 0.70;
mod++;
}
return 1 - vector / max;
}
function escapeHtml(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function generateFlameGraphStyles(fonttype, fontsize, titlesize, searchcolor) {
return `<style type="text/css">
\ttext { font-family:${fonttype}; font-size:${fontsize}px; fill:rgb(0,0,0); }
\t#search, #ignorecase { opacity:0.1; cursor:pointer; }
\t#search:hover, #search.show, #ignorecase:hover, #ignorecase.show { opacity:1; }
\t#subtitle { text-anchor:middle; font-color:rgb(160,160,160); }
\t#title { text-anchor:middle; font-size:${titlesize}px}
\t#unzoom { cursor:pointer; }
\t#frames > *:hover { stroke:black; stroke-width:0.5; cursor:pointer; }
\t.hide { display:none; }
\t.parent { opacity:0.5; }
</style>\n`;
}
function generateFlameGraphScript(nametype, fontsize, fontwidth, xpad, searchcolor) {
return `<script type="text/ecmascript"><![CDATA[
\t"use strict";
\tvar details, searchbtn, unzoombtn, matchedtxt, svg, searching, currentSearchTerm, ignorecase, ignorecaseBtn;
\tfunction init(evt) {
\t\tdetails = document.getElementById("details").firstChild;
\t\tsearchbtn = document.getElementById("search");
\t\tignorecaseBtn = document.getElementById("ignorecase");
\t\tunzoombtn = document.getElementById("unzoom");
\t\tmatchedtxt = document.getElementById("matched");
\t\tsvg = document.getElementsByTagName("svg")[0];
\t\tsearching = 0;
\t\tcurrentSearchTerm = null;
\t\t// use GET parameters to restore a flamegraphs state.
\t\tvar params = get_params();
\t\tif (params.x && params.y)
\t\t\tzoom(find_group(document.querySelector('[x="' + params.x + '"][y="' + params.y + '"]')));
if (params.s) search(params.s);
\t}
\t// event listeners
\twindow.addEventListener("click", function(e) {
\t\tvar target = find_group(e.target);
\t\tif (target) {
\t\t\tif (target.nodeName == "a") {
\t\t\t\tif (e.ctrlKey === false) return;
\t\t\t\te.preventDefault();
\t\t\t}
\t\t\tif (target.classList.contains("parent")) unzoom(true);
\t\t\tzoom(target);
\t\t\tif (!document.querySelector('.parent')) {
\t\t\t\t// we have basically done a clearzoom so clear the url
\t\t\t\tvar params = get_params();
\t\t\t\tif (params.x) delete params.x;
\t\t\t\tif (params.y) delete params.y;
\t\t\t\thistory.replaceState(null, null, parse_params(params));
\t\t\t\tunzoombtn.classList.add("hide");
\t\t\t\treturn;
\t\t\t}
\t\t\t// set parameters for zoom state
\t\t\tvar el = target.querySelector("rect");
\t\t\tif (el && el.attributes && el.attributes.y && el.attributes._orig_x) {
\t\t\t\tvar params = get_params()
\t\t\t\tparams.x = el.attributes._orig_x.value;
\t\t\t\tparams.y = el.attributes.y.value;
\t\t\t\thistory.replaceState(null, null, parse_params(params));
\t\t\t}
\t\t}
\t\telse if (e.target.id == "unzoom") clearzoom();
\t\telse if (e.target.id == "search") search_prompt();
\t\telse if (e.target.id == "ignorecase") toggle_ignorecase();
\t}, false)
\t// mouse-over for info
\t// show
\twindow.addEventListener("mouseover", function(e) {
\t\tvar target = find_group(e.target);
\t\tif (target) details.nodeValue = "${nametype} " + g_to_text(target);
\t}, false)
\t// clear
\twindow.addEventListener("mouseout", function(e) {
\t\tvar target = find_group(e.target);
\t\tif (target) details.nodeValue = ' ';
\t}, false)
\t// ctrl-F for search
\t// ctrl-I to toggle case-sensitive search
\twindow.addEventListener("keydown",function (e) {
\t\tif (e.keyCode === 114 || (e.ctrlKey && e.keyCode === 70)) {
\t\t\te.preventDefault();
\t\t\tsearch_prompt();
\t\t}
\t\telse if (e.ctrlKey && e.keyCode === 73) {
\t\t\te.preventDefault();
\t\t\ttoggle_ignorecase();
\t\t}
\t}, false)
\t// functions
\tfunction get_params() {
\t\tvar params = {};
\t\tvar paramsarr = window.location.search.substr(1).split('&');
\t\tfor (var i = 0; i < paramsarr.length; ++i) {
\t\t\tvar tmp = paramsarr[i].split("=");
\t\t\tif (!tmp[0] || !tmp[1]) continue;
\t\t\tparams[tmp[0]] = decodeURIComponent(tmp[1]);
\t\t}
\t\treturn params;
\t}
\tfunction parse_params(params) {
\t\tvar uri = "?";
\t\tfor (var key in params) {
\t\t\turi += key + '=' + encodeURIComponent(params[key]) + '&';
\t\t}
\t\tif (uri.slice(-1) == "&")
\t\t\turi = uri.substring(0, uri.length - 1);
\t\tif (uri == '?')
\t\t\turi = window.location.href.split('?')[0];
\t\treturn uri;
\t}
\tfunction find_child(node, selector) {
\t\tvar children = node.querySelectorAll(selector);
\t\tif (children.length) return children[0];
\t}
\tfunction find_group(node) {
\t\tvar parent = node.parentElement;
\t\tif (!parent) return;
\t\tif (parent.id == "frames") return node;
\t\treturn find_group(parent);
\t}
\tfunction orig_save(e, attr, val) {
\t\tif (e.attributes["_orig_" + attr] != undefined) return;
\t\tif (e.attributes[attr] == undefined) return;
\t\tif (val == undefined) val = e.attributes[attr].value;
\t\te.setAttribute("_orig_" + attr, val);
\t}
\tfunction orig_load(e, attr) {
\t\tif (e.attributes["_orig_"+attr] == undefined) return;
\t\te.attributes[attr].value = e.attributes["_orig_" + attr].value;
\t\te.removeAttribute("_orig_"+attr);
\t}
\tfunction g_to_text(e) {
\t\tvar text = find_child(e, "title").firstChild.nodeValue;
\t\treturn (text)
\t}
\tfunction g_to_func(e) {
\t\tvar func = g_to_text(e);
\t\treturn (func);
\t}
function update_text(e) {
var r = find_child(e, "rect");
var t = find_child(e, "text");
var w = parseFloat(r.attributes.width.value) -3;
var txt = find_child(e, "title").textContent.replace(/\\([^(]*\\)\$/,"");
t.attributes.x.value = parseFloat(r.attributes.x.value) + 3;
if (w < 2 * ${fontsize} * ${fontwidth}) {
t.textContent = "";
return;
}
t.textContent = txt;
var sl = t.getSubStringLength(0, txt.length);
if (/^ *\$/.test(txt) || sl < w)
return;
var start = Math.floor((w/sl) * txt.length);
for (var x = start; x > 0; x = x-2) {
if (t.getSubStringLength(0, x + 2) <= w) {
t.textContent = txt.substring(0, x) + "..";
return;
}
}
t.textContent = "";
}
function zoom_reset(e) {
if (e.attributes != undefined) {
orig_load(e, "x");
orig_load(e, "width");
}
if (e.childNodes == undefined) return;
for (var i = 0, c = e.childNodes; i < c.length; i++) {
zoom_reset(c[i]);
}
}
function zoom_child(e, x, ratio) {
if (e.attributes != undefined) {
if (e.attributes.x != undefined) {
orig_save(e, "x");
e.attributes.x.value = (parseFloat(e.attributes.x.value) - x - ${xpad}) * ratio + ${xpad};
if (e.tagName == "text")
e.attributes.x.value = find_child(e.parentNode, "rect[x]").attributes.x.value + 3;
}
if (e.attributes.width != undefined) {
orig_save(e, "width");
e.attributes.width.value = parseFloat(e.attributes.width.value) * ratio;
}
}
if (e.childNodes == undefined) return;
for (var i = 0, c = e.childNodes; i < c.length; i++) {
zoom_child(c[i], x - ${xpad}, ratio);
}
}
function zoom_parent(e) {
if (e.attributes) {
if (e.attributes.x != undefined) {
orig_save(e, "x");
e.attributes.x.value = ${xpad};
}
if (e.attributes.width != undefined) {
orig_save(e, "width");
e.attributes.width.value = parseInt(svg.width.baseVal.value) - (${xpad} * 2);
}
}
if (e.childNodes == undefined) return;
for (var i = 0, c = e.childNodes; i < c.length; i++) {
zoom_parent(c[i]);
}
}
function zoom(node) {
var attr = find_child(node, "rect").attributes;
var width = parseFloat(attr.width.value);
var xmin = parseFloat(attr.x.value);
var xmax = parseFloat(xmin + width);
var ymin = parseFloat(attr.y.value);
var ratio = (svg.width.baseVal.value - 2 * ${xpad}) / width;
var fudge = 0.0001;
unzoombtn.classList.remove("hide");
var el = document.getElementById("frames").children;
for (var i = 0; i < el.length; i++) {
var e = el[i];
var a = find_child(e, "rect").attributes;
var ex = parseFloat(a.x.value);
var ew = parseFloat(a.width.value);
var upstack;
// Is it an ancestor
if (0 == 0) {
upstack = parseFloat(a.y.value) > ymin;
} else {
upstack = parseFloat(a.y.value) < ymin;
}
if (upstack) {
// Direct ancestor
if (ex <= xmin && (ex+ew+fudge) >= xmax) {
e.classList.add("parent");
zoom_parent(e);
update_text(e);
}
// not in current path
else
e.classList.add("hide");
}
// Children maybe
else {
// no common path
if (ex < xmin || ex + fudge >= xmax) {
e.classList.add("hide");
}
else {
zoom_child(e, xmin, ratio);
update_text(e);
}
}
}
search();
}
function unzoom(dont_update_text) {
unzoombtn.classList.add("hide");
var el = document.getElementById("frames").children;
for(var i = 0; i < el.length; i++) {
el[i].classList.remove("parent");
el[i].classList.remove("hide");
zoom_reset(el[i]);
if(!dont_update_text) update_text(el[i]);
}
search();
}
function clearzoom() {
unzoom();
}
function toggle_ignorecase() {
ignorecase = !ignorecase;
if (ignorecase) {
ignorecaseBtn.classList.add("show");
} else {
ignorecaseBtn.classList.remove("show");
}
reset_search();
search();
}
function reset_search() {
var el = document.querySelectorAll("#frames rect");
for (var i = 0; i < el.length; i++) {
orig_load(el[i], "fill")
}
}
function search_prompt() {
if (!searching) {
var term = prompt("Enter a search term (regexp allowed, eg: ^ext4_)" + (ignorecase ? ", ignoring case" : "") + "\\nPress Ctrl-i to toggle case sensitivity", "");
if (term != null) search(term);
} else {
reset_search();
searching = 0;
currentSearchTerm = null;
searchbtn.classList.remove("show");
searchbtn.firstChild.nodeValue = "Search"
matchedtxt.classList.add("hide");
matchedtxt.firstChild.nodeValue = ""
}
}
function search(term) {
if (term) currentSearchTerm = term;
var re = new RegExp(currentSearchTerm, ignorecase ? 'i' : '');
var el = document.getElementById("frames").children;
var matches = new Object();
var maxwidth = 0;
for (var i = 0; i < el.length; i++) {
var e = el[i];
var func = g_to_func(e);
var rect = find_child(e, "rect");
if (func == null || rect == null)
continue;
var w = parseFloat(rect.attributes.width.value);
if (w > maxwidth)
maxwidth = w;
if (func.match(re)) {
var x = parseFloat(rect.attributes.x.value);
orig_save(rect, "fill");
rect.attributes.fill.value = "${searchcolor}";
if (matches[x] == undefined) {
matches[x] = w;
} else {
if (w > matches[x]) {
matches[x] = w;
}
}
searching = 1;
}
}
if (!searching)
return;
searchbtn.classList.add("show");
searchbtn.firstChild.nodeValue = "Reset Search";
var count = 0;
var lastx = -1;
var lastw = 0;
var keys = Array();
for (k in matches) {
if (matches.hasOwnProperty(k))
keys.push(k);
}
keys.sort(function(a, b){
return a - b;
});
var fudge = 0.0001;
for (var k in keys) {
var x = parseFloat(keys[k]);
var w = matches[keys[k]];
if (x >= lastx + lastw - fudge) {
count += w;
lastx = x;
lastw = w;
}
}
matchedtxt.classList.remove("hide");
var pct = 100 * count / maxwidth;
if (pct != 100) pct = pct.toFixed(1)
matchedtxt.firstChild.nodeValue = "Matched: " + pct + "%";
}
]]><\/script>\n`;
}
function start() {
document.getElementById('url').value = host;
document.getElementById('user').value = user;
document.getElementById('password').value = password;
loadStats();
updateRefreshInterval();
}
start();
</script>
</body>
</html>