Skip to content

Commit 50454f2

Browse files
feat(ui-react): add inline error banner to terminal sessions
Replace raw ANSI error output with a structured inline banner that appears at the top of the terminal area on connection failures. - Add error map that resolves server error strings into user-friendly messages with contextual hints and navigation links - Parse WebSocket JSON frames (kind:4) as structured error messages - Show inline banner with title, message, hints, links, and retry - Dim terminal and hide cursor when error is displayed - Close terminal tab when clicking the X button or navigation links
1 parent d48fbd3 commit 50454f2

File tree

7 files changed

+417
-22
lines changed

7 files changed

+417
-22
lines changed

ui-react/apps/admin/src/components/terminal/TerminalControls.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import TerminalSettingsDrawer from "./TerminalSettingsDrawer";
1111

1212
/** Terminal info shown on the left side of the AppBar */
1313
export function TerminalInfo({ session }: { session: TerminalSession }) {
14-
const { connectionStatus: status } = session;
14+
const status = useTerminalStore(
15+
(s) =>
16+
s.sessions.find((ss) => ss.id === session.id)?.connectionStatus ??
17+
"disconnected",
18+
);
1519

1620
return (
1721
<div className="flex items-center gap-2.5 min-w-0">
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Link } from "react-router-dom";
2+
import { ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/24/outline";
3+
import { useTerminalStore } from "../../stores/terminalStore";
4+
import type { TerminalError } from "./terminalErrors";
5+
6+
interface TerminalErrorBannerProps {
7+
error: TerminalError;
8+
sessionId: string;
9+
}
10+
11+
export default function TerminalErrorBanner({
12+
error,
13+
sessionId,
14+
}: TerminalErrorBannerProps) {
15+
return (
16+
<div
17+
role="alert"
18+
className="bg-accent-red/[0.08] border-b border-accent-red/20 px-5 py-3.5 flex items-start gap-3 animate-slide-down"
19+
>
20+
<ExclamationCircleIcon
21+
className="w-4 h-4 text-accent-red shrink-0 mt-0.5"
22+
strokeWidth={1.5}
23+
/>
24+
<div className="flex-1 min-w-0">
25+
<div className="flex items-baseline gap-2 mb-1">
26+
<span className="text-sm font-semibold text-text-primary">
27+
{error.title}
28+
</span>
29+
<span className="text-sm text-text-muted">{error.message}</span>
30+
</div>
31+
{error.hints.length > 0 && (
32+
<p className="text-sm text-text-secondary leading-relaxed mb-1.5">
33+
{error.hints.join(" ")}
34+
</p>
35+
)}
36+
{(error.links.length > 0 || error.reconnect) && (
37+
<div className="flex items-center gap-3">
38+
{error.links.map((link) => (
39+
<Link
40+
key={link.to}
41+
to={link.to}
42+
onClick={() => useTerminalStore.getState().close(sessionId)}
43+
className="text-sm text-primary hover:text-primary-600 font-medium transition-colors"
44+
>
45+
{link.label}
46+
</Link>
47+
))}
48+
{error.links.length > 0 && error.reconnect && (
49+
<span className="w-px h-3.5 bg-border-light" />
50+
)}
51+
{error.reconnect && (
52+
<button
53+
type="button"
54+
onClick={() => {
55+
useTerminalStore.getState().closeAndReconnect(sessionId);
56+
}}
57+
className="px-2.5 py-1 bg-primary hover:bg-primary-600 text-white rounded text-xs font-semibold transition-colors"
58+
>
59+
Retry
60+
</button>
61+
)}
62+
</div>
63+
)}
64+
</div>
65+
<button
66+
type="button"
67+
aria-label="Close"
68+
onClick={() => useTerminalStore.getState().close(sessionId)}
69+
className="text-text-muted hover:text-text-primary transition-colors p-0.5 shrink-0"
70+
>
71+
<XMarkIcon className="w-3.5 h-3.5" />
72+
</button>
73+
</div>
74+
);
75+
}

ui-react/apps/admin/src/components/terminal/TerminalInstance.tsx

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useCallback } from "react";
1+
import { useEffect, useRef, useCallback, useState } from "react";
22
import { Terminal } from "@xterm/xterm";
33
import { FitAddon } from "@xterm/addon-fit";
44
import { WebLinksAddon } from "@xterm/addon-web-links";
@@ -7,31 +7,52 @@ import apiClient from "../../api/client";
77
import type { TerminalSession } from "../../stores/terminalStore";
88
import { useTerminalStore } from "../../stores/terminalStore";
99
import { useTerminalThemeStore } from "../../stores/terminalThemeStore";
10+
import type { TerminalError } from "./terminalErrors";
11+
import TerminalErrorBanner from "./TerminalErrorBanner";
12+
import {
13+
WS_KIND,
14+
HTTP_CONNECT_ERROR,
15+
WS_CLOSE_ERROR,
16+
WS_NETWORK_ERROR,
17+
parseMessage,
18+
resolveError,
19+
} from "./terminalErrors";
1020

1121
interface TerminalInstanceProps {
1222
session: TerminalSession;
1323
visible: boolean;
1424
}
1525

16-
export default function TerminalInstance({ session, visible }: TerminalInstanceProps) {
26+
export default function TerminalInstance({
27+
session,
28+
visible,
29+
}: TerminalInstanceProps) {
1730
const containerRef = useRef<HTMLDivElement>(null);
1831
const termRef = useRef<Terminal | null>(null);
1932
const fitRef = useRef<FitAddon | null>(null);
2033
const wsRef = useRef<WebSocket | null>(null);
2134
const observerRef = useRef<ResizeObserver | null>(null);
2235
const prevVisibleRef = useRef(visible);
36+
const [error, setError] = useState<TerminalError | null>(null);
2337

2438
const { theme, fontFamilyWithFallback, fontSize } = useTerminalThemeStore();
2539

26-
const updateStatus = useCallback((s: "connecting" | "connected" | "disconnected") => {
27-
useTerminalStore.getState().setConnectionStatus(session.id, s);
28-
}, [session.id]);
40+
const updateStatus = useCallback(
41+
(s: "connecting" | "connected" | "disconnected") => {
42+
useTerminalStore.getState().setConnectionStatus(session.id, s);
43+
},
44+
[session.id],
45+
);
2946

3047
// Connect on mount, cleanup on unmount
3148
useEffect(() => {
3249
let cancelled = false;
33-
const { theme: initTheme, fontFamilyWithFallback: initFont, fontSize: initSize } =
34-
useTerminalThemeStore.getState();
50+
let lastError = false;
51+
const {
52+
theme: initTheme,
53+
fontFamilyWithFallback: initFont,
54+
fontSize: initSize,
55+
} = useTerminalThemeStore.getState();
3556

3657
async function connect() {
3758
updateStatus("connecting");
@@ -45,7 +66,9 @@ export default function TerminalInstance({ session, visible }: TerminalInstanceP
4566
});
4667
token = res.data.token;
4768
} catch {
69+
if (cancelled) return;
4870
updateStatus("disconnected");
71+
setError(HTTP_CONNECT_ERROR);
4972
return;
5073
}
5174

@@ -88,34 +111,49 @@ export default function TerminalInstance({ session, visible }: TerminalInstanceP
88111
};
89112

90113
ws.onmessage = async (event) => {
114+
if (cancelled) return;
91115
if (event.data instanceof Blob) {
92116
term.write(await event.data.text());
93117
} else {
94-
term.write(event.data);
118+
const msg = parseMessage(event.data);
119+
if (msg?.kind === WS_KIND.ERROR) {
120+
lastError = true;
121+
updateStatus("disconnected");
122+
setError(resolveError(msg.data, session.deviceUid));
123+
} else if (!lastError) {
124+
term.write(event.data);
125+
}
95126
}
96127
};
97128

98129
ws.onclose = () => {
99130
if (cancelled) return;
100131
updateStatus("disconnected");
101-
term.write("\r\n\x1b[1;31mDisconnected.\x1b[0m\r\n");
132+
if (!lastError) {
133+
setError(WS_CLOSE_ERROR);
134+
}
102135
};
103136

104137
ws.onerror = () => {
105138
if (cancelled) return;
139+
lastError = true;
106140
updateStatus("disconnected");
107-
term.write("\r\n\x1b[1;31mConnection error.\x1b[0m\r\n");
141+
setError(WS_NETWORK_ERROR);
108142
};
109143

110144
term.onData((data) => {
111145
if (ws.readyState === WebSocket.OPEN) {
112-
ws.send(JSON.stringify({ kind: 1, data: data.slice(0, 4096) }));
146+
ws.send(
147+
JSON.stringify({ kind: WS_KIND.INPUT, data: data.slice(0, 4096) }),
148+
);
113149
}
114150
});
115151

116152
term.onResize(({ cols, rows }) => {
117153
if (ws.readyState === WebSocket.OPEN) {
118-
ws.send(JSON.stringify({ kind: 2, data: { cols, rows } }));
154+
ws.send(
155+
JSON.stringify({ kind: WS_KIND.RESIZE, data: { cols, rows } }),
156+
);
119157
}
120158
});
121159

@@ -135,15 +173,18 @@ export default function TerminalInstance({ session, visible }: TerminalInstanceP
135173
observerRef.current?.disconnect();
136174
observerRef.current = null;
137175
if (wsRef.current) {
176+
wsRef.current.onopen = null;
138177
wsRef.current.onclose = null;
178+
wsRef.current.onerror = null;
179+
wsRef.current.onmessage = null;
139180
wsRef.current.close();
140181
wsRef.current = null;
141182
}
142183
termRef.current?.dispose();
143184
termRef.current = null;
144185
fitRef.current = null;
145186
};
146-
// eslint-disable-next-line react-hooks/exhaustive-deps
187+
// eslint-disable-next-line react-hooks/exhaustive-deps
147188
}, [session.id]);
148189

149190
// Live theme/font updates
@@ -167,20 +208,35 @@ export default function TerminalInstance({ session, visible }: TerminalInstanceP
167208
fitRef.current?.fit();
168209
}, [fontSize]);
169210

211+
// Hide cursor on error
212+
useEffect(() => {
213+
const term = termRef.current;
214+
if (!term || error === null) return;
215+
term.options.cursorBlink = false;
216+
term.blur();
217+
term.write("\x1b[?25l"); // CSI sequence to hide cursor
218+
}, [error]);
219+
170220
// Handle visibility changes (minimize/restore)
171221
useEffect(() => {
172-
if (!prevVisibleRef.current && visible) {
222+
if (!prevVisibleRef.current && visible && error === null) {
173223
requestAnimationFrame(() => {
174224
fitRef.current?.fit();
175225
termRef.current?.focus();
176226
});
177227
}
178228
prevVisibleRef.current = visible;
179-
}, [visible]);
229+
}, [visible, error]);
180230

