feat: 完善告警规则配置页面 — 增删改查弹窗 + 修复白屏bug + UpdateRule notify_channels序列化修复
- AlertManager: 新建/编辑告警规则弹窗(规则名称/描述/类型/数据源/表达式/级别/持续时间/通知渠道Switch)
- AlertManager: 修复白屏bug — getRules返回{list,total}未解包导致rules.map崩溃(Frontend Pitfall #1)
- AlertManager: 修复规则列表字段显示 — 原引用不存在的rule.metric/operator/threshold字段,改为rule.source/expression
- AlertManager: 新增规则删除按钮(带确认)
- alert.go: 修复UpdateRule notify_channels序列化bug — 原直接赋值[]string到text列导致500,改用toJSON()
- 同步提交前序未提交改动(WebShell/K8s/Server/Login等组件改进)
This commit is contained in:
1 parent
170818c141
commit
00d5510366
13 files changed
+2170
-496
No files matched your search
@@ -2,9 +2,6 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,18 +23,12 @@ import {
|
||||
Plus,
|
||||
X,
|
||||
Server,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
Download,
|
||||
Upload,
|
||||
Copy,
|
||||
Settings,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { serverApi } from '@/services/api';
|
||||
|
||||
interface TerminalSession {
|
||||
@@ -46,7 +37,13 @@ interface TerminalSession {
|
||||
serverName: string;
|
||||
serverIp: string;
|
||||
connected: boolean;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
interface TermEntry {
|
||||
term: XTerm;
|
||||
ws: WebSocket;
|
||||
fit: FitAddon;
|
||||
opened: boolean;
|
||||
}
|
||||
|
||||
export function WebShell() {
|
||||
@@ -54,9 +51,13 @@ export function WebShell() {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [activeSession, setActiveSession] = useState<string | null>(null);
|
||||
const [selectedServer, setSelectedServer] = useState<string>('');
|
||||
const [command, setCommand] = useState('');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
// 每个会话的终端实例:sessionId -> { term, ws, fit, opened }
|
||||
const termsRef = useRef<Map<string, TermEntry>>(new Map());
|
||||
// 每个会话的容器 DOM
|
||||
const containerRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
const loadServers = async () => {
|
||||
try {
|
||||
@@ -72,6 +73,21 @@ export function WebShell() {
|
||||
loadServers();
|
||||
}, []);
|
||||
|
||||
// 卸载时关闭所有连接
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
termsRef.current.forEach(({ ws, term }) => {
|
||||
try {
|
||||
ws.close();
|
||||
term.dispose();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
termsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const runningServers = servers.filter((s) => s.status === 'running');
|
||||
|
||||
const createSession = () => {
|
||||
@@ -79,26 +95,94 @@ export function WebShell() {
|
||||
const server = servers.find((s) => s.id === selectedServer);
|
||||
if (!server) return;
|
||||
|
||||
const sessionId = `session-${Date.now()}`;
|
||||
const cols = 80;
|
||||
const rows = 24;
|
||||
|
||||
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);
|
||||
|
||||
const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${server.id}&cols=${cols}&rows=${rows}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
// 终端输入 -> WebSocket
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ status: 0, data }));
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket 输出 -> 终端
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.status === 0 && typeof msg.data === 'string') {
|
||||
term.write(msg.data);
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON,直接写入原始数据
|
||||
term.write(typeof event.data === 'string' ? event.data : '');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
term.write(`\x1b[32m正在连接 ${server.hostname} (${server.ip})...\x1b[0m\r\n`);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.id === sessionId ? { ...s, connected: false } : s))
|
||||
);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
termsRef.current.set(sessionId, { term, ws, fit, opened: false });
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
id: `session-${Date.now()}`,
|
||||
id: sessionId,
|
||||
serverId: server.id,
|
||||
serverName: server.hostname,
|
||||
serverIp: server.ip,
|
||||
connected: true,
|
||||
logs: [
|
||||
`Connecting to ${server.hostname} (${server.ip})...`,
|
||||
`Connection established.`,
|
||||
`Last login: ${new Date().toLocaleString()} from 192.168.1.100`,
|
||||
`[root@${server.hostname} ~]# `,
|
||||
],
|
||||
};
|
||||
|
||||
setSessions([...sessions, newSession]);
|
||||
setActiveSession(newSession.id);
|
||||
setSessions((prev) => [...prev, newSession]);
|
||||
setActiveSession(sessionId);
|
||||
setSelectedServer('');
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const closeSession = (sessionId: string) => {
|
||||
const entry = termsRef.current.get(sessionId);
|
||||
if (entry) {
|
||||
try {
|
||||
if (entry.ws.readyState === WebSocket.OPEN || entry.ws.readyState === WebSocket.CONNECTING) {
|
||||
entry.ws.close();
|
||||
}
|
||||
entry.term.dispose();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
termsRef.current.delete(sessionId);
|
||||
}
|
||||
containerRef.current.delete(sessionId);
|
||||
const newSessions = sessions.filter((s) => s.id !== sessionId);
|
||||
setSessions(newSessions);
|
||||
if (activeSession === sessionId) {
|
||||
@@ -106,93 +190,127 @@ export function WebShell() {
|
||||
}
|
||||
};
|
||||
|
||||
const executeCommand = () => {
|
||||
if (!command.trim() || !activeSession) return;
|
||||
|
||||
const session = sessions.find((s) => s.id === activeSession);
|
||||
if (!session) return;
|
||||
|
||||
const newLogs = [...session.logs, command];
|
||||
|
||||
// 模拟命令执行结果
|
||||
const mockResponses: Record<string, string> = {
|
||||
ls: 'total 32\ndrwxr-xr-x 5 root root 4096 Mar 30 10:00 .\ndrwxr-xr-x 18 root root 4096 Mar 30 09:00 ..\n-rw-r--r-- 1 root root 220 Mar 30 08:00 .bash_logout\n-rw-r--r-- 1 root root 3771 Mar 30 08:00 .bashrc\n-rw-r--r-- 1 root root 807 Mar 30 08:00 .profile\ndrwxr-xr-x 2 root root 4096 Mar 30 09:00 scripts\ndrwxr-xr-x 3 root root 4096 Mar 30 09:00 logs',
|
||||
pwd: '/root',
|
||||
'uname -a': 'Linux web-server-01 5.4.210-1.el7.elrepo.x86_64 #1 SMP x86_64 x86_64 x86_64 GNU/Linux',
|
||||
'df -h': 'Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 500G 180G 320G 36% /\n/dev/sdb1 1.0T 450G 550G 45% /data',
|
||||
'free -m': ' total used free shared buff/cache available\nMem: 65536 42000 8000 1024 15536 21000\nSwap: 8192 0 8192',
|
||||
'ps aux': 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nroot 1 0.0 0.1 18532 3200 ? Ss Mar30 0:03 /sbin/init\nroot 512 0.1 0.5 45200 12800 ? Ss Mar30 2:15 nginx: master process\nroot 1024 0.5 2.0 256000 51200 ? Sl Mar30 15:30 java -jar app.jar',
|
||||
top: 'top - 10:30:00 up 30 days, 12:00, 1 user, load average: 0.52, 0.58, 0.59\nTasks: 156 total, 1 running, 155 sleeping, 0 stopped, 0 zombie\n%Cpu(s): 25.0 us, 5.0 sy, 0.0 ni, 68.0 id, 0.0 wa, 0.0 hi, 2.0 si, 0.0 st',
|
||||
};
|
||||
|
||||
const cmd = command.trim().split(' ')[0];
|
||||
const response = mockResponses[command.trim()] || mockResponses[cmd] || `${cmd}: command executed successfully`;
|
||||
|
||||
newLogs.push(response);
|
||||
newLogs.push(`[${session.serverName} ~]# `);
|
||||
|
||||
setSessions(
|
||||
sessions.map((s) => (s.id === activeSession ? { ...s, logs: newLogs } : s))
|
||||
);
|
||||
setCommand('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
executeCommand();
|
||||
}
|
||||
};
|
||||
|
||||
// 自动滚动到底部
|
||||
// 窗口 resize 时适配当前激活的终端
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
|
||||
const handleResize = () => {
|
||||
if (!activeSession) return;
|
||||
const entry = termsRef.current.get(activeSession);
|
||||
if (!entry || !entry.opened) return;
|
||||
try {
|
||||
entry.fit.fit();
|
||||
const { cols, rows } = entry.term;
|
||||
entry.term.resize(cols, rows);
|
||||
if (entry.ws.readyState === WebSocket.OPEN) {
|
||||
entry.ws.send(JSON.stringify({ status: 1, cols, rows }));
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [activeSession, isFullscreen]);
|
||||
|
||||
// 激活会话切换后重新 fit
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
const entry = termsRef.current.get(activeSession);
|
||||
if (entry && entry.opened) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
entry.fit.fit();
|
||||
entry.term.focus();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [sessions, activeSession]);
|
||||
}, [activeSession, isFullscreen]);
|
||||
|
||||
// 快捷命令:直接发送到当前激活的 WebSocket
|
||||
const sendCommand = (cmd: string) => {
|
||||
if (!activeSession) return;
|
||||
const entry = termsRef.current.get(activeSession);
|
||||
if (entry && entry.ws.readyState === WebSocket.OPEN) {
|
||||
entry.ws.send(JSON.stringify({ status: 0, data: cmd + '\n' }));
|
||||
entry.term.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// 注册容器并首次挂载终端
|
||||
const registerContainer = (sessionId: string, el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
containerRef.current.set(sessionId, el);
|
||||
const entry = termsRef.current.get(sessionId);
|
||||
if (entry && !entry.opened) {
|
||||
try {
|
||||
entry.term.open(el);
|
||||
entry.opened = true;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
entry.fit.fit();
|
||||
entry.term.focus();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('打开终端失败:', e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
containerRef.current.delete(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const activeSessionData = sessions.find((s) => s.id === activeSession);
|
||||
const terminalHeight = isFullscreen ? 'calc(100vh - 140px)' : '500px';
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', isFullscreen && 'fixed inset-0 z-50 bg-slate-950 p-4')}>
|
||||
<div className={cn('space-y-4', isFullscreen && 'fixed inset-0 z-50 p-4')} style={isFullscreen ? { background: 'var(--dg-bg-primary)' } : undefined}>
|
||||
{/* Page Header */}
|
||||
{!isFullscreen && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">WebShell</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">远程服务器终端管理</p>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">WebShell</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">远程服务器终端管理</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建连接
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-slate-900 border-slate-800">
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100">新建终端连接</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">新建终端连接</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
选择要连接的服务器
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">选择服务器</label>
|
||||
<label className="text-sm font-medium text-[var(--dg-text-secondary)]">选择服务器</label>
|
||||
<Select value={selectedServer} onValueChange={setSelectedServer}>
|
||||
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue placeholder="选择服务器" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{runningServers.map((server) => (
|
||||
<SelectItem key={server.id} value={server.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{server.hostname}</span>
|
||||
<span className="text-slate-500">({server.ip})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
{runningServers.length === 0 ? (
|
||||
<SelectItem value="__none" disabled>暂无运行中的服务器</SelectItem>
|
||||
) : (
|
||||
runningServers.map((server) => (
|
||||
<SelectItem key={server.id} value={server.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{server.hostname}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">({server.ip})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -200,8 +318,8 @@ export function WebShell() {
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={createSession}
|
||||
disabled={!selectedServer}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
disabled={!selectedServer || selectedServer === '__none'}
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
连接
|
||||
@@ -214,8 +332,8 @@ export function WebShell() {
|
||||
)}
|
||||
|
||||
{/* Terminal Container */}
|
||||
<Card className="bg-slate-900 border-slate-800 flex-1">
|
||||
<CardHeader className="pb-2 border-b border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] flex-1">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{sessions.length > 0 ? (
|
||||
@@ -227,8 +345,8 @@ export function WebShell() {
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||
activeSession === session.id
|
||||
? 'bg-slate-800 text-slate-100'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)]'
|
||||
: 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -238,43 +356,32 @@ export function WebShell() {
|
||||
)}
|
||||
/>
|
||||
<span className="max-w-[120px] truncate">{session.serverName}</span>
|
||||
<button
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeSession(session.id);
|
||||
}}
|
||||
className="ml-1 p-0.5 rounded hover:bg-slate-700"
|
||||
className="ml-1 p-0.5 rounded hover:bg-[var(--dg-bg-hover)] cursor-pointer"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-slate-500">暂无连接</span>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">暂无连接</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{activeSessionData && (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
<span className="text-xs text-[var(--dg-text-muted)] mr-2">
|
||||
{activeSessionData.serverIp}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
@@ -283,65 +390,27 @@ export function WebShell() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{activeSessionData ? (
|
||||
<div className="flex flex-col" style={{ height: isFullscreen ? 'calc(100vh - 140px)' : '500px' }}>
|
||||
{/* Terminal Output */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 p-4 font-mono text-sm bg-slate-950 overflow-auto"
|
||||
>
|
||||
{activeSessionData.logs.map((log, index) => (
|
||||
<div key={index} className="whitespace-pre-wrap text-slate-300 leading-relaxed">
|
||||
{log.startsWith('[') && log.endsWith(']# ') ? (
|
||||
<span>
|
||||
<span className="text-emerald-400">{log.split('#')[0]}#</span>
|
||||
<span className="text-slate-100">{log.split('#')[1]}</span>
|
||||
</span>
|
||||
) : log.startsWith('total') || log.startsWith('drwx') || log.startsWith('-rw') ? (
|
||||
<span className="text-slate-400">{log}</span>
|
||||
) : log.startsWith('Filesystem') || log.startsWith('/dev') ? (
|
||||
<span className="text-slate-400">{log}</span>
|
||||
) : log.startsWith('USER') || log.startsWith('root') ? (
|
||||
<span className="text-slate-400">{log}</span>
|
||||
) : log.startsWith('Connecting') || log.startsWith('Connection') || log.startsWith('Last login') ? (
|
||||
<span className="text-slate-500">{log}</span>
|
||||
) : (
|
||||
<span className="text-slate-300">{log}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Command Input */}
|
||||
<div className="flex items-center gap-2 p-3 border-t border-slate-800 bg-slate-900">
|
||||
<span className="text-emerald-400 font-mono text-sm whitespace-nowrap">
|
||||
[{activeSessionData.serverName} ~]#
|
||||
</span>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入命令..."
|
||||
className="flex-1 bg-transparent border-0 text-slate-200 font-mono text-sm placeholder:text-slate-600 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
autoFocus
|
||||
{sessions.length > 0 ? (
|
||||
<div className="relative bg-[#0a0e14]" style={{ height: terminalHeight }}>
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
ref={(el) => registerContainer(session.id, el)}
|
||||
className={cn(
|
||||
'absolute inset-0 p-1',
|
||||
activeSession === session.id ? 'block' : 'hidden'
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={executeCommand}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-slate-500"
|
||||
style={{ height: isFullscreen ? 'calc(100vh - 140px)' : '500px' }}
|
||||
className="flex flex-col items-center justify-center text-[var(--dg-text-secondary)]"
|
||||
style={{ height: terminalHeight }}
|
||||
>
|
||||
<Terminal className="h-16 w-16 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">暂无活动连接</p>
|
||||
<p className="text-sm mt-1">点击"新建连接"开始远程会话</p>
|
||||
<p className="text-sm mt-1">点击“新建连接”开始远程会话</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -349,9 +418,9 @@ export function WebShell() {
|
||||
|
||||
{/* Quick Commands */}
|
||||
{activeSessionData && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-slate-400">快捷命令</CardTitle>
|
||||
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">快捷命令</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -361,11 +430,8 @@ export function WebShell() {
|
||||
key={cmd}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setCommand(cmd);
|
||||
setTimeout(executeCommand, 100);
|
||||
}}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
onClick={() => sendCommand(cmd)}
|
||||
>
|
||||
{cmd}
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user