@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/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; min-width: 110px; flex-shrink: 0; gap: 1px; }
13
- .metric-card:last-child { border-right: none; margin-left: auto; min-width: auto; }
14
- .metric-top { display: flex; justify-content: space-between; align-items: baseline; }
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()">↻</button>
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','{&quot;name&quot;:&quot;Widget A&quot;,&quot;price&quot;:34.99,&quot;stock&quot;:200}')">update widget price</button>
604
+ <button class="sm" onclick="fillReplWrite('catalog/new-item','{&quot;name&quot;:&quot;New Item&quot;,&quot;price&quot;:9.99,&quot;stock&quot;:50}')">add item</button>
605
+ <button class="sm" onclick="fillReplWrite('alerts/sys-3','{&quot;level&quot;:&quot;error&quot;,&quot;msg&quot;:&quot;Disk full&quot;,&quot;ts&quot;:' + 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/&lt;path&gt;</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.data ?? null) : p.reject(new Error(msg.error)); }
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
- _send(op, params = {}) {
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
- localStorage.setItem('zuzdb:tab', id);
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)) showTab(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.get(path).then(data => {
1251
- document.getElementById('view-time').textContent = (performance.now() - t0).toFixed(1) + ' ms';
1252
- document.getElementById('view-result').textContent = JSON.stringify(data, null, 2);
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 += ' &gt; <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 ? '&#x1F4C1;' : '&#x1F4C4;';
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>