181231
return (
182-
<div className="flex-1 overflow-hidden">
183-
<div ref={containerRef} className="w-full h-full" />
232+
<div className="relative flex-1 flex flex-col overflow-hidden">
233+
{error !== null && (
234+
<TerminalErrorBanner error={error} sessionId={session.id} />
235+
)}
236+
<div
237+
ref={containerRef}
238+
className={`flex-1 ${error !== null ? "opacity-30 pointer-events-none" : ""}`}
239+
/>
184240
</div>
185241
);
186242
}

ui-react/apps/admin/src/components/terminal/TerminalManager.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,38 @@
1-
import { useEffect, useRef } from "react";
1+
import { useEffect, useRef, useState } from "react";
22
import { useLocation } from "react-router-dom";
33
import { useTerminalStore } from "../../stores/terminalStore";
4+
import { useNamespacesStore } from "../../stores/namespacesStore";
5+
import ConnectDrawer from "../ConnectDrawer";
46
import TerminalInstance from "./TerminalInstance";
57
import TerminalTaskbar from "./TerminalTaskbar";
68

79
export default function TerminalManager() {
810
const sessions = useTerminalStore((s) => s.sessions);
911
const minimizeAll = useTerminalStore((s) => s.minimizeAll);
12+
const reconnectTarget = useTerminalStore((s) => s.reconnectTarget);
13+
const currentNamespace = useNamespacesStore((s) => s.currentNamespace);
14+
15+
const [connectTarget, setConnectTarget] = useState<{
16+
uid: string;
17+
name: string;
18+
sshid: string;
19+
} | null>(null);
20+
21+
// Open ConnectDrawer when a reconnect is requested (works from any page)
22+
useEffect(() => {
23+
if (!reconnectTarget) return;
24+
useTerminalStore.getState().clearReconnect();
25+
const nsName = currentNamespace?.name;
26+
const sshid = nsName
27+
? `${nsName}.${reconnectTarget.deviceName}@${nsName}`
28+
: reconnectTarget.deviceUid;
29+
// eslint-disable-next-line react-hooks/set-state-in-effect
30+
setConnectTarget({
31+
uid: reconnectTarget.deviceUid,
32+
name: reconnectTarget.deviceName,
33+
sshid,
34+
});
35+
}, [reconnectTarget, currentNamespace]);
1036

1137
// Auto-minimize terminal when navigating to another page
1238
const location = useLocation();
@@ -20,6 +46,16 @@ export default function TerminalManager() {
2046

2147
return (
2248
<>
49+
{connectTarget && (
50+
<ConnectDrawer
51+
open
52+
onClose={() => setConnectTarget(null)}
53+
deviceUid={connectTarget.uid}
54+
deviceName={connectTarget.name}
55+
sshid={connectTarget.sshid}
56+
/>
57+
)}
58+
2359
{sessions.map((s) => {
2460
const isVisible = s.state !== "minimized";
2561
const isFullscreen = s.state === "fullscreen";

0 commit comments

Comments
 (0)