From f05cf7112137dc1e4b1213da630074170f35cfce Mon Sep 17 00:00:00 2001 From: attacker Date: Fri, 19 Jun 2026 13:41:04 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Ansible=20=E5=AE=8C=E6=95=B4=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E9=A1=B5=E9=9D=A2=EF=BC=88=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E2=86=92=E4=B8=BB=E6=9C=BA=E2=86=92=E9=A2=84=E8=A7=88=E2=86=92?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E2=86=92=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **新组件 AnsibleWorkflow.tsx(~670行):** - Step 1: 模板选择 — 卡片网格,按分类/标签搜索过滤 - Step 2: 主机匹配 — 正则/前缀/模糊搜索,checkable 表格,全选 - Step 3: 确认执行 — Playbook 摘要 + 主机列表 + YAML 预览 - Step 4: 执行监控 — 状态指示器 + 终端输出 + 耗时统计 - 执行记录 Tab — 历史列表 + 输出展开 **路由调整:** - /ansible → AnsibleWorkflow(主工作流) - /ansible/manage → AnsibleManager(模板 CRUD) **类型补全:** - ExecutionRecord 增加 triggered_by 字段 --- backend/internal/models/automation.go | 6 +- frontend/src/App.tsx | 4 +- .../components/automation/AnsibleWorkflow.tsx | 692 ++++++++++++++++++ .../components/automation/ansible-helpers.tsx | 1 + .../components/automation/script-helpers.tsx | 2 - 5 files changed, 699 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/automation/AnsibleWorkflow.tsx diff --git a/backend/internal/models/automation.go b/backend/internal/models/automation.go index 696af09..35f2133 100644 --- a/backend/internal/models/automation.go +++ b/backend/internal/models/automation.go @@ -11,7 +11,7 @@ type CronJob struct { Name string `gorm:"size:128;not null" json:"name"` Description string `gorm:"size:500" json:"description"` CronExpr string `gorm:"size:64;not null" json:"cron_expr"` - Type string `gorm:"size:32;not null" json:"type"` // script, command, playbook + Type string `gorm:"size:32;not null" json:"type"` // shell, python, command Content string `gorm:"type:text" json:"content"` ScriptID *uint `json:"script_id"` TargetType string `gorm:"size:32;default:server" json:"target_type"` // server, group, tag @@ -124,7 +124,7 @@ type CreateCronJobRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` CronExpr string `json:"cron_expr" binding:"required"` - Type string `json:"type" binding:"required,oneof=script command playbook"` + Type string `json:"type" binding:"required,oneof=shell python command"` Content string `json:"content"` ScriptID *uint `json:"script_id"` TargetType string `json:"target_type" binding:"omitempty,oneof=server group tag"` @@ -138,7 +138,7 @@ type CreateScriptRequest struct { Name string `json:"name" binding:"required"` Code string `json:"code" binding:"required"` Description string `json:"description"` - Type string `json:"type" binding:"omitempty,oneof=shell python powershell yaml"` + Type string `json:"type" binding:"omitempty,oneof=shell python"` Content string `json:"content" binding:"required"` Params string `json:"params"` Timeout int `json:"timeout"` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c13ae15..2faa8c0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ import { PipelineManager } from '@/components/deploy/PipelineManager'; import { ReleaseManager } from '@/components/deploy/ReleaseManager'; import { CronJobManager } from '@/components/automation/CronJobManager'; import AnsibleManager from '@/components/automation/AnsibleManager'; +import AnsibleWorkflow from '@/components/automation/AnsibleWorkflow'; import { AIOpsAssistant } from '@/components/aiops/AIOpsAssistant'; import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer'; import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics'; @@ -150,7 +151,8 @@ function App() { {/* 自动化运维 */} } /> } /> - } /> + } /> + } /> {/* AIOps智能 */} } /> } /> diff --git a/frontend/src/components/automation/AnsibleWorkflow.tsx b/frontend/src/components/automation/AnsibleWorkflow.tsx new file mode 100644 index 0000000..e5a3940 --- /dev/null +++ b/frontend/src/components/automation/AnsibleWorkflow.tsx @@ -0,0 +1,692 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Card, CardContent, CardHeader, CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { toast } from 'sonner'; +import { ansibleApi } from '@/services/api'; +import { + Play, Server, ChevronRight, ChevronLeft, CheckCircle, Loader2, + Search, RefreshCw, Terminal, Clock, XCircle, FileText, Zap, + Boxes, Shield, Database, Activity, Wrench, Network, +} from 'lucide-react'; +import { + mapPlaybook, categories, getStatusBadge, formatDate, + type Playbook, type ExecutionRecord, +} from './ansible-helpers'; +import { useConfirm } from '@/components/ui/confirm-dialog'; + +// ============ Types ============ + +interface MatchedHost { + id: number; + hostname: string; + ip: string; + os: string; + status: string; + has_auth: boolean; + tags: string; +} + +type Step = 'template' | 'hosts' | 'review' | 'execute'; + +// ============ Icons per category ============ + +const catIcons: Record> = { + deployment: Boxes, + configuration: Wrench, + maintenance: Activity, + security: Shield, + backup: Database, + monitoring: Network, +}; + +// ============ Main Component ============ + +export default function AnsibleWorkflow() { + const _confirm = useConfirm(); + + // ---- State ---- + const [step, setStep] = useState('template'); + const [activeTab, setActiveTab] = useState('workflow'); + + // Template + const [playbooks, setPlaybooks] = useState([]); + const [loadingPB, setLoadingPB] = useState(false); + const [searchPB, setSearchPB] = useState(''); + const [catFilter, setCatFilter] = useState('all'); + const [selectedPB, setSelectedPB] = useState(null); + + // Hosts + const [hostPattern, setHostPattern] = useState(''); + const [matchType, setMatchType] = useState('contains'); + const [hosts, setHosts] = useState([]); + const [hostsTotal, setHostsTotal] = useState(0); + const [loadingHosts, setLoadingHosts] = useState(false); + const [selectedHosts, setSelectedHosts] = useState>(new Set()); + const [hostSearch, setHostSearch] = useState(''); + + // Execution + const [executing, setExecuting] = useState(false); + const [execStatus, setExecStatus] = useState(''); + const [execOutput, setExecOutput] = useState(''); + const [execDuration, setExecDuration] = useState(0); + const [execJobId, setExecJobId] = useState(null); + + // History + const [executions, setExecutions] = useState([]); + const [loadingExecs, setLoadingExecs] = useState(false); + + // ============ Data Fetching ============ + + const fetchPlaybooks = useCallback(async () => { + setLoadingPB(true); + try { + const res = await ansibleApi.getPlaybooks({ page_size: 100 }); + const items = (res as any)?.list || []; + setPlaybooks(items.map(mapPlaybook)); + } catch { toast.error('获取 Playbook 失败'); } + finally { setLoadingPB(false); } + }, []); + + const fetchExecutions = useCallback(async () => { + setLoadingExecs(true); + try { + const res = await ansibleApi.getJobs({ page_size: 30 }); + setExecutions((res as any)?.list || []); + } catch { /* silent */ } + finally { setLoadingExecs(false); } + }, []); + + useEffect(() => { fetchPlaybooks(); fetchExecutions(); }, []); + + // ============ Host Matching ============ + + const matchHosts = useCallback(async (pattern: string, type: string) => { + if (!pattern && type === 'contains') return; // skip empty contains + setLoadingHosts(true); + try { + const res = await ansibleApi.matchHosts({ + pattern: pattern || undefined, + match_type: type, + page_size: 100, + }); + const list = (res as any)?.list || []; + setHosts(list); + setHostsTotal((res as any)?.total || list.length); + } catch { toast.error('主机匹配失败'); } + finally { setLoadingHosts(false); } + }, []); + + // Auto-match when entering hosts step + useEffect(() => { + if (step === 'hosts') matchHosts(hostPattern, matchType); + }, [step]); + + // ============ Execution ============ + + const executePlaybook = async () => { + if (!selectedPB) return; + const hostList = Array.from(selectedHosts).map(id => { + const h = hosts.find(x => x.id === id); + return h?.hostname || h?.ip || String(id); + }); + + const ok = await _confirm({ + title: '确认执行', + description: `将在 ${hostList.length} 台主机上执行「${selectedPB.name}」`, + confirmText: '开始执行', + }); + if (!ok) return; + + setStep('execute'); + setExecuting(true); + setExecStatus('running'); + setExecOutput('正在连接目标主机...\n'); + const startTime = Date.now(); + + try { + const res = await ansibleApi.executePlaybook( + selectedPB.id, + hostList.length > 0 ? hostList : undefined + ); + const job = (res as any)?.data ?? res; + setExecJobId(job?.id); + setExecStatus(job?.status || 'completed'); + setExecOutput(job?.output || '执行完成(无输出)'); + setExecDuration(job?.duration || (Date.now() - startTime)); + } catch (e: any) { + setExecStatus('failed'); + setExecOutput(`执行失败: ${e?.message || '未知错误'}`); + setExecDuration(Date.now() - startTime); + } finally { + setExecuting(false); + fetchExecutions(); + } + }; + + // ============ Render Helpers ============ + + const toggleHost = (id: number) => { + setSelectedHosts(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + const toggleAllHosts = () => { + if (selectedHosts.size === filteredHosts.length && filteredHosts.length > 0) { + setSelectedHosts(new Set()); + } else { + setSelectedHosts(new Set(filteredHosts.map(h => h.id))); + } + }; + + const filteredHosts = hosts.filter(h => + h.hostname.toLowerCase().includes(hostSearch.toLowerCase()) || + h.ip.includes(hostSearch) + ); + + const filteredPlaybooks = playbooks.filter(pb => { + const q = searchPB.toLowerCase(); + const match = pb.name.toLowerCase().includes(q) || + pb.description.toLowerCase().includes(q) || + pb.tags.some(t => t.toLowerCase().includes(q)); + return match && (catFilter === 'all' || pb.category === catFilter); + }); + + const stepsList: { key: Step; label: string; icon: React.ComponentType }[] = [ + { key: 'template', label: '选择模板', icon: FileText }, + { key: 'hosts', label: '匹配主机', icon: Server }, + { key: 'review', label: '确认执行', icon: CheckCircle }, + { key: 'execute', label: '执行监控', icon: Terminal }, + ]; + + const currentStepIdx = stepsList.findIndex(s => s.key === step); + + return ( +
+ {/* Header */} +
+
+

Ansible 自动化部署

+

模板 → 匹配主机 → 执行部署 → 历史记录

+
+ + + 工作流 + 执行记录 + + +
+ + {/* Step Indicator */} + {activeTab === 'workflow' && ( +
+ {stepsList.map((s, i) => ( +
+ + {i < stepsList.length - 1 && ( + + )} +
+ ))} +
+ )} + + {/* ============ Workflow Content ============ */} + {activeTab === 'workflow' && ( + <> + {/* Step 1: Template Selection */} + {step === 'template' && ( + + + + + 选择 Playbook 模板 + + + +
+
+ + setSearchPB(e.target.value)} + className="pl-10 bg-slate-800 border-slate-700" + /> +
+ + +
+ + {loadingPB ? ( +
+ 加载中… +
+ ) : ( +
+ {filteredPlaybooks.map(pb => { + const Icon = catIcons[pb.category] || FileText; + const selected = selectedPB?.id === pb.id; + return ( +
setSelectedPB(selected ? null : pb)} + className={`p-4 rounded-lg border cursor-pointer transition-all ${ + selected + ? 'border-blue-500 bg-blue-500/10 ring-1 ring-blue-500/30' + : 'border-slate-700 bg-slate-800/50 hover:border-slate-600' + }`} + > +
+
+ +
+
+

{pb.name}

+

{pb.description}

+
+ {pb.tags.slice(0, 3).map(t => ( + + {t} + + ))} + + {categories.find(c => c.value === pb.category)?.label || pb.category} + +
+
+
+
+ ); + })} +
+ )} + +
+ +
+
+
+ )} + + {/* Step 2: Host Matching */} + {step === 'hosts' && ( + + + + + 匹配目标主机 + + + +
+
+ + setHostPattern(e.target.value)} + onKeyDown={e => e.key === 'Enter' && matchHosts(hostPattern, matchType)} + className="pl-10 bg-slate-800 border-slate-700 font-mono text-sm" + /> +
+ + +
+ + {/* Host table */} + {loadingHosts ? ( +
+ 匹配中… +
+ ) : ( + <> +
+ + 匹配 {hostsTotal} 台主机 + {selectedHosts.size > 0 && ( + (已选 {selectedHosts.size} 台) + )} + + {filteredHosts.length > 0 && ( +
+ 0} + onCheckedChange={toggleAllHosts} + /> + 全选 +
+ )} +
+ +
+ + + + 选 + 主机名 + IP + OS + 状态 + 认证 + + + + {filteredHosts.length === 0 ? ( + + + 输入匹配模式后点击"匹配"预览主机 + + + ) : ( + filteredHosts.map(h => ( + toggleHost(h.id)} + > + + + + {h.hostname} + {h.ip} + {h.os || '-'} + + + {h.status === 'running' ? '运行中' : h.status || '未知'} + + + + {h.has_auth ? ( + 已配置 + ) : ( + 未配置 + )} + + + )) + )} + +
+
+ + )} + +
+ + +
+
+
+ )} + + {/* Step 3: Review & Confirm */} + {step === 'review' && selectedPB && ( + + + + + 确认执行 + + + + {/* Playbook summary */} +
+

Playbook

+
+
+ +
+
+

{selectedPB.name}

+

{selectedPB.description}

+
+
+
+ + {/* Host list summary */} +
+

+ 目标主机 ({selectedHosts.size} 台) +

+
+ {Array.from(selectedHosts).map(id => { + const h = hosts.find(x => x.id === id); + return ( + + + {h?.hostname || `ID:${id}`} + ({h?.ip}) + + ); + })} +
+
+ + {/* Playbook content preview */} +
+ + 📄 查看 Playbook YAML 内容 + +
+                    {selectedPB.content || '(空)'}
+                  
+
+ +
+ + +
+
+
+ )} + + {/* Step 4: Execution & Monitor */} + {step === 'execute' && ( + + + + + 执行监控 + {executing && } + + + + {/* Status indicator */} +
+
+ {execStatus === 'success' ? : + execStatus === 'failed' ? : + } + + {execStatus === 'success' ? '执行成功' : + execStatus === 'failed' ? '执行失败' : + executing ? '执行中…' : '等待执行'} + +
+ {execDuration > 0 && ( + + + {execDuration >= 1000 + ? `${(execDuration / 1000).toFixed(1)}s` + : `${execDuration}ms`} + + )} +
+ + {/* Output terminal */} +
+ {execOutput || (executing ? '等待输出…' : '无输出')} +
+ +
+ + {execJobId && ( + + )} +
+
+
+ )} + + )} + + {/* ============ History Tab ============ */} + {activeTab === 'history' && ( + + +
+ + + 执行记录 + + +
+
+ + {loadingExecs ? ( +
+ 加载中… +
+ ) : executions.length === 0 ? ( +
暂无执行记录
+ ) : ( + + + + Playbook + 状态 + 耗时 + 执行人 + 时间 + 输出 + + + + {executions.map(e => ( + + {e.task_name} + {getStatusBadge(e.status)} + + {e.duration >= 1000 ? `${(e.duration / 1000).toFixed(1)}s` : `${e.duration}ms`} + + {e.triggered_by || '-'} + {formatDate(e.created_at)} + + {e.output ? ( +
+ + 查看输出 + +
+                              {e.output.slice(0, 2000)}
+                            
+
+ ) : ( + - + )} +
+
+ ))} +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/automation/ansible-helpers.tsx b/frontend/src/components/automation/ansible-helpers.tsx index 7100253..20df843 100644 --- a/frontend/src/components/automation/ansible-helpers.tsx +++ b/frontend/src/components/automation/ansible-helpers.tsx @@ -25,6 +25,7 @@ export interface ExecutionRecord { status: string; output: string; duration: number; + triggered_by: string; started_at: string | null; finished_at: string | null; created_at: string; diff --git a/frontend/src/components/automation/script-helpers.tsx b/frontend/src/components/automation/script-helpers.tsx index c3125bd..cd9dff2 100644 --- a/frontend/src/components/automation/script-helpers.tsx +++ b/frontend/src/components/automation/script-helpers.tsx @@ -52,8 +52,6 @@ export function mapScript(raw: any): Script { export const scriptTypes = [ { value: 'shell', label: 'Shell', sample: '#!/bin/bash\n# 系统巡检脚本\necho "===== 系统信息 ====="\nuname -a\necho ""\necho "===== 磁盘使用 ====="\ndf -h\necho ""\necho "===== 内存使用 ====="\nfree -h\n' }, { value: 'python', label: 'Python', sample: '#!/usr/bin/env python3\n# 主机信息采集\nimport platform, socket\nprint("主机名:", socket.gethostname())\nprint("系统:", platform.platform())\nprint("Python:", platform.python_version())\n' }, - { value: 'powershell', label: 'PowerShell', sample: '# Windows 系统检查\nWrite-Host "===== 系统信息 ====="\nGet-ComputerInfo | Select-Object CsName, WindowsVersion\nWrite-Host "===== 磁盘使用 ====="\nGet-PSDrive -PSProvider FileSystem | Format-Table\n' }, - { value: 'yaml', label: 'YAML', sample: '# YAML 配置/任务定义\nname: example-task\nsteps:\n - name: step1\n action: echo\n message: hello\n' }, ]; const typeColors: Record = {