feat: Ansible 模板克隆 (后端 ClonePlaybook API + 前端卡片悬浮克隆按钮 + 模板管理入口)
This commit is contained in:
1 parent
2d02d63806
commit
7be277658a
4 files changed
+78
-1
No files matched your search
@@ -121,6 +121,41 @@ func (h *AnsibleHandler) DeletePlaybook(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"})
|
||||
}
|
||||
|
||||
// ClonePlaybook 克隆 Playbook 为自定义模板 // Logan.Li
|
||||
// POST /api/v1/ansible/playbooks/:id/clone
|
||||
func (h *AnsibleHandler) ClonePlaybook(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var src models.AnsiblePlaybook
|
||||
if err := h.db.First(&src, id); err.Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "Playbook 不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"` // 可选自定义名称
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
cloneName := src.Name + " (副本)"
|
||||
if req.Name != nil && *req.Name != "" {
|
||||
cloneName = *req.Name
|
||||
}
|
||||
|
||||
clone := models.AnsiblePlaybook{
|
||||
Name: cloneName,
|
||||
Description: src.Description,
|
||||
Content: src.Content,
|
||||
Tags: src.Tags,
|
||||
Category: src.Category,
|
||||
CreatedBy: c.GetString("username"),
|
||||
}
|
||||
if err := h.db.Create(&clone); err.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "克隆失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: clone})
|
||||
}
|
||||
|
||||
// BatchDeletePlaybooks 批量删除 Playbook // Logan.Li
|
||||
func (h *AnsibleHandler) BatchDeletePlaybooks(c *gin.Context) {
|
||||
var req models.IDsRequest
|
||||
|
||||
@@ -443,6 +443,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
ansible.DELETE("/playbooks/:id", ansibleHandler.DeletePlaybook)
|
||||
ansible.DELETE("/playbooks/batch-delete", ansibleHandler.BatchDeletePlaybooks)
|
||||
ansible.POST("/playbooks/:id/execute", ansibleHandler.ExecutePlaybook)
|
||||
ansible.POST("/playbooks/:id/clone", ansibleHandler.ClonePlaybook)
|
||||
|
||||
ansible.GET("/jobs", ansibleHandler.ListJobs)
|
||||
ansible.GET("/jobs/:id", ansibleHandler.GetJob)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Play, Server, ChevronRight, ChevronLeft, CheckCircle, Loader2,
|
||||
Search, RefreshCw, Terminal, Clock, XCircle, FileText, Zap,
|
||||
Boxes, Shield, Database, Activity, Wrench, Network,
|
||||
Copy, Plus, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
mapPlaybook, categories, getStatusBadge, formatDate,
|
||||
@@ -100,6 +101,18 @@ export default function AnsibleWorkflow() {
|
||||
finally { setLoadingPB(false); }
|
||||
}, []);
|
||||
|
||||
// 克隆 Playbook 为自定义模板 // Logan.Li
|
||||
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);
|
||||
toast.success(`已克隆「${cloned.name}」,可在模板管理中编辑`);
|
||||
await fetchPlaybooks();
|
||||
setSelectedPB(cloned);
|
||||
} catch { toast.error('克隆失败'); }
|
||||
};
|
||||
|
||||
const fetchExecutions = useCallback(async () => {
|
||||
setLoadingExecs(true);
|
||||
try {
|
||||
@@ -301,6 +314,22 @@ export default function AnsibleWorkflow() {
|
||||
</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>
|
||||
<span className="text-xs text-slate-600">
|
||||
点击卡片选择模板执行 · 悬停卡片可克隆为自定义模板
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loadingPB ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />加载中…
|
||||
@@ -314,12 +343,20 @@ export default function AnsibleWorkflow() {
|
||||
<div
|
||||
key={pb.id}
|
||||
onClick={() => setSelectedPB(selected ? null : pb)}
|
||||
className={`p-4 rounded-lg border cursor-pointer transition-all ${
|
||||
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'}`} />
|
||||
|
||||
@@ -747,6 +747,10 @@ export const ansibleApi = {
|
||||
executePlaybook: async (id: number | string, targetHosts?: string[]) => {
|
||||
return http.post(`/ansible/playbooks/${id}/execute`, targetHosts ? { target_hosts: targetHosts } : undefined);
|
||||
},
|
||||
// 克隆 Playbook 为自定义模板 // Logan.Li
|
||||
clonePlaybook: async (id: number | string, name?: string) => {
|
||||
return http.post(`/ansible/playbooks/${id}/clone`, name ? { name } : undefined);
|
||||
},
|
||||
// 主机匹配预览 // Logan.Li
|
||||
matchHosts: async (params: { pattern?: string; match_type?: string; page?: number; page_size?: number }) => {
|
||||
return http.get<ListResponse>('/ansible/match-hosts', params);
|
||||
|
||||
Reference in new issue
Block a user