mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 17:56:37 +08:00
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.
This commit is contained in:
@@ -47,7 +47,7 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部
|
|||||||
* 节点接入:支持全局 `discovery_token` 首次接入,也支持节点专属 `agent_token`
|
* 节点接入:支持全局 `discovery_token` 首次接入,也支持节点专属 `agent_token`
|
||||||
* Agent 自动应用:周期性同步、落盘、`openresty -t`、`openresty -s reload`、失败自动回滚
|
* Agent 自动应用:周期性同步、落盘、`openresty -t`、`openresty -s reload`、失败自动回滚
|
||||||
* TLS 与域名管理:支持证书托管、域名资产维护、精确匹配与通配符匹配
|
* TLS 与域名管理:支持证书托管、域名资产维护、精确匹配与通配符匹配
|
||||||
* 运维能力:配置变更摘要、Agent 运行参数下发、Agent 自更新、Server GitHub 自升级、Server 手动上传二进制确认升级
|
* 运维能力:配置变更摘要、Agent 运行参数下发、Agent 正式版自动更新与 preview 手动升级、Server 正式版 GitHub 自升级、Server preview 手动检查升级、Server 手动上传二进制确认升级
|
||||||
* 管理端 UI:基于 Next.js App Router + React 19 + Tailwind CSS 4 的新版前端
|
* 管理端 UI:基于 Next.js App Router + React 19 + Tailwind CSS 4 的新版前端
|
||||||
|
|
||||||
## 界面预览
|
## 界面预览
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ type SyncService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Updater 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 {
|
type Runner struct {
|
||||||
@@ -37,6 +43,8 @@ type Runner struct {
|
|||||||
autoUpdate bool
|
autoUpdate bool
|
||||||
updateNow bool
|
updateNow bool
|
||||||
updateRepo string
|
updateRepo string
|
||||||
|
updateChan string
|
||||||
|
updateTag string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) Run(ctx context.Context) error {
|
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.autoUpdate = settings.AutoUpdate
|
||||||
r.updateNow = settings.UpdateNow
|
r.updateNow = settings.UpdateNow
|
||||||
r.updateRepo = strings.TrimSpace(settings.UpdateRepo)
|
r.updateRepo = strings.TrimSpace(settings.UpdateRepo)
|
||||||
|
r.updateChan = strings.TrimSpace(settings.UpdateChannel)
|
||||||
|
r.updateTag = strings.TrimSpace(settings.UpdateTag)
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) tryAutoUpdate(ctx context.Context) {
|
func (r *Runner) tryAutoUpdate(ctx context.Context) {
|
||||||
shouldCheck := r.autoUpdate || r.updateNow
|
force := r.updateNow
|
||||||
|
shouldCheck := r.autoUpdate || force
|
||||||
r.updateNow = false
|
r.updateNow = false
|
||||||
|
r.updateTag = strings.TrimSpace(r.updateTag)
|
||||||
if !shouldCheck || r.Updater == nil || r.updateRepo == "" {
|
if !shouldCheck || r.Updater == nil || r.updateRepo == "" {
|
||||||
return
|
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)
|
log.Printf("agent update check failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if force {
|
||||||
|
r.updateTag = ""
|
||||||
|
r.updateChan = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
|
func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type AgentSettings struct {
|
|||||||
AutoUpdate bool `json:"auto_update"`
|
AutoUpdate bool `json:"auto_update"`
|
||||||
UpdateRepo string `json:"update_repo"`
|
UpdateRepo string `json:"update_repo"`
|
||||||
UpdateNow bool `json:"update_now"`
|
UpdateNow bool `json:"update_now"`
|
||||||
|
UpdateChannel string `json:"update_channel"`
|
||||||
|
UpdateTag string `json:"update_tag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodePayload struct {
|
type NodePayload struct {
|
||||||
|
|||||||
@@ -9,15 +9,17 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"atsflare-agent/internal/agent"
|
||||||
"atsflare-agent/internal/config"
|
"atsflare-agent/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
lastCheckTag string
|
lastCheckKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Service {
|
func New() *Service {
|
||||||
@@ -27,8 +29,10 @@ func New() *Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type githubRelease struct {
|
type githubRelease struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
Assets []githubAsset `json:"assets"`
|
Prerelease bool `json:"prerelease"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Assets []githubAsset `json:"assets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type githubAsset struct {
|
type githubAsset struct {
|
||||||
@@ -36,8 +40,8 @@ type githubAsset struct {
|
|||||||
BrowserDownloadURL string `json:"browser_download_url"`
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CheckAndUpdate(ctx context.Context, repo string) error {
|
func (s *Service) CheckAndUpdate(ctx context.Context, repo string, options agent.UpdateOptions) error {
|
||||||
release, err := s.getLatestRelease(ctx, repo)
|
release, err := s.getRelease(ctx, repo, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check latest release: %w", err)
|
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)
|
remoteVersion := normalizeVersion(release.TagName)
|
||||||
localVersion := normalizeVersion(config.AgentVersion)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
if !isNewer(localVersion, remoteVersion) {
|
if !isNewer(localVersion, remoteVersion) {
|
||||||
s.lastCheckTag = remoteVersion
|
s.lastCheckKey = checkKey
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +75,7 @@ func (s *Service) CheckAndUpdate(ctx context.Context, repo string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if downloadURL == "" {
|
if downloadURL == "" {
|
||||||
s.lastCheckTag = remoteVersion
|
s.lastCheckKey = checkKey
|
||||||
return fmt.Errorf("no matching asset %q in release %s", assetName, release.TagName)
|
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 {
|
if err = s.downloadAndRestart(ctx, downloadURL, execPath); err != nil {
|
||||||
return fmt.Errorf("download and restart: %w", err)
|
return fmt.Errorf("download and restart: %w", err)
|
||||||
}
|
}
|
||||||
|
s.lastCheckKey = checkKey
|
||||||
return nil
|
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)
|
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != 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 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
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &release, nil
|
return &release, nil
|
||||||
@@ -158,26 +238,160 @@ func normalizeVersion(v string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isNewer(local, remote string) bool {
|
func isNewer(local, remote string) bool {
|
||||||
localParts := strings.Split(local, ".")
|
return compareVersions(local, remote) < 0
|
||||||
remoteParts := strings.Split(remote, ".")
|
}
|
||||||
maxLen := len(localParts)
|
|
||||||
if len(remoteParts) > maxLen {
|
func buildReleaseCheckKey(options agent.UpdateOptions, remoteVersion string) string {
|
||||||
maxLen = len(remoteParts)
|
channel := strings.TrimSpace(options.Channel)
|
||||||
}
|
if channel == "" {
|
||||||
for i := 0; i < maxLen; i++ {
|
channel = "stable"
|
||||||
lp, rp := "0", "0"
|
}
|
||||||
if i < len(localParts) {
|
if tagName := strings.TrimSpace(options.TagName); tagName != "" {
|
||||||
lp = localParts[i]
|
return channel + ":" + tagName
|
||||||
}
|
}
|
||||||
if i < len(remoteParts) {
|
return channel + ":" + remoteVersion
|
||||||
rp = remoteParts[i]
|
}
|
||||||
}
|
|
||||||
if rp > lp {
|
type versionInfo struct {
|
||||||
return true
|
valid bool
|
||||||
}
|
isDev bool
|
||||||
if rp < lp {
|
numbers []int
|
||||||
return false
|
prerelease []string
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,19 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"atsflare/service"
|
"atsflare/service"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type nodeAgentUpdateRequest struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
}
|
||||||
|
|
||||||
// CreateNode godoc
|
// CreateNode godoc
|
||||||
// @Summary Create node
|
// @Summary Create node
|
||||||
// @Tags Nodes
|
// @Tags Nodes
|
||||||
@@ -181,7 +188,20 @@ func RequestNodeAgentUpdate(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -195,3 +215,37 @@ func RequestNodeAgentUpdate(c *gin.Context) {
|
|||||||
"data": node,
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"atsflare/service"
|
"atsflare/service"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -12,6 +14,10 @@ type confirmManualUpgradeRequest struct {
|
|||||||
UploadToken string `json:"upload_token"`
|
UploadToken string `json:"upload_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type serverUpgradeRequest struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
}
|
||||||
|
|
||||||
// GetLatestRelease godoc
|
// GetLatestRelease godoc
|
||||||
// @Summary Get latest GitHub release
|
// @Summary Get latest GitHub release
|
||||||
// @Tags Update
|
// @Tags Update
|
||||||
@@ -19,7 +25,7 @@ type confirmManualUpgradeRequest struct {
|
|||||||
// @Success 200 {object} map[string]interface{}
|
// @Success 200 {object} map[string]interface{}
|
||||||
// @Router /api/update/latest-release [get]
|
// @Router /api/update/latest-release [get]
|
||||||
func GetLatestRelease(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -42,7 +48,17 @@ func GetLatestRelease(c *gin.Context) {
|
|||||||
// @Success 200 {object} map[string]interface{}
|
// @Success 200 {object} map[string]interface{}
|
||||||
// @Router /api/update/upgrade [post]
|
// @Router /api/update/upgrade [post]
|
||||||
func UpgradeServer(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ type Node struct {
|
|||||||
AgentToken string `json:"-" gorm:"size:128;index"`
|
AgentToken string `json:"-" gorm:"size:128;index"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"`
|
||||||
UpdateRequested bool `json:"update_requested" 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"`
|
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
|
||||||
NginxVersion string `json:"nginx_version" gorm:"size:64"`
|
NginxVersion string `json:"nginx_version" gorm:"size:64"`
|
||||||
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
|
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken)
|
nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken)
|
||||||
nodeRoute.GET("/", controller.GetNodes)
|
nodeRoute.GET("/", controller.GetNodes)
|
||||||
nodeRoute.POST("/", controller.CreateNode)
|
nodeRoute.POST("/", controller.CreateNode)
|
||||||
|
nodeRoute.GET("/:id/agent-release", controller.GetNodeAgentRelease)
|
||||||
nodeRoute.POST("/:id/agent-update", controller.RequestNodeAgentUpdate)
|
nodeRoute.POST("/:id/agent-update", controller.RequestNodeAgentUpdate)
|
||||||
nodeRoute.PUT("/:id", controller.UpdateNode)
|
nodeRoute.PUT("/:id", controller.UpdateNode)
|
||||||
nodeRoute.DELETE("/:id", controller.DeleteNode)
|
nodeRoute.DELETE("/:id", controller.DeleteNode)
|
||||||
|
|||||||
@@ -77,9 +77,11 @@ func TestLatestReleaseProxy(t *testing.T) {
|
|||||||
if loginRecorder.Code != http.StatusOK {
|
if loginRecorder.Code != http.StatusOK {
|
||||||
t.Fatalf("unexpected login status code: %d", loginRecorder.Code)
|
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)
|
req := httptest.NewRequest(http.MethodGet, "/api/update/latest-release", nil)
|
||||||
for _, cookieValue := range loginRecorder.Result().Cookies() {
|
for _, cookieValue := range loginResult.Cookies() {
|
||||||
req.AddCookie(cookieValue)
|
req.AddCookie(cookieValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,8 +135,10 @@ func loginRootAndBuildEngine(t *testing.T) (*gin.Engine, []*http.Cookie) {
|
|||||||
if loginRecorder.Code != http.StatusOK {
|
if loginRecorder.Code != http.StatusOK {
|
||||||
t.Fatalf("unexpected login status code: %d", loginRecorder.Code)
|
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) {
|
func fakeManualServerBinary(version string) (string, []byte) {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ type AgentSettings struct {
|
|||||||
AutoUpdate bool `json:"auto_update"`
|
AutoUpdate bool `json:"auto_update"`
|
||||||
UpdateRepo string `json:"update_repo"`
|
UpdateRepo string `json:"update_repo"`
|
||||||
UpdateNow bool `json:"update_now"`
|
UpdateNow bool `json:"update_now"`
|
||||||
|
UpdateChannel string `json:"update_channel"`
|
||||||
|
UpdateTag string `json:"update_tag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HeartbeatResponse struct {
|
type HeartbeatResponse struct {
|
||||||
@@ -65,6 +67,8 @@ type NodeView struct {
|
|||||||
AgentToken string `json:"agent_token"`
|
AgentToken string `json:"agent_token"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
UpdateRequested bool `json:"update_requested"`
|
UpdateRequested bool `json:"update_requested"`
|
||||||
|
UpdateChannel string `json:"update_channel"`
|
||||||
|
UpdateTag string `json:"update_tag"`
|
||||||
AgentVersion string `json:"agent_version"`
|
AgentVersion string `json:"agent_version"`
|
||||||
NginxVersion string `json:"nginx_version"`
|
NginxVersion string `json:"nginx_version"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -90,9 +94,13 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
updateNow := node.UpdateRequested
|
updateNow := node.UpdateRequested
|
||||||
|
updateChannel := normalizeReleaseChannel(node.UpdateChannel)
|
||||||
|
updateTag := strings.TrimSpace(node.UpdateTag)
|
||||||
applyNodeRuntime(node, payload, true)
|
applyNodeRuntime(node, payload, true)
|
||||||
node.UpdateRequested = false
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &HeartbeatResponse{
|
return &HeartbeatResponse{
|
||||||
@@ -103,6 +111,8 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
|
|||||||
AutoUpdate: node.AutoUpdateEnabled,
|
AutoUpdate: node.AutoUpdateEnabled,
|
||||||
UpdateRepo: common.AgentUpdateRepo,
|
UpdateRepo: common.AgentUpdateRepo,
|
||||||
UpdateNow: updateNow,
|
UpdateNow: updateNow,
|
||||||
|
UpdateChannel: updateChannel.String(),
|
||||||
|
UpdateTag: updateTag,
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"atsflare/common"
|
"atsflare/common"
|
||||||
"atsflare/model"
|
"atsflare/model"
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -15,6 +16,25 @@ type NodeInput struct {
|
|||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
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 {
|
type NodeBootstrapView struct {
|
||||||
DiscoveryToken string `json:"discovery_token"`
|
DiscoveryToken string `json:"discovery_token"`
|
||||||
}
|
}
|
||||||
@@ -84,16 +104,44 @@ func DeleteNode(id uint) error {
|
|||||||
return node.Delete()
|
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)
|
node, err := model.GetNodeByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
node.UpdateRequested = true
|
release, err := fetchLatestGitHubRelease(ctx, common.AgentUpdateRepo, normalizeReleaseChannel(channel))
|
||||||
if err = model.DB.Model(node).Update("update_requested", true).Error; err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return buildNodeView(node), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +218,8 @@ func buildNodeView(node *model.Node) *NodeView {
|
|||||||
Name: node.Name,
|
Name: node.Name,
|
||||||
IP: node.IP,
|
IP: node.IP,
|
||||||
AgentToken: node.AgentToken,
|
AgentToken: node.AgentToken,
|
||||||
|
UpdateChannel: strings.TrimSpace(node.UpdateChannel),
|
||||||
|
UpdateTag: strings.TrimSpace(node.UpdateTag),
|
||||||
AgentVersion: node.AgentVersion,
|
AgentVersion: node.AgentVersion,
|
||||||
NginxVersion: node.NginxVersion,
|
NginxVersion: node.NginxVersion,
|
||||||
Status: status,
|
Status: status,
|
||||||
@@ -181,6 +231,30 @@ func buildNodeView(node *model.Node) *NodeView {
|
|||||||
AutoUpdateEnabled: node.AutoUpdateEnabled,
|
AutoUpdateEnabled: node.AutoUpdateEnabled,
|
||||||
UpdateRequested: node.UpdateRequested,
|
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
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+249
-45
@@ -20,7 +20,17 @@ import (
|
|||||||
"time"
|
"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{
|
var updateHTTPClient = &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -45,6 +55,8 @@ type LatestServerRelease struct {
|
|||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
PublishedAt string `json:"published_at"`
|
PublishedAt string `json:"published_at"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
CurrentVersion string `json:"current_version"`
|
CurrentVersion string `json:"current_version"`
|
||||||
HasUpdate bool `json:"has_update"`
|
HasUpdate bool `json:"has_update"`
|
||||||
UpgradeSupported bool `json:"upgrade_supported"`
|
UpgradeSupported bool `json:"upgrade_supported"`
|
||||||
@@ -56,6 +68,8 @@ type githubReleaseResponse struct {
|
|||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
PublishedAt string `json:"published_at"`
|
PublishedAt string `json:"published_at"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
Assets []githubAsset `json:"assets"`
|
Assets []githubAsset `json:"assets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,22 +106,24 @@ type manualServerBinaryCandidate struct {
|
|||||||
UploadedAt time.Time
|
UploadedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetLatestServerRelease(ctx context.Context) (*LatestServerRelease, error) {
|
func GetLatestServerRelease(ctx context.Context, channel string) (*LatestServerRelease, error) {
|
||||||
release, err := fetchLatestRelease(ctx)
|
normalizedChannel := normalizeReleaseChannel(channel)
|
||||||
|
release, err := fetchLatestRelease(ctx, normalizedChannel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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()
|
serverUpgradeState.Lock()
|
||||||
if serverUpgradeState.inProgress {
|
if serverUpgradeState.inProgress {
|
||||||
serverUpgradeState.Unlock()
|
serverUpgradeState.Unlock()
|
||||||
return nil, fmt.Errorf("服务升级已在执行中,请稍后再试")
|
return nil, fmt.Errorf("服务升级已在执行中,请稍后再试")
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared, err := prepareServerUpgrade(context.Background())
|
prepared, err := prepareServerUpgrade(context.Background(), normalizedChannel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverUpgradeState.Unlock()
|
serverUpgradeState.Unlock()
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -248,13 +264,25 @@ func ConfirmManualServerUpgrade(uploadToken string) (*UploadedServerBinary, erro
|
|||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) {
|
func fetchLatestRelease(ctx context.Context, channel ReleaseChannel) (*githubReleaseResponse, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建更新请求失败")
|
return nil, fmt.Errorf("创建更新请求失败")
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
|
||||||
req.Header.Set("User-Agent", "ATSFlare-Server")
|
|
||||||
|
|
||||||
resp, err := updateHTTPClient.Do(req)
|
resp, err := updateHTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -266,14 +294,86 @@ func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) {
|
|||||||
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
|
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
|
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 nil, fmt.Errorf("解析最新版本信息失败")
|
||||||
}
|
}
|
||||||
return &release, nil
|
return &release, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerRelease {
|
func buildLatestServerReleaseView(release *githubReleaseResponse, channel ReleaseChannel) *LatestServerRelease {
|
||||||
currentVersion := strings.TrimSpace(common.Version)
|
currentVersion := strings.TrimSpace(common.Version)
|
||||||
isDevBuild := currentVersion == "" || strings.EqualFold(currentVersion, "dev")
|
isDevBuild := currentVersion == "" || strings.EqualFold(currentVersion, "dev")
|
||||||
hasUpdate := false
|
hasUpdate := false
|
||||||
@@ -286,6 +386,7 @@ func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerR
|
|||||||
serverUpgradeState.Unlock()
|
serverUpgradeState.Unlock()
|
||||||
|
|
||||||
view := &LatestServerRelease{
|
view := &LatestServerRelease{
|
||||||
|
Channel: channel.String(),
|
||||||
CurrentVersion: currentVersion,
|
CurrentVersion: currentVersion,
|
||||||
HasUpdate: hasUpdate,
|
HasUpdate: hasUpdate,
|
||||||
UpgradeSupported: !isDevBuild && runtime.GOOS != "windows",
|
UpgradeSupported: !isDevBuild && runtime.GOOS != "windows",
|
||||||
@@ -296,17 +397,18 @@ func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerR
|
|||||||
view.Body = release.Body
|
view.Body = release.Body
|
||||||
view.HTMLURL = release.HTMLURL
|
view.HTMLURL = release.HTMLURL
|
||||||
view.PublishedAt = release.PublishedAt
|
view.PublishedAt = release.PublishedAt
|
||||||
|
view.Prerelease = release.Prerelease
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareServerUpgrade(ctx context.Context) (*preparedServerUpgrade, error) {
|
func prepareServerUpgrade(ctx context.Context, channel ReleaseChannel) (*preparedServerUpgrade, error) {
|
||||||
release, err := fetchLatestRelease(ctx)
|
release, err := fetchLatestRelease(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
view := buildLatestServerReleaseView(release)
|
view := buildLatestServerReleaseView(release, channel)
|
||||||
if !view.HasUpdate {
|
if !view.HasUpdate {
|
||||||
return nil, fmt.Errorf("当前已是最新版本")
|
return nil, fmt.Errorf("当前已是最新版本")
|
||||||
}
|
}
|
||||||
@@ -411,40 +513,54 @@ func serverAssetName(goos string, goarch string) string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
func isVersionNewer(current string, latest string) bool {
|
func normalizeReleaseChannel(channel string) ReleaseChannel {
|
||||||
currentParts := parseVersionParts(current)
|
switch strings.ToLower(strings.TrimSpace(channel)) {
|
||||||
latestParts := parseVersionParts(latest)
|
case string(ReleaseChannelPreview):
|
||||||
maxLen := len(currentParts)
|
return ReleaseChannelPreview
|
||||||
if len(latestParts) > maxLen {
|
default:
|
||||||
maxLen = len(latestParts)
|
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"))
|
normalized := strings.TrimSpace(strings.TrimPrefix(version, "v"))
|
||||||
if normalized == "" || normalized == "dev" {
|
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))
|
parts := make([]int, 0, len(segments))
|
||||||
for _, segment := range segments {
|
for _, segment := range segments {
|
||||||
segment = strings.TrimSpace(segment)
|
segment = strings.TrimSpace(segment)
|
||||||
@@ -466,12 +582,100 @@ func parseVersionParts(version string) []int {
|
|||||||
}
|
}
|
||||||
value, err := strconv.Atoi(numeric.String())
|
value, err := strconv.Atoi(numeric.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
parts = append(parts, 0)
|
return versionInfo{}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
parts = append(parts, value)
|
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 {
|
func buildUploadedServerBinaryView(fileName string, currentVersion string, detectedVersion string, uploadedAt time.Time) *UploadedServerBinary {
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ func TestIsVersionNewer(t *testing.T) {
|
|||||||
{name: "same version", current: "v1.2.3", latest: "v1.2.3", expected: false},
|
{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: "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: "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},
|
{name: "dev build", current: "dev", latest: "v0.4.0", expected: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +73,7 @@ func TestBuildLatestServerReleaseView(t *testing.T) {
|
|||||||
Body: "release notes",
|
Body: "release notes",
|
||||||
HTMLURL: "https://github.com/Rain-kl/ATSFlare/releases/tag/v0.5.0",
|
HTMLURL: "https://github.com/Rain-kl/ATSFlare/releases/tag/v0.5.0",
|
||||||
PublishedAt: "2026-03-11T00:00:00Z",
|
PublishedAt: "2026-03-11T00:00:00Z",
|
||||||
})
|
}, ReleaseChannelStable)
|
||||||
|
|
||||||
if view.CurrentVersion != "v0.4.0" {
|
if view.CurrentVersion != "v0.4.0" {
|
||||||
t.Fatalf("unexpected current version: %s", view.CurrentVersion)
|
t.Fatalf("unexpected current version: %s", view.CurrentVersion)
|
||||||
@@ -84,6 +87,9 @@ func TestBuildLatestServerReleaseView(t *testing.T) {
|
|||||||
if view.TagName != "v0.5.0" {
|
if view.TagName != "v0.5.0" {
|
||||||
t.Fatalf("unexpected tag name: %s", view.TagName)
|
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) {
|
func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) {
|
||||||
@@ -98,7 +104,7 @@ func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) {
|
|||||||
|
|
||||||
view := buildLatestServerReleaseView(&githubReleaseResponse{
|
view := buildLatestServerReleaseView(&githubReleaseResponse{
|
||||||
TagName: "v0.5.0",
|
TagName: "v0.5.0",
|
||||||
})
|
}, ReleaseChannelStable)
|
||||||
|
|
||||||
if view.HasUpdate {
|
if view.HasUpdate {
|
||||||
t.Fatal("expected dev build not to report update availability")
|
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) {
|
func TestUploadManualServerBinary(t *testing.T) {
|
||||||
originalVersion := common.Version
|
originalVersion := common.Version
|
||||||
common.Version = "v0.4.0"
|
common.Version = "v0.4.0"
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
uploadServerBinary,
|
uploadServerBinary,
|
||||||
} from '@/features/update/api/update';
|
} from '@/features/update/api/update';
|
||||||
import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal';
|
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 { publicEnv } from '@/lib/env/public-env';
|
||||||
import { useAppShellStore } from '@/store/app-shell';
|
import { useAppShellStore } from '@/store/app-shell';
|
||||||
|
|
||||||
@@ -31,6 +34,8 @@ export function DashboardTopbar() {
|
|||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||||
|
const [selectedReleaseChannel, setSelectedReleaseChannel] =
|
||||||
|
useState<ReleaseChannel>('stable');
|
||||||
const [versionFeedback, setVersionFeedback] = useState<string | null>(null);
|
const [versionFeedback, setVersionFeedback] = useState<string | null>(null);
|
||||||
const [manualUpgradeStatus, setManualUpgradeStatus] = useState<string | null>(
|
const [manualUpgradeStatus, setManualUpgradeStatus] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -48,23 +53,32 @@ export function DashboardTopbar() {
|
|||||||
queryFn: getPublicStatus,
|
queryFn: getPublicStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
const latestReleaseQuery = useQuery({
|
const stableReleaseQuery = useQuery({
|
||||||
queryKey: ['update', 'latest-release'],
|
queryKey: ['update', 'latest-release', 'stable'],
|
||||||
queryFn: getLatestRelease,
|
queryFn: () => getLatestRelease('stable'),
|
||||||
enabled: isRoot,
|
enabled: isRoot,
|
||||||
refetchInterval: 60 * 60 * 1000,
|
refetchInterval: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const previewReleaseQuery = useQuery({
|
||||||
|
queryKey: ['update', 'latest-release', 'preview'],
|
||||||
|
queryFn: () => getLatestRelease('preview'),
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
const upgradeMutation = useMutation({
|
const upgradeMutation = useMutation({
|
||||||
mutationFn: upgradeServer,
|
mutationFn: (channel: ReleaseChannel) => upgradeServer(channel),
|
||||||
onSuccess: (release) => {
|
onSuccess: (release) => {
|
||||||
setUploadedBinary(null);
|
setUploadedBinary(null);
|
||||||
setManualUpgradeStatus(null);
|
setManualUpgradeStatus(null);
|
||||||
setManualUpgradeError(null);
|
setManualUpgradeError(null);
|
||||||
setVersionFeedback(
|
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) => {
|
onError: (error) => {
|
||||||
setVersionFeedback(
|
setVersionFeedback(
|
||||||
@@ -99,7 +113,8 @@ export function DashboardTopbar() {
|
|||||||
setManualUpgradeStatus(
|
setManualUpgradeStatus(
|
||||||
`手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`,
|
`手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`,
|
||||||
);
|
);
|
||||||
void latestReleaseQuery.refetch();
|
void stableReleaseQuery.refetch();
|
||||||
|
void previewReleaseQuery.refetch();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setManualUpgradeStatus(null);
|
setManualUpgradeStatus(null);
|
||||||
@@ -154,12 +169,13 @@ export function DashboardTopbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenVersionModal = () => {
|
const handleOpenVersionModal = () => {
|
||||||
|
setSelectedReleaseChannel('stable');
|
||||||
setVersionFeedback(null);
|
setVersionFeedback(null);
|
||||||
setManualUpgradeStatus(null);
|
setManualUpgradeStatus(null);
|
||||||
setManualUpgradeError(null);
|
setManualUpgradeError(null);
|
||||||
setIsVersionModalOpen(true);
|
setIsVersionModalOpen(true);
|
||||||
if (isRoot) {
|
if (isRoot) {
|
||||||
void latestReleaseQuery.refetch();
|
void stableReleaseQuery.refetch();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,7 +183,23 @@ export function DashboardTopbar() {
|
|||||||
setVersionFeedback(null);
|
setVersionFeedback(null);
|
||||||
setManualUpgradeStatus(null);
|
setManualUpgradeStatus(null);
|
||||||
setManualUpgradeError(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) => {
|
const handleUploadBinary = (binary: File) => {
|
||||||
@@ -188,8 +220,19 @@ export function DashboardTopbar() {
|
|||||||
confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token);
|
confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token);
|
||||||
};
|
};
|
||||||
|
|
||||||
const release = latestReleaseQuery.data;
|
const selectedRelease =
|
||||||
const hasUpdate = Boolean(isRoot && release?.has_update);
|
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 currentVersion = publicStatusQuery.data?.version || 'unknown';
|
||||||
const versionLabel = hasUpdate
|
const versionLabel = hasUpdate
|
||||||
? `版本 ${publicEnv.appVersion} · 可升级`
|
? `版本 ${publicEnv.appVersion} · 可升级`
|
||||||
@@ -199,9 +242,9 @@ export function DashboardTopbar() {
|
|||||||
: 'border-[var(--border-default)]';
|
: 'border-[var(--border-default)]';
|
||||||
const versionErrorMessage =
|
const versionErrorMessage =
|
||||||
versionFeedback ||
|
versionFeedback ||
|
||||||
(latestReleaseQuery.isError
|
(isSelectedReleaseError
|
||||||
? latestReleaseQuery.error instanceof Error
|
? selectedReleaseError instanceof Error
|
||||||
? latestReleaseQuery.error.message
|
? selectedReleaseError.message
|
||||||
: '版本检查失败,请稍后重试。'
|
: '版本检查失败,请稍后重试。'
|
||||||
: undefined);
|
: undefined);
|
||||||
const manualUpgradeErrorMessage = manualUpgradeError ?? undefined;
|
const manualUpgradeErrorMessage = manualUpgradeError ?? undefined;
|
||||||
@@ -283,23 +326,29 @@ export function DashboardTopbar() {
|
|||||||
currentVersion={currentVersion}
|
currentVersion={currentVersion}
|
||||||
frontendVersion={publicEnv.appVersion}
|
frontendVersion={publicEnv.appVersion}
|
||||||
startTime={publicStatusQuery.data?.start_time}
|
startTime={publicStatusQuery.data?.start_time}
|
||||||
release={release}
|
release={selectedRelease}
|
||||||
|
selectedChannel={selectedReleaseChannel}
|
||||||
uploadedBinary={uploadedBinary}
|
uploadedBinary={uploadedBinary}
|
||||||
isLoading={latestReleaseQuery.isLoading && !release && isRoot}
|
isLoading={
|
||||||
|
(selectedReleaseChannel === 'preview'
|
||||||
|
? previewReleaseQuery.isLoading && !previewReleaseQuery.data
|
||||||
|
: stableReleaseQuery.isLoading && !stableReleaseQuery.data) &&
|
||||||
|
isRoot
|
||||||
|
}
|
||||||
releaseErrorMessage={versionErrorMessage}
|
releaseErrorMessage={versionErrorMessage}
|
||||||
manualStatusMessage={manualUpgradeStatus ?? undefined}
|
manualStatusMessage={manualUpgradeStatus ?? undefined}
|
||||||
manualErrorMessage={manualUpgradeErrorMessage}
|
manualErrorMessage={manualUpgradeErrorMessage}
|
||||||
canUpgrade={isRoot}
|
canUpgrade={isRoot}
|
||||||
isChecking={latestReleaseQuery.isFetching}
|
isChecking={
|
||||||
|
selectedReleaseChannel === 'preview'
|
||||||
|
? previewReleaseQuery.isFetching
|
||||||
|
: stableReleaseQuery.isFetching
|
||||||
|
}
|
||||||
isUpgrading={upgradeMutation.isPending}
|
isUpgrading={upgradeMutation.isPending}
|
||||||
isUploadingBinary={uploadBinaryMutation.isPending}
|
isUploadingBinary={uploadBinaryMutation.isPending}
|
||||||
isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending}
|
isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending}
|
||||||
onRefresh={() => {
|
onCheckStable={handleCheckStableRelease}
|
||||||
setVersionFeedback(null);
|
onCheckPreview={handleCheckPreviewRelease}
|
||||||
if (isRoot) {
|
|
||||||
void latestReleaseQuery.refetch();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onUpgrade={handleUpgrade}
|
onUpgrade={handleUpgrade}
|
||||||
onUploadBinary={handleUploadBinary}
|
onUploadBinary={handleUploadBinary}
|
||||||
onConfirmManualUpgrade={handleConfirmManualUpgrade}
|
onConfirmManualUpgrade={handleConfirmManualUpgrade}
|
||||||
|
|||||||
@@ -1,47 +1,63 @@
|
|||||||
import { apiRequest } from '@/lib/api/client';
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
NodeBootstrapToken,
|
NodeAgentReleaseInfo,
|
||||||
NodeItem,
|
NodeAgentUpdatePayload,
|
||||||
NodeMutationPayload,
|
NodeBootstrapToken,
|
||||||
|
NodeItem,
|
||||||
|
NodeMutationPayload,
|
||||||
} from '@/features/nodes/types';
|
} from '@/features/nodes/types';
|
||||||
|
import type { ReleaseChannel } from '@/features/update/types';
|
||||||
|
|
||||||
export function getNodes() {
|
export function getNodes() {
|
||||||
return apiRequest<NodeItem[]>('/nodes/');
|
return apiRequest<NodeItem[]>('/nodes/');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNode(payload: NodeMutationPayload) {
|
export function createNode(payload: NodeMutationPayload) {
|
||||||
return apiRequest<NodeItem>('/nodes/', {
|
return apiRequest<NodeItem>('/nodes/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateNode(id: number, payload: NodeMutationPayload) {
|
export function updateNode(id: number, payload: NodeMutationPayload) {
|
||||||
return apiRequest<NodeItem>(`/nodes/${id}`, {
|
return apiRequest<NodeItem>(`/nodes/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteNode(id: number) {
|
export function deleteNode(id: number) {
|
||||||
return apiRequest<void>(`/nodes/${id}`, {
|
return apiRequest<void>(`/nodes/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeBootstrapToken() {
|
export function getNodeBootstrapToken() {
|
||||||
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token');
|
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rotateNodeBootstrapToken() {
|
export function rotateNodeBootstrapToken() {
|
||||||
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token/rotate', {
|
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token/rotate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requestNodeAgentUpdate(id: number) {
|
export function getNodeAgentRelease(
|
||||||
return apiRequest<NodeItem>(`/nodes/${id}/agent-update`, {
|
id: number,
|
||||||
method: 'POST',
|
channel: ReleaseChannel = 'stable',
|
||||||
});
|
) {
|
||||||
|
return apiRequest<NodeAgentReleaseInfo>(
|
||||||
|
`/nodes/${id}/agent-release?channel=${channel}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestNodeAgentUpdate(
|
||||||
|
id: number,
|
||||||
|
payload?: NodeAgentUpdatePayload,
|
||||||
|
) {
|
||||||
|
return apiRequest<NodeItem>(`/nodes/${id}/agent-update`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,15 +16,18 @@ import { PageHeader } from '@/components/layout/page-header';
|
|||||||
import { AppModal } from '@/components/ui/app-modal';
|
import { AppModal } from '@/components/ui/app-modal';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { StatusBadge } from '@/components/ui/status-badge';
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
import { getPublicStatus } from '@/features/auth/api/public';
|
|
||||||
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
|
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
|
||||||
import {
|
import {
|
||||||
deleteNode,
|
deleteNode,
|
||||||
|
getNodeAgentRelease,
|
||||||
getNodes,
|
getNodes,
|
||||||
requestNodeAgentUpdate,
|
requestNodeAgentUpdate,
|
||||||
updateNode,
|
updateNode,
|
||||||
} from '@/features/nodes/api/nodes';
|
} from '@/features/nodes/api/nodes';
|
||||||
import type { NodeMutationPayload } from '@/features/nodes/types';
|
import type {
|
||||||
|
NodeAgentReleaseInfo,
|
||||||
|
NodeMutationPayload,
|
||||||
|
} from '@/features/nodes/types';
|
||||||
import {
|
import {
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
DangerButton,
|
DangerButton,
|
||||||
@@ -34,6 +37,7 @@ import {
|
|||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
ToggleField,
|
ToggleField,
|
||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
import type { ReleaseChannel } from '@/features/update/types';
|
||||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||||
import {
|
import {
|
||||||
buildNodeInstallCommand,
|
buildNodeInstallCommand,
|
||||||
@@ -44,7 +48,6 @@ import {
|
|||||||
getServerUrl,
|
getServerUrl,
|
||||||
getUpdateMode,
|
getUpdateMode,
|
||||||
isMeaningfulTime,
|
isMeaningfulTime,
|
||||||
shouldShowManualUpdate,
|
|
||||||
} from '@/features/nodes/utils';
|
} from '@/features/nodes/utils';
|
||||||
|
|
||||||
const nodesQueryKey = ['nodes'];
|
const nodesQueryKey = ['nodes'];
|
||||||
@@ -90,6 +93,11 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||||
|
const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false);
|
||||||
|
const [selectedReleaseChannel, setSelectedReleaseChannel] =
|
||||||
|
useState<ReleaseChannel>('stable');
|
||||||
|
const [agentUpdateFeedback, setAgentUpdateFeedback] =
|
||||||
|
useState<FeedbackState | null>(null);
|
||||||
const [serverUrl, setServerUrl] = useState('');
|
const [serverUrl, setServerUrl] = useState('');
|
||||||
|
|
||||||
const form = useForm<NodeFormValues>({
|
const form = useForm<NodeFormValues>({
|
||||||
@@ -108,9 +116,16 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const publicStatusQuery = useQuery({
|
const stableAgentReleaseQuery = useQuery({
|
||||||
queryKey: ['public-status'],
|
queryKey: ['node-agent-release', nodeId, 'stable'],
|
||||||
queryFn: getPublicStatus,
|
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(() => {
|
const node = useMemo(() => {
|
||||||
@@ -161,16 +176,29 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateAgentMutation = useMutation({
|
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) => {
|
onSuccess: async (updatedNode) => {
|
||||||
setFeedback({
|
setFeedback({
|
||||||
tone: 'success',
|
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 });
|
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
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)
|
? buildNodeInstallCommand(normalizedServerUrl, node.agent_token)
|
||||||
: '';
|
: '';
|
||||||
const updateMode = getUpdateMode(node);
|
const updateMode = getUpdateMode(node);
|
||||||
const serverVersion = publicStatusQuery.data?.version ?? '';
|
const selectedAgentRelease =
|
||||||
const showManualUpdate = shouldShowManualUpdate(
|
selectedReleaseChannel === 'preview'
|
||||||
node.agent_version || '',
|
? previewAgentReleaseQuery.data
|
||||||
serverVersion,
|
: stableAgentReleaseQuery.data;
|
||||||
);
|
const selectedAgentReleaseError =
|
||||||
|
selectedReleaseChannel === 'preview'
|
||||||
|
? previewAgentReleaseQuery.error
|
||||||
|
: stableAgentReleaseQuery.error;
|
||||||
|
const isCheckingAgentRelease =
|
||||||
|
selectedReleaseChannel === 'preview'
|
||||||
|
? previewAgentReleaseQuery.isFetching
|
||||||
|
: stableAgentReleaseQuery.isFetching;
|
||||||
const applyLogs = applyLogsQuery.data ?? [];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -272,17 +330,13 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
>
|
>
|
||||||
编辑节点
|
编辑节点
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
{showManualUpdate ? (
|
<PrimaryButton
|
||||||
<PrimaryButton
|
type="button"
|
||||||
type="button"
|
onClick={handleOpenAgentUpdateModal}
|
||||||
onClick={() => updateAgentMutation.mutate()}
|
disabled={updateAgentMutation.isPending}
|
||||||
disabled={
|
>
|
||||||
updateAgentMutation.isPending || node.update_requested
|
{node.update_requested ? '查看 Agent 更新' : 'Agent 更新'}
|
||||||
}
|
</PrimaryButton>
|
||||||
>
|
|
||||||
{node.update_requested ? '等待更新中...' : '手动升级 Agent'}
|
|
||||||
</PrimaryButton>
|
|
||||||
) : null}
|
|
||||||
<DangerButton
|
<DangerButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
@@ -332,9 +386,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
/>
|
/>
|
||||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||||
{node.update_requested
|
{node.update_requested
|
||||||
? '已等待节点在下一次心跳后执行更新。'
|
? `已等待节点在下一次心跳后执行${node.update_channel === 'preview' ? '预览版' : '正式版'}更新。`
|
||||||
: node.auto_update_enabled
|
: node.auto_update_enabled
|
||||||
? '节点已启用自动更新。'
|
? '节点已启用正式版自动更新。'
|
||||||
: '当前仅支持手动触发更新。'}
|
: '当前仅支持手动触发更新。'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -567,6 +621,184 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</AppModal>
|
</AppModal>
|
||||||
|
|
||||||
|
<AppModal
|
||||||
|
isOpen={isAgentUpdateModalOpen}
|
||||||
|
onClose={() => setIsAgentUpdateModalOpen(false)}
|
||||||
|
title="Agent 更新"
|
||||||
|
description="默认检查正式版;你也可以手动检查 preview 发布,并选择向当前节点下发对应版本的升级指令。"
|
||||||
|
footer={
|
||||||
|
<div className="flex flex-wrap justify-end gap-3">
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleCheckStableAgentRelease}
|
||||||
|
disabled={isCheckingAgentRelease || updateAgentMutation.isPending}
|
||||||
|
>
|
||||||
|
{isCheckingAgentRelease && selectedReleaseChannel === 'stable'
|
||||||
|
? '检查中...'
|
||||||
|
: '检查正式版'}
|
||||||
|
</SecondaryButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleCheckPreviewAgentRelease}
|
||||||
|
disabled={isCheckingAgentRelease || updateAgentMutation.isPending}
|
||||||
|
>
|
||||||
|
{isCheckingAgentRelease && selectedReleaseChannel === 'preview'
|
||||||
|
? '检查中...'
|
||||||
|
: '检查预览版'}
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleRequestAgentUpdate}
|
||||||
|
disabled={
|
||||||
|
!selectedAgentRelease?.has_update ||
|
||||||
|
updateAgentMutation.isPending ||
|
||||||
|
isCheckingAgentRelease ||
|
||||||
|
node.update_requested
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{updateAgentMutation.isPending
|
||||||
|
? '下发中...'
|
||||||
|
: selectedReleaseChannel === 'preview'
|
||||||
|
? '升级到预览版'
|
||||||
|
: '升级到正式版'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{agentUpdateFeedback ? (
|
||||||
|
<InlineMessage
|
||||||
|
tone={agentUpdateFeedback.tone}
|
||||||
|
message={agentUpdateFeedback.message}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<AppCard title="当前 Agent 版本">
|
||||||
|
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{node.agent_version || 'unknown'}
|
||||||
|
</p>
|
||||||
|
</AppCard>
|
||||||
|
<AppCard title="检查通道">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{selectedReleaseChannel === 'preview' ? '预览版' : '正式版'}
|
||||||
|
</p>
|
||||||
|
<StatusBadge
|
||||||
|
label={
|
||||||
|
selectedReleaseChannel === 'preview' ? 'Preview' : 'Stable'
|
||||||
|
}
|
||||||
|
variant={
|
||||||
|
selectedReleaseChannel === 'preview' ? 'warning' : 'info'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
<AppCard title="更新状态">
|
||||||
|
<StatusBadge
|
||||||
|
label={
|
||||||
|
node.update_requested
|
||||||
|
? node.update_channel === 'preview'
|
||||||
|
? '等待预览更新'
|
||||||
|
: '等待更新'
|
||||||
|
: '未下发'
|
||||||
|
}
|
||||||
|
variant={node.update_requested ? 'warning' : 'info'}
|
||||||
|
/>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCheckingAgentRelease && !selectedAgentRelease ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : null}
|
||||||
|
{!isCheckingAgentRelease && selectedAgentReleaseError ? (
|
||||||
|
<ErrorState
|
||||||
|
title="Agent 版本检查失败"
|
||||||
|
description={getErrorMessage(selectedAgentReleaseError)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!isCheckingAgentRelease &&
|
||||||
|
!selectedAgentReleaseError &&
|
||||||
|
!selectedAgentRelease ? (
|
||||||
|
<EmptyState
|
||||||
|
title="尚未检查 Agent 更新"
|
||||||
|
description="点击“检查正式版”或“检查预览版”后,会在这里显示对应发布信息。"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedAgentRelease ? (
|
||||||
|
<AppCard
|
||||||
|
title={`GitHub ${selectedReleaseChannel === 'preview' ? '预览版' : '正式版'} · ${selectedAgentRelease.tag_name || '未找到版本'}`}
|
||||||
|
description={
|
||||||
|
selectedAgentRelease.published_at
|
||||||
|
? `发布时间:${formatRelativeTime(selectedAgentRelease.published_at)} · ${formatDateTime(selectedAgentRelease.published_at)}`
|
||||||
|
: '未提供发布时间'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<StatusBadge
|
||||||
|
label={
|
||||||
|
selectedAgentRelease.has_update
|
||||||
|
? '发现可升级版本'
|
||||||
|
: '当前已是最新版本'
|
||||||
|
}
|
||||||
|
variant={
|
||||||
|
selectedAgentRelease.has_update ? 'warning' : 'success'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{selectedAgentRelease.prerelease ? (
|
||||||
|
<StatusBadge label="Preview 发布" variant="warning" />
|
||||||
|
) : (
|
||||||
|
<StatusBadge label="正式发布" variant="info" />
|
||||||
|
)}
|
||||||
|
{node.update_requested ? (
|
||||||
|
<StatusBadge
|
||||||
|
label={`已下发${node.update_channel === 'preview' ? '预览版' : '正式版'}更新`}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
) : 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)]">
|
||||||
|
{selectedAgentRelease.current_version || 'unknown'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
目标版本
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{selectedAgentRelease.tag_name || '未找到'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm leading-6 whitespace-pre-wrap text-[var(--foreground-secondary)]">
|
||||||
|
{selectedAgentRelease.body || '暂无更新说明'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedAgentRelease.html_url ? (
|
||||||
|
<a
|
||||||
|
href={selectedAgentRelease.html_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
||||||
|
>
|
||||||
|
查看发布详情
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</AppModal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,52 @@
|
|||||||
|
import type { ReleaseChannel } from '@/features/update/types';
|
||||||
|
|
||||||
export interface NodeItem {
|
export interface NodeItem {
|
||||||
id: number;
|
id: number;
|
||||||
node_id: string;
|
node_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
agent_token: string;
|
agent_token: string;
|
||||||
auto_update_enabled: boolean;
|
auto_update_enabled: boolean;
|
||||||
update_requested: boolean;
|
update_requested: boolean;
|
||||||
agent_version: string;
|
update_channel: ReleaseChannel;
|
||||||
nginx_version: string;
|
update_tag: string;
|
||||||
status: 'online' | 'offline' | 'pending';
|
agent_version: string;
|
||||||
current_version: string;
|
nginx_version: string;
|
||||||
last_seen_at: string;
|
status: 'online' | 'offline' | 'pending';
|
||||||
last_error: string;
|
current_version: string;
|
||||||
latest_apply_result: 'success' | 'failed' | '';
|
last_seen_at: string;
|
||||||
latest_apply_message: string;
|
last_error: string;
|
||||||
latest_apply_at?: string | null;
|
latest_apply_result: 'success' | 'failed' | '';
|
||||||
created_at: string;
|
latest_apply_message: string;
|
||||||
updated_at: string;
|
latest_apply_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeBootstrapToken {
|
export interface NodeBootstrapToken {
|
||||||
discovery_token: string;
|
discovery_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeMutationPayload {
|
export interface NodeMutationPayload {
|
||||||
name: string;
|
name: string;
|
||||||
auto_update_enabled: boolean;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export function getApplyLabel(result: NodeItem['latest_apply_result']) {
|
|||||||
|
|
||||||
export function getUpdateMode(node: NodeItem) {
|
export function getUpdateMode(node: NodeItem) {
|
||||||
if (node.update_requested) {
|
if (node.update_requested) {
|
||||||
|
if (node.update_channel === 'preview') {
|
||||||
|
return { label: '等待预览更新', variant: 'warning' as const };
|
||||||
|
}
|
||||||
|
|
||||||
return { label: '等待更新', variant: 'warning' as const };
|
return { label: '等待更新', variant: 'warning' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ import { apiRequest } from '@/lib/api/client';
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
LatestReleaseInfo,
|
LatestReleaseInfo,
|
||||||
|
ReleaseChannel,
|
||||||
UploadedServerBinaryInfo,
|
UploadedServerBinaryInfo,
|
||||||
} from '@/features/update/types';
|
} from '@/features/update/types';
|
||||||
|
|
||||||
export function getLatestRelease() {
|
export function getLatestRelease(channel: ReleaseChannel = 'stable') {
|
||||||
return apiRequest<LatestReleaseInfo>('/update/latest-release');
|
return apiRequest<LatestReleaseInfo>(
|
||||||
|
`/update/latest-release?channel=${channel}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upgradeServer() {
|
export function upgradeServer(channel: ReleaseChannel = 'stable') {
|
||||||
return apiRequest<LatestReleaseInfo>('/update/upgrade', {
|
return apiRequest<LatestReleaseInfo>('/update/upgrade', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ channel }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { AppModal } from '@/components/ui/app-modal';
|
|||||||
import { StatusBadge } from '@/components/ui/status-badge';
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
import type {
|
import type {
|
||||||
LatestReleaseInfo,
|
LatestReleaseInfo,
|
||||||
|
ReleaseChannel,
|
||||||
UploadedServerBinaryInfo,
|
UploadedServerBinaryInfo,
|
||||||
} from '@/features/update/types';
|
} from '@/features/update/types';
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +29,7 @@ interface VersionUpgradeModalProps {
|
|||||||
frontendVersion: string;
|
frontendVersion: string;
|
||||||
startTime?: number;
|
startTime?: number;
|
||||||
release: LatestReleaseInfo | null | undefined;
|
release: LatestReleaseInfo | null | undefined;
|
||||||
|
selectedChannel: ReleaseChannel;
|
||||||
uploadedBinary: UploadedServerBinaryInfo | null;
|
uploadedBinary: UploadedServerBinaryInfo | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
releaseErrorMessage?: string;
|
releaseErrorMessage?: string;
|
||||||
@@ -38,7 +40,8 @@ interface VersionUpgradeModalProps {
|
|||||||
isUpgrading: boolean;
|
isUpgrading: boolean;
|
||||||
isUploadingBinary: boolean;
|
isUploadingBinary: boolean;
|
||||||
isConfirmingManualUpgrade: boolean;
|
isConfirmingManualUpgrade: boolean;
|
||||||
onRefresh: () => void;
|
onCheckStable: () => void;
|
||||||
|
onCheckPreview: () => void;
|
||||||
onUpgrade: () => void;
|
onUpgrade: () => void;
|
||||||
onUploadBinary: (file: File) => void;
|
onUploadBinary: (file: File) => void;
|
||||||
onConfirmManualUpgrade: () => void;
|
onConfirmManualUpgrade: () => void;
|
||||||
@@ -64,6 +67,7 @@ export function VersionUpgradeModal({
|
|||||||
frontendVersion,
|
frontendVersion,
|
||||||
startTime,
|
startTime,
|
||||||
release,
|
release,
|
||||||
|
selectedChannel,
|
||||||
uploadedBinary,
|
uploadedBinary,
|
||||||
isLoading,
|
isLoading,
|
||||||
releaseErrorMessage,
|
releaseErrorMessage,
|
||||||
@@ -74,13 +78,16 @@ export function VersionUpgradeModal({
|
|||||||
isUpgrading,
|
isUpgrading,
|
||||||
isUploadingBinary,
|
isUploadingBinary,
|
||||||
isConfirmingManualUpgrade,
|
isConfirmingManualUpgrade,
|
||||||
onRefresh,
|
onCheckStable,
|
||||||
|
onCheckPreview,
|
||||||
onUpgrade,
|
onUpgrade,
|
||||||
onUploadBinary,
|
onUploadBinary,
|
||||||
onConfirmManualUpgrade,
|
onConfirmManualUpgrade,
|
||||||
}: VersionUpgradeModalProps) {
|
}: VersionUpgradeModalProps) {
|
||||||
const upgradeBadge = getUpgradeBadge(release);
|
const upgradeBadge = getUpgradeBadge(release);
|
||||||
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
|
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
|
||||||
|
const selectedChannelLabel =
|
||||||
|
selectedChannel === 'preview' ? '预览版' : '正式版';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -93,17 +100,28 @@ export function VersionUpgradeModal({
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="版本"
|
title="版本"
|
||||||
description="在这里检查 GitHub 最新版本,或手动上传 Server 二进制并确认升级。升级开始后服务会短暂重启。"
|
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级,或上传 Server 二进制确认升级。升级开始后服务会短暂重启。"
|
||||||
size="lg"
|
size="lg"
|
||||||
footer={
|
footer={
|
||||||
canUpgrade ? (
|
canUpgrade ? (
|
||||||
<div className="flex flex-wrap justify-end gap-3">
|
<div className="flex flex-wrap justify-end gap-3">
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRefresh}
|
onClick={onCheckStable}
|
||||||
disabled={isChecking || isUpgrading || isUploadingBinary}
|
disabled={isChecking || isUpgrading || isUploadingBinary}
|
||||||
>
|
>
|
||||||
{isChecking ? '检查中...' : '检查更新'}
|
{isChecking && selectedChannel === 'stable'
|
||||||
|
? '检查中...'
|
||||||
|
: '检查正式版'}
|
||||||
|
</SecondaryButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={onCheckPreview}
|
||||||
|
disabled={isChecking || isUpgrading || isUploadingBinary}
|
||||||
|
>
|
||||||
|
{isChecking && selectedChannel === 'preview'
|
||||||
|
? '检查中...'
|
||||||
|
: '检查预览版'}
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -149,7 +167,9 @@ export function VersionUpgradeModal({
|
|||||||
? '升级中...'
|
? '升级中...'
|
||||||
: release?.in_progress
|
: release?.in_progress
|
||||||
? '升级中...'
|
? '升级中...'
|
||||||
: '立即升级'}
|
: selectedChannel === 'preview'
|
||||||
|
? '升级到预览版'
|
||||||
|
: '升级到正式版'}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -174,9 +194,15 @@ export function VersionUpgradeModal({
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
<AppCard title="最新版本">
|
<AppCard title="最新版本">
|
||||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{release?.tag_name || '未检查'}
|
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
</p>
|
{release?.tag_name || '未检查'}
|
||||||
|
</p>
|
||||||
|
<StatusBadge
|
||||||
|
label={selectedChannelLabel}
|
||||||
|
variant={selectedChannel === 'preview' ? 'warning' : 'info'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
<AppCard title="启动时间">
|
<AppCard title="启动时间">
|
||||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
@@ -191,13 +217,13 @@ export function VersionUpgradeModal({
|
|||||||
) : null}
|
) : null}
|
||||||
{!isLoading && !releaseErrorMessage && !release ? (
|
{!isLoading && !releaseErrorMessage && !release ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="尚未检查更新"
|
title={`尚未检查${selectedChannelLabel}`}
|
||||||
description="点击“检查更新”后会在这里展示最新 GitHub Release 信息。"
|
description={`点击“检查${selectedChannelLabel}”后会在这里展示对应 GitHub Release 信息。`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isLoading && !releaseErrorMessage && release ? (
|
{!isLoading && !releaseErrorMessage && release ? (
|
||||||
<AppCard
|
<AppCard
|
||||||
title={`GitHub Release · ${release.tag_name}`}
|
title={`GitHub ${selectedChannelLabel} · ${release.tag_name}`}
|
||||||
description={
|
description={
|
||||||
release.published_at
|
release.published_at
|
||||||
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
||||||
@@ -210,6 +236,11 @@ export function VersionUpgradeModal({
|
|||||||
label={release.has_update ? '发现新版本' : '已经是最新版本'}
|
label={release.has_update ? '发现新版本' : '已经是最新版本'}
|
||||||
variant={release.has_update ? 'warning' : 'success'}
|
variant={release.has_update ? 'warning' : 'success'}
|
||||||
/>
|
/>
|
||||||
|
{release.prerelease ? (
|
||||||
|
<StatusBadge label="Preview 发布" variant="warning" />
|
||||||
|
) : (
|
||||||
|
<StatusBadge label="正式发布" variant="info" />
|
||||||
|
)}
|
||||||
{!release.upgrade_supported ? (
|
{!release.upgrade_supported ? (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label="当前平台不支持自动升级"
|
label="当前平台不支持自动升级"
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
export type ReleaseChannel = 'stable' | 'preview';
|
||||||
|
|
||||||
export interface LatestReleaseInfo {
|
export interface LatestReleaseInfo {
|
||||||
tag_name: string;
|
tag_name: string;
|
||||||
body: string;
|
body: string;
|
||||||
html_url: string;
|
html_url: string;
|
||||||
published_at: string;
|
published_at: string;
|
||||||
|
channel: ReleaseChannel;
|
||||||
|
prerelease: boolean;
|
||||||
current_version: string;
|
current_version: string;
|
||||||
has_update: boolean;
|
has_update: boolean;
|
||||||
upgrade_supported: boolean;
|
upgrade_supported: boolean;
|
||||||
|
|||||||
+4
-2
@@ -93,10 +93,12 @@ docker compose up -d
|
|||||||
|
|
||||||
版本升级说明:
|
版本升级说明:
|
||||||
|
|
||||||
* Root 用户可在管理端顶栏点击「版本」检查 GitHub 最新 Release
|
* Root 用户可在管理端顶栏点击「版本」默认检查正式版 GitHub Release
|
||||||
|
* 若需尝试 preview 版本,可在同一弹窗中手动检查 preview 发布并选择是否升级
|
||||||
* 当前运行的是 Release 二进制且二进制目录可写时,可直接在弹窗内触发 Server 自升级
|
* 当前运行的是 Release 二进制且二进制目录可写时,可直接在弹窗内触发 Server 自升级
|
||||||
* 自升级会下载匹配当前平台的 `atsflare-server-*` 资产,替换当前二进制并自动重启进程
|
* Server 自升级会下载匹配当前平台的 `atsflare-server-*` 资产,替换当前二进制并自动重启进程
|
||||||
* 也可在同一弹窗中手动上传 Server 二进制,服务端先检测上传文件版本,前端确认后再执行替换与重启
|
* 也可在同一弹窗中手动上传 Server 二进制,服务端先检测上传文件版本,前端确认后再执行替换与重启
|
||||||
|
* 节点 Agent 默认仅跟随正式版自动更新;如需 preview 版本,可在节点详情中手动检查 preview 发布并下发更新
|
||||||
|
|
||||||
### 2.4 首次登录
|
### 2.4 首次登录
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -27,8 +27,8 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部
|
|||||||
* 节点管理、节点专属 `agent_token`、全局 `discovery_token`
|
* 节点管理、节点专属 `agent_token`、全局 `discovery_token`
|
||||||
* 配置变更摘要
|
* 配置变更摘要
|
||||||
* Agent 运行参数下发
|
* Agent 运行参数下发
|
||||||
* Agent 自我更新与一键部署
|
* Agent 自我更新、一键部署、正式版默认更新与手动 preview 更新
|
||||||
* Server 版本检查、GitHub 自升级与手动上传二进制确认升级
|
* Server 版本检查、正式版默认自升级、手动 preview 检查升级与手动上传二进制确认升级
|
||||||
* 新版管理端 UI、主题切换与统一交互框架
|
* 新版管理端 UI、主题切换与统一交互框架
|
||||||
|
|
||||||
默认工作方式:
|
默认工作方式:
|
||||||
|
|||||||
Reference in New Issue
Block a user