Files
datastore/programs/server/schema.html
T
Nikita Mikhaylov cde2049c00 /schema: address AI review (CSS comments, key/escape correctness, section bounds, route test)
* Use a JSON-tuple node key instead of `db + '.' + table`, which collides for
  identifiers containing dots. Display uses a new `displayName` field
  (backquoted qualified name) so the UI still renders human-readably.
* Return parallel dependency arrays from `system.tables` and zip them in JS,
  removing the now-unnecessary `concat(d, '.', t)` and matching the new key
  format end-to-end.
* Mirror server-side identifier escaping in `backQuoteIfNeed` (backslash,
  backtick, plus `\b\f\n\r\t\0`) so the strings we compare against
  `system.query_views_log.view_name` / `view_target` (produced by
  `getFullTableName`) actually match.
* Replace `///` comments inside the `<style>` block with `/* */`; the former
  are invalid CSS and can void nearby custom-property declarations.
* Recompute section bounds via the section index stamped on each `.db-group`
  and the section's `.nodes` array, instead of looking up by label text —
  the synthetic `Independent tables (N)` label is not a database key and
  was collapsing the section to the empty 24x24 padding.
* Add `tests/queries/0_stateless/04257_schema_route.sh` smoke test for the
  `/schema` route, matching the existing `/merges` / `/dashboard` style.

https://github.com/ClickHouse/ClickHouse/pull/105276#issuecomment-4481327676
2026-05-21 13:04:05 +02:00

