feat(remote-exec): 新增远程执行页(执行/上传) + 移除ansible命名 + 主机监控bug修复
远程执行页 RemoteExecPage:关联主机多选 + 批量执行命令 + 批量上传文件(Go-native SSH),高危操作 useConfirm 确认;后端新增 multipart /remote-exec/upload-file 接口(接收文件→SCP 转发到多台主机)。 移除 ansible 命名:菜单 /ansible→/remote、组 id automation→task、子项 id ansible→remote-exec、种子显示名 Ansible→远程执行;删除 dead code ansibleApi。 主机监控 bug:installOne 不再强制 server.Status==running(默认 stopped 导致批量安装必失败);UninstallAgent 凭据判断修正(SSH key 主机 Password 为空不应误判为无凭据而跳过远程卸载)。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
0b2e43ea9e
commit
f820f0271f
8 files changed
+356
-61
No files matched your search
@@ -211,9 +211,7 @@ func (h *AgentInstallHandler) installOne(server model.Server, serverURL, token s
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
if server.Status != "running" {
|
// 不强制 server.Status==running:只要 SSH 可达即可安装(DB 状态可能滞后于实际开机状态) // Logan.Li
|
||||||
return false, "", "", fmt.Errorf("服务器未运行")
|
|
||||||
}
|
|
||||||
|
|
||||||
installCmd := fmt.Sprintf(
|
installCmd := fmt.Sprintf(
|
||||||
`curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token %s`,
|
`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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无 SSH 凭据的主机(自动注册的)直接改本地状态
|
// 无 SSH 凭据的主机(自动注册的)直接改本地状态;注意 SSH key 认证的主机 Password 为空但 SSHKey 非空,仍可远程卸载 // Logan.Li
|
||||||
if server.Username == "" || server.Password == "" {
|
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
|
||||||
h.db.Model(&server).Updates(map[string]interface{}{
|
h.db.Model(&server).Updates(map[string]interface{}{
|
||||||
"agent_status": "offline", "agent_version": "",
|
"agent_status": "offline", "agent_version": "",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -133,6 +134,61 @@ func (h *RemoteExecHandler) BatchUpload(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: results})
|
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
|
// MatchHosts 预览匹配的主机列表(从资产服务器表查询)// Logan.Li
|
||||||
|
|||||||
@@ -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("/execute", remoteExecHandler.BatchExec)
|
||||||
remoteExec.POST("/upload", remoteExecHandler.BatchUpload)
|
remoteExec.POST("/upload", remoteExecHandler.BatchUpload)
|
||||||
|
remoteExec.POST("/upload-file", remoteExecHandler.UploadFile)
|
||||||
remoteExec.GET("/hosts", remoteExecHandler.MatchHosts)
|
remoteExec.GET("/hosts", remoteExecHandler.MatchHosts)
|
||||||
remoteExec.POST("/server/:id/exec", remoteExecHandler.ExecOnServer)
|
remoteExec.POST("/server/:id/exec", remoteExecHandler.ExecOnServer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ func SeedData(db *gorm.DB) {
|
|||||||
// 自动化运维
|
// 自动化运维
|
||||||
{Name: "定时任务", Code: "auto:cron", Type: "menu", Sort: 50, Status: 1},
|
{Name: "定时任务", Code: "auto:cron", Type: "menu", Sort: 50, Status: 1},
|
||||||
{Name: "脚本管理", Code: "auto:script", Type: "menu", Sort: 51, 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
|
// AIOps
|
||||||
{Name: "智能助手", Code: "aiops:assistant", Type: "menu", Sort: 60, Status: 1},
|
{Name: "智能助手", Code: "aiops:assistant", Type: "menu", Sort: 60, Status: 1},
|
||||||
{Name: "告警分析", Code: "aiops:analysis", Type: "menu", Sort: 61, Status: 1},
|
{Name: "告警分析", Code: "aiops:analysis", Type: "menu", Sort: 61, Status: 1},
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const PipelineRunDetail = lazy(() => import('@/components/deploy/PipelineRunDeta
|
|||||||
const DeployRBAC = lazy(() => import('@/components/deploy/DeployRBAC').then(m => ({ default: m.default })));
|
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 UserGroupsManager = lazy(() => import('@/components/deploy/UserGroupsManager').then(m => ({ default: m.default })));
|
||||||
const CronJobManager = lazy(() => import('@/components/task/CronJobManager').then(m => ({ default: m.CronJobManager })));
|
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 AIOpsAssistant = lazy(() => import('@/components/aiops/AIOpsAssistant').then(m => ({ default: m.AIOpsAssistant })));
|
||||||
const AIAlertAnalyzer = lazy(() => import('@/components/aiops/AIAlertAnalyzer').then(m => ({ default: m.AIAlertAnalyzer })));
|
const AIAlertAnalyzer = lazy(() => import('@/components/aiops/AIAlertAnalyzer').then(m => ({ default: m.AIAlertAnalyzer })));
|
||||||
const AIPredictiveAnalytics = lazy(() => import('@/components/aiops/AIPredictiveAnalytics').then(m => ({ default: m.AIPredictiveAnalytics })));
|
const AIPredictiveAnalytics = lazy(() => import('@/components/aiops/AIPredictiveAnalytics').then(m => ({ default: m.AIPredictiveAnalytics })));
|
||||||
@@ -193,6 +194,7 @@ function App() {
|
|||||||
<Route path="approval/permission" element={<LazyPage><ApprovalPermissionPage /></LazyPage>} />
|
<Route path="approval/permission" element={<LazyPage><ApprovalPermissionPage /></LazyPage>} />
|
||||||
<Route path="cronjobs" element={<LazyPage><CronJobManager /></LazyPage>} />
|
<Route path="cronjobs" element={<LazyPage><CronJobManager /></LazyPage>} />
|
||||||
<Route path="scripts" element={<LazyPage><ScriptManager /></LazyPage>} />
|
<Route path="scripts" element={<LazyPage><ScriptManager /></LazyPage>} />
|
||||||
|
<Route path="remote" element={<LazyPage><RemoteExecPage /></LazyPage>} />
|
||||||
|
|
||||||
|
|
||||||
{/* AIOps */}
|
{/* AIOps */}
|
||||||
|
|||||||
@@ -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';
|
import { http, type ListResponse } from '@/utils/request';
|
||||||
|
|
||||||
@@ -9,57 +10,43 @@ export const taskExecutionApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ansible / 远程执行 API
|
// 远程执行结果(Go-native SSH 批量执行返回) // Logan.Li
|
||||||
export const ansibleApi = {
|
export interface RemoteExecResult {
|
||||||
// Playbook 管理
|
host: string;
|
||||||
getPlaybooks: async (params?: { page?: number; page_size?: number; keyword?: string }) => {
|
port: number;
|
||||||
return http.get<ListResponse>('/ansible/playbooks', params);
|
output: string;
|
||||||
},
|
exit_code: number;
|
||||||
getPlaybook: async (id: number | string) => {
|
duration: number;
|
||||||
return http.get(`/ansible/playbooks/${id}`);
|
error?: string;
|
||||||
},
|
success: boolean;
|
||||||
createPlaybook: async ( data: Record<string, unknown>) => {
|
}
|
||||||
return http.post('/ansible/playbooks', data);
|
export interface RemoteUploadResult {
|
||||||
},
|
host: string;
|
||||||
updatePlaybook: async (id: number | string, data: Record<string, unknown>) => {
|
port: number;
|
||||||
return http.put(`/ansible/playbooks/${id}`, data);
|
local_path: string;
|
||||||
},
|
remote_path: string;
|
||||||
deletePlaybook: async (id: number | string) => {
|
error?: string;
|
||||||
return http.delete(`/ansible/playbooks/${id}`);
|
success: boolean;
|
||||||
},
|
}
|
||||||
// 批量删除 Playbook // Logan.Li
|
|
||||||
batchDeletePlaybooks: async (ids: number[]) => {
|
// 远程执行 API(Go-native SSH:批量执行命令 / 单机执行 / 文件上传 / 主机匹配) // Logan.Li
|
||||||
return http.delete('/ansible/playbooks/batch-delete', { ids });
|
// 对接后端 /remote-exec/*(controller.RemoteExecHandler + pkg/remoteexec)。
|
||||||
},
|
export const remoteExecApi = {
|
||||||
executePlaybook: async (id: number | string, targetHosts?: string[]) => {
|
// 批量执行命令(多台主机并发)
|
||||||
return http.post(`/ansible/playbooks/${id}/execute`, targetHosts ? { target_hosts: targetHosts } : undefined);
|
execute: (data: { server_ids: number[]; command: string; timeout?: number }) =>
|
||||||
},
|
http.post<RemoteExecResult[]>('/remote-exec/execute', data),
|
||||||
// 克隆 Playbook 为自定义模板 // Logan.Li
|
// 单机执行命令
|
||||||
clonePlaybook: async (id: number | string, name?: string) => {
|
execOnServer: (id: number | string, data: { command: string; timeout?: number }) =>
|
||||||
return http.post(`/ansible/playbooks/${id}/clone`, name ? { name } : undefined);
|
http.post<RemoteExecResult>(`/remote-exec/server/${id}/exec`, data),
|
||||||
},
|
// 上传文件到多台主机(multipart:file + server_ids + remote_path)
|
||||||
// 主机匹配预览 // Logan.Li
|
uploadFile: (data: { server_ids: number[]; remote_path: string; file: File }) => {
|
||||||
matchHosts: async (params: { pattern?: string; match_type?: string; page?: number; page_size?: number }) => {
|
const fd = new FormData();
|
||||||
return http.get<ListResponse>('/ansible/match-hosts', params);
|
fd.append('file', data.file);
|
||||||
},
|
fd.append('server_ids', JSON.stringify(data.server_ids));
|
||||||
// 任务执行记录
|
fd.append('remote_path', data.remote_path);
|
||||||
getJobs: async (params?: { page?: number; page_size?: number; status?: string }) => {
|
return http.upload<RemoteUploadResult[]>('/remote-exec/upload-file', fd);
|
||||||
return http.get<ListResponse>('/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<ListResponse>('/ansible/inventories', params);
|
|
||||||
},
|
|
||||||
createInventory: async ( data: Record<string, unknown>) => {
|
|
||||||
return http.post('/ansible/inventories', data);
|
|
||||||
},
|
|
||||||
updateInventory: async (id: number | string, data: Record<string, unknown>) => {
|
|
||||||
return http.put(`/ansible/inventories/${id}`, data);
|
|
||||||
},
|
|
||||||
deleteInventory: async (id: number | string) => {
|
|
||||||
return http.delete(`/ansible/inventories/${id}`);
|
|
||||||
},
|
},
|
||||||
|
// 主机匹配预览(按 hostname/ip/tags)
|
||||||
|
matchHosts: (params?: { pattern?: string; match_type?: string; page?: number; page_size?: number }) =>
|
||||||
|
http.get<ListResponse>('/remote-exec/hosts', params),
|
||||||
};
|
};
|
||||||
@@ -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: [
|
children: [
|
||||||
{ id: 'cronjobs', name: '定时任务', labelKey: 'menu.cronJobs', icon: PlayCircle, path: '/cronjobs', permission: 'auto:cron' },
|
{ 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: '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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<RemoteServer[]>([]);
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(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<File | null>(null);
|
||||||
|
const [remotePath, setRemotePath] = useState('/tmp/');
|
||||||
|
// 结果 + 运行态
|
||||||
|
const [execResults, setExecResults] = useState<RemoteExecResult[]>([]);
|
||||||
|
const [uploadResults, setUploadResults] = useState<RemoteUploadResult[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--dg-text-primary)]">
|
||||||
|
<Terminal className="h-5 w-5 text-blue-400" />
|
||||||
|
<span className="text-lg font-medium">远程执行</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">— 关联主机后批量执行命令 / 上传文件(Go-native SSH)</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={load} className="h-8 text-xs gap-1">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-12 gap-4">
|
||||||
|
{/* 左侧:主机选择 */}
|
||||||
|
<Card className="col-span-5 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm flex items-center justify-between text-[var(--dg-text-primary)]">
|
||||||
|
<span className="flex items-center gap-2"><ServerIcon className="h-4 w-4 text-blue-400" />选择目标主机</span>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索 主机名/IP"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => 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)]"
|
||||||
|
/>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="max-h-[520px] overflow-y-auto space-y-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-10 text-[var(--dg-text-muted)]">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
|
||||||
|
</div>
|
||||||
|
) : servers.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-[var(--dg-text-muted)] text-xs">暂无服务器,请先在「服务器管理」添加</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
|
||||||
|
<Checkbox checked={selected.size === servers.length && servers.length > 0} onCheckedChange={toggleAll} />
|
||||||
|
全选({servers.length} 台)
|
||||||
|
{selCount > 0 && <Badge className="ml-auto bg-blue-500/20 text-blue-400">已选 {selCount}</Badge>}
|
||||||
|
</div>
|
||||||
|
{servers.map(s => (
|
||||||
|
<label key={s.id} className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-[var(--dg-bg-tertiary)] cursor-pointer">
|
||||||
|
<Checkbox checked={selected.has(s.id)} onCheckedChange={() => toggle(s.id)} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium text-[var(--dg-text-primary)] truncate">{s.hostname}</div>
|
||||||
|
<div className="text-[10px] text-[var(--dg-text-muted)] font-mono">{s.ip} · {s.username || 'root'}</div>
|
||||||
|
</div>
|
||||||
|
{s.has_auth === false
|
||||||
|
? <Badge className="bg-amber-500/20 text-amber-400 text-[10px]">无凭据</Badge>
|
||||||
|
: <Badge className="bg-emerald-500/20 text-emerald-400 text-[10px]">{s.auth_type === 'key' ? '密钥' : '密码'}</Badge>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 右侧:操作 + 结果 */}
|
||||||
|
<div className="col-span-7 space-y-4">
|
||||||
|
{/* Tab 切换 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant={tab === 'exec' ? 'default' : 'outline'} size="sm" onClick={() => setTab('exec')} className="h-8 text-xs gap-1">
|
||||||
|
<Terminal className="w-3.5 h-3.5" />执行命令
|
||||||
|
</Button>
|
||||||
|
<Button variant={tab === 'upload' ? 'default' : 'outline'} size="sm" onClick={() => setTab('upload')} className="h-8 text-xs gap-1">
|
||||||
|
<Upload className="w-3.5 h-3.5" />上传文件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'exec' ? (
|
||||||
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
|
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]">批量执行命令</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<textarea
|
||||||
|
value={command}
|
||||||
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder="输入要在选中主机上执行的 shell 命令,例如:df -h | head -5"
|
||||||
|
className="w-full bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md px-3 py-2 text-xs font-mono text-[var(--dg-text-primary)] resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-[var(--dg-text-secondary)]">超时(秒)</span>
|
||||||
|
<Input type="number" min={1} value={timeoutSec} onChange={(e) => setTimeoutSec(Number(e.target.value) || 60)}
|
||||||
|
className="h-8 w-24 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||||
|
<Button onClick={doExec} disabled={running} className="ml-auto bg-blue-600 hover:bg-blue-700 h-8 text-xs">
|
||||||
|
{running ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Terminal className="w-3.5 h-3.5 mr-1" />}
|
||||||
|
执行({selCount} 台)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
|
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]">批量上传文件</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-xs text-[var(--dg-text-secondary)]">选择本地文件</span>
|
||||||
|
<Input type="file" onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||||
|
className="h-8 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||||
|
{file && <span className="text-[10px] text-[var(--dg-text-muted)]">{file.name} ({(file.size / 1024).toFixed(1)} KB)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-xs text-[var(--dg-text-secondary)]">远程目标路径(目录则自动追加文件名)</span>
|
||||||
|
<Input value={remotePath} onChange={(e) => setRemotePath(e.target.value)} placeholder="/tmp/ 或 /opt/app/config.yaml"
|
||||||
|
className="h-8 text-xs font-mono bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||||
|
</div>
|
||||||
|
<Button onClick={doUpload} disabled={running} className="bg-blue-600 hover:bg-blue-700 h-8 text-xs">
|
||||||
|
{running ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileUp className="w-3.5 h-3.5 mr-1" />}
|
||||||
|
上传({selCount} 台)
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 结果区 */}
|
||||||
|
{(execResults.length > 0 || uploadResults.length > 0) && (
|
||||||
|
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||||
|
<CardHeader className="pb-3"><CardTitle className="text-sm text-[var(--dg-text-primary)]">执行结果</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{execResults.map((r, i) => (
|
||||||
|
<div key={i} className="p-2 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{r.success ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" /> : <XCircle className="w-3.5 h-3.5 text-red-400" />}
|
||||||
|
<span className="text-xs font-mono text-[var(--dg-text-primary)]">{r.host}</span>
|
||||||
|
<Badge className={r.success ? 'bg-emerald-500/20 text-emerald-400 text-[10px]' : 'bg-red-500/20 text-red-400 text-[10px]'}>
|
||||||
|
exit={r.exit_code} · {r.duration}ms
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] whitespace-pre-wrap break-all max-h-32 overflow-y-auto">
|
||||||
|
{r.error || r.output || '(无输出)'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{uploadResults.map((r, i) => (
|
||||||
|
<div key={i} className="p-2 rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{r.success ? <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" /> : <XCircle className="w-3.5 h-3.5 text-red-400" />}
|
||||||
|
<span className="text-xs font-mono text-[var(--dg-text-primary)]">{r.host}</span>
|
||||||
|
<span className="text-[10px] text-[var(--dg-text-muted)] truncate">{r.remote_path}</span>
|
||||||
|
{r.error && <span className="text-[10px] text-red-400 ml-auto">{r.error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user