@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,329 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
import { db } from '../../context/DbContext';
|
|
3
|
+
import { showStatus, escHtml, refreshTreePath } from './utils';
|
|
4
|
+
|
|
5
|
+
interface StreamSub { id: string; cb: (events: unknown[]) => void; }
|
|
6
|
+
|
|
7
|
+
export default function Streams() {
|
|
8
|
+
const activeStreamSubs = useRef<Record<string, StreamSub>>({});
|
|
9
|
+
const subListRef = useRef<HTMLDivElement>(null);
|
|
10
|
+
|
|
11
|
+
function fillCompact(path: string, age: string, count: string, key: string) {
|
|
12
|
+
(document.getElementById('st-compact-path') as HTMLInputElement).value = path;
|
|
13
|
+
(document.getElementById('st-compact-age') as HTMLInputElement).value = age;
|
|
14
|
+
(document.getElementById('st-compact-count') as HTMLInputElement).value = count;
|
|
15
|
+
(document.getElementById('st-compact-key') as HTMLInputElement).value = key;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fillStreamPush(path: string, value: string, idem: string) {
|
|
19
|
+
(document.getElementById('st-push-path') as HTMLInputElement).value = path;
|
|
20
|
+
(document.getElementById('st-push-value') as HTMLTextAreaElement).value = value;
|
|
21
|
+
(document.getElementById('st-push-idem') as HTMLInputElement).value = idem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function doStreamSnapshot() {
|
|
25
|
+
const path = (document.getElementById('st-snap-path') as HTMLInputElement).value.trim();
|
|
26
|
+
if (!path) return showStatus('st-snap-status', 'Path required', false);
|
|
27
|
+
const t0 = performance.now();
|
|
28
|
+
try {
|
|
29
|
+
const snap = await db.streamSnapshot(path) as { key: string; data: Record<string, unknown> } | null;
|
|
30
|
+
const el = document.getElementById('st-snap-result') as HTMLElement;
|
|
31
|
+
if (snap) {
|
|
32
|
+
showStatus('st-snap-status', `Snapshot at key ${snap.key} — ${Object.keys(snap.data).length} entries`, true, performance.now() - t0);
|
|
33
|
+
el.textContent = JSON.stringify(snap, null, 2);
|
|
34
|
+
} else {
|
|
35
|
+
showStatus('st-snap-status', 'No snapshot (not yet compacted)', true, performance.now() - t0);
|
|
36
|
+
el.textContent = 'null';
|
|
37
|
+
}
|
|
38
|
+
el.style.display = 'block';
|
|
39
|
+
} catch (e: unknown) { showStatus('st-snap-status', (e as Error).message, false, performance.now() - t0); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function doStreamMaterialize() {
|
|
43
|
+
const path = (document.getElementById('st-snap-path') as HTMLInputElement).value.trim();
|
|
44
|
+
const keepKey = (document.getElementById('st-snap-keepkey') as HTMLInputElement).value.trim() || undefined;
|
|
45
|
+
if (!path) return showStatus('st-snap-status', 'Path required', false);
|
|
46
|
+
const t0 = performance.now();
|
|
47
|
+
try {
|
|
48
|
+
const view = await db.streamMaterialize(path, keepKey) as Record<string, unknown>;
|
|
49
|
+
const keys = Object.keys(view);
|
|
50
|
+
showStatus('st-snap-status', `Materialized view — ${keys.length} entries`, true, performance.now() - t0);
|
|
51
|
+
const el = document.getElementById('st-snap-result') as HTMLElement;
|
|
52
|
+
el.textContent = JSON.stringify(view, null, 2);
|
|
53
|
+
el.style.display = 'block';
|
|
54
|
+
} catch (e: unknown) { showStatus('st-snap-status', (e as Error).message, false, performance.now() - t0); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function doStreamCompact() {
|
|
58
|
+
const path = (document.getElementById('st-compact-path') as HTMLInputElement).value.trim();
|
|
59
|
+
const maxAge = (document.getElementById('st-compact-age') as HTMLInputElement).value.trim();
|
|
60
|
+
const maxCount = (document.getElementById('st-compact-count') as HTMLInputElement).value.trim();
|
|
61
|
+
const keepKey = (document.getElementById('st-compact-key') as HTMLInputElement).value.trim();
|
|
62
|
+
if (!path) return showStatus('st-compact-status', 'Path required', false);
|
|
63
|
+
const opts: Record<string, unknown> = {};
|
|
64
|
+
if (maxAge) opts.maxAge = Number(maxAge);
|
|
65
|
+
if (maxCount) opts.maxCount = Number(maxCount);
|
|
66
|
+
if (keepKey) opts.keepKey = keepKey;
|
|
67
|
+
if (!Object.keys(opts).length) return showStatus('st-compact-status', 'At least one option required', false);
|
|
68
|
+
const t0 = performance.now();
|
|
69
|
+
try {
|
|
70
|
+
const result = await db.streamCompact(path, opts) as { deleted: number; snapshotSize: number };
|
|
71
|
+
showStatus('st-compact-status', `Compacted — folded ${result.deleted} events into snapshot (${result.snapshotSize} keys)`, true, performance.now() - t0);
|
|
72
|
+
refreshTreePath(path);
|
|
73
|
+
} catch (e: unknown) { showStatus('st-compact-status', (e as Error).message, false, performance.now() - t0); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function doStreamReset() {
|
|
77
|
+
const path = (document.getElementById('st-compact-path') as HTMLInputElement).value.trim();
|
|
78
|
+
if (!path) return showStatus('st-compact-status', 'Path required', false);
|
|
79
|
+
if (!confirm(`Reset "${path}"? This deletes ALL events, snapshot, and consumer offsets.`)) return;
|
|
80
|
+
try {
|
|
81
|
+
await db.streamReset(path);
|
|
82
|
+
showStatus('st-compact-status', `Reset "${path}" — all events, snapshot, and offsets deleted`, true);
|
|
83
|
+
refreshTreePath(path);
|
|
84
|
+
} catch (e: unknown) { showStatus('st-compact-status', (e as Error).message, false); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function doStreamPush() {
|
|
88
|
+
const path = (document.getElementById('st-push-path') as HTMLInputElement).value.trim();
|
|
89
|
+
const raw = (document.getElementById('st-push-value') as HTMLTextAreaElement).value.trim();
|
|
90
|
+
const idem = (document.getElementById('st-push-idem') as HTMLInputElement).value.trim();
|
|
91
|
+
let value;
|
|
92
|
+
try { value = JSON.parse(raw); } catch { return showStatus('st-push-status', 'Invalid JSON', false); }
|
|
93
|
+
const t0 = performance.now();
|
|
94
|
+
try {
|
|
95
|
+
const params: Record<string, unknown> = { path, value };
|
|
96
|
+
if (idem) params.idempotencyKey = idem;
|
|
97
|
+
const key = await db._send('push', params);
|
|
98
|
+
showStatus('st-push-status', `Pushed → ${key}${idem ? ' (idempotent)' : ''}`, true, performance.now() - t0);
|
|
99
|
+
refreshTreePath(path);
|
|
100
|
+
} catch (e: unknown) { showStatus('st-push-status', (e as Error).message, false, performance.now() - t0); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function doStreamRead() {
|
|
104
|
+
const path = (document.getElementById('st-read-path') as HTMLInputElement).value.trim();
|
|
105
|
+
const groupId = (document.getElementById('st-read-group') as HTMLInputElement).value.trim();
|
|
106
|
+
const limit = Number((document.getElementById('st-read-limit') as HTMLInputElement).value) || 50;
|
|
107
|
+
if (!path || !groupId) return showStatus('st-read-status', 'Path and group ID required', false);
|
|
108
|
+
const t0 = performance.now();
|
|
109
|
+
try {
|
|
110
|
+
const events = await db.streamRead(path, groupId, limit) as Array<{ key: string }>;
|
|
111
|
+
showStatus('st-read-status', `${events.length} events`, true, performance.now() - t0);
|
|
112
|
+
const el = document.getElementById('st-read-result') as HTMLElement;
|
|
113
|
+
el.textContent = JSON.stringify(events, null, 2);
|
|
114
|
+
el.style.display = 'block';
|
|
115
|
+
if (events.length > 0) {
|
|
116
|
+
(document.getElementById('st-ack-key') as HTMLInputElement).value = events[events.length - 1].key;
|
|
117
|
+
(document.getElementById('st-ack-path') as HTMLInputElement).value = path;
|
|
118
|
+
(document.getElementById('st-ack-group') as HTMLInputElement).value = groupId;
|
|
119
|
+
}
|
|
120
|
+
} catch (e: unknown) { showStatus('st-read-status', (e as Error).message, false, performance.now() - t0); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function doStreamAck() {
|
|
124
|
+
const path = (document.getElementById('st-ack-path') as HTMLInputElement).value.trim();
|
|
125
|
+
const groupId = (document.getElementById('st-ack-group') as HTMLInputElement).value.trim();
|
|
126
|
+
const key = (document.getElementById('st-ack-key') as HTMLInputElement).value.trim();
|
|
127
|
+
if (!path || !groupId || !key) return showStatus('st-ack-status', 'All fields required', false);
|
|
128
|
+
const t0 = performance.now();
|
|
129
|
+
try {
|
|
130
|
+
await db.streamAck(path, groupId, key);
|
|
131
|
+
showStatus('st-ack-status', `Acked → ${key}`, true, performance.now() - t0);
|
|
132
|
+
} catch (e: unknown) { showStatus('st-ack-status', (e as Error).message, false, performance.now() - t0); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function doStreamSub() {
|
|
136
|
+
const path = (document.getElementById('st-sub-path') as HTMLInputElement).value.trim();
|
|
137
|
+
const groupId = (document.getElementById('st-sub-group') as HTMLInputElement).value.trim();
|
|
138
|
+
if (!path || !groupId) return;
|
|
139
|
+
const subKey = `${path}:${groupId}`;
|
|
140
|
+
if (activeStreamSubs.current[subKey]) return;
|
|
141
|
+
if (!db._streamCbs) db._streamCbs = new Map();
|
|
142
|
+
if (!db._streamCbs.has(subKey)) db._streamCbs.set(subKey, new Set());
|
|
143
|
+
const id = 'ssub-' + Math.random().toString(36).slice(2);
|
|
144
|
+
const div = document.createElement('div');
|
|
145
|
+
div.className = 'sub-item'; div.id = id;
|
|
146
|
+
div.innerHTML = `
|
|
147
|
+
<div class="sub-item-header">
|
|
148
|
+
<b>${escHtml(path)}</b>
|
|
149
|
+
<span style="color:#555;font-size:11px;margin-left:4px">[stream:${escHtml(groupId)}]</span>
|
|
150
|
+
<button class="danger sm" id="unsub-${id}">✕</button>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="sub-log" id="log-${id}"><div style="color:#555">Waiting for events…</div></div>`;
|
|
153
|
+
subListRef.current!.appendChild(div);
|
|
154
|
+
document.getElementById('unsub-' + id)!.onclick = () => doStreamUnsub(id, subKey, path, groupId);
|
|
155
|
+
const cb = (events: unknown[]) => {
|
|
156
|
+
const logEl = document.getElementById('log-' + id)!;
|
|
157
|
+
for (const ev of events as Array<{ key: string; data: unknown }>) {
|
|
158
|
+
const time = new Date().toLocaleTimeString();
|
|
159
|
+
const entry = document.createElement('div');
|
|
160
|
+
entry.innerHTML = `<span>${time}</span><span style="color:#4ec9b0;margin-right:6px">${escHtml(ev.key)}</span>${escHtml(JSON.stringify(ev.data))}`;
|
|
161
|
+
logEl.prepend(entry);
|
|
162
|
+
if (logEl.children.length > 100) logEl.lastChild?.remove();
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
db._streamCbs.get(subKey)!.add(cb);
|
|
166
|
+
try {
|
|
167
|
+
await db.streamSub(path, groupId);
|
|
168
|
+
activeStreamSubs.current[subKey] = { id, cb };
|
|
169
|
+
} catch {
|
|
170
|
+
div.remove();
|
|
171
|
+
db._streamCbs.get(subKey)?.delete(cb);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function doStreamUnsub(id: string, subKey: string, path: string, groupId: string) {
|
|
176
|
+
const sub = activeStreamSubs.current[subKey];
|
|
177
|
+
if (sub) {
|
|
178
|
+
db._streamCbs?.get(subKey)?.delete(sub.cb);
|
|
179
|
+
db.streamUnsub(path, groupId).catch(() => {});
|
|
180
|
+
delete activeStreamSubs.current[subKey];
|
|
181
|
+
}
|
|
182
|
+
document.getElementById(id)?.remove();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function doStreamDemo() {
|
|
186
|
+
const TOPIC = 'events/demo-orders';
|
|
187
|
+
const el = document.getElementById('st-demo-result') as HTMLElement;
|
|
188
|
+
el.style.display = 'block'; el.textContent = '';
|
|
189
|
+
const wait = (ms = 500) => new Promise(r => setTimeout(r, ms));
|
|
190
|
+
const appendLog = async (s: string, delay = 500) => {
|
|
191
|
+
el.textContent += (el.textContent ? '\n' : '') + s;
|
|
192
|
+
el.scrollTop = el.scrollHeight;
|
|
193
|
+
if (delay > 0) await wait(delay);
|
|
194
|
+
};
|
|
195
|
+
const t0 = performance.now();
|
|
196
|
+
try {
|
|
197
|
+
await db.streamReset(TOPIC);
|
|
198
|
+
await appendLog('🧹 Reset topic — clean slate');
|
|
199
|
+
const keys: string[] = [];
|
|
200
|
+
for (let i = 1; i <= 5; i++) {
|
|
201
|
+
const k = await db._send('push', { path: TOPIC, value: { orderId: `order-${i}`, amount: i * 100, status: 'created' } }) as string;
|
|
202
|
+
keys.push(k);
|
|
203
|
+
await appendLog(`📥 Pushed order-${i}: ${k.slice(0,8)} ($${i * 100})`, 300);
|
|
204
|
+
}
|
|
205
|
+
let eventsA = await db.streamRead(TOPIC, 'group-analytics', 10) as unknown[];
|
|
206
|
+
await appendLog(`\n📊 Group "analytics" read ${eventsA.length} events`);
|
|
207
|
+
let eventsB = await db.streamRead(TOPIC, 'group-billing', 10) as unknown[];
|
|
208
|
+
await appendLog(`💰 Group "billing" read ${eventsB.length} events (same events — fan-out!)`);
|
|
209
|
+
await db.streamAck(TOPIC, 'group-analytics', keys[2]);
|
|
210
|
+
await appendLog(`\n📊 Analytics acked up to ${keys[2].slice(0,8)} (3 of 5 processed)`);
|
|
211
|
+
await db.streamAck(TOPIC, 'group-billing', keys[4]);
|
|
212
|
+
await appendLog(`💰 Billing acked up to ${keys[4].slice(0,8)} (all 5 processed)`);
|
|
213
|
+
eventsA = await db.streamRead(TOPIC, 'group-analytics', 10) as unknown[];
|
|
214
|
+
await appendLog(`\n📊 Analytics re-read: ${eventsA.length} unprocessed events remaining`);
|
|
215
|
+
eventsB = await db.streamRead(TOPIC, 'group-billing', 10) as unknown[];
|
|
216
|
+
await appendLog(`💰 Billing re-read: ${eventsB.length} (all caught up)`);
|
|
217
|
+
await appendLog('\n📥 Pushing 3 more events...');
|
|
218
|
+
for (let i = 6; i <= 8; i++) {
|
|
219
|
+
await db._send('push', { path: TOPIC, value: { orderId: `order-${i}`, amount: i * 100, status: 'created' } });
|
|
220
|
+
await appendLog(` → order-${i} ($${i * 100})`, 250);
|
|
221
|
+
}
|
|
222
|
+
const idemKey1 = await db._send('push', { path: TOPIC, value: { orderId: 'order-9', amount: 900 }, idempotencyKey: 'order-9-created' }) as string;
|
|
223
|
+
await appendLog(`\n🔑 Idempotent push order-9: ${idemKey1.slice(0,8)}`);
|
|
224
|
+
const idemKey2 = await db._send('push', { path: TOPIC, value: { orderId: 'order-9', amount: 900 }, idempotencyKey: 'order-9-created' }) as string;
|
|
225
|
+
await appendLog(`🔑 Duplicate push order-9: ${idemKey2.slice(0,8)} (same key — deduped!)`);
|
|
226
|
+
await appendLog('\n🗜️ Compacting: keep last 3 events, keepKey=orderId...');
|
|
227
|
+
const compactResult = await db.streamCompact(TOPIC, { maxCount: 3, keepKey: 'orderId' }) as { deleted: number; snapshotSize: number };
|
|
228
|
+
await appendLog(` Folded ${compactResult.deleted} events into snapshot (${compactResult.snapshotSize} unique orders)`);
|
|
229
|
+
const snap = await db.streamSnapshot(TOPIC) as { data: Record<string, unknown> } | null;
|
|
230
|
+
await appendLog(`\n📸 Snapshot has ${snap ? Object.keys(snap.data).length : 0} entries`);
|
|
231
|
+
const view = await db.streamMaterialize(TOPIC, 'orderId') as Record<string, { amount?: number }>;
|
|
232
|
+
const viewKeys = Object.keys(view);
|
|
233
|
+
await appendLog(`🔮 Materialized view: ${viewKeys.length} total orders`);
|
|
234
|
+
for (const k of viewKeys.slice(0, 5)) await appendLog(` → ${k}: $${view[k]?.amount}`, 200);
|
|
235
|
+
if (viewKeys.length > 5) await appendLog(` ... and ${viewKeys.length - 5} more`, 200);
|
|
236
|
+
await db.streamReset(TOPIC);
|
|
237
|
+
await appendLog('\n🧹 Cleaned up topic');
|
|
238
|
+
const ms = (performance.now() - t0).toFixed(1);
|
|
239
|
+
await appendLog(`🎉 Demo complete in ${ms}ms — fan-out, offsets, idempotency, compaction, materialization`, 0);
|
|
240
|
+
showStatus('st-demo-status', 'Demo complete', true, performance.now() - t0);
|
|
241
|
+
} catch (e: unknown) {
|
|
242
|
+
await appendLog(`\n❌ Error: ${(e as Error).message}`, 0);
|
|
243
|
+
showStatus('st-demo-status', (e as Error).message, false);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
249
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
250
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>E2E Demo — push events → consumer groups → ack → compact → materialize</label>
|
|
251
|
+
<div className="row"><button className="success" onClick={doStreamDemo}>RUN DEMO</button></div>
|
|
252
|
+
<div id="st-demo-status" style={{ marginTop: 4 }}></div>
|
|
253
|
+
<div className="result" id="st-demo-result" style={{ marginTop: 4, display: 'none', maxHeight: 400, whiteSpace: 'pre-wrap' }}>—</div>
|
|
254
|
+
</div>
|
|
255
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
256
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Push Event — append to a topic (idempotent push optional)</label>
|
|
257
|
+
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
258
|
+
<button className="sm" onClick={() => fillStreamPush('events/orders', '{"orderId":"o99","amount":250}', '')}>push order</button>
|
|
259
|
+
<button className="sm" onClick={() => fillStreamPush('events/orders', '{"orderId":"o99","amount":250}', 'order-o99')}>idempotent push</button>
|
|
260
|
+
</div>
|
|
261
|
+
<div className="row">
|
|
262
|
+
<input id="st-push-path" type="text" placeholder="events/orders" defaultValue="events/orders" style={{ flex: 2 }} />
|
|
263
|
+
<input id="st-push-idem" type="text" placeholder="idempotency key (optional)" style={{ flex: 1 }} />
|
|
264
|
+
</div>
|
|
265
|
+
<textarea id="st-push-value" style={{ minHeight: 40, marginTop: 4 }} defaultValue='{ "orderId": "o99", "amount": 250 }'></textarea>
|
|
266
|
+
<div className="row" style={{ marginTop: 4 }}><button className="success" onClick={doStreamPush}>PUSH</button></div>
|
|
267
|
+
<div id="st-push-status" style={{ marginTop: 4 }}></div>
|
|
268
|
+
</div>
|
|
269
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
270
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Read — fetch unprocessed events for a consumer group</label>
|
|
271
|
+
<div className="row">
|
|
272
|
+
<input id="st-read-path" type="text" placeholder="events/orders" defaultValue="events/orders" style={{ flex: 2 }} />
|
|
273
|
+
<input id="st-read-group" type="text" placeholder="group ID" defaultValue="admin-ui" style={{ flex: 1 }} />
|
|
274
|
+
<input id="st-read-limit" type="number" defaultValue="50" style={{ width: 70 }} placeholder="limit" />
|
|
275
|
+
<button onClick={doStreamRead}>READ</button>
|
|
276
|
+
</div>
|
|
277
|
+
<div id="st-read-status" style={{ marginTop: 4 }}></div>
|
|
278
|
+
<div className="result" id="st-read-result" style={{ marginTop: 4, display: 'none', maxHeight: 300 }}>—</div>
|
|
279
|
+
</div>
|
|
280
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
281
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Ack — commit offset for a consumer group</label>
|
|
282
|
+
<div className="row">
|
|
283
|
+
<input id="st-ack-path" type="text" placeholder="events/orders" defaultValue="events/orders" style={{ flex: 2 }} />
|
|
284
|
+
<input id="st-ack-group" type="text" placeholder="group ID" defaultValue="admin-ui" style={{ flex: 1 }} />
|
|
285
|
+
<input id="st-ack-key" type="text" placeholder="event key to ack" style={{ flex: 2 }} />
|
|
286
|
+
<button onClick={doStreamAck}>ACK</button>
|
|
287
|
+
</div>
|
|
288
|
+
<div id="st-ack-status" style={{ marginTop: 4 }}></div>
|
|
289
|
+
</div>
|
|
290
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
291
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Compact — remove old/excess events (safe: respects consumer offsets)</label>
|
|
292
|
+
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
293
|
+
<button className="sm" onClick={() => fillCompact('events/orders', '', '100', '')}>keep 100</button>
|
|
294
|
+
<button className="sm" onClick={() => fillCompact('events/orders', '3600', '', '')}>max 1h</button>
|
|
295
|
+
<button className="sm" onClick={() => fillCompact('events/orders', '', '', 'orderId')}>keepKey: orderId</button>
|
|
296
|
+
</div>
|
|
297
|
+
<div className="row">
|
|
298
|
+
<input id="st-compact-path" type="text" placeholder="events/orders" defaultValue="events/orders" style={{ flex: 2 }} />
|
|
299
|
+
<input id="st-compact-age" type="number" placeholder="maxAge (sec)" style={{ width: 110 }} />
|
|
300
|
+
<input id="st-compact-count" type="number" placeholder="maxCount" style={{ width: 100 }} />
|
|
301
|
+
<input id="st-compact-key" type="text" placeholder="keepKey field" style={{ flex: 1 }} />
|
|
302
|
+
<button className="danger" onClick={doStreamCompact}>COMPACT</button>
|
|
303
|
+
<button className="danger" onClick={doStreamReset} title="Delete all events, snapshot, and offsets">RESET</button>
|
|
304
|
+
</div>
|
|
305
|
+
<div id="st-compact-status" style={{ marginTop: 4 }}></div>
|
|
306
|
+
</div>
|
|
307
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
308
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Snapshot & Materialize — view base state and merged view</label>
|
|
309
|
+
<div className="row">
|
|
310
|
+
<input id="st-snap-path" type="text" placeholder="events/orders" defaultValue="events/orders" style={{ flex: 2 }} />
|
|
311
|
+
<input id="st-snap-keepkey" type="text" placeholder="keepKey (for materialize)" style={{ flex: 1 }} />
|
|
312
|
+
<button onClick={doStreamSnapshot}>SNAPSHOT</button>
|
|
313
|
+
<button className="success" onClick={doStreamMaterialize}>MATERIALIZE</button>
|
|
314
|
+
</div>
|
|
315
|
+
<div id="st-snap-status" style={{ marginTop: 4 }}></div>
|
|
316
|
+
<div className="result" id="st-snap-result" style={{ marginTop: 4, display: 'none', maxHeight: 300 }}>—</div>
|
|
317
|
+
</div>
|
|
318
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
319
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Subscribe — live stream with replay from last offset</label>
|
|
320
|
+
<div className="row">
|
|
321
|
+
<input id="st-sub-path" type="text" placeholder="events/orders" defaultValue="events/orders" style={{ flex: 2 }} />
|
|
322
|
+
<input id="st-sub-group" type="text" placeholder="group ID" defaultValue="admin-live" style={{ flex: 1 }} />
|
|
323
|
+
<button className="sub" onClick={doStreamSub}>SUBSCRIBE</button>
|
|
324
|
+
</div>
|
|
325
|
+
<div ref={subListRef} id="st-sub-list" style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 6 }}></div>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { db } from '../../context/DbContext';
|
|
2
|
+
|
|
3
|
+
function setProgress(id: string, pct: number) {
|
|
4
|
+
const el = document.getElementById(id) as HTMLElement;
|
|
5
|
+
if (el) el.style.width = pct + '%';
|
|
6
|
+
}
|
|
7
|
+
function setStressResult(id: string, text: string) {
|
|
8
|
+
const el = document.getElementById(id) as HTMLElement;
|
|
9
|
+
if (el) el.textContent = text;
|
|
10
|
+
}
|
|
11
|
+
function fmtStressResult({ n, elapsedMs, extraLines = [] }: { n: number; elapsedMs: number; extraLines?: string[] }) {
|
|
12
|
+
const ops = Math.round(n / (elapsedMs / 1000));
|
|
13
|
+
return [`ops: ${n}`, `time: ${elapsedMs.toFixed(1)}ms`, `throughput: ${ops.toLocaleString()} ops/s`, ...extraLines].join('\n');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function StressTests() {
|
|
17
|
+
async function runSeqWrites() {
|
|
18
|
+
const n = Number((document.getElementById('sw-n') as HTMLInputElement).value);
|
|
19
|
+
setStressResult('sw-result', 'Running…'); setProgress('sw-prog', 0);
|
|
20
|
+
const t0 = performance.now();
|
|
21
|
+
const batch = 50;
|
|
22
|
+
for (let i = 0; i < n; i += batch) {
|
|
23
|
+
const end = Math.min(i + batch, n);
|
|
24
|
+
await Promise.all(Array.from({ length: end - i }, (_, j) => db.set(`stress/seq/${i+j}`, { v: i+j, ts: Date.now() })));
|
|
25
|
+
setProgress('sw-prog', Math.round((end / n) * 100));
|
|
26
|
+
}
|
|
27
|
+
setStressResult('sw-result', fmtStressResult({ n, elapsedMs: performance.now() - t0 }));
|
|
28
|
+
setProgress('sw-prog', 100);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function runBurstReads() {
|
|
32
|
+
const n = Number((document.getElementById('br-n') as HTMLInputElement).value);
|
|
33
|
+
setStressResult('br-result', 'Seeding…');
|
|
34
|
+
await db.set('stress/read-target', { hello: 'world', x: 42 });
|
|
35
|
+
setStressResult('br-result', 'Running…'); setProgress('br-prog', 0);
|
|
36
|
+
const t0 = performance.now();
|
|
37
|
+
const latencies: number[] = [];
|
|
38
|
+
const batch = 100;
|
|
39
|
+
for (let i = 0; i < n; i += batch) {
|
|
40
|
+
const end = Math.min(i + batch, n);
|
|
41
|
+
const bt = performance.now();
|
|
42
|
+
await Promise.all(Array.from({ length: end - i }, () => db.get('stress/read-target')));
|
|
43
|
+
latencies.push(performance.now() - bt);
|
|
44
|
+
setProgress('br-prog', Math.round((end / n) * 100));
|
|
45
|
+
}
|
|
46
|
+
const elapsedMs = performance.now() - t0;
|
|
47
|
+
const avgBatch = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
|
48
|
+
setStressResult('br-result', fmtStressResult({ n, elapsedMs, extraLines: [`avg batch: ${avgBatch.toFixed(1)}ms`] }));
|
|
49
|
+
setProgress('br-prog', 100);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function runMixed() {
|
|
53
|
+
const n = Number((document.getElementById('mw-n') as HTMLInputElement).value);
|
|
54
|
+
const writePct = Number((document.getElementById('mw-wp') as HTMLInputElement).value) / 100;
|
|
55
|
+
setStressResult('mw-result', 'Running…'); setProgress('mw-prog', 0);
|
|
56
|
+
const t0 = performance.now();
|
|
57
|
+
let writes = 0, reads = 0;
|
|
58
|
+
const batch = 50;
|
|
59
|
+
for (let i = 0; i < n; i += batch) {
|
|
60
|
+
const end = Math.min(i + batch, n);
|
|
61
|
+
await Promise.all(Array.from({ length: end - i }, (_, j) => {
|
|
62
|
+
if (Math.random() < writePct) { writes++; return db.set(`stress/mixed/${i+j}`, i+j); }
|
|
63
|
+
reads++; return db.get(`stress/mixed/${(i+j) % Math.max(1, i)}`);
|
|
64
|
+
}));
|
|
65
|
+
setProgress('mw-prog', Math.round((end / n) * 100));
|
|
66
|
+
}
|
|
67
|
+
setStressResult('mw-result', fmtStressResult({ n, elapsedMs: performance.now() - t0, extraLines: [`writes: ${writes}`, `reads: ${reads}`] }));
|
|
68
|
+
setProgress('mw-prog', 100);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function runQueryLoad() {
|
|
72
|
+
const n = Number((document.getElementById('ql-n') as HTMLInputElement).value);
|
|
73
|
+
setStressResult('ql-result', 'Seeding ' + n + ' records…'); setProgress('ql-prog', 5);
|
|
74
|
+
const batch = 50;
|
|
75
|
+
for (let i = 0; i < n; i += batch) {
|
|
76
|
+
const end = Math.min(i + batch, n);
|
|
77
|
+
await Promise.all(Array.from({ length: end - i }, (_, j) => {
|
|
78
|
+
const idx = i + j;
|
|
79
|
+
return db.set(`stress/qload/u${idx}`, { name: `User${idx}`, score: Math.floor(Math.random() * 100), role: idx % 3 === 0 ? 'admin' : 'user' });
|
|
80
|
+
}));
|
|
81
|
+
setProgress('ql-prog', 5 + Math.round((Math.min(i + batch, n) / n) * 45));
|
|
82
|
+
}
|
|
83
|
+
setStressResult('ql-result', 'Running queries…');
|
|
84
|
+
const queries = [
|
|
85
|
+
{ filters: [{ field: 'role', op: '==', value: 'admin' }] },
|
|
86
|
+
{ filters: [{ field: 'score', op: '>=', value: 50 }], order: { field: 'score', dir: 'desc' as const }, limit: 10 },
|
|
87
|
+
{ order: { field: 'name', dir: 'asc' as const }, limit: 20 },
|
|
88
|
+
];
|
|
89
|
+
const t0 = performance.now();
|
|
90
|
+
const qRuns = 30;
|
|
91
|
+
for (let i = 0; i < qRuns; i++) {
|
|
92
|
+
const qp = queries[i % queries.length];
|
|
93
|
+
let q = db.query('stress/qload');
|
|
94
|
+
for (const f of qp.filters ?? []) q = q.where(f.field, f.op, f.value);
|
|
95
|
+
if (qp.order) q = q.order(qp.order.field, qp.order.dir);
|
|
96
|
+
if (qp.limit) q = q.limit(qp.limit);
|
|
97
|
+
await q.get();
|
|
98
|
+
setProgress('ql-prog', 50 + Math.round((i / qRuns) * 50));
|
|
99
|
+
}
|
|
100
|
+
const elapsedMs = performance.now() - t0;
|
|
101
|
+
setStressResult('ql-result', fmtStressResult({ n: qRuns, elapsedMs, extraLines: [`records: ${n}`, `avg/query: ${(elapsedMs/qRuns).toFixed(1)}ms`] }));
|
|
102
|
+
setProgress('ql-prog', 100);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function runBulkUpdate() {
|
|
106
|
+
const keysPerBatch = Number((document.getElementById('bu-n') as HTMLInputElement).value);
|
|
107
|
+
const batches = Number((document.getElementById('bu-b') as HTMLInputElement).value);
|
|
108
|
+
setStressResult('bu-result', 'Running…'); setProgress('bu-prog', 0);
|
|
109
|
+
const t0 = performance.now();
|
|
110
|
+
for (let b = 0; b < batches; b++) {
|
|
111
|
+
const updates: Record<string, unknown> = {};
|
|
112
|
+
for (let k = 0; k < keysPerBatch; k++) updates[`stress/bulk/k${k}`] = { batch: b, val: k * b };
|
|
113
|
+
await db.update(updates);
|
|
114
|
+
setProgress('bu-prog', Math.round(((b + 1) / batches) * 100));
|
|
115
|
+
}
|
|
116
|
+
setStressResult('bu-result', fmtStressResult({ n: keysPerBatch * batches, elapsedMs: performance.now() - t0, extraLines: [`batches: ${batches}`, `keys/batch: ${keysPerBatch}`] }));
|
|
117
|
+
setProgress('bu-prog', 100);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runDeepPaths() {
|
|
121
|
+
const n = Number((document.getElementById('dp-n') as HTMLInputElement).value);
|
|
122
|
+
const depth = Number((document.getElementById('dp-d') as HTMLInputElement).value);
|
|
123
|
+
setStressResult('dp-result', 'Running…'); setProgress('dp-prog', 0);
|
|
124
|
+
const t0 = performance.now();
|
|
125
|
+
const batch = 20;
|
|
126
|
+
for (let i = 0; i < n; i += batch) {
|
|
127
|
+
const end = Math.min(i + batch, n);
|
|
128
|
+
await Promise.all(Array.from({ length: end - i }, (_, j) => {
|
|
129
|
+
const idx = i + j;
|
|
130
|
+
const segs = Array.from({ length: depth }, (_, d) => `l${d}_${idx % (10 + d)}`);
|
|
131
|
+
return db.set('stress/deep/' + segs.join('/'), idx);
|
|
132
|
+
}));
|
|
133
|
+
setProgress('dp-prog', Math.round((end / n) * 100));
|
|
134
|
+
}
|
|
135
|
+
setStressResult('dp-result', fmtStressResult({ n, elapsedMs: performance.now() - t0, extraLines: [`depth: ${depth}`] }));
|
|
136
|
+
setProgress('dp-prog', 100);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="stress-grid">
|
|
141
|
+
<div className="stress-card">
|
|
142
|
+
<h4>Sequential Writes</h4>
|
|
143
|
+
<div className="row" style={{ alignItems: 'center', gap: 8 }}>
|
|
144
|
+
<label style={{ margin: 0 }}>N:</label>
|
|
145
|
+
<input type="number" id="sw-n" defaultValue="1000" min="10" max="100000" style={{ width: 90 }} />
|
|
146
|
+
<button onClick={runSeqWrites}>Run</button>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="progress-bar"><div className="progress-fill" id="sw-prog"></div></div>
|
|
149
|
+
<div className="stress-result" id="sw-result">—</div>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="stress-card">
|
|
152
|
+
<h4>Burst Reads</h4>
|
|
153
|
+
<div className="row" style={{ alignItems: 'center', gap: 8 }}>
|
|
154
|
+
<label style={{ margin: 0 }}>N:</label>
|
|
155
|
+
<input type="number" id="br-n" defaultValue="2000" min="10" max="100000" style={{ width: 90 }} />
|
|
156
|
+
<button onClick={runBurstReads}>Run</button>
|
|
157
|
+
</div>
|
|
158
|
+
<div className="progress-bar"><div className="progress-fill" id="br-prog"></div></div>
|
|
159
|
+
<div className="stress-result" id="br-result">—</div>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="stress-card">
|
|
162
|
+
<h4>Mixed Read/Write</h4>
|
|
163
|
+
<div className="row" style={{ alignItems: 'center', gap: 8 }}>
|
|
164
|
+
<label style={{ margin: 0 }}>N:</label>
|
|
165
|
+
<input type="number" id="mw-n" defaultValue="500" min="10" max="50000" style={{ width: 90 }} />
|
|
166
|
+
<label style={{ margin: 0 }}>W%:</label>
|
|
167
|
+
<input type="number" id="mw-wp" defaultValue="30" min="0" max="100" style={{ width: 60 }} />
|
|
168
|
+
<button onClick={runMixed}>Run</button>
|
|
169
|
+
</div>
|
|
170
|
+
<div className="progress-bar"><div className="progress-fill" id="mw-prog"></div></div>
|
|
171
|
+
<div className="stress-result" id="mw-result">—</div>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="stress-card">
|
|
174
|
+
<h4>Query Under Load</h4>
|
|
175
|
+
<div className="row" style={{ alignItems: 'center', gap: 8 }}>
|
|
176
|
+
<label style={{ margin: 0 }}>Records:</label>
|
|
177
|
+
<input type="number" id="ql-n" defaultValue="500" min="10" max="10000" style={{ width: 80 }} />
|
|
178
|
+
<button onClick={runQueryLoad}>Run</button>
|
|
179
|
+
</div>
|
|
180
|
+
<div className="progress-bar"><div className="progress-fill" id="ql-prog"></div></div>
|
|
181
|
+
<div className="stress-result" id="ql-result">—</div>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="stress-card">
|
|
184
|
+
<h4>Bulk Update</h4>
|
|
185
|
+
<div className="row" style={{ alignItems: 'center', gap: 8 }}>
|
|
186
|
+
<label style={{ margin: 0 }}>Keys/batch:</label>
|
|
187
|
+
<input type="number" id="bu-n" defaultValue="100" min="5" max="5000" style={{ width: 80 }} />
|
|
188
|
+
<label style={{ margin: 0 }}>Batches:</label>
|
|
189
|
+
<input type="number" id="bu-b" defaultValue="20" min="1" max="500" style={{ width: 60 }} />
|
|
190
|
+
<button onClick={runBulkUpdate}>Run</button>
|
|
191
|
+
</div>
|
|
192
|
+
<div className="progress-bar"><div className="progress-fill" id="bu-prog"></div></div>
|
|
193
|
+
<div className="stress-result" id="bu-result">—</div>
|
|
194
|
+
</div>
|
|
195
|
+
<div className="stress-card">
|
|
196
|
+
<h4>Deep Path Writes</h4>
|
|
197
|
+
<div className="row" style={{ alignItems: 'center', gap: 8 }}>
|
|
198
|
+
<label style={{ margin: 0 }}>N:</label>
|
|
199
|
+
<input type="number" id="dp-n" defaultValue="200" min="10" max="5000" style={{ width: 80 }} />
|
|
200
|
+
<label style={{ margin: 0 }}>Depth:</label>
|
|
201
|
+
<input type="number" id="dp-d" defaultValue="6" min="2" max="12" style={{ width: 60 }} />
|
|
202
|
+
<button onClick={runDeepPaths}>Run</button>
|
|
203
|
+
</div>
|
|
204
|
+
<div className="progress-bar"><div className="progress-fill" id="dp-prog"></div></div>
|
|
205
|
+
<div className="stress-result" id="dp-result">—</div>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
import { db } from '../../context/DbContext';
|
|
3
|
+
import { escHtml } from './utils';
|
|
4
|
+
|
|
5
|
+
const EVENT_BADGE: Record<string, string> = { added: 'color:#4ec9b0', changed: 'color:#569cd6', removed: 'color:#f48771', value: 'color:#9cdcfe' };
|
|
6
|
+
|
|
7
|
+
interface SubEntry { unsub: () => void; id: string; }
|
|
8
|
+
|
|
9
|
+
export default function Subscriptions() {
|
|
10
|
+
const activeSubs = useRef<Record<string, SubEntry>>({});
|
|
11
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
12
|
+
|
|
13
|
+
function doSubscribe(mode: 'value' | 'child') {
|
|
14
|
+
const path = (document.getElementById('sub-path') as HTMLInputElement).value.trim();
|
|
15
|
+
const subKey = `${mode}:${path}`;
|
|
16
|
+
if (!path || activeSubs.current[subKey]) return;
|
|
17
|
+
const id = 'sub-' + Math.random().toString(36).slice(2);
|
|
18
|
+
const div = document.createElement('div');
|
|
19
|
+
div.className = 'sub-item'; div.id = id;
|
|
20
|
+
div.innerHTML = `
|
|
21
|
+
<div class="sub-item-header">
|
|
22
|
+
<b>${escHtml(path)}</b>
|
|
23
|
+
<span style="color:#555;font-size:11px;margin-left:4px">[${mode}]</span>
|
|
24
|
+
<button class="danger sm" id="unsub-${id}">✕</button>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="sub-log" id="log-${id}"><div style="color:#555">Waiting for events…</div></div>`;
|
|
27
|
+
listRef.current!.appendChild(div);
|
|
28
|
+
document.getElementById('unsub-' + id)!.onclick = () => doUnsubscribe(id, subKey);
|
|
29
|
+
|
|
30
|
+
function appendEntry(type: string, label: string, val: unknown) {
|
|
31
|
+
const logEl = document.getElementById('log-' + id)!;
|
|
32
|
+
const time = new Date().toLocaleTimeString();
|
|
33
|
+
const entry = document.createElement('div');
|
|
34
|
+
const style = EVENT_BADGE[type] ?? 'color:#9cdcfe';
|
|
35
|
+
entry.innerHTML = `<span>${time}</span><span style="${style};margin-right:6px">${escHtml(type)}</span><span style="color:#666;margin-right:4px">${escHtml(label)}</span>${escHtml(JSON.stringify(val))}`;
|
|
36
|
+
logEl.prepend(entry);
|
|
37
|
+
if (logEl.children.length > 50) logEl.lastChild?.remove();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let unsub: () => void;
|
|
41
|
+
if (mode === 'child') {
|
|
42
|
+
unsub = db.onChild(path, (ev) => appendEntry(ev.type, ev.key, ev.val()));
|
|
43
|
+
} else {
|
|
44
|
+
unsub = db.on(path, (snap) => appendEntry('value', snap.path, snap.val()));
|
|
45
|
+
}
|
|
46
|
+
activeSubs.current[subKey] = { unsub, id };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function doUnsubscribe(id: string, subKey: string) {
|
|
50
|
+
activeSubs.current[subKey]?.unsub();
|
|
51
|
+
delete activeSubs.current[subKey];
|
|
52
|
+
document.getElementById(id)?.remove();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<div>
|
|
58
|
+
<label>Path to Subscribe</label>
|
|
59
|
+
<div className="row">
|
|
60
|
+
<input id="sub-path" type="text" placeholder="users/alice" />
|
|
61
|
+
<button className="sub" onClick={() => doSubscribe('value')}>VALUE</button>
|
|
62
|
+
<button className="sub" onClick={() => doSubscribe('child')} style={{ background: '#4a3f6b' }}>CHILD</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div style={{ color: '#555', fontSize: 11, marginTop: 4 }}>VALUE — fires on any change at/below path · CHILD — fires for direct child added/changed/removed</div>
|
|
65
|
+
</div>
|
|
66
|
+
<div ref={listRef} id="sub-list" style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 6 }}></div>
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
}
|