1972 lines
73 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 Schema Visualizer</title>
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDExIDEwIj48c3R5bGU+LmJne2ZpbGw6I2ZmMH0ub3tmaWxsOiMwMDB9PC9zdHlsZT48cmVjdCBjbGFzcz0iYmciIHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48cGF0aCBkPSJNMSwxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik0zLDEgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTUsMSBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNNywxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik05LDQuMjUgaDEgdjEuNSBoLTEgeiIgY2xhc3M9Im8iLz48L3N2Zz4K">
<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);
--error-color: #dc3545;
--success-color: #28a745;
--warning-color: #b58900;
--muted: #6A6A6A;
--table-header-bg: #f8f9fa;
--table-hover-bg: #fffce0;
--btn-primary-bg: black;
--btn-primary-color: white;
--accent: #FCFF74;
--accent-hover: #f0f24a;
/* Engine palette — yellow stays the brand, supporting colors stay distinct enough
to scan a complex graph at a glance. */
--header-mt: #B58900; /* dark yellow (MergeTree / table — anchored on the brand color) */
--header-mt-text: black;
--header-mv: #6A1B9A;
--header-mv-text: white;
--header-rmv: #AD1457;
--header-rmv-text: white;
--header-dict: #0277BD;
--header-dict-text: white;
--header-distributed: #E65100;
--header-distributed-text: white;
--header-view: #455A64;
--header-view-text: white;
--header-other: #5D4037;
--header-other-text: white;
--column-key: #B71C1C;
--column-default: #2E7D32;
--column-type: #6A6A6A;
--link: #999999;
--link-mv: #6A1B9A;
--link-dict: #0277BD;
--link-distributed: #E65100;
--link-highlight: #B58900;
--db-bg: #FFFEF0;
--db-border: #E0D060;
}
[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);
--error-color: #F66;
--success-color: #4ade80;
--warning-color: #facc15;
--muted: #aaaaaa;
--table-header-bg: #2a3f5f;
--table-hover-bg: #2a3f5f;
--btn-primary-bg: #FCFF74;
--btn-primary-color: black;
--accent: #FCFF74;
--accent-hover: #e8eb6a;
--header-mt: #FCFF74;
--header-mt-text: black;
--header-mv: #AB47BC;
--header-mv-text: white;
--header-rmv: #EC407A;
--header-rmv-text: white;
--header-dict: #29B6F6;
--header-dict-text: black;
--header-distributed: #FF8A65;
--header-distributed-text: black;
--header-view: #90A4AE;
--header-view-text: black;
--header-other: #A1887F;
--header-other-text: black;
--column-key: #EF5350;
--column-default: #66BB6A;
--column-type: #BBBBBB;
--link: #666666;
--link-mv: #CE93D8;
--link-dict: #4FC3F7;
--link-distributed: #FF8A65;
--link-highlight: #FCFF74;
--db-bg: #1d2a18;
--db-border: #555533;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
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: 0.6rem 0.6rem;
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: 320px; }
#user, #password { width: 110px; }
.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);
}
.btn-toggle {
background: var(--card-background);
color: var(--color);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btn-toggle:hover { opacity: 0.85; }
.btn-toggle.active {
background: var(--btn-primary-bg);
color: var(--btn-primary-color);
border-color: var(--btn-primary-bg);
}
.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;
}
.container {
max-width: 100%;
margin: 0;
padding: 0.4rem 0.4rem 0.5rem;
}
.section {
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.5rem 0.6rem;
margin-bottom: 0.5rem;
box-shadow: 0 1px 2px var(--shadow-color);
}
.filter-row {
display: flex;
gap: 0.6rem;
align-items: center;
flex-wrap: wrap;
}
.filter-row select,
.filter-row input[type="text"] {
padding: 0.45rem 0.7rem;
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);
}
.filter-row select:focus,
.filter-row input[type="text"]:focus {
outline: none;
border-color: var(--btn-primary-bg);
}
#search { min-width: 16rem; }
#db-filter { min-width: 12rem; }
.legend {
display: flex;
gap: 0.9rem;
align-items: center;
font-size: 0.78rem;
flex-wrap: wrap;
padding-top: 0.7rem;
border-top: 1px solid var(--border-color);
margin-top: 0.7rem;
}
.legend-item { display: inline-flex; align-items: center; gap: 0.35rem; }
.legend-swatch {
display: inline-block;
width: 0.9rem;
height: 0.9rem;
border-radius: 2px;
border: 1px solid rgba(0,0,0,0.2);
}
.legend-line {
display: inline-block;
width: 1.6rem;
height: 2px;
}
.load-badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.05rem 0.35rem;
font-size: 0.65rem;
font-family: Monaco, Consolas, monospace;
color: white;
background: var(--muted);
border-radius: 2px;
}
#status {
margin-left: auto;
font-family: Monaco, Consolas, monospace;
font-size: 0.8rem;
opacity: 0.8;
white-space: pre-wrap;
}
#status.error {
color: var(--error-color);
opacity: 1;
font-weight: 600;
}
.graph-container {
position: relative;
background: var(--card-background);
color: var(--color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0;
box-shadow: 0 2px 4px var(--shadow-color);
overflow: hidden;
}
#viewport {
position: relative;
overflow: auto;
background: var(--card-background);
height: calc(100vh - 18rem);
min-height: 24rem;
border-radius: 8px;
}
#canvas-wrap {
position: relative;
min-width: 100%;
min-height: 100%;
}
#canvas {
position: relative;
transform-origin: 0 0;
}
#links-svg {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
overflow: visible;
}
.db-group {
position: absolute;
border: 2px dashed var(--db-border);
background: color-mix(in srgb, var(--db-bg) 60%, transparent);
border-radius: 8px;
pointer-events: none;
}
.db-label {
position: absolute;
top: 0.4rem;
left: 0.8rem;
color: var(--color);
background: var(--header-background);
padding: 0.05rem 0.5rem;
font-weight: 700;
font-size: 0.95rem;
pointer-events: auto;
user-select: none;
border-radius: 3px;
box-shadow: 1px 1px 0 var(--shadow-color);
}
.node {
position: absolute;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 1px 3px var(--shadow-color);
font-size: 0.78rem;
min-width: 13rem;
max-width: 19rem;
cursor: pointer;
transition: box-shadow 0.15s, border-color 0.15s, transform 0.15s;
}
.node:hover {
box-shadow: 0 4px 12px var(--shadow-color);
transform: translateY(-1px);
z-index: 5;
}
.node.selected {
border-color: var(--btn-primary-bg);
box-shadow: 0 0 0 3px var(--accent), 0 4px 10px var(--shadow-color);
z-index: 6;
}
.node.highlighted {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent);
z-index: 6;
}
.node.dimmed { opacity: 0.25; }
.node-header {
padding: 0.35rem 0.55rem;
border-radius: 5px 5px 0 0;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.4rem;
}
.node-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
font-family: Monaco, Consolas, monospace;
}
.node-kind {
font-size: 0.7rem;
opacity: 0.85;
white-space: nowrap;
font-family: Monaco, Consolas, monospace;
}
.node-mt .node-header { background: var(--header-mt); color: var(--header-mt-text); }
.node-mv .node-header { background: var(--header-mv); color: var(--header-mv-text); }
.node-rmv .node-header { background: var(--header-rmv); color: var(--header-rmv-text); }
.node-dict .node-header { background: var(--header-dict); color: var(--header-dict-text); }
.node-distributed .node-header { background: var(--header-distributed); color: var(--header-distributed-text); }
.node-view .node-header { background: var(--header-view); color: var(--header-view-text); }
.node-other .node-header { background: var(--header-other); color: var(--header-other-text); }
.node-columns {
padding: 0.25rem 0.55rem;
max-height: 16rem;
overflow-y: auto;
}
.node-columns.collapsed { display: none; }
.column {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-family: Monaco, Consolas, monospace;
font-size: 0.78rem;
line-height: 1.45;
border-bottom: 1px dotted var(--border-color);
}
.column:last-child { border-bottom: none; }
.column-name { color: var(--color); }
.column-name.key { color: var(--column-key); font-weight: 600; }
.column-name.default { color: var(--column-default); }
.column-type {
color: var(--column-type);
font-size: 0.72rem;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 50%;
}
.node-stats {
padding: 0.2rem 0.55rem;
background: var(--table-header-bg);
border-top: 1px solid var(--border-color);
border-radius: 0 0 5px 5px;
font-size: 0.72rem;
color: var(--muted);
display: flex;
justify-content: space-between;
font-family: Monaco, Consolas, monospace;
}
#sidebar {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 30rem;
background: var(--card-background);
border-left: 4px solid var(--header-background);
padding: 1rem 1.25rem;
overflow-y: auto;
z-index: 200;
display: none;
box-shadow: -6px 0 18px var(--shadow-color);
}
#sidebar.open { display: block; }
#sidebar h2 {
font-size: 1.05rem;
margin: 0 0 0.6rem;
display: flex;
justify-content: space-between;
align-items: center;
font-family: Monaco, Consolas, monospace;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--header-background);
}
#sidebar h3 {
font-size: 0.8rem;
margin: 1rem 0 0.4rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
#sidebar pre {
background: var(--table-header-bg);
border: 1px solid var(--border-color);
padding: 0.6rem 0.75rem;
font-size: 0.78rem;
font-family: Monaco, Consolas, monospace;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
border-radius: 4px;
}
#sidebar table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
font-family: Monaco, Consolas, monospace;
}
#sidebar table td {
padding: 0.2rem 0.4rem;
border-bottom: 1px dotted var(--border-color);
vertical-align: top;
}
#sidebar table td:first-child {
color: var(--muted);
white-space: nowrap;
width: 36%;
}
#sidebar .close {
background: transparent;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 1.4rem;
line-height: 1;
}
#sidebar .close:hover { color: var(--color); }
#sidebar a {
color: var(--btn-primary-bg);
cursor: pointer;
text-decoration: underline;
font-family: Monaco, Consolas, monospace;
}
#sidebar .related-table {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.2rem 0;
}
#empty {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--muted);
font-size: 1rem;
text-align: center;
font-style: italic;
}
#empty.hidden { display: none; }
#empty .hint { font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.75; }
.arrow {
fill: none;
stroke: var(--link);
stroke-width: 1.4px;
opacity: 0.85;
}
.arrow.mv { stroke: var(--link-mv); }
.arrow.dict { stroke: var(--link-dict); }
.arrow.distributed { stroke: var(--link-distributed); }
.arrow.highlighted { stroke: var(--link-highlight); stroke-width: 2.4px; opacity: 1; }
.arrow.dimmed { opacity: 0.1; }
.arrowhead { fill: var(--link); }
.arrowhead.mv { fill: var(--link-mv); }
.arrowhead.dict { fill: var(--link-dict); }
.arrowhead.distributed { fill: var(--link-distributed); }
.arrowhead.highlighted { fill: var(--link-highlight); }
#zoom-controls {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
gap: 0.3rem;
background: var(--card-background);
border: 1px solid var(--border-color);
padding: 0.3rem;
border-radius: 6px;
z-index: 50;
box-shadow: 0 2px 6px var(--shadow-color);
}
#zoom-controls button {
background: var(--header-background);
color: black;
border: 1px solid var(--border-color);
width: 2.1rem;
height: 2.1rem;
cursor: pointer;
font-size: 1rem;
font-weight: bold;
border-radius: 4px;
}
#zoom-controls button:hover { opacity: 0.85; }
</style>
</head>
<body>
<div class="header">
<h1>ClickHouse Schema Visualizer</h1>
<div class="controls">
<div class="connection-params">
<input id="url" type="text" placeholder="URL" spellcheck="false">
<input id="user" type="text" placeholder="user" spellcheck="false">
<input id="password" type="password" placeholder="password">
</div>
<button id="load" class="btn btn-primary">Load</button>
<button id="theme-toggle" class="theme-toggle" title="Toggle theme">🌘</button>
</div>
</div>
<div class="container">
<div class="section">
<div class="filter-row">
<input id="search" type="text" placeholder="🔍 Search table or column…" spellcheck="false">
<select id="db-filter"><option value="">All databases</option></select>
<button id="toggle-columns" class="btn-toggle active">Columns</button>
<select id="load-period" title="Highlight INSERT pipeline load from system.query_views_log">
<option value="0">Load: off</option>
<option value="1">Last 1 day</option>
<option value="7" selected>Last 7 days</option>
<option value="30">Last 30 days</option>
</select>
<select id="load-metric" title="Metric used to colour the heat-map">
<option value="total_duration_ms" selected>View duration</option>
<option value="written_rows">Rows written</option>
<option value="written_bytes">Bytes written</option>
<option value="read_rows">Rows read</option>
<option value="read_bytes">Bytes read</option>
<option value="peak_memory_usage">Peak memory</option>
<option value="executions">Executions</option>
</select>
<button id="relayout" class="btn btn-secondary">Re-layout</button>
<span id="status"></span>
</div>
<div class="legend">
<span class="legend-item"><span class="legend-swatch" style="background:var(--header-mt)"></span>MergeTree / Table</span>
<span class="legend-item"><span class="legend-swatch" style="background:var(--header-mv)"></span>Materialized View</span>
<span class="legend-item"><span class="legend-swatch" style="background:var(--header-rmv)"></span>Refreshable MV</span>
<span class="legend-item"><span class="legend-swatch" style="background:var(--header-dict)"></span>Dictionary</span>
<span class="legend-item"><span class="legend-swatch" style="background:var(--header-distributed)"></span>Distributed</span>
<span class="legend-item"><span class="legend-swatch" style="background:var(--header-view)"></span>View</span>
<span class="legend-item"><span class="legend-line" style="background:var(--link-mv)"></span>MV flow</span>
<span class="legend-item"><span class="legend-line" style="background:var(--link-dict)"></span>Dictionary source</span>
<span class="legend-item"><span class="legend-line" style="background:var(--link-distributed)"></span>Distributed shard</span>
</div>
</div>
<div class="graph-container">
<div id="viewport">
<div id="canvas-wrap">
<div id="canvas">
<svg id="links-svg" xmlns="http://www.w3.org/2000/svg"></svg>
</div>
</div>
<div id="empty">
Press <b>Load</b> to fetch the schema from this server.
<div class="hint">Click a node to see details, drag a node to move it.</div>
</div>
</div>
</div>
</div>
<aside id="sidebar">
<h2><span id="sidebar-title"></span><button class="close" id="sidebar-close">×</button></h2>
<div id="sidebar-content"></div>
</aside>
<div id="zoom-controls">
<button id="zoom-out" title="Zoom out">−</button>
<button id="zoom-reset" title="Reset zoom">⌂</button>
<button id="zoom-in" title="Zoom in">+</button>
</div>
<script>
'use strict';
const $ = id => document.getElementById(id);
if (!$('url').value)
$('url').value = location.protocol !== 'file:' ? location.origin : 'http://localhost:8123/';
if (!$('user').value) {
const u = new URL(window.location).searchParams.get('user');
$('user').value = u || 'default';
}
let theme = window.localStorage.getItem('theme') || 'light';
function setTheme(t) {
theme = t;
document.documentElement.setAttribute('data-theme', t);
window.localStorage.setItem('theme', t);
$('theme-toggle').textContent = t === 'dark' ? '☀️' : '🌘';
}
setTheme(theme);
$('theme-toggle').onclick = () => setTheme(theme === 'dark' ? 'light' : 'dark');
const state = {
tables: [],
columnsByTable: new Map(),
dictSources: new Map(),
refreshes: new Map(),
nodes: new Map(),
nodesByDb: new Map(),
edges: [],
showColumns: true,
dbFilter: '',
selectedKey: null,
zoom: 1.0,
/// INSERT-pipeline heat-map state populated from `system.query_views_log`.
loadPeriodDays: 7, /// 0 disables the heat-map; otherwise the lookback window.
loadMetric: 'total_duration_ms',
loadByMv: new Map(), /// node key -> { executions, total_duration_ms, read_rows, ... }
loadByEdge: new Map(), /// "from\x00to" -> same shape (MV -> target only)
loadMax: { byMv: {}, byEdge: {} }, /// per-metric maxima used to normalize colour intensity
};
function setStatus(text, isError) {
$('status').textContent = text;
$('status').classList.toggle('error', !!isError);
}
/// Unambiguous composite key for a `(database, table)` pair. We cannot use
/// `db + '.' + t` because both halves are arbitrary ClickHouse identifiers
/// (any byte is legal in a backquoted name), so the resulting string is not
/// invertible — a database called `a.b` and a table called `c` would collide
/// with database `a` and table `b.c`. `JSON.stringify` on a two-element array
/// gives a one-to-one encoding regardless of the characters inside.
function tableKey(db, t) { return JSON.stringify([db, t]); }
/// Mirrors `backQuoteIfNeed` in ClickHouse (`src/IO/WriteHelpers.h`): bare identifier
/// when it matches `[A-Za-z_][A-Za-z0-9_]*` and is not a reserved word, otherwise
/// backquoted with the same character set the server escapes inside
/// `writeBackQuotedString` (backslash, backtick, plus `\b\f\n\r\t\0`). We need
/// to match exactly because the strings we compare against (`view_name` /
/// `view_target` in `system.query_views_log`) are produced by `getFullTableName`
/// on the server, which calls into the same writer.
const QUOTED_NAME_RESERVED = /^(distinct|all|table|select|from|values)$/i;
const QUOTED_NAME_ESCAPE = { '\\': '\\\\', '`': '\\`', '\b': '\\b', '\f': '\\f',
'\n': '\\n', '\r': '\\r', '\t': '\\t', '\0': '\\0' };
function backQuoteIfNeed(s) {
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(s) && !QUOTED_NAME_RESERVED.test(s)) return s;
return '`' + s.replace(/[\\`\b\f\n\r\t\0]/g, ch => QUOTED_NAME_ESCAPE[ch]) + '`';
}
function quotedFullName(db, t) { return backQuoteIfNeed(db) + '.' + backQuoteIfNeed(t); }
async function chQuery(query) {
const host = $('url').value.replace(/\/+$/, '');
const user = $('user').value;
const password = $('password').value;
const params = new URLSearchParams();
params.set('default_format', 'JSONCompactEachRowWithNamesAndTypes');
/// Always request CORS headers: covers both `file://` and the case where the
/// page is served from `/schema` but `URL` points to a different ClickHouse host.
/// The explicit `Authorization` header below triggers a preflight that would
/// otherwise fail without `Access-Control-Allow-*` on the response.
params.set('add_http_cors_header', '1');
if (user) params.set('user', user);
if (password) params.set('password', password);
const resp = await fetch(host + '/?' + params.toString(), {
method: 'POST',
body: query,
headers: { 'Authorization': 'never' },
});
const text = await resp.text();
if (!resp.ok) throw new Error(text);
/// JSONCompactEachRowWithNamesAndTypes: one row per line; first row = names, second = types, rest = data
const lines = text.split('\n').filter(l => l.length);
if (lines.length < 2) return { columns: [], rows: [] };
const names = JSON.parse(lines[0]);
const types = JSON.parse(lines[1]);
const rows = [];
for (let i = 2; i < lines.length; i++) {
const arr = JSON.parse(lines[i]);
const obj = {};
for (let j = 0; j < names.length; j++) obj[names[j]] = arr[j];
rows.push(obj);
}
return { columns: names, types, rows };
}
function engineKind(engine) {
if (!engine) return 'other';
if (engine === 'Dictionary') return 'dict';
if (engine === 'Distributed') return 'distributed';
if (engine === 'View') return 'view';
if (engine === 'LiveView' || engine === 'WindowView') return 'view';
if (engine === 'MaterializedView') return 'mv';
if (engine.includes('MergeTree')) return 'mt';
return 'other';
}
/// Short engine label for node headers — full engine names like
/// "MaterializedView" hide the actual table name in the header.
function engineLabel(n) {
if (n.kind === 'rmv') return 'RMV';
if (n.kind === 'mv') return 'MV';
return n.engine || '';
}
async function loadAll() {
setStatus('Loading tables...');
/// Skip server-internal databases; they pollute the graph and aren't user-facing.
const sysFilter = "WHERE database NOT IN ('system', 'INFORMATION_SCHEMA', 'information_schema')";
const tablesQ = `
SELECT
database,
name,
engine,
engine_full,
create_table_query,
sorting_key,
primary_key,
partition_key,
sampling_key,
total_rows,
total_bytes,
comment,
target_database,
target_table,
dependencies_database,
dependencies_table,
loading_dependencies_database,
loading_dependencies_table
FROM system.tables
${sysFilter}
ORDER BY database, name
`;
const columnsQ = `
SELECT database, table, name, type,
(is_in_primary_key OR is_in_sorting_key) AS is_key,
default_kind != '' AS has_default
FROM system.columns
${sysFilter}
ORDER BY database, table, position
`;
const dictsQ = `
SELECT database, name, source
FROM system.dictionaries
WHERE status = 'LOADED' OR status = 'NOT_LOADED' OR status = 'LOADING'
`;
const refreshesQ = `
SELECT database, view, status, last_success_time, next_refresh_time, exception
FROM system.view_refreshes
`;
let tablesRes;
try {
tablesRes = await chQuery(tablesQ);
} catch (e) {
setStatus('Failed: ' + e.message, true);
return;
}
let columnsRes = { rows: [] };
try { columnsRes = await chQuery(columnsQ); } catch (e) { console.warn(e); }
let dictsRes = { rows: [] };
try { dictsRes = await chQuery(dictsQ); } catch (e) { console.warn(e); }
let refreshesRes = { rows: [] };
try { refreshesRes = await chQuery(refreshesQ); } catch (e) { /// table may not exist
console.info('system.view_refreshes not available:', e.message);
}
state.tables = tablesRes.rows;
state.columnsByTable = new Map();
for (const c of columnsRes.rows) {
const k = tableKey(c.database, c.table);
if (!state.columnsByTable.has(k)) state.columnsByTable.set(k, []);
state.columnsByTable.get(k).push(c);
}
state.dictSources = new Map();
for (const d of dictsRes.rows) {
state.dictSources.set(tableKey(d.database, d.name), d.source);
}
state.refreshes = new Map();
for (const r of refreshesRes.rows) {
state.refreshes.set(tableKey(r.database, r.view), r);
}
setStatus(`Loaded ${state.tables.length} tables, ${columnsRes.rows.length} columns.`);
buildGraph();
updateDbFilter();
await loadViewsLoad();
render();
}
/// Pulls aggregated INSERT-pipeline load from `system.query_views_log` for the configured
/// lookback window and stores it on `state.loadByMv` / `state.loadByEdge`. Best-effort:
/// if the table is empty, missing, or query_views_log is disabled, we silently fall back
/// to an empty heat-map.
async function loadViewsLoad() {
state.loadByMv = new Map();
state.loadByEdge = new Map();
state.loadMax = { byMv: {}, byEdge: {} };
const days = Number(state.loadPeriodDays || 0);
if (!days || !state.nodes.size) {
return;
}
/// `view_name` and `view_target` are produced by `StorageID::getFullTableName`, so they
/// arrive backquoted ("`weird db`.`weird tbl`") whenever an identifier isn't bare-safe.
/// Build a lookup map from that quoted form back to the plain `db.name` keys our graph
/// uses, then enrich the matching edges.
const quotedToKey = new Map();
for (const node of state.nodes.values()) {
quotedToKey.set(quotedFullName(node.database, node.name), node.key);
}
const q = `
SELECT
view_name,
view_target,
count() AS executions,
sum(view_duration_ms) AS total_duration_ms,
sum(read_rows) AS read_rows,
sum(read_bytes) AS read_bytes,
sum(written_rows) AS written_rows,
sum(written_bytes) AS written_bytes,
sum(peak_memory_usage) AS peak_memory_usage
FROM system.query_views_log
WHERE event_date >= today() - INTERVAL ${days} DAY
AND status IN ('QueryFinish', 'ExceptionWhileProcessing')
GROUP BY view_name, view_target
`;
let rows;
try {
const res = await chQuery(q);
rows = res.rows;
} catch (e) {
console.info('system.query_views_log not available:', e.message);
setStatus(`Loaded ${state.tables.length} tables, ${state.nodes.size} nodes (query_views_log unavailable).`);
return;
}
const metricKeys = ['executions', 'total_duration_ms', 'read_rows', 'read_bytes',
'written_rows', 'written_bytes', 'peak_memory_usage'];
const maxByMv = {}, maxByEdge = {};
for (const m of metricKeys) { maxByMv[m] = 0; maxByEdge[m] = 0; }
let matched = 0;
for (const r of rows) {
const mvKey = quotedToKey.get(r.view_name);
if (!mvKey) continue;
++matched;
const targetKey = r.view_target ? quotedToKey.get(r.view_target) : null;
const mvAgg = state.loadByMv.get(mvKey) || mkMetrics();
for (const m of metricKeys) mvAgg[m] += Number(r[m]) || 0;
state.loadByMv.set(mvKey, mvAgg);
for (const m of metricKeys) maxByMv[m] = Math.max(maxByMv[m], mvAgg[m]);
if (targetKey) {
const edgeKey = mvKey + '\x00' + targetKey;
const eAgg = state.loadByEdge.get(edgeKey) || mkMetrics();
for (const m of metricKeys) eAgg[m] += Number(r[m]) || 0;
state.loadByEdge.set(edgeKey, eAgg);
for (const m of metricKeys) maxByEdge[m] = Math.max(maxByEdge[m], eAgg[m]);
}
}
state.loadMax = { byMv: maxByMv, byEdge: maxByEdge };
setStatus(`Loaded ${state.tables.length} tables, ${state.nodes.size} nodes. ` +
`INSERT load: ${matched}/${rows.length} log rows over ${days}d (${state.loadByMv.size} MVs hit).`);
}
function mkMetrics() {
return {
executions: 0,
total_duration_ms: 0,
read_rows: 0,
read_bytes: 0,
written_rows: 0,
written_bytes: 0,
peak_memory_usage: 0,
};
}
/// 0..1 -> hex colour along the cool→hot gradient used in the legend.
function loadColour(intensity) {
intensity = Math.max(0, Math.min(1, intensity));
/// Four stops to match the CSS legend: cool blue → green → amber → red.
const stops = [
{ p: 0.00, r: 0x2a, g: 0x4d, b: 0x8a },
{ p: 0.33, r: 0x6a, g: 0xa8, b: 0x4f },
{ p: 0.66, r: 0xf1, g: 0xc2, b: 0x32 },
{ p: 1.00, r: 0xcc, g: 0x00, b: 0x00 },
];
let i = 0;
while (i < stops.length - 1 && intensity > stops[i + 1].p) ++i;
const a = stops[i], b = stops[i + 1] || stops[i];
const t = b.p === a.p ? 0 : (intensity - a.p) / (b.p - a.p);
const r = Math.round(a.r + (b.r - a.r) * t);
const g = Math.round(a.g + (b.g - a.g) * t);
const bl = Math.round(a.b + (b.b - a.b) * t);
return `rgb(${r}, ${g}, ${bl})`;
}
/// log-scale normalization — INSERT volumes span many orders of magnitude across views.
function loadIntensity(value, max) {
if (!value || !max || max <= 0) return 0;
if (max === value) return 1;
return Math.log1p(value) / Math.log1p(max);
}
function buildGraph() {
const nodes = new Map();
const nodesByDb = new Map();
const zipKeys = (dbs, tbls) => {
if (!dbs || !tbls) return [];
const out = [];
for (let i = 0; i < dbs.length && i < tbls.length; i++)
out.push(tableKey(dbs[i], tbls[i]));
return out;
};
for (const t of state.tables) {
const key = tableKey(t.database, t.name);
const isRefreshable = state.refreshes.has(key);
const kind = isRefreshable ? 'rmv' : engineKind(t.engine);
const node = {
key,
/// `key` is the unambiguous lookup key (a JSON tuple); `displayName`
/// is the human-facing qualified name we render in tooltips, lonely
/// labels, and the sidebar.
displayName: quotedFullName(t.database, t.name),
database: t.database,
name: t.name,
engine: t.engine,
engineFull: t.engine_full,
kind,
createQuery: t.create_table_query,
sortingKey: t.sorting_key,
primaryKey: t.primary_key,
partitionKey: t.partition_key,
samplingKey: t.sampling_key,
totalRows: t.total_rows,
totalBytes: t.total_bytes,
comment: t.comment,
targetDatabase: t.target_database || '',
targetTable: t.target_table || '',
dependents: zipKeys(t.dependencies_database, t.dependencies_table),
dependsOn: zipKeys(t.loading_dependencies_database, t.loading_dependencies_table),
columns: state.columnsByTable.get(key) || [],
dictSource: state.dictSources.get(key),
refresh: state.refreshes.get(key),
x: 0, y: 0, w: 0, h: 0,
depth: 0,
};
nodes.set(key, node);
if (!nodesByDb.has(t.database)) nodesByDb.set(t.database, []);
nodesByDb.get(t.database).push(node);
}
/// Edges: for each table, draw arrows source -> target.
/// Use loading_dependencies (depends_on) and dependents (already gives forward direction).
const edges = [];
const seen = new Set();
function addEdge(from, to, kind) {
if (!nodes.has(from) || !nodes.has(to)) return;
if (from === to) return;
const k = from + '\x00' + to;
if (seen.has(k)) return;
seen.add(k);
edges.push({ from, to, kind });
}
for (const node of nodes.values()) {
/// MV/RMV: SELECT FROM dependsOn, write to dependents
const isMv = node.kind === 'mv' || node.kind === 'rmv';
for (const dep of node.dependsOn) {
if (isMv) addEdge(dep, node.key, 'mv');
else if (node.kind === 'dict') addEdge(dep, node.key, 'dict');
else if (node.kind === 'distributed') addEdge(dep, node.key, 'distributed');
else addEdge(dep, node.key, 'normal');
}
for (const dep of node.dependents) {
/// "Tables that depend on me"; for MV the target table is here.
if (isMv) addEdge(node.key, dep, 'mv');
else if (nodes.get(dep) && (nodes.get(dep).kind === 'mv' || nodes.get(dep).kind === 'rmv'))
addEdge(node.key, dep, 'mv');
else if (nodes.get(dep) && nodes.get(dep).kind === 'dict')
addEdge(node.key, dep, 'dict');
else if (nodes.get(dep) && nodes.get(dep).kind === 'distributed')
addEdge(node.key, dep, 'distributed');
else
addEdge(node.key, dep, 'normal');
}
/// MV -> destination table. Exposed by `system.tables.target_table`,
/// covering both the explicit `TO` target and the implicit `.inner.*`
/// table created by `MATERIALIZED VIEW ... ENGINE = ... AS SELECT ...`.
if (isMv && node.targetTable) {
addEdge(node.key, tableKey(node.targetDatabase || node.database, node.targetTable), 'mv');
}
}
state.nodes = nodes;
state.nodesByDb = nodesByDb;
state.edges = edges;
}
function updateDbFilter() {
const sel = $('db-filter');
const cur = sel.value;
sel.innerHTML = '<option value="">All databases</option>';
const dbs = Array.from(state.nodesByDb.keys()).sort();
for (const db of dbs) {
const o = document.createElement('option');
o.value = db;
o.textContent = `${db} (${state.nodesByDb.get(db).length})`;
sel.appendChild(o);
}
sel.value = dbs.includes(cur) ? cur : '';
state.dbFilter = sel.value;
}
/// Compute longest-path layering for a subgraph (one database).
function layoutDatabase(dbNodes, edges) {
const inDb = new Set(dbNodes.map(n => n.key));
/// Restrict edges to within database; cross-DB edges are drawn but ignored for layering.
const adjOut = new Map();
const adjIn = new Map();
for (const n of dbNodes) { adjOut.set(n.key, []); adjIn.set(n.key, []); }
for (const e of edges) {
if (inDb.has(e.from) && inDb.has(e.to)) {
adjOut.get(e.from).push(e.to);
adjIn.get(e.to).push(e.from);
}
}
/// Longest-path layering (topological), tolerate cycles by capping depth.
const depth = new Map();
const visiting = new Set();
function dfs(k, stack) {
if (depth.has(k)) return depth.get(k);
if (visiting.has(k)) return 0;
visiting.add(k);
let d = 0;
for (const u of adjIn.get(k) || []) {
if (stack.has(u)) continue;
stack.add(u);
d = Math.max(d, dfs(u, stack) + 1);
stack.delete(u);
}
visiting.delete(k);
depth.set(k, d);
return d;
}
for (const n of dbNodes) dfs(n.key, new Set([n.key]));
/// Group by depth.
const layers = new Map();
let maxDepth = 0;
for (const n of dbNodes) {
const d = depth.get(n.key) || 0;
if (!layers.has(d)) layers.set(d, []);
layers.get(d).push(n);
if (d > maxDepth) maxDepth = d;
}
/// Order within layer alphabetically initially, then apply barycenter for crossing reduction.
for (const arr of layers.values()) arr.sort((a, b) => a.name.localeCompare(b.name));
for (let iter = 0; iter < 8; iter++) {
for (let d = 1; d <= maxDepth; d++) {
const layer = layers.get(d) || [];
const prev = layers.get(d - 1) || [];
const prevIdx = new Map();
prev.forEach((n, i) => prevIdx.set(n.key, i));
for (const n of layer) {
const ins = (adjIn.get(n.key) || []).filter(k => prevIdx.has(k)).map(k => prevIdx.get(k));
n._bary = ins.length ? ins.reduce((a, b) => a + b, 0) / ins.length : prevIdx.size / 2;
}
layer.sort((a, b) => (a._bary - b._bary) || a.name.localeCompare(b.name));
}
for (let d = maxDepth - 1; d >= 0; d--) {
const layer = layers.get(d) || [];
const next = layers.get(d + 1) || [];
const nextIdx = new Map();
next.forEach((n, i) => nextIdx.set(n.key, i));
for (const n of layer) {
const outs = (adjOut.get(n.key) || []).filter(k => nextIdx.has(k)).map(k => nextIdx.get(k));
n._bary = outs.length ? outs.reduce((a, b) => a + b, 0) / outs.length : nextIdx.size / 2;
}
layer.sort((a, b) => (a._bary - b._bary) || a.name.localeCompare(b.name));
}
}
return { layers, maxDepth };
}
function estimateNodeSize(node) {
const showCols = state.showColumns;
const colCount = showCols ? Math.min(node.columns.length, 14) : 0;
const w = 220;
const h = 36 + colCount * 17 + (node.totalRows != null && node.totalRows !== '0' ? 20 : 0);
return { w, h };
}
/// Find connected components in `nodeSet`, treating `state.edges` as undirected
/// for grouping. Each returned component is the list of node keys.
function connectedComponentsIn(nodeSet) {
const adj = new Map();
for (const k of nodeSet) adj.set(k, []);
for (const e of state.edges) {
if (nodeSet.has(e.from) && nodeSet.has(e.to)) {
adj.get(e.from).push(e.to);
adj.get(e.to).push(e.from);
}
}
const seen = new Set();
const comps = [];
for (const start of nodeSet) {
if (seen.has(start)) continue;
const stack = [start], comp = [];
seen.add(start);
while (stack.length) {
const v = stack.pop();
comp.push(v);
for (const u of adj.get(v)) {
if (!seen.has(u)) { seen.add(u); stack.push(u); }
}
}
comps.push(comp);
}
return comps;
}
/// Layout strategy:
/// 1. Each database section contains only its multi-node chains. Within a section
/// we stack chains top-to-bottom, ordered by depth desc then load desc — the
/// longest / beefiest MV graph rises to the top.
/// 2. Database sections themselves are then ordered the same way: the database
/// with the longest top chain (and highest load) goes first.
/// 3. Singleton (lonely) nodes from every database are pulled out into one wide
/// "Independent tables" section at the bottom, packed into a grid grouped by
/// engine kind and sorted by `db.name` within each group.
function layoutAll() {
const visibleDbs = Array.from(state.nodesByDb.keys())
.filter(db => !state.dbFilter || state.dbFilter === db)
.sort();
const LAYER_W = 260;
const NODE_GAP_X = 60; /// horizontal gap between layers (= length of inter-layer arrow)
const NODE_GAP_Y = 36; /// vertical gap between nodes inside a column / grid row
const CHAIN_GAP_Y = 60; /// vertical gap between stacked chains in the same DB section
const DB_PADDING = 16;
const DB_PADDING_TOP = 32; /// space for the section label
const DB_GAP = 28;
/// Pre-size nodes (estimate; the real height is measured after the first render).
for (const n of state.nodes.values()) {
const s = estimateNodeSize(n);
n.w = s.w;
n.h = s.h;
n._lonely = false;
}
const metric = state.loadMetric;
function chainLoad(keys) {
let s = 0;
for (const k of keys) {
const ld = state.loadByMv.get(k);
if (ld) s += ld[metric] || 0;
}
return s;
}
const dbSections = [];
const lonelyNodes = [];
for (const db of visibleDbs) {
const dbNodes = state.nodesByDb.get(db);
const dbKeys = new Set(dbNodes.map(n => n.key));
const comps = connectedComponentsIn(dbKeys);
const chains = [];
for (const comp of comps) {
if (comp.length === 1) {
lonelyNodes.push(state.nodes.get(comp[0]));
} else {
chains.push(comp);
}
}
if (!chains.length) continue;
/// Layered layout per chain.
const chainLayouts = [];
for (const comp of chains) {
const compNodes = comp.map(k => state.nodes.get(k));
const { layers, maxDepth } = layoutDatabase(compNodes, state.edges);
const layerHeights = new Map();
for (let d = 0; d <= maxDepth; d++) {
const layer = layers.get(d) || [];
let y = 0;
for (const n of layer) {
n._cx = d * (LAYER_W + NODE_GAP_X);
n._cy = y;
y += n.h + NODE_GAP_Y;
}
layerHeights.set(d, y);
}
const chainW = Math.max(0, (maxDepth + 1) * LAYER_W + maxDepth * NODE_GAP_X);
const chainH = Math.max(...Array.from(layerHeights.values()), 0);
chainLayouts.push({
comp,
maxDepth,
loadSum: chainLoad(comp),
chainW,
chainH,
});
}
chainLayouts.sort((a, b) =>
(b.maxDepth - a.maxDepth) ||
(b.loadSum - a.loadSum) ||
((a.comp[0] || '').localeCompare(b.comp[0] || '')));
/// Stack chains vertically inside the section.
let curY = 0;
let sectionW = 0;
for (const cl of chainLayouts) {
for (const k of cl.comp) {
const n = state.nodes.get(k);
n._rx = n._cx || 0;
n._ry = curY + (n._cy || 0);
}
curY += cl.chainH + CHAIN_GAP_Y;
sectionW = Math.max(sectionW, cl.chainW);
}
const sectionH = Math.max(0, curY - CHAIN_GAP_Y);
dbSections.push({
kind: 'db',
db,
nodes: chains.flat().map(k => state.nodes.get(k)),
w: sectionW + DB_PADDING * 2,
h: sectionH + DB_PADDING_TOP + DB_PADDING,
sortDepth: chainLayouts[0].maxDepth,
sortLoad: chainLayouts.reduce((s, c) => s + c.loadSum, 0),
});
}
/// Order DB sections: longest top chain first, then by total load, then alpha.
dbSections.sort((a, b) =>
(b.sortDepth - a.sortDepth) ||
(b.sortLoad - a.sortLoad) ||
a.db.localeCompare(b.db));
/// Pack DB sections in a wrapping grid that fills viewport width.
const MARGIN = 8;
const viewportW = Math.max(LAYER_W, $('viewport').clientWidth - MARGIN * 2);
let cursorX = MARGIN, cursorY = MARGIN, rowH = 0;
const placed = [];
for (const s of dbSections) {
if (cursorX > MARGIN && cursorX + s.w > viewportW + MARGIN) {
cursorX = MARGIN;
cursorY += rowH + DB_GAP;
rowH = 0;
}
s.x = cursorX;
s.y = cursorY;
for (const n of s.nodes) {
n.x = cursorX + DB_PADDING + (n._rx || 0);
n.y = cursorY + DB_PADDING_TOP + (n._ry || 0);
}
cursorX += s.w + DB_GAP;
rowH = Math.max(rowH, s.h);
placed.push(s);
}
/// Spread lonely tables across the full viewport width.
if (lonelyNodes.length) {
for (const n of lonelyNodes) n._lonely = true;
/// Group by engine kind, sort alphabetically by `db.name` within each group.
const kindOrder = ['mt', 'view', 'mv', 'rmv', 'dict', 'distributed', 'other'];
lonelyNodes.sort((a, b) => {
const ka = kindOrder.indexOf(a.kind);
const kb = kindOrder.indexOf(b.kind);
const ra = ka < 0 ? 99 : ka;
const rb = kb < 0 ? 99 : kb;
if (ra !== rb) return ra - rb;
return a.key.localeCompare(b.key);
});
/// Drop down to a new row so the grid stretches across the full width.
if (cursorX > MARGIN) {
cursorX = MARGIN;
cursorY += rowH + DB_GAP;
rowH = 0;
}
const innerW = viewportW - DB_PADDING * 2;
const cols = Math.max(1, Math.floor((innerW + NODE_GAP_X) / (LAYER_W + NODE_GAP_X)));
const usedW = cols * LAYER_W + (cols - 1) * NODE_GAP_X;
/// Row-major grid: when we wrap to a new row use the *prior* row's max height.
let col = 0;
let gx = 0, gy = 0, rowMaxH = 0;
for (const n of lonelyNodes) {
n._rx = col * (LAYER_W + NODE_GAP_X);
n._ry = gy;
rowMaxH = Math.max(rowMaxH, n.h);
col++;
if (col >= cols) {
gy += rowMaxH + NODE_GAP_Y;
rowMaxH = 0;
col = 0;
}
}
const lonelyInnerH = gy + (col > 0 ? rowMaxH : 0);
const lonelySection = {
kind: 'lonely',
db: `Independent tables (${lonelyNodes.length})`,
nodes: lonelyNodes.slice(),
w: usedW + DB_PADDING * 2,
h: lonelyInnerH + DB_PADDING_TOP + DB_PADDING,
x: cursorX,
y: cursorY,
};
for (const n of lonelyNodes) {
n.x = cursorX + DB_PADDING + (n._rx || 0);
n.y = cursorY + DB_PADDING_TOP + (n._ry || 0);
}
placed.push(lonelySection);
}
state.sections = placed;
}
function fmtBytes(n) {
if (n == null) return '';
n = Number(n);
if (!isFinite(n) || n === 0) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let u = 0;
while (n >= 1024 && u < units.length - 1) { n /= 1024; u++; }
return n.toFixed(n < 10 ? 2 : (n < 100 ? 1 : 0)) + ' ' + units[u];
}
function fmtRows(n) {
if (n == null) return '';
n = Number(n);
if (!isFinite(n) || n === 0) return '';
if (n >= 1e12) return (n / 1e12).toFixed(2) + 'T';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(2) + 'K';
return String(n);
}
function fmtMs(n) {
n = Number(n) || 0;
if (n < 1000) return n.toFixed(0) + ' ms';
if (n < 60 * 1000) return (n / 1000).toFixed(2) + ' s';
if (n < 60 * 60 * 1000) return (n / 60000).toFixed(2) + ' min';
return (n / 3600000).toFixed(2) + ' h';
}
function formatLoadValue(metric, value) {
switch (metric) {
case 'written_bytes':
case 'read_bytes':
case 'peak_memory_usage':
return fmtBytes(value);
case 'total_duration_ms':
return fmtMs(value);
case 'executions':
return String(Math.round(Number(value) || 0));
default:
return fmtRows(value);
}
}
function render() {
const canvas = $('canvas');
const svg = $('links-svg');
/// Remove existing nodes/sections (keep svg).
Array.from(canvas.querySelectorAll('.node, .db-group')).forEach(e => e.remove());
if (!state.tables.length) {
$('empty').classList.remove('hidden');
svg.innerHTML = '';
return;
}
$('empty').classList.add('hidden');
layoutAll();
const search = $('search').value.trim().toLowerCase();
const filtered = new Set();
if (search) {
for (const n of state.nodes.values()) {
if (n.displayName.toLowerCase().includes(search)) filtered.add(n.key);
else if (n.columns.some(c => c.name.toLowerCase().includes(search))) filtered.add(n.key);
}
}
let maxX = 0, maxY = 0;
for (let i = 0; i < state.sections.length; ++i) {
const s = state.sections[i];
const g = document.createElement('div');
g.className = 'db-group';
g.dataset.sectionIndex = String(i);
g.style.left = s.x + 'px';
g.style.top = s.y + 'px';
g.style.width = s.w + 'px';
g.style.height = s.h + 'px';
const label = document.createElement('div');
label.className = 'db-label';
label.textContent = s.db;
g.appendChild(label);
canvas.appendChild(g);
}
for (const n of state.nodes.values()) {
if (state.dbFilter && n.database !== state.dbFilter) continue;
if (!n.w) continue;
const el = document.createElement('div');
el.className = 'node node-' + n.kind;
el.dataset.key = n.key;
el.style.left = n.x + 'px';
el.style.top = n.y + 'px';
el.style.width = n.w + 'px';
if (state.selectedKey === n.key) el.classList.add('selected');
if (search && !filtered.has(n.key)) el.classList.add('dimmed');
const header = document.createElement('div');
header.className = 'node-header';
const nameEl = document.createElement('span');
nameEl.className = 'node-name';
nameEl.title = n.displayName;
/// Lonely nodes live in the shared "Independent tables" section that doesn't
/// have a per-database label, so show the qualified name there.
nameEl.textContent = n._lonely ? n.displayName : n.name;
const kindEl = document.createElement('span');
kindEl.className = 'node-kind';
kindEl.textContent = engineLabel(n);
header.appendChild(nameEl);
header.appendChild(kindEl);
/// INSERT-pipeline load badge: only on MVs/RMVs that ran during the window.
const mvLoad = state.loadByMv.get(n.key);
if (mvLoad && state.loadPeriodDays) {
const metric = state.loadMetric;
const value = mvLoad[metric] || 0;
const max = state.loadMax.byMv[metric] || 0;
if (value > 0) {
const badge = document.createElement('span');
badge.className = 'load-badge';
badge.title = `INSERT pipeline load (${metric}) over last ${state.loadPeriodDays}d`;
badge.style.background = loadColour(loadIntensity(value, max));
badge.textContent = formatLoadValue(metric, value);
header.appendChild(badge);
}
}
el.appendChild(header);
if (state.showColumns && n.columns.length) {
const cols = document.createElement('div');
cols.className = 'node-columns';
const maxShow = 14;
const slice = n.columns.slice(0, maxShow);
for (const c of slice) {
const row = document.createElement('div');
row.className = 'column';
const cn = document.createElement('span');
cn.className = 'column-name';
if (c.is_key === 1 || c.is_key === true) cn.classList.add('key');
else if (c.has_default === 1 || c.has_default === true) cn.classList.add('default');
cn.textContent = c.name;
const ct = document.createElement('span');
ct.className = 'column-type';
ct.title = c.type;
ct.textContent = c.type;
row.appendChild(cn);
row.appendChild(ct);
cols.appendChild(row);
}
if (n.columns.length > maxShow) {
const more = document.createElement('div');
more.className = 'column';
more.style.color = 'var(--muted)';
more.style.fontStyle = 'italic';
more.textContent = `… ${n.columns.length - maxShow} more`;
cols.appendChild(more);
}
el.appendChild(cols);
}
if (n.totalRows && Number(n.totalRows) > 0) {
const stats = document.createElement('div');
stats.className = 'node-stats';
const r = document.createElement('span');
r.textContent = fmtRows(n.totalRows) + ' rows';
const b = document.createElement('span');
b.textContent = fmtBytes(n.totalBytes);
stats.appendChild(r);
stats.appendChild(b);
el.appendChild(stats);
}
el.addEventListener('click', e => {
e.stopPropagation();
selectNode(n.key);
});
attachDrag(el, n);
canvas.appendChild(el);
maxX = Math.max(maxX, n.x + n.w);
maxY = Math.max(maxY, n.y + n.h);
}
/// Now after nodes are in DOM, measure actual heights (column lists can vary slightly).
for (const el of canvas.querySelectorAll('.node')) {
const k = el.dataset.key;
const node = state.nodes.get(k);
if (node) {
node.h = el.offsetHeight;
node.w = el.offsetWidth;
maxX = Math.max(maxX, node.x + node.w);
maxY = Math.max(maxY, node.y + node.h);
}
}
/// Update section bounds based on actual node sizes. Use the section index
/// stamped on each `.db-group` so we can resolve nodes via `state.sections`
/// instead of looking up by label text — the synthetic "Independent tables"
/// section's label is not a database key and would otherwise collapse the
/// section to the empty 24x24 padding.
for (const g of canvas.querySelectorAll('.db-group')) {
const idx = Number(g.dataset.sectionIndex);
const section = state.sections[idx];
if (!section) continue;
let mx = 0, my = 0;
const sx = parseFloat(g.style.left), sy = parseFloat(g.style.top);
for (const n of section.nodes) {
if (state.dbFilter && n.database !== state.dbFilter) continue;
mx = Math.max(mx, n.x + n.w - sx);
my = Math.max(my, n.y + n.h - sy);
}
g.style.width = (mx + 24) + 'px';
g.style.height = (my + 24) + 'px';
}
drawEdges();
canvas.style.width = (maxX + 60) + 'px';
canvas.style.height = (maxY + 60) + 'px';
svg.setAttribute('width', maxX + 60);
svg.setAttribute('height', maxY + 60);
}
function drawEdges() {
const svg = $('links-svg');
svg.innerHTML = '';
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
svg.appendChild(defs);
const visible = new Set();
for (const n of state.nodes.values()) {
if (!state.dbFilter || n.database === state.dbFilter) visible.add(n.key);
}
const search = $('search').value.trim().toLowerCase();
const filtered = new Set();
if (search) {
for (const n of state.nodes.values()) {
if (n.displayName.toLowerCase().includes(search) ||
n.columns.some(c => c.name.toLowerCase().includes(search))) filtered.add(n.key);
}
}
/// Build adjacency for highlighting.
const incoming = new Map(), outgoing = new Map();
for (const e of state.edges) {
if (!visible.has(e.from) || !visible.has(e.to)) continue;
if (!incoming.has(e.to)) incoming.set(e.to, []);
if (!outgoing.has(e.from)) outgoing.set(e.from, []);
incoming.get(e.to).push(e.from);
outgoing.get(e.from).push(e.to);
}
state._incoming = incoming;
state._outgoing = outgoing;
const metric = state.loadMetric;
const edgeMax = state.loadMax.byEdge[metric] || 0;
for (const e of state.edges) {
if (!visible.has(e.from) || !visible.has(e.to)) continue;
const a = state.nodes.get(e.from);
const b = state.nodes.get(e.to);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
const kindClass = e.kind === 'normal' ? '' : e.kind;
path.setAttribute('class', 'arrow ' + kindClass);
path.setAttribute('data-from', e.from);
path.setAttribute('data-to', e.to);
const x1 = a.x + a.w;
const y1 = a.y + a.h / 2;
const x2 = b.x;
const y2 = b.y + b.h / 2;
const dx = Math.max(40, (x2 - x1) / 2);
const d = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2 - 8} ${y2}`;
path.setAttribute('d', d);
/// Heat-map: paint MV -> target edges that carried INSERT traffic during the window.
let arrowColour = null;
if (state.loadPeriodDays && edgeMax > 0) {
const edgeLoad = state.loadByEdge.get(e.from + '\x00' + e.to);
if (edgeLoad) {
const value = edgeLoad[metric] || 0;
if (value > 0) {
const intensity = loadIntensity(value, edgeMax);
arrowColour = loadColour(intensity);
path.setAttribute('stroke', arrowColour);
path.setAttribute('stroke-width', String(1.5 + 3 * intensity));
path.style.opacity = String(0.55 + 0.45 * intensity);
path.classList.add('hot');
const titleNode = document.createElementNS('http://www.w3.org/2000/svg', 'title');
titleNode.textContent =
`${e.from} → ${e.to}\n${metric} = ${formatLoadValue(metric, value)}` +
` over last ${state.loadPeriodDays}d`;
path.appendChild(titleNode);
}
}
}
svg.appendChild(path);
/// Arrowhead.
const ah = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
ah.setAttribute('class', 'arrowhead ' + kindClass);
ah.setAttribute('data-from', e.from);
ah.setAttribute('data-to', e.to);
const angle = Math.atan2(y2 - y1, (x2 - 8) - (x1 + dx));
const hx = x2 - 4, hy = y2;
const len = 8, sp = 4;
const p1x = hx - len * Math.cos(angle) + sp * Math.sin(angle);
const p1y = hy - len * Math.sin(angle) - sp * Math.cos(angle);
const p2x = hx - len * Math.cos(angle) - sp * Math.sin(angle);
const p2y = hy - len * Math.sin(angle) + sp * Math.cos(angle);
ah.setAttribute('points', `${hx},${hy} ${p1x},${p1y} ${p2x},${p2y}`);
if (arrowColour) {
ah.setAttribute('fill', arrowColour);
ah.setAttribute('stroke', arrowColour);
}
svg.appendChild(ah);
if (search) {
if (!filtered.has(e.from) && !filtered.has(e.to)) {
path.classList.add('dimmed');
ah.classList.add('dimmed');
}
}
}
}
function highlightSelection(key) {
const ins = state._incoming?.get(key) || [];
const outs = state._outgoing?.get(key) || [];
const relevant = new Set([key, ...ins, ...outs]);
for (const el of document.querySelectorAll('.node')) {
el.classList.remove('highlighted', 'dimmed', 'selected');
if (el.dataset.key === key) el.classList.add('selected');
else if (relevant.has(el.dataset.key)) el.classList.add('highlighted');
else el.classList.add('dimmed');
}
for (const p of document.querySelectorAll('.arrow, .arrowhead')) {
p.classList.remove('highlighted', 'dimmed');
const from = p.getAttribute('data-from');
const to = p.getAttribute('data-to');
if (from === key || to === key) p.classList.add('highlighted');
else p.classList.add('dimmed');
}
}
function clearHighlight() {
for (const el of document.querySelectorAll('.node'))
el.classList.remove('highlighted', 'dimmed', 'selected');
for (const p of document.querySelectorAll('.arrow, .arrowhead'))
p.classList.remove('highlighted', 'dimmed');
state.selectedKey = null;
$('sidebar').classList.remove('open');
}
function selectNode(key) {
state.selectedKey = key;
highlightSelection(key);
showSidebar(key);
}
function showSidebar(key) {
const n = state.nodes.get(key);
if (!n) return;
$('sidebar-title').textContent = n.displayName;
const c = $('sidebar-content');
c.innerHTML = '';
const tbl = document.createElement('table');
const rows = [
['Database', n.database],
['Name', n.name],
['Engine', n.engineFull || n.engine],
];
if (n.partitionKey) rows.push(['Partition by', n.partitionKey]);
if (n.sortingKey) rows.push(['Order by', n.sortingKey]);
if (n.primaryKey && n.primaryKey !== n.sortingKey) rows.push(['Primary key', n.primaryKey]);
if (n.samplingKey) rows.push(['Sample by', n.samplingKey]);
if (n.totalRows && Number(n.totalRows) > 0) rows.push(['Rows', fmtRows(n.totalRows)]);
if (n.totalBytes && Number(n.totalBytes) > 0) rows.push(['Bytes', fmtBytes(n.totalBytes)]);
if (n.comment) rows.push(['Comment', n.comment]);
if (n.refresh) {
rows.push(['Refresh status', n.refresh.status]);
if (n.refresh.last_success_time) rows.push(['Last refresh', n.refresh.last_success_time]);
if (n.refresh.next_refresh_time) rows.push(['Next refresh', n.refresh.next_refresh_time]);
if (n.refresh.exception) rows.push(['Refresh error', n.refresh.exception]);
}
if (n.dictSource) rows.push(['Dictionary source', n.dictSource]);
const mvLoad = state.loadByMv.get(n.key);
if (mvLoad && state.loadPeriodDays) {
rows.push([`INSERT load (last ${state.loadPeriodDays}d)`, '']);
rows.push([' Executions', formatLoadValue('executions', mvLoad.executions)]);
rows.push([' Rows written', formatLoadValue('written_rows', mvLoad.written_rows)]);
rows.push([' Bytes written', formatLoadValue('written_bytes', mvLoad.written_bytes)]);
rows.push([' Rows read', formatLoadValue('read_rows', mvLoad.read_rows)]);
rows.push([' Bytes read', formatLoadValue('read_bytes', mvLoad.read_bytes)]);
rows.push([' Total view duration', formatLoadValue('total_duration_ms', mvLoad.total_duration_ms)]);
rows.push([' Peak memory (sum)', formatLoadValue('peak_memory_usage', mvLoad.peak_memory_usage)]);
}
for (const [k, v] of rows) {
const tr = document.createElement('tr');
const td1 = document.createElement('td'); td1.textContent = k;
const td2 = document.createElement('td'); td2.textContent = v == null ? '' : String(v);
tr.appendChild(td1); tr.appendChild(td2);
tbl.appendChild(tr);
}
c.appendChild(tbl);
if (n.columns.length) {
const h = document.createElement('h3');
h.textContent = `Columns (${n.columns.length})`;
c.appendChild(h);
const ct = document.createElement('table');
for (const col of n.columns) {
const tr = document.createElement('tr');
const td1 = document.createElement('td');
td1.textContent = col.name;
if (col.is_key === 1 || col.is_key === true) td1.style.color = 'var(--column-key)';
const td2 = document.createElement('td');
td2.textContent = col.type;
td2.style.color = 'var(--muted)';
td2.style.whiteSpace = 'normal';
td2.style.wordBreak = 'break-word';
tr.appendChild(td1); tr.appendChild(td2);
ct.appendChild(tr);
}
c.appendChild(ct);
}
const ins = state._incoming?.get(key) || [];
const outs = state._outgoing?.get(key) || [];
if (ins.length) {
const h = document.createElement('h3');
h.textContent = `Reads from (${ins.length})`;
c.appendChild(h);
const wrap = document.createElement('div');
wrap.className = 'related-table';
for (const k of ins) {
const a = document.createElement('a');
a.textContent = state.nodes.get(k)?.displayName || k;
a.onclick = () => selectNode(k);
wrap.appendChild(a);
}
c.appendChild(wrap);
}
if (outs.length) {
const h = document.createElement('h3');
h.textContent = `Writes to / depended on by (${outs.length})`;
c.appendChild(h);
const wrap = document.createElement('div');
wrap.className = 'related-table';
for (const k of outs) {
const a = document.createElement('a');
a.textContent = state.nodes.get(k)?.displayName || k;
a.onclick = () => selectNode(k);
wrap.appendChild(a);
}
c.appendChild(wrap);
}
if (n.createQuery) {
const h = document.createElement('h3');
h.textContent = 'CREATE statement';
c.appendChild(h);
const pre = document.createElement('pre');
pre.textContent = n.createQuery;
c.appendChild(pre);
}
$('sidebar').classList.add('open');
}
function attachDrag(el, node) {
let startX, startY, origX, origY, dragging = false;
el.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
dragging = false;
startX = e.clientX; startY = e.clientY;
origX = node.x; origY = node.y;
const onMove = (ev) => {
const dx = (ev.clientX - startX) / state.zoom;
const dy = (ev.clientY - startY) / state.zoom;
if (!dragging && Math.abs(dx) + Math.abs(dy) > 4) dragging = true;
if (!dragging) return;
node.x = origX + dx;
node.y = origY + dy;
el.style.left = node.x + 'px';
el.style.top = node.y + 'px';
drawEdges();
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
if (dragging) {
/// Suppress the click after a drag.
const stopClick = (ev) => { ev.stopPropagation(); el.removeEventListener('click', stopClick, true); };
el.addEventListener('click', stopClick, true);
}
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
});
}
function applyZoom() {
$('canvas').style.transform = `scale(${state.zoom})`;
}
$('zoom-in').onclick = () => { state.zoom = Math.min(2, state.zoom * 1.2); applyZoom(); };
$('zoom-out').onclick = () => { state.zoom = Math.max(0.2, state.zoom / 1.2); applyZoom(); };
$('zoom-reset').onclick = () => { state.zoom = 1; applyZoom(); };
$('load').onclick = loadAll;
$('relayout').onclick = render;
$('db-filter').onchange = (e) => { state.dbFilter = e.target.value; render(); };
$('toggle-columns').onclick = (e) => {
state.showColumns = !state.showColumns;
e.target.classList.toggle('active', state.showColumns);
render();
};
$('load-period').onchange = async (e) => {
state.loadPeriodDays = Number(e.target.value) || 0;
if (state.nodes.size) {
await loadViewsLoad();
render();
}
};
$('load-metric').onchange = (e) => {
state.loadMetric = e.target.value;
render();
};
let searchTimer;
$('search').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(render, 200);
});
$('sidebar-close').onclick = clearHighlight;
$('viewport').addEventListener('click', clearHighlight);
state.showColumns = $('toggle-columns').classList.contains('active');
state.loadPeriodDays = Number($('load-period').value) || 0;
state.loadMetric = $('load-metric').value;
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') clearHighlight();
});
/// Auto-load if same-origin (location.protocol !== 'file:') and user is default.
if (location.protocol !== 'file:') {
loadAll();
}
</script>
</body>
</html>