refactor: Ansible template list — cards to table with batch delete, edit dialog, new template

- AnsibleManager: card grid → table list with checkbox/批量删除/edit/clone/delete
- AnsibleWorkflow: template step cards → table, add 新建模板 button
- Edit: window.prompt → AnsibleFormDialog (name/YAML/category/tags)
- Delete: window.confirm → _confirm dialog
- Batch delete: window.confirm → _confirm dialog
- Clone: card hover → table row button
This commit is contained in:
attacker committed 2026-06-19 15:28:52 +00:00
1 parent 732974d152
commit 0fea0ccc20
2 files changed
+391 -219

No files matched your search

@@ -1,24 +1,24 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
RotateCcw, Plus, Search, Trash2, Loader2, RotateCcw, Plus, Search, Trash2, Loader2,
Eye, Pencil, Copy, Play, Tags,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { ansibleApi, taskExecutionApi } from '@/services/api'; import { ansibleApi, taskExecutionApi } from '@/services/api';
import { import {
mapPlaybook, categories, samplePlaybook, mapPlaybook, categories, samplePlaybook, formatDate,
type Playbook, type ExecutionRecord, type Playbook, type ExecutionRecord,
} from './ansible-helpers'; } from './ansible-helpers';
import AnsibleFormDialog, { type PlaybookFormData } from './AnsibleFormDialog'; import AnsibleFormDialog, { type PlaybookFormData } from './AnsibleFormDialog';
import AnsibleDetailDialog from './AnsibleDetailDialog'; import AnsibleDetailDialog from './AnsibleDetailDialog';
import AnsibleLogsDialog from './AnsibleLogsDialog'; import AnsibleLogsDialog from './AnsibleLogsDialog';
import AnsibleStatsCards from './AnsibleStatsCards';
import AnsiblePlaybookCard from './AnsiblePlaybookCard';
import AnsibleExecutionsTable from './AnsibleExecutionsTable'; import AnsibleExecutionsTable from './AnsibleExecutionsTable';
const AnsibleManager: React.FC = () => { const AnsibleManager: React.FC = () => {
@@ -40,7 +40,6 @@ const AnsibleManager: React.FC = () => {
const [selectedPlaybook, setSelectedPlaybook] = useState<Playbook | null>(null); const [selectedPlaybook, setSelectedPlaybook] = useState<Playbook | null>(null);
const [selectedExec, setSelectedExec] = useState<ExecutionRecord | null>(null); const [selectedExec, setSelectedExec] = useState<ExecutionRecord | null>(null);
// 表单状态
const [formData, setFormData] = useState<PlaybookFormData>({ const [formData, setFormData] = useState<PlaybookFormData>({
name: '', description: '', content: '', category: 'deployment', tags: '', timeout: 600, name: '', description: '', content: '', category: 'deployment', tags: '', timeout: 600,
}); });
@@ -48,10 +47,10 @@ const AnsibleManager: React.FC = () => {
const fetchPlaybooks = async () => { const fetchPlaybooks = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await ansibleApi.getPlaybooks({ page_size: 100 }); const res = await ansibleApi.getPlaybooks({ page_size: 200 });
const items = (res as any)?.list || []; const items = (res as any)?.list || [];
setPlaybooks(items.map(mapPlaybook)); setPlaybooks(items.map(mapPlaybook));
} catch (error) { } catch {
toast.error('获取 Playbook 列表失败'); toast.error('获取 Playbook 列表失败');
} finally { } finally {
setLoading(false); setLoading(false);
@@ -61,10 +60,9 @@ const AnsibleManager: React.FC = () => {
const fetchExecutions = async () => { const fetchExecutions = async () => {
try { try {
const res = await taskExecutionApi.getExecutions({ task_type: 'playbook', page_size: 50 }); const res = await taskExecutionApi.getExecutions({ task_type: 'playbook', page_size: 50 });
const items = (res as any)?.list || []; setExecutions((res as any)?.list || []);
setExecutions(items); } catch {
} catch (error) { // 静默
// 静默失败,避免切换 tab 时弹窗
} }
}; };
@@ -74,7 +72,6 @@ const AnsibleManager: React.FC = () => {
}, []); }, []);
// ---- 新建/编辑 ---- // ---- 新建/编辑 ----
const openCreate = () => { const openCreate = () => {
setEditingId(null); setEditingId(null);
setFormData({ name: '', description: '', content: samplePlaybook, category: 'deployment', tags: '', timeout: 600 }); setFormData({ name: '', description: '', content: samplePlaybook, category: 'deployment', tags: '', timeout: 600 });
@@ -84,17 +81,12 @@ const AnsibleManager: React.FC = () => {
const openEdit = (pb: Playbook) => { const openEdit = (pb: Playbook) => {
setEditingId(pb.id); setEditingId(pb.id);
setFormData({ setFormData({
name: pb.name, name: pb.name, description: pb.description, content: pb.content,
description: pb.description, category: pb.category, tags: pb.tags.join(', '), timeout: pb.timeout,
content: pb.content,
category: pb.category,
tags: pb.tags.join(', '),
timeout: pb.timeout,
}); });
setEditorOpen(true); setEditorOpen(true);
}; };
// 将逗号分隔的标签字符串转为 JSON 数组字符串 // Logan.Li
const tagsToJson = (raw: string): string => { const tagsToJson = (raw: string): string => {
const arr = raw.split(',').map(t => t.trim()).filter(Boolean); const arr = raw.split(',').map(t => t.trim()).filter(Boolean);
return JSON.stringify(arr); return JSON.stringify(arr);
@@ -106,49 +98,36 @@ const AnsibleManager: React.FC = () => {
return; return;
} }
const payload = { const payload = {
name: formData.name, name: formData.name, description: formData.description, content: formData.content,
description: formData.description, category: formData.category, tags: tagsToJson(formData.tags), timeout: Number(formData.timeout) || 600,
content: formData.content,
category: formData.category,
tags: tagsToJson(formData.tags),
timeout: Number(formData.timeout) || 600,
}; };
try { try {
if (editingId !== null) { if (editingId !== null) {
await ansibleApi.updatePlaybook(editingId, payload); await ansibleApi.updatePlaybook(editingId, payload);
toast.success('Playbook 更新成功'); toast.success('更新成功');
} else { } else {
await ansibleApi.createPlaybook(payload); await ansibleApi.createPlaybook(payload);
toast.success('Playbook 创建成功'); toast.success('创建成功');
} }
setEditorOpen(false); setEditorOpen(false);
fetchPlaybooks(); fetchPlaybooks();
} catch (error) { } catch {
toast.error(editingId !== null ? '更新失败' : '创建失败'); toast.error(editingId !== null ? '更新失败' : '创建失败');
} }
}; };
// ---- 复制 ---- // ---- 克隆 ----
const handleClone = async (pb: Playbook) => { const handleClone = async (pb: Playbook) => {
try { try {
await ansibleApi.createPlaybook({ await ansibleApi.clonePlaybook(pb.id);
name: `${pb.name} (副本)`, toast.success('克隆成功');
description: pb.description,
content: pb.content,
category: pb.category,
tags: JSON.stringify(pb.tags),
timeout: pb.timeout,
});
toast.success('复制成功');
fetchPlaybooks(); fetchPlaybooks();
} catch (error) { } catch {
toast.error('复制失败'); toast.error('克隆失败');
} }
}; };
// ---- 删除 ---- // ---- 单个删除 ----
const handleDelete = async (pb: Playbook) => { const handleDelete = async (pb: Playbook) => {
const ok = await _confirm({ const ok = await _confirm({
title: '删除 Playbook', title: '删除 Playbook',
@@ -159,54 +138,50 @@ const AnsibleManager: React.FC = () => {
if (!ok) return; if (!ok) return;
try { try {
await ansibleApi.deletePlaybook(pb.id); await ansibleApi.deletePlaybook(pb.id);
toast.success('删除成功'); toast.success('已删除');
fetchPlaybooks(); fetchPlaybooks();
} catch (error) { } catch {
toast.error('删除失败'); toast.error('删除失败');
} }
}; };
// ---- 批量选择 / 删除 ---- // Logan.Li // ---- 批量删除 ----
const toggleSelect = (id: number) => { const toggleSelect = (id: number) => {
setSelectedIds((prev) => setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
);
}; };
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (selectedIds.length === filteredPlaybooks.length && filteredPlaybooks.length > 0) { if (selectedIds.length === filtered.length && filtered.length > 0) {
setSelectedIds([]); setSelectedIds([]);
} else { } else {
setSelectedIds(filteredPlaybooks.map((pb) => pb.id)); setSelectedIds(filtered.map(p => p.id));
} }
}; };
const handleBatchDelete = async () => { const handleBatchDelete = async () => {
if (selectedIds.length === 0) return; if (selectedIds.length === 0) return;
const ok = await _confirm({ const ok = await _confirm({
title: '批量删除 Playbook', title: '批量删除',
description: `确定删除选中的 ${selectedIds.length} 个 Playbook 吗?此操作不可恢复。`, description: `确定删除选中的 ${selectedIds.length} 个 Playbook 吗?`,
confirmText: '批量删除', confirmText: '批量删除',
variant: 'destructive', variant: 'destructive',
}); });
if (!ok) return; if (!ok) return;
try { try {
await ansibleApi.batchDeletePlaybooks(selectedIds); await ansibleApi.batchDeletePlaybooks(selectedIds);
toast.success(`已删除 ${selectedIds.length} 个 Playbook`); toast.success(`已删除 ${selectedIds.length} 个`);
setSelectedIds([]); setSelectedIds([]);
fetchPlaybooks(); fetchPlaybooks();
} catch (error) { } catch {
toast.error('批量删除失败'); toast.error('批量删除失败');
} }
}; };
// ---- 执行 ---- // ---- 执行 ----
const handleExecute = async (pb: Playbook) => { const handleExecute = async (pb: Playbook) => {
const ok = await _confirm({ const ok = await _confirm({
title: '执行 Playbook', title: '执行 Playbook',
description: `将在 AIOps 主机上本地执行「${pb.name}」(ansible-playbook --connection=local)。`, description: `将在 AIOps 主机本地执行「${pb.name}」(ansible-playbook --connection=local)。`,
confirmText: '开始执行', confirmText: '开始执行',
}); });
if (!ok) return; if (!ok) return;
@@ -217,67 +192,50 @@ const AnsibleManager: React.FC = () => {
if (exec?.status === 'success') { if (exec?.status === 'success') {
toast.success(`执行成功,耗时 ${exec.duration ?? 0}ms`); toast.success(`执行成功,耗时 ${exec.duration ?? 0}ms`);
} else { } else {
toast.error(`执行失败(退出码异常),耗时 ${exec?.duration ?? 0}ms`); toast.error(`执行失败,耗时 ${exec?.duration ?? 0}ms`);
} }
fetchPlaybooks(); fetchPlaybooks();
fetchExecutions(); fetchExecutions();
} catch (error) { } catch {
toast.error('执行请求失败'); toast.error('执行请求失败');
} finally { } finally {
setExecuting(false); setExecuting(false);
} }
}; };
// ---- 辅助 ---- // ---- 过滤 ----
const filtered = playbooks.filter(pb => {
const filteredPlaybooks = playbooks.filter(pb => {
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
const matchSearch = pb.name.toLowerCase().includes(q) || return pb.name.toLowerCase().includes(q) || pb.description.toLowerCase().includes(q) ||
pb.description.toLowerCase().includes(q) ||
pb.tags.some(t => t.toLowerCase().includes(q)); pb.tags.some(t => t.toLowerCase().includes(q));
const matchCategory = categoryFilter === 'all' || pb.category === categoryFilter; }).filter(pb => categoryFilter === 'all' || pb.category === categoryFilter);
return matchSearch && matchCategory;
});
const runningCount = executions.filter(e => e.status === 'running').length; const catLabel = (v: string) => categories.find(c => c.value === v)?.label || v;
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 ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* 统计卡片 */} <Tabs value={activeTab} onValueChange={v => { setActiveTab(v); if (v === 'jobs') fetchExecutions(); }}>
<AnsibleStatsCards
total={playbooks.length}
running={runningCount}
todaySuccess={todaySuccess}
todayFailed={todayFailed}
/>
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v); if (v === 'jobs') fetchExecutions(); }}>
<TabsList className="grid w-full grid-cols-2 lg:w-[360px]"> <TabsList className="grid w-full grid-cols-2 lg:w-[360px]">
<TabsTrigger value="playbooks">Playbook 管理</TabsTrigger> <TabsTrigger value="playbooks">模板管理</TabsTrigger>
<TabsTrigger value="jobs">执行记录</TabsTrigger> <TabsTrigger value="jobs">执行记录</TabsTrigger>
</TabsList> </TabsList>
{/* ============ Playbook 管理 ============ */} {/* ============ 模板管理 ============ */}
<TabsContent value="playbooks" className="space-y-4"> <TabsContent value="playbooks" className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4"> {/* 搜索栏 + 新建按钮 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-1 min-w-[300px]"> <div className="flex items-center gap-2 flex-1 min-w-[300px]">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
placeholder="搜索名称、标签..." placeholder="搜索名称、描述、标签..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
className="pl-10" className="pl-10"
/> />
</div> </div>
<Select value={categoryFilter} onValueChange={setCategoryFilter}> <Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[140px]"> <SelectTrigger className="w-[130px]">
<SelectValue placeholder="分类" /> <SelectValue placeholder="分类" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -289,72 +247,129 @@ const AnsibleManager: React.FC = () => {
</Select> </Select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchPlaybooks}> {selectedIds.length > 0 && (
<RotateCcw className="w-4 h-4 mr-2" /> <Button variant="destructive" size="sm" onClick={handleBatchDelete}>
刷新 <Trash2 className="w-4 h-4 mr-1" />
删除 ({selectedIds.length})
</Button>
)}
<Button variant="outline" size="sm" onClick={fetchPlaybooks}>
<RotateCcw className="w-4 h-4" />
</Button> </Button>
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-1" />
新建 Playbook 新建模板
</Button> </Button>
</div> </div>
</div> </div>
{/* 批量操作栏 */} {/* 表格 */}
{filteredPlaybooks.length > 0 && ( <div className="rounded-md border">
<div className="flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-4 py-2"> <table className="w-full text-sm">
<div className="flex items-center gap-3"> <thead className="bg-muted/50 border-b">
<Checkbox <tr>
checked={ <th className="w-10 px-3 py-3 text-left">
selectedIds.length === filteredPlaybooks.length && filteredPlaybooks.length > 0 <Checkbox
} checked={selectedIds.length === filtered.length && filtered.length > 0}
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
/> />
<span className="text-sm text-muted-foreground"> </th>
{selectedIds.length > 0 <th className="px-3 py-3 text-left font-medium">名称</th>
? `已选择 ${selectedIds.length} / ${filteredPlaybooks.length} 项` <th className="px-3 py-3 text-left font-medium hidden md:table-cell">分类</th>
: `共 ${filteredPlaybooks.length} 项`} <th className="px-3 py-3 text-left font-medium hidden lg:table-cell">标签</th>
</span> <th className="px-3 py-3 text-left font-medium hidden xl:table-cell">执行次数</th>
</div> <th className="px-3 py-3 text-left font-medium hidden xl:table-cell">更新时间</th>
{selectedIds.length > 0 && ( <th className="px-3 py-3 text-right font-medium">操作</th>
<div className="flex items-center gap-2"> </tr>
<Button variant="ghost" size="sm" onClick={() => setSelectedIds([])}> </thead>
取消选择 <tbody>
</Button> {loading ? (
<Button variant="destructive" size="sm" onClick={handleBatchDelete}> <tr>
<Trash2 className="w-4 h-4 mr-2" /> <td colSpan={7} className="text-center py-12 text-muted-foreground">
批量删除 <Loader2 className="w-5 h-5 animate-spin inline mr-2" />
</Button> 加载中...
</div> </td>
)} </tr>
</div> ) : filtered.length === 0 ? (
)} <tr>
<td colSpan={7} className="text-center py-12 text-muted-foreground">
{/* Playbook 卡片列表 */} 暂无模板,点击"新建模板"创建
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> </td>
{loading ? ( </tr>
<div className="col-span-full text-center py-8 text-muted-foreground"> ) : (
<Loader2 className="w-5 h-5 animate-spin inline mr-2" /> filtered.map(pb => (
加载中... <tr key={pb.id} className="border-b hover:bg-muted/30 transition-colors">
</div> <td className="px-3 py-3">
) : filteredPlaybooks.length === 0 ? ( <Checkbox
<div className="col-span-full text-center py-8 text-muted-foreground">暂无 Playbook</div> checked={selectedIds.includes(pb.id)}
) : ( onCheckedChange={() => toggleSelect(pb.id)}
filteredPlaybooks.map((playbook) => ( />
<AnsiblePlaybookCard </td>
key={playbook.id} <td className="px-3 py-3">
playbook={playbook} <div className="font-medium truncate max-w-[200px]">{pb.name}</div>
selected={selectedIds.includes(playbook.id)} {pb.description && (
executing={executing} <div className="text-xs text-muted-foreground truncate max-w-[200px]">{pb.description}</div>
onToggleSelect={toggleSelect} )}
onView={(pb) => { setSelectedPlaybook(pb); setDetailOpen(true); }} </td>
onExecute={handleExecute} <td className="px-3 py-3 hidden md:table-cell">
onEdit={openEdit} <Badge variant="secondary" className="text-xs">{catLabel(pb.category)}</Badge>
onClone={handleClone} </td>
onDelete={handleDelete} <td className="px-3 py-3 hidden lg:table-cell">
/> <div className="flex flex-wrap gap-1">
)) {pb.tags.length > 0 ? pb.tags.map(t => (
)} <Badge key={t} variant="outline" className="text-xs"><Tags className="w-3 h-3 mr-0.5" />{t}</Badge>
)) : <span className="text-muted-foreground">-</span>}
</div>
</td>
<td className="px-3 py-3 hidden xl:table-cell text-muted-foreground">{pb.run_count}</td>
<td className="px-3 py-3 hidden xl:table-cell text-muted-foreground text-xs">
{formatDate(pb.updated_at)}
</td>
<td className="px-3 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost" size="icon" className="h-8 w-8"
title="查看内容"
onClick={() => { setSelectedPlaybook(pb); setDetailOpen(true); }}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8"
title="编辑"
onClick={() => openEdit(pb)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8"
title="克隆"
onClick={() => handleClone(pb)}
>
<Copy className="w-4 h-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8 text-green-600"
title="执行"
disabled={executing}
onClick={() => handleExecute(pb)}
>
<Play className="w-4 h-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8 text-destructive"
title="删除"
onClick={() => handleDelete(pb)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div> </div>
</TabsContent> </TabsContent>
@@ -363,12 +378,12 @@ const AnsibleManager: React.FC = () => {
<AnsibleExecutionsTable <AnsibleExecutionsTable
executions={executions} executions={executions}
onRefresh={fetchExecutions} onRefresh={fetchExecutions}
onViewLogs={(exec) => { setSelectedExec(exec); setLogsOpen(true); }} onViewLogs={exec => { setSelectedExec(exec); setLogsOpen(true); }}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{/* ===== 新建/编辑 Playbook 对话框 ===== */} {/* 新建/编辑对话框 */}
<AnsibleFormDialog <AnsibleFormDialog
open={editorOpen} open={editorOpen}
onOpenChange={setEditorOpen} onOpenChange={setEditorOpen}
@@ -378,15 +393,15 @@ const AnsibleManager: React.FC = () => {
onSave={handleSave} onSave={handleSave}
/> />
{/* ===== 查看 Playbook 内容 ===== */} {/* 查看内容对话框 */}
<AnsibleDetailDialog <AnsibleDetailDialog
open={detailOpen} open={detailOpen}
onOpenChange={setDetailOpen} onOpenChange={setDetailOpen}
playbook={selectedPlaybook} playbook={selectedPlaybook}
onEdit={(pb) => { setDetailOpen(false); openEdit(pb); }} onEdit={pb => { setDetailOpen(false); openEdit(pb); }}
/> />
{/* ===== 执行日志 ===== */} {/* 执行日志对话框 */}
<AnsibleLogsDialog <AnsibleLogsDialog
open={logsOpen} open={logsOpen}
onOpenChange={setLogsOpen} onOpenChange={setLogsOpen}
@@ -20,13 +20,14 @@ import {
Play, Server, ChevronRight, ChevronLeft, CheckCircle, Loader2, Play, Server, ChevronRight, ChevronLeft, CheckCircle, Loader2,
Search, RefreshCw, Terminal, Clock, XCircle, FileText, Zap, Search, RefreshCw, Terminal, Clock, XCircle, FileText, Zap,
Boxes, Shield, Database, Activity, Wrench, Network, Boxes, Shield, Database, Activity, Wrench, Network,
Copy, Plus, ExternalLink, Copy, Plus, Trash2, Pencil, Eye,
} from 'lucide-react'; } from 'lucide-react';
import { import {
mapPlaybook, categories, getStatusBadge, formatDate, mapPlaybook, categories, getStatusBadge, formatDate, samplePlaybook,
type Playbook, type ExecutionRecord, type Playbook, type ExecutionRecord,
} from './ansible-helpers'; } from './ansible-helpers';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import AnsibleFormDialog, { type PlaybookFormData } from './AnsibleFormDialog';
// ============ Types ============ // ============ Types ============
@@ -68,6 +69,60 @@ export default function AnsibleWorkflow() {
const [searchPB, setSearchPB] = useState(''); const [searchPB, setSearchPB] = useState('');
const [catFilter, setCatFilter] = useState('all'); const [catFilter, setCatFilter] = useState('all');
const [selectedPB, setSelectedPB] = useState<Playbook | null>(null); const [selectedPB, setSelectedPB] = useState<Playbook | null>(null);
const [selectedPBs, setSelectedPBs] = useState<Set<number>>(new Set()); // 批量选择
// 模板编辑弹窗
const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<PlaybookFormData>({
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 // Hosts
const [hostPattern, setHostPattern] = useState(''); const [hostPattern, setHostPattern] = useState('');
@@ -102,8 +157,8 @@ export default function AnsibleWorkflow() {
}, []); }, []);
// 克隆 Playbook 为自定义模板 // Logan.Li // 克隆 Playbook 为自定义模板 // Logan.Li
const clonePlaybook = async (pb: Playbook, e: React.MouseEvent) => { const clonePlaybook = async (pb: Playbook, e?: React.MouseEvent) => {
e.stopPropagation(); e?.stopPropagation();
try { try {
const res = await ansibleApi.clonePlaybook(pb.id); const res = await ansibleApi.clonePlaybook(pb.id);
const cloned = mapPlaybook((res as any)?.data ?? res); const cloned = mapPlaybook((res as any)?.data ?? res);
@@ -278,16 +333,17 @@ export default function AnsibleWorkflow() {
{/* ============ Workflow Content ============ */} {/* ============ Workflow Content ============ */}
{activeTab === 'workflow' && ( {activeTab === 'workflow' && (
<> <>
{/* Step 1: Template Selection */} {/* Step 1: Template Selection — 表格列表 */}
{step === 'template' && ( {step === 'template' && (
<Card className="bg-slate-900 border-slate-800"> <Card className="bg-slate-900 border-slate-800">
<CardHeader> <CardHeader className="pb-2">
<CardTitle className="text-slate-100 flex items-center gap-2"> <CardTitle className="text-slate-100 flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-400" /> <FileText className="h-5 w-5 text-blue-400" />
选择 Playbook 模板 选择 Playbook 模板
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-3">
{/* 搜索/过滤栏 */}
<div className="flex gap-3"> <div className="flex gap-3">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
@@ -312,73 +368,164 @@ export default function AnsibleWorkflow() {
<Button variant="outline" onClick={fetchPlaybooks} className="border-slate-700"> <Button variant="outline" onClick={fetchPlaybooks} className="border-slate-700">
<RefreshCw className="h-4 w-4 mr-2" />刷新 <RefreshCw className="h-4 w-4 mr-2" />刷新
</Button> </Button>
</div> <Button onClick={openCreate} size="sm" className="gap-1">
<Plus className="h-4 w-4" />新建模板
{/* 快捷操作栏 */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.open('/ansible/manage', '_blank')}
className="border-slate-700 text-slate-400 hover:text-slate-200 text-xs h-8"
>
<ExternalLink className="h-3 w-3 mr-1" />
模板管理
</Button> </Button>
<span className="text-xs text-slate-600">
点击卡片选择模板执行 · 悬停卡片可克隆为自定义模板
</span>
</div> </div>
{/* 批量操作栏 */}
{selectedPBs.size > 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded bg-slate-800/50 border border-slate-700">
<span className="text-sm text-slate-400">
已选 {selectedPBs.size} 项
</span>
<Button
variant="destructive"
size="sm"
onClick={async () => {
const ids = Array.from(selectedPBs);
const ok = await _confirm({
title: '批量删除',
description: `确定删除选中的 ${ids.length} 个模板吗?此操作不可恢复。`,
confirmText: '批量删除',
variant: 'destructive',
});
if (!ok) return;
try {
await ansibleApi.batchDeletePlaybooks(ids);
toast.success(`已删除 ${ids.length} 个`);
setSelectedPBs(new Set());
fetchPlaybooks();
} catch { toast.error('删除失败'); }
}}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />批量删除
</Button>
</div>
)}
{/* 表格 */}
{loadingPB ? ( {loadingPB ? (
<div className="text-center py-12 text-slate-500"> <div className="text-center py-12 text-slate-500">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />加载中… <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />加载中…
</div> </div>
) : filteredPlaybooks.length === 0 ? (
<div className="text-center py-12 text-slate-500">暂无模板</div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="border border-slate-800 rounded-lg overflow-hidden">
{filteredPlaybooks.map(pb => { <Table>
const Icon = catIcons[pb.category] || FileText; <TableHeader>
const selected = selectedPB?.id === pb.id; <TableRow className="border-slate-800 hover:bg-transparent">
return ( <TableHead className="w-10 text-slate-500">
<div <Checkbox
key={pb.id} checked={selectedPBs.size === filteredPlaybooks.length && filteredPlaybooks.length > 0}
onClick={() => setSelectedPB(selected ? null : pb)} onCheckedChange={() => {
className={`group relative p-4 rounded-lg border cursor-pointer transition-all ${ if (selectedPBs.size === filteredPlaybooks.length) {
selected setSelectedPBs(new Set());
? 'border-blue-500 bg-blue-500/10 ring-1 ring-blue-500/30' } else {
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600' setSelectedPBs(new Set(filteredPlaybooks.map(p => p.id)));
}`} }
> }}
{/* 悬浮克隆按钮 */} />
<button </TableHead>
onClick={(e) => clonePlaybook(pb, e)} <TableHead className="text-slate-400">名称</TableHead>
title="克隆为自定义模板" <TableHead className="text-slate-400 hidden md:table-cell">分类</TableHead>
className="absolute top-2 right-2 p-1.5 rounded-md opacity-0 group-hover:opacity-100 transition-opacity bg-slate-700 hover:bg-blue-600 text-slate-400 hover:text-white" <TableHead className="text-slate-400 hidden lg:table-cell">标签</TableHead>
> <TableHead className="text-slate-400 text-right">操作</TableHead>
<Copy className="h-3.5 w-3.5" /> </TableRow>
</button> </TableHeader>
<div className="flex items-start gap-3"> <TableBody>
<div className={`p-2 rounded-lg ${selected ? 'bg-blue-500/20' : 'bg-slate-700/50'}`}> {filteredPlaybooks.map(pb => {
<Icon className={`h-5 w-5 ${selected ? 'text-blue-400' : 'text-slate-400'}`} /> const selected = selectedPB?.id === pb.id;
</div> const batchChecked = selectedPBs.has(pb.id);
<div className="flex-1 min-w-0"> return (
<h3 className="font-medium text-slate-100 text-sm truncate">{pb.name}</h3> <TableRow
<p className="text-xs text-slate-500 mt-1 line-clamp-2">{pb.description}</p> key={pb.id}
<div className="flex flex-wrap gap-1 mt-2"> onClick={() => setSelectedPB(selected ? null : pb)}
{pb.tags.slice(0, 3).map(t => ( className={`border-slate-800 cursor-pointer transition-colors ${
<Badge key={t} variant="outline" className="text-[10px] border-slate-700 text-slate-500 px-1.5 py-0"> selected
{t} ? 'bg-blue-500/10 hover:bg-blue-500/15'
</Badge> : 'hover:bg-slate-800/40'
))} }`}
<Badge className="text-[10px] bg-slate-700/50 text-slate-400 px-1.5 py-0"> >
<TableCell onClick={e => e.stopPropagation()}>
<Checkbox
checked={batchChecked}
onCheckedChange={() => {
const next = new Set(selectedPBs);
batchChecked ? next.delete(pb.id) : next.add(pb.id);
setSelectedPBs(next);
}}
/>
</TableCell>
<TableCell>
<div className="font-medium text-slate-100 text-sm truncate max-w-[220px]">
{pb.name}
</div>
{pb.description && (
<div className="text-xs text-slate-500 truncate max-w-[220px] mt-0.5">
{pb.description}
</div>
)}
</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant="secondary" className="text-xs bg-slate-700/50 text-slate-400">
{categories.find(c => c.value === pb.category)?.label || pb.category} {categories.find(c => c.value === pb.category)?.label || pb.category}
</Badge> </Badge>
</div> </TableCell>
</div> <TableCell className="hidden lg:table-cell">
</div> <div className="flex flex-wrap gap-1">
</div> {pb.tags.slice(0, 3).map(t => (
); <Badge key={t} variant="outline" className="text-[10px] border-slate-700 text-slate-500 px-1.5 py-0">
})} {t}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1" onClick={e => e.stopPropagation()}>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-slate-200"
title="编辑(修改名称/分类/YAML)"
onClick={() => openEdit(pb)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
title="克隆"
onClick={() => clonePlaybook(pb)}
>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-red-400"
title="删除"
onClick={async () => {
const ok = await _confirm({
title: '删除模板',
description: `确定删除「${pb.name}」吗?此操作不可恢复。`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
try {
await ansibleApi.deletePlaybook(pb.id);
toast.success('已删除');
if (selectedPB?.id === pb.id) setSelectedPB(null);
fetchPlaybooks();
} catch { toast.error('删除失败'); }
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div> </div>
)} )}
@@ -724,6 +871,16 @@ export default function AnsibleWorkflow() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* 新建/编辑模板弹窗 */}
<AnsibleFormDialog
open={editorOpen}
onOpenChange={setEditorOpen}
editingId={editingId}
formData={formData}
setFormData={setFormData}
onSave={handleSave}
/>
</div> </div>
); );
} }