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:
attackerandClaude committed 2026-06-28 06:06:48 +00:00
1 parent da01cbb119
commit c0ee8e4ddd
74 files changed
+229 -216

No files matched your search

+428
View File
@@ -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>
);
}