From f50eb9adee7f22a6067b1c040530b3a789c64496 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 12 Mar 2026 11:25:13 +0800 Subject: [PATCH] feat: add support for release channels in version upgrade and node agent updates - Introduced ReleaseChannel type to manage stable and preview releases. - Updated DashboardTopbar to handle version upgrades based on selected release channel. - Enhanced node detail page to allow manual checks for agent updates on stable and preview channels. - Modified API endpoints to support fetching and upgrading based on release channels. - Updated UI components to reflect changes in version checking and upgrade processes. - Added tests for new functionality related to preview releases and agent updates. --- README.md | 2 +- atsf_agent/internal/agent/runner.go | 30 +- atsf_agent/internal/protocol/agent_api.go | 2 + atsf_agent/internal/updater/updater.go | 278 +++++++++++++++-- atsf_agent/internal/updater/updater_test.go | 91 ++++++ atsf_server/controller/node.go | 56 +++- atsf_server/controller/update.go | 20 +- atsf_server/model/node.go | 2 + atsf_server/router/api-router.go | 1 + atsf_server/router/update_test.go | 8 +- atsf_server/service/agent.go | 12 +- atsf_server/service/node.go | 82 ++++- atsf_server/service/node_update_test.go | 117 +++++++ atsf_server/service/update.go | 294 +++++++++++++++--- atsf_server/service/update_test.go | 35 ++- .../components/layout/dashboard-topbar.tsx | 97 ++++-- atsf_server/web/features/nodes/api/nodes.ts | 110 ++++--- .../nodes/components/node-detail-page.tsx | 286 +++++++++++++++-- atsf_server/web/features/nodes/types.ts | 81 +++-- atsf_server/web/features/nodes/utils.ts | 4 + atsf_server/web/features/update/api/update.ts | 10 +- .../components/version-upgrade-modal.tsx | 55 +++- atsf_server/web/features/update/types.ts | 4 + docs/deployment.md | 6 +- docs/design.md | 4 +- 25 files changed, 1448 insertions(+), 239 deletions(-) create mode 100644 atsf_agent/internal/updater/updater_test.go create mode 100644 atsf_server/service/node_update_test.go diff --git a/README.md b/README.md index 96e12e4f..e4409559 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部 * 节点接入:支持全局 `discovery_token` 首次接入,也支持节点专属 `agent_token` * Agent 自动应用:周期性同步、落盘、`openresty -t`、`openresty -s reload`、失败自动回滚 * TLS 与域名管理:支持证书托管、域名资产维护、精确匹配与通配符匹配 -* 运维能力:配置变更摘要、Agent 运行参数下发、Agent 自更新、Server GitHub 自升级、Server 手动上传二进制确认升级 +* 运维能力:配置变更摘要、Agent 运行参数下发、Agent 正式版自动更新与 preview 手动升级、Server 正式版 GitHub 自升级、Server preview 手动检查升级、Server 手动上传二进制确认升级 * 管理端 UI:基于 Next.js App Router + React 19 + Tailwind CSS 4 的新版前端 ## 界面预览 diff --git a/atsf_agent/internal/agent/runner.go b/atsf_agent/internal/agent/runner.go index 954ec6a7..add1bef0 100644 --- a/atsf_agent/internal/agent/runner.go +++ b/atsf_agent/internal/agent/runner.go @@ -24,7 +24,13 @@ type SyncService interface { } type Updater interface { - CheckAndUpdate(ctx context.Context, repo string) error + CheckAndUpdate(ctx context.Context, repo string, options UpdateOptions) error +} + +type UpdateOptions struct { + Channel string + TagName string + Force bool } type Runner struct { @@ -37,6 +43,8 @@ type Runner struct { autoUpdate bool updateNow bool updateRepo string + updateChan string + updateTag string } func (r *Runner) Run(ctx context.Context) error { @@ -133,18 +141,34 @@ func (r *Runner) applySettings(settings *protocol.AgentSettings) bool { r.autoUpdate = settings.AutoUpdate r.updateNow = settings.UpdateNow r.updateRepo = strings.TrimSpace(settings.UpdateRepo) + r.updateChan = strings.TrimSpace(settings.UpdateChannel) + r.updateTag = strings.TrimSpace(settings.UpdateTag) return changed } func (r *Runner) tryAutoUpdate(ctx context.Context) { - shouldCheck := r.autoUpdate || r.updateNow + force := r.updateNow + shouldCheck := r.autoUpdate || force r.updateNow = false + r.updateTag = strings.TrimSpace(r.updateTag) if !shouldCheck || r.Updater == nil || r.updateRepo == "" { return } - if err := r.Updater.CheckAndUpdate(ctx, r.updateRepo); err != nil { + channel := "stable" + if force && r.updateChan != "" { + channel = r.updateChan + } + if err := r.Updater.CheckAndUpdate(ctx, r.updateRepo, UpdateOptions{ + Channel: channel, + TagName: r.updateTag, + Force: force, + }); err != nil { log.Printf("agent update check failed: %v", err) } + if force { + r.updateTag = "" + r.updateChan = "" + } } func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error { diff --git a/atsf_agent/internal/protocol/agent_api.go b/atsf_agent/internal/protocol/agent_api.go index 8169872f..a10f1065 100644 --- a/atsf_agent/internal/protocol/agent_api.go +++ b/atsf_agent/internal/protocol/agent_api.go @@ -19,6 +19,8 @@ type AgentSettings struct { AutoUpdate bool `json:"auto_update"` UpdateRepo string `json:"update_repo"` UpdateNow bool `json:"update_now"` + UpdateChannel string `json:"update_channel"` + UpdateTag string `json:"update_tag"` } type NodePayload struct { diff --git a/atsf_agent/internal/updater/updater.go b/atsf_agent/internal/updater/updater.go index 2f325dea..368885e5 100644 --- a/atsf_agent/internal/updater/updater.go +++ b/atsf_agent/internal/updater/updater.go @@ -9,15 +9,17 @@ import ( "net/http" "os" "runtime" + "strconv" "strings" "time" + "atsflare-agent/internal/agent" "atsflare-agent/internal/config" ) type Service struct { httpClient *http.Client - lastCheckTag string + lastCheckKey string } func New() *Service { @@ -27,8 +29,10 @@ func New() *Service { } type githubRelease struct { - TagName string `json:"tag_name"` - Assets []githubAsset `json:"assets"` + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + Assets []githubAsset `json:"assets"` } type githubAsset struct { @@ -36,8 +40,8 @@ type githubAsset struct { BrowserDownloadURL string `json:"browser_download_url"` } -func (s *Service) CheckAndUpdate(ctx context.Context, repo string) error { - release, err := s.getLatestRelease(ctx, repo) +func (s *Service) CheckAndUpdate(ctx context.Context, repo string, options agent.UpdateOptions) error { + release, err := s.getRelease(ctx, repo, options) if err != nil { return fmt.Errorf("check latest release: %w", err) } @@ -47,12 +51,16 @@ func (s *Service) CheckAndUpdate(ctx context.Context, repo string) error { remoteVersion := normalizeVersion(release.TagName) localVersion := normalizeVersion(config.AgentVersion) + checkKey := buildReleaseCheckKey(options, remoteVersion) - if remoteVersion == localVersion || remoteVersion == s.lastCheckTag { + if remoteVersion == localVersion { + return nil + } + if !options.Force && checkKey != "" && checkKey == s.lastCheckKey { return nil } if !isNewer(localVersion, remoteVersion) { - s.lastCheckTag = remoteVersion + s.lastCheckKey = checkKey return nil } @@ -67,7 +75,7 @@ func (s *Service) CheckAndUpdate(ctx context.Context, repo string) error { } } if downloadURL == "" { - s.lastCheckTag = remoteVersion + s.lastCheckKey = checkKey return fmt.Errorf("no matching asset %q in release %s", assetName, release.TagName) } @@ -78,10 +86,22 @@ func (s *Service) CheckAndUpdate(ctx context.Context, repo string) error { if err = s.downloadAndRestart(ctx, downloadURL, execPath); err != nil { return fmt.Errorf("download and restart: %w", err) } + s.lastCheckKey = checkKey return nil } -func (s *Service) getLatestRelease(ctx context.Context, repo string) (*githubRelease, error) { +func (s *Service) getRelease(ctx context.Context, repo string, options agent.UpdateOptions) (*githubRelease, error) { + tagName := strings.TrimSpace(options.TagName) + if tagName != "" { + return s.getReleaseByTag(ctx, repo, tagName) + } + if strings.EqualFold(strings.TrimSpace(options.Channel), "preview") { + return s.getLatestPreviewRelease(ctx, repo) + } + return s.getLatestStableRelease(ctx, repo) +} + +func (s *Service) getLatestStableRelease(ctx context.Context, repo string) (*githubRelease, error) { url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -102,8 +122,68 @@ func (s *Service) getLatestRelease(ctx context.Context, repo string) (*githubRel return nil, fmt.Errorf("github api returned %s", resp.Status) } + return decodeRelease(resp.Body) +} + +func (s *Service) getLatestPreviewRelease(ctx context.Context, repo string) (*githubRelease, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=20", repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github api returned %s", resp.Status) + } + + var releases []githubRelease + if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, err + } + for _, release := range releases { + if release.Draft || !release.Prerelease { + continue + } + releaseCopy := release + return &releaseCopy, nil + } + return nil, nil +} + +func (s *Service) getReleaseByTag(ctx context.Context, repo string, tag string) (*githubRelease, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", repo, strings.TrimSpace(tag)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github api returned %s", resp.Status) + } + + return decodeRelease(resp.Body) +} + +func decodeRelease(reader io.Reader) (*githubRelease, error) { var release githubRelease - if err = json.NewDecoder(resp.Body).Decode(&release); err != nil { + if err := json.NewDecoder(reader).Decode(&release); err != nil { return nil, err } return &release, nil @@ -158,26 +238,160 @@ func normalizeVersion(v string) string { } func isNewer(local, remote string) bool { - localParts := strings.Split(local, ".") - remoteParts := strings.Split(remote, ".") - maxLen := len(localParts) - if len(remoteParts) > maxLen { - maxLen = len(remoteParts) - } - for i := 0; i < maxLen; i++ { - lp, rp := "0", "0" - if i < len(localParts) { - lp = localParts[i] - } - if i < len(remoteParts) { - rp = remoteParts[i] - } - if rp > lp { - return true - } - if rp < lp { - return false - } - } - return false + return compareVersions(local, remote) < 0 +} + +func buildReleaseCheckKey(options agent.UpdateOptions, remoteVersion string) string { + channel := strings.TrimSpace(options.Channel) + if channel == "" { + channel = "stable" + } + if tagName := strings.TrimSpace(options.TagName); tagName != "" { + return channel + ":" + tagName + } + return channel + ":" + remoteVersion +} + +type versionInfo struct { + valid bool + isDev bool + numbers []int + prerelease []string +} + +func parseVersionInfo(version string) versionInfo { + normalized := normalizeVersion(version) + if normalized == "" || strings.EqualFold(normalized, "dev") { + return versionInfo{isDev: strings.EqualFold(normalized, "dev")} + } + base := normalized + prerelease := "" + if index := strings.IndexRune(normalized, '-'); index >= 0 { + base = normalized[:index] + prerelease = normalized[index+1:] + } + segments := strings.Split(base, ".") + parts := make([]int, 0, len(segments)) + for _, segment := range segments { + segment = strings.TrimSpace(segment) + if segment == "" { + parts = append(parts, 0) + continue + } + numeric := strings.Builder{} + for _, r := range segment { + if r < '0' || r > '9' { + break + } + numeric.WriteRune(r) + } + if numeric.Len() == 0 { + return versionInfo{} + } + value, err := strconv.Atoi(numeric.String()) + if err != nil { + return versionInfo{} + } + parts = append(parts, value) + } + info := versionInfo{valid: len(parts) > 0, numbers: parts} + if prerelease != "" { + info.prerelease = splitPrereleaseIdentifiers(prerelease) + } + return info +} + +func splitPrereleaseIdentifiers(value string) []string { + parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool { + return r == '.' || r == '-' + }) + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + filtered = append(filtered, part) + } + } + return filtered +} + +func compareVersions(local string, remote string) int { + left := parseVersionInfo(local) + right := parseVersionInfo(remote) + if left.isDev { + if right.valid { + return -1 + } + return 0 + } + if !left.valid || !right.valid { + return 0 + } + + maxLen := len(left.numbers) + if len(right.numbers) > maxLen { + maxLen = len(right.numbers) + } + for index := 0; index < maxLen; index++ { + leftValue := 0 + rightValue := 0 + if index < len(left.numbers) { + leftValue = left.numbers[index] + } + if index < len(right.numbers) { + rightValue = right.numbers[index] + } + if leftValue < rightValue { + return -1 + } + if leftValue > rightValue { + return 1 + } + } + if len(left.prerelease) == 0 && len(right.prerelease) == 0 { + return 0 + } + if len(left.prerelease) == 0 { + return 1 + } + if len(right.prerelease) == 0 { + return -1 + } + maxLen = len(left.prerelease) + if len(right.prerelease) > maxLen { + maxLen = len(right.prerelease) + } + for index := 0; index < maxLen; index++ { + if index >= len(left.prerelease) { + return -1 + } + if index >= len(right.prerelease) { + return 1 + } + leftPart := left.prerelease[index] + rightPart := right.prerelease[index] + leftNumber, leftErr := strconv.Atoi(leftPart) + rightNumber, rightErr := strconv.Atoi(rightPart) + switch { + case leftErr == nil && rightErr == nil: + if leftNumber < rightNumber { + return -1 + } + if leftNumber > rightNumber { + return 1 + } + case leftErr == nil && rightErr != nil: + return -1 + case leftErr != nil && rightErr == nil: + return 1 + default: + if leftPart < rightPart { + return -1 + } + if leftPart > rightPart { + return 1 + } + } + } + return 0 } diff --git a/atsf_agent/internal/updater/updater_test.go b/atsf_agent/internal/updater/updater_test.go new file mode 100644 index 00000000..11bf27d7 --- /dev/null +++ b/atsf_agent/internal/updater/updater_test.go @@ -0,0 +1,91 @@ +package updater + +import ( + "atsflare-agent/internal/agent" + "context" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(req *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestGetLatestPreviewRelease(t *testing.T) { + service := &Service{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != "https://api.github.com/repos/Rain-kl/ATSFlare/releases?per_page=20" { + t.Fatalf("unexpected request url: %s", req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`[ + {"tag_name":"v1.0.0","prerelease":false}, + {"tag_name":"v1.1.0-rc.1","prerelease":true} + ]`)), + }, nil + }), + }, + } + + release, err := service.getRelease(context.Background(), "Rain-kl/ATSFlare", agent.UpdateOptions{Channel: "preview"}) + if err != nil { + t.Fatalf("expected preview release query to succeed: %v", err) + } + if release == nil || release.TagName != "v1.1.0-rc.1" { + t.Fatalf("unexpected preview release: %#v", release) + } +} + +func TestGetReleaseByTag(t *testing.T) { + service := &Service{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != "https://api.github.com/repos/Rain-kl/ATSFlare/releases/tags/v1.1.0-rc.1" { + t.Fatalf("unexpected request url: %s", req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.1.0-rc.1","prerelease":true}`)), + }, nil + }), + }, + } + + release, err := service.getRelease(context.Background(), "Rain-kl/ATSFlare", agent.UpdateOptions{Channel: "preview", TagName: "v1.1.0-rc.1", Force: true}) + if err != nil { + t.Fatalf("expected tag release query to succeed: %v", err) + } + if release == nil || release.TagName != "v1.1.0-rc.1" { + t.Fatalf("unexpected tag release: %#v", release) + } +} + +func TestIsNewerSupportsPrerelease(t *testing.T) { + testCases := []struct { + name string + local string + remote string + expected bool + }{ + {name: "stable newer than prerelease", local: "1.2.3-rc.1", remote: "1.2.3", expected: true}, + {name: "same stable not newer", local: "1.2.3", remote: "1.2.3-rc.1", expected: false}, + {name: "higher prerelease sequence", local: "1.2.3-rc.1", remote: "1.2.3-rc.2", expected: true}, + {name: "higher minor", local: "1.2.3", remote: "1.3.0-rc.1", expected: true}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + if actual := isNewer(testCase.local, testCase.remote); actual != testCase.expected { + t.Fatalf("unexpected compare result: local=%s remote=%s actual=%v expected=%v", testCase.local, testCase.remote, actual, testCase.expected) + } + }) + } +} diff --git a/atsf_server/controller/node.go b/atsf_server/controller/node.go index b4291a68..2723eeac 100644 --- a/atsf_server/controller/node.go +++ b/atsf_server/controller/node.go @@ -3,12 +3,19 @@ package controller import ( "atsflare/service" "encoding/json" + "errors" + "io" "net/http" "strconv" "github.com/gin-gonic/gin" ) +type nodeAgentUpdateRequest struct { + Channel string `json:"channel"` + TagName string `json:"tag_name"` +} + // CreateNode godoc // @Summary Create node // @Tags Nodes @@ -181,7 +188,20 @@ func RequestNodeAgentUpdate(c *gin.Context) { }) return } - node, err := service.RequestNodeAgentUpdate(uint(id)) + var request nodeAgentUpdateRequest + if c.Request.ContentLength > 0 { + if err = json.NewDecoder(c.Request.Body).Decode(&request); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + } + node, err := service.RequestNodeAgentUpdate(uint(id), service.NodeAgentUpdateInput{ + Channel: request.Channel, + TagName: request.TagName, + }) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -195,3 +215,37 @@ func RequestNodeAgentUpdate(c *gin.Context) { "data": node, }) } + +// GetNodeAgentRelease godoc +// @Summary Check latest agent release for node +// @Tags Nodes +// @Produce json +// @Security BearerAuth +// @Param id path int true "Node ID" +// @Param channel query string false "stable or preview" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Router /api/nodes/{id}/agent-release [get] +func GetNodeAgentRelease(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + release, err := service.GetNodeAgentRelease(c.Request.Context(), uint(id), c.Query("channel")) + 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": release, + }) +} diff --git a/atsf_server/controller/update.go b/atsf_server/controller/update.go index 56eb4a29..56f6f237 100644 --- a/atsf_server/controller/update.go +++ b/atsf_server/controller/update.go @@ -2,6 +2,8 @@ package controller import ( "atsflare/service" + "errors" + "io" "net/http" "strings" @@ -12,6 +14,10 @@ type confirmManualUpgradeRequest struct { UploadToken string `json:"upload_token"` } +type serverUpgradeRequest struct { + Channel string `json:"channel"` +} + // GetLatestRelease godoc // @Summary Get latest GitHub release // @Tags Update @@ -19,7 +25,7 @@ type confirmManualUpgradeRequest struct { // @Success 200 {object} map[string]interface{} // @Router /api/update/latest-release [get] func GetLatestRelease(c *gin.Context) { - release, err := service.GetLatestServerRelease(c.Request.Context()) + release, err := service.GetLatestServerRelease(c.Request.Context(), c.Query("channel")) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -42,7 +48,17 @@ func GetLatestRelease(c *gin.Context) { // @Success 200 {object} map[string]interface{} // @Router /api/update/upgrade [post] func UpgradeServer(c *gin.Context) { - release, err := service.ScheduleServerUpgrade() + var request serverUpgradeRequest + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + } + release, err := service.ScheduleServerUpgrade(request.Channel) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, diff --git a/atsf_server/model/node.go b/atsf_server/model/node.go index b3b7b752..9bcda88f 100644 --- a/atsf_server/model/node.go +++ b/atsf_server/model/node.go @@ -10,6 +10,8 @@ type Node struct { AgentToken string `json:"-" gorm:"size:128;index"` AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"` UpdateRequested bool `json:"update_requested" gorm:"not null;default:false"` + UpdateChannel string `json:"update_channel" gorm:"size:16;not null;default:'stable'"` + UpdateTag string `json:"update_tag" gorm:"size:64"` AgentVersion string `json:"agent_version" gorm:"size:64;not null"` NginxVersion string `json:"nginx_version" gorm:"size:64"` Status string `json:"status" gorm:"size:16;not null;default:'offline'"` diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go index 897b5634..0de3a36d 100644 --- a/atsf_server/router/api-router.go +++ b/atsf_server/router/api-router.go @@ -113,6 +113,7 @@ func SetApiRouter(router *gin.Engine) { nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken) nodeRoute.GET("/", controller.GetNodes) nodeRoute.POST("/", controller.CreateNode) + nodeRoute.GET("/:id/agent-release", controller.GetNodeAgentRelease) nodeRoute.POST("/:id/agent-update", controller.RequestNodeAgentUpdate) nodeRoute.PUT("/:id", controller.UpdateNode) nodeRoute.DELETE("/:id", controller.DeleteNode) diff --git a/atsf_server/router/update_test.go b/atsf_server/router/update_test.go index c2f62c19..3c44fbe2 100644 --- a/atsf_server/router/update_test.go +++ b/atsf_server/router/update_test.go @@ -77,9 +77,11 @@ func TestLatestReleaseProxy(t *testing.T) { if loginRecorder.Code != http.StatusOK { t.Fatalf("unexpected login status code: %d", loginRecorder.Code) } + loginResult := loginRecorder.Result() + defer loginResult.Body.Close() req := httptest.NewRequest(http.MethodGet, "/api/update/latest-release", nil) - for _, cookieValue := range loginRecorder.Result().Cookies() { + for _, cookieValue := range loginResult.Cookies() { req.AddCookie(cookieValue) } @@ -133,8 +135,10 @@ func loginRootAndBuildEngine(t *testing.T) (*gin.Engine, []*http.Cookie) { if loginRecorder.Code != http.StatusOK { t.Fatalf("unexpected login status code: %d", loginRecorder.Code) } + loginResult := loginRecorder.Result() + defer loginResult.Body.Close() - return engine, loginRecorder.Result().Cookies() + return engine, loginResult.Cookies() } func fakeManualServerBinary(version string) (string, []byte) { diff --git a/atsf_server/service/agent.go b/atsf_server/service/agent.go index 2ea8562a..1a67b656 100644 --- a/atsf_server/service/agent.go +++ b/atsf_server/service/agent.go @@ -50,6 +50,8 @@ type AgentSettings struct { AutoUpdate bool `json:"auto_update"` UpdateRepo string `json:"update_repo"` UpdateNow bool `json:"update_now"` + UpdateChannel string `json:"update_channel"` + UpdateTag string `json:"update_tag"` } type HeartbeatResponse struct { @@ -65,6 +67,8 @@ type NodeView struct { AgentToken string `json:"agent_token"` AutoUpdateEnabled bool `json:"auto_update_enabled"` UpdateRequested bool `json:"update_requested"` + UpdateChannel string `json:"update_channel"` + UpdateTag string `json:"update_tag"` AgentVersion string `json:"agent_version"` NginxVersion string `json:"nginx_version"` Status string `json:"status"` @@ -90,9 +94,13 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon return nil, err } updateNow := node.UpdateRequested + updateChannel := normalizeReleaseChannel(node.UpdateChannel) + updateTag := strings.TrimSpace(node.UpdateTag) applyNodeRuntime(node, payload, true) node.UpdateRequested = false - if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error", "update_requested").Updates(node).Error; err != nil { + node.UpdateChannel = ReleaseChannelStable.String() + node.UpdateTag = "" + if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error", "update_requested", "update_channel", "update_tag").Updates(node).Error; err != nil { return nil, err } return &HeartbeatResponse{ @@ -103,6 +111,8 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon AutoUpdate: node.AutoUpdateEnabled, UpdateRepo: common.AgentUpdateRepo, UpdateNow: updateNow, + UpdateChannel: updateChannel.String(), + UpdateTag: updateTag, }, }, nil } diff --git a/atsf_server/service/node.go b/atsf_server/service/node.go index a80a9619..9430e805 100644 --- a/atsf_server/service/node.go +++ b/atsf_server/service/node.go @@ -3,6 +3,7 @@ package service import ( "atsflare/common" "atsflare/model" + "context" "crypto/rand" "encoding/hex" "errors" @@ -15,6 +16,25 @@ type NodeInput struct { AutoUpdateEnabled bool `json:"auto_update_enabled"` } +type NodeAgentUpdateInput struct { + Channel string `json:"channel"` + TagName string `json:"tag_name"` +} + +type NodeAgentReleaseInfo struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` + CurrentVersion string `json:"current_version"` + HasUpdate bool `json:"has_update"` + Channel string `json:"channel"` + Prerelease bool `json:"prerelease"` + UpdateRequested bool `json:"update_requested"` + RequestedChannel string `json:"requested_channel"` + RequestedTag string `json:"requested_tag"` +} + type NodeBootstrapView struct { DiscoveryToken string `json:"discovery_token"` } @@ -84,16 +104,44 @@ func DeleteNode(id uint) error { return node.Delete() } -func RequestNodeAgentUpdate(id uint) (*NodeView, error) { +func GetNodeAgentRelease(ctx context.Context, id uint, channel string) (*NodeAgentReleaseInfo, error) { node, err := model.GetNodeByID(id) if err != nil { return nil, err } - node.UpdateRequested = true - if err = model.DB.Model(node).Update("update_requested", true).Error; err != nil { + release, err := fetchLatestGitHubRelease(ctx, common.AgentUpdateRepo, normalizeReleaseChannel(channel)) + if err != nil { return nil, err } - common.SysLog("agent manual update requested: node_id=" + node.NodeID + " name=" + node.Name) + return buildNodeAgentReleaseView(node, release, normalizeReleaseChannel(channel)), nil +} + +func RequestNodeAgentUpdate(id uint, input NodeAgentUpdateInput) (*NodeView, error) { + node, err := model.GetNodeByID(id) + if err != nil { + return nil, err + } + channel := normalizeReleaseChannel(input.Channel) + tagName := strings.TrimSpace(input.TagName) + if tagName != "" { + release, releaseErr := fetchGitHubReleaseByTag(context.Background(), common.AgentUpdateRepo, tagName) + if releaseErr != nil { + return nil, releaseErr + } + if channel == ReleaseChannelPreview && !release.Prerelease { + return nil, errors.New("指定版本不是 preview 发布") + } + if channel == ReleaseChannelStable && release.Prerelease { + return nil, errors.New("正式版更新不能选择 preview 发布") + } + } + node.UpdateRequested = true + node.UpdateChannel = channel.String() + node.UpdateTag = tagName + if err = model.DB.Model(node).Select("update_requested", "update_channel", "update_tag").Updates(node).Error; err != nil { + return nil, err + } + common.SysLog("agent manual update requested: node_id=" + node.NodeID + " name=" + node.Name + " channel=" + channel.String() + " tag=" + tagName) return buildNodeView(node), nil } @@ -170,6 +218,8 @@ func buildNodeView(node *model.Node) *NodeView { Name: node.Name, IP: node.IP, AgentToken: node.AgentToken, + UpdateChannel: strings.TrimSpace(node.UpdateChannel), + UpdateTag: strings.TrimSpace(node.UpdateTag), AgentVersion: node.AgentVersion, NginxVersion: node.NginxVersion, Status: status, @@ -181,6 +231,30 @@ func buildNodeView(node *model.Node) *NodeView { AutoUpdateEnabled: node.AutoUpdateEnabled, UpdateRequested: node.UpdateRequested, } + if view.UpdateChannel == "" { + view.UpdateChannel = ReleaseChannelStable.String() + } + return view +} + +func buildNodeAgentReleaseView(node *model.Node, release *githubReleaseResponse, channel ReleaseChannel) *NodeAgentReleaseInfo { + currentVersion := strings.TrimSpace(node.AgentVersion) + view := &NodeAgentReleaseInfo{ + CurrentVersion: currentVersion, + Channel: channel.String(), + UpdateRequested: node.UpdateRequested, + RequestedChannel: normalizeReleaseChannel(node.UpdateChannel).String(), + RequestedTag: strings.TrimSpace(node.UpdateTag), + } + if release == nil { + return view + } + view.TagName = release.TagName + view.Body = release.Body + view.HTMLURL = release.HTMLURL + view.PublishedAt = release.PublishedAt + view.Prerelease = release.Prerelease + view.HasUpdate = isVersionNewer(currentVersion, release.TagName) return view } diff --git a/atsf_server/service/node_update_test.go b/atsf_server/service/node_update_test.go new file mode 100644 index 00000000..0f380081 --- /dev/null +++ b/atsf_server/service/node_update_test.go @@ -0,0 +1,117 @@ +package service + +import ( + "atsflare/common" + "atsflare/model" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(req *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestRequestNodeAgentPreviewUpdate(t *testing.T) { + setupServiceTestDB(t) + + node, err := CreateNode(NodeInput{Name: "preview-edge-1"}) + if err != nil { + t.Fatalf("failed to create node: %v", err) + } + + originalClient := UpdateHTTPClientForTest() + SetUpdateHTTPClientForTest(&http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != "https://api.github.com/repos/"+common.AgentUpdateRepo+"/releases/tags/v0.5.0-rc.1" { + t.Fatalf("unexpected request url: %s", req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"tag_name":"v0.5.0-rc.1","prerelease":true}`)), + }, nil + }), + }) + t.Cleanup(func() { + SetUpdateHTTPClientForTest(originalClient) + }) + + updated, err := RequestNodeAgentUpdate(node.ID, NodeAgentUpdateInput{ + Channel: "preview", + TagName: "v0.5.0-rc.1", + }) + if err != nil { + t.Fatalf("expected preview update request to succeed: %v", err) + } + if !updated.UpdateRequested { + t.Fatal("expected update_requested to be true") + } + if updated.UpdateChannel != "preview" { + t.Fatalf("unexpected update channel: %s", updated.UpdateChannel) + } + if updated.UpdateTag != "v0.5.0-rc.1" { + t.Fatalf("unexpected update tag: %s", updated.UpdateTag) + } +} + +func TestHeartbeatNodeReturnsPreviewUpdateSettings(t *testing.T) { + setupServiceTestDB(t) + + node := &model.Node{ + NodeID: "node-preview-1", + Name: "preview-edge-1", + IP: "10.0.0.8", + AgentToken: "agent-token", + AgentVersion: "v0.4.0", + NginxVersion: "1.27.1.2", + Status: NodeStatusOnline, + UpdateRequested: true, + UpdateChannel: "preview", + UpdateTag: "v0.5.0-rc.1", + AutoUpdateEnabled: false, + } + if err := node.Insert(); err != nil { + t.Fatalf("failed to seed node: %v", err) + } + + resp, err := HeartbeatNode(node, AgentNodePayload{ + NodeID: node.NodeID, + Name: node.Name, + IP: node.IP, + AgentVersion: node.AgentVersion, + NginxVersion: node.NginxVersion, + }) + if err != nil { + t.Fatalf("expected heartbeat to succeed: %v", err) + } + if resp.AgentSettings == nil { + t.Fatal("expected agent settings in heartbeat response") + } + if !resp.AgentSettings.UpdateNow { + t.Fatal("expected update_now to be true") + } + if resp.AgentSettings.UpdateChannel != "preview" { + t.Fatalf("unexpected update channel: %s", resp.AgentSettings.UpdateChannel) + } + if resp.AgentSettings.UpdateTag != "v0.5.0-rc.1" { + t.Fatalf("unexpected update tag: %s", resp.AgentSettings.UpdateTag) + } + + storedNode, err := model.GetNodeByID(node.ID) + if err != nil { + t.Fatalf("failed to reload node: %v", err) + } + if storedNode.UpdateRequested { + t.Fatal("expected update_requested to be reset after heartbeat") + } + if storedNode.UpdateChannel != "stable" { + t.Fatalf("expected update channel to reset to stable, got %s", storedNode.UpdateChannel) + } + if storedNode.UpdateTag != "" { + t.Fatalf("expected update tag to be cleared, got %s", storedNode.UpdateTag) + } +} diff --git a/atsf_server/service/update.go b/atsf_server/service/update.go index f918efe7..666870da 100644 --- a/atsf_server/service/update.go +++ b/atsf_server/service/update.go @@ -20,7 +20,17 @@ import ( "time" ) -const latestReleaseURL = "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest" +const ( + serverReleaseRepo = "Rain-kl/ATSFlare" + githubReleasesAPIBase = "https://api.github.com/repos/%s/releases" +) + +type ReleaseChannel string + +const ( + ReleaseChannelStable ReleaseChannel = "stable" + ReleaseChannelPreview ReleaseChannel = "preview" +) var updateHTTPClient = &http.Client{ Timeout: 30 * time.Second, @@ -45,6 +55,8 @@ type LatestServerRelease struct { Body string `json:"body"` HTMLURL string `json:"html_url"` PublishedAt string `json:"published_at"` + Channel string `json:"channel"` + Prerelease bool `json:"prerelease"` CurrentVersion string `json:"current_version"` HasUpdate bool `json:"has_update"` UpgradeSupported bool `json:"upgrade_supported"` @@ -56,6 +68,8 @@ type githubReleaseResponse struct { Body string `json:"body"` HTMLURL string `json:"html_url"` PublishedAt string `json:"published_at"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` Assets []githubAsset `json:"assets"` } @@ -92,22 +106,24 @@ type manualServerBinaryCandidate struct { UploadedAt time.Time } -func GetLatestServerRelease(ctx context.Context) (*LatestServerRelease, error) { - release, err := fetchLatestRelease(ctx) +func GetLatestServerRelease(ctx context.Context, channel string) (*LatestServerRelease, error) { + normalizedChannel := normalizeReleaseChannel(channel) + release, err := fetchLatestRelease(ctx, normalizedChannel) if err != nil { return nil, err } - return buildLatestServerReleaseView(release), nil + return buildLatestServerReleaseView(release, normalizedChannel), nil } -func ScheduleServerUpgrade() (*LatestServerRelease, error) { +func ScheduleServerUpgrade(channel string) (*LatestServerRelease, error) { + normalizedChannel := normalizeReleaseChannel(channel) serverUpgradeState.Lock() if serverUpgradeState.inProgress { serverUpgradeState.Unlock() return nil, fmt.Errorf("服务升级已在执行中,请稍后再试") } - prepared, err := prepareServerUpgrade(context.Background()) + prepared, err := prepareServerUpgrade(context.Background(), normalizedChannel) if err != nil { serverUpgradeState.Unlock() return nil, err @@ -248,13 +264,25 @@ func ConfirmManualServerUpgrade(uploadToken string) (*UploadedServerBinary, erro return info, nil } -func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil) +func fetchLatestRelease(ctx context.Context, channel ReleaseChannel) (*githubReleaseResponse, error) { + return fetchLatestGitHubRelease(ctx, serverReleaseRepo, channel) +} + +func fetchLatestGitHubRelease(ctx context.Context, repo string, channel ReleaseChannel) (*githubReleaseResponse, error) { + switch normalizeReleaseChannel(string(channel)) { + case ReleaseChannelPreview: + return fetchLatestPreviewGitHubRelease(ctx, repo) + default: + return fetchLatestStableGitHubRelease(ctx, repo) + } +} + +func fetchLatestStableGitHubRelease(ctx context.Context, repo string) (*githubReleaseResponse, error) { + url := fmt.Sprintf(githubReleasesAPIBase+"/latest", strings.TrimSpace(repo)) + req, err := newGitHubReleaseRequest(ctx, url) if err != nil { return nil, fmt.Errorf("创建更新请求失败") } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "ATSFlare-Server") resp, err := updateHTTPClient.Do(req) if err != nil { @@ -266,14 +294,86 @@ func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) { return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) } + return decodeGitHubRelease(resp.Body) +} + +func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubReleaseResponse, error) { + url := fmt.Sprintf(githubReleasesAPIBase+"?per_page=20", strings.TrimSpace(repo)) + req, err := newGitHubReleaseRequest(ctx, url) + if err != nil { + return nil, fmt.Errorf("创建更新请求失败") + } + + resp, err := updateHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("获取 preview 版本失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) + } + + var releases []githubReleaseResponse + if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, fmt.Errorf("解析 preview 版本信息失败") + } + for _, release := range releases { + if release.Draft || !release.Prerelease { + continue + } + releaseCopy := release + return &releaseCopy, nil + } + return nil, fmt.Errorf("当前没有可用的 preview 发布") +} + +func fetchGitHubReleaseByTag(ctx context.Context, repo string, tag string) (*githubReleaseResponse, error) { + tag = strings.TrimSpace(tag) + if tag == "" { + return nil, fmt.Errorf("缺少发布版本号") + } + url := fmt.Sprintf(githubReleasesAPIBase+"/tags/%s", strings.TrimSpace(repo), tag) + req, err := newGitHubReleaseRequest(ctx, url) + if err != nil { + return nil, fmt.Errorf("创建更新请求失败") + } + + resp, err := updateHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("获取指定版本失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("未找到指定版本: %s", tag) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) + } + + return decodeGitHubRelease(resp.Body) +} + +func newGitHubReleaseRequest(ctx context.Context, url string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "ATSFlare-Server") + return req, nil +} + +func decodeGitHubRelease(reader io.Reader) (*githubReleaseResponse, error) { var release githubReleaseResponse - if err = json.NewDecoder(resp.Body).Decode(&release); err != nil { + if err := json.NewDecoder(reader).Decode(&release); err != nil { return nil, fmt.Errorf("解析最新版本信息失败") } return &release, nil } -func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerRelease { +func buildLatestServerReleaseView(release *githubReleaseResponse, channel ReleaseChannel) *LatestServerRelease { currentVersion := strings.TrimSpace(common.Version) isDevBuild := currentVersion == "" || strings.EqualFold(currentVersion, "dev") hasUpdate := false @@ -286,6 +386,7 @@ func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerR serverUpgradeState.Unlock() view := &LatestServerRelease{ + Channel: channel.String(), CurrentVersion: currentVersion, HasUpdate: hasUpdate, UpgradeSupported: !isDevBuild && runtime.GOOS != "windows", @@ -296,17 +397,18 @@ func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerR view.Body = release.Body view.HTMLURL = release.HTMLURL view.PublishedAt = release.PublishedAt + view.Prerelease = release.Prerelease } return view } -func prepareServerUpgrade(ctx context.Context) (*preparedServerUpgrade, error) { - release, err := fetchLatestRelease(ctx) +func prepareServerUpgrade(ctx context.Context, channel ReleaseChannel) (*preparedServerUpgrade, error) { + release, err := fetchLatestRelease(ctx, channel) if err != nil { return nil, err } - view := buildLatestServerReleaseView(release) + view := buildLatestServerReleaseView(release, channel) if !view.HasUpdate { return nil, fmt.Errorf("当前已是最新版本") } @@ -411,40 +513,54 @@ func serverAssetName(goos string, goarch string) string { return name } -func isVersionNewer(current string, latest string) bool { - currentParts := parseVersionParts(current) - latestParts := parseVersionParts(latest) - maxLen := len(currentParts) - if len(latestParts) > maxLen { - maxLen = len(latestParts) +func normalizeReleaseChannel(channel string) ReleaseChannel { + switch strings.ToLower(strings.TrimSpace(channel)) { + case string(ReleaseChannelPreview): + return ReleaseChannelPreview + default: + return ReleaseChannelStable } - - for i := 0; i < maxLen; i++ { - currentPart := 0 - latestPart := 0 - if i < len(currentParts) { - currentPart = currentParts[i] - } - if i < len(latestParts) { - latestPart = latestParts[i] - } - if latestPart > currentPart { - return true - } - if latestPart < currentPart { - return false - } - } - return false } -func parseVersionParts(version string) []int { +func (channel ReleaseChannel) String() string { + if channel == ReleaseChannelPreview { + return string(ReleaseChannelPreview) + } + return string(ReleaseChannelStable) +} + +func isVersionNewer(current string, latest string) bool { + currentInfo := parseVersionInfo(current) + latestInfo := parseVersionInfo(latest) + if currentInfo.IsDev { + return latestInfo.Valid + } + if !currentInfo.Valid || !latestInfo.Valid { + return false + } + return compareVersionInfo(currentInfo, latestInfo) < 0 +} + +type versionInfo struct { + Valid bool + IsDev bool + Numbers []int + Prerelease []string +} + +func parseVersionInfo(version string) versionInfo { normalized := strings.TrimSpace(strings.TrimPrefix(version, "v")) if normalized == "" || normalized == "dev" { - return nil + return versionInfo{IsDev: strings.EqualFold(normalized, "dev")} + } + base := normalized + prerelease := "" + if separator := strings.IndexRune(normalized, '-'); separator >= 0 { + base = normalized[:separator] + prerelease = normalized[separator+1:] } - segments := strings.Split(normalized, ".") + segments := strings.Split(base, ".") parts := make([]int, 0, len(segments)) for _, segment := range segments { segment = strings.TrimSpace(segment) @@ -466,12 +582,100 @@ func parseVersionParts(version string) []int { } value, err := strconv.Atoi(numeric.String()) if err != nil { - parts = append(parts, 0) - continue + return versionInfo{} } parts = append(parts, value) } - return parts + info := versionInfo{Valid: len(parts) > 0, Numbers: parts} + if prerelease != "" { + info.Prerelease = splitPrereleaseIdentifiers(prerelease) + } + return info +} + +func splitPrereleaseIdentifiers(value string) []string { + parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool { + return r == '.' || r == '-' + }) + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + filtered = append(filtered, part) + } + } + return filtered +} + +func compareVersionInfo(left versionInfo, right versionInfo) int { + maxLen := len(left.Numbers) + if len(right.Numbers) > maxLen { + maxLen = len(right.Numbers) + } + for index := 0; index < maxLen; index++ { + leftValue := 0 + rightValue := 0 + if index < len(left.Numbers) { + leftValue = left.Numbers[index] + } + if index < len(right.Numbers) { + rightValue = right.Numbers[index] + } + if leftValue < rightValue { + return -1 + } + if leftValue > rightValue { + return 1 + } + } + + if len(left.Prerelease) == 0 && len(right.Prerelease) == 0 { + return 0 + } + if len(left.Prerelease) == 0 { + return 1 + } + if len(right.Prerelease) == 0 { + return -1 + } + + maxLen = len(left.Prerelease) + if len(right.Prerelease) > maxLen { + maxLen = len(right.Prerelease) + } + for index := 0; index < maxLen; index++ { + if index >= len(left.Prerelease) { + return -1 + } + if index >= len(right.Prerelease) { + return 1 + } + leftPart := left.Prerelease[index] + rightPart := right.Prerelease[index] + leftNumber, leftErr := strconv.Atoi(leftPart) + rightNumber, rightErr := strconv.Atoi(rightPart) + switch { + case leftErr == nil && rightErr == nil: + if leftNumber < rightNumber { + return -1 + } + if leftNumber > rightNumber { + return 1 + } + case leftErr == nil && rightErr != nil: + return -1 + case leftErr != nil && rightErr == nil: + return 1 + default: + if leftPart < rightPart { + return -1 + } + if leftPart > rightPart { + return 1 + } + } + } + return 0 } func buildUploadedServerBinaryView(fileName string, currentVersion string, detectedVersion string, uploadedAt time.Time) *UploadedServerBinary { diff --git a/atsf_server/service/update_test.go b/atsf_server/service/update_test.go index 833a7bd8..5f473ed9 100644 --- a/atsf_server/service/update_test.go +++ b/atsf_server/service/update_test.go @@ -38,6 +38,9 @@ func TestIsVersionNewer(t *testing.T) { {name: "same version", current: "v1.2.3", latest: "v1.2.3", expected: false}, {name: "older remote", current: "v1.3.0", latest: "v1.2.9", expected: false}, {name: "double digit segment", current: "v1.9.9", latest: "v1.10.0", expected: true}, + {name: "stable newer than prerelease", current: "v1.2.3-rc.1", latest: "v1.2.3", expected: true}, + {name: "prerelease not newer than same stable", current: "v1.2.3", latest: "v1.2.3-rc.1", expected: false}, + {name: "newer prerelease sequence", current: "v1.2.3-rc.1", latest: "v1.2.3-rc.2", expected: true}, {name: "dev build", current: "dev", latest: "v0.4.0", expected: true}, } @@ -70,7 +73,7 @@ func TestBuildLatestServerReleaseView(t *testing.T) { Body: "release notes", HTMLURL: "https://github.com/Rain-kl/ATSFlare/releases/tag/v0.5.0", PublishedAt: "2026-03-11T00:00:00Z", - }) + }, ReleaseChannelStable) if view.CurrentVersion != "v0.4.0" { t.Fatalf("unexpected current version: %s", view.CurrentVersion) @@ -84,6 +87,9 @@ func TestBuildLatestServerReleaseView(t *testing.T) { if view.TagName != "v0.5.0" { t.Fatalf("unexpected tag name: %s", view.TagName) } + if view.Channel != ReleaseChannelStable.String() { + t.Fatalf("unexpected channel: %s", view.Channel) + } } func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) { @@ -98,7 +104,7 @@ func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) { view := buildLatestServerReleaseView(&githubReleaseResponse{ TagName: "v0.5.0", - }) + }, ReleaseChannelStable) if view.HasUpdate { t.Fatal("expected dev build not to report update availability") @@ -108,6 +114,31 @@ func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) { } } +func TestBuildLatestServerReleaseViewPreview(t *testing.T) { + originalVersion := common.Version + common.Version = "v0.5.0-rc.1" + t.Cleanup(func() { + common.Version = originalVersion + resetServerUpgradeTestState(t) + }) + + view := buildLatestServerReleaseView(&githubReleaseResponse{ + TagName: "v0.5.0-rc.2", + Prerelease: true, + PublishedAt: "2026-03-12T00:00:00Z", + }, ReleaseChannelPreview) + + if !view.HasUpdate { + t.Fatal("expected preview release to be newer") + } + if !view.Prerelease { + t.Fatal("expected preview flag to be true") + } + if view.Channel != ReleaseChannelPreview.String() { + t.Fatalf("unexpected channel: %s", view.Channel) + } +} + func TestUploadManualServerBinary(t *testing.T) { originalVersion := common.Version common.Version = "v0.4.0" diff --git a/atsf_server/web/components/layout/dashboard-topbar.tsx b/atsf_server/web/components/layout/dashboard-topbar.tsx index 835bd62b..9fb188a4 100644 --- a/atsf_server/web/components/layout/dashboard-topbar.tsx +++ b/atsf_server/web/components/layout/dashboard-topbar.tsx @@ -14,7 +14,10 @@ import { uploadServerBinary, } from '@/features/update/api/update'; import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal'; -import type { UploadedServerBinaryInfo } from '@/features/update/types'; +import type { + ReleaseChannel, + UploadedServerBinaryInfo, +} from '@/features/update/types'; import { publicEnv } from '@/lib/env/public-env'; import { useAppShellStore } from '@/store/app-shell'; @@ -31,6 +34,8 @@ export function DashboardTopbar() { const [isLoggingOut, setIsLoggingOut] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [isVersionModalOpen, setIsVersionModalOpen] = useState(false); + const [selectedReleaseChannel, setSelectedReleaseChannel] = + useState('stable'); const [versionFeedback, setVersionFeedback] = useState(null); const [manualUpgradeStatus, setManualUpgradeStatus] = useState( null, @@ -48,23 +53,32 @@ export function DashboardTopbar() { queryFn: getPublicStatus, }); - const latestReleaseQuery = useQuery({ - queryKey: ['update', 'latest-release'], - queryFn: getLatestRelease, + const stableReleaseQuery = useQuery({ + queryKey: ['update', 'latest-release', 'stable'], + queryFn: () => getLatestRelease('stable'), enabled: isRoot, refetchInterval: 60 * 60 * 1000, }); + const previewReleaseQuery = useQuery({ + queryKey: ['update', 'latest-release', 'preview'], + queryFn: () => getLatestRelease('preview'), + enabled: false, + }); + const upgradeMutation = useMutation({ - mutationFn: upgradeServer, + mutationFn: (channel: ReleaseChannel) => upgradeServer(channel), onSuccess: (release) => { setUploadedBinary(null); setManualUpgradeStatus(null); setManualUpgradeError(null); setVersionFeedback( - `服务升级任务已启动,目标版本 ${release.tag_name}。页面可能短暂不可用。`, + `服务升级任务已启动,目标版本 ${release.tag_name}(${release.channel === 'preview' ? '预览版' : '正式版'})。页面可能短暂不可用。`, ); - void latestReleaseQuery.refetch(); + void stableReleaseQuery.refetch(); + if (release.channel === 'preview') { + void previewReleaseQuery.refetch(); + } }, onError: (error) => { setVersionFeedback( @@ -99,7 +113,8 @@ export function DashboardTopbar() { setManualUpgradeStatus( `手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`, ); - void latestReleaseQuery.refetch(); + void stableReleaseQuery.refetch(); + void previewReleaseQuery.refetch(); }, onError: (error) => { setManualUpgradeStatus(null); @@ -154,12 +169,13 @@ export function DashboardTopbar() { }; const handleOpenVersionModal = () => { + setSelectedReleaseChannel('stable'); setVersionFeedback(null); setManualUpgradeStatus(null); setManualUpgradeError(null); setIsVersionModalOpen(true); if (isRoot) { - void latestReleaseQuery.refetch(); + void stableReleaseQuery.refetch(); } }; @@ -167,7 +183,23 @@ export function DashboardTopbar() { setVersionFeedback(null); setManualUpgradeStatus(null); setManualUpgradeError(null); - upgradeMutation.mutate(); + upgradeMutation.mutate(selectedReleaseChannel); + }; + + const handleCheckStableRelease = () => { + setSelectedReleaseChannel('stable'); + setVersionFeedback(null); + if (isRoot) { + void stableReleaseQuery.refetch(); + } + }; + + const handleCheckPreviewRelease = () => { + setSelectedReleaseChannel('preview'); + setVersionFeedback(null); + if (isRoot) { + void previewReleaseQuery.refetch(); + } }; const handleUploadBinary = (binary: File) => { @@ -188,8 +220,19 @@ export function DashboardTopbar() { confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token); }; - const release = latestReleaseQuery.data; - const hasUpdate = Boolean(isRoot && release?.has_update); + const selectedRelease = + selectedReleaseChannel === 'preview' + ? previewReleaseQuery.data + : stableReleaseQuery.data; + const selectedReleaseError = + selectedReleaseChannel === 'preview' + ? previewReleaseQuery.error + : stableReleaseQuery.error; + const isSelectedReleaseError = + selectedReleaseChannel === 'preview' + ? previewReleaseQuery.isError + : stableReleaseQuery.isError; + const hasUpdate = Boolean(isRoot && stableReleaseQuery.data?.has_update); const currentVersion = publicStatusQuery.data?.version || 'unknown'; const versionLabel = hasUpdate ? `版本 ${publicEnv.appVersion} · 可升级` @@ -199,9 +242,9 @@ export function DashboardTopbar() { : 'border-[var(--border-default)]'; const versionErrorMessage = versionFeedback || - (latestReleaseQuery.isError - ? latestReleaseQuery.error instanceof Error - ? latestReleaseQuery.error.message + (isSelectedReleaseError + ? selectedReleaseError instanceof Error + ? selectedReleaseError.message : '版本检查失败,请稍后重试。' : undefined); const manualUpgradeErrorMessage = manualUpgradeError ?? undefined; @@ -283,23 +326,29 @@ export function DashboardTopbar() { currentVersion={currentVersion} frontendVersion={publicEnv.appVersion} startTime={publicStatusQuery.data?.start_time} - release={release} + release={selectedRelease} + selectedChannel={selectedReleaseChannel} uploadedBinary={uploadedBinary} - isLoading={latestReleaseQuery.isLoading && !release && isRoot} + isLoading={ + (selectedReleaseChannel === 'preview' + ? previewReleaseQuery.isLoading && !previewReleaseQuery.data + : stableReleaseQuery.isLoading && !stableReleaseQuery.data) && + isRoot + } releaseErrorMessage={versionErrorMessage} manualStatusMessage={manualUpgradeStatus ?? undefined} manualErrorMessage={manualUpgradeErrorMessage} canUpgrade={isRoot} - isChecking={latestReleaseQuery.isFetching} + isChecking={ + selectedReleaseChannel === 'preview' + ? previewReleaseQuery.isFetching + : stableReleaseQuery.isFetching + } isUpgrading={upgradeMutation.isPending} isUploadingBinary={uploadBinaryMutation.isPending} isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending} - onRefresh={() => { - setVersionFeedback(null); - if (isRoot) { - void latestReleaseQuery.refetch(); - } - }} + onCheckStable={handleCheckStableRelease} + onCheckPreview={handleCheckPreviewRelease} onUpgrade={handleUpgrade} onUploadBinary={handleUploadBinary} onConfirmManualUpgrade={handleConfirmManualUpgrade} diff --git a/atsf_server/web/features/nodes/api/nodes.ts b/atsf_server/web/features/nodes/api/nodes.ts index 4fd2090d..14731328 100644 --- a/atsf_server/web/features/nodes/api/nodes.ts +++ b/atsf_server/web/features/nodes/api/nodes.ts @@ -1,47 +1,63 @@ -import { apiRequest } from '@/lib/api/client'; - -import type { - NodeBootstrapToken, - NodeItem, - NodeMutationPayload, -} from '@/features/nodes/types'; - -export function getNodes() { - return apiRequest('/nodes/'); -} - -export function createNode(payload: NodeMutationPayload) { - return apiRequest('/nodes/', { - method: 'POST', - body: JSON.stringify(payload), - }); -} - -export function updateNode(id: number, payload: NodeMutationPayload) { - return apiRequest(`/nodes/${id}`, { - method: 'PUT', - body: JSON.stringify(payload), - }); -} - -export function deleteNode(id: number) { - return apiRequest(`/nodes/${id}`, { - method: 'DELETE', - }); -} - -export function getNodeBootstrapToken() { - return apiRequest('/nodes/bootstrap-token'); -} - -export function rotateNodeBootstrapToken() { - return apiRequest('/nodes/bootstrap-token/rotate', { - method: 'POST', - }); -} - -export function requestNodeAgentUpdate(id: number) { - return apiRequest(`/nodes/${id}/agent-update`, { - method: 'POST', - }); -} +import { apiRequest } from '@/lib/api/client'; + +import type { + NodeAgentReleaseInfo, + NodeAgentUpdatePayload, + NodeBootstrapToken, + NodeItem, + NodeMutationPayload, +} from '@/features/nodes/types'; +import type { ReleaseChannel } from '@/features/update/types'; + +export function getNodes() { + return apiRequest('/nodes/'); +} + +export function createNode(payload: NodeMutationPayload) { + return apiRequest('/nodes/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updateNode(id: number, payload: NodeMutationPayload) { + return apiRequest(`/nodes/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + +export function deleteNode(id: number) { + return apiRequest(`/nodes/${id}`, { + method: 'DELETE', + }); +} + +export function getNodeBootstrapToken() { + return apiRequest('/nodes/bootstrap-token'); +} + +export function rotateNodeBootstrapToken() { + return apiRequest('/nodes/bootstrap-token/rotate', { + method: 'POST', + }); +} + +export function getNodeAgentRelease( + id: number, + channel: ReleaseChannel = 'stable', +) { + return apiRequest( + `/nodes/${id}/agent-release?channel=${channel}`, + ); +} + +export function requestNodeAgentUpdate( + id: number, + payload?: NodeAgentUpdatePayload, +) { + return apiRequest(`/nodes/${id}/agent-update`, { + method: 'POST', + body: JSON.stringify(payload ?? {}), + }); +} diff --git a/atsf_server/web/features/nodes/components/node-detail-page.tsx b/atsf_server/web/features/nodes/components/node-detail-page.tsx index 1df3df0a..b939f0d4 100644 --- a/atsf_server/web/features/nodes/components/node-detail-page.tsx +++ b/atsf_server/web/features/nodes/components/node-detail-page.tsx @@ -16,15 +16,18 @@ import { PageHeader } from '@/components/layout/page-header'; import { AppModal } from '@/components/ui/app-modal'; import { AppCard } from '@/components/ui/app-card'; import { StatusBadge } from '@/components/ui/status-badge'; -import { getPublicStatus } from '@/features/auth/api/public'; import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; import { deleteNode, + getNodeAgentRelease, getNodes, requestNodeAgentUpdate, updateNode, } from '@/features/nodes/api/nodes'; -import type { NodeMutationPayload } from '@/features/nodes/types'; +import type { + NodeAgentReleaseInfo, + NodeMutationPayload, +} from '@/features/nodes/types'; import { CodeBlock, DangerButton, @@ -34,6 +37,7 @@ import { SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; +import type { ReleaseChannel } from '@/features/update/types'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { buildNodeInstallCommand, @@ -44,7 +48,6 @@ import { getServerUrl, getUpdateMode, isMeaningfulTime, - shouldShowManualUpdate, } from '@/features/nodes/utils'; const nodesQueryKey = ['nodes']; @@ -90,6 +93,11 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { const queryClient = useQueryClient(); const [feedback, setFeedback] = useState(null); const [isEditorOpen, setIsEditorOpen] = useState(false); + const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false); + const [selectedReleaseChannel, setSelectedReleaseChannel] = + useState('stable'); + const [agentUpdateFeedback, setAgentUpdateFeedback] = + useState(null); const [serverUrl, setServerUrl] = useState(''); const form = useForm({ @@ -108,9 +116,16 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { refetchInterval: 5000, }); - const publicStatusQuery = useQuery({ - queryKey: ['public-status'], - queryFn: getPublicStatus, + const stableAgentReleaseQuery = useQuery({ + queryKey: ['node-agent-release', nodeId, 'stable'], + queryFn: () => getNodeAgentRelease(Number(nodeId), 'stable'), + enabled: false, + }); + + const previewAgentReleaseQuery = useQuery({ + queryKey: ['node-agent-release', nodeId, 'preview'], + queryFn: () => getNodeAgentRelease(Number(nodeId), 'preview'), + enabled: false, }); const node = useMemo(() => { @@ -161,16 +176,29 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { }); const updateAgentMutation = useMutation({ - mutationFn: () => requestNodeAgentUpdate(Number(nodeId)), + mutationFn: (release: NodeAgentReleaseInfo | null) => + requestNodeAgentUpdate(Number(nodeId), { + channel: release?.channel ?? selectedReleaseChannel, + tag_name: + release?.channel === 'preview' + ? release.tag_name || undefined + : undefined, + }), onSuccess: async (updatedNode) => { setFeedback({ tone: 'success', - message: `已向节点 ${updatedNode.name} 下发更新指令。`, + message: `已向节点 ${updatedNode.name} 下发${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'}更新指令。`, + }); + setAgentUpdateFeedback({ + tone: 'success', + message: `节点将在下一次心跳后执行${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'} Agent 更新。`, }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + const message = getErrorMessage(error); + setFeedback({ tone: 'danger', message }); + setAgentUpdateFeedback({ tone: 'danger', message }); }, }); @@ -245,13 +273,43 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { ? buildNodeInstallCommand(normalizedServerUrl, node.agent_token) : ''; const updateMode = getUpdateMode(node); - const serverVersion = publicStatusQuery.data?.version ?? ''; - const showManualUpdate = shouldShowManualUpdate( - node.agent_version || '', - serverVersion, - ); + const selectedAgentRelease = + selectedReleaseChannel === 'preview' + ? previewAgentReleaseQuery.data + : stableAgentReleaseQuery.data; + const selectedAgentReleaseError = + selectedReleaseChannel === 'preview' + ? previewAgentReleaseQuery.error + : stableAgentReleaseQuery.error; + const isCheckingAgentRelease = + selectedReleaseChannel === 'preview' + ? previewAgentReleaseQuery.isFetching + : stableAgentReleaseQuery.isFetching; const applyLogs = applyLogsQuery.data ?? []; + const handleOpenAgentUpdateModal = () => { + setAgentUpdateFeedback(null); + setSelectedReleaseChannel('stable'); + setIsAgentUpdateModalOpen(true); + void stableAgentReleaseQuery.refetch(); + }; + + const handleCheckStableAgentRelease = () => { + setAgentUpdateFeedback(null); + setSelectedReleaseChannel('stable'); + void stableAgentReleaseQuery.refetch(); + }; + + const handleCheckPreviewAgentRelease = () => { + setAgentUpdateFeedback(null); + setSelectedReleaseChannel('preview'); + void previewAgentReleaseQuery.refetch(); + }; + + const handleRequestAgentUpdate = () => { + updateAgentMutation.mutate(selectedAgentRelease ?? null); + }; + return ( <>
@@ -272,17 +330,13 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { > 编辑节点 - {showManualUpdate ? ( - updateAgentMutation.mutate()} - disabled={ - updateAgentMutation.isPending || node.update_requested - } - > - {node.update_requested ? '等待更新中...' : '手动升级 Agent'} - - ) : null} + + {node.update_requested ? '查看 Agent 更新' : 'Agent 更新'} +

