feat: WebShell真实SSH终端 + 移除日志能力菜单

This commit is contained in:
attacker committed 2026-06-15 02:28:28 +08:00
1 parent 925348e583
commit 3be53b89fa
6 files changed
+338 -50

No files matched your search

+1 -1
View File
@@ -90,7 +90,6 @@ const menuItems: MenuItem[] = [
children: [
{ id: 'metrics', name: '指标监控', icon: Cpu, path: '/metrics' },
{ id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center' },
{ id: 'logs-monitor', name: '日志能力', icon: FileCode, path: '/logs-monitor' },
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' },
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/grafana' },
{ id: 'agent-alerts', name: 'Agent告警', icon: AlertTriangle, path: '/agent-alerts' },
@@ -106,6 +105,7 @@ const menuItems: MenuItem[] = [
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' },
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' },
{ id: 'ansible', name: 'Ansible', icon: FileTerminal, path: '/ansible' },
{ id: 'sql-audit', name: 'SQL审计', icon: Shield, path: '/sql-audit' },
],
},
{
@@ -48,6 +48,7 @@ import {
import { serverApi } from '@/services/api';
import type { Server as ServerType } from '@/types';
import { CloudServerImport } from './CloudServerImport';
import { TerminalDialog } from './TerminalDialog';
import { ExcelServerImport } from './ExcelServerImport';
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
@@ -67,6 +68,15 @@ export function ServerManager() {
const [formSaving, setFormSaving] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
// 终端 Dialog 状态
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string } | null>(null);
const openTerminal = (server: any) => {
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip });
setTerminalOpen(true);
};
const loadData = async () => {
try {
setLoading(true);
@@ -558,7 +568,7 @@ export function ServerManager() {
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]">
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => openTerminal(server)}>
<Terminal className="h-4 w-4" />
</Button>
<DropdownMenu>
@@ -577,9 +587,9 @@ export function ServerManager() {
<Edit className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer" onClick={() => openTerminal(server)}>
<Terminal className="h-4 w-4 mr-2" />
WebShell
终端
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
@@ -635,6 +645,15 @@ export function ServerManager() {
loadData();
}}
/>
{/* 终端 Dialog */}
<TerminalDialog
open={terminalOpen}
onOpenChange={setTerminalOpen}
serverId={terminalServer?.id ?? null}
serverName={terminalServer?.name ?? ''}
serverIp={terminalServer?.ip ?? ''}
/>
</div>
);
}
@@ -0,0 +1,188 @@
import { useState, useRef, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { Maximize2, Minimize2, X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface TerminalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serverId: number | null;
serverName: string;
serverIp: string;
}
/**
* 终端 Dialog — WebSocket SSH 终端 // Logan.Li
* 打开时自动连接,关闭时自动断开
*/
export function TerminalDialog({
open,
onOpenChange,
serverId,
serverName,
serverIp,
}: TerminalDialogProps) {
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);
// 连接终端
useEffect(() => {
if (!open || !serverId) return;
// 创建终端
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 wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ status: 0, data }));
}
});
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
term.write(msg.data);
}
} catch {
term.write(typeof event.data === 'string' ? event.data : '');
}
};
ws.onopen = () => {
setConnected(true);
// 挂载终端到容器
if (containerRef.current) {
term.open(containerRef.current);
requestAnimationFrame(() => {
try {
fit.fit();
term.focus();
} catch { /* noop */ }
});
}
};
ws.onclose = () => {
setConnected(false);
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
};
ws.onerror = () => {
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
// resize 监听
const handleResize = () => {
if (fitRef.current && termRef.current) {
try {
fit.fit();
const { cols, rows } = termRef.current;
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ status: 1, cols, rows }));
}
} catch { /* noop */ }
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
try {
ws.close();
term.dispose();
} catch { /* noop */ }
termRef.current = null;
wsRef.current = null;
setConnected(false);
};
}, [open, serverId]);
// fullscreen 切换时重新 fit
useEffect(() => {
if (open && fitRef.current) {
requestAnimationFrame(() => {
try {
fitRef.current?.fit();
termRef.current?.focus();
} catch { /* noop */ }
});
}
}, [fullscreen, open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
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: '900px', width: '90vw' }}
>
<DialogTitle className="sr-only">终端 - {serverName}</DialogTitle>
<DialogDescription className="sr-only">SSH 终端连接</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(!fullscreen)}
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>
{/* 终端容器 */}
<div
ref={containerRef}
className="bg-[#0a0e14]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
</DialogContent>
</Dialog>
);
}