mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
feat: add manual server binary upload and confirmation upgrade process
- Implemented UploadManualServerBinary endpoint for uploading server binaries and checking their versions. - Added ConfirmManualServerUpgrade endpoint to confirm the upgrade with the uploaded binary. - Updated API routes to include manual upload and upgrade confirmation. - Enhanced service layer to handle manual binary uploads, version detection, and upgrade execution. - Introduced new types for handling uploaded binary information. - Updated frontend components to support manual binary upload and confirmation, including UI feedback for users. - Modified documentation to reflect new manual upload and upgrade features.
This commit is contained in:
@@ -45,9 +45,9 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部
|
||||
* 反向代理规则管理:一个域名对应一个源站地址,统一维护、统一发布
|
||||
* 配置版本化:支持预览、发布、激活、历史回滚,版本不可变
|
||||
* 节点接入:支持全局 `discovery_token` 首次接入,也支持节点专属 `agent_token`
|
||||
* Agent 自动应用:周期性同步、落盘、`nginx -t`、`nginx -s reload`、失败自动回滚
|
||||
* Agent 自动应用:周期性同步、落盘、`openresty -t`、`openresty -s reload`、失败自动回滚
|
||||
* TLS 与域名管理:支持证书托管、域名资产维护、精确匹配与通配符匹配
|
||||
* 运维能力:配置变更摘要、Agent 运行参数下发、Agent 自更新、Server 自升级
|
||||
* 运维能力:配置变更摘要、Agent 运行参数下发、Agent 自更新、Server GitHub 自升级、Server 手动上传二进制确认升级
|
||||
* 管理端 UI:基于 Next.js App Router + React 19 + Tailwind CSS 4 的新版前端
|
||||
|
||||
## 界面预览
|
||||
|
||||
@@ -3,10 +3,15 @@ package controller
|
||||
import (
|
||||
"atsflare/service"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type confirmManualUpgradeRequest struct {
|
||||
UploadToken string `json:"upload_token"`
|
||||
}
|
||||
|
||||
// GetLatestRelease godoc
|
||||
// @Summary Get latest GitHub release
|
||||
// @Tags Update
|
||||
@@ -52,3 +57,86 @@ func UpgradeServer(c *gin.Context) {
|
||||
"data": release,
|
||||
})
|
||||
}
|
||||
|
||||
// UploadManualServerBinary godoc
|
||||
// @Summary Upload server binary and inspect version before upgrade
|
||||
// @Tags Update
|
||||
// @Accept mpfd
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/update/manual-upload [post]
|
||||
func UploadManualServerBinary(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("binary")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请先选择要上传的服务端二进制文件。",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "读取上传文件失败。",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
info, err := service.UploadManualServerBinary(c.Request.Context(), fileHeader.Filename, file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(info.ComparisonMessage)
|
||||
if message == "" {
|
||||
message = "已完成上传并检查升级包版本。"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": message,
|
||||
"data": info,
|
||||
})
|
||||
}
|
||||
|
||||
// ConfirmManualServerUpgrade godoc
|
||||
// @Summary Confirm upgrade with previously uploaded server binary
|
||||
// @Tags Update
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/update/manual-upgrade [post]
|
||||
func ConfirmManualServerUpgrade(c *gin.Context) {
|
||||
var request confirmManualUpgradeRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "升级确认参数无效。",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := service.ConfirmManualServerUpgrade(request.UploadToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "服务升级任务已启动,确认无误后将自动重启。",
|
||||
"data": info,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
||||
{
|
||||
updateRoute.GET("/latest-release", controller.GetLatestRelease)
|
||||
updateRoute.POST("/manual-upload", controller.UploadManualServerBinary)
|
||||
updateRoute.POST("/manual-upgrade", controller.ConfirmManualServerUpgrade)
|
||||
updateRoute.POST("/upgrade", controller.UpgradeServer)
|
||||
}
|
||||
fileRoute := apiRouter.Group("/file")
|
||||
|
||||
@@ -7,10 +7,13 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
@@ -105,3 +108,194 @@ func TestLatestReleaseProxy(t *testing.T) {
|
||||
t.Fatalf("unexpected current_version: %#v", data["current_version"])
|
||||
}
|
||||
}
|
||||
|
||||
func loginRootAndBuildEngine(t *testing.T) (*gin.Engine, []*http.Cookie) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
loginBody, err := json.Marshal(map[string]string{
|
||||
"username": "root",
|
||||
"password": "123456",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal login body: %v", err)
|
||||
}
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/user/login", bytes.NewReader(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(loginRecorder, loginReq)
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected login status code: %d", loginRecorder.Code)
|
||||
}
|
||||
|
||||
return engine, loginRecorder.Result().Cookies()
|
||||
}
|
||||
|
||||
func fakeManualServerBinary(version string) (string, []byte) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "atsflare-server-test.cmd", []byte("@echo off\r\necho " + version + "\r\n")
|
||||
}
|
||||
return "atsflare-server-test.sh", []byte("#!/bin/sh\necho " + version + "\n")
|
||||
}
|
||||
|
||||
func TestManualUploadRoute(t *testing.T) {
|
||||
originalVersion := common.Version
|
||||
common.Version = "v0.4.0"
|
||||
t.Cleanup(func() {
|
||||
common.Version = originalVersion
|
||||
service.SetServerBinaryUpgradeExecutorForTest(nil)
|
||||
service.SetServerUpgradeDispatchDelayForTest(500 * time.Millisecond)
|
||||
})
|
||||
|
||||
engine, cookies := loginRootAndBuildEngine(t)
|
||||
fileName, content := fakeManualServerBinary("v0.5.0")
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("binary", fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create form file: %v", err)
|
||||
}
|
||||
if _, err = part.Write(content); err != nil {
|
||||
t.Fatalf("failed to write upload content: %v", err)
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/update/manual-upload", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
for _, cookieValue := range cookies {
|
||||
req.AddCookie(cookieValue)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status code: %d", recorder.Code)
|
||||
}
|
||||
|
||||
var resp apiResponse
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("expected success response, got message: %s", resp.Message)
|
||||
}
|
||||
|
||||
var data map[string]any
|
||||
if err = json.Unmarshal(resp.Data, &data); err != nil {
|
||||
t.Fatalf("failed to decode response data: %v", err)
|
||||
}
|
||||
if data["detected_version"] != "v0.5.0" {
|
||||
t.Fatalf("unexpected detected_version: %#v", data["detected_version"])
|
||||
}
|
||||
if data["ready_to_upgrade"] != true {
|
||||
t.Fatalf("expected ready_to_upgrade to be true: %#v", data["ready_to_upgrade"])
|
||||
}
|
||||
if data["upload_token"] == "" {
|
||||
t.Fatal("expected upload_token to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualUpgradeConfirmRoute(t *testing.T) {
|
||||
originalVersion := common.Version
|
||||
originalExecutor := service.ServerBinaryUpgradeExecutorForTest()
|
||||
originalDelay := service.ServerUpgradeDispatchDelayForTest()
|
||||
common.Version = "v0.4.0"
|
||||
called := make(chan string, 1)
|
||||
service.SetServerBinaryUpgradeExecutorForTest(func(execPath string, tempPath string) error {
|
||||
called <- tempPath
|
||||
return nil
|
||||
})
|
||||
service.SetServerUpgradeDispatchDelayForTest(0)
|
||||
t.Cleanup(func() {
|
||||
common.Version = originalVersion
|
||||
service.SetServerBinaryUpgradeExecutorForTest(originalExecutor)
|
||||
service.SetServerUpgradeDispatchDelayForTest(originalDelay)
|
||||
})
|
||||
|
||||
engine, cookies := loginRootAndBuildEngine(t)
|
||||
fileName, content := fakeManualServerBinary("v0.5.0")
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("binary", fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create form file: %v", err)
|
||||
}
|
||||
if _, err = part.Write(content); err != nil {
|
||||
t.Fatalf("failed to write upload content: %v", err)
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
uploadReq := httptest.NewRequest(http.MethodPost, "/api/update/manual-upload", body)
|
||||
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
for _, cookieValue := range cookies {
|
||||
uploadReq.AddCookie(cookieValue)
|
||||
}
|
||||
|
||||
uploadRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(uploadRecorder, uploadReq)
|
||||
if uploadRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected upload status code: %d", uploadRecorder.Code)
|
||||
}
|
||||
|
||||
var uploadResp apiResponse
|
||||
if err = json.Unmarshal(uploadRecorder.Body.Bytes(), &uploadResp); err != nil {
|
||||
t.Fatalf("failed to decode upload response: %v", err)
|
||||
}
|
||||
if !uploadResp.Success {
|
||||
t.Fatalf("expected upload success, got message: %s", uploadResp.Message)
|
||||
}
|
||||
|
||||
var uploadData map[string]any
|
||||
if err = json.Unmarshal(uploadResp.Data, &uploadData); err != nil {
|
||||
t.Fatalf("failed to decode upload response data: %v", err)
|
||||
}
|
||||
uploadToken, _ := uploadData["upload_token"].(string)
|
||||
if uploadToken == "" {
|
||||
t.Fatal("expected upload token in upload response")
|
||||
}
|
||||
|
||||
confirmBody, err := json.Marshal(map[string]string{"upload_token": uploadToken})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal confirm body: %v", err)
|
||||
}
|
||||
confirmReq := httptest.NewRequest(http.MethodPost, "/api/update/manual-upgrade", bytes.NewReader(confirmBody))
|
||||
confirmReq.Header.Set("Content-Type", "application/json")
|
||||
for _, cookieValue := range cookies {
|
||||
confirmReq.AddCookie(cookieValue)
|
||||
}
|
||||
|
||||
confirmRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(confirmRecorder, confirmReq)
|
||||
if confirmRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected confirm status code: %d", confirmRecorder.Code)
|
||||
}
|
||||
|
||||
var confirmResp apiResponse
|
||||
if err = json.Unmarshal(confirmRecorder.Body.Bytes(), &confirmResp); err != nil {
|
||||
t.Fatalf("failed to decode confirm response: %v", err)
|
||||
}
|
||||
if !confirmResp.Success {
|
||||
t.Fatalf("expected confirm success, got message: %s", confirmResp.Message)
|
||||
}
|
||||
|
||||
select {
|
||||
case tempPath := <-called:
|
||||
if tempPath == "" {
|
||||
t.Fatal("expected manual upgrade executor to receive temp path")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected manual upgrade executor to be called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ package service
|
||||
import (
|
||||
"atsflare/common"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
@@ -28,6 +31,15 @@ var serverUpgradeState struct {
|
||||
inProgress bool
|
||||
}
|
||||
|
||||
var manualServerBinaryState struct {
|
||||
sync.Mutex
|
||||
candidate *manualServerBinaryCandidate
|
||||
}
|
||||
|
||||
var serverBinaryUpgradeExecutor = replaceAndRestartServer
|
||||
|
||||
var serverUpgradeDispatchDelay = 500 * time.Millisecond
|
||||
|
||||
type LatestServerRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Body string `json:"body"`
|
||||
@@ -58,6 +70,28 @@ type preparedServerUpgrade struct {
|
||||
execPath string
|
||||
}
|
||||
|
||||
type UploadedServerBinary struct {
|
||||
UploadToken string `json:"upload_token"`
|
||||
FileName string `json:"file_name"`
|
||||
DetectedVersion string `json:"detected_version"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
HasUpdate bool `json:"has_update"`
|
||||
UpgradeSupported bool `json:"upgrade_supported"`
|
||||
ReadyToUpgrade bool `json:"ready_to_upgrade"`
|
||||
ComparisonMessage string `json:"comparison_message"`
|
||||
UploadedAt time.Time `json:"uploaded_at"`
|
||||
}
|
||||
|
||||
type manualServerBinaryCandidate struct {
|
||||
UploadToken string
|
||||
FileName string
|
||||
DetectedVersion string
|
||||
CurrentVersion string
|
||||
TempPath string
|
||||
ExecPath string
|
||||
UploadedAt time.Time
|
||||
}
|
||||
|
||||
func GetLatestServerRelease(ctx context.Context) (*LatestServerRelease, error) {
|
||||
release, err := fetchLatestRelease(ctx)
|
||||
if err != nil {
|
||||
@@ -85,7 +119,7 @@ func ScheduleServerUpgrade() (*LatestServerRelease, error) {
|
||||
prepared.release.InProgress = true
|
||||
|
||||
go func(task *preparedServerUpgrade) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
time.Sleep(serverUpgradeDispatchDelay)
|
||||
if err := executeServerUpgrade(task); err != nil {
|
||||
log.Printf("server self-update failed: %v", err)
|
||||
serverUpgradeState.Lock()
|
||||
@@ -97,6 +131,123 @@ func ScheduleServerUpgrade() (*LatestServerRelease, error) {
|
||||
return prepared.release, nil
|
||||
}
|
||||
|
||||
func UploadManualServerBinary(ctx context.Context, fileName string, reader io.Reader) (*UploadedServerBinary, error) {
|
||||
serverUpgradeState.Lock()
|
||||
inProgress := serverUpgradeState.inProgress
|
||||
serverUpgradeState.Unlock()
|
||||
if inProgress {
|
||||
return nil, fmt.Errorf("服务升级已在执行中,请稍后再试")
|
||||
}
|
||||
if strings.TrimSpace(fileName) == "" {
|
||||
return nil, fmt.Errorf("缺少上传文件名")
|
||||
}
|
||||
if reader == nil {
|
||||
return nil, fmt.Errorf("缺少上传文件内容")
|
||||
}
|
||||
|
||||
tempPath, err := persistUploadedServerBinary(fileName, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
detectedVersion, err := detectUploadedServerBinaryVersion(ctx, tempPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currentVersion := strings.TrimSpace(common.Version)
|
||||
uploadedAt := time.Now()
|
||||
info := buildUploadedServerBinaryView(fileName, currentVersion, detectedVersion, uploadedAt)
|
||||
if !info.ReadyToUpgrade {
|
||||
_ = os.Remove(tempPath)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return nil, fmt.Errorf("获取当前服务程序路径失败: %v", err)
|
||||
}
|
||||
if err = verifyExecutableDirectoryWritable(execPath); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uploadToken, err := newUpgradeToken()
|
||||
if err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return nil, fmt.Errorf("生成升级令牌失败: %v", err)
|
||||
}
|
||||
|
||||
manualServerBinaryState.Lock()
|
||||
cleanupManualServerBinaryCandidateLocked()
|
||||
manualServerBinaryState.candidate = &manualServerBinaryCandidate{
|
||||
UploadToken: uploadToken,
|
||||
FileName: fileName,
|
||||
DetectedVersion: detectedVersion,
|
||||
CurrentVersion: currentVersion,
|
||||
TempPath: tempPath,
|
||||
ExecPath: execPath,
|
||||
UploadedAt: uploadedAt,
|
||||
}
|
||||
manualServerBinaryState.Unlock()
|
||||
|
||||
info.UploadToken = uploadToken
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func ConfirmManualServerUpgrade(uploadToken string) (*UploadedServerBinary, error) {
|
||||
uploadToken = strings.TrimSpace(uploadToken)
|
||||
if uploadToken == "" {
|
||||
return nil, fmt.Errorf("缺少升级令牌")
|
||||
}
|
||||
|
||||
serverUpgradeState.Lock()
|
||||
if serverUpgradeState.inProgress {
|
||||
serverUpgradeState.Unlock()
|
||||
return nil, fmt.Errorf("服务升级已在执行中,请稍后再试")
|
||||
}
|
||||
serverUpgradeState.Unlock()
|
||||
|
||||
manualServerBinaryState.Lock()
|
||||
candidate := manualServerBinaryState.candidate
|
||||
if candidate == nil {
|
||||
manualServerBinaryState.Unlock()
|
||||
return nil, fmt.Errorf("未找到待确认的上传升级包,请重新上传")
|
||||
}
|
||||
if candidate.UploadToken != uploadToken {
|
||||
manualServerBinaryState.Unlock()
|
||||
return nil, fmt.Errorf("升级令牌无效或已过期,请重新上传")
|
||||
}
|
||||
manualServerBinaryState.candidate = nil
|
||||
manualServerBinaryState.Unlock()
|
||||
|
||||
info := buildUploadedServerBinaryView(candidate.FileName, candidate.CurrentVersion, candidate.DetectedVersion, candidate.UploadedAt)
|
||||
info.UploadToken = candidate.UploadToken
|
||||
if !info.ReadyToUpgrade {
|
||||
_ = os.Remove(candidate.TempPath)
|
||||
return nil, fmt.Errorf("当前上传的二进制不满足升级条件")
|
||||
}
|
||||
|
||||
serverUpgradeState.Lock()
|
||||
serverUpgradeState.inProgress = true
|
||||
serverUpgradeState.Unlock()
|
||||
|
||||
go func(task *manualServerBinaryCandidate) {
|
||||
time.Sleep(serverUpgradeDispatchDelay)
|
||||
if err := executeManualServerUpgrade(task); err != nil {
|
||||
log.Printf("server manual upgrade failed: %v", err)
|
||||
serverUpgradeState.Lock()
|
||||
serverUpgradeState.inProgress = false
|
||||
serverUpgradeState.Unlock()
|
||||
_ = os.Remove(task.TempPath)
|
||||
}
|
||||
}(candidate)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil)
|
||||
if err != nil {
|
||||
@@ -244,7 +395,12 @@ func executeServerUpgrade(task *preparedServerUpgrade) error {
|
||||
}
|
||||
|
||||
common.SysLog("server self-update starting: from=" + strings.TrimSpace(common.Version) + " to=" + task.release.TagName)
|
||||
return replaceAndRestartServer(task.execPath, tmpPath)
|
||||
return serverBinaryUpgradeExecutor(task.execPath, tmpPath)
|
||||
}
|
||||
|
||||
func executeManualServerUpgrade(task *manualServerBinaryCandidate) error {
|
||||
common.SysLog("server manual self-update starting: from=" + strings.TrimSpace(task.CurrentVersion) + " to=" + strings.TrimSpace(task.DetectedVersion))
|
||||
return serverBinaryUpgradeExecutor(task.ExecPath, task.TempPath)
|
||||
}
|
||||
|
||||
func serverAssetName(goos string, goarch string) string {
|
||||
@@ -318,6 +474,109 @@ func parseVersionParts(version string) []int {
|
||||
return parts
|
||||
}
|
||||
|
||||
func buildUploadedServerBinaryView(fileName string, currentVersion string, detectedVersion string, uploadedAt time.Time) *UploadedServerBinary {
|
||||
upgradeSupported := isManualServerUpgradeSupported(currentVersion)
|
||||
hasUpdate := false
|
||||
comparisonMessage := ""
|
||||
|
||||
switch {
|
||||
case !upgradeSupported:
|
||||
comparisonMessage = "当前服务版本不支持手动升级确认流程"
|
||||
case normalizeVersion(currentVersion) == normalizeVersion(detectedVersion):
|
||||
comparisonMessage = "上传二进制与当前服务版本一致,无需升级"
|
||||
case isVersionNewer(currentVersion, detectedVersion):
|
||||
hasUpdate = true
|
||||
comparisonMessage = fmt.Sprintf("检测到可升级版本:%s -> %s", strings.TrimSpace(currentVersion), strings.TrimSpace(detectedVersion))
|
||||
default:
|
||||
comparisonMessage = "上传二进制版本不高于当前服务版本,已拒绝升级"
|
||||
}
|
||||
|
||||
return &UploadedServerBinary{
|
||||
FileName: strings.TrimSpace(fileName),
|
||||
DetectedVersion: strings.TrimSpace(detectedVersion),
|
||||
CurrentVersion: strings.TrimSpace(currentVersion),
|
||||
HasUpdate: hasUpdate,
|
||||
UpgradeSupported: upgradeSupported,
|
||||
ReadyToUpgrade: upgradeSupported && hasUpdate,
|
||||
ComparisonMessage: comparisonMessage,
|
||||
UploadedAt: uploadedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func isManualServerUpgradeSupported(currentVersion string) bool {
|
||||
normalized := strings.TrimSpace(strings.TrimPrefix(currentVersion, "v"))
|
||||
return normalized != "" && !strings.EqualFold(normalized, "dev")
|
||||
}
|
||||
|
||||
func persistUploadedServerBinary(fileName string, reader io.Reader) (string, error) {
|
||||
suffix := filepath.Ext(strings.TrimSpace(fileName))
|
||||
if runtime.GOOS == "windows" && suffix == "" {
|
||||
suffix = ".exe"
|
||||
}
|
||||
tempFile, err := os.CreateTemp("", "atsflare-server-manual-upgrade-*"+suffix)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建临时升级文件失败: %v", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
if _, err = io.Copy(tempFile, reader); err != nil {
|
||||
_ = tempFile.Close()
|
||||
_ = os.Remove(tempPath)
|
||||
return "", fmt.Errorf("写入上传二进制失败: %v", err)
|
||||
}
|
||||
if err = tempFile.Close(); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return "", fmt.Errorf("关闭临时升级文件失败: %v", err)
|
||||
}
|
||||
if err = os.Chmod(tempPath, 0o755); err != nil && runtime.GOOS != "windows" {
|
||||
_ = os.Remove(tempPath)
|
||||
return "", fmt.Errorf("设置临时升级文件权限失败: %v", err)
|
||||
}
|
||||
return tempPath, nil
|
||||
}
|
||||
|
||||
func detectUploadedServerBinaryVersion(ctx context.Context, filePath string) (string, error) {
|
||||
commandCtx := ctx
|
||||
if commandCtx == nil {
|
||||
commandCtx = context.Background()
|
||||
}
|
||||
cmd := exec.CommandContext(commandCtx, filePath, "--version")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("检查上传二进制版本失败: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
version := strings.TrimSpace(string(output))
|
||||
if version == "" {
|
||||
return "", fmt.Errorf("上传二进制未返回有效版本号")
|
||||
}
|
||||
for _, line := range strings.Split(version, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
return trimmed, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("上传二进制未返回有效版本号")
|
||||
}
|
||||
|
||||
func cleanupManualServerBinaryCandidateLocked() {
|
||||
if manualServerBinaryState.candidate == nil {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(manualServerBinaryState.candidate.TempPath)
|
||||
manualServerBinaryState.candidate = nil
|
||||
}
|
||||
|
||||
func newUpgradeToken() (string, error) {
|
||||
buffer := make([]byte, 16)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buffer), nil
|
||||
}
|
||||
|
||||
func normalizeVersion(version string) string {
|
||||
return strings.TrimSpace(strings.TrimPrefix(version, "v"))
|
||||
}
|
||||
|
||||
func UpdateHTTPClientForTest() *http.Client {
|
||||
return updateHTTPClient
|
||||
}
|
||||
@@ -325,3 +584,26 @@ func UpdateHTTPClientForTest() *http.Client {
|
||||
func SetUpdateHTTPClientForTest(client *http.Client) {
|
||||
updateHTTPClient = client
|
||||
}
|
||||
|
||||
func ServerBinaryUpgradeExecutorForTest() func(string, string) error {
|
||||
return serverBinaryUpgradeExecutor
|
||||
}
|
||||
|
||||
func SetServerBinaryUpgradeExecutorForTest(executor func(string, string) error) {
|
||||
if executor == nil {
|
||||
serverBinaryUpgradeExecutor = replaceAndRestartServer
|
||||
return
|
||||
}
|
||||
serverBinaryUpgradeExecutor = executor
|
||||
}
|
||||
|
||||
func ServerUpgradeDispatchDelayForTest() time.Duration {
|
||||
return serverUpgradeDispatchDelay
|
||||
}
|
||||
|
||||
func SetServerUpgradeDispatchDelayForTest(delay time.Duration) {
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
serverUpgradeDispatchDelay = delay
|
||||
}
|
||||
|
||||
@@ -2,9 +2,31 @@ package service
|
||||
|
||||
import (
|
||||
"atsflare/common"
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resetServerUpgradeTestState(t *testing.T) {
|
||||
t.Helper()
|
||||
serverUpgradeState.Lock()
|
||||
serverUpgradeState.inProgress = false
|
||||
serverUpgradeState.Unlock()
|
||||
manualServerBinaryState.Lock()
|
||||
cleanupManualServerBinaryCandidateLocked()
|
||||
manualServerBinaryState.Unlock()
|
||||
}
|
||||
|
||||
func fakeServerBinaryFixture(version string) (string, []byte) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "atsflare-server-test.cmd", []byte("@echo off\r\necho " + version + "\r\n")
|
||||
}
|
||||
return "atsflare-server-test.sh", []byte("#!/bin/sh\necho " + version + "\n")
|
||||
}
|
||||
|
||||
func TestIsVersionNewer(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -85,3 +107,109 @@ func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) {
|
||||
t.Fatal("expected dev build not to support self-upgrade")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadManualServerBinary(t *testing.T) {
|
||||
originalVersion := common.Version
|
||||
common.Version = "v0.4.0"
|
||||
t.Cleanup(func() {
|
||||
common.Version = originalVersion
|
||||
resetServerUpgradeTestState(t)
|
||||
})
|
||||
|
||||
fileName, content := fakeServerBinaryFixture("v0.5.0")
|
||||
info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("expected upload to succeed: %v", err)
|
||||
}
|
||||
if !info.ReadyToUpgrade {
|
||||
t.Fatal("expected uploaded binary to be ready for upgrade")
|
||||
}
|
||||
if info.UploadToken == "" {
|
||||
t.Fatal("expected upload token to be returned")
|
||||
}
|
||||
if info.DetectedVersion != "v0.5.0" {
|
||||
t.Fatalf("unexpected detected version: %s", info.DetectedVersion)
|
||||
}
|
||||
|
||||
manualServerBinaryState.Lock()
|
||||
candidate := manualServerBinaryState.candidate
|
||||
manualServerBinaryState.Unlock()
|
||||
if candidate == nil {
|
||||
t.Fatal("expected manual upgrade candidate to be stored")
|
||||
}
|
||||
if _, err := os.Stat(candidate.TempPath); err != nil {
|
||||
t.Fatalf("expected temporary binary to exist: %v", err)
|
||||
}
|
||||
if candidate.UploadToken != info.UploadToken {
|
||||
t.Fatalf("unexpected stored upload token: %s", candidate.UploadToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadManualServerBinaryRejectsSameVersion(t *testing.T) {
|
||||
originalVersion := common.Version
|
||||
common.Version = "v0.5.0"
|
||||
t.Cleanup(func() {
|
||||
common.Version = originalVersion
|
||||
resetServerUpgradeTestState(t)
|
||||
})
|
||||
|
||||
fileName, content := fakeServerBinaryFixture("v0.5.0")
|
||||
info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("expected upload to succeed: %v", err)
|
||||
}
|
||||
if info.ReadyToUpgrade {
|
||||
t.Fatal("expected same-version upload not to be upgradeable")
|
||||
}
|
||||
if info.UploadToken != "" {
|
||||
t.Fatal("expected same-version upload not to issue a token")
|
||||
}
|
||||
|
||||
manualServerBinaryState.Lock()
|
||||
defer manualServerBinaryState.Unlock()
|
||||
if manualServerBinaryState.candidate != nil {
|
||||
t.Fatal("expected no pending manual upgrade candidate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmManualServerUpgrade(t *testing.T) {
|
||||
originalVersion := common.Version
|
||||
originalExecutor := ServerBinaryUpgradeExecutorForTest()
|
||||
originalDelay := ServerUpgradeDispatchDelayForTest()
|
||||
common.Version = "v0.4.0"
|
||||
called := make(chan string, 1)
|
||||
SetServerBinaryUpgradeExecutorForTest(func(execPath string, tempPath string) error {
|
||||
called <- tempPath
|
||||
return nil
|
||||
})
|
||||
SetServerUpgradeDispatchDelayForTest(0)
|
||||
t.Cleanup(func() {
|
||||
common.Version = originalVersion
|
||||
SetServerBinaryUpgradeExecutorForTest(originalExecutor)
|
||||
SetServerUpgradeDispatchDelayForTest(originalDelay)
|
||||
resetServerUpgradeTestState(t)
|
||||
})
|
||||
|
||||
fileName, content := fakeServerBinaryFixture("v0.5.0")
|
||||
info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("expected upload to succeed: %v", err)
|
||||
}
|
||||
|
||||
confirmed, err := ConfirmManualServerUpgrade(info.UploadToken)
|
||||
if err != nil {
|
||||
t.Fatalf("expected confirm to succeed: %v", err)
|
||||
}
|
||||
if confirmed.UploadToken != info.UploadToken {
|
||||
t.Fatalf("unexpected confirmed upload token: %s", confirmed.UploadToken)
|
||||
}
|
||||
|
||||
select {
|
||||
case tempPath := <-called:
|
||||
if tempPath == "" {
|
||||
t.Fatal("expected upgrade executor to receive temp path")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected manual upgrade executor to be called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,14 @@ import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import { getLatestRelease, upgradeServer } from '@/features/update/api/update';
|
||||
import {
|
||||
confirmManualServerUpgrade,
|
||||
getLatestRelease,
|
||||
upgradeServer,
|
||||
uploadServerBinary,
|
||||
} from '@/features/update/api/update';
|
||||
import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal';
|
||||
import type { UploadedServerBinaryInfo } from '@/features/update/types';
|
||||
import { publicEnv } from '@/lib/env/public-env';
|
||||
import { useAppShellStore } from '@/store/app-shell';
|
||||
|
||||
@@ -26,6 +32,14 @@ export function DashboardTopbar() {
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||
const [versionFeedback, setVersionFeedback] = useState<string | null>(null);
|
||||
const [manualUpgradeStatus, setManualUpgradeStatus] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [manualUpgradeError, setManualUpgradeError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [uploadedBinary, setUploadedBinary] =
|
||||
useState<UploadedServerBinaryInfo | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const isRoot = (user?.role ?? 0) >= 100;
|
||||
|
||||
@@ -44,6 +58,9 @@ export function DashboardTopbar() {
|
||||
const upgradeMutation = useMutation({
|
||||
mutationFn: upgradeServer,
|
||||
onSuccess: (release) => {
|
||||
setUploadedBinary(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
setVersionFeedback(
|
||||
`服务升级任务已启动,目标版本 ${release.tag_name}。页面可能短暂不可用。`,
|
||||
);
|
||||
@@ -56,6 +73,44 @@ export function DashboardTopbar() {
|
||||
},
|
||||
});
|
||||
|
||||
const uploadBinaryMutation = useMutation({
|
||||
mutationFn: uploadServerBinary,
|
||||
onSuccess: (candidate) => {
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeError(null);
|
||||
setUploadedBinary(candidate);
|
||||
setManualUpgradeStatus(candidate.comparison_message);
|
||||
},
|
||||
onError: (error) => {
|
||||
setUploadedBinary(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(
|
||||
error instanceof Error ? error.message : '上传升级包失败,请稍后重试。',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const confirmManualUpgradeMutation = useMutation({
|
||||
mutationFn: confirmManualServerUpgrade,
|
||||
onSuccess: (candidate) => {
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeError(null);
|
||||
setUploadedBinary(candidate);
|
||||
setManualUpgradeStatus(
|
||||
`手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`,
|
||||
);
|
||||
void latestReleaseQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '确认手动升级失败,请稍后重试。',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserMenuOpen) {
|
||||
return;
|
||||
@@ -100,6 +155,8 @@ export function DashboardTopbar() {
|
||||
|
||||
const handleOpenVersionModal = () => {
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
setIsVersionModalOpen(true);
|
||||
if (isRoot) {
|
||||
void latestReleaseQuery.refetch();
|
||||
@@ -108,9 +165,29 @@ export function DashboardTopbar() {
|
||||
|
||||
const handleUpgrade = () => {
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
upgradeMutation.mutate();
|
||||
};
|
||||
|
||||
const handleUploadBinary = (binary: File) => {
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
uploadBinaryMutation.mutate(binary);
|
||||
};
|
||||
|
||||
const handleConfirmManualUpgrade = () => {
|
||||
if (!uploadedBinary?.upload_token) {
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError('请先上传并检查升级包。');
|
||||
return;
|
||||
}
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token);
|
||||
};
|
||||
|
||||
const release = latestReleaseQuery.data;
|
||||
const hasUpdate = Boolean(isRoot && release?.has_update);
|
||||
const currentVersion = publicStatusQuery.data?.version || 'unknown';
|
||||
@@ -127,6 +204,7 @@ export function DashboardTopbar() {
|
||||
? latestReleaseQuery.error.message
|
||||
: '版本检查失败,请稍后重试。'
|
||||
: undefined);
|
||||
const manualUpgradeErrorMessage = manualUpgradeError ?? undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -206,11 +284,16 @@ export function DashboardTopbar() {
|
||||
frontendVersion={publicEnv.appVersion}
|
||||
startTime={publicStatusQuery.data?.start_time}
|
||||
release={release}
|
||||
uploadedBinary={uploadedBinary}
|
||||
isLoading={latestReleaseQuery.isLoading && !release && isRoot}
|
||||
errorMessage={versionErrorMessage}
|
||||
releaseErrorMessage={versionErrorMessage}
|
||||
manualStatusMessage={manualUpgradeStatus ?? undefined}
|
||||
manualErrorMessage={manualUpgradeErrorMessage}
|
||||
canUpgrade={isRoot}
|
||||
isChecking={latestReleaseQuery.isFetching}
|
||||
isUpgrading={upgradeMutation.isPending}
|
||||
isUploadingBinary={uploadBinaryMutation.isPending}
|
||||
isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending}
|
||||
onRefresh={() => {
|
||||
setVersionFeedback(null);
|
||||
if (isRoot) {
|
||||
@@ -218,6 +301,8 @@ export function DashboardTopbar() {
|
||||
}
|
||||
}}
|
||||
onUpgrade={handleUpgrade}
|
||||
onUploadBinary={handleUploadBinary}
|
||||
onConfirmManualUpgrade={handleConfirmManualUpgrade}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type { LatestReleaseInfo } from '@/features/update/types';
|
||||
import type {
|
||||
LatestReleaseInfo,
|
||||
UploadedServerBinaryInfo,
|
||||
} from '@/features/update/types';
|
||||
|
||||
export function getLatestRelease() {
|
||||
return apiRequest<LatestReleaseInfo>('/update/latest-release');
|
||||
@@ -11,3 +14,20 @@ export function upgradeServer() {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadServerBinary(binary: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('binary', binary);
|
||||
|
||||
return apiRequest<UploadedServerBinaryInfo>('/update/manual-upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmManualServerUpgrade(uploadToken: string) {
|
||||
return apiRequest<UploadedServerBinaryInfo>('/update/manual-upgrade', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ upload_token: uploadToken }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { marked } from 'marked';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
@@ -8,9 +9,14 @@ import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import type { LatestReleaseInfo } from '@/features/update/types';
|
||||
import type {
|
||||
LatestReleaseInfo,
|
||||
UploadedServerBinaryInfo,
|
||||
} from '@/features/update/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
@@ -22,13 +28,20 @@ interface VersionUpgradeModalProps {
|
||||
frontendVersion: string;
|
||||
startTime?: number;
|
||||
release: LatestReleaseInfo | null | undefined;
|
||||
uploadedBinary: UploadedServerBinaryInfo | null;
|
||||
isLoading: boolean;
|
||||
errorMessage?: string;
|
||||
releaseErrorMessage?: string;
|
||||
manualStatusMessage?: string;
|
||||
manualErrorMessage?: string;
|
||||
canUpgrade: boolean;
|
||||
isChecking: boolean;
|
||||
isUpgrading: boolean;
|
||||
isUploadingBinary: boolean;
|
||||
isConfirmingManualUpgrade: boolean;
|
||||
onRefresh: () => void;
|
||||
onUpgrade: () => void;
|
||||
onUploadBinary: (file: File) => void;
|
||||
onConfirmManualUpgrade: () => void;
|
||||
}
|
||||
|
||||
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
|
||||
@@ -51,33 +64,75 @@ export function VersionUpgradeModal({
|
||||
frontendVersion,
|
||||
startTime,
|
||||
release,
|
||||
uploadedBinary,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
releaseErrorMessage,
|
||||
manualStatusMessage,
|
||||
manualErrorMessage,
|
||||
canUpgrade,
|
||||
isChecking,
|
||||
isUpgrading,
|
||||
isUploadingBinary,
|
||||
isConfirmingManualUpgrade,
|
||||
onRefresh,
|
||||
onUpgrade,
|
||||
onUploadBinary,
|
||||
onConfirmManualUpgrade,
|
||||
}: VersionUpgradeModalProps) {
|
||||
const upgradeBadge = getUpgradeBadge(release);
|
||||
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedBinary(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="版本"
|
||||
description="在这里检查 GitHub 最新版本,并直接触发 Server 自升级。升级开始后服务会短暂重启。"
|
||||
description="在这里检查 GitHub 最新版本,或手动上传 Server 二进制并确认升级。升级开始后服务会短暂重启。"
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking || isUpgrading}
|
||||
>
|
||||
{isChecking ? '检查中...' : '检查更新'}
|
||||
</SecondaryButton>
|
||||
{canUpgrade ? (
|
||||
canUpgrade ? (
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking || isUpgrading || isUploadingBinary}
|
||||
>
|
||||
{isChecking ? '检查中...' : '检查更新'}
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedBinary) {
|
||||
onUploadBinary(selectedBinary);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!selectedBinary ||
|
||||
isUploadingBinary ||
|
||||
isConfirmingManualUpgrade
|
||||
}
|
||||
>
|
||||
{isUploadingBinary ? '上传检查中...' : '上传并检查'}
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onConfirmManualUpgrade}
|
||||
disabled={
|
||||
!uploadedBinary?.ready_to_upgrade ||
|
||||
!uploadedBinary.upload_token ||
|
||||
isConfirmingManualUpgrade ||
|
||||
isUploadingBinary ||
|
||||
isUpgrading
|
||||
}
|
||||
>
|
||||
{isConfirmingManualUpgrade ? '升级中...' : '确认手动升级'}
|
||||
</PrimaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onUpgrade}
|
||||
@@ -85,7 +140,9 @@ export function VersionUpgradeModal({
|
||||
!release?.has_update ||
|
||||
release.in_progress ||
|
||||
isUpgrading ||
|
||||
!release.upgrade_supported
|
||||
!release.upgrade_supported ||
|
||||
isUploadingBinary ||
|
||||
isConfirmingManualUpgrade
|
||||
}
|
||||
>
|
||||
{isUpgrading
|
||||
@@ -94,8 +151,8 @@ export function VersionUpgradeModal({
|
||||
? '升级中...'
|
||||
: '立即升级'}
|
||||
</PrimaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
@@ -129,16 +186,16 @@ export function VersionUpgradeModal({
|
||||
</div>
|
||||
|
||||
{isLoading ? <LoadingState /> : null}
|
||||
{!isLoading && errorMessage ? (
|
||||
<ErrorState title="版本检查失败" description={errorMessage} />
|
||||
{!isLoading && releaseErrorMessage ? (
|
||||
<ErrorState title="版本检查失败" description={releaseErrorMessage} />
|
||||
) : null}
|
||||
{!isLoading && !errorMessage && !release ? (
|
||||
{!isLoading && !releaseErrorMessage && !release ? (
|
||||
<EmptyState
|
||||
title="尚未检查更新"
|
||||
description="点击“检查更新”后会在这里展示最新 GitHub Release 信息。"
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !errorMessage && release ? (
|
||||
{!isLoading && !releaseErrorMessage && release ? (
|
||||
<AppCard
|
||||
title={`GitHub Release · ${release.tag_name}`}
|
||||
description={
|
||||
@@ -182,6 +239,113 @@ export function VersionUpgradeModal({
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
{canUpgrade ? (
|
||||
<AppCard
|
||||
title="手动升级"
|
||||
description="上传服务端二进制后,服务端会先检查版本,再由你确认是否执行升级。"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<ResourceField
|
||||
label="服务端二进制"
|
||||
hint="支持上传已编译好的当前平台 Server 可执行文件。"
|
||||
>
|
||||
<ResourceInput
|
||||
type="file"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
setSelectedBinary(file);
|
||||
}}
|
||||
disabled={isUploadingBinary || isConfirmingManualUpgrade}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
{manualErrorMessage ? (
|
||||
<ErrorState
|
||||
title="手动升级检查失败"
|
||||
description={manualErrorMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!manualErrorMessage && manualStatusMessage ? (
|
||||
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-3 text-sm text-[var(--status-warning-foreground)]">
|
||||
{manualStatusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{uploadedBinary ? (
|
||||
<div className="space-y-4 rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
uploadedBinary.ready_to_upgrade
|
||||
? '可确认升级'
|
||||
: uploadedBinary.has_update
|
||||
? '待确认'
|
||||
: '不可升级'
|
||||
}
|
||||
variant={
|
||||
uploadedBinary.ready_to_upgrade ? 'warning' : 'info'
|
||||
}
|
||||
/>
|
||||
{!uploadedBinary.upgrade_supported ? (
|
||||
<StatusBadge
|
||||
label="当前版本不支持手动升级"
|
||||
variant="danger"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
文件名
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.file_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
上传时间
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.uploaded_at
|
||||
? formatDateTime(uploadedBinary.uploaded_at)
|
||||
: '未知'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
当前版本
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.current_version}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
上传版本
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{uploadedBinary.detected_version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{uploadedBinary.comparison_message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="尚未上传升级包"
|
||||
description="上传后会在这里展示识别出的版本信息,并允许你确认升级。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
|
||||
@@ -8,3 +8,15 @@ export interface LatestReleaseInfo {
|
||||
upgrade_supported: boolean;
|
||||
in_progress: boolean;
|
||||
}
|
||||
|
||||
export interface UploadedServerBinaryInfo {
|
||||
upload_token: string;
|
||||
file_name: string;
|
||||
detected_version: string;
|
||||
current_version: string;
|
||||
has_update: boolean;
|
||||
upgrade_supported: boolean;
|
||||
ready_to_upgrade: boolean;
|
||||
comparison_message: string;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ docker compose up -d
|
||||
* Root 用户可在管理端顶栏点击「版本」检查 GitHub 最新 Release
|
||||
* 当前运行的是 Release 二进制且二进制目录可写时,可直接在弹窗内触发 Server 自升级
|
||||
* 自升级会下载匹配当前平台的 `atsflare-server-*` 资产,替换当前二进制并自动重启进程
|
||||
* 也可在同一弹窗中手动上传 Server 二进制,服务端先检测上传文件版本,前端确认后再执行替换与重启
|
||||
|
||||
### 2.4 首次登录
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部
|
||||
* 配置变更摘要
|
||||
* Agent 运行参数下发
|
||||
* Agent 自我更新与一键部署
|
||||
* Server 版本检查与自升级
|
||||
* Server 版本检查、GitHub 自升级与手动上传二进制确认升级
|
||||
* 新版管理端 UI、主题切换与统一交互框架
|
||||
|
||||
默认工作方式:
|
||||
|
||||
Reference in New Issue
Block a user