Files
datastore/programs/server/processors_profile.html
T
Alexey MilovidovandClaude Opus 4.7 b0e9aa8328 Guard profileQuery against overlapping calls
Overlapping `profileQuery` calls (auto-select on `loadQueries`, picker
change) could complete out of order, letting a slower earlier call
overwrite the graph with stale data for a no-longer-selected query.

Each call now captures a monotonically increasing generation token on
entry and bails after every `await` if a newer call has been started.
`renderDot` accepts the same predicate so the actual SVG mutation is
also skipped once the call is stale.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-18 22:48:31 +00:00

968 lines
40 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 Processors Profile</title>
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDExIDEwIj48c3R5bGU+LmJne2ZpbGw6IzAwMH0ub3tmaWxsOiNmZjB9PC9zdHlsZT48cmVjdCBjbGFzcz0iYmciIHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48cGF0aCBkPSJNMSwxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik0zLDEgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTUsMSBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNNywxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik05LDQuMjUgaDEgdjEuNSBoLTEgeiIgY2xhc3M9Im8iLz48L3N2Zz4">
<script src="/js/viz-standalone.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;
--graph-background: white;
--graph-text: black;
}
[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;
--graph-background: #ffffff;
--graph-text: 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;
flex-wrap: wrap;
gap: 0.5rem;
}
.header h1 {
margin: 0;
font-size: 1.5rem;
}
.controls {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.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;
font-family: inherit;
}
.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: 95vw;
margin: 0 auto;
padding: 2rem;
}
.error {
background: var(--error-color);
color: white;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
white-space: pre-wrap;
font-family: Monaco, Consolas, monospace;
font-size: 0.85rem;
}
.loading {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
color: var(--color);
opacity: 0.7;
}
.section {
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px var(--shadow-color);
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.filter-row {
display: flex;
gap: 0.75rem;
align-items: stretch;
flex-wrap: wrap;
}
.filter-row textarea {
flex: 1;
min-width: 300px;
min-height: 60px;
padding: 0.6rem 0.75rem;
border: 2px solid var(--btn-primary-bg);
border-radius: 4px;
background: var(--card-background);
color: var(--color);
font-family: Monaco, Consolas, monospace;
font-size: 0.85rem;
resize: vertical;
box-shadow: 0 0 0 3px var(--header-background), 0 2px 6px var(--shadow-color);
transition: box-shadow 0.15s ease;
}
.filter-row textarea:focus {
outline: none;
box-shadow: 0 0 0 3px var(--header-background), 0 0 0 5px var(--btn-primary-bg);
}
.filter-help {
font-size: 0.8rem;
opacity: 0.7;
margin-bottom: 0.5rem;
font-family: Monaco, Consolas, monospace;
}
.filter-help code {
background: var(--table-header-bg);
padding: 0 0.3em;
border-radius: 3px;
}
.legend {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
opacity: 0.85;
}
.legend-gradient {
width: 200px;
height: 12px;
border: 1px solid var(--border-color);
border-radius: 2px;
background: linear-gradient(to right, #FFFFFF, #FFC080, #FF8000);
}
.graph-container {
overflow: auto;
background: var(--graph-background);
color: var(--graph-text);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 1rem;
min-height: 200px;
max-height: 80vh;
}
.graph-container svg {
display: block;
}
.placeholder {
text-align: center;
padding: 2rem;
color: var(--color);
opacity: 0.5;
font-style: italic;
}
.theme-toggle {
background: none;
border: 1px solid var(--border-color);
color: var(--header-text);
font-size: 1.2rem;
cursor: pointer;
padding: 0.4rem 0.6rem;
border-radius: 4px;
line-height: 1;
}
.btn-toggle {
background: var(--card-background);
color: var(--color);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btn-toggle.active {
background: var(--btn-primary-bg);
color: var(--btn-primary-color);
border-color: var(--btn-primary-bg);
}
.btn-toggle:hover {
opacity: 0.85;
}
.selected-info {
font-family: Monaco, Consolas, monospace;
font-size: 0.8rem;
opacity: 0.8;
font-weight: normal;
}
.empty {
text-align: center;
padding: 1.5rem;
opacity: 0.6;
font-style: italic;
}
details.query-text-full {
font-family: Monaco, Consolas, monospace;
font-size: 0.85rem;
background: var(--table-header-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem 0.75rem;
margin-bottom: 1rem;
}
details.query-text-full pre {
margin: 0.5rem 0 0;
white-space: pre-wrap;
word-break: break-word;
}
.node-tooltip {
position: fixed;
z-index: 1000;
min-width: 240px;
max-width: 380px;
background: var(--card-background);
color: var(--color);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px var(--shadow-color);
padding: 0.6rem 0.75rem;
font-family: Monaco, Consolas, monospace;
font-size: 0.8rem;
line-height: 1.45;
pointer-events: none;
}
.node-tooltip .tt-row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: baseline;
}
.node-tooltip .tt-key {
opacity: 0.7;
flex: 0 0 auto;
min-width: 0;
}
.node-tooltip .tt-val {
text-align: right;
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
#graph svg g.node:hover polygon,
#graph svg g.node:hover ellipse,
#graph svg g.node:hover rect {
stroke: var(--btn-primary-bg);
stroke-width: 2;
}
</style>
</head>
<body>
<div class="header">
<h1>ClickHouse Processors Profile</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>
<button class="btn btn-primary" onclick="event.preventDefault(); loadQueries(); return false;">Refresh</button>
<button class="btn-toggle" id="cluster-toggle" onclick="toggleCluster()"
title="When on, wrap log tables in clusterAllReplicas('default', merge('system', '^...')) so the page sees every replica and any rotated *_log_N tables">
Cluster: <span id="cluster-state">off</span>
</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 class="section">
<div class="filter-row">
<textarea id="where-filter" spellcheck="false"></textarea>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<button class="btn btn-primary" onclick="loadQueries()">Apply</button>
<button class="btn btn-secondary" onclick="resetFilter()">Reset</button>
</div>
</div>
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<select id="query-picker" onchange="onPickerChange()" style="flex: 1; min-width: 300px; padding: 0.5rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--card-background); color: var(--color); font-family: Monaco, Consolas, monospace; font-size: 0.8rem;">
<option value="">— click Apply to load matching queries —</option>
</select>
</div>
</div>
<div class="section">
<div class="section-title" style="margin-bottom: 0.75rem; padding-bottom: 0.5rem;">
<span class="selected-info" id="selected-query-id"></span>
<div class="legend">
<span>fast</span>
<div class="legend-gradient" title="white → deep orange"></div>
<span>slow</span>
</div>
</div>
<div id="selected-query-text"></div>
<div id="graph" class="graph-container">
<div class="placeholder">Select a query above to render its pipeline with <code>elapsed_us</code> heatmap.</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);
const user_from_url = current_url.searchParams.get('user');
if (user_from_url) user = user_from_url;
const password_from_url = current_url.searchParams.get('password');
if (password_from_url) password = password_from_url;
document.getElementById('url').value = host;
document.getElementById('user').value = user;
document.getElementById('password').value = password;
function readConnectionParams() {
host = document.getElementById('url').value || host;
user = document.getElementById('user').value || user;
password = document.getElementById('password').value || password;
}
/// Build a URL carrying only credential and format params. The SQL itself is sent in the POST body
/// so it can't be mangled by URL encoding, intermediate proxies, or URL length limits.
function buildBaseUrl(format) {
let url = `${host}?default_format=${format}&enable_http_compression=1`;
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;
}
/// Run a query against the server. `params` is an object of named query parameters that bind
/// to ClickHouse `{name:Type}` placeholders. Always prefer this over string interpolation for
/// any user-controlled value — it eliminates SQL injection risk because the server treats the
/// parameter as a literal of the declared type, never as SQL syntax.
async function runQuery(query, format = 'TabSeparatedWithNames', params = {}) {
readConnectionParams();
let url = buildBaseUrl(format);
for (const name of Object.keys(params)) {
url += `&param_${encodeURIComponent(name)}=${encodeURIComponent(params[name])}`;
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Authorization': 'never', 'Content-Type': 'text/plain; charset=UTF-8' },
body: query,
});
const text = await response.text();
if (!response.ok) {
throw new Error(text || `HTTP ${response.status}: ${response.statusText}`);
}
return text;
}
function showError(msg) {
const el = document.getElementById('error');
el.textContent = msg;
el.style.display = 'block';
}
function clearError() {
const el = document.getElementById('error');
el.textContent = '';
el.style.display = 'none';
}
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);
}
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
document.getElementById('theme-icon').textContent = savedTheme === 'dark' ? '☀️' : '🌙';
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
const DEFAULT_WHERE = "type = 'QueryFinish' AND query_kind = 'Select' AND event_time > now() - INTERVAL 24 HOUR";
/// In cluster mode each `system.*_log` reference is wrapped in `clusterAllReplicas` over
/// `merge(...)` so the page sees every replica's local log table and any rotated tables
/// like `query_log_0`, `query_log_1`, … In local mode we hit `system.*_log` directly,
/// which is the right default for a single-node server with no `default` cluster.
const QUERY_LOG_CLUSTER = "clusterAllReplicas('default', merge('system', '^query_log'))";
const PROCESSORS_PROFILE_CLUSTER = "clusterAllReplicas('default', merge('system', '^processors_profile_log'))";
const QUERY_LOG_LOCAL = "system.query_log";
const PROCESSORS_PROFILE_LOCAL = "system.processors_profile_log";
let clusterMode = localStorage.getItem('processors-profile.cluster-mode') === '1';
function queryLogTable() { return clusterMode ? QUERY_LOG_CLUSTER : QUERY_LOG_LOCAL; }
function processorsProfileTable() { return clusterMode ? PROCESSORS_PROFILE_CLUSTER : PROCESSORS_PROFILE_LOCAL; }
function applyClusterButtonState() {
const btn = document.getElementById('cluster-toggle');
const lab = document.getElementById('cluster-state');
if (clusterMode) {
btn.classList.add('active');
lab.textContent = 'on';
} else {
btn.classList.remove('active');
lab.textContent = 'off';
}
}
function toggleCluster() {
clusterMode = !clusterMode;
localStorage.setItem('processors-profile.cluster-mode', clusterMode ? '1' : '0');
applyClusterButtonState();
loadQueries();
}
function resetFilter() {
document.getElementById('where-filter').value = DEFAULT_WHERE;
loadQueries();
}
/// Parse the TSVWithNames body returned by ClickHouse. TSV unescaping is per the format spec:
/// \n, \t, \r, \\ and \0.
function unescapeTSV(s) {
let out = '';
for (let i = 0; i < s.length; ++i) {
const c = s[i];
if (c === '\\' && i + 1 < s.length) {
const n = s[i + 1];
if (n === 'n') { out += '\n'; ++i; }
else if (n === 't') { out += '\t'; ++i; }
else if (n === 'r') { out += '\r'; ++i; }
else if (n === '\\') { out += '\\'; ++i; }
else if (n === '0') { out += '\0'; ++i; }
else if (n === "'") { out += "'"; ++i; }
else { out += c; }
} else {
out += c;
}
}
return out;
}
function parseTSVWithNames(text) {
text = text.replace(/\n$/, '');
if (!text) return { columns: [], rows: [] };
const lines = text.split('\n');
const columns = lines[0].split('\t').map(unescapeTSV);
const rows = [];
for (let i = 1; i < lines.length; ++i) {
const row = lines[i].split('\t').map(unescapeTSV);
rows.push(row);
}
return { columns, rows };
}
function rowsToObjects({ columns, rows }) {
return rows.map(r => {
const o = {};
columns.forEach((c, i) => { o[c] = r[i]; });
return o;
});
}
function formatDuration(ms) {
const n = Number(ms);
if (!isFinite(n)) return ms;
if (n < 1000) return `${n} ms`;
return `${(n / 1000).toFixed(2)} s`;
}
async function loadQueries() {
clearError();
const picker = document.getElementById('query-picker');
picker.innerHTML = '<option value="">loading…</option>';
picker.disabled = true;
/// Whatever the user typed in the textarea becomes the entire WHERE clause. Default fallback
/// only kicks in if the textarea is somehow empty.
const where = document.getElementById('where-filter').value.trim() || DEFAULT_WHERE;
/// NOTE: do not alias `event_time` to a `formatDateTime(...)` expression — the alias would
/// shadow the raw DateTime column inside the user's WHERE selector, breaking filters like
/// `event_time > now() - INTERVAL 1 HOUR`. DateTime is already serialized as `YYYY-MM-DD HH:MM:SS`
/// in TSV, so leaving it raw is enough.
const query = `
SELECT
query_id,
user,
event_time,
query_duration_ms,
substring(replaceRegexpAll(query, '[\\\\s]+', ' '), 1, 120) AS query
FROM ${queryLogTable()}
WHERE ${where}
ORDER BY event_time DESC
LIMIT 50
FORMAT TabSeparatedWithNames`;
try {
const text = await runQuery(query);
const parsed = parseTSVWithNames(text);
const rows = rowsToObjects(parsed);
populatePicker(rows);
/// Auto-profile the most recent match, if any.
if (rows.length > 0) {
picker.value = rows[0].query_id;
await profileQuery(rows[0].query_id);
}
} catch (e) {
showError('Failed to load queries: ' + e.message);
picker.innerHTML = '<option value="">— failed to load —</option>';
} finally {
picker.disabled = false;
}
}
function populatePicker(rows) {
const picker = document.getElementById('query-picker');
if (!rows.length) {
picker.innerHTML = '<option value="">— no queries match the filter —</option>';
return;
}
picker.innerHTML = rows.map(r => {
const label = `${r.event_time} · ${r.user} · ${formatDuration(r.query_duration_ms)} · ${r.query_id} · ${r.query}`;
return `<option value="${escapeHtml(r.query_id)}">${escapeHtml(label)}</option>`;
}).join('');
}
async function onPickerChange() {
const qid = document.getElementById('query-picker').value;
if (qid) await profileQuery(qid);
}
/// Look up the original query text for a query_id from system.query_log.
/// `query_id` is bound as a `{qid:String}` parameter so adversarial values (containing
/// quotes, backslashes, newlines, …) cannot break out of the SQL literal.
async function fetchQueryText(queryId) {
const q = `
SELECT query
FROM ${queryLogTable()}
WHERE query_id = {qid:String}
AND type = 'QueryFinish'
ORDER BY event_time DESC
LIMIT 1
FORMAT TabSeparatedRaw`;
const text = await runQuery(q, 'TabSeparatedRaw', { qid: queryId });
return text.replace(/\n$/, '');
}
/// Strip a trailing `FORMAT <name>` clause and any trailing semicolon/whitespace from a query
/// before wrapping it in EXPLAIN. The inner SELECT parser doesn't consume FORMAT, so leaving it
/// in place causes `ParserQueryWithOutput` to attach it to the EXPLAIN itself instead of to
/// the original query, which is at best confusing and at worst causes a syntax error.
function stripTrailingFormatAndSemicolons(queryText) {
let q = queryText.replace(/\s+$/, '');
q = q.replace(/;+\s*$/, '');
q = q.replace(/\s+FORMAT\s+[A-Za-z0-9_]+\s*$/i, '');
return q;
}
/// Fetch the DOT graph for a query via `EXPLAIN PIPELINE compact=0, graph=1`. Both settings
/// matter: `compact=0` gives a flat DOT (one node per processor) whose labels are exactly the
/// `processor_uniq_id` strings stored in `system.processors_profile_log`. With `compact=1`
/// (the default) the labels are grouped names like `ExpressionTransform × 32`, which can't
/// be joined against the profile log.
async function fetchDotGraph(queryText) {
const cleaned = stripTrailingFormatAndSemicolons(queryText);
const q = `EXPLAIN PIPELINE compact=0, graph=1 ${cleaned}`;
return await runQuery(q, 'TabSeparatedRaw');
}
/// Pull processor-level profiling data for the given query_id.
async function fetchProfileData(queryId) {
const q = `
SELECT
toString(step_uniq_id) AS step_id,
toString(processor_uniq_id) AS processor_id,
any(name) AS name,
sum(elapsed_us) AS elapsed_us,
sum(input_wait_elapsed_us) AS input_wait_us,
sum(output_wait_elapsed_us) AS output_wait_us,
sum(input_rows) AS input_rows,
sum(input_bytes) AS input_bytes,
sum(output_rows) AS output_rows,
sum(output_bytes) AS output_bytes
FROM ${processorsProfileTable()}
WHERE query_id = {qid:String}
GROUP BY step_uniq_id, processor_uniq_id
FORMAT TabSeparatedWithNames`;
const text = await runQuery(q, 'TabSeparatedWithNames', { qid: queryId });
const parsed = parseTSVWithNames(text);
const map = {};
let minUs = Infinity, maxUs = 0;
const idx = Object.fromEntries(parsed.columns.map((c, i) => [c, i]));
const numCols = ['elapsed_us', 'input_wait_us', 'output_wait_us',
'input_rows', 'input_bytes', 'output_rows', 'output_bytes'];
for (const row of parsed.rows) {
const procId = row[idx.processor_id];
if (!procId) continue;
const data = { step_id: row[idx.step_id], name: row[idx.name] };
for (const col of numCols) {
const n = parseInt(row[idx[col]], 10);
data[col] = isFinite(n) ? n : 0;
}
if (data.elapsed_us > maxUs) maxUs = data.elapsed_us;
if (data.elapsed_us < minUs) minUs = data.elapsed_us;
map[procId] = data;
}
if (minUs === Infinity) minUs = 0;
return { map, minUs, maxUs };
}
/// Generate a color ranging from white (#FFFFFF) for fastest operations
/// to deep orange (#FF8000) for slowest. Mirrors the original Python script.
function heatmapColor(us, minUs, maxUs) {
const range = maxUs - minUs;
const t = range === 0 ? 0.5 : (us - minUs) / range;
const green = Math.round(255 - t * 127);
const blue = Math.round(255 * (1 - t));
const hex = v => v.toString(16).padStart(2, '0').toUpperCase();
return `#FF${hex(green)}${hex(blue)}`;
}
/// Replace each `nN [label="processor_uniq_id"]` node in the DOT source with a label that
/// shows the processor's id and elapsed time and a heatmap fillcolor based on `elapsed_us`.
/// Full per-processor stats stay in JS — the rendered SVG looks them up by processor_uniq_id
/// (which is what graphviz emits as the first `<text>` line inside each node group) when
/// drawing the hover tooltip.
function enrichDot(dotContent, profile) {
const { map, minUs, maxUs } = profile;
const nodeRe = /(n\d+\s*\[\s*label\s*=\s*")(.*?)("\s*\]\s*;)/g;
const enriched = dotContent.replace(nodeRe, (_match, prefix, label, suffix) => {
const data = map[label];
if (!data) return `${prefix}${label}${suffix}`;
const ms = data.elapsed_us / 1000;
const color = heatmapColor(data.elapsed_us, minUs, maxUs);
/// Keep the on-node label short — full stats appear on hover.
const newLabel = `${label}\\n${ms.toFixed(2)} ms`;
return `${prefix}${newLabel}", fillcolor="${color}", style="filled"];`;
});
/// Make sure the digraph has a transparent background so dark mode works.
if (/\bbgcolor\b/.test(enriched)) return enriched;
return enriched.replace(/\bdigraph(\s+\w+)?\s*\{/, m => `${m}\n graph [bgcolor="transparent"];`);
}
function formatBytes(n) {
n = Number(n) || 0;
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let i = -1; let v = n;
do { v /= 1024; ++i; } while (v >= 1024 && i < units.length - 1);
return `${v.toFixed(2)} ${units[i]}`;
}
function formatUs(us) {
us = Number(us) || 0;
if (us < 1000) return `${us} µs`;
if (us < 1_000_000) return `${(us / 1000).toFixed(2)} ms`;
return `${(us / 1_000_000).toFixed(2)} s`;
}
function formatNum(n) {
return Number(n || 0).toLocaleString('en-US');
}
function tooltipHtml(procId, data) {
/// Some processors in the pipeline graph never produce a row in
/// `system.processors_profile_log` — typically sources or processors that finished before
/// the profiler started sampling. Show a stub for them so hover still works.
if (!data) {
return `
<div class="tt-row"><span class="tt-key">Processor</span><span class="tt-val">${escapeHtml(procId)}</span></div>
<div class="tt-row" style="opacity:0.7; font-style: italic; margin-top: 0.3rem;">
<span class="tt-val">no rows in system.processors_profile_log</span>
</div>`;
}
const rows = [
['Processor', escapeHtml(data.name || procId)],
['Uniq id', escapeHtml(procId)],
['Step', escapeHtml(data.step_id || '—')],
['Elapsed', formatUs(data.elapsed_us)],
['Input wait', formatUs(data.input_wait_us)],
['Output wait', formatUs(data.output_wait_us)],
['Input rows', formatNum(data.input_rows)],
['Input bytes', formatBytes(data.input_bytes)],
['Output rows', formatNum(data.output_rows)],
['Output bytes', formatBytes(data.output_bytes)],
];
return rows.map(([k, v]) => `<div class="tt-row"><span class="tt-key">${k}</span><span class="tt-val">${v}</span></div>`).join('');
}
let _ttEl = null;
function getTooltipEl() {
if (_ttEl) return _ttEl;
_ttEl = document.createElement('div');
_ttEl.className = 'node-tooltip';
_ttEl.style.display = 'none';
document.body.appendChild(_ttEl);
return _ttEl;
}
function moveTooltip(e) {
const tt = getTooltipEl();
const pad = 14;
let x = e.clientX + pad;
let y = e.clientY + pad;
const rect = tt.getBoundingClientRect();
if (x + rect.width > window.innerWidth) x = e.clientX - rect.width - pad;
if (y + rect.height > window.innerHeight) y = e.clientY - rect.height - pad;
tt.style.left = `${x}px`;
tt.style.top = `${y}px`;
}
function showTooltip(e, html) {
const tt = getTooltipEl();
tt.innerHTML = html;
tt.style.display = 'block';
moveTooltip(e);
}
function hideTooltip() {
if (_ttEl) _ttEl.style.display = 'none';
}
/// Attach hover handlers to every `<g class="node">` in the rendered SVG. The first `<text>`
/// child of each node carries the original `processor_uniq_id` (the first line of our
/// enriched label), which we use to look up the full stats from `profile.map`. Every node
/// gets a tooltip, even those missing from the profile log — see `tooltipHtml`.
function attachNodeTooltips(svg, profile) {
const nodes = svg.querySelectorAll('g.node');
nodes.forEach(g => {
const firstText = g.querySelector('text');
if (!firstText) return;
const procId = firstText.textContent.trim();
const html = tooltipHtml(procId, profile.map[procId]);
g.style.cursor = 'help';
g.addEventListener('mouseenter', e => showTooltip(e, html));
g.addEventListener('mousemove', moveTooltip);
g.addEventListener('mouseleave', hideTooltip);
});
}
function renderStepError(step, err) {
const graphEl = document.getElementById('graph');
graphEl.innerHTML = `
<div class="error" style="display: block; margin: 0;">
<div style="font-weight: 600; margin-bottom: 0.5rem;">Failed at step: ${escapeHtml(step)}</div>
<div>${escapeHtml(err.message || String(err))}</div>
</div>`;
}
/// Monotonic generation counter for `profileQuery`. The user can pick a new query at any time
/// (auto-select on `loadQueries`, picker change, etc.) and several `profileQuery` calls can
/// overlap. Each call captures its generation on entry and, after every `await`, checks that
/// it is still the latest before mutating the DOM. Older calls bail silently so a slower
/// earlier call cannot overwrite the graph with stale data for a no-longer-selected query.
let profileGeneration = 0;
async function profileQuery(queryId) {
const myGen = ++profileGeneration;
const isStale = () => myGen !== profileGeneration;
clearError();
const graphEl = document.getElementById('graph');
const textEl = document.getElementById('selected-query-text');
document.getElementById('selected-query-id').textContent = queryId;
graphEl.innerHTML = '<div class="loading">Loading pipeline graph…</div>';
textEl.innerHTML = '';
let queryText;
try {
queryText = await fetchQueryText(queryId);
if (isStale()) return;
if (!queryText) throw new Error(`No query text found in system.query_log for query_id=${queryId}`);
} catch (e) {
if (isStale()) return;
renderStepError('fetch query text from system.query_log', e);
return;
}
textEl.innerHTML = `
<details class="query-text-full">
<summary>Query text (click to expand)</summary>
<pre>${escapeHtml(queryText)}</pre>
</details>`;
/// Flush logs so the processors profile for this query is visible. Non-fatal — if the user
/// lacks the privilege we keep going; data is likely flushed already.
try {
await runQuery('SYSTEM FLUSH LOGS', 'TabSeparatedRaw');
} catch (e) {
console.warn('SYSTEM FLUSH LOGS failed (continuing):', e.message);
}
if (isStale()) return;
let dot;
try {
dot = await fetchDotGraph(queryText);
if (isStale()) return;
if (!dot || !dot.includes('digraph')) {
throw new Error('EXPLAIN PIPELINE returned no DOT graph. Output:\n' + dot.slice(0, 500));
}
} catch (e) {
if (isStale()) return;
renderStepError('EXPLAIN PIPELINE graph=1', e);
return;
}
let profile;
try {
profile = await fetchProfileData(queryId);
if (isStale()) return;
} catch (e) {
if (isStale()) return;
renderStepError('read system.processors_profile_log', e);
return;
}
try {
const enriched = enrichDot(dot, profile);
await renderDot(enriched, profile, isStale);
} catch (e) {
if (isStale()) return;
renderStepError('render DOT with Viz.js', e);
}
}
let vizInstance = null;
async function getViz() {
if (vizInstance) return vizInstance;
if (typeof Viz === 'undefined') {
throw new Error('Viz.js failed to load from /js/viz-standalone.js.');
}
vizInstance = await Viz.instance();
return vizInstance;
}
async function renderDot(dot, profile, isStale = () => false) {
const graphEl = document.getElementById('graph');
const viz = await getViz();
if (isStale()) return;
let svg;
try {
svg = viz.renderSVGElement(dot);
} catch (e) {
throw new Error('Graphviz rendering failed: ' + e.message);
}
if (isStale()) return;
graphEl.innerHTML = '';
graphEl.appendChild(svg);
attachNodeTooltips(svg, profile);
/// Show "no profile data" hint if the profile map is empty.
if (!Object.keys(profile.map).length) {
const hint = document.createElement('div');
hint.className = 'empty';
hint.style.marginTop = '0.5rem';
hint.innerHTML = 'No rows in <code>system.processors_profile_log</code> for this query — the heatmap is empty. ' +
'The query may have run with <code>log_processors_profiles = 0</code>, or its logs may have aged out.';
graphEl.appendChild(hint);
} else {
const min_ms = (profile.minUs / 1000).toFixed(2);
const max_ms = (profile.maxUs / 1000).toFixed(2);
const info = document.createElement('div');
info.style.marginTop = '0.5rem';
info.style.fontSize = '0.8rem';
info.style.opacity = '0.7';
info.style.color = 'var(--color)';
info.textContent = `Heatmap range: ${min_ms} ms (min) – ${max_ms} ms (max)`;
graphEl.appendChild(info);
}
}
/// Initial state.
document.getElementById('where-filter').value = DEFAULT_WHERE;
applyClusterButtonState();
loadQueries();
</script>
</body>
</html>