@bod.ee/db 0.12.8 → 0.13.1
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.
- package/CLAUDE.md +2 -1
- package/admin/admin.ts +23 -3
- package/admin/bun.lock +248 -0
- package/admin/index.html +12 -0
- package/admin/package.json +22 -0
- package/admin/src/App.tsx +23 -0
- package/admin/src/client/ZuzClient.ts +183 -0
- package/admin/src/client/types.ts +28 -0
- package/admin/src/components/MetricsBar.tsx +167 -0
- package/admin/src/components/Sparkline.tsx +72 -0
- package/admin/src/components/TreePane.tsx +287 -0
- package/admin/src/components/tabs/Advanced.tsx +222 -0
- package/admin/src/components/tabs/AuthRules.tsx +104 -0
- package/admin/src/components/tabs/Cache.tsx +113 -0
- package/admin/src/components/tabs/KeyAuth.tsx +462 -0
- package/admin/src/components/tabs/MessageQueue.tsx +237 -0
- package/admin/src/components/tabs/Query.tsx +75 -0
- package/admin/src/components/tabs/ReadWrite.tsx +177 -0
- package/admin/src/components/tabs/Replication.tsx +94 -0
- package/admin/src/components/tabs/Streams.tsx +329 -0
- package/admin/src/components/tabs/StressTests.tsx +209 -0
- package/admin/src/components/tabs/Subscriptions.tsx +69 -0
- package/admin/src/components/tabs/TabPane.tsx +151 -0
- package/admin/src/components/tabs/VFS.tsx +435 -0
- package/admin/src/components/tabs/View.tsx +14 -0
- package/admin/src/components/tabs/utils.ts +25 -0
- package/admin/src/context/DbContext.tsx +33 -0
- package/admin/src/context/StatsContext.tsx +56 -0
- package/admin/src/main.tsx +10 -0
- package/admin/src/styles.css +96 -0
- package/admin/tsconfig.app.json +21 -0
- package/admin/tsconfig.json +7 -0
- package/admin/tsconfig.node.json +15 -0
- package/admin/vite.config.ts +42 -0
- package/deploy/base.yaml +1 -1
- package/deploy/prod-il.config.ts +5 -2
- package/deploy/prod.config.ts +5 -2
- package/package.json +4 -1
- package/src/server/BodDB.ts +62 -5
- package/src/server/ReplicationEngine.ts +148 -35
- package/src/server/StreamEngine.ts +2 -2
- package/src/server/Transport.ts +17 -0
- package/tests/replication.test.ts +162 -1
- package/admin/ui.html +0 -3562
package/admin/ui.html
DELETED
|
@@ -1,3562 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8">
|
|
5
|
-
<title>ZuzDB Admin</title>
|
|
6
|
-
<style>
|
|
7
|
-
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
8
|
-
body { font-family: monospace; font-size: 13px; background: #0d0d0d; color: #d4d4d4; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
|
9
|
-
|
|
10
|
-
/* Metrics bar */
|
|
11
|
-
#metrics-bar { display: flex; background: #0a0a0a; border-bottom: 1px solid #2a2a2a; flex-shrink: 0; align-items: stretch; width: 100%; }
|
|
12
|
-
.metric-card { display: flex; flex-direction: column; padding: 5px 10px 4px; border-right: 1px solid #181818; min-width: 140px; flex-shrink: 0; gap: 1px; overflow: hidden; }
|
|
13
|
-
.metric-card:last-child { border-right: none; width: auto; }
|
|
14
|
-
.metric-top { display: flex; justify-content: space-between; align-items: baseline; width: 100%; }
|
|
15
|
-
.metric-label { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
16
|
-
.metric-value { font-size: 13px; color: #4ec9b0; font-weight: bold; min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
|
|
17
|
-
.metric-value.warn { color: #ce9178; }
|
|
18
|
-
.metric-value.dim { color: #888; }
|
|
19
|
-
.metric-canvas { display: block; margin-top: 2px; width: 100%; }
|
|
20
|
-
#ws-dot { width: 7px; height: 7px; border-radius: 50%; background: #555; display: inline-block; margin-left: 4px; vertical-align: middle; }
|
|
21
|
-
#ws-dot.live { background: #4ec9b0; animation: pulse 1.5s infinite; }
|
|
22
|
-
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
|
23
|
-
|
|
24
|
-
/* Main layout */
|
|
25
|
-
#main { display: flex; flex: 1; overflow: hidden; }
|
|
26
|
-
|
|
27
|
-
/* Tree */
|
|
28
|
-
#tree-pane { width: 28%; border-right: 1px solid #2a2a2a; display: flex; flex-direction: column; min-width: 180px; }
|
|
29
|
-
#tree-header { padding: 6px 10px; background: #161616; border-bottom: 1px solid #2a2a2a; display: flex; justify-content: space-between; align-items: center; }
|
|
30
|
-
#tree-header span { color: #555; font-size: 11px; }
|
|
31
|
-
#tree-container { flex: 1; overflow-y: auto; padding: 4px; }
|
|
32
|
-
#tree-container details { margin-left: 12px; }
|
|
33
|
-
#tree-container summary { cursor: pointer; padding: 2px 4px; border-radius: 3px; color: #4ec9b0; list-style: none; display: flex; align-items: center; white-space: nowrap; overflow: hidden; }
|
|
34
|
-
#tree-container summary::before { content: '▶'; font-size: 10px; color: #666; margin-right: 5px; flex-shrink: 0; display: inline-block; transition: transform 0.15s; }
|
|
35
|
-
details[open] > summary::before { transform: rotate(90deg); color: #aaa; }
|
|
36
|
-
#tree-container summary .tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
37
|
-
#tree-container summary .ttl-badge, #tree-container summary .count-badge { flex-shrink: 0; margin-left: 4px; }
|
|
38
|
-
#tree-container summary:hover { background: #1e1e1e; }
|
|
39
|
-
.tree-leaf { padding: 2px 4px 2px 16px; cursor: pointer; border-radius: 3px; color: #9cdcfe; display: flex; gap: 4px; align-items: baseline; overflow: hidden; }
|
|
40
|
-
.tree-leaf:hover { background: #1e1e1e; }
|
|
41
|
-
.tree-val { color: #ce9178; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
|
|
42
|
-
.tree-key { color: #4ec9b0; flex-shrink: 0; }
|
|
43
|
-
.ttl-badge { font-size: 9px; padding: 0 4px; border-radius: 3px; background: #4d3519; color: #d4a054; flex-shrink: 0; }
|
|
44
|
-
.count-badge { font-size: 9px; padding: 0 4px; border-radius: 3px; background: #1e2d3d; color: #569cd6; flex-shrink: 0; }
|
|
45
|
-
@keyframes treeFlash { 0%,100% { background: transparent; } 30% { background: rgba(86,156,214,0.25); } }
|
|
46
|
-
.flash { animation: treeFlash 1.2s ease-out; border-radius: 3px; }
|
|
47
|
-
|
|
48
|
-
/* Right pane */
|
|
49
|
-
#right-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
50
|
-
.tabs { display: flex; background: #161616; border-bottom: 1px solid #2a2a2a; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
|
|
51
|
-
.tabs::-webkit-scrollbar { display: none; }
|
|
52
|
-
.tab { padding: 7px 16px; cursor: pointer; border-bottom: 2px solid transparent; color: #666; font-size: 12px; white-space: nowrap; flex-shrink: 0; }
|
|
53
|
-
.tab.active { color: #fff; border-bottom-color: #569cd6; }
|
|
54
|
-
.panel { display: none; flex: 1; overflow-y: auto; padding: 12px; flex-direction: column; gap: 10px; }
|
|
55
|
-
.panel.active { display: flex; }
|
|
56
|
-
|
|
57
|
-
/* Controls */
|
|
58
|
-
label { color: #666; font-size: 10px; margin-bottom: 2px; display: block; text-transform: uppercase; letter-spacing: 0.3px; }
|
|
59
|
-
input[type=text], textarea, select { width: 100%; background: #1a1a1a; border: 1px solid #333; color: #d4d4d4; padding: 5px 8px; border-radius: 3px; font-family: monospace; font-size: 12px; }
|
|
60
|
-
input[type=text]:focus, textarea:focus { outline: none; border-color: #569cd6; }
|
|
61
|
-
textarea { resize: vertical; min-height: 80px; }
|
|
62
|
-
.row { display: flex; gap: 6px; align-items: flex-end; }
|
|
63
|
-
.row input { flex: 1; }
|
|
64
|
-
button { padding: 5px 11px; border: none; border-radius: 3px; cursor: pointer; font-family: monospace; font-size: 12px; background: #264f78; color: #ccc; white-space: nowrap; }
|
|
65
|
-
button:hover { background: #3a6fa8; color: #fff; }
|
|
66
|
-
button.danger { background: #4a1e1e; color: #f48771; }
|
|
67
|
-
button.danger:hover { background: #7a2e2e; }
|
|
68
|
-
button.success { background: #1e4a1e; color: #4ec9b0; }
|
|
69
|
-
button.success:hover { background: #2e7a2e; }
|
|
70
|
-
button.sub { background: #3a2e5a; color: #c5b8f0; }
|
|
71
|
-
button.sub:hover { background: #5a4a8a; }
|
|
72
|
-
button.sm { padding: 3px 7px; font-size: 11px; }
|
|
73
|
-
button.icon { display:inline-flex;align-items:center;justify-content:center;padding:3px 5px;vertical-align:middle; }
|
|
74
|
-
.result { background: #111; border: 1px solid #222; border-radius: 3px; padding: 8px; overflow: auto; max-height: 280px; white-space: pre; color: #9cdcfe; font-size: 12px; }
|
|
75
|
-
.status { font-size: 11px; padding: 3px 8px; border-radius: 3px; }
|
|
76
|
-
.status.ok { background: #1a3a1a; color: #4ec9b0; }
|
|
77
|
-
.status.err { background: #3a1a1a; color: #f48771; }
|
|
78
|
-
|
|
79
|
-
/* Query filters */
|
|
80
|
-
.filter-row { display: flex; gap: 5px; align-items: center; margin-bottom: 4px; }
|
|
81
|
-
.filter-row input { flex: 2; }
|
|
82
|
-
.filter-row select { flex: 1; }
|
|
83
|
-
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
84
|
-
th { background: #1a1a1a; padding: 4px 8px; text-align: left; color: #666; border-bottom: 1px solid #222; }
|
|
85
|
-
td { padding: 4px 8px; border-bottom: 1px solid #1a1a1a; color: #d4d4d4; word-break: break-all; }
|
|
86
|
-
tr:hover td { background: #161616; }
|
|
87
|
-
|
|
88
|
-
/* Subscriptions */
|
|
89
|
-
.sub-item { background: #111; border: 1px solid #222; border-radius: 3px; padding: 8px; }
|
|
90
|
-
.sub-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; color: #4ec9b0; }
|
|
91
|
-
.sub-log { max-height: 130px; overflow-y: auto; font-size: 11px; background: #0a0a0a; padding: 5px; border-radius: 2px; }
|
|
92
|
-
.sub-log div { padding: 2px 0; border-bottom: 1px solid #161616; color: #9cdcfe; }
|
|
93
|
-
.sub-log div span { color: #555; margin-right: 6px; }
|
|
94
|
-
|
|
95
|
-
/* Stress test */
|
|
96
|
-
.stress-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
97
|
-
.stress-card { background: #111; border: 1px solid #222; border-radius: 3px; padding: 10px; }
|
|
98
|
-
.stress-card h4 { color: #888; font-size: 11px; text-transform: uppercase; margin-bottom: 8px; letter-spacing: 0.5px; }
|
|
99
|
-
.stress-result { margin-top: 8px; font-size: 11px; background: #0a0a0a; border-radius: 2px; padding: 6px; color: #4ec9b0; min-height: 60px; white-space: pre-wrap; }
|
|
100
|
-
.progress-bar { height: 4px; background: #222; border-radius: 2px; margin-top: 6px; overflow: hidden; }
|
|
101
|
-
.progress-fill { height: 100%; background: #569cd6; border-radius: 2px; width: 0%; transition: width 0.1s; }
|
|
102
|
-
input[type=number] { width: 80px; background: #1a1a1a; border: 1px solid #333; color: #d4d4d4; padding: 4px 6px; border-radius: 3px; font-family: monospace; font-size: 12px; }
|
|
103
|
-
</style>
|
|
104
|
-
</head>
|
|
105
|
-
<body>
|
|
106
|
-
|
|
107
|
-
<!-- Metrics Bar -->
|
|
108
|
-
<div id="metrics-bar">
|
|
109
|
-
<div class="metric-card">
|
|
110
|
-
<div class="metric-top"><span class="metric-label">CPU</span><span class="metric-value" id="s-cpu">—</span></div>
|
|
111
|
-
<canvas class="metric-canvas" id="g-cpu" width="100" height="28"></canvas>
|
|
112
|
-
<div style="font-size:10px;color:#555;margin-top:2px">sys <span id="s-syscpu">—</span>% · <span id="s-cpucores">—</span> cores</div>
|
|
113
|
-
</div>
|
|
114
|
-
<div class="metric-card">
|
|
115
|
-
<div class="metric-top"><span class="metric-label">Heap</span><span class="metric-value" id="s-heap">—</span></div>
|
|
116
|
-
<canvas class="metric-canvas" id="g-heap" width="100" height="28"></canvas>
|
|
117
|
-
</div>
|
|
118
|
-
<div class="metric-card">
|
|
119
|
-
<div class="metric-top"><span class="metric-label">RSS</span><span class="metric-value" id="s-rss">—</span></div>
|
|
120
|
-
<canvas class="metric-canvas" id="g-rss" width="100" height="28"></canvas>
|
|
121
|
-
<div style="font-size:10px;color:#555;margin-top:2px">of <span id="s-totalmem">—</span> total</div>
|
|
122
|
-
</div>
|
|
123
|
-
<div class="metric-card">
|
|
124
|
-
<div class="metric-top"><span class="metric-label">Nodes</span><span class="metric-value" id="s-nodes">—</span></div>
|
|
125
|
-
<canvas class="metric-canvas" id="g-nodes" width="100" height="28"></canvas>
|
|
126
|
-
<div style="font-size:10px;color:#555;margin-top:2px"><span id="s-dbsize">—</span> MB on disk</div>
|
|
127
|
-
</div>
|
|
128
|
-
<div class="metric-card">
|
|
129
|
-
<div class="metric-top"><span class="metric-label">Ping</span><span class="metric-value" id="s-ping">—</span></div>
|
|
130
|
-
<canvas class="metric-canvas" id="g-ping" width="100" height="28"></canvas>
|
|
131
|
-
</div>
|
|
132
|
-
<div style="margin-left:auto;display:flex;flex-shrink:0">
|
|
133
|
-
<div class="metric-card" id="repl-card" style="border-left:1px solid #282828;display:none;width:180px">
|
|
134
|
-
<div class="metric-top"><span class="metric-label">Replication</span><span class="metric-value dim" id="s-repl-role">—</span></div>
|
|
135
|
-
<div style="margin-top:4px;font-size:10px" id="s-repl-sources"></div>
|
|
136
|
-
</div>
|
|
137
|
-
<div class="metric-card" style="border-left:1px solid #282828;justify-content:space-between">
|
|
138
|
-
<div class="metric-top"><span class="metric-label">Uptime</span><span class="metric-value dim" id="s-uptime">—</span></div>
|
|
139
|
-
<div style="font-size:10px;color:#555;display:flex;justify-content:space-between"><span id="s-ts">—</span><span>v<span id="s-version">—</span></span></div>
|
|
140
|
-
<div><span class="metric-label">WS<span id="ws-dot"></span></span> <span style="font-size:10px;color:#555"><span id="s-clients">0</span> clients · <span id="s-subs">0</span> subs</span></div>
|
|
141
|
-
<div><button id="stats-toggle" class="sm" onclick="toggleStats()" title="Toggle server stats collection">Stats: ON</button></div>
|
|
142
|
-
</div>
|
|
143
|
-
</div>
|
|
144
|
-
</div>
|
|
145
|
-
|
|
146
|
-
<div id="main">
|
|
147
|
-
<!-- Tree Pane -->
|
|
148
|
-
<div id="tree-pane">
|
|
149
|
-
<div id="tree-header">
|
|
150
|
-
<b>Tree</b>
|
|
151
|
-
<span id="node-count">—</span>
|
|
152
|
-
<button class="sm icon" onclick="loadTree()" title="Reload"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg></button>
|
|
153
|
-
</div>
|
|
154
|
-
<div id="tree-container">Connecting…</div>
|
|
155
|
-
</div>
|
|
156
|
-
|
|
157
|
-
<!-- Right Pane -->
|
|
158
|
-
<div id="right-pane">
|
|
159
|
-
<div class="tabs">
|
|
160
|
-
<div class="tab active" onclick="showTab('rw')">Read / Write</div>
|
|
161
|
-
<div class="tab" onclick="showTab('query')">Query Builder</div>
|
|
162
|
-
<div class="tab" onclick="showTab('subs')">Live Subscriptions</div>
|
|
163
|
-
<div class="tab" onclick="showTab('auth');loadRules()">Auth & Rules</div>
|
|
164
|
-
<div class="tab" onclick="showTab('advanced')">Advanced</div>
|
|
165
|
-
<div class="tab" onclick="showTab('streams')">Streams</div>
|
|
166
|
-
<div class="tab" onclick="showTab('mq')">MQ</div>
|
|
167
|
-
<div class="tab" onclick="showTab('repl');loadRepl()">Replication</div>
|
|
168
|
-
<div class="tab" onclick="showTab('vfs');vfsNavigate(vfsPath)">VFS</div>
|
|
169
|
-
<div class="tab" onclick="showTab('cache')">Cache</div>
|
|
170
|
-
<div class="tab" onclick="showTab('keyauth');loadKeyAuth()">KeyAuth</div>
|
|
171
|
-
<div class="tab" onclick="showTab('stress')">Stress Tests</div>
|
|
172
|
-
<div class="tab" onclick="showTab('view')">View</div>
|
|
173
|
-
</div>
|
|
174
|
-
|
|
175
|
-
<!-- Read/Write Panel -->
|
|
176
|
-
<div class="panel active" id="panel-rw">
|
|
177
|
-
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
|
178
|
-
<span style="font-size:11px;color:#555">Auth:</span>
|
|
179
|
-
<input id="rw-auth-token" type="text" placeholder="token (or leave empty)" style="flex:1;font-size:11px">
|
|
180
|
-
<button onclick="doRwAuth()" class="sm">SET</button>
|
|
181
|
-
<span id="rw-auth-status" style="font-size:11px;color:#555">—</span>
|
|
182
|
-
</div>
|
|
183
|
-
<div>
|
|
184
|
-
<label>Path</label>
|
|
185
|
-
<div class="row">
|
|
186
|
-
<input id="rw-path" type="text" placeholder="users/alice/name">
|
|
187
|
-
<button onclick="doGet()">GET</button>
|
|
188
|
-
<button class="danger" onclick="doDelete()">DELETE</button>
|
|
189
|
-
</div>
|
|
190
|
-
</div>
|
|
191
|
-
<div>
|
|
192
|
-
<label>Value (JSON)</label>
|
|
193
|
-
<textarea id="rw-value" placeholder='"Alice" or { "name": "Alice", "age": 30 }'></textarea>
|
|
194
|
-
</div>
|
|
195
|
-
<div class="row"><button class="success" onclick="doSet()">SET</button><button onclick="doUpdate()">UPDATE</button></div>
|
|
196
|
-
<div id="rw-status"></div>
|
|
197
|
-
<div>
|
|
198
|
-
<label>Result</label>
|
|
199
|
-
<div class="result" id="rw-result">—</div>
|
|
200
|
-
</div>
|
|
201
|
-
<div style="margin-top:8px">
|
|
202
|
-
<label>Multi-path Update — JSON object mapping path → value</label>
|
|
203
|
-
<textarea id="upd-value" style="min-height:80px" placeholder='{ "users/alice/age": 31, "users/bob/role": "admin" }'></textarea>
|
|
204
|
-
<div class="row" style="margin-top:4px"><button onclick="doUpdateTab()">MULTI-UPDATE</button></div>
|
|
205
|
-
<div id="upd-status"></div>
|
|
206
|
-
</div>
|
|
207
|
-
<div style="margin-top:8px;border-top:1px solid #222;padding-top:8px">
|
|
208
|
-
<label>Push — append value with auto-generated key</label>
|
|
209
|
-
<div class="row">
|
|
210
|
-
<input id="push-path" type="text" placeholder="logs" style="flex:1">
|
|
211
|
-
<button class="success" onclick="doPush()">PUSH</button>
|
|
212
|
-
</div>
|
|
213
|
-
<textarea id="push-value" style="min-height:50px;margin-top:4px" placeholder='{ "level": "info", "msg": "hello" }'></textarea>
|
|
214
|
-
<div id="push-status"></div>
|
|
215
|
-
</div>
|
|
216
|
-
<div style="margin-top:8px;border-top:1px solid #222;padding-top:8px">
|
|
217
|
-
<label>Batch — atomic multi-op (JSON array of operations)</label>
|
|
218
|
-
<textarea id="batch-value" style="min-height:80px" placeholder='[
|
|
219
|
-
{ "op": "set", "path": "users/dave", "value": { "name": "Dave", "role": "user" } },
|
|
220
|
-
{ "op": "delete", "path": "settings/temp" },
|
|
221
|
-
{ "op": "push", "path": "logs", "value": { "msg": "batch op" } }
|
|
222
|
-
]'></textarea>
|
|
223
|
-
<div class="row" style="margin-top:4px"><button onclick="doBatch()">RUN BATCH</button></div>
|
|
224
|
-
<div id="batch-status"></div>
|
|
225
|
-
<div class="result" id="batch-result" style="margin-top:4px;display:none">—</div>
|
|
226
|
-
</div>
|
|
227
|
-
</div>
|
|
228
|
-
|
|
229
|
-
<!-- Query Panel -->
|
|
230
|
-
<div class="panel" id="panel-query">
|
|
231
|
-
<div>
|
|
232
|
-
<label>Base Path</label>
|
|
233
|
-
<input id="q-path" type="text" placeholder="users">
|
|
234
|
-
</div>
|
|
235
|
-
<div>
|
|
236
|
-
<label>Filters</label>
|
|
237
|
-
<div id="q-filters"></div>
|
|
238
|
-
<button onclick="addFilter()" style="margin-top:5px" class="sm">+ Filter</button>
|
|
239
|
-
</div>
|
|
240
|
-
<div class="row">
|
|
241
|
-
<div style="flex:2"><label>Order Field</label><input id="q-order-field" type="text" placeholder="name"></div>
|
|
242
|
-
<div style="flex:1"><label>Dir</label><select id="q-order-dir"><option value="asc">asc</option><option value="desc">desc</option></select></div>
|
|
243
|
-
<div style="flex:1"><label>Limit</label><input id="q-limit" type="text" placeholder="—"></div>
|
|
244
|
-
<div style="flex:1"><label>Offset</label><input id="q-offset" type="text" placeholder="—"></div>
|
|
245
|
-
</div>
|
|
246
|
-
<div><button onclick="doQuery()">RUN QUERY</button></div>
|
|
247
|
-
<div id="q-status"></div>
|
|
248
|
-
<div id="q-result"></div>
|
|
249
|
-
</div>
|
|
250
|
-
|
|
251
|
-
<!-- Subscriptions Panel -->
|
|
252
|
-
<div class="panel" id="panel-subs">
|
|
253
|
-
<div>
|
|
254
|
-
<label>Path to Subscribe</label>
|
|
255
|
-
<div class="row">
|
|
256
|
-
<input id="sub-path" type="text" placeholder="users/alice">
|
|
257
|
-
<button class="sub" onclick="doSubscribe('value')">VALUE</button>
|
|
258
|
-
<button class="sub" onclick="doSubscribe('child')" style="background:#4a3f6b">CHILD</button>
|
|
259
|
-
</div>
|
|
260
|
-
<div style="color:#555;font-size:11px;margin-top:4px">VALUE — fires on any change at/below path · CHILD — fires for direct child added/changed/removed</div>
|
|
261
|
-
</div>
|
|
262
|
-
<div id="sub-list" style="display:flex;flex-direction:column;gap:8px;margin-top:6px;"></div>
|
|
263
|
-
</div>
|
|
264
|
-
|
|
265
|
-
<!-- Auth & Rules Panel -->
|
|
266
|
-
<div class="panel" id="panel-auth">
|
|
267
|
-
<div style="display:flex;flex-direction:column;gap:12px">
|
|
268
|
-
<div>
|
|
269
|
-
<label>Authenticate</label>
|
|
270
|
-
<div class="row">
|
|
271
|
-
<input id="auth-token" type="text" placeholder='admin-secret or user:alice' style="flex:1">
|
|
272
|
-
<button onclick="doAuth()">AUTH</button>
|
|
273
|
-
<button class="danger" onclick="doDeauth()">CLEAR</button>
|
|
274
|
-
</div>
|
|
275
|
-
<div id="auth-status" style="margin-top:4px;font-size:12px;color:#555">Not authenticated — all open rules apply</div>
|
|
276
|
-
</div>
|
|
277
|
-
<div>
|
|
278
|
-
<label>Active Rules (server-configured)</label>
|
|
279
|
-
<div class="result" style="font-size:11px;line-height:1.6;white-space:pre" id="auth-rules">Loading…</div>
|
|
280
|
-
</div>
|
|
281
|
-
<div>
|
|
282
|
-
<label>Test Rule — check if current auth can read/write a path</label>
|
|
283
|
-
<div class="row">
|
|
284
|
-
<input id="auth-test-path" type="text" placeholder="users/alice" style="flex:1">
|
|
285
|
-
<select id="auth-test-op" style="width:90px"><option value="read">READ</option><option value="write">WRITE</option></select>
|
|
286
|
-
<button onclick="doTestRule()">TEST</button>
|
|
287
|
-
</div>
|
|
288
|
-
<div class="result" id="auth-test-result">—</div>
|
|
289
|
-
</div>
|
|
290
|
-
</div>
|
|
291
|
-
</div>
|
|
292
|
-
|
|
293
|
-
<!-- Advanced Panel (Transforms, TTL, FTS, Vectors) -->
|
|
294
|
-
<div class="panel" id="panel-advanced">
|
|
295
|
-
<div style="display:flex;flex-direction:column;gap:12px">
|
|
296
|
-
|
|
297
|
-
<!-- Transforms -->
|
|
298
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
299
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Transforms — apply atomic operations to paths</label>
|
|
300
|
-
<div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
|
|
301
|
-
<button class="sm" onclick="fillTransform('counters/likes','increment','1')">increment +1</button>
|
|
302
|
-
<button class="sm" onclick="fillTransform('users/alice/updatedAt','serverTimestamp','')">serverTimestamp</button>
|
|
303
|
-
<button class="sm" onclick="fillTransform('users/alice/tags','arrayUnion','["vip","active"]')">arrayUnion</button>
|
|
304
|
-
<button class="sm" onclick="fillTransform('users/alice/tags','arrayRemove','["active"]')">arrayRemove</button>
|
|
305
|
-
</div>
|
|
306
|
-
<div class="row">
|
|
307
|
-
<input id="tf-path" type="text" placeholder="counters/likes" style="flex:2">
|
|
308
|
-
<select id="tf-type" style="flex:1">
|
|
309
|
-
<option value="increment">increment</option>
|
|
310
|
-
<option value="serverTimestamp">serverTimestamp</option>
|
|
311
|
-
<option value="arrayUnion">arrayUnion</option>
|
|
312
|
-
<option value="arrayRemove">arrayRemove</option>
|
|
313
|
-
</select>
|
|
314
|
-
</div>
|
|
315
|
-
<div class="row" style="margin-top:4px">
|
|
316
|
-
<input id="tf-value" type="text" placeholder="value (number for increment, JSON array for array ops)" style="flex:1">
|
|
317
|
-
<button class="success" onclick="doTransform()">APPLY</button>
|
|
318
|
-
</div>
|
|
319
|
-
<div id="tf-status" style="margin-top:4px"></div>
|
|
320
|
-
<div class="result" id="tf-result" style="margin-top:4px;display:none">—</div>
|
|
321
|
-
</div>
|
|
322
|
-
|
|
323
|
-
<!-- TTL -->
|
|
324
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
325
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">TTL — set values with auto-expiry</label>
|
|
326
|
-
<div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
|
|
327
|
-
<button class="sm" onclick="fillTTL('sessions/temp','{"token":"abc123"}',60)">session 60s</button>
|
|
328
|
-
<button class="sm" onclick="fillTTL('cache/homepage','"cached html"',10)">cache 10s</button>
|
|
329
|
-
</div>
|
|
330
|
-
<div class="row">
|
|
331
|
-
<input id="ttl-path" type="text" placeholder="sessions/temp" style="flex:2">
|
|
332
|
-
<input id="ttl-seconds" type="number" placeholder="TTL (seconds)" value="60" style="width:120px">
|
|
333
|
-
</div>
|
|
334
|
-
<div style="margin-top:4px">
|
|
335
|
-
<textarea id="ttl-value" style="min-height:40px" placeholder='"session data" or { "token": "abc" }'></textarea>
|
|
336
|
-
</div>
|
|
337
|
-
<div class="row" style="margin-top:4px">
|
|
338
|
-
<button class="success" onclick="doSetTTL()">SET WITH TTL</button>
|
|
339
|
-
<button onclick="doSweep()">SWEEP NOW</button>
|
|
340
|
-
</div>
|
|
341
|
-
<div id="ttl-status" style="margin-top:4px"></div>
|
|
342
|
-
</div>
|
|
343
|
-
|
|
344
|
-
<!-- FTS -->
|
|
345
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
346
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Full-Text Search (FTS5)</label>
|
|
347
|
-
<div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
|
|
348
|
-
<button class="sm" onclick="fillFts('admin','users')">search "admin"</button>
|
|
349
|
-
<button class="sm" onclick="fillFts('design','')">search "design"</button>
|
|
350
|
-
<button class="sm" onclick="fillFtsIndex('posts/p1','A tutorial about building reactive databases')">index post</button>
|
|
351
|
-
</div>
|
|
352
|
-
<div class="row">
|
|
353
|
-
<input id="fts-path" type="text" placeholder="path to index" style="flex:1">
|
|
354
|
-
<input id="fts-content" type="text" placeholder="text content to index" style="flex:2">
|
|
355
|
-
<button onclick="doFtsIndex()">INDEX</button>
|
|
356
|
-
</div>
|
|
357
|
-
<div class="row" style="margin-top:4px">
|
|
358
|
-
<input id="fts-query" type="text" placeholder="search query" style="flex:2">
|
|
359
|
-
<input id="fts-prefix" type="text" placeholder="path prefix (optional)" style="flex:1">
|
|
360
|
-
<button class="success" onclick="doFtsSearch()">SEARCH</button>
|
|
361
|
-
</div>
|
|
362
|
-
<div id="fts-status" style="margin-top:4px"></div>
|
|
363
|
-
<div class="result" id="fts-result" style="margin-top:4px;display:none">—</div>
|
|
364
|
-
</div>
|
|
365
|
-
|
|
366
|
-
<!-- Vectors -->
|
|
367
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
368
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Vector Search (cosine similarity, 384d)</label>
|
|
369
|
-
<div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
|
|
370
|
-
<button class="sm" onclick="fillVecSearch('users')">search users</button>
|
|
371
|
-
<button class="sm" onclick="fillVecSearch('')">search all</button>
|
|
372
|
-
</div>
|
|
373
|
-
<div class="row">
|
|
374
|
-
<input id="vec-path" type="text" placeholder="path to store embedding" style="flex:1">
|
|
375
|
-
<input id="vec-embedding" type="text" placeholder="[0.1, 0.2, ...] (384 floats)" style="flex:2">
|
|
376
|
-
<button onclick="doVecStore()">STORE</button>
|
|
377
|
-
</div>
|
|
378
|
-
<div class="row" style="margin-top:4px">
|
|
379
|
-
<input id="vec-query" type="text" placeholder="query vector [0.1, 0.2, ...]" style="flex:2">
|
|
380
|
-
<input id="vec-prefix" type="text" placeholder="path prefix" style="flex:1">
|
|
381
|
-
<input id="vec-limit" type="number" value="5" style="width:60px" placeholder="limit">
|
|
382
|
-
<button class="success" onclick="doVecSearch()">SEARCH</button>
|
|
383
|
-
</div>
|
|
384
|
-
<div id="vec-status" style="margin-top:4px"></div>
|
|
385
|
-
<div class="result" id="vec-result" style="margin-top:4px;display:none">—</div>
|
|
386
|
-
</div>
|
|
387
|
-
|
|
388
|
-
</div>
|
|
389
|
-
</div>
|
|
390
|
-
|
|
391
|
-
<!-- Streams Panel -->
|
|
392
|
-
<div class="panel" id="panel-streams">
|
|
393
|
-
<div style="display:flex;flex-direction:column;gap:12px">
|
|
394
|
-
|
|
395
|
-
<!-- E2E Demo -->
|
|
396
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
397
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">E2E Demo — push events → consumer groups → ack → compact → materialize</label>
|
|
398
|
-
<div class="row">
|
|
399
|
-
<button class="success" onclick="doStreamDemo()">RUN DEMO</button>
|
|
400
|
-
</div>
|
|
401
|
-
<div id="st-demo-status" style="margin-top:4px"></div>
|
|
402
|
-
<div class="result" id="st-demo-result" style="margin-top:4px;display:none;max-height:400px;white-space:pre-wrap">—</div>
|
|
403
|
-
</div>
|
|
404
|
-
|
|
405
|
-
<!-- Push Event -->
|
|
406
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
407
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Push Event — append to a topic (idempotent push optional)</label>
|
|
408
|
-
<div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
|
|
409
|
-
<button class="sm" onclick="fillStreamPush('events/orders','{"orderId":"o99","amount":250}','')">push order</button>
|
|
410
|
-
<button class="sm" onclick="fillStreamPush('events/orders','{"orderId":"o99","amount":250}','order-o99')">idempotent push</button>
|
|
411
|
-
</div>
|
|
412
|
-
<div class="row">
|
|
413
|
-
<input id="st-push-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
|
|
414
|
-
<input id="st-push-idem" type="text" placeholder="idempotency key (optional)" style="flex:1">
|
|
415
|
-
</div>
|
|
416
|
-
<textarea id="st-push-value" style="min-height:40px;margin-top:4px" placeholder='{ "orderId": "o99", "amount": 250 }'>{ "orderId": "o99", "amount": 250 }</textarea>
|
|
417
|
-
<div class="row" style="margin-top:4px">
|
|
418
|
-
<button class="success" onclick="doStreamPush()">PUSH</button>
|
|
419
|
-
</div>
|
|
420
|
-
<div id="st-push-status" style="margin-top:4px"></div>
|
|
421
|
-
</div>
|
|
422
|
-
|
|
423
|
-
<!-- Read Stream -->
|
|
424
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
425
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Read — fetch unprocessed events for a consumer group</label>
|
|
426
|
-
<div class="row">
|
|
427
|
-
<input id="st-read-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
|
|
428
|
-
<input id="st-read-group" type="text" placeholder="group ID" value="admin-ui" style="flex:1">
|
|
429
|
-
<input id="st-read-limit" type="number" value="50" style="width:70px" placeholder="limit">
|
|
430
|
-
<button onclick="doStreamRead()">READ</button>
|
|
431
|
-
</div>
|
|
432
|
-
<div id="st-read-status" style="margin-top:4px"></div>
|
|
433
|
-
<div class="result" id="st-read-result" style="margin-top:4px;display:none;max-height:300px">—</div>
|
|
434
|
-
</div>
|
|
435
|
-
|
|
436
|
-
<!-- Ack -->
|
|
437
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
438
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Ack — commit offset for a consumer group</label>
|
|
439
|
-
<div class="row">
|
|
440
|
-
<input id="st-ack-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
|
|
441
|
-
<input id="st-ack-group" type="text" placeholder="group ID" value="admin-ui" style="flex:1">
|
|
442
|
-
<input id="st-ack-key" type="text" placeholder="event key to ack" style="flex:2">
|
|
443
|
-
<button onclick="doStreamAck()">ACK</button>
|
|
444
|
-
</div>
|
|
445
|
-
<div id="st-ack-status" style="margin-top:4px"></div>
|
|
446
|
-
</div>
|
|
447
|
-
|
|
448
|
-
<!-- Compact -->
|
|
449
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
450
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Compact — remove old/excess events (safe: respects consumer offsets)</label>
|
|
451
|
-
<div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
|
|
452
|
-
<button class="sm" onclick="fillCompact('events/orders','','100','')">keep 100</button>
|
|
453
|
-
<button class="sm" onclick="fillCompact('events/orders','3600','','')">max 1h</button>
|
|
454
|
-
<button class="sm" onclick="fillCompact('events/orders','','','orderId')">keepKey: orderId</button>
|
|
455
|
-
</div>
|
|
456
|
-
<div class="row">
|
|
457
|
-
<input id="st-compact-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
|
|
458
|
-
<input id="st-compact-age" type="number" placeholder="maxAge (sec)" style="width:110px">
|
|
459
|
-
<input id="st-compact-count" type="number" placeholder="maxCount" style="width:100px">
|
|
460
|
-
<input id="st-compact-key" type="text" placeholder="keepKey field" style="flex:1">
|
|
461
|
-
<button class="danger" onclick="doStreamCompact()">COMPACT</button>
|
|
462
|
-
<button class="danger" onclick="doStreamReset()" title="Delete all events, snapshot, and offsets">RESET</button>
|
|
463
|
-
</div>
|
|
464
|
-
<div id="st-compact-status" style="margin-top:4px"></div>
|
|
465
|
-
</div>
|
|
466
|
-
|
|
467
|
-
<!-- Snapshot & Materialize -->
|
|
468
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
469
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Snapshot & Materialize — view base state and merged view</label>
|
|
470
|
-
<div class="row">
|
|
471
|
-
<input id="st-snap-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
|
|
472
|
-
<input id="st-snap-keepkey" type="text" placeholder="keepKey (for materialize)" style="flex:1">
|
|
473
|
-
<button onclick="doStreamSnapshot()">SNAPSHOT</button>
|
|
474
|
-
<button class="success" onclick="doStreamMaterialize()">MATERIALIZE</button>
|
|
475
|
-
</div>
|
|
476
|
-
<div id="st-snap-status" style="margin-top:4px"></div>
|
|
477
|
-
<div class="result" id="st-snap-result" style="margin-top:4px;display:none;max-height:300px">—</div>
|
|
478
|
-
</div>
|
|
479
|
-
|
|
480
|
-
<!-- Stream Subscribe -->
|
|
481
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
482
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Subscribe — live stream with replay from last offset</label>
|
|
483
|
-
<div class="row">
|
|
484
|
-
<input id="st-sub-path" type="text" placeholder="events/orders" value="events/orders" style="flex:2">
|
|
485
|
-
<input id="st-sub-group" type="text" placeholder="group ID" value="admin-live" style="flex:1">
|
|
486
|
-
<button class="sub" onclick="doStreamSub()">SUBSCRIBE</button>
|
|
487
|
-
</div>
|
|
488
|
-
<div id="st-sub-list" style="display:flex;flex-direction:column;gap:8px;margin-top:6px;"></div>
|
|
489
|
-
</div>
|
|
490
|
-
|
|
491
|
-
</div>
|
|
492
|
-
</div>
|
|
493
|
-
|
|
494
|
-
<!-- MQ Panel -->
|
|
495
|
-
<div class="panel" id="panel-mq">
|
|
496
|
-
<div style="display:flex;flex-direction:column;gap:12px">
|
|
497
|
-
|
|
498
|
-
<!-- E2E Demo -->
|
|
499
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
500
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">E2E Demo — full worker lifecycle: push → fetch → ack/nack → retry → DLQ</label>
|
|
501
|
-
<div class="row">
|
|
502
|
-
<button class="success" onclick="doMqDemo()">RUN DEMO</button>
|
|
503
|
-
</div>
|
|
504
|
-
<div id="mq-demo-status" style="margin-top:4px"></div>
|
|
505
|
-
<div class="result" id="mq-demo-result" style="margin-top:4px;display:none;max-height:400px;white-space:pre-wrap">—</div>
|
|
506
|
-
</div>
|
|
507
|
-
|
|
508
|
-
<!-- Push Job -->
|
|
509
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
510
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Push Job — enqueue a message (SQS-style, exactly-once delivery)</label>
|
|
511
|
-
<div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
|
|
512
|
-
<button class="sm" onclick="fillMqPush('queues/jobs','{"type":"email","to":"user@example.com"}','')">email job</button>
|
|
513
|
-
<button class="sm" onclick="fillMqPush('queues/jobs','{"type":"sms","to":"+1234567890"}','sms-123')">idempotent sms</button>
|
|
514
|
-
</div>
|
|
515
|
-
<div class="row">
|
|
516
|
-
<input id="mq-push-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
|
|
517
|
-
<input id="mq-push-idem" type="text" placeholder="idempotency key (optional)" style="flex:1">
|
|
518
|
-
</div>
|
|
519
|
-
<textarea id="mq-push-value" style="min-height:40px;margin-top:4px" placeholder='{ "type": "email", "to": "user@example.com" }'>{ "type": "email", "to": "user@example.com" }</textarea>
|
|
520
|
-
<div class="row" style="margin-top:4px">
|
|
521
|
-
<button class="success" onclick="doMqPush()">PUSH</button>
|
|
522
|
-
</div>
|
|
523
|
-
<div id="mq-push-status" style="margin-top:4px"></div>
|
|
524
|
-
</div>
|
|
525
|
-
|
|
526
|
-
<!-- Fetch -->
|
|
527
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
528
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Fetch — claim messages (visibility timeout applies)</label>
|
|
529
|
-
<div class="row">
|
|
530
|
-
<input id="mq-fetch-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
|
|
531
|
-
<input id="mq-fetch-count" type="number" value="1" style="width:70px" placeholder="count">
|
|
532
|
-
<button onclick="doMqFetch()">FETCH</button>
|
|
533
|
-
</div>
|
|
534
|
-
<div id="mq-fetch-status" style="margin-top:4px"></div>
|
|
535
|
-
<div class="result" id="mq-fetch-result" style="margin-top:4px;display:none;max-height:300px">—</div>
|
|
536
|
-
</div>
|
|
537
|
-
|
|
538
|
-
<!-- Ack / Nack -->
|
|
539
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
540
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Ack / Nack — confirm processing or release back to queue</label>
|
|
541
|
-
<div class="row">
|
|
542
|
-
<input id="mq-ack-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
|
|
543
|
-
<input id="mq-ack-key" type="text" placeholder="message key" style="flex:2">
|
|
544
|
-
<button class="danger" onclick="doMqAck()">ACK</button>
|
|
545
|
-
<button onclick="doMqNack()">NACK</button>
|
|
546
|
-
</div>
|
|
547
|
-
<div id="mq-ack-status" style="margin-top:4px"></div>
|
|
548
|
-
</div>
|
|
549
|
-
|
|
550
|
-
<!-- Peek -->
|
|
551
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
552
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Peek — view messages without claiming</label>
|
|
553
|
-
<div class="row">
|
|
554
|
-
<input id="mq-peek-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
|
|
555
|
-
<input id="mq-peek-count" type="number" value="10" style="width:70px" placeholder="count">
|
|
556
|
-
<button onclick="doMqPeek()">PEEK</button>
|
|
557
|
-
</div>
|
|
558
|
-
<div id="mq-peek-status" style="margin-top:4px"></div>
|
|
559
|
-
<div class="result" id="mq-peek-result" style="margin-top:4px;display:none;max-height:300px">—</div>
|
|
560
|
-
</div>
|
|
561
|
-
|
|
562
|
-
<!-- DLQ -->
|
|
563
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
564
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Dead Letter Queue — messages that exceeded max deliveries</label>
|
|
565
|
-
<div class="row">
|
|
566
|
-
<input id="mq-dlq-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
|
|
567
|
-
<button onclick="doMqDlq()">VIEW DLQ</button>
|
|
568
|
-
</div>
|
|
569
|
-
<div id="mq-dlq-status" style="margin-top:4px"></div>
|
|
570
|
-
<div class="result" id="mq-dlq-result" style="margin-top:4px;display:none;max-height:300px">—</div>
|
|
571
|
-
</div>
|
|
572
|
-
|
|
573
|
-
<!-- Purge -->
|
|
574
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
575
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Purge — delete all pending messages in a queue</label>
|
|
576
|
-
<div class="row">
|
|
577
|
-
<input id="mq-purge-path" type="text" placeholder="queues/jobs" value="queues/jobs" style="flex:2">
|
|
578
|
-
<button class="danger" onclick="if(confirm('Purge all pending messages?'))doMqPurge()">PURGE</button>
|
|
579
|
-
</div>
|
|
580
|
-
<div id="mq-purge-status" style="margin-top:4px"></div>
|
|
581
|
-
</div>
|
|
582
|
-
|
|
583
|
-
</div>
|
|
584
|
-
</div>
|
|
585
|
-
|
|
586
|
-
<!-- Replication Panel -->
|
|
587
|
-
<div class="panel" id="panel-repl">
|
|
588
|
-
<div style="display:flex;flex-direction:column;gap:12px">
|
|
589
|
-
|
|
590
|
-
<!-- Status -->
|
|
591
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
592
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Source Feed Status — paths synced from remote BodDB instances</label>
|
|
593
|
-
<div class="row" style="margin-bottom:6px">
|
|
594
|
-
<button onclick="loadRepl()">REFRESH</button>
|
|
595
|
-
</div>
|
|
596
|
-
<div id="repl-status" style="margin-top:4px"></div>
|
|
597
|
-
<div class="result" id="repl-config" style="margin-top:4px;display:none;max-height:300px">—</div>
|
|
598
|
-
</div>
|
|
599
|
-
|
|
600
|
-
<!-- Synced Data -->
|
|
601
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
602
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Synced Data — local copy of source paths (read-only mirror)</label>
|
|
603
|
-
<div class="result" id="repl-synced" style="max-height:400px;display:none">—</div>
|
|
604
|
-
</div>
|
|
605
|
-
|
|
606
|
-
<!-- Write to Source (demo) -->
|
|
607
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
608
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Write to Source — modify remote data and watch it sync locally</label>
|
|
609
|
-
<div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
|
|
610
|
-
<button class="sm" onclick="fillReplWrite('catalog/widgets','{"name":"Widget A","price":34.99,"stock":200}')">update widget price</button>
|
|
611
|
-
<button class="sm" onclick="fillReplWrite('catalog/new-item','{"name":"New Item","price":9.99,"stock":50}')">add item</button>
|
|
612
|
-
<button class="sm" onclick="fillReplWrite('alerts/sys-3','{"level":"error","msg":"Disk full","ts":' + Date.now() + '}')">add alert</button>
|
|
613
|
-
</div>
|
|
614
|
-
<div class="row">
|
|
615
|
-
<input id="repl-write-path" type="text" placeholder="catalog/widgets" value="catalog/widgets" style="flex:2">
|
|
616
|
-
</div>
|
|
617
|
-
<textarea id="repl-write-value" style="min-height:40px;margin-top:4px" placeholder='{ "name": "Widget A", "price": 34.99 }'>{ "name": "Widget A", "price": 34.99, "stock": 200 }</textarea>
|
|
618
|
-
<div class="row" style="margin-top:4px">
|
|
619
|
-
<button class="success" onclick="doReplWrite()">WRITE TO SOURCE</button>
|
|
620
|
-
<button class="danger" onclick="doReplDelete()">DELETE ON SOURCE</button>
|
|
621
|
-
</div>
|
|
622
|
-
<div id="repl-write-status" style="margin-top:4px"></div>
|
|
623
|
-
</div>
|
|
624
|
-
|
|
625
|
-
</div>
|
|
626
|
-
</div>
|
|
627
|
-
|
|
628
|
-
<!-- VFS Panel -->
|
|
629
|
-
<div class="panel" id="panel-vfs">
|
|
630
|
-
<!-- Explorer toolbar -->
|
|
631
|
-
<div id="vfs-toolbar" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
|
|
632
|
-
<div id="vfs-breadcrumb" style="flex:1;font-size:13px;color:#ccc;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></div>
|
|
633
|
-
<input id="vfs-upload-file" type="file" style="display:none" onchange="vfsUploadHere()">
|
|
634
|
-
<button class="success sm" onclick="document.getElementById('vfs-upload-file').click()">Upload</button>
|
|
635
|
-
<button class="sm" onclick="vfsSyncFolder()">Sync Folder</button>
|
|
636
|
-
<button class="sm" onclick="vfsMkdirHere()">New Folder</button>
|
|
637
|
-
<button class="sm icon" onclick="vfsNavigate(vfsPath)" title="Reload"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg></button>
|
|
638
|
-
</div>
|
|
639
|
-
<!-- File table -->
|
|
640
|
-
<div id="vfs-table" style="border:1px solid #222;border-radius:3px;overflow:auto;max-height:320px;font-size:12px">
|
|
641
|
-
<div style="padding:20px;text-align:center;color:#555">Loading…</div>
|
|
642
|
-
</div>
|
|
643
|
-
<div id="vfs-explorer-status" style="margin-top:4px"></div>
|
|
644
|
-
<!-- Preview panel -->
|
|
645
|
-
<div id="vfs-preview" style="display:none;margin-top:6px;border:1px solid #222;border-radius:3px;overflow:hidden">
|
|
646
|
-
<div id="vfs-preview-header" style="padding:6px 10px;border-bottom:1px solid #222;display:flex;align-items:center;gap:8px;font-size:12px">
|
|
647
|
-
<span id="vfs-preview-name" style="color:#569cd6;font-weight:bold"></span>
|
|
648
|
-
<span id="vfs-preview-info" style="color:#888"></span>
|
|
649
|
-
<span style="flex:1"></span>
|
|
650
|
-
<button class="sm" onclick="vfsDownload()">Download</button>
|
|
651
|
-
<button class="sm" onclick="vfsRename()">Rename</button>
|
|
652
|
-
<button class="danger sm" onclick="if(confirm('Delete?'))vfsDeleteSel()">Delete</button>
|
|
653
|
-
<button class="sm" onclick="document.getElementById('vfs-preview').style.display='none'">✕</button>
|
|
654
|
-
</div>
|
|
655
|
-
<div id="vfs-preview-body" style="padding:10px;max-height:250px;overflow:auto;font-size:12px;color:#ccc;white-space:pre-wrap;font-family:monospace"></div>
|
|
656
|
-
</div>
|
|
657
|
-
<!-- Advanced: original form-based ops -->
|
|
658
|
-
<details style="margin-top:12px">
|
|
659
|
-
<summary style="cursor:pointer;color:#888;font-size:11px">Advanced (raw API)</summary>
|
|
660
|
-
<div style="display:flex;flex-direction:column;gap:12px;margin-top:8px">
|
|
661
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
662
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Upload File — POST binary to /files/<path></label>
|
|
663
|
-
<div class="row">
|
|
664
|
-
<input id="vfs-upload-path" type="text" placeholder="docs/readme.txt" style="flex:2">
|
|
665
|
-
<input id="vfs-adv-upload-file" type="file" style="flex:2" onchange="if(this.files[0])document.getElementById('vfs-upload-path').value=this.files[0].name">
|
|
666
|
-
<button class="success" onclick="doVfsUpload()">UPLOAD</button>
|
|
667
|
-
</div>
|
|
668
|
-
<div id="vfs-upload-status" style="margin-top:4px"></div>
|
|
669
|
-
</div>
|
|
670
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
671
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Download File</label>
|
|
672
|
-
<div class="row">
|
|
673
|
-
<input id="vfs-download-path" type="text" placeholder="docs/readme.txt" style="flex:2">
|
|
674
|
-
<button onclick="doVfsDownload()">DOWNLOAD</button>
|
|
675
|
-
</div>
|
|
676
|
-
<div id="vfs-download-status" style="margin-top:4px"></div>
|
|
677
|
-
</div>
|
|
678
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
679
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Stat</label>
|
|
680
|
-
<div class="row">
|
|
681
|
-
<input id="vfs-stat-path" type="text" placeholder="docs/readme.txt" style="flex:2">
|
|
682
|
-
<button onclick="doVfsStat()">STAT</button>
|
|
683
|
-
</div>
|
|
684
|
-
<div id="vfs-stat-status" style="margin-top:4px"></div>
|
|
685
|
-
<div class="result" id="vfs-stat-result" style="margin-top:4px;display:none;max-height:200px">—</div>
|
|
686
|
-
</div>
|
|
687
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
688
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">List Directory</label>
|
|
689
|
-
<div class="row">
|
|
690
|
-
<input id="vfs-list-path" type="text" placeholder="docs" style="flex:2">
|
|
691
|
-
<button onclick="doVfsList()">LIST</button>
|
|
692
|
-
</div>
|
|
693
|
-
<div id="vfs-list-status" style="margin-top:4px"></div>
|
|
694
|
-
<div class="result" id="vfs-list-result" style="margin-top:4px;display:none;max-height:300px">—</div>
|
|
695
|
-
</div>
|
|
696
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
697
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Mkdir</label>
|
|
698
|
-
<div class="row">
|
|
699
|
-
<input id="vfs-mkdir-path" type="text" placeholder="docs/drafts" style="flex:2">
|
|
700
|
-
<button class="success" onclick="doVfsMkdir()">MKDIR</button>
|
|
701
|
-
</div>
|
|
702
|
-
<div id="vfs-mkdir-status" style="margin-top:4px"></div>
|
|
703
|
-
</div>
|
|
704
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
705
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Move / Rename</label>
|
|
706
|
-
<div class="row">
|
|
707
|
-
<input id="vfs-move-src" type="text" placeholder="docs/old.txt" style="flex:2">
|
|
708
|
-
<span style="color:#555">→</span>
|
|
709
|
-
<input id="vfs-move-dst" type="text" placeholder="docs/new.txt" style="flex:2">
|
|
710
|
-
<button onclick="doVfsMove()">MOVE</button>
|
|
711
|
-
</div>
|
|
712
|
-
<div id="vfs-move-status" style="margin-top:4px"></div>
|
|
713
|
-
</div>
|
|
714
|
-
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
715
|
-
<label style="font-size:11px;color:#888;margin-bottom:6px">Delete</label>
|
|
716
|
-
<div class="row">
|
|
717
|
-
<input id="vfs-delete-path" type="text" placeholder="docs/readme.txt" style="flex:2">
|
|
718
|
-
<button class="danger" onclick="if(confirm('Delete this file?'))doVfsDelete()">DELETE</button>
|
|
719
|
-
</div>
|
|
720
|
-
<div id="vfs-delete-status" style="margin-top:4px"></div>
|
|
721
|
-
</div>
|
|
722
|
-
</div>
|
|
723
|
-
</details>
|
|
724
|
-
</div>
|
|
725
|
-
|
|
726
|
-
<div class="panel" id="panel-cache">
|
|
727
|
-
<h3 style="color:#569cd6;margin-bottom:8px">BodClientCached Demo</h3>
|
|
728
|
-
<p style="color:#666;font-size:11px;margin-bottom:10px">Two-tier cache (memory + IndexedDB) with stale-while-revalidate. Wraps BodClient for instant reads.</p>
|
|
729
|
-
|
|
730
|
-
<div style="display:flex;gap:6px;margin-bottom:10px">
|
|
731
|
-
<div style="flex:1">
|
|
732
|
-
<label>Path</label>
|
|
733
|
-
<input type="text" id="cache-path" value="cache/demo" placeholder="path">
|
|
734
|
-
</div>
|
|
735
|
-
<div style="flex:1">
|
|
736
|
-
<label>Value (JSON)</label>
|
|
737
|
-
<input type="text" id="cache-value" value='{"name":"Alice","score":42}' placeholder="value">
|
|
738
|
-
</div>
|
|
739
|
-
</div>
|
|
740
|
-
|
|
741
|
-
<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap">
|
|
742
|
-
<button onclick="cacheSet()">Set (invalidates cache)</button>
|
|
743
|
-
<button onclick="cacheGet()">Get (cache-first)</button>
|
|
744
|
-
<button onclick="cacheGetFresh()">Get Fresh (network)</button>
|
|
745
|
-
<button class="success" onclick="cacheSub()">Subscribe (keeps fresh)</button>
|
|
746
|
-
<button class="danger" onclick="cacheUnsub()">Unsubscribe</button>
|
|
747
|
-
<button onclick="cacheStats()">Cache Stats</button>
|
|
748
|
-
</div>
|
|
749
|
-
|
|
750
|
-
<div id="cache-result" class="result" style="min-height:120px">Cache demo — set a value, then get it (served from cache on second read).</div>
|
|
751
|
-
<div id="cache-log" class="result" style="min-height:80px;margin-top:8px;color:#888;font-size:11px">Event log...</div>
|
|
752
|
-
</div>
|
|
753
|
-
|
|
754
|
-
<!-- KeyAuth Panel -->
|
|
755
|
-
<div class="panel" id="panel-keyauth">
|
|
756
|
-
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
|
757
|
-
<!-- LEFT: Accounts + Auth Flows -->
|
|
758
|
-
<div style="flex:2;min-width:400px">
|
|
759
|
-
<label>Accounts</label>
|
|
760
|
-
<div id="ka-accounts" style="margin-bottom:8px">Loading...</div>
|
|
761
|
-
<div class="row" style="margin-bottom:6px">
|
|
762
|
-
<input type="text" id="ka-new-pw" placeholder="Password" value="demo123">
|
|
763
|
-
<input type="text" id="ka-new-name" placeholder="Display name">
|
|
764
|
-
<input type="text" id="ka-new-roles" placeholder="Roles (comma-sep)">
|
|
765
|
-
<button onclick="kaCreateAccount()">+ Account</button>
|
|
766
|
-
<button onclick="kaRegisterDevice()">+ Device</button>
|
|
767
|
-
</div>
|
|
768
|
-
<!-- Auth Flows -->
|
|
769
|
-
<div style="margin-top:10px;border:1px solid #444;border-radius:6px;padding:10px">
|
|
770
|
-
<label>Auth Flows</label>
|
|
771
|
-
<!-- 1. Challenge-Response (password) -->
|
|
772
|
-
<div style="margin-bottom:8px">
|
|
773
|
-
<b>1. Challenge-Response (password)</b>
|
|
774
|
-
<div class="row" style="margin-top:4px">
|
|
775
|
-
<select id="ka-auth-fp" style="flex:1"><option value="">— select account —</option></select>
|
|
776
|
-
<input type="text" id="ka-auth-pw" placeholder="Password" value="demo123">
|
|
777
|
-
<button class="success" onclick="kaChallengeResponse()">Authenticate</button>
|
|
778
|
-
</div>
|
|
779
|
-
</div>
|
|
780
|
-
<!-- 2. Device Auth (keypair) -->
|
|
781
|
-
<div style="margin-bottom:8px">
|
|
782
|
-
<b>2. Device Auth (keypair)</b>
|
|
783
|
-
<div class="row" style="margin-top:4px">
|
|
784
|
-
<button onclick="kaDeviceAuth()">Authenticate with Stored Keypair</button>
|
|
785
|
-
</div>
|
|
786
|
-
</div>
|
|
787
|
-
<!-- 3. QR Cross-Device Auth -->
|
|
788
|
-
<div>
|
|
789
|
-
<b>3. QR Cross-Device Auth</b>
|
|
790
|
-
<div class="row" style="margin-top:4px">
|
|
791
|
-
<button onclick="kaShowQR()">Show QR (new device)</button>
|
|
792
|
-
<input type="text" id="ka-qr-request-id" placeholder="Request ID to approve">
|
|
793
|
-
<button class="success" onclick="kaApproveQR()">Approve (signed-in device)</button>
|
|
794
|
-
</div>
|
|
795
|
-
<div id="ka-qr-display" style="display:none;margin-top:6px;padding:8px;background:#1a1a2e;border-radius:4px;font-family:monospace;font-size:12px"></div>
|
|
796
|
-
</div>
|
|
797
|
-
<div id="ka-auth-result" class="result" style="min-height:40px;margin-top:6px">Select an auth flow above</div>
|
|
798
|
-
</div>
|
|
799
|
-
</div>
|
|
800
|
-
<!-- RIGHT: Roles & IAM + Server -->
|
|
801
|
-
<div style="flex:1;min-width:280px">
|
|
802
|
-
<label>Roles & IAM</label>
|
|
803
|
-
<div id="ka-roles" class="result" style="min-height:80px;margin-bottom:8px">Loading...</div>
|
|
804
|
-
<div class="row" style="margin-bottom:6px">
|
|
805
|
-
<input type="text" id="ka-role-id" placeholder="Role ID" value="editor">
|
|
806
|
-
<input type="text" id="ka-role-name" placeholder="Name" value="Editor">
|
|
807
|
-
<button onclick="kaCreateRole()">+ Role</button>
|
|
808
|
-
</div>
|
|
809
|
-
<div class="row" style="margin-bottom:8px">
|
|
810
|
-
<input type="text" id="ka-perm-path" placeholder="Path pattern" value="posts/$postId">
|
|
811
|
-
<label style="display:inline;margin:0"><input type="checkbox" id="ka-perm-read" checked> R</label>
|
|
812
|
-
<label style="display:inline;margin:0"><input type="checkbox" id="ka-perm-write" checked> W</label>
|
|
813
|
-
<button class="sm" onclick="kaAddPermission()">+ Perm</button>
|
|
814
|
-
</div>
|
|
815
|
-
<!-- Assign Roles -->
|
|
816
|
-
<div style="border:1px solid #444;border-radius:6px;padding:8px;margin-bottom:8px">
|
|
817
|
-
<label>Assign Roles</label>
|
|
818
|
-
<div class="row" style="margin-top:4px">
|
|
819
|
-
<select id="ka-assign-account" style="flex:1"></select>
|
|
820
|
-
<input type="text" id="ka-assign-roles" placeholder="Roles (comma-sep)">
|
|
821
|
-
<button onclick="kaUpdateRoles()">Update</button>
|
|
822
|
-
</div>
|
|
823
|
-
</div>
|
|
824
|
-
<!-- Server Info -->
|
|
825
|
-
<label>Server Info</label>
|
|
826
|
-
<div id="ka-server" class="result" style="min-height:60px">Loading...</div>
|
|
827
|
-
</div>
|
|
828
|
-
</div>
|
|
829
|
-
<div id="ka-status" class="status" style="margin-top:6px"></div>
|
|
830
|
-
</div>
|
|
831
|
-
|
|
832
|
-
<!-- Stress Tests Panel -->
|
|
833
|
-
<div class="panel" id="panel-stress">
|
|
834
|
-
<div class="stress-grid">
|
|
835
|
-
<div class="stress-card">
|
|
836
|
-
<h4>Sequential Writes</h4>
|
|
837
|
-
<div class="row" style="align-items:center;gap:8px">
|
|
838
|
-
<label style="margin:0">N:</label>
|
|
839
|
-
<input type="number" id="sw-n" value="1000" min="10" max="100000" style="width:90px">
|
|
840
|
-
<button onclick="runSeqWrites()">Run</button>
|
|
841
|
-
</div>
|
|
842
|
-
<div class="progress-bar"><div class="progress-fill" id="sw-prog"></div></div>
|
|
843
|
-
<div class="stress-result" id="sw-result">—</div>
|
|
844
|
-
</div>
|
|
845
|
-
<div class="stress-card">
|
|
846
|
-
<h4>Burst Reads</h4>
|
|
847
|
-
<div class="row" style="align-items:center;gap:8px">
|
|
848
|
-
<label style="margin:0">N:</label>
|
|
849
|
-
<input type="number" id="br-n" value="2000" min="10" max="100000" style="width:90px">
|
|
850
|
-
<button onclick="runBurstReads()">Run</button>
|
|
851
|
-
</div>
|
|
852
|
-
<div class="progress-bar"><div class="progress-fill" id="br-prog"></div></div>
|
|
853
|
-
<div class="stress-result" id="br-result">—</div>
|
|
854
|
-
</div>
|
|
855
|
-
<div class="stress-card">
|
|
856
|
-
<h4>Mixed Read/Write</h4>
|
|
857
|
-
<div class="row" style="align-items:center;gap:8px">
|
|
858
|
-
<label style="margin:0">N:</label>
|
|
859
|
-
<input type="number" id="mw-n" value="500" min="10" max="50000" style="width:90px">
|
|
860
|
-
<label style="margin:0">W%:</label>
|
|
861
|
-
<input type="number" id="mw-wp" value="30" min="0" max="100" style="width:60px">
|
|
862
|
-
<button onclick="runMixed()">Run</button>
|
|
863
|
-
</div>
|
|
864
|
-
<div class="progress-bar"><div class="progress-fill" id="mw-prog"></div></div>
|
|
865
|
-
<div class="stress-result" id="mw-result">—</div>
|
|
866
|
-
</div>
|
|
867
|
-
<div class="stress-card">
|
|
868
|
-
<h4>Query Under Load</h4>
|
|
869
|
-
<div class="row" style="align-items:center;gap:8px">
|
|
870
|
-
<label style="margin:0">Records:</label>
|
|
871
|
-
<input type="number" id="ql-n" value="500" min="10" max="10000" style="width:80px">
|
|
872
|
-
<button onclick="runQueryLoad()">Run</button>
|
|
873
|
-
</div>
|
|
874
|
-
<div class="progress-bar"><div class="progress-fill" id="ql-prog"></div></div>
|
|
875
|
-
<div class="stress-result" id="ql-result">—</div>
|
|
876
|
-
</div>
|
|
877
|
-
<div class="stress-card">
|
|
878
|
-
<h4>Bulk Update</h4>
|
|
879
|
-
<div class="row" style="align-items:center;gap:8px">
|
|
880
|
-
<label style="margin:0">Keys/batch:</label>
|
|
881
|
-
<input type="number" id="bu-n" value="100" min="5" max="5000" style="width:80px">
|
|
882
|
-
<label style="margin:0">Batches:</label>
|
|
883
|
-
<input type="number" id="bu-b" value="20" min="1" max="500" style="width:60px">
|
|
884
|
-
<button onclick="runBulkUpdate()">Run</button>
|
|
885
|
-
</div>
|
|
886
|
-
<div class="progress-bar"><div class="progress-fill" id="bu-prog"></div></div>
|
|
887
|
-
<div class="stress-result" id="bu-result">—</div>
|
|
888
|
-
</div>
|
|
889
|
-
<div class="stress-card">
|
|
890
|
-
<h4>Deep Path Writes</h4>
|
|
891
|
-
<div class="row" style="align-items:center;gap:8px">
|
|
892
|
-
<label style="margin:0">N:</label>
|
|
893
|
-
<input type="number" id="dp-n" value="200" min="10" max="5000" style="width:80px">
|
|
894
|
-
<label style="margin:0">Depth:</label>
|
|
895
|
-
<input type="number" id="dp-d" value="6" min="2" max="12" style="width:60px">
|
|
896
|
-
<button onclick="runDeepPaths()">Run</button>
|
|
897
|
-
</div>
|
|
898
|
-
<div class="progress-bar"><div class="progress-fill" id="dp-prog"></div></div>
|
|
899
|
-
<div class="stress-result" id="dp-result">—</div>
|
|
900
|
-
</div>
|
|
901
|
-
</div>
|
|
902
|
-
</div>
|
|
903
|
-
|
|
904
|
-
<!-- View Panel -->
|
|
905
|
-
<div class="panel" id="panel-view">
|
|
906
|
-
<div style="display:flex;flex-direction:column;gap:10px;height:100%">
|
|
907
|
-
<div style="display:flex;align-items:center;gap:10px">
|
|
908
|
-
<span style="color:#888;font-size:12px">Path:</span>
|
|
909
|
-
<span id="view-path" style="color:#4ec9b0;font-family:monospace;font-size:13px">—</span>
|
|
910
|
-
<span id="view-time" style="color:#555;font-size:11px"></span>
|
|
911
|
-
</div>
|
|
912
|
-
<pre id="view-result" style="flex:1;margin:0;padding:12px;background:#0a0a0a;border:1px solid #2a2a2a;border-radius:4px;overflow:auto;color:#d4d4d4;font-size:13px;white-space:pre-wrap;word-break:break-all">Select a node in the tree to view its data.</pre>
|
|
913
|
-
</div>
|
|
914
|
-
</div>
|
|
915
|
-
</div>
|
|
916
|
-
</div>
|
|
917
|
-
|
|
918
|
-
<script>
|
|
919
|
-
// ── Logger ─────────────────────────────────────────────────────────────────────
|
|
920
|
-
const LEVEL_STYLE = {
|
|
921
|
-
debug: 'color:#666',
|
|
922
|
-
info: 'color:#4ec9b0',
|
|
923
|
-
warn: 'color:#ce9178',
|
|
924
|
-
error: 'color:#f48771;font-weight:bold',
|
|
925
|
-
};
|
|
926
|
-
|
|
927
|
-
// Aggregates rapid repeated debug messages into a collapsed group with a count
|
|
928
|
-
const _agg = { key: null, count: 0, paths: [], timer: null };
|
|
929
|
-
function _flushAgg() {
|
|
930
|
-
if (!_agg.count) return;
|
|
931
|
-
console.groupCollapsed(`%c[DEBUG] ${_agg.key} ×${_agg.count}`, 'color:#666');
|
|
932
|
-
_agg.paths.forEach(p => console.debug('%c ' + p, 'color:#555'));
|
|
933
|
-
console.groupEnd();
|
|
934
|
-
_agg.key = null; _agg.count = 0; _agg.paths = [];
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
function log(level, ...args) {
|
|
938
|
-
if (level === 'debug') {
|
|
939
|
-
const key = args[0]; // e.g. '→ set'
|
|
940
|
-
const path = args[1] ?? '';
|
|
941
|
-
// Aggregate high-frequency debug ops
|
|
942
|
-
if (_agg.key === key) {
|
|
943
|
-
_agg.count++;
|
|
944
|
-
_agg.paths.push(path);
|
|
945
|
-
clearTimeout(_agg.timer);
|
|
946
|
-
_agg.timer = setTimeout(_flushAgg, 400);
|
|
947
|
-
return;
|
|
948
|
-
}
|
|
949
|
-
_flushAgg();
|
|
950
|
-
// Start new aggregation window
|
|
951
|
-
_agg.key = key; _agg.count = 1; _agg.paths = [path];
|
|
952
|
-
clearTimeout(_agg.timer);
|
|
953
|
-
_agg.timer = setTimeout(_flushAgg, 400);
|
|
954
|
-
return;
|
|
955
|
-
}
|
|
956
|
-
_flushAgg(); // flush any pending aggregate before an info/warn/error
|
|
957
|
-
const style = LEVEL_STYLE[level] ?? '';
|
|
958
|
-
console[level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log'](`%c[${level.toUpperCase()}]`, style, ...args);
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// ── ZuzClient (browser port, mirrors src/client/ZuzClient.ts API exactly) ──────
|
|
962
|
-
class ValueSnapshot {
|
|
963
|
-
constructor(path, data, updatedAt) { this.path = path; this._data = data; this.updatedAt = updatedAt; }
|
|
964
|
-
val() { return this._data; }
|
|
965
|
-
get key() { return this.path.split('/').pop(); }
|
|
966
|
-
exists() { return this._data !== null && this._data !== undefined; }
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
class ClientQueryBuilder {
|
|
970
|
-
constructor(client, path) { this._client = client; this._path = path; this._filters = []; }
|
|
971
|
-
where(field, op, value) { this._filters.push({ field, op, value }); return this; }
|
|
972
|
-
order(field, dir = 'asc') { this._order = { field, dir }; return this; }
|
|
973
|
-
limit(n) { this._limit = n; return this; }
|
|
974
|
-
offset(n) { this._offset = n; return this; }
|
|
975
|
-
get() { return this._client._query(this._path, this._filters.length ? this._filters : undefined, this._order, this._limit, this._offset); }
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
class ZuzClient {
|
|
979
|
-
constructor(url = window.__BODDB_URL__ || new URLSearchParams(location.search).get('url') || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`) {
|
|
980
|
-
this._url = url;
|
|
981
|
-
this._ws = null;
|
|
982
|
-
this._msgId = 0;
|
|
983
|
-
this._pending = new Map();
|
|
984
|
-
this._valueCbs = new Map(); // path → Set<(ValueSnapshot)=>void>
|
|
985
|
-
this._childCbs = new Map(); // path → Set<(ChildEvent)=>void>
|
|
986
|
-
this._activeSubs = new Set(); // 'value:path' | 'child:path'
|
|
987
|
-
this._reconnectDelay = 1000;
|
|
988
|
-
this._closed = false;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
connect() {
|
|
992
|
-
if (this._ws?.readyState === WebSocket.OPEN) return Promise.resolve();
|
|
993
|
-
return new Promise((resolve) => {
|
|
994
|
-
log('info', `WS connecting to ${this._url}`);
|
|
995
|
-
const ws = new WebSocket(this._url);
|
|
996
|
-
this._ws = ws;
|
|
997
|
-
ws.onopen = async () => {
|
|
998
|
-
this._reconnectDelay = 1000;
|
|
999
|
-
document.getElementById('ws-dot').classList.add('live');
|
|
1000
|
-
log('info', `WS connected`);
|
|
1001
|
-
// re-subscribe all active subs after reconnect
|
|
1002
|
-
for (const key of this._activeSubs) {
|
|
1003
|
-
const colon = key.indexOf(':');
|
|
1004
|
-
const event = key.slice(0, colon), path = key.slice(colon + 1);
|
|
1005
|
-
this._send('sub', { path, event }).catch(() => {});
|
|
1006
|
-
}
|
|
1007
|
-
if (this._activeSubs.size) log('debug', `Re-subscribed ${this._activeSubs.size} active subs`);
|
|
1008
|
-
resolve();
|
|
1009
|
-
setTimeout(loadTree, 0);
|
|
1010
|
-
};
|
|
1011
|
-
ws.onclose = () => {
|
|
1012
|
-
document.getElementById('ws-dot').classList.remove('live');
|
|
1013
|
-
for (const [, p] of this._pending) p.reject(new Error('Connection closed'));
|
|
1014
|
-
this._pending.clear();
|
|
1015
|
-
if (!this._closed) {
|
|
1016
|
-
const delay = this._reconnectDelay = Math.min(this._reconnectDelay * 2, 30000);
|
|
1017
|
-
log('warn', `WS disconnected — reconnecting in ${delay}ms`);
|
|
1018
|
-
setTimeout(() => this.connect(), delay);
|
|
1019
|
-
} else {
|
|
1020
|
-
log('info', 'WS closed');
|
|
1021
|
-
}
|
|
1022
|
-
};
|
|
1023
|
-
ws.onerror = (e) => log('error', 'WS error', e.message ?? '');
|
|
1024
|
-
ws.onmessage = (e) => {
|
|
1025
|
-
const msg = JSON.parse(e.data);
|
|
1026
|
-
if (msg.type === 'value') {
|
|
1027
|
-
if (msg.path !== '_admin/stats' && !msg.path.startsWith('stress/')) log('debug', `← value`, msg.path);
|
|
1028
|
-
const snap = new ValueSnapshot(msg.path, msg.data, msg.updatedAt);
|
|
1029
|
-
for (const cb of this._valueCbs.get(msg.path) ?? []) cb(snap);
|
|
1030
|
-
return;
|
|
1031
|
-
}
|
|
1032
|
-
if (msg.type === 'stream') {
|
|
1033
|
-
const streamKey = `${msg.path}:${msg.groupId}`;
|
|
1034
|
-
const cbs = this._streamCbs?.get(streamKey);
|
|
1035
|
-
if (cbs) { for (const cb of cbs) cb(msg.events); }
|
|
1036
|
-
return;
|
|
1037
|
-
}
|
|
1038
|
-
if (msg.type === 'child') {
|
|
1039
|
-
if (!msg.path.startsWith('_admin') && msg.path !== 'stress' && !msg.path.startsWith('stress/')) log('debug', `← child:${msg.event}`, `${msg.path}/${msg.key}`);
|
|
1040
|
-
const ev = { type: msg.event, key: msg.key, path: msg.path + '/' + msg.key, val: () => msg.data };
|
|
1041
|
-
for (const cb of this._childCbs.get(msg.path) ?? []) cb(ev);
|
|
1042
|
-
return;
|
|
1043
|
-
}
|
|
1044
|
-
const p = this._pending.get(msg.id);
|
|
1045
|
-
if (p) { this._pending.delete(msg.id); msg.ok ? p.resolve(msg) : p.reject(new Error(msg.error)); }
|
|
1046
|
-
};
|
|
1047
|
-
});
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
disconnect() { this._closed = true; this._ws?.close(); }
|
|
1051
|
-
|
|
1052
|
-
_sendRaw(op, params = {}) {
|
|
1053
|
-
return new Promise((resolve, reject) => {
|
|
1054
|
-
if (this._ws?.readyState !== WebSocket.OPEN) return reject(new Error('Not connected'));
|
|
1055
|
-
const id = String(++this._msgId);
|
|
1056
|
-
this._pending.set(id, { resolve, reject });
|
|
1057
|
-
const p = params.path ?? '';
|
|
1058
|
-
if (!['sub','unsub'].includes(op) && !p.startsWith('_admin') && !p.startsWith('stress/'))
|
|
1059
|
-
log('debug', `→ ${op}`, p);
|
|
1060
|
-
this._ws.send(JSON.stringify({ id, op, ...params }));
|
|
1061
|
-
});
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
async _send(op, params = {}) {
|
|
1065
|
-
const msg = await this._sendRaw(op, params);
|
|
1066
|
-
return msg.data ?? null;
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
get(path) { return this._send('get', { path }); }
|
|
1070
|
-
async getSnapshot(path) { const msg = await this._sendRaw('get', { path }); return new ValueSnapshot(path, msg.data ?? null, msg.updatedAt); }
|
|
1071
|
-
getShallow(path) { return this._send('get', { path: path ?? '', shallow: true }); }
|
|
1072
|
-
set(path, value) { return this._send('set', { path, value }); }
|
|
1073
|
-
update(updates) { return this._send('update', { updates }); }
|
|
1074
|
-
delete(path) { return this._send('delete', { path }); }
|
|
1075
|
-
batch(operations) { return this._send('batch', { operations }); }
|
|
1076
|
-
push(path, value) { return this._send('push', { path, value }); }
|
|
1077
|
-
transform(path, type, value) { return this._send('transform', { path, type, value }); }
|
|
1078
|
-
setTTL(path, value, ttl) { return this._send('set-ttl', { path, value, ttl }); }
|
|
1079
|
-
sweep() { return this._send('sweep', {}); }
|
|
1080
|
-
ftsIndex(path, content) { return this._send('fts-index', { path, content }); }
|
|
1081
|
-
ftsSearch(text, path, limit) { return this._send('fts-search', { text, path, limit }); }
|
|
1082
|
-
vecStore(path, embedding) { return this._send('vec-store', { path, embedding }); }
|
|
1083
|
-
vecSearch(query, path, limit, threshold) { return this._send('vec-search', { query, path, limit, threshold }); }
|
|
1084
|
-
getRules() { return this._send('get-rules', {}); }
|
|
1085
|
-
streamCompact(path, opts) { return this._send('stream-compact', { path, ...opts }); }
|
|
1086
|
-
streamReset(path) { return this._send('stream-reset', { path }); }
|
|
1087
|
-
streamSnapshot(path) { return this._send('stream-snapshot', { path }); }
|
|
1088
|
-
streamMaterialize(path, keepKey) { return this._send('stream-materialize', { path, keepKey }); }
|
|
1089
|
-
streamRead(path, groupId, limit) { return this._send('stream-read', { path, groupId, limit }); }
|
|
1090
|
-
streamAck(path, groupId, key) { return this._send('stream-ack', { path, groupId, key }); }
|
|
1091
|
-
streamSub(path, groupId) { return this._send('stream-sub', { path, groupId }); }
|
|
1092
|
-
streamUnsub(path, groupId) { return this._send('stream-unsub', { path, groupId }); }
|
|
1093
|
-
|
|
1094
|
-
mqPush(path, value, idem) { return this._send('mq-push', { path, value, idempotencyKey: idem || undefined }); }
|
|
1095
|
-
mqFetch(path, count) { return this._send('mq-fetch', { path, count }); }
|
|
1096
|
-
mqAck(path, key) { return this._send('mq-ack', { path, key }); }
|
|
1097
|
-
mqNack(path, key) { return this._send('mq-nack', { path, key }); }
|
|
1098
|
-
mqPeek(path, count) { return this._send('mq-peek', { path, count }); }
|
|
1099
|
-
mqDlq(path) { return this._send('mq-dlq', { path }); }
|
|
1100
|
-
mqPurge(path, opts) { return this._send('mq-purge', { path, all: opts?.all }); }
|
|
1101
|
-
|
|
1102
|
-
query(path) { return new ClientQueryBuilder(this, path); }
|
|
1103
|
-
|
|
1104
|
-
_query(path, filters, order, limit, offset) {
|
|
1105
|
-
return this._send('query', { path, filters, order, limit, offset });
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
on(path, cb) {
|
|
1109
|
-
const key = `value:${path}`;
|
|
1110
|
-
if (!this._valueCbs.has(path)) this._valueCbs.set(path, new Set());
|
|
1111
|
-
this._valueCbs.get(path).add(cb);
|
|
1112
|
-
if (!this._activeSubs.has(key)) {
|
|
1113
|
-
this._activeSubs.add(key);
|
|
1114
|
-
if (this._ws?.readyState === WebSocket.OPEN) this._send('sub', { path, event: 'value' }).catch(() => {});
|
|
1115
|
-
}
|
|
1116
|
-
return () => {
|
|
1117
|
-
this._valueCbs.get(path)?.delete(cb);
|
|
1118
|
-
if (!this._valueCbs.get(path)?.size) {
|
|
1119
|
-
this._valueCbs.delete(path); this._activeSubs.delete(key);
|
|
1120
|
-
if (this._ws?.readyState === WebSocket.OPEN) this._send('unsub', { path, event: 'value' }).catch(() => {});
|
|
1121
|
-
}
|
|
1122
|
-
};
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
onChild(path, cb) {
|
|
1126
|
-
const key = `child:${path}`;
|
|
1127
|
-
if (!this._childCbs.has(path)) this._childCbs.set(path, new Set());
|
|
1128
|
-
this._childCbs.get(path).add(cb);
|
|
1129
|
-
if (!this._activeSubs.has(key)) {
|
|
1130
|
-
this._activeSubs.add(key);
|
|
1131
|
-
if (this._ws?.readyState === WebSocket.OPEN) this._send('sub', { path, event: 'child' }).catch(() => {});
|
|
1132
|
-
}
|
|
1133
|
-
return () => {
|
|
1134
|
-
this._childCbs.get(path)?.delete(cb);
|
|
1135
|
-
if (!this._childCbs.get(path)?.size) {
|
|
1136
|
-
this._childCbs.delete(path); this._activeSubs.delete(key);
|
|
1137
|
-
if (this._ws?.readyState === WebSocket.OPEN) this._send('unsub', { path, event: 'child' }).catch(() => {});
|
|
1138
|
-
}
|
|
1139
|
-
};
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
get connected() { return this._ws?.readyState === WebSocket.OPEN; }
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
const db = new ZuzClient();
|
|
1146
|
-
|
|
1147
|
-
// ── Sparkline ──────────────────────────────────────────────────────────────────
|
|
1148
|
-
const SPARK_POINTS = 60;
|
|
1149
|
-
|
|
1150
|
-
function hexToRgba(hex, a) {
|
|
1151
|
-
const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
|
|
1152
|
-
return `rgba(${r},${g},${b},${a})`;
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
class Sparkline {
|
|
1156
|
-
constructor(canvasId, color = '#4ec9b0') {
|
|
1157
|
-
this.canvas = document.getElementById(canvasId);
|
|
1158
|
-
this.ctx = this.canvas.getContext('2d');
|
|
1159
|
-
this.color = color; this.data = [];
|
|
1160
|
-
const dpr = window.devicePixelRatio || 1;
|
|
1161
|
-
const w = this.canvas.width, h = this.canvas.height;
|
|
1162
|
-
this.canvas.width = w * dpr; this.canvas.height = h * dpr;
|
|
1163
|
-
this.canvas.style.width = w + 'px'; this.canvas.style.height = h + 'px';
|
|
1164
|
-
this.ctx.scale(dpr, dpr);
|
|
1165
|
-
this.W = w; this.H = h;
|
|
1166
|
-
}
|
|
1167
|
-
push(v) {
|
|
1168
|
-
this.data.push(v);
|
|
1169
|
-
if (this.data.length > SPARK_POINTS) this.data.shift();
|
|
1170
|
-
this.draw();
|
|
1171
|
-
}
|
|
1172
|
-
draw() {
|
|
1173
|
-
const { ctx, data, W, H, color } = this;
|
|
1174
|
-
ctx.clearRect(0, 0, W, H);
|
|
1175
|
-
if (data.length < 2) return;
|
|
1176
|
-
const min = Math.min(...data), max = Math.max(...data), range = max - min || 1;
|
|
1177
|
-
const xStep = W / (SPARK_POINTS - 1), pad = 2;
|
|
1178
|
-
const yScale = v => H - pad - ((v - min) / range) * (H - pad * 2);
|
|
1179
|
-
const x0 = (SPARK_POINTS - data.length) * xStep;
|
|
1180
|
-
ctx.beginPath();
|
|
1181
|
-
ctx.moveTo(x0, H);
|
|
1182
|
-
data.forEach((v, i) => ctx.lineTo(x0 + i * xStep, yScale(v)));
|
|
1183
|
-
ctx.lineTo(x0 + (data.length - 1) * xStep, H);
|
|
1184
|
-
ctx.closePath();
|
|
1185
|
-
ctx.fillStyle = hexToRgba(color, 0.15);
|
|
1186
|
-
ctx.fill();
|
|
1187
|
-
ctx.beginPath();
|
|
1188
|
-
data.forEach((v, i) => { const x = x0 + i * xStep; i === 0 ? ctx.moveTo(x, yScale(v)) : ctx.lineTo(x, yScale(v)); });
|
|
1189
|
-
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.stroke();
|
|
1190
|
-
const lv = data[data.length - 1];
|
|
1191
|
-
ctx.beginPath(); ctx.arc(x0 + (data.length - 1) * xStep, yScale(lv), 2.5, 0, Math.PI * 2);
|
|
1192
|
-
ctx.fillStyle = color; ctx.fill();
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
// ── Stats subscription via ZuzDB ───────────────────────────────────────────────
|
|
1197
|
-
const graphs = {
|
|
1198
|
-
cpu: new Sparkline('g-cpu', '#ce9178'),
|
|
1199
|
-
heap: new Sparkline('g-heap', '#569cd6'),
|
|
1200
|
-
rss: new Sparkline('g-rss', '#9cdcfe'),
|
|
1201
|
-
nodes:new Sparkline('g-nodes','#4ec9b0'),
|
|
1202
|
-
ping: new Sparkline('g-ping', '#c586c0'),
|
|
1203
|
-
};
|
|
1204
|
-
|
|
1205
|
-
// ── Ping measurement ────────────────────────────────────────────────────────
|
|
1206
|
-
async function measurePing() {
|
|
1207
|
-
try {
|
|
1208
|
-
const t0 = performance.now();
|
|
1209
|
-
await db.get('_admin/stats');
|
|
1210
|
-
const ms = Math.round(performance.now() - t0);
|
|
1211
|
-
const el = document.getElementById('s-ping');
|
|
1212
|
-
el.textContent = ms + ' ms';
|
|
1213
|
-
el.className = 'metric-value' + (ms > 200 ? ' warn' : '');
|
|
1214
|
-
graphs.ping.push(ms);
|
|
1215
|
-
} catch {}
|
|
1216
|
-
}
|
|
1217
|
-
setInterval(measurePing, 2000);
|
|
1218
|
-
|
|
1219
|
-
let _statsEnabled = true;
|
|
1220
|
-
async function toggleStats() {
|
|
1221
|
-
try {
|
|
1222
|
-
const result = await db._send('admin-stats-toggle', { enabled: !_statsEnabled });
|
|
1223
|
-
_statsEnabled = result.enabled;
|
|
1224
|
-
const btn = document.getElementById('stats-toggle');
|
|
1225
|
-
btn.textContent = 'Stats: ' + (_statsEnabled ? 'ON' : 'OFF');
|
|
1226
|
-
btn.style.color = _statsEnabled ? '#4ec9b0' : '#f48771';
|
|
1227
|
-
if (!_statsEnabled) {
|
|
1228
|
-
// Clear displays
|
|
1229
|
-
for (const id of ['s-cpu','s-heap','s-rss','s-nodes']) document.getElementById(id).textContent = '—';
|
|
1230
|
-
}
|
|
1231
|
-
} catch (e) { console.warn('Stats toggle failed:', e); }
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
db.on('_admin/stats', (snap) => {
|
|
1235
|
-
const s = snap.val();
|
|
1236
|
-
if (!s) return;
|
|
1237
|
-
const cpu = s.process.cpuPercent;
|
|
1238
|
-
const cpuEl = document.getElementById('s-cpu');
|
|
1239
|
-
cpuEl.textContent = cpu + '%';
|
|
1240
|
-
cpuEl.className = 'metric-value' + (cpu > 80 ? ' warn' : '');
|
|
1241
|
-
graphs.cpu.push(cpu);
|
|
1242
|
-
|
|
1243
|
-
document.getElementById('s-heap').textContent = s.process.heapUsedMb + ' MB';
|
|
1244
|
-
graphs.heap.push(s.process.heapUsedMb);
|
|
1245
|
-
|
|
1246
|
-
document.getElementById('s-rss').textContent = s.process.rssMb + ' MB';
|
|
1247
|
-
graphs.rss.push(s.process.rssMb);
|
|
1248
|
-
|
|
1249
|
-
document.getElementById('s-nodes').textContent = s.db.nodeCount;
|
|
1250
|
-
document.getElementById('s-dbsize').textContent = s.db.sizeMb ?? '—';
|
|
1251
|
-
graphs.nodes.push(s.db.nodeCount);
|
|
1252
|
-
|
|
1253
|
-
if (s.system) {
|
|
1254
|
-
document.getElementById('s-cpucores').textContent = s.system.cpuCores;
|
|
1255
|
-
document.getElementById('s-syscpu').textContent = s.system.cpuPercent;
|
|
1256
|
-
const totalGb = (s.system.totalMemMb / 1024).toFixed(1);
|
|
1257
|
-
document.getElementById('s-totalmem').textContent = totalGb + ' GB';
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
document.getElementById('s-clients').textContent = s.clients ?? 0;
|
|
1261
|
-
document.getElementById('s-subs').textContent = s.subs ?? 0;
|
|
1262
|
-
document.getElementById('s-uptime').textContent = fmtUptime(s.process.uptimeSec);
|
|
1263
|
-
document.getElementById('s-ts').textContent = new Date(s.ts).toLocaleTimeString();
|
|
1264
|
-
if (s.version) document.getElementById('s-version').textContent = s.version;
|
|
1265
|
-
|
|
1266
|
-
// Replication stats
|
|
1267
|
-
if (s.repl) {
|
|
1268
|
-
const card = document.getElementById('repl-card');
|
|
1269
|
-
card.style.display = '';
|
|
1270
|
-
document.getElementById('s-repl-role').textContent = s.repl.role;
|
|
1271
|
-
const srcEl = document.getElementById('s-repl-sources');
|
|
1272
|
-
if (s.repl.sources.length) {
|
|
1273
|
-
srcEl.innerHTML = s.repl.sources.map(src => {
|
|
1274
|
-
const dot = src.connected ? '<span style="color:#4ec9b0">●</span>' : '<span style="color:#f48771">●</span>';
|
|
1275
|
-
const prefix = src.localPrefix ? `→${src.localPrefix}/` : '';
|
|
1276
|
-
const pend = src.pending;
|
|
1277
|
-
const pendColor = pend > 100 ? '#f48771' : pend > 10 ? '#ce9178' : '#4ec9b0';
|
|
1278
|
-
return `<span style="color:#555">${dot} ${src.paths.join(',')} ${prefix} <span style="color:${pendColor}">${pend} behind</span> · ${src.eventsApplied}ev</span>`;
|
|
1279
|
-
}).join('<br>');
|
|
1280
|
-
} else {
|
|
1281
|
-
srcEl.innerHTML = '<span style="color:#555">no sources</span>';
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
});
|
|
1285
|
-
|
|
1286
|
-
// Connect after all subscriptions are registered so onopen re-subscribes them all
|
|
1287
|
-
db.connect();
|
|
1288
|
-
|
|
1289
|
-
// Handle #approve=<requestId> deep link (from QR scan)
|
|
1290
|
-
if (location.hash.startsWith('#approve=')) {
|
|
1291
|
-
const rid = location.hash.slice('#approve='.length);
|
|
1292
|
-
location.hash = '';
|
|
1293
|
-
// Wait for WS to connect, switch to KeyAuth tab, fill & prompt
|
|
1294
|
-
const _awaitApprove = setInterval(() => {
|
|
1295
|
-
if (!db.connected) return;
|
|
1296
|
-
clearInterval(_awaitApprove);
|
|
1297
|
-
showTab('keyauth'); loadKeyAuth();
|
|
1298
|
-
document.getElementById('ka-qr-request-id').value = rid;
|
|
1299
|
-
kaStatus('QR scanned — click "Approve" to authorize the device');
|
|
1300
|
-
}, 300);
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// ── Live tree via child subscriptions ──────────────────────────────────────────
|
|
1304
|
-
const _treeUnsubs = new Map(); // key → unsub fn
|
|
1305
|
-
const showAdmin = new URLSearchParams(location.search).has('showAdmin');
|
|
1306
|
-
|
|
1307
|
-
let _treeDebounceTimer = null;
|
|
1308
|
-
const _pendingChangedPaths = new Set();
|
|
1309
|
-
let _lastAdminRefresh = 0;
|
|
1310
|
-
function debouncedLoadTree(path) {
|
|
1311
|
-
_pendingChangedPaths.add(path);
|
|
1312
|
-
clearTimeout(_treeDebounceTimer);
|
|
1313
|
-
_treeDebounceTimer = setTimeout(() => {
|
|
1314
|
-
const paths = [..._pendingChangedPaths];
|
|
1315
|
-
_pendingChangedPaths.clear();
|
|
1316
|
-
const roots = new Set();
|
|
1317
|
-
for (const p of paths) {
|
|
1318
|
-
const top = p.split('/')[0];
|
|
1319
|
-
// Throttle _admin to once per 3s (updates every 1s)
|
|
1320
|
-
if (top === '_admin') {
|
|
1321
|
-
const now = Date.now();
|
|
1322
|
-
if (now - _lastAdminRefresh < 3000) continue;
|
|
1323
|
-
_lastAdminRefresh = now;
|
|
1324
|
-
}
|
|
1325
|
-
roots.add(top);
|
|
1326
|
-
}
|
|
1327
|
-
for (const r of roots) refreshPath(r);
|
|
1328
|
-
}, 300);
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
function subscribeTreeLive(topLevelKeys) {
|
|
1332
|
-
// Unsub keys no longer in tree
|
|
1333
|
-
for (const [key, unsub] of _treeUnsubs) {
|
|
1334
|
-
if (!topLevelKeys.includes(key)) { unsub(); _treeUnsubs.delete(key); }
|
|
1335
|
-
}
|
|
1336
|
-
// Skip stress key (too noisy); _admin is throttled via debounce
|
|
1337
|
-
const NOISY_KEYS = new Set(['stress', '_admin']);
|
|
1338
|
-
const visibleKeys = topLevelKeys.filter(k => !NOISY_KEYS.has(k));
|
|
1339
|
-
for (const key of visibleKeys) {
|
|
1340
|
-
if (_treeUnsubs.has(key)) continue;
|
|
1341
|
-
const unsub = db.onChild(key, (ev) => debouncedLoadTree(ev.path));
|
|
1342
|
-
_treeUnsubs.set(key, unsub);
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
function fmtUptime(sec) {
|
|
1347
|
-
if (sec < 60) return sec + 's';
|
|
1348
|
-
if (sec < 3600) return Math.floor(sec/60) + 'm ' + (sec%60) + 's';
|
|
1349
|
-
return Math.floor(sec/3600) + 'h ' + Math.floor((sec%3600)/60) + 'm';
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
// ── Tab switching ──────────────────────────────────────────────────────────────
|
|
1353
|
-
const TAB_IDS = ['rw','query','subs','auth','advanced','streams','mq','repl','vfs','cache','keyauth','stress','view'];
|
|
1354
|
-
function showTab(id) {
|
|
1355
|
-
document.querySelectorAll('.tab').forEach((t, i) => t.classList.toggle('active', TAB_IDS[i] === id));
|
|
1356
|
-
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
|
1357
|
-
document.getElementById('panel-' + id).classList.add('active');
|
|
1358
|
-
const url = new URL(location.href);
|
|
1359
|
-
url.searchParams.set('tab', id);
|
|
1360
|
-
history.replaceState(null, '', url);
|
|
1361
|
-
localStorage.setItem('zuzdb:tab', id);
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
// ── Field persistence ──────────────────────────────────────────────────────────
|
|
1365
|
-
const PERSIST_FIELDS = ['rw-path','rw-value','upd-value','q-path','sub-path','auth-test-path','rw-auth-token','auth-token','push-path','push-value','batch-value','tf-path','tf-value','ttl-path','ttl-value','fts-path','fts-content','fts-query','fts-prefix','vec-path','vec-embedding','vec-query','vec-prefix'];
|
|
1366
|
-
const DEFAULT_VALUES = {
|
|
1367
|
-
'rw-path': 'users/alice',
|
|
1368
|
-
'rw-value': '{ "name": "Alice", "age": 30, "role": "admin" }',
|
|
1369
|
-
'upd-value': '{ "users/alice/age": 31, "users/bob/role": "admin" }',
|
|
1370
|
-
'q-path': 'users',
|
|
1371
|
-
'sub-path': 'users',
|
|
1372
|
-
'auth-test-path': 'users/alice',
|
|
1373
|
-
'push-path': 'logs',
|
|
1374
|
-
'push-value': `{ "level": "info", "msg": "hello from admin", "ts": ${Date.now()} }`,
|
|
1375
|
-
'batch-value': '[\n { "op": "set", "path": "counters/views", "value": 42 },\n { "op": "push", "path": "logs", "value": { "msg": "batch test" } }\n]',
|
|
1376
|
-
'tf-path': 'counters/likes',
|
|
1377
|
-
'tf-value': '1',
|
|
1378
|
-
'ttl-path': 'sessions/demo',
|
|
1379
|
-
'ttl-value': '{ "token": "abc123", "user": "alice" }',
|
|
1380
|
-
'fts-path': 'users/alice',
|
|
1381
|
-
'fts-content': 'Alice is an admin user who manages the system',
|
|
1382
|
-
'fts-query': 'admin',
|
|
1383
|
-
'fts-prefix': 'users',
|
|
1384
|
-
'vec-path': 'users/alice',
|
|
1385
|
-
'vec-embedding': JSON.stringify(Array.from({length:384}, (_,i) => +(Math.sin(i*0.1)*0.5).toFixed(4))),
|
|
1386
|
-
'vec-query': JSON.stringify(Array.from({length:384}, (_,i) => +(Math.sin(i*0.1)*0.5).toFixed(4))),
|
|
1387
|
-
'vec-prefix': 'users',
|
|
1388
|
-
};
|
|
1389
|
-
function persistFields() {
|
|
1390
|
-
const state = {};
|
|
1391
|
-
for (const id of PERSIST_FIELDS) state[id] = document.getElementById(id).value;
|
|
1392
|
-
localStorage.setItem('zuzdb:fields', JSON.stringify(state));
|
|
1393
|
-
}
|
|
1394
|
-
function restoreFields() {
|
|
1395
|
-
const saved = localStorage.getItem('zuzdb:fields');
|
|
1396
|
-
try {
|
|
1397
|
-
const state = JSON.parse(saved ?? '{}');
|
|
1398
|
-
const hasState = saved && Object.keys(state).some(k => state[k]);
|
|
1399
|
-
for (const id of PERSIST_FIELDS) {
|
|
1400
|
-
const el = document.getElementById(id);
|
|
1401
|
-
if (!el) continue;
|
|
1402
|
-
if (hasState && state[id]) { el.value = state[id]; }
|
|
1403
|
-
else if (DEFAULT_VALUES[id]) { el.value = DEFAULT_VALUES[id]; }
|
|
1404
|
-
}
|
|
1405
|
-
} catch {}
|
|
1406
|
-
const tab = new URLSearchParams(location.search).get('tab') || localStorage.getItem('zuzdb:tab');
|
|
1407
|
-
if (tab && TAB_IDS.includes(tab)) {
|
|
1408
|
-
showTab(tab);
|
|
1409
|
-
setTimeout(() => {
|
|
1410
|
-
if (tab === 'vfs') vfsNavigate(vfsPath);
|
|
1411
|
-
if (tab === 'repl') loadRepl();
|
|
1412
|
-
if (tab === 'auth') loadRules();
|
|
1413
|
-
if (tab === 'keyauth') loadKeyAuth();
|
|
1414
|
-
}, 0);
|
|
1415
|
-
}
|
|
1416
|
-
}
|
|
1417
|
-
for (const id of PERSIST_FIELDS) {
|
|
1418
|
-
document.getElementById(id).addEventListener('input', persistFields);
|
|
1419
|
-
}
|
|
1420
|
-
restoreFields();
|
|
1421
|
-
|
|
1422
|
-
// ── Tree — preserves open state on refresh + across reloads ────────────────────
|
|
1423
|
-
const OPEN_KEY = 'zuzdb:treeOpen';
|
|
1424
|
-
|
|
1425
|
-
function getOpenPaths() {
|
|
1426
|
-
const open = new Set();
|
|
1427
|
-
document.querySelectorAll('#tree-container details[open]').forEach(d => open.add(d.dataset.path));
|
|
1428
|
-
return open;
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
function saveOpenPaths(open) {
|
|
1432
|
-
try { localStorage.setItem(OPEN_KEY, JSON.stringify([...open])); } catch {}
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
function loadOpenPaths() {
|
|
1436
|
-
try { return new Set(JSON.parse(localStorage.getItem(OPEN_KEY) ?? '[]')); } catch { return new Set(); }
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
// ── Lazy tree ─────────────────────────────────────────────────────────────────
|
|
1440
|
-
// Fetches only immediate children of a path. Nodes expand on click.
|
|
1441
|
-
const _loadedPaths = new Set(); // tracks which branch paths have been fetched
|
|
1442
|
-
let _treeGeneration = 0; // incremented on full reload to cancel stale expansions
|
|
1443
|
-
|
|
1444
|
-
async function fetchChildren(path) {
|
|
1445
|
-
try {
|
|
1446
|
-
return await db.getShallow(path || undefined) || [];
|
|
1447
|
-
} catch { return []; }
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
function renderChildren(children, parentPath) {
|
|
1451
|
-
let html = '';
|
|
1452
|
-
for (const ch of children) {
|
|
1453
|
-
if (!showAdmin && (parentPath ? parentPath + '/' + ch.key : ch.key).startsWith('_admin')) continue;
|
|
1454
|
-
const path = parentPath ? parentPath + '/' + ch.key : ch.key;
|
|
1455
|
-
const ttlBadge = ch.ttl != null && ch.ttl >= 0 ? `<span class="ttl-badge" title="Expires in ${ch.ttl}s">${ch.ttl}s</span>` : ch.ttl === -1 ? '<span class="ttl-badge" title="Contains TTL entries">ttl</span>' : '';
|
|
1456
|
-
if (ch.isLeaf) {
|
|
1457
|
-
const raw = typeof ch.value === 'object' && ch.value !== null ? JSON.stringify(ch.value) : String(ch.value ?? '');
|
|
1458
|
-
const display = raw.length > 36 ? raw.slice(0, 36) + '…' : raw;
|
|
1459
|
-
html += `<div class="tree-leaf" data-path="${escHtml(path)}" onclick="selectPath('${path.replace(/'/g, "\\'")}')"><span class="tree-key">${escHtml(ch.key)}</span>${ttlBadge}<span class="tree-val">${escHtml(String(display ?? ''))}</span></div>`;
|
|
1460
|
-
} else {
|
|
1461
|
-
const isOpen = _restoredOpenPaths.has(path);
|
|
1462
|
-
const countBadge = ch.count != null ? `<span class="count-badge">${ch.count}</span>` : '';
|
|
1463
|
-
html += `<details data-path="${escHtml(path)}"${isOpen ? ' open' : ''}><summary><span class="tree-label" onclick="selectPath('${path.replace(/'/g, "\\'")}')">${escHtml(ch.key)}</span>${ttlBadge}${countBadge}</summary><div class="tree-children" data-parent="${escHtml(path)}"></div></details>`;
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
return html;
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
let _restoredOpenPaths = loadOpenPaths();
|
|
1470
|
-
|
|
1471
|
-
async function loadTree(changedPaths) {
|
|
1472
|
-
_loadedPaths.clear();
|
|
1473
|
-
const gen = ++_treeGeneration;
|
|
1474
|
-
_restoredOpenPaths = getOpenPaths().size ? getOpenPaths() : loadOpenPaths();
|
|
1475
|
-
const children = await fetchChildren('');
|
|
1476
|
-
if (!children || gen !== _treeGeneration) return;
|
|
1477
|
-
|
|
1478
|
-
const container = document.getElementById('tree-container');
|
|
1479
|
-
container.innerHTML = renderChildren(children, '');
|
|
1480
|
-
|
|
1481
|
-
// Subscribe live to top-level keys
|
|
1482
|
-
subscribeTreeLive(children.filter(c => !c.isLeaf).map(c => c.key));
|
|
1483
|
-
|
|
1484
|
-
// Auto-expand any that were previously open
|
|
1485
|
-
for (const det of container.querySelectorAll('details[open]')) {
|
|
1486
|
-
expandNode(det);
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
// Attach toggle listeners
|
|
1490
|
-
attachToggleListeners(container);
|
|
1491
|
-
|
|
1492
|
-
// Update node count
|
|
1493
|
-
updateNodeCount();
|
|
1494
|
-
|
|
1495
|
-
// Flash changed
|
|
1496
|
-
if (changedPaths) flashPaths(changedPaths);
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
function attachToggleListeners(root) {
|
|
1500
|
-
root.querySelectorAll('details').forEach(d => {
|
|
1501
|
-
if (d._treeListener) return;
|
|
1502
|
-
d._treeListener = true;
|
|
1503
|
-
d.addEventListener('toggle', () => {
|
|
1504
|
-
saveOpenPaths(getOpenPaths());
|
|
1505
|
-
if (d.open) expandNode(d);
|
|
1506
|
-
}, { passive: true });
|
|
1507
|
-
});
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
async function expandNode(details, isRefresh) {
|
|
1511
|
-
const path = details.dataset.path;
|
|
1512
|
-
if (_loadedPaths.has(path)) return;
|
|
1513
|
-
_loadedPaths.add(path);
|
|
1514
|
-
const gen = _treeGeneration;
|
|
1515
|
-
|
|
1516
|
-
// Remember which sub-paths were open before re-render
|
|
1517
|
-
const prevOpen = new Set();
|
|
1518
|
-
if (isRefresh) {
|
|
1519
|
-
details.querySelectorAll('details[open]').forEach(d => prevOpen.add(d.dataset.path));
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
const childContainer = details.querySelector(':scope > .tree-children');
|
|
1523
|
-
if (!childContainer) return;
|
|
1524
|
-
|
|
1525
|
-
const children = await fetchChildren(path);
|
|
1526
|
-
if (gen !== _treeGeneration) return; // stale — tree was reloaded
|
|
1527
|
-
|
|
1528
|
-
// Merge prevOpen into _restoredOpenPaths so renderChildren marks them open
|
|
1529
|
-
for (const p of prevOpen) _restoredOpenPaths.add(p);
|
|
1530
|
-
|
|
1531
|
-
childContainer.innerHTML = renderChildren(children, path);
|
|
1532
|
-
|
|
1533
|
-
// Auto-expand open children (restored or previously open)
|
|
1534
|
-
for (const det of childContainer.querySelectorAll(':scope details[open]')) {
|
|
1535
|
-
expandNode(det);
|
|
1536
|
-
}
|
|
1537
|
-
attachToggleListeners(childContainer);
|
|
1538
|
-
updateNodeCount();
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
async function refreshPath(path) {
|
|
1542
|
-
const parts = path.split('/');
|
|
1543
|
-
let target = '';
|
|
1544
|
-
// Walk up to find the deepest loaded ancestor
|
|
1545
|
-
for (let i = 1; i <= parts.length; i++) {
|
|
1546
|
-
const p = parts.slice(0, i).join('/');
|
|
1547
|
-
if (_loadedPaths.has(p)) target = p;
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
|
-
if (!target) {
|
|
1551
|
-
// No loaded ancestor — try to update just the root-level item without full reload
|
|
1552
|
-
const topKey = parts[0];
|
|
1553
|
-
const container = document.getElementById('tree-container');
|
|
1554
|
-
const existing = container.querySelector(`[data-path="${CSS.escape(topKey)}"]`);
|
|
1555
|
-
if (!existing) {
|
|
1556
|
-
// Truly new top-level key — must reload root
|
|
1557
|
-
return loadTree([path]);
|
|
1558
|
-
}
|
|
1559
|
-
// Root item exists but children weren't loaded — just flash it
|
|
1560
|
-
flashPaths([path]);
|
|
1561
|
-
return;
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
// Re-fetch this node and all its children
|
|
1565
|
-
for (const p of [..._loadedPaths]) {
|
|
1566
|
-
if (p === target || p.startsWith(target + '/')) _loadedPaths.delete(p);
|
|
1567
|
-
}
|
|
1568
|
-
const det = document.querySelector(`#tree-container details[data-path="${CSS.escape(target)}"]`);
|
|
1569
|
-
if (det && det.open) {
|
|
1570
|
-
await expandNode(det, true);
|
|
1571
|
-
flashPaths([path]);
|
|
1572
|
-
} else {
|
|
1573
|
-
// Node exists but is collapsed — just flash it
|
|
1574
|
-
flashPaths([path]);
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
function updateNodeCount() {
|
|
1579
|
-
const leaves = document.querySelectorAll('#tree-container .tree-leaf').length;
|
|
1580
|
-
const branches = document.querySelectorAll('#tree-container details').length;
|
|
1581
|
-
document.getElementById('node-count').textContent = `${leaves} leaves, ${branches} branches`;
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
function flashPaths(paths) {
|
|
1585
|
-
for (const path of paths) {
|
|
1586
|
-
let el = document.querySelector(`#tree-container [data-path="${CSS.escape(path)}"]`);
|
|
1587
|
-
if (!el) {
|
|
1588
|
-
const parts = path.split('/');
|
|
1589
|
-
while (parts.length > 1) {
|
|
1590
|
-
parts.pop();
|
|
1591
|
-
el = document.querySelector(`#tree-container [data-path="${CSS.escape(parts.join('/'))}"]`);
|
|
1592
|
-
if (el) break;
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1595
|
-
if (el) { el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 1200); }
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
function selectPath(path) {
|
|
1600
|
-
document.getElementById('rw-path').value = path;
|
|
1601
|
-
document.getElementById('sub-path').value = path;
|
|
1602
|
-
document.getElementById('q-path').value = path.includes('/') ? path.split('/').slice(0, -1).join('/') : path;
|
|
1603
|
-
showTab('view');
|
|
1604
|
-
document.getElementById('view-path').textContent = path;
|
|
1605
|
-
document.getElementById('view-time').textContent = '';
|
|
1606
|
-
document.getElementById('view-result').textContent = 'Loading…';
|
|
1607
|
-
const t0 = performance.now();
|
|
1608
|
-
db.getSnapshot(path).then(snap => {
|
|
1609
|
-
const elapsed = (performance.now() - t0).toFixed(1) + ' ms';
|
|
1610
|
-
const updated = snap.updatedAt ? new Date(snap.updatedAt).toLocaleString() : '';
|
|
1611
|
-
document.getElementById('view-time').textContent = elapsed + (updated ? ` · updated ${updated}` : '');
|
|
1612
|
-
document.getElementById('view-result').textContent = JSON.stringify(snap.val(), null, 2);
|
|
1613
|
-
}).catch(e => {
|
|
1614
|
-
document.getElementById('view-time').textContent = (performance.now() - t0).toFixed(1) + ' ms';
|
|
1615
|
-
document.getElementById('view-result').textContent = 'Error: ' + e.message;
|
|
1616
|
-
});
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
1620
|
-
|
|
1621
|
-
// ── Read/Write ─────────────────────────────────────────────────────────────────
|
|
1622
|
-
async function doGet() {
|
|
1623
|
-
const path = document.getElementById('rw-path').value.trim();
|
|
1624
|
-
log('info', `GET ${path}`);
|
|
1625
|
-
const t0 = performance.now();
|
|
1626
|
-
try {
|
|
1627
|
-
const res = await db.get(path);
|
|
1628
|
-
const ms = performance.now() - t0;
|
|
1629
|
-
log('info', `GET ${path} →`, res);
|
|
1630
|
-
document.getElementById('rw-result').textContent = JSON.stringify(res, null, 2);
|
|
1631
|
-
showStatus('rw-status', 'OK', true, ms);
|
|
1632
|
-
} catch(e) { log('error', `GET ${path} failed:`, e.message); showStatus('rw-status', e.message, false, performance.now() - t0); }
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
async function doSet() {
|
|
1636
|
-
const path = document.getElementById('rw-path').value.trim();
|
|
1637
|
-
const raw = document.getElementById('rw-value').value.trim();
|
|
1638
|
-
let value;
|
|
1639
|
-
try { value = JSON.parse(raw); } catch { return showStatus('rw-status', 'Invalid JSON', false); }
|
|
1640
|
-
log('info', `SET ${path} =`, value);
|
|
1641
|
-
const t0 = performance.now();
|
|
1642
|
-
try {
|
|
1643
|
-
await db.set(path, value);
|
|
1644
|
-
showStatus('rw-status', 'Set OK', true, performance.now() - t0);
|
|
1645
|
-
refreshPath(path);
|
|
1646
|
-
} catch(e) { log('error', `SET ${path} failed:`, e.message); showStatus('rw-status', e.message, false, performance.now() - t0); }
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
async function doUpdate() {
|
|
1650
|
-
const path = document.getElementById('rw-path').value.trim();
|
|
1651
|
-
const raw = document.getElementById('rw-value').value.trim();
|
|
1652
|
-
let value;
|
|
1653
|
-
try { value = JSON.parse(raw); } catch { return showStatus('rw-status', 'Invalid JSON', false); }
|
|
1654
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value)) return showStatus('rw-status', 'UPDATE requires an object', false);
|
|
1655
|
-
const updates = Object.fromEntries(Object.entries(value).map(([k, v]) => [`${path}/${k}`, v]));
|
|
1656
|
-
log('info', `UPDATE ${path}`, updates);
|
|
1657
|
-
const t0 = performance.now();
|
|
1658
|
-
try {
|
|
1659
|
-
await db.update(updates);
|
|
1660
|
-
showStatus('rw-status', 'Updated OK', true, performance.now() - t0);
|
|
1661
|
-
for (const p of Object.keys(updates)) refreshPath(p);
|
|
1662
|
-
} catch(e) { log('error', `UPDATE ${path} failed:`, e.message); showStatus('rw-status', e.message, false, performance.now() - t0); }
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
async function doDelete() {
|
|
1666
|
-
const path = document.getElementById('rw-path').value.trim();
|
|
1667
|
-
log('info', `DELETE ${path}`);
|
|
1668
|
-
const t0 = performance.now();
|
|
1669
|
-
try {
|
|
1670
|
-
await db.delete(path);
|
|
1671
|
-
showStatus('rw-status', 'Deleted', true, performance.now() - t0);
|
|
1672
|
-
refreshPath(path);
|
|
1673
|
-
} catch(e) { log('error', `DELETE ${path} failed:`, e.message); showStatus('rw-status', e.message, false, performance.now() - t0); }
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
// ── Update tab ─────────────────────────────────────────────────────────────────
|
|
1677
|
-
async function doUpdateTab() {
|
|
1678
|
-
const raw = document.getElementById('upd-value').value.trim();
|
|
1679
|
-
let updates;
|
|
1680
|
-
try { updates = JSON.parse(raw); } catch { return showStatus('upd-status', 'Invalid JSON', false); }
|
|
1681
|
-
log('info', 'UPDATE (multi-path)', updates);
|
|
1682
|
-
const t0 = performance.now();
|
|
1683
|
-
try {
|
|
1684
|
-
await db.update(updates);
|
|
1685
|
-
showStatus('upd-status', 'Updated', true, performance.now() - t0);
|
|
1686
|
-
for (const p of Object.keys(updates)) refreshPath(p);
|
|
1687
|
-
} catch(e) { log('error', 'UPDATE failed:', e.message); showStatus('upd-status', e.message, false, performance.now() - t0); }
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
// ── Push ────────────────────────────────────────────────────────────────────────
|
|
1691
|
-
async function doPush() {
|
|
1692
|
-
const path = document.getElementById('push-path').value.trim();
|
|
1693
|
-
const raw = document.getElementById('push-value').value.trim();
|
|
1694
|
-
let value;
|
|
1695
|
-
try { value = JSON.parse(raw); } catch { return showStatus('push-status', 'Invalid JSON', false); }
|
|
1696
|
-
log('info', `PUSH ${path}`, value);
|
|
1697
|
-
const t0 = performance.now();
|
|
1698
|
-
try {
|
|
1699
|
-
const key = await db.push(path, value);
|
|
1700
|
-
showStatus('push-status', `Pushed → ${key}`, true, performance.now() - t0);
|
|
1701
|
-
refreshPath(path);
|
|
1702
|
-
} catch(e) { log('error', `PUSH ${path} failed:`, e.message); showStatus('push-status', e.message, false, performance.now() - t0); }
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
// ── Batch ───────────────────────────────────────────────────────────────────────
|
|
1706
|
-
async function doBatch() {
|
|
1707
|
-
const raw = document.getElementById('batch-value').value.trim();
|
|
1708
|
-
let operations;
|
|
1709
|
-
try { operations = JSON.parse(raw); } catch { return showStatus('batch-status', 'Invalid JSON array', false); }
|
|
1710
|
-
if (!Array.isArray(operations)) return showStatus('batch-status', 'Must be a JSON array', false);
|
|
1711
|
-
log('info', `BATCH ${operations.length} ops`, operations);
|
|
1712
|
-
const t0 = performance.now();
|
|
1713
|
-
try {
|
|
1714
|
-
const result = await db.batch(operations);
|
|
1715
|
-
showStatus('batch-status', `Batch OK (${operations.length} ops)`, true, performance.now() - t0);
|
|
1716
|
-
const resultEl = document.getElementById('batch-result');
|
|
1717
|
-
if (result) { resultEl.textContent = JSON.stringify(result, null, 2); resultEl.style.display = 'block'; }
|
|
1718
|
-
else { resultEl.style.display = 'none'; }
|
|
1719
|
-
// Refresh affected paths
|
|
1720
|
-
const batchPaths = new Set(operations.map(op => (op.path || Object.keys(op.updates || {})[0] || '').split('/')[0]).filter(Boolean));
|
|
1721
|
-
for (const p of batchPaths) refreshPath(p);
|
|
1722
|
-
} catch(e) { log('error', 'BATCH failed:', e.message); showStatus('batch-status', e.message, false, performance.now() - t0); }
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
// ── Query Builder ──────────────────────────────────────────────────────────────
|
|
1726
|
-
function addFilter() {
|
|
1727
|
-
const container = document.getElementById('q-filters');
|
|
1728
|
-
const row = document.createElement('div');
|
|
1729
|
-
row.className = 'filter-row';
|
|
1730
|
-
row.innerHTML = `
|
|
1731
|
-
<input type="text" placeholder="field" style="flex:2">
|
|
1732
|
-
<select style="flex:1"><option>==</option><option>!=</option><option>></option><option>>=</option><option><</option><option><=</option><option>contains</option><option>startsWith</option></select>
|
|
1733
|
-
<input type="text" placeholder="value" style="flex:2">
|
|
1734
|
-
<button onclick="this.parentElement.remove()" class="danger sm">✕</button>`;
|
|
1735
|
-
container.appendChild(row);
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
async function doQuery() {
|
|
1739
|
-
const path = document.getElementById('q-path').value.trim();
|
|
1740
|
-
const filterRows = document.querySelectorAll('#q-filters .filter-row');
|
|
1741
|
-
const filters = [...filterRows].map(r => {
|
|
1742
|
-
const inputs = r.querySelectorAll('input');
|
|
1743
|
-
const op = r.querySelector('select').value;
|
|
1744
|
-
let val = inputs[1].value.trim();
|
|
1745
|
-
try { val = JSON.parse(val); } catch {}
|
|
1746
|
-
return { field: inputs[0].value.trim(), op, value: val };
|
|
1747
|
-
}).filter(f => f.field);
|
|
1748
|
-
const orderField = document.getElementById('q-order-field').value.trim();
|
|
1749
|
-
const orderDir = document.getElementById('q-order-dir').value;
|
|
1750
|
-
const limit = document.getElementById('q-limit').value.trim();
|
|
1751
|
-
const offset = document.getElementById('q-offset').value.trim();
|
|
1752
|
-
let q = db.query(path);
|
|
1753
|
-
for (const f of filters) q = q.where(f.field, f.op, f.value);
|
|
1754
|
-
if (orderField) q = q.order(orderField, orderDir);
|
|
1755
|
-
if (limit) q = q.limit(Number(limit));
|
|
1756
|
-
if (offset) q = q.offset(Number(offset));
|
|
1757
|
-
const t0 = performance.now();
|
|
1758
|
-
const res = await q.get();
|
|
1759
|
-
showStatus('q-status', Array.isArray(res) ? `${res.length} results` : 'Error', Array.isArray(res), performance.now() - t0);
|
|
1760
|
-
document.getElementById('q-result').innerHTML = renderTable(res);
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
function renderTable(data) {
|
|
1764
|
-
if (!Array.isArray(data) || !data.length) return '<div class="result">No results</div>';
|
|
1765
|
-
const allKeys = [...new Set(data.flatMap(r => Object.keys(r)))];
|
|
1766
|
-
const ths = allKeys.map(k => `<th>${escHtml(k)}</th>`).join('');
|
|
1767
|
-
const trs = data.map(r => `<tr>${allKeys.map(k => `<td>${escHtml(r[k] == null ? '' : String(r[k]))}</td>`).join('')}</tr>`).join('');
|
|
1768
|
-
return `<div style="overflow:auto;margin-top:6px"><table><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table></div>`;
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
|
-
// ── Subscriptions (via ZuzDB WS) ───────────────────────────────────────────────
|
|
1772
|
-
const activeSubs = {};
|
|
1773
|
-
const EVENT_BADGE = { added: 'color:#4ec9b0', changed: 'color:#569cd6', removed: 'color:#f48771', value: 'color:#9cdcfe' };
|
|
1774
|
-
|
|
1775
|
-
function doSubscribe(mode = 'value') {
|
|
1776
|
-
const path = document.getElementById('sub-path').value.trim();
|
|
1777
|
-
const subKey = `${mode}:${path}`;
|
|
1778
|
-
if (!path || activeSubs[subKey]) return;
|
|
1779
|
-
const id = 'sub-' + Math.random().toString(36).slice(2);
|
|
1780
|
-
const div = document.createElement('div');
|
|
1781
|
-
div.className = 'sub-item'; div.id = id;
|
|
1782
|
-
div.innerHTML = `
|
|
1783
|
-
<div class="sub-item-header">
|
|
1784
|
-
<b>${escHtml(path)}</b>
|
|
1785
|
-
<span style="color:#555;font-size:11px;margin-left:4px">[${mode}]</span>
|
|
1786
|
-
<button class="danger sm" onclick="doUnsubscribe('${id}','${subKey}')">✕</button>
|
|
1787
|
-
</div>
|
|
1788
|
-
<div class="sub-log" id="log-${id}"><div style="color:#555">Waiting for events…</div></div>`;
|
|
1789
|
-
document.getElementById('sub-list').appendChild(div);
|
|
1790
|
-
|
|
1791
|
-
function appendEntry(type, label, val) {
|
|
1792
|
-
const logEl = document.getElementById('log-' + id);
|
|
1793
|
-
const time = new Date().toLocaleTimeString();
|
|
1794
|
-
const entry = document.createElement('div');
|
|
1795
|
-
const style = EVENT_BADGE[type] ?? 'color:#9cdcfe';
|
|
1796
|
-
entry.innerHTML = `<span>${time}</span><span style="${style};margin-right:6px">${escHtml(type)}</span><span style="color:#666;margin-right:4px">${escHtml(label)}</span>${escHtml(JSON.stringify(val))}`;
|
|
1797
|
-
logEl.prepend(entry);
|
|
1798
|
-
if (logEl.children.length > 50) logEl.lastChild.remove();
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
let unsub;
|
|
1802
|
-
if (mode === 'child') {
|
|
1803
|
-
unsub = db.onChild(path, (ev) => appendEntry(ev.type, ev.key, ev.val()));
|
|
1804
|
-
} else {
|
|
1805
|
-
unsub = db.on(path, (snap) => appendEntry('value', snap.path, snap.val()));
|
|
1806
|
-
}
|
|
1807
|
-
activeSubs[subKey] = { unsub, id };
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
function doUnsubscribe(id, subKey) {
|
|
1811
|
-
activeSubs[subKey]?.unsub();
|
|
1812
|
-
delete activeSubs[subKey];
|
|
1813
|
-
document.getElementById(id)?.remove();
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
|
-
// ── Auth & Rules ───────────────────────────────────────────────────────────────
|
|
1817
|
-
let _authCtx = null;
|
|
1818
|
-
|
|
1819
|
-
async function loadRules() {
|
|
1820
|
-
const el = document.getElementById('auth-rules');
|
|
1821
|
-
try {
|
|
1822
|
-
const rules = await db.getRules();
|
|
1823
|
-
el.textContent = rules.map(r => {
|
|
1824
|
-
const parts = [];
|
|
1825
|
-
if (r.read !== null) parts.push(`read: ${typeof r.read === 'string' ? r.read : r.read}`);
|
|
1826
|
-
if (r.write !== null) parts.push(`write: ${typeof r.write === 'string' ? r.write : r.write}`);
|
|
1827
|
-
return `${r.pattern.padEnd(20)} → ${parts.join(', ')}`;
|
|
1828
|
-
}).join('\n') || '(no rules configured)';
|
|
1829
|
-
} catch { el.textContent = 'Failed to load rules'; }
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
function _updateAuthStatus(ctx) {
|
|
1833
|
-
const summary = ctx ? `${escHtml(JSON.stringify(ctx))}` : 'Not authenticated';
|
|
1834
|
-
const color = ctx ? '#4ec9b0' : '#555';
|
|
1835
|
-
document.getElementById('auth-status').innerHTML = `<span style="color:${color}">${ctx ? 'Authenticated' : 'Not authenticated'}</span>${ctx ? ' — ' + summary : ''}`;
|
|
1836
|
-
document.getElementById('rw-auth-status').innerHTML = ctx
|
|
1837
|
-
? `<span style="color:#4ec9b0">${escHtml(JSON.stringify(ctx))}</span>`
|
|
1838
|
-
: '<span style="color:#555">none</span>';
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
async function _applyAuth(token) {
|
|
1842
|
-
if (!token) {
|
|
1843
|
-
_authCtx = null;
|
|
1844
|
-
db.disconnect(); db._closed = false; db.connect();
|
|
1845
|
-
_updateAuthStatus(null);
|
|
1846
|
-
log('info', 'Auth cleared — reconnecting');
|
|
1847
|
-
return;
|
|
1848
|
-
}
|
|
1849
|
-
try {
|
|
1850
|
-
const ctx = await db._send('auth', { token });
|
|
1851
|
-
_authCtx = ctx;
|
|
1852
|
-
_updateAuthStatus(ctx);
|
|
1853
|
-
log('info', 'Auth success', ctx);
|
|
1854
|
-
} catch(e) {
|
|
1855
|
-
_authCtx = null;
|
|
1856
|
-
_updateAuthStatus(null);
|
|
1857
|
-
document.getElementById('auth-status').innerHTML = `<span style="color:#f48771">Auth failed: ${escHtml(e.message)}</span>`;
|
|
1858
|
-
document.getElementById('rw-auth-status').innerHTML = `<span style="color:#f48771">failed</span>`;
|
|
1859
|
-
log('warn', 'Auth failed:', e.message);
|
|
1860
|
-
}
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
async function doAuth() {
|
|
1864
|
-
const token = document.getElementById('auth-token').value.trim();
|
|
1865
|
-
await _applyAuth(token);
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
async function doRwAuth() {
|
|
1869
|
-
const token = document.getElementById('rw-auth-token').value.trim();
|
|
1870
|
-
document.getElementById('auth-token').value = token; // keep in sync
|
|
1871
|
-
await _applyAuth(token);
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
function doDeauth() {
|
|
1875
|
-
document.getElementById('auth-token').value = '';
|
|
1876
|
-
document.getElementById('rw-auth-token').value = '';
|
|
1877
|
-
_applyAuth('');
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
async function doTestRule() {
|
|
1881
|
-
const path = document.getElementById('auth-test-path').value.trim();
|
|
1882
|
-
const op = document.getElementById('auth-test-op').value;
|
|
1883
|
-
const resultEl = document.getElementById('auth-test-result');
|
|
1884
|
-
if (!path) return;
|
|
1885
|
-
try {
|
|
1886
|
-
// Attempt the op — if it throws PERMISSION_DENIED we know it's blocked
|
|
1887
|
-
if (op === 'read') {
|
|
1888
|
-
await db.get(path);
|
|
1889
|
-
resultEl.innerHTML = `<span style="color:#4ec9b0">ALLOWED</span> — read ${escHtml(path)}`;
|
|
1890
|
-
} else {
|
|
1891
|
-
const existing = await db.get(path);
|
|
1892
|
-
await db.set(path, existing); // write same value back — non-destructive test
|
|
1893
|
-
resultEl.innerHTML = `<span style="color:#4ec9b0">ALLOWED</span> — write ${escHtml(path)}`;
|
|
1894
|
-
}
|
|
1895
|
-
} catch(e) {
|
|
1896
|
-
const denied = e.message?.includes('Permission denied') || e.message?.includes('denied');
|
|
1897
|
-
resultEl.innerHTML = `<span style="color:${denied ? '#f48771' : '#ce9178'}">${denied ? 'DENIED' : 'ERROR'}</span> — ${escHtml(e.message)}`;
|
|
1898
|
-
}
|
|
1899
|
-
}
|
|
1900
|
-
|
|
1901
|
-
// ── Quick-fill helpers ───────────────────────────────────────────────────────────
|
|
1902
|
-
function fillTransform(path, type, value) {
|
|
1903
|
-
document.getElementById('tf-path').value = path;
|
|
1904
|
-
document.getElementById('tf-type').value = type;
|
|
1905
|
-
document.getElementById('tf-value').value = value;
|
|
1906
|
-
}
|
|
1907
|
-
function fillTTL(path, value, seconds) {
|
|
1908
|
-
document.getElementById('ttl-path').value = path;
|
|
1909
|
-
document.getElementById('ttl-value').value = value;
|
|
1910
|
-
document.getElementById('ttl-seconds').value = seconds;
|
|
1911
|
-
}
|
|
1912
|
-
function fillFts(query, prefix) {
|
|
1913
|
-
document.getElementById('fts-query').value = query;
|
|
1914
|
-
document.getElementById('fts-prefix').value = prefix || '';
|
|
1915
|
-
}
|
|
1916
|
-
function fillFtsIndex(path, content) {
|
|
1917
|
-
document.getElementById('fts-path').value = path;
|
|
1918
|
-
document.getElementById('fts-content').value = content;
|
|
1919
|
-
}
|
|
1920
|
-
function fillVecSearch(prefix) {
|
|
1921
|
-
// Fill with alice's embedding pattern for easy testing
|
|
1922
|
-
const q = JSON.stringify(Array.from({length: 384}, (_, i) => +(Math.sin(i * 0.1) * 0.5).toFixed(4)));
|
|
1923
|
-
document.getElementById('vec-query').value = q;
|
|
1924
|
-
document.getElementById('vec-prefix').value = prefix || '';
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
// ── Transforms ──────────────────────────────────────────────────────────────────
|
|
1928
|
-
async function doTransform() {
|
|
1929
|
-
const path = document.getElementById('tf-path').value.trim();
|
|
1930
|
-
const type = document.getElementById('tf-type').value;
|
|
1931
|
-
const rawVal = document.getElementById('tf-value').value.trim();
|
|
1932
|
-
let value;
|
|
1933
|
-
if (type === 'serverTimestamp') {
|
|
1934
|
-
value = null;
|
|
1935
|
-
} else if (type === 'increment') {
|
|
1936
|
-
value = Number(rawVal) || 0;
|
|
1937
|
-
} else {
|
|
1938
|
-
try { value = JSON.parse(rawVal); } catch { return showStatus('tf-status', 'Invalid JSON value', false); }
|
|
1939
|
-
}
|
|
1940
|
-
const t0 = performance.now();
|
|
1941
|
-
try {
|
|
1942
|
-
const result = await db.transform(path, type, value);
|
|
1943
|
-
showStatus('tf-status', `${type} applied`, true, performance.now() - t0);
|
|
1944
|
-
const el = document.getElementById('tf-result');
|
|
1945
|
-
el.textContent = JSON.stringify(result, null, 2); el.style.display = 'block';
|
|
1946
|
-
refreshPath(path);
|
|
1947
|
-
} catch(e) { showStatus('tf-status', e.message, false, performance.now() - t0); }
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
|
-
// ── TTL ─────────────────────────────────────────────────────────────────────────
|
|
1951
|
-
async function doSetTTL() {
|
|
1952
|
-
const path = document.getElementById('ttl-path').value.trim();
|
|
1953
|
-
const ttl = Number(document.getElementById('ttl-seconds').value) || 60;
|
|
1954
|
-
const raw = document.getElementById('ttl-value').value.trim();
|
|
1955
|
-
let value;
|
|
1956
|
-
try { value = JSON.parse(raw); } catch { return showStatus('ttl-status', 'Invalid JSON', false); }
|
|
1957
|
-
const t0 = performance.now();
|
|
1958
|
-
try {
|
|
1959
|
-
await db.setTTL(path, value, ttl);
|
|
1960
|
-
showStatus('ttl-status', `Set with TTL=${ttl}s`, true, performance.now() - t0);
|
|
1961
|
-
refreshPath(path);
|
|
1962
|
-
} catch(e) { showStatus('ttl-status', e.message, false, performance.now() - t0); }
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
async function doSweep() {
|
|
1966
|
-
const t0 = performance.now();
|
|
1967
|
-
try {
|
|
1968
|
-
const expired = await db.sweep();
|
|
1969
|
-
showStatus('ttl-status', `Swept ${expired?.length ?? 0} entries`, true, performance.now() - t0);
|
|
1970
|
-
if (expired?.length) loadTree();
|
|
1971
|
-
} catch(e) { showStatus('ttl-status', e.message, false, performance.now() - t0); }
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
// ── FTS ─────────────────────────────────────────────────────────────────────────
|
|
1975
|
-
async function doFtsIndex() {
|
|
1976
|
-
const path = document.getElementById('fts-path').value.trim();
|
|
1977
|
-
const content = document.getElementById('fts-content').value.trim();
|
|
1978
|
-
if (!path || !content) return showStatus('fts-status', 'Path and content required', false);
|
|
1979
|
-
const t0 = performance.now();
|
|
1980
|
-
try {
|
|
1981
|
-
await db.ftsIndex(path, content);
|
|
1982
|
-
showStatus('fts-status', 'Indexed', true, performance.now() - t0);
|
|
1983
|
-
} catch(e) { showStatus('fts-status', e.message, false, performance.now() - t0); }
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
async function doFtsSearch() {
|
|
1987
|
-
const text = document.getElementById('fts-query').value.trim();
|
|
1988
|
-
const pathPrefix = document.getElementById('fts-prefix').value.trim() || undefined;
|
|
1989
|
-
if (!text) return showStatus('fts-status', 'Enter a search query', false);
|
|
1990
|
-
const t0 = performance.now();
|
|
1991
|
-
try {
|
|
1992
|
-
const results = await db.ftsSearch(text, pathPrefix);
|
|
1993
|
-
const el = document.getElementById('fts-result');
|
|
1994
|
-
showStatus('fts-status', `${results.length} results`, true, performance.now() - t0);
|
|
1995
|
-
el.textContent = JSON.stringify(results, null, 2); el.style.display = 'block';
|
|
1996
|
-
} catch(e) { showStatus('fts-status', e.message, false, performance.now() - t0); }
|
|
1997
|
-
}
|
|
1998
|
-
|
|
1999
|
-
// ── Vectors ─────────────────────────────────────────────────────────────────────
|
|
2000
|
-
async function doVecStore() {
|
|
2001
|
-
const path = document.getElementById('vec-path').value.trim();
|
|
2002
|
-
const raw = document.getElementById('vec-embedding').value.trim();
|
|
2003
|
-
let embedding;
|
|
2004
|
-
try { embedding = JSON.parse(raw); } catch { return showStatus('vec-status', 'Invalid JSON array', false); }
|
|
2005
|
-
if (!Array.isArray(embedding)) return showStatus('vec-status', 'Embedding must be an array', false);
|
|
2006
|
-
const t0 = performance.now();
|
|
2007
|
-
try {
|
|
2008
|
-
await db.vecStore(path, embedding);
|
|
2009
|
-
showStatus('vec-status', `Stored (${embedding.length}d)`, true, performance.now() - t0);
|
|
2010
|
-
} catch(e) { showStatus('vec-status', e.message, false, performance.now() - t0); }
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
async function doVecSearch() {
|
|
2014
|
-
const raw = document.getElementById('vec-query').value.trim();
|
|
2015
|
-
const pathPrefix = document.getElementById('vec-prefix').value.trim() || undefined;
|
|
2016
|
-
const limit = Number(document.getElementById('vec-limit').value) || 5;
|
|
2017
|
-
let query;
|
|
2018
|
-
try { query = JSON.parse(raw); } catch { return showStatus('vec-status', 'Invalid JSON array', false); }
|
|
2019
|
-
const t0 = performance.now();
|
|
2020
|
-
try {
|
|
2021
|
-
const results = await db.vecSearch(query, pathPrefix, limit);
|
|
2022
|
-
const el = document.getElementById('vec-result');
|
|
2023
|
-
showStatus('vec-status', `${results.length} results`, true, performance.now() - t0);
|
|
2024
|
-
el.textContent = JSON.stringify(results, null, 2); el.style.display = 'block';
|
|
2025
|
-
} catch(e) { showStatus('vec-status', e.message, false, performance.now() - t0); }
|
|
2026
|
-
}
|
|
2027
|
-
|
|
2028
|
-
// ── Stress Tests ───────────────────────────────────────────────────────────────
|
|
2029
|
-
function setProgress(id, pct) { document.getElementById(id).style.width = pct + '%'; }
|
|
2030
|
-
function setStressResult(id, text) { document.getElementById(id).textContent = text; }
|
|
2031
|
-
function fmtStressResult({ n, elapsedMs, extraLines = [] }) {
|
|
2032
|
-
const ops = Math.round(n / (elapsedMs / 1000));
|
|
2033
|
-
return [`ops: ${n}`, `time: ${elapsedMs.toFixed(1)}ms`, `throughput: ${ops.toLocaleString()} ops/s`, ...extraLines].join('\n');
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
async function runSeqWrites() {
|
|
2037
|
-
const n = Number(document.getElementById('sw-n').value);
|
|
2038
|
-
setStressResult('sw-result', 'Running…'); setProgress('sw-prog', 0);
|
|
2039
|
-
const t0 = performance.now();
|
|
2040
|
-
const batch = 50;
|
|
2041
|
-
for (let i = 0; i < n; i += batch) {
|
|
2042
|
-
const end = Math.min(i + batch, n);
|
|
2043
|
-
await Promise.all(Array.from({ length: end - i }, (_, j) => db.set(`stress/seq/${i+j}`, { v: i+j, ts: Date.now() })));
|
|
2044
|
-
setProgress('sw-prog', Math.round((end / n) * 100));
|
|
2045
|
-
}
|
|
2046
|
-
setStressResult('sw-result', fmtStressResult({ n, elapsedMs: performance.now() - t0 }));
|
|
2047
|
-
setProgress('sw-prog', 100); loadTree();
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
async function runBurstReads() {
|
|
2051
|
-
const n = Number(document.getElementById('br-n').value);
|
|
2052
|
-
setStressResult('br-result', 'Seeding…');
|
|
2053
|
-
await db.set('stress/read-target', { hello: 'world', x: 42 });
|
|
2054
|
-
setStressResult('br-result', 'Running…'); setProgress('br-prog', 0);
|
|
2055
|
-
const t0 = performance.now(); const latencies = [];
|
|
2056
|
-
const batch = 100;
|
|
2057
|
-
for (let i = 0; i < n; i += batch) {
|
|
2058
|
-
const end = Math.min(i + batch, n); const bt = performance.now();
|
|
2059
|
-
await Promise.all(Array.from({ length: end - i }, () => db.get('stress/read-target')));
|
|
2060
|
-
latencies.push(performance.now() - bt);
|
|
2061
|
-
setProgress('br-prog', Math.round((end / n) * 100));
|
|
2062
|
-
}
|
|
2063
|
-
const elapsedMs = performance.now() - t0;
|
|
2064
|
-
const avgBatch = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
|
2065
|
-
setStressResult('br-result', fmtStressResult({ n, elapsedMs, extraLines: [`avg batch: ${avgBatch.toFixed(1)}ms`] }));
|
|
2066
|
-
setProgress('br-prog', 100);
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
async function runMixed() {
|
|
2070
|
-
const n = Number(document.getElementById('mw-n').value);
|
|
2071
|
-
const writePct = Number(document.getElementById('mw-wp').value) / 100;
|
|
2072
|
-
setStressResult('mw-result', 'Running…'); setProgress('mw-prog', 0);
|
|
2073
|
-
const t0 = performance.now(); let writes = 0, reads = 0;
|
|
2074
|
-
const batch = 50;
|
|
2075
|
-
for (let i = 0; i < n; i += batch) {
|
|
2076
|
-
const end = Math.min(i + batch, n);
|
|
2077
|
-
await Promise.all(Array.from({ length: end - i }, (_, j) => {
|
|
2078
|
-
if (Math.random() < writePct) { writes++; return db.set(`stress/mixed/${i+j}`, i+j); }
|
|
2079
|
-
reads++; return db.get(`stress/mixed/${(i+j) % Math.max(1, i)}`);
|
|
2080
|
-
}));
|
|
2081
|
-
setProgress('mw-prog', Math.round((end / n) * 100));
|
|
2082
|
-
}
|
|
2083
|
-
setStressResult('mw-result', fmtStressResult({ n, elapsedMs: performance.now() - t0, extraLines: [`writes: ${writes}`, `reads: ${reads}`] }));
|
|
2084
|
-
setProgress('mw-prog', 100); loadTree();
|
|
2085
|
-
}
|
|
2086
|
-
|
|
2087
|
-
async function runQueryLoad() {
|
|
2088
|
-
const n = Number(document.getElementById('ql-n').value);
|
|
2089
|
-
setStressResult('ql-result', 'Seeding ' + n + ' records…'); setProgress('ql-prog', 5);
|
|
2090
|
-
const batch = 50;
|
|
2091
|
-
for (let i = 0; i < n; i += batch) {
|
|
2092
|
-
const end = Math.min(i + batch, n);
|
|
2093
|
-
await Promise.all(Array.from({ length: end - i }, (_, j) => {
|
|
2094
|
-
const idx = i + j;
|
|
2095
|
-
return db.set(`stress/qload/u${idx}`, { name: `User${idx}`, score: Math.floor(Math.random() * 100), role: idx % 3 === 0 ? 'admin' : 'user' });
|
|
2096
|
-
}));
|
|
2097
|
-
setProgress('ql-prog', 5 + Math.round((Math.min(i + batch, n) / n) * 45));
|
|
2098
|
-
}
|
|
2099
|
-
setStressResult('ql-result', 'Running queries…');
|
|
2100
|
-
const queries = [
|
|
2101
|
-
{ filters: [{ field: 'role', op: '==', value: 'admin' }] },
|
|
2102
|
-
{ filters: [{ field: 'score', op: '>=', value: 50 }], order: { field: 'score', dir: 'desc' }, limit: 10 },
|
|
2103
|
-
{ order: { field: 'name', dir: 'asc' }, limit: 20 },
|
|
2104
|
-
];
|
|
2105
|
-
const t0 = performance.now(); const qRuns = 30;
|
|
2106
|
-
for (let i = 0; i < qRuns; i++) {
|
|
2107
|
-
const qp = queries[i % queries.length];
|
|
2108
|
-
let q = db.query('stress/qload');
|
|
2109
|
-
for (const f of qp.filters ?? []) q = q.where(f.field, f.op, f.value);
|
|
2110
|
-
if (qp.order) q = q.order(qp.order.field, qp.order.dir);
|
|
2111
|
-
if (qp.limit) q = q.limit(qp.limit);
|
|
2112
|
-
await q.get();
|
|
2113
|
-
setProgress('ql-prog', 50 + Math.round((i / qRuns) * 50));
|
|
2114
|
-
}
|
|
2115
|
-
const elapsedMs = performance.now() - t0;
|
|
2116
|
-
setStressResult('ql-result', fmtStressResult({ n: qRuns, elapsedMs, extraLines: [`records: ${n}`, `avg/query: ${(elapsedMs/qRuns).toFixed(1)}ms`] }));
|
|
2117
|
-
setProgress('ql-prog', 100);
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
async function runBulkUpdate() {
|
|
2121
|
-
const keysPerBatch = Number(document.getElementById('bu-n').value);
|
|
2122
|
-
const batches = Number(document.getElementById('bu-b').value);
|
|
2123
|
-
setStressResult('bu-result', 'Running…'); setProgress('bu-prog', 0);
|
|
2124
|
-
const t0 = performance.now();
|
|
2125
|
-
for (let b = 0; b < batches; b++) {
|
|
2126
|
-
const updates = {};
|
|
2127
|
-
for (let k = 0; k < keysPerBatch; k++) updates[`stress/bulk/k${k}`] = { batch: b, val: k * b };
|
|
2128
|
-
await db.update(updates);
|
|
2129
|
-
setProgress('bu-prog', Math.round(((b + 1) / batches) * 100));
|
|
2130
|
-
}
|
|
2131
|
-
const elapsedMs = performance.now() - t0;
|
|
2132
|
-
setStressResult('bu-result', fmtStressResult({ n: keysPerBatch * batches, elapsedMs, extraLines: [`batches: ${batches}`, `keys/batch: ${keysPerBatch}`] }));
|
|
2133
|
-
setProgress('bu-prog', 100); loadTree();
|
|
2134
|
-
}
|
|
2135
|
-
|
|
2136
|
-
async function runDeepPaths() {
|
|
2137
|
-
const n = Number(document.getElementById('dp-n').value);
|
|
2138
|
-
const depth = Number(document.getElementById('dp-d').value);
|
|
2139
|
-
setStressResult('dp-result', 'Running…'); setProgress('dp-prog', 0);
|
|
2140
|
-
const t0 = performance.now(); const batch = 20;
|
|
2141
|
-
for (let i = 0; i < n; i += batch) {
|
|
2142
|
-
const end = Math.min(i + batch, n);
|
|
2143
|
-
await Promise.all(Array.from({ length: end - i }, (_, j) => {
|
|
2144
|
-
const idx = i + j;
|
|
2145
|
-
const segs = Array.from({ length: depth }, (_, d) => `l${d}_${idx % (10 + d)}`);
|
|
2146
|
-
return db.set('stress/deep/' + segs.join('/'), idx);
|
|
2147
|
-
}));
|
|
2148
|
-
setProgress('dp-prog', Math.round((end / n) * 100));
|
|
2149
|
-
}
|
|
2150
|
-
setStressResult('dp-result', fmtStressResult({ n, elapsedMs: performance.now() - t0, extraLines: [`depth: ${depth}`] }));
|
|
2151
|
-
setProgress('dp-prog', 100); loadTree();
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
|
-
// ── Streams ────────────────────────────────────────────────────────────────────
|
|
2155
|
-
function fillCompact(path, age, count, key) {
|
|
2156
|
-
document.getElementById('st-compact-path').value = path;
|
|
2157
|
-
document.getElementById('st-compact-age').value = age;
|
|
2158
|
-
document.getElementById('st-compact-count').value = count;
|
|
2159
|
-
document.getElementById('st-compact-key').value = key;
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
async function doStreamSnapshot() {
|
|
2163
|
-
const path = document.getElementById('st-snap-path').value.trim();
|
|
2164
|
-
if (!path) return showStatus('st-snap-status', 'Path required', false);
|
|
2165
|
-
const t0 = performance.now();
|
|
2166
|
-
try {
|
|
2167
|
-
const snap = await db.streamSnapshot(path);
|
|
2168
|
-
const el = document.getElementById('st-snap-result');
|
|
2169
|
-
if (snap) {
|
|
2170
|
-
showStatus('st-snap-status', `Snapshot at key ${snap.key} — ${Object.keys(snap.data).length} entries`, true, performance.now() - t0);
|
|
2171
|
-
el.textContent = JSON.stringify(snap, null, 2);
|
|
2172
|
-
} else {
|
|
2173
|
-
showStatus('st-snap-status', 'No snapshot (not yet compacted)', true, performance.now() - t0);
|
|
2174
|
-
el.textContent = 'null';
|
|
2175
|
-
}
|
|
2176
|
-
el.style.display = 'block';
|
|
2177
|
-
} catch(e) { showStatus('st-snap-status', e.message, false, performance.now() - t0); }
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
async function doStreamMaterialize() {
|
|
2181
|
-
const path = document.getElementById('st-snap-path').value.trim();
|
|
2182
|
-
const keepKey = document.getElementById('st-snap-keepkey').value.trim() || undefined;
|
|
2183
|
-
if (!path) return showStatus('st-snap-status', 'Path required', false);
|
|
2184
|
-
const t0 = performance.now();
|
|
2185
|
-
try {
|
|
2186
|
-
const view = await db.streamMaterialize(path, keepKey);
|
|
2187
|
-
const keys = Object.keys(view);
|
|
2188
|
-
showStatus('st-snap-status', `Materialized view — ${keys.length} entries`, true, performance.now() - t0);
|
|
2189
|
-
const el = document.getElementById('st-snap-result');
|
|
2190
|
-
el.textContent = JSON.stringify(view, null, 2);
|
|
2191
|
-
el.style.display = 'block';
|
|
2192
|
-
} catch(e) { showStatus('st-snap-status', e.message, false, performance.now() - t0); }
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
async function doStreamCompact() {
|
|
2196
|
-
const path = document.getElementById('st-compact-path').value.trim();
|
|
2197
|
-
const maxAge = document.getElementById('st-compact-age').value.trim();
|
|
2198
|
-
const maxCount = document.getElementById('st-compact-count').value.trim();
|
|
2199
|
-
const keepKey = document.getElementById('st-compact-key').value.trim();
|
|
2200
|
-
if (!path) return showStatus('st-compact-status', 'Path required', false);
|
|
2201
|
-
const opts = {};
|
|
2202
|
-
if (maxAge) opts.maxAge = Number(maxAge);
|
|
2203
|
-
if (maxCount) opts.maxCount = Number(maxCount);
|
|
2204
|
-
if (keepKey) opts.keepKey = keepKey;
|
|
2205
|
-
if (!Object.keys(opts).length) return showStatus('st-compact-status', 'At least one option required', false);
|
|
2206
|
-
const t0 = performance.now();
|
|
2207
|
-
try {
|
|
2208
|
-
const result = await db.streamCompact(path, opts);
|
|
2209
|
-
showStatus('st-compact-status', `Compacted — folded ${result.deleted} events into snapshot (${result.snapshotSize} keys)`, true, performance.now() - t0);
|
|
2210
|
-
refreshPath(path);
|
|
2211
|
-
} catch(e) { showStatus('st-compact-status', e.message, false, performance.now() - t0); }
|
|
2212
|
-
}
|
|
2213
|
-
|
|
2214
|
-
async function doStreamReset() {
|
|
2215
|
-
const path = document.getElementById('st-compact-path').value.trim();
|
|
2216
|
-
if (!path) return showStatus('st-compact-status', 'Path required', false);
|
|
2217
|
-
if (!confirm(`Reset "${path}"? This deletes ALL events, snapshot, and consumer offsets.`)) return;
|
|
2218
|
-
try {
|
|
2219
|
-
await db.streamReset(path);
|
|
2220
|
-
showStatus('st-compact-status', `Reset "${path}" — all events, snapshot, and offsets deleted`, true);
|
|
2221
|
-
refreshPath(path);
|
|
2222
|
-
} catch(e) { showStatus('st-compact-status', e.message, false); }
|
|
2223
|
-
}
|
|
2224
|
-
|
|
2225
|
-
function fillStreamPush(path, value, idem) {
|
|
2226
|
-
document.getElementById('st-push-path').value = path;
|
|
2227
|
-
document.getElementById('st-push-value').value = value;
|
|
2228
|
-
document.getElementById('st-push-idem').value = idem;
|
|
2229
|
-
}
|
|
2230
|
-
|
|
2231
|
-
async function doStreamPush() {
|
|
2232
|
-
const path = document.getElementById('st-push-path').value.trim();
|
|
2233
|
-
const raw = document.getElementById('st-push-value').value.trim();
|
|
2234
|
-
const idem = document.getElementById('st-push-idem').value.trim();
|
|
2235
|
-
let value;
|
|
2236
|
-
try { value = JSON.parse(raw); } catch { return showStatus('st-push-status', 'Invalid JSON', false); }
|
|
2237
|
-
const t0 = performance.now();
|
|
2238
|
-
try {
|
|
2239
|
-
const params = { path, value };
|
|
2240
|
-
if (idem) params.idempotencyKey = idem;
|
|
2241
|
-
const key = await db._send('push', params);
|
|
2242
|
-
showStatus('st-push-status', `Pushed → ${key}${idem ? ' (idempotent)' : ''}`, true, performance.now() - t0);
|
|
2243
|
-
refreshPath(path);
|
|
2244
|
-
} catch(e) { showStatus('st-push-status', e.message, false, performance.now() - t0); }
|
|
2245
|
-
}
|
|
2246
|
-
|
|
2247
|
-
async function doStreamRead() {
|
|
2248
|
-
const path = document.getElementById('st-read-path').value.trim();
|
|
2249
|
-
const groupId = document.getElementById('st-read-group').value.trim();
|
|
2250
|
-
const limit = Number(document.getElementById('st-read-limit').value) || 50;
|
|
2251
|
-
if (!path || !groupId) return showStatus('st-read-status', 'Path and group ID required', false);
|
|
2252
|
-
const t0 = performance.now();
|
|
2253
|
-
try {
|
|
2254
|
-
const events = await db.streamRead(path, groupId, limit);
|
|
2255
|
-
showStatus('st-read-status', `${events.length} events`, true, performance.now() - t0);
|
|
2256
|
-
const el = document.getElementById('st-read-result');
|
|
2257
|
-
el.textContent = JSON.stringify(events, null, 2);
|
|
2258
|
-
el.style.display = 'block';
|
|
2259
|
-
// Auto-fill ack key with last event
|
|
2260
|
-
if (events.length > 0) {
|
|
2261
|
-
document.getElementById('st-ack-key').value = events[events.length - 1].key;
|
|
2262
|
-
document.getElementById('st-ack-path').value = path;
|
|
2263
|
-
document.getElementById('st-ack-group').value = groupId;
|
|
2264
|
-
}
|
|
2265
|
-
} catch(e) { showStatus('st-read-status', e.message, false, performance.now() - t0); }
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
async function doStreamAck() {
|
|
2269
|
-
const path = document.getElementById('st-ack-path').value.trim();
|
|
2270
|
-
const groupId = document.getElementById('st-ack-group').value.trim();
|
|
2271
|
-
const key = document.getElementById('st-ack-key').value.trim();
|
|
2272
|
-
if (!path || !groupId || !key) return showStatus('st-ack-status', 'All fields required', false);
|
|
2273
|
-
const t0 = performance.now();
|
|
2274
|
-
try {
|
|
2275
|
-
await db.streamAck(path, groupId, key);
|
|
2276
|
-
showStatus('st-ack-status', `Acked → ${key}`, true, performance.now() - t0);
|
|
2277
|
-
} catch(e) { showStatus('st-ack-status', e.message, false, performance.now() - t0); }
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
const activeStreamSubs = {};
|
|
2281
|
-
|
|
2282
|
-
async function doStreamSub() {
|
|
2283
|
-
const path = document.getElementById('st-sub-path').value.trim();
|
|
2284
|
-
const groupId = document.getElementById('st-sub-group').value.trim();
|
|
2285
|
-
if (!path || !groupId) return;
|
|
2286
|
-
const subKey = `${path}:${groupId}`;
|
|
2287
|
-
if (activeStreamSubs[subKey]) return;
|
|
2288
|
-
|
|
2289
|
-
// Register callback
|
|
2290
|
-
if (!db._streamCbs) db._streamCbs = new Map();
|
|
2291
|
-
if (!db._streamCbs.has(subKey)) db._streamCbs.set(subKey, new Set());
|
|
2292
|
-
|
|
2293
|
-
const id = 'ssub-' + Math.random().toString(36).slice(2);
|
|
2294
|
-
const div = document.createElement('div');
|
|
2295
|
-
div.className = 'sub-item'; div.id = id;
|
|
2296
|
-
div.innerHTML = `
|
|
2297
|
-
<div class="sub-item-header">
|
|
2298
|
-
<b>${escHtml(path)}</b>
|
|
2299
|
-
<span style="color:#555;font-size:11px;margin-left:4px">[stream:${escHtml(groupId)}]</span>
|
|
2300
|
-
<button class="danger sm" onclick="doStreamUnsub('${id}','${subKey}','${path}','${groupId}')">✕</button>
|
|
2301
|
-
</div>
|
|
2302
|
-
<div class="sub-log" id="log-${id}"><div style="color:#555">Waiting for events…</div></div>`;
|
|
2303
|
-
document.getElementById('st-sub-list').appendChild(div);
|
|
2304
|
-
|
|
2305
|
-
const cb = (events) => {
|
|
2306
|
-
const logEl = document.getElementById('log-' + id);
|
|
2307
|
-
for (const ev of events) {
|
|
2308
|
-
const time = new Date().toLocaleTimeString();
|
|
2309
|
-
const entry = document.createElement('div');
|
|
2310
|
-
entry.innerHTML = `<span>${time}</span><span style="color:#4ec9b0;margin-right:6px">${escHtml(ev.key)}</span>${escHtml(JSON.stringify(ev.data))}`;
|
|
2311
|
-
logEl.prepend(entry);
|
|
2312
|
-
if (logEl.children.length > 100) logEl.lastChild.remove();
|
|
2313
|
-
}
|
|
2314
|
-
};
|
|
2315
|
-
db._streamCbs.get(subKey).add(cb);
|
|
2316
|
-
|
|
2317
|
-
try {
|
|
2318
|
-
await db.streamSub(path, groupId);
|
|
2319
|
-
activeStreamSubs[subKey] = { id, cb };
|
|
2320
|
-
} catch(e) {
|
|
2321
|
-
div.remove();
|
|
2322
|
-
db._streamCbs.get(subKey)?.delete(cb);
|
|
2323
|
-
}
|
|
2324
|
-
}
|
|
2325
|
-
|
|
2326
|
-
function doStreamUnsub(id, subKey, path, groupId) {
|
|
2327
|
-
const sub = activeStreamSubs[subKey];
|
|
2328
|
-
if (sub) {
|
|
2329
|
-
db._streamCbs?.get(subKey)?.delete(sub.cb);
|
|
2330
|
-
db.streamUnsub(path, groupId).catch(() => {});
|
|
2331
|
-
delete activeStreamSubs[subKey];
|
|
2332
|
-
}
|
|
2333
|
-
document.getElementById(id)?.remove();
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
|
-
async function doStreamDemo() {
|
|
2337
|
-
const TOPIC = 'events/demo-orders';
|
|
2338
|
-
const el = document.getElementById('st-demo-result');
|
|
2339
|
-
el.style.display = 'block';
|
|
2340
|
-
el.textContent = '';
|
|
2341
|
-
const wait = (ms = 500) => new Promise(r => setTimeout(r, ms));
|
|
2342
|
-
const log = async (s, delay = 500) => {
|
|
2343
|
-
el.textContent += (el.textContent ? '\n' : '') + s;
|
|
2344
|
-
el.scrollTop = el.scrollHeight;
|
|
2345
|
-
if (delay > 0) await wait(delay);
|
|
2346
|
-
};
|
|
2347
|
-
const t0 = performance.now();
|
|
2348
|
-
|
|
2349
|
-
try {
|
|
2350
|
-
// Clean slate
|
|
2351
|
-
await db.streamReset(TOPIC);
|
|
2352
|
-
await log('🧹 Reset topic — clean slate');
|
|
2353
|
-
|
|
2354
|
-
// 1. Push 5 order events
|
|
2355
|
-
const keys = [];
|
|
2356
|
-
for (let i = 1; i <= 5; i++) {
|
|
2357
|
-
const k = await db._send('push', { path: TOPIC, value: { orderId: `order-${i}`, amount: i * 100, status: 'created' } });
|
|
2358
|
-
keys.push(k);
|
|
2359
|
-
await log(`📥 Pushed order-${i}: ${k.slice(0,8)} ($${i * 100})`, 300);
|
|
2360
|
-
}
|
|
2361
|
-
|
|
2362
|
-
// 2. Consumer group A reads all
|
|
2363
|
-
let eventsA = await db.streamRead(TOPIC, 'group-analytics', 10);
|
|
2364
|
-
await log(`\n📊 Group "analytics" read ${eventsA.length} events`);
|
|
2365
|
-
|
|
2366
|
-
// 3. Consumer group B reads all
|
|
2367
|
-
let eventsB = await db.streamRead(TOPIC, 'group-billing', 10);
|
|
2368
|
-
await log(`💰 Group "billing" read ${eventsB.length} events (same events — fan-out!)`);
|
|
2369
|
-
|
|
2370
|
-
// 4. Analytics acks up to event 3
|
|
2371
|
-
await db.streamAck(TOPIC, 'group-analytics', keys[2]);
|
|
2372
|
-
await log(`\n📊 Analytics acked up to ${keys[2].slice(0,8)} (3 of 5 processed)`);
|
|
2373
|
-
|
|
2374
|
-
// 5. Billing acks all
|
|
2375
|
-
await db.streamAck(TOPIC, 'group-billing', keys[4]);
|
|
2376
|
-
await log(`💰 Billing acked up to ${keys[4].slice(0,8)} (all 5 processed)`);
|
|
2377
|
-
|
|
2378
|
-
// 6. Analytics re-reads — should get remaining 2
|
|
2379
|
-
eventsA = await db.streamRead(TOPIC, 'group-analytics', 10);
|
|
2380
|
-
await log(`\n📊 Analytics re-read: ${eventsA.length} unprocessed events remaining`);
|
|
2381
|
-
for (const ev of eventsA) {
|
|
2382
|
-
await log(` → ${ev.key.slice(0,8)}: order-${ev.data?.orderId?.split('-')[1]} ($${ev.data?.amount})`, 250);
|
|
2383
|
-
}
|
|
2384
|
-
|
|
2385
|
-
// 7. Billing re-reads — should get 0
|
|
2386
|
-
eventsB = await db.streamRead(TOPIC, 'group-billing', 10);
|
|
2387
|
-
await log(`💰 Billing re-read: ${eventsB.length} (all caught up)`);
|
|
2388
|
-
|
|
2389
|
-
// 8. Push more events
|
|
2390
|
-
await log('\n📥 Pushing 3 more events...');
|
|
2391
|
-
for (let i = 6; i <= 8; i++) {
|
|
2392
|
-
await db._send('push', { path: TOPIC, value: { orderId: `order-${i}`, amount: i * 100, status: 'created' } });
|
|
2393
|
-
await log(` → order-${i} ($${i * 100})`, 250);
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
|
-
// 9. Idempotent push
|
|
2397
|
-
const idemKey1 = await db._send('push', { path: TOPIC, value: { orderId: 'order-9', amount: 900 }, idempotencyKey: 'order-9-created' });
|
|
2398
|
-
await log(`\n🔑 Idempotent push order-9: ${idemKey1.slice(0,8)}`);
|
|
2399
|
-
const idemKey2 = await db._send('push', { path: TOPIC, value: { orderId: 'order-9', amount: 900 }, idempotencyKey: 'order-9-created' });
|
|
2400
|
-
await log(`🔑 Duplicate push order-9: ${idemKey2.slice(0,8)} (same key — deduped!)`);
|
|
2401
|
-
|
|
2402
|
-
// 10. Compact — fold old events into snapshot
|
|
2403
|
-
await log('\n🗜️ Compacting: keep last 3 events, keepKey=orderId...');
|
|
2404
|
-
const compactResult = await db.streamCompact(TOPIC, { maxCount: 3, keepKey: 'orderId' });
|
|
2405
|
-
await log(` Folded ${compactResult.deleted} events into snapshot (${compactResult.snapshotSize} unique orders)`);
|
|
2406
|
-
|
|
2407
|
-
// 11. View snapshot
|
|
2408
|
-
const snap = await db.streamSnapshot(TOPIC);
|
|
2409
|
-
await log(`\n📸 Snapshot has ${snap ? Object.keys(snap.data).length : 0} entries`);
|
|
2410
|
-
|
|
2411
|
-
// 12. Materialize — merged view
|
|
2412
|
-
const view = await db.streamMaterialize(TOPIC, 'orderId');
|
|
2413
|
-
const viewKeys = Object.keys(view);
|
|
2414
|
-
await log(`🔮 Materialized view: ${viewKeys.length} total orders`);
|
|
2415
|
-
for (const k of viewKeys.slice(0, 5)) {
|
|
2416
|
-
await log(` → ${k}: $${view[k]?.amount}`, 200);
|
|
2417
|
-
}
|
|
2418
|
-
if (viewKeys.length > 5) await log(` ... and ${viewKeys.length - 5} more`, 200);
|
|
2419
|
-
|
|
2420
|
-
// Cleanup
|
|
2421
|
-
await db.streamReset(TOPIC);
|
|
2422
|
-
await log('\n🧹 Cleaned up topic');
|
|
2423
|
-
|
|
2424
|
-
const ms = (performance.now() - t0).toFixed(1);
|
|
2425
|
-
await log(`🎉 Demo complete in ${ms}ms — fan-out, offsets, idempotency, compaction, materialization`, 0);
|
|
2426
|
-
showStatus('st-demo-status', 'Demo complete', true, performance.now() - t0);
|
|
2427
|
-
} catch (e) {
|
|
2428
|
-
await log(`\n❌ Error: ${e.message}`, 0);
|
|
2429
|
-
showStatus('st-demo-status', e.message, false);
|
|
2430
|
-
}
|
|
2431
|
-
}
|
|
2432
|
-
|
|
2433
|
-
// ── MQ ─────────────────────────────────────────────────────────────────────────
|
|
2434
|
-
function fillMqPush(path, value, idem) {
|
|
2435
|
-
document.getElementById('mq-push-path').value = path;
|
|
2436
|
-
document.getElementById('mq-push-value').value = value;
|
|
2437
|
-
document.getElementById('mq-push-idem').value = idem;
|
|
2438
|
-
}
|
|
2439
|
-
|
|
2440
|
-
async function doMqPush() {
|
|
2441
|
-
const path = document.getElementById('mq-push-path').value.trim();
|
|
2442
|
-
const idem = document.getElementById('mq-push-idem').value.trim();
|
|
2443
|
-
if (!path) return showStatus('mq-push-status', 'Path required', false);
|
|
2444
|
-
const t0 = performance.now();
|
|
2445
|
-
try {
|
|
2446
|
-
const value = JSON.parse(document.getElementById('mq-push-value').value);
|
|
2447
|
-
const key = await db.mqPush(path, value, idem);
|
|
2448
|
-
showStatus('mq-push-status', `Pushed → ${key}`, true, performance.now() - t0);
|
|
2449
|
-
} catch (e) { showStatus('mq-push-status', e.message, false); }
|
|
2450
|
-
}
|
|
2451
|
-
|
|
2452
|
-
async function doMqFetch() {
|
|
2453
|
-
const path = document.getElementById('mq-fetch-path').value.trim();
|
|
2454
|
-
const count = parseInt(document.getElementById('mq-fetch-count').value) || 1;
|
|
2455
|
-
if (!path) return showStatus('mq-fetch-status', 'Path required', false);
|
|
2456
|
-
const t0 = performance.now();
|
|
2457
|
-
try {
|
|
2458
|
-
const msgs = await db.mqFetch(path, count);
|
|
2459
|
-
const el = document.getElementById('mq-fetch-result');
|
|
2460
|
-
el.style.display = 'block';
|
|
2461
|
-
el.textContent = JSON.stringify(msgs, null, 2);
|
|
2462
|
-
showStatus('mq-fetch-status', `${msgs.length} message(s) claimed`, true, performance.now() - t0);
|
|
2463
|
-
// Auto-fill ack key
|
|
2464
|
-
if (msgs.length > 0) {
|
|
2465
|
-
document.getElementById('mq-ack-path').value = path;
|
|
2466
|
-
document.getElementById('mq-ack-key').value = msgs[0].key;
|
|
2467
|
-
}
|
|
2468
|
-
} catch (e) { showStatus('mq-fetch-status', e.message, false); }
|
|
2469
|
-
}
|
|
2470
|
-
|
|
2471
|
-
async function doMqAck() {
|
|
2472
|
-
const path = document.getElementById('mq-ack-path').value.trim();
|
|
2473
|
-
const key = document.getElementById('mq-ack-key').value.trim();
|
|
2474
|
-
if (!path || !key) return showStatus('mq-ack-status', 'Path and key required', false);
|
|
2475
|
-
const t0 = performance.now();
|
|
2476
|
-
try {
|
|
2477
|
-
await db.mqAck(path, key);
|
|
2478
|
-
showStatus('mq-ack-status', `Acked ${key}`, true, performance.now() - t0);
|
|
2479
|
-
} catch (e) { showStatus('mq-ack-status', e.message, false); }
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
async function doMqNack() {
|
|
2483
|
-
const path = document.getElementById('mq-ack-path').value.trim();
|
|
2484
|
-
const key = document.getElementById('mq-ack-key').value.trim();
|
|
2485
|
-
if (!path || !key) return showStatus('mq-ack-status', 'Path and key required', false);
|
|
2486
|
-
const t0 = performance.now();
|
|
2487
|
-
try {
|
|
2488
|
-
await db.mqNack(path, key);
|
|
2489
|
-
showStatus('mq-ack-status', `Nacked ${key} — released to pending`, true, performance.now() - t0);
|
|
2490
|
-
} catch (e) { showStatus('mq-ack-status', e.message, false); }
|
|
2491
|
-
}
|
|
2492
|
-
|
|
2493
|
-
async function doMqPeek() {
|
|
2494
|
-
const path = document.getElementById('mq-peek-path').value.trim();
|
|
2495
|
-
const count = parseInt(document.getElementById('mq-peek-count').value) || 10;
|
|
2496
|
-
if (!path) return showStatus('mq-peek-status', 'Path required', false);
|
|
2497
|
-
const t0 = performance.now();
|
|
2498
|
-
try {
|
|
2499
|
-
const msgs = await db.mqPeek(path, count);
|
|
2500
|
-
const el = document.getElementById('mq-peek-result');
|
|
2501
|
-
el.style.display = 'block';
|
|
2502
|
-
el.textContent = JSON.stringify(msgs, null, 2);
|
|
2503
|
-
showStatus('mq-peek-status', `${msgs.length} message(s)`, true, performance.now() - t0);
|
|
2504
|
-
} catch (e) { showStatus('mq-peek-status', e.message, false); }
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
|
-
async function doMqDlq() {
|
|
2508
|
-
const path = document.getElementById('mq-dlq-path').value.trim();
|
|
2509
|
-
if (!path) return showStatus('mq-dlq-status', 'Path required', false);
|
|
2510
|
-
const t0 = performance.now();
|
|
2511
|
-
try {
|
|
2512
|
-
const msgs = await db.mqDlq(path);
|
|
2513
|
-
const el = document.getElementById('mq-dlq-result');
|
|
2514
|
-
el.style.display = 'block';
|
|
2515
|
-
el.textContent = msgs.length ? JSON.stringify(msgs, null, 2) : '(empty)';
|
|
2516
|
-
showStatus('mq-dlq-status', `${msgs.length} dead letter(s)`, true, performance.now() - t0);
|
|
2517
|
-
} catch (e) { showStatus('mq-dlq-status', e.message, false); }
|
|
2518
|
-
}
|
|
2519
|
-
|
|
2520
|
-
async function doMqPurge() {
|
|
2521
|
-
const path = document.getElementById('mq-purge-path').value.trim();
|
|
2522
|
-
if (!path) return showStatus('mq-purge-status', 'Path required', false);
|
|
2523
|
-
const t0 = performance.now();
|
|
2524
|
-
try {
|
|
2525
|
-
const count = await db.mqPurge(path);
|
|
2526
|
-
showStatus('mq-purge-status', `Purged ${count} message(s)`, true, performance.now() - t0);
|
|
2527
|
-
} catch (e) { showStatus('mq-purge-status', e.message, false); }
|
|
2528
|
-
}
|
|
2529
|
-
|
|
2530
|
-
async function doMqDemo() {
|
|
2531
|
-
const Q = 'queues/demo';
|
|
2532
|
-
const el = document.getElementById('mq-demo-result');
|
|
2533
|
-
el.style.display = 'block';
|
|
2534
|
-
el.textContent = '';
|
|
2535
|
-
const wait = (ms = 500) => new Promise(r => setTimeout(r, ms));
|
|
2536
|
-
const log = async (s, delay = 500) => {
|
|
2537
|
-
el.textContent += (el.textContent ? '\n' : '') + s;
|
|
2538
|
-
el.scrollTop = el.scrollHeight;
|
|
2539
|
-
if (delay > 0) await wait(delay);
|
|
2540
|
-
};
|
|
2541
|
-
const t0 = performance.now();
|
|
2542
|
-
|
|
2543
|
-
try {
|
|
2544
|
-
await db.mqPurge(Q, { all: true });
|
|
2545
|
-
await log('🧹 Purged queue — clean slate');
|
|
2546
|
-
|
|
2547
|
-
// 1. Push 5 jobs
|
|
2548
|
-
const keys = [];
|
|
2549
|
-
for (let i = 1; i <= 5; i++) {
|
|
2550
|
-
const k = await db.mqPush(Q, { task: `job-${i}`, priority: i > 3 ? 'high' : 'normal' });
|
|
2551
|
-
keys.push(k);
|
|
2552
|
-
await log(`📥 Pushed job-${i}: ${k.slice(0,8)}`, 300);
|
|
2553
|
-
}
|
|
2554
|
-
|
|
2555
|
-
// 2. Peek — all pending
|
|
2556
|
-
let peek = await db.mqPeek(Q, 10);
|
|
2557
|
-
await log(`\n📋 Peek: ${peek.length} msgs — all ${peek.map(m => m.status).join(', ')}`);
|
|
2558
|
-
|
|
2559
|
-
// 3. Worker 1 fetches 3
|
|
2560
|
-
const batch1 = await db.mqFetch(Q, 3);
|
|
2561
|
-
await log(`\n👷 Worker 1 claimed ${batch1.length}: ${batch1.map(m => m.key.slice(0,8)).join(', ')}`);
|
|
2562
|
-
|
|
2563
|
-
// 4. Worker 2 fetches remaining
|
|
2564
|
-
const batch2 = await db.mqFetch(Q, 5);
|
|
2565
|
-
await log(`👷 Worker 2 claimed ${batch2.length}: ${batch2.map(m => m.key.slice(0,8)).join(', ')}`);
|
|
2566
|
-
|
|
2567
|
-
// 5. Worker 3 gets nothing
|
|
2568
|
-
const batch3 = await db.mqFetch(Q, 5);
|
|
2569
|
-
await log(`👷 Worker 3 claimed ${batch3.length} (none left!)`);
|
|
2570
|
-
|
|
2571
|
-
// 6. Peek — all inflight
|
|
2572
|
-
peek = await db.mqPeek(Q, 10);
|
|
2573
|
-
await log(`📋 Peek: all ${peek.map(m => m.status).join(', ')}`);
|
|
2574
|
-
|
|
2575
|
-
// 7. Worker 1: ack 1 & 3, nack 2
|
|
2576
|
-
await db.mqAck(Q, batch1[0].key);
|
|
2577
|
-
await log(`\n✅ Acked ${batch1[0].key.slice(0,8)} (job-1 done)`);
|
|
2578
|
-
|
|
2579
|
-
await db.mqNack(Q, batch1[1].key);
|
|
2580
|
-
await log(`🔄 Nacked ${batch1[1].key.slice(0,8)} (job-2 → back to pending)`);
|
|
2581
|
-
|
|
2582
|
-
await db.mqAck(Q, batch1[2].key);
|
|
2583
|
-
await log(`✅ Acked ${batch1[2].key.slice(0,8)} (job-3 done)`);
|
|
2584
|
-
|
|
2585
|
-
// 8. Worker 2: ack both
|
|
2586
|
-
await db.mqAck(Q, batch2[0].key);
|
|
2587
|
-
await log(`✅ Acked ${batch2[0].key.slice(0,8)} (job-4 done)`);
|
|
2588
|
-
await db.mqAck(Q, batch2[1].key);
|
|
2589
|
-
await log(`✅ Acked ${batch2[1].key.slice(0,8)} (job-5 done)`);
|
|
2590
|
-
|
|
2591
|
-
// 9. Peek — only job-2 remains
|
|
2592
|
-
peek = await db.mqPeek(Q, 10);
|
|
2593
|
-
await log(`\n📋 Remaining: ${peek.length} msg — status: ${peek[0]?.status}, deliveries: ${peek[0]?.deliveryCount}`);
|
|
2594
|
-
|
|
2595
|
-
// 10. Retry job-2 multiple times
|
|
2596
|
-
const retry1 = await db.mqFetch(Q, 1);
|
|
2597
|
-
await log(`\n🔄 Retry 1: fetched, deliveryCount=${retry1[0].deliveryCount}`);
|
|
2598
|
-
await db.mqNack(Q, retry1[0].key);
|
|
2599
|
-
await log(` → nacked back to pending`, 300);
|
|
2600
|
-
|
|
2601
|
-
const retry2 = await db.mqFetch(Q, 1);
|
|
2602
|
-
await log(`🔄 Retry 2: fetched, deliveryCount=${retry2[0].deliveryCount}`);
|
|
2603
|
-
await db.mqNack(Q, retry2[0].key);
|
|
2604
|
-
await log(` → nacked back to pending`, 300);
|
|
2605
|
-
|
|
2606
|
-
const retry3 = await db.mqFetch(Q, 1);
|
|
2607
|
-
await log(`🔄 Retry 3: fetched, deliveryCount=${retry3[0].deliveryCount}`);
|
|
2608
|
-
await log(` → simulating worker crash (left inflight)...`, 300);
|
|
2609
|
-
|
|
2610
|
-
// 11. State check
|
|
2611
|
-
await log(`\n⏳ In production, sweep reclaims expired inflight msgs every 60s`);
|
|
2612
|
-
peek = await db.mqPeek(Q, 10);
|
|
2613
|
-
await log(`📋 Peek: ${peek.length} msg — status: ${peek[0]?.status}, deliveries: ${peek[0]?.deliveryCount}`);
|
|
2614
|
-
await log(` After maxDeliveries (default 5), sweep moves to DLQ`);
|
|
2615
|
-
|
|
2616
|
-
// 12. Check DLQ
|
|
2617
|
-
const dlq = await db.mqDlq(Q);
|
|
2618
|
-
await log(`\n💀 DLQ: ${dlq.length} dead letters`);
|
|
2619
|
-
|
|
2620
|
-
// Cleanup
|
|
2621
|
-
await db.mqPurge(Q, { all: true });
|
|
2622
|
-
await log(`\n🧹 Cleaned up queue`);
|
|
2623
|
-
|
|
2624
|
-
const ms = (performance.now() - t0).toFixed(1);
|
|
2625
|
-
await log(`🎉 Demo complete in ${ms}ms — 5 pushed, 4 acked, 1 retried 3x`, 0);
|
|
2626
|
-
showStatus('mq-demo-status', `Demo complete`, true, performance.now() - t0);
|
|
2627
|
-
} catch (e) {
|
|
2628
|
-
await log(`\n❌ Error: ${e.message}`, 0);
|
|
2629
|
-
showStatus('mq-demo-status', e.message, false);
|
|
2630
|
-
}
|
|
2631
|
-
}
|
|
2632
|
-
|
|
2633
|
-
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
2634
|
-
|
|
2635
|
-
/** Fetch that always returns { ok, error?, data?, status } — never throws on non-2xx or non-JSON */
|
|
2636
|
-
async function apiFetch(url, opts) {
|
|
2637
|
-
let res;
|
|
2638
|
-
try { res = await fetch(url, opts); } catch (e) { return { ok: false, error: e.message }; }
|
|
2639
|
-
const ct = res.headers.get('content-type') || '';
|
|
2640
|
-
if (ct.includes('application/json')) {
|
|
2641
|
-
try { return await res.json(); } catch (e) { return { ok: false, error: 'Invalid JSON: ' + e.message }; }
|
|
2642
|
-
}
|
|
2643
|
-
const text = await res.text().catch(() => res.statusText);
|
|
2644
|
-
return { ok: false, error: res.status + ' ' + (text || res.statusText) };
|
|
2645
|
-
}
|
|
2646
|
-
|
|
2647
|
-
function showStatus(id, msg, ok, ms) {
|
|
2648
|
-
const el = document.getElementById(id);
|
|
2649
|
-
el.className = 'status ' + (ok ? 'ok' : 'err');
|
|
2650
|
-
el.textContent = ms != null ? `${msg} (${ms.toFixed(1)}ms)` : msg;
|
|
2651
|
-
}
|
|
2652
|
-
|
|
2653
|
-
// ── Replication ────────────────────────────────────────────────────────────────
|
|
2654
|
-
async function loadRepl() {
|
|
2655
|
-
const t0 = performance.now();
|
|
2656
|
-
try {
|
|
2657
|
-
const res = await fetch('/replication');
|
|
2658
|
-
const json = await res.json();
|
|
2659
|
-
const ms = performance.now() - t0;
|
|
2660
|
-
if (!json.ok) { showStatus('repl-status', json.error || 'Failed', false, ms); return; }
|
|
2661
|
-
|
|
2662
|
-
const { role, sources, synced } = json;
|
|
2663
|
-
showStatus('repl-status', `Role: ${role} — ${sources.length} source(s)`, true, ms);
|
|
2664
|
-
|
|
2665
|
-
const configEl = document.getElementById('repl-config');
|
|
2666
|
-
configEl.style.display = 'block';
|
|
2667
|
-
configEl.textContent = sources.map(s =>
|
|
2668
|
-
`${s.url}\n paths: [${s.paths.join(', ')}]\n localPrefix: ${s.localPrefix || '(none)'}\n id: ${s.id || '(auto)'}`
|
|
2669
|
-
).join('\n\n') || '(no sources configured)';
|
|
2670
|
-
|
|
2671
|
-
const syncedEl = document.getElementById('repl-synced');
|
|
2672
|
-
syncedEl.style.display = 'block';
|
|
2673
|
-
syncedEl.textContent = JSON.stringify(synced, null, 2);
|
|
2674
|
-
} catch (e) {
|
|
2675
|
-
showStatus('repl-status', e.message, false);
|
|
2676
|
-
}
|
|
2677
|
-
}
|
|
2678
|
-
|
|
2679
|
-
function fillReplWrite(path, value) {
|
|
2680
|
-
document.getElementById('repl-write-path').value = path;
|
|
2681
|
-
document.getElementById('repl-write-value').value = value;
|
|
2682
|
-
}
|
|
2683
|
-
|
|
2684
|
-
async function doReplWrite() {
|
|
2685
|
-
const path = document.getElementById('repl-write-path').value.trim();
|
|
2686
|
-
const raw = document.getElementById('repl-write-value').value.trim();
|
|
2687
|
-
if (!path) return showStatus('repl-write-status', 'Path required', false);
|
|
2688
|
-
const t0 = performance.now();
|
|
2689
|
-
try {
|
|
2690
|
-
const value = JSON.parse(raw);
|
|
2691
|
-
const res = await fetch('/replication/source-write', {
|
|
2692
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2693
|
-
body: JSON.stringify({ path, value }),
|
|
2694
|
-
});
|
|
2695
|
-
const json = await res.json();
|
|
2696
|
-
const ms = performance.now() - t0;
|
|
2697
|
-
showStatus('repl-write-status', json.ok ? `Written to source: ${path}` : (json.error || 'Failed'), json.ok, ms);
|
|
2698
|
-
if (json.ok) setTimeout(loadRepl, 600);
|
|
2699
|
-
} catch (e) {
|
|
2700
|
-
showStatus('repl-write-status', e.message, false, performance.now() - t0);
|
|
2701
|
-
}
|
|
2702
|
-
}
|
|
2703
|
-
|
|
2704
|
-
async function doReplDelete() {
|
|
2705
|
-
const path = document.getElementById('repl-write-path').value.trim();
|
|
2706
|
-
if (!path) return showStatus('repl-write-status', 'Path required', false);
|
|
2707
|
-
const t0 = performance.now();
|
|
2708
|
-
try {
|
|
2709
|
-
const res = await fetch('/replication/source-delete/' + path, { method: 'DELETE' });
|
|
2710
|
-
const json = await res.json();
|
|
2711
|
-
const ms = performance.now() - t0;
|
|
2712
|
-
showStatus('repl-write-status', json.ok ? `Deleted on source: ${path}` : (json.error || 'Failed'), json.ok, ms);
|
|
2713
|
-
if (json.ok) setTimeout(loadRepl, 600);
|
|
2714
|
-
} catch (e) {
|
|
2715
|
-
showStatus('repl-write-status', e.message, false, performance.now() - t0);
|
|
2716
|
-
}
|
|
2717
|
-
}
|
|
2718
|
-
// ── VFS Explorer ──────────────────────────────────────────────────────────────
|
|
2719
|
-
let vfsPath = '';
|
|
2720
|
-
let vfsSelected = null;
|
|
2721
|
-
|
|
2722
|
-
function vfsFormatSize(b) {
|
|
2723
|
-
if (b == null) return '—';
|
|
2724
|
-
if (b < 1024) return b + ' B';
|
|
2725
|
-
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
|
|
2726
|
-
return (b/(1024*1024)).toFixed(1) + ' MB';
|
|
2727
|
-
}
|
|
2728
|
-
function vfsFormatDate(ts) {
|
|
2729
|
-
if (!ts) return '';
|
|
2730
|
-
const d = new Date(typeof ts === 'number' ? ts : ts);
|
|
2731
|
-
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
2732
|
-
}
|
|
2733
|
-
|
|
2734
|
-
async function vfsNavigate(path) {
|
|
2735
|
-
vfsPath = (path || '').replace(/^\/+|\/+$/g, '');
|
|
2736
|
-
vfsSelected = null;
|
|
2737
|
-
document.getElementById('vfs-preview').style.display = 'none';
|
|
2738
|
-
// breadcrumb
|
|
2739
|
-
const bc = document.getElementById('vfs-breadcrumb');
|
|
2740
|
-
const segs = vfsPath ? vfsPath.split('/') : [];
|
|
2741
|
-
let html = '<span style="cursor:pointer;color:#569cd6" onclick="vfsNavigate(\'\')">/</span>';
|
|
2742
|
-
let built = '';
|
|
2743
|
-
for (let i = 0; i < segs.length; i++) {
|
|
2744
|
-
built += (i ? '/' : '') + segs[i];
|
|
2745
|
-
const p = built;
|
|
2746
|
-
html += ' > <span style="cursor:pointer;color:#569cd6" onclick="vfsNavigate(\'' + p.replace(/'/g, "\\'") + '\')">' + segs[i] + '</span>';
|
|
2747
|
-
}
|
|
2748
|
-
bc.innerHTML = html;
|
|
2749
|
-
// fetch listing
|
|
2750
|
-
const tbl = document.getElementById('vfs-table');
|
|
2751
|
-
const t0 = performance.now();
|
|
2752
|
-
try {
|
|
2753
|
-
const json = await apiFetch('/files/' + (vfsPath || '') + '?list=1');
|
|
2754
|
-
const ms = performance.now() - t0;
|
|
2755
|
-
if (!json.ok) { tbl.innerHTML = '<div style="padding:12px;color:#f44">Error: ' + (json.error || 'Failed') + '</div>'; return; }
|
|
2756
|
-
const items = json.data || [];
|
|
2757
|
-
// sort: dirs first, then alpha
|
|
2758
|
-
items.sort((a, b) => {
|
|
2759
|
-
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
2760
|
-
return (a.name || '').localeCompare(b.name || '');
|
|
2761
|
-
});
|
|
2762
|
-
showStatus('vfs-explorer-status', items.length + ' items', true, ms);
|
|
2763
|
-
if (!items.length) {
|
|
2764
|
-
tbl.innerHTML = '<div style="padding:20px;text-align:center;color:#555">Empty directory</div>';
|
|
2765
|
-
return;
|
|
2766
|
-
}
|
|
2767
|
-
window._vfsItems = items;
|
|
2768
|
-
let rows = '<table style="width:100%;border-collapse:collapse"><tbody>';
|
|
2769
|
-
for (let idx = 0; idx < items.length; idx++) {
|
|
2770
|
-
const item = items[idx];
|
|
2771
|
-
const icon = item.isDir ? '📁' : '📄';
|
|
2772
|
-
const fp = (vfsPath ? vfsPath + '/' + item.name : item.name).replace(/'/g, "\\'");
|
|
2773
|
-
const clickAttr = item.isDir
|
|
2774
|
-
? 'onclick="vfsNavigate(\'' + fp + '\')"'
|
|
2775
|
-
: 'onclick="vfsPreview(' + idx + ',\'' + fp + '\')"';
|
|
2776
|
-
const svgDl = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
|
2777
|
-
const svgRn = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
|
|
2778
|
-
const svgDel = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>';
|
|
2779
|
-
const btnBase = 'display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;padding:0';
|
|
2780
|
-
const dlStyle = item.isDir ? btnBase + ';visibility:hidden' : btnBase;
|
|
2781
|
-
const actions = '<span class="vfs-row-actions" style="display:flex;gap:3px;align-items:center;visibility:hidden">' +
|
|
2782
|
-
'<button class="sm" style="' + dlStyle + '" onclick="event.stopPropagation();vfsActOn(' + idx + ',\'' + fp + '\',\'download\')" title="Download">' + svgDl + '</button>' +
|
|
2783
|
-
'<button class="sm" style="' + btnBase + '" onclick="event.stopPropagation();vfsActOn(' + idx + ',\'' + fp + '\',\'rename\')" title="Rename">' + svgRn + '</button>' +
|
|
2784
|
-
'<button class="danger sm" style="' + btnBase + '" onclick="event.stopPropagation();vfsActOn(' + idx + ',\'' + fp + '\',\'delete\')" title="Delete">' + svgDel + '</button>' +
|
|
2785
|
-
'</span>';
|
|
2786
|
-
rows += '<tr style="cursor:pointer;padding:0;border-bottom:1px solid #1a1a1a;height:34px" ' + clickAttr +
|
|
2787
|
-
' onmouseover="this.style.background=\'#1e1e1e\';var a=this.querySelector(\'.vfs-row-actions\');if(a)a.style.visibility=\'visible\'"' +
|
|
2788
|
-
' onmouseout="this.style.background=\'\';var a=this.querySelector(\'.vfs-row-actions\');if(a)a.style.visibility=\'hidden\'">';
|
|
2789
|
-
rows += '<td style="width:28px;padding:6px 4px 6px 10px">' + icon + '</td>';
|
|
2790
|
-
rows += '<td style="color:#ccc;padding:6px 4px">' + (item.name || '') + '</td>';
|
|
2791
|
-
rows += '<td style="width:70px;text-align:right;color:#888;padding:6px 4px">' + (item.isDir ? '' : vfsFormatSize(item.size)) + '</td>';
|
|
2792
|
-
rows += '<td style="width:70px;text-align:right;color:#666;padding:6px 4px">' + vfsFormatDate(item.mtime) + '</td>';
|
|
2793
|
-
rows += '<td style="width:90px;text-align:right;padding:6px 10px 6px 4px">' + actions + '</td>';
|
|
2794
|
-
rows += '</tr>';
|
|
2795
|
-
}
|
|
2796
|
-
rows += '</tbody></table>';
|
|
2797
|
-
tbl.innerHTML = rows;
|
|
2798
|
-
} catch (e) {
|
|
2799
|
-
tbl.innerHTML = '<div style="padding:12px;color:#f44">' + e.message + '</div>';
|
|
2800
|
-
}
|
|
2801
|
-
}
|
|
2802
|
-
|
|
2803
|
-
function vfsSetSelected(idx, fullPath) {
|
|
2804
|
-
vfsSelected = { ...window._vfsItems[idx], fullPath };
|
|
2805
|
-
}
|
|
2806
|
-
|
|
2807
|
-
async function vfsPreview(idx, fullPath) {
|
|
2808
|
-
vfsSetSelected(idx, fullPath);
|
|
2809
|
-
const item = vfsSelected;
|
|
2810
|
-
const panel = document.getElementById('vfs-preview');
|
|
2811
|
-
const body = document.getElementById('vfs-preview-body');
|
|
2812
|
-
document.getElementById('vfs-preview-name').textContent = item.name;
|
|
2813
|
-
document.getElementById('vfs-preview-info').textContent = vfsFormatSize(item.size) + (item.mime ? ' \u00b7 ' + item.mime : '');
|
|
2814
|
-
panel.style.display = 'block';
|
|
2815
|
-
body.textContent = 'Loading\u2026';
|
|
2816
|
-
// preview text-based files, show info for binary
|
|
2817
|
-
const textTypes = ['text/', 'application/json', 'application/xml', 'application/javascript', 'application/typescript', 'application/yaml', 'application/toml'];
|
|
2818
|
-
const isText = !item.mime || textTypes.some(t => (item.mime || '').startsWith(t)) || (item.size || 0) === 0;
|
|
2819
|
-
if (isText && item.size > 0) {
|
|
2820
|
-
try {
|
|
2821
|
-
const res = await fetch('/files/' + fullPath);
|
|
2822
|
-
if (!res.ok) { body.textContent = 'Failed to load'; return; }
|
|
2823
|
-
const text = await res.text();
|
|
2824
|
-
body.textContent = text.length > 50000 ? text.slice(0, 50000) + '\n\u2026 (truncated)' : text;
|
|
2825
|
-
} catch (e) { body.textContent = e.message; }
|
|
2826
|
-
} else if (item.size === 0) {
|
|
2827
|
-
body.textContent = '(empty file)';
|
|
2828
|
-
} else {
|
|
2829
|
-
body.textContent = 'Binary file \u00b7 ' + vfsFormatSize(item.size) + ' \u00b7 ' + (item.mime || 'unknown type') + '\n\nUse Download to save.';
|
|
2830
|
-
}
|
|
2831
|
-
}
|
|
2832
|
-
|
|
2833
|
-
function vfsActOn(idx, fullPath, action) {
|
|
2834
|
-
vfsSetSelected(idx, fullPath);
|
|
2835
|
-
if (action === 'download') vfsDownload();
|
|
2836
|
-
else if (action === 'rename') vfsRename();
|
|
2837
|
-
else if (action === 'delete' && confirm('Delete ' + vfsSelected.name + '?')) vfsDeleteSel();
|
|
2838
|
-
}
|
|
2839
|
-
|
|
2840
|
-
async function vfsUploadHere() {
|
|
2841
|
-
const fileInput = document.getElementById('vfs-upload-file');
|
|
2842
|
-
if (!fileInput.files.length) return;
|
|
2843
|
-
const file = fileInput.files[0];
|
|
2844
|
-
const path = (vfsPath ? vfsPath + '/' : '') + file.name;
|
|
2845
|
-
const t0 = performance.now();
|
|
2846
|
-
try {
|
|
2847
|
-
const json = await apiFetch('/files/' + path, { method: 'POST', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
|
|
2848
|
-
showStatus('vfs-explorer-status', json.ok ? 'Uploaded ' + file.name : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
2849
|
-
} catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
|
|
2850
|
-
fileInput.value = '';
|
|
2851
|
-
vfsNavigate(vfsPath);
|
|
2852
|
-
}
|
|
2853
|
-
|
|
2854
|
-
async function vfsSyncFolder() {
|
|
2855
|
-
if (!window.showDirectoryPicker) { alert('Browser does not support directory picker (use Chrome/Edge)'); return; }
|
|
2856
|
-
let dirHandle;
|
|
2857
|
-
try { dirHandle = await window.showDirectoryPicker(); } catch { return; } // user cancelled
|
|
2858
|
-
const basePath = vfsPath ? vfsPath + '/' + dirHandle.name : dirHandle.name;
|
|
2859
|
-
// recursively collect all files
|
|
2860
|
-
async function collectFiles(handle, prefix) {
|
|
2861
|
-
const files = [];
|
|
2862
|
-
for await (const [name, entry] of handle.entries()) {
|
|
2863
|
-
const path = prefix ? prefix + '/' + name : name;
|
|
2864
|
-
if (entry.kind === 'file') {
|
|
2865
|
-
files.push({ path, handle: entry });
|
|
2866
|
-
} else {
|
|
2867
|
-
files.push(...await collectFiles(entry, path));
|
|
2868
|
-
}
|
|
2869
|
-
}
|
|
2870
|
-
return files;
|
|
2871
|
-
}
|
|
2872
|
-
showStatus('vfs-explorer-status', 'Scanning folder\u2026', true);
|
|
2873
|
-
const files = await collectFiles(dirHandle, '');
|
|
2874
|
-
if (!files.length) { showStatus('vfs-explorer-status', 'Folder is empty', false); return; }
|
|
2875
|
-
const t0 = performance.now();
|
|
2876
|
-
let ok = 0, fail = 0;
|
|
2877
|
-
showStatus('vfs-explorer-status', 'Uploading 0/' + files.length + '\u2026', true);
|
|
2878
|
-
// upload in batches of 5
|
|
2879
|
-
for (let i = 0; i < files.length; i += 5) {
|
|
2880
|
-
const batch = files.slice(i, i + 5);
|
|
2881
|
-
const results = await Promise.allSettled(batch.map(async (f) => {
|
|
2882
|
-
const file = await f.handle.getFile();
|
|
2883
|
-
const json = await apiFetch('/files/' + basePath + '/' + f.path, {
|
|
2884
|
-
method: 'POST', body: file,
|
|
2885
|
-
headers: { 'Content-Type': file.type || 'application/octet-stream' }
|
|
2886
|
-
});
|
|
2887
|
-
if (!json.ok) throw new Error(json.error);
|
|
2888
|
-
}));
|
|
2889
|
-
for (const r of results) r.status === 'fulfilled' ? ok++ : fail++;
|
|
2890
|
-
showStatus('vfs-explorer-status', 'Uploading ' + (ok + fail) + '/' + files.length + '\u2026', true);
|
|
2891
|
-
}
|
|
2892
|
-
const ms = performance.now() - t0;
|
|
2893
|
-
showStatus('vfs-explorer-status', 'Synced ' + ok + ' files' + (fail ? ', ' + fail + ' failed' : ''), !fail, ms);
|
|
2894
|
-
vfsNavigate(vfsPath);
|
|
2895
|
-
}
|
|
2896
|
-
|
|
2897
|
-
async function vfsMkdirHere() {
|
|
2898
|
-
const name = prompt('Folder name:');
|
|
2899
|
-
if (!name) return;
|
|
2900
|
-
const path = (vfsPath ? vfsPath + '/' : '') + name;
|
|
2901
|
-
const t0 = performance.now();
|
|
2902
|
-
try {
|
|
2903
|
-
const json = await apiFetch('/files/' + path + '?mkdir=1', { method: 'POST' });
|
|
2904
|
-
showStatus('vfs-explorer-status', json.ok ? 'Created ' + name : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
2905
|
-
} catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
|
|
2906
|
-
vfsNavigate(vfsPath);
|
|
2907
|
-
}
|
|
2908
|
-
|
|
2909
|
-
async function vfsDownload() {
|
|
2910
|
-
if (!vfsSelected) return;
|
|
2911
|
-
const t0 = performance.now();
|
|
2912
|
-
try {
|
|
2913
|
-
const res = await fetch('/files/' + vfsSelected.fullPath);
|
|
2914
|
-
if (!res.ok) return showStatus('vfs-explorer-status', 'Not found', false, performance.now() - t0);
|
|
2915
|
-
const blob = await res.blob();
|
|
2916
|
-
const url = URL.createObjectURL(blob);
|
|
2917
|
-
const a = document.createElement('a'); a.href = url; a.download = vfsSelected.name;
|
|
2918
|
-
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
|
2919
|
-
showStatus('vfs-explorer-status', 'Downloaded ' + vfsSelected.name + ' (' + vfsFormatSize(blob.size) + ')', true, performance.now() - t0);
|
|
2920
|
-
} catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
|
|
2921
|
-
}
|
|
2922
|
-
|
|
2923
|
-
async function vfsRename() {
|
|
2924
|
-
if (!vfsSelected) return;
|
|
2925
|
-
const newName = prompt('New name:', vfsSelected.name);
|
|
2926
|
-
if (!newName || newName === vfsSelected.name) return;
|
|
2927
|
-
const dst = (vfsPath ? vfsPath + '/' : '') + newName;
|
|
2928
|
-
const t0 = performance.now();
|
|
2929
|
-
try {
|
|
2930
|
-
const json = await apiFetch('/files/' + vfsSelected.fullPath + '?move=' + encodeURIComponent(dst), { method: 'PUT' });
|
|
2931
|
-
showStatus('vfs-explorer-status', json.ok ? 'Renamed → ' + newName : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
2932
|
-
} catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
|
|
2933
|
-
vfsNavigate(vfsPath);
|
|
2934
|
-
}
|
|
2935
|
-
|
|
2936
|
-
async function vfsDeleteSel() {
|
|
2937
|
-
if (!vfsSelected) return;
|
|
2938
|
-
const t0 = performance.now();
|
|
2939
|
-
try {
|
|
2940
|
-
const json = await apiFetch('/files/' + vfsSelected.fullPath, { method: 'DELETE' });
|
|
2941
|
-
showStatus('vfs-explorer-status', json.ok ? 'Deleted ' + vfsSelected.name : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
2942
|
-
} catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
|
|
2943
|
-
vfsSelected = null;
|
|
2944
|
-
document.getElementById('vfs-preview').style.display = 'none';
|
|
2945
|
-
vfsNavigate(vfsPath);
|
|
2946
|
-
}
|
|
2947
|
-
|
|
2948
|
-
// ── VFS Advanced (raw API) ────────────────────────────────────────────────────
|
|
2949
|
-
async function doVfsUpload() {
|
|
2950
|
-
const path = document.getElementById('vfs-upload-path').value.trim();
|
|
2951
|
-
const fileInput = document.getElementById('vfs-adv-upload-file');
|
|
2952
|
-
if (!path) return showStatus('vfs-upload-status', 'Path required', false);
|
|
2953
|
-
if (!fileInput.files.length) return showStatus('vfs-upload-status', 'Select a file', false);
|
|
2954
|
-
const file = fileInput.files[0];
|
|
2955
|
-
const t0 = performance.now();
|
|
2956
|
-
try {
|
|
2957
|
-
const json = await apiFetch('/files/' + path, { method: 'POST', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
|
|
2958
|
-
const ms = performance.now() - t0;
|
|
2959
|
-
if (json.ok) {
|
|
2960
|
-
for (const id of ['vfs-download-path','vfs-stat-path','vfs-delete-path','vfs-move-src']) document.getElementById(id).value = path;
|
|
2961
|
-
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
|
|
2962
|
-
document.getElementById('vfs-list-path').value = dir || '/';
|
|
2963
|
-
}
|
|
2964
|
-
showStatus('vfs-upload-status', json.ok ? `Uploaded ${json.data.size} bytes (${json.data.mime})` : (json.error || 'Failed'), json.ok, ms);
|
|
2965
|
-
} catch (e) { showStatus('vfs-upload-status', e.message, false, performance.now() - t0); }
|
|
2966
|
-
}
|
|
2967
|
-
|
|
2968
|
-
async function doVfsDownload() {
|
|
2969
|
-
const path = document.getElementById('vfs-download-path').value.trim();
|
|
2970
|
-
if (!path) return showStatus('vfs-download-status', 'Path required', false);
|
|
2971
|
-
const t0 = performance.now();
|
|
2972
|
-
try {
|
|
2973
|
-
const res = await fetch('/files/' + path);
|
|
2974
|
-
if (!res.ok) { showStatus('vfs-download-status', 'Not found', false, performance.now() - t0); return; }
|
|
2975
|
-
const blob = await res.blob();
|
|
2976
|
-
const url = URL.createObjectURL(blob);
|
|
2977
|
-
const a = document.createElement('a');
|
|
2978
|
-
a.href = url; a.download = path.split('/').pop();
|
|
2979
|
-
document.body.appendChild(a); a.click(); a.remove();
|
|
2980
|
-
URL.revokeObjectURL(url);
|
|
2981
|
-
showStatus('vfs-download-status', `Downloaded ${blob.size} bytes`, true, performance.now() - t0);
|
|
2982
|
-
} catch (e) { showStatus('vfs-download-status', e.message, false, performance.now() - t0); }
|
|
2983
|
-
}
|
|
2984
|
-
|
|
2985
|
-
async function doVfsStat() {
|
|
2986
|
-
const path = document.getElementById('vfs-stat-path').value.trim();
|
|
2987
|
-
if (!path) return showStatus('vfs-stat-status', 'Path required', false);
|
|
2988
|
-
const t0 = performance.now();
|
|
2989
|
-
try {
|
|
2990
|
-
const json = await apiFetch('/files/' + path + '?stat=1');
|
|
2991
|
-
const ms = performance.now() - t0;
|
|
2992
|
-
const el = document.getElementById('vfs-stat-result');
|
|
2993
|
-
if (json.ok) { el.style.display = 'block'; el.textContent = JSON.stringify(json.data, null, 2); }
|
|
2994
|
-
else { el.style.display = 'none'; }
|
|
2995
|
-
showStatus('vfs-stat-status', json.ok ? `${json.data.isDir ? 'Dir' : 'File'}: ${json.data.name} (${json.data.size}b)` : (json.error || 'Not found'), json.ok, ms);
|
|
2996
|
-
} catch (e) { showStatus('vfs-stat-status', e.message, false, performance.now() - t0); }
|
|
2997
|
-
}
|
|
2998
|
-
|
|
2999
|
-
async function doVfsList() {
|
|
3000
|
-
const path = document.getElementById('vfs-list-path').value.trim();
|
|
3001
|
-
if (!path) return showStatus('vfs-list-status', 'Path required', false);
|
|
3002
|
-
const t0 = performance.now();
|
|
3003
|
-
try {
|
|
3004
|
-
const json = await apiFetch('/files/' + path + '?list=1');
|
|
3005
|
-
const ms = performance.now() - t0;
|
|
3006
|
-
const el = document.getElementById('vfs-list-result');
|
|
3007
|
-
if (json.ok) { el.style.display = 'block'; el.textContent = JSON.stringify(json.data, null, 2); }
|
|
3008
|
-
else { el.style.display = 'none'; }
|
|
3009
|
-
showStatus('vfs-list-status', json.ok ? `${json.data.length} entries` : (json.error || 'Failed'), json.ok, ms);
|
|
3010
|
-
} catch (e) { showStatus('vfs-list-status', e.message, false, performance.now() - t0); }
|
|
3011
|
-
}
|
|
3012
|
-
|
|
3013
|
-
async function doVfsMkdir() {
|
|
3014
|
-
const path = document.getElementById('vfs-mkdir-path').value.trim();
|
|
3015
|
-
if (!path) return showStatus('vfs-mkdir-status', 'Path required', false);
|
|
3016
|
-
const t0 = performance.now();
|
|
3017
|
-
try {
|
|
3018
|
-
const json = await apiFetch('/files/' + path + '?mkdir=1', { method: 'POST' });
|
|
3019
|
-
showStatus('vfs-mkdir-status', json.ok ? `Created: ${path}` : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
3020
|
-
} catch (e) { showStatus('vfs-mkdir-status', e.message, false, performance.now() - t0); }
|
|
3021
|
-
}
|
|
3022
|
-
|
|
3023
|
-
async function doVfsMove() {
|
|
3024
|
-
const src = document.getElementById('vfs-move-src').value.trim();
|
|
3025
|
-
const dst = document.getElementById('vfs-move-dst').value.trim();
|
|
3026
|
-
if (!src || !dst) return showStatus('vfs-move-status', 'Both paths required', false);
|
|
3027
|
-
const t0 = performance.now();
|
|
3028
|
-
try {
|
|
3029
|
-
const json = await apiFetch('/files/' + src + '?move=' + encodeURIComponent(dst), { method: 'PUT' });
|
|
3030
|
-
showStatus('vfs-move-status', json.ok ? `Moved → ${json.data.path}` : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
3031
|
-
} catch (e) { showStatus('vfs-move-status', e.message, false, performance.now() - t0); }
|
|
3032
|
-
}
|
|
3033
|
-
|
|
3034
|
-
async function doVfsDelete() {
|
|
3035
|
-
const path = document.getElementById('vfs-delete-path').value.trim();
|
|
3036
|
-
if (!path) return showStatus('vfs-delete-status', 'Path required', false);
|
|
3037
|
-
const t0 = performance.now();
|
|
3038
|
-
try {
|
|
3039
|
-
const json = await apiFetch('/files/' + path, { method: 'DELETE' });
|
|
3040
|
-
showStatus('vfs-delete-status', json.ok ? `Deleted: ${path}` : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
3041
|
-
} catch (e) { showStatus('vfs-delete-status', e.message, false, performance.now() - t0); }
|
|
3042
|
-
}
|
|
3043
|
-
|
|
3044
|
-
// ── Cache Demo ──────────────────────────────────────────────────────────────────
|
|
3045
|
-
const _cache = { memory: new Map(), subOff: null, log: [] };
|
|
3046
|
-
|
|
3047
|
-
function _cacheLog(msg) {
|
|
3048
|
-
_cache.log.push(`[${new Date().toLocaleTimeString()}] ${msg}`);
|
|
3049
|
-
if (_cache.log.length > 20) _cache.log.shift();
|
|
3050
|
-
const el = document.getElementById('cache-log');
|
|
3051
|
-
if (el) el.textContent = _cache.log.join('\n');
|
|
3052
|
-
}
|
|
3053
|
-
|
|
3054
|
-
async function cacheSet() {
|
|
3055
|
-
const path = document.getElementById('cache-path').value.trim();
|
|
3056
|
-
const val = JSON.parse(document.getElementById('cache-value').value);
|
|
3057
|
-
await db.set(path, val);
|
|
3058
|
-
_cache.memory.delete(path); // invalidate
|
|
3059
|
-
_cacheLog(`SET ${path} → invalidated cache`);
|
|
3060
|
-
document.getElementById('cache-result').textContent = `✓ Set ${path} and invalidated cache`;
|
|
3061
|
-
}
|
|
3062
|
-
|
|
3063
|
-
async function cacheGet() {
|
|
3064
|
-
const path = document.getElementById('cache-path').value.trim();
|
|
3065
|
-
const t0 = performance.now();
|
|
3066
|
-
const cached = _cache.memory.get(path);
|
|
3067
|
-
if (cached) {
|
|
3068
|
-
const ms = (performance.now() - t0).toFixed(2);
|
|
3069
|
-
_cacheLog(`HIT ${path} (${ms}ms) — served from memory`);
|
|
3070
|
-
document.getElementById('cache-result').textContent = `[CACHE HIT · ${ms}ms]\n` + JSON.stringify(cached.data, null, 2) +
|
|
3071
|
-
(cached.updatedAt ? `\n\nupdatedAt: ${new Date(cached.updatedAt).toLocaleString()}` : '');
|
|
3072
|
-
// background revalidate
|
|
3073
|
-
db.getSnapshot(path).then(snap => {
|
|
3074
|
-
_cache.memory.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
3075
|
-
_cacheLog(`REVALIDATED ${path}`);
|
|
3076
|
-
}).catch(() => {});
|
|
3077
|
-
return;
|
|
3078
|
-
}
|
|
3079
|
-
const snap = await db.getSnapshot(path);
|
|
3080
|
-
const ms = (performance.now() - t0).toFixed(2);
|
|
3081
|
-
_cache.memory.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
3082
|
-
_cacheLog(`MISS ${path} (${ms}ms) — fetched from network, cached`);
|
|
3083
|
-
document.getElementById('cache-result').textContent = `[CACHE MISS · ${ms}ms]\n` + JSON.stringify(snap.val(), null, 2) +
|
|
3084
|
-
(snap.updatedAt ? `\n\nupdatedAt: ${new Date(snap.updatedAt).toLocaleString()}` : '');
|
|
3085
|
-
}
|
|
3086
|
-
|
|
3087
|
-
async function cacheGetFresh() {
|
|
3088
|
-
const path = document.getElementById('cache-path').value.trim();
|
|
3089
|
-
const t0 = performance.now();
|
|
3090
|
-
const snap = await db.getSnapshot(path);
|
|
3091
|
-
const ms = (performance.now() - t0).toFixed(2);
|
|
3092
|
-
_cache.memory.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
3093
|
-
_cacheLog(`FRESH ${path} (${ms}ms) — network fetch, cache updated`);
|
|
3094
|
-
document.getElementById('cache-result').textContent = `[NETWORK · ${ms}ms]\n` + JSON.stringify(snap.val(), null, 2) +
|
|
3095
|
-
(snap.updatedAt ? `\n\nupdatedAt: ${new Date(snap.updatedAt).toLocaleString()}` : '');
|
|
3096
|
-
}
|
|
3097
|
-
|
|
3098
|
-
function cacheSub() {
|
|
3099
|
-
if (_cache.subOff) { _cacheLog('Already subscribed'); return; }
|
|
3100
|
-
const path = document.getElementById('cache-path').value.trim();
|
|
3101
|
-
_cache.subOff = db.on(path, (snap) => {
|
|
3102
|
-
_cache.memory.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
3103
|
-
_cacheLog(`SUB UPDATE ${path} — cache refreshed`);
|
|
3104
|
-
document.getElementById('cache-result').textContent = `[LIVE · subscribed]\n` + JSON.stringify(snap.val(), null, 2) +
|
|
3105
|
-
(snap.updatedAt ? `\n\nupdatedAt: ${new Date(snap.updatedAt).toLocaleString()}` : '');
|
|
3106
|
-
});
|
|
3107
|
-
_cacheLog(`SUBSCRIBED to ${path} — cache stays fresh`);
|
|
3108
|
-
}
|
|
3109
|
-
|
|
3110
|
-
function cacheUnsub() {
|
|
3111
|
-
if (_cache.subOff) { _cache.subOff(); _cache.subOff = null; _cacheLog('Unsubscribed'); }
|
|
3112
|
-
}
|
|
3113
|
-
|
|
3114
|
-
function cacheStats() {
|
|
3115
|
-
const stats = { memoryEntries: _cache.memory.size, paths: [..._cache.memory.keys()], subscribed: !!_cache.subOff };
|
|
3116
|
-
document.getElementById('cache-result').textContent = JSON.stringify(stats, null, 2);
|
|
3117
|
-
_cacheLog(`Stats: ${_cache.memory.size} entries in memory`);
|
|
3118
|
-
}
|
|
3119
|
-
|
|
3120
|
-
// ── KeyAuth ──────────────────────────────────────────────────────────────────
|
|
3121
|
-
let _kaPerms = [];
|
|
3122
|
-
let _kaLastFp = '';
|
|
3123
|
-
let _kaExpandedFp = null;
|
|
3124
|
-
let _kaDeviceKeys = null; // { publicKey, privateKeyDer } stored in sessionStorage-like
|
|
3125
|
-
let _kaQrPollTimer = null;
|
|
3126
|
-
let _kaAccounts = [];
|
|
3127
|
-
|
|
3128
|
-
function kaSend(op, params = {}) {
|
|
3129
|
-
return db._send(op, params);
|
|
3130
|
-
}
|
|
3131
|
-
|
|
3132
|
-
async function loadKeyAuth() {
|
|
3133
|
-
if (!db.connected) { setTimeout(loadKeyAuth, 300); return; }
|
|
3134
|
-
try {
|
|
3135
|
-
// Always fetch server info (unauthenticated read) + fingerprints (no auth needed)
|
|
3136
|
-
const [server, fingerprints] = await Promise.all([
|
|
3137
|
-
db.get('_auth/server'),
|
|
3138
|
-
kaSend('auth-list-account-fingerprints'),
|
|
3139
|
-
]);
|
|
3140
|
-
|
|
3141
|
-
// Server info
|
|
3142
|
-
const sEl = document.getElementById('ka-server');
|
|
3143
|
-
if (server) {
|
|
3144
|
-
sEl.textContent = 'fp: ' + (server.fingerprint || '—') + '\nkey: ' + (server.publicKey || '—').slice(0, 40) + '…';
|
|
3145
|
-
} else {
|
|
3146
|
-
sEl.textContent = 'KeyAuth not enabled.\nAdd keyAuth: {} to config.ts';
|
|
3147
|
-
}
|
|
3148
|
-
|
|
3149
|
-
// Try authenticated endpoints (full accounts + roles); fall back to fingerprints-only
|
|
3150
|
-
let accounts = null, roles = null;
|
|
3151
|
-
try {
|
|
3152
|
-
[accounts, roles] = await Promise.all([
|
|
3153
|
-
kaSend('auth-list-accounts'),
|
|
3154
|
-
kaSend('auth-list-roles'),
|
|
3155
|
-
]);
|
|
3156
|
-
} catch {}
|
|
3157
|
-
|
|
3158
|
-
const authenticated = !!accounts;
|
|
3159
|
-
_kaAccounts = accounts || (fingerprints || []).map(f => ({ ...f, roles: [] }));
|
|
3160
|
-
|
|
3161
|
-
// Accounts list
|
|
3162
|
-
const aEl = document.getElementById('ka-accounts');
|
|
3163
|
-
if (_kaAccounts.length) {
|
|
3164
|
-
aEl.innerHTML = _kaAccounts.map(a => {
|
|
3165
|
-
const fp = a.fingerprint;
|
|
3166
|
-
const name = a.displayName || '—';
|
|
3167
|
-
const rls = authenticated ? ((a.roles || []).join(', ') || 'none') : '';
|
|
3168
|
-
const isDevice = authenticated && !a.encryptedPrivateKey;
|
|
3169
|
-
const expanded = _kaExpandedFp === fp;
|
|
3170
|
-
return `<div style="border:1px solid #333;border-radius:4px;padding:6px;margin-bottom:4px;cursor:pointer" onclick="kaExpandAccount('${fp}')">
|
|
3171
|
-
<span style="font-weight:bold">${name}</span>${rls ? ' [' + rls + ']' : ''} ${isDevice ? '<span style="color:#888">(device)</span>' : ''}
|
|
3172
|
-
<span style="color:#666;font-size:11px;float:right">${fp.slice(0,12)}… ${expanded?'▼':'▶'}</span>
|
|
3173
|
-
<div id="ka-detail-${fp}" style="display:${expanded?'block':'none'};margin-top:6px;padding-top:6px;border-top:1px solid #333"></div>
|
|
3174
|
-
</div>`;
|
|
3175
|
-
}).join('');
|
|
3176
|
-
if (!authenticated) aEl.innerHTML += '<div style="color:#888;font-size:12px;margin-top:4px">Authenticate to see roles & details</div>';
|
|
3177
|
-
// If expanded, load detail
|
|
3178
|
-
if (_kaExpandedFp && authenticated) kaLoadDetail(_kaExpandedFp);
|
|
3179
|
-
} else {
|
|
3180
|
-
aEl.innerHTML = '<div class="result">No accounts yet.</div>';
|
|
3181
|
-
}
|
|
3182
|
-
|
|
3183
|
-
// Roles
|
|
3184
|
-
const rEl = document.getElementById('ka-roles');
|
|
3185
|
-
if (roles && roles.length) {
|
|
3186
|
-
rEl.innerHTML = roles.map(r => {
|
|
3187
|
-
const perms = (r.permissions || []).map(p => `${p.path||'/'} R:${!!p.read} W:${!!p.write}`).join(', ');
|
|
3188
|
-
return `<div style="margin-bottom:2px">${r.id}: ${r.name||r.id} → ${perms||'none'} <button class="sm" onclick="kaDeleteRole('${r.id}')" style="float:right">×</button></div>`;
|
|
3189
|
-
}).join('');
|
|
3190
|
-
} else {
|
|
3191
|
-
rEl.textContent = authenticated ? 'No roles.' : 'Authenticate to view roles';
|
|
3192
|
-
}
|
|
3193
|
-
|
|
3194
|
-
// Populate dropdowns
|
|
3195
|
-
const sel = document.getElementById('ka-assign-account');
|
|
3196
|
-
sel.innerHTML = _kaAccounts.map(a => `<option value="${a.fingerprint}">${a.displayName||a.fingerprint.slice(0,12)}</option>`).join('');
|
|
3197
|
-
const authSel = document.getElementById('ka-auth-fp');
|
|
3198
|
-
const prevFp = authSel.value;
|
|
3199
|
-
const pwAccounts = authenticated ? _kaAccounts.filter(a => !!a.encryptedPrivateKey) : _kaAccounts;
|
|
3200
|
-
authSel.innerHTML = '<option value="">— select account —</option>' + pwAccounts.map(a => `<option value="${a.fingerprint}"${a.fingerprint===prevFp?' selected':''}>${a.displayName||a.fingerprint.slice(0,12)}${authenticated ? ' [' + ((a.roles||[]).join(',')||'none') + ']' : ''}</option>`).join('');
|
|
3201
|
-
} catch (e) {
|
|
3202
|
-
document.getElementById('ka-server').textContent = 'Error: ' + e.message;
|
|
3203
|
-
}
|
|
3204
|
-
}
|
|
3205
|
-
|
|
3206
|
-
async function kaExpandAccount(fp) {
|
|
3207
|
-
_kaExpandedFp = _kaExpandedFp === fp ? null : fp;
|
|
3208
|
-
// Auto-fill auth fingerprint field when expanding
|
|
3209
|
-
document.getElementById('ka-auth-fp').value = fp;
|
|
3210
|
-
loadKeyAuth();
|
|
3211
|
-
}
|
|
3212
|
-
|
|
3213
|
-
async function kaLoadDetail(fp) {
|
|
3214
|
-
const el = document.getElementById('ka-detail-' + fp);
|
|
3215
|
-
if (!el) return;
|
|
3216
|
-
try {
|
|
3217
|
-
const [devices, sessions] = await Promise.all([
|
|
3218
|
-
kaSend('auth-list-devices', { accountFingerprint: fp }),
|
|
3219
|
-
kaSend('auth-list-sessions', { accountFingerprint: fp }),
|
|
3220
|
-
]);
|
|
3221
|
-
const acct = _kaAccounts.find(a => a.fingerprint === fp);
|
|
3222
|
-
const isDevice = acct && !acct.encryptedPrivateKey;
|
|
3223
|
-
let html = '<div style="font-size:12px">';
|
|
3224
|
-
// Devices
|
|
3225
|
-
html += '<b>Devices:</b> ';
|
|
3226
|
-
if (devices && devices.length) {
|
|
3227
|
-
html += devices.map(d => `${d.name||d.fingerprint.slice(0,8)} <button class="sm" onclick="event.stopPropagation();kaRevokeDevice('${fp}','${d.fingerprint}')">×</button>`).join(' | ');
|
|
3228
|
-
} else {
|
|
3229
|
-
html += '<span style="color:#666">none</span>';
|
|
3230
|
-
}
|
|
3231
|
-
// Sessions
|
|
3232
|
-
html += '<br><b>Sessions:</b> ';
|
|
3233
|
-
if (sessions && sessions.length) {
|
|
3234
|
-
html += sessions.map(s => `${s.sid.slice(0,8)}… exp:${new Date(s.expiresAt).toLocaleTimeString()} <button class="sm" onclick="event.stopPropagation();kaRevokeSession('${s.sid}')">×</button>`).join(' | ');
|
|
3235
|
-
} else {
|
|
3236
|
-
html += '<span style="color:#666">none</span>';
|
|
3237
|
-
}
|
|
3238
|
-
// Actions
|
|
3239
|
-
html += '<br>';
|
|
3240
|
-
if (!isDevice) {
|
|
3241
|
-
html += `<button class="sm" onclick="event.stopPropagation();kaLinkDevice('${fp}')">Link Device</button> `;
|
|
3242
|
-
html += `<button class="sm" onclick="event.stopPropagation();kaChangePassword('${fp}')">Change Pw</button>`;
|
|
3243
|
-
}
|
|
3244
|
-
html += '</div>';
|
|
3245
|
-
el.innerHTML = html;
|
|
3246
|
-
} catch (e) {
|
|
3247
|
-
el.textContent = 'Error: ' + e.message;
|
|
3248
|
-
}
|
|
3249
|
-
}
|
|
3250
|
-
|
|
3251
|
-
async function kaRevokeDevice(accountFp, deviceFp) {
|
|
3252
|
-
try {
|
|
3253
|
-
await kaSend('auth-revoke-device', { accountFingerprint: accountFp, deviceFingerprint: deviceFp });
|
|
3254
|
-
kaStatus('Device revoked');
|
|
3255
|
-
loadKeyAuth();
|
|
3256
|
-
} catch (e) { kaStatus('Revoke failed: ' + e.message, true); }
|
|
3257
|
-
}
|
|
3258
|
-
|
|
3259
|
-
async function kaRevokeSession(sid) {
|
|
3260
|
-
try {
|
|
3261
|
-
await kaSend('auth-revoke-session', { sid });
|
|
3262
|
-
kaStatus('Session revoked');
|
|
3263
|
-
loadKeyAuth();
|
|
3264
|
-
} catch (e) { kaStatus('Revoke failed: ' + e.message, true); }
|
|
3265
|
-
}
|
|
3266
|
-
|
|
3267
|
-
async function kaChangePassword(fp) {
|
|
3268
|
-
const oldPw = prompt('Current password:');
|
|
3269
|
-
if (!oldPw) return;
|
|
3270
|
-
const newPw = prompt('New password:');
|
|
3271
|
-
if (!newPw) return;
|
|
3272
|
-
try {
|
|
3273
|
-
await kaSend('auth-change-password', { fingerprint: fp, oldPassword: oldPw, newPassword: newPw });
|
|
3274
|
-
kaStatus('Password changed');
|
|
3275
|
-
} catch (e) { kaStatus('Change failed: ' + e.message, true); }
|
|
3276
|
-
}
|
|
3277
|
-
|
|
3278
|
-
async function kaLinkDevice(accountFp) {
|
|
3279
|
-
const pw = prompt('Account password (to authorize linking):');
|
|
3280
|
-
if (!pw) return;
|
|
3281
|
-
const name = prompt('Device name:', 'New Device');
|
|
3282
|
-
try {
|
|
3283
|
-
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
3284
|
-
const spki = await crypto.subtle.exportKey('spki', kp.publicKey);
|
|
3285
|
-
const pubB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
|
3286
|
-
const result = await kaSend('auth-link-device', { accountFingerprint: accountFp, password: pw, devicePublicKey: pubB64, deviceName: name });
|
|
3287
|
-
kaStatus('Device linked: ' + result.fingerprint.slice(0, 12));
|
|
3288
|
-
loadKeyAuth();
|
|
3289
|
-
} catch (e) { kaStatus('Link failed: ' + e.message, true); }
|
|
3290
|
-
}
|
|
3291
|
-
|
|
3292
|
-
async function kaDeleteRole(roleId) {
|
|
3293
|
-
try {
|
|
3294
|
-
await kaSend('auth-delete-role', { roleId });
|
|
3295
|
-
kaStatus('Role deleted');
|
|
3296
|
-
loadKeyAuth();
|
|
3297
|
-
} catch (e) { kaStatus('Delete failed: ' + e.message, true); }
|
|
3298
|
-
}
|
|
3299
|
-
|
|
3300
|
-
async function kaUpdateRoles() {
|
|
3301
|
-
const fp = document.getElementById('ka-assign-account').value;
|
|
3302
|
-
const rolesStr = document.getElementById('ka-assign-roles').value.trim();
|
|
3303
|
-
if (!fp || !rolesStr) { kaStatus('Select account and enter roles', true); return; }
|
|
3304
|
-
const roles = rolesStr.split(',').map(r => r.trim()).filter(Boolean);
|
|
3305
|
-
try {
|
|
3306
|
-
await kaSend('auth-update-roles', { accountFingerprint: fp, roles });
|
|
3307
|
-
kaStatus('Roles updated');
|
|
3308
|
-
loadKeyAuth();
|
|
3309
|
-
} catch (e) { kaStatus('Update failed: ' + e.message, true); }
|
|
3310
|
-
}
|
|
3311
|
-
|
|
3312
|
-
async function kaCreateAccount() {
|
|
3313
|
-
const pw = document.getElementById('ka-new-pw').value.trim();
|
|
3314
|
-
if (!pw) { kaStatus('Password required', true); return; }
|
|
3315
|
-
const displayName = document.getElementById('ka-new-name').value.trim() || undefined;
|
|
3316
|
-
const rolesStr = document.getElementById('ka-new-roles').value.trim();
|
|
3317
|
-
const roles = rolesStr ? rolesStr.split(',').map(r => r.trim()) : [];
|
|
3318
|
-
try {
|
|
3319
|
-
const result = await kaSend('auth-create-account', { password: pw, roles, displayName });
|
|
3320
|
-
_kaLastFp = result.fingerprint;
|
|
3321
|
-
document.getElementById('ka-auth-fp').value = result.fingerprint;
|
|
3322
|
-
kaStatus('Account created: ' + result.fingerprint.slice(0, 12));
|
|
3323
|
-
loadKeyAuth();
|
|
3324
|
-
} catch (e) {
|
|
3325
|
-
kaStatus('Create failed: ' + e.message, true);
|
|
3326
|
-
}
|
|
3327
|
-
}
|
|
3328
|
-
|
|
3329
|
-
async function kaRegisterDevice() {
|
|
3330
|
-
const el = document.getElementById('ka-auth-result');
|
|
3331
|
-
try {
|
|
3332
|
-
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
3333
|
-
const spki = await crypto.subtle.exportKey('spki', kp.publicKey);
|
|
3334
|
-
const pkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey);
|
|
3335
|
-
const pubB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
|
3336
|
-
_kaDeviceKeys = { publicKey: pubB64, privateKeyDer: new Uint8Array(pkcs8) };
|
|
3337
|
-
sessionStorage.setItem('ka-device-pub', pubB64);
|
|
3338
|
-
sessionStorage.setItem('ka-device-priv', btoa(String.fromCharCode(...new Uint8Array(pkcs8))));
|
|
3339
|
-
const result = await kaSend('auth-register-device', { publicKey: pubB64, displayName: 'Browser Device' });
|
|
3340
|
-
kaStatus('Device registered: ' + result.fingerprint.slice(0, 12));
|
|
3341
|
-
el.textContent = 'Device registered! fp: ' + result.fingerprint + '\nUse "Device Auth" to authenticate.';
|
|
3342
|
-
loadKeyAuth();
|
|
3343
|
-
} catch (e) {
|
|
3344
|
-
kaStatus('Register failed: ' + e.message, true);
|
|
3345
|
-
}
|
|
3346
|
-
}
|
|
3347
|
-
|
|
3348
|
-
async function kaDeviceAuth() {
|
|
3349
|
-
const el = document.getElementById('ka-auth-result');
|
|
3350
|
-
// Restore from sessionStorage if needed
|
|
3351
|
-
if (!_kaDeviceKeys) {
|
|
3352
|
-
const pub = sessionStorage.getItem('ka-device-pub');
|
|
3353
|
-
const priv = sessionStorage.getItem('ka-device-priv');
|
|
3354
|
-
if (pub && priv) {
|
|
3355
|
-
_kaDeviceKeys = { publicKey: pub, privateKeyDer: Uint8Array.from(atob(priv), c => c.charCodeAt(0)) };
|
|
3356
|
-
}
|
|
3357
|
-
}
|
|
3358
|
-
if (!_kaDeviceKeys) {
|
|
3359
|
-
el.textContent = 'No device keys. Click "Register Device" first.';
|
|
3360
|
-
kaStatus('No device keys', true);
|
|
3361
|
-
return;
|
|
3362
|
-
}
|
|
3363
|
-
try {
|
|
3364
|
-
el.textContent = 'Requesting challenge…';
|
|
3365
|
-
const challenge = await kaSend('auth-challenge');
|
|
3366
|
-
el.textContent = 'Signing with device key…';
|
|
3367
|
-
const sigKey = await crypto.subtle.importKey('pkcs8', _kaDeviceKeys.privateKeyDer, { name: 'Ed25519' }, false, ['sign']);
|
|
3368
|
-
const sigBuf = await crypto.subtle.sign('Ed25519', sigKey, new TextEncoder().encode(challenge.nonce));
|
|
3369
|
-
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sigBuf)));
|
|
3370
|
-
const result = await kaSend('auth-verify', { publicKey: _kaDeviceKeys.publicKey, signature: sigB64, nonce: challenge.nonce });
|
|
3371
|
-
el.textContent = 'Device authenticated!\nToken: ' + result.token.slice(0, 60) + '…\nExpires: ' + new Date(result.expiresAt).toLocaleString();
|
|
3372
|
-
kaStatus('Device auth succeeded');
|
|
3373
|
-
loadKeyAuth();
|
|
3374
|
-
} catch (e) {
|
|
3375
|
-
el.textContent = 'Error: ' + e.message;
|
|
3376
|
-
kaStatus('Device auth failed: ' + e.message, true);
|
|
3377
|
-
}
|
|
3378
|
-
}
|
|
3379
|
-
|
|
3380
|
-
async function kaShowQR() {
|
|
3381
|
-
const el = document.getElementById('ka-auth-result');
|
|
3382
|
-
const qrEl = document.getElementById('ka-qr-display');
|
|
3383
|
-
try {
|
|
3384
|
-
// Generate ephemeral keypair for new device
|
|
3385
|
-
const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
3386
|
-
const spki = await crypto.subtle.exportKey('spki', kp.publicKey);
|
|
3387
|
-
const pkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey);
|
|
3388
|
-
const pubB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
|
3389
|
-
_kaDeviceKeys = { publicKey: pubB64, privateKeyDer: new Uint8Array(pkcs8) };
|
|
3390
|
-
|
|
3391
|
-
const result = await kaSend('auth-request-approval', { publicKey: pubB64 });
|
|
3392
|
-
const requestId = result.requestId;
|
|
3393
|
-
const approveUrl = `${location.origin}${location.pathname}#approve=${requestId}`;
|
|
3394
|
-
|
|
3395
|
-
qrEl.style.display = 'block';
|
|
3396
|
-
const qrImg = await generateQRSvg(approveUrl, 150);
|
|
3397
|
-
qrEl.innerHTML = `<div style="display:flex;gap:12px;align-items:flex-start">` +
|
|
3398
|
-
`<div style="background:#fff;padding:4px;border-radius:4px;line-height:0">${qrImg}</div>` +
|
|
3399
|
-
`<div style="flex:1">` +
|
|
3400
|
-
`<div style="margin-bottom:6px"><b>Scan QR</b> or copy Request ID:</div>` +
|
|
3401
|
-
`<input type="text" value="${requestId}" readonly onclick="this.select();navigator.clipboard?.writeText(this.value)" style="width:100%;font-size:13px;padding:6px;background:#0d1117;color:#58a6ff;border:1px solid #58a6ff;border-radius:3px;cursor:pointer" title="Click to copy">` +
|
|
3402
|
-
`<div style="margin-top:8px;color:#0f0">⏳ Waiting for approval…</div>` +
|
|
3403
|
-
`</div></div>`;
|
|
3404
|
-
// Auto-fill the approve field too for same-tab testing
|
|
3405
|
-
document.getElementById('ka-qr-request-id').value = requestId;
|
|
3406
|
-
el.textContent = 'Waiting for a signed-in device to approve…';
|
|
3407
|
-
|
|
3408
|
-
// Poll for approval
|
|
3409
|
-
if (_kaQrPollTimer) clearInterval(_kaQrPollTimer);
|
|
3410
|
-
_kaQrPollTimer = setInterval(async () => {
|
|
3411
|
-
try {
|
|
3412
|
-
const poll = await kaSend('auth-poll-approval', { requestId });
|
|
3413
|
-
if (poll.status === 'approved') {
|
|
3414
|
-
clearInterval(_kaQrPollTimer);
|
|
3415
|
-
_kaQrPollTimer = null;
|
|
3416
|
-
qrEl.innerHTML += '<br><b style="color:#0f0">✓ APPROVED!</b>';
|
|
3417
|
-
el.textContent = 'Cross-device auth complete!\nToken: ' + poll.token.slice(0, 60) + '…\nExpires: ' + new Date(poll.expiresAt).toLocaleString();
|
|
3418
|
-
kaStatus('QR approval succeeded');
|
|
3419
|
-
loadKeyAuth();
|
|
3420
|
-
} else if (poll.status === 'expired') {
|
|
3421
|
-
clearInterval(_kaQrPollTimer);
|
|
3422
|
-
_kaQrPollTimer = null;
|
|
3423
|
-
qrEl.innerHTML += '<br><b style="color:#f44">✗ Expired</b>';
|
|
3424
|
-
kaStatus('QR request expired', true);
|
|
3425
|
-
}
|
|
3426
|
-
} catch {}
|
|
3427
|
-
}, 2000);
|
|
3428
|
-
} catch (e) {
|
|
3429
|
-
el.textContent = 'Error: ' + e.message;
|
|
3430
|
-
kaStatus('QR failed: ' + e.message, true);
|
|
3431
|
-
}
|
|
3432
|
-
}
|
|
3433
|
-
|
|
3434
|
-
async function kaApproveQR() {
|
|
3435
|
-
const requestId = document.getElementById('ka-qr-request-id').value.trim();
|
|
3436
|
-
if (!requestId) { kaStatus('Enter a Request ID', true); return; }
|
|
3437
|
-
try {
|
|
3438
|
-
const result = await kaSend('auth-approve-device', { requestId });
|
|
3439
|
-
kaStatus('Approved! Device fp: ' + result.fingerprint.slice(0, 12));
|
|
3440
|
-
loadKeyAuth();
|
|
3441
|
-
} catch (e) {
|
|
3442
|
-
kaStatus('Approve failed: ' + e.message, true);
|
|
3443
|
-
}
|
|
3444
|
-
}
|
|
3445
|
-
|
|
3446
|
-
async function kaCreateRole() {
|
|
3447
|
-
const id = document.getElementById('ka-role-id').value.trim();
|
|
3448
|
-
const name = document.getElementById('ka-role-name').value.trim();
|
|
3449
|
-
if (!id) { kaStatus('Role ID required', true); return; }
|
|
3450
|
-
const role = { id, name: name || id, permissions: [..._kaPerms] };
|
|
3451
|
-
try {
|
|
3452
|
-
await kaSend('auth-create-role', { role });
|
|
3453
|
-
_kaPerms = [];
|
|
3454
|
-
kaStatus('Role created: ' + id);
|
|
3455
|
-
loadKeyAuth();
|
|
3456
|
-
} catch (e) {
|
|
3457
|
-
kaStatus('Create role failed: ' + e.message, true);
|
|
3458
|
-
}
|
|
3459
|
-
}
|
|
3460
|
-
|
|
3461
|
-
function kaAddPermission() {
|
|
3462
|
-
const path = document.getElementById('ka-perm-path').value.trim();
|
|
3463
|
-
const read = document.getElementById('ka-perm-read').checked;
|
|
3464
|
-
const write = document.getElementById('ka-perm-write').checked;
|
|
3465
|
-
if (!path) { kaStatus('Path required', true); return; }
|
|
3466
|
-
_kaPerms.push({ path, read, write });
|
|
3467
|
-
kaStatus(`Permission queued: ${path} (R:${read} W:${write}). ${_kaPerms.length} pending.`);
|
|
3468
|
-
}
|
|
3469
|
-
|
|
3470
|
-
async function kaChallengeResponse() {
|
|
3471
|
-
const fp = document.getElementById('ka-auth-fp').value.trim();
|
|
3472
|
-
const pw = document.getElementById('ka-auth-pw').value.trim();
|
|
3473
|
-
if (!fp) { kaStatus('Enter account fingerprint', true); return; }
|
|
3474
|
-
const el = document.getElementById('ka-auth-result');
|
|
3475
|
-
try {
|
|
3476
|
-
el.textContent = 'Step 1: Fetching account + decrypting key…';
|
|
3477
|
-
const account = await db.get('_auth/accounts/' + fp);
|
|
3478
|
-
if (!account) { el.textContent = 'Account not found: ' + fp; return; }
|
|
3479
|
-
|
|
3480
|
-
const salt = Uint8Array.from(atob(account.salt), c => c.charCodeAt(0));
|
|
3481
|
-
const iv = Uint8Array.from(atob(account.iv), c => c.charCodeAt(0));
|
|
3482
|
-
const authTag = Uint8Array.from(atob(account.authTag), c => c.charCodeAt(0));
|
|
3483
|
-
const encrypted = Uint8Array.from(atob(account.encryptedPrivateKey), c => c.charCodeAt(0));
|
|
3484
|
-
|
|
3485
|
-
const keyMaterial = await crypto.subtle.importKey('raw', new TextEncoder().encode(pw), 'PBKDF2', false, ['deriveKey']);
|
|
3486
|
-
const aesKey = await crypto.subtle.deriveKey(
|
|
3487
|
-
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
|
|
3488
|
-
keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['decrypt']
|
|
3489
|
-
);
|
|
3490
|
-
|
|
3491
|
-
const ciphertext = new Uint8Array(encrypted.length + authTag.length);
|
|
3492
|
-
ciphertext.set(encrypted);
|
|
3493
|
-
ciphertext.set(authTag, encrypted.length);
|
|
3494
|
-
|
|
3495
|
-
let privateKeyDer;
|
|
3496
|
-
try {
|
|
3497
|
-
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, ciphertext);
|
|
3498
|
-
privateKeyDer = new Uint8Array(decrypted);
|
|
3499
|
-
} catch {
|
|
3500
|
-
el.textContent = 'Wrong password — decryption failed';
|
|
3501
|
-
kaStatus('Wrong password', true);
|
|
3502
|
-
return;
|
|
3503
|
-
}
|
|
3504
|
-
|
|
3505
|
-
el.textContent = 'Step 2: Requesting challenge nonce…';
|
|
3506
|
-
const challenge = await kaSend('auth-challenge');
|
|
3507
|
-
|
|
3508
|
-
el.textContent = 'Step 3: Signing nonce with Ed25519…';
|
|
3509
|
-
const sigKey = await crypto.subtle.importKey('pkcs8', privateKeyDer, { name: 'Ed25519' }, false, ['sign']);
|
|
3510
|
-
const sigBuf = await crypto.subtle.sign('Ed25519', sigKey, new TextEncoder().encode(challenge.nonce));
|
|
3511
|
-
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sigBuf)));
|
|
3512
|
-
|
|
3513
|
-
el.textContent = 'Step 4: Verifying signature…';
|
|
3514
|
-
const result = await kaSend('auth-verify', { publicKey: account.publicKey, signature: sigB64, nonce: challenge.nonce });
|
|
3515
|
-
|
|
3516
|
-
el.textContent = 'Authenticated!\n\nToken: ' + result.token.slice(0, 60) + '…\nExpires: ' + new Date(result.expiresAt).toLocaleString();
|
|
3517
|
-
kaStatus('Challenge-response succeeded');
|
|
3518
|
-
loadKeyAuth();
|
|
3519
|
-
} catch (e) {
|
|
3520
|
-
el.textContent = 'Error: ' + e.message;
|
|
3521
|
-
kaStatus('Auth failed: ' + e.message, true);
|
|
3522
|
-
}
|
|
3523
|
-
}
|
|
3524
|
-
|
|
3525
|
-
// QR Code generation via qrcode-generator (loaded from CDN, ~4kb gzipped)
|
|
3526
|
-
let _qrLib = null;
|
|
3527
|
-
async function loadQRLib() {
|
|
3528
|
-
if (_qrLib) return _qrLib;
|
|
3529
|
-
return new Promise((resolve, reject) => {
|
|
3530
|
-
const s = document.createElement('script');
|
|
3531
|
-
s.src = 'https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js';
|
|
3532
|
-
s.onload = () => { _qrLib = window.qrcode; resolve(_qrLib); };
|
|
3533
|
-
s.onerror = () => reject(new Error('Failed to load QR lib'));
|
|
3534
|
-
document.head.appendChild(s);
|
|
3535
|
-
});
|
|
3536
|
-
}
|
|
3537
|
-
|
|
3538
|
-
async function generateQRSvg(text, size = 150) {
|
|
3539
|
-
const qr = await loadQRLib();
|
|
3540
|
-
const q = qr(0, 'L');
|
|
3541
|
-
q.addData(text);
|
|
3542
|
-
q.make();
|
|
3543
|
-
const moduleCount = q.getModuleCount();
|
|
3544
|
-
const cellSize = Math.floor(size / moduleCount);
|
|
3545
|
-
const actualSize = cellSize * moduleCount;
|
|
3546
|
-
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${actualSize}" height="${actualSize}" viewBox="0 0 ${moduleCount} ${moduleCount}">`;
|
|
3547
|
-
svg += `<rect width="${moduleCount}" height="${moduleCount}" fill="#fff"/>`;
|
|
3548
|
-
for (let r = 0; r < moduleCount; r++)
|
|
3549
|
-
for (let c = 0; c < moduleCount; c++)
|
|
3550
|
-
if (q.isDark(r, c)) svg += `<rect x="${c}" y="${r}" width="1" height="1" fill="#000"/>`;
|
|
3551
|
-
svg += '</svg>';
|
|
3552
|
-
return svg;
|
|
3553
|
-
}
|
|
3554
|
-
|
|
3555
|
-
function kaStatus(msg, isErr) {
|
|
3556
|
-
const el = document.getElementById('ka-status');
|
|
3557
|
-
el.textContent = msg;
|
|
3558
|
-
el.className = 'status ' + (isErr ? 'err' : 'ok');
|
|
3559
|
-
}
|
|
3560
|
-
</script>
|
|
3561
|
-
</body>
|
|
3562
|
-
</html>
|