diff --git a/backend/internal/api/handlers/automation.go b/backend/internal/api/handlers/automation.go index 02c26b6..a3348ba 100644 --- a/backend/internal/api/handlers/automation.go +++ b/backend/internal/api/handlers/automation.go @@ -3,8 +3,11 @@ package handlers import ( "bytes" "context" + "crypto/rand" + "encoding/hex" "fmt" "net/http" + "os" "os/exec" "strconv" "time" @@ -15,6 +18,15 @@ import ( "gorm.io/gorm" ) +// randomCode returns a short unique code for records that require a unique +// string identifier (e.g. playbooks). It combines a unix timestamp with 4 +// random hex bytes to guarantee uniqueness even under rapid sequential inserts. +func randomCode(prefix string) string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return fmt.Sprintf("%s-%d-%s", prefix, time.Now().Unix(), hex.EncodeToString(b)) +} + // runLocal executes a script or shell command locally on the AIOps host and // returns the combined stdout+stderr output, the process exit code (0 = success) // and the wall-clock duration in milliseconds. A timeout prevents runaway jobs. @@ -711,3 +723,345 @@ func (h *AutomationHandler) ListExecutions(c *gin.Context) { }, }) } + +// runPlaybookLocal executes an Ansible playbook YAML locally on the AIOps host. +// It writes the content to a temporary file and invokes `ansible-playbook` +// against localhost with a local connection. If the ansible-playbook binary is +// missing the exec error is captured in the output. Returns combined +// stdout+stderr, the exit code (-1 on failure) and duration in milliseconds. +func runPlaybookLocal(content, inventory string, timeoutSec int) (output string, exitCode int, durationMs int) { + start := time.Now() + timeout := time.Duration(timeoutSec) * time.Second + if timeout <= 0 { + timeout = 600 * time.Second + } + + // Write the playbook YAML to a temporary file. + tmpFile, err := os.CreateTemp("", "ezops-playbook-*.yml") + if err != nil { + return fmt.Sprintf("创建临时文件失败: %v", err), -1, 0 + } + defer os.Remove(tmpFile.Name()) + if _, err := tmpFile.WriteString(content); err != nil { + tmpFile.Close() + return fmt.Sprintf("写入临时文件失败: %v", err), -1, 0 + } + tmpFile.Close() + + // Write an optional inventory file. + inventoryArg := "localhost," + if inventory != "" { + invFile, err := os.CreateTemp("", "ezops-inventory-*.ini") + if err == nil { + if _, werr := invFile.WriteString(inventory); werr == nil { + inventoryArg = invFile.Name() + defer os.Remove(invFile.Name()) + } + invFile.Close() + } + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + args := []string{"-i", inventoryArg} + if inventoryArg == "localhost," { + args = append(args, "--connection=local") + } + args = append(args, tmpFile.Name()) + + cmd := exec.CommandContext(ctx, "ansible-playbook", args...) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + runErr := cmd.Run() + + durationMs = int(time.Since(start).Milliseconds()) + output = out.String() + + if ctx.Err() == context.DeadlineExceeded { + output += fmt.Sprintf("\n[超时] 执行超过 %d 秒被终止", timeoutSec) + return output, -1, durationMs + } + if runErr != nil { + if exitError, ok := runErr.(*exec.ExitError); ok { + exitCode = exitError.ExitCode() + } else { + // Binary not found or other exec error (e.g. ansible-playbook not + // installed). Provide a clear hint instead of an empty output. + exitCode = -1 + if output == "" { + output = fmt.Sprintf("无法执行 ansible-playbook:%v\n(请确认 AIOps 主机已安装 ansible:yum install -y ansible / pip install ansible)", runErr) + } + } + return output, exitCode, durationMs + } + return output, 0, durationMs +} + +// ListPlaybooks 获取Playbook列表 +// @Summary 获取Playbook列表 +// @Description 获取所有Ansible Playbook列表 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} models.Response{data=models.ListResponse} +// @Router /playbooks [get] +func (h *AutomationHandler) ListPlaybooks(c *gin.Context) { + var query models.PageQuery + c.ShouldBindQuery(&query) + + var total int64 + h.db.Model(&models.Playbook{}).Count(&total) + + var playbooks []models.Playbook + h.db.Order("created_at DESC").Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&playbooks) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: playbooks, + Total: total, + }, + }) +} + +// GetPlaybook 获取Playbook详情 +// @Summary 获取Playbook详情 +// @Description 获取指定Playbook的详细信息 +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param id path int true "Playbook ID" +// @Success 200 {object} models.Response{data=models.Playbook} +// @Router /playbooks/{id} [get] +func (h *AutomationHandler) GetPlaybook(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var playbook models.Playbook + if err := h.db.First(&playbook, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "Playbook不存在", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: playbook, + }) +} + +// CreatePlaybook 创建Playbook +// @Summary 创建Playbook +// @Description 创建新的Ansible Playbook +// @Tags 自动化运维 +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body models.CreatePlaybookRequest true "创建请求" +// @Success 200 {object} models.Response{data=models.Playbook} +// @Router /playbooks [post] +func (h *AutomationHandler) CreatePlaybook(c *gin.Context) { + var req models.CreatePlaybookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + playbook := models.Playbook{ + Name: req.Name, + Code: randomCode("pb"), + Description: req.Description, + Content: req.Content, + Category: req.Category, + Tags: req.Tags, + Variables: req.Variables, + Inventory: req.Inventory, + Timeout: req.Timeout, + } + if playbook.Category == "" { + playbook.Category = "deployment" + } + if playbook.Timeout == 0 { + playbook.Timeout = 600 + } + + if err := h.db.Create(&playbook).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "创建成功", + Data: playbook, + }) +} + +// UpdatePlaybook 更新Playbook +// @Summary 更新Playbook +// @Description 更新指定Playbook +// @Tags 自动化运维 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Playbook ID" +// @Param request body models.CreatePlaybookRequest true "更新请求" +// @Success 200 {object} models.Response{data=models.Playbook} +// @Router /playbooks/{id} [put] +func (h *AutomationHandler) UpdatePlaybook(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var req models.CreatePlaybookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{ + Code: 400, + Message: "请求参数错误: " + err.Error(), + }) + return + } + + var playbook models.Playbook + if err := h.db.First(&playbook, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "Playbook不存在", + }) + return + } + + updates := map[string]interface{}{ + "name": req.Name, + "description": req.Description, + "content": req.Content, + "category": req.Category, + "tags": req.Tags, + "variables": req.Variables, + "inventory": req.Inventory, + "timeout": req.Timeout, + } + + if err := h.db.Model(&playbook).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "更新失败", + }) + return + } + + h.db.First(&playbook, id) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "更新成功", + Data: playbook, + }) +} + +// DeletePlaybook 删除Playbook +// @Summary 删除Playbook +// @Description 删除指定Playbook +// @Tags 自动化运维 +// @Produce json +// @Security Bearer +// @Param id path int true "Playbook ID" +// @Success 200 {object} models.Response +// @Router /playbooks/{id} [delete] +func (h *AutomationHandler) DeletePlaybook(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + if err := h.db.Delete(&models.Playbook{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "删除失败", + }) + return + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "删除成功", + }) +} + +// ExecutePlaybook 执行Playbook +// @Summary 执行Playbook +// @Description 在AIOps主机上本地执行Ansible Playbook +// @Tags 自动化运维 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "Playbook ID" +// @Success 200 {object} models.Response{data=models.TaskExecution} +// @Router /playbooks/{id}/execute [post] +func (h *AutomationHandler) ExecutePlaybook(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var playbook models.Playbook + if err := h.db.First(&playbook, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{ + Code: 404, + Message: "Playbook不存在", + }) + return + } + + now := time.Now() + execution := models.TaskExecution{ + TaskType: "playbook", + TaskID: uint(id), + TaskName: playbook.Name, + Status: "running", + StartedAt: &now, + } + if err := h.db.Create(&execution).Error; err != nil { + c.JSON(http.StatusInternalServerError, models.Response{ + Code: 500, + Message: "创建执行记录失败", + }) + return + } + + // Execute the playbook locally on the AIOps host. + output, exitCode, duration := runPlaybookLocal(playbook.Content, playbook.Inventory, playbook.Timeout) + finishedAt := time.Now() + status := "success" + if exitCode != 0 { + status = "failed" + } + + h.db.Model(&execution).Updates(map[string]interface{}{ + "status": status, + "output": output, + "duration": duration, + "finished_at": finishedAt, + }) + h.db.Model(&playbook).UpdateColumn("run_count", playbook.RunCount+1) + + execution.Output = output + execution.Status = status + execution.Duration = duration + execution.FinishedAt = &finishedAt + + msg := "执行成功" + if status == "failed" { + msg = "执行失败" + } + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: msg, + Data: execution, + }) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index f95959a..a9e5a23 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -261,6 +261,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { scripts.POST("/:id/execute", automationHandler.ExecuteScript) } + playbooks := authorized.Group("/playbooks") + { + playbooks.GET("", automationHandler.ListPlaybooks) + playbooks.POST("", automationHandler.CreatePlaybook) + playbooks.GET("/:id", automationHandler.GetPlaybook) + playbooks.PUT("/:id", automationHandler.UpdatePlaybook) + playbooks.DELETE("/:id", automationHandler.DeletePlaybook) + playbooks.POST("/:id/execute", automationHandler.ExecutePlaybook) + } + // 任务执行记录(脚本/定时任务执行历史) authorized.GET("/task-executions", automationHandler.ListExecutions) diff --git a/backend/internal/models/automation.go b/backend/internal/models/automation.go index f433398..fcd8656 100644 --- a/backend/internal/models/automation.go +++ b/backend/internal/models/automation.go @@ -93,6 +93,8 @@ type Playbook struct { Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` Description string `gorm:"size:500" json:"description"` Content string `gorm:"type:text;not null" json:"content"` + Category string `gorm:"size:64" json:"category"` + Tags string `gorm:"type:text" json:"tags"` // JSON 数组字符串,如 ["deploy","docker"] Variables string `gorm:"type:text" json:"variables"` Inventory string `gorm:"type:text" json:"inventory"` Timeout int `gorm:"default:600" json:"timeout"` @@ -109,6 +111,18 @@ func (Playbook) TableName() string { return "playbooks" } +// CreatePlaybookRequest 创建Playbook请求 +type CreatePlaybookRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Content string `json:"content" binding:"required"` + Category string `json:"category"` + Tags string `json:"tags"` // JSON 数组字符串 + Variables string `json:"variables"` + Inventory string `json:"inventory"` + Timeout int `json:"timeout"` +} + // CreateCronJobRequest 创建定时任务请求 type CreateCronJobRequest struct { Name string `json:"name" binding:"required"` diff --git a/frontend/src/components/automation/AnsibleManager.tsx b/frontend/src/components/automation/AnsibleManager.tsx index cfb894a..a9bfbdf 100644 --- a/frontend/src/components/automation/AnsibleManager.tsx +++ b/frontend/src/components/automation/AnsibleManager.tsx @@ -1,204 +1,229 @@ import React, { useState, useEffect } from 'react'; import { - Play, FileCode, Server, Clock, CheckCircle, XCircle, RotateCcw, Eye, - Download, Plus, Search, Filter, Trash2, Edit, Copy, Terminal, - BookOpen, Settings, ChevronRight, Pause, RefreshCw, FolderOpen, - Zap, AlertTriangle, History, Code, Users, Globe, Shield + Play, FileCode, Clock, CheckCircle, XCircle, RotateCcw, Eye, + Plus, Search, Trash2, Edit, Copy, Terminal, Loader2, Zap, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import { toast } from 'sonner'; import { useConfirm } from '@/components/ui/confirm-dialog'; -import { ansibleApi } from '@/services/api'; +import { ansibleApi, taskExecutionApi } from '@/services/api'; + +// ---- 类型定义 ---- interface Playbook { - id: string; + id: number; name: string; description: string; content: string; - tags: string[]; category: string; - createdAt: string; - updatedAt: string; - createdBy: string; - variables: PlaybookVariable[]; + tags: string[]; + timeout: number; + run_count: number; + created_at: string; + updated_at: string; } -interface PlaybookVariable { - name: string; - defaultValue: string; - description: string; - required: boolean; -} - -interface Job { - id: string; - playbookId: string; - playbookName: string; - status: 'pending' | 'running' | 'success' | 'failed' | 'cancelled'; - targetHosts: string[]; - variables: Record; - startedAt: string; - completedAt?: string; - duration?: number; +interface ExecutionRecord { + id: number; + task_type: string; + task_id: number; + task_name: string; + status: string; output: string; - triggeredBy: string; + duration: number; + started_at: string | null; + finished_at: string | null; + created_at: string; } -interface Inventory { - id: string; - name: string; - hosts: Host[]; - groups: Group[]; +// 将后端返回的 playbook 原始对象映射为前端结构(解析 tags JSON) +function mapPlaybook(raw: any): Playbook { + let tags: string[] = []; + try { + if (raw.tags) tags = JSON.parse(raw.tags); + } catch { + tags = []; + } + return { + id: raw.id, + name: raw.name, + description: raw.description || '', + content: raw.content || '', + category: raw.category || 'deployment', + tags, + timeout: raw.timeout || 600, + run_count: raw.run_count || 0, + created_at: raw.created_at, + updated_at: raw.updated_at, + }; } -interface Host { - id: string; - name: string; - ip: string; - port: number; - user: string; - os: string; -} +const categories = [ + { value: 'deployment', label: '应用部署' }, + { value: 'configuration', label: '配置管理' }, + { value: 'maintenance', label: '系统维护' }, + { value: 'security', label: '安全加固' }, + { value: 'backup', label: '备份恢复' }, + { value: 'monitoring', label: '监控配置' }, +]; -interface Group { - id: string; - name: string; - hosts: string[]; -} +const samplePlaybook = `--- +- name: Deploy Application + hosts: all + become: yes + vars: + app_name: myapp + deploy_path: /opt/{{ app_name }} + + tasks: + - name: Install dependencies + package: + name: + - nginx + state: present + + - name: Create deploy directory + file: + path: "{{ deploy_path }}" + state: directory + mode: '0755' + + handlers: + - name: restart app + service: + name: nginx + state: restarted`; const AnsibleManager: React.FC = () => { const _confirm = useConfirm(); const [playbooks, setPlaybooks] = useState([]); - const [jobs, setJobs] = useState([]); - const [inventories, setInventories] = useState([]); + const [executions, setExecutions] = useState([]); const [loading, setLoading] = useState(false); + const [executing, setExecuting] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); const [activeTab, setActiveTab] = useState('playbooks'); - - // Dialog states - const [createOpen, setCreateOpen] = useState(false); - const [executeOpen, setExecuteOpen] = useState(false); - const [detailOpen, setDetailOpen] = useState(false); - const [logsOpen, setLogsOpen] = useState(false); - const [selectedPlaybook, setSelectedPlaybook] = useState(null); - const [selectedJob, setSelectedJob] = useState(null); - - // Form states - const [newPlaybook, setNewPlaybook] = useState({ - name: '', - description: '', - content: '', - category: 'deployment', - tags: '' - }); - const [executionConfig, setExecutionConfig] = useState({ - inventoryId: '', - targetHosts: [] as string[], - variables: {} as Record - }); - useEffect(() => { - fetchPlaybooks(); - fetchJobs(); - fetchInventories(); - }, []); + // 弹窗状态 + const [editorOpen, setEditorOpen] = useState(false); // 新建/编辑 + const [detailOpen, setDetailOpen] = useState(false); // 查看 + const [logsOpen, setLogsOpen] = useState(false); // 执行日志 + const [editingId, setEditingId] = useState(null); + const [selectedPlaybook, setSelectedPlaybook] = useState(null); + const [selectedExec, setSelectedExec] = useState(null); + + // 表单状态 + const [formData, setFormData] = useState({ + name: '', description: '', content: '', category: 'deployment', tags: '', timeout: 600, + }); const fetchPlaybooks = async () => { setLoading(true); try { - const res = await ansibleApi.getPlaybooks(); - setPlaybooks(res.data || []); + const res = await ansibleApi.getPlaybooks({ page_size: 100 }); + const items = (res as any)?.list || []; + setPlaybooks(items.map(mapPlaybook)); } catch (error) { - toast.error('获取Playbook列表失败'); + toast.error('获取 Playbook 列表失败'); } finally { setLoading(false); } }; - const fetchJobs = async () => { + const fetchExecutions = async () => { try { - const res = await ansibleApi.getJobs(); - setJobs(res.data || []); + const res = await taskExecutionApi.getExecutions({ task_type: 'playbook', page_size: 50 }); + const items = (res as any)?.list || []; + setExecutions(items); } catch (error) { - toast.error('获取执行记录失败'); + // 静默失败,避免切换 tab 时弹窗 } }; - const fetchInventories = async () => { - try { - const res = await ansibleApi.getInventories(); - setInventories(res.data || []); - } catch (error) { - toast.error('获取主机清单失败'); - } + useEffect(() => { + fetchPlaybooks(); + fetchExecutions(); + }, []); + + // ---- 新建/编辑 ---- + + const openCreate = () => { + setEditingId(null); + setFormData({ name: '', description: '', content: samplePlaybook, category: 'deployment', tags: '', timeout: 600 }); + setEditorOpen(true); }; - const handleCreatePlaybook = async () => { - if (!newPlaybook.name || !newPlaybook.content) { - toast.error('请填写完整信息'); + const openEdit = (pb: Playbook) => { + setEditingId(pb.id); + setFormData({ + name: pb.name, + description: pb.description, + content: pb.content, + category: pb.category, + tags: pb.tags.join(', '), + timeout: pb.timeout, + }); + setEditorOpen(true); + }; + + // 将逗号分隔的标签字符串转为 JSON 数组字符串 + const tagsToJson = (raw: string): string => { + const arr = raw.split(',').map(t => t.trim()).filter(Boolean); + return JSON.stringify(arr); + }; + + const handleSave = async () => { + if (!formData.name || !formData.content) { + toast.error('请填写名称和 Playbook 内容'); return; } + const payload = { + name: formData.name, + description: formData.description, + content: formData.content, + category: formData.category, + tags: tagsToJson(formData.tags), + timeout: Number(formData.timeout) || 600, + }; try { - await ansibleApi.createPlaybook({ - ...newPlaybook, - tags: newPlaybook.tags.split(',').map(t => t.trim()) - }); - toast.success('Playbook创建成功'); - setCreateOpen(false); - setNewPlaybook({ name: '', description: '', content: '', category: 'deployment', tags: '' }); + if (editingId !== null) { + await ansibleApi.updatePlaybook(editingId, payload); + toast.success('Playbook 更新成功'); + } else { + await ansibleApi.createPlaybook(payload); + toast.success('Playbook 创建成功'); + } + setEditorOpen(false); fetchPlaybooks(); } catch (error) { - toast.error('创建失败'); + toast.error(editingId !== null ? '更新失败' : '创建失败'); } }; - const handleExecute = async () => { - if (!selectedPlaybook || !executionConfig.inventoryId) { - toast.error('请选择目标主机清单'); - return; - } - try { - await ansibleApi.executePlaybook(selectedPlaybook.id, executionConfig); - toast.success('Playbook执行已启动'); - setExecuteOpen(false); - fetchJobs(); - } catch (error) { - toast.error('执行失败'); - } - }; + // ---- 复制 ---- - const handleDelete = async (id: string) => { - { const ok = await _confirm({ title: '删除 Playbook', description: '确定删除此Playbook吗?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; } - try { - await ansibleApi.deletePlaybook(id); - toast.success('删除成功'); - fetchPlaybooks(); - } catch (error) { - toast.error('删除失败'); - } - }; - - const handleClone = async (playbook: Playbook) => { + const handleClone = async (pb: Playbook) => { try { await ansibleApi.createPlaybook({ - ...playbook, - name: `${playbook.name} (副本)`, - id: undefined + name: `${pb.name} (副本)`, + description: pb.description, + content: pb.content, + category: pb.category, + tags: JSON.stringify(pb.tags), + timeout: pb.timeout, }); toast.success('复制成功'); fetchPlaybooks(); @@ -207,89 +232,105 @@ const AnsibleManager: React.FC = () => { } }; + // ---- 删除 ---- + + const handleDelete = async (pb: Playbook) => { + const ok = await _confirm({ + title: '删除 Playbook', + description: `确定删除「${pb.name}」吗?此操作不可恢复。`, + confirmText: '删除', + variant: 'destructive', + }); + if (!ok) return; + try { + await ansibleApi.deletePlaybook(pb.id); + toast.success('删除成功'); + fetchPlaybooks(); + } catch (error) { + toast.error('删除失败'); + } + }; + + // ---- 执行 ---- + + const handleExecute = async (pb: Playbook) => { + const ok = await _confirm({ + title: '执行 Playbook', + description: `将在 AIOps 主机上本地执行「${pb.name}」(ansible-playbook --connection=local)。`, + confirmText: '开始执行', + }); + if (!ok) return; + setExecuting(true); + try { + const res = await ansibleApi.executePlaybook(pb.id); + const exec = (res as any)?.data ?? res; + if (exec?.status === 'success') { + toast.success(`执行成功,耗时 ${exec.duration ?? 0}ms`); + } else { + toast.error(`执行失败(退出码异常),耗时 ${exec?.duration ?? 0}ms`); + } + fetchPlaybooks(); + fetchExecutions(); + } catch (error) { + toast.error('执行请求失败'); + } finally { + setExecuting(false); + } + }; + + // ---- 辅助 ---- + const getStatusBadge = (status: string) => { switch (status) { case 'pending': return 等待中; case 'running': - return 执行中; + return 执行中; case 'success': - return 成功; + return 成功; case 'failed': return 失败; - case 'cancelled': - return 已取消; default: - return 未知; + return {status || '未知'}; } }; + const formatDate = (s: string | null) => { + if (!s) return '-'; + const d = new Date(s); + return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('zh-CN', { hour12: false }); + }; + const filteredPlaybooks = playbooks.filter(pb => { - const matchSearch = pb.name.toLowerCase().includes(searchQuery.toLowerCase()) || - pb.description.toLowerCase().includes(searchQuery.toLowerCase()) || - pb.tags.some(t => t.toLowerCase().includes(searchQuery.toLowerCase())); + const q = searchQuery.toLowerCase(); + const matchSearch = pb.name.toLowerCase().includes(q) || + pb.description.toLowerCase().includes(q) || + pb.tags.some(t => t.toLowerCase().includes(q)); const matchCategory = categoryFilter === 'all' || pb.category === categoryFilter; return matchSearch && matchCategory; }); - const categories = [ - { value: 'deployment', label: '应用部署' }, - { value: 'configuration', label: '配置管理' }, - { value: 'maintenance', label: '系统维护' }, - { value: 'security', label: '安全加固' }, - { value: 'backup', label: '备份恢复' }, - { value: 'monitoring', label: '监控配置' } - ]; - - const samplePlaybook = `--- -- name: Deploy Application - hosts: all - become: yes - vars: - app_name: myapp - app_version: "1.0.0" - deploy_path: /opt/{{ app_name }} - - tasks: - - name: Install dependencies - apt: - name: - - docker.io - - docker-compose - state: present - update_cache: yes - - - name: Create deploy directory - file: - path: "{{ deploy_path }}" - state: directory - mode: '0755' - - - name: Deploy application - template: - src: docker-compose.yml.j2 - dest: "{{ deploy_path }}/docker-compose.yml" - notify: restart app - - handlers: - - name: restart app - command: docker-compose up -d - args: - chdir: "{{ deploy_path }}"`; + const runningCount = executions.filter(e => e.status === 'running').length; + const todaySuccess = executions.filter(e => + e.status === 'success' && new Date(e.created_at).toDateString() === new Date().toDateString() + ).length; + const todayFailed = executions.filter(e => + e.status === 'failed' && new Date(e.created_at).toDateString() === new Date().toDateString() + ).length; return (
{/* 统计卡片 */} -
+
-

Playbook总数

+

Playbook 总数

{playbooks.length}

-
- +
+
@@ -299,12 +340,10 @@ const AnsibleManager: React.FC = () => {

执行中

-

- {jobs.filter(j => j.status === 'running').length} -

+

{runningCount}

-
- +
+
@@ -314,11 +353,9 @@ const AnsibleManager: React.FC = () => {

今日成功

-

- {jobs.filter(j => j.status === 'success').length} -

+

{todaySuccess}

-
+
@@ -328,12 +365,10 @@ const AnsibleManager: React.FC = () => {
-

执行失败

-

- {jobs.filter(j => j.status === 'failed').length} -

+

今日失败

+

{todayFailed}

-
+
@@ -341,21 +376,20 @@ const AnsibleManager: React.FC = () => {
- - - Playbook管理 + { setActiveTab(v); if (v === 'jobs') fetchExecutions(); }}> + + Playbook 管理 执行记录 - 主机清单 + {/* ============ Playbook 管理 ============ */} - {/* 操作栏 */}
setSearchQuery(e.target.value)} className="pl-10" @@ -375,74 +409,71 @@ const AnsibleManager: React.FC = () => {
-
- {/* Playbook列表 */} + {/* Playbook 卡片列表 */}
{loading ? ( -
加载中...
+
+ + 加载中... +
) : filteredPlaybooks.length === 0 ? ( -
暂无Playbook
+
暂无 Playbook
) : ( filteredPlaybooks.map((playbook) => ( -
-
- - {playbook.name} +
+
+ + {playbook.name}
- + {categories.find(c => c.value === playbook.category)?.label || playbook.category}
- {playbook.description} + {playbook.description || '暂无描述'} -
- {playbook.tags.map((tag, idx) => ( - {tag} - ))} -
+ {playbook.tags.length > 0 && ( +
+ {playbook.tags.map((tag, idx) => ( + {tag} + ))} +
+ )}
- 创建者: {playbook.createdBy} - 更新: {playbook.updatedAt} + 执行 {playbook.run_count} 次 + 超时 {playbook.timeout}s
- - - + -
@@ -453,57 +484,50 @@ const AnsibleManager: React.FC = () => {
+ {/* ============ 执行记录 ============ */} +
+ +
- + - - - {jobs.length === 0 ? ( + {executions.length === 0 ? ( - ) : ( - jobs.map((job) => ( + executions.map((job) => ( - - + + - - - + )) @@ -514,77 +538,29 @@ const AnsibleManager: React.FC = () => { - - -
- -
-
- {inventories.map((inventory) => ( - - -
- - {inventory.name} -
-
- -
-
- 主机数量 - {inventory.hosts.length} -
-
- 分组数量 - {inventory.groups.length} -
-
-
- - -
-
-
- ))} -
-
- {/* 创建Playbook对话框 */} - + {/* ===== 新建/编辑 Playbook 对话框 ===== */} + - 新建Playbook - 编写Ansible Playbook实现自动化运维 + {editingId !== null ? '编辑 Playbook' : '新建 Playbook'} + 编写 Ansible Playbook 实现自动化运维 -
+
setNewPlaybook({ ...newPlaybook, name: e.target.value })} - placeholder="Playbook名称" + value={formData.name} + onChange={(e) => setFormData({ ...formData, name: e.target.value })} + placeholder="Playbook 名称" />
- setFormData({ ...formData, category: v })}> + {categories.map(c => ( {c.label} @@ -596,105 +572,92 @@ const AnsibleManager: React.FC = () => {
setNewPlaybook({ ...newPlaybook, description: e.target.value })} - placeholder="Playbook描述" + value={formData.description} + onChange={(e) => setFormData({ ...formData, description: e.target.value })} + placeholder="Playbook 描述" />
-
- - setNewPlaybook({ ...newPlaybook, tags: e.target.value })} - placeholder="如: deployment, docker, production" - /> -
-
- -
任务ID# Playbook 状态目标主机执行人 开始时间 耗时 操作
+ 暂无执行记录
{job.id.slice(0, 8)}{job.playbookName}{job.id}{job.task_name} {getStatusBadge(job.status)}{job.targetHosts.length} 台{job.triggeredBy}{job.startedAt}{formatDate(job.started_at)} - {job.duration ? `${Math.floor(job.duration / 1000)}s` : '-'} + {job.duration ? `${(job.duration / 1000).toFixed(1)}s` : '-'} -
- - {job.status === 'running' && ( - - )} -
+