diff --git a/backend/controller/agent_install.go b/backend/controller/agent_install.go index 606acc7..d040afa 100644 --- a/backend/controller/agent_install.go +++ b/backend/controller/agent_install.go @@ -211,9 +211,7 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s } defer client.Close() - if server.Status != "running" { - return false, "", "", fmt.Errorf("服务器未运行") - } + // 不强制 server.Status==running:只要 SSH 可达即可安装(DB 状态可能滞后于实际开机状态) // Logan.Li installCmd := fmt.Sprintf( `curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`, @@ -312,8 +310,8 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) { return } - // 无 SSH 凭据的主机(自动注册的)直接改本地状态 - if server.Username == "" || server.Password == "" { + // 无 SSH 凭据的主机(自动注册的)直接改本地状态;注意 SSH key 认证的主机 Password 为空但 SSHKey 非空,仍可远程卸载 // Logan.Li + if server.Username == "" || (server.Password == "" && server.SSHKey == "") { h.db.Model(&server).Updates(map[string]interface{}{ "agent_status": "offline", "agent_version": "", }) diff --git a/backend/controller/remote_exec.go b/backend/controller/remote_exec.go index 3c4d390..50d91cd 100644 --- a/backend/controller/remote_exec.go +++ b/backend/controller/remote_exec.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "os" "strconv" "strings" "time" @@ -133,6 +134,61 @@ func (h *RemoteExecHandler) BatchUpload(c *gin.Context) { c.JSON(http.StatusOK, model.Response{Code: 200, Data: results}) } +// UploadFile 前端上传文件到多台远程主机(multipart:file + server_ids + remote_path) // Logan.Li +// 后端接收文件到临时路径,经 SSH SCP 转发到目标主机,完成后清理临时文件。 +// POST /api/v1/remote-exec/upload-file +func (h *RemoteExecHandler) UploadFile(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择要上传的文件"}) + return + } + remotePath := strings.TrimSpace(c.PostForm("remote_path")) + if remotePath == "" { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请填写远程目标路径(含文件名)"}) + return + } + var serverIDs []uint + if err := json.Unmarshal([]byte(c.PostForm("server_ids")), &serverIDs); err != nil || len(serverIDs) == 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择目标主机"}) + return + } + + // 1. 保存上传文件到临时路径 + tmp, err := os.CreateTemp("", "aiops-upload-*") + if err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建临时文件失败"}) + return + } + tmpPath := tmp.Name() + tmp.Close() + defer os.Remove(tmpPath) + if err := c.SaveUploadedFile(file, tmpPath); err != nil { + c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存上传文件失败: " + err.Error()}) + return + } + + // 2. 查服务器 + 构建 SSH 配置 + var servers []model.Server + if err := h.db.Where("id IN ?", serverIDs).Find(&servers).Error; err != nil || len(servers) == 0 { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "未找到目标服务器"}) + return + } + configs := make([]remoteexec.SSHConfig, 0, len(servers)) + for _, s := range servers { + cfg, err := buildSSHConfig(h.db, s) + if err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: fmt.Sprintf("服务器 %s 认证配置错误: %v", s.Hostname, err)}) + return + } + configs = append(configs, cfg) + } + + // 3. 并发 SCP 上传到所有目标主机 + results := remoteexec.BatchUpload(configs, tmpPath, remotePath, 5) + c.JSON(http.StatusOK, model.Response{Code: 200, Data: results}) +} + // ==================== 主机匹配 ==================== // MatchHosts 预览匹配的主机列表(从资产服务器表查询)// Logan.Li diff --git a/backend/router/routes.go b/backend/router/routes.go index 33e8846..1eda912 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -701,6 +701,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c { remoteExec.POST("/execute", remoteExecHandler.BatchExec) remoteExec.POST("/upload", remoteExecHandler.BatchUpload) + remoteExec.POST("/upload-file", remoteExecHandler.UploadFile) remoteExec.GET("/hosts", remoteExecHandler.MatchHosts) remoteExec.POST("/server/:id/exec", remoteExecHandler.ExecOnServer) } diff --git a/backend/seed/seed.go b/backend/seed/seed.go index 50c774f..e59f1af 100644 --- a/backend/seed/seed.go +++ b/backend/seed/seed.go @@ -167,7 +167,7 @@ func SeedData(db *gorm.DB) { // 自动化运维 {Name: "定时任务", Code: "auto:cron", Type: "menu", Sort: 50, Status: 1}, {Name: "脚本管理", Code: "auto:script", Type: "menu", Sort: 51, Status: 1}, - {Name: "Ansible", Code: "auto:ansible", Type: "menu", Sort: 52, Status: 1}, + {Name: "远程执行", Code: "auto:ansible", Type: "menu", Sort: 52, Status: 1}, // AIOps {Name: "智能助手", Code: "aiops:assistant", Type: "menu", Sort: 60, Status: 1}, {Name: "告警分析", Code: "aiops:analysis", Type: "menu", Sort: 61, Status: 1}, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1b2e5e3..1550b6a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,6 +51,7 @@ const PipelineRunDetail = lazy(() => import('@/components/deploy/PipelineRunDeta const DeployRBAC = lazy(() => import('@/components/deploy/DeployRBAC').then(m => ({ default: m.default }))); const UserGroupsManager = lazy(() => import('@/components/deploy/UserGroupsManager').then(m => ({ default: m.default }))); const CronJobManager = lazy(() => import('@/components/task/CronJobManager').then(m => ({ default: m.CronJobManager }))); +const RemoteExecPage = lazy(() => import('@/components/task/RemoteExecPage').then(m => ({ default: m.RemoteExecPage }))); const AIOpsAssistant = lazy(() => import('@/components/aiops/AIOpsAssistant').then(m => ({ default: m.AIOpsAssistant }))); const AIAlertAnalyzer = lazy(() => import('@/components/aiops/AIAlertAnalyzer').then(m => ({ default: m.AIAlertAnalyzer }))); const AIPredictiveAnalytics = lazy(() => import('@/components/aiops/AIPredictiveAnalytics').then(m => ({ default: m.AIPredictiveAnalytics }))); @@ -193,6 +194,7 @@ function App() { } /> } /> } /> + } /> {/* AIOps */} diff --git a/frontend/src/api/task/remote-exec.ts b/frontend/src/api/task/remote-exec.ts index 2665322..4679719 100644 --- a/frontend/src/api/task/remote-exec.ts +++ b/frontend/src/api/task/remote-exec.ts @@ -1,4 +1,5 @@ -// 作业任务 — 远程执行 API(/ansible + /task-executions)// Logan.Li +// 作业任务 — 任务执行记录 API(/task-executions)// Logan.Li +// 注:项目远程执行已由 Go-native SSH(pkg/remoteexec)承担,不再使用 Ansible,原 ansibleApi 已移除。 import { http, type ListResponse } from '@/utils/request'; @@ -9,57 +10,43 @@ export const taskExecutionApi = { }, }; -// Ansible / 远程执行 API -export const ansibleApi = { - // Playbook 管理 - getPlaybooks: async (params?: { page?: number; page_size?: number; keyword?: string }) => { - return http.get('/ansible/playbooks', params); - }, - getPlaybook: async (id: number | string) => { - return http.get(`/ansible/playbooks/${id}`); - }, - createPlaybook: async ( data: Record) => { - return http.post('/ansible/playbooks', data); - }, - updatePlaybook: async (id: number | string, data: Record) => { - return http.put(`/ansible/playbooks/${id}`, data); - }, - deletePlaybook: async (id: number | string) => { - return http.delete(`/ansible/playbooks/${id}`); - }, - // 批量删除 Playbook // Logan.Li - batchDeletePlaybooks: async (ids: number[]) => { - return http.delete('/ansible/playbooks/batch-delete', { ids }); - }, - 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('/ansible/match-hosts', params); - }, - // 任务执行记录 - getJobs: async (params?: { page?: number; page_size?: number; status?: string }) => { - return http.get('/ansible/jobs', params); - }, - getJob: async (id: number | string) => { - return http.get(`/ansible/jobs/${id}`); - }, - // 清单(Inventory)管理 - getInventories: async (params?: { page?: number; page_size?: number; keyword?: string }) => { - return http.get('/ansible/inventories', params); - }, - createInventory: async ( data: Record) => { - return http.post('/ansible/inventories', data); - }, - updateInventory: async (id: number | string, data: Record) => { - return http.put(`/ansible/inventories/${id}`, data); - }, - deleteInventory: async (id: number | string) => { - return http.delete(`/ansible/inventories/${id}`); +// 远程执行结果(Go-native SSH 批量执行返回) // Logan.Li +export interface RemoteExecResult { + host: string; + port: number; + output: string; + exit_code: number; + duration: number; + error?: string; + success: boolean; +} +export interface RemoteUploadResult { + host: string; + port: number; + local_path: string; + remote_path: string; + error?: string; + success: boolean; +} + +// 远程执行 API(Go-native SSH:批量执行命令 / 单机执行 / 文件上传 / 主机匹配) // Logan.Li +// 对接后端 /remote-exec/*(controller.RemoteExecHandler + pkg/remoteexec)。 +export const remoteExecApi = { + // 批量执行命令(多台主机并发) + execute: (data: { server_ids: number[]; command: string; timeout?: number }) => + http.post('/remote-exec/execute', data), + // 单机执行命令 + execOnServer: (id: number | string, data: { command: string; timeout?: number }) => + http.post(`/remote-exec/server/${id}/exec`, data), + // 上传文件到多台主机(multipart:file + server_ids + remote_path) + uploadFile: (data: { server_ids: number[]; remote_path: string; file: File }) => { + const fd = new FormData(); + fd.append('file', data.file); + fd.append('server_ids', JSON.stringify(data.server_ids)); + fd.append('remote_path', data.remote_path); + return http.upload('/remote-exec/upload-file', fd); }, + // 主机匹配预览(按 hostname/ip/tags) + matchHosts: (params?: { pattern?: string; match_type?: string; page?: number; page_size?: number }) => + http.get('/remote-exec/hosts', params), }; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 4bcb59f..c658560 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -136,11 +136,11 @@ const menuItems: MenuItem[] = [ ], }, { - id: 'automation', name: '作业任务', labelKey: 'menu.automation', icon: PlayCircle, permission: 'auto:cron', + id: 'task', name: '作业任务', labelKey: 'menu.automation', icon: PlayCircle, permission: 'auto:cron', children: [ { id: 'cronjobs', name: '定时任务', labelKey: 'menu.cronJobs', icon: PlayCircle, path: '/cronjobs', permission: 'auto:cron' }, { id: 'scripts', name: '脚本库', labelKey: 'menu.scripts', icon: FileCode, path: '/scripts', permission: 'auto:script' }, - { id: 'ansible', name: '远程执行', labelKey: 'menu.remoteExec', icon: Terminal, path: '/ansible', permission: 'auto:ansible' }, + { id: 'remote-exec', name: '远程执行', labelKey: 'menu.remoteExec', icon: Terminal, path: '/remote', permission: 'auto:ansible' }, ], }, { diff --git a/frontend/src/components/task/RemoteExecPage.tsx b/frontend/src/components/task/RemoteExecPage.tsx new file mode 100644 index 0000000..55dd8ec --- /dev/null +++ b/frontend/src/components/task/RemoteExecPage.tsx @@ -0,0 +1,251 @@ +// 远程执行页 — 关联主机后批量执行命令 / 上传文件(Go-native SSH) // Logan.Li +// 对接后端 /remote-exec/*(controller.RemoteExecHandler + pkg/remoteexec),替代已移除的 Ansible。 +import { useState, useEffect } from 'react'; +import { + Terminal, Upload, RefreshCw, Loader2, Server as ServerIcon, CheckCircle2, XCircle, FileUp, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; +import { useConfirm } from '@/components/ui/confirm-dialog'; +import { serverApi, remoteExecApi, type RemoteExecResult, type RemoteUploadResult } from '@/api'; +import type { JsonData } from '@/types/common'; + +interface RemoteServer { + id: number; hostname: string; ip: string; username: string; port: number; + auth_type: string; status: string; has_auth?: boolean; +} + +export function RemoteExecPage() { + const _confirm = useConfirm(); + const [servers, setServers] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [keyword, setKeyword] = useState(''); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState<'exec' | 'upload'>('exec'); + // 执行命令 + const [command, setCommand] = useState(''); + const [timeoutSec, setTimeoutSec] = useState(60); + // 上传文件 + const [file, setFile] = useState(null); + const [remotePath, setRemotePath] = useState('/tmp/'); + // 结果 + 运行态 + const [execResults, setExecResults] = useState([]); + const [uploadResults, setUploadResults] = useState([]); + const [running, setRunning] = useState(false); + + const load = async () => { + setLoading(true); + try { + const res = await serverApi.getServers({ page: 1, page_size: 500, keyword }); + setServers(((res as JsonData)?.list as RemoteServer[]) || []); + } catch { toast.error('加载服务器列表失败'); } + finally { setLoading(false); } + }; + useEffect(() => { load(); }, []); + + const selIds = [...selected]; + const selCount = selIds.length; + const toggle = (id: number) => { + const next = new Set(selected); + next.has(id) ? next.delete(id) : next.add(id); + setSelected(next); + }; + const toggleAll = () => { + setSelected(selected.size === servers.length ? new Set() : new Set(servers.map(s => s.id))); + }; + + // 批量执行命令(高危,需确认) + const doExec = async () => { + if (!command.trim()) { toast.error('请输入要执行的命令'); return; } + if (selCount === 0) { toast.error('请至少选择一台主机'); return; } + const ok = await _confirm({ + title: '确认批量执行命令', + description: `将在 ${selCount} 台主机上执行该命令,请确认命令安全:\n\n${command}`, + confirmText: '执行', variant: 'destructive', + }); + if (!ok) return; + setRunning(true); setExecResults([]); + try { + const res = await remoteExecApi.execute({ server_ids: selIds, command, timeout: timeoutSec }); + setExecResults(res || []); + const succ = (res || []).filter(r => r.success).length; + toast.success(`执行完成:${succ}/${selCount} 成功`); + } catch { toast.error('执行失败'); } + finally { setRunning(false); } + }; + + // 批量上传文件 + const doUpload = async () => { + if (!file) { toast.error('请选择要上传的文件'); return; } + if (!remotePath.trim()) { toast.error('请填写远程目标路径'); return; } + if (selCount === 0) { toast.error('请至少选择一台主机'); return; } + // 远程路径若是目录则追加文件名 + const dst = remotePath.trim().endsWith('/') ? remotePath.trim() + file.name : remotePath.trim(); + setRunning(true); setUploadResults([]); + try { + const res = await remoteExecApi.uploadFile({ server_ids: selIds, remote_path: dst, file }); + setUploadResults(res || []); + const succ = (res || []).filter(r => r.success).length; + toast.success(`上传完成:${succ}/${selCount} 成功`); + } catch { toast.error('上传失败'); } + finally { setRunning(false); } + }; + + return ( +
+ {/* 标题栏 */} +
+
+ + 远程执行 + — 关联主机后批量执行命令 / 上传文件(Go-native SSH) +
+ +
+ +
+ {/* 左侧:主机选择 */} + + + + 选择目标主机 + setKeyword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && load()} + className="h-7 w-40 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" + /> + + + + {loading ? ( +
+ 加载中… +
+ ) : servers.length === 0 ? ( +
暂无服务器,请先在「服务器管理」添加
+ ) : ( + <> +
+ 0} onCheckedChange={toggleAll} /> + 全选({servers.length} 台) + {selCount > 0 && 已选 {selCount}} +
+ {servers.map(s => ( + + ))} + + )} +
+
+ + {/* 右侧:操作 + 结果 */} +
+ {/* Tab 切换 */} +
+ + +
+ + {tab === 'exec' ? ( + + 批量执行命令 + +