dbopt-engine 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,926 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>DBOpt — Topology</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0B0F17;
10
+ --panel: rgba(17, 23, 36, 0.82);
11
+ --panel-solid:#111724;
12
+ --border: rgba(255,255,255,0.08);
13
+ --border-hi: rgba(255,255,255,0.16);
14
+ --ink: #E7ECF4;
15
+ --ink-2: #9AA6B8;
16
+ --ink-3: #5C6B78;
17
+ --accent: #3B82F6;
18
+ --accent-2: #60A5FA;
19
+ --purple: #8B5CF6;
20
+ --green: #22C55E;
21
+ --orange: #F59E0B;
22
+ --glow: rgba(59,130,246,0.55);
23
+ }
24
+ * { box-sizing: border-box; margin: 0; padding: 0; }
25
+ html, body { height: 100%; overflow: hidden; }
26
+ body {
27
+ background: var(--bg);
28
+ color: var(--ink);
29
+ font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
30
+ font-size: 13px;
31
+ }
32
+ body::before { /* subtle radial ambience */
33
+ content: ""; position: fixed; inset: 0; pointer-events: none;
34
+ background: radial-gradient(900px 500px at 30% -10%, rgba(59,130,246,.08), transparent 60%),
35
+ radial-gradient(700px 500px at 90% 110%, rgba(139,92,246,.07), transparent 60%);
36
+ }
37
+
38
+ /* ---------- toolbar ---------- */
39
+ #toolbar {
40
+ position: fixed; top: 0; left: 0; right: 0; z-index: 30;
41
+ display: flex; align-items: center; gap: 10px;
42
+ padding: 10px 14px;
43
+ background: linear-gradient(to bottom, rgba(11,15,23,.95), rgba(11,15,23,.75));
44
+ backdrop-filter: blur(12px);
45
+ border-bottom: 1px solid var(--border);
46
+ }
47
+ #toolbar h1 { font-size: 14px; font-weight: 600; letter-spacing: .2px; }
48
+ #toolbar h1 span { color: var(--accent-2); }
49
+ .chip {
50
+ border: 1px solid var(--border); border-radius: 999px;
51
+ padding: 4px 12px; font-size: 11.5px; color: var(--ink-2);
52
+ background: rgba(255,255,255,0.03); white-space: nowrap;
53
+ }
54
+ .chip b { color: var(--ink); font-weight: 600; }
55
+ #search {
56
+ flex: 0 1 300px; min-width: 140px;
57
+ background: rgba(255,255,255,0.05);
58
+ border: 1px solid var(--border); border-radius: 10px;
59
+ color: var(--ink); padding: 7px 12px; font-size: 12.5px; outline: none;
60
+ transition: border-color .15s;
61
+ }
62
+ #search:focus { border-color: var(--accent); }
63
+ .tbtn {
64
+ background: rgba(255,255,255,0.05); color: var(--ink-2);
65
+ border: 1px solid var(--border); border-radius: 10px;
66
+ padding: 7px 12px; font-size: 12px; cursor: pointer;
67
+ transition: all .15s; white-space: nowrap;
68
+ }
69
+ .tbtn:hover { color: var(--ink); border-color: var(--border-hi); }
70
+ .tbtn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
71
+ .tbtn.primary:hover { background: var(--accent-2); }
72
+ .spacer { flex: 1; }
73
+ a.tbtn { text-decoration: none; display: inline-block; }
74
+
75
+ /* ---------- canvas ---------- */
76
+ #canvas { position: fixed; inset: 0; cursor: grab; overflow: hidden; }
77
+ #canvas.panning { cursor: grabbing; }
78
+ #world { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }
79
+ #edges { position: absolute; overflow: visible; pointer-events: none; }
80
+ #edges path.edge { pointer-events: stroke; cursor: pointer; }
81
+
82
+ .edge {
83
+ fill: none; stroke: rgba(120,140,175,0.35); stroke-width: 1.6;
84
+ transition: stroke .25s, opacity .3s, stroke-width .25s;
85
+ }
86
+ .edge:hover, .edge.hovered { stroke: var(--accent-2); stroke-width: 2.5; }
87
+ .edge.active {
88
+ stroke: url(#flowGrad); stroke-width: 3;
89
+ filter: drop-shadow(0 0 6px var(--glow));
90
+ }
91
+ /* Query-flow load colors: blink speed + hue reflect how heavy the joined
92
+ table is (by row count). */
93
+ @keyframes edgeBlink { 0%,100% { stroke-opacity: 1; } 50% { stroke-opacity: .25; } }
94
+ .edge.active.load-green { stroke: #22C55E; filter: drop-shadow(0 0 7px rgba(34,197,94,.7)); animation: edgeBlink 1.3s ease-in-out infinite; }
95
+ .edge.active.load-yellow { stroke: #EAB308; filter: drop-shadow(0 0 7px rgba(234,179,8,.7)); animation: edgeBlink 0.9s ease-in-out infinite; }
96
+ .edge.active.load-red { stroke: #EF4444; stroke-width: 3.6; filter: drop-shadow(0 0 9px rgba(239,68,68,.8)); animation: edgeBlink 0.55s ease-in-out infinite; }
97
+ .edge.dimmed { opacity: .08; }
98
+ .particle { fill: var(--accent-2); filter: drop-shadow(0 0 4px var(--glow)); }
99
+ .particle.load-green { fill: #4ADE80; }
100
+ .particle.load-yellow { fill: #FACC15; }
101
+ .particle.load-red { fill: #F87171; }
102
+ .node.glow.load-green { border-color: #22C55E; box-shadow: 0 0 0 1px #22C55E, 0 0 26px rgba(34,197,94,.5); animation: none; }
103
+ .node.glow.load-yellow { border-color: #EAB308; box-shadow: 0 0 0 1px #EAB308, 0 0 26px rgba(234,179,8,.5); animation: none; }
104
+ .node.glow.load-red { border-color: #EF4444; box-shadow: 0 0 0 2px #EF4444, 0 0 34px rgba(239,68,68,.6); }
105
+
106
+ /* ---------- live alerts ---------- */
107
+ #alerts { position: fixed; top: 56px; right: 12px; z-index: 45; display: flex;
108
+ flex-direction: column; gap: 8px; max-width: 380px; }
109
+ .alert {
110
+ background: var(--panel-solid); border: 1px solid var(--border-hi);
111
+ border-left: 4px solid var(--accent); border-radius: 12px;
112
+ padding: 10px 34px 10px 13px; font-size: 12px; line-height: 1.45;
113
+ box-shadow: 0 12px 40px rgba(0,0,0,.55); position: relative;
114
+ animation: slideIn .3s cubic-bezier(.2,.8,.2,1);
115
+ }
116
+ @keyframes slideIn { from { transform: translateX(40px); opacity: 0; } }
117
+ .alert.info { border-left-color: var(--accent); }
118
+ .alert.warning { border-left-color: var(--orange); }
119
+ .alert.critical { border-left-color: #EF4444; animation: slideIn .3s, alertPulse 1.1s ease-in-out infinite; }
120
+ @keyframes alertPulse { 0%,100% { box-shadow: 0 12px 40px rgba(0,0,0,.55); }
121
+ 50% { box-shadow: 0 12px 40px rgba(0,0,0,.55), 0 0 22px rgba(239,68,68,.55); } }
122
+ .alert .x { position: absolute; top: 7px; right: 10px; cursor: pointer; color: var(--ink-2);
123
+ background: none; border: none; font-size: 13px; }
124
+ .alert .x:hover { color: var(--ink); }
125
+ .alert .lvl { font-size: 9.5px; font-weight: 700; letter-spacing: 1px; display: block; margin-bottom: 3px; }
126
+ .alert.info .lvl { color: var(--accent-2); } .alert.warning .lvl { color: var(--orange); }
127
+ .alert.critical .lvl { color: #F87171; }
128
+
129
+ /* ---------- nodes ---------- */
130
+ .node {
131
+ position: absolute; width: 210px;
132
+ background: var(--panel);
133
+ backdrop-filter: blur(10px);
134
+ border: 1px solid var(--border);
135
+ border-radius: 16px;
136
+ padding: 11px 13px 10px;
137
+ cursor: pointer;
138
+ transform: translate(-50%, -50%);
139
+ transition: border-color .2s, box-shadow .3s, opacity .3s, filter .3s;
140
+ box-shadow: 0 8px 28px rgba(0,0,0,0.45);
141
+ user-select: none;
142
+ }
143
+ .node:hover { border-color: var(--border-hi); box-shadow: 0 10px 34px rgba(0,0,0,.6), 0 0 0 1px rgba(96,165,250,.25); }
144
+ .node .name {
145
+ font-size: 12.5px; font-weight: 600; letter-spacing: .3px;
146
+ display: flex; align-items: center; gap: 7px;
147
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
148
+ }
149
+ .node .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex: none;
150
+ box-shadow: 0 0 6px rgba(34,197,94,.7); }
151
+ .node .meta {
152
+ margin-top: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 3px 10px;
153
+ font-size: 11px; color: var(--ink-2);
154
+ }
155
+ .node .meta b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
156
+ .node::before { /* colored top accent */
157
+ content: ""; position: absolute; top: 0; left: 16px; right: 16px; height: 2px;
158
+ border-radius: 2px; background: var(--accent); opacity: .7;
159
+ }
160
+ .node.root { border-color: rgba(139,92,246,.5); }
161
+ .node.root::before { background: var(--purple); }
162
+ .node.root .name::after {
163
+ content: "ROOT"; font-size: 9px; font-weight: 700; letter-spacing: 1px;
164
+ color: var(--purple); border: 1px solid rgba(139,92,246,.4);
165
+ border-radius: 5px; padding: 1px 5px; margin-left: auto;
166
+ }
167
+ .node.dimmed { opacity: .12; filter: saturate(.3); pointer-events: none; }
168
+ .node.glow {
169
+ border-color: var(--accent);
170
+ box-shadow: 0 0 0 1px var(--accent), 0 0 26px var(--glow), 0 0 70px rgba(59,130,246,.25);
171
+ animation: pulse 1.4s ease-in-out infinite;
172
+ }
173
+ @keyframes pulse {
174
+ 0%,100% { box-shadow: 0 0 0 1px var(--accent), 0 0 22px var(--glow); }
175
+ 50% { box-shadow: 0 0 0 2px var(--accent-2), 0 0 44px var(--glow); }
176
+ }
177
+
178
+ /* ---------- side panels ---------- */
179
+ .panel {
180
+ position: fixed; z-index: 40; top: 56px; bottom: 12px;
181
+ background: var(--panel); backdrop-filter: blur(16px);
182
+ border: 1px solid var(--border); border-radius: 16px;
183
+ box-shadow: 0 20px 60px rgba(0,0,0,.5);
184
+ display: flex; flex-direction: column;
185
+ transition: transform .3s cubic-bezier(.2,.8,.2,1), opacity .3s;
186
+ }
187
+ #node-panel { right: 12px; width: 320px; transform: translateX(360px); opacity: 0; }
188
+ #node-panel.open { transform: none; opacity: 1; }
189
+ #query-panel { right: 12px; width: 380px; transform: translateX(420px); opacity: 0; }
190
+ #query-panel.open { transform: none; opacity: 1; }
191
+ #history-panel { left: 12px; width: 240px; transform: translateX(-280px); opacity: 0; }
192
+ #history-panel.open { transform: none; opacity: 1; }
193
+ .panel header {
194
+ padding: 13px 16px; border-bottom: 1px solid var(--border);
195
+ display: flex; align-items: center; justify-content: space-between;
196
+ font-weight: 600; font-size: 13px;
197
+ }
198
+ .panel .close { cursor: pointer; color: var(--ink-2); font-size: 16px; line-height: 1; background: none; border: none; }
199
+ .panel .close:hover { color: var(--ink); }
200
+ .panel .body { padding: 14px 16px; overflow-y: auto; flex: 1; }
201
+ .panel .body::-webkit-scrollbar { width: 8px; }
202
+ .panel .body::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 4px; }
203
+
204
+ .kv { display: grid; grid-template-columns: auto 1fr; gap: 5px 14px; font-size: 12px; margin-bottom: 14px; }
205
+ .kv span { color: var(--ink-2); }
206
+ .kv b { font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; }
207
+ .sect { font-size: 10.5px; font-weight: 700; letter-spacing: 1.2px; color: var(--ink-2);
208
+ text-transform: uppercase; margin: 14px 0 8px; }
209
+ .col-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px;
210
+ border-bottom: 1px solid rgba(255,255,255,.04); }
211
+ .col-row .t { color: var(--ink-2); font-family: ui-monospace, monospace; font-size: 11px; }
212
+ .idx-row { font-family: ui-monospace, monospace; font-size: 11px; color: var(--ink-2);
213
+ padding: 4px 0; word-break: break-all; }
214
+ .rel-chip { display: inline-block; margin: 2px 4px 2px 0; padding: 3px 9px; border-radius: 7px;
215
+ background: rgba(59,130,246,.12); border: 1px solid rgba(59,130,246,.25);
216
+ color: var(--accent-2); font-size: 11px; cursor: pointer; }
217
+ .rel-chip:hover { background: rgba(59,130,246,.22); }
218
+
219
+ #sql-input {
220
+ width: 100%; height: 130px; resize: vertical;
221
+ background: rgba(0,0,0,.35); color: var(--ink);
222
+ border: 1px solid var(--border); border-radius: 10px;
223
+ font-family: ui-monospace, monospace; font-size: 12px; padding: 10px; outline: none;
224
+ }
225
+ #sql-input:focus { border-color: var(--accent); }
226
+ #run-flow { width: 100%; margin-top: 10px; padding: 9px; font-size: 12.5px; }
227
+ #flow-result .step { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 12px; }
228
+ #flow-result .step .n { width: 18px; height: 18px; border-radius: 50%; background: rgba(59,130,246,.15);
229
+ border: 1px solid rgba(59,130,246,.4); color: var(--accent-2);
230
+ display: flex; align-items: center; justify-content: center; font-size: 10px; flex: none; }
231
+ #flow-result .step .on { color: var(--ink-2); font-size: 11px; font-family: ui-monospace, monospace; }
232
+ #flow-error { color: #F87171; font-size: 12px; margin-top: 8px; }
233
+
234
+ .hist-item { padding: 9px 10px; border-radius: 10px; cursor: pointer; margin-bottom: 5px;
235
+ border: 1px solid transparent; }
236
+ .hist-item:hover { background: rgba(255,255,255,.04); border-color: var(--border); }
237
+ .hist-item .time { font-size: 10.5px; color: var(--ink-2); }
238
+ .hist-item .sql { font-family: ui-monospace, monospace; font-size: 11px; color: var(--ink-2);
239
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
240
+ .hist-item .tbls { font-size: 11.5px; color: var(--accent-2); margin-top: 2px; }
241
+
242
+ /* ---------- edge tooltip ---------- */
243
+ #edge-tip {
244
+ position: fixed; z-index: 50; display: none; pointer-events: none;
245
+ background: var(--panel-solid); border: 1px solid var(--border-hi); border-radius: 12px;
246
+ padding: 10px 13px; font-size: 12px; box-shadow: 0 12px 40px rgba(0,0,0,.6); max-width: 300px;
247
+ }
248
+ #edge-tip .cols { font-family: ui-monospace, monospace; font-size: 11.5px; color: var(--accent-2); }
249
+ #edge-tip .sub { color: var(--ink-2); margin-top: 5px; font-size: 11px; }
250
+
251
+ /* ---------- legend / minimap / zoom ---------- */
252
+ #legend {
253
+ position: fixed; right: 12px; bottom: 12px; z-index: 20;
254
+ background: var(--panel); backdrop-filter: blur(10px);
255
+ border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px;
256
+ font-size: 11px; color: var(--ink-2); display: grid; gap: 5px;
257
+ }
258
+ #legend .li { display: flex; align-items: center; gap: 8px; }
259
+ #legend .sw { width: 14px; height: 3px; border-radius: 2px; }
260
+ #legend .sq { width: 9px; height: 9px; border-radius: 3px; }
261
+ #minimap {
262
+ position: fixed; left: 12px; bottom: 12px; z-index: 20;
263
+ border: 1px solid var(--border); border-radius: 12px;
264
+ background: rgba(11,15,23,.85); backdrop-filter: blur(8px); cursor: crosshair;
265
+ }
266
+ #zoombar { position: fixed; right: 12px; bottom: 130px; z-index: 20; display: flex; flex-direction: column; gap: 6px; }
267
+ #zoombar .tbtn { padding: 6px 10px; text-align: center; }
268
+
269
+ #loading { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
270
+ color: var(--ink-2); font-size: 13px; z-index: 60; background: var(--bg); }
271
+ </style>
272
+
273
+ </head>
274
+ <body>
275
+
276
+ <div id="loading">Loading schema topology…</div>
277
+
278
+ <div id="toolbar">
279
+ <h1>DBOpt <span>Topology</span></h1>
280
+ <span class="chip" id="db-chip">…</span>
281
+ <select id="db-select" class="chip" title="Switch database" style="display:none;"></select>
282
+ <input id="search" type="text" placeholder="Search tables… ( / )">
283
+ <span class="chip" id="count-chip"></span>
284
+ <div class="spacer"></div>
285
+ <button class="tbtn" id="btn-history">History</button>
286
+ <button class="tbtn primary" id="btn-query">Query Flow</button>
287
+ <a class="tbtn" id="dash-link" href="/dashboard">Dashboard</a>
288
+ </div>
289
+
290
+ <div id="canvas">
291
+ <div id="world">
292
+ <svg id="edges">
293
+ <defs>
294
+ <linearGradient id="flowGrad" x1="0" y1="0" x2="1" y2="0">
295
+ <stop offset="0" stop-color="#3B82F6"/><stop offset="1" stop-color="#8B5CF6"/>
296
+ </linearGradient>
297
+ <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
298
+ <path d="M0 0 L10 5 L0 10 z" fill="rgba(120,140,175,0.5)"/>
299
+ </marker>
300
+ </defs>
301
+ <g id="edge-g"></g>
302
+ <g id="particle-g"></g>
303
+ </svg>
304
+ <div id="nodes"></div>
305
+ </div>
306
+ </div>
307
+
308
+ <div class="panel" id="node-panel">
309
+ <header><span id="np-title">Table</span><button class="close" data-close="node-panel">✕</button></header>
310
+ <div class="body" id="np-body"></div>
311
+ </div>
312
+
313
+ <div class="panel" id="query-panel">
314
+ <header><span>Query Flow</span><button class="close" data-close="query-panel">✕</button></header>
315
+ <div class="body">
316
+ <textarea id="sql-input" placeholder="SELECT u.name, o.amount&#10;FROM users u&#10;JOIN orders o ON u.id = o.user_id&#10;WHERE o.amount > 5000" spellcheck="false"></textarea>
317
+ <button class="tbtn primary" id="run-flow">▶ &nbsp;Run traversal</button>
318
+ <div id="flow-error"></div>
319
+ <div id="flow-result"></div>
320
+ </div>
321
+ </div>
322
+
323
+ <div class="panel" id="history-panel">
324
+ <header><span>Query History</span><button class="close" data-close="history-panel">✕</button></header>
325
+ <div class="body" id="hist-body"><div style="color:var(--ink-2)">No queries run yet in this session.</div></div>
326
+ </div>
327
+
328
+ <div id="edge-tip"></div>
329
+
330
+ <div id="alerts"></div>
331
+
332
+ <div id="legend">
333
+ <div class="li"><span class="sq" style="background:var(--purple)"></span> Root / focused table</div>
334
+ <div class="li"><span class="sq" style="background:var(--accent)"></span> Table</div>
335
+ <div class="li"><span class="sw" style="background:rgba(120,140,175,.5)"></span> Foreign-key relationship</div>
336
+ <div class="li"><span class="sw" style="background:#22C55E"></span> Query join — light table (&lt;100K rows)</div>
337
+ <div class="li"><span class="sw" style="background:#EAB308"></span> Query join — medium (100K–1M rows)</div>
338
+ <div class="li"><span class="sw" style="background:#EF4444"></span> Query join — heavy (&gt;1M rows)</div>
339
+ </div>
340
+
341
+ <canvas id="minimap" width="200" height="140"></canvas>
342
+
343
+ <div id="zoombar">
344
+ <button class="tbtn" id="z-in">+</button>
345
+ <button class="tbtn" id="z-out">−</button>
346
+ <button class="tbtn" id="z-fit" title="Fit to screen">⛶</button>
347
+ <button class="tbtn" id="z-reset" title="Reset">⟲</button>
348
+ </div>
349
+
350
+ <script>
351
+ "use strict";
352
+ const $ = s => document.querySelector(s);
353
+ const SVG_NS = "http://www.w3.org/2000/svg";
354
+
355
+ /* ============================== state ============================== */
356
+ let NODES = [], EDGES = [], POS = {}, ROOT = null;
357
+ let view = { x: 0, y: 0, k: 1 };
358
+ let adjacency = {};
359
+ let flowTimer = null, particleAnims = [];
360
+ const history = [];
361
+
362
+ /* ============================ data load ============================ */
363
+ // Every API call is scoped to the database picked in the toolbar selector
364
+ // (?db=<name>); without it the server uses its first configured target.
365
+ const DB = new URLSearchParams(location.search).get("db");
366
+ const withDb = (url) => DB ? url + (url.includes("?") ? "&" : "?") + "db=" + encodeURIComponent(DB) : url;
367
+ async function getJSON(url, opts) {
368
+ const r = await fetch(withDb(url), opts);
369
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.status);
370
+ return r.json();
371
+ }
372
+
373
+ async function initDbSelect() {
374
+ try {
375
+ const ts = await getJSON("/targets");
376
+ $("#dash-link").href = withDb("/dashboard");
377
+ if (ts.length < 2) return;
378
+ const sel = $("#db-select");
379
+ const current = DB || ts[0].name;
380
+ for (const t of ts) {
381
+ const o = document.createElement("option");
382
+ o.value = t.name; o.textContent = t.name;
383
+ if (t.name === current) o.selected = true;
384
+ sel.append(o);
385
+ }
386
+ sel.style.display = "";
387
+ sel.onchange = () => location.search = "?db=" + encodeURIComponent(sel.value);
388
+ } catch (e) { /* selector is optional */ }
389
+ }
390
+
391
+ async function init() {
392
+ const [schema, config] = await Promise.all([
393
+ getJSON("/topology/schema"),
394
+ getJSON("/analytics/config"),
395
+ ]);
396
+ NODES = schema.nodes; EDGES = schema.edges;
397
+ $("#db-chip").innerHTML = "";
398
+ $("#db-chip").append(({mysql:"MySQL",mssql:"SQL Server",postgres:"PostgreSQL",oracle:"Oracle",mongodb:"MongoDB"}[schema.engine] || schema.engine) + " · ");
399
+ const b = document.createElement("b"); b.textContent = config.database; $("#db-chip").append(b);
400
+ $("#count-chip").textContent = `${NODES.length} tables · ${EDGES.length} relations`;
401
+
402
+ adjacency = {};
403
+ for (const n of NODES) adjacency[n.id] = new Set();
404
+ for (const e of EDGES) {
405
+ if (adjacency[e.from] && adjacency[e.to]) { adjacency[e.from].add(e.to); adjacency[e.to].add(e.from); }
406
+ }
407
+ ROOT = NODES.map(n => n.id).sort((a, b) => adjacency[b].size - adjacency[a].size)[0];
408
+ buildDOM();
409
+ layout(ROOT);
410
+ fitToScreen();
411
+ $("#loading").remove();
412
+ requestAnimationFrame(tick);
413
+ }
414
+
415
+ /* ======================== radial BFS layout ======================== */
416
+ function layout(rootId) {
417
+ ROOT = rootId;
418
+ const depth = { [rootId]: 0 }, parent = {};
419
+ const q = [rootId];
420
+ while (q.length) {
421
+ const cur = q.shift();
422
+ for (const nb of adjacency[cur] || []) {
423
+ if (depth[nb] === undefined) { depth[nb] = depth[cur] + 1; parent[nb] = cur; q.push(nb); }
424
+ }
425
+ }
426
+ const rings = {};
427
+ let maxDepth = 0;
428
+ for (const n of NODES) {
429
+ let d = depth[n.id];
430
+ if (d === undefined) d = -1; // disconnected -> outer ring
431
+ (rings[d] = rings[d] || []).push(n.id);
432
+ if (d > maxDepth) maxDepth = d;
433
+ }
434
+ if (rings[-1]) { rings[maxDepth + 1] = rings[-1]; delete rings[-1]; maxDepth += 1; }
435
+
436
+ const target = {};
437
+ target[rootId] = { x: 0, y: 0 };
438
+ const R = 330;
439
+ for (let d = 1; d <= maxDepth; d++) {
440
+ const ids = rings[d] || [];
441
+ // order children near their parent's angle to reduce crossings
442
+ ids.sort((a, b) => (angleOf(parent[a]) - angleOf(parent[b])) || a.localeCompare(b));
443
+ const radius = d * R + Math.max(0, ids.length - 14) * 9;
444
+ ids.forEach((id, i) => {
445
+ const a = (i / ids.length) * Math.PI * 2 - Math.PI / 2;
446
+ target[id] = { x: Math.cos(a) * radius, y: Math.sin(a) * radius };
447
+ });
448
+ }
449
+ function angleOf(id) {
450
+ const p = target[id]; if (!p) return 0;
451
+ return Math.atan2(p.y, p.x);
452
+ }
453
+ // animate to new positions
454
+ const start = {}, t0 = performance.now();
455
+ for (const n of NODES) start[n.id] = POS[n.id] || target[n.id];
456
+ const D = 550;
457
+ (function anim(t) {
458
+ const e = Math.min(1, (t - t0) / D), s = 1 - Math.pow(1 - e, 3);
459
+ for (const n of NODES) {
460
+ const a = start[n.id], b = target[n.id];
461
+ POS[n.id] = { x: a.x + (b.x - a.x) * s, y: a.y + (b.y - a.y) * s };
462
+ }
463
+ positionAll();
464
+ if (e < 1) requestAnimationFrame(anim);
465
+ })(t0);
466
+ }
467
+
468
+ /* ============================ DOM build ============================ */
469
+ const nodeEls = {}, edgeEls = [];
470
+
471
+ function buildDOM() {
472
+ const wrap = $("#nodes");
473
+ for (const n of NODES) {
474
+ const el = document.createElement("div");
475
+ el.className = "node";
476
+ el.dataset.id = n.id;
477
+ const name = document.createElement("div"); name.className = "name";
478
+ const dot = document.createElement("span"); dot.className = "dot";
479
+ const label = document.createElement("span"); label.textContent = n.id;
480
+ label.style.overflow = "hidden"; label.style.textOverflow = "ellipsis";
481
+ name.append(dot, label);
482
+ const meta = document.createElement("div"); meta.className = "meta";
483
+ const mk = (k, v) => { const s = document.createElement("span"); s.textContent = k + " "; const b = document.createElement("b"); b.textContent = v; s.append(b); return s; };
484
+ meta.append(
485
+ mk("rows", fmtNum(n.rows)),
486
+ mk("size", n.size || "—"),
487
+ mk("cols", n.columns ?? "—"),
488
+ mk("idx", n.indexes ?? "—"),
489
+ );
490
+ el.append(name, meta);
491
+ el.addEventListener("click", ev => { ev.stopPropagation(); openNodePanel(n.id); });
492
+ el.addEventListener("dblclick", ev => { ev.stopPropagation(); layout(n.id); refreshRootClass(); });
493
+ wrap.appendChild(el);
494
+ nodeEls[n.id] = el;
495
+ }
496
+ const eg = $("#edge-g");
497
+ for (const e of EDGES) {
498
+ if (!nodeEls[e.from] || !nodeEls[e.to]) continue;
499
+ const p = document.createElementNS(SVG_NS, "path");
500
+ p.setAttribute("class", "edge");
501
+ p.setAttribute("marker-end", "url(#arrow)");
502
+ p.addEventListener("pointermove", ev => showEdgeTip(ev, e));
503
+ p.addEventListener("pointerleave", hideEdgeTip);
504
+ eg.appendChild(p);
505
+ edgeEls.push({ el: p, e });
506
+ }
507
+ refreshRootClass();
508
+ }
509
+
510
+ function refreshRootClass() {
511
+ for (const id in nodeEls) nodeEls[id].classList.toggle("root", id === ROOT);
512
+ }
513
+
514
+ function positionAll() {
515
+ for (const n of NODES) {
516
+ const p = POS[n.id], el = nodeEls[n.id];
517
+ if (!p || !el) continue;
518
+ el.style.left = p.x + "px";
519
+ el.style.top = p.y + "px";
520
+ }
521
+ for (const { el, e } of edgeEls) {
522
+ const a = POS[e.from], b = POS[e.to];
523
+ if (!a || !b) continue;
524
+ const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
525
+ const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1;
526
+ const bend = Math.min(46, len * 0.12);
527
+ const cx = mx - (dy / len) * bend, cy = my + (dx / len) * bend;
528
+ // stop the line at the card edge (approx radius)
529
+ const rA = 82, rB = 92;
530
+ const ax = a.x + (dx / len) * rA, ay = a.y + (dy / len) * (rA * 0.55);
531
+ const bx = b.x - (dx / len) * rB, by = b.y - (dy / len) * (rB * 0.55);
532
+ el.setAttribute("d", `M ${ax} ${ay} Q ${cx} ${cy} ${bx} ${by}`);
533
+ }
534
+ drawMinimap();
535
+ }
536
+
537
+ /* ====================== pan / zoom / viewport ====================== */
538
+ const canvas = $("#canvas"), world = $("#world");
539
+ function applyView() {
540
+ world.style.transform = `translate(${view.x}px, ${view.y}px) scale(${view.k})`;
541
+ drawMinimap();
542
+ }
543
+ canvas.addEventListener("pointerdown", ev => {
544
+ if (ev.target.closest(".node") || ev.target.closest(".panel")) return;
545
+ canvas.classList.add("panning");
546
+ const sx = ev.clientX - view.x, sy = ev.clientY - view.y;
547
+ const move = m => { view.x = m.clientX - sx; view.y = m.clientY - sy; applyView(); };
548
+ const up = () => { canvas.classList.remove("panning"); removeEventListener("pointermove", move); removeEventListener("pointerup", up); };
549
+ addEventListener("pointermove", move); addEventListener("pointerup", up);
550
+ });
551
+ canvas.addEventListener("wheel", ev => {
552
+ ev.preventDefault();
553
+ const k2 = Math.min(2.5, Math.max(0.08, view.k * (ev.deltaY < 0 ? 1.12 : 0.89)));
554
+ const rx = ev.clientX, ry = ev.clientY;
555
+ view.x = rx - (rx - view.x) * (k2 / view.k);
556
+ view.y = ry - (ry - view.y) * (k2 / view.k);
557
+ view.k = k2;
558
+ applyView();
559
+ }, { passive: false });
560
+
561
+ function bounds() {
562
+ let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
563
+ for (const id in POS) {
564
+ const p = POS[id];
565
+ x0 = Math.min(x0, p.x - 120); y0 = Math.min(y0, p.y - 60);
566
+ x1 = Math.max(x1, p.x + 120); y1 = Math.max(y1, p.y + 60);
567
+ }
568
+ return { x0, y0, x1, y1 };
569
+ }
570
+ function fitToScreen() {
571
+ const b = bounds(), W = innerWidth, H = innerHeight - 50;
572
+ const k = Math.min(1.1, Math.min(W / (b.x1 - b.x0), H / (b.y1 - b.y0)) * 0.94);
573
+ view.k = k;
574
+ view.x = W / 2 - ((b.x0 + b.x1) / 2) * k;
575
+ view.y = 50 + H / 2 - ((b.y0 + b.y1) / 2) * k;
576
+ applyView();
577
+ }
578
+ function focusNode(id, zoom = 0.9) {
579
+ const p = POS[id]; if (!p) return;
580
+ view.k = zoom;
581
+ view.x = innerWidth / 2 - p.x * zoom;
582
+ view.y = (innerHeight + 50) / 2 - p.y * zoom;
583
+ applyView();
584
+ }
585
+ $("#z-in").onclick = () => { view.k = Math.min(2.5, view.k * 1.25); applyView(); };
586
+ $("#z-out").onclick = () => { view.k = Math.max(0.08, view.k / 1.25); applyView(); };
587
+ $("#z-fit").onclick = fitToScreen;
588
+ $("#z-reset").onclick = () => { layout(ROOT); setTimeout(fitToScreen, 580); };
589
+
590
+ /* ============================= minimap ============================= */
591
+ const mm = $("#minimap"), mctx = mm.getContext("2d");
592
+ function drawMinimap() {
593
+ const b = bounds();
594
+ mctx.clearRect(0, 0, mm.width, mm.height);
595
+ const sx = mm.width / (b.x1 - b.x0), sy = mm.height / (b.y1 - b.y0), s = Math.min(sx, sy);
596
+ const ox = (mm.width - (b.x1 - b.x0) * s) / 2, oy = (mm.height - (b.y1 - b.y0) * s) / 2;
597
+ mctx.strokeStyle = "rgba(120,140,175,0.25)";
598
+ mctx.lineWidth = 0.5;
599
+ for (const { e } of edgeEls) {
600
+ const a = POS[e.from], c = POS[e.to]; if (!a || !c) continue;
601
+ mctx.beginPath();
602
+ mctx.moveTo(ox + (a.x - b.x0) * s, oy + (a.y - b.y0) * s);
603
+ mctx.lineTo(ox + (c.x - b.x0) * s, oy + (c.y - b.y0) * s);
604
+ mctx.stroke();
605
+ }
606
+ for (const id in POS) {
607
+ const p = POS[id];
608
+ mctx.fillStyle = id === ROOT ? "#8B5CF6" : "#3B82F6";
609
+ mctx.fillRect(ox + (p.x - b.x0) * s - 1.5, oy + (p.y - b.y0) * s - 1.5, 3, 3);
610
+ }
611
+ // viewport rect
612
+ const vx0 = (-view.x) / view.k, vy0 = (50 - view.y) / view.k;
613
+ const vx1 = (innerWidth - view.x) / view.k, vy1 = (innerHeight - view.y) / view.k;
614
+ mctx.strokeStyle = "rgba(96,165,250,0.9)";
615
+ mctx.lineWidth = 1;
616
+ mctx.strokeRect(ox + (vx0 - b.x0) * s, oy + (vy0 - b.y0) * s, (vx1 - vx0) * s, (vy1 - vy0) * s);
617
+ }
618
+ mm.addEventListener("pointerdown", ev => {
619
+ const move = m => {
620
+ const r = mm.getBoundingClientRect(), b = bounds();
621
+ const s = Math.min(mm.width / (b.x1 - b.x0), mm.height / (b.y1 - b.y0));
622
+ const ox = (mm.width - (b.x1 - b.x0) * s) / 2, oy = (mm.height - (b.y1 - b.y0) * s) / 2;
623
+ const wx = (m.clientX - r.left - ox) / s + b.x0;
624
+ const wy = (m.clientY - r.top - oy) / s + b.y0;
625
+ view.x = innerWidth / 2 - wx * view.k;
626
+ view.y = (innerHeight + 50) / 2 - wy * view.k;
627
+ applyView();
628
+ };
629
+ move(ev);
630
+ const up = () => { removeEventListener("pointermove", move); removeEventListener("pointerup", up); };
631
+ addEventListener("pointermove", move); addEventListener("pointerup", up);
632
+ });
633
+
634
+ /* ============================== search ============================= */
635
+ $("#search").addEventListener("input", ev => {
636
+ const q = ev.target.value.trim().toLowerCase();
637
+ for (const id in nodeEls) {
638
+ nodeEls[id].classList.toggle("dimmed", !!q && !id.toLowerCase().includes(q));
639
+ }
640
+ for (const { el, e } of edgeEls) {
641
+ const dim = !!q && !(e.from.toLowerCase().includes(q) || e.to.toLowerCase().includes(q));
642
+ el.classList.toggle("dimmed", dim);
643
+ }
644
+ });
645
+ addEventListener("keydown", ev => {
646
+ if (ev.key === "/" && document.activeElement.tagName !== "TEXTAREA" && document.activeElement.tagName !== "INPUT") {
647
+ ev.preventDefault(); $("#search").focus();
648
+ }
649
+ if (ev.key === "Escape") { closePanels(); clearFlow(); $("#search").value = ""; $("#search").dispatchEvent(new Event("input")); }
650
+ });
651
+
652
+ /* ========================= edge tooltip ============================ */
653
+ function showEdgeTip(ev, e) {
654
+ const tip = $("#edge-tip");
655
+ tip.innerHTML = "";
656
+ const cols = document.createElement("div"); cols.className = "cols";
657
+ cols.textContent = `${e.to}.${e.to_columns.join(",")} ← ${e.from}.${e.from_columns.join(",")}`;
658
+ const sub = document.createElement("div"); sub.className = "sub";
659
+ sub.textContent = `Foreign key · one-to-many · ${e.constraint}`;
660
+ tip.append(cols, sub);
661
+ tip.style.display = "block";
662
+ tip.style.left = Math.min(ev.clientX + 14, innerWidth - 320) + "px";
663
+ tip.style.top = (ev.clientY + 14) + "px";
664
+ }
665
+ function hideEdgeTip() { $("#edge-tip").style.display = "none"; }
666
+
667
+ /* ========================= node detail panel ======================= */
668
+ function closePanels() { document.querySelectorAll(".panel").forEach(p => p.classList.remove("open")); }
669
+ document.querySelectorAll(".close").forEach(b => b.onclick = () => $("#" + b.dataset.close).classList.remove("open"));
670
+
671
+ async function openNodePanel(id) {
672
+ const n = NODES.find(x => x.id === id);
673
+ $("#np-title").textContent = id;
674
+ const body = $("#np-body");
675
+ body.innerHTML = "";
676
+ const kv = document.createElement("div"); kv.className = "kv";
677
+ const addKV = (k, v) => { const s = document.createElement("span"); s.textContent = k; const b = document.createElement("b"); b.textContent = v; kv.append(s, b); };
678
+ addKV("Rows", fmtNum(n.rows)); addKV("Size", n.size || "—");
679
+ addKV("Columns", n.columns ?? "—"); addKV("Indexes", n.indexes ?? "—");
680
+ addKV("Relationships", adjacency[id]?.size ?? 0);
681
+ body.appendChild(kv);
682
+
683
+ const relTitle = document.createElement("div"); relTitle.className = "sect"; relTitle.textContent = "Related tables";
684
+ body.appendChild(relTitle);
685
+ const rels = document.createElement("div");
686
+ for (const other of [...(adjacency[id] || [])].sort()) {
687
+ const c = document.createElement("span"); c.className = "rel-chip"; c.textContent = other;
688
+ c.onclick = () => { layout(other); refreshRootClass(); setTimeout(() => focusNode(other), 580); openNodePanel(other); };
689
+ rels.appendChild(c);
690
+ }
691
+ if (!rels.children.length) { rels.textContent = "None"; rels.style.color = "var(--ink-2)"; }
692
+ body.appendChild(rels);
693
+
694
+ $("#node-panel").classList.add("open");
695
+ $("#query-panel").classList.remove("open");
696
+
697
+ try {
698
+ const d = await getJSON("/topology/table/" + encodeURIComponent(id));
699
+ const ct = document.createElement("div"); ct.className = "sect"; ct.textContent = `Columns (${d.columns.length})`;
700
+ body.appendChild(ct);
701
+ for (const c of d.columns) {
702
+ const row = document.createElement("div"); row.className = "col-row";
703
+ const nm = document.createElement("span"); nm.textContent = c.name;
704
+ const ty = document.createElement("span"); ty.className = "t"; ty.textContent = c.type + (String(c.nullable).toUpperCase().startsWith("Y") ? " · null" : "");
705
+ row.append(nm, ty); body.appendChild(row);
706
+ }
707
+ const it = document.createElement("div"); it.className = "sect"; it.textContent = `Indexes (${d.indexes.length})`;
708
+ body.appendChild(it);
709
+ for (const ix of d.indexes) {
710
+ const row = document.createElement("div"); row.className = "idx-row"; row.textContent = ix.indexdef || ix.indexname;
711
+ body.appendChild(row);
712
+ }
713
+ } catch (e) { /* detail unavailable -- header data already shown */ }
714
+ }
715
+
716
+ /* ====================== query flow animation ======================= */
717
+ $("#btn-query").onclick = () => { $("#query-panel").classList.toggle("open"); $("#node-panel").classList.remove("open"); $("#history-panel").classList.remove("open"); };
718
+ $("#btn-history").onclick = () => { $("#history-panel").classList.toggle("open"); };
719
+
720
+ $("#run-flow").onclick = () => runFlow($("#sql-input").value);
721
+
722
+ async function runFlow(sql) {
723
+ $("#flow-error").textContent = "";
724
+ clearFlow();
725
+ let res;
726
+ try {
727
+ res = await getJSON("/topology/analyze-query", {
728
+ method: "POST", headers: { "Content-Type": "application/json" },
729
+ body: JSON.stringify({ sql }),
730
+ });
731
+ } catch (e) { $("#flow-error").textContent = "✕ " + e.message; return; }
732
+
733
+ const known = res.tables.filter(t => nodeEls[t]);
734
+ if (!known.length) { $("#flow-error").textContent = "✕ None of the referenced tables exist in this database."; return; }
735
+
736
+ history.unshift({ time: new Date().toLocaleTimeString(), sql, tables: res.tables });
737
+ renderHistory();
738
+ renderFlowResult(res);
739
+ animateFlow(res);
740
+ }
741
+
742
+ function renderFlowResult(res) {
743
+ const box = $("#flow-result");
744
+ box.innerHTML = "";
745
+ const kv = document.createElement("div"); kv.className = "kv"; kv.style.marginTop = "14px";
746
+ const add = (k, v) => { const s = document.createElement("span"); s.textContent = k; const b = document.createElement("b"); b.textContent = v; kv.append(s, b); };
747
+ add("Joins", res.join_count);
748
+ add("Tables", res.tables.length);
749
+ if (res.plan_cost != null) add("Plan cost", (+res.plan_cost).toLocaleString());
750
+ box.appendChild(kv);
751
+ const st = document.createElement("div"); st.className = "sect"; st.textContent = "Traversal";
752
+ box.appendChild(st);
753
+ res.steps.forEach((s, i) => {
754
+ const row = document.createElement("div"); row.className = "step";
755
+ const n = document.createElement("span"); n.className = "n"; n.textContent = i + 1;
756
+ const t = document.createElement("span"); t.textContent = s.table;
757
+ row.append(n, t);
758
+ if (s.edge) {
759
+ const on = document.createElement("span"); on.className = "on";
760
+ on.textContent = `on ${s.edge.from}.${s.edge.from_column} = ${s.edge.to}.${s.edge.to_column}`;
761
+ row.appendChild(on);
762
+ }
763
+ box.appendChild(row);
764
+ });
765
+ }
766
+
767
+ function edgeBetween(a, b) {
768
+ return edgeEls.find(({ e }) => (e.from === a && e.to === b) || (e.from === b && e.to === a));
769
+ }
770
+
771
+ function animateFlow(res) {
772
+ const inPath = new Set(res.tables);
773
+ for (const id in nodeEls) nodeEls[id].classList.toggle("dimmed", !inPath.has(id));
774
+ for (const { el, e } of edgeEls) {
775
+ const active = false;
776
+ el.classList.toggle("dimmed", !(inPath.has(e.from) && inPath.has(e.to)));
777
+ el.classList.toggle("active", active);
778
+ }
779
+ if (nodeEls[res.tables[0]]) focusNode(res.tables[0], Math.max(0.5, view.k));
780
+
781
+ let i = 0;
782
+ const stepMs = 950;
783
+ (function step() {
784
+ if (i >= res.steps.length) return;
785
+ const s = res.steps[i];
786
+ const load = loadClass(s.table); // green / yellow / red by table rows
787
+ if (nodeEls[s.table]) nodeEls[s.table].classList.add("glow", load);
788
+ if (i > 0) {
789
+ const prev = res.steps[i - 1].table;
790
+ const eref = edgeBetween(prev, s.table);
791
+ if (eref) { eref.el.classList.add("active", load); spawnParticles(eref.el, load); }
792
+ }
793
+ i++;
794
+ flowTimer = setTimeout(step, stepMs);
795
+ })();
796
+ }
797
+
798
+ // The blink color of a traversal edge reflects how heavy the joined table
799
+ // is: green under 100K rows, yellow up to 1M, red above (faster blink).
800
+ function loadClass(tableId) {
801
+ const n = NODES.find(x => x.id === tableId);
802
+ const rows = n ? (n.rows || 0) : 0;
803
+ if (rows >= 1_000_000) return "load-red";
804
+ if (rows >= 100_000) return "load-yellow";
805
+ return "load-green";
806
+ }
807
+
808
+ function clearFlow() {
809
+ clearTimeout(flowTimer);
810
+ particleAnims.forEach(cancelAnimationFrame);
811
+ particleAnims = [];
812
+ $("#particle-g").innerHTML = "";
813
+ for (const id in nodeEls) nodeEls[id].classList.remove("glow", "dimmed", "load-green", "load-yellow", "load-red");
814
+ for (const { el } of edgeEls) el.classList.remove("active", "dimmed", "load-green", "load-yellow", "load-red");
815
+ }
816
+
817
+ function spawnParticles(pathEl, load) {
818
+ const g = $("#particle-g");
819
+ const len = pathEl.getTotalLength();
820
+ for (let p = 0; p < 3; p++) {
821
+ const c = document.createElementNS(SVG_NS, "circle");
822
+ c.setAttribute("class", "particle " + (load || ""));
823
+ c.setAttribute("r", 3.4);
824
+ g.appendChild(c);
825
+ const t0 = performance.now() + p * 260;
826
+ const D = 900;
827
+ (function anim(t) {
828
+ const e = ((t - t0) % (D * 2.2)) / D;
829
+ if (e >= 0 && e <= 1) {
830
+ const pt = pathEl.getPointAtLength(e * len);
831
+ c.setAttribute("cx", pt.x); c.setAttribute("cy", pt.y);
832
+ c.setAttribute("opacity", 1);
833
+ } else c.setAttribute("opacity", 0);
834
+ particleAnims.push(requestAnimationFrame(anim));
835
+ })(t0);
836
+ }
837
+ }
838
+
839
+ /* ============================= history ============================= */
840
+ function renderHistory() {
841
+ const b = $("#hist-body");
842
+ b.innerHTML = "";
843
+ for (const h of history.slice(0, 30)) {
844
+ const item = document.createElement("div"); item.className = "hist-item";
845
+ const time = document.createElement("div"); time.className = "time"; time.textContent = h.time;
846
+ const tbls = document.createElement("div"); tbls.className = "tbls"; tbls.textContent = h.tables.join(" → ");
847
+ const sql = document.createElement("div"); sql.className = "sql"; sql.textContent = h.sql.replace(/\s+/g, " ");
848
+ item.append(time, tbls, sql);
849
+ item.onclick = () => { $("#sql-input").value = h.sql; $("#query-panel").classList.add("open"); runFlow(h.sql); };
850
+ b.appendChild(item);
851
+ }
852
+ }
853
+
854
+ /* =============================== misc ============================== */
855
+ function fmtNum(n) {
856
+ if (n == null) return "—";
857
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
858
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
859
+ return String(n);
860
+ }
861
+ function tick() { /* reserved for future live updates */ }
862
+
863
+ /* ======================= real-time monitoring ====================== */
864
+ const dismissed = new Set();
865
+
866
+ async function pollAlerts() {
867
+ try {
868
+ const d = await getJSON("/topology/alerts");
869
+ const box = $("#alerts");
870
+ const seen = new Set();
871
+ for (const a of d.alerts) {
872
+ const key = a.level + "|" + a.message;
873
+ seen.add(key);
874
+ if (dismissed.has(key) || box.querySelector(`[data-key="${CSS.escape(key)}"]`)) continue;
875
+ const el = document.createElement("div");
876
+ el.className = "alert " + a.level;
877
+ el.dataset.key = key;
878
+ const lvl = document.createElement("span"); lvl.className = "lvl";
879
+ lvl.textContent = { info: "MONITOR", warning: "WARNING", critical: "CRITICAL" }[a.level] || a.level;
880
+ const msg = document.createElement("span"); msg.textContent = a.message;
881
+ const x = document.createElement("button"); x.className = "x"; x.textContent = "✕";
882
+ x.onclick = () => { dismissed.add(key); el.remove(); };
883
+ el.append(lvl, msg, x);
884
+ box.appendChild(el);
885
+ }
886
+ // remove alerts that no longer apply
887
+ box.querySelectorAll(".alert").forEach(el => { if (!seen.has(el.dataset.key)) el.remove(); });
888
+ } catch (e) { /* transient; retry next poll */ }
889
+ }
890
+
891
+ // Live node refresh: re-pull table stats and update the cards in place
892
+ // (rows/size/cols/idx) without touching layout or the user's viewport.
893
+ async function refreshNodes() {
894
+ try {
895
+ const schema = await getJSON("/topology/schema");
896
+ const byId = Object.fromEntries(schema.nodes.map(n => [n.id, n]));
897
+ for (const n of NODES) {
898
+ const u = byId[n.id];
899
+ if (!u) continue;
900
+ Object.assign(n, u);
901
+ const el = nodeEls[n.id];
902
+ if (!el) continue;
903
+ const vals = el.querySelectorAll(".meta b");
904
+ if (vals.length === 4) {
905
+ vals[0].textContent = fmtNum(n.rows);
906
+ vals[1].textContent = n.size || "—";
907
+ vals[2].textContent = n.columns ?? "—";
908
+ vals[3].textContent = n.indexes ?? "—";
909
+ }
910
+ }
911
+ } catch (e) { /* transient */ }
912
+ }
913
+
914
+ setInterval(pollAlerts, 10000);
915
+ setInterval(refreshNodes, 30000);
916
+ setTimeout(pollAlerts, 800);
917
+
918
+ canvas.addEventListener("click", ev => {
919
+ if (!ev.target.closest(".node") && !ev.target.closest(".panel")) closePanels();
920
+ });
921
+
922
+ initDbSelect();
923
+ init().catch(e => { $("#loading").textContent = "Failed to load topology: " + e.message; });
924
+ </script>
925
+ </body>
926
+ </html>