@bod.ee/db 0.12.6 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -1
- package/admin/admin.ts +24 -4
- package/admin/bun.lock +248 -0
- package/admin/index.html +12 -0
- package/admin/package.json +22 -0
- package/admin/src/App.tsx +23 -0
- package/admin/src/client/ZuzClient.ts +183 -0
- package/admin/src/client/types.ts +28 -0
- package/admin/src/components/MetricsBar.tsx +167 -0
- package/admin/src/components/Sparkline.tsx +72 -0
- package/admin/src/components/TreePane.tsx +287 -0
- package/admin/src/components/tabs/Advanced.tsx +222 -0
- package/admin/src/components/tabs/AuthRules.tsx +104 -0
- package/admin/src/components/tabs/Cache.tsx +113 -0
- package/admin/src/components/tabs/KeyAuth.tsx +462 -0
- package/admin/src/components/tabs/MessageQueue.tsx +237 -0
- package/admin/src/components/tabs/Query.tsx +75 -0
- package/admin/src/components/tabs/ReadWrite.tsx +177 -0
- package/admin/src/components/tabs/Replication.tsx +94 -0
- package/admin/src/components/tabs/Streams.tsx +329 -0
- package/admin/src/components/tabs/StressTests.tsx +209 -0
- package/admin/src/components/tabs/Subscriptions.tsx +69 -0
- package/admin/src/components/tabs/TabPane.tsx +151 -0
- package/admin/src/components/tabs/VFS.tsx +435 -0
- package/admin/src/components/tabs/View.tsx +14 -0
- package/admin/src/components/tabs/utils.ts +25 -0
- package/admin/src/context/DbContext.tsx +33 -0
- package/admin/src/context/StatsContext.tsx +56 -0
- package/admin/src/main.tsx +10 -0
- package/admin/src/styles.css +96 -0
- package/admin/tsconfig.app.json +21 -0
- package/admin/tsconfig.json +7 -0
- package/admin/tsconfig.node.json +15 -0
- package/admin/vite.config.ts +42 -0
- package/deploy/base.yaml +1 -1
- package/deploy/prod-il.config.ts +5 -2
- package/deploy/prod.config.ts +5 -2
- package/package.json +5 -1
- package/src/server/BodDB.ts +62 -5
- package/src/server/ReplicationEngine.ts +149 -34
- package/src/server/StorageEngine.ts +12 -4
- package/src/server/StreamEngine.ts +2 -2
- package/src/server/Transport.ts +60 -0
- package/tests/replication.test.ts +162 -1
- package/admin/ui.html +0 -3547
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class ValueSnapshot {
|
|
2
|
+
constructor(
|
|
3
|
+
public path: string,
|
|
4
|
+
private _data: unknown,
|
|
5
|
+
public updatedAt?: number,
|
|
6
|
+
) {}
|
|
7
|
+
val() { return this._data; }
|
|
8
|
+
get key() { return this.path.split('/').pop()!; }
|
|
9
|
+
exists() { return this._data !== null && this._data !== undefined; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ChildEvent {
|
|
13
|
+
type: 'added' | 'changed' | 'removed';
|
|
14
|
+
key: string;
|
|
15
|
+
path: string;
|
|
16
|
+
val: () => unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FilterSpec {
|
|
20
|
+
field: string;
|
|
21
|
+
op: string;
|
|
22
|
+
value: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OrderSpec {
|
|
26
|
+
field: string;
|
|
27
|
+
dir: 'asc' | 'desc';
|
|
28
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useRef, useEffect, useState } from 'react';
|
|
2
|
+
import { useStats } from '../context/StatsContext';
|
|
3
|
+
import { useDb } from '../context/DbContext';
|
|
4
|
+
import { db } from '../context/DbContext';
|
|
5
|
+
import Sparkline, { SparklineHandle } from './Sparkline';
|
|
6
|
+
|
|
7
|
+
function fmtUptime(sec: number) {
|
|
8
|
+
if (sec < 60) return sec + 's';
|
|
9
|
+
if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
|
|
10
|
+
return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function MetricsBar() {
|
|
14
|
+
const { stats, statsEnabled, toggleStats } = useStats();
|
|
15
|
+
const { connected } = useDb();
|
|
16
|
+
const [ping, setPing] = useState<number | null>(null);
|
|
17
|
+
|
|
18
|
+
const cpuRef = useRef<SparklineHandle>(null);
|
|
19
|
+
const heapRef = useRef<SparklineHandle>(null);
|
|
20
|
+
const rssRef = useRef<SparklineHandle>(null);
|
|
21
|
+
const nodesRef = useRef<SparklineHandle>(null);
|
|
22
|
+
const pingRef = useRef<SparklineHandle>(null);
|
|
23
|
+
|
|
24
|
+
const pingValueRef = useRef<number>(0);
|
|
25
|
+
|
|
26
|
+
// Ping: measure WS round-trip every 5s
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const interval = setInterval(async () => {
|
|
29
|
+
try {
|
|
30
|
+
const t0 = performance.now();
|
|
31
|
+
await db.get('__ping__');
|
|
32
|
+
const ms = Math.round(performance.now() - t0);
|
|
33
|
+
pingValueRef.current = ms;
|
|
34
|
+
setPing(ms);
|
|
35
|
+
} catch {}
|
|
36
|
+
}, 5000);
|
|
37
|
+
return () => clearInterval(interval);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
// Push all sparklines on every stats tick (1s)
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (stats) {
|
|
43
|
+
cpuRef.current?.push(stats.process.cpuPercent);
|
|
44
|
+
heapRef.current?.push(stats.process.heapUsedMb);
|
|
45
|
+
rssRef.current?.push(stats.process.rssMb);
|
|
46
|
+
nodesRef.current?.push(stats.db.nodeCount);
|
|
47
|
+
pingRef.current?.push(pingValueRef.current);
|
|
48
|
+
}
|
|
49
|
+
}, [stats]);
|
|
50
|
+
|
|
51
|
+
const cpu = stats?.process.cpuPercent;
|
|
52
|
+
const replCard = stats?.repl;
|
|
53
|
+
const dbPath = stats?.db.path as string | undefined;
|
|
54
|
+
const dbPathName = dbPath ? (dbPath.includes('/') ? dbPath.substring(dbPath.lastIndexOf('/') + 1) : dbPath) : null;
|
|
55
|
+
const dbPathDir = dbPath ? (dbPath.includes('/') ? dbPath.substring(0, dbPath.lastIndexOf('/')) : '.') : null;
|
|
56
|
+
const dbPathIsLocal = dbPath ? (!dbPath.startsWith('http') && !dbPath.startsWith('ws') && dbPath !== ':memory:') : false;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<div className="metric-card">
|
|
61
|
+
<div className="metric-top">
|
|
62
|
+
<span className="metric-label">CPU</span>
|
|
63
|
+
<span className={`metric-value${cpu != null && cpu > 80 ? ' warn' : ''}`}>{cpu != null ? cpu + '%' : '—'}</span>
|
|
64
|
+
</div>
|
|
65
|
+
<Sparkline ref={cpuRef} color="#ce9178" />
|
|
66
|
+
<div style={{ fontSize: 10, color: '#555', marginTop: 'auto' }}>
|
|
67
|
+
sys <span>{stats?.system?.cpuPercent ?? '—'}%</span> · <span>{stats?.system?.cpuCores ?? '—'}</span> cores
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div className="metric-card">
|
|
72
|
+
<div className="metric-top">
|
|
73
|
+
<span className="metric-label">Heap</span>
|
|
74
|
+
<span className="metric-value">{stats ? stats.process.heapUsedMb + ' MB' : '—'}</span>
|
|
75
|
+
</div>
|
|
76
|
+
<Sparkline ref={heapRef} color="#569cd6" />
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div className="metric-card">
|
|
80
|
+
<div className="metric-top">
|
|
81
|
+
<span className="metric-label">RSS</span>
|
|
82
|
+
<span className="metric-value">{stats ? stats.process.rssMb + ' MB' : '—'}</span>
|
|
83
|
+
</div>
|
|
84
|
+
<Sparkline ref={rssRef} color="#9cdcfe" />
|
|
85
|
+
<div style={{ fontSize: 10, color: '#555', marginTop: 'auto' }}>
|
|
86
|
+
of <span>{stats?.system ? (stats.system.totalMemMb / 1024).toFixed(1) + ' GB' : '—'}</span> total
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className="metric-card">
|
|
91
|
+
<div className="metric-top">
|
|
92
|
+
<span className="metric-label">Nodes</span>
|
|
93
|
+
<span className="metric-value">{stats?.db.nodeCount ?? '—'}</span>
|
|
94
|
+
</div>
|
|
95
|
+
<Sparkline ref={nodesRef} color="#4ec9b0" />
|
|
96
|
+
<div style={{ fontSize: 10, color: '#555', marginTop: 'auto' }}>
|
|
97
|
+
<span>{stats?.db.sizeMb ?? '—'}</span> MB on disk
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div className="metric-card">
|
|
102
|
+
<div className="metric-top">
|
|
103
|
+
<span className="metric-label">Ping</span>
|
|
104
|
+
<span className={`metric-value${ping != null && ping > 200 ? ' warn' : ''}`}>{ping != null ? ping + ' ms' : '—'}</span>
|
|
105
|
+
</div>
|
|
106
|
+
<Sparkline ref={pingRef} color="#c586c0" />
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div style={{ marginLeft: 'auto', display: 'flex', flexShrink: 0 }}>
|
|
110
|
+
{replCard && (
|
|
111
|
+
<div className="metric-card" style={{ borderLeft: '1px solid #282828', width: 180 }}>
|
|
112
|
+
<div className="metric-top">
|
|
113
|
+
<span className="metric-label">Replication</span>
|
|
114
|
+
<span className="metric-value dim">{replCard.role}</span>
|
|
115
|
+
</div>
|
|
116
|
+
<div style={{ marginTop: 4, fontSize: 10 }}>
|
|
117
|
+
{replCard.sources.length ? replCard.sources.map((src, i) => {
|
|
118
|
+
const dot = src.connected ? <span style={{ color: '#4ec9b0' }}>●</span> : <span style={{ color: '#f48771' }}>●</span>;
|
|
119
|
+
const pend = src.pending;
|
|
120
|
+
const pendColor = pend > 100 ? '#f48771' : pend > 10 ? '#ce9178' : '#4ec9b0';
|
|
121
|
+
const prefix = src.localPrefix ? `→${src.localPrefix}/` : '';
|
|
122
|
+
return (
|
|
123
|
+
<span key={i} style={{ color: '#555', display: 'block' }}>
|
|
124
|
+
{dot} {src.paths.join(',')} {prefix} <span style={{ color: pendColor }}>{pend} behind</span> · {src.eventsApplied}ev
|
|
125
|
+
</span>
|
|
126
|
+
);
|
|
127
|
+
}) : <span style={{ color: '#555' }}>no sources</span>}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
<div className="metric-card" style={{ borderLeft: '1px solid #282828' }}>
|
|
132
|
+
<div className="metric-top">
|
|
133
|
+
<span className="metric-label">Uptime</span>
|
|
134
|
+
<span className="metric-value dim" style={{ fontSize: 11 }}>{stats ? fmtUptime(stats.process.uptimeSec) : '—'}</span>
|
|
135
|
+
</div>
|
|
136
|
+
<div style={{ fontSize: 10, color: '#555', display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 0', marginTop: 4, lineHeight: '13px' }}>
|
|
137
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, height: 13 }}>
|
|
138
|
+
<span className="metric-label" style={{ color: '#555', lineHeight: '13px' }}>WS</span>
|
|
139
|
+
<span onClick={toggleStats} title="Toggle server stats collection"
|
|
140
|
+
style={{ cursor: 'pointer', userSelect: 'none', display: 'inline-flex', alignItems: 'center',
|
|
141
|
+
width: 20, height: 10, borderRadius: 5, padding: '0 2px', boxSizing: 'border-box', flexShrink: 0,
|
|
142
|
+
background: statsEnabled ? '#1a3a2a' : '#1c1c1c',
|
|
143
|
+
border: '1px solid ' + (statsEnabled ? '#2a5a3a' : '#2e2e2e'),
|
|
144
|
+
transition: 'background 0.2s, border-color 0.2s', justifyContent: statsEnabled ? 'flex-end' : 'flex-start' }}>
|
|
145
|
+
<span style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
|
|
146
|
+
background: statsEnabled ? '#4ec9b0' : '#3a3a3a', display: 'inline-block',
|
|
147
|
+
...(statsEnabled ? { animation: 'pulse 1.5s infinite' } : {}) }}></span>
|
|
148
|
+
</span>
|
|
149
|
+
</span>
|
|
150
|
+
<span style={{ textAlign: 'right', lineHeight: '13px' }}>{stats ? new Date(stats.ts).toLocaleTimeString() : '—'}</span>
|
|
151
|
+
<span style={{ lineHeight: '13px' }}>{stats?.subs ?? 0} subs</span>
|
|
152
|
+
<span style={{ textAlign: 'right', lineHeight: '13px' }}>v{stats?.version ?? '—'}</span>
|
|
153
|
+
<span style={{ lineHeight: '13px' }}>{stats?.clients ?? 0} clients</span>
|
|
154
|
+
<span style={{ textAlign: 'right', lineHeight: '13px' }}>
|
|
155
|
+
{dbPath && (dbPathIsLocal ? (
|
|
156
|
+
<a href="#" onClick={(e) => { e.preventDefault(); fetch(`/open-folder?path=${encodeURIComponent(dbPath)}`); }}
|
|
157
|
+
style={{ color: '#444', textDecoration: 'none', cursor: 'pointer' }} title={dbPath}>./{dbPathName}</a>
|
|
158
|
+
) : (
|
|
159
|
+
<span style={{ color: '#333' }} title={dbPath}>{dbPath}</span>
|
|
160
|
+
))}
|
|
161
|
+
</span>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
|
|
2
|
+
|
|
3
|
+
const SPARK_POINTS = 60;
|
|
4
|
+
|
|
5
|
+
function hexToRgba(hex: string, a: number) {
|
|
6
|
+
const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
|
|
7
|
+
return `rgba(${r},${g},${b},${a})`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SparklineHandle {
|
|
11
|
+
push(v: number): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
color?: string;
|
|
16
|
+
id?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const Sparkline = forwardRef<SparklineHandle, Props>(({ color = '#4ec9b0', id }, ref) => {
|
|
20
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
21
|
+
const dataRef = useRef<number[]>([]);
|
|
22
|
+
|
|
23
|
+
useImperativeHandle(ref, () => ({
|
|
24
|
+
push(v: number) {
|
|
25
|
+
dataRef.current.push(v);
|
|
26
|
+
if (dataRef.current.length > SPARK_POINTS) dataRef.current.shift();
|
|
27
|
+
draw();
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!canvasRef.current) return;
|
|
33
|
+
const canvas = canvasRef.current;
|
|
34
|
+
const dpr = window.devicePixelRatio || 1;
|
|
35
|
+
const w = canvas.clientWidth || 100, h = canvas.clientHeight || 28;
|
|
36
|
+
canvas.width = w * dpr; canvas.height = h * dpr;
|
|
37
|
+
const ctx = canvas.getContext('2d')!;
|
|
38
|
+
ctx.scale(dpr, dpr);
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
function draw() {
|
|
42
|
+
const canvas = canvasRef.current;
|
|
43
|
+
if (!canvas) return;
|
|
44
|
+
const ctx = canvas.getContext('2d')!;
|
|
45
|
+
const dpr = window.devicePixelRatio || 1;
|
|
46
|
+
const W = canvas.width / dpr, H = canvas.height / dpr;
|
|
47
|
+
const data = dataRef.current;
|
|
48
|
+
ctx.clearRect(0, 0, W, H);
|
|
49
|
+
if (data.length < 2) return;
|
|
50
|
+
const min = Math.min(...data), max = Math.max(...data), range = max - min;
|
|
51
|
+
const xStep = W / (SPARK_POINTS - 1), pad = 2;
|
|
52
|
+
const yScale = (v: number) => range === 0 ? H / 2 : H - pad - ((v - min) / range) * (H - pad * 2);
|
|
53
|
+
const x0 = (SPARK_POINTS - data.length) * xStep;
|
|
54
|
+
ctx.beginPath();
|
|
55
|
+
ctx.moveTo(x0, H);
|
|
56
|
+
data.forEach((v, i) => ctx.lineTo(x0 + i * xStep, yScale(v)));
|
|
57
|
+
ctx.lineTo(x0 + (data.length - 1) * xStep, H);
|
|
58
|
+
ctx.closePath();
|
|
59
|
+
ctx.fillStyle = hexToRgba(color, 0.15);
|
|
60
|
+
ctx.fill();
|
|
61
|
+
ctx.beginPath();
|
|
62
|
+
data.forEach((v, i) => { const x = x0 + i * xStep; i === 0 ? ctx.moveTo(x, yScale(v)) : ctx.lineTo(x, yScale(v)); });
|
|
63
|
+
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.stroke();
|
|
64
|
+
const lv = data[data.length - 1];
|
|
65
|
+
ctx.beginPath(); ctx.arc(x0 + (data.length - 1) * xStep, yScale(lv), 2.5, 0, Math.PI * 2);
|
|
66
|
+
ctx.fillStyle = color; ctx.fill();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return <canvas id={id} ref={canvasRef} className="metric-canvas" width={100} height={28} />;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export default Sparkline;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { db } from '../context/DbContext';
|
|
3
|
+
import { useDb } from '../context/DbContext';
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
interface Window { _vfsItems?: unknown[]; }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const OPEN_KEY = 'zuzdb:treeOpen';
|
|
10
|
+
const showAdmin = new URLSearchParams(location.search).has('showAdmin');
|
|
11
|
+
|
|
12
|
+
function getOpenPaths(): Set<string> {
|
|
13
|
+
const open = new Set<string>();
|
|
14
|
+
document.querySelectorAll('#tree-container details[open]').forEach((d) => {
|
|
15
|
+
open.add((d as HTMLElement).dataset.path!);
|
|
16
|
+
});
|
|
17
|
+
return open;
|
|
18
|
+
}
|
|
19
|
+
function saveOpenPaths(open: Set<string>) {
|
|
20
|
+
try { localStorage.setItem(OPEN_KEY, JSON.stringify([...open])); } catch {}
|
|
21
|
+
}
|
|
22
|
+
function loadOpenPaths(): Set<string> {
|
|
23
|
+
try { return new Set(JSON.parse(localStorage.getItem(OPEN_KEY) ?? '[]')); } catch { return new Set(); }
|
|
24
|
+
}
|
|
25
|
+
function escHtml(s: string) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
26
|
+
|
|
27
|
+
interface ShallowChild {
|
|
28
|
+
key: string;
|
|
29
|
+
isLeaf: boolean;
|
|
30
|
+
value?: unknown;
|
|
31
|
+
count?: number;
|
|
32
|
+
ttl?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function selectPath(path: string) {
|
|
36
|
+
const rwPath = document.getElementById('rw-path') as HTMLInputElement;
|
|
37
|
+
const subPath = document.getElementById('sub-path') as HTMLInputElement;
|
|
38
|
+
const qPath = document.getElementById('q-path') as HTMLInputElement;
|
|
39
|
+
const viewPath = document.getElementById('view-path');
|
|
40
|
+
const viewTime = document.getElementById('view-time');
|
|
41
|
+
const viewResult = document.getElementById('view-result');
|
|
42
|
+
if (rwPath) rwPath.value = path;
|
|
43
|
+
if (subPath) subPath.value = path;
|
|
44
|
+
if (qPath) qPath.value = path.includes('/') ? path.split('/').slice(0, -1).join('/') : path;
|
|
45
|
+
// Switch to view tab
|
|
46
|
+
const tabEvent = new CustomEvent('zuzdb:selectPath', { detail: path });
|
|
47
|
+
window.dispatchEvent(tabEvent);
|
|
48
|
+
if (viewPath) viewPath.textContent = path;
|
|
49
|
+
if (viewTime) viewTime.textContent = '';
|
|
50
|
+
if (viewResult) viewResult.textContent = 'Loading…';
|
|
51
|
+
const t0 = performance.now();
|
|
52
|
+
db.getSnapshot(path).then(snap => {
|
|
53
|
+
const elapsed = (performance.now() - t0).toFixed(1) + ' ms';
|
|
54
|
+
const updated = snap.updatedAt ? new Date(snap.updatedAt).toLocaleString() : '';
|
|
55
|
+
if (viewTime) viewTime.textContent = elapsed + (updated ? ` · updated ${updated}` : '');
|
|
56
|
+
if (viewResult) viewResult.textContent = JSON.stringify(snap.val(), null, 2);
|
|
57
|
+
}).catch(e => {
|
|
58
|
+
if (viewTime) viewTime.textContent = (performance.now() - t0).toFixed(1) + ' ms';
|
|
59
|
+
if (viewResult) viewResult.textContent = 'Error: ' + e.message;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default function TreePane() {
|
|
64
|
+
const { connected } = useDb();
|
|
65
|
+
const loadedPathsRef = useRef(new Set<string>());
|
|
66
|
+
const treeGenRef = useRef(0);
|
|
67
|
+
const restoredOpenRef = useRef<Set<string>>(loadOpenPaths());
|
|
68
|
+
const treeUnsubs = useRef(new Map<string, () => void>());
|
|
69
|
+
const pendingChangedPaths = useRef(new Set<string>());
|
|
70
|
+
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
71
|
+
const lastAdminRefresh = useRef(0);
|
|
72
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
73
|
+
const nodeCountRef = useRef<HTMLSpanElement>(null);
|
|
74
|
+
|
|
75
|
+
async function fetchChildren(path?: string): Promise<ShallowChild[]> {
|
|
76
|
+
try {
|
|
77
|
+
return (await db.getShallow(path || undefined) as ShallowChild[]) || [];
|
|
78
|
+
} catch { return []; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderChildren(children: ShallowChild[], parentPath: string): string {
|
|
82
|
+
let html = '';
|
|
83
|
+
for (const ch of children) {
|
|
84
|
+
const path = parentPath ? parentPath + '/' + ch.key : ch.key;
|
|
85
|
+
if (!showAdmin && path.startsWith('_admin')) continue;
|
|
86
|
+
const ttlBadge = ch.ttl != null && ch.ttl >= 0
|
|
87
|
+
? `<span class="ttl-badge" title="Expires in ${ch.ttl}s">${ch.ttl}s</span>`
|
|
88
|
+
: ch.ttl === -1 ? '<span class="ttl-badge" title="Contains TTL entries">ttl</span>' : '';
|
|
89
|
+
if (ch.isLeaf) {
|
|
90
|
+
const raw = typeof ch.value === 'object' && ch.value !== null ? JSON.stringify(ch.value) : String(ch.value ?? '');
|
|
91
|
+
const display = raw.length > 36 ? raw.slice(0, 36) + '…' : raw;
|
|
92
|
+
html += `<div class="tree-leaf" data-path="${escHtml(path)}" onclick="window.__treeSelectPath('${path.replace(/'/g, "\\'")}')">`
|
|
93
|
+
+ `<span class="tree-key">${escHtml(ch.key)}</span>${ttlBadge}`
|
|
94
|
+
+ `<span class="tree-val">${escHtml(String(display ?? ''))}</span></div>`;
|
|
95
|
+
} else {
|
|
96
|
+
const isOpen = restoredOpenRef.current.has(path);
|
|
97
|
+
const countBadge = ch.count != null ? `<span class="count-badge">${ch.count}</span>` : '';
|
|
98
|
+
html += `<details data-path="${escHtml(path)}"${isOpen ? ' open' : ''}>`
|
|
99
|
+
+ `<summary><span class="tree-label" onclick="window.__treeSelectPath('${path.replace(/'/g, "\\'")}')">${escHtml(ch.key)}</span>${ttlBadge}${countBadge}</summary>`
|
|
100
|
+
+ `<div class="tree-children" data-parent="${escHtml(path)}"></div></details>`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return html;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function updateNodeCount() {
|
|
107
|
+
if (!containerRef.current || !nodeCountRef.current) return;
|
|
108
|
+
const leaves = containerRef.current.querySelectorAll('.tree-leaf').length;
|
|
109
|
+
const branches = containerRef.current.querySelectorAll('details').length;
|
|
110
|
+
nodeCountRef.current.textContent = `${leaves} leaves, ${branches} branches`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function flashPaths(paths: string[]) {
|
|
114
|
+
for (const path of paths) {
|
|
115
|
+
let el = containerRef.current?.querySelector(`[data-path="${CSS.escape(path)}"]`) as HTMLElement | null;
|
|
116
|
+
if (!el) {
|
|
117
|
+
const parts = path.split('/');
|
|
118
|
+
while (parts.length > 1) {
|
|
119
|
+
parts.pop();
|
|
120
|
+
el = containerRef.current?.querySelector(`[data-path="${CSS.escape(parts.join('/'))}"]`) as HTMLElement | null;
|
|
121
|
+
if (el) break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (el) { el.classList.add('flash'); setTimeout(() => el!.classList.remove('flash'), 1200); }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function attachToggleListeners(root: Element) {
|
|
129
|
+
root.querySelectorAll('details').forEach(d => {
|
|
130
|
+
if ((d as HTMLElement & { _treeListener?: boolean })._treeListener) return;
|
|
131
|
+
(d as HTMLElement & { _treeListener?: boolean })._treeListener = true;
|
|
132
|
+
d.addEventListener('toggle', () => {
|
|
133
|
+
saveOpenPaths(getOpenPaths());
|
|
134
|
+
if ((d as HTMLDetailsElement).open) expandNode(d as HTMLDetailsElement);
|
|
135
|
+
}, { passive: true });
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const expandNode = useCallback(async (details: HTMLDetailsElement, isRefresh?: boolean) => {
|
|
140
|
+
const path = details.dataset.path!;
|
|
141
|
+
if (loadedPathsRef.current.has(path)) return;
|
|
142
|
+
loadedPathsRef.current.add(path);
|
|
143
|
+
const gen = treeGenRef.current;
|
|
144
|
+
const prevOpen = new Set<string>();
|
|
145
|
+
if (isRefresh) {
|
|
146
|
+
details.querySelectorAll('details[open]').forEach(d => prevOpen.add((d as HTMLElement).dataset.path!));
|
|
147
|
+
}
|
|
148
|
+
const childContainer = details.querySelector(':scope > .tree-children') as HTMLElement | null;
|
|
149
|
+
if (!childContainer) return;
|
|
150
|
+
const children = await fetchChildren(path);
|
|
151
|
+
if (gen !== treeGenRef.current) return;
|
|
152
|
+
for (const p of prevOpen) restoredOpenRef.current.add(p);
|
|
153
|
+
childContainer.innerHTML = renderChildren(children, path);
|
|
154
|
+
for (const det of childContainer.querySelectorAll(':scope details[open]')) {
|
|
155
|
+
expandNode(det as HTMLDetailsElement);
|
|
156
|
+
}
|
|
157
|
+
attachToggleListeners(childContainer);
|
|
158
|
+
updateNodeCount();
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
function subscribeTreeLive(topLevelKeys: string[]) {
|
|
162
|
+
const NOISY = new Set(['stress', '_admin']);
|
|
163
|
+
for (const [key, unsub] of treeUnsubs.current) {
|
|
164
|
+
if (!topLevelKeys.includes(key)) { unsub(); treeUnsubs.current.delete(key); }
|
|
165
|
+
}
|
|
166
|
+
for (const key of topLevelKeys.filter(k => !NOISY.has(k))) {
|
|
167
|
+
if (treeUnsubs.current.has(key)) continue;
|
|
168
|
+
const unsub = db.onChild(key, (ev) => debouncedLoadTree(ev.path));
|
|
169
|
+
treeUnsubs.current.set(key, unsub);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function debouncedLoadTree(path: string) {
|
|
174
|
+
pendingChangedPaths.current.add(path);
|
|
175
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
176
|
+
debounceTimer.current = setTimeout(() => {
|
|
177
|
+
const paths = [...pendingChangedPaths.current];
|
|
178
|
+
pendingChangedPaths.current.clear();
|
|
179
|
+
const roots = new Set<string>();
|
|
180
|
+
for (const p of paths) {
|
|
181
|
+
const top = p.split('/')[0];
|
|
182
|
+
if (top === '_admin') {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
if (now - lastAdminRefresh.current < 3000) continue;
|
|
185
|
+
lastAdminRefresh.current = now;
|
|
186
|
+
}
|
|
187
|
+
roots.add(top);
|
|
188
|
+
}
|
|
189
|
+
for (const r of roots) refreshPath(r);
|
|
190
|
+
}, 300);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const refreshPath = useCallback(async (path: string) => {
|
|
194
|
+
const parts = path.split('/');
|
|
195
|
+
let target = '';
|
|
196
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
197
|
+
const p = parts.slice(0, i).join('/');
|
|
198
|
+
if (loadedPathsRef.current.has(p)) target = p;
|
|
199
|
+
}
|
|
200
|
+
if (!target) {
|
|
201
|
+
const topKey = parts[0];
|
|
202
|
+
const existing = containerRef.current?.querySelector(`[data-path="${CSS.escape(topKey)}"]`);
|
|
203
|
+
if (!existing) { return loadTree([path]); }
|
|
204
|
+
flashPaths([path]);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
for (const p of [...loadedPathsRef.current]) {
|
|
208
|
+
if (p === target || p.startsWith(target + '/')) loadedPathsRef.current.delete(p);
|
|
209
|
+
}
|
|
210
|
+
const det = containerRef.current?.querySelector(`details[data-path="${CSS.escape(target)}"]`) as HTMLDetailsElement | null;
|
|
211
|
+
if (det && det.open) {
|
|
212
|
+
await expandNode(det, true);
|
|
213
|
+
flashPaths([path]);
|
|
214
|
+
} else {
|
|
215
|
+
flashPaths([path]);
|
|
216
|
+
}
|
|
217
|
+
}, [expandNode]);
|
|
218
|
+
|
|
219
|
+
const loadTree = useCallback(async (changedPaths?: string[]) => {
|
|
220
|
+
loadedPathsRef.current.clear();
|
|
221
|
+
const gen = ++treeGenRef.current;
|
|
222
|
+
restoredOpenRef.current = getOpenPaths().size ? getOpenPaths() : loadOpenPaths();
|
|
223
|
+
const children = await fetchChildren('');
|
|
224
|
+
if (!children || gen !== treeGenRef.current) return;
|
|
225
|
+
const container = containerRef.current;
|
|
226
|
+
if (!container) return;
|
|
227
|
+
container.innerHTML = renderChildren(children, '');
|
|
228
|
+
subscribeTreeLive(children.filter(c => !c.isLeaf).map(c => c.key));
|
|
229
|
+
for (const det of container.querySelectorAll('details[open]')) {
|
|
230
|
+
expandNode(det as HTMLDetailsElement);
|
|
231
|
+
}
|
|
232
|
+
attachToggleListeners(container);
|
|
233
|
+
updateNodeCount();
|
|
234
|
+
if (changedPaths) flashPaths(changedPaths);
|
|
235
|
+
}, [expandNode]);
|
|
236
|
+
|
|
237
|
+
// Expose selectPath and loadTree globally for tree leaf clicks
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
(window as unknown as Record<string, unknown>).__treeSelectPath = selectPath;
|
|
240
|
+
(window as unknown as Record<string, unknown>).__treeRefreshPath = refreshPath;
|
|
241
|
+
}, [refreshPath]);
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
if (!connected) return;
|
|
245
|
+
// Retry: first load may return empty if server is still initializing
|
|
246
|
+
let cancelled = false;
|
|
247
|
+
(async () => {
|
|
248
|
+
for (let i = 0; i < 3; i++) {
|
|
249
|
+
await loadTree();
|
|
250
|
+
if (cancelled || (containerRef.current?.children.length ?? 0) > 0) return;
|
|
251
|
+
await new Promise(r => setTimeout(r, 500));
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
254
|
+
return () => { cancelled = true; };
|
|
255
|
+
}, [connected, loadTree]);
|
|
256
|
+
|
|
257
|
+
// Handle #approve= deep link
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
if (location.hash.startsWith('#approve=')) {
|
|
260
|
+
const rid = location.hash.slice('#approve='.length);
|
|
261
|
+
location.hash = '';
|
|
262
|
+
const timer = setInterval(() => {
|
|
263
|
+
if (!db.connected) return;
|
|
264
|
+
clearInterval(timer);
|
|
265
|
+
window.dispatchEvent(new CustomEvent('zuzdb:tab', { detail: 'keyauth' }));
|
|
266
|
+
const el = document.getElementById('ka-qr-request-id') as HTMLInputElement;
|
|
267
|
+
if (el) el.value = rid;
|
|
268
|
+
}, 300);
|
|
269
|
+
}
|
|
270
|
+
}, []);
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<div id="tree-pane">
|
|
274
|
+
<div id="tree-header">
|
|
275
|
+
<b>Tree</b>
|
|
276
|
+
<span ref={nodeCountRef}>—</span>
|
|
277
|
+
<button className="sm icon" onClick={() => loadTree()} title="Reload">
|
|
278
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
279
|
+
<polyline points="23 4 23 10 17 10" />
|
|
280
|
+
<path d="M20.49 15a9 9 0 11-2.12-9.36L23 10" />
|
|
281
|
+
</svg>
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
<div id="tree-container" ref={containerRef}>Connecting…</div>
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
}
|