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: "已删除"})
|
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
|
// BatchDeletePlaybooks 批量删除 Playbook // Logan.Li
|
||||||
func (h *AnsibleHandler) BatchDeletePlaybooks(c *gin.Context) {
|
func (h *AnsibleHandler) BatchDeletePlaybooks(c *gin.Context) {
|
||||||
var req models.IDsRequest
|
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/:id", ansibleHandler.DeletePlaybook)
|
||||||
ansible.DELETE("/playbooks/batch-delete", ansibleHandler.BatchDeletePlaybooks)
|
ansible.DELETE("/playbooks/batch-delete", ansibleHandler.BatchDeletePlaybooks)
|
||||||
ansible.POST("/playbooks/:id/execute", ansibleHandler.ExecutePlaybook)
|
ansible.POST("/playbooks/:id/execute", ansibleHandler.ExecutePlaybook)
|
||||||
|
ansible.POST("/playbooks/:id/clone", ansibleHandler.ClonePlaybook)
|
||||||
|
|
||||||
ansible.GET("/jobs", ansibleHandler.ListJobs)
|
ansible.GET("/jobs", ansibleHandler.ListJobs)
|
||||||
ansible.GET("/jobs/:id", ansibleHandler.GetJob)
|
ansible.GET("/jobs/:id", ansibleHandler.GetJob)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
mapPlaybook, categories, getStatusBadge, formatDate,
|
mapPlaybook, categories, getStatusBadge, formatDate,
|
||||||
@@ -100,6 +101,18 @@ export default function AnsibleWorkflow() {
|
|||||||
finally { setLoadingPB(false); }
|
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 () => {
|
const fetchExecutions = useCallback(async () => {
|
||||||
setLoadingExecs(true);
|
setLoadingExecs(true);
|
||||||
try {
|
try {
|
||||||
@@ -301,6 +314,22 @@ export default function AnsibleWorkflow() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{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" />加载中…
|
||||||
@@ -314,12 +343,20 @@ export default function AnsibleWorkflow() {
|
|||||||
<div
|
<div
|
||||||
key={pb.id}
|
key={pb.id}
|
||||||
onClick={() => setSelectedPB(selected ? null : pb)}
|
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
|
selected
|
||||||
? 'border-blue-500 bg-blue-500/10 ring-1 ring-blue-500/30'
|
? 'border-blue-500 bg-blue-500/10 ring-1 ring-blue-500/30'
|
||||||
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
|
: '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="flex items-start gap-3">
|
||||||
<div className={`p-2 rounded-lg ${selected ? 'bg-blue-500/20' : 'bg-slate-700/50'}`}>
|
<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'}`} />
|
<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[]) => {
|
executePlaybook: async (id: number | string, targetHosts?: string[]) => {
|
||||||
return http.post(`/ansible/playbooks/${id}/execute`, targetHosts ? { target_hosts: targetHosts } : undefined);
|
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
|
// 主机匹配预览 // Logan.Li
|
||||||
matchHosts: async (params: { pattern?: string; match_type?: string; page?: number; page_size?: number }) => {
|
matchHosts: async (params: { pattern?: string; match_type?: string; page?: number; page_size?: number }) => {
|
||||||
return http.get<ListResponse>('/ansible/match-hosts', params);
|
return http.get<ListResponse>('/ansible/match-hosts', params);
|
||||||
|
|||||||
Reference in new issue
Block a user