Files
SenMeshNet/components/inspection/AlertCenterPage.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

887 lines
36 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 {
AlertTriangle,
CheckCircle,
XCircle,
Clock,
MessageSquare,
RefreshCw,
Filter,
Info,
AlertOctagon,
Bell,
ChevronRight,
Bot,
Loader2,
Sparkles,
Terminal,
Lightbulb,
Zap,
} from "lucide-react";
import React, { useEffect, useState, useCallback, useRef } from "react";
import { Button } from "../ui/button";
import { Badge } from "../ui/badge";
import { Input } from "../ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../ui/dialog";
import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea";
import {
inspectionAlertApi,
type AlertRecord,
type AlertLevel,
type AlertRecordStatus,
type AlertStats,
} from "../../infrastructure/inspection/inspectionApi";
import { toast } from "../ui/toast";
import { runRCA, type RunRCAResult } from "../../infrastructure/ai/rcaAgent/rcaRunner";
const LEVEL_COLORS: Record<AlertLevel, { bg: string; text: string; border: string; dot: string }> = {
critical: {
bg: "bg-red-500/10",
text: "text-red-500",
border: "border-red-500/30",
dot: "bg-red-500",
},
warning: {
bg: "bg-orange-500/10",
text: "text-orange-500",
border: "border-orange-500/30",
dot: "bg-orange-500",
},
info: {
bg: "bg-blue-500/10",
text: "text-blue-500",
border: "border-blue-500/30",
dot: "bg-blue-500",
},
};
const LEVEL_LABELS: Record<AlertLevel, string> = {
critical: "严重",
warning: "警告",
info: "提示",
};
const STATUS_LABELS: Record<AlertRecordStatus, string> = {
active: "待处理",
acknowledged: "已确认",
resolved: "已恢复",
closed: "已关闭",
};
const STATUS_COLORS: Record<AlertRecordStatus, string> = {
active: "text-red-500",
acknowledged: "text-orange-500",
resolved: "text-green-500",
closed: "text-muted-foreground",
};
export function AlertCenterPage() {
const [alerts, setAlerts] = useState<AlertRecord[]>([]);
const [stats, setStats] = useState<AlertStats | null>(null);
const [loading, setLoading] = useState(true);
const [selectedAlert, setSelectedAlert] = useState<AlertRecord | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [noteDialogOpen, setNoteDialogOpen] = useState(false);
const [noteText, setNoteText] = useState("");
// 防御:清洗告警里可能残留的 [object Object](老数据修复前写入的坏值)
const _clean = (v: any): string => {
if (v === null || v === undefined) return "";
const s = typeof v === "string" ? v : String(v);
// 已经包含 [object Object] 片段 → 尝试 JSON.parse 救回来,失败就显示 "—"
if (s.includes("[object Object]")) {
// 可能是 JSON.parse 后还能救
try {
const parsed = JSON.parse(s.replace(/\[object Object\]/g, '"..."'));
return JSON.stringify(parsed).slice(0, 300);
} catch {
return s.replace(/\[object Object\]/g, "—").replace(/, —/g, " —");
}
}
return s;
};
// RCA 状态
const [rcaLoading, setRcaLoading] = useState(false);
const [rcaResult, setRcaResult] = useState<RunRCAResult | null>(null);
// 筛选
const [filterLevel, setFilterLevel] = useState<string>("all");
const [filterStatus, setFilterStatus] = useState<string>("all");
const [searchText, setSearchText] = useState("");
const [activeTab, setActiveTab] = useState<"active" | "all">("active");
const isFirstLoadRef = useRef(true);
const loadData = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
try {
const [statsData, alertsData] = await Promise.all([
inspectionAlertApi.getStats(),
activeTab === "active"
? inspectionAlertApi.getActive()
: inspectionAlertApi.list({ limit: 2000 }),
]);
setStats(statsData);
setAlerts(alertsData);
} catch (e: any) {
console.error("[AlertCenter] 加载失败:", e);
} finally {
if (!silent) setLoading(false);
isFirstLoadRef.current = false;
}
}, [activeTab]);
useEffect(() => {
loadData(false);
}, [loadData]);
// 订阅主进程告警变化事件,自动刷新(静默刷新,不显示 loading 避免闪动)
useEffect(() => {
const api = (window as any).netcatty?.inspection;
if (!api?.onAlertChanged) return;
// 给事件刷新再加 500ms 前端侧 debounce,避免主进程高频广播时连续触发
let timer: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => loadData(true), 500);
};
const unsub = api.onAlertChanged(handler);
return () => {
if (timer) clearTimeout(timer);
try { unsub?.(); } catch (_) {}
};
}, [loadData]);
// 筛选后的数据
const filteredAlerts = (alerts ?? []).filter((a) => {
if (filterLevel !== "all" && a.level !== filterLevel) return false;
if (filterStatus !== "all" && a.status !== filterStatus) return false;
if (searchText) {
const s = searchText.toLowerCase();
if (
!a.device_name.toLowerCase().includes(s) &&
!a.rule_name.toLowerCase().includes(s) &&
!a.message.toLowerCase().includes(s)
)
return false;
}
return true;
});
const handleViewDetail = (alert: AlertRecord) => {
setSelectedAlert(alert);
// 自动恢复:如果告警 note 里有 [RCA_JSON] 标记,解析并填充 rcaResult
if (alert.note?.startsWith("[RCA_JSON]")) {
try {
const parsed = JSON.parse(alert.note.slice("[RCA_JSON]".length));
if (parsed.__rca_json && parsed.summary) {
setRcaResult({
success: true,
rca: {
summary: parsed.summary,
confidence: parsed.confidence ?? "medium",
rootCauses: parsed.rootCauses || [],
evidence: parsed.evidence || [],
recommendations: parsed.recommendations || [],
},
error: undefined,
} as any);
} else {
setRcaResult(null);
}
} catch {
setRcaResult(null);
}
} else {
setRcaResult(null);
}
setDetailOpen(true);
};
/** 触发 AI 根因分析(RCA)— 同步阻塞直到结果返回,完成后持久化到告警记录 note 字段 */
const triggerRCA = async (alert: AlertRecord) => {
setRcaLoading(true);
setRcaResult(null);
try {
toast.info("AI 正在分析故障根因...(最多 3 分钟)");
const result = await runRCA(alert);
setRcaResult(result);
if (result.success && result.rca) {
toast.success("AI 分析完成");
// 持久化 RCA 结果到告警记录 note 字段,加 [RCA_JSON] 前缀标记
try {
const rcaPayload = JSON.stringify({
__rca_json: true,
generated_at: Date.now(),
summary: result.rca.summary,
confidence: result.rca.confidence,
rootCauses: result.rca.rootCauses,
evidence: result.rca.evidence,
recommendations: result.rca.recommendations,
});
await inspectionAlertApi.updateNote(alert.id, `[RCA_JSON]${rcaPayload}`);
// 同时更新本地 selectedAlert 的 note,这样详情弹窗里也能看到
setSelectedAlert(prev => prev ? { ...prev, note: `[RCA_JSON]${rcaPayload}` } : prev);
} catch (persistErr) {
console.warn("RCA 结果持久化失败:", persistErr);
// 持久化失败不影响 UI 展示
}
} else {
toast.error("AI 分析失败:" + (result.error || "输出解析失败"));
}
} catch (e: any) {
toast.error("AI 分析异常:" + (e?.message || String(e)));
} finally {
setRcaLoading(false);
}
};
const handleAcknowledge = async () => {
if (!selectedAlert) return;
try {
await inspectionAlertApi.acknowledge(selectedAlert.id, "operator");
toast.success("已确认告警");
setDetailOpen(false);
loadData();
} catch (e: any) {
toast.error("操作失败");
}
};
const handleResolve = async () => {
if (!selectedAlert) return;
try {
await inspectionAlertApi.resolve(selectedAlert.id, "manual", "operator");
toast.success("已恢复告警");
setDetailOpen(false);
loadData();
} catch (e: any) {
toast.error("操作失败");
}
};
const handleClose = () => {
if (!selectedAlert) return;
setNoteText("");
setNoteDialogOpen(true);
};
const handleCloseConfirm = async () => {
if (!selectedAlert) return;
try {
await inspectionAlertApi.close(selectedAlert.id, noteText);
toast.success("已关闭告警");
setNoteDialogOpen(false);
setDetailOpen(false);
loadData();
} catch (e: any) {
toast.error("操作失败");
}
};
const formatTime = (ts: number) => {
return new Date(ts).toLocaleString();
};
const getTimeAgo = (ts: number) => {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins} 分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
return `${days} 天前`;
};
return (
<div className="h-full flex flex-col">
{/* 顶部统计 */}
<div className="px-6 py-4 border-b border-border/60">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-red-500/10 flex items-center justify-center">
<Bell size={18} className="text-red-500" />
</div>
<div>
<h2 className="text-lg font-semibold">告警中心</h2>
<p className="text-sm text-muted-foreground">
监控设备巡检产生的告警
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={() => loadData(false)}>
<RefreshCw size={14} className="mr-2" />
刷新
</Button>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-3">
<div className="p-3 rounded-lg border border-red-500/30 bg-red-500/5">
<div className="flex items-center gap-2">
<AlertOctagon size={16} className="text-red-500" />
<span className="text-sm text-muted-foreground">严重告警</span>
</div>
<div className="text-2xl font-bold text-red-500 mt-1">
{stats?.active_critical ?? 0}
</div>
</div>
<div className="p-3 rounded-lg border border-orange-500/30 bg-orange-500/5">
<div className="flex items-center gap-2">
<AlertTriangle size={16} className="text-orange-500" />
<span className="text-sm text-muted-foreground">警告</span>
</div>
<div className="text-2xl font-bold text-orange-500 mt-1">
{stats?.active_warning ?? 0}
</div>
</div>
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
<div className="flex items-center gap-2">
<Info size={16} className="text-blue-500" />
<span className="text-sm text-muted-foreground">提示</span>
</div>
<div className="text-2xl font-bold text-blue-500 mt-1">
{stats?.active_info ?? 0}
</div>
</div>
<div className="p-3 rounded-lg border border-green-500/30 bg-green-500/5">
<div className="flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
<span className="text-sm text-muted-foreground">已恢复</span>
</div>
<div className="text-2xl font-bold text-green-500 mt-1">
{stats?.resolved ?? 0}
</div>
</div>
</div>
</div>
{/* 筛选栏 */}
<div className="flex items-center gap-3 px-6 py-3 border-b border-border/60">
<div className="flex bg-secondary/50 rounded-md p-0.5">
<button
onClick={() => setActiveTab("active")}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
activeTab === "active"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
待处理
{stats && stats.active > 0 && (
<Badge className="ml-2" variant="secondary">
{stats.active}
</Badge>
)}
</button>
<button
onClick={() => setActiveTab("all")}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
activeTab === "all"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
全部
</button>
</div>
<div className="flex-1" />
<Select value={filterLevel} onValueChange={setFilterLevel}>
<SelectTrigger className="w-32">
<SelectValue placeholder="级别" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部级别</SelectItem>
<SelectItem value="critical">严重</SelectItem>
<SelectItem value="warning">警告</SelectItem>
<SelectItem value="info">提示</SelectItem>
</SelectContent>
</Select>
{activeTab === "all" && (
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-32">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="active">待处理</SelectItem>
<SelectItem value="acknowledged">已确认</SelectItem>
<SelectItem value="resolved">已恢复</SelectItem>
<SelectItem value="closed">已关闭</SelectItem>
</SelectContent>
</Select>
)}
<Input
placeholder="搜索设备/规则..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-48"
/>
</div>
{/* 告警列表 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="text-center text-muted-foreground py-12">加载中...</div>
) : filteredAlerts.length === 0 ? (
<div className="text-center text-muted-foreground py-16">
<CheckCircle size={40} className="mx-auto mb-3 text-green-500 opacity-40" />
<p className="text-sm">暂无告警记录</p>
<p className="text-xs text-muted-foreground mt-1">
{activeTab === "active" ? "所有设备运行正常" : "还没有产生过告警"}
</p>
</div>
) : (
<div className="divide-y divide-border/60">
{filteredAlerts.map((alert) => {
const colors = LEVEL_COLORS[alert.level];
return (
<div
key={alert.id}
onClick={() => handleViewDetail(alert)}
className="flex items-center gap-4 px-6 py-3 hover:bg-accent/30 cursor-pointer transition-colors"
>
{/* 级别指示点 */}
<div className={`w-2 h-2 rounded-full ${colors.dot}`} />
{/* 主要内容 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-medium truncate">{alert.device_name}</span>
<Badge
variant="outline"
className={`${colors.bg} ${colors.text} ${colors.border} border`}
>
{LEVEL_LABELS[alert.level]}
</Badge>
<span className={`text-xs ${STATUS_COLORS[alert.status]}`}>
{STATUS_LABELS[alert.status]}
</span>
</div>
<div className="text-sm text-muted-foreground truncate">
{alert.rule_name} — {alert.message}
</div>
</div>
{/* 时间 */}
<div className="text-right shrink-0">
<div className="text-sm text-muted-foreground">
{getTimeAgo(alert.triggered_at)}
</div>
<div className="text-xs text-muted-foreground/60">
{formatTime(alert.triggered_at)}
</div>
</div>
{/* 快速 AI 根因分析(阻止冒泡避免触发详情 Dialog) */}
{(() => {
const hasRca = alert.note?.startsWith("[RCA_JSON]");
if (hasRca) {
return (
<span
className="shrink-0 inline-flex items-center gap-1 px-2 h-7 rounded-md bg-purple-500/10 text-purple-400 text-[11px] font-medium cursor-pointer"
title="AI 已完成根因分析,点击查看详情"
onClick={(e) => { e.stopPropagation(); handleViewDetail(alert); }}
>
<Bot size={12} />
已分析
</span>
);
}
return (
<Button
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-purple-500 hover:text-purple-400 hover:bg-purple-500/10"
onClick={(e) => {
e.stopPropagation();
handleViewDetail(alert);
triggerRCA(alert);
}}
title="AI 根因分析"
>
<Bot size={14} />
</Button>
);
})()}
<ChevronRight size={16} className="text-muted-foreground shrink-0" />
</div>
);
})}
</div>
)}
</div>
{/* 详情弹窗 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="w-[66vw] h-[80vh] max-w-[95vw] max-h-[95vh] flex flex-col overflow-hidden">
{selectedAlert && (
<>
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${LEVEL_COLORS[selectedAlert.level].dot}`}
/>
告警详情
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto pr-2 space-y-4 py-2">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-xs text-muted-foreground mb-1">设备名称</div>
<div className="text-sm font-medium">{selectedAlert.device_name}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">告警级别</div>
<Badge
variant="outline"
className={`${LEVEL_COLORS[selectedAlert.level].bg} ${
LEVEL_COLORS[selectedAlert.level].text
} ${LEVEL_COLORS[selectedAlert.level].border} border`}
>
{LEVEL_LABELS[selectedAlert.level]}
</Badge>
</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">规则名称</div>
<div className="text-sm">{selectedAlert.rule_name}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">告警消息</div>
<div className="text-sm p-2 bg-secondary/30 rounded-md break-all">
{_clean(selectedAlert.message)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-xs text-muted-foreground mb-1">当前值</div>
<div className="text-sm font-mono break-all">{_clean(selectedAlert.metric_value)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">阈值</div>
<div className="text-sm font-mono break-all">{_clean(selectedAlert.threshold)}</div>
</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">触发时间</div>
<div className="text-sm">{formatTime(selectedAlert.triggered_at)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">当前状态</div>
<div className={`text-sm font-medium ${STATUS_COLORS[selectedAlert.status]}`}>
{STATUS_LABELS[selectedAlert.status]}
</div>
</div>
{/* 备注(过滤掉 [RCA_JSON] 结构化数据段,只展示人工备注) */}
{(() => {
const rawNote = selectedAlert.note || "";
const humanNote = rawNote
.replace(/\[RCA_JSON\][\s\S]*?(?=\n\[|\n---|\n$|$)/g, "")
.replace(/\[RCA_JSON\][\s\S]*$/g, "")
.trim();
if (!humanNote) return null;
return (
<div>
<div className="text-xs text-muted-foreground mb-1">备注</div>
<div className="text-sm text-muted-foreground whitespace-pre-wrap">{humanNote}</div>
</div>
);
})()}
{/* AI 根因分析结果卡片 */}
{rcaLoading && (
<div className="rounded-md border border-purple-500/30 bg-purple-500/5 p-3">
<div className="flex items-center gap-2 text-purple-500 text-sm">
<Loader2 size={16} className="animate-spin" />
<span className="font-medium">AI 正在分析故障根因...</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
主动连接设备执行诊断命令,可能需要 1-3 分钟
</div>
</div>
)}
{rcaResult && !rcaLoading && (
<div className="rounded-xl border border-purple-500/30 bg-gradient-to-br from-purple-500/5 via-transparent to-transparent p-5 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5 text-purple-400">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-purple-500/15 border border-purple-500/30">
<Sparkles size={14} />
</div>
<span className="font-semibold text-base">AI 根因分析结果</span>
<span className="text-[11px] text-muted-foreground border border-border/60 rounded px-1.5 py-0.5">
{rcaResult.source === "ai" ? "AI 驱动" : "规则兜底"}
</span>
</div>
{rcaResult.rca && (
<Badge
variant="outline"
className={
rcaResult.rca.confidence >= 80
? "border-green-500/50 bg-green-500/10 text-green-400"
: rcaResult.rca.confidence >= 50
? "border-yellow-500/50 bg-yellow-500/10 text-yellow-400"
: "border-red-500/50 bg-red-500/10 text-red-400"
}
>
置信度 {rcaResult.rca.confidence}%
</Badge>
)}
</div>
{rcaResult.success && rcaResult.rca ? (
<>
{/* Summary card */}
<div className="text-sm bg-purple-500/10 border border-purple-500/20 rounded-lg px-4 py-3 leading-relaxed">
{rcaResult.rca.summary}
</div>
{/* Root causes */}
<div>
<div className="text-xs font-semibold text-muted-foreground mb-2 flex items-center gap-1.5 uppercase tracking-wider">
<AlertTriangle size={12} className="text-amber-500" /> 根因分析
</div>
<ul className="text-sm space-y-1.5">
{rcaResult.rca.rootCauses.map((c, i) => (
<li key={i} className="flex gap-2 bg-background/40 border border-border/40 rounded-md px-3 py-1.5">
<span className="text-purple-400 shrink-0 font-semibold">{i + 1}.</span>
<span className="leading-relaxed">{c}</span>
</li>
))}
</ul>
</div>
{/* Evidence — 成功/失败区分 */}
<div>
<div className="text-xs font-semibold text-muted-foreground mb-2 flex items-center gap-1.5 uppercase tracking-wider">
<Terminal size={12} className="text-cyan-400" /> 关键证据
<span className="ml-auto text-[10px] text-muted-foreground font-normal normal-case tracking-normal">
{(rcaResult.rca.evidence ?? []).filter((e: any) => !e.summary.includes("失败")).length} 成功 · {(rcaResult.rca.evidence ?? []).filter((e: any) => e.summary.includes("失败")).length} 失败
</span>
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{(rcaResult.rca.evidence ?? []).map((e: any, i: number) => {
const isFailed = e.summary.includes("失败") || e.summary.includes("error") || e.summary.includes("错误");
const raw = e.keyFindings?.[0] || "";
const shortOutput = raw.length > 200 ? raw.slice(0, 200) + "…" : raw;
return (
<details
key={i}
className={`text-xs rounded-md border transition-colors ${
isFailed
? "bg-red-500/5 border-red-500/30"
: "bg-background/60 border-border/40 hover:border-border"
}`}
>
<summary className="flex items-center gap-2 px-3 py-2 cursor-pointer select-none">
<span className={`shrink-0 w-1.5 h-1.5 rounded-full ${isFailed ? "bg-red-500" : "bg-emerald-500"}`} />
<code className={`font-mono ${isFailed ? "text-red-400" : "text-cyan-400"}`}>{e.tool}</code>
<span className="text-muted-foreground truncate">{e.summary}</span>
{isFailed && (
<span className="ml-auto text-[10px] text-red-400 font-semibold">执行失败</span>
)}
</summary>
{raw && (
<pre className="m-0 mx-3 mb-2 p-2 bg-black/40 rounded text-[11px] text-green-300/80 font-mono whitespace-pre-wrap break-all max-h-40 overflow-auto leading-relaxed">
{shortOutput}
</pre>
)}
</details>
);
})}
{rcaResult.rca.evidence.length === 0 && (
<div className="text-xs text-muted-foreground italic px-2">无命令证据(可能设备离线或诊断阶段被跳过)</div>
)}
</div>
</div>
{/* Recommendations */}
<div>
<div className="text-xs font-semibold text-muted-foreground mb-2 flex items-center gap-1.5 uppercase tracking-wider">
<Lightbulb size={12} className="text-yellow-400" /> 处置建议
</div>
<div className="space-y-2">
{rcaResult.rca.recommendations.map((r, i) => (
<div
key={i}
className={`rounded-lg border p-3 ${
r.priority === "immediate"
? "border-red-500/40 bg-red-500/5"
: r.priority === "short_term"
? "border-yellow-500/40 bg-yellow-500/5"
: "border-slate-500/40 bg-slate-500/5"
}`}
>
<div className="flex items-center justify-between mb-1.5">
<span
className={`text-[10px] uppercase font-bold tracking-wide ${
r.priority === "immediate"
? "text-red-400"
: r.priority === "short_term"
? "text-yellow-400"
: "text-slate-400"
}`}
>
{r.priority === "immediate"
? "🔥 立即执行"
: r.priority === "short_term"
? "⚡ 短期建议"
: "📋 长期优化"}
</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded ${
r.risk === "high"
? "text-red-400 bg-red-500/10"
: r.risk === "medium"
? "text-yellow-400 bg-yellow-500/10"
: "text-green-400 bg-green-500/10"
}`}
>
风险 {r.risk === "low" ? "低" : r.risk === "medium" ? "中" : "高"}
</span>
</div>
<div className="text-sm leading-relaxed">{r.action}</div>
{r.command && (
<div className="mt-2 flex items-center gap-2">
<Terminal size={11} className="text-muted-foreground shrink-0" />
<code className="text-[11px] bg-black/40 rounded px-2 py-1 text-green-300 font-mono break-all">
{r.command}
</code>
</div>
)}
{r.note && (
<div className="mt-1.5 text-[11px] text-muted-foreground leading-relaxed">
💡 {r.note}
</div>
)}
</div>
))}
</div>
</div>
</>
) : (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/30 rounded-md px-3 py-2">
AI 分析失败: {rcaResult.error || "未知错误"}
</div>
)}
{/* Footer stats */}
<div className="flex items-center justify-between pt-3 border-t border-border/40 text-[11px] text-muted-foreground">
<span>耗时 {(rcaResult.durationMs / 1000).toFixed(1)}s</span>
<span>{(rcaResult.toolInvocations ?? []).filter((i: any) => i.ok).length} / {(rcaResult.toolInvocations ?? []).length} 工具调用成功</span>
</div>
</div>
)}
</div>
<DialogFooter className="shrink-0 flex justify-between">
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setDetailOpen(false)}
>
关闭
</Button>
{selectedAlert && (
<Button
variant={rcaLoading ? "outline" : "secondary"}
onClick={() => triggerRCA(selectedAlert)}
disabled={rcaLoading}
className="border-purple-500/40 text-purple-500 hover:bg-purple-500/10 hover:text-purple-400"
>
{rcaLoading ? (
<Loader2 size={14} className="mr-2 animate-spin" />
) : (
<Bot size={14} className="mr-2" />
)}
{rcaLoading ? "分析中..." : "AI 根因分析"}
</Button>
)}
</div>
<div className="flex gap-2">
{selectedAlert.status === "active" && (
<Button variant="outline" onClick={handleAcknowledge}>
<MessageSquare size={14} className="mr-2" />
确认
</Button>
)}
{(selectedAlert.status === "active" ||
selectedAlert.status === "acknowledged") && (
<Button variant="outline" onClick={handleResolve}>
<CheckCircle size={14} className="mr-2" />
恢复
</Button>
)}
{selectedAlert.status !== "closed" && (
<Button variant="destructive" onClick={handleClose}>
<XCircle size={14} className="mr-2" />
关闭
</Button>
)}
</div>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
{/* 关闭备注弹窗 */}
<Dialog open={noteDialogOpen} onOpenChange={setNoteDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>关闭告警</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<Label>关闭原因(可选)</Label>
<Textarea
placeholder="请输入关闭原因或处理备注..."
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNoteDialogOpen(false)}>
取消
</Button>
<Button variant="destructive" onClick={handleCloseConfirm}>
确认关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AlertCenterPage;