@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,631 @@
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
+ * Workers Overview — 2-column card grid displaying all Hoox Workers.
9
+ *
10
+ * Each card shows:
11
+ * - No.XX header with worker name and StatusDot
12
+ * - Uptime (formatted: 72h, 3d12h, etc.)
13
+ * - CPU (percentage, from 0-100)
14
+ * - Memory (used/128 MB)
15
+ * - Requests per 24h (formatted: 1.2K, 1.2M)
16
+ * - Durable Object count
17
+ * - Edge count
18
+ * - [View Details] [Logs] action buttons
19
+ *
20
+ * Arrow keys navigate the 2D grid (2 columns):
21
+ * left/right → move 1 card; up/down → move 2 cards (skip a row)
22
+ * Enter on a focused card → selectWorker(id) + navigate to worker-detail view.
23
+ *
24
+ * Wrapped in ScrollBox for overflow when workers exceed viewport height.
25
+ * Follows TUI Patterns 1 (View Composition), 2 (Store Subscription), 8 (ScrollBox).
26
+ */
27
+ import { useState, useMemo, useCallback, useEffect, useRef } from "react";
28
+ import { useKeyboard } from "@opentui/react";
29
+ import { Colors } from "@hoox-sh/hoox-shared";
30
+ import { useServiceStore } from "@hoox-sh/hoox-shared";
31
+ import { useUIStore } from "@hoox-sh/hoox-shared";
32
+ import { ErrorBoundary } from "../shared/error-boundary";
33
+ import { StatusDot } from "../shared/status-dot";
34
+ import { Spinner, EmptyState } from "../shared/spinner";
35
+ import { Panel } from "../shared/panel";
36
+ import { ViewHeader } from "../shared/view-header";
37
+ import { cliBridge } from "../../services/cli-bridge";
38
+ import { redactSecretsInText } from "../../services/dev-log";
39
+ import type { WorkerInfo } from "@hoox-sh/hoox-shared";
40
+ import { showConfirm } from "../ui/dialog";
41
+ import type { DialogHandle } from "../ui/dialog";
42
+
43
+ // ── Props ─────────────────────────────────────────────────────────────────────
44
+
45
+ export interface WorkersOverviewProps {
46
+ /** Dialog handle for deploy/restart confirmation (from DialogProvider). */
47
+ dialog?: DialogHandle;
48
+ }
49
+
50
+ // ── Grid Constants ────────────────────────────────────────────────────────────
51
+
52
+ const COLS = 2;
53
+
54
+ // ── Formatting Helpers ────────────────────────────────────────────────────────
55
+
56
+ /** Convert uptime in seconds to a compact human-readable string. */
57
+ function formatUptime(seconds: number): string {
58
+ const days = Math.floor(seconds / 86400);
59
+ const hours = Math.floor((seconds % 86400) / 3600);
60
+ if (days > 0) return `${days}d${hours}h`;
61
+ if (hours > 0) return `${hours}h`;
62
+ const minutes = Math.floor(seconds / 60);
63
+ return `${minutes}m`;
64
+ }
65
+
66
+ /** Format large numbers with K/M suffixes. */
67
+ function formatCount(n: number): string {
68
+ if (n >= 1_000_000) {
69
+ const val = (n / 1_000_000).toFixed(1);
70
+ return `${val.replace(/\.0$/, "")}M`;
71
+ }
72
+ if (n >= 1_000) {
73
+ const val = (n / 1_000).toFixed(1);
74
+ return `${val.replace(/\.0$/, "")}K`;
75
+ }
76
+ return String(n);
77
+ }
78
+
79
+ /** Format CPU percentage (0-100). */
80
+ function formatCpu(pct: number): string {
81
+ return `${pct.toFixed(1)}%`;
82
+ }
83
+
84
+ /** Format memory as "used/128 MB". */
85
+ function formatMemory(mb: number): string {
86
+ return `${Math.round(mb)}/128 MB`;
87
+ }
88
+
89
+ // ── Sub-component: Single Worker Card ─────────────────────────────────────────
90
+
91
+ interface WorkerCardProps {
92
+ worker: WorkerInfo;
93
+ index: number;
94
+ focused: boolean;
95
+ onViewDetails: () => void;
96
+ onDeploy: () => void;
97
+ onRestart: () => void;
98
+ onLogs: () => void;
99
+ isDeploying: boolean;
100
+ }
101
+
102
+ function WorkerCard({
103
+ worker,
104
+ index,
105
+ focused,
106
+ onViewDetails,
107
+ onDeploy,
108
+ onRestart,
109
+ onLogs,
110
+ isDeploying,
111
+ }: WorkerCardProps) {
112
+ // elevated=false: card bg only when focused (matches prior DNA).
113
+ // compact: avoid double padding with inner metric layout.
114
+ return (
115
+ <Panel focused={focused} elevated={false} compact flexGrow={1}>
116
+ {/* Header row: No.XX + name + StatusDot */}
117
+ <box flexDirection="row" gap={1} paddingX={1}>
118
+ <text fg={Colors.accent} bold>
119
+ No.{String(index + 1).padStart(2, "0")}
120
+ </text>
121
+ <text fg={Colors.foreground} bold>
122
+ {worker.name.toUpperCase()}
123
+ </text>
124
+ <StatusDot
125
+ status={worker.status}
126
+ pulse={worker.status === "operational"}
127
+ />
128
+ </box>
129
+
130
+ {/* Metrics — three 2-column rows (uppercase labels match landing page convention) */}
131
+ <box flexDirection="column" paddingLeft={1} gap={0}>
132
+ <box flexDirection="row" gap={2}>
133
+ <text fg={Colors.muted}>UPTIME:</text>
134
+ <text fg={Colors.foreground}>{formatUptime(worker.uptime)}</text>
135
+ </box>
136
+ <box flexDirection="row" gap={2}>
137
+ <text fg={Colors.muted}>CPU AVG:</text>
138
+ <text fg={Colors.foreground}>{formatCpu(worker.cpu)}</text>
139
+ </box>
140
+ <box flexDirection="row" gap={2}>
141
+ <text fg={Colors.muted}>MEMORY:</text>
142
+ <text fg={Colors.foreground}>{formatMemory(worker.memory)}</text>
143
+ </box>
144
+ <box flexDirection="row" gap={2}>
145
+ <text fg={Colors.muted}>REQ/24H:</text>
146
+ <text fg={Colors.foreground}>{formatCount(worker.requests)}</text>
147
+ </box>
148
+ <box flexDirection="row" gap={2}>
149
+ <text fg={Colors.muted}>DOs:</text>
150
+ <text fg={Colors.foreground}>{worker.durableObjectCount}</text>
151
+ </box>
152
+ <box flexDirection="row" gap={2}>
153
+ <text fg={Colors.muted}>EDGES:</text>
154
+ <text fg={Colors.foreground}>{worker.edgeCount}</text>
155
+ </box>
156
+ </box>
157
+
158
+ {/* Action buttons */}
159
+ <box flexDirection="row" gap={1} paddingTop={1} paddingX={1}>
160
+ <text
161
+ fg={focused ? Colors.accent : Colors.muted}
162
+ bg={focused ? Colors.card : undefined}
163
+ onMouseUp={onViewDetails}
164
+ >
165
+ [VIEW DETAILS]
166
+ </text>
167
+ <text
168
+ fg={
169
+ isDeploying ? Colors.muted : focused ? Colors.accent : Colors.muted
170
+ }
171
+ bg={focused ? Colors.card : undefined}
172
+ onMouseUp={isDeploying ? undefined : onDeploy}
173
+ >
174
+ [DEPLOY]
175
+ </text>
176
+ <text
177
+ fg={
178
+ isDeploying ? Colors.muted : focused ? Colors.accent : Colors.muted
179
+ }
180
+ bg={focused ? Colors.card : undefined}
181
+ onMouseUp={isDeploying ? undefined : onRestart}
182
+ >
183
+ [RESTART]
184
+ </text>
185
+ <text
186
+ fg={
187
+ isDeploying ? Colors.muted : focused ? Colors.accent : Colors.muted
188
+ }
189
+ bg={focused ? Colors.card : undefined}
190
+ onMouseUp={isDeploying ? undefined : onLogs}
191
+ >
192
+ [LOGS]
193
+ </text>
194
+ </box>
195
+ </Panel>
196
+ );
197
+ }
198
+
199
+ // ── Main Component ────────────────────────────────────────────────────────────
200
+
201
+ export function WorkersOverview({ dialog }: WorkersOverviewProps = {}) {
202
+ // ── 2D grid focus state ─────────────────────────────────────────────────
203
+ const [focusedIndex, setFocusedIndex] = useState(0);
204
+
205
+ // ── Deploy state ────────────────────────────────────────────────────────
206
+ const [deployingWorker, setDeployingWorker] = useState<string | null>(null);
207
+ const [deployProgress, setDeployProgress] = useState("");
208
+ const mountedRef = useRef(true);
209
+ const deployingRef = useRef(false);
210
+
211
+ useEffect(() => {
212
+ mountedRef.current = true;
213
+ return () => {
214
+ mountedRef.current = false;
215
+ };
216
+ }, []);
217
+
218
+ // ── Store subscriptions (selectors for performance) ─────────────────────
219
+ const workers = useServiceStore((s) => s.workers);
220
+ const connectionStatus = useServiceStore((s) => s.connectionStatus);
221
+ const selectWorker = useServiceStore((s) => s.selectWorker);
222
+ const setView = useUIStore((s) => s.setView);
223
+
224
+ // ── CliBridge fallback: try CLI when API is unavailable ─────────────────
225
+ const [cliFallbackTried, setCliFallbackTried] = useState(false);
226
+ const [cliFallbackError, setCliFallbackError] = useState<string | null>(null);
227
+
228
+ useEffect(() => {
229
+ if (workers.length > 0) {
230
+ setCliFallbackTried(false);
231
+ setCliFallbackError(null);
232
+ return;
233
+ }
234
+ if (cliFallbackTried) return;
235
+ setCliFallbackTried(true);
236
+
237
+ let cancelled = false;
238
+
239
+ (async () => {
240
+ try {
241
+ const result = await cliBridge.monitorStatus();
242
+ if (cancelled) return;
243
+ if (result.success && result.data) {
244
+ const raw = result.data as Record<string, unknown>;
245
+ // CLI may return workers array, health object, or similar
246
+ const rawWorkers = (raw.workers ?? raw.status ?? raw) as
247
+ | unknown[]
248
+ | Record<string, unknown>;
249
+ const parsed = Array.isArray(rawWorkers)
250
+ ? rawWorkers
251
+ : typeof rawWorkers === "object" && rawWorkers !== null
252
+ ? Object.values(rawWorkers)
253
+ : [];
254
+ if (parsed.length > 0) {
255
+ const store = useServiceStore.getState();
256
+ const workers: WorkerInfo[] = (
257
+ parsed as Record<string, unknown>[]
258
+ ).map((w, i) => {
259
+ const cliStatus = String(w.status ?? "healthy");
260
+ const status =
261
+ cliStatus === "healthy"
262
+ ? "operational"
263
+ : cliStatus === "degraded"
264
+ ? "degraded"
265
+ : "down";
266
+ return {
267
+ id: String(w.id ?? w.worker ?? `worker-${i}`),
268
+ name: String(w.worker ?? w.name ?? `worker-${i}`),
269
+ status: status as WorkerInfo["status"],
270
+ uptime: Number(w.uptime ?? 0) || 0,
271
+ cpu: Number(w.cpu ?? 0) || 0,
272
+ memory: Number(w.memory ?? 0) || 0,
273
+ requests: Number(w.requests ?? 0) || 0,
274
+ durableObjectCount: Number(w.durableObjectCount ?? 0) || 0,
275
+ edgeCount: Number(w.edgeCount ?? 0) || 0,
276
+ version: String(w.version ?? ""),
277
+ lastDeployed: Number(w.lastDeployed ?? 0) || 0,
278
+ };
279
+ });
280
+ store.setWorkers(workers);
281
+ store.setMetrics({
282
+ totalWorkers: workers.length,
283
+ onlineWorkers: workers.filter((x) => x.status === "operational")
284
+ .length,
285
+ totalPnl: 0,
286
+ activeStrategies: 0,
287
+ dailyTrades: 0,
288
+ aiCalls: 0,
289
+ uptime: 0,
290
+ lastUpdated: Date.now(),
291
+ });
292
+ store.handleConnectionSuccess();
293
+ store.addAlert({
294
+ id: `cli-fallback-${Date.now()}`,
295
+ type: "info",
296
+ severity: "info",
297
+ message: "Workers loaded via CLI fallback (API unreachable)",
298
+ timestamp: Date.now(),
299
+ acknowledged: false,
300
+ });
301
+ return;
302
+ }
303
+ }
304
+ } catch {
305
+ // CLI also unavailable — show improved empty state below
306
+ }
307
+ if (!cancelled) {
308
+ setCliFallbackError(
309
+ "CLI unavailable. Run `hoox dev start` locally or deploy to Cloudflare."
310
+ );
311
+ }
312
+ })();
313
+
314
+ return () => {
315
+ cancelled = true;
316
+ };
317
+ }, [workers.length, cliFallbackTried]);
318
+
319
+ // ── Derived grid dimensions ─────────────────────────────────────────────
320
+ const maxIndex = Math.max(0, workers.length - 1);
321
+
322
+ /** Group workers into rows of 2 for the 2-column grid. */
323
+ const rows: WorkerInfo[][] = useMemo(() => {
324
+ const result: WorkerInfo[][] = [];
325
+ for (let i = 0; i < workers.length; i += COLS) {
326
+ result.push(workers.slice(i, i + COLS));
327
+ }
328
+ return result;
329
+ }, [workers]);
330
+
331
+ // Clamp focusedIndex when worker list changes
332
+ const safeIndex = Math.min(focusedIndex, maxIndex);
333
+
334
+ // Keep focus in range without thrashing setState every render
335
+ useEffect(() => {
336
+ setFocusedIndex((i) => Math.min(i, maxIndex));
337
+ }, [maxIndex]);
338
+
339
+ // ── View-local keyboard: 2D grid navigation ─────────────────────────────
340
+ useKeyboard((key) => {
341
+ switch (key.name) {
342
+ case "up":
343
+ setFocusedIndex((i) => Math.max(0, i - COLS));
344
+ break;
345
+ case "down":
346
+ setFocusedIndex((i) => Math.min(maxIndex, i + COLS));
347
+ break;
348
+ case "left":
349
+ setFocusedIndex((i) => Math.max(0, i - 1));
350
+ break;
351
+ case "right":
352
+ setFocusedIndex((i) => Math.min(maxIndex, i + 1));
353
+ break;
354
+ case "enter": {
355
+ const worker = workers[safeIndex];
356
+ if (worker) {
357
+ selectWorker(worker.id);
358
+ setView("worker-detail");
359
+ }
360
+ break;
361
+ }
362
+ }
363
+ });
364
+
365
+ // ── Action Handlers ─────────────────────────────────────────────────────
366
+ const onProgress = useCallback((chunk: string) => {
367
+ // Scrub tokens that may appear in wrangler/CLI progress streams
368
+ const safe = redactSecretsInText(chunk);
369
+ setDeployProgress((prev) => (prev + safe).slice(-2000));
370
+ }, []);
371
+
372
+ const handleLogs = useCallback(async (worker: WorkerInfo) => {
373
+ if (deployingRef.current) return;
374
+ try {
375
+ const result = await cliBridge.workerLogs(worker.name);
376
+ if (!mountedRef.current) return;
377
+ const failDetail = redactSecretsInText(
378
+ result.stderr || result.stdout || "unknown error"
379
+ );
380
+ useServiceStore.getState().addAlert({
381
+ id: `logs-${Date.now()}-${worker.name}`,
382
+ type: "logs",
383
+ severity: result.success ? "info" : "warning",
384
+ message: result.success
385
+ ? `${worker.name} logs retrieved (${(result.duration / 1000).toFixed(1)}s)`
386
+ : `${worker.name} logs failed: ${failDetail}`,
387
+ timestamp: Date.now(),
388
+ acknowledged: false,
389
+ });
390
+ } catch (err) {
391
+ if (!mountedRef.current) return;
392
+ useServiceStore.getState().addAlert({
393
+ id: `logs-err-${Date.now()}`,
394
+ type: "logs",
395
+ severity: "warning",
396
+ message: `${worker.name} logs error: ${err instanceof Error ? err.message : String(err)}`,
397
+ timestamp: Date.now(),
398
+ acknowledged: false,
399
+ });
400
+ }
401
+ }, []);
402
+
403
+ const handleDeploy = useCallback(
404
+ async (worker: WorkerInfo) => {
405
+ if (deployingRef.current) return;
406
+ // Fail closed: never deploy without an interactive confirm surface.
407
+ if (!dialog) {
408
+ useServiceStore.getState().addAlert({
409
+ id: `deploy-noconfirm-${Date.now()}`,
410
+ type: "deploy",
411
+ severity: "warning",
412
+ message: `Deploy blocked: confirmation dialog unavailable for ${worker.name}`,
413
+ timestamp: Date.now(),
414
+ acknowledged: false,
415
+ });
416
+ return;
417
+ }
418
+ const confirmed = await showConfirm(dialog, {
419
+ title: `Deploy ${worker.name}`,
420
+ message: `Publish the latest code for ${worker.name} to Cloudflare? This replaces the current deployment.`,
421
+ confirmLabel: "Deploy",
422
+ cancelLabel: "Cancel",
423
+ });
424
+ if (!confirmed || !mountedRef.current) return;
425
+
426
+ deployingRef.current = true;
427
+ setDeployingWorker(worker.name);
428
+ setDeployProgress("");
429
+ try {
430
+ const result = await cliBridge.deployWorker(worker.name, onProgress);
431
+ if (!mountedRef.current) return;
432
+ const store = useServiceStore.getState();
433
+ const failDetail = redactSecretsInText(
434
+ result.stderr || result.stdout || "unknown error"
435
+ );
436
+ store.addAlert({
437
+ id: `deploy-${Date.now()}-${worker.name}`,
438
+ type: "deploy",
439
+ severity: result.success ? "info" : "warning",
440
+ message: result.success
441
+ ? `${worker.name} deployed (${(result.duration / 1000).toFixed(1)}s)`
442
+ : `${worker.name} deploy failed: ${failDetail}`,
443
+ timestamp: Date.now(),
444
+ acknowledged: false,
445
+ });
446
+ if (result.success) await store.fetchWorkers();
447
+ } catch (err) {
448
+ if (!mountedRef.current) return;
449
+ useServiceStore.getState().addAlert({
450
+ id: `deploy-err-${Date.now()}`,
451
+ type: "deploy",
452
+ severity: "warning",
453
+ message: `${worker.name} deploy error: ${err instanceof Error ? err.message : String(err)}`,
454
+ timestamp: Date.now(),
455
+ acknowledged: false,
456
+ });
457
+ } finally {
458
+ deployingRef.current = false;
459
+ if (mountedRef.current) setDeployingWorker(null);
460
+ }
461
+ },
462
+ [onProgress, dialog]
463
+ );
464
+
465
+ const handleRestart = useCallback(
466
+ async (worker: WorkerInfo) => {
467
+ if (deployingRef.current) return;
468
+ if (!dialog) {
469
+ useServiceStore.getState().addAlert({
470
+ id: `restart-noconfirm-${Date.now()}`,
471
+ type: "restart",
472
+ severity: "warning",
473
+ message: `Restart blocked: confirmation dialog unavailable for ${worker.name}`,
474
+ timestamp: Date.now(),
475
+ acknowledged: false,
476
+ });
477
+ return;
478
+ }
479
+ const confirmed = await showConfirm(dialog, {
480
+ title: `Restart ${worker.name}`,
481
+ message: `Restart ${worker.name}? Running tasks will be drained before the worker is repaired/restarted.`,
482
+ confirmLabel: "Restart",
483
+ cancelLabel: "Cancel",
484
+ });
485
+ if (!confirmed || !mountedRef.current) return;
486
+
487
+ deployingRef.current = true;
488
+ setDeployingWorker(worker.name);
489
+ setDeployProgress("");
490
+ try {
491
+ const result = await cliBridge.repairWorker(worker.name, onProgress);
492
+ if (!mountedRef.current) return;
493
+ const store = useServiceStore.getState();
494
+ const failDetail = redactSecretsInText(
495
+ result.stderr || result.stdout || "unknown error"
496
+ );
497
+ store.addAlert({
498
+ id: `restart-${Date.now()}-${worker.name}`,
499
+ type: "restart",
500
+ severity: result.success ? "info" : "warning",
501
+ message: result.success
502
+ ? `${worker.name} restarted (${(result.duration / 1000).toFixed(1)}s)`
503
+ : `${worker.name} restart failed: ${failDetail}`,
504
+ timestamp: Date.now(),
505
+ acknowledged: false,
506
+ });
507
+ if (result.success) await store.fetchWorkers();
508
+ } catch (err) {
509
+ if (!mountedRef.current) return;
510
+ useServiceStore.getState().addAlert({
511
+ id: `restart-err-${Date.now()}`,
512
+ type: "restart",
513
+ severity: "warning",
514
+ message: `${worker.name} restart error: ${err instanceof Error ? err.message : String(err)}`,
515
+ timestamp: Date.now(),
516
+ acknowledged: false,
517
+ });
518
+ } finally {
519
+ deployingRef.current = false;
520
+ if (mountedRef.current) setDeployingWorker(null);
521
+ }
522
+ },
523
+ [onProgress, dialog]
524
+ );
525
+
526
+ // ── Escape hatch: empty state ───────────────────────────────────────────
527
+ if (workers.length === 0) {
528
+ return (
529
+ <ErrorBoundary viewName="Workers Overview">
530
+ <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
531
+ <ViewHeader
532
+ title="Workers"
533
+ showDivider={false}
534
+ meta={<text fg={Colors.muted}>0 total</text>}
535
+ />
536
+ <EmptyState
537
+ message="No workers connected. Check your hoox deployment."
538
+ icon="🔌"
539
+ />
540
+ {connectionStatus === "connected" && (
541
+ <text fg={Colors.warning}>
542
+ API is reachable but returned 0 workers.
543
+ </text>
544
+ )}
545
+ {connectionStatus !== "connected" && cliFallbackError && (
546
+ <text fg={Colors.warning}>
547
+ API unavailable — CLI fallback attempted
548
+ </text>
549
+ )}
550
+ {connectionStatus !== "connected" && !cliFallbackTried && (
551
+ <box alignItems="center">
552
+ <Spinner label="Checking via CLI..." />
553
+ </box>
554
+ )}
555
+ {connectionStatus !== "connected" && cliFallbackError && (
556
+ <box flexDirection="column" gap={0} marginTop={1}>
557
+ <text fg={Colors.muted}>Suggestions:</text>
558
+ <box flexDirection="row" gap={1}>
559
+ <text fg={Colors.muted}>• Start the dev server:</text>
560
+ <text fg={Colors.accent}>hoox dev start</text>
561
+ </box>
562
+ <box flexDirection="row" gap={1}>
563
+ <text fg={Colors.muted}>• Deploy workers to Cloudflare:</text>
564
+ <text fg={Colors.accent}>hoox workers deploy</text>
565
+ </box>
566
+ <box flexDirection="row" gap={1}>
567
+ <text fg={Colors.muted}>• Check worker health:</text>
568
+ <text fg={Colors.accent}>hoox monitor status</text>
569
+ </box>
570
+ </box>
571
+ )}
572
+ </box>
573
+ </ErrorBoundary>
574
+ );
575
+ }
576
+
577
+ // ── Render ──────────────────────────────────────────────────────────────
578
+ return (
579
+ <ErrorBoundary viewName="Workers Overview">
580
+ <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
581
+ {/* Header */}
582
+ <ViewHeader
583
+ title="Workers"
584
+ showDivider={false}
585
+ meta={<text fg={Colors.muted}>{workers.length} total</text>}
586
+ />
587
+
588
+ {/* Deploy progress */}
589
+ {deployingWorker && (
590
+ <box flexDirection="column" gap={0}>
591
+ <text fg={Colors.accent}>DEPLOYING {deployingWorker}...</text>
592
+ {deployProgress && (
593
+ <text fg={Colors.muted} dim>
594
+ {redactSecretsInText(deployProgress.slice(-200))}
595
+ </text>
596
+ )}
597
+ </box>
598
+ )}
599
+
600
+ {/* Scrollable 2-column card grid */}
601
+ <scrollbox width="100%" flexGrow={1} border={false}>
602
+ <box flexDirection="column" gap={1}>
603
+ {rows.map((row, rowIdx) => (
604
+ <box key={rowIdx} flexDirection="row" gap={1}>
605
+ {row.map((worker, colIdx) => {
606
+ const globalIdx = rowIdx * COLS + colIdx;
607
+ return (
608
+ <WorkerCard
609
+ key={worker.id}
610
+ worker={worker}
611
+ index={globalIdx}
612
+ focused={globalIdx === safeIndex}
613
+ onViewDetails={() => {
614
+ selectWorker(worker.id);
615
+ setView("worker-detail");
616
+ }}
617
+ onDeploy={() => handleDeploy(worker)}
618
+ onRestart={() => handleRestart(worker)}
619
+ onLogs={() => handleLogs(worker)}
620
+ isDeploying={deployingWorker === worker.name}
621
+ />
622
+ );
623
+ })}
624
+ </box>
625
+ ))}
626
+ </box>
627
+ </scrollbox>
628
+ </box>
629
+ </ErrorBoundary>
630
+ );
631
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Hook unit tests — renderer ref, global keyboard registration, polling helpers.
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
10
+ import { setRendererRef, getRendererRef, registerGlobalHandler } from "./index";
11
+ import type { KeyEvent } from "./use-keyboard";
12
+
13
+ describe("renderer-ref", () => {
14
+ afterEach(() => {
15
+ setRendererRef(null);
16
+ });
17
+
18
+ it("stores and returns the renderer singleton", () => {
19
+ const fake = {
20
+ id: "renderer",
21
+ } as unknown as import("@opentui/core").CliRenderer;
22
+ setRendererRef(fake);
23
+ expect(getRendererRef()).toBe(fake);
24
+ });
25
+
26
+ it("clears when set to null", () => {
27
+ setRendererRef({} as import("@opentui/core").CliRenderer);
28
+ setRendererRef(null);
29
+ expect(getRendererRef()).toBeNull();
30
+ });
31
+ });
32
+
33
+ describe("registerGlobalHandler", () => {
34
+ beforeEach(() => {
35
+ // Ensure no renderer → attach is a no-op; handlers still register
36
+ setRendererRef(null);
37
+ });
38
+
39
+ it("registers and unregisters without throwing", () => {
40
+ const calls: string[] = [];
41
+ const unsub = registerGlobalHandler((key: KeyEvent) => {
42
+ calls.push(key.name);
43
+ }, 10);
44
+ expect(typeof unsub).toBe("function");
45
+ unsub();
46
+ expect(calls).toEqual([]);
47
+ });
48
+
49
+ it("multiple handlers can register and all unsubscribe cleanly", () => {
50
+ const a = registerGlobalHandler(() => {}, 0);
51
+ const b = registerGlobalHandler(() => {}, 50);
52
+ const c = registerGlobalHandler(() => {}, 100);
53
+ a();
54
+ b();
55
+ c();
56
+ });
57
+ });
58
+
59
+ describe("usePolling backoff math", () => {
60
+ it("caps exponential backoff at 16s", () => {
61
+ const base = 1000;
62
+ const MAX = 16_000;
63
+ for (let retry = 0; retry < 10; retry++) {
64
+ const backoff = Math.min(base * Math.pow(2, retry), MAX);
65
+ expect(backoff).toBeLessThanOrEqual(MAX);
66
+ }
67
+ expect(Math.min(base * Math.pow(2, 10), MAX)).toBe(MAX);
68
+ });
69
+ });
70
+
71
+ // Silence unused import if mock is tree-shaken away in some runners
72
+ void mock;