Files
aiops/frontend/src/components/server/TerminalDialog.tsx
T
Logan.Li d666840ed0 fix: 修复SSH终端Escape按键
- 将弹框捕获的Escape控制字符直接转发到远端
- 阻止xterm对同一次Escape事件重复发送
2026-07-26 18:21:55 +08:00

341 lines
11 KiB
TypeScript

import { useState, useRef, useEffect, useLayoutEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Terminal as XTerm } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import "xterm/css/xterm.css";
import { Maximize2, Minimize2, X, Shield } from "lucide-react";
import { cn } from "@/lib/utils";
import {
attachTerminalResize,
fitTerminal,
mountXterm,
} from "@/lib/xterm-utils";
import { TerminalLoadingOverlay } from "@/components/common/TerminalLoadingOverlay";
import { SessionWatermark } from "@/components/common/SessionWatermark";
import { useUserStore } from "@/store";
import { credentialApi, serverCredentialApi } from "@/api";
import { toast } from "sonner";
import type { JsonData } from "@/types/common";
interface TerminalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serverId: number | null;
serverName: string;
serverIp: string;
/** 服务器是否有可用的认证凭据(密码或SSH密钥) */
hasAuth?: boolean;
}
/**
* 终端 Dialog — WebSocket SSH 终端 // Logan.Li
* 打开时自动连接,关闭时自动断开
*/
export function TerminalDialog({
open,
onOpenChange,
serverId,
serverName,
serverIp,
hasAuth = true,
}: TerminalDialogProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [hasOutput, setHasOutput] = useState(false);
// 凭据选择状态(hasAuth=false 时显示) // Logan.Li
const [credentials, setCredentials] = useState<JsonData[]>([]);
const [selectedCredId, setSelectedCredId] = useState<string>("");
const [assigning, setAssigning] = useState(false);
const [authReady, setAuthReady] = useState(hasAuth); // 认证就绪标志
const sendTerminalInput = (data: string) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ status: 0, data }));
return true;
}
return false;
};
// 加载凭据列表
useEffect(() => {
if (!open || hasAuth) return;
credentialApi
.list()
.then((res: JsonData) => setCredentials(res?.list || []))
.catch(() => {});
}, [open, hasAuth]);
// 关联凭据
const handleAssignCredential = async () => {
if (!selectedCredId || !serverId) return;
try {
setAssigning(true);
await serverCredentialApi.assign(serverId, Number(selectedCredId));
toast.success(t("common.operationSuccess"));
setAuthReady(true);
} catch (e) {
toast.error(e?.message || t("common.operationFailed"));
} finally {
setAssigning(false);
}
};
// 重置状态
useEffect(() => {
if (!open) {
setAuthReady(hasAuth);
setSelectedCredId("");
}
}, [open, hasAuth]);
// 连接终端(authReady=true 时才执行)
useEffect(() => {
if (!open || !serverId || !authReady) return;
setHasOutput(false);
// 创建终端
const term = new XTerm({
cursorBlink: true,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true,
theme: {
background: "#0a0e14",
foreground: "#d6d6d6",
cursor: "#d6d6d6",
selectionBackground: "rgba(255,255,255,0.2)",
},
});
const fit = new FitAddon();
term.loadAddon(fit);
termRef.current = term;
fitRef.current = fit;
// WebSocket 连接
const proto = location.protocol === "https:" ? "wss" : "ws";
const token = useUserStore.getState().token || "";
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24&token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
// Escape 由 Dialog 捕获后直接转发,xterm 跳过同一次事件以免重复发送。 // Logan.Li
term.attachCustomKeyEventHandler((event) => event.key !== "Escape");
term.onData(sendTerminalInput);
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === "string") {
setHasOutput(true);
term.write(msg.data);
}
} catch {
setHasOutput(true);
term.write(typeof event.data === "string" ? event.data : "");
}
};
ws.onopen = () => {
setConnected(true);
// 挂载后 fit + 同步 PTY 尺寸,避免右侧黑边 // Logan.Li
mountXterm(term, fit, () => containerRef.current, ws);
};
ws.onclose = () => {
setConnected(false);
setHasOutput(true);
term.write(`\r\n\x1b[31m[${t("server.connectionClosed")}]\r\n`);
};
ws.onerror = () => {
setHasOutput(true);
term.write(`\r\n\x1b[31m[${t("server.connectionError")}]\r\n`);
};
return () => {
try {
ws.close();
term.dispose();
} catch {
/* noop */
}
termRef.current = null;
wsRef.current = null;
setConnected(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, serverId, authReady]);
// 在浏览器绘制前完成 fit,避免最大化还原时短暂显示旧宽度画布。 // Logan.Li
useLayoutEffect(() => {
if (!open || !authReady) return;
let cleanup: (() => void) | undefined;
let fitFrame = 0;
let retry = 0;
const attach = () => {
const container = containerRef.current;
const fit = fitRef.current;
const term = termRef.current;
const ws = wsRef.current;
if (!container || !fit || !term) {
if (retry++ < 30) requestAnimationFrame(attach);
return;
}
cleanup = attachTerminalResize(container, fit, term, ws);
const applyFit = () => {
try {
fitTerminal(fit, term, ws);
} catch {
/* noop */
}
};
applyFit();
// 下一帧再校准一次,兼容 Radix Dialog 全屏样式刚提交的场景。 // Logan.Li
fitFrame = requestAnimationFrame(applyFit);
};
attach();
return () => {
if (fitFrame) cancelAnimationFrame(fitFrame);
cleanup?.();
};
}, [open, authReady, fullscreen]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
// Radix 在捕获阶段收到 Escape,阻止关窗并把控制字符送给远端 Vim。 // Logan.Li
onEscapeKeyDown={(event) => {
event.preventDefault();
sendTerminalInput("\x1b");
}}
className={cn(
"min-w-0 overflow-hidden bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0",
// 禁用 CSS 过渡:终端窗口应该即时缩放,不需要动画
"!duration-0 !transition-none",
fullscreen
? "!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0"
: "",
)}
style={
fullscreen
? { maxWidth: "100vw", width: "100vw", height: "100vh" }
: { maxWidth: "960px", width: "92vw" }
}
>
<DialogTitle className="sr-only">{t("common.podLogs")} - {serverName}</DialogTitle>
<DialogDescription className="sr-only">{t("system.sshTerminalDesc")}</DialogDescription>
{/* 终端头部 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm">
<div
className={cn(
"w-2 h-2 rounded-full",
connected ? "bg-emerald-400" : "bg-red-400",
)}
/>
<span className="text-[var(--dg-text-primary)] font-medium">
{serverName}
</span>
<span className="text-[var(--dg-text-muted)]">({serverIp})</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setFullscreen((current) => !current)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
{fullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 凭据选择器(无认证凭据时显示) */}
{!authReady && (
<div
className="p-6 flex flex-col items-center justify-center gap-4"
style={{ height: "480px" }}
>
<Shield className="h-12 w-12 dg-text-yellow" />
<div className="text-center">
<p className="text-[var(--dg-text-primary)] font-medium mb-1">
{t("server.noAuthCred")}
</p>
<p className="text-sm text-[var(--dg-text-muted)]">
{t("server.selectCredHint")}
</p>
</div>
{credentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)]">
{t("server.noCredAvailable")}
</p>
) : (
<select
value={selectedCredId}
onChange={(e) => setSelectedCredId(e.target.value)}
className="w-64 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
>
<option value="">{t("server.selectCred")}</option>
{credentials.map((c: JsonData) => (
<option key={c.id} value={String(c.id)}>
{c.type === "ssh_key" ? "🔑" : "🔒"} {c.name}
</option>
))}
</select>
)}
<Button
onClick={handleAssignCredential}
disabled={!selectedCredId || assigning}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
>
{assigning ? t("server.linking") : t("server.confirmConnect")}
</Button>
</div>
)}
{/* 终端容器 */}
{authReady && (
<div
className="relative min-w-0 overflow-hidden bg-[#0a0e14]"
style={{ height: fullscreen ? "calc(100vh - 49px)" : "520px" }}
>
<div
ref={containerRef}
className="xterm-terminal-host h-full min-w-0"
/>
<SessionWatermark className="opacity-60" />
<TerminalLoadingOverlay
show={!hasOutput}
label={t("server.terminalLoggingIn")}
/>
</div>
)}
</DialogContent>
</Dialog>
);
}