Files
SenMeshNet/components/inspection/PlanManagementPage.tsx
zhaolei a5744e720b
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
feat(plan-management): implement alert-remediation-plan system v1.3
- 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
2026-10-30 16:55:55 +08:00

1083 lines
46 KiB
TypeScript
Raw Permalink 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 React, { useEffect, useState } from 'react';
import {
Wrench, Zap, Bot, Plus, Trash2, Edit3, Eye, CheckCircle2, XCircle, Clock,
ChevronDown, ChevronRight, Sparkles, AlertTriangle, Shield, Activity,
Play, Pause, X, Loader2, Search, RefreshCw, Settings, FileText,
} from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Badge } from '../ui/badge';
import { Textarea } from '../ui/textarea';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '../ui/select';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '../ui/dialog';
import {
Tabs, TabsContent, TabsList, TabsTrigger,
} from '../ui/tabs';
import {
planManagementApi,
type DevicePlan,
type CommandCatalogItem,
type PlanTemplate,
} from '../../infrastructure/inspection/inspectionApi';
// ====================================================================
// 预案管理页面
// ====================================================================
export function PlanManagementPage() {
const [activeTab, setActiveTab] = useState<'plans' | 'templates' | 'commands' | 'generate'>('plans');
const [plans, setPlans] = useState<DevicePlan[]>([]);
const [templates, setTemplates] = useState<PlanTemplate[]>([]);
const [commands, setCommands] = useState<CommandCatalogItem[]>([]);
const [filterStatus, setFilterStatus] = useState<string>('all');
const [filterLevel, setFilterLevel] = useState<string>('all');
const [filterDevice, setFilterDevice] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [editPlan, setEditPlan] = useState<DevicePlan | null>(null);
const [isNewPlan, setIsNewPlan] = useState(false);
const [detailPlan, setDetailPlan] = useState<DevicePlan | null>(null);
async function loadAll() {
setLoading(true);
try {
const [p, t, c] = await Promise.all([
planManagementApi.listPlans({}),
planManagementApi.listTemplates(),
planManagementApi.listCommandCatalog(),
]);
setPlans(p || []);
setTemplates(t || []);
setCommands(c || []);
} catch (e) {
console.error('[PlanManagement] load failed:', e);
} finally {
setLoading(false);
}
}
useEffect(() => { loadAll(); }, []);
const filteredPlans = plans.filter((p) => {
if (filterStatus !== 'all' && p.status !== filterStatus) return false;
if (filterLevel !== 'all' && p.level !== filterLevel) return false;
if (filterDevice !== 'all' && p.device_type !== filterDevice) return false;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
return (
p.description?.toLowerCase().includes(q) ||
p.id.toLowerCase().includes(q) ||
p.device_type.toLowerCase().includes(q)
);
}
return true;
});
// ---------- Actions ----------
async function handleToggleEnabled(p: DevicePlan) {
await planManagementApi.updatePlan(p.id, { enabled: !p.enabled });
loadAll();
}
async function handleDelete(p: DevicePlan) {
if (p.builtin) return;
if (!confirm(`确定删除预案 "${p.description || p.id}"?`)) return;
await planManagementApi.deletePlan(p.id);
loadAll();
}
async function handleApprove(p: DevicePlan) {
await planManagementApi.approvePlan(p.id, 'admin');
loadAll();
}
async function handleReject(p: DevicePlan) {
const reason = prompt('拒绝原因:', '');
if (!reason) return;
await planManagementApi.rejectPlan(p.id, reason, 'admin');
loadAll();
}
function openNewPlan() {
setIsNewPlan(true);
setEditPlan(_createEmptyPlan());
}
function openEditPlan(p: DevicePlan) {
setIsNewPlan(false);
setEditPlan(JSON.parse(JSON.stringify(p)));
}
async function handleSavePlan(data: DevicePlan) {
if (isNewPlan) {
await planManagementApi.createPlan(data);
} else {
await planManagementApi.updatePlan(data.id, data);
}
setEditPlan(null);
loadAll();
}
// ---------- Render ----------
return (
<div className="h-full flex flex-col" style={{ background: '#0f172a' }}>
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700/50 flex-shrink-0">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-violet-500/15 flex items-center justify-center">
<Wrench className="w-4 h-4 text-violet-400" />
</div>
<div>
<div className="text-sm font-semibold text-foreground">预案管理</div>
<div className="text-[11px] text-muted-foreground">双轨预案体系:值班员流程 + AI 操作步骤</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadAll} disabled={loading}>
<RefreshCw className={`w-3 h-3 mr-1 ${loading ? 'animate-spin' : ''}`} /> 刷新
</Button>
<Button size="sm" onClick={openNewPlan}>
<Plus className="w-3 h-3 mr-1" /> 新建预案
</Button>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col overflow-hidden">
<div className="px-4 pt-3 flex-shrink-0">
<TabsList>
<TabsTrigger value="plans">
<Activity className="w-3 h-3 mr-1.5" /> 设备预案
{plans.filter((p) => p.status === 'draft').length > 0 && (
<Badge variant="secondary" className="ml-1.5 bg-amber-500/15 text-amber-400 text-[9px] px-1 py-0">
{plans.filter((p) => p.status === 'draft').length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="templates">
<FileText className="w-3 h-3 mr-1.5" /> 模板
</TabsTrigger>
<TabsTrigger value="commands">
<Shield className="w-3 h-3 mr-1.5" /> 命令目录
</TabsTrigger>
<TabsTrigger value="generate">
<Bot className="w-3 h-3 mr-1.5" /> AI 生成
</TabsTrigger>
</TabsList>
</div>
{/* 预案列表 */}
<TabsContent value="plans" className="flex-1 overflow-hidden flex flex-col m-0 p-0">
<div className="px-4 pt-3 flex items-center gap-2 flex-wrap flex-shrink-0">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索预案..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-7 h-8 text-xs"
/>
</div>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="h-8 w-[110px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="active">生效中</SelectItem>
<SelectItem value="draft">待审核</SelectItem>
<SelectItem value="disabled">已禁用</SelectItem>
</SelectContent>
</Select>
<Select value={filterLevel} onValueChange={setFilterLevel}>
<SelectTrigger className="h-8 w-[100px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部级别</SelectItem>
<SelectItem value="critical">严重</SelectItem>
<SelectItem value="warning">警告</SelectItem>
<SelectItem value="info">提示</SelectItem>
</SelectContent>
</Select>
<Select value={filterDevice} onValueChange={setFilterDevice}>
<SelectTrigger className="h-8 w-[140px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部设备类型</SelectItem>
{Array.from(new Set(plans.map((p) => p.device_type))).map((dt) => (
<SelectItem key={dt} value={dt}>{dt}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<PlanList
plans={filteredPlans}
onToggle={handleToggleEnabled}
onDelete={handleDelete}
onApprove={handleApprove}
onReject={handleReject}
onEdit={openEditPlan}
onView={setDetailPlan}
/>
</TabsContent>
{/* 模板列表 */}
<TabsContent value="templates" className="flex-1 overflow-auto m-0 p-4">
<TemplateList templates={templates} />
</TabsContent>
{/* 命令目录 */}
<TabsContent value="commands" className="flex-1 overflow-auto m-0 p-4">
<CommandCatalogList commands={commands} />
</TabsContent>
{/* AI 生成 */}
<TabsContent value="generate" className="flex-1 overflow-auto m-0 p-4">
<PlanAIGenerator onGenerated={loadAll} />
</TabsContent>
</Tabs>
{/* 编辑对话框 */}
{editPlan && (
<PlanEditDialog
plan={editPlan}
isNew={isNewPlan}
onClose={() => setEditPlan(null)}
onSave={handleSavePlan}
/>
)}
{/* 详情对话框 */}
{detailPlan && (
<PlanDetailDialog
plan={detailPlan}
onClose={() => setDetailPlan(null)}
onEdit={() => { openEditPlan(detailPlan); setDetailPlan(null); }}
/>
)}
</div>
);
}
// ====================================================================
// 预案列表
// ====================================================================
function PlanList({
plans, onToggle, onDelete, onApprove, onReject, onEdit, onView,
}: {
plans: DevicePlan[];
onToggle: (p: DevicePlan) => void;
onDelete: (p: DevicePlan) => void;
onApprove: (p: DevicePlan) => void;
onReject: (p: DevicePlan) => void;
onEdit: (p: DevicePlan) => void;
onView: (p: DevicePlan) => void;
}) {
if (plans.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
<div className="text-center">
<Wrench className="w-8 h-8 mx-auto mb-3 opacity-30" />
<div>暂无预案,点击右上角"新建预案"开始配置</div>
</div>
</div>
);
}
return (
<div className="flex-1 overflow-auto p-4">
<div className="grid gap-3">
{plans.map((p) => {
const levelColor: Record<string, string> = {
critical: 'text-red-400 bg-red-500/10 border-red-500/20',
warning: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
info: 'text-sky-400 bg-sky-500/10 border-sky-500/20',
};
const statusBadge: Record<string, { cls: string; label: string; icon: any }> = {
active: { cls: 'bg-emerald-500/15 text-emerald-400', label: '生效', icon: CheckCircle2 },
draft: { cls: 'bg-amber-500/15 text-amber-400', label: '待审核', icon: Clock },
disabled: { cls: 'bg-slate-500/15 text-slate-400', label: '已禁用', icon: XCircle },
};
const sb = statusBadge[p.status] || statusBadge.active;
const SBIcon = sb.icon;
return (
<div key={p.id} className="p-3 rounded-lg border transition-all hover:border-violet-500/40"
style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className={levelColor[p.level] || levelColor.info}>
{p.level === 'critical' ? '严重' : p.level === 'warning' ? '警告' : '提示'}
</Badge>
<Badge className={sb.cls}>
<SBIcon className="w-3 h-3 mr-1" /> {sb.label}
</Badge>
{p.source === 'ai' && (
<Badge variant="outline" className="bg-violet-500/15 text-violet-400 border-violet-500/30">
<Bot className="w-2.5 h-2.5 mr-1" /> AI 生成
</Badge>
)}
{p.builtin && (
<Badge variant="outline" className="text-xs">内置</Badge>
)}
{!p.enabled && (
<Badge variant="outline" className="text-xs bg-slate-500/10 text-slate-400">未启用</Badge>
)}
</div>
<div className="text-sm font-medium text-foreground mb-0.5">
{p.description || p.id}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-3 flex-wrap">
<span>设备类型:<span className="text-cyan-400 font-mono">{p.device_type}</span></span>
<span>处置步骤:<span className="text-violet-400">{p.remediation.steps.length}</span></span>
{p.escalation_fallback?.auto_escalate_minutes && (
<span>
升级超时:<span className="text-amber-400">{p.escalation_fallback.auto_escalate_minutes}min</span>
</span>
)}
</div>
{p.status === 'draft' && p.reject_reason && (
<div className="mt-1 text-[11px] text-amber-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> 上次拒绝原因:{p.reject_reason}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
{p.status === 'draft' && (
<>
<Button size="sm" variant="ghost" onClick={() => handleApprove(p)} className="h-7 text-emerald-400 hover:text-emerald-300">
<CheckCircle2 className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={() => handleReject(p)} className="h-7 text-red-400 hover:text-red-300">
<XCircle className="w-3.5 h-3.5" />
</Button>
</>
)}
{p.status !== 'draft' && !p.builtin && (
<Button size="sm" variant="ghost" onClick={() => onToggle(p)} className="h-7">
{p.enabled ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5" />}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => onEdit(p)} className="h-7">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={() => onView(p)} className="h-7">
<Eye className="w-3.5 h-3.5" />
</Button>
{!p.builtin && (
<Button size="sm" variant="ghost" onClick={() => onDelete(p)} className="h-7 text-red-400 hover:text-red-300">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
// ====================================================================
// 模板 / 命令目录(简化展示)
// ====================================================================
function TemplateList({ templates }: { templates: PlanTemplate[] }) {
return (
<div className="grid gap-3 md:grid-cols-2">
{templates.map((t) => (
<div key={t.id} className="p-3 rounded-lg border"
style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}>
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium text-foreground">{t.name}</div>
<Badge variant="outline">{t.level}</Badge>
</div>
<div className="text-xs text-muted-foreground mb-2">{t.description}</div>
<div className="text-[11px] text-muted-foreground space-y-0.5">
<div>通知通道:{t.notification.channels.join(', ') || '无'}</div>
{t.escalation_fallback && (
<div>自动升级:{t.escalation_fallback.auto_escalate_minutes}min → {t.escalation_fallback.escalation_target_level}</div>
)}
<div>超时:{t.remediation_defaults.execution_timeout_sec}s</div>
</div>
</div>
))}
</div>
);
}
function CommandCatalogList({ commands }: { commands: CommandCatalogItem[] }) {
const [filterType, setFilterType] = useState('all');
const filtered = filterType === 'all' ? commands : commands.filter((c) => c.device_types.includes(filterType));
const deviceTypes = Array.from(new Set(commands.flatMap((c) => c.device_types)));
return (
<div>
<div className="flex items-center gap-2 mb-3">
<span className="text-xs text-muted-foreground">过滤:</span>
<Button size="sm" variant="ghost" onClick={() => setFilterType('all')}
className={filterType === 'all' ? 'bg-violet-500/20 text-violet-400' : ''}>全部</Button>
{deviceTypes.map((dt) => (
<Button key={dt} size="sm" variant="ghost" onClick={() => setFilterType(dt)}
className={filterType === dt ? 'bg-violet-500/20 text-violet-400' : ''}>
{dt}
</Button>
))}
</div>
<div className="grid gap-2 md:grid-cols-2">
{filtered.map((c) => {
const execColor: Record<string, string> = {
auto: 'bg-emerald-500/15 text-emerald-400',
confirm: 'bg-amber-500/15 text-amber-400',
manual_only: 'bg-slate-500/15 text-slate-400',
};
return (
<div key={c.id} className="p-2.5 rounded border"
style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-foreground font-mono truncate">{c.id}</div>
<div className="text-[11px] text-muted-foreground">{c.name}</div>
<div className="text-[11px] text-emerald-400/80 font-mono mt-0.5 truncate">{c.command}</div>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<Badge variant="outline" className={execColor[c.execution]}>
{c.execution === 'auto' ? '自动' : c.execution === 'confirm' ? '确认' : '手动'}
</Badge>
<Badge variant="outline" className="text-[9px]">{c.risk_level}</Badge>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
// ====================================================================
// AI 生成面板
// ====================================================================
function PlanAIGenerator({ onGenerated }: { onGenerated: () => void }) {
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<DevicePlan | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
async function handleGenerate() {
if (!input.trim()) return;
setLoading(true);
setError(null);
setResult(null);
setWarnings([]);
try {
const r = await planManagementApi.generatePlan(input);
if (!r.ok || !r.plan) {
setError(r.error || '生成失败');
} else {
setResult(r.plan);
setWarnings(r.warnings || []);
}
} catch (e: any) {
setError(e.message || String(e));
} finally {
setLoading(false);
}
}
async function handleSave() {
if (!result) return;
await planManagementApi.createPlan(result);
onGenerated();
setInput('');
setResult(null);
setWarnings([]);
}
function handleEdit() {
if (!result) return;
// 把草稿直接加载到编辑面板(简化:创建后再编辑)
setInput('');
setResult(null);
setWarnings([]);
}
return (
<div className="max-w-3xl space-y-4">
<div className="p-4 rounded-lg border"
style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}>
<div className="flex items-center gap-2 mb-3">
<Bot className="w-4 h-4 text-violet-400" />
<span className="text-sm font-medium">AI 预案生成</span>
<span className="text-[11px] text-muted-foreground">一句话描述场景,AI 自动生成双轨预案草稿</span>
</div>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="例如:核心交换机 CPU 超 90% 持续 5 分钟,先 display cpu-usage 看是哪个进程,持续不降就调 CPU 告警阈值到 95%"
className="min-h-[80px] text-xs mb-3"
/>
<div className="flex items-center gap-2">
<Button onClick={handleGenerate} disabled={loading || !input.trim()}>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1.5" /> : <Sparkles className="w-3.5 h-3.5 mr-1.5" />}
{loading ? '生成中...' : '生成预案'}
</Button>
</div>
</div>
{error && (
<div className="p-3 rounded border border-red-500/30 bg-red-500/10 text-xs text-red-400 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{result && (
<div className="p-4 rounded-lg border border-violet-500/30"
style={{ background: 'rgba(15,23,42,0.6)' }}>
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-4 h-4 text-violet-400" />
<span className="text-sm font-medium text-foreground">生成结果(待审核草稿)</span>
</div>
{warnings.length > 0 && (
<div className="mb-3 p-2 rounded bg-amber-500/10 border border-amber-500/30">
<div className="text-[11px] text-amber-400 mb-1 font-medium">三重校验警告</div>
{warnings.map((w, i) => (
<div key={i} className="text-[11px] text-amber-300">• {w}</div>
))}
</div>
)}
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<span className="text-muted-foreground w-20">设备类型</span>
<Badge variant="outline">{result.device_type}</Badge>
<span className="text-muted-foreground w-16 ml-4">级别</span>
<Badge variant="outline">{result.level}</Badge>
</div>
<div>
<span className="text-muted-foreground">描述:</span>
<span className="text-foreground">{result.description}</span>
</div>
{/* human_action_guide */}
<div className="pt-2 mt-2 border-t border-slate-600/30">
<div className="text-[11px] text-cyan-400 mb-1 font-medium">值班员动作预案</div>
{result.human_action_guide.info_gathering.length > 0 && (
<div className="space-y-0.5">
{result.human_action_guide.info_gathering.map((g, i) => (
<div key={i} className="text-[11px] flex items-start gap-1">
<span className="text-muted-foreground">[{g.priority}]</span>
<span className="text-foreground">{g.action}</span>
<span className="text-muted-foreground">— {g.purpose}</span>
</div>
))}
</div>
)}
</div>
{/* remediation */}
<div className="pt-2 mt-2 border-t border-slate-600/30">
<div className="text-[11px] text-violet-400 mb-1 font-medium">AI 操作步骤({result.remediation.steps.length} 步)</div>
{result.remediation.steps.map((s, i) => (
<div key={i} className="text-[11px] flex items-center gap-2">
<span className="text-muted-foreground">#{s.order}</span>
<span className="font-mono text-foreground">{s.command_id}</span>
<Badge variant="outline" className="text-[9px] px-1">
{s.execution === 'auto' ? '自动' : s.execution === 'confirm' ? '确认' : '手动'}
</Badge>
</div>
))}
</div>
</div>
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-slate-600/30">
<Button onClick={handleSave} size="sm">
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5" /> 保存为草稿(待审核)
</Button>
<Button variant="outline" size="sm" onClick={handleEdit}>
放弃
</Button>
</div>
</div>
)}
</div>
);
}
// ====================================================================
// 预案编辑对话框
// ====================================================================
function PlanEditDialog({
plan, isNew, onClose, onSave,
}: {
plan: DevicePlan;
isNew: boolean;
onClose: () => void;
onSave: (p: DevicePlan) => void;
}) {
const [data, setData] = useState<DevicePlan>(plan);
const [activeSection, setActiveSection] = useState('basic');
function update<K extends keyof DevicePlan>(key: K, value: DevicePlan[K]) {
setData((d) => ({ ...d, [key]: value }));
}
function updateMatch<K extends keyof DevicePlan['match']>(key: K, value: any) {
setData((d) => ({ ...d, match: { ...d.match, [key]: value } }));
}
function updateHuman<K extends keyof DevicePlan['human_action_guide']>(key: K, value: any) {
setData((d) => ({ ...d, human_action_guide: { ...d.human_action_guide, [key]: value } }));
}
function updateRemediationSteps(steps: any[]) {
setData((d) => ({ ...d, remediation: { ...d.remediation, steps } }));
}
function addStep() {
updateRemediationSteps([...data.remediation.steps, {
order: data.remediation.steps.length + 1,
action: 'command',
execution: 'auto',
on_success: 'next',
on_failure: 'stop',
}]);
}
function removeStep(idx: number) {
const steps = data.remediation.steps.filter((_, i) => i !== idx).map((s, i) => ({ ...s, order: i + 1 }));
updateRemediationSteps(steps);
}
function updateStep(idx: number, updates: any) {
const steps = data.remediation.steps.map((s, i) => (i === idx ? { ...s, ...updates } : s));
updateRemediationSteps(steps);
}
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col" style={{ background: '#0f172a' }}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wrench className="w-4 h-4 text-violet-400" />
{isNew ? '新建预案' : '编辑预案'}
<Badge variant="outline" className="text-[10px] ml-2">
双轨 · 值班员 + AI 操作
</Badge>
</DialogTitle>
<DialogDescription>
{isNew ? '配置告警触发时的自动处置预案' : `预案 ID: ${plan.id}`}
</DialogDescription>
</DialogHeader>
{/* Section nav */}
<div className="flex items-center gap-1 border-b border-slate-700/50 pb-2 flex-shrink-0">
{[
{ key: 'basic', label: '基本信息' },
{ key: 'match', label: '匹配规则' },
{ key: 'human', label: '值班员流程' },
{ key: 'remediation', label: 'AI 处置步骤' },
{ key: 'security', label: '安全与升级' },
].map((sec) => (
<button
key={sec.key}
onClick={() => setActiveSection(sec.key)}
className={`px-3 py-1 text-xs rounded transition-colors ${
activeSection === sec.key
? 'bg-violet-500/20 text-violet-400'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{sec.label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto py-4 pr-2 space-y-4 text-sm">
{/* 基本信息 */}
{activeSection === 'basic' && (
<div className="grid gap-4">
<Field label="预案描述">
<Input
value={data.description}
onChange={(e) => update('description', e.target.value)}
placeholder="例如:核心交换机 CPU 超 90% 持续 5 分钟处置"
className="text-sm"
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="设备类型">
<Select value={data.device_type} onValueChange={(v) => update('device_type', v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="huawei_h3c_switch">华为/H3C 交换机</SelectItem>
<SelectItem value="cisco_switch">Cisco 交换机</SelectItem>
<SelectItem value="huawei_firewall">华为防火墙</SelectItem>
<SelectItem value="linux">Linux 服务器</SelectItem>
<SelectItem value="windows">Windows 服务器</SelectItem>
<SelectItem value="any">任意</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="告警级别">
<Select value={data.level} onValueChange={(v) => update('level', v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="critical">严重</SelectItem>
<SelectItem value="warning">警告</SelectItem>
<SelectItem value="info">提示</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="plan-enabled"
checked={data.enabled}
onChange={(e) => update('enabled', e.target.checked)}
className="accent-violet-500"
/>
<label htmlFor="plan-enabled" className="text-xs text-muted-foreground">启用此预案</label>
</div>
</div>
)}
{/* 匹配规则 */}
{activeSection === 'match' && (
<div className="space-y-4">
<Field label="匹配告警类型(逗号分隔)">
<Input
value={data.match.alert_types.join(', ')}
onChange={(e) => updateMatch('alert_types', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))}
placeholder="performance_high, connectivity"
/>
</Field>
<Field label="匹配设备层级(逗号分隔)">
<Input
value={data.match.device_tiers.join(', ')}
onChange={(e) => updateMatch('device_tiers', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))}
placeholder="core, aggregation"
/>
</Field>
<Field label="scope(host_ids 逗号分隔)">
<Input
value={data.scope.host_ids.join(', ')}
onChange={(e) => setData((d) => ({ ...d, scope: { ...d.scope, host_ids: e.target.value.split(',').map((s) => s.trim()).filter(Boolean) } }))}
placeholder="留空 = 全局"
/>
</Field>
</div>
)}
{/* 值班员流程 */}
{activeSection === 'human' && (
<div className="space-y-4">
<div>
<div className="text-xs text-cyan-400 mb-1 font-medium">收到告警第一时间做什么</div>
<Textarea
value={data.human_action_guide.receiving_alerts.join('\n')}
onChange={(e) => updateHuman('receiving_alerts', e.target.value.split('\n').filter(Boolean))}
className="min-h-[60px] text-xs"
/>
</div>
<div>
<div className="text-xs text-cyan-400 mb-1 font-medium">先收集哪些信息</div>
<Textarea
value={data.human_action_guide.info_gathering.map((g) => `[${g.priority}] ${g.action} — ${g.purpose}`).join('\n')}
onChange={(e) => {
const lines = e.target.value.split('\n').filter(Boolean);
const parsed = lines.map((line) => {
const m = line.match(/^\[(\d+)\]\s*(.*?)\s*—\s*(.*)$/);
if (m) return { priority: parseInt(m[1]), action: m[2], purpose: m[3] };
return { priority: 1, action: line, purpose: '' };
});
updateHuman('info_gathering', parsed);
}}
className="min-h-[80px] text-xs font-mono"
/>
<div className="text-[10px] text-muted-foreground mt-1">
格式:[优先级] 动作 — 目的
</div>
</div>
<div>
<div className="text-xs text-cyan-400 mb-1 font-medium">升级触发条件</div>
<Textarea
value={data.human_action_guide.escalation_triggers.map((t) => `${t.condition} → ${t.target_level}`).join('\n')}
onChange={(e) => {
const parsed = e.target.value.split('\n').filter(Boolean).map((line) => {
const m = line.match(/^(.*?)\s*→\s*(.*)$/);
if (m) return { condition: m[1], target_level: m[2] };
return { condition: line, target_level: 'critical' };
});
updateHuman('escalation_triggers', parsed);
}}
className="min-h-[60px] text-xs"
/>
</div>
</div>
)}
{/* AI 处置步骤 */}
{activeSection === 'remediation' && (
<div className="space-y-3">
{data.remediation.steps.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">
暂无处置步骤
</div>
)}
{data.remediation.steps.map((step, idx) => (
<div key={idx} className="p-3 rounded border border-violet-500/20"
style={{ background: 'rgba(15,23,42,0.4)' }}>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-muted-foreground">步骤 #{step.order}</span>
<span className="flex-1" />
<Button size="sm" variant="ghost" onClick={() => removeStep(idx)} className="h-6 text-red-400 hover:text-red-300">
<Trash2 className="w-3 h-3" />
</Button>
</div>
<div className="grid grid-cols-3 gap-2">
<Field label="command_id">
<Input
value={step.command_id || ''}
onChange={(e) => updateStep(idx, { command_id: e.target.value })}
className="text-xs font-mono"
/>
</Field>
<Field label="执行权限">
<Select value={step.execution} onValueChange={(v) => updateStep(idx, { execution: v })}>
<SelectTrigger className="text-xs h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="auto">自动</SelectItem>
<SelectItem value="confirm">确认</SelectItem>
<SelectItem value="manual_only">手动</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="失败处理">
<Select value={step.on_failure} onValueChange={(v) => updateStep(idx, { on_failure: v })}>
<SelectTrigger className="text-xs h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="stop">停止</SelectItem>
<SelectItem value="escalate">升级</SelectItem>
<SelectItem value="retry:1">重试 1 次</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
</div>
))}
<Button variant="outline" size="sm" onClick={addStep}>
<Plus className="w-3 h-3 mr-1.5" /> 添加步骤
</Button>
</div>
)}
{/* 安全与升级 */}
{activeSection === 'security' && (
<div className="space-y-4">
<div>
<div className="text-xs text-amber-400 mb-1 font-medium">黑名单模式(包含这些关键字的命令会被拦截)</div>
<Input
value={data.security.blocked_patterns.join(', ')}
onChange={(e) => setData((d) => ({
...d, security: { ...d.security, blocked_patterns: e.target.value.split(',').map((s) => s.trim()).filter(Boolean) },
}))}
placeholder="shutdown, reload, reboot"
/>
</div>
<div className="pt-3 border-t border-slate-600/30">
<div className="text-xs text-amber-400 mb-2 font-medium">自动升级超时</div>
<div className="grid grid-cols-2 gap-4">
<Field label="无动作 N 分钟后">
<Input
type="number"
value={data.escalation_fallback?.auto_escalate_minutes ?? 30}
onChange={(e) => setData((d) => ({
...d,
escalation_fallback: {
auto_escalate_minutes: parseInt(e.target.value) || 30,
escalation_target_level: d.escalation_fallback?.escalation_target_level || 'critical',
},
}))}
/>
</Field>
<Field label="目标级别">
<Select
value={data.escalation_fallback?.escalation_target_level || 'critical'}
onValueChange={(v) => setData((d) => ({
...d,
escalation_fallback: {
...(d.escalation_fallback || { auto_escalate_minutes: 30 }),
escalation_target_level: v,
},
}))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="critical">严重</SelectItem>
<SelectItem value="warning">警告</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="flex-shrink-0 pt-2 border-t border-slate-700/50">
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={() => onSave(data)}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-xs text-muted-foreground mb-1">{label}</div>
{children}
</div>
);
}
// ====================================================================
// 预案详情对话框
// ====================================================================
function PlanDetailDialog({ plan, onClose, onEdit }: { plan: DevicePlan; onClose: () => void; onEdit: () => void }) {
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col" style={{ background: '#0f172a' }}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wrench className="w-4 h-4 text-violet-400" /> {plan.description || plan.id}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto text-sm space-y-4">
<div className="grid grid-cols-2 gap-2 text-xs">
<div><span className="text-muted-foreground">设备类型:</span><span className="text-foreground">{plan.device_type}</span></div>
<div><span className="text-muted-foreground">告警级别:</span><span className="text-foreground">{plan.level}</span></div>
<div><span className="text-muted-foreground">状态:</span><span className="text-foreground">{plan.status}</span></div>
<div><span className="text-muted-foreground">启用:</span><span className="text-foreground">{plan.enabled ? '是' : '否'}</span></div>
</div>
<Section title="匹配条件">
<div className="text-xs space-y-1">
<div>告警类型:{plan.match.alert_types.join(', ') || '无限制'}</div>
<div>设备层级:{plan.match.device_tiers.join(', ') || '无限制'}</div>
<div>scope:{plan.scope.host_ids.length > 0 ? `${plan.scope.host_ids.length} 台设备` : '全局'}</div>
</div>
</Section>
<Section title={`值班员流程(${plan.human_action_guide.info_gathering.length} 步)`}>
{plan.human_action_guide.info_gathering.length === 0 ? (
<div className="text-xs text-muted-foreground">暂无</div>
) : (
<div className="space-y-1 text-xs">
{plan.human_action_guide.info_gathering.map((g, i) => (
<div key={i} className="flex items-start gap-2">
<span className="w-5 h-5 flex items-center justify-center text-[10px] bg-cyan-500/15 text-cyan-400 rounded flex-shrink-0">{g.priority}</span>
<div>
<div>{g.action}</div>
{g.purpose && <div className="text-muted-foreground">— {g.purpose}</div>}
</div>
</div>
))}
</div>
)}
</Section>
<Section title={`AI 处置步骤(${plan.remediation.steps.length} 步)`}>
{plan.remediation.steps.length === 0 ? (
<div className="text-xs text-muted-foreground">暂无</div>
) : (
<div className="space-y-1 text-xs">
{plan.remediation.steps.map((s, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-muted-foreground">#{s.order}</span>
<span className="font-mono text-violet-400">{s.command_id}</span>
<Badge variant="outline" className="text-[9px]">
{s.execution === 'auto' ? '自动' : s.execution === 'confirm' ? '确认' : '手动'}
</Badge>
</div>
))}
</div>
)}
</Section>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>关闭</Button>
<Button onClick={onEdit}>编辑</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="p-3 rounded border" style={{ background: 'rgba(15,23,42,0.4)', borderColor: 'rgba(148,163,184,0.15)' }}>
<div className="text-xs font-medium text-foreground mb-2">{title}</div>
{children}
</div>
);
}
function _createEmptyPlan(): DevicePlan {
return {
id: `plan_manual_${Date.now()}`,
template_id: null,
device_type: 'huawei_h3c_switch',
level: 'warning',
enabled: true,
builtin: false,
status: 'active',
source: 'manual',
ai_provider: null,
ai_prompt_version: null,
reject_reason: null,
review_log: [],
description: '',
match: { metric_rules: [], alert_types: [], device_tiers: [], device_tags: [] },
scope: { host_ids: [], host_groups: [] },
human_action_guide: {
receiving_alerts: [],
info_gathering: [],
notification_chain: [],
escalation_triggers: [],
pre_remediation: [],
post_remediation: [],
},
remediation: { steps: [] },
security: { allowed_command_ids: [], allowed_commands: [], blocked_patterns: ['shutdown', 'reload', 'reboot'] },
required_role: 'operator',
escalation_fallback: { auto_escalate_minutes: 30, escalation_target_level: 'critical' },
created_at: Date.now(),
updated_at: Date.now(),
};
}