@hoox-sh/hoox-tui 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.
Files changed (104) hide show
  1. package/README.md +247 -0
  2. package/bin/hoox-tui.js +35 -0
  3. package/dist/main.js +17594 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +696 -0
  6. package/src/cli-bridge-test-double.ts +212 -0
  7. package/src/components/layout/layout.test.tsx +205 -0
  8. package/src/components/layout/sidebar.tsx +69 -0
  9. package/src/components/layout/statusbar.test.tsx +423 -0
  10. package/src/components/layout/statusbar.tsx +348 -0
  11. package/src/components/shared/animated-border.tsx +58 -0
  12. package/src/components/shared/chrome.test.tsx +29 -0
  13. package/src/components/shared/command-palette.tsx +361 -0
  14. package/src/components/shared/cool-brackets.tsx +75 -0
  15. package/src/components/shared/crash-screen.tsx +167 -0
  16. package/src/components/shared/error-boundary.tsx +99 -0
  17. package/src/components/shared/keybinding-hint.tsx +37 -0
  18. package/src/components/shared/panel.tsx +61 -0
  19. package/src/components/shared/quit-modal.tsx +65 -0
  20. package/src/components/shared/shared.test.tsx +538 -0
  21. package/src/components/shared/spinner.tsx +85 -0
  22. package/src/components/shared/status-dot.tsx +54 -0
  23. package/src/components/shared/view-header.tsx +53 -0
  24. package/src/components/ui/connection-toasts.test.ts +50 -0
  25. package/src/components/ui/connection-toasts.ts +215 -0
  26. package/src/components/ui/dialog.tsx +281 -0
  27. package/src/components/ui/select.tsx +197 -0
  28. package/src/components/ui/toast.tsx +185 -0
  29. package/src/components/views/ai-chat.test.tsx +81 -0
  30. package/src/components/views/ai-chat.tsx +576 -0
  31. package/src/components/views/config-editor/action-bar.tsx +102 -0
  32. package/src/components/views/config-editor/code-editor.tsx +597 -0
  33. package/src/components/views/config-editor/file-tree.tsx +113 -0
  34. package/src/components/views/config-editor/types.ts +37 -0
  35. package/src/components/views/config-editor.test.tsx +465 -0
  36. package/src/components/views/config-editor.tsx +709 -0
  37. package/src/components/views/dashboard/alerts-panel.tsx +154 -0
  38. package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
  39. package/src/components/views/dashboard/model-health-section.tsx +276 -0
  40. package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
  41. package/src/components/views/dashboard.test.tsx +347 -0
  42. package/src/components/views/dashboard.tsx +647 -0
  43. package/src/components/views/db-query.test.tsx +115 -0
  44. package/src/components/views/db-query.tsx +849 -0
  45. package/src/components/views/edge-topology.test.tsx +198 -0
  46. package/src/components/views/edge-topology.tsx +572 -0
  47. package/src/components/views/kv-viewer.test.tsx +241 -0
  48. package/src/components/views/kv-viewer.tsx +717 -0
  49. package/src/components/views/logs-viewer.test.tsx +273 -0
  50. package/src/components/views/logs-viewer.tsx +616 -0
  51. package/src/components/views/queue-depth.test.tsx +221 -0
  52. package/src/components/views/queue-depth.tsx +376 -0
  53. package/src/components/views/secrets-viewer.test.tsx +65 -0
  54. package/src/components/views/secrets-viewer.tsx +548 -0
  55. package/src/components/views/service-manager.test.tsx +358 -0
  56. package/src/components/views/service-manager.tsx +946 -0
  57. package/src/components/views/settings.test.tsx +648 -0
  58. package/src/components/views/settings.tsx +1497 -0
  59. package/src/components/views/setup-wizard.test.tsx +297 -0
  60. package/src/components/views/setup-wizard.tsx +1321 -0
  61. package/src/components/views/trade-monitor.test.tsx +592 -0
  62. package/src/components/views/trade-monitor.tsx +745 -0
  63. package/src/components/views/worker-detail.test.tsx +289 -0
  64. package/src/components/views/worker-detail.tsx +676 -0
  65. package/src/components/views/worker-settings.test.tsx +43 -0
  66. package/src/components/views/worker-settings.tsx +563 -0
  67. package/src/components/views/workers-overview.test.tsx +260 -0
  68. package/src/components/views/workers-overview.tsx +631 -0
  69. package/src/hooks/hooks.test.ts +72 -0
  70. package/src/hooks/index.ts +11 -0
  71. package/src/hooks/renderer-ref.ts +22 -0
  72. package/src/hooks/use-keyboard.ts +130 -0
  73. package/src/hooks/use-polling.ts +73 -0
  74. package/src/hooks/use-service-data.ts +18 -0
  75. package/src/main.tsx +117 -0
  76. package/src/network-test-double.ts +133 -0
  77. package/src/opentui-augment.d.ts +45 -0
  78. package/src/opentui-jsx.d.ts +70 -0
  79. package/src/services/cli-bridge/index.ts +1662 -0
  80. package/src/services/cli-bridge/standalone.ts +315 -0
  81. package/src/services/cli-bridge/types.ts +307 -0
  82. package/src/services/cli-bridge.test.ts +406 -0
  83. package/src/services/clipboard.test.ts +57 -0
  84. package/src/services/clipboard.ts +180 -0
  85. package/src/services/dashboard-settings-loader.ts +31 -0
  86. package/src/services/dev-log.test.ts +168 -0
  87. package/src/services/dev-log.ts +194 -0
  88. package/src/services/hoox-path-service.test.ts +183 -0
  89. package/src/services/hoox-path-service.ts +185 -0
  90. package/src/services/tui-connection.test.ts +212 -0
  91. package/src/services/tui-connection.ts +236 -0
  92. package/src/services/tui-storage.test.ts +92 -0
  93. package/src/services/tui-storage.ts +86 -0
  94. package/src/stores/config-store.test.ts +360 -0
  95. package/src/stores/service-store.test.ts +1026 -0
  96. package/src/stores/session-restore.test.ts +134 -0
  97. package/src/stores/ui-store.test.ts +276 -0
  98. package/src/test-setup.ts +265 -0
  99. package/src/test-utils.ts +179 -0
  100. package/src/types.ts +153 -0
  101. package/src/utils/colors.test.ts +326 -0
  102. package/src/utils/formatters.test.ts +236 -0
  103. package/src/view-registry.test.ts +132 -0
  104. package/src/view-registry.tsx +368 -0
