@objy/server 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +1469 -0
- package/LICENSE +21 -0
- package/NOTICE.md +9 -0
- package/README.md +21 -2
- package/objydb.config.json +48 -0
- package/package.json +42 -4
- package/public/index.html +4134 -0
- package/public/objy-db-logo.png +0 -0
- package/src/aggregate.js +438 -0
- package/src/api.js +15 -0
- package/src/auth.js +472 -0
- package/src/clock.js +18 -0
- package/src/cluster-secret.js +25 -0
- package/src/cluster-status.js +562 -0
- package/src/compression-stats.js +32 -0
- package/src/config.js +360 -0
- package/src/crdt.js +1076 -0
- package/src/derived-state-scheduler.js +128 -0
- package/src/files.js +324 -0
- package/src/hnsw.js +269 -0
- package/src/http-cors.js +31 -0
- package/src/http-router.js +162 -0
- package/src/index.js +137 -0
- package/src/interfaces/auth.js +123 -0
- package/src/interfaces/cluster.js +296 -0
- package/src/interfaces/public-object.js +2452 -0
- package/src/interfaces/ui.js +38 -0
- package/src/lib/crypto.cjs +54 -0
- package/src/lib/jwt.cjs +231 -0
- package/src/local-wal.js +122 -0
- package/src/memory-guard.js +152 -0
- package/src/ndjson.js +117 -0
- package/src/node-runtime.js +29 -0
- package/src/object-hooks.js +282 -0
- package/src/object-id.js +7 -0
- package/src/object-storage.js +263 -0
- package/src/object-store.js +288 -0
- package/src/patch.js +87 -0
- package/src/permissions.js +178 -0
- package/src/persistence.js +1918 -0
- package/src/plugin-loader.js +21 -0
- package/src/query-planner.js +266 -0
- package/src/query.js +416 -0
- package/src/references.js +349 -0
- package/src/replication-scheduler.js +303 -0
- package/src/restore-status.js +77 -0
- package/src/runtime.js +47 -0
- package/src/server.js +242 -0
- package/src/shape.js +286 -0
- package/src/shard-map-store.js +85 -0
- package/src/shard-map.js +333 -0
- package/src/shard-router.js +737 -0
- package/src/store.js +3410 -0
- package/src/system-secrets.js +107 -0
- package/src/vector.js +83 -0
|
@@ -0,0 +1,4134 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>OBJY DB</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0d0e0f;
|
|
10
|
+
--band: #121416;
|
|
11
|
+
--panel: #171a1d;
|
|
12
|
+
--panel-2: #1d2125;
|
|
13
|
+
--line: #2b3136;
|
|
14
|
+
--line-soft: #22272b;
|
|
15
|
+
--text: #ece7db;
|
|
16
|
+
--muted: #a6a095;
|
|
17
|
+
--faint: #746f66;
|
|
18
|
+
--teal: #3ed7b5;
|
|
19
|
+
--teal-2: #16443a;
|
|
20
|
+
--amber: #f0b64d;
|
|
21
|
+
--red: #ef6f6c;
|
|
22
|
+
--blue: #6ea8fe;
|
|
23
|
+
--shadow: 0 16px 48px rgba(0, 0, 0, 0.34);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
* { box-sizing: border-box; }
|
|
27
|
+
|
|
28
|
+
html, body {
|
|
29
|
+
height: 100%;
|
|
30
|
+
min-height: 100%;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
body {
|
|
35
|
+
margin: 0;
|
|
36
|
+
background: var(--bg);
|
|
37
|
+
color: var(--text);
|
|
38
|
+
font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
button, input, textarea, select { font: inherit; }
|
|
42
|
+
|
|
43
|
+
button {
|
|
44
|
+
border: 1px solid transparent;
|
|
45
|
+
border-radius: 8px;
|
|
46
|
+
min-height: 34px;
|
|
47
|
+
padding: 7px 11px;
|
|
48
|
+
background: var(--teal);
|
|
49
|
+
color: #061713;
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
justify-content: center;
|
|
54
|
+
gap: 7px;
|
|
55
|
+
white-space: nowrap;
|
|
56
|
+
font-weight: 700;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
button:hover { filter: brightness(1.06); }
|
|
60
|
+
button:disabled { opacity: .45; cursor: not-allowed; filter: none; }
|
|
61
|
+
button.secondary { background: var(--panel-2); color: var(--text); border-color: var(--line); }
|
|
62
|
+
button.ghost { background: transparent; color: var(--muted); border-color: var(--line-soft); }
|
|
63
|
+
button.danger { background: #3c1f21; color: #ffd7d4; border-color: #743134; }
|
|
64
|
+
button.icon-only { width: 34px; padding: 0; }
|
|
65
|
+
|
|
66
|
+
svg {
|
|
67
|
+
width: 16px;
|
|
68
|
+
height: 16px;
|
|
69
|
+
flex: 0 0 auto;
|
|
70
|
+
stroke: currentColor;
|
|
71
|
+
stroke-width: 2;
|
|
72
|
+
stroke-linecap: round;
|
|
73
|
+
stroke-linejoin: round;
|
|
74
|
+
fill: none;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
input, textarea, select {
|
|
78
|
+
width: 100%;
|
|
79
|
+
min-width: 0;
|
|
80
|
+
border: 1px solid var(--line);
|
|
81
|
+
border-radius: 8px;
|
|
82
|
+
background: #0f1112;
|
|
83
|
+
color: var(--text);
|
|
84
|
+
padding: 9px 10px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
input[type="file"] {
|
|
88
|
+
padding: 7px 10px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
textarea {
|
|
92
|
+
min-height: 78px;
|
|
93
|
+
resize: vertical;
|
|
94
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
95
|
+
font-size: 12px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
input:focus, textarea:focus, select:focus, button:focus-visible {
|
|
99
|
+
outline: 2px solid rgba(62, 215, 181, .45);
|
|
100
|
+
outline-offset: 1px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
label {
|
|
104
|
+
display: grid;
|
|
105
|
+
gap: 6px;
|
|
106
|
+
color: var(--muted);
|
|
107
|
+
font-size: 12px;
|
|
108
|
+
min-width: 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.app {
|
|
112
|
+
height: 100vh;
|
|
113
|
+
min-height: 0;
|
|
114
|
+
display: grid;
|
|
115
|
+
grid-template-rows: 58px 1fr;
|
|
116
|
+
overflow: hidden;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.topbar {
|
|
120
|
+
display: grid;
|
|
121
|
+
grid-template-columns: minmax(170px, 260px) max-content minmax(0, 1fr) auto;
|
|
122
|
+
align-items: center;
|
|
123
|
+
gap: 12px;
|
|
124
|
+
min-height: 58px;
|
|
125
|
+
padding: 0 18px;
|
|
126
|
+
border-bottom: 1px solid var(--line-soft);
|
|
127
|
+
background: rgba(13, 14, 15, .94);
|
|
128
|
+
position: sticky;
|
|
129
|
+
top: 0;
|
|
130
|
+
z-index: 10;
|
|
131
|
+
backdrop-filter: blur(16px);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.brand {
|
|
135
|
+
display: flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
gap: 10px;
|
|
138
|
+
min-width: 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.brand-logo {
|
|
142
|
+
width: 64px;
|
|
143
|
+
height: 32px;
|
|
144
|
+
object-fit: contain;
|
|
145
|
+
flex: 0 0 auto;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.brand h1 {
|
|
149
|
+
margin: 0;
|
|
150
|
+
font-size: 15px;
|
|
151
|
+
line-height: 1.1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.brand small {
|
|
155
|
+
display: block;
|
|
156
|
+
color: var(--muted);
|
|
157
|
+
font-size: 11px;
|
|
158
|
+
margin-top: 2px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.connection {
|
|
162
|
+
display: flex;
|
|
163
|
+
align-items: center;
|
|
164
|
+
gap: 9px;
|
|
165
|
+
width: max-content;
|
|
166
|
+
max-width: 100%;
|
|
167
|
+
min-width: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.server-label {
|
|
171
|
+
flex: 0 1 auto;
|
|
172
|
+
width: max-content;
|
|
173
|
+
max-width: min(260px, 28vw);
|
|
174
|
+
min-width: 0;
|
|
175
|
+
height: 34px;
|
|
176
|
+
justify-content: flex-start;
|
|
177
|
+
border-color: var(--line-soft);
|
|
178
|
+
background: transparent;
|
|
179
|
+
color: var(--muted);
|
|
180
|
+
padding: 4px 9px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.server-label:hover {
|
|
184
|
+
color: var(--text);
|
|
185
|
+
border-color: var(--line);
|
|
186
|
+
filter: none;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.server-kicker {
|
|
190
|
+
color: var(--faint);
|
|
191
|
+
font-size: 10px;
|
|
192
|
+
font-weight: 850;
|
|
193
|
+
text-transform: uppercase;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.server-value {
|
|
197
|
+
min-width: 0;
|
|
198
|
+
max-width: 180px;
|
|
199
|
+
overflow: hidden;
|
|
200
|
+
text-overflow: ellipsis;
|
|
201
|
+
white-space: nowrap;
|
|
202
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
203
|
+
font-size: 12px;
|
|
204
|
+
color: #fff7e8;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.session {
|
|
208
|
+
display: flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
justify-content: end;
|
|
211
|
+
gap: 9px;
|
|
212
|
+
min-width: 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.top-stats-wrap {
|
|
216
|
+
display: flex;
|
|
217
|
+
align-items: center;
|
|
218
|
+
gap: 8px;
|
|
219
|
+
min-width: 0;
|
|
220
|
+
overflow: hidden;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.top-stats {
|
|
224
|
+
display: flex;
|
|
225
|
+
flex: 1 1 auto;
|
|
226
|
+
align-items: center;
|
|
227
|
+
gap: 7px;
|
|
228
|
+
min-width: 0;
|
|
229
|
+
overflow-x: auto;
|
|
230
|
+
overflow-y: hidden;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.top-stat {
|
|
234
|
+
display: inline-grid;
|
|
235
|
+
grid-template-columns: auto auto;
|
|
236
|
+
align-items: baseline;
|
|
237
|
+
column-gap: 6px;
|
|
238
|
+
min-height: 30px;
|
|
239
|
+
min-width: 0;
|
|
240
|
+
flex: 0 0 auto;
|
|
241
|
+
border: 1px solid var(--line-soft);
|
|
242
|
+
border-radius: 8px;
|
|
243
|
+
background: rgba(255, 255, 255, .02);
|
|
244
|
+
padding: 4px 8px;
|
|
245
|
+
white-space: nowrap;
|
|
246
|
+
color: var(--text);
|
|
247
|
+
text-align: left;
|
|
248
|
+
justify-content: start;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
button.top-stat {
|
|
252
|
+
min-height: 30px;
|
|
253
|
+
font-weight: 400;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
button.top-stat:hover {
|
|
257
|
+
border-color: var(--line);
|
|
258
|
+
background: rgba(255, 255, 255, .045);
|
|
259
|
+
filter: none;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.top-stat .stat-label {
|
|
263
|
+
color: var(--faint);
|
|
264
|
+
font-size: 10px;
|
|
265
|
+
font-weight: 850;
|
|
266
|
+
text-transform: uppercase;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.top-stat strong {
|
|
270
|
+
color: #fff7e8;
|
|
271
|
+
font-size: 12px;
|
|
272
|
+
font-weight: 850;
|
|
273
|
+
overflow: hidden;
|
|
274
|
+
text-overflow: ellipsis;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.top-stat.ok strong { color: var(--teal); }
|
|
278
|
+
.top-stat.warn strong { color: var(--amber); }
|
|
279
|
+
.top-stat.bad strong { color: var(--red); }
|
|
280
|
+
|
|
281
|
+
.top-stat.node-rss .stat-label {
|
|
282
|
+
max-width: 96px;
|
|
283
|
+
overflow: hidden;
|
|
284
|
+
text-overflow: ellipsis;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.memory-stat {
|
|
288
|
+
grid-template-columns: auto auto;
|
|
289
|
+
grid-template-rows: auto 3px;
|
|
290
|
+
align-items: center;
|
|
291
|
+
min-width: 112px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.memory-meter {
|
|
295
|
+
grid-column: 1 / -1;
|
|
296
|
+
width: 100%;
|
|
297
|
+
height: 3px;
|
|
298
|
+
border-radius: 999px;
|
|
299
|
+
background: rgba(255, 255, 255, .09);
|
|
300
|
+
overflow: hidden;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.memory-meter::before {
|
|
304
|
+
content: "";
|
|
305
|
+
display: block;
|
|
306
|
+
width: var(--used, 0%);
|
|
307
|
+
height: 100%;
|
|
308
|
+
border-radius: inherit;
|
|
309
|
+
background: var(--meter-color, var(--teal));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.memory-stat.warn .memory-meter { --meter-color: var(--amber); }
|
|
313
|
+
.memory-stat.bad .memory-meter { --meter-color: var(--red); }
|
|
314
|
+
|
|
315
|
+
.top-config-message {
|
|
316
|
+
min-height: 30px;
|
|
317
|
+
display: flex;
|
|
318
|
+
align-items: center;
|
|
319
|
+
color: var(--muted);
|
|
320
|
+
font-size: 12px;
|
|
321
|
+
overflow: hidden;
|
|
322
|
+
text-overflow: ellipsis;
|
|
323
|
+
white-space: nowrap;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.top-config-message.bad { color: #ffd1cf; }
|
|
327
|
+
|
|
328
|
+
.pill {
|
|
329
|
+
display: inline-flex;
|
|
330
|
+
align-items: center;
|
|
331
|
+
gap: 7px;
|
|
332
|
+
min-height: 28px;
|
|
333
|
+
max-width: 280px;
|
|
334
|
+
padding: 4px 9px;
|
|
335
|
+
border-radius: 999px;
|
|
336
|
+
border: 1px solid var(--line);
|
|
337
|
+
color: var(--muted);
|
|
338
|
+
background: var(--panel);
|
|
339
|
+
overflow: hidden;
|
|
340
|
+
text-overflow: ellipsis;
|
|
341
|
+
white-space: nowrap;
|
|
342
|
+
font-size: 12px;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.shell {
|
|
346
|
+
display: grid;
|
|
347
|
+
grid-template-columns: 326px minmax(0, 1fr);
|
|
348
|
+
min-height: 0;
|
|
349
|
+
overflow: hidden;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.sidebar {
|
|
353
|
+
min-width: 0;
|
|
354
|
+
min-height: 0;
|
|
355
|
+
border-right: 1px solid var(--line-soft);
|
|
356
|
+
background: var(--band);
|
|
357
|
+
display: grid;
|
|
358
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
359
|
+
overflow: hidden;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.login-panel {
|
|
363
|
+
padding: 16px;
|
|
364
|
+
border-bottom: 1px solid var(--line-soft);
|
|
365
|
+
display: grid;
|
|
366
|
+
gap: 12px;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.login-panel[hidden] {
|
|
370
|
+
display: none;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.login-grid {
|
|
374
|
+
display: grid;
|
|
375
|
+
grid-template-columns: 1fr 1fr;
|
|
376
|
+
gap: 9px;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.tree-panel {
|
|
380
|
+
min-height: 0;
|
|
381
|
+
display: grid;
|
|
382
|
+
grid-template-rows: auto auto 1fr;
|
|
383
|
+
overflow: hidden;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.tree-head, .content-head, .detail-head {
|
|
387
|
+
display: flex;
|
|
388
|
+
align-items: center;
|
|
389
|
+
justify-content: space-between;
|
|
390
|
+
gap: 10px;
|
|
391
|
+
min-width: 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.tree-actions, .content-actions, .object-actions, .modal-actions {
|
|
395
|
+
display: flex;
|
|
396
|
+
align-items: center;
|
|
397
|
+
gap: 8px;
|
|
398
|
+
min-width: 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.content-actions, .object-actions {
|
|
402
|
+
flex-wrap: wrap;
|
|
403
|
+
justify-content: flex-end;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.tree-head {
|
|
407
|
+
padding: 14px 16px 10px;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
.tree-head.actions-only {
|
|
411
|
+
justify-content: flex-end;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.tree-filter {
|
|
415
|
+
padding: 0 16px 10px;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.tree-filter input {
|
|
419
|
+
height: 34px;
|
|
420
|
+
padding: 7px 10px;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.pager {
|
|
424
|
+
display: inline-flex;
|
|
425
|
+
align-items: center;
|
|
426
|
+
gap: 6px;
|
|
427
|
+
min-width: 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.pager .pill {
|
|
431
|
+
min-width: 56px;
|
|
432
|
+
justify-content: center;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.section-title {
|
|
436
|
+
color: var(--muted);
|
|
437
|
+
font-size: 11px;
|
|
438
|
+
font-weight: 800;
|
|
439
|
+
text-transform: uppercase;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.tree {
|
|
443
|
+
min-height: 0;
|
|
444
|
+
overflow: auto;
|
|
445
|
+
padding: 0 10px 14px;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.db-block {
|
|
449
|
+
margin-bottom: 8px;
|
|
450
|
+
border: 1px solid var(--line-soft);
|
|
451
|
+
border-radius: 8px;
|
|
452
|
+
overflow: hidden;
|
|
453
|
+
background: rgba(255, 255, 255, .015);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.db-row, .coll-row {
|
|
457
|
+
width: 100%;
|
|
458
|
+
background: transparent;
|
|
459
|
+
color: var(--text);
|
|
460
|
+
min-height: 38px;
|
|
461
|
+
display: grid;
|
|
462
|
+
grid-template-columns: minmax(0, 1fr) 30px;
|
|
463
|
+
align-items: center;
|
|
464
|
+
font-weight: 750;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.db-row {
|
|
468
|
+
color: #fff7e8;
|
|
469
|
+
background: rgba(255, 255, 255, .025);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.db-row .chevron {
|
|
473
|
+
width: 14px;
|
|
474
|
+
height: 14px;
|
|
475
|
+
transition: transform .12s ease;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.db-row[aria-expanded="false"] .chevron {
|
|
479
|
+
transform: rotate(-90deg);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.coll-list {
|
|
483
|
+
display: grid;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.coll-list[hidden] {
|
|
487
|
+
display: none;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.coll-row {
|
|
491
|
+
color: var(--muted);
|
|
492
|
+
font-weight: 650;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.coll-row.active {
|
|
496
|
+
color: var(--teal);
|
|
497
|
+
background: rgba(62, 215, 181, .09);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.db-name, .coll-name {
|
|
501
|
+
overflow: hidden;
|
|
502
|
+
text-overflow: ellipsis;
|
|
503
|
+
white-space: nowrap;
|
|
504
|
+
min-width: 0;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.count-chip {
|
|
508
|
+
margin-left: auto;
|
|
509
|
+
color: var(--faint);
|
|
510
|
+
font-size: 11px;
|
|
511
|
+
font-weight: 800;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.tree-select, .tree-delete {
|
|
515
|
+
min-height: 38px;
|
|
516
|
+
border: 0;
|
|
517
|
+
border-radius: 0;
|
|
518
|
+
background: transparent;
|
|
519
|
+
color: inherit;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.tree-select {
|
|
523
|
+
min-width: 0;
|
|
524
|
+
width: 100%;
|
|
525
|
+
padding: 8px 10px;
|
|
526
|
+
justify-content: flex-start;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
.coll-select {
|
|
530
|
+
padding-left: 28px;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.tree-delete {
|
|
534
|
+
width: 28px;
|
|
535
|
+
min-height: 28px;
|
|
536
|
+
padding: 0;
|
|
537
|
+
margin-right: 4px;
|
|
538
|
+
color: var(--faint);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.tree-delete:hover {
|
|
542
|
+
background: rgba(255, 104, 96, .11);
|
|
543
|
+
color: #ffd7d4;
|
|
544
|
+
filter: none;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
.tree-delete svg {
|
|
548
|
+
width: 14px;
|
|
549
|
+
height: 14px;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.main {
|
|
553
|
+
min-width: 0;
|
|
554
|
+
min-height: 0;
|
|
555
|
+
display: grid;
|
|
556
|
+
grid-template-rows: auto 1fr;
|
|
557
|
+
overflow: hidden;
|
|
558
|
+
background:
|
|
559
|
+
linear-gradient(180deg, rgba(62, 215, 181, .035), transparent 180px),
|
|
560
|
+
var(--bg);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
.toolbar {
|
|
564
|
+
padding: 16px 18px;
|
|
565
|
+
border-bottom: 1px solid var(--line-soft);
|
|
566
|
+
display: grid;
|
|
567
|
+
gap: 12px;
|
|
568
|
+
min-width: 0;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.breadcrumb {
|
|
572
|
+
min-width: 0;
|
|
573
|
+
display: flex;
|
|
574
|
+
align-items: center;
|
|
575
|
+
gap: 8px;
|
|
576
|
+
color: var(--muted);
|
|
577
|
+
font-size: 13px;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
.breadcrumb strong {
|
|
581
|
+
color: var(--text);
|
|
582
|
+
overflow: hidden;
|
|
583
|
+
text-overflow: ellipsis;
|
|
584
|
+
white-space: nowrap;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.query-row {
|
|
588
|
+
display: grid;
|
|
589
|
+
grid-template-columns: minmax(220px, 1fr) 92px auto auto;
|
|
590
|
+
gap: 10px;
|
|
591
|
+
align-items: end;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.query-row textarea {
|
|
595
|
+
height: 42px;
|
|
596
|
+
min-height: 42px;
|
|
597
|
+
max-height: 160px;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.query-row button {
|
|
601
|
+
min-height: 42px;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.query-mode {
|
|
605
|
+
display: inline-flex;
|
|
606
|
+
align-items: center;
|
|
607
|
+
gap: 4px;
|
|
608
|
+
border: 1px solid var(--line);
|
|
609
|
+
border-radius: 8px;
|
|
610
|
+
padding: 3px;
|
|
611
|
+
background: rgba(255, 255, 255, .025);
|
|
612
|
+
width: max-content;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.query-mode button {
|
|
616
|
+
min-height: 30px;
|
|
617
|
+
padding: 6px 10px;
|
|
618
|
+
border-color: transparent;
|
|
619
|
+
background: transparent;
|
|
620
|
+
color: var(--muted);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
.query-mode button.active {
|
|
624
|
+
color: var(--text);
|
|
625
|
+
background: rgba(62, 215, 181, .12);
|
|
626
|
+
border-color: rgba(62, 215, 181, .25);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
.query-builder {
|
|
630
|
+
display: none;
|
|
631
|
+
border: 1px solid var(--line-soft);
|
|
632
|
+
border-radius: 8px;
|
|
633
|
+
background: rgba(255, 255, 255, .025);
|
|
634
|
+
padding: 10px;
|
|
635
|
+
gap: 10px;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.query-builder.open {
|
|
639
|
+
display: grid;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.builder-section {
|
|
643
|
+
display: grid;
|
|
644
|
+
gap: 8px;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
.builder-section-head {
|
|
648
|
+
display: flex;
|
|
649
|
+
align-items: center;
|
|
650
|
+
justify-content: space-between;
|
|
651
|
+
gap: 10px;
|
|
652
|
+
min-width: 0;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.builder-title {
|
|
656
|
+
color: var(--muted);
|
|
657
|
+
font-size: 11px;
|
|
658
|
+
text-transform: uppercase;
|
|
659
|
+
letter-spacing: .08em;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.builder-controls {
|
|
663
|
+
display: flex;
|
|
664
|
+
align-items: center;
|
|
665
|
+
gap: 8px;
|
|
666
|
+
flex-wrap: wrap;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
.builder-grid {
|
|
670
|
+
display: grid;
|
|
671
|
+
gap: 6px;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
.builder-row {
|
|
675
|
+
display: grid;
|
|
676
|
+
grid-template-columns: minmax(140px, 1.3fr) 120px minmax(120px, 1fr) 34px;
|
|
677
|
+
gap: 6px;
|
|
678
|
+
align-items: center;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
.builder-row.metric {
|
|
682
|
+
grid-template-columns: minmax(110px, .9fr) 112px minmax(140px, 1.2fr) 34px;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
.builder-row input,
|
|
686
|
+
.builder-row select,
|
|
687
|
+
.builder-controls input,
|
|
688
|
+
.builder-controls select {
|
|
689
|
+
height: 34px;
|
|
690
|
+
padding: 7px 9px;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.builder-row .icon-only {
|
|
694
|
+
width: 34px;
|
|
695
|
+
height: 34px;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.builder-empty {
|
|
699
|
+
color: var(--muted);
|
|
700
|
+
font-size: 12px;
|
|
701
|
+
border: 1px dashed var(--line-soft);
|
|
702
|
+
border-radius: 8px;
|
|
703
|
+
padding: 9px;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
.builder-error {
|
|
707
|
+
color: #ffd1cf;
|
|
708
|
+
font-size: 12px;
|
|
709
|
+
min-height: 16px;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.workspace {
|
|
713
|
+
min-height: 0;
|
|
714
|
+
display: grid;
|
|
715
|
+
grid-template-columns: minmax(260px, 420px) minmax(0, 1fr);
|
|
716
|
+
overflow: hidden;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.object-list {
|
|
720
|
+
min-width: 0;
|
|
721
|
+
min-height: 0;
|
|
722
|
+
border-right: 1px solid var(--line-soft);
|
|
723
|
+
display: grid;
|
|
724
|
+
grid-template-rows: auto 1fr;
|
|
725
|
+
overflow: hidden;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.content-head, .detail-head {
|
|
729
|
+
padding: 13px 16px;
|
|
730
|
+
border-bottom: 1px solid var(--line-soft);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.objects {
|
|
734
|
+
min-height: 0;
|
|
735
|
+
overflow: auto;
|
|
736
|
+
padding: 12px;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.object-card {
|
|
740
|
+
display: grid;
|
|
741
|
+
gap: 7px;
|
|
742
|
+
width: 100%;
|
|
743
|
+
margin-bottom: 9px;
|
|
744
|
+
border: 1px solid var(--line-soft);
|
|
745
|
+
border-radius: 8px;
|
|
746
|
+
background: var(--panel);
|
|
747
|
+
color: var(--text);
|
|
748
|
+
text-align: left;
|
|
749
|
+
padding: 10px;
|
|
750
|
+
font-weight: 500;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.object-card:hover { border-color: rgba(62, 215, 181, .45); filter: none; }
|
|
754
|
+
.object-card.active { border-color: var(--teal); background: rgba(62, 215, 181, .075); }
|
|
755
|
+
|
|
756
|
+
.object-id {
|
|
757
|
+
display: flex;
|
|
758
|
+
align-items: center;
|
|
759
|
+
gap: 8px;
|
|
760
|
+
min-width: 0;
|
|
761
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
762
|
+
font-size: 12px;
|
|
763
|
+
color: #fff5df;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.object-id span {
|
|
767
|
+
overflow: hidden;
|
|
768
|
+
text-overflow: ellipsis;
|
|
769
|
+
white-space: nowrap;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
.file-chip {
|
|
773
|
+
margin-left: auto;
|
|
774
|
+
border: 1px solid rgba(110, 168, 254, .34);
|
|
775
|
+
border-radius: 999px;
|
|
776
|
+
padding: 1px 6px;
|
|
777
|
+
color: var(--blue);
|
|
778
|
+
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
779
|
+
font-size: 10px;
|
|
780
|
+
font-weight: 850;
|
|
781
|
+
text-transform: uppercase;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
.file-chip[hidden] {
|
|
785
|
+
display: none;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.object-preview {
|
|
789
|
+
color: var(--muted);
|
|
790
|
+
font-size: 12px;
|
|
791
|
+
overflow: hidden;
|
|
792
|
+
text-overflow: ellipsis;
|
|
793
|
+
white-space: nowrap;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
.viewer {
|
|
797
|
+
min-width: 0;
|
|
798
|
+
min-height: 0;
|
|
799
|
+
display: grid;
|
|
800
|
+
grid-template-rows: auto 1fr;
|
|
801
|
+
overflow: hidden;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
.detail-tools {
|
|
805
|
+
display: flex;
|
|
806
|
+
gap: 8px;
|
|
807
|
+
align-items: center;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
.json-view {
|
|
811
|
+
min-height: 0;
|
|
812
|
+
overflow: auto;
|
|
813
|
+
padding: 18px;
|
|
814
|
+
display: grid;
|
|
815
|
+
gap: 12px;
|
|
816
|
+
align-content: start;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
.detail-file {
|
|
820
|
+
display: flex;
|
|
821
|
+
align-items: center;
|
|
822
|
+
justify-content: space-between;
|
|
823
|
+
gap: 12px;
|
|
824
|
+
min-width: 0;
|
|
825
|
+
border: 1px solid var(--line-soft);
|
|
826
|
+
border-radius: 8px;
|
|
827
|
+
background: #101213;
|
|
828
|
+
padding: 12px;
|
|
829
|
+
box-shadow: var(--shadow);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
.detail-file div {
|
|
833
|
+
min-width: 0;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
.detail-file strong,
|
|
837
|
+
.detail-file small {
|
|
838
|
+
display: block;
|
|
839
|
+
overflow: hidden;
|
|
840
|
+
text-overflow: ellipsis;
|
|
841
|
+
white-space: nowrap;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
.detail-file strong {
|
|
845
|
+
color: #fff7e8;
|
|
846
|
+
font-size: 13px;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
.detail-file small {
|
|
850
|
+
color: var(--muted);
|
|
851
|
+
font-size: 12px;
|
|
852
|
+
margin-top: 2px;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
pre {
|
|
856
|
+
margin: 0;
|
|
857
|
+
white-space: pre-wrap;
|
|
858
|
+
word-break: break-word;
|
|
859
|
+
font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
.json-box {
|
|
863
|
+
min-width: 0;
|
|
864
|
+
border: 1px solid var(--line-soft);
|
|
865
|
+
border-radius: 8px;
|
|
866
|
+
background: #101213;
|
|
867
|
+
padding: 16px;
|
|
868
|
+
box-shadow: var(--shadow);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
.modal.docs-modal {
|
|
872
|
+
width: min(1120px, 100%);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
.modal.cluster-modal {
|
|
876
|
+
width: min(1180px, 100%);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
.modal-body.docs-body {
|
|
880
|
+
padding: 0;
|
|
881
|
+
display: block;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
.modal-body.cluster-body {
|
|
885
|
+
padding: 0;
|
|
886
|
+
display: block;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
.markdown-view {
|
|
890
|
+
min-height: min(680px, calc(100vh - 150px));
|
|
891
|
+
max-height: min(760px, calc(100vh - 150px));
|
|
892
|
+
overflow: auto;
|
|
893
|
+
padding: 24px 28px 32px;
|
|
894
|
+
color: var(--text);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
.markdown-view h1, .markdown-view h2, .markdown-view h3 {
|
|
898
|
+
color: #fff7e8;
|
|
899
|
+
line-height: 1.18;
|
|
900
|
+
margin: 0 0 10px;
|
|
901
|
+
letter-spacing: 0;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
.markdown-view h1 {
|
|
905
|
+
font-size: 26px;
|
|
906
|
+
padding-bottom: 14px;
|
|
907
|
+
border-bottom: 1px solid var(--line-soft);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
.markdown-view h2 {
|
|
911
|
+
font-size: 18px;
|
|
912
|
+
margin-top: 28px;
|
|
913
|
+
padding-top: 4px;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.markdown-view h3 {
|
|
917
|
+
font-size: 14px;
|
|
918
|
+
margin-top: 22px;
|
|
919
|
+
color: var(--teal);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
.markdown-view p {
|
|
923
|
+
margin: 0 0 12px;
|
|
924
|
+
color: var(--muted);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
.markdown-view img {
|
|
928
|
+
display: block;
|
|
929
|
+
max-width: 100%;
|
|
930
|
+
height: auto;
|
|
931
|
+
margin: 4px auto 20px;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
.markdown-view > p:first-child img {
|
|
935
|
+
width: min(360px, 100%);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
.markdown-view a {
|
|
939
|
+
color: var(--blue);
|
|
940
|
+
text-decoration: none;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
.markdown-view a:hover {
|
|
944
|
+
text-decoration: underline;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
.markdown-view ul {
|
|
948
|
+
margin: 0 0 14px 20px;
|
|
949
|
+
padding: 0;
|
|
950
|
+
color: var(--muted);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
.markdown-view li {
|
|
954
|
+
margin: 5px 0;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.markdown-view strong {
|
|
958
|
+
color: var(--text);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
.markdown-view code {
|
|
962
|
+
border: 1px solid var(--line-soft);
|
|
963
|
+
border-radius: 6px;
|
|
964
|
+
background: #0f1112;
|
|
965
|
+
color: #f3c878;
|
|
966
|
+
padding: 1px 5px;
|
|
967
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
968
|
+
font-size: .92em;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.markdown-view pre {
|
|
972
|
+
margin: 0 0 16px;
|
|
973
|
+
border: 1px solid var(--line-soft);
|
|
974
|
+
border-radius: 8px;
|
|
975
|
+
background: #0f1112;
|
|
976
|
+
padding: 14px 16px;
|
|
977
|
+
overflow: auto;
|
|
978
|
+
white-space: pre;
|
|
979
|
+
word-break: normal;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
.markdown-view pre code {
|
|
983
|
+
border: 0;
|
|
984
|
+
border-radius: 0;
|
|
985
|
+
background: transparent;
|
|
986
|
+
color: var(--text);
|
|
987
|
+
padding: 0;
|
|
988
|
+
font-size: 12px;
|
|
989
|
+
line-height: 1.55;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
.cluster-dashboard {
|
|
993
|
+
display: grid;
|
|
994
|
+
gap: 18px;
|
|
995
|
+
padding: 18px;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
.cluster-hero {
|
|
999
|
+
display: grid;
|
|
1000
|
+
grid-template-columns: minmax(180px, .55fr) minmax(0, 1fr);
|
|
1001
|
+
gap: 16px;
|
|
1002
|
+
align-items: stretch;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
.cluster-title-block {
|
|
1006
|
+
border: 1px solid var(--line-soft);
|
|
1007
|
+
border-radius: 8px;
|
|
1008
|
+
background: #101213;
|
|
1009
|
+
padding: 16px;
|
|
1010
|
+
min-width: 0;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
.cluster-title-block h3 {
|
|
1014
|
+
margin: 0 0 8px;
|
|
1015
|
+
color: #fff7e8;
|
|
1016
|
+
font-size: 16px;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
.cluster-title-block p {
|
|
1020
|
+
margin: 0;
|
|
1021
|
+
color: var(--muted);
|
|
1022
|
+
font-size: 12px;
|
|
1023
|
+
word-break: break-word;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
.cluster-metrics {
|
|
1027
|
+
display: grid;
|
|
1028
|
+
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
|
1029
|
+
gap: 10px;
|
|
1030
|
+
min-width: 0;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
.cluster-metric,
|
|
1034
|
+
.cluster-panel {
|
|
1035
|
+
border: 1px solid var(--line-soft);
|
|
1036
|
+
border-radius: 8px;
|
|
1037
|
+
background: #101213;
|
|
1038
|
+
min-width: 0;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
.cluster-metric {
|
|
1042
|
+
padding: 12px;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
.cluster-metric span,
|
|
1046
|
+
.cluster-section-title span,
|
|
1047
|
+
.cluster-label {
|
|
1048
|
+
display: block;
|
|
1049
|
+
color: var(--faint);
|
|
1050
|
+
font-size: 10px;
|
|
1051
|
+
font-weight: 850;
|
|
1052
|
+
text-transform: uppercase;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
.cluster-metric strong {
|
|
1056
|
+
display: block;
|
|
1057
|
+
margin-top: 5px;
|
|
1058
|
+
color: #fff7e8;
|
|
1059
|
+
font-size: 20px;
|
|
1060
|
+
line-height: 1.1;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
.cluster-metric small,
|
|
1064
|
+
.cluster-subtle {
|
|
1065
|
+
color: var(--muted);
|
|
1066
|
+
font-size: 12px;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
.cluster-metric.ok strong { color: var(--teal); }
|
|
1070
|
+
.cluster-metric.warn strong { color: var(--amber); }
|
|
1071
|
+
.cluster-metric.bad strong { color: var(--red); }
|
|
1072
|
+
|
|
1073
|
+
.cluster-section {
|
|
1074
|
+
display: grid;
|
|
1075
|
+
gap: 10px;
|
|
1076
|
+
min-width: 0;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
.cluster-section-title {
|
|
1080
|
+
display: flex;
|
|
1081
|
+
align-items: baseline;
|
|
1082
|
+
justify-content: space-between;
|
|
1083
|
+
gap: 12px;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
.cluster-section-title h3 {
|
|
1087
|
+
margin: 0;
|
|
1088
|
+
color: #fff7e8;
|
|
1089
|
+
font-size: 13px;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
.cluster-grid {
|
|
1093
|
+
display: grid;
|
|
1094
|
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
1095
|
+
gap: 10px;
|
|
1096
|
+
min-width: 0;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
.cluster-panel {
|
|
1100
|
+
display: grid;
|
|
1101
|
+
gap: 10px;
|
|
1102
|
+
padding: 12px;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
.cluster-panel-head {
|
|
1106
|
+
display: flex;
|
|
1107
|
+
align-items: flex-start;
|
|
1108
|
+
justify-content: space-between;
|
|
1109
|
+
gap: 10px;
|
|
1110
|
+
min-width: 0;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
.cluster-panel-head strong {
|
|
1114
|
+
color: #fff7e8;
|
|
1115
|
+
font-size: 13px;
|
|
1116
|
+
min-width: 0;
|
|
1117
|
+
overflow: hidden;
|
|
1118
|
+
text-overflow: ellipsis;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
.state-text {
|
|
1122
|
+
color: var(--teal);
|
|
1123
|
+
font-size: 11px;
|
|
1124
|
+
font-weight: 800;
|
|
1125
|
+
text-transform: uppercase;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
.state-text.warn { color: var(--amber); }
|
|
1129
|
+
.state-text.bad { color: var(--red); }
|
|
1130
|
+
|
|
1131
|
+
.config-meter {
|
|
1132
|
+
height: 7px;
|
|
1133
|
+
border-radius: 999px;
|
|
1134
|
+
background: rgba(255, 255, 255, .08);
|
|
1135
|
+
overflow: hidden;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
.config-meter span {
|
|
1139
|
+
display: block;
|
|
1140
|
+
width: var(--used, 0%);
|
|
1141
|
+
height: 100%;
|
|
1142
|
+
border-radius: inherit;
|
|
1143
|
+
background: var(--meter-color, var(--teal));
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
.config-meter.warn span { --meter-color: var(--amber); }
|
|
1147
|
+
.config-meter.bad span { --meter-color: var(--red); }
|
|
1148
|
+
|
|
1149
|
+
.compression-chart {
|
|
1150
|
+
display: grid;
|
|
1151
|
+
gap: 8px;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
.compression-chart-row {
|
|
1155
|
+
display: grid;
|
|
1156
|
+
grid-template-columns: 68px minmax(72px, 1fr) auto;
|
|
1157
|
+
gap: 8px;
|
|
1158
|
+
align-items: center;
|
|
1159
|
+
min-width: 0;
|
|
1160
|
+
color: var(--muted);
|
|
1161
|
+
font-size: 11px;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
.compression-track {
|
|
1165
|
+
height: 8px;
|
|
1166
|
+
border-radius: 999px;
|
|
1167
|
+
background: rgba(255, 255, 255, .08);
|
|
1168
|
+
overflow: hidden;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
.compression-track span {
|
|
1172
|
+
display: block;
|
|
1173
|
+
width: var(--bar, 0%);
|
|
1174
|
+
height: 100%;
|
|
1175
|
+
border-radius: inherit;
|
|
1176
|
+
background: var(--amber);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
.compression-track.compressed span {
|
|
1180
|
+
background: var(--teal);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
.compression-contexts {
|
|
1184
|
+
display: grid;
|
|
1185
|
+
border: 1px solid var(--line-soft);
|
|
1186
|
+
border-radius: 8px;
|
|
1187
|
+
overflow: hidden;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
.compression-context-row {
|
|
1191
|
+
display: grid;
|
|
1192
|
+
grid-template-columns: minmax(180px, 1.25fr) minmax(150px, .9fr) minmax(190px, 1.3fr) minmax(160px, 1fr);
|
|
1193
|
+
gap: 12px;
|
|
1194
|
+
align-items: center;
|
|
1195
|
+
padding: 9px 10px;
|
|
1196
|
+
border-top: 1px solid var(--line-soft);
|
|
1197
|
+
color: var(--muted);
|
|
1198
|
+
font-size: 12px;
|
|
1199
|
+
min-width: 0;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
.compression-context-row:first-child {
|
|
1203
|
+
border-top: 0;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
.compression-context-row.header {
|
|
1207
|
+
background: rgba(255, 255, 255, .035);
|
|
1208
|
+
color: var(--faint);
|
|
1209
|
+
font-size: 10px;
|
|
1210
|
+
font-weight: 850;
|
|
1211
|
+
text-transform: uppercase;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
.compression-context-row > * {
|
|
1215
|
+
min-width: 0;
|
|
1216
|
+
overflow: hidden;
|
|
1217
|
+
text-overflow: ellipsis;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
.cluster-kv {
|
|
1221
|
+
display: grid;
|
|
1222
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
1223
|
+
gap: 8px;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
.cluster-kv div {
|
|
1227
|
+
min-width: 0;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
.cluster-kv strong {
|
|
1231
|
+
display: block;
|
|
1232
|
+
margin-top: 2px;
|
|
1233
|
+
color: var(--text);
|
|
1234
|
+
font-size: 12px;
|
|
1235
|
+
overflow: hidden;
|
|
1236
|
+
text-overflow: ellipsis;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
.cluster-list {
|
|
1240
|
+
display: grid;
|
|
1241
|
+
gap: 7px;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
.cluster-row {
|
|
1245
|
+
display: grid;
|
|
1246
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
1247
|
+
gap: 10px;
|
|
1248
|
+
align-items: center;
|
|
1249
|
+
border-top: 1px solid var(--line-soft);
|
|
1250
|
+
padding-top: 7px;
|
|
1251
|
+
color: var(--muted);
|
|
1252
|
+
font-size: 12px;
|
|
1253
|
+
min-width: 0;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
.cluster-row:first-child {
|
|
1257
|
+
border-top: 0;
|
|
1258
|
+
padding-top: 0;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
.cluster-row strong,
|
|
1262
|
+
.cluster-context {
|
|
1263
|
+
color: var(--text);
|
|
1264
|
+
min-width: 0;
|
|
1265
|
+
overflow: hidden;
|
|
1266
|
+
text-overflow: ellipsis;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
.index-table {
|
|
1270
|
+
display: grid;
|
|
1271
|
+
border: 1px solid var(--line-soft);
|
|
1272
|
+
border-radius: 8px;
|
|
1273
|
+
overflow: hidden;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
.index-row {
|
|
1277
|
+
display: grid;
|
|
1278
|
+
grid-template-columns: minmax(170px, 1.2fr) 82px minmax(0, 2fr);
|
|
1279
|
+
gap: 10px;
|
|
1280
|
+
align-items: start;
|
|
1281
|
+
padding: 9px 10px;
|
|
1282
|
+
border-top: 1px solid var(--line-soft);
|
|
1283
|
+
color: var(--muted);
|
|
1284
|
+
font-size: 12px;
|
|
1285
|
+
min-width: 0;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
.index-row:first-child {
|
|
1289
|
+
border-top: 0;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
.index-row.header {
|
|
1293
|
+
background: rgba(255, 255, 255, .035);
|
|
1294
|
+
color: var(--faint);
|
|
1295
|
+
font-size: 10px;
|
|
1296
|
+
font-weight: 850;
|
|
1297
|
+
text-transform: uppercase;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
.index-row > * {
|
|
1301
|
+
min-width: 0;
|
|
1302
|
+
overflow: hidden;
|
|
1303
|
+
text-overflow: ellipsis;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
.raw-config {
|
|
1307
|
+
border: 1px solid var(--line-soft);
|
|
1308
|
+
border-radius: 8px;
|
|
1309
|
+
background: #101213;
|
|
1310
|
+
overflow: hidden;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
.raw-config summary {
|
|
1314
|
+
cursor: pointer;
|
|
1315
|
+
color: var(--muted);
|
|
1316
|
+
padding: 12px;
|
|
1317
|
+
font-weight: 800;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
.raw-config pre {
|
|
1321
|
+
border-top: 1px solid var(--line-soft);
|
|
1322
|
+
max-height: 340px;
|
|
1323
|
+
overflow: auto;
|
|
1324
|
+
padding: 14px;
|
|
1325
|
+
white-space: pre;
|
|
1326
|
+
word-break: normal;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
.j-key { color: var(--teal); }
|
|
1330
|
+
.j-str { color: #f3c878; }
|
|
1331
|
+
.j-num { color: var(--blue); }
|
|
1332
|
+
.j-bool { color: #b98cff; }
|
|
1333
|
+
.j-null { color: var(--faint); }
|
|
1334
|
+
|
|
1335
|
+
.empty, .error {
|
|
1336
|
+
padding: 18px;
|
|
1337
|
+
color: var(--muted);
|
|
1338
|
+
min-height: 110px;
|
|
1339
|
+
display: grid;
|
|
1340
|
+
place-items: center;
|
|
1341
|
+
text-align: center;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
.error { color: #ffd1cf; }
|
|
1345
|
+
|
|
1346
|
+
.meta-line {
|
|
1347
|
+
display: flex;
|
|
1348
|
+
align-items: center;
|
|
1349
|
+
gap: 8px;
|
|
1350
|
+
min-width: 0;
|
|
1351
|
+
color: var(--muted);
|
|
1352
|
+
font-size: 12px;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
.toast {
|
|
1356
|
+
position: fixed;
|
|
1357
|
+
right: 18px;
|
|
1358
|
+
bottom: 18px;
|
|
1359
|
+
z-index: 20;
|
|
1360
|
+
max-width: min(420px, calc(100vw - 36px));
|
|
1361
|
+
border: 1px solid var(--line);
|
|
1362
|
+
border-radius: 8px;
|
|
1363
|
+
background: #151819;
|
|
1364
|
+
color: var(--text);
|
|
1365
|
+
box-shadow: var(--shadow);
|
|
1366
|
+
padding: 12px 14px;
|
|
1367
|
+
display: none;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
.toast.show { display: block; }
|
|
1371
|
+
.toast.bad { border-color: rgba(239, 111, 108, .55); color: #ffd1cf; }
|
|
1372
|
+
|
|
1373
|
+
.modal-backdrop {
|
|
1374
|
+
position: fixed;
|
|
1375
|
+
inset: 0;
|
|
1376
|
+
z-index: 30;
|
|
1377
|
+
display: none;
|
|
1378
|
+
place-items: center;
|
|
1379
|
+
padding: 18px;
|
|
1380
|
+
background: rgba(0, 0, 0, .62);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
.modal-backdrop.open {
|
|
1384
|
+
display: grid;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
.modal {
|
|
1388
|
+
width: min(720px, 100%);
|
|
1389
|
+
max-height: min(760px, calc(100vh - 36px));
|
|
1390
|
+
display: grid;
|
|
1391
|
+
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
1392
|
+
border: 1px solid var(--line);
|
|
1393
|
+
border-radius: 8px;
|
|
1394
|
+
background: #141719;
|
|
1395
|
+
box-shadow: var(--shadow);
|
|
1396
|
+
overflow: hidden;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
.modal.small { width: min(460px, 100%); }
|
|
1400
|
+
|
|
1401
|
+
.modal-head, .modal-foot {
|
|
1402
|
+
padding: 14px 16px;
|
|
1403
|
+
display: flex;
|
|
1404
|
+
align-items: center;
|
|
1405
|
+
justify-content: space-between;
|
|
1406
|
+
gap: 10px;
|
|
1407
|
+
border-bottom: 1px solid var(--line-soft);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
.modal-foot {
|
|
1411
|
+
border-top: 1px solid var(--line-soft);
|
|
1412
|
+
border-bottom: 0;
|
|
1413
|
+
justify-content: flex-end;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
.modal-head h2 {
|
|
1417
|
+
margin: 0;
|
|
1418
|
+
font-size: 14px;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
.modal-body {
|
|
1422
|
+
overflow: auto;
|
|
1423
|
+
padding: 16px;
|
|
1424
|
+
display: grid;
|
|
1425
|
+
gap: 12px;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
.form-grid {
|
|
1429
|
+
display: grid;
|
|
1430
|
+
grid-template-columns: 1fr 1fr;
|
|
1431
|
+
gap: 12px;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
.form-grid .wide, .modal-body .wide {
|
|
1435
|
+
grid-column: 1 / -1;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
.json-editor {
|
|
1439
|
+
min-height: 260px;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
.file-summary {
|
|
1443
|
+
color: var(--muted);
|
|
1444
|
+
font-size: 12px;
|
|
1445
|
+
min-height: 18px;
|
|
1446
|
+
overflow: hidden;
|
|
1447
|
+
text-overflow: ellipsis;
|
|
1448
|
+
white-space: nowrap;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
.file-summary.has-file {
|
|
1452
|
+
color: var(--blue);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
.modal-error {
|
|
1456
|
+
display: none;
|
|
1457
|
+
color: #ffd1cf;
|
|
1458
|
+
border: 1px solid rgba(239, 111, 108, .38);
|
|
1459
|
+
border-radius: 8px;
|
|
1460
|
+
background: rgba(239, 111, 108, .08);
|
|
1461
|
+
padding: 10px;
|
|
1462
|
+
font-size: 12px;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
.modal-error.show {
|
|
1466
|
+
display: block;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
.confirm-name {
|
|
1470
|
+
color: #fff5df;
|
|
1471
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1472
|
+
word-break: break-word;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
@media (max-width: 980px) {
|
|
1476
|
+
.app { grid-template-rows: auto 1fr; }
|
|
1477
|
+
.topbar { grid-template-columns: 1fr; height: auto; padding: 12px; gap: 10px; position: static; }
|
|
1478
|
+
.connection { justify-content: flex-start; }
|
|
1479
|
+
.top-stats-wrap, .session { justify-content: stretch; }
|
|
1480
|
+
.top-stats { flex-wrap: wrap; overflow: visible; }
|
|
1481
|
+
.cluster-hero { grid-template-columns: 1fr; }
|
|
1482
|
+
.index-table, .compression-contexts { overflow-x: auto; }
|
|
1483
|
+
.index-row { min-width: 520px; }
|
|
1484
|
+
.compression-context-row { min-width: 720px; }
|
|
1485
|
+
.shell { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
|
1486
|
+
.sidebar { border-right: 0; border-bottom: 1px solid var(--line-soft); }
|
|
1487
|
+
.tree { max-height: 260px; }
|
|
1488
|
+
.workspace { grid-template-columns: 1fr; grid-template-rows: minmax(180px, 34vh) minmax(0, 1fr); }
|
|
1489
|
+
.object-list { border-right: 0; border-bottom: 1px solid var(--line-soft); }
|
|
1490
|
+
.query-row { grid-template-columns: 1fr 84px; }
|
|
1491
|
+
.builder-row, .builder-row.metric { grid-template-columns: 1fr 110px 1fr 34px; }
|
|
1492
|
+
.query-row button { min-width: 0; }
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
@media (max-width: 560px) {
|
|
1496
|
+
.login-grid, .query-row { grid-template-columns: 1fr; }
|
|
1497
|
+
.query-row { align-items: stretch; }
|
|
1498
|
+
.builder-row, .builder-row.metric { grid-template-columns: 1fr; }
|
|
1499
|
+
.builder-row .icon-only { width: 100%; }
|
|
1500
|
+
.form-grid { grid-template-columns: 1fr; }
|
|
1501
|
+
.tree-head, .content-head, .detail-head { align-items: flex-start; flex-direction: column; }
|
|
1502
|
+
.tree-head.actions-only { align-items: flex-end; }
|
|
1503
|
+
.tree-actions, .content-actions, .object-actions, .modal-actions { width: 100%; justify-content: flex-start; flex-wrap: wrap; }
|
|
1504
|
+
.session { flex-wrap: wrap; justify-content: flex-start; }
|
|
1505
|
+
.pill { max-width: 100%; }
|
|
1506
|
+
}
|
|
1507
|
+
</style>
|
|
1508
|
+
</head>
|
|
1509
|
+
<body>
|
|
1510
|
+
<div class="app">
|
|
1511
|
+
<header class="topbar">
|
|
1512
|
+
<div class="brand">
|
|
1513
|
+
<img class="brand-logo" src="/objy-db-logo.png" alt="OBJY DB {[]...}">
|
|
1514
|
+
<div>
|
|
1515
|
+
<h1>OBJY DB</h1>
|
|
1516
|
+
<small id="node-label">checking node</small>
|
|
1517
|
+
</div>
|
|
1518
|
+
</div>
|
|
1519
|
+
|
|
1520
|
+
<div class="connection">
|
|
1521
|
+
<button id="server-label" class="server-label" title="Change server">
|
|
1522
|
+
<span class="server-kicker">Server</span>
|
|
1523
|
+
<span class="server-value">localhost:8080</span>
|
|
1524
|
+
</button>
|
|
1525
|
+
<button id="health-btn" class="secondary icon-only" title="Check health" aria-label="Check health">
|
|
1526
|
+
<svg viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 4v7h-7"/></svg>
|
|
1527
|
+
</button>
|
|
1528
|
+
</div>
|
|
1529
|
+
|
|
1530
|
+
<div class="top-stats-wrap">
|
|
1531
|
+
<div class="top-stats" id="cluster-status">
|
|
1532
|
+
<span class="top-config-message">Sign in for cluster stats.</span>
|
|
1533
|
+
</div>
|
|
1534
|
+
<button id="refresh-cluster-btn" class="secondary icon-only" title="Refresh cluster" aria-label="Refresh cluster" disabled>
|
|
1535
|
+
<svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 0 0-14.2-4.9L4 8"/><path d="M4 4v4h4"/><path d="M4 13a8 8 0 0 0 14.2 4.9L20 16"/><path d="M20 20v-4h-4"/></svg>
|
|
1536
|
+
</button>
|
|
1537
|
+
</div>
|
|
1538
|
+
|
|
1539
|
+
<div class="session">
|
|
1540
|
+
<span class="pill" id="session-pill">not signed in</span>
|
|
1541
|
+
<button id="api-docs-btn" class="secondary">
|
|
1542
|
+
<svg viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5z"/><path d="M8 7h8"/><path d="M8 11h8"/></svg>
|
|
1543
|
+
API
|
|
1544
|
+
</button>
|
|
1545
|
+
<button id="renew-btn" class="secondary" disabled>
|
|
1546
|
+
<svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 0 0-14.2-4.9L4 8"/><path d="M4 4v4h4"/><path d="M4 13a8 8 0 0 0 14.2 4.9L20 16"/><path d="M20 20v-4h-4"/></svg>
|
|
1547
|
+
Renew
|
|
1548
|
+
</button>
|
|
1549
|
+
<button id="logout-btn" class="ghost" disabled>
|
|
1550
|
+
<svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/></svg>
|
|
1551
|
+
Logout
|
|
1552
|
+
</button>
|
|
1553
|
+
</div>
|
|
1554
|
+
</header>
|
|
1555
|
+
|
|
1556
|
+
<div class="shell">
|
|
1557
|
+
<aside class="sidebar">
|
|
1558
|
+
<section class="login-panel" id="login-panel">
|
|
1559
|
+
<div class="tree-head" style="padding:0">
|
|
1560
|
+
<span class="section-title">Login</span>
|
|
1561
|
+
<button id="login-btn" title="Log in">
|
|
1562
|
+
<svg viewBox="0 0 24 24"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
|
|
1563
|
+
Login
|
|
1564
|
+
</button>
|
|
1565
|
+
</div>
|
|
1566
|
+
<div class="login-grid">
|
|
1567
|
+
<label>Database <input id="auth-db" value="_global" autocomplete="off" /></label>
|
|
1568
|
+
<label>Collection <input id="auth-coll" value="users" autocomplete="off" /></label>
|
|
1569
|
+
<label>Username <input id="username" value="admin" autocomplete="username" /></label>
|
|
1570
|
+
<label>Password <input id="password" type="password" autocomplete="current-password" /></label>
|
|
1571
|
+
</div>
|
|
1572
|
+
</section>
|
|
1573
|
+
|
|
1574
|
+
<section class="tree-panel">
|
|
1575
|
+
<div class="tree-head actions-only">
|
|
1576
|
+
<div class="tree-actions">
|
|
1577
|
+
<button id="add-db-btn" class="secondary" title="Add database" aria-label="Add database" disabled>
|
|
1578
|
+
<svg viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
|
1579
|
+
Database
|
|
1580
|
+
</button>
|
|
1581
|
+
<button id="add-coll-btn" class="secondary" title="Add collection" aria-label="Add collection" disabled>
|
|
1582
|
+
<svg viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h10"/><path d="M18 16v6"/><path d="M15 19h6"/></svg>
|
|
1583
|
+
Collection
|
|
1584
|
+
</button>
|
|
1585
|
+
<button id="refresh-tree-btn" class="secondary icon-only" title="Refresh tree" aria-label="Refresh tree">
|
|
1586
|
+
<svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 0 0-14.2-4.9L4 8"/><path d="M4 4v4h4"/><path d="M4 13a8 8 0 0 0 14.2 4.9L20 16"/><path d="M20 20v-4h-4"/></svg>
|
|
1587
|
+
</button>
|
|
1588
|
+
</div>
|
|
1589
|
+
</div>
|
|
1590
|
+
<div class="tree-filter">
|
|
1591
|
+
<input id="tree-filter" type="search" placeholder="Search databases / collections" autocomplete="off" />
|
|
1592
|
+
</div>
|
|
1593
|
+
<div class="tree" id="tree">
|
|
1594
|
+
<div class="empty">Sign in to load databases.</div>
|
|
1595
|
+
</div>
|
|
1596
|
+
</section>
|
|
1597
|
+
</aside>
|
|
1598
|
+
|
|
1599
|
+
<main class="main">
|
|
1600
|
+
<section class="toolbar">
|
|
1601
|
+
<div class="content-head" style="padding:0;border:0">
|
|
1602
|
+
<div>
|
|
1603
|
+
<div class="breadcrumb" id="breadcrumb"><span>Select a collection</span></div>
|
|
1604
|
+
<div class="meta-line" id="collection-meta"></div>
|
|
1605
|
+
</div>
|
|
1606
|
+
<div class="content-actions">
|
|
1607
|
+
<button id="edit-meta-btn" class="secondary" disabled>
|
|
1608
|
+
<svg viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
|
1609
|
+
Meta
|
|
1610
|
+
</button>
|
|
1611
|
+
</div>
|
|
1612
|
+
</div>
|
|
1613
|
+
<div class="query-row">
|
|
1614
|
+
<textarea id="query" spellcheck="false" aria-label="Query JSON" placeholder='{"status":"active"}'></textarea>
|
|
1615
|
+
<label>
|
|
1616
|
+
Limit
|
|
1617
|
+
<select id="limit">
|
|
1618
|
+
<option>25</option>
|
|
1619
|
+
<option selected>50</option>
|
|
1620
|
+
<option>100</option>
|
|
1621
|
+
<option>250</option>
|
|
1622
|
+
</select>
|
|
1623
|
+
</label>
|
|
1624
|
+
<button id="format-query-btn" class="secondary">
|
|
1625
|
+
<svg viewBox="0 0 24 24"><path d="M21 10H7"/><path d="M21 6H3"/><path d="M21 14H3"/><path d="M21 18H7"/></svg>
|
|
1626
|
+
Format
|
|
1627
|
+
</button>
|
|
1628
|
+
<button id="load-objects-btn">
|
|
1629
|
+
<svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M3 12h18"/><path d="M3 18h18"/></svg>
|
|
1630
|
+
Load
|
|
1631
|
+
</button>
|
|
1632
|
+
</div>
|
|
1633
|
+
<div class="query-mode" role="tablist" aria-label="Query editor mode">
|
|
1634
|
+
<button id="query-json-mode-btn" class="active" type="button">JSON</button>
|
|
1635
|
+
<button id="query-builder-mode-btn" type="button">Builder</button>
|
|
1636
|
+
</div>
|
|
1637
|
+
<div class="query-builder" id="query-builder" aria-label="Graphical query builder">
|
|
1638
|
+
<div class="builder-section">
|
|
1639
|
+
<div class="builder-section-head">
|
|
1640
|
+
<div class="builder-title">Filter</div>
|
|
1641
|
+
<div class="builder-controls">
|
|
1642
|
+
<label>
|
|
1643
|
+
Match
|
|
1644
|
+
<select id="builder-match">
|
|
1645
|
+
<option value="and">all</option>
|
|
1646
|
+
<option value="or">any</option>
|
|
1647
|
+
</select>
|
|
1648
|
+
</label>
|
|
1649
|
+
<button id="builder-add-filter" class="secondary" type="button">
|
|
1650
|
+
<svg viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
|
1651
|
+
Condition
|
|
1652
|
+
</button>
|
|
1653
|
+
</div>
|
|
1654
|
+
</div>
|
|
1655
|
+
<div class="builder-grid" id="builder-filters"></div>
|
|
1656
|
+
</div>
|
|
1657
|
+
<div class="builder-section">
|
|
1658
|
+
<div class="builder-section-head">
|
|
1659
|
+
<div class="builder-title">Aggregate</div>
|
|
1660
|
+
<div class="builder-controls">
|
|
1661
|
+
<label>
|
|
1662
|
+
Group by
|
|
1663
|
+
<input id="builder-group-by" placeholder="properties.department.value" autocomplete="off" />
|
|
1664
|
+
</label>
|
|
1665
|
+
<button id="builder-add-metric" class="secondary" type="button">
|
|
1666
|
+
<svg viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
|
1667
|
+
Metric
|
|
1668
|
+
</button>
|
|
1669
|
+
</div>
|
|
1670
|
+
</div>
|
|
1671
|
+
<div class="builder-grid" id="builder-metrics"></div>
|
|
1672
|
+
</div>
|
|
1673
|
+
<div class="builder-error" id="builder-error"></div>
|
|
1674
|
+
</div>
|
|
1675
|
+
</section>
|
|
1676
|
+
|
|
1677
|
+
<section class="workspace">
|
|
1678
|
+
<div class="object-list">
|
|
1679
|
+
<div class="content-head">
|
|
1680
|
+
<span class="section-title">Objects</span>
|
|
1681
|
+
<div class="object-actions">
|
|
1682
|
+
<span class="pill" id="object-count">0 shown</span>
|
|
1683
|
+
<div class="pager" aria-label="Object pages">
|
|
1684
|
+
<button id="prev-page-btn" class="secondary icon-only" title="Previous page" aria-label="Previous page" disabled>
|
|
1685
|
+
<svg viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg>
|
|
1686
|
+
</button>
|
|
1687
|
+
<span class="pill" id="object-page">page 1</span>
|
|
1688
|
+
<button id="next-page-btn" class="secondary icon-only" title="Next page" aria-label="Next page" disabled>
|
|
1689
|
+
<svg viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>
|
|
1690
|
+
</button>
|
|
1691
|
+
</div>
|
|
1692
|
+
<button id="add-object-btn" class="secondary" disabled>
|
|
1693
|
+
<svg viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
|
1694
|
+
Object
|
|
1695
|
+
</button>
|
|
1696
|
+
</div>
|
|
1697
|
+
</div>
|
|
1698
|
+
<div class="objects" id="objects">
|
|
1699
|
+
<div class="empty">Pick a collection from the tree.</div>
|
|
1700
|
+
</div>
|
|
1701
|
+
</div>
|
|
1702
|
+
|
|
1703
|
+
<div class="viewer">
|
|
1704
|
+
<div class="detail-head">
|
|
1705
|
+
<span class="section-title" id="detail-title">Object</span>
|
|
1706
|
+
<div class="detail-tools">
|
|
1707
|
+
<button id="download-file-btn" class="secondary" disabled>
|
|
1708
|
+
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M12 18v-6"/><path d="m9 15 3 3 3-3"/></svg>
|
|
1709
|
+
File
|
|
1710
|
+
</button>
|
|
1711
|
+
<button id="edit-object-btn" class="secondary" disabled>
|
|
1712
|
+
<svg viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
|
1713
|
+
Edit
|
|
1714
|
+
</button>
|
|
1715
|
+
<button id="delete-object-btn" class="danger" disabled>
|
|
1716
|
+
<svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
|
|
1717
|
+
Delete
|
|
1718
|
+
</button>
|
|
1719
|
+
<button id="reload-object-btn" class="secondary" disabled>
|
|
1720
|
+
<svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 0 0-14.2-4.9L4 8"/><path d="M4 4v4h4"/><path d="M4 13a8 8 0 0 0 14.2 4.9L20 16"/><path d="M20 20v-4h-4"/></svg>
|
|
1721
|
+
Reload
|
|
1722
|
+
</button>
|
|
1723
|
+
</div>
|
|
1724
|
+
</div>
|
|
1725
|
+
<div class="json-view" id="detail">
|
|
1726
|
+
<div class="empty">Select an object.</div>
|
|
1727
|
+
</div>
|
|
1728
|
+
</div>
|
|
1729
|
+
</section>
|
|
1730
|
+
</main>
|
|
1731
|
+
</div>
|
|
1732
|
+
</div>
|
|
1733
|
+
|
|
1734
|
+
<div class="modal-backdrop" id="db-modal" role="dialog" aria-modal="true" aria-labelledby="db-modal-title">
|
|
1735
|
+
<div class="modal">
|
|
1736
|
+
<div class="modal-head">
|
|
1737
|
+
<h2 id="db-modal-title">Add Database</h2>
|
|
1738
|
+
<button class="ghost icon-only" data-close-modal="db-modal" title="Close" aria-label="Close">
|
|
1739
|
+
<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
1740
|
+
</button>
|
|
1741
|
+
</div>
|
|
1742
|
+
<div class="modal-body">
|
|
1743
|
+
<div class="form-grid">
|
|
1744
|
+
<label>Name <input id="db-name" autocomplete="off" /></label>
|
|
1745
|
+
<label class="wide">Meta JSON <textarea id="db-meta-json" class="json-editor" spellcheck="false">{}</textarea></label>
|
|
1746
|
+
</div>
|
|
1747
|
+
<div class="modal-error" id="db-modal-error"></div>
|
|
1748
|
+
</div>
|
|
1749
|
+
<div class="modal-foot">
|
|
1750
|
+
<button class="ghost" data-close-modal="db-modal">Cancel</button>
|
|
1751
|
+
<button id="db-modal-save">
|
|
1752
|
+
<svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
|
|
1753
|
+
Save
|
|
1754
|
+
</button>
|
|
1755
|
+
</div>
|
|
1756
|
+
</div>
|
|
1757
|
+
</div>
|
|
1758
|
+
|
|
1759
|
+
<div class="modal-backdrop" id="collection-modal" role="dialog" aria-modal="true" aria-labelledby="collection-modal-title">
|
|
1760
|
+
<div class="modal">
|
|
1761
|
+
<div class="modal-head">
|
|
1762
|
+
<h2 id="collection-modal-title">Add Collection</h2>
|
|
1763
|
+
<button class="ghost icon-only" data-close-modal="collection-modal" title="Close" aria-label="Close">
|
|
1764
|
+
<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
1765
|
+
</button>
|
|
1766
|
+
</div>
|
|
1767
|
+
<div class="modal-body">
|
|
1768
|
+
<div class="form-grid">
|
|
1769
|
+
<label>Database <input id="collection-db" autocomplete="off" /></label>
|
|
1770
|
+
<label>Collection <input id="collection-name" autocomplete="off" /></label>
|
|
1771
|
+
<label class="wide">Meta JSON <textarea id="collection-meta-json" class="json-editor" spellcheck="false">{
|
|
1772
|
+
"indexes": []
|
|
1773
|
+
}</textarea></label>
|
|
1774
|
+
</div>
|
|
1775
|
+
<div class="modal-error" id="collection-modal-error"></div>
|
|
1776
|
+
</div>
|
|
1777
|
+
<div class="modal-foot">
|
|
1778
|
+
<button class="ghost" data-close-modal="collection-modal">Cancel</button>
|
|
1779
|
+
<button id="collection-modal-save">
|
|
1780
|
+
<svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
|
|
1781
|
+
Save
|
|
1782
|
+
</button>
|
|
1783
|
+
</div>
|
|
1784
|
+
</div>
|
|
1785
|
+
</div>
|
|
1786
|
+
|
|
1787
|
+
<div class="modal-backdrop" id="meta-modal" role="dialog" aria-modal="true" aria-labelledby="meta-modal-title">
|
|
1788
|
+
<div class="modal">
|
|
1789
|
+
<div class="modal-head">
|
|
1790
|
+
<h2 id="meta-modal-title">Edit Collection Meta</h2>
|
|
1791
|
+
<button class="ghost icon-only" data-close-modal="meta-modal" title="Close" aria-label="Close">
|
|
1792
|
+
<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
1793
|
+
</button>
|
|
1794
|
+
</div>
|
|
1795
|
+
<div class="modal-body">
|
|
1796
|
+
<label>Meta JSON <textarea id="meta-json" class="json-editor" spellcheck="false"></textarea></label>
|
|
1797
|
+
<div class="modal-error" id="meta-modal-error"></div>
|
|
1798
|
+
</div>
|
|
1799
|
+
<div class="modal-foot">
|
|
1800
|
+
<button class="ghost" data-close-modal="meta-modal">Cancel</button>
|
|
1801
|
+
<button id="meta-modal-save">
|
|
1802
|
+
<svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
|
|
1803
|
+
Save
|
|
1804
|
+
</button>
|
|
1805
|
+
</div>
|
|
1806
|
+
</div>
|
|
1807
|
+
</div>
|
|
1808
|
+
|
|
1809
|
+
<div class="modal-backdrop" id="object-modal" role="dialog" aria-modal="true" aria-labelledby="object-modal-title">
|
|
1810
|
+
<div class="modal">
|
|
1811
|
+
<div class="modal-head">
|
|
1812
|
+
<h2 id="object-modal-title">Add Object</h2>
|
|
1813
|
+
<button class="ghost icon-only" data-close-modal="object-modal" title="Close" aria-label="Close">
|
|
1814
|
+
<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
1815
|
+
</button>
|
|
1816
|
+
</div>
|
|
1817
|
+
<div class="modal-body">
|
|
1818
|
+
<div class="form-grid">
|
|
1819
|
+
<label>Object _id <input id="object-id" autocomplete="off" /></label>
|
|
1820
|
+
<label class="wide">File <input id="object-file" type="file" /></label>
|
|
1821
|
+
<div class="file-summary wide" id="object-file-summary">No file selected</div>
|
|
1822
|
+
<label class="wide">Object JSON <textarea id="object-json" class="json-editor" spellcheck="false">{}</textarea></label>
|
|
1823
|
+
</div>
|
|
1824
|
+
<div class="modal-error" id="object-modal-error"></div>
|
|
1825
|
+
</div>
|
|
1826
|
+
<div class="modal-foot">
|
|
1827
|
+
<button class="ghost" data-close-modal="object-modal">Cancel</button>
|
|
1828
|
+
<button id="object-modal-save">
|
|
1829
|
+
<svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
|
|
1830
|
+
Save
|
|
1831
|
+
</button>
|
|
1832
|
+
</div>
|
|
1833
|
+
</div>
|
|
1834
|
+
</div>
|
|
1835
|
+
|
|
1836
|
+
<div class="modal-backdrop" id="delete-object-modal" role="dialog" aria-modal="true" aria-labelledby="delete-object-title">
|
|
1837
|
+
<div class="modal small">
|
|
1838
|
+
<div class="modal-head">
|
|
1839
|
+
<h2 id="delete-object-title">Delete Object</h2>
|
|
1840
|
+
<button class="ghost icon-only" data-close-modal="delete-object-modal" title="Close" aria-label="Close">
|
|
1841
|
+
<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
1842
|
+
</button>
|
|
1843
|
+
</div>
|
|
1844
|
+
<div class="modal-body">
|
|
1845
|
+
<div>Delete <span class="confirm-name" id="delete-object-name"></span>?</div>
|
|
1846
|
+
<div class="modal-error" id="delete-object-modal-error"></div>
|
|
1847
|
+
</div>
|
|
1848
|
+
<div class="modal-foot">
|
|
1849
|
+
<button class="ghost" data-close-modal="delete-object-modal">Cancel</button>
|
|
1850
|
+
<button id="delete-object-confirm" class="danger">
|
|
1851
|
+
<svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
|
|
1852
|
+
Delete
|
|
1853
|
+
</button>
|
|
1854
|
+
</div>
|
|
1855
|
+
</div>
|
|
1856
|
+
</div>
|
|
1857
|
+
|
|
1858
|
+
<div class="modal-backdrop" id="delete-collection-modal" role="dialog" aria-modal="true" aria-labelledby="delete-collection-title">
|
|
1859
|
+
<div class="modal small">
|
|
1860
|
+
<div class="modal-head">
|
|
1861
|
+
<h2 id="delete-collection-title">Delete</h2>
|
|
1862
|
+
<button class="ghost icon-only" data-close-modal="delete-collection-modal" title="Close" aria-label="Close">
|
|
1863
|
+
<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
1864
|
+
</button>
|
|
1865
|
+
</div>
|
|
1866
|
+
<div class="modal-body">
|
|
1867
|
+
<div>Delete <span class="confirm-name" id="delete-collection-name"></span>?</div>
|
|
1868
|
+
<div class="modal-error" id="delete-collection-modal-error"></div>
|
|
1869
|
+
</div>
|
|
1870
|
+
<div class="modal-foot">
|
|
1871
|
+
<button class="ghost" data-close-modal="delete-collection-modal">Cancel</button>
|
|
1872
|
+
<button id="delete-collection-confirm" class="danger">
|
|
1873
|
+
<svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
|
|
1874
|
+
Delete
|
|
1875
|
+
</button>
|
|
1876
|
+
</div>
|
|
1877
|
+
</div>
|
|
1878
|
+
</div>
|
|
1879
|
+
|
|
1880
|
+
<div class="modal-backdrop" id="api-docs-modal" role="dialog" aria-modal="true" aria-labelledby="api-docs-title">
|
|
1881
|
+
<div class="modal docs-modal">
|
|
1882
|
+
<div class="modal-head">
|
|
1883
|
+
<h2 id="api-docs-title">API Documentation</h2>
|
|
1884
|
+
<button class="ghost icon-only" data-close-modal="api-docs-modal" title="Close" aria-label="Close">
|
|
1885
|
+
<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
1886
|
+
</button>
|
|
1887
|
+
</div>
|
|
1888
|
+
<div class="modal-body docs-body">
|
|
1889
|
+
<div class="markdown-view" id="api-docs-view">
|
|
1890
|
+
<div class="empty">Loading API documentation.</div>
|
|
1891
|
+
</div>
|
|
1892
|
+
</div>
|
|
1893
|
+
</div>
|
|
1894
|
+
</div>
|
|
1895
|
+
|
|
1896
|
+
<div class="modal-backdrop" id="cluster-modal" role="dialog" aria-modal="true" aria-labelledby="cluster-modal-title">
|
|
1897
|
+
<div class="modal cluster-modal">
|
|
1898
|
+
<div class="modal-head">
|
|
1899
|
+
<h2 id="cluster-modal-title">Cluster Status</h2>
|
|
1900
|
+
<button class="ghost icon-only" data-close-modal="cluster-modal" title="Close" aria-label="Close">
|
|
1901
|
+
<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
1902
|
+
</button>
|
|
1903
|
+
</div>
|
|
1904
|
+
<div class="modal-body cluster-body">
|
|
1905
|
+
<div class="cluster-dashboard" id="cluster-modal-body">
|
|
1906
|
+
<div class="empty">Load cluster status first.</div>
|
|
1907
|
+
</div>
|
|
1908
|
+
</div>
|
|
1909
|
+
</div>
|
|
1910
|
+
</div>
|
|
1911
|
+
|
|
1912
|
+
<div class="toast" id="toast"></div>
|
|
1913
|
+
|
|
1914
|
+
<script type="module">
|
|
1915
|
+
import { createClient } from "https://cdn.jsdelivr.net/npm/@objy/client@0.1.0/index.js";
|
|
1916
|
+
|
|
1917
|
+
const $ = (id) => document.getElementById(id);
|
|
1918
|
+
const STORE_KEY = "objydb-ui:session";
|
|
1919
|
+
const SERVER_KEY = "objydb-ui:server";
|
|
1920
|
+
|
|
1921
|
+
const state = {
|
|
1922
|
+
server: defaultServer(),
|
|
1923
|
+
authDb: "_global",
|
|
1924
|
+
authColl: "users",
|
|
1925
|
+
tree: {},
|
|
1926
|
+
active: null,
|
|
1927
|
+
collectionMeta: null,
|
|
1928
|
+
cluster: null,
|
|
1929
|
+
clusterDetails: null,
|
|
1930
|
+
apiDocs: null,
|
|
1931
|
+
apiDocsSource: "",
|
|
1932
|
+
objects: [],
|
|
1933
|
+
objectPage: 1,
|
|
1934
|
+
objectHasMore: false,
|
|
1935
|
+
selected: null,
|
|
1936
|
+
objectMode: "add",
|
|
1937
|
+
collapsedDbs: new Set(),
|
|
1938
|
+
treeFilter: "",
|
|
1939
|
+
deleteTarget: null,
|
|
1940
|
+
queryMode: "json",
|
|
1941
|
+
queryBuilder: {
|
|
1942
|
+
match: "and",
|
|
1943
|
+
filters: [],
|
|
1944
|
+
aggregate: {
|
|
1945
|
+
groupBy: "",
|
|
1946
|
+
metrics: [],
|
|
1947
|
+
},
|
|
1948
|
+
},
|
|
1949
|
+
};
|
|
1950
|
+
|
|
1951
|
+
let client = createClient({ url: state.server });
|
|
1952
|
+
let sessionEpoch = 0;
|
|
1953
|
+
|
|
1954
|
+
const BUILDER_OPERATORS = [
|
|
1955
|
+
["eq", "="],
|
|
1956
|
+
["ne", "!="],
|
|
1957
|
+
["contains", "contains"],
|
|
1958
|
+
["in", "in"],
|
|
1959
|
+
["gt", ">"],
|
|
1960
|
+
["gte", ">="],
|
|
1961
|
+
["lt", "<"],
|
|
1962
|
+
["lte", "<="],
|
|
1963
|
+
["startsWith", "starts with"],
|
|
1964
|
+
["exists", "exists"],
|
|
1965
|
+
];
|
|
1966
|
+
|
|
1967
|
+
const BUILDER_METRICS = [
|
|
1968
|
+
["$count", "count"],
|
|
1969
|
+
["$sum", "sum"],
|
|
1970
|
+
["$avg", "avg"],
|
|
1971
|
+
["$min", "min"],
|
|
1972
|
+
["$max", "max"],
|
|
1973
|
+
];
|
|
1974
|
+
|
|
1975
|
+
function defaultServer() {
|
|
1976
|
+
const origin = window.location.origin;
|
|
1977
|
+
if (origin && origin !== "null" && /^https?:\/\//i.test(origin)) return origin.replace(/\/+$/, "");
|
|
1978
|
+
return "http://localhost:8080";
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
function normalizeServer(value) {
|
|
1982
|
+
return String(value || "").trim().replace(/\/+$/, "") || defaultServer();
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function loadSession() {
|
|
1986
|
+
try {
|
|
1987
|
+
state.server = normalizeServer(localStorage.getItem(SERVER_KEY) || state.server);
|
|
1988
|
+
client = createClient({ url: state.server });
|
|
1989
|
+
const saved = JSON.parse(localStorage.getItem(STORE_KEY) || "null");
|
|
1990
|
+
if (saved?.accessToken) {
|
|
1991
|
+
client.setSession(saved);
|
|
1992
|
+
state.authDb = client.getSession().database;
|
|
1993
|
+
state.authColl = client.getSession().collection;
|
|
1994
|
+
}
|
|
1995
|
+
} catch {
|
|
1996
|
+
state.server = defaultServer();
|
|
1997
|
+
client = createClient({ url: state.server });
|
|
1998
|
+
clearSession(false);
|
|
1999
|
+
}
|
|
2000
|
+
$("auth-db").value = state.authDb;
|
|
2001
|
+
$("auth-coll").value = state.authColl;
|
|
2002
|
+
saveSession();
|
|
2003
|
+
renderServerLabel();
|
|
2004
|
+
renderSession();
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function saveSession() {
|
|
2008
|
+
localStorage.setItem(SERVER_KEY, state.server);
|
|
2009
|
+
const session = client.getSession();
|
|
2010
|
+
if (session) localStorage.setItem(STORE_KEY, JSON.stringify(session));
|
|
2011
|
+
else localStorage.removeItem(STORE_KEY);
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function clearSession(render = true) {
|
|
2015
|
+
sessionEpoch++;
|
|
2016
|
+
client.logout();
|
|
2017
|
+
state.tree = {};
|
|
2018
|
+
state.active = null;
|
|
2019
|
+
state.collectionMeta = null;
|
|
2020
|
+
state.cluster = null;
|
|
2021
|
+
state.clusterDetails = null;
|
|
2022
|
+
state.objects = [];
|
|
2023
|
+
state.objectPage = 1;
|
|
2024
|
+
state.objectHasMore = false;
|
|
2025
|
+
state.selected = null;
|
|
2026
|
+
state.collapsedDbs.clear();
|
|
2027
|
+
state.treeFilter = "";
|
|
2028
|
+
$("tree-filter").value = "";
|
|
2029
|
+
localStorage.removeItem(STORE_KEY);
|
|
2030
|
+
if (render) {
|
|
2031
|
+
closeAllModals();
|
|
2032
|
+
renderSession();
|
|
2033
|
+
renderTree();
|
|
2034
|
+
renderClusterStatus(null);
|
|
2035
|
+
renderObjects();
|
|
2036
|
+
renderDetail(null);
|
|
2037
|
+
setMeta("");
|
|
2038
|
+
setBreadcrumb();
|
|
2039
|
+
renderActionState();
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function renderSession() {
|
|
2044
|
+
const session = client.getSession();
|
|
2045
|
+
const user = session?.user;
|
|
2046
|
+
$("login-panel").hidden = !!session?.accessToken;
|
|
2047
|
+
$("session-pill").textContent = user
|
|
2048
|
+
? `${user.username || user._id || "user"} at ${state.authDb}/${state.authColl}`
|
|
2049
|
+
: "not signed in";
|
|
2050
|
+
$("renew-btn").disabled = !session?.refreshToken;
|
|
2051
|
+
$("logout-btn").disabled = !session?.accessToken;
|
|
2052
|
+
renderActionState();
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function renderServerLabel() {
|
|
2056
|
+
const label = $("server-label");
|
|
2057
|
+
const server = normalizeServer(state.server);
|
|
2058
|
+
label.title = `Change server: ${server}`;
|
|
2059
|
+
label.querySelector(".server-value").textContent = compactServerLabel(server);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function compactServerLabel(server) {
|
|
2063
|
+
return String(server || "").replace(/^https?:\/\//i, "") || "localhost:8080";
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
async function editServer() {
|
|
2067
|
+
const current = normalizeServer(state.server);
|
|
2068
|
+
const next = prompt("Server URL", current);
|
|
2069
|
+
if (next === null) return;
|
|
2070
|
+
|
|
2071
|
+
const normalized = normalizeServer(next);
|
|
2072
|
+
if (normalized === current) return;
|
|
2073
|
+
|
|
2074
|
+
let nextClient;
|
|
2075
|
+
try { nextClient = createClient({ url: normalized }); }
|
|
2076
|
+
catch (error) { return showToast(error.message, true); }
|
|
2077
|
+
clearSession();
|
|
2078
|
+
client = nextClient;
|
|
2079
|
+
state.server = normalized;
|
|
2080
|
+
state.apiDocs = null;
|
|
2081
|
+
state.apiDocsSource = "";
|
|
2082
|
+
saveSession();
|
|
2083
|
+
renderServerLabel();
|
|
2084
|
+
await checkHealth();
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function renderActionState() {
|
|
2088
|
+
const signedIn = !!client.getSession()?.accessToken;
|
|
2089
|
+
const hasCollection = signedIn && !!state.active;
|
|
2090
|
+
const hasObject = hasCollection && !!state.selected?._id;
|
|
2091
|
+
const hasFile = hasObject && !!fileMetadata(state.selected);
|
|
2092
|
+
$("add-db-btn").disabled = !signedIn;
|
|
2093
|
+
$("add-coll-btn").disabled = !signedIn;
|
|
2094
|
+
$("refresh-cluster-btn").disabled = !signedIn;
|
|
2095
|
+
$("edit-meta-btn").disabled = !hasCollection;
|
|
2096
|
+
$("add-object-btn").disabled = !hasCollection;
|
|
2097
|
+
$("download-file-btn").disabled = !hasFile;
|
|
2098
|
+
$("edit-object-btn").disabled = !hasObject;
|
|
2099
|
+
$("delete-object-btn").disabled = !hasObject;
|
|
2100
|
+
$("reload-object-btn").disabled = !hasObject;
|
|
2101
|
+
updateObjectPaging(false);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// UI adaptation only: HTTP, authentication and retries belong to the shared client.
|
|
2105
|
+
async function run(operation) {
|
|
2106
|
+
const connection = client;
|
|
2107
|
+
const epoch = sessionEpoch;
|
|
2108
|
+
const previousSession = connection.getSession();
|
|
2109
|
+
let result;
|
|
2110
|
+
try {
|
|
2111
|
+
result = { ok: true, body: await operation(connection) };
|
|
2112
|
+
} catch (error) {
|
|
2113
|
+
result = { ok: false, status: error.status || 0, body: {
|
|
2114
|
+
...error.body,
|
|
2115
|
+
error: error.message,
|
|
2116
|
+
writeMayHaveSucceeded: error.writeMayHaveSucceeded,
|
|
2117
|
+
} };
|
|
2118
|
+
}
|
|
2119
|
+
if (connection !== client || epoch !== sessionEpoch) return { stale: true };
|
|
2120
|
+
if (previousSession && !connection.getSession()) {
|
|
2121
|
+
clearSession();
|
|
2122
|
+
showToast("Session expired. Sign in again.", true);
|
|
2123
|
+
return { stale: true };
|
|
2124
|
+
}
|
|
2125
|
+
saveSession();
|
|
2126
|
+
renderSession();
|
|
2127
|
+
return result;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function activeCollection(connection) {
|
|
2131
|
+
return connection.database(state.active.db).collection(state.active.collection);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
async function openApiDocs() {
|
|
2135
|
+
openModal("api-docs-modal");
|
|
2136
|
+
await loadApiDocs();
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
async function loadApiDocs({ force = false } = {}) {
|
|
2140
|
+
const source = state.server || defaultServer();
|
|
2141
|
+
const view = $("api-docs-view");
|
|
2142
|
+
if (!force && state.apiDocs && state.apiDocsSource === source) {
|
|
2143
|
+
view.innerHTML = renderMarkdown(state.apiDocs);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
view.innerHTML = `<div class="empty">Loading API documentation.</div>`;
|
|
2148
|
+
try {
|
|
2149
|
+
const res = await fetch(`${source}/API.md`, {
|
|
2150
|
+
headers: { accept: "text/markdown, text/plain, */*" },
|
|
2151
|
+
});
|
|
2152
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
2153
|
+
const markdown = await res.text();
|
|
2154
|
+
state.apiDocs = markdown;
|
|
2155
|
+
state.apiDocsSource = source;
|
|
2156
|
+
view.innerHTML = renderMarkdown(markdown);
|
|
2157
|
+
} catch (err) {
|
|
2158
|
+
view.innerHTML = `<div class="error">Could not load API documentation from ${escapeHtml(source)}.</div>`;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
function renderMarkdown(markdown) {
|
|
2163
|
+
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
|
|
2164
|
+
const html = [];
|
|
2165
|
+
let paragraph = [];
|
|
2166
|
+
let listOpen = false;
|
|
2167
|
+
let codeOpen = false;
|
|
2168
|
+
let code = [];
|
|
2169
|
+
|
|
2170
|
+
const flushParagraph = () => {
|
|
2171
|
+
if (!paragraph.length) return;
|
|
2172
|
+
html.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
|
|
2173
|
+
paragraph = [];
|
|
2174
|
+
};
|
|
2175
|
+
const closeList = () => {
|
|
2176
|
+
if (!listOpen) return;
|
|
2177
|
+
html.push("</ul>");
|
|
2178
|
+
listOpen = false;
|
|
2179
|
+
};
|
|
2180
|
+
|
|
2181
|
+
for (const line of lines) {
|
|
2182
|
+
if (codeOpen) {
|
|
2183
|
+
if (line.startsWith("```")) {
|
|
2184
|
+
html.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
|
|
2185
|
+
code = [];
|
|
2186
|
+
codeOpen = false;
|
|
2187
|
+
} else {
|
|
2188
|
+
code.push(line);
|
|
2189
|
+
}
|
|
2190
|
+
continue;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
if (line.startsWith("```")) {
|
|
2194
|
+
flushParagraph();
|
|
2195
|
+
closeList();
|
|
2196
|
+
codeOpen = true;
|
|
2197
|
+
code = [];
|
|
2198
|
+
continue;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
const trimmed = line.trim();
|
|
2202
|
+
if (!trimmed) {
|
|
2203
|
+
flushParagraph();
|
|
2204
|
+
closeList();
|
|
2205
|
+
continue;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
|
|
2209
|
+
if (heading) {
|
|
2210
|
+
flushParagraph();
|
|
2211
|
+
closeList();
|
|
2212
|
+
const level = heading[1].length;
|
|
2213
|
+
html.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
|
|
2214
|
+
continue;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
const bullet = trimmed.match(/^-\s+(.+)$/);
|
|
2218
|
+
if (bullet) {
|
|
2219
|
+
flushParagraph();
|
|
2220
|
+
if (!listOpen) {
|
|
2221
|
+
html.push("<ul>");
|
|
2222
|
+
listOpen = true;
|
|
2223
|
+
}
|
|
2224
|
+
html.push(`<li>${renderInlineMarkdown(bullet[1])}</li>`);
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
paragraph.push(trimmed);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
if (codeOpen) html.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
|
|
2232
|
+
flushParagraph();
|
|
2233
|
+
closeList();
|
|
2234
|
+
return html.join("");
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
function renderInlineMarkdown(text) {
|
|
2238
|
+
const protectedParts = [];
|
|
2239
|
+
const protect = (html) => `@@PART_${protectedParts.push(html) - 1}@@`;
|
|
2240
|
+
const protectedText = String(text).replace(/`([^`]+)`/g, (match, value) => {
|
|
2241
|
+
return protect(`<code>${escapeHtml(value)}</code>`);
|
|
2242
|
+
}).replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
|
|
2243
|
+
const safeUrl = markdownUrl(url);
|
|
2244
|
+
return safeUrl
|
|
2245
|
+
? protect(`<img src="${escapeHtml(safeUrl)}" alt="${escapeHtml(alt)}" loading="lazy">`)
|
|
2246
|
+
: match;
|
|
2247
|
+
}).replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => {
|
|
2248
|
+
const safeUrl = markdownUrl(url);
|
|
2249
|
+
if (!safeUrl) return match;
|
|
2250
|
+
const external = /^https?:\/\//i.test(safeUrl) ? ' target="_blank" rel="noreferrer"' : "";
|
|
2251
|
+
return protect(`<a href="${escapeHtml(safeUrl)}"${external}>${escapeHtml(label)}</a>`);
|
|
2252
|
+
});
|
|
2253
|
+
return escapeHtml(protectedText)
|
|
2254
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
2255
|
+
.replace(/@@PART_(\d+)@@/g, (match, index) => protectedParts[Number(index)] || match);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function markdownUrl(value) {
|
|
2259
|
+
const url = String(value || "").trim();
|
|
2260
|
+
if (!url || /[\u0000-\u001f\u007f]/.test(url)) return null;
|
|
2261
|
+
if (/^(?:https?:\/\/|#|\/|\.\.\/|\.\/)/i.test(url)) return url;
|
|
2262
|
+
return /^[a-z0-9][a-z0-9._/-]*$/i.test(url) ? url : null;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
async function checkHealth() {
|
|
2266
|
+
const result = await run(connection => connection.health());
|
|
2267
|
+
if (result.stale) return;
|
|
2268
|
+
$("node-label").textContent = result.ok
|
|
2269
|
+
? (result.body?.node ? `node ${result.body.node}` : "node reachable")
|
|
2270
|
+
: "node unreachable";
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
async function login() {
|
|
2274
|
+
clearSession();
|
|
2275
|
+
state.authDb = $("auth-db").value.trim() || "_global";
|
|
2276
|
+
state.authColl = $("auth-coll").value.trim() || "users";
|
|
2277
|
+
const credentials = {
|
|
2278
|
+
database: state.authDb,
|
|
2279
|
+
collection: state.authColl,
|
|
2280
|
+
username: $("username").value.trim(),
|
|
2281
|
+
password: $("password").value,
|
|
2282
|
+
};
|
|
2283
|
+
const result = await run(connection => connection.login(credentials));
|
|
2284
|
+
if (result.stale) return;
|
|
2285
|
+
if (!result.ok) return showToast(result.body?.error || "Login failed", true);
|
|
2286
|
+
$("password").value = "";
|
|
2287
|
+
showToast("Signed in");
|
|
2288
|
+
await Promise.all([loadTree(), loadClusterStatus()]);
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
async function renewSession() {
|
|
2292
|
+
const result = await run(connection => connection.renew());
|
|
2293
|
+
if (result.stale) return;
|
|
2294
|
+
showToast(result.ok ? "Token renewed" : result.body?.error || "Renew failed", !result.ok);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
async function loadTree() {
|
|
2298
|
+
if (!client.getSession()?.accessToken) {
|
|
2299
|
+
$("tree").innerHTML = `<div class="empty">Sign in to load databases.</div>`;
|
|
2300
|
+
renderActionState();
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
$("tree").innerHTML = `<div class="empty">Loading databases...</div>`;
|
|
2305
|
+
const dbs = await run(connection => connection.listDatabases());
|
|
2306
|
+
if (dbs.stale) return;
|
|
2307
|
+
if (!dbs.ok) {
|
|
2308
|
+
$("tree").innerHTML = `<div class="error">${escapeHtml(dbs.body?.error || "Could not load databases")}</div>`;
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
const epoch = sessionEpoch;
|
|
2313
|
+
const previousDbs = new Set(Object.keys(state.tree));
|
|
2314
|
+
const names = Array.isArray(dbs.body?.databases) ? dbs.body.databases : [];
|
|
2315
|
+
const entries = await Promise.all(names.map(async (db) => {
|
|
2316
|
+
const result = await run(connection => connection.database(db).listCollections());
|
|
2317
|
+
if (result.stale) return;
|
|
2318
|
+
return [db, result.ok && Array.isArray(result.body?.collections) ? result.body.collections : []];
|
|
2319
|
+
}));
|
|
2320
|
+
if (epoch !== sessionEpoch || entries.some(entry => !entry)) return;
|
|
2321
|
+
state.tree = Object.fromEntries(entries);
|
|
2322
|
+
for (const db of names) {
|
|
2323
|
+
if (!previousDbs.has(db) && state.active?.db !== db) state.collapsedDbs.add(db);
|
|
2324
|
+
}
|
|
2325
|
+
renderTree();
|
|
2326
|
+
renderActionState();
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
async function loadClusterStatus() {
|
|
2330
|
+
if (!client.getSession()?.accessToken) {
|
|
2331
|
+
state.cluster = null;
|
|
2332
|
+
state.clusterDetails = null;
|
|
2333
|
+
renderClusterStatus(null);
|
|
2334
|
+
renderActionState();
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
const root = $("cluster-status");
|
|
2339
|
+
root.title = "";
|
|
2340
|
+
root.innerHTML = `<span class="top-config-message">Loading cluster...</span>`;
|
|
2341
|
+
const result = await run(connection => connection.status());
|
|
2342
|
+
if (result.stale) return;
|
|
2343
|
+
if (!result.ok) {
|
|
2344
|
+
state.cluster = null;
|
|
2345
|
+
state.clusterDetails = null;
|
|
2346
|
+
root.title = "";
|
|
2347
|
+
root.innerHTML = `<span class="top-config-message bad">${escapeHtml(result.body?.error || "Could not load cluster status")}</span>`;
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
state.cluster = result.body || null;
|
|
2352
|
+
state.clusterDetails = null;
|
|
2353
|
+
renderClusterStatus(state.cluster);
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
function renderClusterStatus(config) {
|
|
2357
|
+
const root = $("cluster-status");
|
|
2358
|
+
if (!client.getSession()?.accessToken) {
|
|
2359
|
+
root.innerHTML = `<span class="top-config-message">Sign in for cluster stats.</span>`;
|
|
2360
|
+
root.title = "";
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
if (!config) {
|
|
2364
|
+
root.innerHTML = `<span class="top-config-message">No cluster status loaded.</span>`;
|
|
2365
|
+
root.title = "";
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
const nodes = Array.isArray(config.nodes) ? config.nodes : [];
|
|
2370
|
+
const shards = Array.isArray(config.shards) ? config.shards : [];
|
|
2371
|
+
const memory = configMemory(config, nodes);
|
|
2372
|
+
const taskCount = Number(config.indexes?.activeTasks) || 0;
|
|
2373
|
+
|
|
2374
|
+
root.title = clusterTitle({ nodes, shards });
|
|
2375
|
+
const stats = [
|
|
2376
|
+
topStat("shards", shards.length, shards.length ? "ok" : "warn", shardTitle(shards)),
|
|
2377
|
+
memoryStat("heap logical", memory.cluster, memoryClusterTitle(memory)),
|
|
2378
|
+
...memory.nodes.map((entry) => memoryStat(memoryNodeLabel(entry), entry, memoryNodeTitle(entry), "node-rss")),
|
|
2379
|
+
];
|
|
2380
|
+
if (taskCount > 0) {
|
|
2381
|
+
stats.push(topStat("tasks", taskCount, "warn", `${taskCount} active derived-state tasks`));
|
|
2382
|
+
}
|
|
2383
|
+
root.innerHTML = stats.join("");
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
async function openClusterStatsModal() {
|
|
2387
|
+
if (!client.getSession()?.accessToken) return showToast("Sign in first", true);
|
|
2388
|
+
const root = $("cluster-modal-body");
|
|
2389
|
+
root.innerHTML = `<div class="empty">Loading cluster status...</div>`;
|
|
2390
|
+
openModal("cluster-modal");
|
|
2391
|
+
const result = await run(connection => connection.status({ details: true }));
|
|
2392
|
+
if (result.stale) return;
|
|
2393
|
+
if (!result.ok) {
|
|
2394
|
+
root.innerHTML = `<div class="error">${escapeHtml(result.body?.error || "Could not load cluster status.")}</div>`;
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
state.clusterDetails = result.body || null;
|
|
2398
|
+
renderClusterStatsModal(state.clusterDetails);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function renderClusterStatsModal(config) {
|
|
2402
|
+
const nodes = Array.isArray(config.nodes) ? config.nodes : [];
|
|
2403
|
+
const shards = Array.isArray(config.shards) ? config.shards : [];
|
|
2404
|
+
const memory = configMemory(config, nodes);
|
|
2405
|
+
const compression = configCompression(config, nodes);
|
|
2406
|
+
const hasCompression = compression.physical.collectionCount > 0;
|
|
2407
|
+
const online = nodes.filter((nodeStatus) => isNodeOnline(nodeStatus)).length;
|
|
2408
|
+
const indexes = config.indexes || {};
|
|
2409
|
+
const indexCount = (Number(indexes.ready) || 0) + (Number(indexes.building) || 0) + (Number(indexes.error) || 0);
|
|
2410
|
+
const taskCount = Number(indexes.activeTasks) || 0;
|
|
2411
|
+
const vectors = config.vectors || {};
|
|
2412
|
+
|
|
2413
|
+
$("cluster-modal-body").innerHTML = `
|
|
2414
|
+
<section class="cluster-hero">
|
|
2415
|
+
<div class="cluster-title-block">
|
|
2416
|
+
<h3>${escapeHtml(config.node?.id || "cluster")}</h3>
|
|
2417
|
+
<p>${escapeHtml(config.node?.url || state.server)}</p>
|
|
2418
|
+
<p>Generated ${escapeHtml(formatDateTime(config.generatedAt))}</p>
|
|
2419
|
+
</div>
|
|
2420
|
+
<div class="cluster-metrics">
|
|
2421
|
+
${clusterMetric("nodes", `${online}/${nodes.length}`, "online", nodesTone(online, nodes.length))}
|
|
2422
|
+
${clusterMetric("shards", shards.length, `${coveredShardText(memory.cluster)}`, shards.length ? "ok" : "warn")}
|
|
2423
|
+
${clusterMetric("indexes", `${Number(indexes.ready) || 0}/${indexCount}`, "collections ready", Number(indexes.building) || Number(indexes.error) ? "warn" : "ok")}
|
|
2424
|
+
${clusterMetric("tasks", taskCount, "active", taskCount ? "warn" : "ok")}
|
|
2425
|
+
${clusterMetric("vectors", formatNumber(vectors.storedVectors), `${formatNumber(vectors.activeIndexes)} active indexes`, Number(vectors.errors) ? "warn" : "ok")}
|
|
2426
|
+
${hasCompression ? clusterMetric("compression", formatPercent(compression.logical.savedPercent), "logical payload saved", "ok") : ""}
|
|
2427
|
+
</div>
|
|
2428
|
+
</section>
|
|
2429
|
+
|
|
2430
|
+
${renderMemoryDetails(memory)}
|
|
2431
|
+
${renderCompressionDetails(compression)}
|
|
2432
|
+
${renderShardDetails(shards)}
|
|
2433
|
+
${renderNodeDetails(nodes)}
|
|
2434
|
+
${renderIndexDetails(nodes)}
|
|
2435
|
+
${renderReplicationDetails(nodes)}
|
|
2436
|
+
|
|
2437
|
+
<details class="raw-config">
|
|
2438
|
+
<summary>Raw /api/config payload</summary>
|
|
2439
|
+
<pre>${highlightJson(config)}</pre>
|
|
2440
|
+
</details>
|
|
2441
|
+
`;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
function clusterMetric(label, value, sub = "", tone = "") {
|
|
2445
|
+
return `
|
|
2446
|
+
<div class="cluster-metric ${tone}">
|
|
2447
|
+
<span>${escapeHtml(label)}</span>
|
|
2448
|
+
<strong>${escapeHtml(value)}</strong>
|
|
2449
|
+
<small>${escapeHtml(sub)}</small>
|
|
2450
|
+
</div>
|
|
2451
|
+
`;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
function renderMemoryDetails(memory) {
|
|
2455
|
+
const selectedReplicas = Array.isArray(memory.cluster.selectedReplicas) ? memory.cluster.selectedReplicas : [];
|
|
2456
|
+
return `
|
|
2457
|
+
<section class="cluster-section">
|
|
2458
|
+
<div class="cluster-section-title">
|
|
2459
|
+
<h3>Memory</h3>
|
|
2460
|
+
<span>logical capacity excludes extra replicas</span>
|
|
2461
|
+
</div>
|
|
2462
|
+
<div class="cluster-grid">
|
|
2463
|
+
${memoryPanel("Logical cluster heap", memory.cluster, coveredShardText(memory.cluster))}
|
|
2464
|
+
${memoryPanel("Physical process heap", memory.physical, `${memory.physical.onlineNodeCount}/${memory.physical.nodeCount} nodes online`)}
|
|
2465
|
+
${selectedReplicas.length ? selectedReplicaPanel(selectedReplicas) : ""}
|
|
2466
|
+
</div>
|
|
2467
|
+
</section>
|
|
2468
|
+
`;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
function memoryPanel(title, entry, subtitle = "") {
|
|
2472
|
+
const percent = memoryPercent(entry);
|
|
2473
|
+
const tone = memoryTone(percent);
|
|
2474
|
+
const committed = Number(entry.committed);
|
|
2475
|
+
return `
|
|
2476
|
+
<div class="cluster-panel">
|
|
2477
|
+
<div class="cluster-panel-head">
|
|
2478
|
+
<strong>${escapeHtml(title)}</strong>
|
|
2479
|
+
<span class="state-text ${tone === "ok" ? "" : tone}">${formatPercent(percent)}</span>
|
|
2480
|
+
</div>
|
|
2481
|
+
${meterHtml(percent, tone)}
|
|
2482
|
+
<div class="cluster-kv">
|
|
2483
|
+
${kv("Heap", formatMemory(memoryUsed(entry)))}
|
|
2484
|
+
${kv("Limit", formatMemory(memoryTotal(entry)))}
|
|
2485
|
+
${Number.isFinite(committed) ? kv("Committed", formatMemory(committed)) : ""}
|
|
2486
|
+
${kv("Nodes", `${entry.onlineNodeCount ?? 0}/${entry.nodeCount ?? 0}`)}
|
|
2487
|
+
</div>
|
|
2488
|
+
${subtitle ? `<div class="cluster-subtle">${escapeHtml(subtitle)}</div>` : ""}
|
|
2489
|
+
</div>
|
|
2490
|
+
`;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
function selectedReplicaPanel(replicas) {
|
|
2494
|
+
return `
|
|
2495
|
+
<div class="cluster-panel">
|
|
2496
|
+
<div class="cluster-panel-head">
|
|
2497
|
+
<strong>Logical replicas</strong>
|
|
2498
|
+
<span class="state-text">selected</span>
|
|
2499
|
+
</div>
|
|
2500
|
+
<div class="cluster-list">
|
|
2501
|
+
${replicas.map((replica) => `
|
|
2502
|
+
<div class="cluster-row">
|
|
2503
|
+
<strong>${escapeHtml(replica.shard || "default")}</strong>
|
|
2504
|
+
<span>${escapeHtml(replica.nodeId || replica.url || "unknown")}</span>
|
|
2505
|
+
</div>
|
|
2506
|
+
`).join("")}
|
|
2507
|
+
</div>
|
|
2508
|
+
</div>
|
|
2509
|
+
`;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function renderCompressionDetails(compression) {
|
|
2513
|
+
const nodes = compression.nodes.filter((entry) => entry.collectionCount > 0);
|
|
2514
|
+
const contexts = nodes.flatMap((entry) => entry.collections.map((collection) => ({
|
|
2515
|
+
node: entry.nodeId || entry.url || "unknown",
|
|
2516
|
+
...collection,
|
|
2517
|
+
})));
|
|
2518
|
+
if (!contexts.length) return "";
|
|
2519
|
+
|
|
2520
|
+
return `
|
|
2521
|
+
<section class="cluster-section">
|
|
2522
|
+
<div class="cluster-section-title">
|
|
2523
|
+
<h3>Compression</h3>
|
|
2524
|
+
<span>tiered payload bytes only</span>
|
|
2525
|
+
</div>
|
|
2526
|
+
<div class="cluster-grid">
|
|
2527
|
+
${compressionPanel("Logical data", compression.logical, "one replica per shard")}
|
|
2528
|
+
${compressionPanel("Physical data", compression.physical, "all online replicas")}
|
|
2529
|
+
${nodes.map((entry) => compressionPanel(entry.nodeId || entry.url || "node", entry, `${entry.collectionCount} configured collection${entry.collectionCount === 1 ? "" : "s"}`)).join("")}
|
|
2530
|
+
</div>
|
|
2531
|
+
<div class="compression-contexts">
|
|
2532
|
+
<div class="compression-context-row header">
|
|
2533
|
+
<span>Context</span><span>Coverage</span><span>Payload</span><span>Policy</span>
|
|
2534
|
+
</div>
|
|
2535
|
+
${contexts.map(compressionContextRow).join("")}
|
|
2536
|
+
</div>
|
|
2537
|
+
</section>
|
|
2538
|
+
`;
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
function compressionPanel(title, entry, subtitle = "") {
|
|
2542
|
+
const savedPercent = Number(entry.savedPercent) || 0;
|
|
2543
|
+
const tone = entry.uncompressedBytes > 0 && savedPercent <= 0 ? "warn" : "";
|
|
2544
|
+
return `
|
|
2545
|
+
<div class="cluster-panel">
|
|
2546
|
+
<div class="cluster-panel-head">
|
|
2547
|
+
<strong>${escapeHtml(title)}</strong>
|
|
2548
|
+
<span class="state-text ${tone}">${formatPercent(savedPercent)} saved</span>
|
|
2549
|
+
</div>
|
|
2550
|
+
${compressionChart(entry)}
|
|
2551
|
+
<div class="cluster-kv">
|
|
2552
|
+
${kv("Original", formatMemory(entry.uncompressedBytes))}
|
|
2553
|
+
${kv("Compressed", formatMemory(entry.compressedBytes))}
|
|
2554
|
+
${kv("Objects", formatNumber(entry.compressedObjects))}
|
|
2555
|
+
${kv("Coverage", formatPercent(entry.coveragePercent))}
|
|
2556
|
+
</div>
|
|
2557
|
+
${subtitle ? `<div class="cluster-subtle">${escapeHtml(subtitle)}</div>` : ""}
|
|
2558
|
+
</div>
|
|
2559
|
+
`;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
function compressionChart(entry) {
|
|
2563
|
+
const original = Number(entry.uncompressedBytes) || 0;
|
|
2564
|
+
const compressed = Number(entry.compressedBytes) || 0;
|
|
2565
|
+
const storedPercent = original > 0 ? Math.max(0, Math.min(100, (compressed / original) * 100)) : 0;
|
|
2566
|
+
const label = `Original ${formatMemory(original)}, compressed ${formatMemory(compressed)}`;
|
|
2567
|
+
return `
|
|
2568
|
+
<div class="compression-chart" role="img" aria-label="${escapeHtml(label)}">
|
|
2569
|
+
<div class="compression-chart-row">
|
|
2570
|
+
<span>Original</span>
|
|
2571
|
+
<div class="compression-track"><span style="--bar:${original > 0 ? 100 : 0}%"></span></div>
|
|
2572
|
+
<strong>${formatMemory(original)}</strong>
|
|
2573
|
+
</div>
|
|
2574
|
+
<div class="compression-chart-row">
|
|
2575
|
+
<span>Compressed</span>
|
|
2576
|
+
<div class="compression-track compressed"><span style="--bar:${storedPercent}%"></span></div>
|
|
2577
|
+
<strong>${formatMemory(compressed)}</strong>
|
|
2578
|
+
</div>
|
|
2579
|
+
</div>
|
|
2580
|
+
`;
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
function compressionContextRow(item) {
|
|
2584
|
+
const context = `${item.node} · ${item.database || "_"} / ${item.collection || "_"}`;
|
|
2585
|
+
const coverage = `${formatNumber(item.compressedObjects)} / ${formatNumber(item.liveObjects)} · ${formatPercent(item.coveragePercent)}`;
|
|
2586
|
+
const payload = `${formatMemory(item.uncompressedBytes)} to ${formatMemory(item.compressedBytes)} · ${formatPercent(item.savedPercent)} saved`;
|
|
2587
|
+
return `
|
|
2588
|
+
<div class="compression-context-row">
|
|
2589
|
+
<span class="cluster-context" title="${escapeHtml(context)}">${escapeHtml(context)}</span>
|
|
2590
|
+
<span>${escapeHtml(coverage)}</span>
|
|
2591
|
+
<span title="${escapeHtml(payload)}">${escapeHtml(payload)}</span>
|
|
2592
|
+
<span title="${escapeHtml(compressionPolicy(item.policy))}">${escapeHtml(compressionPolicy(item.policy))}</span>
|
|
2593
|
+
</div>
|
|
2594
|
+
`;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
function compressionPolicy(policy = {}) {
|
|
2598
|
+
const min = `min ${formatByteSize(policy.minBytes)}`;
|
|
2599
|
+
if (Array.isArray(policy.keep) && policy.keep.length) return `keep ${policy.keep.join(", ")} · ${min}`;
|
|
2600
|
+
if (Array.isArray(policy.offload) && policy.offload.length) return `compress ${policy.offload.join(", ")} · ${min}`;
|
|
2601
|
+
return min;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
function renderShardDetails(shards) {
|
|
2605
|
+
return `
|
|
2606
|
+
<section class="cluster-section">
|
|
2607
|
+
<div class="cluster-section-title">
|
|
2608
|
+
<h3>Shards</h3>
|
|
2609
|
+
<span>${shards.length} configured</span>
|
|
2610
|
+
</div>
|
|
2611
|
+
<div class="cluster-grid">
|
|
2612
|
+
${shards.length ? shards.map(shardPanel).join("") : emptyPanel("No shards configured")}
|
|
2613
|
+
</div>
|
|
2614
|
+
</section>
|
|
2615
|
+
`;
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
function shardPanel(shard) {
|
|
2619
|
+
const peers = Array.isArray(shard.peers) ? shard.peers : [];
|
|
2620
|
+
const online = peers.filter((peer) => peer.online).length;
|
|
2621
|
+
const tone = nodesTone(online, peers.length);
|
|
2622
|
+
return `
|
|
2623
|
+
<div class="cluster-panel">
|
|
2624
|
+
<div class="cluster-panel-head">
|
|
2625
|
+
<strong>${escapeHtml(shard.name || "default")}</strong>
|
|
2626
|
+
<span class="state-text ${tone === "ok" ? "" : tone}">${online}/${peers.length} online</span>
|
|
2627
|
+
</div>
|
|
2628
|
+
<div class="cluster-list">
|
|
2629
|
+
${peers.map((peer) => peerRow(peer)).join("") || `<div class="cluster-subtle">No peers assigned.</div>`}
|
|
2630
|
+
</div>
|
|
2631
|
+
</div>
|
|
2632
|
+
`;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
function peerRow(peer) {
|
|
2636
|
+
const tone = peer.online ? "" : "bad";
|
|
2637
|
+
const label = peer.nodeId || peer.url || peer.key || "unknown";
|
|
2638
|
+
const detail = [
|
|
2639
|
+
peer.self ? "self" : "",
|
|
2640
|
+
peer.rss ? formatMemory(peer.rss) : "",
|
|
2641
|
+
].filter(Boolean).join(" · ");
|
|
2642
|
+
return `
|
|
2643
|
+
<div class="cluster-row">
|
|
2644
|
+
<strong>${escapeHtml(label)}</strong>
|
|
2645
|
+
<span class="state-text ${tone}">${escapeHtml(detail || (peer.online ? "online" : "offline"))}</span>
|
|
2646
|
+
</div>
|
|
2647
|
+
`;
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
function renderNodeDetails(nodes) {
|
|
2651
|
+
return `
|
|
2652
|
+
<section class="cluster-section">
|
|
2653
|
+
<div class="cluster-section-title">
|
|
2654
|
+
<h3>Nodes</h3>
|
|
2655
|
+
<span>${nodes.length} observed</span>
|
|
2656
|
+
</div>
|
|
2657
|
+
<div class="cluster-grid">
|
|
2658
|
+
${nodes.length ? nodes.map(nodePanel).join("") : emptyPanel("No node status available")}
|
|
2659
|
+
</div>
|
|
2660
|
+
</section>
|
|
2661
|
+
`;
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
function nodePanel(nodeStatus) {
|
|
2665
|
+
const name = nodeName(nodeStatus);
|
|
2666
|
+
const online = isNodeOnline(nodeStatus);
|
|
2667
|
+
const memory = nodeMemory(nodeStatus);
|
|
2668
|
+
const percent = memoryPercent(memory);
|
|
2669
|
+
const tone = online ? memoryTone(percent) : "bad";
|
|
2670
|
+
const guard = nodeStatus.memoryGuard || {};
|
|
2671
|
+
const processInfo = nodeStatus.process || {};
|
|
2672
|
+
return `
|
|
2673
|
+
<div class="cluster-panel">
|
|
2674
|
+
<div class="cluster-panel-head">
|
|
2675
|
+
<strong>${escapeHtml(name)}</strong>
|
|
2676
|
+
<span class="state-text ${online ? "" : "bad"}">${online ? "online" : "offline"}</span>
|
|
2677
|
+
</div>
|
|
2678
|
+
${meterHtml(online ? percent : 0, tone)}
|
|
2679
|
+
<div class="cluster-kv">
|
|
2680
|
+
${kv("Heap", online ? formatMemory(memoryUsed(memory)) : "offline")}
|
|
2681
|
+
${kv("Limit", online ? formatMemory(memoryTotal(memory)) : "unknown")}
|
|
2682
|
+
${kv("Committed", formatMemory(memory.committed))}
|
|
2683
|
+
${kv("RSS", formatMemory(nodeStatus.memory?.rss))}
|
|
2684
|
+
${kv("Writes", guard.acceptingWrites === false ? "blocked" : "accepting")}
|
|
2685
|
+
${kv("Uptime", formatDuration(processInfo.uptimeMs))}
|
|
2686
|
+
</div>
|
|
2687
|
+
<div class="cluster-subtle">${escapeHtml(nodeStatus.node?.url || nodeStatus.sourcePeer?.url || "")}</div>
|
|
2688
|
+
</div>
|
|
2689
|
+
`;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
function renderIndexDetails(nodes) {
|
|
2693
|
+
const rows = [];
|
|
2694
|
+
for (const nodeStatus of nodes) {
|
|
2695
|
+
const collections = Array.isArray(nodeStatus.indexes?.collections) ? nodeStatus.indexes.collections : [];
|
|
2696
|
+
for (const item of collections) {
|
|
2697
|
+
if (hasIndexState(item)) rows.push({ node: nodeName(nodeStatus), ...item });
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
return `
|
|
2701
|
+
<section class="cluster-section">
|
|
2702
|
+
<div class="cluster-section-title">
|
|
2703
|
+
<h3>Indexes</h3>
|
|
2704
|
+
<span>${rows.length} collection states</span>
|
|
2705
|
+
</div>
|
|
2706
|
+
<div class="index-table">
|
|
2707
|
+
<div class="index-row header">
|
|
2708
|
+
<span>Context</span><span>Status</span><span>Indexes</span>
|
|
2709
|
+
</div>
|
|
2710
|
+
${rows.length ? rows.map(indexRow).join("") : `<div class="cluster-row"><span>No index state available.</span><span></span></div>`}
|
|
2711
|
+
</div>
|
|
2712
|
+
</section>
|
|
2713
|
+
`;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
function indexRow(item) {
|
|
2717
|
+
const context = `${item.node} · ${item.database || "_"} / ${item.collection || "_"}`;
|
|
2718
|
+
const summary = indexSummary(item);
|
|
2719
|
+
return `
|
|
2720
|
+
<div class="index-row">
|
|
2721
|
+
<span class="cluster-context" title="${escapeHtml(context)}">${escapeHtml(context)}</span>
|
|
2722
|
+
<span class="state-text ${item.status === "ready" ? "" : "warn"}">${escapeHtml(statusLabel(item.status))}</span>
|
|
2723
|
+
<span title="${escapeHtml(summary)}">${escapeHtml(summary)}</span>
|
|
2724
|
+
</div>
|
|
2725
|
+
`;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
function renderReplicationDetails(nodes) {
|
|
2729
|
+
return `
|
|
2730
|
+
<section class="cluster-section">
|
|
2731
|
+
<div class="cluster-section-title">
|
|
2732
|
+
<h3>Replication</h3>
|
|
2733
|
+
<span>per node</span>
|
|
2734
|
+
</div>
|
|
2735
|
+
<div class="cluster-grid">
|
|
2736
|
+
${nodes.length ? nodes.map(replicationPanel).join("") : emptyPanel("No replication status available")}
|
|
2737
|
+
</div>
|
|
2738
|
+
</section>
|
|
2739
|
+
`;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
function replicationPanel(nodeStatus) {
|
|
2743
|
+
const replication = nodeStatus.replication || {};
|
|
2744
|
+
const scheduler = replication.scheduler || {};
|
|
2745
|
+
const lastError = scheduler.lastError ? String(scheduler.lastError) : "";
|
|
2746
|
+
return `
|
|
2747
|
+
<div class="cluster-panel">
|
|
2748
|
+
<div class="cluster-panel-head">
|
|
2749
|
+
<strong>${escapeHtml(nodeName(nodeStatus))}</strong>
|
|
2750
|
+
<span class="state-text ${lastError ? "bad" : ""}">${escapeHtml(scheduler.mode || "sync")}</span>
|
|
2751
|
+
</div>
|
|
2752
|
+
<div class="cluster-kv">
|
|
2753
|
+
${kv("Seq", formatNumber(replication.seq))}
|
|
2754
|
+
${kv("Trimmed", formatNumber(replication.trimmed))}
|
|
2755
|
+
${kv("Log", formatNumber(replication.logLength))}
|
|
2756
|
+
${kv("Batch", formatNumber(replication.syncBatchSize))}
|
|
2757
|
+
${kv("Pulls", formatNumber(scheduler.pullSyncs))}
|
|
2758
|
+
${kv("Anti-entropy", formatNumber(scheduler.antiEntropySyncs))}
|
|
2759
|
+
${kv("Pending hints", formatNumber(scheduler.pendingHintScopes))}
|
|
2760
|
+
${kv("Inflight", formatNumber(scheduler.inflightPulls))}
|
|
2761
|
+
</div>
|
|
2762
|
+
<div class="cluster-subtle">${escapeHtml(lastError || `last sync ${formatDateTime(scheduler.lastSyncAt)}`)}</div>
|
|
2763
|
+
</div>
|
|
2764
|
+
`;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
function meterHtml(percent, tone = memoryTone(percent)) {
|
|
2768
|
+
const safe = Math.max(0, Math.min(100, Number(percent) || 0));
|
|
2769
|
+
return `<div class="config-meter ${tone === "ok" ? "" : tone}"><span style="--used:${safe}%"></span></div>`;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
function kv(label, value) {
|
|
2773
|
+
return `<div><span class="cluster-label">${escapeHtml(label)}</span><strong>${escapeHtml(value ?? "")}</strong></div>`;
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
function emptyPanel(message) {
|
|
2777
|
+
return `<div class="cluster-panel"><div class="cluster-subtle">${escapeHtml(message)}</div></div>`;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
function nodeMemory(nodeStatus) {
|
|
2781
|
+
return {
|
|
2782
|
+
used: nodeStatus.memory?.heapUsed ?? null,
|
|
2783
|
+
total: nodeStatus.memory?.heapLimit ?? nodeStatus.memory?.heapTotal ?? null,
|
|
2784
|
+
committed: nodeStatus.memory?.heapTotal ?? null,
|
|
2785
|
+
rss: nodeStatus.memory?.rss ?? null,
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
function indexPathList(indexes, includePending = false) {
|
|
2790
|
+
if (!Array.isArray(indexes) || !indexes.length) return "none";
|
|
2791
|
+
return indexes.map((index) => {
|
|
2792
|
+
const path = index.path || String(index);
|
|
2793
|
+
const pending = includePending && Number(index.pendingEntries) > 0 ? ` (${formatNumber(index.pendingEntries)} pending)` : "";
|
|
2794
|
+
return `${path}${pending}`;
|
|
2795
|
+
}).join(", ");
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
function indexSummary(item) {
|
|
2799
|
+
const parts = [];
|
|
2800
|
+
if (Array.isArray(item.fieldIndexes) && item.fieldIndexes.length) {
|
|
2801
|
+
parts.push(`field: ${indexPathList(item.fieldIndexes)}`);
|
|
2802
|
+
}
|
|
2803
|
+
if (Array.isArray(item.columns) && item.columns.length) {
|
|
2804
|
+
parts.push(`column: ${indexPathList(item.columns, true)}`);
|
|
2805
|
+
}
|
|
2806
|
+
if (hasVectorState(item.vector)) {
|
|
2807
|
+
parts.push(`vector: ${statusLabel(item.vector.status)} · ${formatNumber(item.vector.stored)} stored`);
|
|
2808
|
+
}
|
|
2809
|
+
return parts.join(" · ") || "none";
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
function hasIndexState(item) {
|
|
2813
|
+
return Boolean(
|
|
2814
|
+
(Array.isArray(item?.fieldIndexes) && item.fieldIndexes.length) ||
|
|
2815
|
+
(Array.isArray(item?.columns) && item.columns.length) ||
|
|
2816
|
+
hasVectorState(item?.vector),
|
|
2817
|
+
);
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
function hasVectorState(vector) {
|
|
2821
|
+
return Boolean(
|
|
2822
|
+
Number(vector?.stored) > 0 ||
|
|
2823
|
+
Number(vector?.indexes) > 0 ||
|
|
2824
|
+
Number(vector?.nodes) > 0 ||
|
|
2825
|
+
(Array.isArray(vector?.indexStates) && vector.indexStates.length > 0) ||
|
|
2826
|
+
(vector?.status && vector.status !== "ready"),
|
|
2827
|
+
);
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
function nodesTone(online, total) {
|
|
2831
|
+
if (!total) return "warn";
|
|
2832
|
+
if (online === total) return "ok";
|
|
2833
|
+
return online > 0 ? "warn" : "bad";
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
function coveredShardText(cluster) {
|
|
2837
|
+
if (!Number.isFinite(Number(cluster.shardCount))) return "logical";
|
|
2838
|
+
return `${cluster.coveredShardCount ?? 0}/${cluster.shardCount} covered`;
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
function formatNumber(value) {
|
|
2842
|
+
const number = Number(value);
|
|
2843
|
+
if (!Number.isFinite(number)) return "0";
|
|
2844
|
+
return number.toLocaleString();
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
function formatDateTime(value) {
|
|
2848
|
+
if (!value) return "never";
|
|
2849
|
+
const date = new Date(value);
|
|
2850
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
2851
|
+
return date.toLocaleString();
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
function formatDuration(ms) {
|
|
2855
|
+
const value = Number(ms);
|
|
2856
|
+
if (!Number.isFinite(value) || value <= 0) return "0s";
|
|
2857
|
+
const seconds = Math.floor(value / 1000);
|
|
2858
|
+
if (seconds < 60) return `${seconds}s`;
|
|
2859
|
+
const minutes = Math.floor(seconds / 60);
|
|
2860
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
2861
|
+
const hours = Math.floor(minutes / 60);
|
|
2862
|
+
return `${hours}h ${minutes % 60}m`;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
function statusLabel(status) {
|
|
2866
|
+
return status || "unknown";
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
function topStat(label, value, tone = "", title = "") {
|
|
2870
|
+
const safeTitle = title || `${label}: ${value}`;
|
|
2871
|
+
return `<button type="button" class="top-stat ${tone}" title="${escapeHtml(safeTitle)}" data-open-cluster="true"><span class="stat-label">${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></button>`;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
function memoryStat(label, entry, title = "", extraClass = "") {
|
|
2875
|
+
const reachable = entry.reachable !== false;
|
|
2876
|
+
const value = reachable ? memoryUsageText(entry) : "offline";
|
|
2877
|
+
const percent = reachable ? memoryPercent(entry) : 0;
|
|
2878
|
+
const tone = reachable ? memoryTone(percent) : "bad";
|
|
2879
|
+
return `
|
|
2880
|
+
<button type="button" class="top-stat memory-stat ${tone} ${extraClass}" title="${escapeHtml(title || `${label}: ${value}`)}" data-open-cluster="true">
|
|
2881
|
+
<span class="stat-label">${escapeHtml(label)}</span>
|
|
2882
|
+
<strong>${escapeHtml(value)}</strong>
|
|
2883
|
+
<span class="memory-meter" style="--used:${percent}%"></span>
|
|
2884
|
+
</button>
|
|
2885
|
+
`;
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
function configMemory(config, nodes) {
|
|
2889
|
+
const apiNodes = Array.isArray(config.heap?.nodes) ? config.heap.nodes : null;
|
|
2890
|
+
const memoryNodes = apiNodes || nodes.map((nodeStatus) => ({
|
|
2891
|
+
nodeId: nodeStatus.node?.id || null,
|
|
2892
|
+
url: nodeStatus.node?.url || nodeStatus.sourcePeer?.url || null,
|
|
2893
|
+
local: nodeStatus.local === true,
|
|
2894
|
+
reachable: nodeStatus.reachable !== false,
|
|
2895
|
+
used: nodeStatus.memory?.heapUsed ?? null,
|
|
2896
|
+
usedMb: nodeStatus.memory?.heapUsedMb ?? null,
|
|
2897
|
+
total: nodeStatus.memory?.heapLimit ?? nodeStatus.memory?.heapTotal ?? null,
|
|
2898
|
+
totalMb: nodeStatus.memory?.heapLimitMb ?? nodeStatus.memory?.heapTotalMb ?? null,
|
|
2899
|
+
committed: nodeStatus.memory?.heapTotal ?? null,
|
|
2900
|
+
committedMb: nodeStatus.memory?.heapTotalMb ?? null,
|
|
2901
|
+
percent: percentValue(nodeStatus.memory?.heapUsed, nodeStatus.memory?.heapLimit ?? nodeStatus.memory?.heapTotal),
|
|
2902
|
+
error: nodeStatus.reachable === false ? nodeStatus.error : undefined,
|
|
2903
|
+
}));
|
|
2904
|
+
const fallbackUsed = memoryNodes.reduce((sum, entry) => sum + (Number(memoryUsed(entry)) || 0), 0);
|
|
2905
|
+
const fallbackTotal = memoryNodes.reduce((sum, entry) => sum + (Number(memoryTotal(entry)) || 0), 0);
|
|
2906
|
+
const fallbackCommitted = memoryNodes.reduce((sum, entry) => sum + (Number(entry.committed) || 0), 0);
|
|
2907
|
+
const cluster = config.heap?.logical || config.heap?.cluster || {};
|
|
2908
|
+
const physical = config.heap?.physical || config.heap?.cluster || {};
|
|
2909
|
+
return {
|
|
2910
|
+
cluster: clusterMemory(cluster, memoryNodes, { fallbackUsed, fallbackTotal, fallbackCommitted }),
|
|
2911
|
+
physical: clusterMemory(physical, memoryNodes, { fallbackUsed, fallbackTotal, fallbackCommitted }),
|
|
2912
|
+
nodes: memoryNodes,
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
function configCompression(config, nodes) {
|
|
2917
|
+
const root = config.compression || {};
|
|
2918
|
+
const detailed = root.logical || root.physical || Array.isArray(root.nodes);
|
|
2919
|
+
const compressionNodes = Array.isArray(root.nodes) ? root.nodes : nodes.map((nodeStatus) => ({
|
|
2920
|
+
nodeId: nodeStatus.node?.id || null,
|
|
2921
|
+
url: nodeStatus.node?.url || nodeStatus.sourcePeer?.url || null,
|
|
2922
|
+
reachable: nodeStatus.reachable !== false,
|
|
2923
|
+
...(nodeStatus.compression || {}),
|
|
2924
|
+
}));
|
|
2925
|
+
const empty = {
|
|
2926
|
+
collectionCount: 0,
|
|
2927
|
+
liveObjects: 0,
|
|
2928
|
+
compressedObjects: 0,
|
|
2929
|
+
coveragePercent: 0,
|
|
2930
|
+
uncompressedBytes: 0,
|
|
2931
|
+
compressedBytes: 0,
|
|
2932
|
+
savedBytes: 0,
|
|
2933
|
+
savedPercent: 0,
|
|
2934
|
+
};
|
|
2935
|
+
return {
|
|
2936
|
+
logical: { ...empty, ...(detailed ? root.logical || root.cluster || {} : root) },
|
|
2937
|
+
physical: { ...empty, ...(detailed ? root.physical || root.cluster || {} : root) },
|
|
2938
|
+
nodes: compressionNodes,
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
function clusterMemory(cluster, memoryNodes, fallback) {
|
|
2943
|
+
const { fallbackUsed, fallbackTotal, fallbackCommitted } = fallback;
|
|
2944
|
+
return {
|
|
2945
|
+
...cluster,
|
|
2946
|
+
used: Number.isFinite(Number(cluster.used)) ? Number(cluster.used) : fallbackUsed,
|
|
2947
|
+
usedMb: Number.isFinite(Number(cluster.usedMb)) ? Number(cluster.usedMb) : fallbackUsed / 1024 / 1024,
|
|
2948
|
+
total: Number.isFinite(Number(cluster.total)) ? Number(cluster.total) : fallbackTotal,
|
|
2949
|
+
totalMb: Number.isFinite(Number(cluster.totalMb)) ? Number(cluster.totalMb) : fallbackTotal / 1024 / 1024,
|
|
2950
|
+
committed: Number.isFinite(Number(cluster.committed)) ? Number(cluster.committed) : fallbackCommitted,
|
|
2951
|
+
committedMb: Number.isFinite(Number(cluster.committedMb)) ? Number(cluster.committedMb) : fallbackCommitted / 1024 / 1024,
|
|
2952
|
+
percent: Number.isFinite(Number(cluster.percent)) ? Number(cluster.percent) : memoryPercent({ used: fallbackUsed, total: fallbackTotal }),
|
|
2953
|
+
nodeCount: Number.isInteger(cluster.nodeCount) ? cluster.nodeCount : memoryNodes.length,
|
|
2954
|
+
onlineNodeCount: Number.isInteger(cluster.onlineNodeCount) ? cluster.onlineNodeCount : memoryNodes.filter((entry) => entry.reachable).length,
|
|
2955
|
+
offlineNodeCount: Number.isInteger(cluster.offlineNodeCount) ? cluster.offlineNodeCount : memoryNodes.filter((entry) => !entry.reachable).length,
|
|
2956
|
+
};
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
function memoryClusterTitle(memory) {
|
|
2960
|
+
const cluster = memory.cluster;
|
|
2961
|
+
const physical = memory.physical;
|
|
2962
|
+
const shards = Number.isFinite(Number(cluster.shardCount))
|
|
2963
|
+
? `; shards ${cluster.coveredShardCount}/${cluster.shardCount}`
|
|
2964
|
+
: "";
|
|
2965
|
+
return `Logical heap ${formatMemory(memoryUsed(cluster))} of ${formatMemory(memoryTotal(cluster))} (${formatPercent(memoryPercent(cluster))})${shards}; physical ${formatMemory(memoryUsed(physical))} of ${formatMemory(memoryTotal(physical))}; ${physical.onlineNodeCount}/${physical.nodeCount} nodes online`;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
function memoryNodeTitle(entry) {
|
|
2969
|
+
const name = entry.nodeId || entry.url || "unknown";
|
|
2970
|
+
return entry.reachable
|
|
2971
|
+
? `${name}: ${formatMemory(memoryUsed(entry))} heap of ${formatMemory(memoryTotal(entry))} (${formatPercent(memoryPercent(entry))}); committed ${formatMemory(entry.committed)}`
|
|
2972
|
+
: `${name}: offline${entry.error ? ` (${entry.error})` : ""}`;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
function memoryNodeLabel(entry) {
|
|
2976
|
+
const label = entry.nodeId || entry.url || "node";
|
|
2977
|
+
const clean = String(label).replace(/^https?:\/\//, "");
|
|
2978
|
+
return clean.length > 18 ? `${clean.slice(0, 15)}...` : clean;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
function memoryUsageText(entry) {
|
|
2982
|
+
const total = Number(memoryTotal(entry));
|
|
2983
|
+
if (!Number.isFinite(total) || total <= 0) return formatMemory(memoryUsed(entry));
|
|
2984
|
+
return `${formatMemory(memoryUsed(entry))} / ${formatMemory(total)}`;
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
function memoryPercent(entry) {
|
|
2988
|
+
const percent = Number(entry.percent);
|
|
2989
|
+
if (Number.isFinite(percent)) return Math.max(0, Math.min(100, percent));
|
|
2990
|
+
return percentValue(memoryUsed(entry), memoryTotal(entry)) || 0;
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
function memoryUsed(entry) {
|
|
2994
|
+
return entry?.used ?? entry?.heapUsed ?? entry?.rss ?? null;
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
function memoryTotal(entry) {
|
|
2998
|
+
return entry?.total ?? entry?.heapLimit ?? entry?.totalMemory ?? entry?.heapTotal ?? null;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
function percentValue(value, total) {
|
|
3002
|
+
const number = Number(value);
|
|
3003
|
+
const denominator = Number(total);
|
|
3004
|
+
if (!Number.isFinite(number) || !Number.isFinite(denominator) || denominator <= 0) return null;
|
|
3005
|
+
return Math.max(0, Math.min(100, Math.round((number / denominator) * 1000) / 10));
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
function memoryTone(percent) {
|
|
3009
|
+
if (percent >= 90) return "bad";
|
|
3010
|
+
if (percent >= 75) return "warn";
|
|
3011
|
+
return "ok";
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
function formatPercent(value) {
|
|
3015
|
+
const number = Number(value);
|
|
3016
|
+
if (!Number.isFinite(number)) return "0%";
|
|
3017
|
+
return `${number.toLocaleString(undefined, { maximumFractionDigits: 1 })}%`;
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
function clusterTitle({ nodes, shards }) {
|
|
3021
|
+
return [
|
|
3022
|
+
nodeMemoryTitle(nodes),
|
|
3023
|
+
shardTitle(shards),
|
|
3024
|
+
].filter(Boolean).join("\n");
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
function nodeMemoryTitle(nodes) {
|
|
3028
|
+
if (!nodes.length) return "No nodes";
|
|
3029
|
+
return nodes.map((nodeStatus) => `${nodeName(nodeStatus)}: ${nodeStatus.memory ? formatMemory(nodeStatus.memory.heapUsed) : "offline"}`).join("\n");
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
function shardTitle(shards) {
|
|
3033
|
+
if (!shards.length) return "No shards";
|
|
3034
|
+
return shards.map((shard) => {
|
|
3035
|
+
const peers = Array.isArray(shard.peers) ? shard.peers : [];
|
|
3036
|
+
const online = peers.filter((peer) => peer.online).length;
|
|
3037
|
+
return `${shard.name || "default"}: ${online}/${peers.length} peers online`;
|
|
3038
|
+
}).join("\n");
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
function isNodeOnline(nodeStatus) {
|
|
3042
|
+
return nodeStatus && nodeStatus.ok !== false && nodeStatus.reachable !== false;
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
function nodeName(nodeStatus) {
|
|
3046
|
+
return nodeStatus.node?.id || nodeStatus.node?.url || nodeStatus.sourcePeer?.url || "unknown";
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
function formatMemory(bytes) {
|
|
3050
|
+
const value = Number(bytes);
|
|
3051
|
+
if (!Number.isFinite(value) || value <= 0) return "0 MiB";
|
|
3052
|
+
const mib = value / 1024 / 1024;
|
|
3053
|
+
if (mib >= 1024) return `${(mib / 1024).toLocaleString(undefined, { maximumFractionDigits: 2 })} GiB`;
|
|
3054
|
+
return `${mib.toLocaleString(undefined, { maximumFractionDigits: 1 })} MiB`;
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
function formatByteSize(bytes) {
|
|
3058
|
+
const value = Number(bytes);
|
|
3059
|
+
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
|
3060
|
+
if (value < 1024) return `${Math.round(value)} B`;
|
|
3061
|
+
if (value < 1024 * 1024) return `${(value / 1024).toLocaleString(undefined, { maximumFractionDigits: 1 })} KiB`;
|
|
3062
|
+
return formatMemory(value);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
function renderTree() {
|
|
3066
|
+
const root = $("tree");
|
|
3067
|
+
const dbs = Object.keys(state.tree).sort();
|
|
3068
|
+
const filter = state.treeFilter.trim().toLowerCase();
|
|
3069
|
+
if (!client.getSession()?.accessToken) {
|
|
3070
|
+
root.innerHTML = `<div class="empty">Sign in to load databases.</div>`;
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
if (!dbs.length) {
|
|
3074
|
+
root.innerHTML = `<div class="empty">No databases yet.</div>`;
|
|
3075
|
+
return;
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
for (const db of [...state.collapsedDbs]) {
|
|
3079
|
+
if (!state.tree[db]) state.collapsedDbs.delete(db);
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
root.innerHTML = "";
|
|
3083
|
+
let rendered = 0;
|
|
3084
|
+
for (const db of dbs) {
|
|
3085
|
+
const dbMatches = filter && db.toLowerCase().includes(filter);
|
|
3086
|
+
const collections = [...(state.tree[db] || [])].sort();
|
|
3087
|
+
const visibleCollections = filter
|
|
3088
|
+
? collections.filter((collection) => dbMatches || collection.toLowerCase().includes(filter))
|
|
3089
|
+
: collections;
|
|
3090
|
+
if (filter && !dbMatches && visibleCollections.length === 0) continue;
|
|
3091
|
+
|
|
3092
|
+
const block = document.createElement("div");
|
|
3093
|
+
block.className = "db-block";
|
|
3094
|
+
const collapsed = filter ? false : state.collapsedDbs.has(db);
|
|
3095
|
+
|
|
3096
|
+
const dbRow = document.createElement("div");
|
|
3097
|
+
dbRow.className = "db-row";
|
|
3098
|
+
dbRow.setAttribute("aria-expanded", String(!collapsed));
|
|
3099
|
+
dbRow.innerHTML = `
|
|
3100
|
+
<button class="tree-select db-select" type="button" title="Toggle database">
|
|
3101
|
+
<svg class="chevron" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
|
3102
|
+
<svg viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/></svg>
|
|
3103
|
+
<span class="db-name"></span>
|
|
3104
|
+
<span class="count-chip"></span>
|
|
3105
|
+
</button>
|
|
3106
|
+
${db === "_global" ? "" : `<button class="tree-delete" type="button" title="Delete database" aria-label="Delete database"><svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>`}
|
|
3107
|
+
`;
|
|
3108
|
+
dbRow.querySelector(".db-name").textContent = db;
|
|
3109
|
+
dbRow.querySelector(".count-chip").textContent = String((state.tree[db] || []).length);
|
|
3110
|
+
dbRow.querySelector(".tree-select").addEventListener("click", () => toggleDatabase(db));
|
|
3111
|
+
dbRow.querySelector(".tree-delete")?.addEventListener("click", () => openDeleteDatabaseModal(db));
|
|
3112
|
+
block.appendChild(dbRow);
|
|
3113
|
+
|
|
3114
|
+
const list = document.createElement("div");
|
|
3115
|
+
list.className = "coll-list";
|
|
3116
|
+
list.hidden = collapsed;
|
|
3117
|
+
for (const collection of visibleCollections) {
|
|
3118
|
+
const active = state.active?.db === db && state.active?.collection === collection;
|
|
3119
|
+
const row = document.createElement("div");
|
|
3120
|
+
row.className = "coll-row" + (active ? " active" : "");
|
|
3121
|
+
row.innerHTML = `
|
|
3122
|
+
<button class="tree-select coll-select" type="button" title="Open collection">
|
|
3123
|
+
<svg viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>
|
|
3124
|
+
<span class="coll-name"></span>
|
|
3125
|
+
</button>
|
|
3126
|
+
<button class="tree-delete" type="button" title="Delete collection" aria-label="Delete collection"><svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
|
3127
|
+
`;
|
|
3128
|
+
row.querySelector(".coll-name").textContent = collection;
|
|
3129
|
+
row.querySelector(".tree-select").addEventListener("click", () => selectCollection(db, collection));
|
|
3130
|
+
row.querySelector(".tree-delete").addEventListener("click", () => openDeleteCollectionModal(db, collection));
|
|
3131
|
+
list.appendChild(row);
|
|
3132
|
+
}
|
|
3133
|
+
block.appendChild(list);
|
|
3134
|
+
root.appendChild(block);
|
|
3135
|
+
rendered++;
|
|
3136
|
+
}
|
|
3137
|
+
if (!rendered) root.innerHTML = `<div class="empty">No matching databases or collections.</div>`;
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
function toggleDatabase(db) {
|
|
3141
|
+
if (state.collapsedDbs.has(db)) state.collapsedDbs.delete(db);
|
|
3142
|
+
else state.collapsedDbs.add(db);
|
|
3143
|
+
renderTree();
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
async function selectCollection(db, collection) {
|
|
3147
|
+
state.collapsedDbs.delete(db);
|
|
3148
|
+
state.active = { db, collection };
|
|
3149
|
+
state.selected = null;
|
|
3150
|
+
state.collectionMeta = null;
|
|
3151
|
+
state.objectPage = 1;
|
|
3152
|
+
state.objectHasMore = false;
|
|
3153
|
+
setBreadcrumb();
|
|
3154
|
+
renderTree();
|
|
3155
|
+
renderDetail(null);
|
|
3156
|
+
renderActionState();
|
|
3157
|
+
await loadCollectionMeta();
|
|
3158
|
+
await loadObjects();
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
function setBreadcrumb() {
|
|
3162
|
+
const el = $("breadcrumb");
|
|
3163
|
+
if (!state.active) {
|
|
3164
|
+
el.innerHTML = `<span>Select a collection</span>`;
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
el.innerHTML = `<strong></strong><span>/</span><strong></strong>`;
|
|
3168
|
+
el.querySelectorAll("strong")[0].textContent = state.active.db;
|
|
3169
|
+
el.querySelectorAll("strong")[1].textContent = state.active.collection;
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
async function loadCollectionMeta() {
|
|
3173
|
+
if (!state.active) {
|
|
3174
|
+
state.collectionMeta = null;
|
|
3175
|
+
renderActionState();
|
|
3176
|
+
return setMeta("");
|
|
3177
|
+
}
|
|
3178
|
+
const result = await run(connection => activeCollection(connection).getMeta());
|
|
3179
|
+
if (result.stale) return;
|
|
3180
|
+
if (!result.ok) {
|
|
3181
|
+
state.collectionMeta = null;
|
|
3182
|
+
renderActionState();
|
|
3183
|
+
return setMeta("");
|
|
3184
|
+
}
|
|
3185
|
+
state.collectionMeta = result.body || {};
|
|
3186
|
+
const indexes = Array.isArray(result.body?.indexes) ? result.body.indexes : [];
|
|
3187
|
+
setMeta(indexes.length ? `indexes: ${indexes.join(", ")}` : "no indexes");
|
|
3188
|
+
renderActionState();
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
function setMeta(text) {
|
|
3192
|
+
$("collection-meta").textContent = text;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
function newBuilderFilter() {
|
|
3196
|
+
return { field: "", op: "eq", value: "" };
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
function newBuilderMetric() {
|
|
3200
|
+
return { alias: "_count", fn: "$count", field: "" };
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
function setQueryMode(mode) {
|
|
3204
|
+
const next = mode === "builder" ? "builder" : "json";
|
|
3205
|
+
if (next === "builder") importQueryIntoBuilder();
|
|
3206
|
+
state.queryMode = next;
|
|
3207
|
+
renderQueryMode();
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
function renderQueryMode() {
|
|
3211
|
+
const builder = state.queryMode === "builder";
|
|
3212
|
+
$("query-json-mode-btn").classList.toggle("active", !builder);
|
|
3213
|
+
$("query-builder-mode-btn").classList.toggle("active", builder);
|
|
3214
|
+
$("query-builder").classList.toggle("open", builder);
|
|
3215
|
+
$("query").readOnly = builder;
|
|
3216
|
+
if (builder) {
|
|
3217
|
+
renderQueryBuilder();
|
|
3218
|
+
syncBuilderToQuery();
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
function renderQueryBuilder() {
|
|
3223
|
+
$("builder-match").value = state.queryBuilder.match;
|
|
3224
|
+
$("builder-group-by").value = state.queryBuilder.aggregate.groupBy || "";
|
|
3225
|
+
renderBuilderFilters();
|
|
3226
|
+
renderBuilderMetrics();
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
function renderBuilderFilters() {
|
|
3230
|
+
const root = $("builder-filters");
|
|
3231
|
+
if (!state.queryBuilder.filters.length) {
|
|
3232
|
+
root.innerHTML = `<div class="builder-empty">No filters. Add a condition or use aggregation only.</div>`;
|
|
3233
|
+
return;
|
|
3234
|
+
}
|
|
3235
|
+
root.innerHTML = state.queryBuilder.filters.map((filter, index) => `
|
|
3236
|
+
<div class="builder-row" data-filter-index="${index}">
|
|
3237
|
+
<input data-builder-field="field" placeholder="field.path" autocomplete="off" value="${escapeAttr(filter.field)}" />
|
|
3238
|
+
<select data-builder-field="op">${BUILDER_OPERATORS.map(([value, label]) => `
|
|
3239
|
+
<option value="${value}"${filter.op === value ? " selected" : ""}>${label}</option>
|
|
3240
|
+
`).join("")}</select>
|
|
3241
|
+
<input data-builder-field="value" placeholder="${filter.op === "exists" ? "true" : "value"}" autocomplete="off" value="${escapeAttr(filter.value)}" />
|
|
3242
|
+
<button class="ghost icon-only" type="button" data-remove-filter="${index}" title="Remove condition" aria-label="Remove condition">
|
|
3243
|
+
<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
3244
|
+
</button>
|
|
3245
|
+
</div>
|
|
3246
|
+
`).join("");
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
function renderBuilderMetrics() {
|
|
3250
|
+
const root = $("builder-metrics");
|
|
3251
|
+
if (!state.queryBuilder.aggregate.metrics.length) {
|
|
3252
|
+
root.innerHTML = `<div class="builder-empty">No aggregate metrics. Add one to create a $aggregate query.</div>`;
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
root.innerHTML = state.queryBuilder.aggregate.metrics.map((metric, index) => `
|
|
3256
|
+
<div class="builder-row metric" data-metric-index="${index}">
|
|
3257
|
+
<input data-metric-field="alias" placeholder="alias" autocomplete="off" value="${escapeAttr(metric.alias)}" />
|
|
3258
|
+
<select data-metric-field="fn">${BUILDER_METRICS.map(([value, label]) => `
|
|
3259
|
+
<option value="${value}"${metric.fn === value ? " selected" : ""}>${label}</option>
|
|
3260
|
+
`).join("")}</select>
|
|
3261
|
+
<input data-metric-field="field" placeholder="${metric.fn === "$count" ? "unused" : "field.path"}" autocomplete="off" value="${escapeAttr(metric.field)}" ${metric.fn === "$count" ? "disabled" : ""} />
|
|
3262
|
+
<button class="ghost icon-only" type="button" data-remove-metric="${index}" title="Remove metric" aria-label="Remove metric">
|
|
3263
|
+
<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
3264
|
+
</button>
|
|
3265
|
+
</div>
|
|
3266
|
+
`).join("");
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
function updateBuilderFilter(event) {
|
|
3270
|
+
const input = event.target.closest("[data-builder-field]");
|
|
3271
|
+
const row = event.target.closest("[data-filter-index]");
|
|
3272
|
+
if (!input || !row) return;
|
|
3273
|
+
const filter = state.queryBuilder.filters[Number(row.dataset.filterIndex)];
|
|
3274
|
+
if (!filter) return;
|
|
3275
|
+
const key = input.dataset.builderField;
|
|
3276
|
+
filter[key] = input.value;
|
|
3277
|
+
if (key === "op") renderBuilderFilters();
|
|
3278
|
+
syncBuilderToQuery();
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
function updateBuilderMetric(event) {
|
|
3282
|
+
const input = event.target.closest("[data-metric-field]");
|
|
3283
|
+
const row = event.target.closest("[data-metric-index]");
|
|
3284
|
+
if (!input || !row) return;
|
|
3285
|
+
const metric = state.queryBuilder.aggregate.metrics[Number(row.dataset.metricIndex)];
|
|
3286
|
+
if (!metric) return;
|
|
3287
|
+
const key = input.dataset.metricField;
|
|
3288
|
+
metric[key] = input.value;
|
|
3289
|
+
if (key === "fn" && metric.fn === "$count") metric.field = "";
|
|
3290
|
+
if (key === "fn") renderBuilderMetrics();
|
|
3291
|
+
syncBuilderToQuery();
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
function syncBuilderToQuery() {
|
|
3295
|
+
const built = buildQueryFromBuilder();
|
|
3296
|
+
if (!built.ok) {
|
|
3297
|
+
$("builder-error").textContent = built.error;
|
|
3298
|
+
return false;
|
|
3299
|
+
}
|
|
3300
|
+
$("builder-error").textContent = "";
|
|
3301
|
+
$("query").value = JSON.stringify(built.query, null, 2);
|
|
3302
|
+
return true;
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
function buildQueryFromBuilder() {
|
|
3306
|
+
const filterQueries = [];
|
|
3307
|
+
for (const filter of state.queryBuilder.filters) {
|
|
3308
|
+
const field = String(filter.field || "").trim();
|
|
3309
|
+
if (!field) continue;
|
|
3310
|
+
try {
|
|
3311
|
+
filterQueries.push({ [field]: builderFilterValue(filter) });
|
|
3312
|
+
} catch (err) {
|
|
3313
|
+
return { ok: false, error: `${field}: ${err.message}` };
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
const query = {};
|
|
3318
|
+
if (filterQueries.length === 1 || state.queryBuilder.match === "and") {
|
|
3319
|
+
for (const part of filterQueries) mergeBuilderFilter(query, part);
|
|
3320
|
+
} else if (filterQueries.length > 1) {
|
|
3321
|
+
query.$or = filterQueries;
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
const aggregate = buildAggregateFromBuilder();
|
|
3325
|
+
if (!aggregate.ok) return aggregate;
|
|
3326
|
+
if (aggregate.value) query.$aggregate = aggregate.value;
|
|
3327
|
+
|
|
3328
|
+
return { ok: true, query };
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
function builderFilterValue(filter) {
|
|
3332
|
+
const value = parseBuilderValue(filter.value, filter.op);
|
|
3333
|
+
if (filter.op === "eq") return value;
|
|
3334
|
+
if (filter.op === "ne") return { $ne: value };
|
|
3335
|
+
if (filter.op === "contains") return { $contains: value };
|
|
3336
|
+
if (filter.op === "in") return { $in: Array.isArray(value) ? value : commaList(filter.value) };
|
|
3337
|
+
if (filter.op === "gt") return { $gt: value };
|
|
3338
|
+
if (filter.op === "gte") return { $gte: value };
|
|
3339
|
+
if (filter.op === "lt") return { $lt: value };
|
|
3340
|
+
if (filter.op === "lte") return { $lte: value };
|
|
3341
|
+
if (filter.op === "startsWith") return { $startsWith: value };
|
|
3342
|
+
if (filter.op === "exists") return { $exists: value };
|
|
3343
|
+
return value;
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
function parseBuilderValue(raw, op) {
|
|
3347
|
+
const value = String(raw ?? "").trim();
|
|
3348
|
+
if (op === "exists") return value === "" ? true : value !== "false";
|
|
3349
|
+
if (op === "in" && !value.startsWith("[")) return commaList(value);
|
|
3350
|
+
if (value === "") return "";
|
|
3351
|
+
try {
|
|
3352
|
+
return JSON.parse(value);
|
|
3353
|
+
} catch {
|
|
3354
|
+
return value;
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
function commaList(value) {
|
|
3359
|
+
return String(value || "")
|
|
3360
|
+
.split(",")
|
|
3361
|
+
.map((item) => parseBuilderValue(item.trim(), "eq"))
|
|
3362
|
+
.filter((item) => item !== "");
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
function mergeBuilderFilter(target, part) {
|
|
3366
|
+
const [[field, value]] = Object.entries(part);
|
|
3367
|
+
if (!isPlainObject(target[field]) || !isPlainObject(value)) {
|
|
3368
|
+
target[field] = value;
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
target[field] = { ...target[field], ...value };
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
function buildAggregateFromBuilder() {
|
|
3375
|
+
const metrics = state.queryBuilder.aggregate.metrics.filter((metric) => String(metric.alias || "").trim());
|
|
3376
|
+
const groupBy = String(state.queryBuilder.aggregate.groupBy || "").trim();
|
|
3377
|
+
if (!metrics.length && !groupBy) return { ok: true, value: null };
|
|
3378
|
+
if (!metrics.length) return { ok: false, error: "Aggregate needs at least one metric" };
|
|
3379
|
+
|
|
3380
|
+
const fields = {};
|
|
3381
|
+
for (const metric of metrics) {
|
|
3382
|
+
const alias = String(metric.alias || "").trim();
|
|
3383
|
+
const fn = metric.fn || "$count";
|
|
3384
|
+
const field = String(metric.field || "").trim();
|
|
3385
|
+
if (fn !== "$count" && !field) return { ok: false, error: `${alias}: metric field is required` };
|
|
3386
|
+
fields[alias] = fn === "$count" ? { $count: true } : { [fn]: field };
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
return {
|
|
3390
|
+
ok: true,
|
|
3391
|
+
value: {
|
|
3392
|
+
...(groupBy ? { groupBy } : {}),
|
|
3393
|
+
fields,
|
|
3394
|
+
},
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
function importQueryIntoBuilder() {
|
|
3399
|
+
const parsed = parseQueryValue();
|
|
3400
|
+
if (!parsed.ok) {
|
|
3401
|
+
$("builder-error").textContent = parsed.error;
|
|
3402
|
+
return;
|
|
3403
|
+
}
|
|
3404
|
+
const imported = queryToBuilder(parsed.value);
|
|
3405
|
+
state.queryBuilder = imported.builder;
|
|
3406
|
+
$("builder-error").textContent = imported.custom
|
|
3407
|
+
? "Some JSON could not be represented visually. The builder shows the parts it understands."
|
|
3408
|
+
: "";
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
function queryToBuilder(query) {
|
|
3412
|
+
const source = isPlainObject(query) ? query : {};
|
|
3413
|
+
const filters = [];
|
|
3414
|
+
let match = "and";
|
|
3415
|
+
|
|
3416
|
+
if (Array.isArray(source.$or)) {
|
|
3417
|
+
match = "or";
|
|
3418
|
+
for (const entry of source.$or) collectBuilderFilters(entry, filters);
|
|
3419
|
+
} else {
|
|
3420
|
+
collectBuilderFilters(source, filters);
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
const aggregate = aggregateToBuilder(source.$aggregate);
|
|
3424
|
+
const knownKeys = new Set(["$aggregate", "$or"]);
|
|
3425
|
+
for (const filter of filters) knownKeys.add(filter.field);
|
|
3426
|
+
const custom = Object.keys(source).some((key) => !knownKeys.has(key) && key.startsWith("$"));
|
|
3427
|
+
|
|
3428
|
+
return {
|
|
3429
|
+
custom,
|
|
3430
|
+
builder: {
|
|
3431
|
+
match,
|
|
3432
|
+
filters,
|
|
3433
|
+
aggregate,
|
|
3434
|
+
},
|
|
3435
|
+
};
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
function collectBuilderFilters(query, filters) {
|
|
3439
|
+
if (!isPlainObject(query)) return;
|
|
3440
|
+
for (const [field, value] of Object.entries(query)) {
|
|
3441
|
+
if (field.startsWith("$")) continue;
|
|
3442
|
+
filters.push(valueToBuilderFilter(field, value));
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
function valueToBuilderFilter(field, value) {
|
|
3447
|
+
if (!isPlainObject(value)) return { field, op: "eq", value: builderValueText(value) };
|
|
3448
|
+
const entries = Object.entries(value);
|
|
3449
|
+
if (entries.length !== 1) return { field, op: "eq", value: builderValueText(value) };
|
|
3450
|
+
const [[op, raw]] = entries;
|
|
3451
|
+
const reverse = {
|
|
3452
|
+
$ne: "ne",
|
|
3453
|
+
$contains: "contains",
|
|
3454
|
+
$in: "in",
|
|
3455
|
+
$gt: "gt",
|
|
3456
|
+
$gte: "gte",
|
|
3457
|
+
$lt: "lt",
|
|
3458
|
+
$lte: "lte",
|
|
3459
|
+
$startsWith: "startsWith",
|
|
3460
|
+
$exists: "exists",
|
|
3461
|
+
};
|
|
3462
|
+
return { field, op: reverse[op] || "eq", value: builderValueText(raw) };
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
function aggregateToBuilder(spec) {
|
|
3466
|
+
if (!isPlainObject(spec)) return { groupBy: "", metrics: [] };
|
|
3467
|
+
const metrics = [];
|
|
3468
|
+
const fields = isPlainObject(spec.fields) ? spec.fields : {};
|
|
3469
|
+
for (const [alias, config] of Object.entries(fields)) {
|
|
3470
|
+
if (!isPlainObject(config)) continue;
|
|
3471
|
+
const entry = Object.entries(config)[0];
|
|
3472
|
+
if (!entry) continue;
|
|
3473
|
+
const [fn, value] = entry;
|
|
3474
|
+
metrics.push({
|
|
3475
|
+
alias,
|
|
3476
|
+
fn,
|
|
3477
|
+
field: fn === "$count" ? "" : String(value || ""),
|
|
3478
|
+
});
|
|
3479
|
+
}
|
|
3480
|
+
return {
|
|
3481
|
+
groupBy: typeof spec.groupBy === "string" ? spec.groupBy : "",
|
|
3482
|
+
metrics,
|
|
3483
|
+
};
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
function builderValueText(value) {
|
|
3487
|
+
if (typeof value === "string") return value;
|
|
3488
|
+
if (value === undefined) return "";
|
|
3489
|
+
return JSON.stringify(value);
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
function parseQueryValue() {
|
|
3493
|
+
const raw = $("query").value.trim();
|
|
3494
|
+
if (!raw) return { ok: true, value: {} };
|
|
3495
|
+
try {
|
|
3496
|
+
const value = JSON.parse(raw);
|
|
3497
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
3498
|
+
return { ok: false, error: "Query must be a JSON object" };
|
|
3499
|
+
}
|
|
3500
|
+
return { ok: true, value };
|
|
3501
|
+
} catch (err) {
|
|
3502
|
+
return { ok: false, error: err.message };
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
|
|
3506
|
+
function escapeAttr(value) {
|
|
3507
|
+
return escapeHtml(String(value ?? "")).replace(/"/g, """);
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
function parseQuery() {
|
|
3511
|
+
if (state.queryMode === "builder" && !syncBuilderToQuery()) return { ok: false, error: $("builder-error").textContent };
|
|
3512
|
+
const parsed = parseQueryValue();
|
|
3513
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
3514
|
+
return parsed;
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
async function loadObjects({ selectId = null } = {}) {
|
|
3518
|
+
if (!state.active) return;
|
|
3519
|
+
const parsed = parseQuery();
|
|
3520
|
+
if (!parsed.ok) return showToast(`Invalid query: ${parsed.error}`, true);
|
|
3521
|
+
const limit = objectLimit();
|
|
3522
|
+
const offset = Math.max(0, state.objectPage - 1) * limit;
|
|
3523
|
+
$("objects").innerHTML = `<div class="empty">Loading objects...</div>`;
|
|
3524
|
+
updateObjectPaging(true);
|
|
3525
|
+
const result = await run(connection => activeCollection(connection).search(parsed.value, { limit, offset }));
|
|
3526
|
+
if (result.stale) return;
|
|
3527
|
+
if (!result.ok) {
|
|
3528
|
+
$("objects").innerHTML = `<div class="error">${escapeHtml(result.body?.error || "Could not load objects")}</div>`;
|
|
3529
|
+
$("object-count").textContent = "0 shown";
|
|
3530
|
+
state.objects = [];
|
|
3531
|
+
state.objectHasMore = false;
|
|
3532
|
+
state.selected = null;
|
|
3533
|
+
renderDetail(null);
|
|
3534
|
+
updateObjectPaging(false);
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
state.objects = Array.isArray(result.body) ? result.body : [];
|
|
3538
|
+
state.objectHasMore = state.objects.length === limit;
|
|
3539
|
+
$("object-count").textContent = objectRangeText(offset, state.objects.length);
|
|
3540
|
+
renderObjects();
|
|
3541
|
+
updateObjectPaging(false);
|
|
3542
|
+
const selected = selectId === null
|
|
3543
|
+
? null
|
|
3544
|
+
: state.objects.find((object) => String(object._id) === String(selectId));
|
|
3545
|
+
if (selected) showObject(selected);
|
|
3546
|
+
else if (state.objects.length) showObject(state.objects[0]);
|
|
3547
|
+
else renderDetail(null);
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
function renderObjects() {
|
|
3551
|
+
const root = $("objects");
|
|
3552
|
+
if (!state.active) {
|
|
3553
|
+
root.innerHTML = `<div class="empty">Pick a collection from the tree.</div>`;
|
|
3554
|
+
$("object-count").textContent = "0 shown";
|
|
3555
|
+
updateObjectPaging(false);
|
|
3556
|
+
return;
|
|
3557
|
+
}
|
|
3558
|
+
if (!state.objects.length) {
|
|
3559
|
+
root.innerHTML = `<div class="empty">No matching objects.</div>`;
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
root.innerHTML = "";
|
|
3564
|
+
for (const object of state.objects) {
|
|
3565
|
+
const card = document.createElement("button");
|
|
3566
|
+
card.type = "button";
|
|
3567
|
+
card.className = "object-card" + (state.selected?._id === object._id ? " active" : "");
|
|
3568
|
+
card.innerHTML = `
|
|
3569
|
+
<div class="object-id"><svg viewBox="0 0 24 24"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h10"/></svg><span></span><span class="file-chip" title="Attached file" hidden>file</span></div>
|
|
3570
|
+
<div class="object-preview"></div>
|
|
3571
|
+
`;
|
|
3572
|
+
card.querySelector(".object-id span").textContent = object._id || "";
|
|
3573
|
+
card.querySelector(".file-chip").hidden = !fileMetadata(object);
|
|
3574
|
+
card.querySelector(".object-preview").textContent = previewObject(object);
|
|
3575
|
+
card.addEventListener("click", () => showObject(object));
|
|
3576
|
+
root.appendChild(card);
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
function objectLimit() {
|
|
3581
|
+
const value = Number($("limit").value);
|
|
3582
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 50;
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
function objectRangeText(offset, count) {
|
|
3586
|
+
if (!count) return "0 shown";
|
|
3587
|
+
return `${offset + 1}-${offset + count} shown`;
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
function updateObjectPaging(loading = false) {
|
|
3591
|
+
const hasCollection = !!state.active;
|
|
3592
|
+
$("object-page").textContent = `page ${state.objectPage}`;
|
|
3593
|
+
$("prev-page-btn").disabled = loading || !hasCollection || state.objectPage <= 1;
|
|
3594
|
+
$("next-page-btn").disabled = loading || !hasCollection || !state.objectHasMore;
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
async function changeObjectPage(delta) {
|
|
3598
|
+
if (!state.active) return;
|
|
3599
|
+
const next = Math.max(1, state.objectPage + delta);
|
|
3600
|
+
if (next === state.objectPage) return;
|
|
3601
|
+
if (delta > 0 && !state.objectHasMore) return;
|
|
3602
|
+
state.objectPage = next;
|
|
3603
|
+
await loadObjects();
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
async function reloadObjectsFromFirstPage() {
|
|
3607
|
+
state.objectPage = 1;
|
|
3608
|
+
state.objectHasMore = false;
|
|
3609
|
+
await loadObjects();
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
function previewObject(object) {
|
|
3613
|
+
const entries = Object.entries(object || {}).filter(([key]) => key !== "_id" && key !== "_file").slice(0, 4);
|
|
3614
|
+
if (!entries.length) return "{}";
|
|
3615
|
+
return entries.map(([key, value]) => `${key}: ${shortValue(value)}`).join(", ");
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
function shortValue(value) {
|
|
3619
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
3620
|
+
return String(text).length > 46 ? String(text).slice(0, 43) + "..." : String(text);
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
function showObject(object) {
|
|
3624
|
+
state.selected = object;
|
|
3625
|
+
renderObjects();
|
|
3626
|
+
renderDetail(object);
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
async function reloadObject() {
|
|
3630
|
+
if (!state.active || !state.selected?._id) return;
|
|
3631
|
+
const result = await run(connection => activeCollection(connection).get(state.selected._id));
|
|
3632
|
+
if (result.stale) return;
|
|
3633
|
+
if (!result.ok) return showToast(result.body?.error || "Could not reload object", true);
|
|
3634
|
+
showObject(result.body);
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
function renderDetail(object) {
|
|
3638
|
+
$("detail-title").textContent = object?._id ? `Object ${object._id}` : "Object";
|
|
3639
|
+
renderActionState();
|
|
3640
|
+
if (!object) {
|
|
3641
|
+
$("detail").innerHTML = `<div class="empty">Select an object.</div>`;
|
|
3642
|
+
return;
|
|
3643
|
+
}
|
|
3644
|
+
const file = fileMetadata(object);
|
|
3645
|
+
const filePanel = file
|
|
3646
|
+
? `<div class="detail-file">
|
|
3647
|
+
<div>
|
|
3648
|
+
<strong>${escapeHtml(fileDisplayName(object))}</strong>
|
|
3649
|
+
<small>${escapeHtml(file.mimeType || "application/octet-stream")}</small>
|
|
3650
|
+
</div>
|
|
3651
|
+
<button type="button" class="secondary" data-open-file>
|
|
3652
|
+
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M12 18v-6"/><path d="m9 15 3 3 3-3"/></svg>
|
|
3653
|
+
Open
|
|
3654
|
+
</button>
|
|
3655
|
+
</div>`
|
|
3656
|
+
: "";
|
|
3657
|
+
$("detail").innerHTML = `${filePanel}<div class="json-box"><pre>${highlightJson(object)}</pre></div>`;
|
|
3658
|
+
$("detail").querySelector("[data-open-file]")?.addEventListener("click", openSelectedFile);
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
function highlightJson(value) {
|
|
3662
|
+
return escapeHtml(JSON.stringify(value, null, 2)).replace(
|
|
3663
|
+
/("(?:\\.|[^\\])*?")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g,
|
|
3664
|
+
(match, str, colon) => {
|
|
3665
|
+
if (str) return colon ? `<span class="j-key">${str}</span>${colon}` : `<span class="j-str">${str}</span>`;
|
|
3666
|
+
if (match === "true" || match === "false") return `<span class="j-bool">${match}</span>`;
|
|
3667
|
+
if (match === "null") return `<span class="j-null">${match}</span>`;
|
|
3668
|
+
return `<span class="j-num">${match}</span>`;
|
|
3669
|
+
},
|
|
3670
|
+
);
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
function formatQuery() {
|
|
3674
|
+
if (state.queryMode === "builder" && !syncBuilderToQuery()) return;
|
|
3675
|
+
const raw = $("query").value.trim();
|
|
3676
|
+
if (!raw) {
|
|
3677
|
+
$("query").value = "{}";
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
try {
|
|
3681
|
+
$("query").value = JSON.stringify(JSON.parse(raw), null, 2);
|
|
3682
|
+
} catch (err) {
|
|
3683
|
+
showToast(`Invalid query: ${err.message}`, true);
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3687
|
+
async function openSelectedFile() {
|
|
3688
|
+
if (!state.active || !state.selected?._id || !fileMetadata(state.selected)) {
|
|
3689
|
+
return showToast("Selected object has no file", true);
|
|
3690
|
+
}
|
|
3691
|
+
const target = window.open("about:blank", "_blank");
|
|
3692
|
+
const id = state.selected._id;
|
|
3693
|
+
const result = await run(async connection => {
|
|
3694
|
+
const response = await activeCollection(connection).download(id);
|
|
3695
|
+
return response.blob();
|
|
3696
|
+
});
|
|
3697
|
+
if (result.stale) { target?.close(); return; }
|
|
3698
|
+
if (!result.ok) {
|
|
3699
|
+
target?.close();
|
|
3700
|
+
return showToast(result.body?.error || "Could not open file", true);
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
const url = URL.createObjectURL(result.body);
|
|
3704
|
+
if (target) {
|
|
3705
|
+
target.opener = null;
|
|
3706
|
+
target.location.href = url;
|
|
3707
|
+
} else {
|
|
3708
|
+
const link = document.createElement("a");
|
|
3709
|
+
link.href = url;
|
|
3710
|
+
link.target = "_blank";
|
|
3711
|
+
link.rel = "noopener";
|
|
3712
|
+
document.body.appendChild(link);
|
|
3713
|
+
link.click();
|
|
3714
|
+
link.remove();
|
|
3715
|
+
}
|
|
3716
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
3717
|
+
showToast("File opened");
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
function openDatabaseModal() {
|
|
3721
|
+
if (!client.getSession()?.accessToken) return showToast("Sign in first", true);
|
|
3722
|
+
$("db-name").value = "";
|
|
3723
|
+
setJsonField("db-meta-json", {});
|
|
3724
|
+
openModal("db-modal", "db-name");
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
async function saveDatabase() {
|
|
3728
|
+
clearModalError("db-modal");
|
|
3729
|
+
const name = $("db-name").value.trim();
|
|
3730
|
+
if (!name) return setModalError("db-modal", "Database name is required");
|
|
3731
|
+
const parsed = parseJsonField("db-meta-json", "Database meta");
|
|
3732
|
+
if (!parsed.ok) return setModalError("db-modal", parsed.error);
|
|
3733
|
+
|
|
3734
|
+
const result = await run(connection => connection.createDatabase(name, { meta: parsed.value }));
|
|
3735
|
+
if (result.stale) return;
|
|
3736
|
+
if (!result.ok) return setModalError("db-modal", result.body?.error || "Could not save database");
|
|
3737
|
+
closeModal("db-modal");
|
|
3738
|
+
showToast(result.body?.created ? "Database created" : "Database updated");
|
|
3739
|
+
await loadTree();
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
function openCollectionModal() {
|
|
3743
|
+
if (!client.getSession()?.accessToken) return showToast("Sign in first", true);
|
|
3744
|
+
$("collection-db").value = state.active?.db || state.authDb || "_global";
|
|
3745
|
+
$("collection-name").value = "";
|
|
3746
|
+
setJsonField("collection-meta-json", { indexes: [] });
|
|
3747
|
+
openModal("collection-modal", "collection-name");
|
|
3748
|
+
}
|
|
3749
|
+
|
|
3750
|
+
async function saveCollection() {
|
|
3751
|
+
clearModalError("collection-modal");
|
|
3752
|
+
const db = $("collection-db").value.trim();
|
|
3753
|
+
const name = $("collection-name").value.trim();
|
|
3754
|
+
if (!db) return setModalError("collection-modal", "Database name is required");
|
|
3755
|
+
if (!name) return setModalError("collection-modal", "Collection name is required");
|
|
3756
|
+
const parsed = parseJsonField("collection-meta-json", "Collection meta");
|
|
3757
|
+
if (!parsed.ok) return setModalError("collection-modal", parsed.error);
|
|
3758
|
+
|
|
3759
|
+
const result = await run(connection => connection.database(db).createCollection(name, parsed.value));
|
|
3760
|
+
if (result.stale) return;
|
|
3761
|
+
if (!result.ok) return setModalError("collection-modal", result.body?.error || "Could not save collection");
|
|
3762
|
+
closeModal("collection-modal");
|
|
3763
|
+
showToast(result.body?.created ? "Collection created" : "Collection updated");
|
|
3764
|
+
state.active = { db, collection: name };
|
|
3765
|
+
await loadTree();
|
|
3766
|
+
await selectCollection(db, name);
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
async function openMetaModal() {
|
|
3770
|
+
if (!state.active) return showToast("Select a collection first", true);
|
|
3771
|
+
if (state.collectionMeta === null) await loadCollectionMeta();
|
|
3772
|
+
setJsonField("meta-json", state.collectionMeta || {});
|
|
3773
|
+
openModal("meta-modal", "meta-json");
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
async function saveCollectionMeta() {
|
|
3777
|
+
if (!state.active) return;
|
|
3778
|
+
clearModalError("meta-modal");
|
|
3779
|
+
const parsed = parseJsonField("meta-json", "Collection meta");
|
|
3780
|
+
if (!parsed.ok) return setModalError("meta-modal", parsed.error);
|
|
3781
|
+
|
|
3782
|
+
const result = await run(connection => activeCollection(connection).setMeta(parsed.value));
|
|
3783
|
+
if (result.stale) return;
|
|
3784
|
+
if (!result.ok) return setModalError("meta-modal", result.body?.error || "Could not save collection meta");
|
|
3785
|
+
state.collectionMeta = result.body || parsed.value;
|
|
3786
|
+
closeModal("meta-modal");
|
|
3787
|
+
showToast("Collection meta saved");
|
|
3788
|
+
await loadCollectionMeta();
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
function openObjectModal(mode) {
|
|
3792
|
+
if (!state.active) return showToast("Select a collection first", true);
|
|
3793
|
+
state.objectMode = mode;
|
|
3794
|
+
clearModalError("object-modal");
|
|
3795
|
+
$("object-modal-title").textContent = mode === "edit" ? "Edit Object" : "Add Object";
|
|
3796
|
+
$("object-id").disabled = mode === "edit";
|
|
3797
|
+
$("object-file").value = "";
|
|
3798
|
+
|
|
3799
|
+
if (mode === "edit") {
|
|
3800
|
+
if (!state.selected?._id) return showToast("Select an object first", true);
|
|
3801
|
+
$("object-id").value = state.selected._id;
|
|
3802
|
+
setJsonField("object-json", state.selected);
|
|
3803
|
+
} else {
|
|
3804
|
+
$("object-id").value = "";
|
|
3805
|
+
setJsonField("object-json", {});
|
|
3806
|
+
}
|
|
3807
|
+
updateObjectFileSummary();
|
|
3808
|
+
openModal("object-modal", mode === "edit" ? "object-json" : "object-id");
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
async function saveObject() {
|
|
3812
|
+
if (!state.active) return;
|
|
3813
|
+
clearModalError("object-modal");
|
|
3814
|
+
const parsed = parseJsonField("object-json", "Object JSON", { allowArray: state.objectMode === "add" });
|
|
3815
|
+
if (!parsed.ok) return setModalError("object-modal", parsed.error);
|
|
3816
|
+
const file = selectedObjectFile();
|
|
3817
|
+
|
|
3818
|
+
if (state.objectMode === "edit") {
|
|
3819
|
+
if (!state.selected?._id) return setModalError("object-modal", "Selected object is missing");
|
|
3820
|
+
if (!isPlainObject(parsed.value)) return setModalError("object-modal", "Object JSON must be a plain object");
|
|
3821
|
+
const id = state.selected._id;
|
|
3822
|
+
const object = { ...parsed.value, _id: id };
|
|
3823
|
+
const result = await run(connection => {
|
|
3824
|
+
const items = activeCollection(connection);
|
|
3825
|
+
return file ? items.replaceFile(id, withoutFileMetadata(object), file) : items.replace(id, object);
|
|
3826
|
+
});
|
|
3827
|
+
if (result.stale) return;
|
|
3828
|
+
if (!result.ok) return setModalError("object-modal", result.body?.error || "Could not save object");
|
|
3829
|
+
closeModal("object-modal");
|
|
3830
|
+
showToast("Object saved");
|
|
3831
|
+
await loadObjects({ selectId: id });
|
|
3832
|
+
return;
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
const explicitId = $("object-id").value.trim();
|
|
3836
|
+
let body = parsed.value;
|
|
3837
|
+
if (Array.isArray(body)) {
|
|
3838
|
+
if (file) return setModalError("object-modal", "Files can only be uploaded with a single object");
|
|
3839
|
+
if (explicitId) return setModalError("object-modal", "Object _id is only for single-object inserts");
|
|
3840
|
+
const badIndex = body.findIndex((item) => !isPlainObject(item));
|
|
3841
|
+
if (badIndex >= 0) return setModalError("object-modal", `Item at index ${badIndex} must be a plain object`);
|
|
3842
|
+
} else if (explicitId) {
|
|
3843
|
+
body = { ...body, _id: explicitId };
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
const result = await run(connection => {
|
|
3847
|
+
const items = activeCollection(connection);
|
|
3848
|
+
if (file) return items.insertFile(withoutFileMetadata(body), file);
|
|
3849
|
+
return Array.isArray(body) ? items.insertMany(body) : items.insert(body);
|
|
3850
|
+
});
|
|
3851
|
+
if (result.stale) return;
|
|
3852
|
+
if (!result.ok) return setModalError("object-modal", result.body?.error || "Could not save object");
|
|
3853
|
+
const newId = Array.isArray(body) ? result.body?.items?.[0]?._id : result.body?._id;
|
|
3854
|
+
closeModal("object-modal");
|
|
3855
|
+
showToast(Array.isArray(body) ? `${result.body?.inserted ?? body.length} objects added` : "Object added");
|
|
3856
|
+
await loadObjects({ selectId: newId || null });
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
function selectedObjectFile() {
|
|
3860
|
+
return $("object-file")?.files?.[0] || null;
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
function updateObjectFileSummary() {
|
|
3864
|
+
const summary = $("object-file-summary");
|
|
3865
|
+
const file = selectedObjectFile();
|
|
3866
|
+
const current = state.objectMode === "edit" ? fileMetadata(state.selected) : null;
|
|
3867
|
+
if (file) {
|
|
3868
|
+
summary.textContent = `${file.name || "blob"} (${formatMemory(file.size)})`;
|
|
3869
|
+
summary.classList.add("has-file");
|
|
3870
|
+
return;
|
|
3871
|
+
}
|
|
3872
|
+
if (current) {
|
|
3873
|
+
summary.textContent = `${current.mimeType || "application/octet-stream"} attached`;
|
|
3874
|
+
summary.classList.add("has-file");
|
|
3875
|
+
return;
|
|
3876
|
+
}
|
|
3877
|
+
summary.textContent = "No file selected";
|
|
3878
|
+
summary.classList.remove("has-file");
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
function withoutFileMetadata(object) {
|
|
3882
|
+
const { _file, ...rest } = object || {};
|
|
3883
|
+
return rest;
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
function fileMetadata(object) {
|
|
3887
|
+
return isPlainObject(object?._file) ? object._file : null;
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
function fileDisplayName(object) {
|
|
3891
|
+
const value = object?.name || object?.filename || object?.title || object?._id || "blob";
|
|
3892
|
+
return String(value).replace(/[\\/:*?"<>|]+/g, "_") || "blob";
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
function openDeleteObjectModal() {
|
|
3896
|
+
if (!state.selected?._id) return showToast("Select an object first", true);
|
|
3897
|
+
clearModalError("delete-object-modal");
|
|
3898
|
+
$("delete-object-name").textContent = state.selected._id;
|
|
3899
|
+
openModal("delete-object-modal", "delete-object-confirm");
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
async function deleteSelectedObject() {
|
|
3903
|
+
if (!state.active || !state.selected?._id) return;
|
|
3904
|
+
clearModalError("delete-object-modal");
|
|
3905
|
+
const id = state.selected._id;
|
|
3906
|
+
const result = await run(connection => activeCollection(connection).remove(id));
|
|
3907
|
+
if (result.stale) return;
|
|
3908
|
+
if (!result.ok) return setModalError("delete-object-modal", result.body?.error || "Could not delete object");
|
|
3909
|
+
closeModal("delete-object-modal");
|
|
3910
|
+
showToast("Object deleted");
|
|
3911
|
+
state.selected = null;
|
|
3912
|
+
await loadObjects();
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
function openDeleteDatabaseModal(db) {
|
|
3916
|
+
if (!db) return;
|
|
3917
|
+
state.deleteTarget = { type: "database", db };
|
|
3918
|
+
clearModalError("delete-collection-modal");
|
|
3919
|
+
$("delete-collection-title").textContent = "Delete Database";
|
|
3920
|
+
$("delete-collection-name").textContent = db;
|
|
3921
|
+
openModal("delete-collection-modal", "delete-collection-confirm");
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
function openDeleteCollectionModal(db = state.active?.db, collection = state.active?.collection) {
|
|
3925
|
+
if (!db || !collection) return showToast("Select a collection first", true);
|
|
3926
|
+
state.deleteTarget = { type: "collection", db, collection };
|
|
3927
|
+
clearModalError("delete-collection-modal");
|
|
3928
|
+
$("delete-collection-title").textContent = "Delete Collection";
|
|
3929
|
+
$("delete-collection-name").textContent = `${db}/${collection}`;
|
|
3930
|
+
openModal("delete-collection-modal", "delete-collection-confirm");
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
async function deleteSelectedContext() {
|
|
3934
|
+
if (!state.deleteTarget) return;
|
|
3935
|
+
clearModalError("delete-collection-modal");
|
|
3936
|
+
const target = state.deleteTarget;
|
|
3937
|
+
const result = await run(connection => {
|
|
3938
|
+
const db = connection.database(target.db);
|
|
3939
|
+
return target.type === "database" ? db.drop() : db.collection(target.collection).drop();
|
|
3940
|
+
});
|
|
3941
|
+
if (result.stale) return;
|
|
3942
|
+
if (!result.ok) {
|
|
3943
|
+
return setModalError("delete-collection-modal", result.body?.error || `Could not delete ${target.type}`);
|
|
3944
|
+
}
|
|
3945
|
+
closeModal("delete-collection-modal");
|
|
3946
|
+
showToast(target.type === "database" ? "Database deleted" : "Collection deleted");
|
|
3947
|
+
if (target.type === "database" || (state.active?.db === target.db && state.active?.collection === target.collection)) {
|
|
3948
|
+
state.active = null;
|
|
3949
|
+
state.collectionMeta = null;
|
|
3950
|
+
state.objects = [];
|
|
3951
|
+
state.objectPage = 1;
|
|
3952
|
+
state.objectHasMore = false;
|
|
3953
|
+
state.selected = null;
|
|
3954
|
+
setBreadcrumb();
|
|
3955
|
+
setMeta("");
|
|
3956
|
+
renderObjects();
|
|
3957
|
+
renderDetail(null);
|
|
3958
|
+
}
|
|
3959
|
+
state.deleteTarget = null;
|
|
3960
|
+
state.cluster = null;
|
|
3961
|
+
state.clusterDetails = null;
|
|
3962
|
+
await Promise.all([loadTree(), loadClusterStatus()]);
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3965
|
+
function parseJsonField(id, label, { allowArray = false } = {}) {
|
|
3966
|
+
const raw = $(id).value.trim();
|
|
3967
|
+
let value;
|
|
3968
|
+
try {
|
|
3969
|
+
value = raw ? JSON.parse(raw) : {};
|
|
3970
|
+
} catch (err) {
|
|
3971
|
+
return { ok: false, error: `${label}: ${err.message}` };
|
|
3972
|
+
}
|
|
3973
|
+
if (allowArray && Array.isArray(value)) return { ok: true, value };
|
|
3974
|
+
if (!isPlainObject(value)) return { ok: false, error: `${label} must be a plain object` };
|
|
3975
|
+
return { ok: true, value };
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
function setJsonField(id, value) {
|
|
3979
|
+
$(id).value = JSON.stringify(value, null, 2);
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
function isPlainObject(value) {
|
|
3983
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
function openModal(id, focusId) {
|
|
3987
|
+
const modal = $(id);
|
|
3988
|
+
modal.classList.add("open");
|
|
3989
|
+
const target = focusId ? $(focusId) : modal.querySelector("input, textarea, button");
|
|
3990
|
+
target?.focus();
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
function closeModal(id) {
|
|
3994
|
+
const modal = $(id);
|
|
3995
|
+
if (!modal) return;
|
|
3996
|
+
modal.classList.remove("open");
|
|
3997
|
+
clearModalError(id);
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
function closeAllModals() {
|
|
4001
|
+
document.querySelectorAll(".modal-backdrop.open").forEach((modal) => closeModal(modal.id));
|
|
4002
|
+
}
|
|
4003
|
+
|
|
4004
|
+
function setModalError(modalId, message) {
|
|
4005
|
+
const el = $(`${modalId}-error`);
|
|
4006
|
+
if (!el) return showToast(message, true);
|
|
4007
|
+
el.textContent = message;
|
|
4008
|
+
el.classList.add("show");
|
|
4009
|
+
}
|
|
4010
|
+
|
|
4011
|
+
function clearModalError(modalId) {
|
|
4012
|
+
const el = $(`${modalId}-error`);
|
|
4013
|
+
if (!el) return;
|
|
4014
|
+
el.textContent = "";
|
|
4015
|
+
el.classList.remove("show");
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
function escapeHtml(value) {
|
|
4019
|
+
return String(value)
|
|
4020
|
+
.replace(/&/g, "&")
|
|
4021
|
+
.replace(/</g, "<")
|
|
4022
|
+
.replace(/>/g, ">")
|
|
4023
|
+
.replace(/"/g, """)
|
|
4024
|
+
.replace(/'/g, "'");
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
let toastTimer = null;
|
|
4028
|
+
function showToast(message, bad = false) {
|
|
4029
|
+
const el = $("toast");
|
|
4030
|
+
el.textContent = message;
|
|
4031
|
+
el.className = "toast show" + (bad ? " bad" : "");
|
|
4032
|
+
clearTimeout(toastTimer);
|
|
4033
|
+
toastTimer = setTimeout(() => { el.className = "toast"; }, 2600);
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
$("server-label").addEventListener("click", editServer);
|
|
4037
|
+
$("health-btn").addEventListener("click", checkHealth);
|
|
4038
|
+
$("api-docs-btn").addEventListener("click", openApiDocs);
|
|
4039
|
+
$("login-btn").addEventListener("click", login);
|
|
4040
|
+
$("password").addEventListener("keydown", (event) => { if (event.key === "Enter") login(); });
|
|
4041
|
+
$("renew-btn").addEventListener("click", renewSession);
|
|
4042
|
+
$("logout-btn").addEventListener("click", () => clearSession());
|
|
4043
|
+
$("refresh-tree-btn").addEventListener("click", loadTree);
|
|
4044
|
+
$("refresh-cluster-btn").addEventListener("click", loadClusterStatus);
|
|
4045
|
+
$("cluster-status").addEventListener("click", (event) => {
|
|
4046
|
+
if (event.target.closest("[data-open-cluster]")) openClusterStatsModal();
|
|
4047
|
+
});
|
|
4048
|
+
$("add-db-btn").addEventListener("click", openDatabaseModal);
|
|
4049
|
+
$("add-coll-btn").addEventListener("click", openCollectionModal);
|
|
4050
|
+
$("edit-meta-btn").addEventListener("click", openMetaModal);
|
|
4051
|
+
$("load-objects-btn").addEventListener("click", reloadObjectsFromFirstPage);
|
|
4052
|
+
$("format-query-btn").addEventListener("click", formatQuery);
|
|
4053
|
+
$("query-json-mode-btn").addEventListener("click", () => setQueryMode("json"));
|
|
4054
|
+
$("query-builder-mode-btn").addEventListener("click", () => setQueryMode("builder"));
|
|
4055
|
+
$("builder-match").addEventListener("change", (event) => {
|
|
4056
|
+
state.queryBuilder.match = event.target.value === "or" ? "or" : "and";
|
|
4057
|
+
syncBuilderToQuery();
|
|
4058
|
+
});
|
|
4059
|
+
$("builder-group-by").addEventListener("input", (event) => {
|
|
4060
|
+
state.queryBuilder.aggregate.groupBy = event.target.value;
|
|
4061
|
+
syncBuilderToQuery();
|
|
4062
|
+
});
|
|
4063
|
+
$("builder-add-filter").addEventListener("click", () => {
|
|
4064
|
+
state.queryBuilder.filters.push(newBuilderFilter());
|
|
4065
|
+
renderBuilderFilters();
|
|
4066
|
+
syncBuilderToQuery();
|
|
4067
|
+
});
|
|
4068
|
+
$("builder-add-metric").addEventListener("click", () => {
|
|
4069
|
+
state.queryBuilder.aggregate.metrics.push(newBuilderMetric());
|
|
4070
|
+
renderBuilderMetrics();
|
|
4071
|
+
syncBuilderToQuery();
|
|
4072
|
+
});
|
|
4073
|
+
$("builder-filters").addEventListener("input", updateBuilderFilter);
|
|
4074
|
+
$("builder-filters").addEventListener("change", updateBuilderFilter);
|
|
4075
|
+
$("builder-filters").addEventListener("click", (event) => {
|
|
4076
|
+
const button = event.target.closest("[data-remove-filter]");
|
|
4077
|
+
if (!button) return;
|
|
4078
|
+
state.queryBuilder.filters.splice(Number(button.dataset.removeFilter), 1);
|
|
4079
|
+
renderBuilderFilters();
|
|
4080
|
+
syncBuilderToQuery();
|
|
4081
|
+
});
|
|
4082
|
+
$("builder-metrics").addEventListener("input", updateBuilderMetric);
|
|
4083
|
+
$("builder-metrics").addEventListener("change", updateBuilderMetric);
|
|
4084
|
+
$("builder-metrics").addEventListener("click", (event) => {
|
|
4085
|
+
const button = event.target.closest("[data-remove-metric]");
|
|
4086
|
+
if (!button) return;
|
|
4087
|
+
state.queryBuilder.aggregate.metrics.splice(Number(button.dataset.removeMetric), 1);
|
|
4088
|
+
renderBuilderMetrics();
|
|
4089
|
+
syncBuilderToQuery();
|
|
4090
|
+
});
|
|
4091
|
+
$("limit").addEventListener("change", reloadObjectsFromFirstPage);
|
|
4092
|
+
$("tree-filter").addEventListener("input", (event) => {
|
|
4093
|
+
state.treeFilter = event.target.value;
|
|
4094
|
+
renderTree();
|
|
4095
|
+
});
|
|
4096
|
+
$("prev-page-btn").addEventListener("click", () => changeObjectPage(-1));
|
|
4097
|
+
$("next-page-btn").addEventListener("click", () => changeObjectPage(1));
|
|
4098
|
+
$("add-object-btn").addEventListener("click", () => openObjectModal("add"));
|
|
4099
|
+
$("edit-object-btn").addEventListener("click", () => openObjectModal("edit"));
|
|
4100
|
+
$("delete-object-btn").addEventListener("click", openDeleteObjectModal);
|
|
4101
|
+
$("download-file-btn").addEventListener("click", openSelectedFile);
|
|
4102
|
+
$("reload-object-btn").addEventListener("click", reloadObject);
|
|
4103
|
+
$("object-file").addEventListener("change", updateObjectFileSummary);
|
|
4104
|
+
$("db-modal-save").addEventListener("click", saveDatabase);
|
|
4105
|
+
$("collection-modal-save").addEventListener("click", saveCollection);
|
|
4106
|
+
$("meta-modal-save").addEventListener("click", saveCollectionMeta);
|
|
4107
|
+
$("object-modal-save").addEventListener("click", saveObject);
|
|
4108
|
+
$("delete-object-confirm").addEventListener("click", deleteSelectedObject);
|
|
4109
|
+
$("delete-collection-confirm").addEventListener("click", deleteSelectedContext);
|
|
4110
|
+
|
|
4111
|
+
document.querySelectorAll("[data-close-modal]").forEach((button) => {
|
|
4112
|
+
button.addEventListener("click", () => closeModal(button.dataset.closeModal));
|
|
4113
|
+
});
|
|
4114
|
+
|
|
4115
|
+
document.querySelectorAll(".modal-backdrop").forEach((modal) => {
|
|
4116
|
+
modal.addEventListener("mousedown", (event) => {
|
|
4117
|
+
if (event.target === modal) closeModal(modal.id);
|
|
4118
|
+
});
|
|
4119
|
+
});
|
|
4120
|
+
|
|
4121
|
+
document.addEventListener("keydown", (event) => {
|
|
4122
|
+
if (event.key === "Escape") closeAllModals();
|
|
4123
|
+
});
|
|
4124
|
+
|
|
4125
|
+
loadSession();
|
|
4126
|
+
renderQueryMode();
|
|
4127
|
+
checkHealth();
|
|
4128
|
+
if (client.getSession()?.accessToken) {
|
|
4129
|
+
loadTree();
|
|
4130
|
+
loadClusterStatus();
|
|
4131
|
+
}
|
|
4132
|
+
</script>
|
|
4133
|
+
</body>
|
|
4134
|
+
</html>
|