diff --git a/frontend/src/components/automation/AnsibleManager.tsx b/frontend/src/components/automation/AnsibleManager.tsx index 90c646d..9406ff3 100644 --- a/frontend/src/components/automation/AnsibleManager.tsx +++ b/frontend/src/components/automation/AnsibleManager.tsx @@ -1,24 +1,24 @@ import React, { useState, useEffect } from 'react'; import { RotateCcw, Plus, Search, Trash2, Loader2, + Eye, Pencil, Copy, Play, Tags, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { useConfirm } from '@/components/ui/confirm-dialog'; import { ansibleApi, taskExecutionApi } from '@/services/api'; import { - mapPlaybook, categories, samplePlaybook, + mapPlaybook, categories, samplePlaybook, formatDate, type Playbook, type ExecutionRecord, } from './ansible-helpers'; import AnsibleFormDialog, { type PlaybookFormData } from './AnsibleFormDialog'; import AnsibleDetailDialog from './AnsibleDetailDialog'; import AnsibleLogsDialog from './AnsibleLogsDialog'; -import AnsibleStatsCards from './AnsibleStatsCards'; -import AnsiblePlaybookCard from './AnsiblePlaybookCard'; import AnsibleExecutionsTable from './AnsibleExecutionsTable'; const AnsibleManager: React.FC = () => { @@ -40,7 +40,6 @@ const AnsibleManager: React.FC = () => { const [selectedPlaybook, setSelectedPlaybook] = useState(null); const [selectedExec, setSelectedExec] = useState(null); - // 表单状态 const [formData, setFormData] = useState({ name: '', description: '', content: '', category: 'deployment', tags: '', timeout: 600, }); @@ -48,10 +47,10 @@ const AnsibleManager: React.FC = () => { const fetchPlaybooks = async () => { setLoading(true); try { - const res = await ansibleApi.getPlaybooks({ page_size: 100 }); + const res = await ansibleApi.getPlaybooks({ page_size: 200 }); const items = (res as any)?.list || []; setPlaybooks(items.map(mapPlaybook)); - } catch (error) { + } catch { toast.error('获取 Playbook 列表失败'); } finally { setLoading(false); @@ -61,10 +60,9 @@ const AnsibleManager: React.FC = () => { const fetchExecutions = async () => { try { const res = await taskExecutionApi.getExecutions({ task_type: 'playbook', page_size: 50 }); - const items = (res as any)?.list || []; - setExecutions(items); - } catch (error) { - // 静默失败,避免切换 tab 时弹窗 + setExecutions((res as any)?.list || []); + } catch { + // 静默 } }; @@ -74,7 +72,6 @@ const AnsibleManager: React.FC = () => { }, []); // ---- 新建/编辑 ---- - const openCreate = () => { setEditingId(null); setFormData({ name: '', description: '', content: samplePlaybook, category: 'deployment', tags: '', timeout: 600 }); @@ -84,17 +81,12 @@ const AnsibleManager: React.FC = () => { 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, + name: pb.name, description: pb.description, content: pb.content, + category: pb.category, tags: pb.tags.join(', '), timeout: pb.timeout, }); setEditorOpen(true); }; - // 将逗号分隔的标签字符串转为 JSON 数组字符串 // Logan.Li const tagsToJson = (raw: string): string => { const arr = raw.split(',').map(t => t.trim()).filter(Boolean); return JSON.stringify(arr); @@ -106,49 +98,36 @@ const AnsibleManager: React.FC = () => { return; } const payload = { - name: formData.name, - description: formData.description, - content: formData.content, - category: formData.category, - tags: tagsToJson(formData.tags), - timeout: Number(formData.timeout) || 600, + name: formData.name, description: formData.description, content: formData.content, + category: formData.category, tags: tagsToJson(formData.tags), timeout: Number(formData.timeout) || 600, }; try { if (editingId !== null) { await ansibleApi.updatePlaybook(editingId, payload); - toast.success('Playbook 更新成功'); + toast.success('更新成功'); } else { await ansibleApi.createPlaybook(payload); - toast.success('Playbook 创建成功'); + toast.success('创建成功'); } setEditorOpen(false); fetchPlaybooks(); - } catch (error) { + } catch { toast.error(editingId !== null ? '更新失败' : '创建失败'); } }; - // ---- 复制 ---- - + // ---- 克隆 ---- const handleClone = async (pb: Playbook) => { try { - await ansibleApi.createPlaybook({ - name: `${pb.name} (副本)`, - description: pb.description, - content: pb.content, - category: pb.category, - tags: JSON.stringify(pb.tags), - timeout: pb.timeout, - }); - toast.success('复制成功'); + await ansibleApi.clonePlaybook(pb.id); + toast.success('克隆成功'); fetchPlaybooks(); - } catch (error) { - toast.error('复制失败'); + } catch { + toast.error('克隆失败'); } }; - // ---- 删除 ---- - + // ---- 单个删除 ---- const handleDelete = async (pb: Playbook) => { const ok = await _confirm({ title: '删除 Playbook', @@ -159,54 +138,50 @@ const AnsibleManager: React.FC = () => { if (!ok) return; try { await ansibleApi.deletePlaybook(pb.id); - toast.success('删除成功'); + toast.success('已删除'); fetchPlaybooks(); - } catch (error) { + } catch { toast.error('删除失败'); } }; - // ---- 批量选择 / 删除 ---- // Logan.Li - + // ---- 批量删除 ---- const toggleSelect = (id: number) => { - setSelectedIds((prev) => - prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id] - ); + setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]); }; const toggleSelectAll = () => { - if (selectedIds.length === filteredPlaybooks.length && filteredPlaybooks.length > 0) { + if (selectedIds.length === filtered.length && filtered.length > 0) { setSelectedIds([]); } else { - setSelectedIds(filteredPlaybooks.map((pb) => pb.id)); + setSelectedIds(filtered.map(p => p.id)); } }; const handleBatchDelete = async () => { if (selectedIds.length === 0) return; const ok = await _confirm({ - title: '批量删除 Playbook', - description: `确定删除选中的 ${selectedIds.length} 个 Playbook 吗?此操作不可恢复。`, + title: '批量删除', + description: `确定删除选中的 ${selectedIds.length} 个 Playbook 吗?`, confirmText: '批量删除', variant: 'destructive', }); if (!ok) return; try { await ansibleApi.batchDeletePlaybooks(selectedIds); - toast.success(`已删除 ${selectedIds.length} 个 Playbook`); + toast.success(`已删除 ${selectedIds.length} 个`); setSelectedIds([]); fetchPlaybooks(); - } catch (error) { + } catch { toast.error('批量删除失败'); } }; // ---- 执行 ---- - const handleExecute = async (pb: Playbook) => { const ok = await _confirm({ title: '执行 Playbook', - description: `将在 AIOps 主机上本地执行「${pb.name}」(ansible-playbook --connection=local)。`, + description: `将在 AIOps 主机本地执行「${pb.name}」(ansible-playbook --connection=local)。`, confirmText: '开始执行', }); if (!ok) return; @@ -217,67 +192,50 @@ const AnsibleManager: React.FC = () => { if (exec?.status === 'success') { toast.success(`执行成功,耗时 ${exec.duration ?? 0}ms`); } else { - toast.error(`执行失败(退出码异常),耗时 ${exec?.duration ?? 0}ms`); + toast.error(`执行失败,耗时 ${exec?.duration ?? 0}ms`); } fetchPlaybooks(); fetchExecutions(); - } catch (error) { + } catch { toast.error('执行请求失败'); } finally { setExecuting(false); } }; - // ---- 辅助 ---- - - const filteredPlaybooks = playbooks.filter(pb => { + // ---- 过滤 ---- + const filtered = playbooks.filter(pb => { const q = searchQuery.toLowerCase(); - const matchSearch = pb.name.toLowerCase().includes(q) || - pb.description.toLowerCase().includes(q) || + return 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; - }); + }).filter(pb => categoryFilter === 'all' || pb.category === categoryFilter); - 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; + const catLabel = (v: string) => categories.find(c => c.value === v)?.label || v; return ( -
- {/* 统计卡片 */} - - - { setActiveTab(v); if (v === 'jobs') fetchExecutions(); }}> +
+ { setActiveTab(v); if (v === 'jobs') fetchExecutions(); }}> - Playbook 管理 + 模板管理 执行记录 - {/* ============ Playbook 管理 ============ */} + {/* ============ 模板管理 ============ */} -
+ {/* 搜索栏 + 新建按钮 */} +
setSearchQuery(e.target.value)} + onChange={e => setSearchQuery(e.target.value)} className="pl-10" />
- + )} +
- {/* 批量操作栏 */} - {filteredPlaybooks.length > 0 && ( -
-
- 0 - } - onCheckedChange={toggleSelectAll} - /> - - {selectedIds.length > 0 - ? `已选择 ${selectedIds.length} / ${filteredPlaybooks.length} 项` - : `共 ${filteredPlaybooks.length} 项`} - -
- {selectedIds.length > 0 && ( -
- - -
- )} -
- )} - - {/* Playbook 卡片列表 */} -
- {loading ? ( -
- - 加载中... -
- ) : filteredPlaybooks.length === 0 ? ( -
暂无 Playbook
- ) : ( - filteredPlaybooks.map((playbook) => ( - { setSelectedPlaybook(pb); setDetailOpen(true); }} - onExecute={handleExecute} - onEdit={openEdit} - onClone={handleClone} - onDelete={handleDelete} - /> - )) - )} + {/* 表格 */} +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : filtered.length === 0 ? ( + + + + ) : ( + filtered.map(pb => ( + + + + + + + + + + )) + )} + +
+ 0} + onCheckedChange={toggleSelectAll} + /> + 名称分类标签执行次数更新时间操作
+ + 加载中... +
+ 暂无模板,点击"新建模板"创建 +
+ toggleSelect(pb.id)} + /> + +
{pb.name}
+ {pb.description && ( +
{pb.description}
+ )} +
+ {catLabel(pb.category)} + +
+ {pb.tags.length > 0 ? pb.tags.map(t => ( + {t} + )) : -} +
+
{pb.run_count} + {formatDate(pb.updated_at)} + +
+ + + + + +
+
@@ -363,12 +378,12 @@ const AnsibleManager: React.FC = () => { { setSelectedExec(exec); setLogsOpen(true); }} + onViewLogs={exec => { setSelectedExec(exec); setLogsOpen(true); }} /> - {/* ===== 新建/编辑 Playbook 对话框 ===== */} + {/* 新建/编辑对话框 */} { onSave={handleSave} /> - {/* ===== 查看 Playbook 内容 ===== */} + {/* 查看内容对话框 */} { setDetailOpen(false); openEdit(pb); }} + onEdit={pb => { setDetailOpen(false); openEdit(pb); }} /> - {/* ===== 执行日志 ===== */} + {/* 执行日志对话框 */} (null); + const [selectedPBs, setSelectedPBs] = useState>(new Set()); // 批量选择 + + // 模板编辑弹窗 + const [editorOpen, setEditorOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formData, setFormData] = useState({ + name: '', description: '', content: '', category: 'deployment', tags: '', timeout: 600, + }); + + // ---- 新建模板 ---- + const openCreate = () => { + setEditingId(null); + setFormData({ name: '', description: '', content: samplePlaybook, category: 'deployment', tags: '', timeout: 600 }); + setEditorOpen(true); + }; + + // ---- 编辑模板 ---- + 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); + }; + + 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('请填写名称和 YAML 内容'); + 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 { + if (editingId !== null) { + await ansibleApi.updatePlaybook(editingId, payload); + toast.success('更新成功'); + } else { + await ansibleApi.createPlaybook(payload); + toast.success('创建成功'); + } + setEditorOpen(false); + fetchPlaybooks(); + } catch { + toast.error(editingId !== null ? '更新失败' : '创建失败'); + } + }; // Hosts const [hostPattern, setHostPattern] = useState(''); @@ -102,8 +157,8 @@ export default function AnsibleWorkflow() { }, []); // 克隆 Playbook 为自定义模板 // Logan.Li - const clonePlaybook = async (pb: Playbook, e: React.MouseEvent) => { - e.stopPropagation(); + const clonePlaybook = async (pb: Playbook, e?: React.MouseEvent) => { + e?.stopPropagation(); try { const res = await ansibleApi.clonePlaybook(pb.id); const cloned = mapPlaybook((res as any)?.data ?? res); @@ -278,16 +333,17 @@ export default function AnsibleWorkflow() { {/* ============ Workflow Content ============ */} {activeTab === 'workflow' && ( <> - {/* Step 1: Template Selection */} + {/* Step 1: Template Selection — 表格列表 */} {step === 'template' && ( - + 选择 Playbook 模板 - + + {/* 搜索/过滤栏 */}
@@ -312,73 +368,164 @@ export default function AnsibleWorkflow() { -
- - {/* 快捷操作栏 */} -
- - - 点击卡片选择模板执行 · 悬停卡片可克隆为自定义模板 -
+ {/* 批量操作栏 */} + {selectedPBs.size > 0 && ( +
+ + 已选 {selectedPBs.size} 项 + + +
+ )} + + {/* 表格 */} {loadingPB ? (
加载中…
+ ) : filteredPlaybooks.length === 0 ? ( +
暂无模板
) : ( -
- {filteredPlaybooks.map(pb => { - const Icon = catIcons[pb.category] || FileText; - const selected = selectedPB?.id === pb.id; - return ( -
setSelectedPB(selected ? null : pb)} - className={`group relative 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} - - ))} - +
+ + + + + 0} + onCheckedChange={() => { + if (selectedPBs.size === filteredPlaybooks.length) { + setSelectedPBs(new Set()); + } else { + setSelectedPBs(new Set(filteredPlaybooks.map(p => p.id))); + } + }} + /> + + 名称 + 分类 + 标签 + 操作 + + + + {filteredPlaybooks.map(pb => { + const selected = selectedPB?.id === pb.id; + const batchChecked = selectedPBs.has(pb.id); + return ( + setSelectedPB(selected ? null : pb)} + className={`border-slate-800 cursor-pointer transition-colors ${ + selected + ? 'bg-blue-500/10 hover:bg-blue-500/15' + : 'hover:bg-slate-800/40' + }`} + > + e.stopPropagation()}> + { + const next = new Set(selectedPBs); + batchChecked ? next.delete(pb.id) : next.add(pb.id); + setSelectedPBs(next); + }} + /> + + +
+ {pb.name} +
+ {pb.description && ( +
+ {pb.description} +
+ )} +
+ + {categories.find(c => c.value === pb.category)?.label || pb.category} - - - - - ); - })} + + +
+ {pb.tags.slice(0, 3).map(t => ( + + {t} + + ))} +
+
+ +
e.stopPropagation()}> + + + +
+
+
+ ); + })} +
+
)} @@ -724,6 +871,16 @@ export default function AnsibleWorkflow() { )} + + {/* 新建/编辑模板弹窗 */} +
); }