fix(frontend): 终端弹框适配与审计实时监控布局优化
- 修复 LiveMonitorDialog 连接中卡死:WebSocket 先连、延后挂载 xterm - 审计实时监控弹框对齐 WebSSH 布局:92vw 宽、520px 高、支持全屏 - 修复服务器终端弹框右侧黑边:fit 后同步 PTY resize,延迟二次 fit - 抽取 xterm-utils 公共适配逻辑,统一 ResizeObserver 处理
This commit is contained in:
1 parent
be2a377f01
commit
bfd6431b9d
4 files changed
+227
-125
No files matched your search
@@ -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<typeof setTimeout>;
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -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' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">终端 - {serverName}</DialogTitle>
|
||||
<DialogDescription className="sr-only">SSH 终端连接</DialogDescription>
|
||||
@@ -267,8 +255,8 @@ export function TerminalDialog({
|
||||
{authReady && (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-[#0a0e14]"
|
||||
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
|
||||
className="xterm-terminal-host"
|
||||
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '520px' }}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connectError, setConnectError] = useState('');
|
||||
const [meta, setMeta] = useState<SessionInfo | null>(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,64 +146,111 @@ 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-[95vw] max-h-[90vh] w-[1200px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-emerald-400" />
|
||||
终端实时监控
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
showOverlay={false}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||
'!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">终端实时监控</DialogTitle>
|
||||
<DialogDescription className="sr-only">审计在线 WebSSH 会话</DialogDescription>
|
||||
|
||||
{/* 工具栏 — 与 TerminalDialog 一致 // Logan.Li */}
|
||||
<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 min-w-0 flex-wrap">
|
||||
<Monitor className="h-4 w-4 text-emerald-400 shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">终端实时监控</span>
|
||||
{meta && (
|
||||
<span className="text-[var(--dg-text-muted)] truncate">
|
||||
{meta.username} · {meta.server_name} ({meta.server_ip}) · {formatTime(meta.start_time)}
|
||||
</span>
|
||||
)}
|
||||
{connected && (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 ml-2 text-xs">
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 text-xs">
|
||||
● 在线
|
||||
</Badge>
|
||||
)}
|
||||
{!connected && !connectError && (
|
||||
<Badge variant="outline" className="border-yellow-500/30 text-yellow-400 bg-yellow-500/10 ml-2 text-xs">
|
||||
<Badge variant="outline" className="border-yellow-500/30 text-yellow-400 bg-yellow-500/10 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||
连接中
|
||||
</Badge>
|
||||
)}
|
||||
{connectError && (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10 ml-2 text-xs">
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10 text-xs">
|
||||
连接失败
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
{meta && (
|
||||
<div className="flex items-center gap-4 text-xs mt-1 flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<UserIcon className="h-3 w-3" />
|
||||
{meta.username}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Server className="h-3 w-3" />
|
||||
{meta.server_name} ({meta.server_ip})
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(meta.start_time)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
title={fullscreen ? '退出全屏' : '全屏'}
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="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>
|
||||
|
||||
{connectError && (
|
||||
<p className="text-sm text-red-400 px-4 py-2 border-b border-[var(--dg-border)]">{connectError}</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full rounded-md overflow-hidden border border-[var(--dg-border)]"
|
||||
style={{ height: '55vh' }}
|
||||
className="xterm-terminal-host"
|
||||
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '520px' }}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -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) {
|
||||
*,
|
||||
|
||||
@@ -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<typeof setTimeout>;
|
||||
const ro = new ResizeObserver(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
try {
|
||||
fitTerminal(fit, term, ws);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, 60);
|
||||
});
|
||||
ro.observe(container);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
ro.disconnect();
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user