1083 lines
46 KiB
TypeScript
1083 lines
46 KiB
TypeScript
|
|
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(),
|
|||
|
|
};
|
|||
|
|
}
|