- 新增 agentUtils.ts(31行): isOnline/timeAgo/asList 工具函数 - 新增 HostDetailDialog.tsx(201行): 主机详情弹窗+InfoCell+趋势图 - AgentMonitor.tsx 从 672 行降至 463 行, 远离 700 行硬上限 - 移除未使用的 recharts import
202 lines
8.2 KiB
TypeScript
202 lines
8.2 KiB
TypeScript
// 主机详情弹窗:展示历史指标趋势图 + 端口发现列表(从 AgentMonitor.tsx 提取) // Logan.Li
|
|
import { useState, useEffect } from 'react';
|
|
import { agentApi } from '@/services/api';
|
|
import { isOnline, timeAgo, asList } from './agentUtils';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Server, Activity, Network, Terminal, Loader2 } from 'lucide-react';
|
|
import {
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
} from 'recharts';
|
|
|
|
export 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 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>
|
|
<DialogDescription className="sr-only">查看主机详情和指标趋势</DialogDescription>
|
|
</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 HostDetailDialog;
|