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
- 8 new store modules + matcher + initialResponse + aggregation + remediation + AlertStateMonitor + planGenerator - alert chain auto-integrated after runInspection: freeze snapshot + generate response + real-time agg + auto-start monitor - AlertAIPanel component + MonitoringDetailPanel expandable alert list - PlanManagementPage: full page with list/edit/AI generate/templates/commands
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';
|