{node.update_requested - ? '已等待节点在下一次心跳后执行更新。' + ? `已等待节点在下一次心跳后执行${node.update_channel === 'preview' ? '预览版' : '正式版'}更新。` : node.auto_update_enabled - ? '节点已启用自动更新。' + ? '节点已启用正式版自动更新。' : '当前仅支持手动触发更新。'}

@@ -567,6 +621,184 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { /> + + setIsAgentUpdateModalOpen(false)} + title="Agent 更新" + description="默认检查正式版;你也可以手动检查 preview 发布,并选择向当前节点下发对应版本的升级指令。" + footer={ +
+ + {isCheckingAgentRelease && selectedReleaseChannel === 'stable' + ? '检查中...' + : '检查正式版'} + + + {isCheckingAgentRelease && selectedReleaseChannel === 'preview' + ? '检查中...' + : '检查预览版'} + + + {updateAgentMutation.isPending + ? '下发中...' + : selectedReleaseChannel === 'preview' + ? '升级到预览版' + : '升级到正式版'} + +
+ } + > +
+ {agentUpdateFeedback ? ( + + ) : null} + +
+ +

+ {node.agent_version || 'unknown'} +

+
+ +
+

+ {selectedReleaseChannel === 'preview' ? '预览版' : '正式版'} +

