@luanpdd/kit-mcp 1.0.0 → 1.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/CHANGELOG.md +150 -1
- package/README.md +48 -2
- package/bin/ui.js +74 -0
- package/package.json +5 -2
- package/src/cli/index.js +371 -35
- package/src/cli/render.js +187 -0
- package/src/core/reverse-sync.js +5 -1
- package/src/core/sync.js +4 -0
- package/src/core/ui.js +167 -0
- package/src/mcp-server/index.js +53 -6
- package/src/ui/auto-spawn.js +108 -0
- package/src/ui/browser.js +78 -0
- package/src/ui/client.js +115 -0
- package/src/ui/events.js +65 -0
- package/src/ui/lockfile.js +147 -0
- package/src/ui/port.js +67 -0
- package/src/ui/server.js +432 -0
- package/src/ui/static/index.html +609 -0
- package/src/ui/wrapper.js +119 -0
|
@@ -0,0 +1,609 @@
|
|
|
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">
|
|
6
|
+
<title>kit-mcp sidecar</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* Design tokens */
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: light dark;
|
|
11
|
+
--bg: #f8fafc;
|
|
12
|
+
--bg-elev: #ffffff;
|
|
13
|
+
--bg-row: #ffffff;
|
|
14
|
+
--bg-row-hover: #f1f5f9;
|
|
15
|
+
--fg: #0f172a;
|
|
16
|
+
--fg-muted: #475569;
|
|
17
|
+
--fg-subtle: #64748b;
|
|
18
|
+
--border: #e2e8f0;
|
|
19
|
+
--accent: #3b82f6;
|
|
20
|
+
--ok: #10b981;
|
|
21
|
+
--warn: #f59e0b;
|
|
22
|
+
--err: #ef4444;
|
|
23
|
+
--info: #6366f1;
|
|
24
|
+
--shadow: 0 1px 2px rgba(0,0,0,.04), 0 4px 10px rgba(0,0,0,.04);
|
|
25
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
26
|
+
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Inter, "Helvetica Neue", sans-serif;
|
|
27
|
+
}
|
|
28
|
+
@media (prefers-color-scheme: dark) {
|
|
29
|
+
:root {
|
|
30
|
+
--bg: #0b1120;
|
|
31
|
+
--bg-elev: #111827;
|
|
32
|
+
--bg-row: #0f172a;
|
|
33
|
+
--bg-row-hover: #1e293b;
|
|
34
|
+
--fg: #e2e8f0;
|
|
35
|
+
--fg-muted: #94a3b8;
|
|
36
|
+
--fg-subtle: #64748b;
|
|
37
|
+
--border: #1f2937;
|
|
38
|
+
--accent: #60a5fa;
|
|
39
|
+
--ok: #34d399;
|
|
40
|
+
--warn: #fbbf24;
|
|
41
|
+
--err: #f87171;
|
|
42
|
+
--info: #818cf8;
|
|
43
|
+
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 4px 10px rgba(0,0,0,.3);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
* { box-sizing: border-box; }
|
|
48
|
+
html, body { margin: 0; padding: 0; height: 100%; }
|
|
49
|
+
body {
|
|
50
|
+
font-family: var(--sans);
|
|
51
|
+
font-size: 14px;
|
|
52
|
+
line-height: 1.5;
|
|
53
|
+
color: var(--fg);
|
|
54
|
+
background: var(--bg);
|
|
55
|
+
display: flex;
|
|
56
|
+
flex-direction: column;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
header {
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: 12px;
|
|
63
|
+
padding: 10px 16px;
|
|
64
|
+
border-bottom: 1px solid var(--border);
|
|
65
|
+
background: var(--bg-elev);
|
|
66
|
+
box-shadow: var(--shadow);
|
|
67
|
+
position: sticky;
|
|
68
|
+
top: 0;
|
|
69
|
+
z-index: 10;
|
|
70
|
+
}
|
|
71
|
+
header h1 {
|
|
72
|
+
margin: 0;
|
|
73
|
+
font-size: 14px;
|
|
74
|
+
font-weight: 600;
|
|
75
|
+
letter-spacing: .2px;
|
|
76
|
+
}
|
|
77
|
+
header h1::before {
|
|
78
|
+
content: "◆";
|
|
79
|
+
color: var(--accent);
|
|
80
|
+
margin-right: 6px;
|
|
81
|
+
}
|
|
82
|
+
header .meta {
|
|
83
|
+
color: var(--fg-subtle);
|
|
84
|
+
font-family: var(--mono);
|
|
85
|
+
font-size: 12px;
|
|
86
|
+
}
|
|
87
|
+
header .grow { flex: 1; }
|
|
88
|
+
|
|
89
|
+
.conn-status {
|
|
90
|
+
display: inline-flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
gap: 6px;
|
|
93
|
+
padding: 3px 10px;
|
|
94
|
+
border-radius: 999px;
|
|
95
|
+
font-family: var(--mono);
|
|
96
|
+
font-size: 11px;
|
|
97
|
+
font-weight: 600;
|
|
98
|
+
border: 1px solid var(--border);
|
|
99
|
+
background: var(--bg-row);
|
|
100
|
+
}
|
|
101
|
+
.conn-status .dot {
|
|
102
|
+
display: inline-block;
|
|
103
|
+
width: 8px;
|
|
104
|
+
height: 8px;
|
|
105
|
+
border-radius: 50%;
|
|
106
|
+
background: var(--fg-subtle);
|
|
107
|
+
}
|
|
108
|
+
.conn-status[data-state="CONNECTING"] .dot { background: var(--warn); animation: pulse 1.4s infinite; }
|
|
109
|
+
.conn-status[data-state="OPEN"] .dot { background: var(--ok); }
|
|
110
|
+
.conn-status[data-state="CLOSED"] .dot { background: var(--err); animation: pulse 1s infinite; }
|
|
111
|
+
@keyframes pulse { 50% { opacity: .35; } }
|
|
112
|
+
|
|
113
|
+
.toolbar {
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
gap: 10px;
|
|
117
|
+
padding: 10px 16px;
|
|
118
|
+
border-bottom: 1px solid var(--border);
|
|
119
|
+
background: var(--bg-elev);
|
|
120
|
+
flex-wrap: wrap;
|
|
121
|
+
}
|
|
122
|
+
.toolbar input[type="search"] {
|
|
123
|
+
flex: 1 1 240px;
|
|
124
|
+
min-width: 180px;
|
|
125
|
+
padding: 6px 10px;
|
|
126
|
+
border: 1px solid var(--border);
|
|
127
|
+
border-radius: 6px;
|
|
128
|
+
background: var(--bg-row);
|
|
129
|
+
color: var(--fg);
|
|
130
|
+
font-family: var(--sans);
|
|
131
|
+
font-size: 13px;
|
|
132
|
+
}
|
|
133
|
+
.toolbar input[type="search"]:focus {
|
|
134
|
+
outline: 2px solid var(--accent);
|
|
135
|
+
outline-offset: -1px;
|
|
136
|
+
}
|
|
137
|
+
.filters {
|
|
138
|
+
display: flex;
|
|
139
|
+
flex-wrap: wrap;
|
|
140
|
+
gap: 4px;
|
|
141
|
+
}
|
|
142
|
+
.filters label {
|
|
143
|
+
display: inline-flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
gap: 4px;
|
|
146
|
+
padding: 3px 8px;
|
|
147
|
+
border-radius: 999px;
|
|
148
|
+
border: 1px solid var(--border);
|
|
149
|
+
background: var(--bg-row);
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
user-select: none;
|
|
152
|
+
font-size: 11px;
|
|
153
|
+
font-family: var(--mono);
|
|
154
|
+
}
|
|
155
|
+
.filters input { display: none; }
|
|
156
|
+
.filters label:has(input:checked) {
|
|
157
|
+
background: var(--accent);
|
|
158
|
+
color: white;
|
|
159
|
+
border-color: var(--accent);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
button {
|
|
163
|
+
padding: 5px 12px;
|
|
164
|
+
border: 1px solid var(--border);
|
|
165
|
+
border-radius: 6px;
|
|
166
|
+
background: var(--bg-row);
|
|
167
|
+
color: var(--fg);
|
|
168
|
+
font-family: var(--sans);
|
|
169
|
+
font-size: 12px;
|
|
170
|
+
cursor: pointer;
|
|
171
|
+
}
|
|
172
|
+
button:hover { background: var(--bg-row-hover); }
|
|
173
|
+
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
174
|
+
button[aria-pressed="true"] { background: var(--accent); color: white; border-color: var(--accent); }
|
|
175
|
+
|
|
176
|
+
main {
|
|
177
|
+
flex: 1;
|
|
178
|
+
overflow: auto;
|
|
179
|
+
padding: 0 16px 24px;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.empty {
|
|
183
|
+
text-align: center;
|
|
184
|
+
color: var(--fg-subtle);
|
|
185
|
+
padding: 64px 16px;
|
|
186
|
+
font-size: 14px;
|
|
187
|
+
}
|
|
188
|
+
.empty strong { display: block; color: var(--fg-muted); margin-bottom: 6px; }
|
|
189
|
+
.empty code {
|
|
190
|
+
font-family: var(--mono);
|
|
191
|
+
background: var(--bg-row);
|
|
192
|
+
padding: 1px 6px;
|
|
193
|
+
border-radius: 4px;
|
|
194
|
+
font-size: 12px;
|
|
195
|
+
border: 1px solid var(--border);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.ev-list {
|
|
199
|
+
list-style: none;
|
|
200
|
+
margin: 12px 0 0;
|
|
201
|
+
padding: 0;
|
|
202
|
+
border: 1px solid var(--border);
|
|
203
|
+
border-radius: 8px;
|
|
204
|
+
overflow: hidden;
|
|
205
|
+
background: var(--bg-elev);
|
|
206
|
+
}
|
|
207
|
+
.ev-row {
|
|
208
|
+
display: grid;
|
|
209
|
+
grid-template-columns: 92px 110px 1fr;
|
|
210
|
+
gap: 12px;
|
|
211
|
+
padding: 8px 12px;
|
|
212
|
+
border-bottom: 1px solid var(--border);
|
|
213
|
+
font-size: 13px;
|
|
214
|
+
align-items: start;
|
|
215
|
+
}
|
|
216
|
+
.ev-row:last-child { border-bottom: 0; }
|
|
217
|
+
.ev-row:hover { background: var(--bg-row-hover); }
|
|
218
|
+
|
|
219
|
+
.ev-time {
|
|
220
|
+
font-family: var(--mono);
|
|
221
|
+
font-size: 11px;
|
|
222
|
+
color: var(--fg-subtle);
|
|
223
|
+
padding-top: 1px;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.ev-badge {
|
|
227
|
+
display: inline-block;
|
|
228
|
+
padding: 2px 8px;
|
|
229
|
+
border-radius: 999px;
|
|
230
|
+
background: var(--bg-row);
|
|
231
|
+
color: var(--fg-muted);
|
|
232
|
+
font-family: var(--mono);
|
|
233
|
+
font-size: 10px;
|
|
234
|
+
font-weight: 700;
|
|
235
|
+
text-transform: uppercase;
|
|
236
|
+
letter-spacing: .5px;
|
|
237
|
+
border: 1px solid var(--border);
|
|
238
|
+
white-space: nowrap;
|
|
239
|
+
text-align: center;
|
|
240
|
+
}
|
|
241
|
+
.ev-badge[data-type="run.start"] { color: var(--info); border-color: var(--info); }
|
|
242
|
+
.ev-badge[data-type="run.end"] { color: var(--info); border-color: var(--info); }
|
|
243
|
+
.ev-badge[data-type="tool_invocation"]{ color: var(--accent); border-color: var(--accent); }
|
|
244
|
+
.ev-badge[data-type="progress"] { color: var(--ok); border-color: var(--ok); }
|
|
245
|
+
.ev-badge[data-type="milestone"] { color: var(--warn); border-color: var(--warn); }
|
|
246
|
+
.ev-badge[data-type="error"] { color: var(--err); border-color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); }
|
|
247
|
+
.ev-badge[data-type="shutdown"] { color: var(--err); border-color: var(--err); }
|
|
248
|
+
|
|
249
|
+
.ev-body { min-width: 0; word-wrap: break-word; }
|
|
250
|
+
.ev-body summary {
|
|
251
|
+
list-style: none;
|
|
252
|
+
cursor: pointer;
|
|
253
|
+
}
|
|
254
|
+
.ev-body summary::-webkit-details-marker { display: none; }
|
|
255
|
+
.ev-body summary .label {
|
|
256
|
+
color: var(--fg);
|
|
257
|
+
font-family: var(--sans);
|
|
258
|
+
}
|
|
259
|
+
.ev-body summary .meta {
|
|
260
|
+
color: var(--fg-subtle);
|
|
261
|
+
font-family: var(--mono);
|
|
262
|
+
font-size: 11px;
|
|
263
|
+
margin-left: 6px;
|
|
264
|
+
}
|
|
265
|
+
.ev-body pre {
|
|
266
|
+
margin: 6px 0 0;
|
|
267
|
+
padding: 8px 10px;
|
|
268
|
+
background: var(--bg-row);
|
|
269
|
+
border: 1px solid var(--border);
|
|
270
|
+
border-radius: 6px;
|
|
271
|
+
font-family: var(--mono);
|
|
272
|
+
font-size: 11px;
|
|
273
|
+
color: var(--fg-muted);
|
|
274
|
+
overflow-x: auto;
|
|
275
|
+
max-height: 280px;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.banner {
|
|
279
|
+
margin: 12px 0 0;
|
|
280
|
+
padding: 12px 16px;
|
|
281
|
+
border-radius: 8px;
|
|
282
|
+
background: color-mix(in srgb, var(--err) 8%, var(--bg-elev));
|
|
283
|
+
border: 1px solid var(--err);
|
|
284
|
+
color: var(--err);
|
|
285
|
+
font-size: 13px;
|
|
286
|
+
}
|
|
287
|
+
.banner[hidden] { display: none; }
|
|
288
|
+
.banner code {
|
|
289
|
+
font-family: var(--mono);
|
|
290
|
+
background: var(--bg-row);
|
|
291
|
+
padding: 1px 5px;
|
|
292
|
+
border-radius: 4px;
|
|
293
|
+
color: var(--fg-muted);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.footer {
|
|
297
|
+
padding: 8px 16px;
|
|
298
|
+
border-top: 1px solid var(--border);
|
|
299
|
+
background: var(--bg-elev);
|
|
300
|
+
color: var(--fg-subtle);
|
|
301
|
+
font-family: var(--mono);
|
|
302
|
+
font-size: 11px;
|
|
303
|
+
display: flex;
|
|
304
|
+
gap: 16px;
|
|
305
|
+
flex-wrap: wrap;
|
|
306
|
+
}
|
|
307
|
+
</style>
|
|
308
|
+
</head>
|
|
309
|
+
<body>
|
|
310
|
+
<header>
|
|
311
|
+
<h1>kit-mcp sidecar</h1>
|
|
312
|
+
<span class="meta" id="meta-port">port —</span>
|
|
313
|
+
<span class="grow"></span>
|
|
314
|
+
<span class="conn-status" id="conn" data-state="CONNECTING"><span class="dot"></span><span id="conn-text">CONNECTING</span></span>
|
|
315
|
+
</header>
|
|
316
|
+
|
|
317
|
+
<div class="toolbar">
|
|
318
|
+
<input type="search" id="search" placeholder="filter by label or payload…" autocomplete="off">
|
|
319
|
+
<fieldset class="filters" id="type-filters" aria-label="Event types">
|
|
320
|
+
<!-- populated from EVENT_TYPES at runtime -->
|
|
321
|
+
</fieldset>
|
|
322
|
+
<button id="pause-btn" aria-pressed="false">⏸ pause</button>
|
|
323
|
+
<button id="autoscroll-btn" aria-pressed="true">↧ autoscroll</button>
|
|
324
|
+
<button id="clear-btn" title="Clear visible events (ring buffer untouched)">clear view</button>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<main>
|
|
328
|
+
<div class="banner" id="shutdown-banner" hidden>
|
|
329
|
+
<strong>Sidecar encerrou.</strong> Recarregue depois de <code>kit ui start</code>.
|
|
330
|
+
</div>
|
|
331
|
+
<ul class="ev-list" id="events" hidden></ul>
|
|
332
|
+
<div class="empty" id="empty">
|
|
333
|
+
<strong>Aguardando primeiro evento…</strong>
|
|
334
|
+
Rode <code>kit sync install</code>, <code>kit reverse-sync apply</code>, ou
|
|
335
|
+
invoke uma tool MCP com <code>autoSpawn: true</code> em outra janela.
|
|
336
|
+
</div>
|
|
337
|
+
</main>
|
|
338
|
+
|
|
339
|
+
<div class="footer">
|
|
340
|
+
<span id="footer-events">events: 0</span>
|
|
341
|
+
<span id="footer-paused" hidden>paused: 0 buffered</span>
|
|
342
|
+
<span id="footer-source">source: live</span>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<script>
|
|
346
|
+
// src/ui/static/index.html — vanilla DOM client for the kit-mcp sidecar.
|
|
347
|
+
// Connects to /events (SSE) and hydrates from /state on load.
|
|
348
|
+
// No build step. No deps.
|
|
349
|
+
|
|
350
|
+
'use strict';
|
|
351
|
+
|
|
352
|
+
const EVENT_TYPES = ['run.start', 'run.end', 'tool_invocation', 'progress', 'milestone', 'error', 'shutdown'];
|
|
353
|
+
const RING_DISPLAY_MAX = 500;
|
|
354
|
+
|
|
355
|
+
const $ = (id) => document.getElementById(id);
|
|
356
|
+
const dom = {
|
|
357
|
+
conn: $('conn'),
|
|
358
|
+
connText: $('conn-text'),
|
|
359
|
+
metaPort: $('meta-port'),
|
|
360
|
+
list: $('events'),
|
|
361
|
+
empty: $('empty'),
|
|
362
|
+
banner: $('shutdown-banner'),
|
|
363
|
+
pauseBtn: $('pause-btn'),
|
|
364
|
+
autoscrollBtn: $('autoscroll-btn'),
|
|
365
|
+
clearBtn: $('clear-btn'),
|
|
366
|
+
search: $('search'),
|
|
367
|
+
typeFilters: $('type-filters'),
|
|
368
|
+
footerEvents: $('footer-events'),
|
|
369
|
+
footerPaused: $('footer-paused'),
|
|
370
|
+
footerSource: $('footer-source'),
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const state = {
|
|
374
|
+
events: [], // currently rendered events (buffered while paused)
|
|
375
|
+
pausedBuffer: [], // events captured while paused
|
|
376
|
+
paused: false,
|
|
377
|
+
autoscroll: true,
|
|
378
|
+
typeFilter: new Set(EVENT_TYPES), // all enabled
|
|
379
|
+
search: '',
|
|
380
|
+
closedAt: null,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// ------- DOM helpers --------------------------------------------------
|
|
384
|
+
|
|
385
|
+
function el(tag, props = {}, kids = []) {
|
|
386
|
+
const e = document.createElement(tag);
|
|
387
|
+
for (const [k, v] of Object.entries(props)) {
|
|
388
|
+
if (k === 'class') e.className = v;
|
|
389
|
+
else if (k === 'data') for (const [dk, dv] of Object.entries(v)) e.dataset[dk] = dv;
|
|
390
|
+
else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), v);
|
|
391
|
+
else if (k === 'text') e.textContent = v;
|
|
392
|
+
else if (v !== undefined && v !== null) e.setAttribute(k, v);
|
|
393
|
+
}
|
|
394
|
+
for (const k of kids) if (k) e.appendChild(typeof k === 'string' ? document.createTextNode(k) : k);
|
|
395
|
+
return e;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function fmtTime(ts) {
|
|
399
|
+
const d = new Date(ts);
|
|
400
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
401
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
402
|
+
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
403
|
+
return `${hh}:${mm}:${ss}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function eventLabel(evt) {
|
|
407
|
+
// Best-effort short label from payload; fallback to type.
|
|
408
|
+
const p = evt.payload;
|
|
409
|
+
if (!p || typeof p !== 'object') return evt.type;
|
|
410
|
+
if (typeof p.label === 'string') return p.label;
|
|
411
|
+
if (typeof p.tool === 'string') return `tool: ${p.tool}`;
|
|
412
|
+
if (typeof p.percent === 'number') return `${p.percent}%${p.kind ? ' · ' + p.kind : ''}`;
|
|
413
|
+
if (typeof p.message === 'string') return p.message;
|
|
414
|
+
if (typeof p.reason === 'string') return p.reason;
|
|
415
|
+
return evt.type;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function eventMeta(evt) {
|
|
419
|
+
const p = evt.payload;
|
|
420
|
+
if (evt.type === 'progress' && p && typeof p.total === 'number' && typeof p.current === 'number') {
|
|
421
|
+
return `${p.current}/${p.total}`;
|
|
422
|
+
}
|
|
423
|
+
if (evt.runId) return evt.runId.slice(0, 6);
|
|
424
|
+
return '';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function renderEventRow(evt) {
|
|
428
|
+
const time = el('div', { class: 'ev-time', text: fmtTime(evt.ts) });
|
|
429
|
+
const badge = el('span', { class: 'ev-badge', data: { type: evt.type }, text: evt.type });
|
|
430
|
+
const summary = el('summary', {}, [
|
|
431
|
+
el('span', { class: 'label', text: eventLabel(evt) }),
|
|
432
|
+
el('span', { class: 'meta', text: eventMeta(evt) }),
|
|
433
|
+
]);
|
|
434
|
+
const pre = el('pre', { text: JSON.stringify(evt.payload ?? null, null, 2) });
|
|
435
|
+
const details = el('details', {}, [summary, pre]);
|
|
436
|
+
const body = el('div', { class: 'ev-body' }, [details]);
|
|
437
|
+
return el('li', { class: 'ev-row', data: { type: evt.type, label: eventLabel(evt).toLowerCase() } }, [time, badge, body]);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function shouldShow(evt) {
|
|
441
|
+
if (!state.typeFilter.has(evt.type)) return false;
|
|
442
|
+
if (state.search) {
|
|
443
|
+
const haystack = (evt.type + ' ' + eventLabel(evt) + ' ' + JSON.stringify(evt.payload ?? '')).toLowerCase();
|
|
444
|
+
if (!haystack.includes(state.search.toLowerCase())) return false;
|
|
445
|
+
}
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function applyFilter() {
|
|
450
|
+
const rows = dom.list.children;
|
|
451
|
+
let visible = 0;
|
|
452
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
453
|
+
const row = rows[i];
|
|
454
|
+
const evt = state.events[i];
|
|
455
|
+
const show = evt && shouldShow(evt);
|
|
456
|
+
row.style.display = show ? '' : 'none';
|
|
457
|
+
if (show) visible += 1;
|
|
458
|
+
}
|
|
459
|
+
dom.empty.hidden = visible > 0 || state.events.length > 0;
|
|
460
|
+
dom.list.hidden = visible === 0 && state.events.length > 0 ? false : visible === 0;
|
|
461
|
+
if (state.events.length === 0) {
|
|
462
|
+
dom.empty.hidden = false;
|
|
463
|
+
dom.list.hidden = true;
|
|
464
|
+
} else {
|
|
465
|
+
dom.empty.hidden = true;
|
|
466
|
+
dom.list.hidden = false;
|
|
467
|
+
}
|
|
468
|
+
dom.footerEvents.textContent = `events: ${state.events.length}` + (visible !== state.events.length ? ` (showing ${visible})` : '');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function pushVisibleEvent(evt) {
|
|
472
|
+
state.events.push(evt);
|
|
473
|
+
while (state.events.length > RING_DISPLAY_MAX) state.events.shift();
|
|
474
|
+
while (dom.list.children.length > RING_DISPLAY_MAX - 1) dom.list.removeChild(dom.list.firstChild);
|
|
475
|
+
const row = renderEventRow(evt);
|
|
476
|
+
dom.list.appendChild(row);
|
|
477
|
+
if (!shouldShow(evt)) row.style.display = 'none';
|
|
478
|
+
applyFilter();
|
|
479
|
+
if (state.autoscroll) {
|
|
480
|
+
requestAnimationFrame(() => row.scrollIntoView({ block: 'end' }));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function ingestEvent(evt) {
|
|
485
|
+
if (state.paused) {
|
|
486
|
+
state.pausedBuffer.push(evt);
|
|
487
|
+
dom.footerPaused.hidden = false;
|
|
488
|
+
dom.footerPaused.textContent = `paused: ${state.pausedBuffer.length} buffered`;
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
pushVisibleEvent(evt);
|
|
492
|
+
if (evt.type === 'shutdown') {
|
|
493
|
+
dom.banner.hidden = false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function flushPaused() {
|
|
498
|
+
for (const evt of state.pausedBuffer) pushVisibleEvent(evt);
|
|
499
|
+
state.pausedBuffer.length = 0;
|
|
500
|
+
dom.footerPaused.hidden = true;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ------- Filter UI ----------------------------------------------------
|
|
504
|
+
|
|
505
|
+
for (const t of EVENT_TYPES) {
|
|
506
|
+
const cb = el('input', { type: 'checkbox', checked: '' });
|
|
507
|
+
cb.checked = true;
|
|
508
|
+
cb.addEventListener('change', () => {
|
|
509
|
+
if (cb.checked) state.typeFilter.add(t);
|
|
510
|
+
else state.typeFilter.delete(t);
|
|
511
|
+
applyFilter();
|
|
512
|
+
});
|
|
513
|
+
const lbl = el('label', {}, [cb, document.createTextNode(t)]);
|
|
514
|
+
dom.typeFilters.appendChild(lbl);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
dom.search.addEventListener('input', () => {
|
|
518
|
+
state.search = dom.search.value;
|
|
519
|
+
applyFilter();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
dom.pauseBtn.addEventListener('click', () => {
|
|
523
|
+
state.paused = !state.paused;
|
|
524
|
+
dom.pauseBtn.setAttribute('aria-pressed', String(state.paused));
|
|
525
|
+
dom.pauseBtn.textContent = state.paused ? '▶ resume' : '⏸ pause';
|
|
526
|
+
if (!state.paused) flushPaused();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
dom.autoscrollBtn.addEventListener('click', () => {
|
|
530
|
+
state.autoscroll = !state.autoscroll;
|
|
531
|
+
dom.autoscrollBtn.setAttribute('aria-pressed', String(state.autoscroll));
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
dom.clearBtn.addEventListener('click', () => {
|
|
535
|
+
state.events.length = 0;
|
|
536
|
+
while (dom.list.firstChild) dom.list.removeChild(dom.list.firstChild);
|
|
537
|
+
applyFilter();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// ------- Connection lifecycle ----------------------------------------
|
|
541
|
+
|
|
542
|
+
let evtSource = null;
|
|
543
|
+
let closedTimer = null;
|
|
544
|
+
let lastConnectedAt = 0;
|
|
545
|
+
|
|
546
|
+
function setConnState(s) {
|
|
547
|
+
dom.conn.dataset.state = s;
|
|
548
|
+
dom.connText.textContent = s;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function scheduleClosedBanner() {
|
|
552
|
+
if (closedTimer) clearTimeout(closedTimer);
|
|
553
|
+
closedTimer = setTimeout(() => {
|
|
554
|
+
// Only show shutdown banner if still closed after 5s
|
|
555
|
+
if (dom.conn.dataset.state === 'CLOSED') {
|
|
556
|
+
dom.banner.hidden = false;
|
|
557
|
+
}
|
|
558
|
+
}, 5000);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function hydrateFromState() {
|
|
562
|
+
try {
|
|
563
|
+
const res = await fetch('/state', { credentials: 'omit' });
|
|
564
|
+
if (!res.ok) return;
|
|
565
|
+
const j = await res.json();
|
|
566
|
+
if (j.port) dom.metaPort.textContent = `port ${j.port}`;
|
|
567
|
+
if (Array.isArray(j.events)) {
|
|
568
|
+
for (const evt of j.events) ingestEvent(evt);
|
|
569
|
+
}
|
|
570
|
+
} catch (_) {
|
|
571
|
+
// Ignore — SSE may still work
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function connect() {
|
|
576
|
+
setConnState('CONNECTING');
|
|
577
|
+
if (evtSource) try { evtSource.close(); } catch (_) { /* noop */ }
|
|
578
|
+
evtSource = new EventSource('/events');
|
|
579
|
+
evtSource.onopen = () => {
|
|
580
|
+
setConnState('OPEN');
|
|
581
|
+
lastConnectedAt = Date.now();
|
|
582
|
+
if (closedTimer) { clearTimeout(closedTimer); closedTimer = null; }
|
|
583
|
+
// Don't hide banner if we already received a 'shutdown' event
|
|
584
|
+
};
|
|
585
|
+
evtSource.onerror = () => {
|
|
586
|
+
setConnState('CLOSED');
|
|
587
|
+
scheduleClosedBanner();
|
|
588
|
+
// EventSource will retry automatically (we send retry: 3000 from server)
|
|
589
|
+
};
|
|
590
|
+
// Listen for each known event type so 'event:' lines get routed to the same handler
|
|
591
|
+
const handler = (msg) => {
|
|
592
|
+
try {
|
|
593
|
+
const data = JSON.parse(msg.data);
|
|
594
|
+
ingestEvent(data);
|
|
595
|
+
} catch (_) { /* swallow malformed */ }
|
|
596
|
+
};
|
|
597
|
+
for (const t of EVENT_TYPES) evtSource.addEventListener(t, handler);
|
|
598
|
+
evtSource.onmessage = handler; // fallback for events without type field
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ------- Boot ---------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
hydrateFromState().then(() => {
|
|
604
|
+
connect();
|
|
605
|
+
applyFilter();
|
|
606
|
+
});
|
|
607
|
+
</script>
|
|
608
|
+
</body>
|
|
609
|
+
</html>
|