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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user