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:
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 {
|
||||
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<Playbook | null>(null);
|
||||
const [selectedExec, setSelectedExec] = useState<ExecutionRecord | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<PlaybookFormData>({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* 统计卡片 */}
|
||||
<AnsibleStatsCards
|
||||
total={playbooks.length}
|
||||
running={runningCount}
|
||||
todaySuccess={todaySuccess}
|
||||
todayFailed={todayFailed}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v); if (v === 'jobs') fetchExecutions(); }}>
|
||||
<div className="space-y-4">
|
||||
<Tabs value={activeTab} onValueChange={v => { setActiveTab(v); if (v === 'jobs') fetchExecutions(); }}>
|
||||
<TabsList className="grid w-full grid-cols-2 lg:w-[360px]">
|
||||
<TabsTrigger value="playbooks">Playbook 管理</TabsTrigger>
|
||||
<TabsTrigger value="playbooks">模板管理</TabsTrigger>
|
||||
<TabsTrigger value="jobs">执行记录</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ============ Playbook 管理 ============ */}
|
||||
{/* ============ 模板管理 ============ */}
|
||||
<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="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索名称、标签..."
|
||||
placeholder="搜索名称、描述、标签..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -289,72 +247,129 @@ const AnsibleManager: React.FC = () => {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={fetchPlaybooks}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
{selectedIds.length > 0 && (
|
||||
<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 onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建 Playbook
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
新建模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{filteredPlaybooks.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === filteredPlaybooks.length && filteredPlaybooks.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedIds.length > 0
|
||||
? `已选择 ${selectedIds.length} / ${filteredPlaybooks.length} 项`
|
||||
: `共 ${filteredPlaybooks.length} 项`}
|
||||
</span>
|
||||
</div>
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedIds([])}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleBatchDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playbook 卡片列表 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{loading ? (
|
||||
<div className="col-span-full text-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin inline mr-2" />
|
||||
加载中...
|
||||
</div>
|
||||
) : filteredPlaybooks.length === 0 ? (
|
||||
<div className="col-span-full text-center py-8 text-muted-foreground">暂无 Playbook</div>
|
||||
) : (
|
||||
filteredPlaybooks.map((playbook) => (
|
||||
<AnsiblePlaybookCard
|
||||
key={playbook.id}
|
||||
playbook={playbook}
|
||||
selected={selectedIds.includes(playbook.id)}
|
||||
executing={executing}
|
||||
onToggleSelect={toggleSelect}
|
||||
onView={(pb) => { setSelectedPlaybook(pb); setDetailOpen(true); }}
|
||||
onExecute={handleExecute}
|
||||
onEdit={openEdit}
|
||||
onClone={handleClone}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{/* 表格 */}
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="w-10 px-3 py-3 text-left">
|
||||
<Checkbox
|
||||
checked={selectedIds.length === filtered.length && filtered.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-3 text-left font-medium">名称</th>
|
||||
<th className="px-3 py-3 text-left font-medium hidden md:table-cell">分类</th>
|
||||
<th className="px-3 py-3 text-left font-medium hidden lg:table-cell">标签</th>
|
||||
<th className="px-3 py-3 text-left font-medium hidden xl:table-cell">执行次数</th>
|
||||
<th className="px-3 py-3 text-left font-medium hidden xl:table-cell">更新时间</th>
|
||||
<th className="px-3 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin inline mr-2" />
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
暂无模板,点击"新建模板"创建
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map(pb => (
|
||||
<tr key={pb.id} className="border-b hover:bg-muted/30 transition-colors">
|
||||
<td className="px-3 py-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(pb.id)}
|
||||
onCheckedChange={() => toggleSelect(pb.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="font-medium truncate max-w-[200px]">{pb.name}</div>
|
||||
{pb.description && (
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[200px]">{pb.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 hidden md:table-cell">
|
||||
<Badge variant="secondary" className="text-xs">{catLabel(pb.category)}</Badge>
|
||||
</td>
|
||||
<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>
|
||||
</TabsContent>
|
||||
|
||||
@@ -363,12 +378,12 @@ const AnsibleManager: React.FC = () => {
|
||||
<AnsibleExecutionsTable
|
||||
executions={executions}
|
||||
onRefresh={fetchExecutions}
|
||||
onViewLogs={(exec) => { setSelectedExec(exec); setLogsOpen(true); }}
|
||||
onViewLogs={exec => { setSelectedExec(exec); setLogsOpen(true); }}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ===== 新建/编辑 Playbook 对话框 ===== */}
|
||||
{/* 新建/编辑对话框 */}
|
||||
<AnsibleFormDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
@@ -378,15 +393,15 @@ const AnsibleManager: React.FC = () => {
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* ===== 查看 Playbook 内容 ===== */}
|
||||
{/* 查看内容对话框 */}
|
||||
<AnsibleDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
playbook={selectedPlaybook}
|
||||
onEdit={(pb) => { setDetailOpen(false); openEdit(pb); }}
|
||||
onEdit={pb => { setDetailOpen(false); openEdit(pb); }}
|
||||
/>
|
||||
|
||||
{/* ===== 执行日志 ===== */}
|
||||
{/* 执行日志对话框 */}
|
||||
<AnsibleLogsDialog
|
||||
open={logsOpen}
|
||||
onOpenChange={setLogsOpen}
|
||||
|
||||
@@ -20,13 +20,14 @@ import {
|
||||
Play, Server, ChevronRight, ChevronLeft, CheckCircle, Loader2,
|
||||
Search, RefreshCw, Terminal, Clock, XCircle, FileText, Zap,
|
||||
Boxes, Shield, Database, Activity, Wrench, Network,
|
||||
Copy, Plus, ExternalLink,
|
||||
Copy, Plus, Trash2, Pencil, Eye,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
mapPlaybook, categories, getStatusBadge, formatDate,
|
||||
mapPlaybook, categories, getStatusBadge, formatDate, samplePlaybook,
|
||||
type Playbook, type ExecutionRecord,
|
||||
} from './ansible-helpers';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import AnsibleFormDialog, { type PlaybookFormData } from './AnsibleFormDialog';
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
@@ -68,6 +69,60 @@ export default function AnsibleWorkflow() {
|
||||
const [searchPB, setSearchPB] = useState('');
|
||||
const [catFilter, setCatFilter] = useState('all');
|
||||
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
|
||||
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' && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-slate-100 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-blue-400" />
|
||||
选择 Playbook 模板
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-3">
|
||||
{/* 搜索/过滤栏 */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<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">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 快捷操作栏 */}
|
||||
<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 onClick={openCreate} size="sm" className="gap-1">
|
||||
<Plus className="h-4 w-4" />新建模板
|
||||
</Button>
|
||||
<span className="text-xs text-slate-600">
|
||||
点击卡片选择模板执行 · 悬停卡片可克隆为自定义模板
|
||||
</span>
|
||||
</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 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />加载中…
|
||||
</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">
|
||||
{filteredPlaybooks.map(pb => {
|
||||
const Icon = catIcons[pb.category] || FileText;
|
||||
const selected = selectedPB?.id === pb.id;
|
||||
return (
|
||||
<div
|
||||
key={pb.id}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
{/* 悬浮克隆按钮 */}
|
||||
<button
|
||||
onClick={(e) => clonePlaybook(pb, e)}
|
||||
title="克隆为自定义模板"
|
||||
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"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${selected ? 'bg-blue-500/20' : 'bg-slate-700/50'}`}>
|
||||
<Icon className={`h-5 w-5 ${selected ? 'text-blue-400' : 'text-slate-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-slate-100 text-sm truncate">{pb.name}</h3>
|
||||
<p className="text-xs text-slate-500 mt-1 line-clamp-2">{pb.description}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{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>
|
||||
))}
|
||||
<Badge className="text-[10px] bg-slate-700/50 text-slate-400 px-1.5 py-0">
|
||||
<div className="border border-slate-800 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="w-10 text-slate-500">
|
||||
<Checkbox
|
||||
checked={selectedPBs.size === filteredPlaybooks.length && filteredPlaybooks.length > 0}
|
||||
onCheckedChange={() => {
|
||||
if (selectedPBs.size === filteredPlaybooks.length) {
|
||||
setSelectedPBs(new Set());
|
||||
} else {
|
||||
setSelectedPBs(new Set(filteredPlaybooks.map(p => p.id)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-400">名称</TableHead>
|
||||
<TableHead className="text-slate-400 hidden md:table-cell">分类</TableHead>
|
||||
<TableHead className="text-slate-400 hidden lg:table-cell">标签</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPlaybooks.map(pb => {
|
||||
const selected = selectedPB?.id === pb.id;
|
||||
const batchChecked = selectedPBs.has(pb.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={pb.id}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<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}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -724,6 +871,16 @@ export default function AnsibleWorkflow() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 新建/编辑模板弹窗 */}
|
||||
<AnsibleFormDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
editingId={editingId}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user