@ai-setting/roy-plugin-task-show 0.4.0 → 0.5.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/public/app.js CHANGED
@@ -1,27 +1,49 @@
1
1
  /* ------------------------------------------------------------------------- */
2
- /* roy-plugin-task-show — small progressive enhancements */
2
+ /* roy-plugin-task-show — frontend with SSE real-time updates */
3
3
  /* ------------------------------------------------------------------------- */
4
4
 
5
5
  /**
6
- * Allow each <tr data-args="..."> to expand a JSON dialog on click.
7
- * `data-args` carries a single-line JSON string; we pretty-print it on demand.
6
+ * Lightweight in-browser state for the index page:
7
+ * - map of taskId session
8
+ * - simple debounce/throttle for re-render
9
+ *
10
+ * The page renders:
11
+ * - a "connection" indicator (green when SSE is up, red when on fallback)
12
+ * - a stats summary (total sessions, running, completed, failed)
13
+ * - a task grid (one card per task with status badge + progress bar)
14
+ * - a live timeline (most recent tool calls across all tasks)
15
+ *
16
+ * Update path:
17
+ * 1. Open an EventSource on /api/events
18
+ * 2. On `snapshot`: replace local state with the server's snapshot
19
+ * 3. On `task.created` / `task.updated` / `task.completed`:
20
+ * replace or insert the session in state, then re-render
21
+ * 4. On `tool.recorded`:
22
+ * append the tool call to the session, update progress, re-render
23
+ * 5. If the SSE connection drops, fall back to a 3-second poll of
24
+ * /api/sessions (the legacy behavior)
8
25
  */
