@hoox-sh/hoox-tui 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -31
- package/dist/main.js +4238 -1711
- package/package.json +2 -2
- package/src/app.tsx +45 -30
- package/src/cli-bridge-test-double.ts +48 -1
- package/src/components/layout/sidebar.tsx +27 -11
- package/src/components/layout/statusbar.test.tsx +19 -5
- package/src/components/layout/statusbar.tsx +34 -18
- package/src/components/shared/crash-screen.tsx +24 -2
- package/src/components/shared/error-boundary.tsx +56 -33
- package/src/components/ui/connection-toasts.test.ts +58 -1
- package/src/components/views/config-editor.test.tsx +4 -2
- package/src/components/views/config-editor.tsx +31 -3
- package/src/components/views/dashboard/alerts-panel.tsx +36 -8
- package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
- package/src/components/views/dashboard.test.tsx +2 -1
- package/src/components/views/dashboard.tsx +126 -41
- package/src/components/views/db-query.test.tsx +7 -0
- package/src/components/views/db-query.tsx +5 -4
- package/src/components/views/edge-topology.tsx +3 -2
- package/src/components/views/kv-viewer.test.tsx +28 -0
- package/src/components/views/kv-viewer.tsx +4 -3
- package/src/components/views/logs-viewer.tsx +46 -19
- package/src/components/views/queue-depth.test.tsx +39 -0
- package/src/components/views/queue-depth.tsx +3 -2
- package/src/components/views/secrets-viewer.test.tsx +2 -2
- package/src/components/views/secrets-viewer.tsx +4 -3
- package/src/components/views/service-manager.tsx +147 -53
- package/src/components/views/settings.tsx +50 -10
- package/src/components/views/setup-wizard.test.tsx +13 -0
- package/src/components/views/setup-wizard.tsx +53 -22
- package/src/components/views/trade-monitor.tsx +20 -13
- package/src/components/views/worker-detail.tsx +27 -12
- package/src/components/views/worker-settings.test.tsx +5 -6
- package/src/components/views/worker-settings.tsx +13 -11
- package/src/components/views/workers-overview.tsx +27 -6
- package/src/hooks/hooks.test.ts +303 -9
- package/src/hooks/index.ts +7 -1
- package/src/hooks/shell-overlay.ts +41 -0
- package/src/hooks/use-polling.ts +91 -35
- package/src/hooks/use-service-data.ts +21 -4
- package/src/main.tsx +15 -0
- package/src/services/cli-bridge/index.ts +28 -22
- package/src/services/cli-bridge/standalone.ts +29 -2
- package/src/services/cli-bridge.test.ts +848 -1
- package/src/services/clipboard.test.ts +217 -3
- package/src/services/dashboard-settings-loader.test.ts +50 -0
- package/src/services/tui-connection.test.ts +21 -0
- package/src/stores/service-store.test.ts +89 -3
- package/src/stores/ui-store.test.ts +23 -0
- package/src/test-setup.ts +11 -2
- package/src/types.ts +1 -0
- package/src/view-registry.test.ts +3 -1
- package/src/view-registry.tsx +14 -1
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Color-coded by level using Hoox design tokens. Wrapped in ErrorBoundary.
|
|
14
14
|
*/
|
|
15
15
|
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
import {
|
|
18
18
|
Colors,
|
|
19
19
|
LogLevelColor,
|
|
@@ -25,6 +25,7 @@ import { ErrorBoundary } from "../shared/error-boundary";
|
|
|
25
25
|
import { cliBridge } from "../../services/cli-bridge";
|
|
26
26
|
import { ViewHeader } from "../shared/view-header";
|
|
27
27
|
import { Panel } from "../shared/panel";
|
|
28
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
28
29
|
|
|
29
30
|
// ─── Color Tokens ────────────────────────────────────────────────────────────
|
|
30
31
|
|
|
@@ -283,6 +284,8 @@ interface ActionBarProps {
|
|
|
283
284
|
onTogglePause: () => void;
|
|
284
285
|
onExport: () => void;
|
|
285
286
|
onClear: () => void;
|
|
287
|
+
/** True when user must confirm clear a second time. */
|
|
288
|
+
clearArmed?: boolean;
|
|
286
289
|
sseConnected: boolean;
|
|
287
290
|
onFetch?: () => void;
|
|
288
291
|
}
|
|
@@ -292,6 +295,7 @@ function ActionBar({
|
|
|
292
295
|
onTogglePause,
|
|
293
296
|
onExport,
|
|
294
297
|
onClear,
|
|
298
|
+
clearArmed = false,
|
|
295
299
|
sseConnected,
|
|
296
300
|
onFetch,
|
|
297
301
|
}: ActionBarProps) {
|
|
@@ -315,8 +319,12 @@ function ActionBar({
|
|
|
315
319
|
<text fg={Colors.foreground} onMouseUp={onExport}>
|
|
316
320
|
[Export]
|
|
317
321
|
</text>
|
|
318
|
-
<text
|
|
319
|
-
|
|
322
|
+
<text
|
|
323
|
+
fg={clearArmed ? Colors.warning : Colors.foreground}
|
|
324
|
+
bold={clearArmed}
|
|
325
|
+
onMouseUp={onClear}
|
|
326
|
+
>
|
|
327
|
+
{clearArmed ? "[Confirm Clear]" : "[Clear]"}
|
|
320
328
|
</text>
|
|
321
329
|
{!sseConnected && onFetch ? (
|
|
322
330
|
<text fg={Colors.accent} onMouseUp={onFetch}>
|
|
@@ -325,7 +333,7 @@ function ActionBar({
|
|
|
325
333
|
) : null}
|
|
326
334
|
</box>
|
|
327
335
|
<text dim fg={Colors.muted}>
|
|
328
|
-
Space:pause /:search
|
|
336
|
+
Space:pause /:search
|
|
329
337
|
</text>
|
|
330
338
|
</box>
|
|
331
339
|
);
|
|
@@ -439,23 +447,41 @@ export function LogsViewer() {
|
|
|
439
447
|
}
|
|
440
448
|
}, []);
|
|
441
449
|
|
|
450
|
+
const [clearArmed, setClearArmed] = useState(false);
|
|
451
|
+
const clearArmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
452
|
+
|
|
442
453
|
const handleClear = useCallback(() => {
|
|
443
|
-
//
|
|
444
|
-
|
|
454
|
+
// Two-step confirm (local buffer only — no dialog on this view).
|
|
455
|
+
if (!clearArmed) {
|
|
456
|
+
setClearArmed(true);
|
|
457
|
+
if (clearArmTimer.current) clearTimeout(clearArmTimer.current);
|
|
458
|
+
clearArmTimer.current = setTimeout(() => setClearArmed(false), 3000);
|
|
459
|
+
useServiceStore.getState().addAlert({
|
|
460
|
+
id: `clear-confirm-${Date.now()}`,
|
|
461
|
+
type: "info",
|
|
462
|
+
severity: "warning",
|
|
463
|
+
message: "Press CLEAR again within 3s to wipe the log buffer",
|
|
464
|
+
timestamp: Date.now(),
|
|
465
|
+
acknowledged: false,
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
setClearArmed(false);
|
|
470
|
+
if (clearArmTimer.current) {
|
|
471
|
+
clearTimeout(clearArmTimer.current);
|
|
472
|
+
clearArmTimer.current = null;
|
|
473
|
+
}
|
|
445
474
|
const ts = Date.now();
|
|
446
|
-
useServiceStore.
|
|
447
|
-
|
|
448
|
-
});
|
|
449
|
-
const alert: Alert = {
|
|
475
|
+
useServiceStore.getState().clearLogs();
|
|
476
|
+
useServiceStore.getState().addAlert({
|
|
450
477
|
id: `clear-${ts}`,
|
|
451
478
|
type: "info",
|
|
452
479
|
severity: "info",
|
|
453
480
|
message: "Log buffer cleared",
|
|
454
481
|
timestamp: ts,
|
|
455
482
|
acknowledged: false,
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
}, []);
|
|
483
|
+
});
|
|
484
|
+
}, [clearArmed]);
|
|
459
485
|
|
|
460
486
|
const handleFetch = useCallback(async () => {
|
|
461
487
|
const ts = Date.now();
|
|
@@ -526,16 +552,17 @@ export function LogsViewer() {
|
|
|
526
552
|
}, [paused, frozen, displayed]);
|
|
527
553
|
|
|
528
554
|
// ── Keyboard (active view only) ─────────────────────────────────────────
|
|
529
|
-
|
|
555
|
+
useViewKeyboard((key) => {
|
|
530
556
|
if (!isActive) return;
|
|
557
|
+
const name = String(key.name ?? "");
|
|
531
558
|
|
|
532
559
|
// Search mode: type to filter, Esc / Enter to leave
|
|
533
560
|
if (searchActive) {
|
|
534
|
-
if (
|
|
561
|
+
if (name === "escape" || name === "return") {
|
|
535
562
|
setSearchActive(false);
|
|
536
563
|
return;
|
|
537
564
|
}
|
|
538
|
-
if (
|
|
565
|
+
if (name === "backspace" || name === "delete") {
|
|
539
566
|
setSearchText((t) => t.slice(0, -1));
|
|
540
567
|
return;
|
|
541
568
|
}
|
|
@@ -545,13 +572,12 @@ export function LogsViewer() {
|
|
|
545
572
|
return;
|
|
546
573
|
}
|
|
547
574
|
|
|
548
|
-
if (
|
|
575
|
+
if (name === "space") {
|
|
549
576
|
setPaused((p) => !p);
|
|
550
577
|
return;
|
|
551
578
|
}
|
|
552
|
-
if (
|
|
579
|
+
if (name === "slash" || name === "/") {
|
|
553
580
|
setSearchActive(true);
|
|
554
|
-
return;
|
|
555
581
|
}
|
|
556
582
|
});
|
|
557
583
|
|
|
@@ -607,6 +633,7 @@ export function LogsViewer() {
|
|
|
607
633
|
onTogglePause={() => setPaused((p) => !p)}
|
|
608
634
|
onExport={handleExport}
|
|
609
635
|
onClear={handleClear}
|
|
636
|
+
clearArmed={clearArmed}
|
|
610
637
|
sseConnected={sseConnected}
|
|
611
638
|
onFetch={handleFetch}
|
|
612
639
|
/>
|
|
@@ -172,6 +172,45 @@ describe("QueueDepthView", () => {
|
|
|
172
172
|
]);
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
it("sortQueuesByPressure ties break by name and handles unknown status", () => {
|
|
176
|
+
const ts = new Date().toISOString();
|
|
177
|
+
const queues = [
|
|
178
|
+
{
|
|
179
|
+
queueName: "b",
|
|
180
|
+
depth: 0,
|
|
181
|
+
max: 1000,
|
|
182
|
+
status: "healthy" as const,
|
|
183
|
+
producers: 0,
|
|
184
|
+
consumers: 0,
|
|
185
|
+
paused: false,
|
|
186
|
+
timestamp: ts,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
queueName: "a",
|
|
190
|
+
depth: 0,
|
|
191
|
+
max: 1000,
|
|
192
|
+
status: "healthy" as const,
|
|
193
|
+
producers: 0,
|
|
194
|
+
consumers: 0,
|
|
195
|
+
paused: false,
|
|
196
|
+
timestamp: ts,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
queueName: "z-unknown",
|
|
200
|
+
depth: 0,
|
|
201
|
+
max: 1000,
|
|
202
|
+
status: "unknown" as const,
|
|
203
|
+
producers: 0,
|
|
204
|
+
consumers: 0,
|
|
205
|
+
paused: false,
|
|
206
|
+
timestamp: ts,
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
// unknown ranks above healthy (3 < 4), ties break by name
|
|
210
|
+
const sorted = sortQueuesByPressure(queues);
|
|
211
|
+
expect(sorted.map((q) => q.queueName)).toEqual(["z-unknown", "a", "b"]);
|
|
212
|
+
});
|
|
213
|
+
|
|
175
214
|
// ── Pattern contract for subsequent views ──────────────────────────────
|
|
176
215
|
// Document the architectural pattern this view establishes so the
|
|
177
216
|
// views added in subtasks 04, 05, 06, 08 can be audited against it.
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* - unknown → Colors.muted (gray)
|
|
36
36
|
*/
|
|
37
37
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
38
|
-
|
|
38
|
+
|
|
39
39
|
import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
|
|
40
40
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
41
41
|
import { Spinner, EmptyState } from "../shared/spinner";
|
|
@@ -43,6 +43,7 @@ import { ViewHeader } from "../shared/view-header";
|
|
|
43
43
|
import { Panel } from "../shared/panel";
|
|
44
44
|
import { cliBridge } from "../../services/cli-bridge";
|
|
45
45
|
import type { QueueDepth, QueueDepthStatus } from "../../services/cli-bridge";
|
|
46
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
46
47
|
|
|
47
48
|
/** Auto-refresh interval in milliseconds. */
|
|
48
49
|
export const REFRESH_INTERVAL_MS = 5_000;
|
|
@@ -265,7 +266,7 @@ export function QueueDepthView() {
|
|
|
265
266
|
}, [isActive, refresh]);
|
|
266
267
|
|
|
267
268
|
// Keyboard: r / F5 manual refresh while active
|
|
268
|
-
|
|
269
|
+
useViewKeyboard((key) => {
|
|
269
270
|
if (!isActive) return;
|
|
270
271
|
if (key.name === "r" || key.name === "f5") {
|
|
271
272
|
void refresh({ soft: false });
|
|
@@ -28,12 +28,12 @@ describe("SecretsViewer", () => {
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
it("documents keyboard search (/) and list navigation (↑↓)", async () => {
|
|
31
|
-
// Contract: SecretsViewer wires
|
|
31
|
+
// Contract: SecretsViewer wires useViewKeyboard for / and ↑↓ (parity with KV Viewer).
|
|
32
32
|
// Regression guard against the previous dead SearchBox (onChange discarded).
|
|
33
33
|
const src = await Bun.file(
|
|
34
34
|
new URL("./secrets-viewer.tsx", import.meta.url)
|
|
35
35
|
).text();
|
|
36
|
-
expect(src).toContain("
|
|
36
|
+
expect(src).toContain("useViewKeyboard");
|
|
37
37
|
expect(src).toContain('key.name === "slash"');
|
|
38
38
|
expect(src).toContain("setSearchActive(true)");
|
|
39
39
|
expect(src).toContain("filteredSecrets");
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* - No ability to reveal or copy secret values
|
|
43
43
|
*/
|
|
44
44
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
45
|
-
|
|
45
|
+
|
|
46
46
|
import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
|
|
47
47
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
48
48
|
import { Spinner, EmptyState } from "../shared/spinner";
|
|
@@ -52,6 +52,7 @@ import type {
|
|
|
52
52
|
SecretMetadata,
|
|
53
53
|
SecretsSnapshot,
|
|
54
54
|
} from "../../services/cli-bridge";
|
|
55
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
55
56
|
|
|
56
57
|
/** Auto-refresh interval in milliseconds. */
|
|
57
58
|
export const REFRESH_INTERVAL_MS = 5_000;
|
|
@@ -144,7 +145,7 @@ function SearchBox({
|
|
|
144
145
|
onDeactivate,
|
|
145
146
|
}: SearchBoxProps) {
|
|
146
147
|
// When active, capture printable keys for filtering (same pattern as KV Viewer).
|
|
147
|
-
|
|
148
|
+
useViewKeyboard((key) => {
|
|
148
149
|
if (!active) return;
|
|
149
150
|
if (key.name === "escape") {
|
|
150
151
|
onDeactivate();
|
|
@@ -291,7 +292,7 @@ export function SecretsViewer() {
|
|
|
291
292
|
}, [allSecrets, selectedSecretName]);
|
|
292
293
|
|
|
293
294
|
// ── Keyboard: list nav + open search (inactive while search is active) ───
|
|
294
|
-
|
|
295
|
+
useViewKeyboard((key) => {
|
|
295
296
|
if (!isActive) return;
|
|
296
297
|
if (searchActive) return; // SearchBox owns keys while active
|
|
297
298
|
if (key.name === "slash" || (key.ctrl && key.name === "f")) {
|
|
@@ -26,7 +26,11 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
|
28
28
|
import { useServiceStore } from "@hoox-sh/hoox-shared/stores/service-store";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
Colors,
|
|
31
|
+
ConnectionStatusColor,
|
|
32
|
+
useUIStore,
|
|
33
|
+
} from "@hoox-sh/hoox-shared";
|
|
30
34
|
import { StatusDot } from "../shared/status-dot";
|
|
31
35
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
32
36
|
import { EmptyState } from "../shared/spinner";
|
|
@@ -39,6 +43,7 @@ import { cliBridge } from "../../services/cli-bridge";
|
|
|
39
43
|
import type { KillSwitchStatus } from "../../services/cli-bridge";
|
|
40
44
|
import type { CliResult } from "../../types";
|
|
41
45
|
import { redactSecretsInText } from "../../services/dev-log";
|
|
46
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
42
47
|
|
|
43
48
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
44
49
|
|
|
@@ -262,6 +267,9 @@ interface WorkerControlListProps {
|
|
|
262
267
|
onDeploy: (worker: WorkerInfo) => void;
|
|
263
268
|
onRestart: (worker: WorkerInfo) => void;
|
|
264
269
|
deployingWorker?: string | null;
|
|
270
|
+
/** Controlled selection (keyboard nav from parent). */
|
|
271
|
+
selectedIndex: number;
|
|
272
|
+
onSelect: (index: number) => void;
|
|
265
273
|
}
|
|
266
274
|
|
|
267
275
|
function WorkerControlList({
|
|
@@ -269,9 +277,9 @@ function WorkerControlList({
|
|
|
269
277
|
onDeploy,
|
|
270
278
|
onRestart,
|
|
271
279
|
deployingWorker,
|
|
280
|
+
selectedIndex,
|
|
281
|
+
onSelect,
|
|
272
282
|
}: WorkerControlListProps) {
|
|
273
|
-
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
274
|
-
|
|
275
283
|
return (
|
|
276
284
|
<box flexDirection="column" flexGrow={1} gap={0}>
|
|
277
285
|
{/* List header */}
|
|
@@ -317,7 +325,7 @@ function WorkerControlList({
|
|
|
317
325
|
worker={worker}
|
|
318
326
|
index={i}
|
|
319
327
|
selectedIndex={selectedIndex}
|
|
320
|
-
onSelect={
|
|
328
|
+
onSelect={onSelect}
|
|
321
329
|
onDeploy={onDeploy}
|
|
322
330
|
onRestart={onRestart}
|
|
323
331
|
deployingWorker={deployingWorker}
|
|
@@ -325,6 +333,11 @@ function WorkerControlList({
|
|
|
325
333
|
))
|
|
326
334
|
)}
|
|
327
335
|
</scrollbox>
|
|
336
|
+
<box paddingLeft={1}>
|
|
337
|
+
<text fg={Colors.dim} dim>
|
|
338
|
+
↑↓ · d/r deploy/restart · D/R all · Enter detail · k/e/u kill-switch
|
|
339
|
+
</text>
|
|
340
|
+
</box>
|
|
328
341
|
</box>
|
|
329
342
|
);
|
|
330
343
|
}
|
|
@@ -427,6 +440,8 @@ function KillSwitchSection({ dialog, onAlert }: KillSwitchSectionProps) {
|
|
|
427
440
|
const [error, setError] = useState<string | null>(null);
|
|
428
441
|
const mountedRef = useRef(true);
|
|
429
442
|
const loadingRef = useRef(false);
|
|
443
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
444
|
+
const isActive = activeView === "service-manager";
|
|
430
445
|
|
|
431
446
|
useEffect(() => {
|
|
432
447
|
mountedRef.current = true;
|
|
@@ -555,64 +570,90 @@ function KillSwitchSection({ dialog, onAlert }: KillSwitchSectionProps) {
|
|
|
555
570
|
const engageDisabled = loading || engaged;
|
|
556
571
|
const releaseDisabled = loading || !engaged;
|
|
557
572
|
|
|
573
|
+
// Keyboard: k refresh · e engage · u release (confirm still required)
|
|
574
|
+
useViewKeyboard((key) => {
|
|
575
|
+
if (!isActive) return;
|
|
576
|
+
const name = String(key.name ?? "").toLowerCase();
|
|
577
|
+
if (name === "k") {
|
|
578
|
+
void refresh();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (name === "e" && !engageDisabled) {
|
|
582
|
+
void setEngaged(true);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (name === "u" && !releaseDisabled) {
|
|
586
|
+
void setEngaged(false);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
558
590
|
return (
|
|
559
591
|
<Panel
|
|
560
592
|
elevated={false}
|
|
561
593
|
compact
|
|
562
594
|
borderColor={engaged ? Colors.error : undefined}
|
|
563
595
|
>
|
|
564
|
-
<box
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
<
|
|
572
|
-
|
|
573
|
-
|
|
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)}
|
|
596
|
+
<box flexDirection="column" gap={0}>
|
|
597
|
+
<box
|
|
598
|
+
flexDirection="row"
|
|
599
|
+
justifyContent="space-between"
|
|
600
|
+
alignItems="center"
|
|
601
|
+
>
|
|
602
|
+
{/* Status side — label, dot, state, timestamp */}
|
|
603
|
+
<box flexDirection="row" gap={1} alignItems="center">
|
|
604
|
+
<text fg={Colors.accent} bold>
|
|
605
|
+
KILL SWITCH
|
|
581
606
|
</text>
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
! {error.length > 40 ? error.slice(0, 37) + "…" : error}
|
|
607
|
+
<StatusDot status={dotStatus} pulse={!engaged} />
|
|
608
|
+
<text fg={stateColor} bold>
|
|
609
|
+
{stateLabel}
|
|
586
610
|
</text>
|
|
587
|
-
|
|
588
|
-
|
|
611
|
+
{status && (
|
|
612
|
+
<text fg={Colors.muted} dim>
|
|
613
|
+
@ {formatKillSwitchTime(status.timestamp)}
|
|
614
|
+
</text>
|
|
615
|
+
)}
|
|
616
|
+
{error && (
|
|
617
|
+
<text fg={Colors.error} dim>
|
|
618
|
+
! {error.length > 40 ? error.slice(0, 37) + "…" : error}
|
|
619
|
+
</text>
|
|
620
|
+
)}
|
|
621
|
+
</box>
|
|
589
622
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
releaseDisabled
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
623
|
+
{/* Action side — engage / release buttons + refresh */}
|
|
624
|
+
<box flexDirection="row" gap={2}>
|
|
625
|
+
<text
|
|
626
|
+
fg={Colors.muted}
|
|
627
|
+
bg={Colors.card}
|
|
628
|
+
onMouseUp={loading ? undefined : () => void refresh()}
|
|
629
|
+
>
|
|
630
|
+
{loading ? " ..." : " [REFRESH] "}
|
|
631
|
+
</text>
|
|
632
|
+
<text
|
|
633
|
+
fg={engageDisabled ? Colors.muted : Colors.error}
|
|
634
|
+
bg={engageDisabled ? undefined : Colors.card}
|
|
635
|
+
dim={engageDisabled}
|
|
636
|
+
onMouseUp={
|
|
637
|
+
engageDisabled ? undefined : () => void setEngaged(true)
|
|
638
|
+
}
|
|
639
|
+
>
|
|
640
|
+
{" ENGAGE "}
|
|
641
|
+
</text>
|
|
642
|
+
<text
|
|
643
|
+
fg={releaseDisabled ? Colors.muted : Colors.success}
|
|
644
|
+
bg={releaseDisabled ? undefined : Colors.card}
|
|
645
|
+
dim={releaseDisabled}
|
|
646
|
+
onMouseUp={
|
|
647
|
+
releaseDisabled ? undefined : () => void setEngaged(false)
|
|
648
|
+
}
|
|
649
|
+
>
|
|
650
|
+
{" RELEASE "}
|
|
651
|
+
</text>
|
|
652
|
+
</box>
|
|
653
|
+
</box>
|
|
654
|
+
<box paddingLeft={1}>
|
|
655
|
+
<text fg={Colors.dim} dim>
|
|
656
|
+
k refresh · e engage · u release
|
|
616
657
|
</text>
|
|
617
658
|
</box>
|
|
618
659
|
</box>
|
|
@@ -626,10 +667,13 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
|
|
|
626
667
|
// Subscribe to workers from the service store
|
|
627
668
|
const workers = useServiceStore((s) => s.workers);
|
|
628
669
|
const connectionStatus = useServiceStore((s) => s.connectionStatus);
|
|
670
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
671
|
+
const isActive = activeView === "service-manager";
|
|
629
672
|
|
|
630
673
|
// Deploy/restart tracking state
|
|
631
674
|
const [deployingWorker, setDeployingWorker] = useState<string | null>(null);
|
|
632
675
|
const [deployProgress, setDeployProgress] = useState("");
|
|
676
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
633
677
|
const mountedRef = useRef(true);
|
|
634
678
|
const deployingRef = useRef(false);
|
|
635
679
|
|
|
@@ -640,6 +684,13 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
|
|
|
640
684
|
};
|
|
641
685
|
}, []);
|
|
642
686
|
|
|
687
|
+
// Keep selection in range when worker list changes
|
|
688
|
+
useEffect(() => {
|
|
689
|
+
setSelectedIndex((i) =>
|
|
690
|
+
workers.length === 0 ? 0 : Math.min(i, workers.length - 1)
|
|
691
|
+
);
|
|
692
|
+
}, [workers.length]);
|
|
693
|
+
|
|
643
694
|
// Edge count from worker telemetry only — never invent a marketing "275+".
|
|
644
695
|
const totalEdgeCount = useMemo(
|
|
645
696
|
() => workers.reduce((sum, w) => sum + (w.edgeCount ?? 0), 0),
|
|
@@ -866,6 +917,47 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
|
|
|
866
917
|
}
|
|
867
918
|
}, [dialog, workers.length, onProgress, addResultAlert, blockWithoutDialog]);
|
|
868
919
|
|
|
920
|
+
// ── Keyboard: list nav + deploy/restart (overlay-safe) ─────────────────
|
|
921
|
+
useViewKeyboard((key) => {
|
|
922
|
+
if (!isActive) return;
|
|
923
|
+
const name = String(key.name ?? "").toLowerCase();
|
|
924
|
+
const selected = workers[selectedIndex];
|
|
925
|
+
|
|
926
|
+
if (name === "up") {
|
|
927
|
+
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (name === "down") {
|
|
931
|
+
setSelectedIndex((i) => Math.min(Math.max(0, workers.length - 1), i + 1));
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
// Enter → worker detail
|
|
935
|
+
if (name === "return" || name === "enter") {
|
|
936
|
+
if (selected) {
|
|
937
|
+
useServiceStore.getState().selectWorker(selected.id);
|
|
938
|
+
useUIStore.getState().setView("worker-detail");
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
// d → deploy selected · r → restart selected (lowercase)
|
|
943
|
+
if (name === "d" && !key.shift) {
|
|
944
|
+
if (selected) void handleDeploy(selected);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (name === "r" && !key.shift) {
|
|
948
|
+
if (selected) void handleRestart(selected);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
// Shift+D / Shift+R → bulk (OpenTUI may report name "d" with shift)
|
|
952
|
+
if (name === "d" && key.shift) {
|
|
953
|
+
void handleDeployAll();
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (name === "r" && key.shift) {
|
|
957
|
+
void handleRestartAll();
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
|
|
869
961
|
// ── Render ──────────────────────────────────────────────────────────────
|
|
870
962
|
return (
|
|
871
963
|
<ErrorBoundary viewName="Service Manager">
|
|
@@ -909,6 +1001,8 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
|
|
|
909
1001
|
onDeploy={handleDeploy}
|
|
910
1002
|
onRestart={handleRestart}
|
|
911
1003
|
deployingWorker={deployingWorker}
|
|
1004
|
+
selectedIndex={selectedIndex}
|
|
1005
|
+
onSelect={setSelectedIndex}
|
|
912
1006
|
/>
|
|
913
1007
|
</Panel>
|
|
914
1008
|
|