@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.
- package/README.md +247 -0
- package/bin/hoox-tui.js +35 -0
- package/dist/main.js +17594 -0
- package/package.json +72 -0
- package/src/app.tsx +696 -0
- package/src/cli-bridge-test-double.ts +212 -0
- package/src/components/layout/layout.test.tsx +205 -0
- package/src/components/layout/sidebar.tsx +69 -0
- package/src/components/layout/statusbar.test.tsx +423 -0
- package/src/components/layout/statusbar.tsx +348 -0
- package/src/components/shared/animated-border.tsx +58 -0
- package/src/components/shared/chrome.test.tsx +29 -0
- package/src/components/shared/command-palette.tsx +361 -0
- package/src/components/shared/cool-brackets.tsx +75 -0
- package/src/components/shared/crash-screen.tsx +167 -0
- package/src/components/shared/error-boundary.tsx +99 -0
- package/src/components/shared/keybinding-hint.tsx +37 -0
- package/src/components/shared/panel.tsx +61 -0
- package/src/components/shared/quit-modal.tsx +65 -0
- package/src/components/shared/shared.test.tsx +538 -0
- package/src/components/shared/spinner.tsx +85 -0
- package/src/components/shared/status-dot.tsx +54 -0
- package/src/components/shared/view-header.tsx +53 -0
- package/src/components/ui/connection-toasts.test.ts +50 -0
- package/src/components/ui/connection-toasts.ts +215 -0
- package/src/components/ui/dialog.tsx +281 -0
- package/src/components/ui/select.tsx +197 -0
- package/src/components/ui/toast.tsx +185 -0
- package/src/components/views/ai-chat.test.tsx +81 -0
- package/src/components/views/ai-chat.tsx +576 -0
- package/src/components/views/config-editor/action-bar.tsx +102 -0
- package/src/components/views/config-editor/code-editor.tsx +597 -0
- package/src/components/views/config-editor/file-tree.tsx +113 -0
- package/src/components/views/config-editor/types.ts +37 -0
- package/src/components/views/config-editor.test.tsx +465 -0
- package/src/components/views/config-editor.tsx +709 -0
- package/src/components/views/dashboard/alerts-panel.tsx +154 -0
- package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
- package/src/components/views/dashboard/model-health-section.tsx +276 -0
- package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
- package/src/components/views/dashboard.test.tsx +347 -0
- package/src/components/views/dashboard.tsx +647 -0
- package/src/components/views/db-query.test.tsx +115 -0
- package/src/components/views/db-query.tsx +849 -0
- package/src/components/views/edge-topology.test.tsx +198 -0
- package/src/components/views/edge-topology.tsx +572 -0
- package/src/components/views/kv-viewer.test.tsx +241 -0
- package/src/components/views/kv-viewer.tsx +717 -0
- package/src/components/views/logs-viewer.test.tsx +273 -0
- package/src/components/views/logs-viewer.tsx +616 -0
- package/src/components/views/queue-depth.test.tsx +221 -0
- package/src/components/views/queue-depth.tsx +376 -0
- package/src/components/views/secrets-viewer.test.tsx +65 -0
- package/src/components/views/secrets-viewer.tsx +548 -0
- package/src/components/views/service-manager.test.tsx +358 -0
- package/src/components/views/service-manager.tsx +946 -0
- package/src/components/views/settings.test.tsx +648 -0
- package/src/components/views/settings.tsx +1497 -0
- package/src/components/views/setup-wizard.test.tsx +297 -0
- package/src/components/views/setup-wizard.tsx +1321 -0
- package/src/components/views/trade-monitor.test.tsx +592 -0
- package/src/components/views/trade-monitor.tsx +745 -0
- package/src/components/views/worker-detail.test.tsx +289 -0
- package/src/components/views/worker-detail.tsx +676 -0
- package/src/components/views/worker-settings.test.tsx +43 -0
- package/src/components/views/worker-settings.tsx +563 -0
- package/src/components/views/workers-overview.test.tsx +260 -0
- package/src/components/views/workers-overview.tsx +631 -0
- package/src/hooks/hooks.test.ts +72 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/renderer-ref.ts +22 -0
- package/src/hooks/use-keyboard.ts +130 -0
- package/src/hooks/use-polling.ts +73 -0
- package/src/hooks/use-service-data.ts +18 -0
- package/src/main.tsx +117 -0
- package/src/network-test-double.ts +133 -0
- package/src/opentui-augment.d.ts +45 -0
- package/src/opentui-jsx.d.ts +70 -0
- package/src/services/cli-bridge/index.ts +1662 -0
- package/src/services/cli-bridge/standalone.ts +315 -0
- package/src/services/cli-bridge/types.ts +307 -0
- package/src/services/cli-bridge.test.ts +406 -0
- package/src/services/clipboard.test.ts +57 -0
- package/src/services/clipboard.ts +180 -0
- package/src/services/dashboard-settings-loader.ts +31 -0
- package/src/services/dev-log.test.ts +168 -0
- package/src/services/dev-log.ts +194 -0
- package/src/services/hoox-path-service.test.ts +183 -0
- package/src/services/hoox-path-service.ts +185 -0
- package/src/services/tui-connection.test.ts +212 -0
- package/src/services/tui-connection.ts +236 -0
- package/src/services/tui-storage.test.ts +92 -0
- package/src/services/tui-storage.ts +86 -0
- package/src/stores/config-store.test.ts +360 -0
- package/src/stores/service-store.test.ts +1026 -0
- package/src/stores/session-restore.test.ts +134 -0
- package/src/stores/ui-store.test.ts +276 -0
- package/src/test-setup.ts +265 -0
- package/src/test-utils.ts +179 -0
- package/src/types.ts +153 -0
- package/src/utils/colors.test.ts +326 -0
- package/src/utils/formatters.test.ts +236 -0
- package/src/view-registry.test.ts +132 -0
- 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
|
+
}
|