feat: implement server self-upgrade functionality

- Added update service to fetch the latest server release from GitHub.
- Implemented server upgrade scheduling and execution logic.
- Created platform-specific restart mechanisms for Unix and Windows.
- Introduced API endpoints for fetching the latest release and triggering upgrades.
- Developed UI components for version upgrade modal, including loading and error states.
- Updated documentation to reflect new upgrade features and instructions.
- Refactored settings types to streamline interface definitions.
This commit is contained in:
ryan
2026-03-11 22:05:42 +08:00
parent c0ec718563
commit 6140ed1718
22 changed files with 3332 additions and 2392 deletions
+6 -5
View File
@@ -108,11 +108,12 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/insta
--agent-token YOUR_AGENT_TOKEN
```
说明:
* `--server-url` 替换为实际控制面地址,例如 `http://192.168.1.10:3000`
* Linux 默认安装到 `/opt/atsflare-agent`,并创建 `atsflare-agent` systemd 服务
* 重复执行相同命令可用于升级 Agent 到最新 Release
说明:
* `--server-url` 替换为实际控制面地址,例如 `http://192.168.1.10:3000`
* Linux 默认安装到 `/opt/atsflare-agent`,并创建 `atsflare-agent` systemd 服务
* 重复执行相同命令可用于升级 Agent 到最新 Release
* Root 用户可在管理端顶栏点击“版本”检查最新 Release,并在 Release 二进制部署场景下直接触发 Server 自升级
## 部署说明
+23 -52
View File
@@ -1,27 +1,12 @@
package controller
import (
"encoding/json"
"fmt"
"atsflare/service"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
const latestReleaseURL = "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest"
var updateHTTPClient = &http.Client{
Timeout: 10 * time.Second,
}
type latestReleaseResponse struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
}
// GetLatestRelease godoc
// @Summary Get latest GitHub release
// @Tags Update
@@ -29,40 +14,11 @@ type latestReleaseResponse struct {
// @Success 200 {object} map[string]interface{}
// @Router /api/update/latest-release [get]
func GetLatestRelease(c *gin.Context) {
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil)
release, err := service.GetLatestServerRelease(c.Request.Context())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "创建更新请求失败",
})
return
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "ATSFlare-Server")
resp, err := updateHTTPClient.Do(req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取最新版本失败: %v", err),
})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("GitHub 返回异常状态: %s", resp.Status),
})
return
}
var release latestReleaseResponse
if err = json.NewDecoder(resp.Body).Decode(&release); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "解析最新版本信息失败",
"message": err.Error(),
})
return
}
@@ -74,10 +30,25 @@ func GetLatestRelease(c *gin.Context) {
})
}
func UpdateHTTPClientForTest() *http.Client {
return updateHTTPClient
}
// UpgradeServer godoc
// @Summary Upgrade server binary from latest GitHub release
// @Tags Update
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/update/upgrade [post]
func UpgradeServer(c *gin.Context) {
release, err := service.ScheduleServerUpgrade()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
func SetUpdateHTTPClientForTest(client *http.Client) {
updateHTTPClient = client
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "服务升级任务已启动,下载完成后将自动重启。",
"data": release,
})
}
+1
View File
@@ -59,6 +59,7 @@ func SetApiRouter(router *gin.Engine) {
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
{
updateRoute.GET("/latest-release", controller.GetLatestRelease)
updateRoute.POST("/upgrade", controller.UpgradeServer)
}
fileRoute := apiRouter.Group("/file")
fileRoute.Use(middleware.AdminAuth())
+7 -4
View File
@@ -2,8 +2,8 @@ package router_test
import (
"atsflare/common"
"atsflare/controller"
"atsflare/router"
"atsflare/service"
"bytes"
"encoding/json"
"io"
@@ -28,8 +28,8 @@ func TestLatestReleaseProxy(t *testing.T) {
common.RedisEnabled = false
setupTestDB(t)
originalClient := controller.UpdateHTTPClientForTest()
controller.SetUpdateHTTPClientForTest(&http.Client{
originalClient := service.UpdateHTTPClientForTest()
service.SetUpdateHTTPClientForTest(&http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.String() != "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest" {
t.Fatalf("unexpected request url: %s", req.URL.String())
@@ -53,7 +53,7 @@ func TestLatestReleaseProxy(t *testing.T) {
}),
})
t.Cleanup(func() {
controller.SetUpdateHTTPClientForTest(originalClient)
service.SetUpdateHTTPClientForTest(originalClient)
})
engine := gin.New()
@@ -101,4 +101,7 @@ func TestLatestReleaseProxy(t *testing.T) {
if data["tag_name"] != "v1.2.3" {
t.Fatalf("unexpected tag_name: %#v", data["tag_name"])
}
if data["current_version"] != common.Version {
t.Fatalf("unexpected current_version: %#v", data["current_version"])
}
}
+326
View File
@@ -0,0 +1,326 @@
package service
import (
"atsflare/common"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
const latestReleaseURL = "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest"
var updateHTTPClient = &http.Client{
Timeout: 30 * time.Second,
}
var serverUpgradeState struct {
sync.Mutex
inProgress bool
}
type LatestServerRelease 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"`
UpgradeSupported bool `json:"upgrade_supported"`
InProgress bool `json:"in_progress"`
}
type githubReleaseResponse struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
Assets []githubAsset `json:"assets"`
}
type githubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
type preparedServerUpgrade struct {
release *LatestServerRelease
downloadURL string
execPath string
}
func GetLatestServerRelease(ctx context.Context) (*LatestServerRelease, error) {
release, err := fetchLatestRelease(ctx)
if err != nil {
return nil, err
}
return buildLatestServerReleaseView(release), nil
}
func ScheduleServerUpgrade() (*LatestServerRelease, error) {
serverUpgradeState.Lock()
if serverUpgradeState.inProgress {
serverUpgradeState.Unlock()
return nil, fmt.Errorf("服务升级已在执行中,请稍后再试")
}
prepared, err := prepareServerUpgrade(context.Background())
if err != nil {
serverUpgradeState.Unlock()
return nil, err
}
serverUpgradeState.inProgress = true
serverUpgradeState.Unlock()
prepared.release.InProgress = true
go func(task *preparedServerUpgrade) {
time.Sleep(500 * time.Millisecond)
if err := executeServerUpgrade(task); err != nil {
log.Printf("server self-update failed: %v", err)
serverUpgradeState.Lock()
serverUpgradeState.inProgress = false
serverUpgradeState.Unlock()
}
}(prepared)
return prepared.release, nil
}
func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil)
if err != nil {
return nil, fmt.Errorf("创建更新请求失败")
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "ATSFlare-Server")
resp, err := updateHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取最新版本失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
}
var release githubReleaseResponse
if err = json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("解析最新版本信息失败")
}
return &release, nil
}
func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerRelease {
currentVersion := strings.TrimSpace(common.Version)
hasUpdate := false
if release != nil {
hasUpdate = isVersionNewer(currentVersion, release.TagName)
}
serverUpgradeState.Lock()
inProgress := serverUpgradeState.inProgress
serverUpgradeState.Unlock()
view := &LatestServerRelease{
CurrentVersion: currentVersion,
HasUpdate: hasUpdate,
UpgradeSupported: runtime.GOOS != "windows",
InProgress: inProgress,
}
if release != nil {
view.TagName = release.TagName
view.Body = release.Body
view.HTMLURL = release.HTMLURL
view.PublishedAt = release.PublishedAt
}
return view
}
func prepareServerUpgrade(ctx context.Context) (*preparedServerUpgrade, error) {
release, err := fetchLatestRelease(ctx)
if err != nil {
return nil, err
}
view := buildLatestServerReleaseView(release)
if !view.HasUpdate {
return nil, fmt.Errorf("当前已是最新版本")
}
if !view.UpgradeSupported {
return nil, fmt.Errorf("当前平台暂不支持自动升级")
}
assetName := serverAssetName(runtime.GOOS, runtime.GOARCH)
var downloadURL string
for _, asset := range release.Assets {
if asset.Name == assetName {
downloadURL = asset.BrowserDownloadURL
break
}
}
if downloadURL == "" {
return nil, fmt.Errorf("最新版本缺少当前平台的服务端二进制: %s", assetName)
}
execPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("获取当前服务程序路径失败: %v", err)
}
if err = verifyExecutableDirectoryWritable(execPath); err != nil {
return nil, err
}
return &preparedServerUpgrade{
release: view,
downloadURL: downloadURL,
execPath: execPath,
}, nil
}
func verifyExecutableDirectoryWritable(execPath string) error {
dir := filepath.Dir(execPath)
tempFile, err := os.CreateTemp(dir, "atsflare-server-upgrade-check-*")
if err != nil {
return fmt.Errorf("当前服务二进制目录不可写,无法升级: %v", err)
}
tempPath := tempFile.Name()
if closeErr := tempFile.Close(); closeErr != nil {
_ = os.Remove(tempPath)
return fmt.Errorf("校验服务升级目录失败: %v", closeErr)
}
if err = os.Remove(tempPath); err != nil {
return fmt.Errorf("清理升级校验文件失败: %v", err)
}
return nil
}
func executeServerUpgrade(task *preparedServerUpgrade) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, task.downloadURL, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/octet-stream")
req.Header.Set("User-Agent", "ATSFlare-Server")
resp, err := updateHTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载服务端升级包失败: %s", resp.Status)
}
tmpPath := task.execPath + ".update"
tmpFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return err
}
if _, err = io.Copy(tmpFile, resp.Body); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return err
}
if err = tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath)
return err
}
common.SysLog("server self-update starting: from=" + strings.TrimSpace(common.Version) + " to=" + task.release.TagName)
return replaceAndRestartServer(task.execPath, tmpPath)
}
func serverAssetName(goos string, goarch string) string {
name := fmt.Sprintf("atsflare-server-%s-%s", goos, goarch)
if goos == "windows" {
return name + ".exe"
}
return name
}
func isVersionNewer(current string, latest string) bool {
currentParts := parseVersionParts(current)
latestParts := parseVersionParts(latest)
maxLen := len(currentParts)
if len(latestParts) > maxLen {
maxLen = len(latestParts)
}
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 {
normalized := strings.TrimSpace(strings.TrimPrefix(version, "v"))
if normalized == "" || normalized == "dev" {
return nil
}
segments := strings.Split(normalized, ".")
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 {
parts = append(parts, 0)
continue
}
value, err := strconv.Atoi(numeric.String())
if err != nil {
parts = append(parts, 0)
continue
}
parts = append(parts, value)
}
return parts
}
func UpdateHTTPClientForTest() *http.Client {
return updateHTTPClient
}
func SetUpdateHTTPClientForTest(client *http.Client) {
updateHTTPClient = client
}
@@ -0,0 +1,27 @@
//go:build !windows
package service
import (
"fmt"
"os"
"syscall"
)
func replaceAndRestartServer(execPath string, tmpPath string) error {
backupPath := execPath + ".bak"
_ = os.Remove(backupPath)
if err := os.Rename(execPath, backupPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("备份当前服务端二进制失败: %w", err)
}
if err := os.Rename(tmpPath, execPath); err != nil {
_ = os.Rename(backupPath, execPath)
return fmt.Errorf("替换服务端二进制失败: %w", err)
}
_ = os.Remove(backupPath)
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
return fmt.Errorf("重启服务失败: %w", err)
}
return fmt.Errorf("unreachable after exec")
}
@@ -0,0 +1,55 @@
//go:build windows
package service
import (
"fmt"
"os"
"os/exec"
"strings"
)
func replaceAndRestartServer(execPath string, tmpPath string) error {
backupPath := execPath + ".bak"
scriptPath := execPath + ".update.cmd"
script := fmt.Sprintf(`@echo off
setlocal
:waitloop
move /Y "%s" "%s" >nul 2>nul
if errorlevel 1 (
ping 127.0.0.1 -n 2 >nul
goto waitloop
)
move /Y "%s" "%s" >nul 2>nul
if errorlevel 1 exit /b 1
start "" %s
del /Q "%s" >nul 2>nul
del /Q "%%~f0" >nul 2>nul
`, execPath, backupPath, tmpPath, execPath, buildWindowsCommandLine(execPath, os.Args[1:]), backupPath)
if err := os.WriteFile(scriptPath, []byte(script), 0o700); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("写入升级重启脚本失败: %w", err)
}
cmd := exec.Command("cmd", "/C", "start", "", scriptPath)
if err := cmd.Start(); err != nil {
_ = os.Remove(scriptPath)
_ = os.Remove(tmpPath)
return fmt.Errorf("调度升级重启失败: %w", err)
}
os.Exit(0)
return nil
}
func buildWindowsCommandLine(execPath string, args []string) string {
parts := []string{quoteWindowsArg(execPath)}
for _, arg := range args {
parts = append(parts, quoteWindowsArg(arg))
}
return strings.Join(parts, " ")
}
func quoteWindowsArg(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
+65
View File
@@ -0,0 +1,65 @@
package service
import (
"atsflare/common"
"testing"
)
func TestIsVersionNewer(t *testing.T) {
testCases := []struct {
name string
current string
latest string
expected bool
}{
{name: "newer patch", current: "v1.2.3", latest: "v1.2.4", expected: true},
{name: "same version", current: "v1.2.3", latest: "v1.2.3", expected: false},
{name: "older remote", current: "v1.3.0", latest: "v1.2.9", expected: false},
{name: "double digit segment", current: "v1.9.9", latest: "v1.10.0", expected: true},
{name: "dev build", current: "dev", latest: "v0.4.0", expected: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
actual := isVersionNewer(testCase.current, testCase.latest)
if actual != testCase.expected {
t.Fatalf("unexpected compare result: current=%s latest=%s actual=%v expected=%v", testCase.current, testCase.latest, actual, testCase.expected)
}
})
}
}
func TestBuildLatestServerReleaseView(t *testing.T) {
originalVersion := common.Version
common.Version = "v0.4.0"
t.Cleanup(func() {
common.Version = originalVersion
serverUpgradeState.Lock()
serverUpgradeState.inProgress = false
serverUpgradeState.Unlock()
})
serverUpgradeState.Lock()
serverUpgradeState.inProgress = true
serverUpgradeState.Unlock()
view := buildLatestServerReleaseView(&githubReleaseResponse{
TagName: "v0.5.0",
Body: "release notes",
HTMLURL: "https://github.com/Rain-kl/ATSFlare/releases/tag/v0.5.0",
PublishedAt: "2026-03-11T00:00:00Z",
})
if view.CurrentVersion != "v0.4.0" {
t.Fatalf("unexpected current version: %s", view.CurrentVersion)
}
if !view.HasUpdate {
t.Fatal("expected has_update to be true")
}
if !view.InProgress {
t.Fatal("expected in_progress to reflect upgrade state")
}
if view.TagName != "v0.5.0" {
t.Fatalf("unexpected tag name: %s", view.TagName)
}
}
@@ -1,10 +1,14 @@
'use client';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/providers/auth-provider';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { getPublicStatus } from '@/features/auth/api/public';
import { getLatestRelease, upgradeServer } from '@/features/update/api/update';
import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal';
import { publicEnv } from '@/lib/env/public-env';
import { useAppShellStore } from '@/store/app-shell';
@@ -12,11 +16,45 @@ export function DashboardTopbar() {
const router = useRouter();
const { logout, user } = useAuth();
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
const isMobileSidebarOpen = useAppShellStore((state) => state.isMobileSidebarOpen);
const setMobileSidebarOpen = useAppShellStore((state) => state.setMobileSidebarOpen);
const isMobileSidebarOpen = useAppShellStore(
(state) => state.isMobileSidebarOpen,
);
const setMobileSidebarOpen = useAppShellStore(
(state) => state.setMobileSidebarOpen,
);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
const [versionFeedback, setVersionFeedback] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const isRoot = (user?.role ?? 0) >= 100;
const publicStatusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const latestReleaseQuery = useQuery({
queryKey: ['update', 'latest-release'],
queryFn: getLatestRelease,
enabled: isRoot,
refetchInterval: 60 * 60 * 1000,
});
const upgradeMutation = useMutation({
mutationFn: upgradeServer,
onSuccess: (release) => {
setVersionFeedback(
`服务升级任务已启动,目标版本 ${release.tag_name}。页面可能短暂不可用。`,
);
void latestReleaseQuery.refetch();
},
onError: (error) => {
setVersionFeedback(
error instanceof Error ? error.message : '升级失败,请稍后重试。',
);
},
});
useEffect(() => {
if (!isUserMenuOpen) {
@@ -60,60 +98,127 @@ export function DashboardTopbar() {
toggleSidebar();
};
const handleOpenVersionModal = () => {
setVersionFeedback(null);
setIsVersionModalOpen(true);
if (isRoot) {
void latestReleaseQuery.refetch();
}
};
const handleUpgrade = () => {
setVersionFeedback(null);
upgradeMutation.mutate();
};
const release = latestReleaseQuery.data;
const hasUpdate = Boolean(isRoot && release?.has_update);
const currentVersion = publicStatusQuery.data?.version || 'unknown';
const versionLabel = hasUpdate
? `版本 ${publicEnv.appVersion} · 可升级`
: `版本 ${publicEnv.appVersion}`;
const versionButtonClassName = hasUpdate
? 'border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] text-[var(--status-warning-foreground)]'
: 'border-[var(--border-default)]';
const versionErrorMessage =
versionFeedback ||
(latestReleaseQuery.isError
? latestReleaseQuery.error instanceof Error
? latestReleaseQuery.error.message
: '版本检查失败,请稍后重试。'
: undefined);
return (
<header className='sticky top-0 z-20 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/88 px-4 py-4 backdrop-blur md:px-8'>
<div className='flex items-center justify-between gap-3'>
<button
type='button'
onClick={handleSidebarToggle}
className='inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
aria-label='切换侧边栏'
>
☰
</button>
<>
<header className="sticky top-0 z-20 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/88 px-4 py-4 backdrop-blur md:px-8">
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={handleSidebarToggle}
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
aria-label="切换侧边栏"
>
☰
</button>
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
<span className='hidden rounded-full border border-[var(--border-default)] px-3 py-1.5 sm:inline-flex'>
版本 {publicEnv.appVersion}
</span>
<ThemeToggle />
<div className='relative' ref={menuRef}>
<div className="flex items-center gap-3 text-sm text-[var(--foreground-secondary)]">
<button
type='button'
onClick={() => setIsUserMenuOpen((value) => !value)}
className='inline-flex h-11 items-center gap-2 rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
aria-expanded={isUserMenuOpen}
aria-haspopup='menu'
type="button"
onClick={handleOpenVersionModal}
className={[
'inline-flex rounded-full border px-3 py-1.5 transition',
versionButtonClassName,
].join(' ')}
>
<span className='inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--accent-soft)] text-xs font-semibold'>
{(user?.display_name || user?.username || 'U').slice(0, 1).toUpperCase()}
</span>
<span className='hidden sm:inline'>{user?.display_name || user?.username || '用户'}</span>
<span className="sm:hidden">版本</span>
<span className="hidden sm:inline">{versionLabel}</span>
</button>
<ThemeToggle />
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setIsUserMenuOpen((value) => !value)}
className="inline-flex h-11 items-center gap-2 rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
aria-expanded={isUserMenuOpen}
aria-haspopup="menu"
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--accent-soft)] text-xs font-semibold">
{(user?.display_name || user?.username || 'U')
.slice(0, 1)
.toUpperCase()}
</span>
<span className="hidden sm:inline">
{user?.display_name || user?.username || '用户'}
</span>
</button>
{isUserMenuOpen ? (
<div className='absolute right-0 top-[calc(100%+0.5rem)] w-52 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-2 shadow-[var(--shadow-lg)]'>
<div className='rounded-xl px-3 py-2'>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>
{user?.display_name || user?.username || '用户'}
</p>
{user?.username ? (
<p className='mt-1 text-xs text-[var(--foreground-secondary)]'>@{user.username}</p>
) : null}
{isUserMenuOpen ? (
<div className="absolute top-[calc(100%+0.5rem)] right-0 w-52 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-2 shadow-[var(--shadow-lg)]">
<div className="rounded-xl px-3 py-2">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{user?.display_name || user?.username || '用户'}
</p>
{user?.username ? (
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
@{user.username}
</p>
) : null}
</div>
<button
type="button"
onClick={() => void handleLogout()}
disabled={isLoggingOut}
className="flex w-full items-center rounded-xl px-3 py-2 text-left text-sm text-[var(--status-danger-foreground)] transition hover:bg-[var(--status-danger-soft)] disabled:cursor-not-allowed disabled:opacity-60"
>
{isLoggingOut ? '退出中...' : '退出登录'}
</button>
</div>
<button
type='button'
onClick={() => void handleLogout()}
disabled={isLoggingOut}
className='flex w-full items-center rounded-xl px-3 py-2 text-left text-sm text-[var(--status-danger-foreground)] transition hover:bg-[var(--status-danger-soft)] disabled:cursor-not-allowed disabled:opacity-60'
>
{isLoggingOut ? '退出中...' : '退出登录'}
</button>
</div>
) : null}
) : null}
</div>
</div>
</div>
</div>
</header>
</header>
<VersionUpgradeModal
isOpen={isVersionModalOpen}
onClose={() => setIsVersionModalOpen(false)}
currentVersion={currentVersion}
frontendVersion={publicEnv.appVersion}
startTime={publicStatusQuery.data?.start_time}
release={release}
isLoading={latestReleaseQuery.isLoading && !release && isRoot}
errorMessage={versionErrorMessage}
canUpgrade={isRoot}
isChecking={latestReleaseQuery.isFetching}
isUpgrading={upgradeMutation.isPending}
onRefresh={() => {
setVersionFeedback(null);
if (isRoot) {
void latestReleaseQuery.refetch();
}
}}
onUpgrade={handleUpgrade}
/>
</>
);
}
@@ -14,14 +14,9 @@ export function PublicShell({ children }: PublicShellProps) {
<div className='w-full max-w-3xl rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-panel)]/85 p-8 shadow-[var(--shadow-soft)] backdrop-blur'>
<div className='mb-8 flex flex-col gap-4 border-b border-[var(--border-default)] pb-6 md:flex-row md:items-end md:justify-between'>
<div>
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>ATSFlare</p>
<h1 className='mt-2 text-3xl font-semibold text-[var(--foreground-primary)]'>欢迎使用 ATSFlare 管理端</h1>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
当前已接入认证入口、核心业务模块以及阶段 4 的设置、用户、文件与关于页迁移。
</p>
<p className='text-3xl font-medium text-[var(--brand-primary)]'>ATSFlare</p>
</div>
<div className='flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end'>
<ThemeToggle />
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
{publicEnv.appVersion}
</span>
@@ -33,10 +28,10 @@ export function PublicShell({ children }: PublicShellProps) {
<div className='mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]'>
<div className='flex flex-wrap gap-4'>
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'>
返回新版总览
返回
</Link>
<Link href='/about' className='text-[var(--brand-primary)] transition hover:opacity-80'>
查看关于页
关于
</Link>
</div>
</div>
@@ -94,9 +94,6 @@ export function AboutPage() {
)}
<div className='flex flex-wrap gap-3 text-sm'>
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'>
返回新版总览
</Link>
<Link href='/login' className='text-[var(--brand-primary)] transition hover:opacity-80'>
前往登录
</Link>
@@ -79,7 +79,7 @@ export function LoginForm() {
return (
<PublicAuthGuard>
<AppCard title='用户登录' description='使用现有 ATSFlare 账户登录新版管理端。'>
<AppCard title='用户登录'>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label='用户名'>
<AuthInput placeholder='请输入用户名' {...form.register('username')} />
@@ -115,11 +115,11 @@ export function LoginForm() {
<div className='mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]'>
<Link href='/reset' className='text-[var(--brand-primary)] transition hover:opacity-80'>
忘记密码?点击重置
忘记密码?
</Link>
<span>·</span>
<Link href='/register' className='text-[var(--brand-primary)] transition hover:opacity-80'>
没有账户?点击注册
注册
</Link>
</div>
</AppCard>
File diff suppressed because it is too large Load Diff
@@ -1,62 +1,48 @@
import { apiRequest } from '@/lib/api/client';
import type {
BootstrapTokenPayload,
LatestReleaseInfo,
OptionItem,
SettingsProfile,
UpdateSelfPayload,
} from '@/features/settings/types';
export function getOptions() {
return apiRequest<OptionItem[]>('/option/');
}
export function updateOption(key: string, value: string) {
return apiRequest<void>('/option/', {
method: 'PUT',
body: JSON.stringify({ key, value }),
});
}
export function getBootstrapToken() {
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
}
export function rotateBootstrapToken() {
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token/rotate', {
method: 'POST',
});
}
export function getLatestRelease() {
return apiRequest<LatestReleaseInfo>('/update/latest-release');
}
export function getSettingsProfile() {
return apiRequest<SettingsProfile>('/user/self');
}
export function updateSelf(payload: UpdateSelfPayload) {
return apiRequest<void>('/user/self', {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function generateAccessToken() {
return apiRequest<string>('/user/token');
}
export function bindWeChat(code: string) {
return apiRequest<void>(`/oauth/wechat/bind?code=${encodeURIComponent(code)}`);
}
export function bindEmail(email: string, code: string) {
const searchParams = new URLSearchParams({ email, code });
return apiRequest<void>(`/oauth/email/bind?${searchParams.toString()}`);
}
export function getAboutContent() {
return apiRequest<string>('/about');
}
import { apiRequest } from '@/lib/api/client';
import type {
OptionItem,
SettingsProfile,
UpdateSelfPayload,
} from '@/features/settings/types';
export function getOptions() {
return apiRequest<OptionItem[]>('/option/');
}
export function updateOption(key: string, value: string) {
return apiRequest<void>('/option/', {
method: 'PUT',
body: JSON.stringify({ key, value }),
});
}
export function getSettingsProfile() {
return apiRequest<SettingsProfile>('/user/self');
}
export function updateSelf(payload: UpdateSelfPayload) {
return apiRequest<void>('/user/self', {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function generateAccessToken() {
return apiRequest<string>('/user/token');
}
export function bindWeChat(code: string) {
return apiRequest<void>(
`/oauth/wechat/bind?code=${encodeURIComponent(code)}`,
);
}
export function bindEmail(email: string, code: string) {
const searchParams = new URLSearchParams({ email, code });
return apiRequest<void>(`/oauth/email/bind?${searchParams.toString()}`);
}
export function getAboutContent() {
return apiRequest<string>('/about');
}
File diff suppressed because it is too large Load Diff
+14 -25
View File
@@ -1,25 +1,14 @@
import type { AuthUser } from '@/types/auth';
export interface OptionItem {
key: string;
value: string;
}
export interface LatestReleaseInfo {
tag_name: string;
body: string;
html_url: string;
published_at: string;
}
export interface BootstrapTokenPayload {
discovery_token: string;
}
export interface UpdateSelfPayload {
username: string;
display_name: string;
password: string;
}
export type SettingsProfile = AuthUser;
import type { AuthUser } from '@/types/auth';
export interface OptionItem {
key: string;
value: string;
}
export interface UpdateSelfPayload {
username: string;
display_name: string;
password: string;
}
export type SettingsProfile = AuthUser;
@@ -0,0 +1,13 @@
import { apiRequest } from '@/lib/api/client';
import type { LatestReleaseInfo } from '@/features/update/types';
export function getLatestRelease() {
return apiRequest<LatestReleaseInfo>('/update/latest-release');
}
export function upgradeServer() {
return apiRequest<LatestReleaseInfo>('/update/upgrade', {
method: 'POST',
});
}
@@ -0,0 +1,188 @@
'use client';
import { marked } from 'marked';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppCard } from '@/components/ui/app-card';
import { AppModal } from '@/components/ui/app-modal';
import { StatusBadge } from '@/components/ui/status-badge';
import type { LatestReleaseInfo } from '@/features/update/types';
import {
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
interface VersionUpgradeModalProps {
isOpen: boolean;
onClose: () => void;
currentVersion: string;
frontendVersion: string;
startTime?: number;
release: LatestReleaseInfo | null | undefined;
isLoading: boolean;
errorMessage?: string;
canUpgrade: boolean;
isChecking: boolean;
isUpgrading: boolean;
onRefresh: () => void;
onUpgrade: () => void;
}
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
if (!release) {
return { label: '未检查', variant: 'info' as const };
}
if (release.in_progress) {
return { label: '升级中', variant: 'warning' as const };
}
if (release.has_update) {
return { label: '可升级', variant: 'warning' as const };
}
return { label: '最新', variant: 'success' as const };
}
export function VersionUpgradeModal({
isOpen,
onClose,
currentVersion,
frontendVersion,
startTime,
release,
isLoading,
errorMessage,
canUpgrade,
isChecking,
isUpgrading,
onRefresh,
onUpgrade,
}: VersionUpgradeModalProps) {
const upgradeBadge = getUpgradeBadge(release);
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title="版本"
description="在这里检查 GitHub 最新版本,并直接触发 Server 自升级。升级开始后服务会短暂重启。"
size="lg"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={onRefresh}
disabled={isChecking || isUpgrading}
>
{isChecking ? '检查中...' : '检查更新'}
</SecondaryButton>
{canUpgrade ? (
<PrimaryButton
type="button"
onClick={onUpgrade}
disabled={
!release?.has_update ||
release.in_progress ||
isUpgrading ||
!release.upgrade_supported
}
>
{isUpgrading
? '升级中...'
: release?.in_progress
? '升级中...'
: '立即升级'}
</PrimaryButton>
) : null}
</div>
}
>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<AppCard title="前端版本">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{frontendVersion}
</p>
</AppCard>
<AppCard title="服务端版本">
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{currentVersion || 'unknown'}
</p>
<StatusBadge
label={upgradeBadge.label}
variant={upgradeBadge.variant}
/>
</div>
</AppCard>
<AppCard title="最新版本">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{release?.tag_name || '未检查'}
</p>
</AppCard>
<AppCard title="启动时间">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{startTime ? formatDateTime(new Date(startTime * 1000)) : '未知'}
</p>
</AppCard>
</div>
{isLoading ? <LoadingState /> : null}
{!isLoading && errorMessage ? (
<ErrorState title="版本检查失败" description={errorMessage} />
) : null}
{!isLoading && !errorMessage && !release ? (
<EmptyState
title="尚未检查更新"
description="点击“检查更新”后会在这里展示最新 GitHub Release 信息。"
/>
) : null}
{!isLoading && !errorMessage && release ? (
<AppCard
title={`GitHub Release · ${release.tag_name}`}
description={
release.published_at
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
: '未提供发布时间'
}
>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge
label={release.has_update ? '发现新版本' : '已经是最新版本'}
variant={release.has_update ? 'warning' : 'success'}
/>
{!release.upgrade_supported ? (
<StatusBadge
label="当前平台不支持自动升级"
variant="danger"
/>
) : null}
{release.in_progress ? (
<StatusBadge label="升级任务执行中" variant="warning" />
) : null}
</div>
<div
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
dangerouslySetInnerHTML={{
__html: marked.parse(
release.body || '暂无更新说明',
) as string,
}}
/>
<a
href={release.html_url}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
>
查看发布详情
</a>
</div>
</AppCard>
) : null}
</div>
</AppModal>
);
}
+10
View File
@@ -0,0 +1,10 @@
export interface LatestReleaseInfo {
tag_name: string;
body: string;
html_url: string;
published_at: string;
current_version: string;
has_update: boolean;
upgrade_supported: boolean;
in_progress: boolean;
}
+12 -6
View File
@@ -84,12 +84,18 @@ volumes:
docker compose up -d
```
说明:
* `SESSION_SECRET` 必须替换为随机字符串
* SQLite 数据文件持久化到 Docker volume `atsflare-data`
* 镜像默认监听容器内 `3000` 端口
* 若要固定版本,可将 `latest` 替换为具体 tag,例如 `ghcr.io/rain-kl/atsflare:v0.3.0`
说明:
* `SESSION_SECRET` 必须替换为随机字符串
* SQLite 数据文件持久化到 Docker volume `atsflare-data`
* 镜像默认监听容器内 `3000` 端口
* 若要固定版本,可将 `latest` 替换为具体 tag,例如 `ghcr.io/rain-kl/atsflare:v0.3.0`
版本升级说明:
* Root 用户可在管理端顶栏点击「版本」检查 GitHub 最新 Release
* 当前运行的是 Release 二进制且二进制目录可写时,可直接在弹窗内触发 Server 自升级
* 自升级会下载匹配当前平台的 `atsflare-server-*` 资产,替换当前二进制并自动重启进程
### 2.4 首次登录
+7 -6
View File
@@ -20,12 +20,13 @@ ATSFlare 当前仍定位为**内部自用的反向代理控制面**,不是面
* 反代规则管理
* 配置渲染、发布、激活与回滚
* Agent 心跳、同步、应用结果上报
* Nginx 配置写入、校验、reload 与失败回滚
* HTTPS/TLS 路由支持
* 证书托管与域名管理
* 节点预创建、节点专属 `agent_token`、全局 `discovery_token`
* 配置预览与变更摘要
* Agent 心跳、同步、应用结果上报
* Nginx 配置写入、校验、reload 与失败回滚
* HTTPS/TLS 路由支持
* 证书托管与域名管理
* 节点预创建、节点专属 `agent_token`、全局 `discovery_token`
* 配置预览与变更摘要
* Server 版本检查与自升级
当前默认工作方式:
+13 -11
View File
@@ -140,16 +140,18 @@
实施步骤:
1. 节点列表时间显示改为友好格式
2. 节点状态颜色标识
3. Discovery Token 在运维设置面板中可查看和重新生成
4. Agent 部署命令一键复制
验收标准:
* 时间显示为友好的相对时间(如「2 分钟前」)
* 节点状态有颜色区分:在线(绿色)、离线(红色)、待接入(黄色)
* curl 部署命令支持一键复制
1. 节点列表时间显示改为友好格式
2. 节点状态颜色标识
3. 节点专属 Agent 部署命令改为节点列表弹窗展示
4. 版本检查入口迁移到顶栏“版本”,支持可升级提示与 Server 自升级
验收标准:
* 时间显示为友好的相对时间(如「2 分钟前」)
* 节点状态有颜色区分:在线(绿色)、离线(红色)、待接入(黄色)
* 节点列表可弹窗查看并复制节点专属部署命令
* 顶栏版本入口可检查 GitHub 最新 Release,并在存在新版本时显示可升级提示
* Root 用户可从顶栏版本弹窗触发 Server 自升级
### 3.6 并行专项:管理端前端工程改造
@@ -174,4 +176,4 @@
* 每个阶段完成后验证验收标准,再进入下一阶段
* 阶段间的代码不相互依赖时可并行
* 每个阶段完成后运行全量测试
* 管理端前端改造按 [docs/frontend-revamp-plan.md](./frontend-revamp-plan.md) 单独跟踪阶段状态
* 管理端前端改造按 [docs/frontend-revamp-plan.md](./frontend-revamp-plan.md) 单独跟踪阶段状态