diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index b75e9d8..88fc4b9 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -112,6 +112,12 @@ jobs: upx --best --lzma gost-amd64 upx --best --lzma gost-arm64 + - name: Generate SHA256 checksums + working-directory: ./go-gost + run: | + sha256sum gost-amd64 > gost-amd64.sha256 + sha256sum gost-arm64 > gost-arm64.sha256 + - name: Upload GOST AMD64 artifact uses: actions/upload-artifact@v4 with: @@ -124,6 +130,18 @@ jobs: name: gost-binary-arm64 path: ./go-gost/gost-arm64 + - name: Upload GOST AMD64 checksum artifact + uses: actions/upload-artifact@v4 + with: + name: gost-checksum-amd64 + path: ./go-gost/gost-amd64.sha256 + + - name: Upload GOST ARM64 checksum artifact + uses: actions/upload-artifact@v4 + with: + name: gost-checksum-arm64 + path: ./go-gost/gost-arm64.sha256 + build-vite: name: Build & Push Vite Frontend needs: check-version @@ -238,7 +256,20 @@ jobs: name: gost-binary-arm64 path: ./artifacts/arm64 + - name: Download GOST AMD64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-amd64 + path: ./artifacts/ + + - name: Download GOST ARM64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-arm64 + path: ./artifacts/ + - name: Prepare release files + run: | VERSION="${{ needs.check-version.outputs.version }}" OWNER="${{ needs.check-version.outputs.image_owner }}" @@ -331,6 +362,10 @@ jobs: gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber + echo "📤 上传 GOST 校验文件..." + gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber + gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber + echo "📤 上传安装脚本..." gh release upload "${VERSION}" ./artifacts/install.sh --clobber gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber @@ -363,6 +398,18 @@ jobs: name: gost-binary-arm64 path: ./artifacts/arm64 + - name: Download GOST AMD64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-amd64 + path: ./artifacts/ + + - name: Download GOST ARM64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-arm64 + path: ./artifacts/ + - name: Rename binaries run: | mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64 @@ -379,4 +426,9 @@ jobs: gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber + echo "📤 上传 GOST 校验文件..." + gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber + gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber + echo "✅ GOST 二进制文件更新完成" + diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 9319956..cc03eb6 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -105,6 +105,10 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/node/update-order", h.nodeUpdateOrder) mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete) mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus) + mux.HandleFunc("/api/v1/node/upgrade", h.nodeUpgrade) + mux.HandleFunc("/api/v1/node/batch-upgrade", h.nodeBatchUpgrade) + mux.HandleFunc("/api/v1/node/rollback", h.nodeRollback) + mux.HandleFunc("/api/v1/node/releases", h.listReleases) mux.HandleFunc("/api/v1/tunnel/list", h.tunnelList) mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate) mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet) diff --git a/go-backend/internal/http/handler/upgrade.go b/go-backend/internal/http/handler/upgrade.go new file mode 100644 index 0000000..1087b86 --- /dev/null +++ b/go-backend/internal/http/handler/upgrade.go @@ -0,0 +1,258 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "go-backend/internal/http/response" +) + +func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req struct { + ID int64 `json:"id"` + Version string `json:"version"` + } + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if req.ID <= 0 { + response.WriteJSON(w, response.ErrDefault("节点ID无效")) + return + } + + version := strings.TrimSpace(req.Version) + if version == "" { + var err error + version, err = resolveLatestRelease() + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err))) + return + } + } + + downloadURL := fmt.Sprintf( + "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}", + version, + ) + checksumURL := fmt.Sprintf( + "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}.sha256", + version, + ) + + result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{ + "downloadUrl": downloadURL, + "checksumUrl": checksumURL, + }, 120*time.Second) + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("升级失败: %v", err))) + return + } + + response.WriteJSON(w, response.OK(map[string]interface{}{ + "version": version, + "message": result.Message, + })) +} + +func resolveLatestRelease() (string, error) { + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: 10 * time.Second, + } + + resp, err := client.Get("https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/latest") + if err != nil { + return "", fmt.Errorf("请求GitHub失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently { + return resolveLatestReleaseAPI() + } + + location := resp.Header.Get("Location") + if location == "" { + return resolveLatestReleaseAPI() + } + + parts := strings.Split(location, "/") + tag := parts[len(parts)-1] + if tag == "" || tag == "latest" { + return resolveLatestReleaseAPI() + } + + return tag, nil +} + +func resolveLatestReleaseAPI() (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get("https://gcode.hostcentral.cc/https://api.github.com/repos/Sagit-chu/flux-panel/releases/latest") + if err != nil { + return "", fmt.Errorf("请求GitHub API失败: %v", err) + } + defer resp.Body.Close() + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("解析GitHub API响应失败: %v", err) + } + if strings.TrimSpace(release.TagName) == "" { + return "", fmt.Errorf("无法从GitHub获取最新版本号") + } + + return release.TagName, nil +} + +func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req struct { + IDs []int64 `json:"ids"` + Version string `json:"version"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if len(req.IDs) == 0 { + response.WriteJSON(w, response.ErrDefault("ids不能为空")) + return + } + + version := strings.TrimSpace(req.Version) + if version == "" { + var err error + version, err = resolveLatestRelease() + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err))) + return + } + } + + downloadURL := fmt.Sprintf( + "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}", + version, + ) + checksumURL := fmt.Sprintf( + "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}.sha256", + version, + ) + + type upgradeResult struct { + ID int64 `json:"id"` + Success bool `json:"success"` + Message string `json:"message"` + } + + results := make([]upgradeResult, 0, len(req.IDs)) + for _, id := range req.IDs { + result, err := h.wsServer.SendCommand(id, "UpgradeAgent", map[string]interface{}{ + "downloadUrl": downloadURL, + "checksumUrl": checksumURL, + }, 120*time.Second) + if err != nil { + results = append(results, upgradeResult{ID: id, Success: false, Message: err.Error()}) + } else { + results = append(results, upgradeResult{ID: id, Success: true, Message: result.Message}) + } + } + + response.WriteJSON(w, response.OK(map[string]interface{}{ + "version": version, + "results": results, + })) +} + +func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get("https://gcode.hostcentral.cc/https://api.github.com/repos/Sagit-chu/flux-panel/releases?per_page=20") + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err))) + return + } + defer resp.Body.Close() + + var releases []struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + PublishedAt string `json:"published_at"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + } + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err))) + return + } + + type releaseItem struct { + Version string `json:"version"` + Name string `json:"name"` + PublishedAt string `json:"publishedAt"` + Prerelease bool `json:"prerelease"` + } + + items := make([]releaseItem, 0, len(releases)) + for _, r := range releases { + if r.Draft { + continue + } + items = append(items, releaseItem{ + Version: r.TagName, + Name: r.Name, + PublishedAt: r.PublishedAt, + Prerelease: r.Prerelease, + }) + } + + response.WriteJSON(w, response.OK(items)) +} + +func (h *Handler) nodeRollback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req struct { + ID int64 `json:"id"` + } + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if req.ID <= 0 { + response.WriteJSON(w, response.ErrDefault("节点ID无效")) + return + } + + result, err := h.wsServer.SendCommand(req.ID, "RollbackAgent", map[string]interface{}{}, 30*time.Second) + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("回退失败: %v", err))) + return + } + + response.WriteJSON(w, response.OK(map[string]interface{}{ + "message": result.Message, + })) +} diff --git a/go-backend/internal/ws/server.go b/go-backend/internal/ws/server.go index f36ec97..e1d35da 100644 --- a/go-backend/internal/ws/server.go +++ b/go-backend/internal/ws/server.go @@ -190,7 +190,15 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64 msg := decryptIfNeeded(payload, secret) s.tryResolvePending(nodeID, msg) - s.broadcastInfo(nodeID, msg) + + var parsed struct { + Type string `json:"type"` + } + if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type == "UpgradeProgress" { + s.broadcastTyped(nodeID, "upgrade_progress", msg) + } else { + s.broadcastInfo(nodeID, msg) + } } } @@ -385,6 +393,12 @@ func (s *Server) broadcastInfo(nodeID int64, data string) { s.broadcastToAdmins(string(raw)) } +func (s *Server) broadcastTyped(nodeID int64, msgType string, data string) { + payload := broadcastMessage{ID: nodeID, Type: msgType, Data: data} + raw, _ := json.Marshal(payload) + s.broadcastToAdmins(string(raw)) +} + func (s *Server) broadcastToAdmins(message string) { s.mu.RLock() admins := make([]*connWrap, 0, len(s.admins)) diff --git a/go-gost/x/socket/websocket_reporter.go b/go-gost/x/socket/websocket_reporter.go index 9a5817f..63fb854 100644 --- a/go-gost/x/socket/websocket_reporter.go +++ b/go-gost/x/socket/websocket_reporter.go @@ -4,13 +4,21 @@ import ( "bytes" "compress/gzip" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "io" "net" + "net/http" "net/url" + "os" + "os/exec" + "runtime" "strconv" "strings" "sync" // 新增:用于管理连接状态的互斥锁 + "syscall" "time" "github.com/go-gost/x/config" @@ -21,7 +29,6 @@ import ( "github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/mem" psnet "github.com/shirou/gopsutil/v3/net" - "os" ) // SystemInfo 系统信息结构体 @@ -579,6 +586,18 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) { response.Type = "SetProtocolResponse" needSaveConfig = true + // 升级 Agent 命令(异步执行,不需要保存配置) + case "UpgradeAgent": + err = w.handleUpgradeAgent(cmd.Data) + response.Type = "UpgradeAgentResponse" + // needSaveConfig = false (默认值) + + // 回退 Agent 到旧版本 + case "RollbackAgent": + err = w.handleRollbackAgent(cmd.Data) + response.Type = "RollbackAgentResponse" + // needSaveConfig = false (默认值) + default: err = fmt.Errorf("未知命令类型: %s", cmd.Type) response.Type = "UnknownCommandResponse" @@ -881,6 +900,186 @@ func (w *WebSocketReporter) handleSetProtocol(data interface{}) error { return nil } +// sendUpgradeProgress 通过 WS 发送升级进度消息 +func (w *WebSocketReporter) sendUpgradeProgress(stage string, percent int, message string) { + response := CommandResponse{ + Type: "UpgradeProgress", + Success: true, + Message: message, + Data: map[string]interface{}{ + "stage": stage, + "percent": percent, + }, + } + w.sendResponse(response) +} + +func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error { + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("序列化数据失败: %v", err) + } + + var req struct { + DownloadURL string `json:"downloadUrl"` + ChecksumURL string `json:"checksumUrl"` + } + if err := json.Unmarshal(jsonData, &req); err != nil { + return fmt.Errorf("解析升级参数失败: %v", err) + } + if strings.TrimSpace(req.DownloadURL) == "" { + return fmt.Errorf("下载地址不能为空") + } + + // 替换架构占位符 + downloadURL := strings.ReplaceAll(req.DownloadURL, "{ARCH}", runtime.GOARCH) + checksumURL := strings.ReplaceAll(req.ChecksumURL, "{ARCH}", runtime.GOARCH) + + w.sendUpgradeProgress("downloading", 0, "开始下载升级包...") + fmt.Printf("📦 开始下载升级包: %s\n", downloadURL) + + // 下载新版本二进制 + const binaryPath = "/etc/flux_agent/flux_agent" + tmpPath := binaryPath + ".new" + backupPath := binaryPath + ".old" + + resp, err := http.Get(downloadURL) + if err != nil { + return fmt.Errorf("下载升级包失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("下载升级包失败, HTTP状态码: %d", resp.StatusCode) + } + + outFile, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("创建临时文件失败: %v", err) + } + + // 带进度的下载 + totalSize := resp.ContentLength + var downloaded int64 + buf := make([]byte, 32*1024) + lastPercent := 0 + hasher := sha256.New() + + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + if _, wErr := outFile.Write(buf[:n]); wErr != nil { + outFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("写入升级包失败: %v", wErr) + } + hasher.Write(buf[:n]) + downloaded += int64(n) + if totalSize > 0 { + percent := int(downloaded * 100 / totalSize) + if percent-lastPercent >= 10 { + lastPercent = percent + w.sendUpgradeProgress("downloading", percent, fmt.Sprintf("下载中... %d%%", percent)) + } + } + } + if readErr != nil { + if readErr == io.EOF { + break + } + outFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("读取升级包失败: %v", readErr) + } + } + outFile.Close() + + if downloaded == 0 { + os.Remove(tmpPath) + return fmt.Errorf("下载的升级包为空") + } + + w.sendUpgradeProgress("downloading", 100, fmt.Sprintf("下载完成 (%d bytes)", downloaded)) + + // Checksum 校验 + if checksumURL != "" { + w.sendUpgradeProgress("verifying", 0, "校验文件完整性...") + checksumResp, err := http.Get(checksumURL) + if err == nil { + defer checksumResp.Body.Close() + if checksumResp.StatusCode == http.StatusOK { + checksumBody, err := io.ReadAll(checksumResp.Body) + if err == nil { + // 格式: " " 或 "" + expectedHash := strings.TrimSpace(strings.Split(string(checksumBody), " ")[0]) + actualHash := hex.EncodeToString(hasher.Sum(nil)) + if !strings.EqualFold(expectedHash, actualHash) { + os.Remove(tmpPath) + return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash) + } + fmt.Printf("✅ Checksum 校验通过: %s\n", actualHash) + } + } + } + w.sendUpgradeProgress("verifying", 100, "校验通过") + } + + if err := os.Chmod(tmpPath, 0755); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("设置执行权限失败: %v", err) + } + + // 备份旧版本 + w.sendUpgradeProgress("installing", 50, "备份旧版本...") + if _, err := os.Stat(binaryPath); err == nil { + // 复制旧文件作为备份(不用 rename,因为可能正在运行) + oldData, err := os.ReadFile(binaryPath) + if err == nil { + _ = os.WriteFile(backupPath, oldData, 0755) + fmt.Println("📦 旧版本已备份到", backupPath) + } + } + + w.sendUpgradeProgress("installing", 80, "准备重启...") + fmt.Printf("✅ 升级包下载完成 (%d bytes), 准备重启...\n", downloaded) + + // 执行重启脚本(分离进程,不阻塞当前进程) + script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && mv %s %s && systemctl start flux_agent", tmpPath, binaryPath) + cmd := exec.Command("/bin/sh", "-c", script) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("启动重启脚本失败: %v", err) + } + + w.sendUpgradeProgress("installing", 100, "重启中...") + fmt.Println("🔄 重启脚本已启动, Agent 将在 1 秒后重启...") + return nil +} + +func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error { + const binaryPath = "/etc/flux_agent/flux_agent" + backupPath := binaryPath + ".old" + + // 检查备份文件是否存在 + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + return fmt.Errorf("没有可用的备份文件,无法回退") + } + + fmt.Println("🔄 开始回退到旧版本...") + + // 执行回退脚本 + script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && cp %s %s && systemctl start flux_agent", backupPath, binaryPath) + cmd := exec.Command("/bin/sh", "-c", script) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动回退脚本失败: %v", err) + } + + fmt.Println("🔄 回退脚本已启动, Agent 将在 1 秒后重启...") + return nil +} + // updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error { path := "config.json" diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index bd8c71e..9dd7cd8 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -41,6 +41,14 @@ export const checkNodeStatus = (nodeId?: number) => { return Network.post("/node/check-status", params); }; +export const upgradeNode = (id: number, version?: string) => + Network.post("/node/upgrade", { id, version: version || "" }); +export const batchUpgradeNodes = (ids: number[], version?: string) => + Network.post("/node/batch-upgrade", { ids, version: version || "" }); +export const getNodeReleases = () => Network.post("/node/releases"); +export const rollbackNode = (id: number) => + Network.post("/node/rollback", { id }); + // 隧道CRUD操作 - 全部使用POST请求 export const createTunnel = (data: any) => Network.post("/tunnel/create", data); export const getTunnelList = () => Network.post("/tunnel/list"); diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 225e8cf..63d7730 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner"; import { Alert } from "@heroui/alert"; import { Progress } from "@heroui/progress"; import { Accordion, AccordionItem } from "@heroui/accordion"; +import { Select, SelectItem } from "@heroui/select"; import { Checkbox } from "@heroui/checkbox"; import toast from "react-hot-toast"; import axios from "axios"; @@ -45,6 +46,10 @@ import { getNodeInstallCommand, updateNodeOrder, batchDeleteNodes, + upgradeNode, + batchUpgradeNodes, + getNodeReleases, + rollbackNode, } from "@/api"; interface Node { @@ -77,6 +82,8 @@ interface Node { uptime: number; } | null; copyLoading?: boolean; + upgradeLoading?: boolean; + rollbackLoading?: boolean; } interface NodeForm { @@ -165,6 +172,16 @@ export default function NodePage() { const [installCommand, setInstallCommand] = useState(""); const [currentNodeName, setCurrentNodeName] = useState(""); + // 升级相关状态 + const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); + const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">("single"); + const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState(null); + const [releases, setReleases] = useState>([]); + const [releasesLoading, setReleasesLoading] = useState(false); + const [selectedVersion, setSelectedVersion] = useState(""); + const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false); + const [upgradeProgress, setUpgradeProgress] = useState>({}); + const websocketRef = useRef(null); const reconnectTimerRef = useRef(null); const reconnectAttemptsRef = useRef(0); @@ -425,6 +442,22 @@ export default function NodePage() { return node; }), ); + } else if (type === "upgrade_progress") { + try { + const progressData = typeof messageData === "string" ? JSON.parse(messageData) : messageData; + if (progressData?.data) { + setUpgradeProgress((prev) => ({ + ...prev, + [nodeId]: { + stage: progressData.data.stage || "", + percent: progressData.data.percent || 0, + message: progressData.message || "", + }, + })); + } + } catch { + // ignore parse errors + } } }; @@ -770,6 +803,93 @@ export default function NodePage() { } }; + + + // 打开版本选择弹窗 + const openUpgradeModal = async (target: "single" | "batch", nodeId?: number) => { + setUpgradeTarget(target); + setUpgradeTargetNodeId(nodeId || null); + setSelectedVersion(""); + setUpgradeModalOpen(true); + setReleasesLoading(true); + try { + const res = await getNodeReleases(); + if (res.code === 0 && Array.isArray(res.data)) { + setReleases(res.data); + } else { + toast.error(res.msg || "获取版本列表失败"); + } + } catch { + toast.error("获取版本列表失败"); + } finally { + setReleasesLoading(false); + } + }; + + // 确认升级(从版本弹窗) + const handleConfirmUpgrade = async () => { + const version = selectedVersion || undefined; + if (upgradeTarget === "single" && upgradeTargetNodeId) { + setUpgradeModalOpen(false); + // Find the node + const node = nodeList.find((n) => n.id === upgradeTargetNodeId); + if (!node) return; + setNodeList((prev) => + prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n)), + ); + try { + const res = await upgradeNode(upgradeTargetNodeId, version); + if (res.code === 0) { + toast.success(`节点升级命令已发送,节点将自动重启`); + } else { + toast.error(res.msg || "升级失败"); + } + } catch { + toast.error("网络错误,请重试"); + } finally { + setNodeList((prev) => + prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n)), + ); + } + } else if (upgradeTarget === "batch") { + setBatchUpgradeLoading(true); + setUpgradeModalOpen(false); + try { + const res = await batchUpgradeNodes(Array.from(selectedIds), version); + if (res.code === 0) { + toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`); + } else { + toast.error(res.msg || "批量升级失败"); + } + } catch { + toast.error("网络错误,请重试"); + } finally { + setBatchUpgradeLoading(false); + } + } + }; + + // 回退节点 + const handleRollbackNode = async (node: Node) => { + setNodeList((prev) => + prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)), + ); + try { + const res = await rollbackNode(node.id); + if (res.code === 0) { + toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`); + } else { + toast.error(res.msg || "回退失败"); + } + } catch { + toast.error("网络错误,请重试"); + } finally { + setNodeList((prev) => + prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: false } : n)), + ); + } + }; + // 提交表单 const handleSubmit = async () => { if (!validateForm()) return; @@ -1048,6 +1168,15 @@ export default function NodePage() { + + + + + + + )} + + + {/* 批量删除确认模态框 */}