@@ -0,0 +1,676 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /** @jsxImportSource @opentui/react */
7
+ /**
8
+ * WorkerDetail — deep-dive view into a single Cloudflare Worker.
9
+ *
10
+ * Layout:
11
+ * - Breadcrumb header: "← BACK | {worker.name} | {StatusDot} {status}"
12
+ * - 4-pane layout:
13
+ * 1. Metrics — uptime, CPU avg/p99, memory, requests, errors (from WorkerInfo)
14
+ * 2. Live Logs — streaming log entries from store, auto-scroll, Space to pause, color-coded
15
+ * 3. Durable Objects — list with name + status (derived from DO count)
16
+ * 4. Config Preview — read-only key:value grid
17
+ * - Tab cycles focus between panes
18
+ * - Esc returns to workers overview via UI store goBack()
19
+ *
20
+ * Follows TUI Patterns 1 (View Composition), 2 (Store Subscription), 8 (ScrollBox).
21
+ * Colors from @hoox-sh/hoox-shared tokens — no hardcoded hex.
22
+ */
23
+ import { useState, useMemo, useEffect, useCallback, useRef } from "react";
24
+ import { useKeyboard } from "@opentui/react";
25
+ import {
26
+ Colors,
27
+ LogLevelColor,
28
+ useServiceStore,
29
+ useUIStore,
30
+ } from "@hoox-sh/hoox-shared";
31
+ import { cliBridge } from "../../services/cli-bridge";
32
+ import {
33
+ redactDevLogContext,
34
+ redactSecretsInText,
35
+ } from "../../services/dev-log";
36
+ import { ErrorBoundary } from "../shared/error-boundary";
37
+ import { StatusDot, type StatusDotStatus } from "../shared/status-dot";
38
+ import { ViewHeader } from "../shared/view-header";
39
+
40
+ // ── Local type aliases (inferred from store return types) ──────────────────
41
+
42
+ /** Worker info shape — inferred from service-store WorkerInfo */
43
+ type Worker = ReturnType<typeof useServiceStore.getState>["workers"][number];
44
+
45
+ /** Log entry shape — inferred from service-store LogEntry */
46
+ type Log = ReturnType<typeof useServiceStore.getState>["logs"][number];
47
+
48
+ /** Log level type */
49
+ type Level = "debug" | "info" | "warn" | "error";
50
+
51
+ // ── Constants ────────────────────────────────────────────────────────────────
52
+
53
+ /** Number of focusable panes in the 2×2 grid */
54
+ const PANE_COUNT = 4;
55
+
56
+ const PANE_NAMES = [
57
+ "Metrics",
58
+ "Live Logs",
59
+ "Durable Objects",
60
+ "Config Preview",
61
+ ] as const;
62
+
63
+ /** Keys that must never be shown cleartext in config preview. */
64
+ const SECRET_CONFIG_KEY_RE =
65
+ /token|secret|password|passwd|authorization|api[_-]?key|private[_-]?key|client[_-]?secret|access[_-]?client|credential|cookie|session|webhook/i;
66
+
67
+ // ── Types ────────────────────────────────────────────────────────────────────
68
+
69
+ interface DemoConfigEntry {
70
+ key: string;
71
+ value: string;
72
+ }
73
+
74
+ /**
75
+ * Flatten CLI config into safe display entries.
76
+ * Secret-looking keys → `[redacted]`; free-text values scrubbed for tokens.
77
+ * Exported for unit tests.
78
+ */
79
+ export function redactConfigEntries(
80
+ data: Record<string, unknown>
81
+ ): DemoConfigEntry[] {
82
+ const scrubbed = (redactDevLogContext(data) ?? {}) as Record<string, unknown>;
83
+ return Object.entries(scrubbed).map(([key, value]) => {
84
+ if (SECRET_CONFIG_KEY_RE.test(key)) {
85
+ return { key, value: "[redacted]" };
86
+ }
87
+ const raw =
88
+ typeof value === "object" && value !== null
89
+ ? JSON.stringify(value)
90
+ : String(value ?? "");
91
+ return { key, value: redactSecretsInText(raw) };
92
+ });
93
+ }
94
+
95
+ // ── Sub-components ───────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Breadcrumb header showing back navigation, worker name, and status indicator.
99
+ */
100
+ function BreadcrumbHeader({ worker }: { worker: Worker }) {
101
+ return (
102
+ <ViewHeader
103
+ title={worker.name}
104
+ showDivider={false}
105
+ meta={
106
+ <box flexDirection="row" gap={2}>
107
+ <text fg={Colors.muted} dim>
108
+ ← BACK
109
+ </text>
110
+ <StatusDot
111
+ status={worker.status as StatusDotStatus}
112
+ pulse={worker.status === "operational"}
113
+ />
114
+ <text fg={Colors.foreground}>{worker.status.toUpperCase()}</text>
115
+ </box>
116
+ }
117
+ />
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Metrics pane — key health and performance indicators.
123
+ */
124
+ function MetricsPane({ worker }: { worker: Worker }) {
125
+ // WorkerInfo.cpu is a percentage (0–100), memory is MB used (no hard limit
126
+ // is exposed by the API — do not invent a denominator or P99).
127
+ const cpuPct = Number.isFinite(worker.cpu) ? worker.cpu : 0;
128
+ const memMB = Number.isFinite(worker.memory) ? worker.memory : 0;
129
+ const requests = Number.isFinite(worker.requests) ? worker.requests : 0;
130
+
131
+ const rows: [string, string, string][] = [
132
+ ["Uptime", formatUptime(worker.uptime), Colors.success],
133
+ [
134
+ "CPU",
135
+ `${cpuPct.toFixed(1)}%`,
136
+ cpuPct > 80
137
+ ? Colors.error
138
+ : cpuPct > 60
139
+ ? Colors.warning
140
+ : Colors.success,
141
+ ],
142
+ [
143
+ "Memory",
144
+ `${memMB.toFixed(0)} MB`,
145
+ memMB > 100 ? Colors.warning : Colors.success,
146
+ ],
147
+ ["Requests (24h)", requests.toLocaleString(), Colors.info],
148
+ ["DO count", String(worker.durableObjectCount ?? 0), Colors.muted],
149
+ ["Edges", String(worker.edgeCount ?? 0), Colors.muted],
150
+ ];
151
+
152
+ return (
153
+ <box
154
+ flexDirection="column"
155
+ flexGrow={1}
156
+ padding={1}
157
+ border={true}
158
+ borderStyle="single"
159
+ borderColor={Colors.border}
160
+ backgroundColor={Colors.card}
161
+ >
162
+ <text fg={Colors.accent} bold>
163
+ Metrics
164
+ </text>
165
+ <box flexDirection="column" paddingTop={1} gap={0}>
166
+ {rows.map(([label, value, color]) => (
167
+ <box key={label} flexDirection="row" gap={1}>
168
+ <text fg={Colors.muted} dim>
169
+ {label.padEnd(14)}
170
+ </text>
171
+ <text fg={color}>{value}</text>
172
+ </box>
173
+ ))}
174
+ </box>
175
+ </box>
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Live Logs pane — streaming log entries with auto-scroll and pause/resume.
181
+ */
182
+ function LogsPane({
183
+ workerId,
184
+ focused,
185
+ }: {
186
+ workerId: string;
187
+ focused: boolean;
188
+ }) {
189
+ // All logs from the ring buffer, filtered to this worker
190
+ const allLogs = useServiceStore((s) => s.logs);
191
+ const workerLogs = useMemo(
192
+ () => allLogs.filter((l) => l.workerId === workerId),
193
+ [allLogs, workerId]
194
+ );
195
+
196
+ // Pause toggle via Space key
197
+ const [paused, setPaused] = useState(false);
198
+
199
+ // Auto-scroll: track whether we're at the bottom
200
+ const [, setAutoScroll] = useState(true);
201
+
202
+ // When not paused, always show newest entries
203
+ useEffect(() => {
204
+ if (!paused) {
205
+ setAutoScroll(true);
206
+ }
207
+ }, [workerLogs.length, paused]);
208
+
209
+ // Keyboard: Space toggles pause when this pane is focused
210
+ useKeyboard((key) => {
211
+ if (!focused) return;
212
+ if (key.name === "space") {
213
+ setPaused((p) => !p);
214
+ }
215
+ });
216
+
217
+ // Display logs: last 50 entries (or all if paused)
218
+ const displayedLogs = useMemo(() => {
219
+ const source = paused ? workerLogs : workerLogs.slice(-50);
220
+ return source;
221
+ }, [workerLogs, paused]);
222
+
223
+ return (
224
+ <box
225
+ flexDirection="column"
226
+ flexGrow={1}
227
+ padding={1}
228
+ border={true}
229
+ borderStyle="single"
230
+ borderColor={focused ? Colors.accent : Colors.border}
231
+ backgroundColor={Colors.card}
232
+ >
233
+ {/* Header with pause indicator */}
234
+ <box flexDirection="row" justifyContent="space-between">
235
+ <text fg={Colors.accent} bold>
236
+ Live Logs
237
+ </text>
238
+ <box flexDirection="row" gap={1}>
239
+ <text fg={paused ? Colors.warning : Colors.success}>
240
+ {paused ? "⏸ PAUSED" : "▶ LIVE"}
241
+ </text>
242
+ {focused && (
243
+ <text fg={Colors.muted} dim>
244
+ [Space]
245
+ </text>
246
+ )}
247
+ </box>
248
+ </box>
249
+
250
+ {/* Log entries in scrollable container */}
251
+ <scrollbox width="100%" flexGrow={1} border={false}>
252
+ {displayedLogs.length === 0 ? (
253
+ <text fg={Colors.muted} dim>
254
+ No logs yet...
255
+ </text>
256
+ ) : (
257
+ displayedLogs.map((log) => <LogLine key={log.id} log={log} />)
258
+ )}
259
+ </scrollbox>
260
+ </box>
261
+ );
262
+ }
263
+
264
+ /** A single log line, color-coded by level. Message is scrubbed for secrets. */
265
+ function LogLine({ log }: { log: Log }) {
266
+ const color = LogLevelColor[log.level as Level] ?? Colors.foreground;
267
+ const time = new Date(log.timestamp).toLocaleTimeString("en-US", {
268
+ hour12: false,
269
+ hour: "2-digit",
270
+ minute: "2-digit",
271
+ second: "2-digit",
272
+ });
273
+ const safeMessage = redactSecretsInText(log.message ?? "");
274
+
275
+ return (
276
+ <text fg={color}>
277
+ {time} [{log.level.toUpperCase().padEnd(5)}] {safeMessage}
278
+ </text>
279
+ );
280
+ }
281
+
282
+ /**
283
+ * Durable Objects pane — count + honesty when names are unavailable.
284
+ *
285
+ * The Workers API currently exposes only `durableObjectCount`, not DO
286
+ * names. We refuse to invent names (older UI fabricated
287
+ * `{worker}-state|cache|queue`).
288
+ */
289
+ function DurableObjectsPane({ worker }: { worker: Worker }) {
290
+ const count = Math.max(0, worker.durableObjectCount ?? 0);
291
+ const statusLabel =
292
+ worker.status === "down"
293
+ ? "worker down"
294
+ : worker.status === "degraded"
295
+ ? "degraded"
296
+ : "operational";
297
+
298
+ return (
299
+ <box
300
+ flexDirection="column"
301
+ flexGrow={1}
302
+ padding={1}
303
+ border={true}
304
+ borderStyle="single"
305
+ borderColor={Colors.border}
306
+ backgroundColor={Colors.card}
307
+ >
308
+ <text fg={Colors.accent} bold>
309
+ Durable Objects
310
+ </text>
311
+ <box flexDirection="column" paddingTop={1} gap={0}>
312
+ <box flexDirection="row" gap={1}>
313
+ <text fg={Colors.muted} dim>
314
+ Count
315
+ </text>
316
+ <text fg={Colors.foreground} bold>
317
+ {count}
318
+ </text>
319
+ </box>
320
+ <box flexDirection="row" gap={1}>
321
+ <text fg={Colors.muted} dim>
322
+ Worker
323
+ </text>
324
+ <text
325
+ fg={
326
+ worker.status === "down"
327
+ ? Colors.error
328
+ : worker.status === "degraded"
329
+ ? Colors.warning
330
+ : Colors.success
331
+ }
332
+ >
333
+ {statusLabel}
334
+ </text>
335
+ </box>
336
+ <text fg={Colors.muted} dim>
337
+ {count === 0
338
+ ? "No Durable Objects reported for this worker."
339
+ : "Names unavailable — API exposes count only."}
340
+ </text>
341
+ </box>
342
+ </box>
343
+ );
344
+ }
345
+
346
+ /**
347
+ * Config Preview pane — read-only key:value pairs from CLI only.
348
+ * Never invents demo exchanges/symbols when configShow is empty.
349
+ */
350
+ function ConfigPreviewPane({
351
+ worker,
352
+ entries,
353
+ loading,
354
+ }: {
355
+ worker: Worker;
356
+ entries?: DemoConfigEntry[];
357
+ loading?: boolean;
358
+ }) {
359
+ // Only show CLI-sourced entries — plus a short identity row from the worker.
360
+ const displayEntries: DemoConfigEntry[] = useMemo(() => {
361
+ if (entries && entries.length > 0) return entries;
362
+ return [
363
+ { key: "name", value: worker.name },
364
+ { key: "status", value: worker.status },
365
+ { key: "version", value: worker.version || "—" },
366
+ ];
367
+ }, [entries, worker]);
368
+
369
+ const showingIdentityOnly = !entries || entries.length === 0;
370
+
371
+ return (
372
+ <box
373
+ flexDirection="column"
374
+ flexGrow={1}
375
+ padding={1}
376
+ border={true}
377
+ borderStyle="single"
378
+ borderColor={Colors.border}
379
+ backgroundColor={Colors.card}
380
+ >
381
+ <box flexDirection="row" justifyContent="space-between">
382
+ <text fg={Colors.accent} bold>
383
+ Config Preview
384
+ </text>
385
+ {loading && (
386
+ <text fg={Colors.muted} dim>
387
+ loading...
388
+ </text>
389
+ )}
390
+ </box>
391
+ {showingIdentityOnly && !loading && (
392
+ <text fg={Colors.muted} dim>
393
+ Live config unavailable · identity only
394
+ </text>
395
+ )}
396
+ <box flexDirection="column" paddingTop={1} gap={0}>
397
+ {displayEntries.map((entry) => (
398
+ <box key={entry.key} flexDirection="row" gap={1}>
399
+ <text fg={Colors.muted} dim>
400
+ {entry.key.padEnd(14)}
401
+ </text>
402
+ <text fg={Colors.foreground}>{entry.value}</text>
403
+ </box>
404
+ ))}
405
+ </box>
406
+ </box>
407
+ );
408
+ }
409
+
410
+ // ── Helpers ──────────────────────────────────────────────────────────────────
411
+
412
+ /** Format uptime seconds into a human-readable string. */
413
+ function formatUptime(seconds: number): string {
414
+ if (seconds < 60) return `${seconds}s`;
415
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
416
+ if (seconds < 86400)
417
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
418
+ const days = Math.floor(seconds / 86400);
419
+ const hours = Math.floor((seconds % 86400) / 3600);
420
+ return `${days}d ${hours}h`;
421
+ }
422
+
423
+ // ── Main View ────────────────────────────────────────────────────────────────
424
+
425
+ export function WorkerDetail() {
426
+ // Focus tracking for pane cycling
427
+ const [focusPane, setFocusPane] = useState(0);
428
+
429
+ // Live data state from CliBridge
430
+ const [configEntries, setConfigEntries] = useState<DemoConfigEntry[]>([]);
431
+ const [configLoading, setConfigLoading] = useState(false);
432
+ const [, setCliLogsLoading] = useState(false);
433
+
434
+ // Cancel in-flight on unmount / worker switch
435
+ const mountedRef = useRef(true);
436
+ const configGenRef = useRef(0);
437
+ const logsGenRef = useRef(0);
438
+
439
+ useEffect(() => {
440
+ mountedRef.current = true;
441
+ return () => {
442
+ mountedRef.current = false;
443
+ };
444
+ }, []);
445
+
446
+ // Store subscriptions (Pattern 2: selective selectors)
447
+ const selectedWorkerId = useServiceStore((s) => s.selectedWorkerId);
448
+ const workers = useServiceStore((s) => s.workers);
449
+ const connectionStatus = useServiceStore((s) => s.connectionStatus);
450
+ const goBack = useUIStore((s) => s.goBack);
451
+
452
+ // Derive the current worker
453
+ const worker = useMemo(
454
+ () => workers.find((w) => w.id === selectedWorkerId) ?? null,
455
+ [workers, selectedWorkerId]
456
+ );
457
+
458
+ // ── CliBridge data fetching ─────────────────────────────────────────────
459
+
460
+ const fetchConfig = useCallback(async () => {
461
+ if (!worker) return;
462
+ const gen = ++configGenRef.current;
463
+ setConfigLoading(true);
464
+ try {
465
+ const result = await cliBridge.configShow();
466
+ if (!mountedRef.current || gen !== configGenRef.current) return;
467
+ if (result.success && result.data) {
468
+ const data = result.data as Record<string, unknown>;
469
+ // Never display secrets cleartext in the TUI config pane
470
+ setConfigEntries(redactConfigEntries(data));
471
+ }
472
+ // Only alert on failure — success spam thrash on every mount/refresh
473
+ if (!result.success) {
474
+ useServiceStore.getState().addAlert({
475
+ id: `cfg-${Date.now()}`,
476
+ type: "config",
477
+ severity: "warning",
478
+ message: "Config load failed",
479
+ timestamp: Date.now(),
480
+ acknowledged: false,
481
+ });
482
+ }
483
+ } catch {
484
+ if (!mountedRef.current || gen !== configGenRef.current) return;
485
+ useServiceStore.getState().addAlert({
486
+ id: `cfg-err-${Date.now()}`,
487
+ type: "config",
488
+ severity: "warning",
489
+ message: "Config fetch error",
490
+ timestamp: Date.now(),
491
+ acknowledged: false,
492
+ });
493
+ } finally {
494
+ if (mountedRef.current && gen === configGenRef.current) {
495
+ setConfigLoading(false);
496
+ }
497
+ }
498
+ }, [worker]);
499
+
500
+ const fetchLogs = useCallback(async () => {
501
+ if (!worker) return;
502
+ const gen = ++logsGenRef.current;
503
+ setCliLogsLoading(true);
504
+ try {
505
+ const result = await cliBridge.workerLogs(worker.name);
506
+ if (!mountedRef.current || gen !== logsGenRef.current) return;
507
+ if (result.success && Array.isArray(result.data)) {
508
+ for (const log of result.data as Log[]) {
509
+ // Scrub free-text before it enters the ring buffer
510
+ useServiceStore.getState().pushLog({
511
+ ...log,
512
+ message: redactSecretsInText(log.message ?? ""),
513
+ });
514
+ }
515
+ }
516
+ // Alert only on failure to avoid thrashing the alerts panel
517
+ if (!result.success) {
518
+ useServiceStore.getState().addAlert({
519
+ id: `logs-${Date.now()}`,
520
+ type: "logs",
521
+ severity: "warning",
522
+ message: "Logs load failed",
523
+ timestamp: Date.now(),
524
+ acknowledged: false,
525
+ });
526
+ }
527
+ } catch {
528
+ if (!mountedRef.current || gen !== logsGenRef.current) return;
529
+ useServiceStore.getState().addAlert({
530
+ id: `logs-err-${Date.now()}`,
531
+ type: "logs",
532
+ severity: "warning",
533
+ message: "Logs fetch error",
534
+ timestamp: Date.now(),
535
+ acknowledged: false,
536
+ });
537
+ } finally {
538
+ if (mountedRef.current && gen === logsGenRef.current) {
539
+ setCliLogsLoading(false);
540
+ }
541
+ }
542
+ }, [worker]);
543
+
544
+ // Fetch live config when worker changes
545
+ useEffect(() => {
546
+ void fetchConfig();
547
+ }, [fetchConfig]);
548
+
549
+ // Fetch logs via CLI as fallback when SSE is not connected
550
+ useEffect(() => {
551
+ if (connectionStatus !== "connected") {
552
+ void fetchLogs();
553
+ }
554
+ }, [connectionStatus, fetchLogs]);
555
+
556
+ const handleRefresh = useCallback(() => {
557
+ void fetchConfig();
558
+ if (connectionStatus !== "connected") {
559
+ void fetchLogs();
560
+ }
561
+ }, [fetchConfig, fetchLogs, connectionStatus]);
562
+
563
+ const activeView = useUIStore((s) => s.activeView);
564
+ const isActive = activeView === "worker-detail";
565
+
566
+ // View-local keyboard handling (only when this view is active)
567
+ useKeyboard((key) => {
568
+ if (!isActive) return;
569
+ switch (key.name) {
570
+ case "tab":
571
+ // Cycle focus forward between panes
572
+ setFocusPane((i) => (i + 1) % PANE_COUNT);
573
+ break;
574
+ case "escape":
575
+ goBack();
576
+ break;
577
+ }
578
+ if (key.ctrl && key.name === "r") {
579
+ handleRefresh();
580
+ }
581
+ });
582
+
583
+ // ── No worker selected or not found ────────────────────────────────────────
584
+ if (!selectedWorkerId || !worker) {
585
+ return (
586
+ <ErrorBoundary viewName="Worker Detail">
587
+ <box flexDirection="column" flexGrow={1} padding={2} gap={1}>
588
+ <BreadcrumbHeader
589
+ worker={{
590
+ id: selectedWorkerId || "unknown",
591
+ name: selectedWorkerId || "Unknown Worker",
592
+ status: "down",
593
+ uptime: 0,
594
+ cpu: 0,
595
+ memory: 0,
596
+ requests: 0,
597
+ durableObjectCount: 0,
598
+ edgeCount: 0,
599
+ version: "",
600
+ lastDeployed: 0,
601
+ }}
602
+ />
603
+ <box
604
+ flexDirection="column"
605
+ flexGrow={1}
606
+ justifyContent="center"
607
+ alignItems="center"
608
+ gap={1}
609
+ >
610
+ <text fg={Colors.error} bold>
611
+ Worker Not Found
612
+ </text>
613
+ <text fg={Colors.muted} dim>
614
+ {selectedWorkerId
615
+ ? `No worker with ID "${selectedWorkerId}" exists.`
616
+ : "No worker selected. Use the Workers Overview to select one."}
617
+ </text>
618
+ <box paddingTop={1}>
619
+ <text fg={Colors.accent} bg={Colors.card} onMouseUp={goBack}>
620
+ {" ← Back to Workers "}
621
+ </text>
622
+ </box>
623
+ </box>
624
+ </box>
625
+ </ErrorBoundary>
626
+ );
627
+ }
628
+
629
+ // ── Full 4-pane layout ─────────────────────────────────────────────────────
630
+ return (
631
+ <ErrorBoundary viewName={`Worker: ${worker.name}`}>
632
+ <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
633
+ {/* Breadcrumb header */}
634
+ <BreadcrumbHeader worker={worker} />
635
+
636
+ {/* 2×2 Grid of panes */}
637
+ <box flexDirection="column" flexGrow={1} gap={1}>
638
+ {/* Row 1: Metrics | Live Logs */}
639
+ <box flexDirection="row" flexGrow={1} gap={1}>
640
+ <MetricsPane worker={worker} />
641
+ <LogsPane workerId={worker.id} focused={focusPane === 1} />
642
+ </box>
643
+
644
+ {/* Row 2: Durable Objects | Config Preview */}
645
+ <box flexDirection="row" flexGrow={1} gap={1}>
646
+ <DurableObjectsPane worker={worker} />
647
+ <ConfigPreviewPane
648
+ worker={worker}
649
+ entries={configEntries}
650
+ loading={configLoading}
651
+ />
652
+ </box>
653
+ </box>
654
+
655
+ {/* Footer: pane navigation hint */}
656
+ <box flexDirection="row" justifyContent="space-between" paddingTop={0}>
657
+ <box flexDirection="row" gap={1}>
658
+ {PANE_NAMES.map((name, i) => (
659
+ <text
660
+ key={name}
661
+ fg={i === focusPane ? Colors.accent : Colors.muted}
662
+ bold={i === focusPane}
663
+ dim={i !== focusPane}
664
+ >
665
+ {i === focusPane ? `▶ ${name}` : name}
666
+ </text>
667
+ ))}
668
+ </box>
669
+ <text fg={Colors.muted} dim>
670
+ Tab: focus · Ctrl+R: refresh · Esc: back
671
+ </text>
672
+ </box>
673
+ </box>
674
+ </ErrorBoundary>
675
+ );
676
+ }