feat: 脚本/定时任务真实执行+输出捕获+执行历史 — 替换pending空壳为本地执行,采集stdout/stderr,更新统计;修复CronJobManager/Automation字段名不匹配(status→enabled,cron→cron_expr,command→content,language→type)
This commit is contained in:
1 parent
85bc2a8e05
commit
a4fd5fc178
19 files changed
+2715
-114
No files matched your search
@@ -0,0 +1,671 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { agentApi } from '@/services/api';
|
||||
import { AgentWizard } from './AgentWizard';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
RefreshCw,
|
||||
Terminal,
|
||||
Rocket,
|
||||
Activity,
|
||||
Network,
|
||||
Loader2,
|
||||
Clock,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
/** 在线判定:3 分钟内有上报视为在线 */
|
||||
function isOnline(lastReport: any): boolean {
|
||||
if (!lastReport) return false;
|
||||
const t = new Date(lastReport).getTime();
|
||||
if (Number.isNaN(t)) return false;
|
||||
return Date.now() - t < 3 * 60 * 1000;
|
||||
}
|
||||
|
||||
function timeAgo(ts: any): string {
|
||||
if (!ts) return '--';
|
||||
const t = new Date(ts).getTime();
|
||||
if (Number.isNaN(t)) return '--';
|
||||
const diff = Math.floor((Date.now() - t) / 1000);
|
||||
if (diff < 60) return `${diff}秒前`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
function asList(data: any): any[] {
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data.list)) return data.list;
|
||||
if (Array.isArray(data.items)) return data.items;
|
||||
if (Array.isArray(data.data)) return data.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
// ============ 组件 ============
|
||||
|
||||
export function AgentMonitor() {
|
||||
const [hosts, setHosts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedHost, setSelectedHost] = useState<any>(null);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 远程命令
|
||||
const [cmdOpen, setCmdOpen] = useState(false);
|
||||
const [cmdHost, setCmdHost] = useState('');
|
||||
const [cmdType, setCmdType] = useState('command');
|
||||
const [cmdCommand, setCmdCommand] = useState('');
|
||||
const [cmdTimeout, setCmdTimeout] = useState('30');
|
||||
const [cmdSubmitting, setCmdSubmitting] = useState(false);
|
||||
const [cmdResult, setCmdResult] = useState<any>(null);
|
||||
const [cmdPolling, setCmdPolling] = useState(false);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const loadLatest = useCallback(async (silent = false) => {
|
||||
if (!silent) setRefreshing(true);
|
||||
try {
|
||||
const data = await agentApi.getLatest();
|
||||
const list = asList(data);
|
||||
setHosts(list);
|
||||
// 同步选中主机的最新数据
|
||||
if (selectedHost) {
|
||||
const fresh = list.find((h) => h.hostname === selectedHost.hostname);
|
||||
if (fresh) setSelectedHost(fresh);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!silent) toast.error('加载主机列表失败');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [selectedHost]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLatest();
|
||||
timerRef.current = setInterval(() => loadLatest(true), 30000);
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 打开远程命令弹窗
|
||||
const openCommandDialog = (hostname: string) => {
|
||||
setCmdHost(hostname);
|
||||
setCmdType('command');
|
||||
setCmdCommand('');
|
||||
setCmdTimeout('30');
|
||||
setCmdResult(null);
|
||||
setCmdOpen(true);
|
||||
};
|
||||
|
||||
// 提交远程命令并轮询结果
|
||||
const submitCommand = async () => {
|
||||
if (!cmdCommand.trim()) {
|
||||
toast.error('请输入要执行的命令');
|
||||
return;
|
||||
}
|
||||
setCmdSubmitting(true);
|
||||
setCmdResult(null);
|
||||
try {
|
||||
const res: any = await agentApi.createTask({
|
||||
hostname: cmdHost,
|
||||
type: cmdType,
|
||||
command: cmdCommand.trim(),
|
||||
timeout: Number(cmdTimeout) || 30,
|
||||
});
|
||||
const taskId = res?.id ?? res?.task_id;
|
||||
toast.success('任务已下发');
|
||||
setCmdSubmitting(false);
|
||||
|
||||
// 轮询任务结果
|
||||
if (taskId != null) {
|
||||
setCmdPolling(true);
|
||||
let attempts = 0;
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const tasks: any = await agentApi.listTasks({ hostname: cmdHost });
|
||||
const list = asList(tasks);
|
||||
const t = list.find((x: any) => String(x.id) === String(taskId));
|
||||
if (t && t.status && t.status !== 'pending' && t.status !== 'running') {
|
||||
setCmdResult(t);
|
||||
setCmdPolling(false);
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
loadLatest(true);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (attempts > 15) {
|
||||
// 30s 超时
|
||||
setCmdPolling(false);
|
||||
setCmdResult({ status: 'timeout', result: '等待结果超时,请稍后在任务列表查看' });
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '任务下发失败');
|
||||
setCmdSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeCommandDialog = () => {
|
||||
setCmdOpen(false);
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
setCmdPolling(false);
|
||||
};
|
||||
|
||||
const onlineCount = hosts.filter((h) => isOnline(h.last_report || h.last_reported_at || h.updated_at)).length;
|
||||
|
||||
const stats = [
|
||||
{ label: '主机总数', value: hosts.length, icon: Server, color: 'text-blue-400' },
|
||||
{ label: '在线', value: onlineCount, icon: Wifi, color: 'text-emerald-400' },
|
||||
{ label: '离线', value: hosts.length - onlineCount, icon: WifiOff, color: 'text-red-400' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-100">Agent 监控</h2>
|
||||
<p className="text-sm text-slate-400 mt-0.5">实时监控 Agent 上报的主机指标</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadLatest()}
|
||||
disabled={refreshing}
|
||||
className="bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700 hover:text-slate-100"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-1.5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setWizardOpen(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Rocket className="h-4 w-4 mr-1.5" />
|
||||
部署Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label} className="bg-slate-900 border-slate-800 py-4">
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center">
|
||||
<s.icon className={`h-5 w-5 ${s.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-100">{s.value}</div>
|
||||
<div className="text-xs text-slate-400">{s.label}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 主机列表 */}
|
||||
{loading ? (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
<span className="ml-2 text-slate-400">加载中...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : hosts.length === 0 ? (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<Server className="h-10 w-10 text-slate-600 mb-3" />
|
||||
<p className="text-slate-400">暂无 Agent 上报数据</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setWizardOpen(true)}
|
||||
className="mt-4 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Rocket className="h-4 w-4 mr-1.5" />
|
||||
立即部署
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{hosts.map((host) => {
|
||||
const online = isOnline(host.last_report || host.last_reported_at || host.updated_at);
|
||||
const cpu = Number(host.cpu_percent ?? host.cpu ?? 0);
|
||||
const mem = Number(host.mem_percent ?? host.mem ?? 0);
|
||||
const disk = Number(host.disk_percent ?? host.disk ?? 0);
|
||||
const ports: any[] = Array.isArray(host.ports) ? host.ports : [];
|
||||
return (
|
||||
<Card
|
||||
key={host.hostname}
|
||||
className="bg-slate-900 border-slate-800 hover:border-slate-700 transition-colors cursor-pointer py-4"
|
||||
onClick={() => setSelectedHost(host)}
|
||||
>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Server className="h-4 w-4 text-slate-500 shrink-0" />
|
||||
<span className="font-medium text-slate-100 truncate">{host.hostname}</span>
|
||||
</div>
|
||||
{online ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
<Wifi className="h-3 w-3 mr-1" />
|
||||
在线
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/15 text-red-400 border border-red-500/30">
|
||||
<WifiOff className="h-3 w-3 mr-1" />
|
||||
离线
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 指标条 */}
|
||||
<div className="space-y-2">
|
||||
<MetricBar icon={Cpu} label="CPU" value={cpu} />
|
||||
<MetricBar icon={MemoryStick} label="内存" value={mem} />
|
||||
<MetricBar icon={HardDrive} label="磁盘" value={disk} />
|
||||
</div>
|
||||
|
||||
{/* 端口 & 时间 */}
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
{ports.length} 个端口
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{timeAgo(host.last_report || host.last_reported_at || host.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
<div className="flex gap-2 pt-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 h-8 bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700"
|
||||
onClick={() => openCommandDialog(host.hostname)}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5 mr-1" />
|
||||
远程命令
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 h-8 bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700"
|
||||
onClick={() => setSelectedHost(host)}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5 mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情抽屉 */}
|
||||
<HostDetailDialog
|
||||
host={selectedHost}
|
||||
onClose={() => setSelectedHost(null)}
|
||||
onCommand={(hn) => openCommandDialog(hn)}
|
||||
/>
|
||||
|
||||
{/* 远程命令弹窗 */}
|
||||
<Dialog open={cmdOpen} onOpenChange={(v) => { if (!v) closeCommandDialog(); }}>
|
||||
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800 text-slate-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-blue-400" />
|
||||
远程命令
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
在 {cmdHost} 上执行命令
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">目标主机</Label>
|
||||
<Input
|
||||
value={cmdHost}
|
||||
onChange={(e) => setCmdHost(e.target.value)}
|
||||
readOnly
|
||||
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">类型</Label>
|
||||
<Select value={cmdType} onValueChange={setCmdType}>
|
||||
<SelectTrigger className="h-9 bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectItem value="command">Shell 命令</SelectItem>
|
||||
<SelectItem value="script">多行脚本</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">命令</Label>
|
||||
<Textarea
|
||||
value={cmdCommand}
|
||||
onChange={(e) => setCmdCommand(e.target.value)}
|
||||
placeholder="例如:free -m && df -h"
|
||||
rows={3}
|
||||
className="bg-slate-950 border-slate-700 text-slate-200 font-mono text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-40 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">超时(秒)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={cmdTimeout}
|
||||
onChange={(e) => setCmdTimeout(e.target.value)}
|
||||
className="h-9 bg-slate-800 border-slate-700 text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 结果展示 */}
|
||||
{cmdResult && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-slate-300 text-xs">执行结果</Label>
|
||||
<Badge
|
||||
className={
|
||||
cmdResult.status === 'success' || cmdResult.status === 'done' || cmdResult.status === 'completed'
|
||||
? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30'
|
||||
: cmdResult.status === 'timeout'
|
||||
? 'bg-amber-500/15 text-amber-400 border border-amber-500/30'
|
||||
: 'bg-red-500/15 text-red-400 border border-red-500/30'
|
||||
}
|
||||
>
|
||||
{cmdResult.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<pre className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all bg-slate-950 border border-slate-800 rounded-lg p-3 max-h-48 overflow-auto">
|
||||
{cmdResult.result || cmdResult.output || cmdResult.error || '无输出'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={closeCommandDialog} className="border-slate-700 text-slate-300">
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submitCommand}
|
||||
disabled={cmdSubmitting || cmdPolling || !cmdCommand.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{cmdSubmitting ? (
|
||||
<><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />下发中</>
|
||||
) : cmdPolling ? (
|
||||
<><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />等待结果</>
|
||||
) : (
|
||||
<><Terminal className="h-4 w-4 mr-1.5" />执行</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 部署向导 */}
|
||||
<AgentWizard open={wizardOpen} onOpenChange={setWizardOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 指标条子组件 ============
|
||||
function MetricBar({ icon: Icon, label, value }: { icon: any; label: string; value: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-3.5 w-3.5 text-slate-500 shrink-0" />
|
||||
<span className="text-xs text-slate-400 w-8 shrink-0">{label}</span>
|
||||
<div className="flex-1">
|
||||
<Progress value={value} className={`h-1.5 ${value >= 90 ? '[&>[data-slot=progress-indicator]]:bg-red-500' : value >= 75 ? '[&>[data-slot=progress-indicator]]:bg-amber-500' : '[&>[data-slot=progress-indicator]]:bg-emerald-500'}`} />
|
||||
</div>
|
||||
<span className={`text-xs font-medium w-10 text-right ${value >= 90 ? 'text-red-400' : value >= 75 ? 'text-amber-400' : 'text-slate-300'}`}>
|
||||
{value.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 主机详情弹窗 ============
|
||||
function HostDetailDialog({
|
||||
host,
|
||||
onClose,
|
||||
onCommand,
|
||||
}: {
|
||||
host: any;
|
||||
onClose: () => void;
|
||||
onCommand: (hostname: string) => void;
|
||||
}) {
|
||||
const [history, setHistory] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hours, setHours] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) return;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
agentApi
|
||||
.getHistory(host.hostname, hours)
|
||||
.then((data) => {
|
||||
if (active) setHistory(asList(data));
|
||||
})
|
||||
.catch(() => { if (active) setHistory([]); })
|
||||
.finally(() => { if (active) setLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [host, hours]);
|
||||
|
||||
if (!host) return null;
|
||||
|
||||
const online = isOnline(host.last_report || host.last_reported_at || host.updated_at);
|
||||
const cpu = Number(host.cpu_percent ?? host.cpu ?? 0);
|
||||
const mem = Number(host.mem_percent ?? host.mem ?? 0);
|
||||
const disk = Number(host.disk_percent ?? host.disk ?? 0);
|
||||
const ports: any[] = Array.isArray(host.ports) ? host.ports : [];
|
||||
|
||||
const chartData = history.map((h) => ({
|
||||
time: new Date(h.timestamp || h.time || h.created_at || Date.now()).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
cpu: Number(h.cpu_percent ?? h.cpu ?? 0),
|
||||
mem: Number(h.mem_percent ?? h.mem ?? 0),
|
||||
disk: Number(h.disk_percent ?? h.disk ?? 0),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Dialog open={!!host} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="max-w-3xl bg-slate-900 border-slate-800 text-slate-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-blue-400" />
|
||||
{host.hostname}
|
||||
{online ? (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">在线</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/15 text-red-400 border border-red-500/30">离线</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<InfoCell label="IP 地址" value={host.ip || '--'} />
|
||||
<InfoCell label="操作系统" value={host.os || host.platform || '--'} />
|
||||
<InfoCell label="最后上报" value={timeAgo(host.last_report || host.last_reported_at || host.updated_at)} />
|
||||
<InfoCell label="端口数" value={String(ports.length)} />
|
||||
</div>
|
||||
|
||||
{/* 趋势图 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-slate-200 flex items-center gap-1.5">
|
||||
<Activity className="h-4 w-4 text-blue-400" />
|
||||
指标趋势
|
||||
</span>
|
||||
<Select value={String(hours)} onValueChange={(v) => setHours(Number(v))}>
|
||||
<SelectTrigger size="sm" className="h-7 w-28 bg-slate-800 border-slate-700 text-slate-300 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
|
||||
<SelectItem value="1">最近 1 小时</SelectItem>
|
||||
<SelectItem value="6">最近 6 小时</SelectItem>
|
||||
<SelectItem value="24">最近 24 小时</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="h-56 rounded-lg bg-slate-950 border border-slate-800 p-3">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-slate-500" />
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-sm text-slate-500">暂无历史数据</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||
<defs>
|
||||
<linearGradient id="gCpu" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gMem" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="gDisk" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="time" stroke="#475569" fontSize={11} />
|
||||
<YAxis stroke="#475569" fontSize={11} domain={[0, 100]} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #1e293b', borderRadius: '6px', color: '#f8fafc', fontSize: '12px' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="cpu" stroke="#3b82f6" fill="url(#gCpu)" name="CPU" />
|
||||
<Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#gMem)" name="内存" />
|
||||
<Area type="monotone" dataKey="disk" stroke="#f59e0b" fill="url(#gDisk)" name="磁盘" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 端口列表 */}
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-200 flex items-center gap-1.5 mb-2">
|
||||
<Network className="h-4 w-4 text-blue-400" />
|
||||
端口发现 ({ports.length})
|
||||
</span>
|
||||
{ports.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">暂无端口数据</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto">
|
||||
{ports.map((p, idx) => (
|
||||
<Badge key={idx} className="bg-slate-800 text-slate-300 border border-slate-700">
|
||||
<Network className="h-3 w-3 mr-1" />
|
||||
{p.port ?? p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
onClick={() => onCommand(host.hostname)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-1.5" />
|
||||
远程命令
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onClose} className="border-slate-700 text-slate-300">
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-slate-950 border border-slate-800 rounded-lg px-3 py-2">
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
<div className="text-sm text-slate-200 font-medium truncate">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentMonitor;
|
||||
Reference in new issue
Block a user