Files
aiops/frontend/src/components/agent/AgentMonitor.tsx
T
attacker e544360398 refactor: 拆分AgentMonitor.tsx(672→463行)提取HostDetailDialog+agentUtils子模块
- 新增 agentUtils.ts(31行): isOnline/timeAgo/asList 工具函数
- 新增 HostDetailDialog.tsx(201行): 主机详情弹窗+InfoCell+趋势图
- AgentMonitor.tsx 从 672 行降至 463 行, 远离 700 行硬上限
- 移除未使用的 recharts import
2026-06-16 05:04:13 +08:00

464 lines
18 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import { agentApi } from '@/services/api';
import { AgentWizard } from './AgentWizard';
import { HostDetailDialog } from './HostDetailDialog';
import { isOnline, timeAgo, asList } from './agentUtils';
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';
// ============ 组件 ============
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>
);
}
export default AgentMonitor;