mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
The correct, safe broad rebrand: renames "ClickHouse" only as a whole word (boundary on BOTH sides), so it rewrites display strings/messages/comments but leaves every compound C++ symbol intact — `ClickHouseDictionarySource`, `getInfoIfClickHouseDictionarySource`, `mainEntryClickHouseServer`, `ClickHouseRevision`, `ClickHouse_SOURCE_DIR` are untouched (an earlier one-sided heuristic mangled suffix symbols like `manageClickHouse`; reverted). 1677 occurrences across 482 files. Compile-safe (string/comment content; symbols preserved). Still NOT renamed (would be a build-coupled symbol rename, needs build-host iteration): the 199 compound identifiers containing ClickHouse, the `DB::` namespace, the `clickhouse_*` CMake targets/macros, the lowercase `clickhouse` SQL dictionary-source keyword. Needs build verification.
968 lines
40 KiB
HTML
968 lines
40 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Datastore 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>Datastore 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 Datastore `{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 += `¶m_${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('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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 Datastore. 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>
|