refactor(frontend): api/components/pages 目录架构统一对齐菜单域 + 日志中心修复
1. 目录架构统一(API = components = 菜单域,一域一目录 + barrel) - log/ 提为独立顶层域(日志中心在菜单中与监控中心平级,不再塞在 monitor/) - task/ 拆分原 automation.ts → cron-job / script / remote-exec - aiops/ 合并 ai.ts + agentCli.ts → assistant + agent-cli - agent/ approval/ audit/ 提升为域目录 - auth 拆分:authApi(认证自服务) + system/user.ts(userApi+ldapApi 管理端) - components/automation → components/task,菜单"自动化运维"→"作业任务" - pages/ 按域分子目录(approval/audit/monitor),删孤儿 AuditPage.tsx - 所有 API 符号名不变,组件经 @/api 聚合引用,运行时行为零变化 2. Loki 查询无返回修复:aiops 容器 DNS 解析外网域名失败 (宿主机 systemd-resolved 转发链路异常),docker-compose 显式配 dns 上游 3. ES 查询框布局:工具栏单行挤 7 控件(ES 多 150px 索引框),拆两行—— 查询输入独占一行(+查询按钮),次级控件换行 flex-wrap Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
da01cbb119
commit
c0ee8e4ddd
74 files changed
+229
-216
No files matched your search
@@ -0,0 +1,428 @@
|
||||
// SNMP 监控 — 网络设备 / 服务器硬件 (OID 模板采集)
|
||||
import { useState, useEffect, useCallback } 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,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Radio, Router, HardDrive, Plus, Trash2 } from 'lucide-react';
|
||||
import { snmpApi } from '@/api';
|
||||
|
||||
// ---------- types ----------
|
||||
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,
|
||||
snmp_version: 'v2c' as 'v2c' | 'v3',
|
||||
community: 'public',
|
||||
template_id: '',
|
||||
};
|
||||
|
||||
export function SNMPMonitor() {
|
||||
// stats
|
||||
const [stats, setStats] = useState<SnmpStats>({ total: 0, online: 0, abnormal: 0 });
|
||||
|
||||
// device list
|
||||
const [devices, setDevices] = useState<SnmpDevice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// dialog
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState({ ...emptyForm });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// ---------- data fetching ----------
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await snmpApi.getStats() as SnmpStats;
|
||||
setStats(data);
|
||||
} catch {
|
||||
// backend unavailable — leave defaults
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try {
|
||||
const data = await snmpApi.getDevices() as DeviceListResponse;
|
||||
setDevices(data.list ?? []);
|
||||
} catch {
|
||||
setDevices([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
loadStats();
|
||||
loadDevices();
|
||||
}, [loadStats, loadDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// ---------- CRUD ----------
|
||||
const handleAdd = async () => {
|
||||
if (!form.name.trim() || !form.ip.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await snmpApi.createDevice({
|
||||
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,
|
||||
});
|
||||
setDialogOpen(false);
|
||||
setForm({ ...emptyForm });
|
||||
refresh();
|
||||
} catch {
|
||||
// silently fail — user can retry
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await snmpApi.deleteDevice(id);
|
||||
refresh();
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- status badge ----------
|
||||
const statusBadge = (status?: string) => {
|
||||
if (status === 'online') {
|
||||
return <Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">在线</Badge>;
|
||||
}
|
||||
if (status === 'abnormal') {
|
||||
return <Badge className="bg-red-500/15 text-red-400 border-red-500/30">异常</Badge>;
|
||||
}
|
||||
return <Badge className="bg-slate-500/15 text-slate-400 border-slate-500/30">离线</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 text-cyan-400" />
|
||||
<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="text-emerald-400 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 text-purple-400" />
|
||||
<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="text-red-400 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 text-cyan-400" />
|
||||
<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 text-cyan-400 hover:bg-cyan-500/20 border border-cyan-500/30">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加设备
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-slate-900 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="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)]"
|
||||
/>
|
||||
</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)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
<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="bg-slate-800 border-slate-700 text-slate-200">
|
||||
<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)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-muted)] hover:bg-slate-800"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={submitting || !form.name.trim() || !form.ip.trim()}
|
||||
className="bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 border border-cyan-500/30"
|
||||
>
|
||||
{submitting ? '添加中...' : '确认添加'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</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 设备,点击「添加设备」开始监控
|
||||
</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="text-slate-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
title="删除设备"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user