@hoox-sh/hoox-tui 0.2.0 → 0.3.1
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 +4239 -1743
- package/package.json +8 -5
- 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/ui/toast.tsx +44 -92
- 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 +6 -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
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Colors from design tokens via @hoox-sh/hoox-shared. No CSS, no DOM.
|
|
19
19
|
*/
|
|
20
20
|
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
import { Colors, useServiceStore } from "@hoox-sh/hoox-shared";
|
|
23
23
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
24
24
|
import { StatusDot } from "../shared/status-dot";
|
|
@@ -36,6 +36,7 @@ import { ModelHealthSection } from "./dashboard/model-health-section";
|
|
|
36
36
|
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
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
39
40
|
|
|
40
41
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
|
41
42
|
|
|
@@ -73,11 +74,13 @@ function formatStatNumber(value: number, isPnl = false): string {
|
|
|
73
74
|
* header. Auto-refreshes on mount and shows the engaged/released state with a
|
|
74
75
|
* color-coded status dot. Errors degrade gracefully (badge just shows UNKNOWN).
|
|
75
76
|
*/
|
|
77
|
+
/** How often the dashboard kill-switch badge re-probes (ms). */
|
|
78
|
+
const KILL_SWITCH_POLL_MS = 15_000;
|
|
79
|
+
|
|
76
80
|
function KillSwitchStatusBadge() {
|
|
77
81
|
const [state, setState] = useState<"engaged" | "released" | "unknown">(
|
|
78
82
|
"unknown"
|
|
79
83
|
);
|
|
80
|
-
|
|
81
84
|
useEffect(() => {
|
|
82
85
|
let cancelled = false;
|
|
83
86
|
const refresh = async () => {
|
|
@@ -94,8 +97,13 @@ function KillSwitchStatusBadge() {
|
|
|
94
97
|
}
|
|
95
98
|
};
|
|
96
99
|
void refresh();
|
|
100
|
+
// Re-probe periodically so engage/release in Service Manager is visible here
|
|
101
|
+
const timer = setInterval(() => {
|
|
102
|
+
void refresh();
|
|
103
|
+
}, KILL_SWITCH_POLL_MS);
|
|
97
104
|
return () => {
|
|
98
105
|
cancelled = true;
|
|
106
|
+
clearInterval(timer);
|
|
99
107
|
};
|
|
100
108
|
}, []);
|
|
101
109
|
|
|
@@ -200,10 +208,18 @@ function DashboardHeader({
|
|
|
200
208
|
* ServiceHealthGrid — displays all workers as a grid of status cards.
|
|
201
209
|
* Each card shows: [█ ▌ ░] WORKER_NAME
|
|
202
210
|
* Limited to first 10 workers (fits the dashboard layout).
|
|
211
|
+
*
|
|
212
|
+
* Keyboard (dashboard active, when not handling alerts):
|
|
213
|
+
* ←→↑↓ / hjkl — move focus among worker cards
|
|
214
|
+
* Enter — open worker detail
|
|
215
|
+
* Mouse: click card → detail
|
|
203
216
|
*/
|
|
204
217
|
function ServiceHealthGrid() {
|
|
205
218
|
const workers = useServiceStore((s) => s.workers);
|
|
206
219
|
const connectionStatus = useServiceStore((s) => s.connectionStatus);
|
|
220
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
221
|
+
const isActive = activeView === "dashboard";
|
|
222
|
+
const [focusIndex, setFocusIndex] = useState(0);
|
|
207
223
|
|
|
208
224
|
// Show first 10 workers (dashboard is an overview)
|
|
209
225
|
const visibleWorkers = useMemo(() => workers.slice(0, 10), [workers]);
|
|
@@ -215,6 +231,51 @@ function ServiceHealthGrid() {
|
|
|
215
231
|
[visibleWorkers.length]
|
|
216
232
|
);
|
|
217
233
|
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
setFocusIndex((i) =>
|
|
236
|
+
visibleWorkers.length === 0 ? 0 : Math.min(i, visibleWorkers.length - 1)
|
|
237
|
+
);
|
|
238
|
+
}, [visibleWorkers.length]);
|
|
239
|
+
|
|
240
|
+
const openWorker = useCallback((workerId: string) => {
|
|
241
|
+
useServiceStore.getState().selectWorker(workerId);
|
|
242
|
+
useUIStore.getState().setView("worker-detail");
|
|
243
|
+
}, []);
|
|
244
|
+
|
|
245
|
+
// 5-column grid navigation
|
|
246
|
+
const COLS = 5;
|
|
247
|
+
useViewKeyboard((key) => {
|
|
248
|
+
if (!isActive || visibleWorkers.length === 0) return;
|
|
249
|
+
const name = String(key.name ?? "").toLowerCase();
|
|
250
|
+
const n = visibleWorkers.length;
|
|
251
|
+
if (name === "left" || name === "h") {
|
|
252
|
+
setFocusIndex((i) => Math.max(0, i - 1));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (name === "right" || name === "l") {
|
|
256
|
+
setFocusIndex((i) => Math.min(n - 1, i + 1));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (name === "up" || name === "k") {
|
|
260
|
+
// Prefer alerts panel owning ↑↓ when alerts exist — grid uses only
|
|
261
|
+
// left/right by default; vertical jumps by COLS when pressed with Alt.
|
|
262
|
+
if (key.alt) {
|
|
263
|
+
setFocusIndex((i) => Math.max(0, i - COLS));
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (name === "down" || name === "j") {
|
|
268
|
+
if (key.alt) {
|
|
269
|
+
setFocusIndex((i) => Math.min(n - 1, i + COLS));
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (name === "return" || name === "enter") {
|
|
274
|
+
const w = visibleWorkers[focusIndex];
|
|
275
|
+
if (w) openWorker(w.id);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
218
279
|
if (workers.length === 0) {
|
|
219
280
|
const offline =
|
|
220
281
|
connectionStatus === "offline" || connectionStatus === "reconnecting";
|
|
@@ -238,10 +299,16 @@ function ServiceHealthGrid() {
|
|
|
238
299
|
|
|
239
300
|
return (
|
|
240
301
|
<box flexDirection="column" gap={0}>
|
|
241
|
-
{/* Section label
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
302
|
+
{/* Section label + compact keyboard affordance (avoid extra footer row —
|
|
303
|
+
OpenTUI flexWrap was overlapping a sibling hint into later cards). */}
|
|
304
|
+
<box flexDirection="row" gap={2}>
|
|
305
|
+
<text fg={Colors.foreground} bold dim>
|
|
306
|
+
SERVICE HEALTH
|
|
307
|
+
</text>
|
|
308
|
+
<text fg={Colors.dim} dim>
|
|
309
|
+
←→ Enter
|
|
310
|
+
</text>
|
|
311
|
+
</box>
|
|
245
312
|
|
|
246
313
|
{/* 2 rows × 5 columns grid */}
|
|
247
314
|
<box
|
|
@@ -251,43 +318,51 @@ function ServiceHealthGrid() {
|
|
|
251
318
|
paddingTop={1}
|
|
252
319
|
paddingBottom={1}
|
|
253
320
|
>
|
|
254
|
-
{visibleWorkers.map((worker) =>
|
|
321
|
+
{visibleWorkers.map((worker, i) => {
|
|
322
|
+
const focused = i === focusIndex;
|
|
323
|
+
return (
|
|
324
|
+
<box
|
|
325
|
+
key={worker.id}
|
|
326
|
+
flexDirection="row"
|
|
327
|
+
width={28}
|
|
328
|
+
gap={1}
|
|
329
|
+
paddingLeft={1}
|
|
330
|
+
paddingRight={1}
|
|
331
|
+
backgroundColor={focused ? Colors.card : undefined}
|
|
332
|
+
onMouseUp={() => {
|
|
333
|
+
setFocusIndex(i);
|
|
334
|
+
openWorker(worker.id);
|
|
335
|
+
}}
|
|
336
|
+
>
|
|
337
|
+
<StatusDot status={worker.status} />
|
|
338
|
+
<text
|
|
339
|
+
fg={focused ? Colors.accent : Colors.foreground}
|
|
340
|
+
bold={focused}
|
|
341
|
+
>
|
|
342
|
+
{worker.name}
|
|
343
|
+
</text>
|
|
344
|
+
</box>
|
|
345
|
+
);
|
|
346
|
+
})}
|
|
347
|
+
{/* Fill empty slots in the same wrap container */}
|
|
348
|
+
{emptySlots.map((i) => (
|
|
255
349
|
<box
|
|
256
|
-
key={
|
|
350
|
+
key={`empty-${i}`}
|
|
257
351
|
flexDirection="row"
|
|
258
352
|
width={28}
|
|
259
353
|
gap={1}
|
|
260
354
|
paddingLeft={1}
|
|
261
355
|
paddingRight={1}
|
|
262
356
|
>
|
|
263
|
-
<
|
|
264
|
-
|
|
357
|
+
<text fg={Colors.dim} dim>
|
|
358
|
+
-
|
|
359
|
+
</text>
|
|
360
|
+
<text fg={Colors.dim} dim>
|
|
361
|
+
-
|
|
362
|
+
</text>
|
|
265
363
|
</box>
|
|
266
364
|
))}
|
|
267
365
|
</box>
|
|
268
|
-
|
|
269
|
-
{/* Fill empty slots in grid to maintain 10-card layout */}
|
|
270
|
-
{emptySlots.length > 0 && (
|
|
271
|
-
<box flexDirection="row" flexWrap="wrap" gap={1}>
|
|
272
|
-
{emptySlots.map((i) => (
|
|
273
|
-
<box
|
|
274
|
-
key={`empty-${i}`}
|
|
275
|
-
flexDirection="row"
|
|
276
|
-
width={28}
|
|
277
|
-
gap={1}
|
|
278
|
-
paddingLeft={1}
|
|
279
|
-
paddingRight={1}
|
|
280
|
-
>
|
|
281
|
-
<text fg={Colors.dim} dim>
|
|
282
|
-
—
|
|
283
|
-
</text>
|
|
284
|
-
<text fg={Colors.dim} dim>
|
|
285
|
-
—
|
|
286
|
-
</text>
|
|
287
|
-
</box>
|
|
288
|
-
))}
|
|
289
|
-
</box>
|
|
290
|
-
)}
|
|
291
366
|
</box>
|
|
292
367
|
);
|
|
293
368
|
}
|
|
@@ -301,11 +376,14 @@ function MetricCard({
|
|
|
301
376
|
value,
|
|
302
377
|
color,
|
|
303
378
|
isPnl = false,
|
|
379
|
+
loading = false,
|
|
304
380
|
}: {
|
|
305
381
|
label: string;
|
|
306
382
|
value: number;
|
|
307
383
|
color: string;
|
|
308
384
|
isPnl?: boolean;
|
|
385
|
+
/** When true, show placeholder instead of inventing zeros. */
|
|
386
|
+
loading?: boolean;
|
|
309
387
|
}) {
|
|
310
388
|
return (
|
|
311
389
|
<box
|
|
@@ -325,10 +403,10 @@ function MetricCard({
|
|
|
325
403
|
</text>
|
|
326
404
|
</box>
|
|
327
405
|
|
|
328
|
-
{/* Value (large, colored) */}
|
|
406
|
+
{/* Value (large, colored) — never invent zeros while loading */}
|
|
329
407
|
<box>
|
|
330
|
-
<text fg={color} bold>
|
|
331
|
-
{formatStatNumber(value, isPnl)}
|
|
408
|
+
<text fg={loading ? Colors.dim : color} bold={!loading}>
|
|
409
|
+
{loading ? "..." : formatStatNumber(value, isPnl)}
|
|
332
410
|
</text>
|
|
333
411
|
</box>
|
|
334
412
|
</box>
|
|
@@ -341,8 +419,8 @@ function MetricCard({
|
|
|
341
419
|
*/
|
|
342
420
|
function QuickStatsRow() {
|
|
343
421
|
const metrics = useServiceStore((s) => s.metrics);
|
|
422
|
+
const loading = metrics === null;
|
|
344
423
|
|
|
345
|
-
// Default values when metrics is null/unavailable
|
|
346
424
|
const totalPnl = metrics?.totalPnl ?? 0;
|
|
347
425
|
const activeStrategies = metrics?.activeStrategies ?? 0;
|
|
348
426
|
const dailyTrades = metrics?.dailyTrades ?? 0;
|
|
@@ -360,22 +438,29 @@ function QuickStatsRow() {
|
|
|
360
438
|
value={totalPnl}
|
|
361
439
|
color={totalPnl >= 0 ? Colors.success : Colors.error}
|
|
362
440
|
isPnl
|
|
441
|
+
loading={loading}
|
|
363
442
|
/>
|
|
364
443
|
<MetricCard
|
|
365
444
|
label="ACTIVE STRATEGIES"
|
|
366
445
|
value={activeStrategies}
|
|
367
446
|
color={Colors.accent}
|
|
447
|
+
loading={loading}
|
|
368
448
|
/>
|
|
369
449
|
<MetricCard
|
|
370
450
|
label="DAILY TRADES"
|
|
371
451
|
value={dailyTrades}
|
|
372
452
|
color={Colors.info}
|
|
453
|
+
loading={loading}
|
|
454
|
+
/>
|
|
455
|
+
<MetricCard
|
|
456
|
+
label="AI CALLS"
|
|
457
|
+
value={aiCalls}
|
|
458
|
+
color={Colors.accent}
|
|
459
|
+
loading={loading}
|
|
373
460
|
/>
|
|
374
|
-
<MetricCard label="AI CALLS" value={aiCalls} color={Colors.accent} />
|
|
375
461
|
</box>
|
|
376
462
|
|
|
377
|
-
{
|
|
378
|
-
{metrics === null && (
|
|
463
|
+
{loading && (
|
|
379
464
|
<box paddingTop={1} alignItems="center">
|
|
380
465
|
<Spinner label="Waiting for metrics data..." />
|
|
381
466
|
</box>
|
|
@@ -554,7 +639,7 @@ export function DashboardView({ dialog }: DashboardViewProps = {}) {
|
|
|
554
639
|
}, [handleRunAutoRepair]);
|
|
555
640
|
|
|
556
641
|
// Dismiss repair panel on ESC key when visible
|
|
557
|
-
|
|
642
|
+
useViewKeyboard((key) => {
|
|
558
643
|
if (repairState.kind !== "idle" && key.name === "escape") {
|
|
559
644
|
dismissRepairPanel();
|
|
560
645
|
}
|
|
@@ -45,18 +45,25 @@ describe("DbQueryView", () => {
|
|
|
45
45
|
expect(formatCell(null, 20)).toBe("NULL");
|
|
46
46
|
expect(formatCell(undefined, 20)).toBe("NULL");
|
|
47
47
|
expect(formatCell(42, 20)).toBe("42");
|
|
48
|
+
expect(formatCell(true, 20)).toBe("true");
|
|
49
|
+
expect(formatCell(false, 20)).toBe("false");
|
|
50
|
+
expect(formatCell(Number.POSITIVE_INFINITY, 20)).toBe("Infinity");
|
|
48
51
|
});
|
|
49
52
|
|
|
50
53
|
it("sanitizes cell text and collapses newlines", () => {
|
|
51
54
|
expect(sanitizeCellText("a\x1bb")).toBe("ab");
|
|
52
55
|
expect(formatCell("line1\nline2", 40)).toBe("line1 line2");
|
|
56
|
+
expect(formatCell("x".repeat(30), 10)).toBe("xxxxxxxxx…");
|
|
53
57
|
});
|
|
54
58
|
|
|
55
59
|
it("sorts nulls last and numbers numerically", () => {
|
|
56
60
|
expect(compareCells(null, 1, "asc")).toBe(1);
|
|
57
61
|
expect(compareCells(1, null, "asc")).toBe(-1);
|
|
62
|
+
expect(compareCells(null, null, "asc")).toBe(0);
|
|
58
63
|
expect(compareCells(2, 10, "asc")).toBeLessThan(0);
|
|
59
64
|
expect(compareCells(2, 10, "desc")).toBeGreaterThan(0);
|
|
65
|
+
expect(compareCells("b", "a", "asc")).toBeGreaterThan(0);
|
|
66
|
+
expect(compareCells("b", "a", "desc")).toBeLessThan(0);
|
|
60
67
|
});
|
|
61
68
|
|
|
62
69
|
it("rejects write SQL before any CLI call (defence in depth)", () => {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* - Renders an explicit empty/error state instead of throwing
|
|
38
38
|
*/
|
|
39
39
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
40
|
-
|
|
40
|
+
|
|
41
41
|
import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
|
|
42
42
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
43
43
|
import { Spinner, EmptyState } from "../shared/spinner";
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
TuiStateFiles,
|
|
54
54
|
writeJsonState,
|
|
55
55
|
} from "../../services/tui-storage";
|
|
56
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
56
57
|
|
|
57
58
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
58
59
|
|
|
@@ -270,7 +271,7 @@ interface HistoryOverlayProps {
|
|
|
270
271
|
|
|
271
272
|
function HistoryOverlay({ history, onSelect, onClose }: HistoryOverlayProps) {
|
|
272
273
|
// Keyboard: Escape closes, Up/Down navigate, Enter selects
|
|
273
|
-
|
|
274
|
+
useViewKeyboard((key) => {
|
|
274
275
|
if (key.name === "escape") {
|
|
275
276
|
onClose();
|
|
276
277
|
return;
|
|
@@ -369,7 +370,7 @@ function SqlInputRow({
|
|
|
369
370
|
onChange,
|
|
370
371
|
}: SqlInputRowProps) {
|
|
371
372
|
// Capture keyboard when active.
|
|
372
|
-
|
|
373
|
+
useViewKeyboard((key) => {
|
|
373
374
|
if (!inputActive) return;
|
|
374
375
|
if (key.name === "escape") {
|
|
375
376
|
onDeactivate();
|
|
@@ -587,7 +588,7 @@ export function DbQueryView() {
|
|
|
587
588
|
}, [historyIdx, history]);
|
|
588
589
|
|
|
589
590
|
// ── Global keyboard shortcuts (active view only) ───────────────────────────
|
|
590
|
-
|
|
591
|
+
useViewKeyboard((key) => {
|
|
591
592
|
if (!isActive) return;
|
|
592
593
|
if (showHistory) return; // History overlay handles its own keys
|
|
593
594
|
|
|
@@ -11,12 +11,13 @@ import {
|
|
|
11
11
|
resolveHooxRuntimeRoot,
|
|
12
12
|
useUIStore,
|
|
13
13
|
} from "@hoox-sh/hoox-shared";
|
|
14
|
-
|
|
14
|
+
|
|
15
15
|
import * as fs from "fs";
|
|
16
16
|
import * as path from "path";
|
|
17
17
|
import { ErrorBoundary } from "../shared/error-boundary";
|
|
18
18
|
import { ViewHeader } from "../shared/view-header";
|
|
19
19
|
import { EmptyState } from "../shared/spinner";
|
|
20
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
20
21
|
|
|
21
22
|
/** Cap default flow list so huge graphs stay responsive. */
|
|
22
23
|
export const MAX_FLOWS_PREVIEW = 15;
|
|
@@ -201,7 +202,7 @@ function EdgeTopologyInner() {
|
|
|
201
202
|
);
|
|
202
203
|
}, [selectableItems.length]);
|
|
203
204
|
|
|
204
|
-
|
|
205
|
+
useViewKeyboard((key) => {
|
|
205
206
|
if (!isActive) return;
|
|
206
207
|
if (key.name === "escape") {
|
|
207
208
|
setSelectedNode(null);
|
|
@@ -151,6 +151,34 @@ describe("KvViewer", () => {
|
|
|
151
151
|
|
|
152
152
|
it("sanitizes revealed KV values (no ESC injection)", () => {
|
|
153
153
|
expect(sanitizeKvValue("secret\x1b[0mvalue")).toBe("secret[0mvalue");
|
|
154
|
+
expect(sanitizeKvValue("a\u0000b\u0007c")).toBe("abc");
|
|
155
|
+
const long = "x".repeat(5000);
|
|
156
|
+
expect(sanitizeKvValue(long).length).toBeLessThanOrEqual(4001);
|
|
157
|
+
expect(
|
|
158
|
+
sanitizeKvValue(long).endsWith("…") ||
|
|
159
|
+
sanitizeKvValue(long).endsWith("\u2026")
|
|
160
|
+
).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("filterKvKeys is case-insensitive and trims", () => {
|
|
164
|
+
const keys = [
|
|
165
|
+
{
|
|
166
|
+
name: "Alpha",
|
|
167
|
+
valueSize: 1,
|
|
168
|
+
lastModified: null,
|
|
169
|
+
isSecret: false,
|
|
170
|
+
manifestType: null as null,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "beta",
|
|
174
|
+
valueSize: 1,
|
|
175
|
+
lastModified: null,
|
|
176
|
+
isSecret: false,
|
|
177
|
+
manifestType: null as null,
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
expect(filterKvKeys(keys, "ALP")).toHaveLength(1);
|
|
181
|
+
expect(filterKvKeys(keys, "\t")).toHaveLength(2);
|
|
154
182
|
});
|
|
155
183
|
|
|
156
184
|
it("caps visible key rows", () => {
|
|
@@ -48,13 +48,14 @@
|
|
|
48
48
|
* while the table shows the filtered count.
|
|
49
49
|
*/
|
|
50
50
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
51
|
-
|
|
51
|
+
|
|
52
52
|
import { Colors, useUIStore } 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
|
+
import { useViewKeyboard } from "../../hooks/shell-overlay";
|
|
58
59
|
|
|
59
60
|
/** Auto-refresh interval in milliseconds. */
|
|
60
61
|
export const REFRESH_INTERVAL_MS = 5_000;
|
|
@@ -307,7 +308,7 @@ function SearchBox({
|
|
|
307
308
|
onActivate,
|
|
308
309
|
onDeactivate,
|
|
309
310
|
}: SearchBoxProps) {
|
|
310
|
-
|
|
311
|
+
useViewKeyboard((key) => {
|
|
311
312
|
if (!active) return;
|
|
312
313
|
if (key.name === "escape") {
|
|
313
314
|
onDeactivate();
|
|
@@ -494,7 +495,7 @@ export function KvViewer() {
|
|
|
494
495
|
}, []);
|
|
495
496
|
|
|
496
497
|
// ── Global keyboard handling (active only when this view is on top) ──
|
|
497
|
-
|
|
498
|
+
useViewKeyboard((key) => {
|
|
498
499
|
if (!isActive) return;
|
|
499
500
|
if (searchActive) return; // SearchBox handles its own keys.
|
|
500
501
|
if (key.name === "slash" || (key.ctrl && key.name === "f")) {
|
|
@@ -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 });
|