@hoox-sh/hoox-tui 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -0
- package/bin/hoox-tui.js +35 -0
- package/dist/main.js +17594 -0
- package/package.json +72 -0
- package/src/app.tsx +696 -0
- package/src/cli-bridge-test-double.ts +212 -0
- package/src/components/layout/layout.test.tsx +205 -0
- package/src/components/layout/sidebar.tsx +69 -0
- package/src/components/layout/statusbar.test.tsx +423 -0
- package/src/components/layout/statusbar.tsx +348 -0
- package/src/components/shared/animated-border.tsx +58 -0
- package/src/components/shared/chrome.test.tsx +29 -0
- package/src/components/shared/command-palette.tsx +361 -0
- package/src/components/shared/cool-brackets.tsx +75 -0
- package/src/components/shared/crash-screen.tsx +167 -0
- package/src/components/shared/error-boundary.tsx +99 -0
- package/src/components/shared/keybinding-hint.tsx +37 -0
- package/src/components/shared/panel.tsx +61 -0
- package/src/components/shared/quit-modal.tsx +65 -0
- package/src/components/shared/shared.test.tsx +538 -0
- package/src/components/shared/spinner.tsx +85 -0
- package/src/components/shared/status-dot.tsx +54 -0
- package/src/components/shared/view-header.tsx +53 -0
- package/src/components/ui/connection-toasts.test.ts +50 -0
- package/src/components/ui/connection-toasts.ts +215 -0
- package/src/components/ui/dialog.tsx +281 -0
- package/src/components/ui/select.tsx +197 -0
- package/src/components/ui/toast.tsx +185 -0
- package/src/components/views/ai-chat.test.tsx +81 -0
- package/src/components/views/ai-chat.tsx +576 -0
- package/src/components/views/config-editor/action-bar.tsx +102 -0
- package/src/components/views/config-editor/code-editor.tsx +597 -0
- package/src/components/views/config-editor/file-tree.tsx +113 -0
- package/src/components/views/config-editor/types.ts +37 -0
- package/src/components/views/config-editor.test.tsx +465 -0
- package/src/components/views/config-editor.tsx +709 -0
- package/src/components/views/dashboard/alerts-panel.tsx +154 -0
- package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
- package/src/components/views/dashboard/model-health-section.tsx +276 -0
- package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
- package/src/components/views/dashboard.test.tsx +347 -0
- package/src/components/views/dashboard.tsx +647 -0
- package/src/components/views/db-query.test.tsx +115 -0
- package/src/components/views/db-query.tsx +849 -0
- package/src/components/views/edge-topology.test.tsx +198 -0
- package/src/components/views/edge-topology.tsx +572 -0
- package/src/components/views/kv-viewer.test.tsx +241 -0
- package/src/components/views/kv-viewer.tsx +717 -0
- package/src/components/views/logs-viewer.test.tsx +273 -0
- package/src/components/views/logs-viewer.tsx +616 -0
- package/src/components/views/queue-depth.test.tsx +221 -0
- package/src/components/views/queue-depth.tsx +376 -0
- package/src/components/views/secrets-viewer.test.tsx +65 -0
- package/src/components/views/secrets-viewer.tsx +548 -0
- package/src/components/views/service-manager.test.tsx +358 -0
- package/src/components/views/service-manager.tsx +946 -0
- package/src/components/views/settings.test.tsx +648 -0
- package/src/components/views/settings.tsx +1497 -0
- package/src/components/views/setup-wizard.test.tsx +297 -0
- package/src/components/views/setup-wizard.tsx +1321 -0
- package/src/components/views/trade-monitor.test.tsx +592 -0
- package/src/components/views/trade-monitor.tsx +745 -0
- package/src/components/views/worker-detail.test.tsx +289 -0
- package/src/components/views/worker-detail.tsx +676 -0
- package/src/components/views/worker-settings.test.tsx +43 -0
- package/src/components/views/worker-settings.tsx +563 -0
- package/src/components/views/workers-overview.test.tsx +260 -0
- package/src/components/views/workers-overview.tsx +631 -0
- package/src/hooks/hooks.test.ts +72 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/renderer-ref.ts +22 -0
- package/src/hooks/use-keyboard.ts +130 -0
- package/src/hooks/use-polling.ts +73 -0
- package/src/hooks/use-service-data.ts +18 -0
- package/src/main.tsx +117 -0
- package/src/network-test-double.ts +133 -0
- package/src/opentui-augment.d.ts +45 -0
- package/src/opentui-jsx.d.ts +70 -0
- package/src/services/cli-bridge/index.ts +1662 -0
- package/src/services/cli-bridge/standalone.ts +315 -0
- package/src/services/cli-bridge/types.ts +307 -0
- package/src/services/cli-bridge.test.ts +406 -0
- package/src/services/clipboard.test.ts +57 -0
- package/src/services/clipboard.ts +180 -0
- package/src/services/dashboard-settings-loader.ts +31 -0
- package/src/services/dev-log.test.ts +168 -0
- package/src/services/dev-log.ts +194 -0
- package/src/services/hoox-path-service.test.ts +183 -0
- package/src/services/hoox-path-service.ts +185 -0
- package/src/services/tui-connection.test.ts +212 -0
- package/src/services/tui-connection.ts +236 -0
- package/src/services/tui-storage.test.ts +92 -0
- package/src/services/tui-storage.ts +86 -0
- package/src/stores/config-store.test.ts +360 -0
- package/src/stores/service-store.test.ts +1026 -0
- package/src/stores/session-restore.test.ts +134 -0
- package/src/stores/ui-store.test.ts +276 -0
- package/src/test-setup.ts +265 -0
- package/src/test-utils.ts +179 -0
- package/src/types.ts +153 -0
- package/src/utils/colors.test.ts +326 -0
- package/src/utils/formatters.test.ts +236 -0
- package/src/view-registry.test.ts +132 -0
- package/src/view-registry.tsx +368 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useMemo, useEffect } from "react";
|
|
7
|
+
import { useKeyboard } from "@opentui/react";
|
|
8
|
+
import {
|
|
9
|
+
Colors,
|
|
10
|
+
AlertSeverityColor,
|
|
11
|
+
useServiceStore,
|
|
12
|
+
} from "@hoox-sh/hoox-shared";
|
|
13
|
+
import type { AlertSeverity } from "@hoox-sh/hoox-shared";
|
|
14
|
+
|
|
15
|
+
/** Severity label prefix */
|
|
16
|
+
const SEVERITY_LABEL: Record<AlertSeverity, string> = {
|
|
17
|
+
info: "INFO",
|
|
18
|
+
warning: "WARN",
|
|
19
|
+
error: "ERR",
|
|
20
|
+
critical: "CRIT",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Maximum alerts shown in the panel */
|
|
24
|
+
const MAX_VISIBLE_ALERTS = 50;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Format a timestamp (ms) to HH:MM:SS for alert display.
|
|
28
|
+
*/
|
|
29
|
+
function formatTime(ts: number): string {
|
|
30
|
+
const d = new Date(ts);
|
|
31
|
+
return [
|
|
32
|
+
d.getHours().toString().padStart(2, "0"),
|
|
33
|
+
d.getMinutes().toString().padStart(2, "0"),
|
|
34
|
+
d.getSeconds().toString().padStart(2, "0"),
|
|
35
|
+
].join(":");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* AlertsPanel — scrollable list of recent alerts, newest first.
|
|
40
|
+
* Each alert row shows: [SEV] HH:MM:SS — message
|
|
41
|
+
* Color-coded by severity. Scrollable with ↑↓ keys.
|
|
42
|
+
*/
|
|
43
|
+
function AlertsPanel() {
|
|
44
|
+
const alerts = useServiceStore((s) => s.alerts);
|
|
45
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
46
|
+
|
|
47
|
+
// Newest first, limited
|
|
48
|
+
const sortedAlerts = useMemo(() => {
|
|
49
|
+
return [...alerts]
|
|
50
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
51
|
+
.slice(0, MAX_VISIBLE_ALERTS);
|
|
52
|
+
}, [alerts]);
|
|
53
|
+
|
|
54
|
+
// Clamp selection when the alert list shrinks (ack/dismiss/race)
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
setScrollOffset((o) => Math.min(o, Math.max(0, sortedAlerts.length - 1)));
|
|
57
|
+
}, [sortedAlerts.length]);
|
|
58
|
+
|
|
59
|
+
// Keyboard: scroll through alerts
|
|
60
|
+
useKeyboard((key) => {
|
|
61
|
+
if (key.name === "up") {
|
|
62
|
+
setScrollOffset((o) => Math.max(0, o - 1));
|
|
63
|
+
} else if (key.name === "down") {
|
|
64
|
+
setScrollOffset((o) =>
|
|
65
|
+
Math.min(Math.max(0, sortedAlerts.length - 1), o + 1)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<box flexDirection="column">
|
|
72
|
+
{/* Section label */}
|
|
73
|
+
<box>
|
|
74
|
+
<text fg={Colors.foreground} bold dim>
|
|
75
|
+
ALERTS
|
|
76
|
+
</text>
|
|
77
|
+
</box>
|
|
78
|
+
|
|
79
|
+
{sortedAlerts.length === 0 ? (
|
|
80
|
+
<box paddingTop={1}>
|
|
81
|
+
<text fg={Colors.muted} dim>
|
|
82
|
+
No alerts
|
|
83
|
+
</text>
|
|
84
|
+
</box>
|
|
85
|
+
) : (
|
|
86
|
+
<scrollbox
|
|
87
|
+
width="100%"
|
|
88
|
+
height={8}
|
|
89
|
+
border={true}
|
|
90
|
+
borderStyle="single"
|
|
91
|
+
borderColor={Colors.border}
|
|
92
|
+
paddingX={1}
|
|
93
|
+
paddingY={0}
|
|
94
|
+
>
|
|
95
|
+
{sortedAlerts.map((alert, i) => {
|
|
96
|
+
const color = AlertSeverityColor[alert.severity];
|
|
97
|
+
const label = SEVERITY_LABEL[alert.severity];
|
|
98
|
+
const isCritical = alert.severity === "critical";
|
|
99
|
+
const isSelected = i === scrollOffset;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<box
|
|
103
|
+
key={alert.id}
|
|
104
|
+
flexDirection="row"
|
|
105
|
+
gap={1}
|
|
106
|
+
backgroundColor={isSelected ? Colors.card : undefined}
|
|
107
|
+
>
|
|
108
|
+
{/* Severity badge */}
|
|
109
|
+
<text fg={color} bold={isCritical} dim={!isCritical}>
|
|
110
|
+
[{label}]
|
|
111
|
+
</text>
|
|
112
|
+
|
|
113
|
+
{/* Timestamp */}
|
|
114
|
+
<text fg={Colors.muted} dim>
|
|
115
|
+
{formatTime(alert.timestamp)}
|
|
116
|
+
</text>
|
|
117
|
+
|
|
118
|
+
{/* Divider */}
|
|
119
|
+
<text fg={Colors.dim} dim>
|
|
120
|
+
—
|
|
121
|
+
</text>
|
|
122
|
+
|
|
123
|
+
{/* Message */}
|
|
124
|
+
<text fg={color} bold={isCritical} dim={alert.acknowledged}>
|
|
125
|
+
{alert.message.length > 60
|
|
126
|
+
? alert.message.slice(0, 57) + "…"
|
|
127
|
+
: alert.message}
|
|
128
|
+
</text>
|
|
129
|
+
|
|
130
|
+
{/* Acknowledged marker */}
|
|
131
|
+
{alert.acknowledged && (
|
|
132
|
+
<text fg={Colors.dim} dim>
|
|
133
|
+
✓
|
|
134
|
+
</text>
|
|
135
|
+
)}
|
|
136
|
+
</box>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</scrollbox>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{/* Scroll hint */}
|
|
143
|
+
{sortedAlerts.length > 0 && (
|
|
144
|
+
<box>
|
|
145
|
+
<text fg={Colors.dim} dim>
|
|
146
|
+
↑↓ scroll · {scrollOffset + 1}/{sortedAlerts.length}
|
|
147
|
+
</text>
|
|
148
|
+
</box>
|
|
149
|
+
)}
|
|
150
|
+
</box>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { AlertsPanel };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @jsxImportSource @opentui/react */
|
|
7
|
+
import { useMemo } from "react";
|
|
8
|
+
import { Colors } from "@hoox-sh/hoox-shared";
|
|
9
|
+
|
|
10
|
+
// ─── Types ─────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A single fix action result from `hoox check fix`.
|
|
14
|
+
* Mirrors the CLI's FixAction shape (`packages/cli/src/commands/check/types.ts`).
|
|
15
|
+
*/
|
|
16
|
+
export interface RepairFixItem {
|
|
17
|
+
/** Human-readable description of the fix. */
|
|
18
|
+
description: string;
|
|
19
|
+
/** Type of fix: file, binding, flag, or config. */
|
|
20
|
+
type: "file" | "binding" | "flag" | "config";
|
|
21
|
+
/** Target path or identifier. */
|
|
22
|
+
target: string;
|
|
23
|
+
/** Whether the fix was successfully applied. */
|
|
24
|
+
applied: boolean;
|
|
25
|
+
/** Error message if application failed. */
|
|
26
|
+
error?: string;
|
|
27
|
+
/** Timestamp when the fix was attempted. */
|
|
28
|
+
timestamp: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* UI state for the Auto-Repair results panel.
|
|
33
|
+
*/
|
|
34
|
+
export type RepairState =
|
|
35
|
+
| { kind: "idle" }
|
|
36
|
+
| { kind: "running" }
|
|
37
|
+
| { kind: "error"; message: string }
|
|
38
|
+
| { kind: "results"; items: RepairFixItem[]; durationMs: number };
|
|
39
|
+
|
|
40
|
+
// ─── Sub-Components ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Status badge for a single repair item: APPLIED, FAILED, or SKIPPED.
|
|
44
|
+
*/
|
|
45
|
+
export function RepairStatusBadge({
|
|
46
|
+
status,
|
|
47
|
+
}: {
|
|
48
|
+
status: "applied" | "failed" | "skipped";
|
|
49
|
+
}) {
|
|
50
|
+
if (status === "applied") {
|
|
51
|
+
return (
|
|
52
|
+
<text fg={Colors.success} bold>
|
|
53
|
+
[APPLIED]
|
|
54
|
+
</text>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (status === "failed") {
|
|
58
|
+
return (
|
|
59
|
+
<text fg={Colors.error} bold>
|
|
60
|
+
[FAILED]
|
|
61
|
+
</text>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return (
|
|
65
|
+
<text fg={Colors.warning} bold>
|
|
66
|
+
[SKIPPED]
|
|
67
|
+
</text>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Format a timestamp (ms) to HH:MM:SS for display.
|
|
73
|
+
*/
|
|
74
|
+
export function formatRepairTime(ts: number): string {
|
|
75
|
+
const d = new Date(ts);
|
|
76
|
+
return [
|
|
77
|
+
d.getHours().toString().padStart(2, "0"),
|
|
78
|
+
d.getMinutes().toString().padStart(2, "0"),
|
|
79
|
+
d.getSeconds().toString().padStart(2, "0"),
|
|
80
|
+
].join(":");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* AutoRepairPanel — shows repair results below the health section.
|
|
85
|
+
* Displays each fix item with timestamp, description, and status.
|
|
86
|
+
* Persists until the user dismisses with ESC or the [DISMISS] button.
|
|
87
|
+
*/
|
|
88
|
+
export function AutoRepairPanel({
|
|
89
|
+
state,
|
|
90
|
+
onDismiss,
|
|
91
|
+
onRerun,
|
|
92
|
+
}: {
|
|
93
|
+
state: RepairState;
|
|
94
|
+
onDismiss: () => void;
|
|
95
|
+
onRerun: () => void;
|
|
96
|
+
}) {
|
|
97
|
+
const resultCounts = useMemo(() => {
|
|
98
|
+
if (state.kind !== "results") return { applied: 0, failed: 0 };
|
|
99
|
+
let applied = 0;
|
|
100
|
+
let failed = 0;
|
|
101
|
+
for (const item of state.items) {
|
|
102
|
+
if (item.error) failed += 1;
|
|
103
|
+
else if (item.applied) applied += 1;
|
|
104
|
+
}
|
|
105
|
+
return { applied, failed };
|
|
106
|
+
}, [state]);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<box
|
|
110
|
+
flexDirection="column"
|
|
111
|
+
border={true}
|
|
112
|
+
borderStyle="single"
|
|
113
|
+
borderColor={Colors.border}
|
|
114
|
+
backgroundColor={Colors.card}
|
|
115
|
+
paddingX={1}
|
|
116
|
+
paddingY={0}
|
|
117
|
+
gap={0}
|
|
118
|
+
>
|
|
119
|
+
{/* Header row */}
|
|
120
|
+
<box flexDirection="row" gap={2}>
|
|
121
|
+
<text fg={Colors.accent} bold>
|
|
122
|
+
AUTO-REPAIR RESULTS
|
|
123
|
+
</text>
|
|
124
|
+
{state.kind === "results" && (
|
|
125
|
+
<>
|
|
126
|
+
<text fg={Colors.success} bold>
|
|
127
|
+
{resultCounts.applied} applied
|
|
128
|
+
</text>
|
|
129
|
+
<text
|
|
130
|
+
fg={resultCounts.failed > 0 ? Colors.error : Colors.muted}
|
|
131
|
+
bold={resultCounts.failed > 0}
|
|
132
|
+
>
|
|
133
|
+
{resultCounts.failed} failed
|
|
134
|
+
</text>
|
|
135
|
+
<text fg={Colors.muted} dim>
|
|
136
|
+
{`(${(state.durationMs / 1000).toFixed(1)}s)`}
|
|
137
|
+
</text>
|
|
138
|
+
</>
|
|
139
|
+
)}
|
|
140
|
+
{state.kind === "running" && (
|
|
141
|
+
<text fg={Colors.info} bold>
|
|
142
|
+
running...
|
|
143
|
+
</text>
|
|
144
|
+
)}
|
|
145
|
+
<text fg={Colors.muted}>{" "}</text>
|
|
146
|
+
<text
|
|
147
|
+
fg={state.kind === "running" ? Colors.muted : Colors.accent}
|
|
148
|
+
bold={state.kind !== "running"}
|
|
149
|
+
dim={state.kind === "running"}
|
|
150
|
+
onMouseUp={state.kind === "running" ? undefined : onRerun}
|
|
151
|
+
>
|
|
152
|
+
[ RE-RUN ]
|
|
153
|
+
</text>
|
|
154
|
+
<text fg={Colors.warning} bold onMouseUp={onDismiss}>
|
|
155
|
+
[ DISMISS ]
|
|
156
|
+
</text>
|
|
157
|
+
</box>
|
|
158
|
+
|
|
159
|
+
{/* Divider */}
|
|
160
|
+
<text fg={Colors.border} dim>
|
|
161
|
+
{"─".repeat(80)}
|
|
162
|
+
</text>
|
|
163
|
+
|
|
164
|
+
{/* Error state */}
|
|
165
|
+
{state.kind === "error" && (
|
|
166
|
+
<box flexDirection="column" gap={0} paddingTop={1}>
|
|
167
|
+
<text fg={Colors.error} bold>
|
|
168
|
+
Auto-repair failed to run:
|
|
169
|
+
</text>
|
|
170
|
+
<text fg={Colors.foreground}>{state.message}</text>
|
|
171
|
+
</box>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{/* Running state */}
|
|
175
|
+
{state.kind === "running" && (
|
|
176
|
+
<box flexDirection="column" gap={0} paddingTop={1}>
|
|
177
|
+
<text fg={Colors.muted} dim>
|
|
178
|
+
Running `hoox check fix` — this may take up to 60 seconds.
|
|
179
|
+
</text>
|
|
180
|
+
</box>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{/* Results state */}
|
|
184
|
+
{state.kind === "results" && (
|
|
185
|
+
<box flexDirection="column" gap={0} paddingTop={1}>
|
|
186
|
+
{state.items.length === 0 ? (
|
|
187
|
+
<text fg={Colors.muted} dim>
|
|
188
|
+
No repairs were needed — all checks passed.
|
|
189
|
+
</text>
|
|
190
|
+
) : (
|
|
191
|
+
state.items.map((item, idx) => {
|
|
192
|
+
const status: "applied" | "failed" | "skipped" = item.error
|
|
193
|
+
? "failed"
|
|
194
|
+
: item.applied
|
|
195
|
+
? "applied"
|
|
196
|
+
: "skipped";
|
|
197
|
+
return (
|
|
198
|
+
<box
|
|
199
|
+
key={`repair-${idx}-${item.target}`}
|
|
200
|
+
flexDirection="column"
|
|
201
|
+
gap={0}
|
|
202
|
+
>
|
|
203
|
+
<box flexDirection="row" gap={1} paddingLeft={1}>
|
|
204
|
+
<RepairStatusBadge status={status} />
|
|
205
|
+
<text fg={Colors.foreground} bold={status === "failed"}>
|
|
206
|
+
{item.description}
|
|
207
|
+
</text>
|
|
208
|
+
</box>
|
|
209
|
+
{/* Target */}
|
|
210
|
+
<text fg={Colors.dim} dim paddingLeft={6}>
|
|
211
|
+
{" target: "}
|
|
212
|
+
{item.target}
|
|
213
|
+
</text>
|
|
214
|
+
{/* Error details */}
|
|
215
|
+
{item.error && (
|
|
216
|
+
<text fg={Colors.error} paddingLeft={6}>
|
|
217
|
+
{" error: "}
|
|
218
|
+
{item.error}
|
|
219
|
+
</text>
|
|
220
|
+
)}
|
|
221
|
+
</box>
|
|
222
|
+
);
|
|
223
|
+
})
|
|
224
|
+
)}
|
|
225
|
+
</box>
|
|
226
|
+
)}
|
|
227
|
+
</box>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @jsxImportSource @opentui/react */
|
|
7
|
+
import { useState, useCallback, useEffect } from "react";
|
|
8
|
+
import { Colors, WorkerStatusColor } from "@hoox-sh/hoox-shared";
|
|
9
|
+
import { StatusDot } from "../../shared/status-dot";
|
|
10
|
+
import { cliBridge } from "../../../services/cli-bridge";
|
|
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;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Map model status to a StatusDot-compatible status string.
|
|
18
|
+
* online → operational, degraded → degraded, offline → down
|
|
19
|
+
*/
|
|
20
|
+
function modelStatusToDot(
|
|
21
|
+
status: ModelHealth["status"]
|
|
22
|
+
): "operational" | "degraded" | "down" {
|
|
23
|
+
if (status === "online") return "operational";
|
|
24
|
+
if (status === "degraded") return "degraded";
|
|
25
|
+
return "down";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Model status color via shared worker status map. */
|
|
29
|
+
function modelStatusColor(status: ModelHealth["status"]): string {
|
|
30
|
+
return WorkerStatusColor[modelStatusToDot(status)];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* ModelHealthRow — a single expandable row showing one AI provider.
|
|
35
|
+
* Click to expand/collapse detailed stats (latency, daily usage, error).
|
|
36
|
+
*/
|
|
37
|
+
function ModelHealthRow({
|
|
38
|
+
model,
|
|
39
|
+
isExpanded,
|
|
40
|
+
onToggle,
|
|
41
|
+
}: {
|
|
42
|
+
model: ModelHealth;
|
|
43
|
+
isExpanded: boolean;
|
|
44
|
+
onToggle: () => void;
|
|
45
|
+
}) {
|
|
46
|
+
const color = modelStatusColor(model.status);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<box flexDirection="column" gap={0}>
|
|
50
|
+
{/* Main row — always visible */}
|
|
51
|
+
<box flexDirection="row" gap={1} paddingLeft={1}>
|
|
52
|
+
{/* Expand/collapse indicator */}
|
|
53
|
+
<text fg={Colors.muted} dim>
|
|
54
|
+
{isExpanded ? "▼" : "▶"}
|
|
55
|
+
</text>
|
|
56
|
+
|
|
57
|
+
{/* Status dot */}
|
|
58
|
+
<StatusDot status={modelStatusToDot(model.status)} />
|
|
59
|
+
|
|
60
|
+
{/* Provider name */}
|
|
61
|
+
<text fg={Colors.foreground} bold>
|
|
62
|
+
{model.name}
|
|
63
|
+
</text>
|
|
64
|
+
|
|
65
|
+
{/* Model identifier (truncated) */}
|
|
66
|
+
<text fg={Colors.muted} dim>
|
|
67
|
+
{model.model.length > 30
|
|
68
|
+
? model.model.slice(0, 27) + "…"
|
|
69
|
+
: model.model}
|
|
70
|
+
</text>
|
|
71
|
+
|
|
72
|
+
{/* Expand hint */}
|
|
73
|
+
<text fg={Colors.accent} onMouseUp={onToggle}>
|
|
74
|
+
[DETAILS]
|
|
75
|
+
</text>
|
|
76
|
+
</box>
|
|
77
|
+
|
|
78
|
+
{/* Expanded details */}
|
|
79
|
+
{isExpanded && (
|
|
80
|
+
<box flexDirection="column" gap={0} paddingLeft={6}>
|
|
81
|
+
{/* Latency */}
|
|
82
|
+
<box flexDirection="row" gap={1} paddingLeft={2}>
|
|
83
|
+
<text fg={Colors.muted} dim>
|
|
84
|
+
latency:
|
|
85
|
+
</text>
|
|
86
|
+
<text fg={color}>
|
|
87
|
+
{model.latencyMs !== null ? `${model.latencyMs}ms` : "-"}
|
|
88
|
+
</text>
|
|
89
|
+
</box>
|
|
90
|
+
|
|
91
|
+
{/* Daily requests */}
|
|
92
|
+
<box flexDirection="row" gap={1} paddingLeft={2}>
|
|
93
|
+
<text fg={Colors.muted} dim>
|
|
94
|
+
daily requests:
|
|
95
|
+
</text>
|
|
96
|
+
<text fg={Colors.info}>
|
|
97
|
+
{model.dailyRequests !== null
|
|
98
|
+
? model.dailyRequests.toLocaleString()
|
|
99
|
+
: "-"}
|
|
100
|
+
</text>
|
|
101
|
+
</box>
|
|
102
|
+
|
|
103
|
+
{/* Error message (if any) */}
|
|
104
|
+
{model.error && (
|
|
105
|
+
<text fg={Colors.error}>
|
|
106
|
+
{" error: "}
|
|
107
|
+
{model.error}
|
|
108
|
+
</text>
|
|
109
|
+
)}
|
|
110
|
+
</box>
|
|
111
|
+
)}
|
|
112
|
+
</box>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ModelHealthSection — displays health status of all configured AI providers.
|
|
118
|
+
*
|
|
119
|
+
* Auto-refreshes every 30 seconds when the dashboard is active.
|
|
120
|
+
* Supports multiple providers: Workers AI, OpenAI, Anthropic, Google, Azure.
|
|
121
|
+
* Click on a provider to expand detailed stats (latency, daily usage, error).
|
|
122
|
+
* Cancels in-flight probes on unmount; race-safe via generation counter.
|
|
123
|
+
*/
|
|
124
|
+
function ModelHealthSection() {
|
|
125
|
+
const [providers, setProviders] = useState<ModelHealth[]>([]);
|
|
126
|
+
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
|
127
|
+
const [loading, setLoading] = useState(true);
|
|
128
|
+
const [error, setError] = useState<string | null>(null);
|
|
129
|
+
const [lastRefresh, setLastRefresh] = useState<number | null>(null);
|
|
130
|
+
|
|
131
|
+
// Initial fetch + 30s polling with cancel + race safety
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
let cancelled = false;
|
|
134
|
+
let generation = 0;
|
|
135
|
+
let inFlight = false;
|
|
136
|
+
|
|
137
|
+
const fetchHealth = async () => {
|
|
138
|
+
if (cancelled || inFlight) return;
|
|
139
|
+
inFlight = true;
|
|
140
|
+
const gen = ++generation;
|
|
141
|
+
try {
|
|
142
|
+
const result = await cliBridge.agentHealthCheck();
|
|
143
|
+
if (cancelled || gen !== generation) return;
|
|
144
|
+
if (result.success && result.data) {
|
|
145
|
+
setProviders(result.data.providers ?? []);
|
|
146
|
+
setError(null);
|
|
147
|
+
setLastRefresh(Date.now());
|
|
148
|
+
} else {
|
|
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);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
void fetchHealth();
|
|
166
|
+
const interval = setInterval(() => {
|
|
167
|
+
void fetchHealth();
|
|
168
|
+
}, MODEL_HEALTH_POLL_MS);
|
|
169
|
+
return () => {
|
|
170
|
+
cancelled = true;
|
|
171
|
+
clearInterval(interval);
|
|
172
|
+
};
|
|
173
|
+
}, []);
|
|
174
|
+
|
|
175
|
+
const toggleExpand = useCallback((index: number) => {
|
|
176
|
+
setExpandedIndex((prev) => (prev === index ? null : index));
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
if (loading && providers.length === 0) {
|
|
180
|
+
return (
|
|
181
|
+
<box flexDirection="column" gap={0}>
|
|
182
|
+
<text fg={Colors.foreground} bold dim>
|
|
183
|
+
AI MODEL HEALTH
|
|
184
|
+
</text>
|
|
185
|
+
<text fg={Colors.muted} dim paddingTop={1}>
|
|
186
|
+
Checking provider status…
|
|
187
|
+
</text>
|
|
188
|
+
</box>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (providers.length === 0) {
|
|
193
|
+
return (
|
|
194
|
+
<box flexDirection="column" gap={0}>
|
|
195
|
+
<text fg={Colors.foreground} bold dim>
|
|
196
|
+
AI MODEL HEALTH
|
|
197
|
+
</text>
|
|
198
|
+
<text fg={error ? Colors.warning : Colors.muted} dim paddingTop={1}>
|
|
199
|
+
{error
|
|
200
|
+
? `Unavailable: ${error.length > 50 ? error.slice(0, 47) + "…" : error}`
|
|
201
|
+
: "No AI providers configured"}
|
|
202
|
+
</text>
|
|
203
|
+
</box>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Summary counts
|
|
208
|
+
const onlineCount = providers.filter((p) => p.status === "online").length;
|
|
209
|
+
const degradedCount = providers.filter((p) => p.status === "degraded").length;
|
|
210
|
+
const offlineCount = providers.filter((p) => p.status === "offline").length;
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<box flexDirection="column" gap={0}>
|
|
214
|
+
{/* Section header */}
|
|
215
|
+
<box flexDirection="row" gap={2}>
|
|
216
|
+
<text fg={Colors.foreground} bold dim>
|
|
217
|
+
AI MODEL HEALTH
|
|
218
|
+
</text>
|
|
219
|
+
|
|
220
|
+
{/* Status summary badges */}
|
|
221
|
+
{onlineCount > 0 && (
|
|
222
|
+
<text fg={Colors.success} dim>
|
|
223
|
+
{onlineCount} online
|
|
224
|
+
</text>
|
|
225
|
+
)}
|
|
226
|
+
{degradedCount > 0 && (
|
|
227
|
+
<text fg={Colors.warning} dim>
|
|
228
|
+
{degradedCount} degraded
|
|
229
|
+
</text>
|
|
230
|
+
)}
|
|
231
|
+
{offlineCount > 0 && (
|
|
232
|
+
<text fg={Colors.error} dim>
|
|
233
|
+
{offlineCount} offline
|
|
234
|
+
</text>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{error && (
|
|
238
|
+
<text fg={Colors.warning} dim>
|
|
239
|
+
probe warn
|
|
240
|
+
</text>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Last refresh timestamp */}
|
|
244
|
+
{lastRefresh !== null && (
|
|
245
|
+
<text fg={Colors.muted} dim>
|
|
246
|
+
{`updated ${new Date(lastRefresh).toLocaleTimeString()}`}
|
|
247
|
+
</text>
|
|
248
|
+
)}
|
|
249
|
+
</box>
|
|
250
|
+
|
|
251
|
+
{/* Provider list */}
|
|
252
|
+
<box
|
|
253
|
+
flexDirection="column"
|
|
254
|
+
gap={0}
|
|
255
|
+
paddingTop={1}
|
|
256
|
+
border={true}
|
|
257
|
+
borderStyle="single"
|
|
258
|
+
borderColor={Colors.border}
|
|
259
|
+
backgroundColor={Colors.card}
|
|
260
|
+
paddingX={1}
|
|
261
|
+
paddingY={0}
|
|
262
|
+
>
|
|
263
|
+
{providers.map((model, index) => (
|
|
264
|
+
<ModelHealthRow
|
|
265
|
+
key={model.name}
|
|
266
|
+
model={model}
|
|
267
|
+
isExpanded={expandedIndex === index}
|
|
268
|
+
onToggle={() => toggleExpand(index)}
|
|
269
|
+
/>
|
|
270
|
+
))}
|
|
271
|
+
</box>
|
|
272
|
+
</box>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export { ModelHealthSection };
|