diff --git a/frontend/src/components/server/TerminalDialog.tsx b/frontend/src/components/server/TerminalDialog.tsx index b5d2f18..c77244f 100644 --- a/frontend/src/components/server/TerminalDialog.tsx +++ b/frontend/src/components/server/TerminalDialog.tsx @@ -11,6 +11,7 @@ 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 { useUserStore } from '@/store'; import { credentialApi, serverCredentialApi } from '@/services/api'; import { toast } from 'sonner'; @@ -127,15 +128,8 @@ export function TerminalDialog({ ws.onopen = () => { setConnected(true); - if (containerRef.current) { - term.open(containerRef.current); - requestAnimationFrame(() => { - try { - fit.fit(); - term.focus(); - } catch { /* noop */ } - }); - } + // 挂载后 fit + 同步 PTY 尺寸,避免右侧黑边 // Logan.Li + mountXterm(term, fit, () => containerRef.current, ws); }; ws.onclose = () => { @@ -158,39 +152,33 @@ export function TerminalDialog({ }; }, [open, serverId, authReady]); - // 用 ResizeObserver 监听容器实际尺寸变化,自动 fit 终端 - // 这比 window resize 或 setTimeout 更可靠: - // - 全屏/窗口切换时容器尺寸在 CSS 过渡期间持续变化,ResizeObserver 每次都会触发 - // - 不需要猜测延迟时间 + // 容器尺寸变化时 fit 并同步远端 PTY // Logan.Li useEffect(() => { - if (!open) return; - const container = containerRef.current; - if (!container) return; + if (!open || !authReady) return; - let resizeTimer: ReturnType; - const ro = new ResizeObserver(() => { - // 防抖:CSS 过渡期间会连续触发多次,合并为最后一次 - clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - if (fitRef.current && termRef.current && wsRef.current) { - try { - fitRef.current.fit(); - termRef.current.focus(); - const { cols, rows } = termRef.current; - if (wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ status: 1, cols, rows })); - } - } catch { /* noop */ } - } - }, 60); - }); + let cleanup: (() => void) | undefined; + let retry = 0; - ro.observe(container); - return () => { - clearTimeout(resizeTimer); - ro.disconnect(); + 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); + try { + fitTerminal(fit, term, ws); + } catch { + /* noop */ + } }; - }, [open]); + + attach(); + return () => cleanup?.(); + }, [open, authReady, fullscreen]); return ( @@ -204,7 +192,7 @@ export function TerminalDialog({ ? '!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: '900px', width: '90vw' }} + style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '960px', width: '92vw' }} > 终端 - {serverName} SSH 终端连接 @@ -267,8 +255,8 @@ export function TerminalDialog({ {authReady && (
)} diff --git a/frontend/src/components/system/LiveMonitorDialog.tsx b/frontend/src/components/system/LiveMonitorDialog.tsx index 2855a51..e83290c 100644 --- a/frontend/src/components/system/LiveMonitorDialog.tsx +++ b/frontend/src/components/system/LiveMonitorDialog.tsx @@ -1,4 +1,4 @@ -// 终端审计实时监控 Dialog — xterm.js 实时查看在线会话 // Logan.Li +// 终端审计实时监控 Dialog — 与 WebSSH 终端弹框一致的布局与适配 // Logan.Li import { useEffect, useRef, useState } from 'react'; import { Terminal as XTerm } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; @@ -7,11 +7,12 @@ import { Dialog, DialogContent, DialogDescription, - DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Badge } from '@/components/ui/badge'; -import { Loader2, Monitor, Server, User as UserIcon, Clock } from 'lucide-react'; +import { Loader2, Maximize2, Minimize2, Monitor, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { attachTerminalResize, mountXterm } from '@/lib/xterm-utils'; import { useUserStore } from '@/store'; interface SessionInfo { @@ -33,74 +34,71 @@ interface LiveMonitorProps { export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo }: LiveMonitorProps) { const containerRef = useRef(null); const termRef = useRef(null); + const fitRef = useRef(null); const wsRef = useRef(null); const [connected, setConnected] = useState(false); const [connectError, setConnectError] = useState(''); const [meta, setMeta] = useState(sessionInfo || null); + const [fullscreen, setFullscreen] = useState(false); + + useEffect(() => { + if (!open) { + setConnected(false); + setConnectError(''); + setFullscreen(false); + } + }, [open]); useEffect(() => { if (!open || !sessionId) return; - const container = containerRef.current; - if (!container) return; + setConnected(false); + setConnectError(''); - // Init xterm const term = new XTerm({ cursorBlink: false, fontSize: 13, fontFamily: 'Menlo, Monaco, "Courier New", monospace', disableStdin: true, + allowProposedApi: true, + scrollback: 5000, theme: { background: '#0a0e14', foreground: '#d6d6d6', cursor: '#d6d6d6', - selectionBackground: '#264f78', - black: '#1a1a2e', - red: '#ff6b6b', - green: '#69db7c', - yellow: '#ffd43b', - blue: '#4dabf7', - magenta: '#da77f2', - cyan: '#63e6be', - white: '#d6d6d6', - brightBlack: '#4a4a4a', - brightRed: '#ff8787', - brightGreen: '#8ce99a', - brightYellow: '#ffe066', - brightBlue: '#74c0fc', - brightMagenta: '#e599f7', - brightCyan: '#7ae5d5', - brightWhite: '#f8f9fa', + selectionBackground: 'rgba(255,255,255,0.2)', }, }); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); - term.open(container); - fitAddon.fit(); termRef.current = term; + fitRef.current = fitAddon; + + const token = + useUserStore.getState().token || + localStorage.getItem('token') || + (() => { + try { + const stored = JSON.parse(localStorage.getItem('user-storage') || '{}'); + return stored?.state?.token || ''; + } catch { + return ''; + } + })(); - // Connect WebSocket — try zustand store first, fallback to localStorage - let token = useUserStore.getState().token; - if (!token) { - try { - const stored = JSON.parse(localStorage.getItem('user-storage') || ''); - token = stored?.state?.token || ''; - } catch { /* ignore */ } - } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/audit/${sessionId}?token=${encodeURIComponent(token || '')}`; - console.log('[LiveMonitor] Connecting to:', wsUrl.replace(/token=[^&]+/, 'token=***')); + const wsUrl = `${protocol}//${window.location.host}/ws/audit/${sessionId}?token=${encodeURIComponent(token)}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; let didOpen = false; ws.onopen = () => { - console.log('[LiveMonitor] WS connected for session:', sessionId); didOpen = true; setConnected(true); setConnectError(''); + mountXterm(term, fitAddon, () => containerRef.current); term.writeln('\x1b[36m[审计监控] 已连接到会话...\x1b[0m'); }; @@ -110,31 +108,25 @@ export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo } switch (msg.type) { case 'session_info': setMeta(msg.payload); - term.writeln(`\x1b[36m用户: ${msg.payload.username} 服务器: ${msg.payload.server_name}\x1b[0m`); + term.writeln( + `\x1b[36m用户: ${msg.payload.username} 服务器: ${msg.payload.server_name} (${msg.payload.server_ip})\x1b[0m`, + ); break; case 'history': - if (msg.data) { - term.write(msg.data); - } + if (msg.data) term.write(msg.data); break; case 'output': - if (msg.data) { - term.write(msg.data); - } + if (msg.data) term.write(msg.data); break; } } catch { - // ignore parse errors + /* ignore */ } }; - ws.onerror = (e) => { - console.log('[LiveMonitor] WS error:', e); - setConnectError('WebSocket 连接失败'); - }; + ws.onerror = () => setConnectError('WebSocket 连接失败'); ws.onclose = (e) => { - console.log('[LiveMonitor] WS closed code:', e.code, 'reason:', e.reason); if (!didOpen) { setConnectError(`连接失败 (code=${e.code}): ${e.reason || '认证失败或网络问题'}`); } @@ -142,7 +134,6 @@ export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo } term.writeln('\r\n\x1b[33m[审计监控] 连接已断开\x1b[0m'); }; - // 5s 超时:如果还没连上,显示错误 const timeout = setTimeout(() => { if (!didOpen) { setConnectError('连接超时 — 请确认已登录且有活跃会话'); @@ -155,66 +146,113 @@ export function LiveMonitorDialog({ open, onOpenChange, sessionId, sessionInfo } ws.close(); term.dispose(); termRef.current = null; + fitRef.current = null; wsRef.current = null; setConnected(false); }; }, [open, sessionId]); - const formatTime = (t: string) => { - if (!t) return '--'; - return new Date(t).toLocaleString(); - }; + // 容器尺寸变化时持续 fit // Logan.Li + useEffect(() => { + if (!open) return; + + let cleanup: (() => void) | undefined; + let retry = 0; + + const attach = () => { + const container = containerRef.current; + const fit = fitRef.current; + const term = termRef.current; + if (!container || !fit || !term) { + if (retry++ < 30) requestAnimationFrame(attach); + return; + } + cleanup = attachTerminalResize(container, fit, term); + }; + + attach(); + return () => cleanup?.(); + }, [open, sessionId, fullscreen]); + + const formatTime = (t: string) => (t ? new Date(t).toLocaleString() : '--'); return ( - - - - - 终端实时监控 + + 终端实时监控 + 审计在线 WebSSH 会话 + + {/* 工具栏 — 与 TerminalDialog 一致 // Logan.Li */} +
+
+ + 终端实时监控 + {meta && ( + + {meta.username} · {meta.server_name} ({meta.server_ip}) · {formatTime(meta.start_time)} + + )} {connected && ( - + ● 在线 )} {!connected && !connectError && ( - + 连接中 )} {connectError && ( - + 连接失败 )} - - - {meta && ( -
- - - {meta.username} - - - - {meta.server_name} ({meta.server_ip}) - - - - {formatTime(meta.start_time)} - -
- )} -
- +
+
+ + +
+
+ + {connectError && ( +

{connectError}

+ )}
); -} +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index a842285..8a43450 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -718,6 +718,16 @@ button { animation: progress-indeterminate 1.5s ease-in-out infinite; } +/* ===== xterm.js 终端铺满容器,避免右侧黑边 // Logan.Li ===== */ +.xterm-terminal-host { + width: 100%; + overflow: hidden; + background: #0a0e14; +} +.xterm-terminal-host .xterm { + height: 100%; +} + /* ===== Reduced motion support ===== */ @media (prefers-reduced-motion: reduce) { *, diff --git a/frontend/src/lib/xterm-utils.ts b/frontend/src/lib/xterm-utils.ts new file mode 100644 index 0000000..48974f5 --- /dev/null +++ b/frontend/src/lib/xterm-utils.ts @@ -0,0 +1,66 @@ +import type { Terminal as XTerm } from 'xterm'; +import type { FitAddon } from 'xterm-addon-fit'; + +/** FitAddon 适配终端并同步远端 PTY 尺寸(WebSSH resize) // Logan.Li */ +export function fitTerminal(fit: FitAddon, term: XTerm, ws?: WebSocket | null) { + fit.fit(); + if (ws?.readyState === WebSocket.OPEN) { + const { cols, rows } = term; + ws.send(JSON.stringify({ status: 1, cols, rows })); + } +} + +/** 等待容器就绪后挂载 xterm,并延迟二次 fit 消除右侧黑边 // Logan.Li */ +export function mountXterm( + term: XTerm, + fit: FitAddon, + getContainer: () => HTMLDivElement | null, + ws?: WebSocket | null, +) { + const applyFit = () => { + try { + fitTerminal(fit, term, ws); + term.focus(); + } catch { + /* noop */ + } + }; + + const tryMount = () => { + const el = getContainer(); + if (!el) { + requestAnimationFrame(tryMount); + return; + } + term.open(el); + requestAnimationFrame(applyFit); + setTimeout(applyFit, 100); + }; + + tryMount(); +} + +/** ResizeObserver 监听容器尺寸,持续 fit // Logan.Li */ +export function attachTerminalResize( + container: HTMLDivElement, + fit: FitAddon, + term: XTerm, + ws?: WebSocket | null, +) { + let timer: ReturnType; + const ro = new ResizeObserver(() => { + clearTimeout(timer); + timer = setTimeout(() => { + try { + fitTerminal(fit, term, ws); + } catch { + /* noop */ + } + }, 60); + }); + ro.observe(container); + return () => { + clearTimeout(timer); + ro.disconnect(); + }; +} \ No newline at end of file