mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
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]>
2250 lines
92 KiB
HTML
2250 lines
92 KiB
HTML
<!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>
|