341 lines
11 KiB
TypeScript
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")}][0m\r\n`);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
setHasOutput(true);
|
|
term.write(`\r\n\x1b[31m[${t("server.connectionError")}][0m\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>
|
|
);
|
|
}
|