@hoox-sh/hoox-tui 0.3.2 → 0.4.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/dist/main.js +1069 -2734
- package/package.json +3 -3
- package/src/app.tsx +15 -11
- package/src/components/shared/shared.test.tsx +2 -2
- package/src/components/ui/connection-toasts.ts +6 -0
- package/src/components/ui/dialog-state.ts +2 -0
- package/src/components/ui/dialog.tsx +0 -13
- package/src/components/views/ai-chat.tsx +6 -1
- package/src/components/views/config-editor/code-editor.tsx +8 -6
- package/src/components/views/config-editor.test.tsx +20 -17
- package/src/components/views/config-editor.tsx +8 -6
- package/src/components/views/dashboard/model-health-section.tsx +38 -46
- package/src/components/views/dashboard/pyne-health-section.tsx +36 -41
- package/src/components/views/dashboard.tsx +25 -28
- package/src/components/views/kv-viewer.test.tsx +4 -16
- package/src/components/views/kv-viewer.tsx +9 -13
- package/src/components/views/logs-viewer.test.tsx +4 -4
- package/src/components/views/queue-depth.test.tsx +5 -7
- package/src/components/views/queue-depth.tsx +10 -14
- package/src/components/views/secrets-viewer.test.tsx +1 -1
- package/src/components/views/secrets-viewer.tsx +11 -15
- package/src/components/views/service-manager.tsx +1 -0
- package/src/components/views/settings.test.tsx +2 -7
- package/src/components/views/settings.tsx +43 -60
- package/src/components/views/setup-wizard.test.tsx +12 -14
- package/src/components/views/setup-wizard.tsx +61 -59
- package/src/components/views/worker-detail.test.tsx +2 -2
- package/src/components/views/worker-settings.tsx +6 -6
- package/src/components/views/workers-overview.tsx +1 -1
- package/src/hooks/hooks.test.ts +10 -174
- package/src/hooks/index.ts +1 -3
- package/src/hooks/renderer-ref.ts +1 -0
- package/src/hooks/use-polling.ts +12 -0
- package/src/services/cli-bridge/index.ts +56 -12
- package/src/services/cli-bridge/standalone.ts +4 -1
- package/src/services/cli-bridge/types.ts +6 -9
- package/src/services/cli-bridge.test.ts +20 -4
- package/src/services/clipboard.ts +2 -2
- package/src/stores/service-store.test.ts +29 -26
- package/src/stores/session-restore.test.ts +2 -0
- package/src/test-setup.ts +5 -13
- package/src/test-utils.ts +4 -24
- package/src/types.ts +9 -47
- package/src/utils/colors.test.ts +8 -3
- package/src/view-registry.test.ts +2 -19
- package/src/view-registry.tsx +7 -20
- package/src/components/shared/animated-border.tsx +0 -58
- package/src/components/shared/keybinding-hint.tsx +0 -37
- package/src/components/ui/select.tsx +0 -49
- package/src/hooks/use-keyboard.ts +0 -130
- package/src/hooks/use-service-data.ts +0 -35
|
@@ -37,6 +37,7 @@ import { PyneHealthSection } from "./dashboard/pyne-health-section";
|
|
|
37
37
|
import { AlertsPanel } from "./dashboard/alerts-panel";
|
|
38
38
|
import { useUIStore } from "@hoox-sh/hoox-shared";
|
|
39
39
|
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
40
|
+
import { usePolling } from "../../hooks/use-polling";
|
|
40
41
|
|
|
41
42
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
|
42
43
|
|
|
@@ -71,42 +72,38 @@ function formatStatNumber(value: number, isPnl = false): string {
|
|
|
71
72
|
|
|
72
73
|
/**
|
|
73
74
|
* KillSwitchStatusBadge — Compact trade kill-switch indicator for the dashboard
|
|
74
|
-
* header. Auto-refreshes on
|
|
75
|
-
* color-coded status dot. Errors degrade
|
|
75
|
+
* header. Auto-refreshes on the Settings interval and shows the engaged/released
|
|
76
|
+
* state with a color-coded status dot. Errors degrade to UNKNOWN.
|
|
76
77
|
*/
|
|
77
|
-
/** How often the dashboard kill-switch badge re-probes (ms). */
|
|
78
|
-
const KILL_SWITCH_POLL_MS = 15_000;
|
|
79
|
-
|
|
80
78
|
function KillSwitchStatusBadge() {
|
|
81
79
|
const [state, setState] = useState<"engaged" | "released" | "unknown">(
|
|
82
80
|
"unknown"
|
|
83
81
|
);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
} catch {
|
|
96
|
-
if (!cancelled) setState("unknown");
|
|
82
|
+
const genRef = useRef(0);
|
|
83
|
+
|
|
84
|
+
const refresh = useCallback(async () => {
|
|
85
|
+
const gen = ++genRef.current;
|
|
86
|
+
try {
|
|
87
|
+
const result = await cliBridge.monitorKillSwitch("show");
|
|
88
|
+
if (gen !== genRef.current) return;
|
|
89
|
+
if (result.success && result.data) {
|
|
90
|
+
setState(result.data.engaged ? "engaged" : "released");
|
|
91
|
+
} else {
|
|
92
|
+
setState("unknown");
|
|
97
93
|
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const timer = setInterval(() => {
|
|
102
|
-
void refresh();
|
|
103
|
-
}, KILL_SWITCH_POLL_MS);
|
|
104
|
-
return () => {
|
|
105
|
-
cancelled = true;
|
|
106
|
-
clearInterval(timer);
|
|
107
|
-
};
|
|
94
|
+
} catch {
|
|
95
|
+
if (gen === genRef.current) setState("unknown");
|
|
96
|
+
}
|
|
108
97
|
}, []);
|
|
109
98
|
|
|
99
|
+
useEffect(
|
|
100
|
+
() => () => {
|
|
101
|
+
genRef.current += 1;
|
|
102
|
+
},
|
|
103
|
+
[]
|
|
104
|
+
);
|
|
105
|
+
usePolling({ callback: refresh, immediate: true });
|
|
106
|
+
|
|
110
107
|
const label =
|
|
111
108
|
state === "engaged"
|
|
112
109
|
? "KILL SWITCH ENGAGED"
|
|
@@ -23,9 +23,10 @@ import {
|
|
|
23
23
|
filterKvKeys,
|
|
24
24
|
sanitizeKvValue,
|
|
25
25
|
MAX_VISIBLE_KEYS,
|
|
26
|
-
REFRESH_INTERVAL_MS,
|
|
27
26
|
} from "./kv-viewer";
|
|
27
|
+
import { formatPollIntervalLabel } from "../../hooks/use-polling";
|
|
28
28
|
import { Colors, type ViewId } from "@hoox-sh/hoox-shared";
|
|
29
|
+
import { ALL_VIEWS } from "../../types";
|
|
29
30
|
import { useUIStore } from "@hoox-sh/hoox-shared/stores/ui-store";
|
|
30
31
|
import {
|
|
31
32
|
cliBridgeDouble,
|
|
@@ -124,8 +125,8 @@ describe("KvViewer", () => {
|
|
|
124
125
|
|
|
125
126
|
// ── Pattern contract (mirrors queue-depth subtask 02) ──────────────────
|
|
126
127
|
|
|
127
|
-
it("
|
|
128
|
-
expect(
|
|
128
|
+
it("labels configured poll intervals", () => {
|
|
129
|
+
expect(formatPollIntervalLabel(5_000)).toBe("5s auto");
|
|
129
130
|
});
|
|
130
131
|
|
|
131
132
|
it("filters keys case-insensitively by name", () => {
|
|
@@ -235,19 +236,6 @@ describe("KvViewer", () => {
|
|
|
235
236
|
});
|
|
236
237
|
|
|
237
238
|
it("is included in the local ALL_VIEWS list (tui/src/types.ts)", () => {
|
|
238
|
-
const ALL_VIEWS: string[] = [
|
|
239
|
-
"dashboard",
|
|
240
|
-
"workers",
|
|
241
|
-
"worker-detail",
|
|
242
|
-
"trade-monitor",
|
|
243
|
-
"logs-viewer",
|
|
244
|
-
"service-manager",
|
|
245
|
-
"config-editor",
|
|
246
|
-
"setup-wizard",
|
|
247
|
-
"settings",
|
|
248
|
-
"queue-depth",
|
|
249
|
-
"kv-viewer",
|
|
250
|
-
];
|
|
251
239
|
expect(ALL_VIEWS).toContain("kv-viewer");
|
|
252
240
|
// ViewId is derived from ALL_VIEWS so the local type must include it
|
|
253
241
|
// as well. The TypeScript type-check is the real assertion; this
|
|
@@ -49,16 +49,14 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
51
51
|
|
|
52
|
-
import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
|
|
52
|
+
import { Colors, useUIStore, useConfigStore } from "@hoox-sh/hoox-shared";
|
|
53
53
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
54
54
|
import { Spinner, EmptyState } from "../shared/spinner";
|
|
55
55
|
import { ViewHeader } from "../shared/view-header";
|
|
56
56
|
import { cliBridge } from "../../services/cli-bridge";
|
|
57
57
|
import type { KvKey, KvKeySnapshot } from "../../services/cli-bridge";
|
|
58
58
|
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
59
|
-
|
|
60
|
-
/** Auto-refresh interval in milliseconds. */
|
|
61
|
-
export const REFRESH_INTERVAL_MS = 5_000;
|
|
59
|
+
import { usePolling, formatPollIntervalLabel } from "../../hooks/use-polling";
|
|
62
60
|
|
|
63
61
|
/** Cap rendered rows so large namespaces stay responsive. */
|
|
64
62
|
export const MAX_VISIBLE_KEYS = 200;
|
|
@@ -427,14 +425,12 @@ export function KvViewer() {
|
|
|
427
425
|
};
|
|
428
426
|
}, [refresh]);
|
|
429
427
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
return () => clearInterval(handle);
|
|
437
|
-
}, [isActive, refresh]);
|
|
428
|
+
const refreshIntervalMs = useConfigStore((s) => s.refreshIntervalMs);
|
|
429
|
+
usePolling({
|
|
430
|
+
callback: () => refresh({ soft: true }),
|
|
431
|
+
enabled: isActive,
|
|
432
|
+
immediate: false,
|
|
433
|
+
});
|
|
438
434
|
|
|
439
435
|
// ── Derived data ─────────────────────────────────────────────────────
|
|
440
436
|
const allKeys = snapshot?.keys ?? [];
|
|
@@ -558,7 +554,7 @@ export function KvViewer() {
|
|
|
558
554
|
⚠ read-only
|
|
559
555
|
</text>
|
|
560
556
|
<text fg={Colors.info} dim>
|
|
561
|
-
{`◉ ${
|
|
557
|
+
{`◉ ${formatPollIntervalLabel(refreshIntervalMs)}`}
|
|
562
558
|
</text>
|
|
563
559
|
</box>
|
|
564
560
|
}
|
|
@@ -113,7 +113,7 @@ describe("LogsViewer filter logic", () => {
|
|
|
113
113
|
});
|
|
114
114
|
// Entries with workerId=w1 (alpha-worker) + entries with no workerId
|
|
115
115
|
expect(result).toHaveLength(1); // only id=5 has workerId=w1
|
|
116
|
-
expect(result[0]
|
|
116
|
+
expect(result[0]?.id).toBe("5");
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
it("filters by text search (case-insensitive)", () => {
|
|
@@ -125,7 +125,7 @@ describe("LogsViewer filter logic", () => {
|
|
|
125
125
|
workers,
|
|
126
126
|
});
|
|
127
127
|
expect(result).toHaveLength(1);
|
|
128
|
-
expect(result[0]
|
|
128
|
+
expect(result[0]?.message).toBe("Trade executed");
|
|
129
129
|
});
|
|
130
130
|
|
|
131
131
|
it("combines filters with AND logic", () => {
|
|
@@ -138,7 +138,7 @@ describe("LogsViewer filter logic", () => {
|
|
|
138
138
|
});
|
|
139
139
|
// Must be error OR info, alpha-worker, contains "auth"
|
|
140
140
|
expect(result).toHaveLength(1);
|
|
141
|
-
expect(result[0]
|
|
141
|
+
expect(result[0]?.id).toBe("5"); // error + w1 + "Auth failure"
|
|
142
142
|
});
|
|
143
143
|
|
|
144
144
|
it("handles empty text search as pass-through", () => {
|
|
@@ -263,7 +263,7 @@ describe("LogsViewer sanitization & caps", () => {
|
|
|
263
263
|
workers: [],
|
|
264
264
|
});
|
|
265
265
|
expect(result).toHaveLength(1);
|
|
266
|
-
expect(result[0]
|
|
266
|
+
expect(result[0]?.id).toBe("1");
|
|
267
267
|
});
|
|
268
268
|
|
|
269
269
|
it("caps visible render window below store ring buffer (1000)", () => {
|
|
@@ -17,11 +17,8 @@
|
|
|
17
17
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
18
18
|
import { createRoot } from "@opentui/react";
|
|
19
19
|
import { createCliRenderer } from "@opentui/core";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
sortQueuesByPressure,
|
|
23
|
-
REFRESH_INTERVAL_MS,
|
|
24
|
-
} from "./queue-depth";
|
|
20
|
+
import { QueueDepthView, sortQueuesByPressure } from "./queue-depth";
|
|
21
|
+
import { formatPollIntervalLabel } from "../../hooks/use-polling";
|
|
25
22
|
import { Colors } from "@hoox-sh/hoox-shared";
|
|
26
23
|
import { useUIStore } from "@hoox-sh/hoox-shared/stores/ui-store";
|
|
27
24
|
import {
|
|
@@ -126,8 +123,9 @@ describe("QueueDepthView", () => {
|
|
|
126
123
|
|
|
127
124
|
// ── Refresh interval contract ─────────────────────────────────────────
|
|
128
125
|
|
|
129
|
-
it("
|
|
130
|
-
expect(
|
|
126
|
+
it("labels configured poll intervals", () => {
|
|
127
|
+
expect(formatPollIntervalLabel(5_000)).toBe("5s auto");
|
|
128
|
+
expect(formatPollIntervalLabel(500)).toBe("500ms auto");
|
|
131
129
|
});
|
|
132
130
|
|
|
133
131
|
it("sorts queues by pressure: critical → backlogged → paused → healthy", () => {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* Pattern established for the TUI feature-parity batch:
|
|
24
24
|
* - Pure function component, no props required
|
|
25
25
|
* - Subscribes to useUIStore (so auto-refresh can pause when not active)
|
|
26
|
-
* - Calls cliBridge.monitorQueueDepth() on mount +
|
|
26
|
+
* - Calls cliBridge.monitorQueueDepth() on mount + config refreshIntervalMs while active
|
|
27
27
|
* - Wraps content in <ErrorBoundary viewName="Queue Depth">
|
|
28
28
|
* - Renders an empty/error state without throwing
|
|
29
29
|
*
|
|
@@ -44,9 +44,8 @@ import { Panel } from "../shared/panel";
|
|
|
44
44
|
import { cliBridge } from "../../services/cli-bridge";
|
|
45
45
|
import type { QueueDepth, QueueDepthStatus } from "../../services/cli-bridge";
|
|
46
46
|
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
export const REFRESH_INTERVAL_MS = 5_000;
|
|
47
|
+
import { usePolling, formatPollIntervalLabel } from "../../hooks/use-polling";
|
|
48
|
+
import { useConfigStore } from "@hoox-sh/hoox-shared";
|
|
50
49
|
|
|
51
50
|
/** Status severity for sort order (critical first). */
|
|
52
51
|
const STATUS_RANK: Record<QueueDepthStatus, number> = {
|
|
@@ -255,15 +254,12 @@ export function QueueDepthView() {
|
|
|
255
254
|
};
|
|
256
255
|
}, [refresh]);
|
|
257
256
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}, REFRESH_INTERVAL_MS);
|
|
265
|
-
return () => clearInterval(handle);
|
|
266
|
-
}, [isActive, refresh]);
|
|
257
|
+
const refreshIntervalMs = useConfigStore((s) => s.refreshIntervalMs);
|
|
258
|
+
usePolling({
|
|
259
|
+
callback: () => refresh({ soft: true }),
|
|
260
|
+
enabled: isActive,
|
|
261
|
+
immediate: false,
|
|
262
|
+
});
|
|
267
263
|
|
|
268
264
|
// Keyboard: r / F5 manual refresh while active
|
|
269
265
|
useViewKeyboard((key) => {
|
|
@@ -291,7 +287,7 @@ export function QueueDepthView() {
|
|
|
291
287
|
{queues.length} queue{queues.length === 1 ? "" : "s"}
|
|
292
288
|
</text>
|
|
293
289
|
<text fg={Colors.info} dim>
|
|
294
|
-
◉ {
|
|
290
|
+
◉ {formatPollIntervalLabel(refreshIntervalMs)}
|
|
295
291
|
</text>
|
|
296
292
|
</box>
|
|
297
293
|
}
|
|
@@ -53,7 +53,7 @@ describe("SecretsViewer", () => {
|
|
|
53
53
|
it("filters by name only (values never considered)", () => {
|
|
54
54
|
const secrets: SecretMetadata[] = [
|
|
55
55
|
{ name: "OPENAI_API_KEY", type: "api_key", source: "config" },
|
|
56
|
-
{ name: "
|
|
56
|
+
{ name: "EXCHANGE_KEY_BINDING", type: "api_key", source: "Cloudflare" },
|
|
57
57
|
];
|
|
58
58
|
expect(filterSecrets(secrets, "openai")).toHaveLength(1);
|
|
59
59
|
expect(filterSecrets(secrets, "")).toHaveLength(2);
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* │ [search…] Last sampled 12:34:56 │
|
|
18
18
|
* │───────────────────────────────────────────────────────────────
|
|
19
19
|
* │ SECRET NAME TYPE SOURCE STATUS │
|
|
20
|
-
* │
|
|
20
|
+
* │ EXCHANGE_KEY_BINDING api_key config OK │
|
|
21
21
|
* │ OPENAI_API_KEY api_key config OK │
|
|
22
22
|
* │ TELEGRAM_BOT_TOKEN token config OK │
|
|
23
23
|
* │ … │
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
*/
|
|
44
44
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
45
45
|
|
|
46
|
-
import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
|
|
46
|
+
import { Colors, useUIStore, useConfigStore } from "@hoox-sh/hoox-shared";
|
|
47
47
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
48
48
|
import { Spinner, EmptyState } from "../shared/spinner";
|
|
49
49
|
import { ViewHeader } from "../shared/view-header";
|
|
@@ -53,9 +53,7 @@ import type {
|
|
|
53
53
|
SecretsSnapshot,
|
|
54
54
|
} from "../../services/cli-bridge";
|
|
55
55
|
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
56
|
-
|
|
57
|
-
/** Auto-refresh interval in milliseconds. */
|
|
58
|
-
export const REFRESH_INTERVAL_MS = 5_000;
|
|
56
|
+
import { usePolling, formatPollIntervalLabel } from "../../hooks/use-polling";
|
|
59
57
|
|
|
60
58
|
/** Cap rendered secret rows for large worker graphs. */
|
|
61
59
|
export const MAX_VISIBLE_SECRETS = 200;
|
|
@@ -117,7 +115,7 @@ function SecretRow({ secret, isSelected, onSelect }: SecretRowProps) {
|
|
|
117
115
|
onMouseUp={() => onSelect(secret.name)}
|
|
118
116
|
>
|
|
119
117
|
<text fg={fg} bold={isSelected}>
|
|
120
|
-
{secret.name}
|
|
118
|
+
{secret.worker ? `${secret.worker}/${secret.name}` : secret.name}
|
|
121
119
|
</text>
|
|
122
120
|
<text fg={Colors.muted}>{formatType(secret.type).padStart(8, " ")}</text>
|
|
123
121
|
<text fg={sourceColor(secret.source)} dim>
|
|
@@ -266,14 +264,12 @@ export function SecretsViewer() {
|
|
|
266
264
|
};
|
|
267
265
|
}, [refresh]);
|
|
268
266
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return () => clearInterval(handle);
|
|
276
|
-
}, [isActive, refresh]);
|
|
267
|
+
const refreshIntervalMs = useConfigStore((s) => s.refreshIntervalMs);
|
|
268
|
+
usePolling({
|
|
269
|
+
callback: () => refresh({ soft: true }),
|
|
270
|
+
enabled: isActive,
|
|
271
|
+
immediate: false,
|
|
272
|
+
});
|
|
277
273
|
|
|
278
274
|
// ── Derived data ───────────────────────────────────────────────────────────
|
|
279
275
|
const allSecrets = snapshot?.secrets ?? [];
|
|
@@ -349,7 +345,7 @@ export function SecretsViewer() {
|
|
|
349
345
|
⚠ READ-ONLY
|
|
350
346
|
</text>
|
|
351
347
|
<text fg={Colors.info} dim>
|
|
352
|
-
{`◉ ${
|
|
348
|
+
{`◉ ${formatPollIntervalLabel(refreshIntervalMs)}`}
|
|
353
349
|
</text>
|
|
354
350
|
</box>
|
|
355
351
|
}
|
|
@@ -939,6 +939,7 @@ export function ServiceManager({ dialog }: ServiceManagerProps) {
|
|
|
939
939
|
}
|
|
940
940
|
return;
|
|
941
941
|
}
|
|
942
|
+
if (key.ctrl || key.alt || key.meta) return;
|
|
942
943
|
// d → deploy selected · r → restart selected (lowercase)
|
|
943
944
|
if (name === "d" && !key.shift) {
|
|
944
945
|
if (selected) void handleDeploy(selected);
|
|
@@ -171,13 +171,9 @@ async function renderSettingsWithCheck(
|
|
|
171
171
|
width = 140,
|
|
172
172
|
height = 60
|
|
173
173
|
): Promise<string> {
|
|
174
|
-
// Tell the view to auto-run the check on mount via a useEffect.
|
|
175
|
-
(
|
|
176
|
-
globalThis as unknown as { __hooxTestAutoCheckSetup?: boolean }
|
|
177
|
-
).__hooxTestAutoCheckSetup = true;
|
|
178
174
|
try {
|
|
179
175
|
const { captureCharFrame, renderOnce } = await testRender(
|
|
180
|
-
<SettingsView />,
|
|
176
|
+
<SettingsView autoCheckSetup />,
|
|
181
177
|
{
|
|
182
178
|
width,
|
|
183
179
|
height,
|
|
@@ -209,8 +205,7 @@ async function renderSettingsWithCheck(
|
|
|
209
205
|
// Fallback - return whatever we have
|
|
210
206
|
return captureCharFrame();
|
|
211
207
|
} finally {
|
|
212
|
-
|
|
213
|
-
.__hooxTestAutoCheckSetup;
|
|
208
|
+
/* autoCheckSetup is a render prop; nothing to clean up */
|
|
214
209
|
}
|
|
215
210
|
}
|
|
216
211
|
|
|
@@ -37,24 +37,22 @@ import { cliBridge as realCliBridge } from "../../services/cli-bridge";
|
|
|
37
37
|
import { getSettingsConnectionSnapshot } from "../../services/tui-connection";
|
|
38
38
|
import * as path from "path";
|
|
39
39
|
import * as os from "os";
|
|
40
|
-
import
|
|
40
|
+
import {
|
|
41
|
+
VIEW_IDS,
|
|
42
|
+
type ViewId,
|
|
43
|
+
type NotificationPreferences,
|
|
44
|
+
} from "@hoox-sh/hoox-shared";
|
|
41
45
|
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
42
46
|
|
|
43
|
-
/**
|
|
44
|
-
* Prefer the process-wide CLI bridge test double when test-setup installed it
|
|
45
|
-
* (`globalThis.__hooxCliBridgeDouble`). Resolved at call time so the double is
|
|
46
|
-
* picked up even if this module evaluated before the preload finished.
|
|
47
|
-
*/
|
|
48
47
|
function cliBridge(): typeof realCliBridge {
|
|
49
|
-
|
|
50
|
-
__hooxCliBridgeDouble?: typeof realCliBridge;
|
|
51
|
-
};
|
|
52
|
-
return g.__hooxCliBridgeDouble ?? realCliBridge;
|
|
48
|
+
return realCliBridge;
|
|
53
49
|
}
|
|
54
50
|
|
|
55
51
|
export interface SettingsViewProps {
|
|
56
52
|
/** Dialog handle for destructive action confirmation. */
|
|
57
53
|
dialog?: DialogHandle;
|
|
54
|
+
/** Test-only: run Check Setup once on mount. */
|
|
55
|
+
autoCheckSetup?: boolean;
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
// ─── Check Setup Types ────────────────────────────────────────────────────────
|
|
@@ -172,26 +170,6 @@ function formatRefreshRate(ms: number): string {
|
|
|
172
170
|
return `${ms}ms`;
|
|
173
171
|
}
|
|
174
172
|
|
|
175
|
-
/** ViewId options for the default view selector */
|
|
176
|
-
const VIEW_OPTIONS: ViewId[] = [
|
|
177
|
-
"dashboard",
|
|
178
|
-
"workers",
|
|
179
|
-
"worker-detail",
|
|
180
|
-
"trade-monitor",
|
|
181
|
-
"logs-viewer",
|
|
182
|
-
"service-manager",
|
|
183
|
-
"config-editor",
|
|
184
|
-
"setup-wizard",
|
|
185
|
-
"settings",
|
|
186
|
-
"queue-depth",
|
|
187
|
-
"kv-viewer",
|
|
188
|
-
"secrets-viewer",
|
|
189
|
-
"db-query",
|
|
190
|
-
"ai-chat",
|
|
191
|
-
"edge-topology",
|
|
192
|
-
"worker-settings",
|
|
193
|
-
];
|
|
194
|
-
|
|
195
173
|
/** Human-readable labels for ViewId values */
|
|
196
174
|
const VIEW_LABELS: Record<ViewId, string> = {
|
|
197
175
|
dashboard: "Dashboard",
|
|
@@ -846,7 +824,10 @@ function CheckSetupResultsPanel({
|
|
|
846
824
|
*
|
|
847
825
|
* Wrapped in ErrorBoundary for crash recovery.
|
|
848
826
|
*/
|
|
849
|
-
export function SettingsView({
|
|
827
|
+
export function SettingsView({
|
|
828
|
+
dialog,
|
|
829
|
+
autoCheckSetup = false,
|
|
830
|
+
}: SettingsViewProps = {}) {
|
|
850
831
|
const activeView = useUIStore((s) => s.activeView);
|
|
851
832
|
const isActive = activeView === "settings";
|
|
852
833
|
|
|
@@ -909,7 +890,18 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
|
|
|
909
890
|
try {
|
|
910
891
|
const cachePath = path.join(os.homedir(), ".hoox", "cache");
|
|
911
892
|
const proc = Bun.spawn(["rm", "-rf", cachePath]);
|
|
912
|
-
await proc.exited;
|
|
893
|
+
const code = await proc.exited;
|
|
894
|
+
if (code !== 0) {
|
|
895
|
+
useServiceStore.getState().addAlert({
|
|
896
|
+
id: `cache-err-${Date.now()}`,
|
|
897
|
+
type: "config",
|
|
898
|
+
severity: "error",
|
|
899
|
+
message: `Cache clear failed (exit ${code})`,
|
|
900
|
+
timestamp: Date.now(),
|
|
901
|
+
acknowledged: false,
|
|
902
|
+
});
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
913
905
|
useServiceStore.getState().addAlert({
|
|
914
906
|
id: `cache-${Date.now()}`,
|
|
915
907
|
type: "config",
|
|
@@ -1259,22 +1251,12 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
|
|
|
1259
1251
|
});
|
|
1260
1252
|
}, [confirmAction, resetDefaults]);
|
|
1261
1253
|
|
|
1262
|
-
/**
|
|
1263
|
-
* Test hook: when `globalThis.__hooxTestAutoCheckSetup` is set, the
|
|
1264
|
-
* Check Setup check runs automatically once on mount. This lets the
|
|
1265
|
-
* test suite populate the results panel without simulating mouse/keyboard
|
|
1266
|
-
* events. Production code never sets this flag.
|
|
1267
|
-
*/
|
|
1268
1254
|
useEffect(() => {
|
|
1269
|
-
|
|
1270
|
-
__hooxTestAutoCheckSetup?: boolean;
|
|
1271
|
-
};
|
|
1272
|
-
if (g.__hooxTestAutoCheckSetup) {
|
|
1255
|
+
if (autoCheckSetup) {
|
|
1273
1256
|
void handleCheckSetup();
|
|
1274
1257
|
}
|
|
1275
|
-
//
|
|
1276
|
-
|
|
1277
|
-
}, []);
|
|
1258
|
+
// Mount-only: tests pass autoCheckSetup then render once.
|
|
1259
|
+
}, [autoCheckSetup]);
|
|
1278
1260
|
|
|
1279
1261
|
/** Max items per panel */
|
|
1280
1262
|
const maxItemsPerPanel = useMemo((): Record<number, number> => {
|
|
@@ -1293,7 +1275,7 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
|
|
|
1293
1275
|
// Import mode overrides
|
|
1294
1276
|
if (importing) {
|
|
1295
1277
|
if (key.name === "return" || key.name === "enter") {
|
|
1296
|
-
handleImportConfirm();
|
|
1278
|
+
void handleImportConfirm();
|
|
1297
1279
|
return;
|
|
1298
1280
|
}
|
|
1299
1281
|
if (key.name === "escape") {
|
|
@@ -1310,9 +1292,11 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
|
|
|
1310
1292
|
return;
|
|
1311
1293
|
}
|
|
1312
1294
|
|
|
1313
|
-
// Tab: cycle panels
|
|
1295
|
+
// Tab / Shift+Tab: cycle panels
|
|
1314
1296
|
if (key.name === "tab") {
|
|
1315
|
-
setActivePanel((p) =>
|
|
1297
|
+
setActivePanel((p) =>
|
|
1298
|
+
key.shift ? (p - 1 + PANEL_COUNT) % PANEL_COUNT : (p + 1) % PANEL_COUNT
|
|
1299
|
+
);
|
|
1316
1300
|
setActiveItem(0);
|
|
1317
1301
|
return;
|
|
1318
1302
|
}
|
|
@@ -1351,14 +1335,13 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
|
|
|
1351
1335
|
}
|
|
1352
1336
|
case 1: {
|
|
1353
1337
|
// Default view
|
|
1354
|
-
const currentIdx =
|
|
1338
|
+
const currentIdx = VIEW_IDS.indexOf(defaultView);
|
|
1355
1339
|
if (key.name === "left") {
|
|
1356
|
-
const prev =
|
|
1357
|
-
|
|
1358
|
-
updateConfig({ defaultView: VIEW_OPTIONS[prev] });
|
|
1340
|
+
const prev = (currentIdx - 1 + VIEW_IDS.length) % VIEW_IDS.length;
|
|
1341
|
+
updateConfig({ defaultView: VIEW_IDS[prev] });
|
|
1359
1342
|
} else if (key.name === "right") {
|
|
1360
|
-
const next = (currentIdx + 1) %
|
|
1361
|
-
updateConfig({ defaultView:
|
|
1343
|
+
const next = (currentIdx + 1) % VIEW_IDS.length;
|
|
1344
|
+
updateConfig({ defaultView: VIEW_IDS[next] });
|
|
1362
1345
|
}
|
|
1363
1346
|
break;
|
|
1364
1347
|
}
|
|
@@ -1375,7 +1358,7 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
|
|
|
1375
1358
|
if (activeItem >= 0 && activeItem < 4) {
|
|
1376
1359
|
if (key.name === "space") {
|
|
1377
1360
|
const channel = NOTIFICATION_CHANNELS[activeItem];
|
|
1378
|
-
toggleNotification(channel);
|
|
1361
|
+
if (channel !== undefined) toggleNotification(channel);
|
|
1379
1362
|
}
|
|
1380
1363
|
} else if (activeItem === 4) {
|
|
1381
1364
|
if (key.name === "space")
|
|
@@ -1394,19 +1377,19 @@ export function SettingsView({ dialog }: SettingsViewProps = {}) {
|
|
|
1394
1377
|
if (key.name === "space" || key.name === "return") {
|
|
1395
1378
|
switch (activeItem) {
|
|
1396
1379
|
case 0:
|
|
1397
|
-
handleClearCache();
|
|
1380
|
+
void handleClearCache();
|
|
1398
1381
|
break;
|
|
1399
1382
|
case 1:
|
|
1400
|
-
handleExportData();
|
|
1383
|
+
void handleExportData();
|
|
1401
1384
|
break;
|
|
1402
1385
|
case 2:
|
|
1403
|
-
handleImportData();
|
|
1386
|
+
void handleImportData();
|
|
1404
1387
|
break;
|
|
1405
1388
|
case 3:
|
|
1406
|
-
handleCheckSetup();
|
|
1389
|
+
void handleCheckSetup();
|
|
1407
1390
|
break;
|
|
1408
1391
|
case 4:
|
|
1409
|
-
handleRunAutoRepair();
|
|
1392
|
+
void handleRunAutoRepair();
|
|
1410
1393
|
break;
|
|
1411
1394
|
}
|
|
1412
1395
|
}
|
|
@@ -32,9 +32,10 @@ describe("redactWizardSecrets", () => {
|
|
|
32
32
|
it("clears exchange keys, AI key, telegram token, and discord webhook", () => {
|
|
33
33
|
const redacted = redactWizardSecrets({
|
|
34
34
|
apiKeys: {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
exchange: {
|
|
36
|
+
key: "exchange-key-secret-value",
|
|
37
|
+
secret: "exchange-sec",
|
|
38
|
+
},
|
|
38
39
|
},
|
|
39
40
|
exchanges: { binance: true, bybit: false, mexc: false },
|
|
40
41
|
ai: {
|
|
@@ -57,9 +58,7 @@ describe("redactWizardSecrets", () => {
|
|
|
57
58
|
},
|
|
58
59
|
});
|
|
59
60
|
|
|
60
|
-
expect(redacted.apiKeys.
|
|
61
|
-
expect(redacted.apiKeys.bybit).toEqual({ key: "", secret: "" });
|
|
62
|
-
expect(redacted.apiKeys.mexc).toEqual({ key: "", secret: "" });
|
|
61
|
+
expect(redacted.apiKeys.exchange).toEqual({ key: "", secret: "" });
|
|
63
62
|
expect(redacted.ai.apiKey).toBe("");
|
|
64
63
|
expect(redacted.ai.providerUrl).toBe("https://api.example.com");
|
|
65
64
|
expect(redacted.ai.model).toBe("gpt-4");
|
|
@@ -74,9 +73,7 @@ describe("redactWizardSecrets", () => {
|
|
|
74
73
|
it("does not mutate the input object", () => {
|
|
75
74
|
const original = {
|
|
76
75
|
apiKeys: {
|
|
77
|
-
|
|
78
|
-
bybit: { key: "", secret: "" },
|
|
79
|
-
mexc: { key: "", secret: "" },
|
|
76
|
+
exchange: { key: "keep-me", secret: "keep-me-too" },
|
|
80
77
|
},
|
|
81
78
|
exchanges: { binance: true, bybit: false, mexc: false },
|
|
82
79
|
ai: { providerUrl: "", apiKey: "secret", model: "default" },
|
|
@@ -88,7 +85,7 @@ describe("redactWizardSecrets", () => {
|
|
|
88
85
|
},
|
|
89
86
|
};
|
|
90
87
|
redactWizardSecrets(original);
|
|
91
|
-
expect(original.apiKeys.
|
|
88
|
+
expect(original.apiKeys.exchange.key).toBe("keep-me");
|
|
92
89
|
expect(original.ai.apiKey).toBe("secret");
|
|
93
90
|
expect(original.notifications.telegram.botToken).toBe("tok");
|
|
94
91
|
});
|
|
@@ -96,9 +93,10 @@ describe("redactWizardSecrets", () => {
|
|
|
96
93
|
it("session payload is safe to JSON.stringify (no secrets)", () => {
|
|
97
94
|
const redacted = redactWizardSecrets({
|
|
98
95
|
apiKeys: {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
exchange: {
|
|
97
|
+
key: "exchange-key-secret-value",
|
|
98
|
+
secret: "exchange-sec",
|
|
99
|
+
},
|
|
102
100
|
},
|
|
103
101
|
exchanges: { binance: true, bybit: false, mexc: false },
|
|
104
102
|
ai: {
|
|
@@ -118,7 +116,7 @@ describe("redactWizardSecrets", () => {
|
|
|
118
116
|
});
|
|
119
117
|
const json = JSON.stringify({ step: 2, data: redacted });
|
|
120
118
|
expect(json).not.toContain("sk-live");
|
|
121
|
-
expect(json).not.toContain("
|
|
119
|
+
expect(json).not.toContain("exchange-key");
|
|
122
120
|
expect(json).not.toContain("123:ABC");
|
|
123
121
|
expect(json).not.toContain("webhooks/x");
|
|
124
122
|
});
|