Files
zeekayandHanzo Dev e53ccdf6d5 the pages call the server this fork actually is
Three of these were not branding at all — they were live breakage, because the
server was renamed and its consumers were not:

- dashboard.html parsed `ClickHouseAsyncMetrics_` while PrometheusMetricsWriter
  emits `DatastoreAsyncMetrics_`. Paste the Prometheus output into the metrics
  editor and it matched nothing.
- trace-visualizer queried `attribute['clickhouse.thread_id']` while
  OpenTelemetryTraceContext records `datastore.thread_id`. The trace tab found
  no spans.
- both visualizers loaded `js/query-clickhouse.js`; the file on disk is
  `query-datastore.js`, so the script 404'd and the query function never
  existed.

The rest is the name: titles, navbar brands, the status line a user reads after
connecting, links into upstream's repo and docs site, their Cloud badges, and
tests-visualizer pulling a logo from their CDN and querying their public demo
server. Local symbols and DOM ids went with them (queryDatastore,
parseDatastoreTrace, #tabDatastore).

LICENSE, NOTICE and every copyright notice are untouched — no removed line in
this diff carries a ©, an SPDX id or an attribution.

`clickhouse_lexer_*` in docs.html STAYS, and a comment at the embed site says
why: those export names are baked into the base64 WebAssembly payload, so the
JS must match the blob. Renaming means renaming the C API, rebuilding
Lexer.wasm, re-embedding it and updating the calls in one change; rename either
side alone and syntax highlighting fails silently.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-14 10:17:16 -07:00

2250 lines
92 KiB
HTML
Raw Permalink 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>Datastore Schema Visualizer</title>
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDExIDEwIj48c3R5bGU+LmJne2ZpbGw6I2ZmMH0ub3tmaWxsOiMwMDB9PC9zdHlsZT48cmVjdCBjbGFzcz0iYmciIHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48cGF0aCBkPSJNMSwxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik0zLDEgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTUsMSBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNNywxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik05LDQuMjUgaDEgdjEuNSBoLTEgeiIgY2xhc3M9Im8iLz48L3N2Zz4K">
<style>
:root {
color-scheme: light;
--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-scheme: 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; }
/* Theme-aware scrollbars: `color-scheme` recolours the native scrollbars of
the connection-param widgets, while the explicit rules below match the
custom scroll areas (viewport, node column lists, sidebar) to the theme. */
html { scrollbar-width: thin; scrollbar-color: var(--border-color) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
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);
}
/* Live credential validation feedback (see `checkCredentials`). */
#user.ok, #password.ok { border-color: var(--success-color); }
#user.fail, #password.fail { border-color: var(--error-color); }
#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;
/* Dragging a node must not leave a parasitic text selection behind. */
user-select: none;
-webkit-user-select: none;
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 1 auto;
/* Allow the name to shrink (and thus ellipsize) below its content width
so a long engine label can't squeeze it out of the header. */
min-width: 0;
font-family: Monaco, Consolas, monospace;
}
.node-kind {
font-size: 0.7rem;
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 8rem;
flex: 0 1 auto;
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>Datastore Schema Visualizer</h1>
<div class="controls">
<form class="connection-params" id="connection-form">
<input id="url" name="url" type="text" placeholder="URL" spellcheck="false">
<input id="user" name="user" type="text" placeholder="user" spellcheck="false">
<input id="password" name="password" type="password" placeholder="password">
</form>
<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);
/// Connection defaults, mirroring `play.html`: the URL, user, and password can all be
/// supplied in the query string; otherwise the URL falls back to this server's origin
/// (or localhost when the page is opened from disk).
const currentUrl = new URL(window.location);
const openedLocally = location.protocol === 'file:';
if (!$('url').value) {
const serverAddress = currentUrl.searchParams.get('url');
if (serverAddress) $('url').value = serverAddress;
else if (!openedLocally) $('url').value = location.origin;
else $('url').value = 'http://localhost:8123/';
}
const userFromUrl = currentUrl.searchParams.get('user');
if (userFromUrl) $('user').value = userFromUrl;
const passFromUrl = currentUrl.searchParams.get('password');
if (passFromUrl) {
$('password').value = passFromUrl;
/// Strip the password back out of the address bar so it is not left in a shareable
/// URL or in browser history (`file:` URLs disallow `history` edits).
if (!openedLocally) {
currentUrl.searchParams.delete('password');
window.history.replaceState(null, '',
window.location.origin + window.location.pathname + '?'
+ currentUrl.searchParams.toString() + window.location.hash);
}
}
/// Browser Credential Management API: remember the entered credentials so the browser
/// can offer to save and later autofill them — same as `play.html`. Guarded because
/// `PasswordCredential` exists only in Chromium-based browsers.
///
/// Persist only for a same-origin target. `PasswordCredential` is scoped to the page origin,
/// not to the backend named in `#url` (its `name` field is a display label, not an isolation
/// key). Storing a cross-origin login here would let the browser autofill it on a later plain
/// visit; because the page then auto-loads the same-origin default (`isSameOriginTarget` at the
/// bottom of the script), those remembered credentials would be POSTed to this page's own server
/// instead of the cross-origin one they belong to. `play.html` can store unconditionally because
/// it never auto-contacts a server on open; `/schema` does, so it must not persist a credential
/// that its own auto-load could later misdirect. This is the same `isSameOriginTarget` invariant
/// that already gates the page-load auto-load and the paste probe.
function storeCredentials() {
if (window.PasswordCredential && $('password').value && isSameOriginTarget($('url').value)) {
try {
navigator.credentials.store(new PasswordCredential({
id: $('user').value,
password: $('password').value,
name: $('url').value,
}));
} catch (e) { console.warn('credentials.store failed:', e); }
}
}
/// Live credential feedback, as in `play.html`: paint the user/password fields green
/// once a probe query succeeds and red when it fails, and surface the server version.
/// The `Connected to Hanzo Datastore v...` message shares the single `#status` line with the load
/// status, so we record when that line currently holds a credential-probe message. That lets
/// `resetCredentialsStatus` clear the stale message — and only that message — when the fields
/// change, without wiping an unrelated load status such as `Loaded N tables`.
let statusIsCredentialProbe = false;
function setCredentialStatus(ok, version) {
for (const id of ['user', 'password']) {
$(id).classList.toggle('ok', ok);
$(id).classList.toggle('fail', !ok);
}
if (ok && version) {
setStatus(`Connected to Hanzo Datastore v${version}.`);
/// `setStatus` clears the flag, so claim ownership of the line afterwards.
statusIsCredentialProbe = true;
}
}
function resetCredentialsStatus() {
for (const id of ['user', 'password']) $(id).classList.remove('ok', 'fail');
/// Editing a field invalidates the probe: drop the now-stale `Connected to ...` text, but
/// leave a load status in place — it describes the last load, not the current credentials.
if (statusIsCredentialProbe) setStatus('');
}
/// Capture the connection (`url` / `user` / `password`) a load is about to use. The captured
/// values are passed to `chQuery` for every request in the batch, so the whole batch is bound
/// to the target the user committed to when they triggered the load: a cross-origin URL pasted
/// into the field mid-load cannot redirect the remaining automatic queries — and their
/// credentials — to a new server without an explicit `Load`. The returned `isCurrent` predicate
/// additionally reports whether the fields still hold those exact values, so a result that
/// arrives after the user edits a field, or after another load runs with different credentials,
/// cannot repaint or store the now-stale credential status.
function connectionSnapshot() {
const url = $('url').value;
const user = $('user').value;
const password = $('password').value;
return {
url, user, password,
isCurrent: () => $('url').value === url && $('user').value === user && $('password').value === password,
};
}
/// Monotonic token shared by every operation that can repaint the credential status (the green/red
/// on the user/password fields and the `Connected to ...` line): the `checkCredentials` probe below
/// and each `loadAll` batch. Both bump it at start and capture the value, and apply the paint/store
/// only while their captured value is still the latest. Field equality (`conn.isCurrent()`) alone is
/// not enough: two operations against the *same* `url`/`user`/`password` — `Load` clicked twice, or a
/// paste-probe racing a newer load — both satisfy it, so an older callback could otherwise flip the
/// fields to `fail` after a newer load has already succeeded. The token makes the latest-started
/// probe-or-load the sole authority over the credential status.
let credentialStatusGeneration = 0;
async function checkCredentials() {
const myStatus = ++credentialStatusGeneration;
const conn = connectionSnapshot();
/// A newer probe or load supersedes this one, even when the field values are unchanged.
const isCurrentStatus = () => myStatus === credentialStatusGeneration && conn.isCurrent();
try {
const res = await chQuery('SELECT version() AS v', conn);
if (isCurrentStatus()) setCredentialStatus(true, res.rows[0] && res.rows[0].v);
} catch (e) {
if (isCurrentStatus()) setCredentialStatus(false);
}
}
for (const id of ['url', 'user', 'password']) {
/// Editing any connection field invalidates the displayed status, including the `url`:
/// a green probe against one server must not stay green after the target changes.
$(id).addEventListener('input', resetCredentialsStatus);
/// The URL and credentials are usually pasted; re-check shortly after so we read the new value.
/// Only auto-probe a same-origin target: `checkCredentials` sends `user`/`password` to the
/// current `url`, so pasting a cross-origin URL into a trusted page that carries
/// password-manager-filled credentials must not contact it until an explicit `Load` — the
/// same invariant `isSameOriginTarget` enforces for page-load auto-load.
$(id).addEventListener('paste', () => setTimeout(() => {
if (isSameOriginTarget($('url').value)) checkCredentials();
}, 100));
}
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);
/// Any non-credential status (load progress/result/failure) takes over the line, so a later
/// field edit must not treat it as a stale probe message. `setCredentialStatus` re-sets this.
statusIsCredentialProbe = false;
}
/// Unambiguous composite key for a `(database, table)` pair. We cannot use
/// `db + '.' + t` because both halves are arbitrary Datastore 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 Datastore (`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, conn) {
/// `conn` binds the request to a connection captured by `connectionSnapshot` so a whole
/// load batch targets one server even if the fields change mid-load; without it we read
/// the live fields, as the single-shot credential probe does.
const host = (conn ? conn.url : $('url').value).replace(/\/+$/, '');
const user = conn ? conn.user : $('user').value;
const password = conn ? conn.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 Datastore 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 || '';
}
/// Monotonic load generation. `connectionSnapshot` binds a batch's requests — and its
/// credential status — to one server, but two loads can still race over the *rendered* result:
/// a same-origin auto-load can start, the user can edit `url` / credentials and press `Load`,
/// and if the newer load renders first the older batch's awaited queries can still return and
/// overwrite `state` and the graph with data from the superseded connection. Each load captures
/// the generation it started in and applies its results only while it is still the latest one,
/// so a superseded batch quietly drops its stale `state` / status / heat-map / render.
let loadGeneration = 0;
/// A finer-grained token for the `system.query_views_log` heat-map alone. The heat-map is
/// (re)loaded both at the end of every `loadAll` and standalone whenever the lookback selector
/// changes, so it can be superseded without a new `loadGeneration`: a standalone reload started
/// against one graph while a `loadAll` for the same generation rebuilds `state.nodes` and runs
/// its own heat-map, or two quick lookback changes. Each heat-map load captures this token and
/// only the latest may reset or apply `state.loadByMv` / `loadByEdge` / `loadMax`, so a stale
/// reload cannot write keys from a superseded graph onto the current one.
let viewsLoadGeneration = 0;
/// The connection (`connectionSnapshot`) that produced the currently displayed `state.nodes`,
/// committed by the last successful `loadAll`. A standalone heat-map reload from the lookback
/// selector binds to this — not the live form fields — so merely changing the lookback after
/// editing `url` cannot contact a newly-edited (possibly cross-origin) target before an explicit
/// `Load`, and cannot overlay one server's `query_views_log` onto another server's graph. Null
/// until the first load commits.
let loadedConnection = null;
async function loadAll() {
const myGeneration = ++loadGeneration;
const isLatestLoad = () => myGeneration === loadGeneration;
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
`;
/// Bind every query in this load to one captured connection (see `connectionSnapshot`):
/// the whole batch targets the server the user committed to, and a URL pasted mid-load
/// cannot redirect later queries — or their credentials — to a different origin. The same
/// snapshot decides whether a result may paint or store a now-stale credential status.
const conn = connectionSnapshot();
/// Take the shared credential-status token too (see `credentialStatusGeneration`): this load
/// may repaint the credential status below, and a newer probe or load must win even when the
/// connection fields are identical, so gating on `conn.isCurrent()` alone is not enough — an
/// older load that fails after a newer one succeeded would otherwise flip the fields to `fail`.
const myStatus = ++credentialStatusGeneration;
const isCurrentStatus = () => myStatus === credentialStatusGeneration && conn.isCurrent();
let tablesRes;
try {
tablesRes = await chQuery(tablesQ, conn);
} catch (e) {
if (isLatestLoad()) setStatus('Failed: ' + e.message, true);
if (isCurrentStatus()) setCredentialStatus(false);
return;
}
/// The probe succeeded, so the credentials are valid: reflect that, and offer to
/// remember them through the Credential Management API - but only while this is still the
/// latest credential-determining operation and the fields still hold the validated values.
if (isCurrentStatus()) {
setCredentialStatus(true);
storeCredentials();
}
let columnsRes = { rows: [] };
try { columnsRes = await chQuery(columnsQ, conn); } catch (e) { console.warn(e); }
let dictsRes = { rows: [] };
try { dictsRes = await chQuery(dictsQ, conn); } catch (e) { console.warn(e); }
let refreshesRes = { rows: [] };
try { refreshesRes = await chQuery(refreshesQ, conn); } catch (e) { /// table may not exist
console.info('system.view_refreshes not available:', e.message);
}
/// A newer load started while these queries were in flight — it owns the view now, so
/// applying this batch would render a schema from a superseded (possibly different) connection.
if (!isLatestLoad()) return;
/// This batch now owns the view: record the connection that produced it so a standalone
/// heat-map reload (a lookback change) stays bound to this server rather than the live fields.
loadedConnection = conn;
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(conn, myGeneration);
if (!isLatestLoad()) return;
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(conn, generation) {
/// Reuse the load batch's captured connection when called from `loadAll`. A standalone reload
/// from the lookback selector (`conn` omitted) must target the connection that produced the
/// displayed `state.nodes` — `loadedConnection`, committed by the last successful `loadAll` —
/// not the live form fields: otherwise editing `url` to a cross-origin target and then merely
/// changing the lookback would POST `user`/`password` there before any explicit `Load`, and
/// would overlay that server's `query_views_log` onto the displayed graph. Fail closed if no
/// load has committed a connection yet (there is no graph to key a heat-map against anyway).
conn = conn || loadedConnection;
if (!conn) return;
/// Reuse the caller's load generation (a `loadAll` batch) or, for a standalone reload from
/// the lookback selector, read the current one without bumping it — a standalone heat-map
/// reload must not cancel an in-flight `loadAll`, but a newer `loadAll` does supersede it.
if (generation === undefined) generation = loadGeneration;
/// On top of the load generation the heat-map carries its own monotonic token: it is reloaded
/// at the end of every `loadAll` and standalone on a lookback change, so it can be superseded
/// with no new `loadGeneration`. A reload is the latest only while both are still current, so
/// a stale batch — a standalone reload whose `state.nodes` a same-generation `loadAll` has
/// since rebuilt, or the earlier of two quick lookback changes — neither resets nor applies.
const myViewsLoad = ++viewsLoadGeneration;
const isLatestLoad = () => generation === loadGeneration && myViewsLoad === viewsLoadGeneration;
const days = Number(state.loadPeriodDays || 0);
if (!days || !state.nodes.size) {
/// Heat-map disabled or no graph to key against: clear it. This path is synchronous, so
/// nothing can have superseded us between the token capture above and here.
state.loadByMv = new Map();
state.loadByEdge = new Map();
state.loadMax = { byMv: {}, byEdge: {} };
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, conn);
rows = res.rows;
} catch (e) {
console.info('system.query_views_log not available:', e.message);
/// query_views_log is unavailable for the current graph: clear any stale heat-map and
/// report it — but only while we are still the latest reload, so a superseded probe
/// cannot blank a newer heat-map or overwrite its status.
if (isLatestLoad()) {
state.loadByMv = new Map();
state.loadByEdge = new Map();
state.loadMax = { byMv: {}, byEdge: {} };
setStatus(`Loaded ${state.tables.length} tables, ${state.nodes.size} nodes (query_views_log unavailable).`);
}
return;
}
/// A newer load or heat-map reload started while query_views_log was loading; it owns `state`
/// now, so this stale heat-map must neither reset nor overwrite the newer one
/// (`state.loadByMv` / `loadByEdge` / `loadMax`).
if (!isLatestLoad()) return;
/// Only now that we are still the latest reload do we replace the heat-map maps. Resetting
/// here rather than before the await means a superseded reload leaves the current heat-map
/// intact instead of blanking it.
state.loadByMv = new Map();
state.loadByEdge = new Map();
state.loadMax = { byMv: {}, byEdge: {} };
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 node tooltips and the
/// sidebar (node headers themselves show just the table name).
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 gets one section holding *all* of its tables. Multi-node
/// dependency chains are laid out as layered graphs and stacked top-to-bottom,
/// ordered by depth desc then load desc — the longest / beefiest MV graph rises
/// to the top.
/// 2. A database's standalone (single-node) tables are packed into a grid directly
/// below its chains, grouped by engine kind and sorted by `db.name`, so every
/// table stays grouped under its own database (no separate global bucket).
/// 3. Database sections are then ordered the same way — the database with the
/// longest top chain (and highest load) first — and wrap to fill the viewport.
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;
}
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 MARGIN = 8;
const viewportW = Math.max(LAYER_W, $('viewport').clientWidth - MARGIN * 2);
const dbSections = [];
for (const db of visibleDbs) {
const dbNodes = state.nodesByDb.get(db);
const dbKeys = new Set(dbNodes.map(n => n.key));
const comps = connectedComponentsIn(dbKeys);
/// Split this database's tables into multi-node dependency chains and
/// standalone (single-node) tables. Both stay inside this database's own
/// section, so grouping is consistent — independent tables are no longer
/// hoisted out into a separate global bucket.
const chains = [];
const singles = [];
for (const comp of comps) {
if (comp.length === 1) singles.push(state.nodes.get(comp[0]));
else chains.push(comp);
}
if (!chains.length && !singles.length) continue;
/// 1) Layered layout per chain; chains stacked top-to-bottom.
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] || '')));
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 chainsBlockH = chains.length ? Math.max(0, curY - CHAIN_GAP_Y) : 0;
/// 2) Independent tables of this database, packed into a grid below its
/// chains, grouped by engine kind and sorted by `db.name` within a group.
let singlesBlockH = 0;
if (singles.length) {
const kindOrder = ['mt', 'view', 'mv', 'rmv', 'dict', 'distributed', 'other'];
singles.sort((a, b) => {
const ra = kindOrder.indexOf(a.kind), rb = kindOrder.indexOf(b.kind);
const pa = ra < 0 ? 99 : ra, pb = rb < 0 ? 99 : rb;
if (pa !== pb) return pa - pb;
return a.key.localeCompare(b.key);
});
/// Column count grows ~sqrt with the table count, so a database with many
/// independent tables forms a compact block instead of one tall column —
/// capped so a single section never grows wider than the viewport.
const maxCols = Math.max(1,
Math.floor((viewportW - DB_PADDING * 2 + NODE_GAP_X) / (LAYER_W + NODE_GAP_X)));
const cols = Math.max(1, Math.min(maxCols, Math.round(Math.sqrt(singles.length * 1.6))));
const startY = chains.length ? chainsBlockH + CHAIN_GAP_Y : 0;
let col = 0, gy = 0, rowMaxH = 0;
for (const n of singles) {
n._rx = col * (LAYER_W + NODE_GAP_X);
n._ry = startY + gy;
rowMaxH = Math.max(rowMaxH, n.h);
if (++col >= cols) { gy += rowMaxH + NODE_GAP_Y; rowMaxH = 0; col = 0; }
}
singlesBlockH = gy + (col > 0 ? rowMaxH : 0);
sectionW = Math.max(sectionW, cols * LAYER_W + (cols - 1) * NODE_GAP_X);
}
const contentH = chainsBlockH
+ (chains.length && singles.length ? CHAIN_GAP_Y : 0)
+ singlesBlockH;
dbSections.push({
kind: 'db',
db,
nodes: chains.flat().map(k => state.nodes.get(k)).concat(singles),
w: sectionW + DB_PADDING * 2,
h: contentH + DB_PADDING_TOP + DB_PADDING,
sortDepth: chainLayouts.length ? chainLayouts[0].maxDepth : 0,
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.
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);
}
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;
/// Every node now lives inside its own database section, so the header shows
/// just the table name; the fully-qualified name is on hover and in the sidebar.
nameEl.textContent = 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. Resolve nodes via the
/// section index stamped on each `.db-group` (rather than by label text), so the
/// box is sized from exactly the nodes the layout assigned to that section.
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;
/// The connection inputs live in a `<form>` (with `name=` attributes, as in `play.html`) so the
/// browser offers to save and later autofill the credentials. Handle its submit — triggered by
/// Enter in a field — by loading the schema instead of navigating away.
$('connection-form').addEventListener('submit', (e) => { e.preventDefault(); loadAll(); });
/// Pressing Enter in any connection field loads the schema, mirroring the Load button.
for (const id of ['url', 'user', 'password']) {
$(id).addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); 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 only when the target is this page's own origin. We must NOT auto-load a
/// cross-origin `?url=...` taken from the query string: the page may carry credentials
/// filled in by the browser's password manager for the trusted page origin, and
/// `chQuery` would forward them as `user`/`password` to the attacker-chosen server
/// (which controls CORS). Cross-origin targets require an explicit `Load` click.
function isSameOriginTarget(value) {
try {
return new URL(value, window.location.href).origin === window.location.origin;
} catch (e) {
return false;
}
}
/// Auto-load the same-origin default on open. First give the browser a chance to supply a
/// remembered same-origin login: `storeCredentials` saves one via the Credential Management API,
/// but that stored credential is only surfaced through `navigator.credentials.get` — the bare
/// inputs are not autofilled reliably before this automatic first load, so it could otherwise fire
/// with empty fields and show `Failed: ...` even though a saved login exists. Retrieve it here —
/// only when the fields are empty and only for a same-origin target (the same `isSameOriginTarget`
/// gate that guards this auto-load), so the credential stays bound to the origin it was stored for
/// — then start the load once the values are in place. `mediation: 'silent'` never shows UI: it
/// returns a previously granted credential or nothing, so opening the page stays non-interactive.
/// This completes the `play.html`-style remembered auth (`play.html` needs no retrieval step
/// because it never auto-contacts a server on open).
async function autoLoadOnOpen() {
if (openedLocally || !isSameOriginTarget($('url').value)) return;
if (window.PasswordCredential && !$('user').value && !$('password').value) {
/// `navigator.credentials.get` is async, so the same-origin target and empty fields checked
/// above can change while it is pending: the user could paste a cross-origin URL into `#url`
/// or start typing their own credentials. Filling the saved same-origin login and then
/// auto-loading against the *now-current* fields would forward those credentials to the
/// user-chosen (possibly attacker) target — the very leak `isSameOriginTarget` guards against
/// on this path. Snapshot the URL, and after the retrieval re-establish both invariants; if
/// either changed, abort the whole auto-load — the user is now driving and will load
/// explicitly via the form (`Load` / Enter).
const url = $('url').value;
let cred = null;
try {
cred = await navigator.credentials.get({ password: true, mediation: 'silent' });
} catch (e) { console.warn('credentials.get failed:', e); }
if ($('url').value !== url || $('user').value || $('password').value) return;
if (cred && cred.password) {
$('user').value = cred.id;
$('password').value = cred.password;
}
}
loadAll();
}
autoLoadOnOpen();
</script>
</body>
</html>