+ +
+
+ + + +
+ + {isCheckingAgentRelease && !selectedAgentRelease ? ( + + ) : null} + {!isCheckingAgentRelease && selectedAgentReleaseError ? ( + + ) : null} + {!isCheckingAgentRelease && + !selectedAgentReleaseError && + !selectedAgentRelease ? ( + + ) : null} + + {selectedAgentRelease ? ( + +
+
+ + {selectedAgentRelease.prerelease ? ( + + ) : ( + + )} + {node.update_requested ? ( + + ) : null} +
+ +
+
+

+ 当前版本 +

+

+ {selectedAgentRelease.current_version || 'unknown'} +

+
+
+

+ 目标版本 +

+

+ {selectedAgentRelease.tag_name || '未找到'} +

+
+
+ +
+ {selectedAgentRelease.body || '暂无更新说明'} +
+ + {selectedAgentRelease.html_url ? ( + + 查看发布详情 + + ) : null} +
+
+ ) : null} +
+
); } diff --git a/atsf_server/web/features/nodes/types.ts b/atsf_server/web/features/nodes/types.ts index 23e52c49..084b0260 100644 --- a/atsf_server/web/features/nodes/types.ts +++ b/atsf_server/web/features/nodes/types.ts @@ -1,29 +1,52 @@ -export interface NodeItem { - id: number; - node_id: string; - name: string; - ip: string; - agent_token: string; - auto_update_enabled: boolean; - update_requested: boolean; - agent_version: string; - nginx_version: string; - status: 'online' | 'offline' | 'pending'; - current_version: string; - last_seen_at: string; - last_error: string; - latest_apply_result: 'success' | 'failed' | ''; - latest_apply_message: string; - latest_apply_at?: string | null; - created_at: string; - updated_at: string; -} - -export interface NodeBootstrapToken { - discovery_token: string; -} - -export interface NodeMutationPayload { - name: string; - auto_update_enabled: boolean; -} +import type { ReleaseChannel } from '@/features/update/types'; + +export interface NodeItem { + id: number; + node_id: string; + name: string; + ip: string; + agent_token: string; + auto_update_enabled: boolean; + update_requested: boolean; + update_channel: ReleaseChannel; + update_tag: string; + agent_version: string; + nginx_version: string; + status: 'online' | 'offline' | 'pending'; + current_version: string; + last_seen_at: string; + last_error: string; + latest_apply_result: 'success' | 'failed' | ''; + latest_apply_message: string; + latest_apply_at?: string | null; + created_at: string; + updated_at: string; +} + +export interface NodeBootstrapToken { + discovery_token: string; +} + +export interface NodeMutationPayload { + name: string; + auto_update_enabled: boolean; +} + +export interface NodeAgentReleaseInfo { + tag_name: string; + body: string; + html_url: string; + published_at: string; + current_version: string; + has_update: boolean; + channel: ReleaseChannel; + prerelease: boolean; + update_requested: boolean; + requested_channel: ReleaseChannel; + requested_tag: string; +} + +export interface NodeAgentUpdatePayload { + channel?: ReleaseChannel; + tag_name?: string; +} diff --git a/atsf_server/web/features/nodes/utils.ts b/atsf_server/web/features/nodes/utils.ts index 1786c8d1..d177928e 100644 --- a/atsf_server/web/features/nodes/utils.ts +++ b/atsf_server/web/features/nodes/utils.ts @@ -54,6 +54,10 @@ export function getApplyLabel(result: NodeItem['latest_apply_result']) { export function getUpdateMode(node: NodeItem) { if (node.update_requested) { + if (node.update_channel === 'preview') { + return { label: '等待预览更新', variant: 'warning' as const }; + } + return { label: '等待更新', variant: 'warning' as const }; } diff --git a/atsf_server/web/features/update/api/update.ts b/atsf_server/web/features/update/api/update.ts index d454ccae..1c52b35c 100644 --- a/atsf_server/web/features/update/api/update.ts +++ b/atsf_server/web/features/update/api/update.ts @@ -2,16 +2,20 @@ import { apiRequest } from '@/lib/api/client'; import type { LatestReleaseInfo, + ReleaseChannel, UploadedServerBinaryInfo, } from '@/features/update/types'; -export function getLatestRelease() { - return apiRequest('/update/latest-release'); +export function getLatestRelease(channel: ReleaseChannel = 'stable') { + return apiRequest( + `/update/latest-release?channel=${channel}`, + ); } -export function upgradeServer() { +export function upgradeServer(channel: ReleaseChannel = 'stable') { return apiRequest('/update/upgrade', { method: 'POST', + body: JSON.stringify({ channel }), }); } diff --git a/atsf_server/web/features/update/components/version-upgrade-modal.tsx b/atsf_server/web/features/update/components/version-upgrade-modal.tsx index b29ade97..9c83fa5c 100644 --- a/atsf_server/web/features/update/components/version-upgrade-modal.tsx +++ b/atsf_server/web/features/update/components/version-upgrade-modal.tsx @@ -11,6 +11,7 @@ import { AppModal } from '@/components/ui/app-modal'; import { StatusBadge } from '@/components/ui/status-badge'; import type { LatestReleaseInfo, + ReleaseChannel, UploadedServerBinaryInfo, } from '@/features/update/types'; import { @@ -28,6 +29,7 @@ interface VersionUpgradeModalProps { frontendVersion: string; startTime?: number; release: LatestReleaseInfo | null | undefined; + selectedChannel: ReleaseChannel; uploadedBinary: UploadedServerBinaryInfo | null; isLoading: boolean; releaseErrorMessage?: string; @@ -38,7 +40,8 @@ interface VersionUpgradeModalProps { isUpgrading: boolean; isUploadingBinary: boolean; isConfirmingManualUpgrade: boolean; - onRefresh: () => void; + onCheckStable: () => void; + onCheckPreview: () => void; onUpgrade: () => void; onUploadBinary: (file: File) => void; onConfirmManualUpgrade: () => void; @@ -64,6 +67,7 @@ export function VersionUpgradeModal({ frontendVersion, startTime, release, + selectedChannel, uploadedBinary, isLoading, releaseErrorMessage, @@ -74,13 +78,16 @@ export function VersionUpgradeModal({ isUpgrading, isUploadingBinary, isConfirmingManualUpgrade, - onRefresh, + onCheckStable, + onCheckPreview, onUpgrade, onUploadBinary, onConfirmManualUpgrade, }: VersionUpgradeModalProps) { const upgradeBadge = getUpgradeBadge(release); const [selectedBinary, setSelectedBinary] = useState(null); + const selectedChannelLabel = + selectedChannel === 'preview' ? '预览版' : '正式版'; useEffect(() => { if (!isOpen) { @@ -93,17 +100,28 @@ export function VersionUpgradeModal({ isOpen={isOpen} onClose={onClose} title="版本" - description="在这里检查 GitHub 最新版本,或手动上传 Server 二进制并确认升级。升级开始后服务会短暂重启。" + description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级,或上传 Server 二进制确认升级。升级开始后服务会短暂重启。" size="lg" footer={ canUpgrade ? (
- {isChecking ? '检查中...' : '检查更新'} + {isChecking && selectedChannel === 'stable' + ? '检查中...' + : '检查正式版'} + + + {isChecking && selectedChannel === 'preview' + ? '检查中...' + : '检查预览版'}
) : undefined @@ -174,9 +194,15 @@ export function VersionUpgradeModal({ -

- {release?.tag_name || '未检查'} -

+
+

+ {release?.tag_name || '未检查'} +

+ +

@@ -191,13 +217,13 @@ export function VersionUpgradeModal({ ) : null} {!isLoading && !releaseErrorMessage && !release ? ( ) : null} {!isLoading && !releaseErrorMessage && release ? ( + {release.prerelease ? ( + + ) : ( + + )} {!release.upgrade_supported ? (