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:
1 parent
31c2e338a3
commit
5949ec5d0c
48 files changed
+2182
-956
No files matched your search
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user