@cef-ai/cli 0.2.1 → 0.2.2

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.
@@ -1,353 +1,3 @@
1
- export function getDevUiHtml() {
2
- return `<!DOCTYPE html>
3
- <html lang="en">
4
- <head>
5
- <meta charset="utf-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1">
7
- <title>CEF Dev</title>
8
- <style>
9
- *{margin:0;padding:0;box-sizing:border-box}
10
- :root{
11
- --bg:#09090b;--surface:#111113;--surface2:#18181b;--surface3:#1e1e22;
12
- --border:#27272a;--border2:#3f3f46;
13
- --text:#fafafa;--text2:#a1a1aa;--text3:#71717a;
14
- --accent:#3b82f6;--accent2:#2563eb;
15
- --success:#22c55e;--warn:#f59e0b;--error:#ef4444;
16
- --mono:'SF Mono','Fira Code','JetBrains Mono','Consolas',monospace;
17
- --sans:system-ui,-apple-system,'Segoe UI',sans-serif;
18
- --radius:8px;
19
- }
20
- html,body{height:100%;background:var(--bg);color:var(--text);font:13px/1.5 var(--sans);overflow:hidden}
21
- ::-webkit-scrollbar{width:6px;height:6px}
22
- ::-webkit-scrollbar-track{background:transparent}
23
- ::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
24
- ::-webkit-scrollbar-thumb:hover{background:var(--border2)}
25
- ::selection{background:var(--accent);color:#fff}
26
-
27
- button{font:12px/1 var(--sans);cursor:pointer;border:none;border-radius:6px;padding:7px 16px;font-weight:500;transition:all .12s ease}
28
- button:hover{filter:brightness(1.1)}
29
- button:active{transform:scale(.98)}
30
- textarea,input[type=text]{font:12px/1.5 var(--mono);background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px;resize:vertical;width:100%;outline:none;transition:border-color .15s,box-shadow .15s}
31
- textarea:focus,input[type=text]:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(59,130,246,.15)}
32
- pre{font:12px/1.5 var(--mono);white-space:pre-wrap;word-break:break-all}
33
- table{width:100%;border-collapse:collapse;font:12px/1.4 var(--mono)}
34
- th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--border)}
35
- th{color:var(--text3);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.3px;position:sticky;top:0;background:var(--surface2)}
36
- td{color:var(--text2)}
37
- tr:hover td{background:rgba(255,255,255,.02)}
38
-
39
- /* ─── Layout ─── */
40
- #app{display:grid;grid-template-columns:200px 1fr;grid-template-rows:48px 1fr auto;height:100vh}
41
-
42
- /* Header */
43
- #header{grid-column:1/-1;display:flex;align-items:center;padding:0 20px;background:var(--surface);border-bottom:1px solid var(--border);gap:10px}
44
- #header h1{font-size:13px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--text2)}
45
- #header h1 span{color:var(--accent)}
46
- .pill{font:10px/1 var(--mono);color:var(--text3);background:var(--surface2);padding:3px 10px;border-radius:12px;border:1px solid var(--border)}
47
-
48
- /* Sidebar */
49
- #sidebar{background:var(--surface);border-right:1px solid var(--border);overflow-y:auto;padding:4px 0}
50
- .s-label{font:10px/1 var(--sans);color:var(--text3);text-transform:uppercase;letter-spacing:.8px;padding:16px 14px 6px;font-weight:600}
51
- .s-item{display:flex;align-items:center;padding:6px 14px;cursor:pointer;font-size:12px;color:var(--text2);gap:8px;border-left:2px solid transparent;transition:all .1s}
52
- .s-item:hover{background:var(--surface2);color:var(--text)}
53
- .s-item.active{background:var(--surface2);color:var(--text);border-left-color:var(--accent)}
54
- .s-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
55
- .s-dot-eng{background:var(--accent)}
56
- .s-dot-agent{background:var(--success)}
57
- .s-dot-cubby{background:var(--warn)}
58
- .s-task{color:var(--text3);font-weight:400}
59
-
60
- /* Main */
61
- #main{overflow-y:auto;padding:24px 28px;background:var(--bg)}
62
-
63
- /* Panels */
64
- .panel{display:none;max-width:600px;animation:fadeIn .15s ease}.panel.active{display:block}.panel.active.panel-wide{max-width:none}
65
- @keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
66
- .panel-title{font-size:16px;font-weight:600;margin-bottom:20px;display:flex;align-items:center;gap:10px}
67
- .field{margin-bottom:14px}
68
- .field-label{display:block;font-size:10px;color:var(--text3);margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px;font-weight:600}
69
- .btn-primary{background:var(--accent);color:#fff}
70
- .btn-primary:hover{background:var(--accent2)}
71
- .btn-secondary{background:var(--surface2);color:var(--text2);border:1px solid var(--border)}
72
- .btn-secondary:hover{background:var(--surface3,#3f3f46);color:var(--text1)}
73
- .btn-danger{background:#991b1b;color:#fca5a5}
74
- .btn-danger:hover{background:#b91c1c}
75
- .btn-ghost{background:transparent;color:var(--text2);border:1px solid var(--border);padding:6px 12px}
76
- .btn-ghost:hover{background:var(--surface2);color:var(--text)}
77
- .btn-row{display:flex;gap:8px;margin:14px 0}
78
- .result{margin-top:14px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);max-height:280px;overflow:auto}
79
- .result-meta{font-size:11px;color:var(--text3);margin-top:6px;display:flex;gap:12px}
80
- .badge{display:inline-block;font:10px/1 var(--mono);padding:2px 6px;border-radius:4px}
81
- .badge-ok{background:#052e16;color:var(--success);border:1px solid #14532d}
82
- .badge-err{background:#1c0a0a;color:var(--error);border:1px solid #450a0a}
83
- .error-banner{background:#1c0a0a;border:1px solid #450a0a;color:#fca5a5;padding:10px 14px;border-radius:var(--radius);margin-top:10px;font-size:12px}
84
- .success-banner{background:#052e16;border:1px solid #14532d;color:#86efac;padding:10px 14px;border-radius:var(--radius);margin-top:10px;font-size:12px}
85
- .table-wrap{max-height:calc(100vh - 280px);overflow:auto;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
86
- #cubby-sql-result .table-wrap{max-height:calc(100vh - 600px)}
87
-
88
- /* Schema cards */
89
- .schema-pair{display:flex;gap:12px;margin-bottom:16px}
90
- .schema-card{flex:1;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px}
91
- .schema-card .st-title{font:10px/1 var(--sans);text-transform:uppercase;letter-spacing:.6px;color:var(--text3);margin-bottom:8px;font-weight:600}
92
- .schema-row{font:11px/1.8 var(--mono);display:flex;align-items:baseline;gap:6px;flex-wrap:wrap}
93
- .schema-row .sk{color:var(--text)}
94
- .schema-row .st{color:var(--accent);opacity:.7}
95
- .schema-row .sr{font:9px/1 var(--sans);color:var(--error);background:#1c0a0a;padding:1px 4px;border-radius:3px}
96
- .schema-row .sd{color:var(--text3);font-size:10px;font-style:italic}
97
-
98
- /* Cubby tabs */
99
- .tab-bar{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid var(--border)}
100
- .tab{padding:7px 16px;font-size:12px;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;font-weight:500;transition:color .1s}
101
- .tab:hover{color:var(--text2)}
102
- .tab.active{color:var(--text);border-bottom-color:var(--accent)}
103
- .cubby-table-item{display:flex;align-items:center;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:8px;cursor:pointer;transition:border-color .1s}
104
- .cubby-table-item:hover{border-color:var(--border2)}
105
- .cubby-table-item .cti-name{font:13px/1 var(--mono);font-weight:500}
106
- .cubby-table-item .cti-count{font-size:11px;color:var(--text3);margin-left:auto}
107
-
108
- /* Stream push */
109
- .stream-area{margin-top:20px;padding-top:20px;border-top:1px solid var(--border)}
110
- .stream-area .tab-bar{margin-bottom:14px}
111
- #file-drop-zone.drag-over{border-color:var(--accent);background:rgba(59,130,246,.05)}
112
- .progress-bar{height:3px;background:var(--surface2);border-radius:2px;margin:8px 0;overflow:hidden}
113
- .progress-bar .fill{height:100%;background:var(--accent);transition:width .1s;border-radius:2px}
114
- .progress-text{font:11px var(--mono);color:var(--text3)}
115
-
116
- /* ─── Bottom panel (resizable) ─── */
117
- #bottom{grid-column:1/-1;display:flex;flex-direction:column;background:var(--surface);min-height:40px;max-height:60vh}
118
- #bottom.collapsed{min-height:40px;max-height:40px}
119
- #bottom.collapsed #log-container,#bottom.collapsed #flow-container{display:none!important}
120
-
121
- #resize-handle{height:3px;background:transparent;cursor:ns-resize;flex-shrink:0;position:relative}
122
- #resize-handle::after{content:'';position:absolute;left:50%;top:0;transform:translateX(-50%);width:40px;height:3px;background:var(--border);border-radius:2px;transition:background .15s}
123
- #resize-handle:hover::after{background:var(--accent)}
124
-
125
- .bottom-bar{display:flex;align-items:center;padding:0 12px;height:36px;flex-shrink:0;border-top:1px solid var(--border);gap:2px}
126
- .btab{padding:4px 12px;font-size:11px;color:var(--text3);cursor:pointer;border-radius:4px;font-weight:500;transition:all .1s;user-select:none}
127
- .btab:hover{color:var(--text2);background:var(--surface2)}
128
- .btab.active{color:var(--text);background:var(--surface2)}
129
- .bottom-actions{margin-left:auto;display:flex;align-items:center;gap:6px}
130
- #log-filter{width:140px;font-size:11px;padding:4px 8px;border-radius:5px;background:var(--surface2);border:1px solid var(--border)}
131
- .icon-btn{background:none;border:none;color:var(--text3);cursor:pointer;padding:4px;border-radius:4px;font-size:14px;line-height:1;display:flex;align-items:center}
132
- .icon-btn:hover{color:var(--text);background:var(--surface2)}
133
-
134
- #log-container,#flow-container{flex:1;overflow-y:auto;font:11px/1.7 var(--mono);padding:6px 14px}
135
- .log-line{padding:1px 0;border-bottom:1px solid rgba(255,255,255,.02)}
136
- .log-line .ts{color:var(--text3);margin-right:6px}
137
- .log-line .agent{color:var(--accent);margin-right:4px}
138
- .log-line.warn{color:var(--warn)}
139
- .log-line.warn .ts{color:var(--warn);opacity:.5}
140
- .log-line.error{color:var(--error)}
141
- .log-line.error .ts{color:var(--error);opacity:.5}
142
-
143
- .flow-entry{padding:2px 0;font-size:11px;line-height:1.6;white-space:pre}
144
- .fe-time{color:var(--text3)}
145
- .fe-type{font-weight:500}
146
- .fe-eng{color:var(--accent)}
147
- .fe-agent{color:var(--success)}
148
- .fe-cubby{color:var(--warn)}
149
- .fe-err{color:var(--error)}
150
- .fe-ok{color:var(--success)}
151
- .fe-dim{color:var(--text3)}
152
-
153
- /* Empty state */
154
- .empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--text3);gap:8px}
155
- .empty-state .empty-icon{font-size:32px;opacity:.3}
156
- .empty{color:var(--text3);font-size:12px;padding:24px 0;text-align:center}
157
-
158
- /* Graph panel */
159
- #cubby-graph{position:relative}
160
- #graph-canvas{width:100%;height:calc(100vh - 200px);min-height:400px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface);overflow:hidden}
161
- #graph-detail{position:absolute;top:8px;right:8px;width:260px;background:var(--surface2);border:1px solid var(--border2);border-radius:var(--radius);padding:12px;display:none;font-size:11px;max-height:400px;overflow-y:auto}
162
- #graph-detail .gd-type{font:10px/1 var(--sans);text-transform:uppercase;letter-spacing:.5px;font-weight:600;margin-bottom:8px;padding:2px 6px;border-radius:3px;display:inline-block}
163
- .gd-row{display:flex;gap:6px;margin-bottom:4px;line-height:1.4}
164
- .gd-key{color:var(--text3);min-width:80px;flex-shrink:0}
165
- .gd-val{color:var(--text2);word-break:break-all}
166
- #graph-legend{position:absolute;bottom:8px;left:8px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:8px 12px;font-size:10px}
167
- #graph-legend .gl-row{display:flex;align-items:center;gap:6px;color:var(--text3);margin-bottom:3px}
168
- #graph-legend .gl-row:last-child{margin-bottom:0}
169
- .gl-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
170
- #graph-stats{font:11px var(--mono);color:var(--text3);margin-bottom:8px}
171
- </style>
172
- <script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
173
- </head>
174
- <body>
175
- <div id="app">
176
- <div id="header">
177
- <h1><span>CEF</span> Dev</h1>
178
- <span class="pill" id="status-pill">connecting</span>
179
- </div>
180
-
181
- <div id="sidebar"></div>
182
-
183
- <div id="main">
184
- <div class="empty-state" id="empty-state">
185
- <div class="empty-icon">&#9671;</div>
186
- <div>Select an item from the sidebar</div>
187
- </div>
188
-
189
- <div class="panel" id="panel-engagement">
190
- <div class="panel-title"><span class="s-dot s-dot-eng"></span> <span id="eng-name"></span> <span style="font-size:10px;font-weight:400;color:var(--text3);background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:2px 7px;margin-left:4px">engagement</span></div>
191
- <div id="eng-desc" style="font-size:11px;color:var(--text3);margin-bottom:14px"></div>
192
- <div id="eng-schema-hints"></div>
193
- <div class="field">
194
- <label class="field-label">Payload</label>
195
- <textarea id="eng-payload" rows="4">{}</textarea>
196
- </div>
197
- <div style="display:flex;gap:8px;align-items:flex-end;margin-bottom:10px;flex-wrap:wrap">
198
- <div style="display:flex;gap:6px;align-items:flex-end">
199
- <div class="field" style="margin:0">
200
- <label class="field-label">Repeat</label>
201
- <input type="number" id="eng-repeat" value="1" min="1" max="999" style="width:70px;padding:6px 8px">
202
- </div>
203
- <div class="field" style="margin:0">
204
- <label class="field-label">Delay ms</label>
205
- <input type="number" id="eng-delay" value="300" min="0" style="width:80px;padding:6px 8px">
206
- </div>
207
- </div>
208
- <div class="field" style="margin:0;flex:1;min-width:180px">
209
- <label class="field-label">or blast from file (JSON array)</label>
210
- <input type="file" id="eng-file-blast" accept=".json" style="width:100%;padding:5px 0;font-size:11px;color:var(--text2);background:none;border:none;cursor:pointer">
211
- </div>
212
- </div>
213
- <div class="btn-row">
214
- <button class="btn-primary" id="eng-start">Start</button>
215
- <button class="btn-danger" id="eng-stop" style="display:none">Stop</button>
216
- </div>
217
- <div id="eng-result"></div>
218
- <div class="stream-area" id="eng-stream-area" style="display:none">
219
- <div class="panel-title" style="font-size:14px">Stream Data</div>
220
- <div class="tab-bar">
221
- <div class="tab active" data-stab="single">Single</div>
222
- <div class="tab" data-stab="bulk">Bulk</div>
223
- <div class="tab" data-stab="file">File</div>
224
- </div>
225
- <div id="stream-single">
226
- <div class="field">
227
- <label class="field-label">Packet</label>
228
- <textarea id="eng-stream-payload" rows="3">{ }</textarea>
229
- </div>
230
- <button class="btn-primary" id="eng-push">Push</button>
231
- </div>
232
- <div id="stream-bulk" style="display:none">
233
- <div class="field">
234
- <label class="field-label">JSON Lines (one event per line) or JSON array</label>
235
- <textarea id="eng-bulk-payload" rows="6">{ }
236
- { }</textarea>
237
- </div>
238
- <div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
239
- <label class="field-label" style="margin:0;white-space:nowrap">Delay (ms)</label>
240
- <input type="text" id="eng-bulk-delay" value="100" style="width:70px;padding:6px 8px">
241
- </div>
242
- <div class="btn-row" style="margin-top:0">
243
- <button class="btn-primary" id="eng-bulk-send">Send All</button>
244
- <button class="btn-danger" id="eng-bulk-stop" style="display:none">Stop</button>
245
- </div>
246
- <div id="eng-bulk-progress"></div>
247
- </div>
248
- <div id="stream-file" style="display:none">
249
- <div id="file-drop-zone" style="border:2px dashed var(--border);border-radius:var(--radius);padding:28px;text-align:center;cursor:pointer;transition:border-color .15s;margin-bottom:12px">
250
- <div style="color:var(--text3);font-size:12px">Drop a <strong>.json</strong> or <strong>.jsonl</strong> file here, or click to browse</div>
251
- <input type="file" id="eng-file-input" accept=".json,.jsonl" style="display:none">
252
- <div id="file-info" style="margin-top:8px;font:11px var(--mono);color:var(--text2)"></div>
253
- </div>
254
- <div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
255
- <label class="field-label" style="margin:0;white-space:nowrap">Delay (ms)</label>
256
- <input type="text" id="eng-file-delay" value="100" style="width:70px;padding:6px 8px">
257
- </div>
258
- <div class="btn-row" style="margin-top:0">
259
- <button class="btn-primary" id="eng-file-send" disabled>Send File</button>
260
- <button class="btn-danger" id="eng-file-stop" style="display:none">Stop</button>
261
- </div>
262
- <div id="eng-file-progress"></div>
263
- </div>
264
- <div id="eng-push-result"></div>
265
- </div>
266
- </div>
267
-
268
- <div class="panel" id="panel-agent">
269
- <div class="panel-title"><span class="s-dot s-dot-agent"></span> <span id="agent-label"></span> <span style="font-size:10px;font-weight:400;color:var(--text3);background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:2px 7px;margin-left:4px">agent task</span></div>
270
- <div id="agent-desc" style="font-size:11px;color:var(--text3);margin-bottom:14px"></div>
271
- <div id="agent-schema-hints"></div>
272
- <div class="field">
273
- <label class="field-label">Payload</label>
274
- <textarea id="agent-payload" rows="5">{}</textarea>
275
- </div>
276
- <div class="btn-row">
277
- <button class="btn-primary" id="agent-call">Call</button>
278
- <button class="btn-secondary" id="agent-batch" style="display:none">Run Batch</button>
279
- </div>
280
- <div id="agent-result"></div>
281
- </div>
282
-
283
- <div class="panel" id="panel-cubby">
284
- <div class="panel-title"><span class="s-dot s-dot-cubby"></span> <span id="cubby-label"></span></div>
285
- <div class="tab-bar">
286
- <div class="tab active" data-tab="browse">Browse</div>
287
- <div class="tab" data-tab="sql">SQL</div>
288
- <div class="tab" data-tab="graph" id="graph-tab" style="display:none">Graph</div>
289
- </div>
290
- <div id="cubby-browse"><div id="cubby-tables"></div></div>
291
- <div id="cubby-sql" style="display:none">
292
- <div style="display:flex;gap:8px;align-items:flex-end;margin-bottom:10px">
293
- <div class="field" style="flex:1;margin:0">
294
- <label class="field-label">Saved Queries</label>
295
- <select id="query-select" style="width:100%;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:7px 10px;font-size:12px;outline:none">
296
- <option value="">— select a saved query —</option>
297
- </select>
298
- </div>
299
- <button class="btn-ghost" id="query-delete" title="Delete selected query" style="display:none;padding:7px 10px">&#10005;</button>
300
- </div>
301
- <div class="field">
302
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
303
- <label class="field-label" style="margin:0">Query</label>
304
- <div style="display:flex;gap:6px;align-items:center">
305
- <input type="text" id="query-name" placeholder="Name to save as..." style="width:160px;padding:4px 8px;font-size:11px;border-radius:5px;background:var(--surface2);border:1px solid var(--border);color:var(--text)">
306
- <button class="btn-ghost" id="query-save" style="padding:4px 10px;font-size:11px">Save</button>
307
- </div>
308
- </div>
309
- <textarea id="cubby-sql-input" rows="4">SELECT * FROM </textarea>
310
- </div>
311
- <button class="btn-primary" id="cubby-run">Run</button>
312
- <div id="cubby-sql-result"></div>
313
- </div>
314
- <div id="cubby-graph">
315
- <div id="graph-stats"></div>
316
- <div style="position:relative">
317
- <div id="graph-canvas"></div>
318
- <div id="graph-detail">
319
- <div id="gd-type-badge" class="gd-type"></div>
320
- <div id="gd-attrs"></div>
321
- </div>
322
- <div id="graph-legend">
323
- <div class="gl-row"><div class="gl-dot" style="background:#6366f1"></div>artifact</div>
324
- <div class="gl-row"><div class="gl-dot" style="background:#f59e0b"></div>work</div>
325
- <div class="gl-row"><div class="gl-dot" style="background:#22c55e"></div>operator</div>
326
- <div class="gl-row"><div class="gl-dot" style="background:#ec4899"></div>eval</div>
327
- <div class="gl-row"><div class="gl-dot" style="background:#71717a"></div>artifact_map</div>
328
- </div>
329
- </div>
330
- </div>
331
- </div>
332
- </div>
333
-
334
- <div id="bottom" style="height:220px">
335
- <div id="resize-handle"></div>
336
- <div class="bottom-bar">
337
- <div class="btab active" data-btab="logs">Logs</div>
338
- <div class="btab" data-btab="flow">Flow</div>
339
- <div class="bottom-actions">
340
- <input type="text" id="log-filter" placeholder="Filter...">
341
- <button class="icon-btn" id="btn-clear" title="Clear">&#10005;</button>
342
- <button class="icon-btn" id="btn-collapse" title="Collapse">&#9660;</button>
343
- </div>
344
- </div>
345
- <div id="log-container"></div>
346
- <div id="flow-container" style="display:none"></div>
347
- </div>
348
- </div>
349
-
350
- <script>
351
1
  (function(){
352
2
  const API='';
353
3
  let topology=null,activeEngagement=null,seqNum=0,ws=null,logFilter='',blastPayloads=null,blastAbort=false,batchRunning=false,batchAbort=false;
@@ -358,16 +8,16 @@ tr:hover td{background:rgba(255,255,255,.02)}
358
8
  const res=await fetch(API+path,opts);return res.json();
359
9
  }
360
10
 
361
- // ─── WebSocket ───
11
+ // WebSocket
362
12
  function connectWs(){
363
13
  const proto=location.protocol==='https:'?'wss:':'ws:';
364
14
  ws=new WebSocket(proto+'//'+location.host+'/ws');
365
- ws.onopen=()=>{$('status-pill').textContent=location.host};
366
- ws.onclose=()=>{$('status-pill').textContent='reconnecting';setTimeout(connectWs,2000)};
15
+ ws.onopen=()=>{$('status-dot').className='status-dot ok';$('status-pill').innerHTML='<span class="status-dot ok" id="status-dot"></span>'+location.host};
16
+ ws.onclose=()=>{$('status-dot').className='status-dot err';$('status-pill').innerHTML='<span class="status-dot err" id="status-dot"></span>reconnecting';setTimeout(connectWs,2000)};
367
17
  ws.onmessage=(e)=>{const m=JSON.parse(e.data);if(m.type==='log')appendLog(m.data);if(m.type==='trace')appendTrace(m.data)};
368
18
  }
369
19
 
370
- // ─── Logs ───
20
+ // Logs
371
21
  function appendLog(entry){
372
22
  const c=$('log-container'),d=document.createElement('div');
373
23
  d.className='log-line'+(entry.level!=='log'?' '+entry.level:'');
@@ -381,7 +31,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
381
31
  c.scrollTop=c.scrollHeight;
382
32
  }
383
33
 
384
- // ─── Flow ───
34
+ // Flow
385
35
  function appendTrace(ev){
386
36
  const c=$('flow-container'),d=document.createElement('div');
387
37
  d.className='flow-entry';
@@ -389,7 +39,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
389
39
  let ind='',det='',cls='';
390
40
  switch(ev.type){
391
41
  case'engagement:start':cls='fe-eng';det=ev.callee||'';break;
392
- case'agent:call':ind=' ';cls='fe-agent';det=ev.callee+(ev.caller?' <span class="fe-dim">\\u2190 '+esc(ev.caller)+'</span>':'');break;
42
+ case'agent:call':ind=' ';cls='fe-agent';det=ev.callee+(ev.caller?' <span class="fe-dim">\u2190 '+esc(ev.caller)+'</span>':'');break;
393
43
  case'agent:return':ind=' ';cls=ev.success?'fe-ok':'fe-err';det=ev.callee+' <span class="fe-dim">'+(ev.durationMs||0).toFixed(1)+'ms</span>'+(ev.success?'':' <span class="fe-err">'+esc(ev.error||'FAILED')+'</span>');break;
394
44
  case'cubby:query':ind=' ';cls='fe-cubby';det=(ev.cubbyAlias||'')+' <span class="fe-dim">'+esc(ev.sql||'')+'</span>';break;
395
45
  case'cubby:exec':ind=' ';cls='fe-cubby';det=(ev.cubbyAlias||'')+' <span class="fe-dim">'+esc(ev.sql||'')+'</span>'+(ev.rowsAffected!=null?' <span class="fe-dim">('+ev.rowsAffected+')</span>':'');break;
@@ -398,21 +48,29 @@ tr:hover td{background:rgba(255,255,255,.02)}
398
48
  c.appendChild(d);c.scrollTop=c.scrollHeight;
399
49
  }
400
50
 
401
- // ─── Sidebar ───
402
- async function loadTopology(){topology=await api('GET','/api/topology');renderSidebar()}
51
+ // Sidebar
52
+ async function loadTopology(){topology=await api('GET','/api/topology');renderSidebar();updateEmptyStats()}
53
+ function updateEmptyStats(){
54
+ if(!topology)return;
55
+ var el=$('empty-stats');if(!el)return;
56
+ el.innerHTML=
57
+ '<div class="stat-card"><div class="stat-num">'+topology.engagements.length+'</div><div class="stat-label">Engagements</div></div>'+
58
+ '<div class="stat-card"><div class="stat-num">'+topology.agents.reduce(function(s,a){return s+a.tasks.length},0)+'</div><div class="stat-label">Agent Tasks</div></div>'+
59
+ '<div class="stat-card"><div class="stat-num">'+topology.cubbies.length+'</div><div class="stat-label">Cubbies</div></div>';
60
+ }
403
61
  function renderSidebar(){
404
62
  const el=$('sidebar');let h='';
405
63
  if(topology.engagements.length){
406
- h+='<div class="s-label">Engagements</div>';
64
+ h+='<div class="s-label">&#9654; Engagements</div>';
407
65
  for(const e of topology.engagements)h+='<div class="s-item" data-type="engagement" data-name="'+ea(e.name)+'"><span class="s-dot s-dot-eng"></span>'+esc(e.name)+'</div>';
408
66
  }
409
67
  if(topology.agents.length){
410
- h+='<div class="s-label">Agents</div>';
68
+ h+='<div class="s-label">&#9881; Agents</div>';
411
69
  for(const a of topology.agents)for(const t of a.tasks)
412
70
  h+='<div class="s-item" data-type="agent" data-alias="'+ea(a.alias)+'" data-task="'+ea(t.alias)+'"><span class="s-dot s-dot-agent"></span>'+esc(a.alias)+'.<span class="s-task">'+esc(t.alias)+'</span></div>';
413
71
  }
414
72
  if(topology.cubbies.length){
415
- h+='<div class="s-label">Cubbies</div>';
73
+ h+='<div class="s-label">&#9636; Cubbies</div>';
416
74
  for(const c of topology.cubbies)h+='<div class="s-item" data-type="cubby" data-alias="'+ea(c.alias)+'"><span class="s-dot s-dot-cubby"></span>'+esc(c.alias)+'</div>';
417
75
  const graphCubbies=topology.cubbies.filter(c=>c.alias);
418
76
  if(graphCubbies.length)h+='<div class="s-item" data-type="graph-viz" style="margin-top:4px"><span class="s-dot" style="background:#22c55e"></span> Graph Visualizer</div>';
@@ -431,7 +89,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
431
89
  });
432
90
  }
433
91
 
434
- // ─── Panels ───
92
+ // Panels
435
93
  function hideAll(){document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));$('empty-state').style.display='none'}
436
94
 
437
95
  function showEngagement(name){
@@ -441,9 +99,9 @@ tr:hover td{background:rgba(255,255,255,.02)}
441
99
  $('eng-desc').textContent=ec?.description||'';
442
100
  if(params&&params.properties){
443
101
  const s=schemaToSample(params);
444
- if('streamId' in s&&!s.streamId)s.streamId=name.toLowerCase().replace(/\\s+/g,'-')+'-stream';
102
+ if('streamId' in s&&!s.streamId)s.streamId=name.toLowerCase().replace(/\s+/g,'-')+'-stream';
445
103
  $('eng-payload').value=JSON.stringify(s,null,2);
446
- }else{$('eng-payload').value=JSON.stringify({streamId:name.toLowerCase().replace(/\\s+/g,'-')+'-stream'},null,2)}
104
+ }else{$('eng-payload').value=JSON.stringify({streamId:name.toLowerCase().replace(/\s+/g,'-')+'-stream'},null,2)}
447
105
  $('eng-schema-hints').innerHTML=renderSchemaHints(params);
448
106
  if(activeEngagement&&activeEngagement.name===name){$('eng-stream-area').style.display='';$('eng-stop').style.display='';$('eng-start').style.display='none'}
449
107
  else{$('eng-stream-area').style.display='none';$('eng-stop').style.display='none';$('eng-start').style.display=''}
@@ -508,7 +166,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
508
166
  }return o;
509
167
  }
510
168
 
511
- // ─── Engagement actions ───
169
+ // Engagement actions
512
170
  $('eng-file-blast').addEventListener('change',function(){
513
171
  if(!this.files||!this.files[0]){blastPayloads=null;return;}
514
172
  var reader=new FileReader();
@@ -539,7 +197,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
539
197
  for(var i=0;i<payloads.length;i++){
540
198
  if(blastAbort)break;
541
199
  var payload=Object.assign({},payloads[i]);
542
- payload.streamId=name.toLowerCase().replace(/\\s+/g,'-')+'-'+i+'-'+Date.now();
200
+ payload.streamId=name.toLowerCase().replace(/\s+/g,'-')+'-'+i+'-'+Date.now();
543
201
  $('eng-result').innerHTML='<div class="success-banner">Firing '+(i+1)+'/'+payloads.length+'...</div>';
544
202
  try{
545
203
  const r=await api('POST','/api/trigger',{engagement:name,payload});
@@ -547,7 +205,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
547
205
  }catch(e){errors++;}
548
206
  if(delay&&i<payloads.length-1)await new Promise(function(r){setTimeout(r,delay);});
549
207
  }
550
- var msg=blastAbort?'Aborted after '+(i)+'/'+payloads.length+' — '+ok+' ok':'Done — '+ok+'/'+payloads.length+' ok'+(errors?' ('+errors+' errors)':'');
208
+ var msg=blastAbort?'Aborted after '+(i)+'/'+payloads.length+' \u2014 '+ok+' ok':'Done \u2014 '+ok+'/'+payloads.length+' ok'+(errors?' ('+errors+' errors)':'');
551
209
  $('eng-result').innerHTML='<div class="'+(errors||blastAbort?'error-banner':'success-banner')+'">'+esc(msg)+'</div>';
552
210
  $('eng-stop').style.display='none';this.style.display='';
553
211
  });
@@ -576,7 +234,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
576
234
  function parseLines(text){
577
235
  const trimmed=text.trim();
578
236
  if(trimmed.startsWith('[')){try{const arr=JSON.parse(trimmed);if(Array.isArray(arr))return arr.map(o=>JSON.stringify(o))}catch{}}
579
- return trimmed.split('\\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('//'));
237
+ return trimmed.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('//'));
580
238
  }
581
239
 
582
240
  let bulkAbort=false;
@@ -643,7 +301,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
643
301
  });
644
302
  $('eng-file-stop').addEventListener('click',()=>{bulkAbort=true});
645
303
 
646
- // ─── Agent actions ───
304
+ // Agent actions
647
305
  $('agent-call').addEventListener('click',async function(){
648
306
  const lbl=$('agent-label'),alias=lbl.dataset.alias,task=lbl.dataset.task;
649
307
  let payload;try{payload=JSON.parse($('agent-payload').value)}catch{payload={}}
@@ -665,7 +323,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
665
323
  var r=await api('POST','/api/cubbies/'+batchCubby+'/query',{sql:batchSql,params:[]});
666
324
  if(r.error){$('agent-result').innerHTML='<div class="error-banner">Query failed: '+esc(r.error)+'</div>';return;}
667
325
  var rows=r.rows||[];
668
- if(!rows.length){$('agent-result').innerHTML='<div class="success-banner">No rows matched — nothing to run</div>';return;}
326
+ if(!rows.length){$('agent-result').innerHTML='<div class="success-banner">No rows matched \u2014 nothing to run</div>';return;}
669
327
  const ac=topology.agents.find(function(a){return a.alias===alias;}),tc=ac&&ac.tasks.find(function(t){return t.alias===task;});
670
328
  const paramKeys=tc&&tc.parameters&&tc.parameters.properties?Object.keys(tc.parameters.properties):[];
671
329
  batchRunning=true;batchAbort=false;
@@ -673,7 +331,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
673
331
  var ok=0,errors=0,i=0;
674
332
  for(;i<rows.length;i++){
675
333
  if(batchAbort)break;
676
- $('agent-result').innerHTML='<div class="success-banner">Running '+(i+1)+'/'+rows.length+' — '+ok+' ok'+( errors?' '+errors+' err':'')+'</div>';
334
+ $('agent-result').innerHTML='<div class="success-banner">Running '+(i+1)+'/'+rows.length+' \u2014 '+ok+' ok'+( errors?' '+errors+' err':'')+'</div>';
677
335
  var payload={};
678
336
  for(var j=0;j<paramKeys.length&&j<rows[i].length;j++)payload[paramKeys[j]]=rows[i][j];
679
337
  try{
@@ -684,11 +342,11 @@ tr:hover td{background:rgba(255,255,255,.02)}
684
342
  }
685
343
  batchRunning=false;
686
344
  this.textContent='Run Batch';this.classList.replace('btn-danger','btn-secondary');
687
- var msg=batchAbort?'Stopped after '+i+'/'+rows.length+' — '+ok+' ok':'Done — '+ok+'/'+rows.length+' ok'+(errors?' ('+errors+' errors)':'');
345
+ var msg=batchAbort?'Stopped after '+i+'/'+rows.length+' \u2014 '+ok+' ok':'Done \u2014 '+ok+'/'+rows.length+' ok'+(errors?' ('+errors+' errors)':'');
688
346
  $('agent-result').innerHTML='<div class="'+(errors||batchAbort?'error-banner':'success-banner')+'">'+esc(msg)+'</div>';
689
347
  });
690
348
 
691
- // ─── Cubby tabs ───
349
+ // Cubby tabs
692
350
  document.querySelectorAll('#panel-cubby .tab').forEach(tab=>{
693
351
  tab.addEventListener('click',()=>{
694
352
  document.querySelectorAll('#panel-cubby .tab').forEach(t=>t.classList.remove('active'));tab.classList.add('active');
@@ -701,7 +359,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
701
359
  });
702
360
  });
703
361
 
704
- // ─── Graph tab ───
362
+ // Graph tab
705
363
  const GRAPH_NODE_COLORS={
706
364
  artifact:{bg:'#6366f1',border:'#818cf8'},
707
365
  work:{bg:'#f59e0b',border:'#fbbf24'},
@@ -721,20 +379,20 @@ tr:hover td{background:rgba(255,255,255,.02)}
721
379
  api('POST','/api/cubbies/'+alias+'/query',{sql:"SELECT id,version,type,ref,created_at,attribute FROM nodes"}),
722
380
  api('POST','/api/cubbies/'+alias+'/query',{sql:"SELECT id,src,type,dst FROM edges"}),
723
381
  ]);
724
- if(nr.error){canvas.innerHTML='<div class="empty">This cubby has no nodes table — graph view requires nodes + edges tables</div>';return}
382
+ if(nr.error){canvas.innerHTML='<div class="empty">This cubby has no nodes table \u2014 graph view requires nodes + edges tables</div>';return}
725
383
  const rows=nr.rows||[],erows=er.rows||[];
726
- if(!rows.length){canvas.innerHTML='<div class="empty">No nodes yet — run an ingest first</div>';return}
384
+ if(!rows.length){canvas.innerHTML='<div class="empty">No nodes yet \u2014 run an ingest first</div>';return}
727
385
 
728
- // Build vis nodes — ALL types, ALL versions as separate nodes
386
+ // Build vis nodes
729
387
  const visNodes=[],versionedNode={},latestVer={},nodeVisId={};
730
388
  let idx=1;
731
389
  for(const r of rows){
732
390
  const[id,ver,type,ref,created_at,attr]=r;
733
391
  const col=GRAPH_NODE_COLORS[type]||GRAPH_NODE_COLORS.artifact_map;
734
392
  const a=safeJ(attr);
735
- const label=type==='artifact'?(a.title||ref||id).slice(0,22)+'\\nv'+ver
393
+ const label=type==='artifact'?(a.title||ref||id).slice(0,22)+'\nv'+ver
736
394
  :type==='operator'?(a.name||ref||'operator').slice(0,18)
737
- :type==='work'?(a.type||'work')+'\\nv'+ver
395
+ :type==='work'?(a.type||'work')+'\nv'+ver
738
396
  :(type+' v'+ver);
739
397
  const shape=type==='operator'?'diamond':type==='work'?'box':'ellipse';
740
398
  visNodes.push({id:idx,label,
@@ -750,16 +408,13 @@ tr:hover td{background:rgba(255,255,255,.02)}
750
408
  }
751
409
 
752
410
  // Build vis edges
753
- // nodeVer[id] = version from the nodes table (single row per unique id for work/operator/eval)
754
411
  const nodeVer={};
755
412
  for(const r of rows)nodeVer[r[0]]=r[1];
756
413
 
757
414
  const visEdges=[];
758
415
  for(const r of erows){
759
416
  const[eid,src,type,dst]=r;
760
- // src: use its single vis node (work/operator each have one entry)
761
417
  const sVisId=latestVer[src]?.visId;
762
- // dst: for versioned nodes (artifact), prefer version matching src's version
763
418
  const srcVersion=nodeVer[src];
764
419
  const dVisId=(versionedNode[dst]&&srcVersion&&versionedNode[dst][srcVersion])
765
420
  ?versionedNode[dst][srcVersion]
@@ -772,7 +427,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
772
427
  smooth:{type:'curvedCW',roundness:0.2}});
773
428
  }
774
429
 
775
- // Synthetic PREV_VERSION dashed edges — chain artifact versions into a timeline
430
+ // Synthetic PREV_VERSION dashed edges
776
431
  for(const[nodeId,verMap]of Object.entries(versionedNode)){
777
432
  const ntype=rows.find(function(r){return r[0]===nodeId;})?.[2];
778
433
  if(ntype!=='artifact')continue;
@@ -793,14 +448,14 @@ tr:hover td{background:rgba(255,255,255,.02)}
793
448
  const typeCounts={};
794
449
  for(const n of visNodes)typeCounts[n._raw.type]=(typeCounts[n._raw.type]||0)+1;
795
450
 
796
- $('graph-stats').innerHTML=visNodes.length+' nodes · '+visEdges.length+' edges';
451
+ $('graph-stats').innerHTML=visNodes.length+' nodes \u00b7 '+visEdges.length+' edges';
797
452
 
798
- // Legend toggles — click to show/hide each type without full reload
453
+ // Legend toggles
799
454
  var legendHtml='<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">';
800
455
  for(const[type,col]of Object.entries(GRAPH_NODE_COLORS)){
801
456
  if(!typeCounts[type])continue;
802
457
  const isHidden=hiddenTypes.has(type);
803
- legendHtml+='<button onclick="window.graphToggleType(\\''+type+'\\')" style="display:flex;align-items:center;gap:5px;background:'+(isHidden?'var(--surface2)':col.bg+'22')+';border:1px solid '+(isHidden?'var(--border)':col.bg+'66')+';color:'+(isHidden?'var(--text3)':col.bg)+';border-radius:4px;padding:2px 8px;font-size:10px;cursor:pointer">'
458
+ legendHtml+='<button onclick="window.graphToggleType(\''+type+'\')" style="display:flex;align-items:center;gap:5px;background:'+(isHidden?'var(--surface2)':col.bg+'22')+';border:1px solid '+(isHidden?'var(--border)':col.bg+'66')+';color:'+(isHidden?'var(--text3)':col.bg)+';border-radius:4px;padding:2px 8px;font-size:10px;cursor:pointer">'
804
459
  +'<span style="width:8px;height:8px;border-radius:50%;background:'+(isHidden?'var(--border)':col.bg)+';display:inline-block"></span>'+esc(type)+' <span style="opacity:.6">'+typeCounts[type]+'</span></button>';
805
460
  }
806
461
  legendHtml+='</div>';
@@ -835,9 +490,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
835
490
  window.graphToggleType=function(type){
836
491
  if(hiddenTypes.has(type))hiddenTypes.delete(type);else hiddenTypes.add(type);
837
492
  if(!graphVisNodes)return;
838
- // Update node visibility in-place — no full reload
839
493
  graphVisNodes.update(graphVisNodes.get().filter(function(n){return n._raw&&n._raw.type===type;}).map(function(n){return{id:n.id,hidden:hiddenTypes.has(type)};}));
840
- // Refresh legend button states
841
494
  document.querySelectorAll('#graph-stats button').forEach(function(btn){
842
495
  var t=btn.textContent.trim().split(' ')[0];
843
496
  var col=GRAPH_NODE_COLORS[t];if(!col)return;
@@ -857,7 +510,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
857
510
  let h='';
858
511
  for(const row of t.rows){
859
512
  const tn=row[0],cr=await api('POST','/api/cubbies/'+alias+'/query',{sql:'SELECT COUNT(*) FROM "'+tn+'"'});
860
- h+='<div class="cubby-table-item" onclick="browseCubbyTable(\\''+ea(alias)+'\\',\\''+ea(tn)+'\\')">';
513
+ h+='<div class="cubby-table-item" onclick="browseCubbyTable(\''+ea(alias)+'\',\''+ea(tn)+'\')">';
861
514
  h+='<span class="cti-name">'+esc(tn)+'</span><span class="cti-count">'+(cr.rows?.[0]?.[0]??'?')+' rows</span></div>';
862
515
  }
863
516
  el.innerHTML=h;
@@ -867,8 +520,8 @@ tr:hover td{background:rgba(255,255,255,.02)}
867
520
  const el=$('cubby-tables');
868
521
  try{const r=await api('POST','/api/cubbies/'+alias+'/query',{sql:'SELECT * FROM "'+table+'" LIMIT 100'});
869
522
  el.innerHTML='<div style="display:flex;gap:8px;justify-content:flex-end;margin-bottom:8px">'
870
- +'<button class="btn-ghost" onclick="loadCubbyBrowse(\\''+ea(alias)+'\\')">\\u2190 Back</button>'
871
- +'<button class="btn-danger" style="font-size:11px;padding:5px 12px" onclick="clearCubbyTable(\\''+ea(alias)+'\\',\\''+ea(table)+'\\')">Clear table</button>'
523
+ +'<button class="btn-ghost" onclick="loadCubbyBrowse(\''+ea(alias)+'\')">\u2190 Back</button>'
524
+ +'<button class="btn-danger" style="font-size:11px;padding:5px 12px" onclick="clearCubbyTable(\''+ea(alias)+'\',\''+ea(table)+'\')">Clear table</button>'
872
525
  +'</div>'+renderTable(r);
873
526
  }catch(e){el.innerHTML='<div class="error-banner">'+esc(e.message)+'</div>'}
874
527
  };
@@ -883,7 +536,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
883
536
 
884
537
  $('cubby-run').addEventListener('click',async function(){
885
538
  const alias=$('cubby-label').dataset.alias,sql=$('cubby-sql-input').value.trim();if(!sql)return;
886
- const isW=/^\\s*(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)/i.test(sql),ep=isW?'exec':'query';
539
+ const isW=/^\s*(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)/i.test(sql),ep=isW?'exec':'query';
887
540
  this.textContent='...';
888
541
  try{const r=await api('POST','/api/cubbies/'+alias+'/'+ep,{sql});
889
542
  if(r.error){$('cubby-sql-result').innerHTML='<div class="error-banner">'+esc(r.error)+'</div>'}
@@ -893,12 +546,12 @@ tr:hover td{background:rgba(255,255,255,.02)}
893
546
  this.textContent='Run';
894
547
  });
895
548
 
896
- // ─── Query template manager (stored in cubby saved_queries table) ───
549
+ // Query template manager (stored in cubby saved_queries table)
897
550
  async function qtRefreshSelect(alias){
898
551
  var sel=$('query-select'),prev=sel.value;
899
552
  try{
900
553
  var r=await api('POST','/api/cubbies/'+alias+'/query',{sql:'SELECT name FROM saved_queries ORDER BY name',params:[]});
901
- sel.innerHTML='<option value="">— select a saved query —</option>';
554
+ sel.innerHTML='<option value="">\u2014 select a saved query \u2014</option>';
902
555
  (r.rows||[]).forEach(function(row){var o=document.createElement('option');o.value=row[0];o.textContent=row[0];sel.appendChild(o);});
903
556
  sel.value=prev||'';
904
557
  $('query-delete').style.display=sel.value?'':'none';
@@ -929,7 +582,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
929
582
  }catch(e){}
930
583
  });
931
584
 
932
- // ─── Bottom panel: tabs, resize, collapse ───
585
+ // Bottom panel: tabs, resize, collapse
933
586
  document.querySelectorAll('.btab').forEach(tab=>{
934
587
  tab.addEventListener('click',()=>{
935
588
  document.querySelectorAll('.btab').forEach(t=>t.classList.remove('active'));tab.classList.add('active');
@@ -965,7 +618,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
965
618
  ta.addEventListener('keydown',function(e){if(e.key==='Tab'){e.preventDefault();const s=this.selectionStart;this.value=this.value.substring(0,s)+' '+this.value.substring(this.selectionEnd);this.selectionStart=this.selectionEnd=s+2}});
966
619
  });
967
620
 
968
- // ─── Helpers ───
621
+ // Helpers
969
622
  function $(id){return document.getElementById(id)}
970
623
  function renderTable(r){
971
624
  if(!r.columns||!r.rows)return'';let h='<div class="table-wrap"><table><thead><tr>';
@@ -974,11 +627,7 @@ tr:hover td{background:rgba(255,255,255,.02)}
974
627
  return h+'</tbody></table></div>';
975
628
  }
976
629
  function esc(s){if(s==null)return'';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
977
- function ea(s){return String(s).replace(/'/g,"\\\\'").replace(/"/g,'&quot;')}
630
+ function ea(s){return String(s).replace(/'/g,"\\'").replace(/"/g,'&quot;')}
978
631
 
979
632
  loadTopology();connectWs();
980
633
  })();
981
- </script>
982
- </body>
983
- </html>`;
984
- }