@bod.ee/db 0.7.0 → 0.8.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/.claude/settings.local.json +7 -1
- package/.claude/skills/config-file.md +7 -0
- package/.claude/skills/deploying-bod-db.md +34 -0
- package/.claude/skills/developing-bod-db.md +20 -2
- package/.claude/skills/using-bod-db.md +165 -0
- package/.github/workflows/test-and-publish.yml +111 -0
- package/CLAUDE.md +10 -1
- package/README.md +57 -2
- package/admin/proxy.ts +79 -0
- package/admin/rules.ts +1 -1
- package/admin/server.ts +134 -50
- package/admin/ui.html +729 -18
- package/cli.ts +10 -0
- package/client.ts +3 -2
- package/config.ts +1 -0
- package/deploy/boddb-il.yaml +14 -0
- package/deploy/prod-il.config.ts +19 -0
- package/deploy/prod.config.ts +1 -0
- package/index.ts +3 -0
- package/package.json +7 -2
- package/src/client/BodClient.ts +129 -6
- package/src/client/CachedClient.ts +228 -0
- package/src/server/BodDB.ts +145 -1
- package/src/server/ReplicationEngine.ts +332 -0
- package/src/server/StorageEngine.ts +19 -0
- package/src/server/Transport.ts +577 -360
- package/src/server/VFSEngine.ts +172 -0
- package/src/shared/protocol.ts +25 -4
- package/tests/cached-client.test.ts +143 -0
- package/tests/replication.test.ts +404 -0
- package/tests/vfs.test.ts +166 -0
package/admin/ui.html
CHANGED
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
|
|
10
10
|
/* Metrics bar */
|
|
11
11
|
#metrics-bar { display: flex; background: #0a0a0a; border-bottom: 1px solid #2a2a2a; flex-shrink: 0; overflow-x: auto; align-items: stretch; }
|
|
12
|
-
.metric-card { display: flex; flex-direction: column; padding: 5px 10px 4px; border-right: 1px solid #181818;
|
|
13
|
-
.metric-card:last-child { border-right: none;
|
|
14
|
-
.metric-
|
|
12
|
+
.metric-card { display: flex; flex-direction: column; padding: 5px 10px 4px; border-right: 1px solid #181818; width: 130px; flex-shrink: 0; gap: 1px; overflow: hidden; }
|
|
13
|
+
.metric-card:last-child { border-right: none; width: auto; }
|
|
14
|
+
.metric-right { margin-left: auto; }
|
|
15
|
+
.metric-top { display: flex; justify-content: space-between; align-items: baseline; width: 100%; }
|
|
15
16
|
.metric-label { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
16
|
-
.metric-value { font-size: 13px; color: #4ec9b0; font-weight: bold; }
|
|
17
|
+
.metric-value { font-size: 13px; color: #4ec9b0; font-weight: bold; min-width: 5ch; text-align: right; font-variant-numeric: tabular-nums; }
|
|
17
18
|
.metric-value.warn { color: #ce9178; }
|
|
18
19
|
.metric-value.dim { color: #888; }
|
|
19
|
-
.metric-canvas { display: block; margin-top: 2px; }
|
|
20
|
+
.metric-canvas { display: block; margin-top: 2px; width: 100%; }
|
|
20
21
|
#ws-dot { width: 7px; height: 7px; border-radius: 50%; background: #555; display: inline-block; margin-left: 4px; vertical-align: middle; }
|
|
21
22
|
#ws-dot.live { background: #4ec9b0; animation: pulse 1.5s infinite; }
|
|
22
23
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
|
@@ -66,6 +67,7 @@
|
|
|
66
67
|
button.sub { background: #3a2e5a; color: #c5b8f0; }
|
|
67
68
|
button.sub:hover { background: #5a4a8a; }
|
|
68
69
|
button.sm { padding: 3px 7px; font-size: 11px; }
|
|
70
|
+
button.icon { display:inline-flex;align-items:center;justify-content:center;padding:3px 5px;vertical-align:middle; }
|
|
69
71
|
.result { background: #111; border: 1px solid #222; border-radius: 3px; padding: 8px; overflow: auto; max-height: 280px; white-space: pre; color: #9cdcfe; font-size: 12px; }
|
|
70
72
|
.status { font-size: 11px; padding: 3px 8px; border-radius: 3px; }
|
|
71
73
|
.status.ok { background: #1a3a1a; color: #4ec9b0; }
|
|
@@ -120,6 +122,14 @@
|
|
|
120
122
|
<canvas class="metric-canvas" id="g-nodes" width="100" height="28"></canvas>
|
|
121
123
|
<div style="font-size:10px;color:#555;margin-top:2px"><span id="s-dbsize">—</span> MB on disk</div>
|
|
122
124
|
</div>
|
|
125
|
+
<div class="metric-card">
|
|
126
|
+
<div class="metric-top"><span class="metric-label">Ping</span><span class="metric-value" id="s-ping">—</span></div>
|
|
127
|
+
<canvas class="metric-canvas" id="g-ping" width="100" height="28"></canvas>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="metric-card metric-right" id="repl-card" style="border-left:1px solid #282828;display:none;width:180px">
|
|
130
|
+
<div class="metric-top"><span class="metric-label">Replication</span><span class="metric-value dim" id="s-repl-role">—</span></div>
|
|
131
|
+
<div style="margin-top:4px;font-size:10px" id="s-repl-sources"></div>
|
|
132
|
+
</div>
|
|
123
133
|
<div class="metric-card" style="border-left:1px solid #282828">
|
|
124
134
|
<div class="metric-top"><span class="metric-label">Uptime</span><span class="metric-value dim" id="s-uptime">—</span></div>
|
|
125
135
|
<div style="margin-top:4px;font-size:10px;color:#555" id="s-ts">—</div>
|
|
@@ -133,7 +143,7 @@
|
|
|
133
143
|
<div id="tree-header">
|
|
134
144
|
<b>Tree</b>
|
|
135
145
|
<span id="node-count">—</span>
|
|
136
|
-
<button class="sm" onclick="loadTree()"
|
|
146
|
+
<button class="sm icon" onclick="loadTree()" title="Reload"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg></button>
|
|
137
147
|
</div>
|
|
138
148
|
<div id="tree-container">Connecting…</div>
|
|
139
149
|
</div>
|
|
@@ -148,6 +158,9 @@
|
|
|
148
158
|
<div class="tab" onclick="showTab('advanced')">Advanced</div>
|
|
149
159
|
<div class="tab" onclick="showTab('streams')">Streams</div>
|
|
150
160
|
<div class="tab" onclick="showTab('mq')">MQ</div>
|
|
161
|
+
<div class="tab" onclick="showTab('repl');loadRepl()">Replication</div>
|
|
162
|
+
<div class="tab" onclick="showTab('vfs');vfsNavigate(vfsPath)">VFS</div>
|
|
163
|
+
<div class="tab" onclick="showTab('cache')">Cache</div>
|
|
151
164
|
<div class="tab" onclick="showTab('stress')">Stress Tests</div>
|
|
152
165
|
<div class="tab" onclick="showTab('view')">View</div>
|
|
153
166
|
</div>
|
|
@@ -563,6 +576,174 @@
|
|
|
563
576
|
</div>
|
|
564
577
|
</div>
|
|
565
578
|
|
|
579
|
+
<!-- Replication Panel -->
|
|
580
|
+
<div class="panel" id="panel-repl">
|
|
581
|
+
<div style="display:flex;flex-direction:column;gap:12px">
|
|
582
|
+
|
|
583
|
+
<!-- Status -->
|
|
584
|
+
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
585
|
+
<label style="font-size:11px;color:#888;margin-bottom:6px">Source Feed Status — paths synced from remote BodDB instances</label>
|
|
586
|
+
<div class="row" style="margin-bottom:6px">
|
|
587
|
+
<button onclick="loadRepl()">REFRESH</button>
|
|
588
|
+
</div>
|
|
589
|
+
<div id="repl-status" style="margin-top:4px"></div>
|
|
590
|
+
<div class="result" id="repl-config" style="margin-top:4px;display:none;max-height:300px">—</div>
|
|
591
|
+
</div>
|
|
592
|
+
|
|
593
|
+
<!-- Synced Data -->
|
|
594
|
+
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
595
|
+
<label style="font-size:11px;color:#888;margin-bottom:6px">Synced Data — local copy of source paths (read-only mirror)</label>
|
|
596
|
+
<div class="result" id="repl-synced" style="max-height:400px;display:none">—</div>
|
|
597
|
+
</div>
|
|
598
|
+
|
|
599
|
+
<!-- Write to Source (demo) -->
|
|
600
|
+
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
601
|
+
<label style="font-size:11px;color:#888;margin-bottom:6px">Write to Source — modify remote data and watch it sync locally</label>
|
|
602
|
+
<div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap">
|
|
603
|
+
<button class="sm" onclick="fillReplWrite('catalog/widgets','{"name":"Widget A","price":34.99,"stock":200}')">update widget price</button>
|
|
604
|
+
<button class="sm" onclick="fillReplWrite('catalog/new-item','{"name":"New Item","price":9.99,"stock":50}')">add item</button>
|
|
605
|
+
<button class="sm" onclick="fillReplWrite('alerts/sys-3','{"level":"error","msg":"Disk full","ts":' + Date.now() + '}')">add alert</button>
|
|
606
|
+
</div>
|
|
607
|
+
<div class="row">
|
|
608
|
+
<input id="repl-write-path" type="text" placeholder="catalog/widgets" value="catalog/widgets" style="flex:2">
|
|
609
|
+
</div>
|
|
610
|
+
<textarea id="repl-write-value" style="min-height:40px;margin-top:4px" placeholder='{ "name": "Widget A", "price": 34.99 }'>{ "name": "Widget A", "price": 34.99, "stock": 200 }</textarea>
|
|
611
|
+
<div class="row" style="margin-top:4px">
|
|
612
|
+
<button class="success" onclick="doReplWrite()">WRITE TO SOURCE</button>
|
|
613
|
+
<button class="danger" onclick="doReplDelete()">DELETE ON SOURCE</button>
|
|
614
|
+
</div>
|
|
615
|
+
<div id="repl-write-status" style="margin-top:4px"></div>
|
|
616
|
+
</div>
|
|
617
|
+
|
|
618
|
+
</div>
|
|
619
|
+
</div>
|
|
620
|
+
|
|
621
|
+
<!-- VFS Panel -->
|
|
622
|
+
<div class="panel" id="panel-vfs">
|
|
623
|
+
<!-- Explorer toolbar -->
|
|
624
|
+
<div id="vfs-toolbar" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
|
|
625
|
+
<div id="vfs-breadcrumb" style="flex:1;font-size:13px;color:#ccc;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></div>
|
|
626
|
+
<input id="vfs-upload-file" type="file" style="display:none" onchange="vfsUploadHere()">
|
|
627
|
+
<button class="success sm" onclick="document.getElementById('vfs-upload-file').click()">Upload</button>
|
|
628
|
+
<button class="sm" onclick="vfsSyncFolder()">Sync Folder</button>
|
|
629
|
+
<button class="sm" onclick="vfsMkdirHere()">New Folder</button>
|
|
630
|
+
<button class="sm icon" onclick="vfsNavigate(vfsPath)" title="Reload"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg></button>
|
|
631
|
+
</div>
|
|
632
|
+
<!-- File table -->
|
|
633
|
+
<div id="vfs-table" style="border:1px solid #222;border-radius:3px;overflow:auto;max-height:320px;font-size:12px">
|
|
634
|
+
<div style="padding:20px;text-align:center;color:#555">Loading…</div>
|
|
635
|
+
</div>
|
|
636
|
+
<div id="vfs-explorer-status" style="margin-top:4px"></div>
|
|
637
|
+
<!-- Preview panel -->
|
|
638
|
+
<div id="vfs-preview" style="display:none;margin-top:6px;border:1px solid #222;border-radius:3px;overflow:hidden">
|
|
639
|
+
<div id="vfs-preview-header" style="padding:6px 10px;border-bottom:1px solid #222;display:flex;align-items:center;gap:8px;font-size:12px">
|
|
640
|
+
<span id="vfs-preview-name" style="color:#569cd6;font-weight:bold"></span>
|
|
641
|
+
<span id="vfs-preview-info" style="color:#888"></span>
|
|
642
|
+
<span style="flex:1"></span>
|
|
643
|
+
<button class="sm" onclick="vfsDownload()">Download</button>
|
|
644
|
+
<button class="sm" onclick="vfsRename()">Rename</button>
|
|
645
|
+
<button class="danger sm" onclick="if(confirm('Delete?'))vfsDeleteSel()">Delete</button>
|
|
646
|
+
<button class="sm" onclick="document.getElementById('vfs-preview').style.display='none'">✕</button>
|
|
647
|
+
</div>
|
|
648
|
+
<div id="vfs-preview-body" style="padding:10px;max-height:250px;overflow:auto;font-size:12px;color:#ccc;white-space:pre-wrap;font-family:monospace"></div>
|
|
649
|
+
</div>
|
|
650
|
+
<!-- Advanced: original form-based ops -->
|
|
651
|
+
<details style="margin-top:12px">
|
|
652
|
+
<summary style="cursor:pointer;color:#888;font-size:11px">Advanced (raw API)</summary>
|
|
653
|
+
<div style="display:flex;flex-direction:column;gap:12px;margin-top:8px">
|
|
654
|
+
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
655
|
+
<label style="font-size:11px;color:#888;margin-bottom:6px">Upload File — POST binary to /files/<path></label>
|
|
656
|
+
<div class="row">
|
|
657
|
+
<input id="vfs-upload-path" type="text" placeholder="docs/readme.txt" style="flex:2">
|
|
658
|
+
<input id="vfs-adv-upload-file" type="file" style="flex:2" onchange="if(this.files[0])document.getElementById('vfs-upload-path').value=this.files[0].name">
|
|
659
|
+
<button class="success" onclick="doVfsUpload()">UPLOAD</button>
|
|
660
|
+
</div>
|
|
661
|
+
<div id="vfs-upload-status" style="margin-top:4px"></div>
|
|
662
|
+
</div>
|
|
663
|
+
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
664
|
+
<label style="font-size:11px;color:#888;margin-bottom:6px">Download File</label>
|
|
665
|
+
<div class="row">
|
|
666
|
+
<input id="vfs-download-path" type="text" placeholder="docs/readme.txt" style="flex:2">
|
|
667
|
+
<button onclick="doVfsDownload()">DOWNLOAD</button>
|
|
668
|
+
</div>
|
|
669
|
+
<div id="vfs-download-status" style="margin-top:4px"></div>
|
|
670
|
+
</div>
|
|
671
|
+
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
672
|
+
<label style="font-size:11px;color:#888;margin-bottom:6px">Stat</label>
|
|
673
|
+
<div class="row">
|
|
674
|
+
<input id="vfs-stat-path" type="text" placeholder="docs/readme.txt" style="flex:2">
|
|
675
|
+
<button onclick="doVfsStat()">STAT</button>
|
|
676
|
+
</div>
|
|
677
|
+
<div id="vfs-stat-status" style="margin-top:4px"></div>
|
|
678
|
+
<div class="result" id="vfs-stat-result" style="margin-top:4px;display:none;max-height:200px">—</div>
|
|
679
|
+
</div>
|
|
680
|
+
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
681
|
+
<label style="font-size:11px;color:#888;margin-bottom:6px">List Directory</label>
|
|
682
|
+
<div class="row">
|
|
683
|
+
<input id="vfs-list-path" type="text" placeholder="docs" style="flex:2">
|
|
684
|
+
<button onclick="doVfsList()">LIST</button>
|
|
685
|
+
</div>
|
|
686
|
+
<div id="vfs-list-status" style="margin-top:4px"></div>
|
|
687
|
+
<div class="result" id="vfs-list-result" style="margin-top:4px;display:none;max-height:300px">—</div>
|
|
688
|
+
</div>
|
|
689
|
+
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
690
|
+
<label style="font-size:11px;color:#888;margin-bottom:6px">Mkdir</label>
|
|
691
|
+
<div class="row">
|
|
692
|
+
<input id="vfs-mkdir-path" type="text" placeholder="docs/drafts" style="flex:2">
|
|
693
|
+
<button class="success" onclick="doVfsMkdir()">MKDIR</button>
|
|
694
|
+
</div>
|
|
695
|
+
<div id="vfs-mkdir-status" style="margin-top:4px"></div>
|
|
696
|
+
</div>
|
|
697
|
+
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
698
|
+
<label style="font-size:11px;color:#888;margin-bottom:6px">Move / Rename</label>
|
|
699
|
+
<div class="row">
|
|
700
|
+
<input id="vfs-move-src" type="text" placeholder="docs/old.txt" style="flex:2">
|
|
701
|
+
<span style="color:#555">→</span>
|
|
702
|
+
<input id="vfs-move-dst" type="text" placeholder="docs/new.txt" style="flex:2">
|
|
703
|
+
<button onclick="doVfsMove()">MOVE</button>
|
|
704
|
+
</div>
|
|
705
|
+
<div id="vfs-move-status" style="margin-top:4px"></div>
|
|
706
|
+
</div>
|
|
707
|
+
<div style="border:1px solid #222;border-radius:3px;padding:10px">
|
|
708
|
+
<label style="font-size:11px;color:#888;margin-bottom:6px">Delete</label>
|
|
709
|
+
<div class="row">
|
|
710
|
+
<input id="vfs-delete-path" type="text" placeholder="docs/readme.txt" style="flex:2">
|
|
711
|
+
<button class="danger" onclick="if(confirm('Delete this file?'))doVfsDelete()">DELETE</button>
|
|
712
|
+
</div>
|
|
713
|
+
<div id="vfs-delete-status" style="margin-top:4px"></div>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
</details>
|
|
717
|
+
</div>
|
|
718
|
+
|
|
719
|
+
<div class="panel" id="panel-cache">
|
|
720
|
+
<h3 style="color:#569cd6;margin-bottom:8px">CachedClient Demo</h3>
|
|
721
|
+
<p style="color:#666;font-size:11px;margin-bottom:10px">Two-tier cache (memory + IndexedDB) with stale-while-revalidate. Wraps BodClient for instant reads.</p>
|
|
722
|
+
|
|
723
|
+
<div style="display:flex;gap:6px;margin-bottom:10px">
|
|
724
|
+
<div style="flex:1">
|
|
725
|
+
<label>Path</label>
|
|
726
|
+
<input type="text" id="cache-path" value="cache/demo" placeholder="path">
|
|
727
|
+
</div>
|
|
728
|
+
<div style="flex:1">
|
|
729
|
+
<label>Value (JSON)</label>
|
|
730
|
+
<input type="text" id="cache-value" value='{"name":"Alice","score":42}' placeholder="value">
|
|
731
|
+
</div>
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
<div style="display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap">
|
|
735
|
+
<button onclick="cacheSet()">Set (invalidates cache)</button>
|
|
736
|
+
<button onclick="cacheGet()">Get (cache-first)</button>
|
|
737
|
+
<button onclick="cacheGetFresh()">Get Fresh (network)</button>
|
|
738
|
+
<button class="success" onclick="cacheSub()">Subscribe (keeps fresh)</button>
|
|
739
|
+
<button class="danger" onclick="cacheUnsub()">Unsubscribe</button>
|
|
740
|
+
<button onclick="cacheStats()">Cache Stats</button>
|
|
741
|
+
</div>
|
|
742
|
+
|
|
743
|
+
<div id="cache-result" class="result" style="min-height:120px">Cache demo — set a value, then get it (served from cache on second read).</div>
|
|
744
|
+
<div id="cache-log" class="result" style="min-height:80px;margin-top:8px;color:#888;font-size:11px">Event log...</div>
|
|
745
|
+
</div>
|
|
746
|
+
|
|
566
747
|
<!-- Stress Tests Panel -->
|
|
567
748
|
<div class="panel" id="panel-stress">
|
|
568
749
|
<div class="stress-grid">
|
|
@@ -694,7 +875,7 @@ function log(level, ...args) {
|
|
|
694
875
|
|
|
695
876
|
// ── ZuzClient (browser port, mirrors src/client/ZuzClient.ts API exactly) ──────
|
|
696
877
|
class ValueSnapshot {
|
|
697
|
-
constructor(path, data) { this.path = path; this._data = data; }
|
|
878
|
+
constructor(path, data, updatedAt) { this.path = path; this._data = data; this.updatedAt = updatedAt; }
|
|
698
879
|
val() { return this._data; }
|
|
699
880
|
get key() { return this.path.split('/').pop(); }
|
|
700
881
|
exists() { return this._data !== null && this._data !== undefined; }
|
|
@@ -740,7 +921,7 @@ class ZuzClient {
|
|
|
740
921
|
}
|
|
741
922
|
if (this._activeSubs.size) log('debug', `Re-subscribed ${this._activeSubs.size} active subs`);
|
|
742
923
|
resolve();
|
|
743
|
-
loadTree
|
|
924
|
+
setTimeout(loadTree, 0);
|
|
744
925
|
};
|
|
745
926
|
ws.onclose = () => {
|
|
746
927
|
document.getElementById('ws-dot').classList.remove('live');
|
|
@@ -759,7 +940,7 @@ class ZuzClient {
|
|
|
759
940
|
const msg = JSON.parse(e.data);
|
|
760
941
|
if (msg.type === 'value') {
|
|
761
942
|
if (msg.path !== '_admin/stats' && !msg.path.startsWith('stress/')) log('debug', `← value`, msg.path);
|
|
762
|
-
const snap = new ValueSnapshot(msg.path, msg.data);
|
|
943
|
+
const snap = new ValueSnapshot(msg.path, msg.data, msg.updatedAt);
|
|
763
944
|
for (const cb of this._valueCbs.get(msg.path) ?? []) cb(snap);
|
|
764
945
|
return;
|
|
765
946
|
}
|
|
@@ -776,14 +957,14 @@ class ZuzClient {
|
|
|
776
957
|
return;
|
|
777
958
|
}
|
|
778
959
|
const p = this._pending.get(msg.id);
|
|
779
|
-
if (p) { this._pending.delete(msg.id); msg.ok ? p.resolve(msg
|
|
960
|
+
if (p) { this._pending.delete(msg.id); msg.ok ? p.resolve(msg) : p.reject(new Error(msg.error)); }
|
|
780
961
|
};
|
|
781
962
|
});
|
|
782
963
|
}
|
|
783
964
|
|
|
784
965
|
disconnect() { this._closed = true; this._ws?.close(); }
|
|
785
966
|
|
|
786
|
-
|
|
967
|
+
_sendRaw(op, params = {}) {
|
|
787
968
|
return new Promise((resolve, reject) => {
|
|
788
969
|
if (this._ws?.readyState !== WebSocket.OPEN) return reject(new Error('Not connected'));
|
|
789
970
|
const id = String(++this._msgId);
|
|
@@ -795,7 +976,13 @@ class ZuzClient {
|
|
|
795
976
|
});
|
|
796
977
|
}
|
|
797
978
|
|
|
979
|
+
async _send(op, params = {}) {
|
|
980
|
+
const msg = await this._sendRaw(op, params);
|
|
981
|
+
return msg.data ?? null;
|
|
982
|
+
}
|
|
983
|
+
|
|
798
984
|
get(path) { return this._send('get', { path }); }
|
|
985
|
+
async getSnapshot(path) { const msg = await this._sendRaw('get', { path }); return new ValueSnapshot(path, msg.data ?? null, msg.updatedAt); }
|
|
799
986
|
getShallow(path) { return this._send('get', { path: path ?? '', shallow: true }); }
|
|
800
987
|
set(path, value) { return this._send('set', { path, value }); }
|
|
801
988
|
update(updates) { return this._send('update', { updates }); }
|
|
@@ -927,8 +1114,23 @@ const graphs = {
|
|
|
927
1114
|
heap: new Sparkline('g-heap', '#569cd6'),
|
|
928
1115
|
rss: new Sparkline('g-rss', '#9cdcfe'),
|
|
929
1116
|
nodes:new Sparkline('g-nodes','#4ec9b0'),
|
|
1117
|
+
ping: new Sparkline('g-ping', '#c586c0'),
|
|
930
1118
|
};
|
|
931
1119
|
|
|
1120
|
+
// ── Ping measurement ────────────────────────────────────────────────────────
|
|
1121
|
+
async function measurePing() {
|
|
1122
|
+
try {
|
|
1123
|
+
const t0 = performance.now();
|
|
1124
|
+
await db.get('_admin/stats');
|
|
1125
|
+
const ms = Math.round(performance.now() - t0);
|
|
1126
|
+
const el = document.getElementById('s-ping');
|
|
1127
|
+
el.textContent = ms + ' ms';
|
|
1128
|
+
el.className = 'metric-value' + (ms > 200 ? ' warn' : '');
|
|
1129
|
+
graphs.ping.push(ms);
|
|
1130
|
+
} catch {}
|
|
1131
|
+
}
|
|
1132
|
+
setInterval(measurePing, 2000);
|
|
1133
|
+
|
|
932
1134
|
db.on('_admin/stats', (snap) => {
|
|
933
1135
|
const s = snap.val();
|
|
934
1136
|
if (!s) return;
|
|
@@ -959,6 +1161,25 @@ db.on('_admin/stats', (snap) => {
|
|
|
959
1161
|
document.getElementById('s-subs').textContent = s.subs ?? 0;
|
|
960
1162
|
document.getElementById('s-uptime').textContent = fmtUptime(s.process.uptimeSec);
|
|
961
1163
|
document.getElementById('s-ts').textContent = new Date(s.ts).toLocaleTimeString();
|
|
1164
|
+
|
|
1165
|
+
// Replication stats
|
|
1166
|
+
if (s.repl) {
|
|
1167
|
+
const card = document.getElementById('repl-card');
|
|
1168
|
+
card.style.display = '';
|
|
1169
|
+
document.getElementById('s-repl-role').textContent = s.repl.role;
|
|
1170
|
+
const srcEl = document.getElementById('s-repl-sources');
|
|
1171
|
+
if (s.repl.sources.length) {
|
|
1172
|
+
srcEl.innerHTML = s.repl.sources.map(src => {
|
|
1173
|
+
const dot = src.connected ? '<span style="color:#4ec9b0">●</span>' : '<span style="color:#f48771">●</span>';
|
|
1174
|
+
const prefix = src.localPrefix ? `→${src.localPrefix}/` : '';
|
|
1175
|
+
const pend = src.pending;
|
|
1176
|
+
const pendColor = pend > 100 ? '#f48771' : pend > 10 ? '#ce9178' : '#4ec9b0';
|
|
1177
|
+
return `<span style="color:#555">${dot} ${src.paths.join(',')} ${prefix} <span style="color:${pendColor}">${pend} behind</span> · ${src.eventsApplied}ev</span>`;
|
|
1178
|
+
}).join('<br>');
|
|
1179
|
+
} else {
|
|
1180
|
+
srcEl.innerHTML = '<span style="color:#555">no sources</span>';
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
962
1183
|
});
|
|
963
1184
|
|
|
964
1185
|
// Connect after all subscriptions are registered so onopen re-subscribes them all
|
|
@@ -1014,12 +1235,14 @@ function fmtUptime(sec) {
|
|
|
1014
1235
|
}
|
|
1015
1236
|
|
|
1016
1237
|
// ── Tab switching ──────────────────────────────────────────────────────────────
|
|
1017
|
-
const TAB_IDS = ['rw','query','subs','auth','advanced','streams','mq','stress','view'];
|
|
1238
|
+
const TAB_IDS = ['rw','query','subs','auth','advanced','streams','mq','repl','vfs','cache','stress','view'];
|
|
1018
1239
|
function showTab(id) {
|
|
1019
1240
|
document.querySelectorAll('.tab').forEach((t, i) => t.classList.toggle('active', TAB_IDS[i] === id));
|
|
1020
1241
|
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
|
1021
1242
|
document.getElementById('panel-' + id).classList.add('active');
|
|
1022
|
-
|
|
1243
|
+
const url = new URL(location.href);
|
|
1244
|
+
url.searchParams.set('tab', id);
|
|
1245
|
+
history.replaceState(null, '', url);
|
|
1023
1246
|
}
|
|
1024
1247
|
|
|
1025
1248
|
// ── Field persistence ──────────────────────────────────────────────────────────
|
|
@@ -1064,8 +1287,15 @@ function restoreFields() {
|
|
|
1064
1287
|
else if (DEFAULT_VALUES[id]) { el.value = DEFAULT_VALUES[id]; }
|
|
1065
1288
|
}
|
|
1066
1289
|
} catch {}
|
|
1067
|
-
const tab = localStorage.getItem('zuzdb:tab');
|
|
1068
|
-
if (tab && TAB_IDS.includes(tab))
|
|
1290
|
+
const tab = new URLSearchParams(location.search).get('tab') || localStorage.getItem('zuzdb:tab');
|
|
1291
|
+
if (tab && TAB_IDS.includes(tab)) {
|
|
1292
|
+
showTab(tab);
|
|
1293
|
+
setTimeout(() => {
|
|
1294
|
+
if (tab === 'vfs') vfsNavigate(vfsPath);
|
|
1295
|
+
if (tab === 'repl') loadRepl();
|
|
1296
|
+
if (tab === 'auth') loadRules();
|
|
1297
|
+
}, 0);
|
|
1298
|
+
}
|
|
1069
1299
|
}
|
|
1070
1300
|
for (const id of PERSIST_FIELDS) {
|
|
1071
1301
|
document.getElementById(id).addEventListener('input', persistFields);
|
|
@@ -1247,9 +1477,11 @@ function selectPath(path) {
|
|
|
1247
1477
|
document.getElementById('view-time').textContent = '';
|
|
1248
1478
|
document.getElementById('view-result').textContent = 'Loading…';
|
|
1249
1479
|
const t0 = performance.now();
|
|
1250
|
-
db.
|
|
1251
|
-
|
|
1252
|
-
|
|
1480
|
+
db.getSnapshot(path).then(snap => {
|
|
1481
|
+
const elapsed = (performance.now() - t0).toFixed(1) + ' ms';
|
|
1482
|
+
const updated = snap.updatedAt ? new Date(snap.updatedAt).toLocaleString() : '';
|
|
1483
|
+
document.getElementById('view-time').textContent = elapsed + (updated ? ` · updated ${updated}` : '');
|
|
1484
|
+
document.getElementById('view-result').textContent = JSON.stringify(snap.val(), null, 2);
|
|
1253
1485
|
}).catch(e => {
|
|
1254
1486
|
document.getElementById('view-time').textContent = (performance.now() - t0).toFixed(1) + ' ms';
|
|
1255
1487
|
document.getElementById('view-result').textContent = 'Error: ' + e.message;
|
|
@@ -2276,6 +2508,485 @@ function showStatus(id, msg, ok, ms) {
|
|
|
2276
2508
|
el.className = 'status ' + (ok ? 'ok' : 'err');
|
|
2277
2509
|
el.textContent = ms != null ? `${msg} (${ms.toFixed(1)}ms)` : msg;
|
|
2278
2510
|
}
|
|
2511
|
+
|
|
2512
|
+
// ── Replication ────────────────────────────────────────────────────────────────
|
|
2513
|
+
async function loadRepl() {
|
|
2514
|
+
const t0 = performance.now();
|
|
2515
|
+
try {
|
|
2516
|
+
const res = await fetch('/replication');
|
|
2517
|
+
const json = await res.json();
|
|
2518
|
+
const ms = performance.now() - t0;
|
|
2519
|
+
if (!json.ok) { showStatus('repl-status', json.error || 'Failed', false, ms); return; }
|
|
2520
|
+
|
|
2521
|
+
const { role, sources, synced } = json;
|
|
2522
|
+
showStatus('repl-status', `Role: ${role} — ${sources.length} source(s)`, true, ms);
|
|
2523
|
+
|
|
2524
|
+
const configEl = document.getElementById('repl-config');
|
|
2525
|
+
configEl.style.display = 'block';
|
|
2526
|
+
configEl.textContent = sources.map(s =>
|
|
2527
|
+
`${s.url}\n paths: [${s.paths.join(', ')}]\n localPrefix: ${s.localPrefix || '(none)'}\n id: ${s.id || '(auto)'}`
|
|
2528
|
+
).join('\n\n') || '(no sources configured)';
|
|
2529
|
+
|
|
2530
|
+
const syncedEl = document.getElementById('repl-synced');
|
|
2531
|
+
syncedEl.style.display = 'block';
|
|
2532
|
+
syncedEl.textContent = JSON.stringify(synced, null, 2);
|
|
2533
|
+
} catch (e) {
|
|
2534
|
+
showStatus('repl-status', e.message, false);
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
function fillReplWrite(path, value) {
|
|
2539
|
+
document.getElementById('repl-write-path').value = path;
|
|
2540
|
+
document.getElementById('repl-write-value').value = value;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
async function doReplWrite() {
|
|
2544
|
+
const path = document.getElementById('repl-write-path').value.trim();
|
|
2545
|
+
const raw = document.getElementById('repl-write-value').value.trim();
|
|
2546
|
+
if (!path) return showStatus('repl-write-status', 'Path required', false);
|
|
2547
|
+
const t0 = performance.now();
|
|
2548
|
+
try {
|
|
2549
|
+
const value = JSON.parse(raw);
|
|
2550
|
+
const res = await fetch('/replication/source-write', {
|
|
2551
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2552
|
+
body: JSON.stringify({ path, value }),
|
|
2553
|
+
});
|
|
2554
|
+
const json = await res.json();
|
|
2555
|
+
const ms = performance.now() - t0;
|
|
2556
|
+
showStatus('repl-write-status', json.ok ? `Written to source: ${path}` : (json.error || 'Failed'), json.ok, ms);
|
|
2557
|
+
if (json.ok) setTimeout(loadRepl, 600);
|
|
2558
|
+
} catch (e) {
|
|
2559
|
+
showStatus('repl-write-status', e.message, false, performance.now() - t0);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
async function doReplDelete() {
|
|
2564
|
+
const path = document.getElementById('repl-write-path').value.trim();
|
|
2565
|
+
if (!path) return showStatus('repl-write-status', 'Path required', false);
|
|
2566
|
+
const t0 = performance.now();
|
|
2567
|
+
try {
|
|
2568
|
+
const res = await fetch('/replication/source-delete/' + path, { method: 'DELETE' });
|
|
2569
|
+
const json = await res.json();
|
|
2570
|
+
const ms = performance.now() - t0;
|
|
2571
|
+
showStatus('repl-write-status', json.ok ? `Deleted on source: ${path}` : (json.error || 'Failed'), json.ok, ms);
|
|
2572
|
+
if (json.ok) setTimeout(loadRepl, 600);
|
|
2573
|
+
} catch (e) {
|
|
2574
|
+
showStatus('repl-write-status', e.message, false, performance.now() - t0);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
// ── VFS Explorer ──────────────────────────────────────────────────────────────
|
|
2578
|
+
let vfsPath = '';
|
|
2579
|
+
let vfsSelected = null;
|
|
2580
|
+
|
|
2581
|
+
function vfsFormatSize(b) {
|
|
2582
|
+
if (b == null) return '—';
|
|
2583
|
+
if (b < 1024) return b + ' B';
|
|
2584
|
+
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
|
|
2585
|
+
return (b/(1024*1024)).toFixed(1) + ' MB';
|
|
2586
|
+
}
|
|
2587
|
+
function vfsFormatDate(ts) {
|
|
2588
|
+
if (!ts) return '';
|
|
2589
|
+
const d = new Date(typeof ts === 'number' ? ts : ts);
|
|
2590
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
async function vfsNavigate(path) {
|
|
2594
|
+
vfsPath = (path || '').replace(/^\/+|\/+$/g, '');
|
|
2595
|
+
vfsSelected = null;
|
|
2596
|
+
document.getElementById('vfs-preview').style.display = 'none';
|
|
2597
|
+
// breadcrumb
|
|
2598
|
+
const bc = document.getElementById('vfs-breadcrumb');
|
|
2599
|
+
const segs = vfsPath ? vfsPath.split('/') : [];
|
|
2600
|
+
let html = '<span style="cursor:pointer;color:#569cd6" onclick="vfsNavigate(\'\')">/</span>';
|
|
2601
|
+
let built = '';
|
|
2602
|
+
for (let i = 0; i < segs.length; i++) {
|
|
2603
|
+
built += (i ? '/' : '') + segs[i];
|
|
2604
|
+
const p = built;
|
|
2605
|
+
html += ' > <span style="cursor:pointer;color:#569cd6" onclick="vfsNavigate(\'' + p.replace(/'/g, "\\'") + '\')">' + segs[i] + '</span>';
|
|
2606
|
+
}
|
|
2607
|
+
bc.innerHTML = html;
|
|
2608
|
+
// fetch listing
|
|
2609
|
+
const tbl = document.getElementById('vfs-table');
|
|
2610
|
+
const t0 = performance.now();
|
|
2611
|
+
try {
|
|
2612
|
+
const res = await fetch('/files/' + (vfsPath || '') + '?list=1');
|
|
2613
|
+
const json = await res.json();
|
|
2614
|
+
const ms = performance.now() - t0;
|
|
2615
|
+
if (!json.ok) { tbl.innerHTML = '<div style="padding:12px;color:#f44">Error: ' + (json.error || 'Failed') + '</div>'; return; }
|
|
2616
|
+
const items = json.data || [];
|
|
2617
|
+
// sort: dirs first, then alpha
|
|
2618
|
+
items.sort((a, b) => {
|
|
2619
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
2620
|
+
return (a.name || '').localeCompare(b.name || '');
|
|
2621
|
+
});
|
|
2622
|
+
showStatus('vfs-explorer-status', items.length + ' items', true, ms);
|
|
2623
|
+
if (!items.length) {
|
|
2624
|
+
tbl.innerHTML = '<div style="padding:20px;text-align:center;color:#555">Empty directory</div>';
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
window._vfsItems = items;
|
|
2628
|
+
let rows = '<table style="width:100%;border-collapse:collapse"><tbody>';
|
|
2629
|
+
for (let idx = 0; idx < items.length; idx++) {
|
|
2630
|
+
const item = items[idx];
|
|
2631
|
+
const icon = item.isDir ? '📁' : '📄';
|
|
2632
|
+
const fp = (vfsPath ? vfsPath + '/' + item.name : item.name).replace(/'/g, "\\'");
|
|
2633
|
+
const clickAttr = item.isDir
|
|
2634
|
+
? 'onclick="vfsNavigate(\'' + fp + '\')"'
|
|
2635
|
+
: 'onclick="vfsPreview(' + idx + ',\'' + fp + '\')"';
|
|
2636
|
+
const svgDl = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
|
2637
|
+
const svgRn = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
|
|
2638
|
+
const svgDel = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>';
|
|
2639
|
+
const btnBase = 'display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;padding:0';
|
|
2640
|
+
const dlStyle = item.isDir ? btnBase + ';visibility:hidden' : btnBase;
|
|
2641
|
+
const actions = '<span class="vfs-row-actions" style="display:flex;gap:3px;align-items:center;visibility:hidden">' +
|
|
2642
|
+
'<button class="sm" style="' + dlStyle + '" onclick="event.stopPropagation();vfsActOn(' + idx + ',\'' + fp + '\',\'download\')" title="Download">' + svgDl + '</button>' +
|
|
2643
|
+
'<button class="sm" style="' + btnBase + '" onclick="event.stopPropagation();vfsActOn(' + idx + ',\'' + fp + '\',\'rename\')" title="Rename">' + svgRn + '</button>' +
|
|
2644
|
+
'<button class="danger sm" style="' + btnBase + '" onclick="event.stopPropagation();vfsActOn(' + idx + ',\'' + fp + '\',\'delete\')" title="Delete">' + svgDel + '</button>' +
|
|
2645
|
+
'</span>';
|
|
2646
|
+
rows += '<tr style="cursor:pointer;padding:0;border-bottom:1px solid #1a1a1a;height:34px" ' + clickAttr +
|
|
2647
|
+
' onmouseover="this.style.background=\'#1e1e1e\';var a=this.querySelector(\'.vfs-row-actions\');if(a)a.style.visibility=\'visible\'"' +
|
|
2648
|
+
' onmouseout="this.style.background=\'\';var a=this.querySelector(\'.vfs-row-actions\');if(a)a.style.visibility=\'hidden\'">';
|
|
2649
|
+
rows += '<td style="width:28px;padding:6px 4px 6px 10px">' + icon + '</td>';
|
|
2650
|
+
rows += '<td style="color:#ccc;padding:6px 4px">' + (item.name || '') + '</td>';
|
|
2651
|
+
rows += '<td style="width:70px;text-align:right;color:#888;padding:6px 4px">' + (item.isDir ? '' : vfsFormatSize(item.size)) + '</td>';
|
|
2652
|
+
rows += '<td style="width:70px;text-align:right;color:#666;padding:6px 4px">' + vfsFormatDate(item.mtime) + '</td>';
|
|
2653
|
+
rows += '<td style="width:90px;text-align:right;padding:6px 10px 6px 4px">' + actions + '</td>';
|
|
2654
|
+
rows += '</tr>';
|
|
2655
|
+
}
|
|
2656
|
+
rows += '</tbody></table>';
|
|
2657
|
+
tbl.innerHTML = rows;
|
|
2658
|
+
} catch (e) {
|
|
2659
|
+
tbl.innerHTML = '<div style="padding:12px;color:#f44">' + e.message + '</div>';
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
function vfsSetSelected(idx, fullPath) {
|
|
2664
|
+
vfsSelected = { ...window._vfsItems[idx], fullPath };
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
async function vfsPreview(idx, fullPath) {
|
|
2668
|
+
vfsSetSelected(idx, fullPath);
|
|
2669
|
+
const item = vfsSelected;
|
|
2670
|
+
const panel = document.getElementById('vfs-preview');
|
|
2671
|
+
const body = document.getElementById('vfs-preview-body');
|
|
2672
|
+
document.getElementById('vfs-preview-name').textContent = item.name;
|
|
2673
|
+
document.getElementById('vfs-preview-info').textContent = vfsFormatSize(item.size) + (item.mime ? ' \u00b7 ' + item.mime : '');
|
|
2674
|
+
panel.style.display = 'block';
|
|
2675
|
+
body.textContent = 'Loading\u2026';
|
|
2676
|
+
// preview text-based files, show info for binary
|
|
2677
|
+
const textTypes = ['text/', 'application/json', 'application/xml', 'application/javascript', 'application/typescript', 'application/yaml', 'application/toml'];
|
|
2678
|
+
const isText = !item.mime || textTypes.some(t => (item.mime || '').startsWith(t)) || (item.size || 0) === 0;
|
|
2679
|
+
if (isText && item.size > 0) {
|
|
2680
|
+
try {
|
|
2681
|
+
const res = await fetch('/files/' + fullPath);
|
|
2682
|
+
if (!res.ok) { body.textContent = 'Failed to load'; return; }
|
|
2683
|
+
const text = await res.text();
|
|
2684
|
+
body.textContent = text.length > 50000 ? text.slice(0, 50000) + '\n\u2026 (truncated)' : text;
|
|
2685
|
+
} catch (e) { body.textContent = e.message; }
|
|
2686
|
+
} else if (item.size === 0) {
|
|
2687
|
+
body.textContent = '(empty file)';
|
|
2688
|
+
} else {
|
|
2689
|
+
body.textContent = 'Binary file \u00b7 ' + vfsFormatSize(item.size) + ' \u00b7 ' + (item.mime || 'unknown type') + '\n\nUse Download to save.';
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
function vfsActOn(idx, fullPath, action) {
|
|
2694
|
+
vfsSetSelected(idx, fullPath);
|
|
2695
|
+
if (action === 'download') vfsDownload();
|
|
2696
|
+
else if (action === 'rename') vfsRename();
|
|
2697
|
+
else if (action === 'delete' && confirm('Delete ' + vfsSelected.name + '?')) vfsDeleteSel();
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
async function vfsUploadHere() {
|
|
2701
|
+
const fileInput = document.getElementById('vfs-upload-file');
|
|
2702
|
+
if (!fileInput.files.length) return;
|
|
2703
|
+
const file = fileInput.files[0];
|
|
2704
|
+
const path = (vfsPath ? vfsPath + '/' : '') + file.name;
|
|
2705
|
+
const t0 = performance.now();
|
|
2706
|
+
try {
|
|
2707
|
+
const res = await fetch('/files/' + path, { method: 'POST', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
|
|
2708
|
+
const json = await res.json();
|
|
2709
|
+
showStatus('vfs-explorer-status', json.ok ? 'Uploaded ' + file.name : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
2710
|
+
} catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
|
|
2711
|
+
fileInput.value = '';
|
|
2712
|
+
vfsNavigate(vfsPath);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
async function vfsSyncFolder() {
|
|
2716
|
+
if (!window.showDirectoryPicker) { alert('Browser does not support directory picker (use Chrome/Edge)'); return; }
|
|
2717
|
+
let dirHandle;
|
|
2718
|
+
try { dirHandle = await window.showDirectoryPicker(); } catch { return; } // user cancelled
|
|
2719
|
+
const basePath = vfsPath ? vfsPath + '/' + dirHandle.name : dirHandle.name;
|
|
2720
|
+
// recursively collect all files
|
|
2721
|
+
async function collectFiles(handle, prefix) {
|
|
2722
|
+
const files = [];
|
|
2723
|
+
for await (const [name, entry] of handle.entries()) {
|
|
2724
|
+
const path = prefix ? prefix + '/' + name : name;
|
|
2725
|
+
if (entry.kind === 'file') {
|
|
2726
|
+
files.push({ path, handle: entry });
|
|
2727
|
+
} else {
|
|
2728
|
+
files.push(...await collectFiles(entry, path));
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
return files;
|
|
2732
|
+
}
|
|
2733
|
+
showStatus('vfs-explorer-status', 'Scanning folder\u2026', true);
|
|
2734
|
+
const files = await collectFiles(dirHandle, '');
|
|
2735
|
+
if (!files.length) { showStatus('vfs-explorer-status', 'Folder is empty', false); return; }
|
|
2736
|
+
const t0 = performance.now();
|
|
2737
|
+
let ok = 0, fail = 0;
|
|
2738
|
+
showStatus('vfs-explorer-status', 'Uploading 0/' + files.length + '\u2026', true);
|
|
2739
|
+
// upload in batches of 5
|
|
2740
|
+
for (let i = 0; i < files.length; i += 5) {
|
|
2741
|
+
const batch = files.slice(i, i + 5);
|
|
2742
|
+
const results = await Promise.allSettled(batch.map(async (f) => {
|
|
2743
|
+
const file = await f.handle.getFile();
|
|
2744
|
+
const res = await fetch('/files/' + basePath + '/' + f.path, {
|
|
2745
|
+
method: 'POST', body: file,
|
|
2746
|
+
headers: { 'Content-Type': file.type || 'application/octet-stream' }
|
|
2747
|
+
});
|
|
2748
|
+
const json = await res.json();
|
|
2749
|
+
if (!json.ok) throw new Error(json.error);
|
|
2750
|
+
}));
|
|
2751
|
+
for (const r of results) r.status === 'fulfilled' ? ok++ : fail++;
|
|
2752
|
+
showStatus('vfs-explorer-status', 'Uploading ' + (ok + fail) + '/' + files.length + '\u2026', true);
|
|
2753
|
+
}
|
|
2754
|
+
const ms = performance.now() - t0;
|
|
2755
|
+
showStatus('vfs-explorer-status', 'Synced ' + ok + ' files' + (fail ? ', ' + fail + ' failed' : ''), !fail, ms);
|
|
2756
|
+
vfsNavigate(vfsPath);
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
async function vfsMkdirHere() {
|
|
2760
|
+
const name = prompt('Folder name:');
|
|
2761
|
+
if (!name) return;
|
|
2762
|
+
const path = (vfsPath ? vfsPath + '/' : '') + name;
|
|
2763
|
+
const t0 = performance.now();
|
|
2764
|
+
try {
|
|
2765
|
+
const res = await fetch('/files/' + path + '?mkdir=1', { method: 'POST' });
|
|
2766
|
+
const json = await res.json();
|
|
2767
|
+
showStatus('vfs-explorer-status', json.ok ? 'Created ' + name : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
2768
|
+
} catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
|
|
2769
|
+
vfsNavigate(vfsPath);
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
async function vfsDownload() {
|
|
2773
|
+
if (!vfsSelected) return;
|
|
2774
|
+
const t0 = performance.now();
|
|
2775
|
+
try {
|
|
2776
|
+
const res = await fetch('/files/' + vfsSelected.fullPath);
|
|
2777
|
+
if (!res.ok) return showStatus('vfs-explorer-status', 'Not found', false, performance.now() - t0);
|
|
2778
|
+
const blob = await res.blob();
|
|
2779
|
+
const url = URL.createObjectURL(blob);
|
|
2780
|
+
const a = document.createElement('a'); a.href = url; a.download = vfsSelected.name;
|
|
2781
|
+
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
|
2782
|
+
showStatus('vfs-explorer-status', 'Downloaded ' + vfsSelected.name + ' (' + vfsFormatSize(blob.size) + ')', true, performance.now() - t0);
|
|
2783
|
+
} catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
async function vfsRename() {
|
|
2787
|
+
if (!vfsSelected) return;
|
|
2788
|
+
const newName = prompt('New name:', vfsSelected.name);
|
|
2789
|
+
if (!newName || newName === vfsSelected.name) return;
|
|
2790
|
+
const dst = (vfsPath ? vfsPath + '/' : '') + newName;
|
|
2791
|
+
const t0 = performance.now();
|
|
2792
|
+
try {
|
|
2793
|
+
const res = await fetch('/files/' + vfsSelected.fullPath + '?move=' + encodeURIComponent(dst), { method: 'PUT' });
|
|
2794
|
+
const json = await res.json();
|
|
2795
|
+
showStatus('vfs-explorer-status', json.ok ? 'Renamed → ' + newName : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
2796
|
+
} catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
|
|
2797
|
+
vfsNavigate(vfsPath);
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
async function vfsDeleteSel() {
|
|
2801
|
+
if (!vfsSelected) return;
|
|
2802
|
+
const t0 = performance.now();
|
|
2803
|
+
try {
|
|
2804
|
+
const res = await fetch('/files/' + vfsSelected.fullPath, { method: 'DELETE' });
|
|
2805
|
+
const json = await res.json();
|
|
2806
|
+
showStatus('vfs-explorer-status', json.ok ? 'Deleted ' + vfsSelected.name : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
2807
|
+
} catch (e) { showStatus('vfs-explorer-status', e.message, false, performance.now() - t0); }
|
|
2808
|
+
vfsSelected = null;
|
|
2809
|
+
document.getElementById('vfs-preview').style.display = 'none';
|
|
2810
|
+
vfsNavigate(vfsPath);
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
// ── VFS Advanced (raw API) ────────────────────────────────────────────────────
|
|
2814
|
+
async function doVfsUpload() {
|
|
2815
|
+
const path = document.getElementById('vfs-upload-path').value.trim();
|
|
2816
|
+
const fileInput = document.getElementById('vfs-adv-upload-file');
|
|
2817
|
+
if (!path) return showStatus('vfs-upload-status', 'Path required', false);
|
|
2818
|
+
if (!fileInput.files.length) return showStatus('vfs-upload-status', 'Select a file', false);
|
|
2819
|
+
const file = fileInput.files[0];
|
|
2820
|
+
const t0 = performance.now();
|
|
2821
|
+
try {
|
|
2822
|
+
const res = await fetch('/files/' + path, { method: 'POST', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
|
|
2823
|
+
const json = await res.json();
|
|
2824
|
+
const ms = performance.now() - t0;
|
|
2825
|
+
if (json.ok) {
|
|
2826
|
+
for (const id of ['vfs-download-path','vfs-stat-path','vfs-delete-path','vfs-move-src']) document.getElementById(id).value = path;
|
|
2827
|
+
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
|
|
2828
|
+
document.getElementById('vfs-list-path').value = dir || '/';
|
|
2829
|
+
}
|
|
2830
|
+
showStatus('vfs-upload-status', json.ok ? `Uploaded ${json.data.size} bytes (${json.data.mime})` : (json.error || 'Failed'), json.ok, ms);
|
|
2831
|
+
} catch (e) { showStatus('vfs-upload-status', e.message, false, performance.now() - t0); }
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
async function doVfsDownload() {
|
|
2835
|
+
const path = document.getElementById('vfs-download-path').value.trim();
|
|
2836
|
+
if (!path) return showStatus('vfs-download-status', 'Path required', false);
|
|
2837
|
+
const t0 = performance.now();
|
|
2838
|
+
try {
|
|
2839
|
+
const res = await fetch('/files/' + path);
|
|
2840
|
+
if (!res.ok) { showStatus('vfs-download-status', 'Not found', false, performance.now() - t0); return; }
|
|
2841
|
+
const blob = await res.blob();
|
|
2842
|
+
const url = URL.createObjectURL(blob);
|
|
2843
|
+
const a = document.createElement('a');
|
|
2844
|
+
a.href = url; a.download = path.split('/').pop();
|
|
2845
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
2846
|
+
URL.revokeObjectURL(url);
|
|
2847
|
+
showStatus('vfs-download-status', `Downloaded ${blob.size} bytes`, true, performance.now() - t0);
|
|
2848
|
+
} catch (e) { showStatus('vfs-download-status', e.message, false, performance.now() - t0); }
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
async function doVfsStat() {
|
|
2852
|
+
const path = document.getElementById('vfs-stat-path').value.trim();
|
|
2853
|
+
if (!path) return showStatus('vfs-stat-status', 'Path required', false);
|
|
2854
|
+
const t0 = performance.now();
|
|
2855
|
+
try {
|
|
2856
|
+
const res = await fetch('/files/' + path + '?stat=1');
|
|
2857
|
+
const json = await res.json();
|
|
2858
|
+
const ms = performance.now() - t0;
|
|
2859
|
+
const el = document.getElementById('vfs-stat-result');
|
|
2860
|
+
if (json.ok) { el.style.display = 'block'; el.textContent = JSON.stringify(json.data, null, 2); }
|
|
2861
|
+
else { el.style.display = 'none'; }
|
|
2862
|
+
showStatus('vfs-stat-status', json.ok ? `${json.data.isDir ? 'Dir' : 'File'}: ${json.data.name} (${json.data.size}b)` : (json.error || 'Not found'), json.ok, ms);
|
|
2863
|
+
} catch (e) { showStatus('vfs-stat-status', e.message, false, performance.now() - t0); }
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
async function doVfsList() {
|
|
2867
|
+
const path = document.getElementById('vfs-list-path').value.trim();
|
|
2868
|
+
if (!path) return showStatus('vfs-list-status', 'Path required', false);
|
|
2869
|
+
const t0 = performance.now();
|
|
2870
|
+
try {
|
|
2871
|
+
const res = await fetch('/files/' + path + '?list=1');
|
|
2872
|
+
const json = await res.json();
|
|
2873
|
+
const ms = performance.now() - t0;
|
|
2874
|
+
const el = document.getElementById('vfs-list-result');
|
|
2875
|
+
if (json.ok) { el.style.display = 'block'; el.textContent = JSON.stringify(json.data, null, 2); }
|
|
2876
|
+
else { el.style.display = 'none'; }
|
|
2877
|
+
showStatus('vfs-list-status', json.ok ? `${json.data.length} entries` : (json.error || 'Failed'), json.ok, ms);
|
|
2878
|
+
} catch (e) { showStatus('vfs-list-status', e.message, false, performance.now() - t0); }
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
async function doVfsMkdir() {
|
|
2882
|
+
const path = document.getElementById('vfs-mkdir-path').value.trim();
|
|
2883
|
+
if (!path) return showStatus('vfs-mkdir-status', 'Path required', false);
|
|
2884
|
+
const t0 = performance.now();
|
|
2885
|
+
try {
|
|
2886
|
+
const res = await fetch('/files/' + path + '?mkdir=1', { method: 'POST' });
|
|
2887
|
+
const json = await res.json();
|
|
2888
|
+
showStatus('vfs-mkdir-status', json.ok ? `Created: ${path}` : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
2889
|
+
} catch (e) { showStatus('vfs-mkdir-status', e.message, false, performance.now() - t0); }
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
async function doVfsMove() {
|
|
2893
|
+
const src = document.getElementById('vfs-move-src').value.trim();
|
|
2894
|
+
const dst = document.getElementById('vfs-move-dst').value.trim();
|
|
2895
|
+
if (!src || !dst) return showStatus('vfs-move-status', 'Both paths required', false);
|
|
2896
|
+
const t0 = performance.now();
|
|
2897
|
+
try {
|
|
2898
|
+
const res = await fetch('/files/' + src + '?move=' + encodeURIComponent(dst), { method: 'PUT' });
|
|
2899
|
+
const json = await res.json();
|
|
2900
|
+
showStatus('vfs-move-status', json.ok ? `Moved → ${json.data.path}` : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
2901
|
+
} catch (e) { showStatus('vfs-move-status', e.message, false, performance.now() - t0); }
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
async function doVfsDelete() {
|
|
2905
|
+
const path = document.getElementById('vfs-delete-path').value.trim();
|
|
2906
|
+
if (!path) return showStatus('vfs-delete-status', 'Path required', false);
|
|
2907
|
+
const t0 = performance.now();
|
|
2908
|
+
try {
|
|
2909
|
+
const res = await fetch('/files/' + path, { method: 'DELETE' });
|
|
2910
|
+
const json = await res.json();
|
|
2911
|
+
showStatus('vfs-delete-status', json.ok ? `Deleted: ${path}` : (json.error || 'Failed'), json.ok, performance.now() - t0);
|
|
2912
|
+
} catch (e) { showStatus('vfs-delete-status', e.message, false, performance.now() - t0); }
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
// ── Cache Demo ──────────────────────────────────────────────────────────────────
|
|
2916
|
+
const _cache = { memory: new Map(), subOff: null, log: [] };
|
|
2917
|
+
|
|
2918
|
+
function _cacheLog(msg) {
|
|
2919
|
+
_cache.log.push(`[${new Date().toLocaleTimeString()}] ${msg}`);
|
|
2920
|
+
if (_cache.log.length > 20) _cache.log.shift();
|
|
2921
|
+
const el = document.getElementById('cache-log');
|
|
2922
|
+
if (el) el.textContent = _cache.log.join('\n');
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
async function cacheSet() {
|
|
2926
|
+
const path = document.getElementById('cache-path').value.trim();
|
|
2927
|
+
const val = JSON.parse(document.getElementById('cache-value').value);
|
|
2928
|
+
await db.set(path, val);
|
|
2929
|
+
_cache.memory.delete(path); // invalidate
|
|
2930
|
+
_cacheLog(`SET ${path} → invalidated cache`);
|
|
2931
|
+
document.getElementById('cache-result').textContent = `✓ Set ${path} and invalidated cache`;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
async function cacheGet() {
|
|
2935
|
+
const path = document.getElementById('cache-path').value.trim();
|
|
2936
|
+
const t0 = performance.now();
|
|
2937
|
+
const cached = _cache.memory.get(path);
|
|
2938
|
+
if (cached) {
|
|
2939
|
+
const ms = (performance.now() - t0).toFixed(2);
|
|
2940
|
+
_cacheLog(`HIT ${path} (${ms}ms) — served from memory`);
|
|
2941
|
+
document.getElementById('cache-result').textContent = `[CACHE HIT · ${ms}ms]\n` + JSON.stringify(cached.data, null, 2) +
|
|
2942
|
+
(cached.updatedAt ? `\n\nupdatedAt: ${new Date(cached.updatedAt).toLocaleString()}` : '');
|
|
2943
|
+
// background revalidate
|
|
2944
|
+
db.getSnapshot(path).then(snap => {
|
|
2945
|
+
_cache.memory.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
2946
|
+
_cacheLog(`REVALIDATED ${path}`);
|
|
2947
|
+
}).catch(() => {});
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
const snap = await db.getSnapshot(path);
|
|
2951
|
+
const ms = (performance.now() - t0).toFixed(2);
|
|
2952
|
+
_cache.memory.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
2953
|
+
_cacheLog(`MISS ${path} (${ms}ms) — fetched from network, cached`);
|
|
2954
|
+
document.getElementById('cache-result').textContent = `[CACHE MISS · ${ms}ms]\n` + JSON.stringify(snap.val(), null, 2) +
|
|
2955
|
+
(snap.updatedAt ? `\n\nupdatedAt: ${new Date(snap.updatedAt).toLocaleString()}` : '');
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
async function cacheGetFresh() {
|
|
2959
|
+
const path = document.getElementById('cache-path').value.trim();
|
|
2960
|
+
const t0 = performance.now();
|
|
2961
|
+
const snap = await db.getSnapshot(path);
|
|
2962
|
+
const ms = (performance.now() - t0).toFixed(2);
|
|
2963
|
+
_cache.memory.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
2964
|
+
_cacheLog(`FRESH ${path} (${ms}ms) — network fetch, cache updated`);
|
|
2965
|
+
document.getElementById('cache-result').textContent = `[NETWORK · ${ms}ms]\n` + JSON.stringify(snap.val(), null, 2) +
|
|
2966
|
+
(snap.updatedAt ? `\n\nupdatedAt: ${new Date(snap.updatedAt).toLocaleString()}` : '');
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
function cacheSub() {
|
|
2970
|
+
if (_cache.subOff) { _cacheLog('Already subscribed'); return; }
|
|
2971
|
+
const path = document.getElementById('cache-path').value.trim();
|
|
2972
|
+
_cache.subOff = db.on(path, (snap) => {
|
|
2973
|
+
_cache.memory.set(path, { data: snap.val(), updatedAt: snap.updatedAt });
|
|
2974
|
+
_cacheLog(`SUB UPDATE ${path} — cache refreshed`);
|
|
2975
|
+
document.getElementById('cache-result').textContent = `[LIVE · subscribed]\n` + JSON.stringify(snap.val(), null, 2) +
|
|
2976
|
+
(snap.updatedAt ? `\n\nupdatedAt: ${new Date(snap.updatedAt).toLocaleString()}` : '');
|
|
2977
|
+
});
|
|
2978
|
+
_cacheLog(`SUBSCRIBED to ${path} — cache stays fresh`);
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
function cacheUnsub() {
|
|
2982
|
+
if (_cache.subOff) { _cache.subOff(); _cache.subOff = null; _cacheLog('Unsubscribed'); }
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
function cacheStats() {
|
|
2986
|
+
const stats = { memoryEntries: _cache.memory.size, paths: [..._cache.memory.keys()], subscribed: !!_cache.subOff };
|
|
2987
|
+
document.getElementById('cache-result').textContent = JSON.stringify(stats, null, 2);
|
|
2988
|
+
_cacheLog(`Stats: ${_cache.memory.size} entries in memory`);
|
|
2989
|
+
}
|
|
2279
2990
|
</script>
|
|
2280
2991
|
</body>
|
|
2281
2992
|
</html>
|