@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,946 @@
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
+ * Service Manager View — Worker deploy/restart controls, edge location map,
9
+ * and bulk actions for the Hoox TUI dashboard.
10
+ *
11
+ * Layout:
12
+ * ┌─────────────────────────────────────────────┐
13
+ * │ SERVICE MANAGER Edge: 275+ │
14
+ * │─────────────────────────────────────────────│
15
+ * │ Workers (scrollable) │ Edge Map (grid) │
16
+ * │ █ worker-alpha [Deploy] │ ● SFO ● LHR │
17
+ * │ ▌ worker-beta [Restart]│ ● SIN ● NRT │
18
+ * │ ░ worker-gamma [Deploy] │ ... │
19
+ * │─────────────────────────────────────────────│
20
+ * │ [Deploy All] [Restart All] │
21
+ * │─────────────────────────────────────────────│
22
+ * │ KILL SWITCH ● RELEASED [Engage] [Release]│
23
+ * └─────────────────────────────────────────────┘
24
+ *
25
+ * Uses Colors tokens, useServiceStore, StatusDot, and showConfirm dialog.
26
+ */
27
+ import { useState, useCallback, useEffect, useMemo, useRef } from "react";
28
+ import { useServiceStore } from "@hoox-sh/hoox-shared/stores/service-store";
29
+ import { Colors, ConnectionStatusColor } from "@hoox-sh/hoox-shared";
30
+ import { StatusDot } from "../shared/status-dot";
31
+ import { ErrorBoundary } from "../shared/error-boundary";
32
+ import { EmptyState } from "../shared/spinner";
33
+ import { ViewHeader } from "../shared/view-header";
34
+ import { Panel } from "../shared/panel";
35
+ import { showConfirm } from "../ui/dialog";
36
+ import type { DialogHandle } from "../ui/dialog";
37
+ import type { WorkerInfo } from "@hoox-sh/hoox-shared/types";
38
+ import { cliBridge } from "../../services/cli-bridge";
39
+ import type { KillSwitchStatus } from "../../services/cli-bridge";
40
+ import type { CliResult } from "../../types";
41
+ import { redactSecretsInText } from "../../services/dev-log";
42
+
43
+ // ─── Types ──────────────────────────────────────────────────────────────────
44
+
45
+ export interface ServiceManagerProps {
46
+ /** Dialog handle for confirm prompts (from useDialog context) */
47
+ dialog?: DialogHandle;
48
+ }
49
+
50
+ /** A single edge location on the world map */
51
+ interface EdgeLocation {
52
+ code: string; // IATA/ICAO airport code
53
+ city: string; // City name for display
54
+ x: number; // Approximate column on the map grid
55
+ y: number; // Approximate row on the map grid
56
+ latency: number; // Simulated latency in ms
57
+ }
58
+
59
+ // ─── Edge map data ──────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Approximate geo-positions mapped to a terminal character grid.
63
+ * Grid is ~60 cols x ~12 rows. Each dot is a Cloudflare edge PoP.
64
+ */
65
+ const EDGE_LOCATIONS: EdgeLocation[] = [
66
+ { code: "SFO", city: "San Francisco", x: 4, y: 3, latency: 2 },
67
+ { code: "LAX", city: "Los Angeles", x: 3, y: 4, latency: 3 },
68
+ { code: "SEA", city: "Seattle", x: 3, y: 2, latency: 5 },
69
+ { code: "DFW", city: "Dallas", x: 8, y: 4, latency: 10 },
70
+ { code: "ORD", city: "Chicago", x: 12, y: 3, latency: 12 },
71
+ { code: "ATL", city: "Atlanta", x: 14, y: 5, latency: 14 },
72
+ { code: "MIA", city: "Miami", x: 15, y: 6, latency: 16 },
73
+ { code: "LHR", city: "London", x: 28, y: 3, latency: 28 },
74
+ { code: "AMS", city: "Amsterdam", x: 30, y: 2, latency: 30 },
75
+ { code: "FRA", city: "Frankfurt", x: 32, y: 3, latency: 32 },
76
+ { code: "CDG", city: "Paris", x: 29, y: 4, latency: 29 },
77
+ { code: "NRT", city: "Tokyo", x: 48, y: 4, latency: 48 },
78
+ { code: "ICN", city: "Seoul", x: 46, y: 3, latency: 45 },
79
+ { code: "SIN", city: "Singapore", x: 44, y: 8, latency: 44 },
80
+ { code: "SYD", city: "Sydney", x: 50, y: 10, latency: 52 },
81
+ { code: "GRU", city: "São Paulo", x: 18, y: 9, latency: 20 },
82
+ ];
83
+
84
+ const MAP_WIDTH = 60;
85
+ const MAP_HEIGHT = 12;
86
+
87
+ /** Prebuilt cell lookup — edge map data is static; avoid rebuilds every render. */
88
+ const EDGE_LOCATION_BY_CELL: Map<string, EdgeLocation> = (() => {
89
+ const map = new Map<string, EdgeLocation>();
90
+ for (const loc of EDGE_LOCATIONS) {
91
+ map.set(`${loc.y},${loc.x}`, loc);
92
+ }
93
+ return map;
94
+ })();
95
+
96
+ // ─── Edge Map Component ─────────────────────────────────────────────────────
97
+
98
+ function EdgeMap() {
99
+ // Track which location is currently hovered/selected
100
+ const [selectedCode, setSelectedCode] = useState<string | null>(null);
101
+
102
+ const selectedLocation = useMemo(
103
+ () => EDGE_LOCATIONS.find((l) => l.code === selectedCode) ?? null,
104
+ [selectedCode]
105
+ );
106
+
107
+ return (
108
+ <box flexDirection="column" flexGrow={1} gap={0}>
109
+ {/* Map header */}
110
+ <box
111
+ flexDirection="row"
112
+ justifyContent="space-between"
113
+ paddingLeft={1}
114
+ paddingRight={1}
115
+ >
116
+ <text fg={Colors.accent} bold>
117
+ <b>EDGE NETWORK</b>
118
+ </text>
119
+ <text fg={Colors.warning} dim>
120
+ schematic · not live CF
121
+ </text>
122
+ <text fg={Colors.muted}>{EDGE_LOCATIONS.length} sample PoPs</text>
123
+ </box>
124
+
125
+ {/* Map grid (outer Panel provides border) */}
126
+ <box flexDirection="column" gap={0} paddingX={1} paddingY={0}>
127
+ {Array.from({ length: MAP_HEIGHT }, (_, row) => (
128
+ <box key={`map-row-${row}`} flexDirection="row" gap={0}>
129
+ {Array.from({ length: MAP_WIDTH }, (_, col) => {
130
+ const loc = EDGE_LOCATION_BY_CELL.get(`${row},${col}`);
131
+ if (loc) {
132
+ const isSelected = selectedCode === loc.code;
133
+ return (
134
+ <text
135
+ key={loc.code}
136
+ fg={isSelected ? Colors.accent : Colors.info}
137
+ bold={isSelected}
138
+ onMouseUp={() =>
139
+ setSelectedCode(
140
+ selectedCode === loc.code ? null : loc.code
141
+ )
142
+ }
143
+ >
144
+ ●
145
+ </text>
146
+ );
147
+ }
148
+ return (
149
+ <text key={`empty-${row}-${col}`} fg={Colors.dim}>
150
+ {" "}
151
+ </text>
152
+ );
153
+ })}
154
+ </box>
155
+ ))}
156
+ </box>
157
+
158
+ {/* Hover/select detail bar — shows city + latency */}
159
+ <box
160
+ flexDirection="row"
161
+ paddingLeft={1}
162
+ paddingRight={1}
163
+ justifyContent="space-between"
164
+ >
165
+ {selectedLocation ? (
166
+ <box flexDirection="row" gap={2}>
167
+ <text fg={Colors.accent} bold>
168
+ {selectedLocation.code}
169
+ </text>
170
+ <text fg={Colors.foreground}>{selectedLocation.city}</text>
171
+ <text fg={Colors.info}>{selectedLocation.latency}ms</text>
172
+ </box>
173
+ ) : (
174
+ <text fg={Colors.muted} dim>
175
+ Click a ● for sample city · map is illustrative only
176
+ </text>
177
+ )}
178
+ </box>
179
+ </box>
180
+ );
181
+ }
182
+
183
+ // ─── Worker Control Row ─────────────────────────────────────────────────────
184
+
185
+ interface WorkerRowProps {
186
+ worker: WorkerInfo;
187
+ index: number;
188
+ selectedIndex: number;
189
+ onSelect: (index: number) => void;
190
+ onDeploy: (worker: WorkerInfo) => void;
191
+ onRestart: (worker: WorkerInfo) => void;
192
+ deployingWorker?: string | null;
193
+ }
194
+
195
+ function WorkerRow({
196
+ worker,
197
+ index,
198
+ selectedIndex,
199
+ onSelect,
200
+ onDeploy,
201
+ onRestart,
202
+ deployingWorker,
203
+ }: WorkerRowProps) {
204
+ const isSelected = index === selectedIndex;
205
+ const bgColor = isSelected ? Colors.card : undefined;
206
+ const isDeploying = deployingWorker === worker.name;
207
+
208
+ return (
209
+ <box
210
+ flexDirection="row"
211
+ gap={1}
212
+ paddingLeft={1}
213
+ paddingRight={1}
214
+ backgroundColor={bgColor}
215
+ >
216
+ {/* Selectable area (status dot + name) */}
217
+ <box
218
+ flexDirection="row"
219
+ gap={1}
220
+ flexGrow={1}
221
+ onMouseUp={() => onSelect(index)}
222
+ >
223
+ <StatusDot
224
+ status={worker.status}
225
+ pulse={worker.status === "operational"}
226
+ />
227
+ <text
228
+ fg={isSelected ? Colors.accent : Colors.foreground}
229
+ bold={isSelected}
230
+ >
231
+ {worker.name}
232
+ </text>
233
+ </box>
234
+
235
+ {/* Per-worker action buttons */}
236
+ <box flexDirection="row" gap={1}>
237
+ <text
238
+ fg={isDeploying ? Colors.muted : Colors.accent}
239
+ bg={Colors.card}
240
+ dim={isDeploying}
241
+ onMouseUp={isDeploying ? undefined : () => onDeploy(worker)}
242
+ >
243
+ {" Deploy "}
244
+ </text>
245
+ <text
246
+ fg={isDeploying ? Colors.muted : Colors.warning}
247
+ bg={Colors.card}
248
+ dim={isDeploying}
249
+ onMouseUp={isDeploying ? undefined : () => onRestart(worker)}
250
+ >
251
+ {" Restart "}
252
+ </text>
253
+ </box>
254
+ </box>
255
+ );
256
+ }
257
+
258
+ // ─── Worker Control List ────────────────────────────────────────────────────
259
+
260
+ interface WorkerControlListProps {
261
+ workers: WorkerInfo[];
262
+ onDeploy: (worker: WorkerInfo) => void;
263
+ onRestart: (worker: WorkerInfo) => void;
264
+ deployingWorker?: string | null;
265
+ }
266
+
267
+ function WorkerControlList({
268
+ workers,
269
+ onDeploy,
270
+ onRestart,
271
+ deployingWorker,
272
+ }: WorkerControlListProps) {
273
+ const [selectedIndex, setSelectedIndex] = useState(0);
274
+
275
+ return (
276
+ <box flexDirection="column" flexGrow={1} gap={0}>
277
+ {/* List header */}
278
+ <box
279
+ flexDirection="row"
280
+ justifyContent="space-between"
281
+ paddingLeft={1}
282
+ paddingRight={1}
283
+ >
284
+ <text fg={Colors.accent} bold>
285
+ <b>WORKERS</b>
286
+ </text>
287
+ <text fg={Colors.muted}>{workers.length} total</text>
288
+ </box>
289
+
290
+ {/* Column header */}
291
+ <box flexDirection="row" paddingLeft={1} paddingRight={1} gap={1}>
292
+ <text fg={Colors.muted} dim>
293
+ Status
294
+ </text>
295
+ <text fg={Colors.muted} dim flexGrow={1}>
296
+ Worker Name
297
+ </text>
298
+ <text fg={Colors.muted} dim>
299
+ Actions
300
+ </text>
301
+ </box>
302
+
303
+ {/* Scrollable worker list */}
304
+ <scrollbox width="100%" flexGrow={1}>
305
+ {workers.length === 0 ? (
306
+ <box padding={1} flexGrow={1}>
307
+ <EmptyState
308
+ message="No workers registered."
309
+ suggestion="Run setup to deploy workers."
310
+ icon="⚙️"
311
+ />
312
+ </box>
313
+ ) : (
314
+ workers.map((worker, i) => (
315
+ <WorkerRow
316
+ key={worker.id}
317
+ worker={worker}
318
+ index={i}
319
+ selectedIndex={selectedIndex}
320
+ onSelect={setSelectedIndex}
321
+ onDeploy={onDeploy}
322
+ onRestart={onRestart}
323
+ deployingWorker={deployingWorker}
324
+ />
325
+ ))
326
+ )}
327
+ </scrollbox>
328
+ </box>
329
+ );
330
+ }
331
+
332
+ // ─── Bulk Actions Bar ───────────────────────────────────────────────────────
333
+
334
+ interface BulkActionsProps {
335
+ workers: WorkerInfo[];
336
+ onDeployAll: () => void;
337
+ onRestartAll: () => void;
338
+ deployingWorker?: string | null;
339
+ }
340
+
341
+ function BulkActions({
342
+ workers,
343
+ onDeployAll,
344
+ onRestartAll,
345
+ deployingWorker,
346
+ }: BulkActionsProps) {
347
+ const operationalCount = workers.filter(
348
+ (w) => w.status === "operational"
349
+ ).length;
350
+ const canAct = workers.length > 0 && !deployingWorker;
351
+ const deployDim = !canAct || !!deployingWorker;
352
+ const restartDim = !canAct || !!deployingWorker;
353
+
354
+ return (
355
+ <Panel elevated={false} compact>
356
+ <box flexDirection="row" justifyContent="space-between">
357
+ {/* Status summary */}
358
+ <box flexDirection="row" gap={1}>
359
+ <text fg={Colors.success}>{operationalCount} up</text>
360
+ <text fg={Colors.muted}>/</text>
361
+ <text fg={Colors.muted}>
362
+ {workers.length - operationalCount} down
363
+ </text>
364
+ </box>
365
+
366
+ {/* Bulk action buttons */}
367
+ <box flexDirection="row" gap={2}>
368
+ <text
369
+ fg={canAct ? Colors.accent : Colors.muted}
370
+ bg={canAct ? Colors.card : undefined}
371
+ dim={deployDim}
372
+ onMouseUp={canAct ? onDeployAll : undefined}
373
+ >
374
+ {" [Deploy All] "}
375
+ </text>
376
+ <text
377
+ fg={canAct ? Colors.warning : Colors.muted}
378
+ bg={canAct ? Colors.card : undefined}
379
+ dim={restartDim}
380
+ onMouseUp={canAct ? onRestartAll : undefined}
381
+ >
382
+ {" [Restart All] "}
383
+ </text>
384
+ </box>
385
+ </box>
386
+ </Panel>
387
+ );
388
+ }
389
+
390
+ // ─── Kill-Switch Section ────────────────────────────────────────────────────
391
+
392
+ /**
393
+ * Render a timestamp (ms or ISO string) as a compact HH:MM:SS label.
394
+ * Falls back to a dash when the input is invalid.
395
+ */
396
+ function formatKillSwitchTime(input: string | number | null): string {
397
+ if (input === null) return "—";
398
+ const ms = typeof input === "number" ? input : Date.parse(input);
399
+ if (Number.isNaN(ms)) return "—";
400
+ const d = new Date(ms);
401
+ const hh = d.getHours().toString().padStart(2, "0");
402
+ const mm = d.getMinutes().toString().padStart(2, "0");
403
+ const ss = d.getSeconds().toString().padStart(2, "0");
404
+ return `${hh}:${mm}:${ss}`;
405
+ }
406
+
407
+ interface KillSwitchSectionProps {
408
+ dialog?: DialogHandle;
409
+ onAlert: (
410
+ id: string,
411
+ type: string,
412
+ result: CliResult,
413
+ successMsg: string,
414
+ failMsg: string
415
+ ) => void;
416
+ }
417
+
418
+ /**
419
+ * KillSwitchSection — Emergency-stop control panel for the global trade kill
420
+ * switch. Shows the current state, lets the user engage/release it, and
421
+ * dispatches alerts on success/failure. Engages always require confirmation
422
+ * (trading safety) and releases confirm as well to prevent accidents.
423
+ */
424
+ function KillSwitchSection({ dialog, onAlert }: KillSwitchSectionProps) {
425
+ const [status, setStatus] = useState<KillSwitchStatus | null>(null);
426
+ const [loading, setLoading] = useState(false);
427
+ const [error, setError] = useState<string | null>(null);
428
+ const mountedRef = useRef(true);
429
+ const loadingRef = useRef(false);
430
+
431
+ useEffect(() => {
432
+ mountedRef.current = true;
433
+ return () => {
434
+ mountedRef.current = false;
435
+ };
436
+ }, []);
437
+
438
+ const refresh = useCallback(async () => {
439
+ if (loadingRef.current) return;
440
+ loadingRef.current = true;
441
+ setLoading(true);
442
+ setError(null);
443
+ try {
444
+ const result = await cliBridge.monitorKillSwitch("show");
445
+ if (!mountedRef.current) return;
446
+ if (result.success && result.data) {
447
+ setStatus(result.data);
448
+ setError(null);
449
+ } else {
450
+ setError(
451
+ result.stderr || result.stdout || "Failed to read kill switch status"
452
+ );
453
+ }
454
+ } catch (err) {
455
+ if (!mountedRef.current) return;
456
+ setError(err instanceof Error ? err.message : "Kill switch probe failed");
457
+ } finally {
458
+ loadingRef.current = false;
459
+ if (mountedRef.current) setLoading(false);
460
+ }
461
+ }, []);
462
+
463
+ // Auto-refresh on mount (with cancellation on unmount)
464
+ useEffect(() => {
465
+ void refresh();
466
+ }, [refresh]);
467
+
468
+ const setEngaged = useCallback(
469
+ async (engaged: boolean) => {
470
+ // Fail closed without dialog — never flip kill switch silently
471
+ if (!dialog) {
472
+ useServiceStore.getState().addAlert({
473
+ id: `killswitch-noconfirm-${Date.now()}`,
474
+ type: "killswitch",
475
+ severity: "warning",
476
+ message:
477
+ "Kill switch action blocked: confirmation dialog unavailable",
478
+ timestamp: Date.now(),
479
+ acknowledged: false,
480
+ });
481
+ return;
482
+ }
483
+ if (loadingRef.current) return;
484
+ const action: "engage" | "release" = engaged ? "engage" : "release";
485
+ const verb = engaged ? "ENGAGE" : "RELEASE";
486
+ const confirmTitle = engaged
487
+ ? "Engage Kill Switch?"
488
+ : "Release Kill Switch?";
489
+ const confirmMessage = engaged
490
+ ? "This will HALT all trading activity immediately. " +
491
+ "Active signals will be rejected until the kill switch is released. Continue?"
492
+ : "This will RESUME normal trading operations. " +
493
+ "All queued signals will be processed. Continue?";
494
+ const confirmed = await showConfirm(dialog, {
495
+ title: confirmTitle,
496
+ message: confirmMessage,
497
+ confirmLabel: verb,
498
+ cancelLabel: "Cancel",
499
+ });
500
+ if (!confirmed || !mountedRef.current) return;
501
+
502
+ loadingRef.current = true;
503
+ setLoading(true);
504
+ setError(null);
505
+ try {
506
+ const result = await cliBridge.monitorKillSwitch(action);
507
+ if (!mountedRef.current) return;
508
+ if (result.success && result.data) {
509
+ setStatus(result.data);
510
+ setError(null);
511
+ onAlert(
512
+ `killswitch-${Date.now()}`,
513
+ "killswitch",
514
+ result,
515
+ `Kill switch ${engaged ? "engaged" : "released"} (${(result.duration / 1000).toFixed(1)}s)`,
516
+ `Kill switch ${engaged ? "engage" : "release"} failed: ${
517
+ result.stderr || result.stdout || "unknown error"
518
+ }`
519
+ );
520
+ } else {
521
+ setError(
522
+ result.stderr ||
523
+ result.stdout ||
524
+ `Failed to ${verb.toLowerCase()} kill switch`
525
+ );
526
+ onAlert(
527
+ `killswitch-err-${Date.now()}`,
528
+ "killswitch",
529
+ result,
530
+ `Kill switch ${engaged ? "engaged" : "released"}`,
531
+ `Kill switch ${engaged ? "engage" : "release"} error: ${
532
+ result.stderr || result.stdout || "unknown error"
533
+ }`
534
+ );
535
+ }
536
+ } catch (err) {
537
+ if (!mountedRef.current) return;
538
+ const message = err instanceof Error ? err.message : String(err);
539
+ setError(message);
540
+ } finally {
541
+ loadingRef.current = false;
542
+ if (mountedRef.current) setLoading(false);
543
+ }
544
+ },
545
+ [dialog, onAlert]
546
+ );
547
+
548
+ // Visual state — unknown when status hasn't loaded yet
549
+ const engaged = status?.engaged ?? false;
550
+ const stateLabel =
551
+ status === null ? "UNKNOWN" : engaged ? "ENGAGED" : "RELEASED";
552
+ const stateColor =
553
+ status === null ? Colors.muted : engaged ? Colors.error : Colors.success;
554
+ const dotStatus: "operational" | "down" = engaged ? "down" : "operational";
555
+ const engageDisabled = loading || engaged;
556
+ const releaseDisabled = loading || !engaged;
557
+
558
+ return (
559
+ <Panel
560
+ elevated={false}
561
+ compact
562
+ borderColor={engaged ? Colors.error : undefined}
563
+ >
564
+ <box
565
+ flexDirection="row"
566
+ justifyContent="space-between"
567
+ alignItems="center"
568
+ >
569
+ {/* Status side — label, dot, state, timestamp */}
570
+ <box flexDirection="row" gap={1} alignItems="center">
571
+ <text fg={Colors.accent} bold>
572
+ KILL SWITCH
573
+ </text>
574
+ <StatusDot status={dotStatus} pulse={!engaged} />
575
+ <text fg={stateColor} bold>
576
+ {stateLabel}
577
+ </text>
578
+ {status && (
579
+ <text fg={Colors.muted} dim>
580
+ @ {formatKillSwitchTime(status.timestamp)}
581
+ </text>
582
+ )}
583
+ {error && (
584
+ <text fg={Colors.error} dim>
585
+ ! {error.length > 40 ? error.slice(0, 37) + "…" : error}
586
+ </text>
587
+ )}
588
+ </box>
589
+
590
+ {/* Action side — engage / release buttons + refresh */}
591
+ <box flexDirection="row" gap={2}>
592
+ <text
593
+ fg={Colors.muted}
594
+ bg={Colors.card}
595
+ onMouseUp={loading ? undefined : () => void refresh()}
596
+ >
597
+ {loading ? " ..." : " [REFRESH] "}
598
+ </text>
599
+ <text
600
+ fg={engageDisabled ? Colors.muted : Colors.error}
601
+ bg={engageDisabled ? undefined : Colors.card}
602
+ dim={engageDisabled}
603
+ onMouseUp={engageDisabled ? undefined : () => void setEngaged(true)}
604
+ >
605
+ {" ENGAGE "}
606
+ </text>
607
+ <text
608
+ fg={releaseDisabled ? Colors.muted : Colors.success}
609
+ bg={releaseDisabled ? undefined : Colors.card}
610
+ dim={releaseDisabled}
611
+ onMouseUp={
612
+ releaseDisabled ? undefined : () => void setEngaged(false)
613
+ }
614
+ >
615
+ {" RELEASE "}
616
+ </text>
617
+ </box>
618
+ </box>
619
+ </Panel>
620
+ );
621
+ }
622
+
623
+ // ─── Main ServiceManager View ───────────────────────────────────────────────
624
+
625
+ export function ServiceManager({ dialog }: ServiceManagerProps) {
626
+ // Subscribe to workers from the service store
627
+ const workers = useServiceStore((s) => s.workers);
628
+ const connectionStatus = useServiceStore((s) => s.connectionStatus);
629
+
630
+ // Deploy/restart tracking state
631
+ const [deployingWorker, setDeployingWorker] = useState<string | null>(null);
632
+ const [deployProgress, setDeployProgress] = useState("");
633
+ const mountedRef = useRef(true);
634
+ const deployingRef = useRef(false);
635
+
636
+ useEffect(() => {
637
+ mountedRef.current = true;
638
+ return () => {
639
+ mountedRef.current = false;
640
+ };
641
+ }, []);
642
+
643
+ // Edge count from worker telemetry only — never invent a marketing "275+".
644
+ const totalEdgeCount = useMemo(
645
+ () => workers.reduce((sum, w) => sum + (w.edgeCount ?? 0), 0),
646
+ [workers]
647
+ );
648
+ const displayEdgeCount = totalEdgeCount;
649
+ const showPlus = false;
650
+
651
+ /** Shared progress callback — scrub secrets from CLI streams */
652
+ const onProgress = useCallback((chunk: string) => {
653
+ const safe = redactSecretsInText(chunk);
654
+ setDeployProgress((prev) => (prev + safe).slice(-2000));
655
+ }, []);
656
+
657
+ /** Alert helper — scrub failure payloads before they hit the UI */
658
+ const addResultAlert = useCallback(
659
+ (
660
+ id: string,
661
+ type: string,
662
+ result: CliResult,
663
+ successMsg: string,
664
+ failMsg: string
665
+ ) => {
666
+ const store = useServiceStore.getState();
667
+ store.addAlert({
668
+ id,
669
+ type,
670
+ severity: result.success ? "info" : "warning",
671
+ message: result.success ? successMsg : redactSecretsInText(failMsg),
672
+ timestamp: Date.now(),
673
+ acknowledged: false,
674
+ });
675
+ },
676
+ []
677
+ );
678
+
679
+ const blockWithoutDialog = useCallback((action: string, target: string) => {
680
+ useServiceStore.getState().addAlert({
681
+ id: `${action}-noconfirm-${Date.now()}`,
682
+ type: action,
683
+ severity: "warning",
684
+ message: `${action} blocked: confirmation dialog unavailable for ${target}`,
685
+ timestamp: Date.now(),
686
+ acknowledged: false,
687
+ });
688
+ }, []);
689
+
690
+ // ── Per-worker action handlers ─────────────────────────────────────────
691
+ const handleDeploy = useCallback(
692
+ async (worker: WorkerInfo) => {
693
+ if (deployingRef.current) return;
694
+ if (!dialog) {
695
+ blockWithoutDialog("deploy", worker.name);
696
+ return;
697
+ }
698
+ const confirmed = await showConfirm(dialog, {
699
+ title: `Deploy ${worker.name}`,
700
+ message: `Trigger a new deployment for ${worker.name}? This will publish the latest code bundle to Cloudflare.`,
701
+ confirmLabel: "Deploy",
702
+ cancelLabel: "Cancel",
703
+ });
704
+ if (!confirmed || !mountedRef.current) return;
705
+ deployingRef.current = true;
706
+ setDeployingWorker(worker.name);
707
+ setDeployProgress("");
708
+ try {
709
+ const result = await cliBridge.deployWorker(worker.name, onProgress);
710
+ if (!mountedRef.current) return;
711
+ addResultAlert(
712
+ `deploy-${Date.now()}-${worker.name}`,
713
+ "deploy",
714
+ result,
715
+ `${worker.name} deployed (${(result.duration / 1000).toFixed(1)}s)`,
716
+ `${worker.name} deploy failed: ${result.stderr || result.stdout || "unknown error"}`
717
+ );
718
+ if (result.success) await useServiceStore.getState().fetchWorkers();
719
+ } catch (err) {
720
+ if (!mountedRef.current) return;
721
+ useServiceStore.getState().addAlert({
722
+ id: `deploy-err-${Date.now()}`,
723
+ type: "deploy",
724
+ severity: "error",
725
+ message: `${worker.name} deploy error: ${err instanceof Error ? err.message : String(err)}`,
726
+ timestamp: Date.now(),
727
+ acknowledged: false,
728
+ });
729
+ } finally {
730
+ deployingRef.current = false;
731
+ if (mountedRef.current) setDeployingWorker(null);
732
+ }
733
+ },
734
+ [dialog, onProgress, addResultAlert, blockWithoutDialog]
735
+ );
736
+
737
+ const handleRestart = useCallback(
738
+ async (worker: WorkerInfo) => {
739
+ if (deployingRef.current) return;
740
+ if (!dialog) {
741
+ blockWithoutDialog("restart", worker.name);
742
+ return;
743
+ }
744
+ const confirmed = await showConfirm(dialog, {
745
+ title: `Restart ${worker.name}`,
746
+ message: `Restart the ${worker.name} worker? Running tasks will be gracefully drained before restart.`,
747
+ confirmLabel: "Restart",
748
+ cancelLabel: "Cancel",
749
+ });
750
+ if (!confirmed || !mountedRef.current) return;
751
+ deployingRef.current = true;
752
+ setDeployingWorker(worker.name);
753
+ setDeployProgress("");
754
+ try {
755
+ const result = await cliBridge.repairWorker(worker.name, onProgress);
756
+ if (!mountedRef.current) return;
757
+ addResultAlert(
758
+ `restart-${Date.now()}-${worker.name}`,
759
+ "restart",
760
+ result,
761
+ `${worker.name} restarted (${(result.duration / 1000).toFixed(1)}s)`,
762
+ `${worker.name} restart failed: ${result.stderr || result.stdout || "unknown error"}`
763
+ );
764
+ if (result.success) await useServiceStore.getState().fetchWorkers();
765
+ } catch (err) {
766
+ if (!mountedRef.current) return;
767
+ useServiceStore.getState().addAlert({
768
+ id: `restart-err-${Date.now()}`,
769
+ type: "restart",
770
+ severity: "error",
771
+ message: `${worker.name} restart error: ${err instanceof Error ? err.message : String(err)}`,
772
+ timestamp: Date.now(),
773
+ acknowledged: false,
774
+ });
775
+ } finally {
776
+ deployingRef.current = false;
777
+ if (mountedRef.current) setDeployingWorker(null);
778
+ }
779
+ },
780
+ [dialog, onProgress, addResultAlert, blockWithoutDialog]
781
+ );
782
+
783
+ const handleDeployAll = useCallback(async () => {
784
+ if (deployingRef.current) return;
785
+ if (!dialog) {
786
+ blockWithoutDialog("deploy", "all workers");
787
+ return;
788
+ }
789
+ const confirmed = await showConfirm(dialog, {
790
+ title: "Deploy All Workers",
791
+ message: `This will deploy the latest code to all ${workers.length} workers. Existing deployments will be replaced. Continue?`,
792
+ confirmLabel: "Deploy All",
793
+ cancelLabel: "Cancel",
794
+ });
795
+ if (!confirmed || !mountedRef.current) return;
796
+ deployingRef.current = true;
797
+ setDeployingWorker("all");
798
+ setDeployProgress("");
799
+ try {
800
+ const result = await cliBridge.deployAll(onProgress);
801
+ if (!mountedRef.current) return;
802
+ addResultAlert(
803
+ `deploy-all-${Date.now()}`,
804
+ "deploy",
805
+ result,
806
+ `All deployed (${(result.duration / 1000).toFixed(1)}s)`,
807
+ `Deploy all failed: ${result.stderr || result.stdout || "unknown error"}`
808
+ );
809
+ if (result.success) await useServiceStore.getState().fetchWorkers();
810
+ } catch (err) {
811
+ if (!mountedRef.current) return;
812
+ useServiceStore.getState().addAlert({
813
+ id: `deploy-all-err-${Date.now()}`,
814
+ type: "deploy",
815
+ severity: "error",
816
+ message: `Deploy all error: ${err instanceof Error ? err.message : String(err)}`,
817
+ timestamp: Date.now(),
818
+ acknowledged: false,
819
+ });
820
+ } finally {
821
+ deployingRef.current = false;
822
+ if (mountedRef.current) setDeployingWorker(null);
823
+ }
824
+ }, [dialog, workers.length, onProgress, addResultAlert, blockWithoutDialog]);
825
+
826
+ const handleRestartAll = useCallback(async () => {
827
+ if (deployingRef.current) return;
828
+ if (!dialog) {
829
+ blockWithoutDialog("restart", "all workers");
830
+ return;
831
+ }
832
+ const confirmed = await showConfirm(dialog, {
833
+ title: "Restart All Workers",
834
+ message: `This will restart all ${workers.length} workers. Active trades will be gracefully drained. Continue?`,
835
+ confirmLabel: "Restart All",
836
+ cancelLabel: "Cancel",
837
+ });
838
+ if (!confirmed || !mountedRef.current) return;
839
+ deployingRef.current = true;
840
+ setDeployingWorker("all");
841
+ setDeployProgress("");
842
+ try {
843
+ const result = await cliBridge.rebuild(onProgress);
844
+ if (!mountedRef.current) return;
845
+ addResultAlert(
846
+ `restart-all-${Date.now()}`,
847
+ "restart",
848
+ result,
849
+ `All restarted (${(result.duration / 1000).toFixed(1)}s)`,
850
+ `Restart all failed: ${result.stderr || result.stdout || "unknown error"}`
851
+ );
852
+ if (result.success) await useServiceStore.getState().fetchWorkers();
853
+ } catch (err) {
854
+ if (!mountedRef.current) return;
855
+ useServiceStore.getState().addAlert({
856
+ id: `restart-all-err-${Date.now()}`,
857
+ type: "restart",
858
+ severity: "error",
859
+ message: `Restart all error: ${err instanceof Error ? err.message : String(err)}`,
860
+ timestamp: Date.now(),
861
+ acknowledged: false,
862
+ });
863
+ } finally {
864
+ deployingRef.current = false;
865
+ if (mountedRef.current) setDeployingWorker(null);
866
+ }
867
+ }, [dialog, workers.length, onProgress, addResultAlert, blockWithoutDialog]);
868
+
869
+ // ── Render ──────────────────────────────────────────────────────────────
870
+ return (
871
+ <ErrorBoundary viewName="Service Manager">
872
+ <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
873
+ {/* Header row */}
874
+ <ViewHeader
875
+ title="SERVICE MANAGER"
876
+ showDivider={false}
877
+ meta={
878
+ <box flexDirection="row" gap={1}>
879
+ <text fg={Colors.info}>Edges:</text>
880
+ <text fg={Colors.accent} bold>
881
+ {displayEdgeCount > 0 ? String(displayEdgeCount) : "—"}
882
+ {showPlus ? "+" : ""}
883
+ </text>
884
+ <text
885
+ fg={
886
+ ConnectionStatusColor[
887
+ connectionStatus as keyof typeof ConnectionStatusColor
888
+ ] ?? Colors.muted
889
+ }
890
+ >
891
+ {connectionStatus === "connected"
892
+ ? "█"
893
+ : connectionStatus === "reconnecting"
894
+ ? "▌"
895
+ : connectionStatus === "polling"
896
+ ? "▌"
897
+ : "░"}
898
+ </text>
899
+ </box>
900
+ }
901
+ />
902
+
903
+ {/* Main content: workers (left) + edge map (right) */}
904
+ <box flexDirection="row" flexGrow={1} gap={1}>
905
+ {/* Left: worker control list */}
906
+ <Panel width="50%" flexGrow={1} elevated={false} compact>
907
+ <WorkerControlList
908
+ workers={workers}
909
+ onDeploy={handleDeploy}
910
+ onRestart={handleRestart}
911
+ deployingWorker={deployingWorker}
912
+ />
913
+ </Panel>
914
+
915
+ {/* Right: edge location map */}
916
+ <Panel width="50%" flexGrow={1} elevated={false} compact>
917
+ <EdgeMap />
918
+ </Panel>
919
+ </box>
920
+
921
+ {/* Bottom: bulk actions bar */}
922
+ <BulkActions
923
+ workers={workers}
924
+ onDeployAll={handleDeployAll}
925
+ onRestartAll={handleRestartAll}
926
+ deployingWorker={deployingWorker}
927
+ />
928
+
929
+ {/* Trading safety: kill-switch control */}
930
+ <KillSwitchSection dialog={dialog} onAlert={addResultAlert} />
931
+
932
+ {/* Deploy progress text */}
933
+ {deployingWorker && (
934
+ <box flexDirection="column" gap={0} paddingLeft={1}>
935
+ <text fg={Colors.accent}>DEPLOYING {deployingWorker}...</text>
936
+ {deployProgress && (
937
+ <text fg={Colors.muted} dim>
938
+ {redactSecretsInText(deployProgress.slice(-200))}
939
+ </text>
940
+ )}
941
+ </box>
942
+ )}
943
+ </box>
944
+ </ErrorBoundary>
945
+ );
946
+ }