@hoox-sh/hoox-tui 0.3.1 → 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 +1400 -2764
- package/package.json +3 -8
- package/src/app.tsx +17 -15
- 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 +17 -0
- package/src/components/ui/dialog.tsx +434 -172
- 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.test.tsx +8 -27
- 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/shell-overlay.ts +3 -2
- 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 -197
- package/src/hooks/use-keyboard.ts +0 -130
- package/src/hooks/use-service-data.ts +0 -35
|
@@ -159,7 +159,7 @@ export function tokenizeTomlLine(line: string): TokenSpan[] {
|
|
|
159
159
|
// ─── Syntax Highlighting — JSON ───────────────────────────────────────────────
|
|
160
160
|
|
|
161
161
|
/** Color map for JSON token types. */
|
|
162
|
-
const JSON_COLORS
|
|
162
|
+
const JSON_COLORS = {
|
|
163
163
|
brace: { fg: Colors.foreground, bold: false },
|
|
164
164
|
key: { fg: Colors.accent, bold: false },
|
|
165
165
|
colon: { fg: Colors.foreground, bold: false },
|
|
@@ -171,7 +171,7 @@ const JSON_COLORS: Record<string, { fg: string; bold: boolean }> = {
|
|
|
171
171
|
whitespace: { fg: Colors.foreground, bold: false },
|
|
172
172
|
error: { fg: Colors.error, bold: true },
|
|
173
173
|
comment: { fg: Colors.muted, bold: false },
|
|
174
|
-
};
|
|
174
|
+
} as const;
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
177
|
* Tokenize a single line of JSON text into colored spans.
|
|
@@ -202,8 +202,9 @@ export function tokenizeJsonLine(line: string): TokenSpan[] {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
// Braces/brackets at start
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
const firstChar = trimmed[0];
|
|
206
|
+
if (firstChar !== undefined && "{]}[".includes(firstChar)) {
|
|
207
|
+
spans.push({ text: firstChar, color: JSON_COLORS.brace.fg, bold: true });
|
|
207
208
|
const rest = tokenizeJsonLine(trimmed.slice(1));
|
|
208
209
|
spans.push(...rest);
|
|
209
210
|
return spans;
|
|
@@ -218,6 +219,7 @@ export function tokenizeJsonLine(line: string): TokenSpan[] {
|
|
|
218
219
|
trimmed.endsWith("]")
|
|
219
220
|
) {
|
|
220
221
|
const last = trimmed[trimmed.length - 1];
|
|
222
|
+
if (last === undefined) return spans;
|
|
221
223
|
const prefix = trimmed.slice(0, -1);
|
|
222
224
|
const prefixSpans = tokenizeJsonLine(prefix);
|
|
223
225
|
spans.push(...prefixSpans);
|
|
@@ -400,8 +402,8 @@ export function validateSyntax(
|
|
|
400
402
|
const colMatch =
|
|
401
403
|
msg.match(/column\s+(\d+)/i) ?? msg.match(/position\s+(\d+)/i);
|
|
402
404
|
errors.push({
|
|
403
|
-
line: lineMatch ? parseInt(lineMatch[1]) : 1,
|
|
404
|
-
column: colMatch ? parseInt(colMatch[1]) : 0,
|
|
405
|
+
line: lineMatch?.[1] !== undefined ? parseInt(lineMatch[1], 10) : 1,
|
|
406
|
+
column: colMatch?.[1] !== undefined ? parseInt(colMatch[1], 10) : 0,
|
|
405
407
|
message: msg,
|
|
406
408
|
});
|
|
407
409
|
}
|
|
@@ -101,22 +101,25 @@ describe("ConfigEditor", () => {
|
|
|
101
101
|
describe("CONFIG_TREE_BLUEPRINT", () => {
|
|
102
102
|
it("has config directory as root", async () => {
|
|
103
103
|
expect(CONFIG_TREE_BLUEPRINT.length).toBeGreaterThan(0);
|
|
104
|
-
expect(CONFIG_TREE_BLUEPRINT[0]
|
|
105
|
-
expect(CONFIG_TREE_BLUEPRINT[0]
|
|
104
|
+
expect(CONFIG_TREE_BLUEPRINT[0]?.name).toBe("config");
|
|
105
|
+
expect(CONFIG_TREE_BLUEPRINT[0]?.type).toBe("directory");
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
it("contains config files without secret-like .env entries", async () => {
|
|
109
|
-
const
|
|
109
|
+
const root = CONFIG_TREE_BLUEPRINT[0];
|
|
110
|
+
expect(root?.children).toBeDefined();
|
|
111
|
+
const children = root!.children!;
|
|
110
112
|
// wrangler.toml, trade.config.json, risk.config.json, strategies (no .env)
|
|
111
113
|
expect(children.length).toBe(4);
|
|
112
114
|
expect(children.some((c) => c.name === ".env")).toBe(false);
|
|
113
115
|
});
|
|
114
116
|
|
|
115
117
|
it("strategies directory has 3 config files", async () => {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
);
|
|
118
|
+
const root = CONFIG_TREE_BLUEPRINT[0];
|
|
119
|
+
expect(root?.children).toBeDefined();
|
|
120
|
+
const strategies = root!.children!.find((c) => c.name === "strategies");
|
|
119
121
|
expect(strategies).toBeDefined();
|
|
122
|
+
expect(strategies!.children).toBeDefined();
|
|
120
123
|
expect(strategies!.children!.length).toBe(3);
|
|
121
124
|
const names = strategies!.children!.map((c) => c.name);
|
|
122
125
|
expect(names).toContain("grid.config.json");
|
|
@@ -139,10 +142,10 @@ describe("ConfigEditor", () => {
|
|
|
139
142
|
];
|
|
140
143
|
const flat = flattenTree(nodes);
|
|
141
144
|
expect(flat.length).toBe(2); // directory + file
|
|
142
|
-
expect(flat[0]
|
|
143
|
-
expect(flat[0]
|
|
144
|
-
expect(flat[1]
|
|
145
|
-
expect(flat[1]
|
|
145
|
+
expect(flat[0]?.node.name).toBe("dir");
|
|
146
|
+
expect(flat[0]?.level).toBe(0);
|
|
147
|
+
expect(flat[1]?.node.name).toBe("file.txt");
|
|
148
|
+
expect(flat[1]?.level).toBe(1);
|
|
146
149
|
});
|
|
147
150
|
|
|
148
151
|
it("returns empty array for empty input", async () => {
|
|
@@ -180,8 +183,8 @@ describe("ConfigEditor", () => {
|
|
|
180
183
|
it("highlights full-line comments as muted", async () => {
|
|
181
184
|
const tokens = tokenizeTomlLine("# this is a comment");
|
|
182
185
|
expect(tokens.length).toBeGreaterThan(0);
|
|
183
|
-
expect(tokens[0]
|
|
184
|
-
expect(tokens[0]
|
|
186
|
+
expect(tokens[0]?.text).toContain("#");
|
|
187
|
+
expect(tokens[0]?.color).toBe(Colors.muted);
|
|
185
188
|
});
|
|
186
189
|
|
|
187
190
|
it("highlights section headers as accent bold", async () => {
|
|
@@ -389,8 +392,8 @@ describe("ConfigEditor", () => {
|
|
|
389
392
|
// line 3 (unclosed string); the parser may point to the same
|
|
390
393
|
// line or to the first line where it becomes unrecoverable.
|
|
391
394
|
// Either way, the error must NOT be on a known-valid line.
|
|
392
|
-
expect(errors[0]
|
|
393
|
-
expect(errors[0]
|
|
395
|
+
expect(errors[0]?.line).toBeGreaterThanOrEqual(3);
|
|
396
|
+
expect(errors[0]?.line).toBeLessThanOrEqual(4);
|
|
394
397
|
});
|
|
395
398
|
|
|
396
399
|
it("reports 1-based column for invalid TOML", async () => {
|
|
@@ -398,16 +401,16 @@ describe("ConfigEditor", () => {
|
|
|
398
401
|
const errors = validateSyntax(toml, "toml");
|
|
399
402
|
expect(errors.length).toBeGreaterThan(0);
|
|
400
403
|
// Column must be at least 1 (1-based) for a useful error marker.
|
|
401
|
-
expect(errors[0]
|
|
404
|
+
expect(errors[0]?.column).toBeGreaterThanOrEqual(1);
|
|
402
405
|
});
|
|
403
406
|
|
|
404
407
|
it("includes the parser's error message verbatim", async () => {
|
|
405
408
|
const toml = 'key = = "invalid"\n';
|
|
406
409
|
const errors = validateSyntax(toml, "toml");
|
|
407
410
|
expect(errors.length).toBeGreaterThan(0);
|
|
408
|
-
expect(errors[0]
|
|
411
|
+
expect(errors[0]?.message.length).toBeGreaterThan(0);
|
|
409
412
|
// Must not be the old generic "Unbalanced brackets" message.
|
|
410
|
-
expect(errors[0]
|
|
413
|
+
expect(errors[0]?.message).not.toMatch(/unbalanced/i);
|
|
411
414
|
});
|
|
412
415
|
|
|
413
416
|
it("returns empty errors for empty TOML", async () => {
|
|
@@ -544,20 +544,22 @@ export function ConfigEditor() {
|
|
|
544
544
|
const handleUndo = useCallback(() => {
|
|
545
545
|
if (undoStack.length === 0 || !selectedFile) return;
|
|
546
546
|
const prev = undoStack[undoStack.length - 1];
|
|
547
|
+
if (prev === undefined) return;
|
|
547
548
|
setUndoStack((s) => s.slice(0, -1));
|
|
548
549
|
setRedoStack((s) => [...s, currentContent]);
|
|
549
|
-
setFileContents((contents) => new Map(contents).set(selectedFile
|
|
550
|
-
setUnsavedPaths((paths) => new Set(paths).add(selectedFile
|
|
550
|
+
setFileContents((contents) => new Map(contents).set(selectedFile, prev));
|
|
551
|
+
setUnsavedPaths((paths) => new Set(paths).add(selectedFile));
|
|
551
552
|
setStatusMessage("Undo");
|
|
552
553
|
}, [undoStack, currentContent, selectedFile]);
|
|
553
554
|
|
|
554
555
|
const handleRedo = useCallback(() => {
|
|
555
556
|
if (redoStack.length === 0 || !selectedFile) return;
|
|
556
557
|
const next = redoStack[redoStack.length - 1];
|
|
558
|
+
if (next === undefined) return;
|
|
557
559
|
setRedoStack((s) => s.slice(0, -1));
|
|
558
560
|
setUndoStack((s) => [...s, currentContent]);
|
|
559
|
-
setFileContents((prev) => new Map(prev).set(selectedFile
|
|
560
|
-
setUnsavedPaths((paths) => new Set(paths).add(selectedFile
|
|
561
|
+
setFileContents((prev) => new Map(prev).set(selectedFile, next));
|
|
562
|
+
setUnsavedPaths((paths) => new Set(paths).add(selectedFile));
|
|
561
563
|
setStatusMessage("Redo");
|
|
562
564
|
}, [redoStack, currentContent, selectedFile]);
|
|
563
565
|
|
|
@@ -624,7 +626,7 @@ export function ConfigEditor() {
|
|
|
624
626
|
setFocusedTreeIdx((i) => Math.min(flatFiles.length - 1, i + 1));
|
|
625
627
|
break;
|
|
626
628
|
case "enter":
|
|
627
|
-
if (focusedFilePath) selectFile(focusedFilePath);
|
|
629
|
+
if (focusedFilePath) void selectFile(focusedFilePath);
|
|
628
630
|
break;
|
|
629
631
|
}
|
|
630
632
|
}
|
|
@@ -641,7 +643,7 @@ export function ConfigEditor() {
|
|
|
641
643
|
}
|
|
642
644
|
|
|
643
645
|
if (key.ctrl && key.name === "s" && selectedFile) {
|
|
644
|
-
handleSave();
|
|
646
|
+
void handleSave();
|
|
645
647
|
}
|
|
646
648
|
});
|
|
647
649
|
|
|
@@ -4,14 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/** @jsxImportSource @opentui/react */
|
|
7
|
-
import { useState, useCallback, useEffect } from "react";
|
|
7
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
8
8
|
import { Colors, WorkerStatusColor } from "@hoox-sh/hoox-shared";
|
|
9
9
|
import { StatusDot } from "../../shared/status-dot";
|
|
10
10
|
import { cliBridge } from "../../../services/cli-bridge";
|
|
11
11
|
import type { ModelHealth } from "../../../services/cli-bridge";
|
|
12
|
-
|
|
13
|
-
/** Polling interval for AI model health checks (30 seconds). */
|
|
14
|
-
const MODEL_HEALTH_POLL_MS = 30_000;
|
|
12
|
+
import { usePolling } from "../../../hooks/use-polling";
|
|
15
13
|
|
|
16
14
|
/**
|
|
17
15
|
* Map model status to a StatusDot-compatible status string.
|
|
@@ -116,7 +114,7 @@ function ModelHealthRow({
|
|
|
116
114
|
/**
|
|
117
115
|
* ModelHealthSection — displays health status of all configured AI providers.
|
|
118
116
|
*
|
|
119
|
-
* Auto-refreshes
|
|
117
|
+
* Auto-refreshes on the Settings interval when the dashboard is active.
|
|
120
118
|
* Supports multiple providers: Workers AI, OpenAI, Anthropic, Google, Azure.
|
|
121
119
|
* Click on a provider to expand detailed stats (latency, daily usage, error).
|
|
122
120
|
* Cancels in-flight probes on unmount; race-safe via generation counter.
|
|
@@ -127,51 +125,45 @@ function ModelHealthSection() {
|
|
|
127
125
|
const [loading, setLoading] = useState(true);
|
|
128
126
|
const [error, setError] = useState<string | null>(null);
|
|
129
127
|
const [lastRefresh, setLastRefresh] = useState<number | null>(null);
|
|
128
|
+
const genRef = useRef(0);
|
|
129
|
+
const inFlightRef = useRef(false);
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// Keep prior providers if any; surface probe failure distinctly
|
|
150
|
-
setError(
|
|
151
|
-
result.stderr ||
|
|
152
|
-
result.stdout ||
|
|
153
|
-
"AI health probe failed (CLI offline?)"
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
} catch (err) {
|
|
157
|
-
if (cancelled || gen !== generation) return;
|
|
158
|
-
setError(err instanceof Error ? err.message : String(err));
|
|
159
|
-
} finally {
|
|
160
|
-
inFlight = false;
|
|
161
|
-
if (!cancelled && gen === generation) setLoading(false);
|
|
131
|
+
const fetchHealth = useCallback(async () => {
|
|
132
|
+
if (inFlightRef.current) return;
|
|
133
|
+
inFlightRef.current = true;
|
|
134
|
+
const gen = ++genRef.current;
|
|
135
|
+
try {
|
|
136
|
+
const result = await cliBridge.agentHealthCheck();
|
|
137
|
+
if (gen !== genRef.current) return;
|
|
138
|
+
if (result.success && result.data) {
|
|
139
|
+
setProviders(result.data.providers ?? []);
|
|
140
|
+
setError(null);
|
|
141
|
+
setLastRefresh(Date.now());
|
|
142
|
+
} else {
|
|
143
|
+
// Keep prior providers if any; surface probe failure distinctly
|
|
144
|
+
setError(
|
|
145
|
+
result.stderr ||
|
|
146
|
+
result.stdout ||
|
|
147
|
+
"AI health probe failed (CLI offline?)"
|
|
148
|
+
);
|
|
162
149
|
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
cancelled = true;
|
|
171
|
-
clearInterval(interval);
|
|
172
|
-
};
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (gen !== genRef.current) return;
|
|
152
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
153
|
+
} finally {
|
|
154
|
+
inFlightRef.current = false;
|
|
155
|
+
if (gen === genRef.current) setLoading(false);
|
|
156
|
+
}
|
|
173
157
|
}, []);
|
|
174
158
|
|
|
159
|
+
useEffect(
|
|
160
|
+
() => () => {
|
|
161
|
+
genRef.current += 1;
|
|
162
|
+
},
|
|
163
|
+
[]
|
|
164
|
+
);
|
|
165
|
+
usePolling({ callback: fetchHealth, immediate: true });
|
|
166
|
+
|
|
175
167
|
const toggleExpand = useCallback((index: number) => {
|
|
176
168
|
setExpandedIndex((prev) => (prev === index ? null : index));
|
|
177
169
|
}, []);
|
|
@@ -4,13 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/** @jsxImportSource @opentui/react */
|
|
7
|
-
import { useState, useEffect } from "react";
|
|
7
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
8
8
|
import { Colors, WorkerStatusColor } from "@hoox-sh/hoox-shared";
|
|
9
9
|
import { StatusDot } from "../../shared/status-dot";
|
|
10
10
|
import { cliBridge } from "../../../services/cli-bridge";
|
|
11
11
|
import type { PyneHealthResult } from "../../../services/cli-bridge";
|
|
12
|
-
|
|
13
|
-
const POLL_MS = 30_000;
|
|
12
|
+
import { usePolling } from "../../../hooks/use-polling";
|
|
14
13
|
|
|
15
14
|
function statusToDot(
|
|
16
15
|
status: PyneHealthResult["status"]
|
|
@@ -27,54 +26,50 @@ function statusToDot(
|
|
|
27
26
|
export function PyneHealthSection() {
|
|
28
27
|
const [result, setResult] = useState<PyneHealthResult | null>(null);
|
|
29
28
|
const [loading, setLoading] = useState(true);
|
|
29
|
+
const genRef = useRef(0);
|
|
30
|
+
const inFlightRef = useRef(false);
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
const res = await cliBridge.pyneHealthCheck();
|
|
43
|
-
if (cancelled || gen !== generation) return;
|
|
44
|
-
if (res.data) {
|
|
45
|
-
setResult(res.data);
|
|
46
|
-
} else {
|
|
47
|
-
setResult({
|
|
48
|
-
worker: "pyne-worker",
|
|
49
|
-
url: "",
|
|
50
|
-
status: "down",
|
|
51
|
-
error: res.stderr || "probe failed",
|
|
52
|
-
timestamp: new Date().toISOString(),
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
} catch (err) {
|
|
56
|
-
if (cancelled || gen !== generation) return;
|
|
32
|
+
const refresh = useCallback(async () => {
|
|
33
|
+
if (inFlightRef.current) return;
|
|
34
|
+
inFlightRef.current = true;
|
|
35
|
+
const gen = ++genRef.current;
|
|
36
|
+
try {
|
|
37
|
+
const res = await cliBridge.pyneHealthCheck();
|
|
38
|
+
if (gen !== genRef.current) return;
|
|
39
|
+
if (res.data) {
|
|
40
|
+
setResult(res.data);
|
|
41
|
+
} else {
|
|
57
42
|
setResult({
|
|
58
43
|
worker: "pyne-worker",
|
|
59
44
|
url: "",
|
|
60
45
|
status: "down",
|
|
61
|
-
error:
|
|
46
|
+
error: res.stderr || "probe failed",
|
|
62
47
|
timestamp: new Date().toISOString(),
|
|
63
48
|
});
|
|
64
|
-
} finally {
|
|
65
|
-
inFlight = false;
|
|
66
|
-
if (!cancelled && gen === generation) setLoading(false);
|
|
67
49
|
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (gen !== genRef.current) return;
|
|
52
|
+
setResult({
|
|
53
|
+
worker: "pyne-worker",
|
|
54
|
+
url: "",
|
|
55
|
+
status: "down",
|
|
56
|
+
error: err instanceof Error ? err.message : "probe failed",
|
|
57
|
+
timestamp: new Date().toISOString(),
|
|
58
|
+
});
|
|
59
|
+
} finally {
|
|
60
|
+
inFlightRef.current = false;
|
|
61
|
+
if (gen === genRef.current) setLoading(false);
|
|
62
|
+
}
|
|
76
63
|
}, []);
|
|
77
64
|
|
|
65
|
+
useEffect(
|
|
66
|
+
() => () => {
|
|
67
|
+
genRef.current += 1;
|
|
68
|
+
},
|
|
69
|
+
[]
|
|
70
|
+
);
|
|
71
|
+
usePolling({ callback: refresh, immediate: true });
|
|
72
|
+
|
|
78
73
|
const status = result?.status ?? "down";
|
|
79
74
|
const dot = statusToDot(status);
|
|
80
75
|
const color = WorkerStatusColor[dot];
|
|
@@ -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", () => {
|