mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-14 02:46:37 +08:00
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:
@@ -113,6 +113,7 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/insta
|
|||||||
* `--server-url` 替换为实际控制面地址,例如 `http://192.168.1.10:3000`
|
* `--server-url` 替换为实际控制面地址,例如 `http://192.168.1.10:3000`
|
||||||
* Linux 默认安装到 `/opt/atsflare-agent`,并创建 `atsflare-agent` systemd 服务
|
* Linux 默认安装到 `/opt/atsflare-agent`,并创建 `atsflare-agent` systemd 服务
|
||||||
* 重复执行相同命令可用于升级 Agent 到最新 Release
|
* 重复执行相同命令可用于升级 Agent 到最新 Release
|
||||||
|
* Root 用户可在管理端顶栏点击“版本”检查最新 Release,并在 Release 二进制部署场景下直接触发 Server 自升级
|
||||||
|
|
||||||
|
|
||||||
## 部署说明
|
## 部署说明
|
||||||
|
|||||||
@@ -1,27 +1,12 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"atsflare/service"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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
|
// GetLatestRelease godoc
|
||||||
// @Summary Get latest GitHub release
|
// @Summary Get latest GitHub release
|
||||||
// @Tags Update
|
// @Tags Update
|
||||||
@@ -29,40 +14,11 @@ type latestReleaseResponse 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) {
|
||||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil)
|
release, err := service.GetLatestServerRelease(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "创建更新请求失败",
|
"message": err.Error(),
|
||||||
})
|
|
||||||
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": "解析最新版本信息失败",
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -74,10 +30,25 @@ func GetLatestRelease(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateHTTPClientForTest() *http.Client {
|
// UpgradeServer godoc
|
||||||
return updateHTTPClient
|
// @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) {
|
c.JSON(http.StatusOK, gin.H{
|
||||||
updateHTTPClient = client
|
"success": true,
|
||||||
|
"message": "服务升级任务已启动,下载完成后将自动重启。",
|
||||||
|
"data": release,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
||||||
{
|
{
|
||||||
updateRoute.GET("/latest-release", controller.GetLatestRelease)
|
updateRoute.GET("/latest-release", controller.GetLatestRelease)
|
||||||
|
updateRoute.POST("/upgrade", controller.UpgradeServer)
|
||||||
}
|
}
|
||||||
fileRoute := apiRouter.Group("/file")
|
fileRoute := apiRouter.Group("/file")
|
||||||
fileRoute.Use(middleware.AdminAuth())
|
fileRoute.Use(middleware.AdminAuth())
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package router_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"atsflare/common"
|
"atsflare/common"
|
||||||
"atsflare/controller"
|
|
||||||
"atsflare/router"
|
"atsflare/router"
|
||||||
|
"atsflare/service"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
@@ -28,8 +28,8 @@ func TestLatestReleaseProxy(t *testing.T) {
|
|||||||
common.RedisEnabled = false
|
common.RedisEnabled = false
|
||||||
setupTestDB(t)
|
setupTestDB(t)
|
||||||
|
|
||||||
originalClient := controller.UpdateHTTPClientForTest()
|
originalClient := service.UpdateHTTPClientForTest()
|
||||||
controller.SetUpdateHTTPClientForTest(&http.Client{
|
service.SetUpdateHTTPClientForTest(&http.Client{
|
||||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
if req.URL.String() != "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest" {
|
if req.URL.String() != "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest" {
|
||||||
t.Fatalf("unexpected request url: %s", req.URL.String())
|
t.Fatalf("unexpected request url: %s", req.URL.String())
|
||||||
@@ -53,7 +53,7 @@ func TestLatestReleaseProxy(t *testing.T) {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
controller.SetUpdateHTTPClientForTest(originalClient)
|
service.SetUpdateHTTPClientForTest(originalClient)
|
||||||
})
|
})
|
||||||
|
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
@@ -101,4 +101,7 @@ func TestLatestReleaseProxy(t *testing.T) {
|
|||||||
if data["tag_name"] != "v1.2.3" {
|
if data["tag_name"] != "v1.2.3" {
|
||||||
t.Fatalf("unexpected tag_name: %#v", data["tag_name"])
|
t.Fatalf("unexpected tag_name: %#v", data["tag_name"])
|
||||||
}
|
}
|
||||||
|
if data["current_version"] != common.Version {
|
||||||
|
t.Fatalf("unexpected current_version: %#v", data["current_version"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, `"`, `""`) + `"`
|
||||||
|
}
|
||||||
@@ -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';
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { useAuth } from '@/components/providers/auth-provider';
|
import { useAuth } from '@/components/providers/auth-provider';
|
||||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
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 { publicEnv } from '@/lib/env/public-env';
|
||||||
import { useAppShellStore } from '@/store/app-shell';
|
import { useAppShellStore } from '@/store/app-shell';
|
||||||
|
|
||||||
@@ -12,11 +16,45 @@ export function DashboardTopbar() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { logout, user } = useAuth();
|
const { logout, user } = useAuth();
|
||||||
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
|
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
|
||||||
const isMobileSidebarOpen = useAppShellStore((state) => state.isMobileSidebarOpen);
|
const isMobileSidebarOpen = useAppShellStore(
|
||||||
const setMobileSidebarOpen = useAppShellStore((state) => state.setMobileSidebarOpen);
|
(state) => state.isMobileSidebarOpen,
|
||||||
|
);
|
||||||
|
const setMobileSidebarOpen = useAppShellStore(
|
||||||
|
(state) => state.setMobileSidebarOpen,
|
||||||
|
);
|
||||||
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 [versionFeedback, setVersionFeedback] = useState<string | null>(null);
|
||||||
const menuRef = useRef<HTMLDivElement | 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(() => {
|
useEffect(() => {
|
||||||
if (!isUserMenuOpen) {
|
if (!isUserMenuOpen) {
|
||||||
@@ -60,52 +98,97 @@ export function DashboardTopbar() {
|
|||||||
toggleSidebar();
|
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 (
|
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'>
|
<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
|
<button
|
||||||
type='button'
|
type="button"
|
||||||
onClick={handleSidebarToggle}
|
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)]'
|
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='切换侧边栏'
|
aria-label="切换侧边栏"
|
||||||
>
|
>
|
||||||
☰
|
☰
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
|
<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}>
|
|
||||||
<button
|
<button
|
||||||
type='button'
|
type="button"
|
||||||
onClick={() => setIsUserMenuOpen((value) => !value)}
|
onClick={handleOpenVersionModal}
|
||||||
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)]'
|
className={[
|
||||||
aria-expanded={isUserMenuOpen}
|
'inline-flex rounded-full border px-3 py-1.5 transition',
|
||||||
aria-haspopup='menu'
|
versionButtonClassName,
|
||||||
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<span className='inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--accent-soft)] text-xs font-semibold'>
|
<span className="sm:hidden">版本</span>
|
||||||
{(user?.display_name || user?.username || 'U').slice(0, 1).toUpperCase()}
|
<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>
|
</span>
|
||||||
<span className='hidden sm:inline'>{user?.display_name || user?.username || '用户'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isUserMenuOpen ? (
|
{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="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'>
|
<div className="rounded-xl px-3 py-2">
|
||||||
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>
|
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
{user?.display_name || user?.username || '用户'}
|
{user?.display_name || user?.username || '用户'}
|
||||||
</p>
|
</p>
|
||||||
{user?.username ? (
|
{user?.username ? (
|
||||||
<p className='mt-1 text-xs text-[var(--foreground-secondary)]'>@{user.username}</p>
|
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
|
||||||
|
@{user.username}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type="button"
|
||||||
onClick={() => void handleLogout()}
|
onClick={() => void handleLogout()}
|
||||||
disabled={isLoggingOut}
|
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'
|
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 ? '退出中...' : '退出登录'}
|
{isLoggingOut ? '退出中...' : '退出登录'}
|
||||||
</button>
|
</button>
|
||||||
@@ -115,5 +198,27 @@ export function DashboardTopbar() {
|
|||||||
</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='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 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>
|
<div>
|
||||||
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>ATSFlare</p>
|
<p className='text-3xl font-medium 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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end'>
|
<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'>
|
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
||||||
{publicEnv.appVersion}
|
{publicEnv.appVersion}
|
||||||
</span>
|
</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='mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]'>
|
||||||
<div className='flex flex-wrap gap-4'>
|
<div className='flex flex-wrap gap-4'>
|
||||||
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||||
返回新版总览
|
返回
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/about' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
<Link href='/about' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||||
查看关于页
|
关于
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,9 +94,6 @@ export function AboutPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='flex flex-wrap gap-3 text-sm'>
|
<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 href='/login' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||||
前往登录
|
前往登录
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function LoginForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicAuthGuard>
|
<PublicAuthGuard>
|
||||||
<AppCard title='用户登录' description='使用现有 ATSFlare 账户登录新版管理端。'>
|
<AppCard title='用户登录'>
|
||||||
<form className='space-y-4' onSubmit={handleSubmit}>
|
<form className='space-y-4' onSubmit={handleSubmit}>
|
||||||
<AuthFormField label='用户名'>
|
<AuthFormField label='用户名'>
|
||||||
<AuthInput placeholder='请输入用户名' {...form.register('username')} />
|
<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)]'>
|
<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 href='/reset' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||||
忘记密码?点击重置
|
忘记密码?
|
||||||
</Link>
|
</Link>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<Link href='/register' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
<Link href='/register' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||||
没有账户?点击注册
|
注册
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ import { StatusBadge } from '@/components/ui/status-badge';
|
|||||||
import {
|
import {
|
||||||
createNode,
|
createNode,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
getNodeBootstrapToken,
|
|
||||||
getNodes,
|
getNodes,
|
||||||
requestNodeAgentUpdate,
|
requestNodeAgentUpdate,
|
||||||
rotateNodeBootstrapToken,
|
|
||||||
updateNode,
|
updateNode,
|
||||||
} from '@/features/nodes/api/nodes';
|
} from '@/features/nodes/api/nodes';
|
||||||
import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types';
|
import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types';
|
||||||
@@ -37,12 +35,15 @@ import {
|
|||||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||||
|
|
||||||
const nodesQueryKey = ['nodes'];
|
const nodesQueryKey = ['nodes'];
|
||||||
const bootstrapQueryKey = ['nodes', 'bootstrap-token'];
|
|
||||||
const installerScriptUrl =
|
const installerScriptUrl =
|
||||||
'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh';
|
'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh';
|
||||||
|
|
||||||
const nodeSchema = z.object({
|
const nodeSchema = z.object({
|
||||||
name: z.string().trim().min(1, '请输入节点名').max(128, '节点名不能超过 128 个字符'),
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, '请输入节点名')
|
||||||
|
.max(128, '节点名不能超过 128 个字符'),
|
||||||
auto_update_enabled: z.boolean(),
|
auto_update_enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,14 +138,6 @@ function getServerUrl(value: string) {
|
|||||||
return value.trim().replace(/\/+$/, '');
|
return value.trim().replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDiscoveryInstallCommand(serverUrl: string, discoveryToken: string) {
|
|
||||||
return [
|
|
||||||
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
|
|
||||||
` --server-url ${serverUrl} \\`,
|
|
||||||
` --discovery-token ${discoveryToken}`,
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
|
function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
|
||||||
return [
|
return [
|
||||||
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
|
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
|
||||||
@@ -162,6 +155,7 @@ export function NodesPage() {
|
|||||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||||
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
||||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||||
|
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false);
|
||||||
const [selectedNode, setSelectedNode] = useState<NodeItem | null>(null);
|
const [selectedNode, setSelectedNode] = useState<NodeItem | null>(null);
|
||||||
const [serverUrl, setServerUrl] = useState('');
|
const [serverUrl, setServerUrl] = useState('');
|
||||||
const [, setRefreshTick] = useState(0);
|
const [, setRefreshTick] = useState(0);
|
||||||
@@ -181,11 +175,6 @@ export function NodesPage() {
|
|||||||
queryFn: getNodes,
|
queryFn: getNodes,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bootstrapQuery = useQuery({
|
|
||||||
queryKey: bootstrapQueryKey,
|
|
||||||
queryFn: getNodeBootstrapToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined' && !serverUrl) {
|
if (typeof window !== 'undefined' && !serverUrl) {
|
||||||
setServerUrl(window.location.origin);
|
setServerUrl(window.location.origin);
|
||||||
@@ -206,7 +195,9 @@ export function NodesPage() {
|
|||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (values: NodeFormValues) => {
|
mutationFn: async (values: NodeFormValues) => {
|
||||||
const payload = toPayload(values);
|
const payload = toPayload(values);
|
||||||
return editingNodeId ? updateNode(editingNodeId, payload) : createNode(payload);
|
return editingNodeId
|
||||||
|
? updateNode(editingNodeId, payload)
|
||||||
|
: createNode(payload);
|
||||||
},
|
},
|
||||||
onSuccess: async (node) => {
|
onSuccess: async (node) => {
|
||||||
setFeedback({
|
setFeedback({
|
||||||
@@ -217,21 +208,7 @@ export function NodesPage() {
|
|||||||
setIsEditorOpen(false);
|
setIsEditorOpen(false);
|
||||||
setSelectedNode(node);
|
setSelectedNode(node);
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
await Promise.all([
|
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||||
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
|
|
||||||
queryClient.invalidateQueries({ queryKey: bootstrapQueryKey }),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rotateMutation = useMutation({
|
|
||||||
mutationFn: rotateNodeBootstrapToken,
|
|
||||||
onSuccess: async () => {
|
|
||||||
setFeedback({ tone: 'success', message: '全局 Discovery Token 已重新生成。' });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: bootstrapQueryKey });
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
@@ -241,7 +218,10 @@ export function NodesPage() {
|
|||||||
const updateAgentMutation = useMutation({
|
const updateAgentMutation = useMutation({
|
||||||
mutationFn: requestNodeAgentUpdate,
|
mutationFn: requestNodeAgentUpdate,
|
||||||
onSuccess: async (node) => {
|
onSuccess: async (node) => {
|
||||||
setFeedback({ tone: 'success', message: `已向节点 ${node.name} 下发更新指令。` });
|
setFeedback({
|
||||||
|
tone: 'success',
|
||||||
|
message: `已向节点 ${node.name} 下发更新指令。`,
|
||||||
|
});
|
||||||
setSelectedNode(node);
|
setSelectedNode(node);
|
||||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||||
},
|
},
|
||||||
@@ -265,7 +245,6 @@ export function NodesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
|
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
|
||||||
const bootstrapToken = bootstrapQuery.data?.discovery_token ?? '';
|
|
||||||
const normalizedServerUrl = getServerUrl(serverUrl);
|
const normalizedServerUrl = getServerUrl(serverUrl);
|
||||||
|
|
||||||
const selectedNodeFromList = useMemo(() => {
|
const selectedNodeFromList = useMemo(() => {
|
||||||
@@ -301,9 +280,17 @@ export function NodesPage() {
|
|||||||
setIsEditorOpen(true);
|
setIsEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenDeployModal = (node: NodeItem) => {
|
||||||
|
setFeedback(null);
|
||||||
|
setSelectedNode(node);
|
||||||
|
setIsDeployModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = (node: NodeItem) => {
|
const handleDelete = (node: NodeItem) => {
|
||||||
if (
|
if (
|
||||||
!window.confirm(`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`)
|
!window.confirm(
|
||||||
|
`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -312,15 +299,6 @@ export function NodesPage() {
|
|||||||
deleteMutation.mutate(node.id);
|
deleteMutation.mutate(node.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRotateToken = () => {
|
|
||||||
if (!window.confirm('确认重新生成全局 Discovery Token 吗?现有批量部署命令需要同步更新。')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFeedback(null);
|
|
||||||
rotateMutation.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = async (value: string, successMessage: string) => {
|
const handleCopy = async (value: string, successMessage: string) => {
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(value);
|
await copyToClipboard(value);
|
||||||
@@ -335,30 +313,28 @@ export function NodesPage() {
|
|||||||
saveMutation.mutate(values);
|
saveMutation.mutate(values);
|
||||||
});
|
});
|
||||||
|
|
||||||
const discoveryCommand =
|
|
||||||
normalizedServerUrl && bootstrapToken
|
|
||||||
? buildDiscoveryInstallCommand(normalizedServerUrl, bootstrapToken)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const nodeInstallCommand =
|
const nodeInstallCommand =
|
||||||
normalizedServerUrl && selectedNodeFromList?.agent_token
|
normalizedServerUrl && selectedNodeFromList?.agent_token
|
||||||
? buildNodeInstallCommand(normalizedServerUrl, selectedNodeFromList.agent_token)
|
? buildNodeInstallCommand(
|
||||||
|
normalizedServerUrl,
|
||||||
|
selectedNodeFromList.agent_token,
|
||||||
|
)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='space-y-6'>
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title='节点管理'
|
title="节点管理"
|
||||||
description='查看节点在线状态、最近心跳、部署命令与 Agent 更新动作,并支持预创建节点。'
|
description="查看节点在线状态、最近心跳、部署入口与 Agent 更新动作,并支持预创建节点。"
|
||||||
action={
|
action={
|
||||||
<>
|
<>
|
||||||
<SecondaryButton type='button' onClick={handleCreate}>
|
<SecondaryButton type="button" onClick={handleCreate}>
|
||||||
新增节点
|
新增节点
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<Link
|
<Link
|
||||||
href='/apply-log'
|
href="/apply-log"
|
||||||
className='inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
|
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||||
>
|
>
|
||||||
应用记录
|
应用记录
|
||||||
</Link>
|
</Link>
|
||||||
@@ -366,59 +342,19 @@ export function NodesPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
{feedback ? (
|
||||||
|
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||||
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
|
) : null}
|
||||||
<AppCard
|
|
||||||
title='节点专属部署命令'
|
|
||||||
description='适用于已在管理端预创建节点的场景,可避免首次接入时匿名自动命名。'
|
|
||||||
action={
|
|
||||||
nodeInstallCommand ? (
|
|
||||||
<PrimaryButton
|
|
||||||
type='button'
|
|
||||||
onClick={() => void handleCopy(nodeInstallCommand, '节点专属部署命令已复制。')}
|
|
||||||
>
|
|
||||||
复制命令
|
|
||||||
</PrimaryButton>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{selectedNodeFromList ? (
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
||||||
<div className='flex items-center justify-between gap-3'>
|
|
||||||
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>{selectedNodeFromList.name}</p>
|
|
||||||
<StatusBadge
|
|
||||||
label={selectedNodeFromList.pending ? '未占用' : '已绑定'}
|
|
||||||
variant={selectedNodeFromList.pending ? 'warning' : 'success'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className='mt-2 text-xs text-[var(--foreground-secondary)]'>Node ID:{selectedNodeFromList.node_id}</p>
|
|
||||||
</div>
|
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
||||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>Agent Token</p>
|
|
||||||
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
|
|
||||||
{selectedNodeFromList.agent_token || '暂无'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{nodeInstallCommand ? <CodeBlock className='whitespace-pre-wrap'>{nodeInstallCommand}</CodeBlock> : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState title='尚未选择节点' description='可在下方节点列表中点击“部署命令”或创建节点后自动查看专属命令。' />
|
|
||||||
)}
|
|
||||||
</AppCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppCard
|
<AppCard
|
||||||
title='节点列表'
|
title="节点列表"
|
||||||
description='展示心跳、最近应用结果、版本信息与更新状态,可直接执行管理动作。'
|
description="展示心跳、最近应用结果、版本信息与更新状态,可直接执行管理动作。"
|
||||||
action={
|
action={
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
type='button'
|
type="button"
|
||||||
onClick={() => void queryClient.invalidateQueries({ queryKey: nodesQueryKey })}
|
onClick={() =>
|
||||||
|
void queryClient.invalidateQueries({ queryKey: nodesQueryKey })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
刷新列表
|
刷新列表
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
@@ -427,114 +363,140 @@ export function NodesPage() {
|
|||||||
{nodesQuery.isLoading ? (
|
{nodesQuery.isLoading ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : nodesQuery.isError ? (
|
) : nodesQuery.isError ? (
|
||||||
<ErrorState title='节点列表加载失败' description={getErrorMessage(nodesQuery.error)} />
|
<ErrorState
|
||||||
|
title="节点列表加载失败"
|
||||||
|
description={getErrorMessage(nodesQuery.error)}
|
||||||
|
/>
|
||||||
) : nodes.length === 0 ? (
|
) : nodes.length === 0 ? (
|
||||||
<EmptyState title='暂无节点' description='请先创建一个节点,或使用 Discovery Token 在目标机器上接入新节点。' />
|
<EmptyState
|
||||||
|
title="暂无节点"
|
||||||
|
description="请先创建一个节点,然后在列表中打开该节点的部署弹窗。"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className='overflow-x-auto'>
|
<div className="overflow-x-auto">
|
||||||
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'>
|
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className='text-[var(--foreground-secondary)]'>
|
<tr className="text-[var(--foreground-secondary)]">
|
||||||
<th className='px-3 py-3 font-medium'>节点</th>
|
<th className="px-3 py-3 font-medium">节点</th>
|
||||||
<th className='px-3 py-3 font-medium'>状态</th>
|
<th className="px-3 py-3 font-medium">状态</th>
|
||||||
<th className='px-3 py-3 font-medium'>更新模式</th>
|
<th className="px-3 py-3 font-medium">更新模式</th>
|
||||||
<th className='px-3 py-3 font-medium'>Agent / Nginx</th>
|
<th className="px-3 py-3 font-medium">Agent / Nginx</th>
|
||||||
<th className='px-3 py-3 font-medium'>当前版本</th>
|
<th className="px-3 py-3 font-medium">当前版本</th>
|
||||||
<th className='px-3 py-3 font-medium'>最近应用</th>
|
<th className="px-3 py-3 font-medium">最近应用</th>
|
||||||
<th className='px-3 py-3 font-medium'>最近心跳</th>
|
<th className="px-3 py-3 font-medium">最近心跳</th>
|
||||||
<th className='px-3 py-3 font-medium'>错误</th>
|
<th className="px-3 py-3 font-medium">错误</th>
|
||||||
<th className='px-3 py-3 font-medium'>操作</th>
|
<th className="px-3 py-3 font-medium">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className='divide-y divide-[var(--border-default)]'>
|
<tbody className="divide-y divide-[var(--border-default)]">
|
||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
const updateMode = getUpdateMode(node);
|
const updateMode = getUpdateMode(node);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={node.id} className='align-top'>
|
<tr key={node.id} className="align-top">
|
||||||
<td className='px-3 py-4'>
|
<td className="px-3 py-4">
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<p className='font-medium text-[var(--foreground-primary)]'>{node.name}</p>
|
<p className="font-medium text-[var(--foreground-primary)]">
|
||||||
<p className='text-xs text-[var(--foreground-secondary)]'>Node ID:{node.node_id}</p>
|
{node.name}
|
||||||
<p className='break-all text-xs text-[var(--foreground-secondary)]'>
|
</p>
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
Node ID:{node.node_id}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs break-all text-[var(--foreground-secondary)]">
|
||||||
Token:{node.agent_token || '暂无'}
|
Token:{node.agent_token || '暂无'}
|
||||||
</p>
|
</p>
|
||||||
<p className='text-xs text-[var(--foreground-secondary)]'>IP:{node.ip || '暂无'}</p>
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
IP:{node.ip || '暂无'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-3 py-4'>
|
<td className="px-3 py-4">
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<StatusBadge label={getStatusLabel(node.status)} variant={getStatusVariant(node.status)} />
|
<StatusBadge
|
||||||
|
label={getStatusLabel(node.status)}
|
||||||
|
variant={getStatusVariant(node.status)}
|
||||||
|
/>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={node.pending ? '未占用' : '已绑定'}
|
label={node.pending ? '未占用' : '已绑定'}
|
||||||
variant={node.pending ? 'warning' : 'success'}
|
variant={node.pending ? 'warning' : 'success'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-3 py-4'>
|
<td className="px-3 py-4">
|
||||||
<StatusBadge label={updateMode.label} variant={updateMode.variant} />
|
<StatusBadge
|
||||||
|
label={updateMode.label}
|
||||||
|
variant={updateMode.variant}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||||
{node.agent_version || 'unknown'} / {node.nginx_version || 'unknown'}
|
{node.agent_version || 'unknown'} /{' '}
|
||||||
|
{node.nginx_version || 'unknown'}
|
||||||
</td>
|
</td>
|
||||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||||
{node.current_version || '未应用'}
|
{node.current_version || '未应用'}
|
||||||
</td>
|
</td>
|
||||||
<td className='px-3 py-4'>
|
<td className="px-3 py-4">
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={getApplyLabel(node.latest_apply_result)}
|
label={getApplyLabel(node.latest_apply_result)}
|
||||||
variant={getApplyVariant(node.latest_apply_result)}
|
variant={getApplyVariant(
|
||||||
|
node.latest_apply_result,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<p className='max-w-56 text-xs leading-5 text-[var(--foreground-secondary)]'>
|
<p className="max-w-56 text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||||
{node.latest_apply_message || '暂无记录'}
|
{node.latest_apply_message || '暂无记录'}
|
||||||
</p>
|
</p>
|
||||||
<p className='text-xs text-[var(--foreground-secondary)]'>
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
{isMeaningfulTime(node.latest_apply_at)
|
{isMeaningfulTime(node.latest_apply_at)
|
||||||
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
|
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
|
||||||
: '暂无'}
|
: '暂无'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||||
{isMeaningfulTime(node.last_seen_at)
|
{isMeaningfulTime(node.last_seen_at)
|
||||||
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||||
: '暂无'}
|
: '暂无'}
|
||||||
</td>
|
</td>
|
||||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||||
<p className='max-w-56 whitespace-pre-wrap break-words'>
|
<p className="max-w-56 break-words whitespace-pre-wrap">
|
||||||
{node.last_error || '无'}
|
{node.last_error || '无'}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-3 py-4'>
|
<td className="px-3 py-4">
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className="flex flex-wrap gap-2">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
type='button'
|
type="button"
|
||||||
onClick={() => updateAgentMutation.mutate(node.id)}
|
onClick={() =>
|
||||||
disabled={updateAgentMutation.isPending || node.update_requested}
|
updateAgentMutation.mutate(node.id)
|
||||||
className='px-3 py-2 text-xs'
|
}
|
||||||
|
disabled={
|
||||||
|
updateAgentMutation.isPending ||
|
||||||
|
node.update_requested
|
||||||
|
}
|
||||||
|
className="px-3 py-2 text-xs"
|
||||||
>
|
>
|
||||||
立即更新
|
立即更新
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
type='button'
|
type="button"
|
||||||
onClick={() => setSelectedNode(node)}
|
onClick={() => handleOpenDeployModal(node)}
|
||||||
className='px-3 py-2 text-xs'
|
className="px-3 py-2 text-xs"
|
||||||
>
|
>
|
||||||
部署命令
|
部署
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
type='button'
|
type="button"
|
||||||
onClick={() => handleEdit(node)}
|
onClick={() => handleEdit(node)}
|
||||||
className='px-3 py-2 text-xs'
|
className="px-3 py-2 text-xs"
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<DangerButton
|
<DangerButton
|
||||||
type='button'
|
type="button"
|
||||||
onClick={() => handleDelete(node)}
|
onClick={() => handleDelete(node)}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
className='px-3 py-2 text-xs'
|
className="px-3 py-2 text-xs"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</DangerButton>
|
</DangerButton>
|
||||||
@@ -549,30 +511,135 @@ export function NodesPage() {
|
|||||||
)}
|
)}
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
|
<AppModal
|
||||||
|
isOpen={isDeployModalOpen}
|
||||||
|
onClose={() => setIsDeployModalOpen(false)}
|
||||||
|
title="节点专属部署命令"
|
||||||
|
description="仅展示节点专属 Agent Token 部署方式。Discovery Token 部署入口已从管理端移除。"
|
||||||
|
footer={
|
||||||
|
<div className="flex flex-wrap justify-end gap-3">
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsDeployModalOpen(false)}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</SecondaryButton>
|
||||||
|
{nodeInstallCommand ? (
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void handleCopy(
|
||||||
|
nodeInstallCommand,
|
||||||
|
'节点专属部署命令已复制。',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
复制命令
|
||||||
|
</PrimaryButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedNodeFromList ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{selectedNodeFromList.name}
|
||||||
|
</p>
|
||||||
|
<StatusBadge
|
||||||
|
label={selectedNodeFromList.pending ? '未占用' : '已绑定'}
|
||||||
|
variant={
|
||||||
|
selectedNodeFromList.pending ? 'warning' : 'success'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-[var(--foreground-secondary)]">
|
||||||
|
Node ID:{selectedNodeFromList.node_id}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-[var(--foreground-secondary)]">
|
||||||
|
IP:{selectedNodeFromList.ip || '暂无'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
Agent Token
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||||
|
{selectedNodeFromList.agent_token || '暂无'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ResourceField
|
||||||
|
label="Server URL"
|
||||||
|
hint="默认使用当前控制面来源地址,可按需改为外部访问地址。"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
value={serverUrl}
|
||||||
|
onChange={(event) => setServerUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
{nodeInstallCommand ? (
|
||||||
|
<CodeBlock className="whitespace-pre-wrap">
|
||||||
|
{nodeInstallCommand}
|
||||||
|
</CodeBlock>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="尚未选择节点"
|
||||||
|
description="请从节点列表中点击“部署”打开当前节点的专属安装命令。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AppModal>
|
||||||
<AppModal
|
<AppModal
|
||||||
isOpen={isEditorOpen}
|
isOpen={isEditorOpen}
|
||||||
onClose={handleReset}
|
onClose={handleReset}
|
||||||
title={editingNodeId ? '编辑节点' : '新增节点'}
|
title={editingNodeId ? '编辑节点' : '新增节点'}
|
||||||
description='预创建节点后会立即生成节点专属 Token,可继续复制专属安装命令。'
|
description="预创建节点后会立即生成节点专属 Token,可继续复制专属安装命令。"
|
||||||
footer={
|
footer={
|
||||||
<div className='flex flex-wrap justify-end gap-3'>
|
<div className="flex flex-wrap justify-end gap-3">
|
||||||
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
取消
|
取消
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton type='submit' form='node-editor-form' disabled={saveMutation.isPending}>
|
<PrimaryButton
|
||||||
{saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'}
|
type="submit"
|
||||||
|
form="node-editor-form"
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending
|
||||||
|
? '保存中...'
|
||||||
|
: editingNodeId
|
||||||
|
? '保存修改'
|
||||||
|
: '新增节点'}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form id='node-editor-form' className='space-y-5' onSubmit={handleSubmit}>
|
<form
|
||||||
<ResourceField label='节点名' hint='示例:shanghai-edge-1' error={form.formState.errors.name?.message}>
|
id="node-editor-form"
|
||||||
<ResourceInput placeholder='shanghai-edge-1' {...form.register('name')} />
|
className="space-y-5"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<ResourceField
|
||||||
|
label="节点名"
|
||||||
|
hint="示例:shanghai-edge-1"
|
||||||
|
error={form.formState.errors.name?.message}
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
placeholder="shanghai-edge-1"
|
||||||
|
{...form.register('name')}
|
||||||
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
<ToggleField
|
<ToggleField
|
||||||
label='启用自动更新'
|
label="启用自动更新"
|
||||||
description='开启后 Agent 心跳返回会提示节点自动执行自更新。'
|
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
|
||||||
checked={watchedAutoUpdate}
|
checked={watchedAutoUpdate}
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
form.setValue('auto_update_enabled', checked, {
|
form.setValue('auto_update_enabled', checked, {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { apiRequest } from '@/lib/api/client';
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BootstrapTokenPayload,
|
|
||||||
LatestReleaseInfo,
|
|
||||||
OptionItem,
|
OptionItem,
|
||||||
SettingsProfile,
|
SettingsProfile,
|
||||||
UpdateSelfPayload,
|
UpdateSelfPayload,
|
||||||
@@ -19,20 +17,6 @@ export function updateOption(key: string, value: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
export function getSettingsProfile() {
|
||||||
return apiRequest<SettingsProfile>('/user/self');
|
return apiRequest<SettingsProfile>('/user/self');
|
||||||
}
|
}
|
||||||
@@ -49,7 +33,9 @@ export function generateAccessToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bindWeChat(code: string) {
|
export function bindWeChat(code: string) {
|
||||||
return apiRequest<void>(`/oauth/wechat/bind?code=${encodeURIComponent(code)}`);
|
return apiRequest<void>(
|
||||||
|
`/oauth/wechat/bind?code=${encodeURIComponent(code)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bindEmail(email: string, code: string) {
|
export function bindEmail(email: string, code: string) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,17 +5,6 @@ export interface OptionItem {
|
|||||||
value: 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 {
|
export interface UpdateSelfPayload {
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -91,6 +91,12 @@ docker compose up -d
|
|||||||
* 镜像默认监听容器内 `3000` 端口
|
* 镜像默认监听容器内 `3000` 端口
|
||||||
* 若要固定版本,可将 `latest` 替换为具体 tag,例如 `ghcr.io/rain-kl/atsflare:v0.3.0`
|
* 若要固定版本,可将 `latest` 替换为具体 tag,例如 `ghcr.io/rain-kl/atsflare:v0.3.0`
|
||||||
|
|
||||||
|
版本升级说明:
|
||||||
|
|
||||||
|
* Root 用户可在管理端顶栏点击「版本」检查 GitHub 最新 Release
|
||||||
|
* 当前运行的是 Release 二进制且二进制目录可写时,可直接在弹窗内触发 Server 自升级
|
||||||
|
* 自升级会下载匹配当前平台的 `atsflare-server-*` 资产,替换当前二进制并自动重启进程
|
||||||
|
|
||||||
### 2.4 首次登录
|
### 2.4 首次登录
|
||||||
|
|
||||||
访问 `http://localhost:3000`
|
访问 `http://localhost:3000`
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ ATSFlare 当前仍定位为**内部自用的反向代理控制面**,不是面
|
|||||||
* 证书托管与域名管理
|
* 证书托管与域名管理
|
||||||
* 节点预创建、节点专属 `agent_token`、全局 `discovery_token`
|
* 节点预创建、节点专属 `agent_token`、全局 `discovery_token`
|
||||||
* 配置预览与变更摘要
|
* 配置预览与变更摘要
|
||||||
|
* Server 版本检查与自升级
|
||||||
|
|
||||||
当前默认工作方式:
|
当前默认工作方式:
|
||||||
|
|
||||||
|
|||||||
@@ -142,14 +142,16 @@
|
|||||||
|
|
||||||
1. 节点列表时间显示改为友好格式
|
1. 节点列表时间显示改为友好格式
|
||||||
2. 节点状态颜色标识
|
2. 节点状态颜色标识
|
||||||
3. Discovery Token 在运维设置面板中可查看和重新生成
|
3. 节点专属 Agent 部署命令改为节点列表弹窗展示
|
||||||
4. Agent 部署命令一键复制
|
4. 版本检查入口迁移到顶栏“版本”,支持可升级提示与 Server 自升级
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
* 时间显示为友好的相对时间(如「2 分钟前」)
|
* 时间显示为友好的相对时间(如「2 分钟前」)
|
||||||
* 节点状态有颜色区分:在线(绿色)、离线(红色)、待接入(黄色)
|
* 节点状态有颜色区分:在线(绿色)、离线(红色)、待接入(黄色)
|
||||||
* curl 部署命令支持一键复制
|
* 节点列表可弹窗查看并复制节点专属部署命令
|
||||||
|
* 顶栏版本入口可检查 GitHub 最新 Release,并在存在新版本时显示可升级提示
|
||||||
|
* Root 用户可从顶栏版本弹窗触发 Server 自升级
|
||||||
|
|
||||||
### 3.6 并行专项:管理端前端工程改造
|
### 3.6 并行专项:管理端前端工程改造
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user