feat: 监控告警体系统一重构 + 自动化任务调度引擎 + 监控大屏拆分
告警中心 - 新增统一告警(alert_unified)、监控告警规则(monitor_alert_rule)、指标目录(alert_metric_catalog) - 通知策略与消息分发(alert_notify_policy/dispatch/message)、静默规则 - 告警引擎按阈值/SNMP 拆分(alert_engine_monitor/snmp) 监控采集 - SNMP 常量/模板与历史指标(snmp templates/constants、snmp_metrics_history、snmp_collect) - 中间件监控 Redis 采集与种子(middleware_monitor_redis/seed) - 站点探测 pkg(siteprobe) + 站点视图(site_view) 自动化任务 - cron 调度器(cronscheduler)、脚本运行器(scriptrunner)、系统任务(systask)及运行记录(cron_job_run、system_task) 审计与凭证 - 审计设置(audit_setting) + 历史清理(auditcleaner) 前端 - 监控页面(host/middleware/site/snmp)拆分为子模块并大屏化 - 告警中心多 Tab(规则/指标目录/通知策略/静默)、任务管理组件重构、Agent CLI Markdown 渲染 - 移除旧脚本卡片/详情/日志组件,统一为 ScriptTable/ScriptCodeEditor/TerminalOutput Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
092d0e4b9e
commit
471353e3d7
190 files changed
+16640
-3484
No files matched your search
@@ -1,428 +1,394 @@
|
||||
// SNMP 监控 — 网络设备 / 服务器硬件 (OID 模板采集)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
// SNMP 监控 — 设备管理 + 指标可视化(主区域优先) // Logan.Li
|
||||
import { useState, useEffect, useCallback, type MouseEvent } from 'react';
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Radio, Router, HardDrive, Plus, Trash2 } from 'lucide-react';
|
||||
import { Router, RefreshCw, Plus, Trash2, Zap, Eye, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { snmpApi } from '@/api';
|
||||
import { SnmpOverviewCharts } from './snmp-monitor/SnmpOverviewCharts';
|
||||
import { SnmpDeviceChartsPanel } from './snmp-monitor/SnmpDeviceChartsPanel';
|
||||
import { SnmpTemplatesCollapsible, type SnmpTemplate } from './snmp-monitor/SnmpTemplatesCollapsible';
|
||||
import type { SnmpDevice, SnmpMetric, SnmpOverviewItem } from './snmp-monitor/helpers';
|
||||
|
||||
// ---------- types ----------
|
||||
interface SnmpStats {
|
||||
total: number;
|
||||
online: number;
|
||||
abnormal: number;
|
||||
}
|
||||
interface SnmpStats { total: number; online: number; abnormal: number }
|
||||
|
||||
interface SnmpDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
snmp_version: string;
|
||||
community: string;
|
||||
template_id: number;
|
||||
status?: string; // "online" | "offline" | "abnormal"
|
||||
}
|
||||
|
||||
interface DeviceListResponse {
|
||||
list: SnmpDevice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---------- initial form state ----------
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
ip: '',
|
||||
port: 161,
|
||||
name: '', ip: '', port: 161,
|
||||
snmp_version: 'v2c' as 'v2c' | 'v3',
|
||||
community: 'public',
|
||||
template_id: '',
|
||||
community: 'public', template_id: '',
|
||||
};
|
||||
|
||||
const keyMetrics = [
|
||||
'h3c_cpu_usage', 'hw_cpu_usage', 'cisco_cpu_5min', 'ruijie_cpu_usage',
|
||||
'inspur_cpu_usage', 'dell_cpu_status', 'h3c_mem_usage', 'hw_mem_usage',
|
||||
'h3c_temperature', 'hw_temperature', 'if_in_octets', 'if_out_octets',
|
||||
];
|
||||
|
||||
export function SNMPMonitor() {
|
||||
// stats
|
||||
const [stats, setStats] = useState<SnmpStats>({ total: 0, online: 0, abnormal: 0 });
|
||||
|
||||
// device list
|
||||
const [devices, setDevices] = useState<SnmpDevice[]>([]);
|
||||
const [overview, setOverview] = useState<SnmpOverviewItem[]>([]);
|
||||
const [templates, setTemplates] = useState<SnmpTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// dialog
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState({ ...emptyForm });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [collectingId, setCollectingId] = useState<number | null>(null);
|
||||
const [selectedDevice, setSelectedDevice] = useState<SnmpDevice | null>(null);
|
||||
const [metricsOpen, setMetricsOpen] = useState(false);
|
||||
const [metricsDevice, setMetricsDevice] = useState<SnmpDevice | null>(null);
|
||||
const [metrics, setMetrics] = useState<SnmpMetric[]>([]);
|
||||
const [metricsLoading, setMetricsLoading] = useState(false);
|
||||
|
||||
// ---------- data fetching ----------
|
||||
const loadStats = useCallback(async () => {
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
const data = await snmpApi.getStats() as SnmpStats;
|
||||
setStats(data);
|
||||
} catch {
|
||||
// backend unavailable — leave defaults
|
||||
}
|
||||
const res = await snmpApi.getTemplates() as { list?: SnmpTemplate[] };
|
||||
setTemplates(res?.list || []);
|
||||
} catch { setTemplates([]); }
|
||||
}, []);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try { setStats(await snmpApi.getStats() as SnmpStats); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
try {
|
||||
const data = await snmpApi.getOverviewMetrics() as { list?: SnmpOverviewItem[] };
|
||||
setOverview(data?.list || []);
|
||||
} catch { setOverview([]); }
|
||||
}, []);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try {
|
||||
const data = await snmpApi.getDevices() as DeviceListResponse;
|
||||
setDevices(data.list ?? []);
|
||||
const data = await snmpApi.getDevices() as { list?: SnmpDevice[] };
|
||||
const list = data.list ?? [];
|
||||
setDevices(list);
|
||||
setSelectedDevice((prev) => {
|
||||
if (prev && list.some((d) => d.id === prev.id)) return list.find((d) => d.id === prev.id) || null;
|
||||
return list.find((d) => d.status === 'online') || list[0] || null;
|
||||
});
|
||||
} catch {
|
||||
setDevices([]);
|
||||
setSelectedDevice(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
loadStats();
|
||||
loadDevices();
|
||||
}, [loadStats, loadDevices]);
|
||||
loadStats(); loadDevices(); loadTemplates(); loadOverview();
|
||||
}, [loadStats, loadDevices, loadTemplates, loadOverview]);
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
// 30s 静默刷新,与采集周期对齐 // Logan.Li
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
if (loading && devices.length === 0) return;
|
||||
const timer = setInterval(refresh, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh, loading, devices.length]);
|
||||
|
||||
const templateName = (id: number) => templates.find((t) => t.id === id)?.name || `#${id}`;
|
||||
|
||||
// ---------- CRUD ----------
|
||||
const handleAdd = async () => {
|
||||
if (!form.name.trim() || !form.ip.trim()) return;
|
||||
if (!form.name.trim() || !form.ip.trim() || !form.template_id) {
|
||||
toast.error('请填写设备名称、IP 并选择 OID 模板');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const tpl = templates.find((t) => String(t.id) === form.template_id);
|
||||
await snmpApi.createDevice({
|
||||
name: form.name.trim(),
|
||||
ip: form.ip.trim(),
|
||||
port: Number(form.port),
|
||||
snmp_version: form.snmp_version,
|
||||
name: form.name.trim(), ip: form.ip.trim(),
|
||||
port: Number(form.port), snmp_version: form.snmp_version,
|
||||
community: form.community.trim(),
|
||||
template_id: form.template_id ? Number(form.template_id) : 0,
|
||||
template_id: Number(form.template_id),
|
||||
vendor: tpl?.vendor || '', device_type: tpl?.device_type || 'switch',
|
||||
});
|
||||
toast.success('设备已添加');
|
||||
setDialogOpen(false);
|
||||
setForm({ ...emptyForm });
|
||||
refresh();
|
||||
} catch {
|
||||
// silently fail — user can retry
|
||||
} catch (e) {
|
||||
toast.error((e as { message?: string }).message || '添加失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleCollect = async (d: SnmpDevice, e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
setCollectingId(d.id);
|
||||
try {
|
||||
await snmpApi.deleteDevice(id);
|
||||
await snmpApi.collectDevice(d.id);
|
||||
toast.success(`${d.name} 采集成功`);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error((err as { message?: string }).message || '采集失败');
|
||||
refresh();
|
||||
} finally {
|
||||
setCollectingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openMetrics = async (d: SnmpDevice, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setMetricsDevice(d);
|
||||
setMetricsOpen(true);
|
||||
setMetricsLoading(true);
|
||||
try {
|
||||
const res = await snmpApi.getDeviceMetrics(d.id) as { list?: SnmpMetric[] };
|
||||
setMetrics(res?.list || []);
|
||||
} catch {
|
||||
// silently fail
|
||||
setMetrics([]);
|
||||
} finally {
|
||||
setMetricsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await snmpApi.deleteDevice(id);
|
||||
toast.success('已删除');
|
||||
if (selectedDevice?.id === id) setSelectedDevice(null);
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- status badge ----------
|
||||
const statusBadge = (status?: string) => {
|
||||
if (status === 'online') {
|
||||
return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">在线</Badge>;
|
||||
}
|
||||
if (status === 'abnormal') {
|
||||
return <Badge className="bg-red-500/15 dg-text-red border-red-500/30">异常</Badge>;
|
||||
}
|
||||
if (status === 'online') return <Badge className="bg-emerald-500/15 dg-text-green border-emerald-500/30">在线</Badge>;
|
||||
if (status === 'error') return <Badge className="bg-red-500/15 dg-text-red border-red-500/30">异常</Badge>;
|
||||
return <Badge className="bg-[var(--dg-bg-active)]/15 dg-text-secondary border-[var(--dg-border)]">离线</Badge>;
|
||||
};
|
||||
|
||||
// ---------- render ----------
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Router className="h-4 w-4 dg-text-cyan" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">网络设备</h3>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">交换机 / 路由器 / 防火墙</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">设备总数</span>
|
||||
<span className="text-[var(--dg-text-primary)] font-mono">{stats.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">在线</span>
|
||||
<span className="dg-text-green font-mono">{stats.online}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="h-4 w-4 dg-text-purple" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">服务器硬件</h3>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">iLO / iDRAC / IPMI</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">异常</span>
|
||||
<span className="dg-text-red font-mono">{stats.abnormal}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Template Info */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Radio className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">OID 监控模板</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">
|
||||
支持 Zabbix 兼容的 SNMP OID 模板(XML 格式),内置思科/H3C/华为交换机模板、服务器硬件监控模板。
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-[var(--dg-text-secondary)]">
|
||||
{['思科交换机', 'H3C S5000', '华为交换机', 'iLO/Dell'].map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="bg-[var(--dg-bg-tertiary)] rounded px-2 py-1 text-center border border-[var(--dg-border)]"
|
||||
>
|
||||
{t}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Device Table */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Router className="h-4 w-4 dg-text-cyan" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
SNMP 设备列表
|
||||
{!loading && (
|
||||
<span className="ml-2 text-xs text-[var(--dg-text-muted)] font-normal">
|
||||
({devices.length} 台)
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Add Device Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="bg-cyan-500/10 dg-text-cyan hover:bg-cyan-500/20 border border-cyan-500/30">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加设备
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">添加 SNMP 设备</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
输入设备信息以开始 SNMP 监控采集。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{/* Name */}
|
||||
<div className="space-y-4">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">SNMP 监控</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">5 分钟采集 · 指标保留 7 天</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={refresh} className="h-8">
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />刷新
|
||||
</Button>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="bg-cyan-500/10 dg-text-cyan border border-cyan-500/30 h-8">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />添加设备
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加 SNMP 设备</DialogTitle>
|
||||
<DialogDescription>选择厂商 OID 模板,配置管理 IP 与 Community</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>设备名称</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="核心交换机-A" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-name" className="text-[var(--dg-text-muted)]">
|
||||
设备名称
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-name"
|
||||
placeholder="例如: 核心交换机"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
<Label>IP 地址</Label>
|
||||
<Input value={form.ip} onChange={(e) => setForm({ ...form, ip: e.target.value })} placeholder="192.168.1.1" />
|
||||
</div>
|
||||
|
||||
{/* IP */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-ip" className="text-[var(--dg-text-muted)]">
|
||||
IP 地址
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-ip"
|
||||
placeholder="例如: 192.168.1.1"
|
||||
value={form.ip}
|
||||
onChange={(e) => setForm({ ...form, ip: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
<Label>端口</Label>
|
||||
<Input type="number" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 161 })} />
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>OID 模板</Label>
|
||||
<Select value={form.template_id} onValueChange={(v) => setForm({ ...form, template_id: v })}>
|
||||
<SelectTrigger><SelectValue placeholder="选择模板" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.id} value={String(t.id)}>{t.name} ({t.vendor})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-port" className="text-[var(--dg-text-muted)]">
|
||||
SNMP 端口
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-port"
|
||||
type="number"
|
||||
placeholder="161"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 161 })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SNMP Version */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-version" className="text-[var(--dg-text-muted)]">
|
||||
SNMP 版本
|
||||
</Label>
|
||||
<Select
|
||||
value={form.snmp_version}
|
||||
onValueChange={(v) => setForm({ ...form, snmp_version: v as 'v2c' | 'v3' })}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="snmp-version"
|
||||
className="w-full bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
<Label>SNMP 版本</Label>
|
||||
<Select value={form.snmp_version} onValueChange={(v) => setForm({ ...form, snmp_version: v as 'v2c' | 'v3' })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="v2c">v2c</SelectItem>
|
||||
<SelectItem value="v3">v3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Community */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-community" className="text-[var(--dg-text-muted)]">
|
||||
Community
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-community"
|
||||
placeholder="public"
|
||||
value={form.community}
|
||||
onChange={(e) => setForm({ ...form, community: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template ID */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="snmp-template" className="text-[var(--dg-text-muted)]">
|
||||
模板 ID
|
||||
</Label>
|
||||
<Input
|
||||
id="snmp-template"
|
||||
type="number"
|
||||
placeholder="模板 ID"
|
||||
value={form.template_id}
|
||||
onChange={(e) => setForm({ ...form, template_id: e.target.value })}
|
||||
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
<Label>Community</Label>
|
||||
<Input value={form.community} onChange={(e) => setForm({ ...form, community: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleAdd} disabled={submitting}>
|
||||
{submitting && <Loader2 className="h-4 w-4 animate-spin mr-1" />}确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={submitting || !form.name.trim() || !form.ip.trim()}
|
||||
className="bg-cyan-500/10 dg-text-cyan hover:bg-cyan-500/20 border border-cyan-500/30"
|
||||
>
|
||||
{submitting ? '添加中...' : '确认添加'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* 紧凑统计 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ label: '设备总数', value: stats.total, color: '' },
|
||||
{ label: '在线', value: stats.online, color: 'dg-text-green' },
|
||||
{ label: '异常', value: stats.abnormal, color: 'dg-text-red' },
|
||||
{ label: 'OID 模板', value: templates.length, color: 'dg-text-cyan' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-center">
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-base font-bold font-mono ${item.color}`}>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-sm text-[var(--dg-text-muted)]">加载中...</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-[var(--dg-text-muted)] border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
暂无 SNMP 设备,点击「添加设备」开始监控
|
||||
{loading ? (
|
||||
<div className="py-16 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="py-12 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
<Router className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
||||
暂无 SNMP 设备,点击「添加设备」开始监控
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<SnmpOverviewCharts devices={devices} overview={overview} />
|
||||
|
||||
{/* 主监控区:设备列表 + 趋势图 */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-4 items-start">
|
||||
<Card className="xl:col-span-5 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3">
|
||||
<h3 className="text-sm font-medium mb-3">设备列表 ({devices.length})</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">名称</TableHead>
|
||||
<TableHead className="text-xs">IP</TableHead>
|
||||
<TableHead className="text-xs">状态</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((d) => (
|
||||
<TableRow
|
||||
key={d.id}
|
||||
className={`cursor-pointer ${selectedDevice?.id === d.id ? 'bg-[var(--dg-bg-tertiary)]' : ''}`}
|
||||
onClick={() => setSelectedDevice(d)}
|
||||
>
|
||||
<TableCell className="text-sm font-medium py-2">{d.name}</TableCell>
|
||||
<TableCell className="font-mono text-[10px] py-2">{d.ip}</TableCell>
|
||||
<TableCell className="py-2">{statusBadge(d.status)}</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon-sm" disabled={collectingId === d.id} onClick={(e) => handleCollect(d, e)}>
|
||||
{collectingId === d.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Zap className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={(e) => openMetrics(d, e)}>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={(e) => handleDelete(d.id, e)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="xl:col-span-7">
|
||||
<SnmpDeviceChartsPanel
|
||||
device={selectedDevice}
|
||||
templateName={selectedDevice ? templateName(selectedDevice.template_id) : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* OID 模板 — 默认折叠 */}
|
||||
<SnmpTemplatesCollapsible templates={templates} />
|
||||
|
||||
{/* 指标详情弹窗 */}
|
||||
<Dialog open={metricsOpen} onOpenChange={setMetricsOpen}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto bg-[var(--dg-bg-secondary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{metricsDevice?.name} — 采集指标</DialogTitle>
|
||||
<DialogDescription>{metricsDevice?.ip} · {templateName(metricsDevice?.template_id || 0)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{metricsLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">加载中...</div>
|
||||
) : metrics.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
暂无指标,请先采集
|
||||
{metricsDevice?.last_error && <p className="mt-2 text-xs dg-text-red">{metricsDevice.last_error}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">IP 地址</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">端口</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">版本</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">Community</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">模板 ID</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-16">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((d) => (
|
||||
<TableRow key={d.id} className="border-[var(--dg-border)]">
|
||||
<TableCell className="text-[var(--dg-text-primary)] font-medium">
|
||||
{d.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)] font-mono text-xs">
|
||||
{d.ip}
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)]">{d.port}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{d.snmp_version}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)] font-mono text-xs">
|
||||
{d.community}
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)]">{d.template_id || '--'}</TableCell>
|
||||
<TableCell>{statusBadge(d.status)}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleDelete(d.id)}
|
||||
className="dg-text-muted hover:dg-text-red hover:bg-red-500/10"
|
||||
title="删除设备"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<div className="space-y-4">
|
||||
{metrics.filter((m) => keyMetrics.includes(m.name)).length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{metrics.filter((m) => keyMetrics.includes(m.name)).map((m) => (
|
||||
<div key={`${m.name}-${m.index}`} className="rounded border border-[var(--dg-border)] p-2 text-center">
|
||||
<p className="text-[10px] text-muted-foreground">{m.name}</p>
|
||||
<p className="text-lg font-mono">{m.raw_value || m.value}{m.unit ? ` ${m.unit}` : ''}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>指标</TableHead><TableHead>索引</TableHead><TableHead>值</TableHead><TableHead>OID</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{metrics.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="text-xs">{m.name}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{m.index || '-'}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{m.raw_value || m.value}{m.unit ? ` ${m.unit}` : ''}</TableCell>
|
||||
<TableCell className="font-mono text-[10px] text-muted-foreground truncate max-w-[160px]">{m.oid}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user