feat: Agent采集uptime, 后端持久化kernel/uptime/diskIO, 前端磁盘BPS+IOPS展示

- Agent: system.go 添加 readUptime(), SystemStat 携带 Uptime
- Backend: Server 模型新增 Kernel 字段, ReceiveReport 持久化 kernel+uptime
- Backend: /agent/latest 返回 kernel/uptime/port_info/disk_io_info/disk_total
- Frontend: 磁盘监控改为 BPS+IOPS (4张卡片), 系统信息修复内核/运行时长/监听端口/磁盘容量
- 图表单位从 MB/s 改为 KB/s, 添加 formatBPS 工具函数
This commit is contained in:
attacker committed 2026-06-19 01:10:00 +00:00
1 parent 31c2e338a3
commit 5949ec5d0c
48 files changed
+2182 -956

No files matched your search

@@ -40,25 +40,36 @@ const AlertCenter: React.FC = () => {
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<any>(null);
const [quickSetupLoading, setQuickSetupLoading] = useState(false);
// 分页
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [total, setTotal] = useState(0);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);
}, [page, severityFilter, statusFilter]);
const fetchData = async () => {
await Promise.all([fetchAlerts(), fetchRules()]);
};
// Logan.Li — 获取告警列表
// Logan.Li — 获取告警列表(分页)
const fetchAlerts = async () => {
setLoading(true);
try {
const res = await alertCenterApi.getAlerts();
const raw = (res.list || []) as any[];
const res: any = await alertCenterApi.getAlerts({
page,
page_size: pageSize,
severity: severityFilter !== 'all' ? severityFilter : undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
});
const raw = (res.list || res.data?.list || []) as any[];
const data: Alert[] = raw.map(mapAlert);
setAlerts(data);
setTotal(res.total || res.data?.total || data.length);
setStats({
firing: data.filter(a => a.status === 'firing').length,
critical: data.filter(a => a.severity === 'critical' && a.status === 'firing').length,
@@ -160,67 +171,64 @@ const AlertCenter: React.FC = () => {
try { await agentApi.deleteRule(Number(rule.id)); toast.success('已删除'); fetchRules(); } catch { toast.error('删除失败'); }
};
const filteredAlerts = alerts.filter(alert => {
const matchSearch = alert.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
alert.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
alert.source.toLowerCase().includes(searchQuery.toLowerCase());
const matchSeverity = severityFilter === 'all' || alert.severity === severityFilter;
const matchStatus = statusFilter === 'all' || alert.status === statusFilter;
return matchSearch && matchSeverity && matchStatus;
});
const handleFilterChange = (type: 'severity' | 'status', value: string) => {
if (type === 'severity') setSeverityFilter(value);
else setStatusFilter(value);
setPage(1); // 过滤条件变化时重置到第一页
};
return (
<div className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<div className="space-y-3">
{/* 统计卡片 — 紧凑 */}
<div className="grid grid-cols-4 gap-3">
<Card className={stats.firing > 0 ? 'border-red-500 border-2' : ''}>
<CardContent className="p-4">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">触发中</p>
<p className={`text-2xl font-bold ${stats.firing > 0 ? 'text-red-600' : ''}`}>{stats.firing}</p>
<p className="text-xs text-muted-foreground">触发中</p>
<p className={`text-xl font-bold ${stats.firing > 0 ? 'text-red-600' : ''}`}>{stats.firing}</p>
</div>
<div className={`p-3 rounded-lg ${stats.firing > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
<Zap className={`w-5 h-5 ${stats.firing > 0 ? 'text-red-600' : 'text-gray-600'}`} />
<div className={`p-2 rounded-md ${stats.firing > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
<Zap className={`w-4 h-4 ${stats.firing > 0 ? 'text-red-600' : 'text-gray-600'}`} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">严重告警</p>
<p className="text-2xl font-bold text-red-600">{stats.critical}</p>
<p className="text-xs text-muted-foreground">严重告警</p>
<p className="text-xl font-bold text-red-600">{stats.critical}</p>
</div>
<div className="p-3 bg-red-100 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600" />
<div className="p-2 bg-red-100 rounded-md">
<AlertCircle className="w-4 h-4 text-red-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">警告告警</p>
<p className="text-2xl font-bold text-orange-600">{stats.warning}</p>
<p className="text-xs text-muted-foreground">警告告警</p>
<p className="text-xl font-bold text-orange-600">{stats.warning}</p>
</div>
<div className="p-3 bg-orange-100 rounded-lg">
<AlertTriangle className="w-5 h-5 text-orange-600" />
<div className="p-2 bg-orange-100 rounded-md">
<AlertTriangle className="w-4 h-4 text-orange-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">今日已解决</p>
<p className="text-2xl font-bold text-green-600">{stats.resolved}</p>
<p className="text-xs text-muted-foreground">今日已解决</p>
<p className="text-xl font-bold text-green-600">{stats.resolved}</p>
</div>
<div className="p-3 bg-green-100 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600" />
<div className="p-2 bg-green-100 rounded-md">
<CheckCircle className="w-4 h-4 text-green-600" />
</div>
</div>
</CardContent>
@@ -228,27 +236,27 @@ const AlertCenter: React.FC = () => {
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
<TabsTrigger value="alerts">告警列表</TabsTrigger>
<TabsTrigger value="rules">告警规则</TabsTrigger>
<TabsTrigger value="channels">通知渠道</TabsTrigger>
<TabsList className="grid w-full grid-cols-3 lg:w-[360px] h-9">
<TabsTrigger value="alerts" className="text-xs">告警列表</TabsTrigger>
<TabsTrigger value="rules" className="text-xs">告警规则</TabsTrigger>
<TabsTrigger value="channels" className="text-xs">通知渠道</TabsTrigger>
</TabsList>
{/* 告警列表 Tab */}
<TabsContent value="alerts" className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
<TabsContent value="alerts" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-[280px]">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="搜索告警标题、描述、来源..."
placeholder="搜索告警..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
className="pl-8 h-8 text-xs"
/>
</div>
<Select value={severityFilter} onValueChange={setSeverityFilter}>
<SelectTrigger className="w-[120px]">
<Select value={severityFilter} onValueChange={(v) => handleFilterChange('severity', v)}>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue placeholder="级别" />
</SelectTrigger>
<SelectContent>
@@ -258,8 +266,8 @@ const AlertCenter: React.FC = () => {
<SelectItem value="info">信息</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<Select value={statusFilter} onValueChange={(v) => handleFilterChange('status', v)}>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
@@ -270,76 +278,85 @@ const AlertCenter: React.FC = () => {
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={fetchAlerts}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>共 {total} 条</span>
<Button variant="outline" size="sm" onClick={fetchAlerts} className="h-7 text-xs px-2">
<RefreshCw className="w-3 h-3 mr-1" />刷新
</Button>
</div>
</div>
{/* 告警列表 */}
{/* 告警表格 — 紧凑 */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">告警信息</th>
<th className="px-4 py-3 text-left text-sm font-medium">级别</th>
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
<th className="px-4 py-3 text-left text-sm font-medium">来源</th>
<th className="px-4 py-3 text-left text-sm font-medium">持续时间</th>
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
<th className="px-3 py-2 text-left text-xs font-medium">告警信息</th>
<th className="px-3 py-2 text-left text-xs font-medium w-16">级别</th>
<th className="px-3 py-2 text-left text-xs font-medium w-16">状态</th>
<th className="px-3 py-2 text-left text-xs font-medium w-24">来源</th>
<th className="px-3 py-2 text-left text-xs font-medium w-20">持续</th>
<th className="px-3 py-2 text-left text-xs font-medium w-28">操作</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">加载中...</td></tr>
) : filteredAlerts.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">暂无告警</td></tr>
<tr><td colSpan={6} className="px-3 py-6 text-center text-xs text-muted-foreground">加载中...</td></tr>
) : alerts.length === 0 ? (
<tr><td colSpan={6} className="px-3 py-6 text-center text-xs text-muted-foreground">暂无告警</td></tr>
) : (
filteredAlerts.map((alert) => (
alerts.filter(alert => {
// 客户端也保留关键词搜索过滤
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return alert.title.toLowerCase().includes(q) ||
alert.description.toLowerCase().includes(q) ||
alert.source.toLowerCase().includes(q);
}).map((alert) => (
<tr key={alert.id} className="hover:bg-muted/50">
<td className="px-4 py-3">
<div className="font-medium">{alert.title}</div>
<div className="text-sm text-muted-foreground line-clamp-1">{alert.description}</div>
<div className="flex gap-1 mt-1">
{Object.entries(alert.labels).slice(0, 3).map(([k, v]) => (
<Badge key={k} variant="outline" className="text-xs">{k}={v}</Badge>
<td className="px-3 py-2">
<div className="font-medium text-xs">{alert.title}</div>
<div className="text-xs text-muted-foreground line-clamp-1">{alert.description}</div>
<div className="flex gap-1 mt-0.5">
{Object.entries(alert.labels).slice(0, 2).map(([k, v]) => (
<Badge key={k} variant="outline" className="text-[10px] px-1 py-0">{k}={v}</Badge>
))}
</div>
</td>
<td className="px-4 py-3">{getSeverityBadge(alert.severity)}</td>
<td className="px-4 py-3">{getStatusBadge(alert.status)}</td>
<td className="px-4 py-3 text-sm">
<td className="px-3 py-2">{getSeverityBadge(alert.severity)}</td>
<td className="px-3 py-2">{getStatusBadge(alert.status)}</td>
<td className="px-3 py-2 text-xs">
<div>{alert.source}</div>
<div className="text-muted-foreground">{alert.service}</div>
<div className="text-muted-foreground text-[10px]">{alert.service}</div>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
<td className="px-3 py-2 text-xs text-muted-foreground">
{alert.status === 'resolved' && alert.resolvedAt
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分钟`
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分钟`}
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分`
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分`}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm"
<td className="px-3 py-2">
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}>
<Eye className="w-4 h-4" />
<Eye className="w-3.5 h-3.5" />
</Button>
{alert.status === 'firing' && (
<Button variant="ghost" size="sm"
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => handleAcknowledge(alert.id)}>
<Check className="w-4 h-4 text-blue-600" />
<Check className="w-3.5 h-3.5 text-blue-600" />
</Button>
)}
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
<Button variant="ghost" size="sm"
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => handleResolve(alert.id)}>
<CheckCircle className="w-4 h-4 text-green-600" />
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
</Button>
)}
<Button variant="ghost" size="sm"
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}>
<VolumeX className="w-4 h-4 text-orange-600" />
<VolumeX className="w-3.5 h-3.5 text-orange-600" />
</Button>
</div>
</td>
@@ -349,6 +366,34 @@ const AlertCenter: React.FC = () => {
</tbody>
</table>
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-3 py-2 border-t text-xs">
<span className="text-muted-foreground">第 {page}/{totalPages} 页,共 {total} 条</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
上一页
</Button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
const pn = start + i;
if (pn > totalPages) return null;
return (
<Button key={pn} variant={pn === page ? 'default' : 'outline'} size="sm"
className="h-7 min-w-[28px] text-xs px-1"
onClick={() => setPage(pn)}>
{pn}
</Button>
);
})}
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
下一页
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
import {
Monitor, Plus, RefreshCw, Maximize2, Minimize2, BarChart3, Clock,
Server, Activity, Database, Wifi, WifiOff, ArrowUpDown, ArrowUp, ArrowDown,
ExternalLink
ExternalLink, Download, Trash2, Rocket
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -15,12 +15,17 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { dashboardApi, promDatasourceApi, zabbixDatasourceApi } from '@/services/api';
import { useUserStore } from '@/store';
import { dashboardApi, promDatasourceApi, zabbixDatasourceApi, agentApi, serverApi } from '@/services/api';
import { GrafanaPanelContent } from './GrafanaPanelContent';
import { ZabbixDashboard } from './ZabbixDashboard';
import { PrometheusDashboard } from './PrometheusDashboard';
import { http } from '@/services/http';
import { asList } from '@/components/agent/agentUtils';
import { AgentWizard } from '@/components/agent/AgentWizard';
import { Checkbox } from '@/components/ui/checkbox';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { useNavigate } from 'react-router-dom';
// ═══════════ Tab 配置 ═══════════
type TabId = 'agent' | 'zabbix' | 'prometheus' | 'dashboard';
@@ -32,16 +37,21 @@ const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
];
// ═══════════ Agent 主机监控表格 ═══════════
interface AgentHost { hostname: string; ip: string; cpu_usage: number; cpu_cores: number; mem_usage: number; mem_total: number; mem_used: number; disk_usage: number; agent_version: string; agent_status: string; os: string; region: string; }
interface AgentHost { server_id: number; hostname: string; ip: string; cpu_usage: number; cpu_cores: number; mem_usage: number; mem_total: number; mem_used: number; disk_usage: number; agent_version: string; agent_status: string; os: string; region: string; }
type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip';
const AgentTab: React.FC = () => {
const _confirm = useConfirm();
const navigate = useNavigate();
const [hosts, setHosts] = useState<AgentHost[]>([]);
const [loading, setLoading] = useState(true);
const [sf, setSf] = useState<SortField>('cpu_usage');
const [sd, setSd] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [wizardOpen, setWizardOpen] = useState(false);
const [wizardServer, setWizardServer] = useState<any>(null);
const totalPages = Math.max(1, Math.ceil(hosts.length / pageSize));
const fetch = async () => { try { setHosts(asList(await http.get('/agent/latest'))); } catch {} finally { setLoading(false); } };
useEffect(() => { fetch(); const t = setInterval(fetch, 30000); return () => clearInterval(t); }, []);
@@ -57,49 +67,133 @@ const AgentTab: React.FC = () => {
const bar = (v: number, l: string) => (
<div className="flex items-center gap-2 min-w-[90px]"><div className="flex-1 h-2.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden"><div className={`h-full rounded-full transition-all duration-500 ${v >= 80 ? 'bg-orange-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(v, 100)}%` }} /></div><span className={`w-14 text-right text-xs font-mono tabular-nums ${v >= 80 ? 'text-orange-400' : ''}`}>{l}</span></div>
);
const selAll = selected.size === hosts.length && hosts.length > 0;
const selAny = selected.size > 0;
const selIds = [...selected].map(Number).filter(n => !isNaN(n));
// 批量安装 Agent // Logan.Li
const [batchInstalling, setBatchInstalling] = useState(false);
const handleBatchInstall = async () => {
const token = useUserStore.getState().token;
if (!token) { toast.error('未登录'); return; }
const ok = await _confirm({ title: '批量安装 Agent', description: `确定对选中的 ${selIds.length} 台主机远程安装 Agent?安装将在后台异步执行。`, confirmText: '安装', variant: 'destructive' });
if (!ok) return;
setBatchInstalling(true);
try {
const res = await serverApi.batchInstallAgent(selIds, token);
toast.success(`已提交:批量安装 ${res?.total ?? selIds.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
setSelected(new Set());
setTimeout(() => fetch(), 30000);
} catch { toast.error('提交失败'); }
finally { setBatchInstalling(false); }
};
// 批量卸载 Agent // Logan.Li
const [batchUninstalling, setBatchUninstalling] = useState(false);
const handleBatchUninstall = async () => {
const ok = await _confirm({ title: '批量卸载 Agent', description: `确定卸载选中的 ${selIds.length} 台主机的 AIOps Agent?卸载将在后台异步执行。`, confirmText: '卸载', variant: 'destructive' });
if (!ok) return;
setBatchUninstalling(true);
try {
await agentApi.batchUninstall(selIds);
toast.success(`已提交:批量卸载 ${selIds.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
setSelected(new Set());
setTimeout(() => fetch(), 30000);
} catch { toast.error('提交失败'); }
finally { setBatchUninstalling(false); }
};
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载中…</p></div>;
if (!hosts.length) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">暂无 Agent 上报数据</p><p className="text-xs mt-1">请确保至少一台主机安装了 AIOps Agent</p></div>;
if (!hosts.length) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">暂无服务器资产</p><p className="text-xs mt-1">请先在「服务器管理」中添加主机,然后在此安装 Agent</p></div>;
return (
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)] border-b"><tr>
{['hostname','agent_version','agent_status','cpu_usage','mem_usage','disk_usage','region','ip'].map(f => (
<th key={f} className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap" onClick={() => toggle(f as SortField)}>
<div className="flex items-center gap-1">{{hostname:'主机名',agent_version:'Agent版本',agent_status:'状态',cpu_usage:'CPU',mem_usage:'内存',disk_usage:'磁盘',region:'区域',ip:'IP'}[f]}<SI f={f as SortField} /></div>
<div>
{/* 工具栏 */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
{selAny && <span className="text-xs text-[var(--dg-text-secondary)]">已选 {selected.size} 项</span>}
<div className="flex-1" />
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={() => {
const picked = selAny ? hosts.find(h => selected.has(String(h.server_id))) : null;
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: (picked as any).has_auth } : null);
setWizardOpen(true);
}}>
<Rocket className="w-3 h-3" />安装 Agent
</Button>
{selAny && (
<>
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-blue-500/30 text-blue-400 hover:bg-blue-500/10" onClick={handleBatchInstall} disabled={batchInstalling}>
<Rocket className="w-3 h-3" />批量安装 ({selected.size})
</Button>
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleBatchUninstall} disabled={batchUninstalling}>
<Trash2 className="w-3 h-3" />批量卸载 ({selected.size})
</Button>
</>
)}
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch}><RefreshCw className="w-3 h-3" />刷新</Button>
</div>
<AgentWizard open={wizardOpen} onOpenChange={setWizardOpen} server={wizardServer}
onInstalled={() => { toast.success('Agent 安装成功'); setWizardOpen(false); fetch(); }}
onFailed={() => toast.error('安装失败')} />
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)] border-b"><tr>
<th className="px-2 py-2 w-8">
<Checkbox checked={selAll} onCheckedChange={(c) => setSelected(c ? new Set(hosts.map(h => String(h.server_id))) : new Set())} />
</th>
))}</tr></thead>
<tbody>
{paged.map((h, i) => (
<tr key={h.hostname + i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2.5 font-medium text-[var(--dg-text-primary)]">{h.hostname}</td>
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.agent_version || '—'}</td>
<td className="px-3 py-2.5"><Badge className={`text-[10px] ${h.agent_status==='online'?'bg-emerald-500/20 text-emerald-400':'bg-slate-500/10 text-slate-400'}`}>{h.agent_status==='online'?<><Wifi className="w-3 h-3 mr-1"/>在线</>:<><WifiOff className="w-3 h-3 mr-1"/>离线</>}</Badge></td>
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage||0), `${Number(h.cpu_usage||0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.mem_usage||0), `${Number(h.mem_usage||0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.disk_usage||0), `${Number(h.disk_usage||0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)]">{h.region||'—'}</td>
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.ip||'—'}</td>
</tr>
))}
</tbody>
</table>
{hosts.length > pageSize && (
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
<span>共 {hosts.length} 条</span>
<select className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}>
<option value={10}>10/页</option>
<option value={20}>20/页</option>
<option value={30}>30/页</option>
</select>
{['hostname','agent_version','agent_status','cpu_usage','mem_usage','disk_usage','region','ip'].map(f => (
<th key={f} className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap" onClick={() => toggle(f as SortField)}>
<div className="flex items-center gap-1">{{hostname:'主机名',agent_version:'Agent版本',agent_status:'状态',cpu_usage:'CPU',mem_usage:'内存',disk_usage:'磁盘',region:'区域',ip:'IP'}[f]}<SI f={f as SortField} /></div>
</th>
))}
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] whitespace-nowrap">操作</th></tr></thead>
<tbody>
{paged.map((h, i) => (
<tr key={h.hostname + i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-2 py-2.5">
<Checkbox checked={selected.has(String(h.server_id))} onCheckedChange={(c) => {
const next = new Set(selected);
c ? next.add(String(h.server_id)) : next.delete(String(h.server_id));
setSelected(next);
}} />
</td>
<td className="px-3 py-2.5 font-medium text-[var(--dg-text-primary)]">{h.hostname}</td>
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.agent_version || '—'}</td>
<td className="px-3 py-2.5">{h.agent_status==='online'?<Badge className="text-[10px] bg-emerald-500/20 text-emerald-400"><Wifi className="w-3 h-3 mr-1"/>在线</Badge>:h.agent_status==='not_installed'?<Badge className="text-[10px] bg-amber-500/20 text-amber-400">未安装</Badge>:<Badge className="text-[10px] bg-slate-500/10 text-slate-400"><WifiOff className="w-3 h-3 mr-1"/>离线</Badge>}</td>
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage||0), `${Number(h.cpu_usage||0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.mem_usage||0), `${Number(h.mem_usage||0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.disk_usage||0), `${Number(h.disk_usage||0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)]">{h.region||'—'}</td>
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.ip||'—'}</td>
<td className="px-3 py-2.5">
<Button variant="ghost" size="sm" className="h-7 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 px-2"
onClick={() => navigate(`/host-monitor?host=${encodeURIComponent(h.hostname)}`)}>
<BarChart3 className="w-3.5 h-3.5 mr-1" />图表
</Button>
</td>
</tr>
))}
</tbody>
</table>
{hosts.length > pageSize && (
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
<span>共 {hosts.length} 条</span>
<select className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}>
<option value={10}>10/页</option>
<option value={20}>20/页</option>
<option value={30}>30/页</option>
</select>
</div>
<div className="flex items-center gap-1">
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
<span className="px-2 text-[var(--dg-text-primary)]">{page} / {totalPages}</span>
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
</div>
<div className="flex items-center gap-1">
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
<span className="px-2 text-[var(--dg-text-primary)]">{page} / {totalPages}</span>
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
</div>
)}
)}
</div>
</div>
);
};