@better-state/server 0.1.0 → 0.2.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/dist/cli.js +7 -7
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +63 -28
- package/dist/index.js.map +1 -1
- package/dist/seed.js +7 -5
- package/dist/seed.js.map +1 -1
- package/dist/state-engine.d.ts +6 -27
- package/dist/state-engine.d.ts.map +1 -1
- package/dist/state-engine.js +40 -54
- package/dist/state-engine.js.map +1 -1
- package/dist/storage/adapter.d.ts +102 -0
- package/dist/storage/adapter.d.ts.map +1 -0
- package/dist/storage/adapter.js +12 -0
- package/dist/storage/adapter.js.map +1 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/sqlite.d.ts +49 -0
- package/dist/storage/sqlite.d.ts.map +1 -0
- package/dist/storage/sqlite.js +192 -0
- package/dist/storage/sqlite.js.map +1 -0
- package/package.json +1 -1
- package/public/playground.html +188 -219
- package/public/studio.html +653 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Better-State Studio</title>
|
|
7
|
+
<script src="/socket.io/socket.io.js"></script>
|
|
8
|
+
<style>
|
|
9
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
10
|
+
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #06080d;
|
|
13
|
+
--surface: #0d1117;
|
|
14
|
+
--border: #1b2230;
|
|
15
|
+
--border-hl: #2a3548;
|
|
16
|
+
--text: #e2e8f0;
|
|
17
|
+
--text-2: #8b98a9;
|
|
18
|
+
--text-3: #4a5568;
|
|
19
|
+
--accent: #38bdf8;
|
|
20
|
+
--green: #34d399;
|
|
21
|
+
--amber: #fbbf24;
|
|
22
|
+
--red: #f87171;
|
|
23
|
+
--violet: #a78bfa;
|
|
24
|
+
--mono: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
body {
|
|
28
|
+
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
29
|
+
background: var(--bg);
|
|
30
|
+
color: var(--text);
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Top bar */
|
|
35
|
+
.topbar {
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
justify-content: space-between;
|
|
39
|
+
padding: 0.75rem 1.25rem;
|
|
40
|
+
border-bottom: 1px solid var(--border);
|
|
41
|
+
background: var(--surface);
|
|
42
|
+
position: sticky;
|
|
43
|
+
top: 0;
|
|
44
|
+
z-index: 10;
|
|
45
|
+
}
|
|
46
|
+
.topbar-left {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
gap: 0.75rem;
|
|
50
|
+
}
|
|
51
|
+
.logo {
|
|
52
|
+
font-size: 0.95rem;
|
|
53
|
+
font-weight: 700;
|
|
54
|
+
letter-spacing: -0.02em;
|
|
55
|
+
}
|
|
56
|
+
.logo span { color: var(--accent); }
|
|
57
|
+
.badge {
|
|
58
|
+
font-size: 0.6rem;
|
|
59
|
+
font-weight: 600;
|
|
60
|
+
text-transform: uppercase;
|
|
61
|
+
letter-spacing: 0.06em;
|
|
62
|
+
background: rgba(56, 189, 248, 0.1);
|
|
63
|
+
color: var(--accent);
|
|
64
|
+
padding: 0.15rem 0.5rem;
|
|
65
|
+
border-radius: 4px;
|
|
66
|
+
}
|
|
67
|
+
.topbar-right {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 1.25rem;
|
|
71
|
+
}
|
|
72
|
+
.status-pill {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: 0.35rem;
|
|
76
|
+
font-size: 0.7rem;
|
|
77
|
+
color: var(--text-2);
|
|
78
|
+
}
|
|
79
|
+
.dot {
|
|
80
|
+
width: 6px;
|
|
81
|
+
height: 6px;
|
|
82
|
+
border-radius: 50%;
|
|
83
|
+
}
|
|
84
|
+
.dot.green { background: var(--green); }
|
|
85
|
+
.dot.amber { background: var(--amber); animation: pulse 1.2s infinite; }
|
|
86
|
+
.dot.red { background: var(--red); }
|
|
87
|
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
|
88
|
+
|
|
89
|
+
/* Stats bar */
|
|
90
|
+
.stats-bar {
|
|
91
|
+
display: flex;
|
|
92
|
+
gap: 0;
|
|
93
|
+
border-bottom: 1px solid var(--border);
|
|
94
|
+
background: var(--surface);
|
|
95
|
+
}
|
|
96
|
+
.stat {
|
|
97
|
+
flex: 1;
|
|
98
|
+
padding: 0.75rem 1.25rem;
|
|
99
|
+
border-right: 1px solid var(--border);
|
|
100
|
+
}
|
|
101
|
+
.stat:last-child { border-right: none; }
|
|
102
|
+
.stat-label {
|
|
103
|
+
font-size: 0.6rem;
|
|
104
|
+
font-weight: 600;
|
|
105
|
+
text-transform: uppercase;
|
|
106
|
+
letter-spacing: 0.06em;
|
|
107
|
+
color: var(--text-3);
|
|
108
|
+
margin-bottom: 0.2rem;
|
|
109
|
+
}
|
|
110
|
+
.stat-value {
|
|
111
|
+
font-size: 1.25rem;
|
|
112
|
+
font-weight: 700;
|
|
113
|
+
font-variant-numeric: tabular-nums;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Main layout */
|
|
117
|
+
.main {
|
|
118
|
+
display: grid;
|
|
119
|
+
grid-template-columns: 1fr 1fr;
|
|
120
|
+
height: calc(100vh - 104px);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Panel */
|
|
124
|
+
.panel {
|
|
125
|
+
border-right: 1px solid var(--border);
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
overflow: hidden;
|
|
129
|
+
}
|
|
130
|
+
.panel:last-child { border-right: none; }
|
|
131
|
+
.panel-header {
|
|
132
|
+
padding: 0.6rem 1rem;
|
|
133
|
+
border-bottom: 1px solid var(--border);
|
|
134
|
+
font-size: 0.65rem;
|
|
135
|
+
font-weight: 600;
|
|
136
|
+
text-transform: uppercase;
|
|
137
|
+
letter-spacing: 0.06em;
|
|
138
|
+
color: var(--text-3);
|
|
139
|
+
background: var(--surface);
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
justify-content: space-between;
|
|
143
|
+
}
|
|
144
|
+
.panel-body {
|
|
145
|
+
flex: 1;
|
|
146
|
+
overflow-y: auto;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* State list */
|
|
150
|
+
.state-item {
|
|
151
|
+
padding: 0.75rem 1rem;
|
|
152
|
+
border-bottom: 1px solid var(--border);
|
|
153
|
+
cursor: pointer;
|
|
154
|
+
transition: background 0.1s;
|
|
155
|
+
}
|
|
156
|
+
.state-item:hover { background: rgba(56, 189, 248, 0.03); }
|
|
157
|
+
.state-item.active { background: rgba(56, 189, 248, 0.06); border-left: 2px solid var(--accent); }
|
|
158
|
+
.state-key {
|
|
159
|
+
font-family: var(--mono);
|
|
160
|
+
font-size: 0.8rem;
|
|
161
|
+
font-weight: 600;
|
|
162
|
+
color: var(--accent);
|
|
163
|
+
margin-bottom: 0.3rem;
|
|
164
|
+
}
|
|
165
|
+
.state-meta {
|
|
166
|
+
display: flex;
|
|
167
|
+
gap: 1rem;
|
|
168
|
+
font-size: 0.65rem;
|
|
169
|
+
color: var(--text-3);
|
|
170
|
+
}
|
|
171
|
+
.state-meta code {
|
|
172
|
+
font-family: var(--mono);
|
|
173
|
+
color: var(--text-2);
|
|
174
|
+
}
|
|
175
|
+
.state-preview {
|
|
176
|
+
margin-top: 0.4rem;
|
|
177
|
+
font-family: var(--mono);
|
|
178
|
+
font-size: 0.7rem;
|
|
179
|
+
color: var(--text-2);
|
|
180
|
+
background: rgba(0,0,0,0.3);
|
|
181
|
+
padding: 0.4rem 0.5rem;
|
|
182
|
+
border-radius: 6px;
|
|
183
|
+
max-height: 80px;
|
|
184
|
+
overflow: hidden;
|
|
185
|
+
white-space: pre-wrap;
|
|
186
|
+
word-break: break-all;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* Detail / right panel */
|
|
190
|
+
.detail-empty {
|
|
191
|
+
display: flex;
|
|
192
|
+
align-items: center;
|
|
193
|
+
justify-content: center;
|
|
194
|
+
height: 100%;
|
|
195
|
+
color: var(--text-3);
|
|
196
|
+
font-size: 0.8rem;
|
|
197
|
+
}
|
|
198
|
+
.detail-header {
|
|
199
|
+
padding: 1rem;
|
|
200
|
+
border-bottom: 1px solid var(--border);
|
|
201
|
+
background: var(--surface);
|
|
202
|
+
}
|
|
203
|
+
.detail-key {
|
|
204
|
+
font-family: var(--mono);
|
|
205
|
+
font-size: 1rem;
|
|
206
|
+
font-weight: 700;
|
|
207
|
+
color: var(--accent);
|
|
208
|
+
margin-bottom: 0.3rem;
|
|
209
|
+
}
|
|
210
|
+
.detail-info {
|
|
211
|
+
display: flex;
|
|
212
|
+
gap: 1.5rem;
|
|
213
|
+
font-size: 0.7rem;
|
|
214
|
+
color: var(--text-3);
|
|
215
|
+
}
|
|
216
|
+
.detail-info strong { color: var(--text-2); }
|
|
217
|
+
.detail-value {
|
|
218
|
+
padding: 1rem;
|
|
219
|
+
border-bottom: 1px solid var(--border);
|
|
220
|
+
}
|
|
221
|
+
.detail-value-label {
|
|
222
|
+
font-size: 0.6rem;
|
|
223
|
+
font-weight: 600;
|
|
224
|
+
text-transform: uppercase;
|
|
225
|
+
letter-spacing: 0.06em;
|
|
226
|
+
color: var(--text-3);
|
|
227
|
+
margin-bottom: 0.5rem;
|
|
228
|
+
}
|
|
229
|
+
.detail-value pre {
|
|
230
|
+
font-family: var(--mono);
|
|
231
|
+
font-size: 0.75rem;
|
|
232
|
+
color: var(--green);
|
|
233
|
+
background: rgba(0,0,0,0.4);
|
|
234
|
+
padding: 0.75rem;
|
|
235
|
+
border-radius: 8px;
|
|
236
|
+
max-height: 240px;
|
|
237
|
+
overflow-y: auto;
|
|
238
|
+
white-space: pre-wrap;
|
|
239
|
+
word-break: break-all;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* Event feed */
|
|
243
|
+
.event-row {
|
|
244
|
+
padding: 0.4rem 1rem;
|
|
245
|
+
border-bottom: 1px solid var(--border);
|
|
246
|
+
font-size: 0.7rem;
|
|
247
|
+
display: flex;
|
|
248
|
+
gap: 0.6rem;
|
|
249
|
+
align-items: baseline;
|
|
250
|
+
transition: background 0.3s;
|
|
251
|
+
}
|
|
252
|
+
.event-row.flash { background: rgba(52, 211, 153, 0.06); }
|
|
253
|
+
.event-ts {
|
|
254
|
+
font-family: var(--mono);
|
|
255
|
+
color: var(--text-3);
|
|
256
|
+
font-size: 0.65rem;
|
|
257
|
+
flex-shrink: 0;
|
|
258
|
+
width: 65px;
|
|
259
|
+
}
|
|
260
|
+
.event-key {
|
|
261
|
+
font-family: var(--mono);
|
|
262
|
+
color: var(--accent);
|
|
263
|
+
font-weight: 600;
|
|
264
|
+
flex-shrink: 0;
|
|
265
|
+
max-width: 120px;
|
|
266
|
+
overflow: hidden;
|
|
267
|
+
text-overflow: ellipsis;
|
|
268
|
+
white-space: nowrap;
|
|
269
|
+
}
|
|
270
|
+
.event-type {
|
|
271
|
+
font-family: var(--mono);
|
|
272
|
+
font-size: 0.6rem;
|
|
273
|
+
padding: 0.1rem 0.35rem;
|
|
274
|
+
border-radius: 3px;
|
|
275
|
+
flex-shrink: 0;
|
|
276
|
+
}
|
|
277
|
+
.event-type.set { background: rgba(167, 139, 250, 0.15); color: var(--violet); }
|
|
278
|
+
.event-type.fn { background: rgba(56, 189, 248, 0.15); color: var(--accent); }
|
|
279
|
+
.event-detail {
|
|
280
|
+
color: var(--text-3);
|
|
281
|
+
font-family: var(--mono);
|
|
282
|
+
overflow: hidden;
|
|
283
|
+
text-overflow: ellipsis;
|
|
284
|
+
white-space: nowrap;
|
|
285
|
+
}
|
|
286
|
+
.event-client {
|
|
287
|
+
color: var(--text-3);
|
|
288
|
+
font-family: var(--mono);
|
|
289
|
+
font-size: 0.6rem;
|
|
290
|
+
margin-left: auto;
|
|
291
|
+
flex-shrink: 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.empty-msg {
|
|
295
|
+
padding: 2rem;
|
|
296
|
+
text-align: center;
|
|
297
|
+
color: var(--text-3);
|
|
298
|
+
font-size: 0.8rem;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.refresh-btn {
|
|
302
|
+
background: none;
|
|
303
|
+
border: 1px solid var(--border);
|
|
304
|
+
color: var(--text-2);
|
|
305
|
+
font-size: 0.6rem;
|
|
306
|
+
padding: 0.2rem 0.5rem;
|
|
307
|
+
border-radius: 4px;
|
|
308
|
+
cursor: pointer;
|
|
309
|
+
font-weight: 600;
|
|
310
|
+
text-transform: uppercase;
|
|
311
|
+
letter-spacing: 0.04em;
|
|
312
|
+
}
|
|
313
|
+
.refresh-btn:hover { border-color: var(--border-hl); color: var(--text); }
|
|
314
|
+
|
|
315
|
+
@media (max-width: 768px) {
|
|
316
|
+
.main { grid-template-columns: 1fr; }
|
|
317
|
+
.stats-bar { flex-wrap: wrap; }
|
|
318
|
+
.stat { min-width: 50%; }
|
|
319
|
+
}
|
|
320
|
+
</style>
|
|
321
|
+
</head>
|
|
322
|
+
<body>
|
|
323
|
+
|
|
324
|
+
<!-- Top bar -->
|
|
325
|
+
<div class="topbar">
|
|
326
|
+
<div class="topbar-left">
|
|
327
|
+
<div class="logo"><span>Better</span>-State</div>
|
|
328
|
+
<span class="badge">Studio</span>
|
|
329
|
+
</div>
|
|
330
|
+
<div class="topbar-right">
|
|
331
|
+
<div class="status-pill">
|
|
332
|
+
<div id="dot" class="dot amber"></div>
|
|
333
|
+
<span id="status">connecting</span>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
<!-- Stats -->
|
|
339
|
+
<div class="stats-bar">
|
|
340
|
+
<div class="stat">
|
|
341
|
+
<div class="stat-label">State Keys</div>
|
|
342
|
+
<div class="stat-value" id="stat-states">—</div>
|
|
343
|
+
</div>
|
|
344
|
+
<div class="stat">
|
|
345
|
+
<div class="stat-label">Total Events</div>
|
|
346
|
+
<div class="stat-value" id="stat-events">—</div>
|
|
347
|
+
</div>
|
|
348
|
+
<div class="stat">
|
|
349
|
+
<div class="stat-label">Connections</div>
|
|
350
|
+
<div class="stat-value" id="stat-conns">—</div>
|
|
351
|
+
</div>
|
|
352
|
+
<div class="stat">
|
|
353
|
+
<div class="stat-label">Uptime</div>
|
|
354
|
+
<div class="stat-value" id="stat-uptime">—</div>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<!-- Main -->
|
|
359
|
+
<div class="main">
|
|
360
|
+
<!-- Left: state list -->
|
|
361
|
+
<div class="panel">
|
|
362
|
+
<div class="panel-header">
|
|
363
|
+
<span>State Explorer</span>
|
|
364
|
+
<button class="refresh-btn" onclick="loadStates()">Refresh</button>
|
|
365
|
+
</div>
|
|
366
|
+
<div class="panel-body" id="state-list">
|
|
367
|
+
<div class="empty-msg">Loading states...</div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<!-- Right: detail + live feed -->
|
|
372
|
+
<div class="panel">
|
|
373
|
+
<div class="panel-header">
|
|
374
|
+
<span id="right-title">Live Event Feed</span>
|
|
375
|
+
</div>
|
|
376
|
+
<div class="panel-body" id="right-panel">
|
|
377
|
+
<div id="detail-view" style="display:none"></div>
|
|
378
|
+
<div id="feed"></div>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
<script type="module">
|
|
384
|
+
const API_KEY = "__API_KEY__";
|
|
385
|
+
const BASE = window.location.origin;
|
|
386
|
+
|
|
387
|
+
const stateListEl = document.getElementById("state-list");
|
|
388
|
+
const rightTitle = document.getElementById("right-title");
|
|
389
|
+
const detailView = document.getElementById("detail-view");
|
|
390
|
+
const feedEl = document.getElementById("feed");
|
|
391
|
+
const dotEl = document.getElementById("dot");
|
|
392
|
+
const statusEl = document.getElementById("status");
|
|
393
|
+
|
|
394
|
+
let allStates = [];
|
|
395
|
+
let selectedKey = null;
|
|
396
|
+
|
|
397
|
+
// --- Stats ---
|
|
398
|
+
async function loadStats() {
|
|
399
|
+
try {
|
|
400
|
+
const res = await fetch(`${BASE}/api/v1/studio/stats`);
|
|
401
|
+
const d = await res.json();
|
|
402
|
+
document.getElementById("stat-states").textContent = d.states;
|
|
403
|
+
document.getElementById("stat-events").textContent = d.events.toLocaleString();
|
|
404
|
+
document.getElementById("stat-conns").textContent = d.connections;
|
|
405
|
+
const m = Math.floor(d.uptime / 60);
|
|
406
|
+
const s = Math.floor(d.uptime % 60);
|
|
407
|
+
document.getElementById("stat-uptime").textContent = m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
408
|
+
} catch {}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// --- States ---
|
|
412
|
+
async function loadStates() {
|
|
413
|
+
try {
|
|
414
|
+
const res = await fetch(`${BASE}/api/v1/studio/states`);
|
|
415
|
+
allStates = await res.json();
|
|
416
|
+
renderStateList();
|
|
417
|
+
} catch {
|
|
418
|
+
stateListEl.innerHTML = '<div class="empty-msg">Failed to load states</div>';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function renderStateList() {
|
|
423
|
+
if (allStates.length === 0) {
|
|
424
|
+
stateListEl.innerHTML = '<div class="empty-msg">No state keys yet. Use the SDK to create some.</div>';
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
stateListEl.innerHTML = allStates.map(s => {
|
|
429
|
+
const val = tryParse(s.snapshot);
|
|
430
|
+
const preview = JSON.stringify(val, null, 2);
|
|
431
|
+
const shortPreview = preview.length > 120 ? preview.slice(0, 120) + "…" : preview;
|
|
432
|
+
const age = timeAgo(s.updated_at);
|
|
433
|
+
const active = s.key === selectedKey ? "active" : "";
|
|
434
|
+
|
|
435
|
+
return `
|
|
436
|
+
<div class="state-item ${active}" onclick="selectState('${s.key}')">
|
|
437
|
+
<div class="state-key">${esc(s.key)}</div>
|
|
438
|
+
<div class="state-meta">
|
|
439
|
+
<span>v<code>${s.version}</code></span>
|
|
440
|
+
<span>updated <code>${age}</code></span>
|
|
441
|
+
<span><code>${s.namespace_name}</code></span>
|
|
442
|
+
</div>
|
|
443
|
+
<div class="state-preview">${esc(shortPreview)}</div>
|
|
444
|
+
</div>
|
|
445
|
+
`;
|
|
446
|
+
}).join("");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
window.selectState = async function(key) {
|
|
450
|
+
selectedKey = key;
|
|
451
|
+
renderStateList();
|
|
452
|
+
|
|
453
|
+
const s = allStates.find(x => x.key === key);
|
|
454
|
+
if (!s) return;
|
|
455
|
+
|
|
456
|
+
rightTitle.textContent = `State: ${key}`;
|
|
457
|
+
detailView.style.display = "block";
|
|
458
|
+
|
|
459
|
+
const val = tryParse(s.snapshot);
|
|
460
|
+
const pretty = JSON.stringify(val, null, 2);
|
|
461
|
+
|
|
462
|
+
let historyHtml = '<div class="empty-msg">Loading history...</div>';
|
|
463
|
+
|
|
464
|
+
detailView.innerHTML = `
|
|
465
|
+
<div class="detail-header">
|
|
466
|
+
<div class="detail-key">${esc(key)}</div>
|
|
467
|
+
<div class="detail-info">
|
|
468
|
+
<span>Version: <strong>${s.version}</strong></span>
|
|
469
|
+
<span>Namespace: <strong>${esc(s.namespace_name)}</strong></span>
|
|
470
|
+
<span>Updated: <strong>${timeAgo(s.updated_at)}</strong></span>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
<div class="detail-value">
|
|
474
|
+
<div class="detail-value-label">Current Value</div>
|
|
475
|
+
<pre id="detail-json">${esc(pretty)}</pre>
|
|
476
|
+
</div>
|
|
477
|
+
<div style="padding:0.6rem 1rem;border-bottom:1px solid var(--border)">
|
|
478
|
+
<div class="detail-value-label" style="margin-bottom:0">Recent Mutations</div>
|
|
479
|
+
</div>
|
|
480
|
+
<div id="detail-history">${historyHtml}</div>
|
|
481
|
+
`;
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
const nsId = s.namespace;
|
|
485
|
+
const res = await fetch(`${BASE}/api/v1/states/${encodeURIComponent(key)}/history?namespace=${nsId}&limit=20`);
|
|
486
|
+
const data = await res.json();
|
|
487
|
+
const histEl = document.getElementById("detail-history");
|
|
488
|
+
if (data.events.length === 0) {
|
|
489
|
+
histEl.innerHTML = '<div class="empty-msg">No mutations yet</div>';
|
|
490
|
+
} else {
|
|
491
|
+
histEl.innerHTML = data.events.map(e => renderEventRow(e, key)).join("");
|
|
492
|
+
}
|
|
493
|
+
} catch {
|
|
494
|
+
document.getElementById("detail-history").innerHTML = '<div class="empty-msg">Failed to load history</div>';
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// --- Live feed ---
|
|
499
|
+
async function loadRecentEvents() {
|
|
500
|
+
try {
|
|
501
|
+
const res = await fetch(`${BASE}/api/v1/studio/events/recent?limit=50`);
|
|
502
|
+
const events = await res.json();
|
|
503
|
+
if (events.length === 0) {
|
|
504
|
+
feedEl.innerHTML = '<div class="empty-msg">No events yet. Mutations will appear here in real-time.</div>';
|
|
505
|
+
} else {
|
|
506
|
+
feedEl.innerHTML = events.map(e => renderEventRow(e, e.state_key)).join("");
|
|
507
|
+
feedEl.scrollTop = feedEl.scrollHeight;
|
|
508
|
+
}
|
|
509
|
+
} catch {}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function renderEventRow(e, key) {
|
|
513
|
+
const ts = new Date(e.server_ts || e.client_ts).toLocaleTimeString("en", { hour12: false });
|
|
514
|
+
const isSet = e.mutation === "__SET__";
|
|
515
|
+
const typeClass = isSet ? "set" : "fn";
|
|
516
|
+
const typeLabel = isSet ? "SET" : "FN";
|
|
517
|
+
|
|
518
|
+
let detail = "";
|
|
519
|
+
if (isSet && e.meta) {
|
|
520
|
+
const meta = tryParse(e.meta);
|
|
521
|
+
if (meta && meta.fallbackValue !== undefined) {
|
|
522
|
+
detail = JSON.stringify(meta.fallbackValue);
|
|
523
|
+
if (detail.length > 60) detail = detail.slice(0, 60) + "…";
|
|
524
|
+
}
|
|
525
|
+
} else if (!isSet) {
|
|
526
|
+
detail = e.mutation;
|
|
527
|
+
if (detail.length > 60) detail = detail.slice(0, 60) + "…";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const clientShort = (e.client_id || "").slice(0, 8);
|
|
531
|
+
|
|
532
|
+
return `
|
|
533
|
+
<div class="event-row">
|
|
534
|
+
<span class="event-ts">${ts}</span>
|
|
535
|
+
<span class="event-key">${esc(key)}</span>
|
|
536
|
+
<span class="event-type ${typeClass}">${typeLabel}</span>
|
|
537
|
+
<span class="event-detail">${esc(detail)}</span>
|
|
538
|
+
<span class="event-client">${clientShort}</span>
|
|
539
|
+
</div>
|
|
540
|
+
`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function addLiveEvent(data) {
|
|
544
|
+
if (!feedEl) return;
|
|
545
|
+
|
|
546
|
+
const ts = new Date(data.timestamp).toLocaleTimeString("en", { hour12: false });
|
|
547
|
+
const val = JSON.stringify(data.value);
|
|
548
|
+
const short = val.length > 60 ? val.slice(0, 60) + "…" : val;
|
|
549
|
+
const clientShort = (data.clientId || "").slice(0, 8);
|
|
550
|
+
|
|
551
|
+
const row = document.createElement("div");
|
|
552
|
+
row.className = "event-row flash";
|
|
553
|
+
row.innerHTML = `
|
|
554
|
+
<span class="event-ts">${ts}</span>
|
|
555
|
+
<span class="event-key">${esc(data.key)}</span>
|
|
556
|
+
<span class="event-type set">UPD</span>
|
|
557
|
+
<span class="event-detail">→ ${esc(short)}</span>
|
|
558
|
+
<span class="event-client">${clientShort}</span>
|
|
559
|
+
`;
|
|
560
|
+
feedEl.appendChild(row);
|
|
561
|
+
feedEl.scrollTop = feedEl.scrollHeight;
|
|
562
|
+
setTimeout(() => row.classList.remove("flash"), 1500);
|
|
563
|
+
|
|
564
|
+
// Update stats
|
|
565
|
+
loadStats();
|
|
566
|
+
|
|
567
|
+
// Update the state in our local list
|
|
568
|
+
const existing = allStates.find(s => s.key === data.key);
|
|
569
|
+
if (existing) {
|
|
570
|
+
existing.snapshot = JSON.stringify(data.value);
|
|
571
|
+
existing.version = data.version;
|
|
572
|
+
existing.updated_at = data.timestamp;
|
|
573
|
+
renderStateList();
|
|
574
|
+
|
|
575
|
+
// Update detail view if this key is selected
|
|
576
|
+
if (selectedKey === data.key) {
|
|
577
|
+
const jsonEl = document.getElementById("detail-json");
|
|
578
|
+
if (jsonEl) jsonEl.textContent = JSON.stringify(data.value, null, 2);
|
|
579
|
+
}
|
|
580
|
+
} else {
|
|
581
|
+
// New state key — reload
|
|
582
|
+
loadStates();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// --- WebSocket for live updates ---
|
|
587
|
+
function connectWs() {
|
|
588
|
+
if (typeof window.io === "undefined") {
|
|
589
|
+
console.warn("socket.io client not loaded");
|
|
590
|
+
dotEl.className = "dot red";
|
|
591
|
+
statusEl.textContent = "ws unavailable";
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const socket = window.io(BASE, { transports: ["websocket"] });
|
|
595
|
+
|
|
596
|
+
socket.on("connect", () => {
|
|
597
|
+
dotEl.className = "dot amber";
|
|
598
|
+
statusEl.textContent = "authenticating";
|
|
599
|
+
socket.emit("auth", { apiKey: API_KEY, clientId: "studio-" + Math.random().toString(36).slice(2, 8) }, (res) => {
|
|
600
|
+
if (res && res.ok) {
|
|
601
|
+
dotEl.className = "dot green";
|
|
602
|
+
statusEl.textContent = "connected";
|
|
603
|
+
socket.emit("subscribe:studio");
|
|
604
|
+
} else {
|
|
605
|
+
dotEl.className = "dot red";
|
|
606
|
+
statusEl.textContent = "auth failed";
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
socket.on("auth:ok", () => {
|
|
612
|
+
dotEl.className = "dot green";
|
|
613
|
+
statusEl.textContent = "connected";
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
socket.on("studio:mutation", (data) => {
|
|
617
|
+
addLiveEvent(data);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
socket.on("disconnect", () => {
|
|
621
|
+
dotEl.className = "dot red";
|
|
622
|
+
statusEl.textContent = "disconnected";
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// --- Helpers ---
|
|
627
|
+
function tryParse(s) {
|
|
628
|
+
try { return JSON.parse(s); } catch { return s; }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function esc(s) {
|
|
632
|
+
const d = document.createElement("div");
|
|
633
|
+
d.textContent = String(s);
|
|
634
|
+
return d.innerHTML;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function timeAgo(ts) {
|
|
638
|
+
const diff = Date.now() - ts;
|
|
639
|
+
if (diff < 60000) return "just now";
|
|
640
|
+
if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
|
|
641
|
+
if (diff < 86400000) return Math.floor(diff / 3600000) + "h ago";
|
|
642
|
+
return Math.floor(diff / 86400000) + "d ago";
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// --- Init ---
|
|
646
|
+
loadStats();
|
|
647
|
+
loadStates();
|
|
648
|
+
loadRecentEvents();
|
|
649
|
+
setInterval(loadStats, 10000);
|
|
650
|
+
connectWs();
|
|
651
|
+
</script>
|
|
652
|
+
</body>
|
|
653
|
+
</html>
|