Files
SenMeshNet/components/inspection/PlanManagementPage.tsx

1083 lines
46 KiB
TypeScript
Raw Permalink Normal View History

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(),
};
}