2026-10-15 13:53:04 +08:00
|
|
|
|
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",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-30 12:12:04 +08:00
|
|
|
|
export function AlertCenterPage({ initialAlertId }: { initialAlertId?: string }) {
|
2026-10-15 13:53:04 +08:00
|
|
|
|
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("");
|
|
|
|
|
|
|
2026-10-30 12:12:04 +08:00
|
|
|
|
// 从工作台跳转时自动打开指定告警的详情弹窗
|
|
|
|
|
|
const autoOpenedRef = useRef(false);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!initialAlertId || autoOpenedRef.current) return;
|
|
|
|
|
|
if (alerts.length === 0) return; // 等数据加载完
|
|
|
|
|
|
const target = alerts.find((a) => a.id === initialAlertId);
|
|
|
|
|
|
if (target) {
|
|
|
|
|
|
autoOpenedRef.current = true;
|
|
|
|
|
|
setSelectedAlert(target);
|
|
|
|
|
|
setDetailOpen(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [initialAlertId, alerts]);
|
|
|
|
|
|
|
2026-10-15 13:53:04 +08:00
|
|
|
|
// 防御:清洗告警里可能残留的 [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 {
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// 远程浏览器环境:fetch 主进程 HTTP API 做简化版 RCA(不 SSH,只用已有巡检数据+AI)
|
|
|
|
|
|
const isRemote = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
|
|
|
|
|
if (isRemote) {
|
|
|
|
|
|
const token = (window as any).__REMOTE_TOKEN__ || "";
|
|
|
|
|
|
const resp = await fetch(`/api/run-alert-rca?token=${encodeURIComponent(token)}`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({ alertId: alert.id }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await resp.json();
|
|
|
|
|
|
if (!data.ok) {
|
|
|
|
|
|
toast.error("AI 分析失败: " + (data.error || "未知错误"));
|
|
|
|
|
|
setRcaLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 解析简化版 RCA 结果
|
|
|
|
|
|
const parsed = data.rca;
|
|
|
|
|
|
if (parsed) {
|
|
|
|
|
|
toast.success("AI 分析完成(远程简化版)");
|
|
|
|
|
|
// 构建 RunRCAResult 兼容格式
|
|
|
|
|
|
const rcaPayload = {
|
|
|
|
|
|
__rca_json: true,
|
|
|
|
|
|
generated_at: Date.now(),
|
|
|
|
|
|
source: "ai_simplified",
|
|
|
|
|
|
summary: parsed.summary,
|
|
|
|
|
|
confidence: parsed.confidence,
|
|
|
|
|
|
rootCauses: parsed.rootCauses,
|
|
|
|
|
|
recommendations: parsed.recommendations,
|
|
|
|
|
|
};
|
|
|
|
|
|
setRcaResult({
|
|
|
|
|
|
success: true, source: "ai",
|
|
|
|
|
|
rca: { summary: parsed.summary || "", confidence: parsed.confidence ?? 0, rootCauses: parsed.rootCauses || [], recommendations: parsed.recommendations || [], evidence: [] },
|
|
|
|
|
|
durationMs: 0,
|
|
|
|
|
|
toolInvocations: [{ tool: "ai_simplified_rca", ok: true, durationMs: 0 }],
|
|
|
|
|
|
});
|
|
|
|
|
|
// 持久化到告警 note(主进程已经写了一份,前端再写一次保证一致)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const existingNote = alert.note || "";
|
|
|
|
|
|
const finalNote = "[RCA_JSON]" + JSON.stringify(rcaPayload);
|
|
|
|
|
|
if (existingNote !== finalNote) {
|
|
|
|
|
|
// 主进程已经写了,这里刷新一下本地告警列表
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
toast.error("AI 返回结果格式异常");
|
|
|
|
|
|
}
|
|
|
|
|
|
setRcaLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Electron 本地:原有的 runRCA(完整 Phase 1/2 + SSH 诊断)
|
2026-10-15 13:53:04 +08:00
|
|
|
|
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;
|