380 lines
15 KiB
TypeScript
380 lines
15 KiB
TypeScript
|
|
import React from 'react';
|
|||
|
|
import { Zap, AlertCircle, CheckCircle2, AlertTriangle, Clock, Bot, RefreshCw, ChevronDown, ChevronUp, Wrench, ShieldAlert, Wifi, Activity, Info } from 'lucide-react';
|
|||
|
|
import type { InitialResponse, DevicePlan, RemediationRun } from '../../infrastructure/inspection/inspectionApi';
|
|||
|
|
|
|||
|
|
// ====================================================================
|
|||
|
|
// 告警 AI 面板(响应包 + 预案 + 处置)
|
|||
|
|
// ====================================================================
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
alertId: string;
|
|||
|
|
matchedPlan?: DevicePlan | null;
|
|||
|
|
initialResponse?: InitialResponse | null;
|
|||
|
|
remediation?: RemediationRun | null;
|
|||
|
|
onRegenerateResponse?: () => void;
|
|||
|
|
onStartRemediation?: () => void;
|
|||
|
|
onPauseRemediation?: () => void;
|
|||
|
|
onResumeRemediation?: () => void;
|
|||
|
|
onCancelRemediation?: () => void;
|
|||
|
|
isRemediationRunning?: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function AlertAIPanel({
|
|||
|
|
alertId,
|
|||
|
|
matchedPlan,
|
|||
|
|
initialResponse,
|
|||
|
|
remediation,
|
|||
|
|
onRegenerateResponse,
|
|||
|
|
onStartRemediation,
|
|||
|
|
onPauseRemediation,
|
|||
|
|
onResumeRemediation,
|
|||
|
|
onCancelRemediation,
|
|||
|
|
}: Props) {
|
|||
|
|
return (
|
|||
|
|
<div className="mt-2 space-y-2">
|
|||
|
|
{/* 预案匹配 */}
|
|||
|
|
{matchedPlan && (
|
|||
|
|
<PlanMatchedBadge plan={matchedPlan} />
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* AI 初始响应包 */}
|
|||
|
|
{initialResponse ? (
|
|||
|
|
<ResponsePanel
|
|||
|
|
response={initialResponse}
|
|||
|
|
onRegenerate={onRegenerateResponse}
|
|||
|
|
/>
|
|||
|
|
) : matchedPlan ? (
|
|||
|
|
<div className="p-2 rounded border border-dashed border-slate-600/50 text-xs text-muted-foreground flex items-center gap-1.5"
|
|||
|
|
style={{ background: 'rgba(15,23,42,0.4)' }}>
|
|||
|
|
<Clock className="w-3 h-3 animate-pulse" />
|
|||
|
|
AI 正在生成初始响应包…
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
|
|||
|
|
{/* 处置执行区 */}
|
|||
|
|
{matchedPlan?.remediation?.steps?.length ? (
|
|||
|
|
<RemediationPanel
|
|||
|
|
alertId={alertId}
|
|||
|
|
remediation={remediation}
|
|||
|
|
planSteps={matchedPlan.remediation.steps}
|
|||
|
|
onStart={onStartRemediation}
|
|||
|
|
onPause={onPauseRemediation}
|
|||
|
|
onResume={onResumeRemediation}
|
|||
|
|
onCancel={onCancelRemediation}
|
|||
|
|
/>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --------------------------------------------------------------------
|
|||
|
|
// 预案匹配徽章
|
|||
|
|
// --------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function PlanMatchedBadge({ plan }: { plan: DevicePlan }) {
|
|||
|
|
const levelColors: Record<string, string> = {
|
|||
|
|
critical: 'bg-red-500/15 text-red-400 border-red-500/30',
|
|||
|
|
warning: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
|||
|
|
info: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
|
|||
|
|
};
|
|||
|
|
return (
|
|||
|
|
<div className={`p-2 rounded border ${levelColors[plan.level] || levelColors.info} flex items-center gap-2 text-xs`}
|
|||
|
|
style={{ background: 'rgba(15,23,42,0.5)' }}>
|
|||
|
|
<Wrench className="w-3.5 h-3.5 flex-shrink-0" />
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<div className="font-medium truncate">
|
|||
|
|
匹配预案:{plan.description || plan.id}
|
|||
|
|
</div>
|
|||
|
|
{plan.escalation_fallback?.auto_escalate_minutes ? (
|
|||
|
|
<div className="text-[11px] opacity-75">
|
|||
|
|
挂起 {plan.escalation_fallback.auto_escalate_minutes} 分钟自动升级
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --------------------------------------------------------------------
|
|||
|
|
// AI 初始响应包面板(可折叠)
|
|||
|
|
// --------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function ResponsePanel({
|
|||
|
|
response,
|
|||
|
|
onRegenerate,
|
|||
|
|
}: {
|
|||
|
|
response: InitialResponse;
|
|||
|
|
onRegenerate?: () => void;
|
|||
|
|
}) {
|
|||
|
|
const [expanded, setExpanded] = React.useState(true);
|
|||
|
|
const [causesExpanded, setCausesExpanded] = React.useState(true);
|
|||
|
|
|
|||
|
|
const statusColor = response.status?.includes('fallback') ? 'text-amber-400'
|
|||
|
|
: response.status?.includes('partial') ? 'text-amber-400'
|
|||
|
|
: 'text-emerald-400';
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="rounded border border-cyan-500/20 overflow-hidden"
|
|||
|
|
style={{ background: 'rgba(15,23,42,0.5)' }}>
|
|||
|
|
{/* Header */}
|
|||
|
|
<div
|
|||
|
|
className="px-2.5 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-cyan-500/5"
|
|||
|
|
onClick={() => setExpanded(!expanded)}
|
|||
|
|
>
|
|||
|
|
<Bot className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />
|
|||
|
|
<span className="text-xs font-medium text-cyan-400">AI 初始响应包</span>
|
|||
|
|
<span className={`text-[10px] ${statusColor} flex-1 text-right`}>
|
|||
|
|
{response.generator === 'fallback' ? '降级' : 'AI'} · {response.status?.replace(/_/g, ' ')}
|
|||
|
|
</span>
|
|||
|
|
{onRegenerate && (
|
|||
|
|
<button
|
|||
|
|
onClick={(e) => { e.stopPropagation(); onRegenerate(); }}
|
|||
|
|
className="p-0.5 hover:bg-slate-600/50 rounded"
|
|||
|
|
title="重新生成"
|
|||
|
|
>
|
|||
|
|
<RefreshCw className="w-3 h-3 text-muted-foreground" />
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
{expanded ? <ChevronUp className="w-3 h-3 text-muted-foreground" /> : <ChevronDown className="w-3 h-3 text-muted-foreground" />}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{expanded && (
|
|||
|
|
<div className="px-2.5 py-2 border-t border-slate-600/30 space-y-2.5">
|
|||
|
|
{/* 快速排查清单 */}
|
|||
|
|
{response.quick_checklist?.length > 0 && (
|
|||
|
|
<div>
|
|||
|
|
<div className="text-[11px] text-muted-foreground mb-1 flex items-center gap-1">
|
|||
|
|
<CheckCircle2 className="w-3 h-3 text-emerald-400" /> 快速排查清单
|
|||
|
|
</div>
|
|||
|
|
<div className="space-y-1">
|
|||
|
|
{response.quick_checklist.map((item, i) => (
|
|||
|
|
<div key={i} className="flex items-start gap-1.5 text-xs">
|
|||
|
|
<span className="w-4 h-4 flex items-center justify-center text-[10px] bg-cyan-500/15 text-cyan-400 rounded flex-shrink-0 mt-0.5">
|
|||
|
|
{item.priority || i + 1}
|
|||
|
|
</span>
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<div className="text-foreground">{item.action}</div>
|
|||
|
|
{item.purpose && <div className="text-[11px] text-muted-foreground">{item.purpose}</div>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 可能原因(仅 AI 生成有) */}
|
|||
|
|
{response.possible_causes?.length > 0 && (
|
|||
|
|
<div>
|
|||
|
|
<div
|
|||
|
|
className="text-[11px] text-muted-foreground mb-1 flex items-center gap-1 cursor-pointer hover:text-foreground"
|
|||
|
|
onClick={() => setCausesExpanded(!causesExpanded)}
|
|||
|
|
>
|
|||
|
|
<AlertTriangle className="w-3 h-3 text-amber-400" /> 可能原因
|
|||
|
|
{causesExpanded ? <ChevronUp className="w-2.5 h-2.5" /> : <ChevronDown className="w-2.5 h-2.5" />}
|
|||
|
|
</div>
|
|||
|
|
{causesExpanded && (
|
|||
|
|
<div className="space-y-1">
|
|||
|
|
{response.possible_causes.map((cause, i) => (
|
|||
|
|
<div key={i} className="flex items-start gap-1.5 text-xs">
|
|||
|
|
<ProbabilityBadge p={cause.probability} />
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<span className="text-foreground">{cause.label}</span>
|
|||
|
|
{cause.hint && <span className="text-muted-foreground ml-1">· {cause.hint}</span>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 通知状态 */}
|
|||
|
|
{(response.auto_notifications_sent?.length > 0 || response.pending_notifications?.length > 0) && (
|
|||
|
|
<div className="pt-1 border-t border-slate-600/20">
|
|||
|
|
<div className="text-[11px] text-muted-foreground mb-1 flex items-center gap-1">
|
|||
|
|
<Wifi className="w-3 h-3" /> 通知状态
|
|||
|
|
</div>
|
|||
|
|
<div className="flex flex-wrap gap-1">
|
|||
|
|
{response.auto_notifications_sent.map((n, i) => (
|
|||
|
|
<span key={i} className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-400">
|
|||
|
|
{n.channel} ✓
|
|||
|
|
</span>
|
|||
|
|
))}
|
|||
|
|
{response.pending_notifications.map((n: any, i: number) => (
|
|||
|
|
<span key={`p-${i}`} className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-400">
|
|||
|
|
{n.channel || 'pending'} · 待外部
|
|||
|
|
</span>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Warnings */}
|
|||
|
|
{response.warnings?.length > 0 && (
|
|||
|
|
<div className="pt-1 border-t border-slate-600/20">
|
|||
|
|
{response.warnings.map((w, i) => (
|
|||
|
|
<div key={i} className="text-[11px] text-amber-400 flex items-start gap-1 mt-0.5">
|
|||
|
|
<AlertCircle className="w-3 h-3 flex-shrink-0 mt-0.5" /> {w}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 生成时间 */}
|
|||
|
|
<div className="text-[10px] text-muted-foreground text-right">
|
|||
|
|
{new Date(response.generated_at).toLocaleString()}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ProbabilityBadge({ p }: { p: string }) {
|
|||
|
|
const map: Record<string, { cls: string; label: string }> = {
|
|||
|
|
high: { cls: 'bg-red-500/20 text-red-400 border-red-500/40', label: '高' },
|
|||
|
|
medium: { cls: 'bg-amber-500/20 text-amber-400 border-amber-500/40', label: '中' },
|
|||
|
|
low: { cls: 'bg-slate-500/20 text-slate-400 border-slate-500/40', label: '低' },
|
|||
|
|
};
|
|||
|
|
const cfg = map[p] || map.low;
|
|||
|
|
return (
|
|||
|
|
<span className={`text-[9px] px-1 rounded border ${cfg.cls} flex-shrink-0 mt-0.5`}>{cfg.label}</span>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --------------------------------------------------------------------
|
|||
|
|
// 处置执行面板
|
|||
|
|
// --------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function RemediationPanel({
|
|||
|
|
alertId: _alertId,
|
|||
|
|
remediation,
|
|||
|
|
planSteps,
|
|||
|
|
onStart,
|
|||
|
|
onPause,
|
|||
|
|
onResume,
|
|||
|
|
onCancel,
|
|||
|
|
}: {
|
|||
|
|
alertId: string;
|
|||
|
|
remediation?: RemediationRun | null;
|
|||
|
|
planSteps: any[];
|
|||
|
|
onStart?: () => void;
|
|||
|
|
onPause?: () => void;
|
|||
|
|
onResume?: () => void;
|
|||
|
|
onCancel?: () => void;
|
|||
|
|
}) {
|
|||
|
|
const statusMap: Record<string, { cls: string; label: string; icon: React.ReactNode }> = {
|
|||
|
|
pending: { cls: 'text-slate-400', label: '待处置', icon: <Clock className="w-3 h-3" /> },
|
|||
|
|
running: { cls: 'text-sky-400', label: '执行中', icon: <Loader2 className="w-3 h-3 animate-spin" /> },
|
|||
|
|
completed: { cls: 'text-emerald-400', label: '完成', icon: <CheckCircle2 className="w-3 h-3" /> },
|
|||
|
|
partial: { cls: 'text-amber-400', label: '部分完成', icon: <AlertTriangle className="w-3 h-3" /> },
|
|||
|
|
failed: { cls: 'text-red-400', label: '失败', icon: <XCircle className="w-3 h-3" /> },
|
|||
|
|
cancelled: { cls: 'text-slate-400', label: '已取消', icon: <XCircle className="w-3 h-3" /> },
|
|||
|
|
paused: { cls: 'text-amber-400', label: '已暂停', icon: <Pause className="w-3 h-3" /> },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const runStatus = remediation?.status || 'pending';
|
|||
|
|
const cfg = statusMap[runStatus] || statusMap.pending;
|
|||
|
|
|
|||
|
|
const steps = remediation?.steps?.length ? remediation.steps : planSteps.map((s: any, i: number) => ({
|
|||
|
|
order: s.order || i + 1,
|
|||
|
|
command_id: s.command_id,
|
|||
|
|
execution: s.execution,
|
|||
|
|
status: 'pending',
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="rounded border border-violet-500/20 overflow-hidden"
|
|||
|
|
style={{ background: 'rgba(15,23,42,0.5)' }}>
|
|||
|
|
{/* Header */}
|
|||
|
|
<div className="px-2.5 py-1.5 flex items-center gap-2 border-b border-slate-600/30">
|
|||
|
|
<Wrench className="w-3.5 h-3.5 text-violet-400" />
|
|||
|
|
<span className="text-xs font-medium text-violet-400">处置执行</span>
|
|||
|
|
<span className={`text-[10px] flex items-center gap-1 ${cfg.cls} ml-auto`}>
|
|||
|
|
{cfg.icon} {cfg.label}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Steps */}
|
|||
|
|
<div className="px-2.5 py-1.5 max-h-40 overflow-y-auto">
|
|||
|
|
<div className="space-y-1">
|
|||
|
|
{steps.map((step: any, i: number) => (
|
|||
|
|
<StepRow key={i} step={step} />
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Actions */}
|
|||
|
|
<div className="px-2.5 py-1.5 border-t border-slate-600/30 flex items-center gap-1">
|
|||
|
|
{(runStatus === 'pending') && onStart && (
|
|||
|
|
<button
|
|||
|
|
onClick={onStart}
|
|||
|
|
className="text-[11px] px-2 py-1 rounded bg-violet-500/20 text-violet-400 hover:bg-violet-500/30 flex items-center gap-1"
|
|||
|
|
>
|
|||
|
|
<Zap className="w-3 h-3" /> 开始处置
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
{runStatus === 'running' && onPause && (
|
|||
|
|
<button
|
|||
|
|
onClick={onPause}
|
|||
|
|
className="text-[11px] px-2 py-1 rounded bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 flex items-center gap-1"
|
|||
|
|
>
|
|||
|
|
<Pause className="w-3 h-3" /> 暂停
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
{runStatus === 'paused' && onResume && (
|
|||
|
|
<button
|
|||
|
|
onClick={onResume}
|
|||
|
|
className="text-[11px] px-2 py-1 rounded bg-sky-500/20 text-sky-400 hover:bg-sky-500/30 flex items-center gap-1"
|
|||
|
|
>
|
|||
|
|
<Zap className="w-3 h-3" /> 继续
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
{(runStatus === 'running' || runStatus === 'paused') && onCancel && (
|
|||
|
|
<button
|
|||
|
|
onClick={onCancel}
|
|||
|
|
className="text-[11px] px-2 py-1 rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 flex items-center gap-1"
|
|||
|
|
>
|
|||
|
|
<XCircle className="w-3 h-3" /> 取消
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function StepRow({ step }: { step: any }) {
|
|||
|
|
const execBadge: Record<string, { cls: string; label: string }> = {
|
|||
|
|
auto: { cls: 'bg-emerald-500/15 text-emerald-400', label: '自动' },
|
|||
|
|
confirm: { cls: 'bg-amber-500/15 text-amber-400', label: '确认' },
|
|||
|
|
manual_only: { cls: 'bg-slate-500/15 text-slate-400', label: '手动' },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const stepStatusMap: Record<string, { dot: string; icon: React.ReactNode; cls: string }> = {
|
|||
|
|
pending: { dot: 'bg-slate-500', icon: <Clock className="w-3 h-3" />, cls: 'text-slate-400' },
|
|||
|
|
running: { dot: 'bg-sky-500 animate-pulse', icon: <Loader2 className="w-3 h-3 animate-spin" />, cls: 'text-sky-400' },
|
|||
|
|
success: { dot: 'bg-emerald-500', icon: <CheckCircle2 className="w-3 h-3" />, cls: 'text-emerald-400' },
|
|||
|
|
failed: { dot: 'bg-red-500', icon: <XCircle className="w-3 h-3" />, cls: 'text-red-400' },
|
|||
|
|
skipped: { dot: 'bg-slate-500', icon: <Info className="w-3 h-3" />, cls: 'text-slate-400' },
|
|||
|
|
pending_manual: { dot: 'bg-amber-500', icon: <ShieldAlert className="w-3 h-3" />, cls: 'text-amber-400' },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const ss = stepStatusMap[step.status || 'pending'] || stepStatusMap.pending;
|
|||
|
|
const eb = execBadge[step.execution] || execBadge.auto;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|||
|
|
<div className={`w-1.5 h-1.5 rounded-full ${ss.dot} flex-shrink-0`} />
|
|||
|
|
<span className={`flex-shrink-0 ${ss.cls}`}>{ss.icon}</span>
|
|||
|
|
<span className="font-mono text-foreground/80 truncate flex-1">
|
|||
|
|
#{step.order || '?'} {step.command_id || 'checklist'}
|
|||
|
|
</span>
|
|||
|
|
<span className={`text-[9px] px-1 rounded flex-shrink-0 ${eb.cls}`}>{eb.label}</span>
|
|||
|
|
{step.error && <span className="text-[10px] text-red-400 flex-shrink-0">✗</span>}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 懒加载这些避免循环 import
|
|||
|
|
import { Loader2, XCircle, Pause } from 'lucide-react';
|