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