Files

380 lines
15 KiB
TypeScript
Raw Permalink Normal View History

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';