Files
SenMeshNet/components/inspection/AlertRulesPage.tsx
zhaolei 2f9f8da48e
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
Initial commit: NetMesh smart ops platform with 3D topology, inspection reports, RCA and AI ops assistant
2026-10-15 13:53:04 +08:00

590 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Plus, Trash2, Edit2, AlertTriangle, Bell, Settings2, Power, PowerOff } from "lucide-react";
import React, { useEffect, useState, useCallback } from "react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Badge } from "../ui/badge";
import { Switch } from "../ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../ui/dialog";
import { Label } from "../ui/label";
import {
inspectionAlertRuleApi,
inspectionCatalogApi,
type AlertRule,
type AlertLevel,
type Operator,
type DeviceCatalog,
} from "../../infrastructure/inspection/inspectionApi";
import { toast } from "../ui/toast";
const LEVEL_OPTIONS: { value: AlertLevel; label: string; color: string }[] = [
{ value: "critical", label: "严重", color: "text-red-500 bg-red-500/10 border-red-500/30" },
{ value: "warning", label: "警告", color: "text-orange-500 bg-orange-500/10 border-orange-500/30" },
{ value: "info", label: "提示", color: "text-blue-500 bg-blue-500/10 border-blue-500/30" },
];
const OPERATOR_OPTIONS: { value: Operator; label: string }[] = [
{ value: ">", label: "大于 ( > )" },
{ value: "<", label: "小于 ( < )" },
{ value: ">=", label: "大于等于 ( >= )" },
{ value: "<=", label: "小于等于 ( <= )" },
{ value: "==", label: "等于 ( == )" },
{ value: "!=", label: "不等于 ( != )" },
];
const COMMON_METRICS = [
{ value: "cpu_usage", label: "CPU 使用率 (%)", group: "通用" },
{ value: "memory_usage", label: "内存使用率 (%)", group: "通用" },
{ value: "disk_usage", label: "磁盘使用率 (%)", group: "通用" },
{ value: "load_1m", label: "系统负载 (1分钟)", group: "通用" },
{ value: "online", label: "设备在线状态", group: "通用" },
{ value: "temperature", label: "设备温度 (°C)", group: "网络设备" },
{ value: "power_failure_count", label: "电源故障数", group: "网络设备" },
{ value: "fan_failure_count", label: "风扇故障数", group: "网络设备" },
{ value: "port_down_count", label: "端口 Down 数", group: "网络设备" },
{ value: "port_total_count", label: "端口总数", group: "网络设备" },
];
const SCOPE_OPTIONS = [
{ value: "all", label: "全部设备" },
{ value: "tier", label: "按设备分级" },
{ value: "catalog", label: "按设备类型" },
{ value: "device", label: "指定设备" },
];
export function AlertRulesPage() {
const [rules, setRules] = useState<AlertRule[]>([]);
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<AlertRule | null>(null);
// 筛选条件
const [filterLevel, setFilterLevel] = useState<"all" | AlertLevel>("all");
const [filterScope, setFilterScope] = useState<"all" | "preset" | "custom">("all");
const [searchText, setSearchText] = useState("");
// 表单状态
const [form, setForm] = useState({
name: "",
metric: "cpu_usage",
operator: ">" as Operator,
threshold: "80",
level: "warning" as AlertLevel,
duration: 1,
scope_type: "all" as "all" | "tier" | "catalog" | "device",
scope_value: "" as string,
enabled: true,
});
const loadRules = useCallback(async () => {
setLoading(true);
try {
const [list, catList] = await Promise.all([
inspectionAlertRuleApi.list(),
inspectionCatalogApi.list(),
]);
setRules(list);
setCatalogs(catList);
} catch (e: any) {
console.error("[AlertRules] 加载失败:", e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadRules();
}, [loadRules]);
const handleCreate = () => {
setEditingRule(null);
setForm({
name: "",
metric: "cpu_usage",
operator: ">",
threshold: "80",
level: "warning",
duration: 1,
scope_type: "all",
scope_value: "",
enabled: true,
});
setDialogOpen(true);
};
const handleEdit = (rule: AlertRule) => {
setEditingRule(rule);
setForm({
name: rule.name,
metric: rule.metric,
operator: rule.operator,
threshold: rule.threshold,
level: rule.level,
duration: rule.duration,
scope_type: rule.scope_type as any,
scope_value: Array.isArray(rule.scope_value)
? rule.scope_value.join(", ")
: rule.scope_value || "",
enabled: rule.enabled,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!form.name.trim()) {
toast.error("请输入规则名称");
return;
}
if (!form.threshold.trim()) {
toast.error("请输入阈值");
return;
}
try {
const data: Partial<AlertRule> = {
name: form.name,
metric: form.metric,
operator: form.operator,
threshold: form.threshold,
level: form.level,
duration: form.duration,
scope_type: form.scope_type,
scope_value:
form.scope_type === "all"
? null
: form.scope_type === "device"
? form.scope_value.split(",").map((s) => s.trim()).filter(Boolean)
: form.scope_value || null,
enabled: form.enabled,
};
if (editingRule) {
await inspectionAlertRuleApi.update(editingRule.id, data);
toast.success("规则已更新");
} else {
await inspectionAlertRuleApi.create(data);
toast.success("规则已创建");
}
setDialogOpen(false);
loadRules();
} catch (e: any) {
toast.error("保存失败: " + e.message);
}
};
const handleToggle = async (ruleId: string, enabled: boolean) => {
try {
await inspectionAlertRuleApi.toggle(ruleId, enabled);
toast.success(enabled ? "已启用" : "已停用");
loadRules();
} catch (e: any) {
toast.error("操作失败");
}
};
const handleDelete = async (ruleId: string) => {
if (!confirm("确定要删除这条规则吗?")) return;
try {
await inspectionAlertRuleApi.remove(ruleId);
toast.success("已删除");
loadRules();
} catch (e: any) {
toast.error("删除失败");
}
};
const getLevelBadge = (level: AlertLevel) => {
const opt = LEVEL_OPTIONS.find((l) => l.value === level);
return (
<Badge variant="outline" className={opt?.color}>
{opt?.label}
</Badge>
);
};
const getScopeLabel = (rule: AlertRule) => {
switch (rule.scope_type) {
case "all":
return "全部设备";
case "tier":
const tierLabels: Record<string, string> = {
core: "核心级",
aggregation: "重要级",
access: "一般",
};
return `分级: ${tierLabels[String(rule.scope_value)] || rule.scope_value}`;
case "catalog":
const cat = catalogs.find((c) => c.id === rule.scope_value);
return `类型: ${cat?.name || rule.scope_value}`;
case "device":
return `指定设备 (${Array.isArray(rule.scope_value) ? rule.scope_value.length : 0})`;
default:
return rule.scope_type;
}
};
// 筛选后的数据
const filteredRules = (rules ?? []).filter((r) => {
if (filterLevel !== "all" && r.level !== filterLevel) return false;
if (filterScope === "preset" && !r.preset) return false;
if (filterScope === "custom" && r.preset) return false;
if (searchText) {
const s = searchText.toLowerCase();
if (!r.name.toLowerCase().includes(s) && !r.metric.toLowerCase().includes(s)) {
return false;
}
}
return true;
});
return (
<div className="h-full flex flex-col">
{/* 顶部工具栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<AlertTriangle size={18} className="text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold">告警规则配置</h2>
<p className="text-sm text-muted-foreground">
定义设备巡检的告警触发条件(共 {(rules?.length ?? 0)} 条)
</p>
</div>
</div>
<Button onClick={handleCreate}>
<Plus size={16} className="mr-2" />
新增规则
</Button>
</div>
{/* 筛选栏 */}
<div className="flex items-center gap-3 px-6 py-3 border-b border-border/40 bg-muted/20">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">级别:</span>
<div className="flex gap-1">
{[
{ value: "all", label: "全部" },
{ value: "critical", label: "严重" },
{ value: "warning", label: "警告" },
{ value: "info", label: "提示" },
].map((opt) => (
<button
key={opt.value}
onClick={() => setFilterLevel(opt.value as any)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
filterLevel === opt.value
? "bg-primary text-primary-foreground"
: "bg-background hover:bg-accent text-muted-foreground"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="w-px h-4 bg-border/60" />
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">分类:</span>
<div className="flex gap-1">
{[
{ value: "all", label: "全部" },
{ value: "preset", label: "预置" },
{ value: "custom", label: "自定义" },
].map((opt) => (
<button
key={opt.value}
onClick={() => setFilterScope(opt.value as any)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
filterScope === opt.value
? "bg-primary text-primary-foreground"
: "bg-background hover:bg-accent text-muted-foreground"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex-1" />
<div className="relative w-56">
<Input
placeholder="搜索规则名称..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
{/* 规则列表 */}
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="text-center text-muted-foreground py-8">加载中...</div>
) : filteredRules.length === 0 ? (
<div className="text-center text-muted-foreground py-12">
<Bell size={40} className="mx-auto mb-3 opacity-40" />
<p>暂无符合条件的告警规则</p>
<p className="text-sm">点击右上角新增规则开始配置</p>
</div>
) : (
<div className="space-y-2">
{filteredRules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-4 p-4 border border-border/60 rounded-lg hover:bg-accent/30 transition-colors"
>
{/* 启用开关 */}
<div className="flex flex-col items-center gap-1">
<Switch
checked={rule.enabled}
onCheckedChange={(v) => handleToggle(rule.id, v)}
/>
<span className="text-[10px] text-muted-foreground">
{rule.enabled ? "启用" : "停用"}
</span>
</div>
{/* 规则信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{rule.name}</span>
{getLevelBadge(rule.level)}
{rule.preset && (
<Badge variant="secondary" className="text-xs">
预置
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground flex items-center gap-3">
<span>
{COMMON_METRICS.find((m) => m.value === rule.metric)?.label || rule.metric}
</span>
<span className="font-mono">{rule.operator}</span>
<span>{rule.threshold}</span>
<span className="text-border">|</span>
<span>{getScopeLabel(rule)}</span>
{rule.duration > 1 && (
<>
<span className="text-border">|</span>
<span>持续 {rule.duration} 次</span>
</>
)}
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(rule)}
title="编辑"
>
<Edit2 size={15} />
</Button>
{!rule.preset && (
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(rule.id)}
title="删除"
className="text-destructive hover:text-destructive"
>
<Trash2 size={15} />
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* 编辑弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingRule ? "编辑告警规则" : "新增告警规则"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 规则名称 */}
<div className="space-y-2">
<Label>规则名称</Label>
<Input
placeholder="例如:CPU 使用率过高"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
{/* 指标 + 操作符 + 阈值 */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2">
<Label>指标</Label>
<Select value={form.metric} onValueChange={(v) => setForm({ ...form, metric: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMMON_METRICS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>条件</Label>
<Select
value={form.operator}
onValueChange={(v) => setForm({ ...form, operator: v as Operator })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>阈值</Label>
<Input
placeholder="80"
value={form.threshold}
onChange={(e) => setForm({ ...form, threshold: e.target.value })}
/>
</div>
</div>
{/* 告警级别 */}
<div className="space-y-2">
<Label>告警级别</Label>
<div className="flex gap-2">
{LEVEL_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setForm({ ...form, level: opt.value })}
className={`flex-1 py-2 px-3 rounded-md border text-sm font-medium transition-colors ${
form.level === opt.value
? `border-primary/50 ${opt.color}`
: "border-border hover:border-primary/30"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* 适用范围 */}
<div className="space-y-2">
<Label>适用范围</Label>
<div className="flex gap-2">
<Select
value={form.scope_type}
onValueChange={(v) => setForm({ ...form, scope_type: v as any })}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
{form.scope_type !== "all" && (
<Select
value={form.scope_value}
onValueChange={(v) => setForm({ ...form, scope_value: v })}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent>
{form.scope_type === "catalog" &&
catalogs.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
{form.scope_type === "tier" && (
<>
<SelectItem value="core">核心级</SelectItem>
<SelectItem value="aggregation">重要级</SelectItem>
<SelectItem value="access">一般</SelectItem>
</>
)}
</SelectContent>
</Select>
)}
</div>
{form.scope_type === "device" && (
<Input
placeholder="输入设备 ID,多个用逗号分隔"
value={form.scope_value}
onChange={(e) => setForm({ ...form, scope_value: e.target.value })}
className="mt-1"
/>
)}
</div>
{/* 持续次数 */}
<div className="space-y-2">
<Label>持续次数(防抖动)</Label>
<div className="flex items-center gap-3">
<Input
type="number"
min={1}
max={10}
value={form.duration}
onChange={(e) =>
setForm({ ...form, duration: parseInt(e.target.value, 10) || 1 })
}
className="w-24"
/>
<span className="text-sm text-muted-foreground">
连续触发 N 次后才产生告警,避免瞬时波动误报
</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button onClick={handleSave}>{editingRule ? "保存修改" : "创建规则"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AlertRulesPage;