@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,647 @@
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
+ * Dashboard View — System health overview with service grid, alerts, and quick stats.
9
+ *
10
+ * Layout (vertical):
11
+ * 1. Header: "DASHBOARD" title + animated connection status dot
12
+ * 2. KillSwitchStatusBadge: live trade kill-switch indicator
13
+ * 3. ServiceHealthGrid: worker cards with name + status indicator
14
+ * 4. AlertsPanel: scrollable box with recent alerts, newest first
15
+ * 5. QuickStatsRow: 4 metric cards with large numbers
16
+ *
17
+ * Follows Pattern 1 (View Composition) and Pattern 2 (Store Subscription).
18
+ * Colors from design tokens via @hoox-sh/hoox-shared. No CSS, no DOM.
19
+ */
20
+ import { useEffect, useMemo, useState, useCallback, useRef } from "react";
21
+ import { useKeyboard } from "@opentui/react";
22
+ import { Colors, useServiceStore } from "@hoox-sh/hoox-shared";
23
+ import { ErrorBoundary } from "../shared/error-boundary";
24
+ import { StatusDot } from "../shared/status-dot";
25
+ import { ViewHeader } from "../shared/view-header";
26
+ import { cliBridge } from "../../services/cli-bridge";
27
+ import { showConfirm } from "../ui/dialog";
28
+ import type { DialogHandle } from "../ui/dialog";
29
+ import { Spinner } from "../shared/spinner";
30
+ import {
31
+ AutoRepairPanel,
32
+ RepairFixItem,
33
+ RepairState,
34
+ } from "./dashboard/auto-repair-panel";
35
+ import { ModelHealthSection } from "./dashboard/model-health-section";
36
+ import { PyneHealthSection } from "./dashboard/pyne-health-section";
37
+ import { AlertsPanel } from "./dashboard/alerts-panel";
38
+ import { useUIStore } from "@hoox-sh/hoox-shared";
39
+
40
+ // ─── Props ────────────────────────────────────────────────────────────────────
41
+
42
+ export interface DashboardViewProps {
43
+ /** Dialog handle for confirm prompts (from useDialog context) */
44
+ dialog?: DialogHandle;
45
+ }
46
+
47
+ // ─── Number Formatter (Bebas-style large numbers) ────────────────────────────
48
+
49
+ /**
50
+ * Format a number for the stats cards.
51
+ * - ≥1M: "1.2M"
52
+ * - ≥1K: "12.3K"
53
+ * - else: comma-separated integer
54
+ * - P&L gets +/- prefix
55
+ */
56
+ function formatStatNumber(value: number, isPnl = false): string {
57
+ if (isPnl) {
58
+ const sign = value >= 0 ? "+" : "-";
59
+ const abs = Math.abs(value);
60
+ if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`;
61
+ if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(1)}K`;
62
+ return `${sign}${abs.toFixed(2)}`;
63
+ }
64
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
65
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
66
+ return value.toLocaleString("en-US");
67
+ }
68
+
69
+ // ─── Sub-Components ──────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * KillSwitchStatusBadge — Compact trade kill-switch indicator for the dashboard
73
+ * header. Auto-refreshes on mount and shows the engaged/released state with a
74
+ * color-coded status dot. Errors degrade gracefully (badge just shows UNKNOWN).
75
+ */
76
+ function KillSwitchStatusBadge() {
77
+ const [state, setState] = useState<"engaged" | "released" | "unknown">(
78
+ "unknown"
79
+ );
80
+
81
+ useEffect(() => {
82
+ let cancelled = false;
83
+ const refresh = async () => {
84
+ try {
85
+ const result = await cliBridge.monitorKillSwitch("show");
86
+ if (cancelled) return;
87
+ if (result.success && result.data) {
88
+ setState(result.data.engaged ? "engaged" : "released");
89
+ } else {
90
+ setState("unknown");
91
+ }
92
+ } catch {
93
+ if (!cancelled) setState("unknown");
94
+ }
95
+ };
96
+ void refresh();
97
+ return () => {
98
+ cancelled = true;
99
+ };
100
+ }, []);
101
+
102
+ const label =
103
+ state === "engaged"
104
+ ? "KILL SWITCH ENGAGED"
105
+ : state === "released"
106
+ ? "KILL SWITCH RELEASED"
107
+ : "KILL SWITCH ?";
108
+ const color =
109
+ state === "engaged"
110
+ ? Colors.error
111
+ : state === "released"
112
+ ? Colors.success
113
+ : Colors.muted;
114
+ const dotStatus: "operational" | "down" =
115
+ state === "engaged"
116
+ ? "down"
117
+ : state === "released"
118
+ ? "operational"
119
+ : "down";
120
+
121
+ return (
122
+ <box flexDirection="row" gap={1}>
123
+ <StatusDot status={dotStatus} pulse={state === "released"} />
124
+ <text fg={color} bold={state === "engaged"}>
125
+ {label}
126
+ </text>
127
+ </box>
128
+ );
129
+ }
130
+
131
+ /**
132
+ * DashboardHeader — view title with animated connection status
133
+ * and auto-repair button, via shared ViewHeader chrome.
134
+ */
135
+ function DashboardHeader({
136
+ onRefresh,
137
+ onRunAutoRepair,
138
+ autoRepairRunning,
139
+ }: {
140
+ onRefresh?: () => void;
141
+ onRunAutoRepair?: () => void;
142
+ autoRepairRunning?: boolean;
143
+ }) {
144
+ const connectionStatus = useServiceStore((s) => s.connectionStatus);
145
+
146
+ const statusLabel: Record<string, string> = {
147
+ connected: "CONNECTED",
148
+ polling: "POLLING",
149
+ reconnecting: "RECONNECTING",
150
+ offline: "OFFLINE",
151
+ };
152
+
153
+ // Divider lives below the kill-switch badge in the main layout —
154
+ // avoid a second rule under the title alone.
155
+ // Meta: sibling text nodes in a row box (no <text> nesting; no fragments).
156
+ return (
157
+ <ViewHeader
158
+ title="DASHBOARD"
159
+ showDivider={false}
160
+ meta={
161
+ <box flexDirection="row" gap={1}>
162
+ <StatusDot
163
+ status={
164
+ connectionStatus === "connected"
165
+ ? "operational"
166
+ : connectionStatus === "polling"
167
+ ? "degraded"
168
+ : "down"
169
+ }
170
+ pulse={connectionStatus === "connected"}
171
+ />
172
+ <text dim fg={Colors.muted}>
173
+ {statusLabel[connectionStatus] ?? connectionStatus.toUpperCase()}
174
+ </text>
175
+ <text fg={Colors.muted} onMouseUp={onRefresh}>
176
+ [REFRESH]
177
+ </text>
178
+ <text
179
+ fg={Colors.accent}
180
+ bold
181
+ onMouseUp={() => useUIStore.getState().setView("edge-topology")}
182
+ >
183
+ [TOPOLOGY]
184
+ </text>
185
+ <text
186
+ fg={autoRepairRunning ? Colors.warning : Colors.accent}
187
+ bold={!autoRepairRunning}
188
+ dim={autoRepairRunning}
189
+ onMouseUp={autoRepairRunning ? undefined : onRunAutoRepair}
190
+ >
191
+ {autoRepairRunning ? "[REPAIRING...]" : "[AUTO-REPAIR]"}
192
+ </text>
193
+ </box>
194
+ }
195
+ />
196
+ );
197
+ }
198
+
199
+ /**
200
+ * ServiceHealthGrid — displays all workers as a grid of status cards.
201
+ * Each card shows: [█ ▌ ░] WORKER_NAME
202
+ * Limited to first 10 workers (fits the dashboard layout).
203
+ */
204
+ function ServiceHealthGrid() {
205
+ const workers = useServiceStore((s) => s.workers);
206
+ const connectionStatus = useServiceStore((s) => s.connectionStatus);
207
+
208
+ // Show first 10 workers (dashboard is an overview)
209
+ const visibleWorkers = useMemo(() => workers.slice(0, 10), [workers]);
210
+ const emptySlots = useMemo(
211
+ () =>
212
+ visibleWorkers.length < 10
213
+ ? Array.from({ length: 10 - visibleWorkers.length }, (_, i) => i)
214
+ : [],
215
+ [visibleWorkers.length]
216
+ );
217
+
218
+ if (workers.length === 0) {
219
+ const offline =
220
+ connectionStatus === "offline" || connectionStatus === "reconnecting";
221
+ return (
222
+ <box flexDirection="column" gap={0}>
223
+ <text fg={Colors.foreground} bold dim>
224
+ SERVICE HEALTH
225
+ </text>
226
+ <box flexDirection="column" paddingY={1} alignItems="center">
227
+ {offline ? (
228
+ <text fg={Colors.warning} dim>
229
+ CLI/API offline — no worker health data
230
+ </text>
231
+ ) : (
232
+ <Spinner label="Waiting for worker data..." />
233
+ )}
234
+ </box>
235
+ </box>
236
+ );
237
+ }
238
+
239
+ return (
240
+ <box flexDirection="column" gap={0}>
241
+ {/* Section label */}
242
+ <text fg={Colors.foreground} bold dim>
243
+ SERVICE HEALTH
244
+ </text>
245
+
246
+ {/* 2 rows × 5 columns grid */}
247
+ <box
248
+ flexDirection="row"
249
+ flexWrap="wrap"
250
+ gap={1}
251
+ paddingTop={1}
252
+ paddingBottom={1}
253
+ >
254
+ {visibleWorkers.map((worker) => (
255
+ <box
256
+ key={worker.id}
257
+ flexDirection="row"
258
+ width={28}
259
+ gap={1}
260
+ paddingLeft={1}
261
+ paddingRight={1}
262
+ >
263
+ <StatusDot status={worker.status} />
264
+ <text fg={Colors.foreground}>{worker.name}</text>
265
+ </box>
266
+ ))}
267
+ </box>
268
+
269
+ {/* Fill empty slots in grid to maintain 10-card layout */}
270
+ {emptySlots.length > 0 && (
271
+ <box flexDirection="row" flexWrap="wrap" gap={1}>
272
+ {emptySlots.map((i) => (
273
+ <box
274
+ key={`empty-${i}`}
275
+ flexDirection="row"
276
+ width={28}
277
+ gap={1}
278
+ paddingLeft={1}
279
+ paddingRight={1}
280
+ >
281
+ <text fg={Colors.dim} dim>
282
+ —
283
+ </text>
284
+ <text fg={Colors.dim} dim>
285
+ —
286
+ </text>
287
+ </box>
288
+ ))}
289
+ </box>
290
+ )}
291
+ </box>
292
+ );
293
+ }
294
+
295
+ /**
296
+ * MetricCard — a single stat card with label and large formatted number.
297
+ */
298
+
299
+ function MetricCard({
300
+ label,
301
+ value,
302
+ color,
303
+ isPnl = false,
304
+ }: {
305
+ label: string;
306
+ value: number;
307
+ color: string;
308
+ isPnl?: boolean;
309
+ }) {
310
+ return (
311
+ <box
312
+ flexDirection="column"
313
+ width={22}
314
+ border={true}
315
+ borderStyle="single"
316
+ borderColor={Colors.border}
317
+ backgroundColor={Colors.card}
318
+ paddingX={1}
319
+ paddingY={0}
320
+ >
321
+ {/* Label (dim, small) */}
322
+ <box>
323
+ <text fg={Colors.muted} dim>
324
+ {label}
325
+ </text>
326
+ </box>
327
+
328
+ {/* Value (large, colored) */}
329
+ <box>
330
+ <text fg={color} bold>
331
+ {formatStatNumber(value, isPnl)}
332
+ </text>
333
+ </box>
334
+ </box>
335
+ );
336
+ }
337
+
338
+ /**
339
+ * QuickStatsRow — row of 4 metric cards from SystemMetrics.
340
+ * P&L, Active Strategies, Daily Trades, AI Calls.
341
+ */
342
+ function QuickStatsRow() {
343
+ const metrics = useServiceStore((s) => s.metrics);
344
+
345
+ // Default values when metrics is null/unavailable
346
+ const totalPnl = metrics?.totalPnl ?? 0;
347
+ const activeStrategies = metrics?.activeStrategies ?? 0;
348
+ const dailyTrades = metrics?.dailyTrades ?? 0;
349
+ const aiCalls = metrics?.aiCalls ?? 0;
350
+
351
+ return (
352
+ <box flexDirection="column" gap={0}>
353
+ <text fg={Colors.foreground} bold dim>
354
+ QUICK STATS
355
+ </text>
356
+
357
+ <box flexDirection="row" gap={1} paddingTop={1}>
358
+ <MetricCard
359
+ label="P&L"
360
+ value={totalPnl}
361
+ color={totalPnl >= 0 ? Colors.success : Colors.error}
362
+ isPnl
363
+ />
364
+ <MetricCard
365
+ label="ACTIVE STRATEGIES"
366
+ value={activeStrategies}
367
+ color={Colors.accent}
368
+ />
369
+ <MetricCard
370
+ label="DAILY TRADES"
371
+ value={dailyTrades}
372
+ color={Colors.info}
373
+ />
374
+ <MetricCard label="AI CALLS" value={aiCalls} color={Colors.accent} />
375
+ </box>
376
+
377
+ {/* Empty state when metrics unavailable */}
378
+ {metrics === null && (
379
+ <box paddingTop={1} alignItems="center">
380
+ <Spinner label="Waiting for metrics data..." />
381
+ </box>
382
+ )}
383
+ </box>
384
+ );
385
+ }
386
+
387
+ // ─── Main View ───────────────────────────────────────────────────────────────
388
+
389
+ /**
390
+ * DashboardView — system health overview.
391
+ *
392
+ * Composes: Header → ServiceHealthGrid → AutoRepairPanel → AlertsPanel → QuickStatsRow
393
+ * Wrapped in an ErrorBoundary for crash recovery.
394
+ *
395
+ * View subscribes to service-store (workers, alerts, metrics, connectionStatus)
396
+ * and re-renders on data changes via Zustand selectors.
397
+ */
398
+ export function DashboardView({ dialog }: DashboardViewProps = {}) {
399
+ const [repairState, setRepairState] = useState<RepairState>({ kind: "idle" });
400
+
401
+ // Mount lifetime: cancel async work so unmount never setStates after teardown
402
+ const mountedRef = useRef(true);
403
+ // Race-safe refresh generation (stale monitorStatus/fetchWorkers ignored)
404
+ const refreshGenRef = useRef(0);
405
+ // Guard double-submit without re-creating handleRunAutoRepair every state flip
406
+ const repairRunningRef = useRef(false);
407
+
408
+ useEffect(() => {
409
+ mountedRef.current = true;
410
+ return () => {
411
+ mountedRef.current = false;
412
+ };
413
+ }, []);
414
+
415
+ const handleRefresh = useCallback(async () => {
416
+ const gen = ++refreshGenRef.current;
417
+ try {
418
+ const result = await cliBridge.monitorStatus();
419
+ if (!mountedRef.current || gen !== refreshGenRef.current) return;
420
+ if (result.success) {
421
+ // Clear any previous CLI error and refresh the data view.
422
+ // Note: failed CLI calls propagate to the status bar automatically
423
+ // via the global error sink registered in app.tsx.
424
+ useServiceStore.getState().setLastErrorDetails(null);
425
+ await useServiceStore.getState().fetchWorkers();
426
+ }
427
+ } catch {
428
+ // Global error sink handles CLI failures; never throw into the view tree.
429
+ }
430
+ }, []);
431
+
432
+ const handleRunAutoRepair = useCallback(async () => {
433
+ // Fail closed: never mutate config without an interactive confirm surface.
434
+ if (!dialog) {
435
+ useServiceStore.getState().addAlert({
436
+ id: `repair-noconfirm-${Date.now()}`,
437
+ type: "config",
438
+ severity: "warning",
439
+ message: "Auto-repair blocked: confirmation dialog unavailable",
440
+ timestamp: Date.now(),
441
+ acknowledged: false,
442
+ });
443
+ return;
444
+ }
445
+ // Prevent double-submit while a repair is already running
446
+ if (repairRunningRef.current) return;
447
+
448
+ const confirmed = await showConfirm(dialog, {
449
+ title: "Run Auto-Repair?",
450
+ message:
451
+ "Auto-repair will apply non-destructive fixes to your configuration. " +
452
+ "This creates placeholder .dev.vars files, adds compatibility flags, " +
453
+ "and ensures worker configs are valid.",
454
+ confirmLabel: "Run Repair",
455
+ cancelLabel: "Cancel",
456
+ });
457
+ if (!confirmed || !mountedRef.current) return;
458
+
459
+ repairRunningRef.current = true;
460
+ setRepairState({ kind: "running" });
461
+ try {
462
+ const result = await cliBridge.checkFix();
463
+ if (!mountedRef.current) return;
464
+ if (!result.success) {
465
+ setRepairState({
466
+ kind: "error",
467
+ message: result.stderr || result.stdout || "unknown error from CLI",
468
+ });
469
+ useServiceStore.getState().addAlert({
470
+ id: `repair-${Date.now()}`,
471
+ type: "config",
472
+ severity: "warning",
473
+ message: `Auto-repair failed: ${result.stderr || result.stdout || "unknown error"}`,
474
+ timestamp: Date.now(),
475
+ acknowledged: false,
476
+ });
477
+ return;
478
+ }
479
+
480
+ // Parse the FixReport from the CLI response
481
+ const data = result.data as {
482
+ actions?: Array<{
483
+ description: string;
484
+ type: string;
485
+ target: string;
486
+ applied: boolean;
487
+ error?: string;
488
+ }>;
489
+ summary?: {
490
+ total: number;
491
+ applied: number;
492
+ skipped: number;
493
+ failed: number;
494
+ };
495
+ } | null;
496
+
497
+ if (!data || !Array.isArray(data.actions)) {
498
+ setRepairState({
499
+ kind: "error",
500
+ message: "CLI did not return a valid FixReport (missing actions).",
501
+ });
502
+ return;
503
+ }
504
+
505
+ const items: RepairFixItem[] = data.actions.map((action) => ({
506
+ description: action.description ?? "Unknown fix",
507
+ type: (action.type as RepairFixItem["type"]) ?? "config",
508
+ target: action.target ?? "",
509
+ applied: Boolean(action.applied),
510
+ error: action.error,
511
+ timestamp: Date.now(),
512
+ }));
513
+
514
+ setRepairState({ kind: "results", items, durationMs: result.duration });
515
+
516
+ // Add summary alert
517
+ const applied = items.filter((i) => i.applied).length;
518
+ const failed = items.filter((i) => i.error).length;
519
+ useServiceStore.getState().addAlert({
520
+ id: `repair-${Date.now()}`,
521
+ type: "config",
522
+ severity: failed > 0 ? "warning" : "info",
523
+ message:
524
+ failed > 0
525
+ ? `Auto-repair completed: ${applied} applied, ${failed} failed`
526
+ : `Auto-repair completed: ${applied} fix(es) applied`,
527
+ timestamp: Date.now(),
528
+ acknowledged: false,
529
+ });
530
+ } catch (err) {
531
+ if (!mountedRef.current) return;
532
+ const message = err instanceof Error ? err.message : String(err);
533
+ setRepairState({ kind: "error", message });
534
+ useServiceStore.getState().addAlert({
535
+ id: `repair-err-${Date.now()}`,
536
+ type: "config",
537
+ severity: "error",
538
+ message: `Auto-repair error: ${message}`,
539
+ timestamp: Date.now(),
540
+ acknowledged: false,
541
+ });
542
+ } finally {
543
+ repairRunningRef.current = false;
544
+ }
545
+ }, [dialog]);
546
+
547
+ const dismissRepairPanel = useCallback(() => {
548
+ setRepairState({ kind: "idle" });
549
+ }, []);
550
+
551
+ /** Re-run the auto-repair. */
552
+ const rerunRepair = useCallback(() => {
553
+ void handleRunAutoRepair();
554
+ }, [handleRunAutoRepair]);
555
+
556
+ // Dismiss repair panel on ESC key when visible
557
+ useKeyboard((key) => {
558
+ if (repairState.kind !== "idle" && key.name === "escape") {
559
+ dismissRepairPanel();
560
+ }
561
+ });
562
+
563
+ useEffect(() => {
564
+ let cancelled = false;
565
+ const runHealthCheck = async () => {
566
+ try {
567
+ const health = await cliBridge.checkHealth();
568
+ if (cancelled) return;
569
+ if (health.success) {
570
+ useServiceStore.getState().setLastErrorDetails(null);
571
+ await useServiceStore.getState().fetchWorkers();
572
+ }
573
+ // Failure path: handled by the global error sink in app.tsx.
574
+ } catch {
575
+ // Fail gracefully when CLI is offline
576
+ }
577
+ };
578
+ void runHealthCheck();
579
+ return () => {
580
+ cancelled = true;
581
+ };
582
+ }, []);
583
+
584
+ return (
585
+ <ErrorBoundary viewName="Dashboard">
586
+ <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
587
+ {/* 1. Header: title + connection status */}
588
+ <box>
589
+ <DashboardHeader
590
+ onRefresh={handleRefresh}
591
+ onRunAutoRepair={handleRunAutoRepair}
592
+ autoRepairRunning={repairState.kind === "running"}
593
+ />
594
+ </box>
595
+
596
+ {/* 2. Kill-switch status badge — trading safety at a glance */}
597
+ <box>
598
+ <KillSwitchStatusBadge />
599
+ </box>
600
+
601
+ {/* Divider */}
602
+ <box>
603
+ <text fg={Colors.border} dim>
604
+ {""}
605
+ {"─".repeat(80)}
606
+ </text>
607
+ </box>
608
+
609
+ {/* 3. Service health grid */}
610
+ <box>
611
+ <ServiceHealthGrid />
612
+ </box>
613
+
614
+ {/* Auto-repair results panel — persists until dismissed or ESC */}
615
+ {repairState.kind !== "idle" && (
616
+ <box>
617
+ <AutoRepairPanel
618
+ state={repairState}
619
+ onDismiss={dismissRepairPanel}
620
+ onRerun={rerunRepair}
621
+ />
622
+ </box>
623
+ )}
624
+
625
+ {/* 4. Alerts panel */}
626
+ <box>
627
+ <AlertsPanel />
628
+ </box>
629
+
630
+ {/* 5. Quick stats row */}
631
+ <box>
632
+ <QuickStatsRow />
633
+ </box>
634
+
635
+ {/* 6. AI model health section */}
636
+ <box>
637
+ <ModelHealthSection />
638
+ </box>
639
+
640
+ {/* 7. PYNE edge evaluate health */}
641
+ <box>
642
+ <PyneHealthSection />
643
+ </box>
644
+ </box>
645
+ </ErrorBoundary>
646
+ );
647
+ }