mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 23:06:36 +08:00
feat: 添加节点自动更新功能,支持手动触发更新
This commit is contained in:
@@ -35,6 +35,7 @@ type Runner struct {
|
|||||||
Updater Updater
|
Updater Updater
|
||||||
|
|
||||||
autoUpdate bool
|
autoUpdate bool
|
||||||
|
updateNow bool
|
||||||
updateRepo string
|
updateRepo string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,16 +131,19 @@ func (r *Runner) applySettings(settings *protocol.AgentSettings) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
r.autoUpdate = settings.AutoUpdate
|
r.autoUpdate = settings.AutoUpdate
|
||||||
r.updateRepo = settings.UpdateRepo
|
r.updateNow = settings.UpdateNow
|
||||||
|
r.updateRepo = strings.TrimSpace(settings.UpdateRepo)
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) tryAutoUpdate(ctx context.Context) {
|
func (r *Runner) tryAutoUpdate(ctx context.Context) {
|
||||||
if !r.autoUpdate || r.Updater == nil || r.updateRepo == "" {
|
shouldCheck := r.autoUpdate || r.updateNow
|
||||||
|
r.updateNow = false
|
||||||
|
if !shouldCheck || r.Updater == nil || r.updateRepo == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.Updater.CheckAndUpdate(ctx, r.updateRepo); err != nil {
|
if err := r.Updater.CheckAndUpdate(ctx, r.updateRepo); err != nil {
|
||||||
log.Printf("agent auto-update check failed: %v", err)
|
log.Printf("agent update check failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type AgentSettings struct {
|
|||||||
SyncInterval int `json:"sync_interval"`
|
SyncInterval int `json:"sync_interval"`
|
||||||
AutoUpdate bool `json:"auto_update"`
|
AutoUpdate bool `json:"auto_update"`
|
||||||
UpdateRepo string `json:"update_repo"`
|
UpdateRepo string `json:"update_repo"`
|
||||||
|
UpdateNow bool `json:"update_now"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodePayload struct {
|
type NodePayload struct {
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ var NodeOfflineThreshold = 2 * time.Minute
|
|||||||
// V3 operational settings (hot-reloadable via Option table)
|
// V3 operational settings (hot-reloadable via Option table)
|
||||||
var AgentHeartbeatInterval = 30000 // milliseconds
|
var AgentHeartbeatInterval = 30000 // milliseconds
|
||||||
var AgentSyncInterval = 30000 // milliseconds
|
var AgentSyncInterval = 30000 // milliseconds
|
||||||
var AgentAutoUpdate = false
|
|
||||||
var AgentUpdateRepo = "Rain-kl/ATSFlare"
|
var AgentUpdateRepo = "Rain-kl/ATSFlare"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"atsflare/service"
|
"atsflare/service"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateNode(c *gin.Context) {
|
func CreateNode(c *gin.Context) {
|
||||||
@@ -117,3 +118,27 @@ func DeleteNode(c *gin.Context) {
|
|||||||
"message": "",
|
"message": "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RequestNodeAgentUpdate(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的参数",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node, err := service.RequestNodeAgentUpdate(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": node,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+15
-13
@@ -3,19 +3,21 @@ package model
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Node struct {
|
type Node struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey"`
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"`
|
NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"`
|
||||||
Name string `json:"name" gorm:"size:128;not null"`
|
Name string `json:"name" gorm:"size:128;not null"`
|
||||||
IP string `json:"ip" gorm:"size:64;not null"`
|
IP string `json:"ip" gorm:"size:64;not null"`
|
||||||
AgentToken string `json:"-" gorm:"size:128;index"`
|
AgentToken string `json:"-" gorm:"size:128;index"`
|
||||||
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"`
|
||||||
NginxVersion string `json:"nginx_version" gorm:"size:64"`
|
UpdateRequested bool `json:"update_requested" gorm:"not null;default:false"`
|
||||||
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
|
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
|
||||||
CurrentVersion string `json:"current_version" gorm:"size:32"`
|
NginxVersion string `json:"nginx_version" gorm:"size:64"`
|
||||||
LastSeenAt time.Time `json:"last_seen_at"`
|
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
|
||||||
LastError string `json:"last_error" gorm:"size:1024"`
|
CurrentVersion string `json:"current_version" gorm:"size:32"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
LastError string `json:"last_error" gorm:"size:1024"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListNodes() (nodes []*Node, err error) {
|
func ListNodes() (nodes []*Node, err error) {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ func InitOptionMap() {
|
|||||||
common.OptionMap["AgentHeartbeatInterval"] = strconv.Itoa(common.AgentHeartbeatInterval)
|
common.OptionMap["AgentHeartbeatInterval"] = strconv.Itoa(common.AgentHeartbeatInterval)
|
||||||
common.OptionMap["AgentSyncInterval"] = strconv.Itoa(common.AgentSyncInterval)
|
common.OptionMap["AgentSyncInterval"] = strconv.Itoa(common.AgentSyncInterval)
|
||||||
common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds()))
|
common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds()))
|
||||||
common.OptionMap["AgentAutoUpdate"] = strconv.FormatBool(common.AgentAutoUpdate)
|
|
||||||
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
|
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
|
||||||
common.OptionMapRWMutex.Unlock()
|
common.OptionMapRWMutex.Unlock()
|
||||||
options, _ := AllOption()
|
options, _ := AllOption()
|
||||||
@@ -162,8 +161,6 @@ func updateOptionMap(key string, value string) {
|
|||||||
if v, err := strconv.Atoi(value); err == nil && v > 0 {
|
if v, err := strconv.Atoi(value); err == nil && v > 0 {
|
||||||
common.NodeOfflineThreshold = time.Duration(v) * time.Millisecond
|
common.NodeOfflineThreshold = time.Duration(v) * time.Millisecond
|
||||||
}
|
}
|
||||||
case "AgentAutoUpdate":
|
|
||||||
common.AgentAutoUpdate = value == "true"
|
|
||||||
case "AgentUpdateRepo":
|
case "AgentUpdateRepo":
|
||||||
if value != "" {
|
if value != "" {
|
||||||
common.AgentUpdateRepo = value
|
common.AgentUpdateRepo = value
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package router
|
|||||||
import (
|
import (
|
||||||
"atsflare/controller"
|
"atsflare/controller"
|
||||||
"atsflare/middleware"
|
"atsflare/middleware"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken)
|
nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken)
|
||||||
nodeRoute.GET("/", controller.GetNodes)
|
nodeRoute.GET("/", controller.GetNodes)
|
||||||
nodeRoute.POST("/", controller.CreateNode)
|
nodeRoute.POST("/", controller.CreateNode)
|
||||||
|
nodeRoute.POST("/:id/agent-update", controller.RequestNodeAgentUpdate)
|
||||||
nodeRoute.PUT("/:id", controller.UpdateNode)
|
nodeRoute.PUT("/:id", controller.UpdateNode)
|
||||||
nodeRoute.DELETE("/:id", controller.DeleteNode)
|
nodeRoute.DELETE("/:id", controller.DeleteNode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type AgentSettings struct {
|
|||||||
SyncInterval int `json:"sync_interval"`
|
SyncInterval int `json:"sync_interval"`
|
||||||
AutoUpdate bool `json:"auto_update"`
|
AutoUpdate bool `json:"auto_update"`
|
||||||
UpdateRepo string `json:"update_repo"`
|
UpdateRepo string `json:"update_repo"`
|
||||||
|
UpdateNow bool `json:"update_now"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HeartbeatResponse struct {
|
type HeartbeatResponse struct {
|
||||||
@@ -63,6 +64,8 @@ type NodeView struct {
|
|||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
AgentToken string `json:"agent_token"`
|
AgentToken string `json:"agent_token"`
|
||||||
Pending bool `json:"pending"`
|
Pending bool `json:"pending"`
|
||||||
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
|
UpdateRequested bool `json:"update_requested"`
|
||||||
AgentVersion string `json:"agent_version"`
|
AgentVersion string `json:"agent_version"`
|
||||||
NginxVersion string `json:"nginx_version"`
|
NginxVersion string `json:"nginx_version"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -87,8 +90,10 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
|
|||||||
if err := validateAgentNodePayload(payload); err != nil {
|
if err := validateAgentNodePayload(payload); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
updateNow := node.UpdateRequested
|
||||||
applyNodeRuntime(node, payload, true)
|
applyNodeRuntime(node, payload, true)
|
||||||
if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error").Updates(node).Error; err != nil {
|
node.UpdateRequested = false
|
||||||
|
if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error", "update_requested").Updates(node).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &HeartbeatResponse{
|
return &HeartbeatResponse{
|
||||||
@@ -96,8 +101,9 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
|
|||||||
AgentSettings: &AgentSettings{
|
AgentSettings: &AgentSettings{
|
||||||
HeartbeatInterval: common.AgentHeartbeatInterval,
|
HeartbeatInterval: common.AgentHeartbeatInterval,
|
||||||
SyncInterval: common.AgentSyncInterval,
|
SyncInterval: common.AgentSyncInterval,
|
||||||
AutoUpdate: common.AgentAutoUpdate,
|
AutoUpdate: node.AutoUpdateEnabled,
|
||||||
UpdateRepo: common.AgentUpdateRepo,
|
UpdateRepo: common.AgentUpdateRepo,
|
||||||
|
UpdateNow: updateNow,
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-20
@@ -11,7 +11,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type NodeInput struct {
|
type NodeInput struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeBootstrapView struct {
|
type NodeBootstrapView struct {
|
||||||
@@ -30,11 +31,12 @@ func CreateNode(input NodeInput) (*NodeView, error) {
|
|||||||
return nil, errors.New("节点名不能为空")
|
return nil, errors.New("节点名不能为空")
|
||||||
}
|
}
|
||||||
node := &model.Node{
|
node := &model.Node{
|
||||||
Name: name,
|
Name: name,
|
||||||
IP: "",
|
IP: "",
|
||||||
AgentVersion: "",
|
AgentVersion: "",
|
||||||
NginxVersion: "",
|
NginxVersion: "",
|
||||||
Status: NodeStatusPending,
|
Status: NodeStatusPending,
|
||||||
|
AutoUpdateEnabled: input.AutoUpdateEnabled,
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
node.NodeID, err = newServerNodeID()
|
node.NodeID, err = newServerNodeID()
|
||||||
@@ -65,6 +67,7 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
node.Name = name
|
node.Name = name
|
||||||
|
node.AutoUpdateEnabled = input.AutoUpdateEnabled
|
||||||
if err = node.Update(); err != nil {
|
if err = node.Update(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -81,6 +84,19 @@ func DeleteNode(id uint) error {
|
|||||||
return node.Delete()
|
return node.Delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RequestNodeAgentUpdate(id uint) (*NodeView, error) {
|
||||||
|
node, err := model.GetNodeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
node.UpdateRequested = true
|
||||||
|
if err = model.DB.Model(node).Update("update_requested", true).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
common.SysLog("agent manual update requested: node_id=" + node.NodeID + " name=" + node.Name)
|
||||||
|
return buildNodeView(node), nil
|
||||||
|
}
|
||||||
|
|
||||||
func AuthenticateAgentToken(token string) (*model.Node, error) {
|
func AuthenticateAgentToken(token string) (*model.Node, error) {
|
||||||
token = strings.TrimSpace(token)
|
token = strings.TrimSpace(token)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -149,20 +165,22 @@ func RotateGlobalDiscoveryToken() (*NodeBootstrapView, error) {
|
|||||||
func buildNodeView(node *model.Node) *NodeView {
|
func buildNodeView(node *model.Node) *NodeView {
|
||||||
status := computeNodeStatus(node)
|
status := computeNodeStatus(node)
|
||||||
view := &NodeView{
|
view := &NodeView{
|
||||||
ID: node.ID,
|
ID: node.ID,
|
||||||
NodeID: node.NodeID,
|
NodeID: node.NodeID,
|
||||||
Name: node.Name,
|
Name: node.Name,
|
||||||
IP: node.IP,
|
IP: node.IP,
|
||||||
AgentToken: node.AgentToken,
|
AgentToken: node.AgentToken,
|
||||||
AgentVersion: node.AgentVersion,
|
AgentVersion: node.AgentVersion,
|
||||||
NginxVersion: node.NginxVersion,
|
NginxVersion: node.NginxVersion,
|
||||||
Status: status,
|
Status: status,
|
||||||
CurrentVersion: node.CurrentVersion,
|
CurrentVersion: node.CurrentVersion,
|
||||||
LastSeenAt: node.LastSeenAt,
|
LastSeenAt: node.LastSeenAt,
|
||||||
LastError: node.LastError,
|
LastError: node.LastError,
|
||||||
CreatedAt: node.CreatedAt,
|
CreatedAt: node.CreatedAt,
|
||||||
UpdatedAt: node.UpdatedAt,
|
UpdatedAt: node.UpdatedAt,
|
||||||
Pending: status == NodeStatusPending,
|
Pending: status == NodeStatusPending,
|
||||||
|
AutoUpdateEnabled: node.AutoUpdateEnabled,
|
||||||
|
UpdateRequested: node.UpdateRequested,
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ import NotFound from './pages/NotFound';
|
|||||||
import Setting from './pages/Setting';
|
import Setting from './pages/Setting';
|
||||||
import EditUser from './pages/User/EditUser';
|
import EditUser from './pages/User/EditUser';
|
||||||
import AddUser from './pages/User/AddUser';
|
import AddUser from './pages/User/AddUser';
|
||||||
import { API, showError, showNotice } from './helpers';
|
import {
|
||||||
|
API,
|
||||||
|
getBuildVersion,
|
||||||
|
hasBuildVersion,
|
||||||
|
normalizeVersion,
|
||||||
|
showError,
|
||||||
|
showNotice,
|
||||||
|
} from './helpers';
|
||||||
import PasswordResetForm from './components/PasswordResetForm';
|
import PasswordResetForm from './components/PasswordResetForm';
|
||||||
import GitHubOAuth from './components/GitHubOAuth';
|
import GitHubOAuth from './components/GitHubOAuth';
|
||||||
import PasswordResetConfirm from './components/PasswordResetConfirm';
|
import PasswordResetConfirm from './components/PasswordResetConfirm';
|
||||||
@@ -47,10 +54,13 @@ function App() {
|
|||||||
localStorage.setItem('system_name', data.system_name);
|
localStorage.setItem('system_name', data.system_name);
|
||||||
localStorage.setItem('footer_html', data.footer_html);
|
localStorage.setItem('footer_html', data.footer_html);
|
||||||
localStorage.setItem('home_page_link', data.home_page_link);
|
localStorage.setItem('home_page_link', data.home_page_link);
|
||||||
|
const currentBuildVersion = getBuildVersion();
|
||||||
|
const latestServerVersion = normalizeVersion(data.version);
|
||||||
if (
|
if (
|
||||||
data.version !== process.env.REACT_APP_VERSION &&
|
hasBuildVersion() &&
|
||||||
data.version !== 'v0.0.0' &&
|
latestServerVersion !== '' &&
|
||||||
process.env.REACT_APP_VERSION !== ''
|
latestServerVersion !== '0.0.0' &&
|
||||||
|
latestServerVersion !== currentBuildVersion
|
||||||
) {
|
) {
|
||||||
showNotice(
|
showNotice(
|
||||||
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
|
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const OperationSetting = () => {
|
|||||||
AgentHeartbeatInterval: '30000',
|
AgentHeartbeatInterval: '30000',
|
||||||
AgentSyncInterval: '30000',
|
AgentSyncInterval: '30000',
|
||||||
NodeOfflineThreshold: '120000',
|
NodeOfflineThreshold: '120000',
|
||||||
AgentAutoUpdate: 'false',
|
|
||||||
AgentUpdateRepo: 'Rain-kl/ATSFlare',
|
AgentUpdateRepo: 'Rain-kl/ATSFlare',
|
||||||
ServerAddress: '',
|
ServerAddress: '',
|
||||||
AgentDiscoveryToken: '',
|
AgentDiscoveryToken: '',
|
||||||
@@ -77,10 +76,6 @@ const OperationSetting = () => {
|
|||||||
setInputs((prev) => ({ ...prev, [name]: value }));
|
setInputs((prev) => ({ ...prev, [name]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckboxChange = (e, { name, checked }) => {
|
|
||||||
setInputs((prev) => ({ ...prev, [name]: checked ? 'true' : 'false' }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitAgentIntervals = async () => {
|
const submitAgentIntervals = async () => {
|
||||||
const hb = parseInt(inputs.AgentHeartbeatInterval, 10);
|
const hb = parseInt(inputs.AgentHeartbeatInterval, 10);
|
||||||
const sync = parseInt(inputs.AgentSyncInterval, 10);
|
const sync = parseInt(inputs.AgentSyncInterval, 10);
|
||||||
@@ -102,8 +97,7 @@ const OperationSetting = () => {
|
|||||||
await updateOption('NodeOfflineThreshold', String(offline));
|
await updateOption('NodeOfflineThreshold', String(offline));
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitAutoUpdate = async () => {
|
const submitUpdateRepo = async () => {
|
||||||
await updateOption('AgentAutoUpdate', inputs.AgentAutoUpdate);
|
|
||||||
await updateOption('AgentUpdateRepo', inputs.AgentUpdateRepo);
|
await updateOption('AgentUpdateRepo', inputs.AgentUpdateRepo);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,15 +172,13 @@ const OperationSetting = () => {
|
|||||||
</Form.Button>
|
</Form.Button>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<Header as='h3'>Agent 自动更新</Header>
|
<Header as='h3'>Agent 更新源</Header>
|
||||||
<Form.Group>
|
<Message info size='small'>
|
||||||
<Form.Checkbox
|
<p>
|
||||||
label='启用 Agent 自动更新'
|
自动更新开关和手动更新由节点页单独控制,这里只配置 Agent
|
||||||
checked={inputs.AgentAutoUpdate === 'true'}
|
更新仓库。
|
||||||
name='AgentAutoUpdate'
|
</p>
|
||||||
onChange={handleCheckboxChange}
|
</Message>
|
||||||
/>
|
|
||||||
</Form.Group>
|
|
||||||
<Form.Group widths='equal'>
|
<Form.Group widths='equal'>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
label='更新仓库'
|
label='更新仓库'
|
||||||
@@ -196,7 +188,7 @@ const OperationSetting = () => {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Button onClick={submitAutoUpdate} primary>
|
<Form.Button onClick={submitUpdateRepo} primary>
|
||||||
保存更新设置
|
保存更新设置
|
||||||
</Form.Button>
|
</Form.Button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Divider, Form, Grid, Header, Modal } from 'semantic-ui-react';
|
import { Button, Divider, Form, Grid, Header, Modal } from 'semantic-ui-react';
|
||||||
import { API, showError, showSuccess } from '../helpers';
|
import {
|
||||||
|
API,
|
||||||
|
getBuildVersion,
|
||||||
|
hasBuildVersion,
|
||||||
|
normalizeVersion,
|
||||||
|
showError,
|
||||||
|
showSuccess,
|
||||||
|
} from '../helpers';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
const OtherSetting = () => {
|
const OtherSetting = () => {
|
||||||
@@ -86,7 +93,10 @@ const OtherSetting = () => {
|
|||||||
'https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest'
|
'https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest'
|
||||||
);
|
);
|
||||||
const { tag_name, body } = res.data;
|
const { tag_name, body } = res.data;
|
||||||
if (tag_name === process.env.REACT_APP_VERSION) {
|
if (
|
||||||
|
hasBuildVersion() &&
|
||||||
|
normalizeVersion(tag_name) === getBuildVersion()
|
||||||
|
) {
|
||||||
showSuccess(`已是最新版本:${tag_name}`);
|
showSuccess(`已是最新版本:${tag_name}`);
|
||||||
} else {
|
} else {
|
||||||
setUpdateData({
|
setUpdateData({
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ export function getFooterHTML() {
|
|||||||
return localStorage.getItem('footer_html');
|
return localStorage.getItem('footer_html');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeVersion(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
return String(value).trim().replace(/^v/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBuildVersion() {
|
||||||
|
return normalizeVersion(process.env.REACT_APP_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBuildVersion() {
|
||||||
|
return getBuildVersion() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
export async function copy(text, label) {
|
export async function copy(text, label) {
|
||||||
let okay = true;
|
let okay = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { API, showError, showSuccess, timeAgo } from '../../helpers';
|
|||||||
|
|
||||||
const initialForm = {
|
const initialForm = {
|
||||||
name: '',
|
name: '',
|
||||||
|
auto_update_enabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderStatus = (status) => {
|
const renderStatus = (status) => {
|
||||||
@@ -26,6 +27,16 @@ const renderApply = (result) => {
|
|||||||
return <Label>暂无</Label>;
|
return <Label>暂无</Label>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderUpdateMode = (node) => {
|
||||||
|
if (node.update_requested) {
|
||||||
|
return <Label color='orange'>等待更新</Label>;
|
||||||
|
}
|
||||||
|
if (node.auto_update_enabled) {
|
||||||
|
return <Label color='green'>自动更新</Label>;
|
||||||
|
}
|
||||||
|
return <Label>手动更新</Label>;
|
||||||
|
};
|
||||||
|
|
||||||
const Node = () => {
|
const Node = () => {
|
||||||
const [nodes, setNodes] = useState([]);
|
const [nodes, setNodes] = useState([]);
|
||||||
const [bootstrap, setBootstrap] = useState({ discovery_token: '' });
|
const [bootstrap, setBootstrap] = useState({ discovery_token: '' });
|
||||||
@@ -91,6 +102,7 @@ const Node = () => {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
|
auto_update_enabled: form.auto_update_enabled,
|
||||||
};
|
};
|
||||||
const res = editingId
|
const res = editingId
|
||||||
? await API.put(`/api/nodes/${editingId}`, payload)
|
? await API.put(`/api/nodes/${editingId}`, payload)
|
||||||
@@ -110,9 +122,21 @@ const Node = () => {
|
|||||||
setEditingId(node.id);
|
setEditingId(node.id);
|
||||||
setForm({
|
setForm({
|
||||||
name: node.name || '',
|
name: node.name || '',
|
||||||
|
auto_update_enabled: !!node.auto_update_enabled,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestAgentUpdate = async (node) => {
|
||||||
|
const res = await API.post(`/api/nodes/${node.id}/agent-update`);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess(`已向节点 ${node.name} 下发更新指令`);
|
||||||
|
await loadNodes(false);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteNode = async (node) => {
|
const deleteNode = async (node) => {
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
@@ -163,6 +187,14 @@ const Node = () => {
|
|||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e, { value }) => setForm({ ...form, name: value })}
|
onChange={(e, { value }) => setForm({ ...form, name: value })}
|
||||||
/>
|
/>
|
||||||
|
<Form.Checkbox
|
||||||
|
label='启用该节点自动更新'
|
||||||
|
checked={form.auto_update_enabled}
|
||||||
|
onChange={(e, { checked }) =>
|
||||||
|
setForm({ ...form, auto_update_enabled: !!checked })
|
||||||
|
}
|
||||||
|
style={{ alignSelf: 'flex-end', marginBottom: '0.5rem' }}
|
||||||
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Button primary type='submit' loading={submitting}>
|
<Button primary type='submit' loading={submitting}>
|
||||||
{editingId ? '保存修改' : '新增节点'}
|
{editingId ? '保存修改' : '新增节点'}
|
||||||
@@ -182,6 +214,7 @@ const Node = () => {
|
|||||||
<Table.HeaderCell>Auth Token</Table.HeaderCell>
|
<Table.HeaderCell>Auth Token</Table.HeaderCell>
|
||||||
<Table.HeaderCell>IP</Table.HeaderCell>
|
<Table.HeaderCell>IP</Table.HeaderCell>
|
||||||
<Table.HeaderCell>状态</Table.HeaderCell>
|
<Table.HeaderCell>状态</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>Agent 更新</Table.HeaderCell>
|
||||||
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
|
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
|
||||||
<Table.HeaderCell>当前版本</Table.HeaderCell>
|
<Table.HeaderCell>当前版本</Table.HeaderCell>
|
||||||
<Table.HeaderCell>最近应用</Table.HeaderCell>
|
<Table.HeaderCell>最近应用</Table.HeaderCell>
|
||||||
@@ -216,6 +249,7 @@ const Node = () => {
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{node.ip}</Table.Cell>
|
<Table.Cell>{node.ip}</Table.Cell>
|
||||||
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
|
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
|
||||||
|
<Table.Cell>{renderUpdateMode(node)}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{node.agent_version} / {node.nginx_version || 'unknown'}
|
{node.agent_version} / {node.nginx_version || 'unknown'}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -231,6 +265,14 @@ const Node = () => {
|
|||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{node.last_error || '无'}</Table.Cell>
|
<Table.Cell>{node.last_error || '无'}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
primary
|
||||||
|
disabled={node.update_requested}
|
||||||
|
onClick={() => requestAgentUpdate(node)}
|
||||||
|
>
|
||||||
|
立即更新
|
||||||
|
</Button>
|
||||||
<Button size='small' onClick={() => beginEdit(node)}>
|
<Button size='small' onClick={() => beginEdit(node)}>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+15
-2
@@ -229,7 +229,14 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/insta
|
|||||||
|
|
||||||
### 7.3 Agent 自动更新
|
### 7.3 Agent 自动更新
|
||||||
|
|
||||||
在管理端 **运维设置** 中启用「Agent 自动更新」后,Agent 会在每次心跳周期检查 GitHub Releases,发现新版本时自动下载并重启。
|
Agent 自动更新默认为关闭。
|
||||||
|
|
||||||
|
在管理端 **节点管理** 页面中可以:
|
||||||
|
|
||||||
|
* 为单个节点开启「自动更新」
|
||||||
|
* 为单个节点点击「立即更新」,下发一次性更新指令
|
||||||
|
|
||||||
|
节点在收到对应心跳响应后,会检查 GitHub Releases,发现新版本时自动下载并重启。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -252,9 +259,15 @@ GitHub Release 中的 Agent 二进制命名格式:
|
|||||||
| AgentHeartbeatInterval | 心跳间隔(毫秒) | heartbeat_interval |
|
| AgentHeartbeatInterval | 心跳间隔(毫秒) | heartbeat_interval |
|
||||||
| AgentSyncInterval | 同步间隔(毫秒) | sync_interval |
|
| AgentSyncInterval | 同步间隔(毫秒) | sync_interval |
|
||||||
| NodeOfflineThreshold | 节点离线阈值(毫秒) | - |
|
| NodeOfflineThreshold | 节点离线阈值(毫秒) | - |
|
||||||
| AgentAutoUpdate | 是否启用自动更新 | auto_update |
|
|
||||||
| AgentUpdateRepo | 自动更新仓库 | update_repo |
|
| AgentUpdateRepo | 自动更新仓库 | update_repo |
|
||||||
|
|
||||||
|
节点管理页下发的字段:
|
||||||
|
|
||||||
|
| 参数 | 说明 | Agent 字段 |
|
||||||
|
| ---------------------- | ------------------ | ----------- |
|
||||||
|
| Node.AutoUpdateEnabled | 节点是否自动更新 | auto_update |
|
||||||
|
| Node.UpdateRequested | 节点一次性更新请求 | update_now |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 当前已知限制
|
## 10. 当前已知限制
|
||||||
|
|||||||
+7
-4
@@ -347,7 +347,6 @@ Agent 接口当前覆盖:
|
|||||||
* `AgentHeartbeatInterval`:Agent 心跳上报间隔(毫秒),默认 30000
|
* `AgentHeartbeatInterval`:Agent 心跳上报间隔(毫秒),默认 30000
|
||||||
* `AgentSyncInterval`:Agent 配置同步间隔(毫秒),默认 30000
|
* `AgentSyncInterval`:Agent 配置同步间隔(毫秒),默认 30000
|
||||||
* `NodeOfflineThreshold`:节点离线判定阈值(毫秒),默认 120000
|
* `NodeOfflineThreshold`:节点离线判定阈值(毫秒),默认 120000
|
||||||
* `AgentAutoUpdate`:是否允许 Agent 自动更新(`true`/`false`),默认 `false`
|
|
||||||
* `AgentUpdateRepo`:Agent 自动更新 GitHub 仓库地址,默认 `Rain-kl/ATSFlare`
|
* `AgentUpdateRepo`:Agent 自动更新 GitHub 仓库地址,默认 `Rain-kl/ATSFlare`
|
||||||
* 环境变量类设置(`SESSION_SECRET`、`SQLITE_PATH`、`PORT`)不迁移,保留原有方式
|
* 环境变量类设置(`SESSION_SECRET`、`SQLITE_PATH`、`PORT`)不迁移,保留原有方式
|
||||||
* 前端在设置页面新增「运维设置」Tab
|
* 前端在设置页面新增「运维设置」Tab
|
||||||
@@ -364,8 +363,9 @@ Agent 接口当前覆盖:
|
|||||||
* 心跳响应新增 `agent_settings` 字段,包含 Server 端可控的运行时参数:
|
* 心跳响应新增 `agent_settings` 字段,包含 Server 端可控的运行时参数:
|
||||||
* `heartbeat_interval`(毫秒)
|
* `heartbeat_interval`(毫秒)
|
||||||
* `sync_interval`(毫秒)
|
* `sync_interval`(毫秒)
|
||||||
* `auto_update`(布尔值)
|
* `auto_update`(节点级布尔值)
|
||||||
* `update_repo`(GitHub 仓库名)
|
* `update_repo`(GitHub 仓库名)
|
||||||
|
* `update_now`(一次性手动更新指令)
|
||||||
* Agent 收到心跳响应后,动态调整本地定时器间隔
|
* Agent 收到心跳响应后,动态调整本地定时器间隔
|
||||||
* 当 Server 未返回 `agent_settings` 或字段为空时,Agent 保持本地值不变
|
* 当 Server 未返回 `agent_settings` 或字段为空时,Agent 保持本地值不变
|
||||||
* Agent 不持久化 Server 下发的间隔值,重启后以本地 `agent.json` 为准,再由下次心跳覆盖
|
* Agent 不持久化 Server 下发的间隔值,重启后以本地 `agent.json` 为准,再由下次心跳覆盖
|
||||||
@@ -378,7 +378,7 @@ Agent 接口当前覆盖:
|
|||||||
|
|
||||||
第三版变更:
|
第三版变更:
|
||||||
|
|
||||||
* Agent 在收到 `auto_update=true` 时:
|
* Agent 在收到 `auto_update=true` 或 `update_now=true` 时:
|
||||||
* 通过 GitHub Releases API 查询 `update_repo` 的最新 Release
|
* 通过 GitHub Releases API 查询 `update_repo` 的最新 Release
|
||||||
* 比较本地 `agent_version` 与远端 tag
|
* 比较本地 `agent_version` 与远端 tag
|
||||||
* 若存在更新,下载对应平台的二进制文件
|
* 若存在更新,下载对应平台的二进制文件
|
||||||
@@ -387,6 +387,8 @@ Agent 接口当前覆盖:
|
|||||||
* 更新过程中不中断当前同步任务
|
* 更新过程中不中断当前同步任务
|
||||||
* 更新失败不影响正常心跳与同步
|
* 更新失败不影响正常心跳与同步
|
||||||
* Agent 二进制文件命名约定:`atsflare-agent-{os}-{arch}`
|
* Agent 二进制文件命名约定:`atsflare-agent-{os}-{arch}`
|
||||||
|
* `auto_update` 默认关闭,由节点管理页逐节点开启
|
||||||
|
* `update_now` 由节点管理页手动触发,一次心跳消费一次
|
||||||
|
|
||||||
### 11.5 Agent 一键部署
|
### 11.5 Agent 一键部署
|
||||||
|
|
||||||
@@ -443,13 +445,14 @@ Agent 接口当前覆盖:
|
|||||||
* Agent 心跳间隔
|
* Agent 心跳间隔
|
||||||
* Agent 同步间隔
|
* Agent 同步间隔
|
||||||
* 节点离线阈值
|
* 节点离线阈值
|
||||||
* Agent 自动更新开关
|
|
||||||
* Agent 更新仓库
|
* Agent 更新仓库
|
||||||
* 全局 Discovery Token 展示与重新生成
|
* 全局 Discovery Token 展示与重新生成
|
||||||
* Agent 一键部署命令展示(根据当前 ServerAddress 和 DiscoveryToken 动态生成 curl 命令)
|
* Agent 一键部署命令展示(根据当前 ServerAddress 和 DiscoveryToken 动态生成 curl 命令)
|
||||||
* 节点列表页优化:
|
* 节点列表页优化:
|
||||||
* 时间显示改为友好的相对时间格式
|
* 时间显示改为友好的相对时间格式
|
||||||
* 节点状态使用颜色标识
|
* 节点状态使用颜色标识
|
||||||
|
* 支持逐节点开启自动更新
|
||||||
|
* 支持逐节点手动触发一次 Agent 更新
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ Agent 必须满足:
|
|||||||
V3 新增行为:
|
V3 新增行为:
|
||||||
|
|
||||||
* 心跳响应包含 `agent_settings` 时,动态调整定时器间隔
|
* 心跳响应包含 `agent_settings` 时,动态调整定时器间隔
|
||||||
* `auto_update=true` 时在每次心跳后检查 GitHub Releases 更新
|
* `auto_update=true` 或 `update_now=true` 时在每次心跳后检查 GitHub Releases 更新
|
||||||
* 自我更新失败不影响心跳与同步
|
* 自我更新失败不影响心跳与同步
|
||||||
* Server 下发的间隔值不持久化到 `agent.json`,重启后以本地为准
|
* Server 下发的间隔值不持久化到 `agent.json`,重启后以本地为准
|
||||||
* Agent 新增 `internal/updater` 模块处理自我更新逻辑
|
* Agent 新增 `internal/updater` 模块处理自我更新逻辑
|
||||||
|
|||||||
@@ -53,7 +53,6 @@
|
|||||||
* `AgentHeartbeatInterval`(默认 30000ms)
|
* `AgentHeartbeatInterval`(默认 30000ms)
|
||||||
* `AgentSyncInterval`(默认 30000ms)
|
* `AgentSyncInterval`(默认 30000ms)
|
||||||
* `NodeOfflineThreshold`(默认 120000ms)
|
* `NodeOfflineThreshold`(默认 120000ms)
|
||||||
* `AgentAutoUpdate`(默认 false)
|
|
||||||
* `AgentUpdateRepo`(默认 `Rain-kl/ATSFlare`)
|
* `AgentUpdateRepo`(默认 `Rain-kl/ATSFlare`)
|
||||||
2. 在 `model/option.go` 的 `InitOptionMap()` 注册新选项
|
2. 在 `model/option.go` 的 `InitOptionMap()` 注册新选项
|
||||||
3. 在 `model/option.go` 的 `updateOptionMap()` 增加对新选项的同步
|
3. 在 `model/option.go` 的 `updateOptionMap()` 增加对新选项的同步
|
||||||
@@ -101,13 +100,14 @@
|
|||||||
* 下载对应平台二进制
|
* 下载对应平台二进制
|
||||||
* 替换自身并重启(exec syscall)
|
* 替换自身并重启(exec syscall)
|
||||||
2. 在 `runner.go` 心跳循环中集成更新检查
|
2. 在 `runner.go` 心跳循环中集成更新检查
|
||||||
3. 更新触发条件:`auto_update=true` 且存在新版本
|
3. 更新触发条件:节点 `auto_update=true` 或收到一次性 `update_now=true` 且存在新版本
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
* Agent 能正确检测新版本
|
* Agent 能正确检测新版本
|
||||||
* 能下载并替换自身二进制
|
* 能下载并替换自身二进制
|
||||||
* 更新后自动重启并恢复心跳
|
* 更新后自动重启并恢复心跳
|
||||||
|
* 默认不自动更新,需由控制面板逐节点开启或手动触发
|
||||||
* 更新失败不影响正常运行
|
* 更新失败不影响正常运行
|
||||||
|
|
||||||
### 3.4 阶段四:GitHub Actions 完善与 Agent 一键部署
|
### 3.4 阶段四:GitHub Actions 完善与 Agent 一键部署
|
||||||
|
|||||||
Reference in New Issue
Block a user