26
+
9
27
  (function attachArgsExpander() {
10
- const rows = document.querySelectorAll('tr[data-args]');
11
- rows.forEach((row) => {
12
- row.style.cursor = "zoom-in";
13
- row.addEventListener("click", () => {
14
- const raw = row.getAttribute("data-args") || "";
15
- let parsed;
16
- try {
17
- parsed = JSON.parse(raw);
18
- } catch {
19
- parsed = raw;
20
- }
21
- openDialog({
22
- title: row.querySelector("td:nth-child(2)")?.textContent?.trim() || "Tool call",
23
- body: JSON.stringify(parsed, null, 2),
24
- });
28
+ /**
29
+ * Allow each <tr data-args="..."> to expand a JSON dialog on click.
30
+ * `data-args` carries a single-line JSON string; we pretty-print it on demand.
31
+ */
32
+ document.addEventListener("click", (e) => {
33
+ const target = e.target;
34
+ if (!(target instanceof Element)) return;
35
+ const row = target.closest("tr[data-args]");
36
+ if (!row) return;
37
+ const raw = row.getAttribute("data-args") || "";
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(raw);
41
+ } catch {
42
+ parsed = raw;
43
+ }
44
+ openDialog({
45
+ title: row.querySelector("td:nth-child(2)")?.textContent?.trim() || "Tool call",
46
+ body: JSON.stringify(parsed, null, 2),
25
47
  });
26
48
  });
27
49
 
@@ -83,51 +105,381 @@
83
105
  })();
84
106
 
85
107
  /**
86
- * Poll the session summary every 3 seconds while a task is running so the
87
- * index page (and the per-task page when status changes) updates without a
88
- * manual refresh. Lightweight: single GET, no debouncing.
108
+ * Initialize a live timeline on the per-task page (when `data-task-id` is
109
+ * present). We re-render the timeline on every `tool.recorded` event.
89
110
  */
90
- (function attachLiveRefresh() {
91
- const banner = document.querySelector('[data-live-refresh]');
111
+ (function attachTaskPageTimeline() {
112
+ const banner = document.querySelector("[data-live-refresh][data-task-id]");
92
113
  if (!banner) return;
93
- const taskId = banner.getAttribute('data-task-id') || '';
94
- const initialStatus = banner.getAttribute('data-status') || 'running';
114
+ const taskId = Number(banner.getAttribute("data-task-id"));
115
+ if (!Number.isFinite(taskId)) return;
116
+
117
+ // Subscribe to SSE for this task only.
118
+ try {
119
+ const es = new EventSource(`/api/events`);
120
+ es.addEventListener("snapshot", (ev) => {
121
+ const data = JSON.parse((ev).data);
122
+ const session = (data.data?.sessions || []).find((s) => s.taskId === taskId);
123
+ if (session) applyTaskUpdate(session);
124
+ });
125
+ ["task.created", "task.updated", "task.completed"].forEach((type) => {
126
+ es.addEventListener(type, (ev) => {
127
+ const data = JSON.parse((ev).data);
128
+ const session = data.data?.session;
129
+ if (session && session.taskId === taskId) applyTaskUpdate(session);
130
+ });
131
+ });
132
+ es.addEventListener("tool.recorded", (ev) => {
133
+ const data = JSON.parse((ev).data);
134
+ const session = data.data?.session;
135
+ if (session && session.taskId === taskId) applyTaskUpdate(session);
136
+ });
137
+ es.onerror = () => {
138
+ // EventSource will auto-reconnect; nothing to do here.
139
+ };
140
+ } catch {
141
+ // ignore — the page will still render statically
142
+ }
143
+
144
+ function applyTaskUpdate(session) {
145
+ // Update status badge.
146
+ const badge = document.querySelector(".badge");
147
+ if (badge) {
148
+ const cls = `badge badge-${session.status}`;
149
+ badge.className = cls;
150
+ badge.textContent = session.status;
151
+ }
152
+ // Update meta line.
153
+ const meta = document.querySelector(".topbar .meta");
154
+ if (meta) {
155
+ const totalMs = session.toolCalls.reduce((a, c) => a + c.durationMs, 0);
156
+ const ok = session.toolCalls.filter((c) => c.success).length;
157
+ const fail = session.toolCalls.length - ok;
158
+ meta.innerHTML =
159
+ `<span class="badge badge-${session.status}">${session.status}</span>` +
160
+ ` · ${session.toolCalls.length} tool call(s)` +
161
+ ` · ${totalMs} ms total` +
162
+ ` · ${ok} ok / ${fail} fail` +
163
+ ` · started ${new Date(session.startedAt).toISOString().replace("T", " ").slice(0, 19)}` +
164
+ (session.endedAt ? ` · ended ${new Date(session.endedAt).toISOString().replace("T", " ").slice(0, 19)}` : "");
165
+ }
166
+ // Re-render tool calls table in place.
167
+ const tbody = document.querySelector("table.toolcalls tbody");
168
+ if (tbody) {
169
+ tbody.innerHTML = session.toolCalls
170
+ .map((c) => renderToolCallRow(c))
171
+ .join("");
172
+ }
173
+ // Re-render stats table.
174
+ const statsTbody = document.querySelector("table.stats tbody");
175
+ if (statsTbody) {
176
+ const aggregate = aggregateStats(session.toolCalls);
177
+ statsTbody.innerHTML =
178
+ Object.entries(aggregate)
179
+ .sort((a, b) => b[1].count - a[1].count)
180
+ .map(
181
+ ([tool, s]) =>
182
+ `<tr><td><code>${escapeHtmlAttr(tool)}</code></td>` +
183
+ `<td>${s.count}</td><td>${s.success}</td><td>${s.fail}</td><td>${s.totalMs} ms</td></tr>`,
184
+ )
185
+ .join("") || `<tr><td colspan="5" class="empty">No tool calls recorded.</td></tr>`;
186
+ }
187
+ // Re-render mermaid diagram.
188
+ const mermaidDiv = document.querySelector(".mermaid");
189
+ if (mermaidDiv && window.mermaid) {
190
+ mermaidDiv.innerHTML = buildMermaidSource(session);
191
+ try {
192
+ window.mermaid.run({ nodes: [mermaidDiv] });
193
+ } catch {
194
+ // ignore — re-render is best-effort
195
+ }
196
+ }
197
+ }
198
+
199
+ function renderToolCallRow(call) {
200
+ const argsJson = JSON.stringify(redactArgs(call.args || {}));
201
+ const preview = call.error
202
+ ? (call.error.slice(0, 200) + (call.error.length > 200 ? "…" : ""))
203
+ : (call.outputPreview.slice(0, 240) + (call.outputPreview.length > 240 ? "…" : ""));
204
+ const status = call.success ? "ok" : "fail";
205
+ const attach = call.hasAttachment ? ' <span class="attach">📎</span>' : "";
206
+ return (
207
+ `<tr class="row-${status}" data-args="${escapeHtmlAttr(argsJson)}">` +
208
+ `<td class="seq">${call.sequence}</td>` +
209
+ `<td><code>${escapeHtml(call.toolName)}</code>${attach}</td>` +
210
+ `<td class="status status-${status}">${status}</td>` +
211
+ `<td>${call.durationMs}</td>` +
212
+ `<td>${escapeHtml(formatTs(call.timestamp))}</td>` +
213
+ `<td class="args">${escapeHtml(argsJson)}</td>` +
214
+ `<td class="result">${escapeHtml(preview)}</td>` +
215
+ `</tr>`
216
+ );
217
+ }
218
+
219
+ function aggregateStats(calls) {
220
+ const out = {};
221
+ for (const c of calls) {
222
+ const cur = out[c.toolName] ?? { count: 0, success: 0, fail: 0, totalMs: 0 };
223
+ cur.count += 1;
224
+ if (c.success) cur.success += 1;
225
+ else cur.fail += 1;
226
+ cur.totalMs += c.durationMs;
227
+ out[c.toolName] = cur;
228
+ }
229
+ return out;
230
+ }
95
231
 
96
- async function poll() {
232
+ function buildMermaidSource(session) {
233
+ const lines = [`flowchart TD`];
234
+ lines.push(` classDef ok fill:#dcfce7,stroke:#16a34a,color:#064e3b`);
235
+ lines.push(` classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d`);
236
+ lines.push(` classDef runtime fill:#dbeafe,stroke:#2563eb,color:#1e3a8a`);
237
+ lines.push(` Start([Task #${session.taskId} start]):::runtime`);
238
+ let prev = "Start";
239
+ if (session.toolCalls.length === 0) {
240
+ lines.push(` End([end]):::runtime`);
241
+ lines.push(` Start --> End`);
242
+ return lines.join("\n");
243
+ }
244
+ session.toolCalls.forEach((call) => {
245
+ const nodeId = `n${call.sequence}`;
246
+ const label = `${call.sequence}. ${escapeMermaid(call.toolName)}${call.hasAttachment ? " 📎" : ""}`;
247
+ lines.push(` ${nodeId}["${label}<br/><small>${call.durationMs}ms · ${call.success ? "ok" : "FAIL"}</small>"]`);
248
+ lines.push(` class ${nodeId} ${call.success ? "ok" : "fail"}`);
249
+ lines.push(` ${prev} --> ${nodeId}`);
250
+ prev = nodeId;
251
+ });
252
+ const endLabel = session.status === "failed" ? `❌ Task ${session.status}` : `✅ Task ${session.status}`;
253
+ lines.push(` End([${endLabel}]):::runtime`);
254
+ lines.push(` ${prev} --> End`);
255
+ return lines.join("\n");
256
+ }
257
+
258
+ function formatTs(ms) {
97
259
  try {
98
- const url = taskId ? `/api/sessions/${encodeURIComponent(taskId)}` : '/api/sessions';
99
- const res = await fetch(url, { headers: { accept: 'application/json' } });
100
- if (!res.ok) return;
101
- const data = await res.json();
102
- if (Array.isArray(data)) {
103
- // Index page — refresh the table cells without losing scroll position.
104
- const tbody = document.querySelector('.sessions tbody');
105
- if (!tbody) return;
106
- tbody.innerHTML = data
107
- .map((s) => {
108
- const tools = Array.from(new Set((s.toolCalls || []).map((c) => c.toolName))).map((t) => `<code>${t}</code>`).join(' ');
109
- return (
110
- '<tr>' +
111
- `<td><a href="/task/${s.taskId}">#${s.taskId}</a></td>` +
112
- `<td><span class="badge badge-${s.status}">${s.status}</span></td>` +
113
- `<td>${(s.toolCalls || []).length} calls · ${(s.toolCalls || []).reduce((a, c) => a + c.durationMs, 0)} ms</td>` +
114
- `<td>${(s.title || '(no title)')}</td>` +
115
- `<td>${new Date(s.startedAt).toISOString().replace('T', ' ').slice(0, 19)}</td>` +
116
- `<td class="tools">${tools}</td>` +
117
- '</tr>'
118
- );
119
- })
120
- .join('');
121
- } else if (data && data.taskId != null) {
122
- if (data.status !== initialStatus) {
123
- // Reload to pick up the new status badge & flow chart.
124
- location.reload();
125
- }
260
+ return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
261
+ } catch {
262
+ return String(ms);
263
+ }
264
+ }
265
+
266
+ function redactArgs(args) {
267
+ const clone = {};
268
+ for (const [k, v] of Object.entries(args || {})) {
269
+ const lk = k.toLowerCase();
270
+ if (/(api[_-]?key|token|password|pass|secret)/.test(lk)) {
271
+ clone[k] = "***REDACTED***";
272
+ } else {
273
+ clone[k] = v;
126
274
  }
127
- } catch (e) {
128
- // network blip — try again next tick
129
275
  }
276
+ return clone;
277
+ }
278
+
279
+ function escapeMermaid(s) {
280
+ return String(s).replace(/[<>"#]/g, "").replace(/[^A-Za-z0-9_.\-]/g, "_");
281
+ }
282
+
283
+ function escapeHtml(s) {
284
+ return String(s)
285
+ .replace(/&/g, "&amp;")
286
+ .replace(/</g, "&lt;")
287
+ .replace(/>/g, "&gt;")
288
+ .replace(/"/g, "&quot;")
289
+ .replace(/'/g, "&#39;");
130
290
  }
131
291
 
132
- setInterval(poll, 3000);
292
+ function escapeHtmlAttr(s) {
293
+ return escapeHtml(s);
294
+ }
133
295
  })();
296
+
297
+ /**
298
+ * Index page: subscribe to SSE and keep the table + cards in sync. Falls
299
+ * back to a 3-second poll if EventSource is unavailable (very old browsers)
300
+ * or if the connection is dropped.
301
+ */
302
+ (function attachIndexLiveRefresh() {
303
+ const banner = document.querySelector("[data-live-refresh]");
304
+ if (!banner) return;
305
+ // Per-task page also has data-live-refresh; skip if it has data-task-id.
306
+ if (banner.hasAttribute("data-task-id")) return;
307
+
308
+ const tbody = document.querySelector(".sessions tbody");
309
+ if (!tbody) return;
310
+
311
+ // Local state.
312
+ const sessions = new Map();
313
+
314
+ let usePollingFallback = typeof EventSource === "undefined";
315
+ let pollTimer = null;
316
+ let source = null;
317
+
318
+ // ---------------------------------------------------------------------
319
+ // Helpers
320
+ // ---------------------------------------------------------------------
321
+
322
+ function setConnectionStatus(state, label) {
323
+ const el = document.querySelector("[data-connection]");
324
+ if (!el) return;
325
+ const dot = el.querySelector(".conn-dot");
326
+ const text = el.querySelector(".conn-label");
327
+ if (dot) {
328
+ dot.className =
329
+ "conn-dot " +
330
+ (state === "connected"
331
+ ? "conn-connected"
332
+ : state === "fallback"
333
+ ? "conn-fallback"
334
+ : "conn-disconnected");
335
+ }
336
+ if (text) text.textContent = label;
337
+ }
338
+
339
+ function renderIndex() {
340
+ const arr = Array.from(sessions.values()).sort(
341
+ (a, b) => b.startedAt - a.startedAt,
342
+ );
343
+ if (arr.length === 0) {
344
+ tbody.innerHTML =
345
+ `<tr><td colspan="6" class="empty">No tasks recorded yet. Trigger a tool call (e.g. send a message in <code>interactive</code> mode) and watch this page update live.</td></tr>`;
346
+ return;
347
+ }
348
+ tbody.innerHTML = arr.map(renderSessionRow).join("");
349
+ }
350
+
351
+ function renderSessionRow(s) {
352
+ const tools = new Set((s.toolCalls || []).map((c) => c.toolName));
353
+ const totalMs = (s.toolCalls || []).reduce((a, c) => a + c.durationMs, 0);
354
+ const status = s.status || "running";
355
+ return (
356
+ `<tr>` +
357
+ `<td><a href="/task/${s.taskId}">#${s.taskId}</a></td>` +
358
+ `<td><span class="badge badge-${status}">${escapeHtml(status)}</span></td>` +
359
+ `<td>${(s.toolCalls || []).length} calls · ${totalMs} ms</td>` +
360
+ `<td>${escapeHtml(s.title || "(no title)")}</td>` +
361
+ `<td>${formatTs(s.startedAt)}</td>` +
362
+ `<td class="tools">${Array.from(tools).map((t) => `<code>${escapeHtml(t)}</code>`).join(" ")}</td>` +
363
+ `</tr>`
364
+ );
365
+ }
366
+
367
+ function upsertSession(session) {
368
+ if (!session || typeof session.taskId !== "number") return;
369
+ sessions.set(session.taskId, session);
370
+ renderIndex();
371
+ }
372
+
373
+ // ---------------------------------------------------------------------
374
+ // SSE path
375
+ // ---------------------------------------------------------------------
376
+
377
+ function openSSE() {
378
+ if (typeof EventSource === "undefined") {
379
+ startPollingFallback();
380
+ return;
381
+ }
382
+ try {
383
+ source = new EventSource("/api/events");
384
+ } catch {
385
+ startPollingFallback();
386
+ return;
387
+ }
388
+
389
+ source.addEventListener("open", () => {
390
+ setConnectionStatus("connected", "Live (SSE)");
391
+ // If we were polling, stop now.
392
+ if (pollTimer) {
393
+ clearInterval(pollTimer);
394
+ pollTimer = null;
395
+ }
396
+ });
397
+
398
+ source.addEventListener("snapshot", (ev) => {
399
+ const data = JSON.parse(ev.data);
400
+ const list = data.data?.sessions || [];
401
+ sessions.clear();
402
+ for (const s of list) sessions.set(s.taskId, s);
403
+ renderIndex();
404
+ });
405
+
406
+ ["task.created", "task.updated", "task.completed"].forEach((type) => {
407
+ source.addEventListener(type, (ev) => {
408
+ const data = JSON.parse(ev.data);
409
+ const session = data.data?.session;
410
+ if (session) upsertSession(session);
411
+ });
412
+ });
413
+
414
+ source.addEventListener("tool.recorded", (ev) => {
415
+ const data = JSON.parse(ev.data);
416
+ const session = data.data?.session;
417
+ if (session) upsertSession(session);
418
+ });
419
+
420
+ source.addEventListener("error", () => {
421
+ // EventSource auto-reconnects, but if it stays down for too long
422
+ // we kick in the polling fallback.
423
+ setConnectionStatus("disconnected", "Reconnecting…");
424
+ if (!pollTimer) {
425
+ setTimeout(() => {
426
+ if (source && source.readyState === EventSource.CLOSED) {
427
+ startPollingFallback();
428
+ }
429
+ }, 3000);
430
+ }
431
+ });
432
+ }
433
+
434
+ // ---------------------------------------------------------------------
435
+ // Polling fallback (3s) — the legacy v0.1.0–v0.4.0 behavior
436
+ // ---------------------------------------------------------------------
437
+
438
+ function startPollingFallback() {
439
+ usePollingFallback = true;
440
+ setConnectionStatus("fallback", "Polling (3s fallback)");
441
+ pollTimer = setInterval(pollOnce, 3000);
442
+ pollOnce();
443
+ }
444
+
445
+ async function pollOnce() {
446
+ try {
447
+ const res = await fetch("/api/sessions", { headers: { accept: "application/json" } });
448
+ if (!res.ok) return;
449
+ const list = await res.json();
450
+ sessions.clear();
451
+ for (const s of list) sessions.set(s.taskId, s);
452
+ renderIndex();
453
+ } catch {
454
+ // network blip — try again next tick
455
+ }
456
+ }
457
+
458
+ // ---------------------------------------------------------------------
459
+ // Initial paint
460
+ // ---------------------------------------------------------------------
461
+
462
+ setConnectionStatus("disconnected", "Connecting…");
463
+ if (!usePollingFallback) {
464
+ openSSE();
465
+ } else {
466
+ startPollingFallback();
467
+ }
468
+
469
+ function formatTs(ms) {
470
+ try {
471
+ return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
472
+ } catch {
473
+ return String(ms);
474
+ }
475
+ }
476
+
477
+ function escapeHtml(s) {
478
+ return String(s)
479
+ .replace(/&/g, "&amp;")
480
+ .replace(/</g, "&lt;")
481
+ .replace(/>/g, "&gt;")
482
+ .replace(/"/g, "&quot;")
483
+ .replace(/'/g, "&#39;");
484
+ }
485
+ })();
package/public/index.html CHANGED
@@ -12,23 +12,41 @@
12
12
  Standalone landing page served by the visualization service. The plugin
13
13
  replaces the index when sessions exist.
14
14
  </p>
15
+ <p class="meta connection-status" data-connection>
16
+ <span class="conn-dot conn-disconnected"></span>
17
+ <span class="conn-label">Connecting…</span>
18
+ </p>
15
19
  </header>
16
20
  <section class="panel">
17
21
  <h2>No sessions yet</h2>
18
22
  <p class="empty">
19
23
  The <code>roy-agent</code> host process has not recorded any tool
20
24
  calls yet. Trigger a session — for example by sending a message in
21
- <code>interactive</code> mode — and refresh this page.
22
- </p>
23
- <p>
24
- Useful endpoints while you wait:
25
- <ul>
26
- <li><a href="/api/sessions">/api/sessions</a> — JSON list of recorded sessions.</li>
27
- <li><a href="/static/style.css">/static/style.css</a> — bundled CSS.</li>
28
- <li><a href="/static/app.js">/static/app.js</a> — bundled JS.</li>
29
- </ul>
25
+ <code>interactive</code> mode — and the page will update live via
26
+ Server-Sent Events.
30
27
  </p>
28
+ <table class="sessions">
29
+ <thead>
30
+ <tr><th>Task ID</th><th>Status</th><th>Stats</th><th>Title</th><th>Started</th><th>Tools</th></tr>
31
+ </thead>
32
+ <tbody></tbody>
33
+ </table>
34
+ </section>
35
+ <section class="panel">
36
+ <h2>How to read this page</h2>
37
+ <ul>
38
+ <li>The page subscribes to <code>/api/events</code> (SSE) for real-time updates.</li>
39
+ <li>If the SSE connection fails, a 3-second polling fallback keeps the page fresh.</li>
40
+ <li>Useful endpoints:
41
+ <ul>
42
+ <li><a href="/api/sessions">/api/sessions</a> — JSON list of recorded sessions.</li>
43
+ <li><a href="/api/events">/api/events</a> — SSE event stream.</li>
44
+ <li><a href="/static/style.css">/static/style.css</a> — bundled CSS.</li>
45
+ <li><a href="/static/app.js">/static/app.js</a> — bundled JS.</li>
46
+ </ul>
47
+ </li>
48
+ </ul>
31
49
  </section>
32
50
  <script src="/static/app.js"></script>
33
51
  </body>
34
- </html>
52
+ </html>
package/public/style.css CHANGED
@@ -11,6 +11,7 @@
11
11
  --accent: #60a5fa;
12
12
  --good: #22c55e;
13
13
  --bad: #ef4444;
14
+ --warn: #f59e0b;
14
15
  --code-bg: #0f172a;
15
16
  --border: #1e293b;
16
17
  --link: #38bdf8;
@@ -89,6 +90,47 @@ pre {
89
90
  margin-bottom: 8px;
90
91
  }
91
92
 
93
+ /* ---------- Connection status indicator ---------- */
94
+ .connection-status {
95
+ display: inline-flex;
96
+ align-items: center;
97
+ gap: 8px;
98
+ margin: 8px 0 0 !important;
99
+ padding: 4px 10px;
100
+ border-radius: 999px;
101
+ background: rgba(148, 163, 184, 0.1);
102
+ border: 1px solid var(--border);
103
+ font-size: 12px !important;
104
+ color: var(--muted) !important;
105
+ }
106
+ .conn-dot {
107
+ width: 9px;
108
+ height: 9px;
109
+ border-radius: 50%;
110
+ display: inline-block;
111
+ flex-shrink: 0;
112
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.04);
113
+ transition: background 0.2s, box-shadow 0.2s;
114
+ }
115
+ .conn-connected {
116
+ background: var(--good);
117
+ box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25), 0 0 8px rgba(34, 197, 94, 0.4);
118
+ animation: pulse 1.6s ease-in-out infinite;
119
+ }
120
+ .conn-fallback {
121
+ background: var(--warn);
122
+ box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25);
123
+ }
124
+ .conn-disconnected {
125
+ background: var(--bad);
126
+ box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25);
127
+ }
128
+ @keyframes pulse {
129
+ 0%, 100% { opacity: 1; }
130
+ 50% { opacity: 0.55; }
131
+ }
132
+
133
+ /* ---------- Status badges ---------- */
92
134
  .badge {
93
135
  display: inline-block;
94
136
  padding: 2px 9px;
@@ -114,11 +156,16 @@ pre {
114
156
  background: rgba(148, 163, 184, 0.25);
115
157
  color: #cbd5e1;
116
158
  }
159
+ .badge-paused {
160
+ background: rgba(245, 158, 11, 0.25);
161
+ color: #fcd34d;
162
+ }
117
163
  .badge-unknown {
118
164
  background: rgba(148, 163, 184, 0.18);
119
165
  color: #cbd5e1;
120
166
  }
121
167
 
168
+ /* ---------- Panels ---------- */
122
169
  .panel {
123
170
  margin: 22px 28px;
124
171
  padding: 18px 22px;
@@ -134,6 +181,7 @@ pre {
134
181
  color: #c7d2fe;
135
182
  }
136
183
 
184
+ /* ---------- Tables ---------- */
137
185
  table {
138
186
  width: 100%;
139
187
  border-collapse: collapse;
@@ -237,4 +285,4 @@ details summary {
237
285
  padding: 14px 14px;
238
286
  margin: 14px 10px;
239
287
  }
240
- }
288
+ }