diff --git a/atsf_agent/internal/agent/runner.go b/atsf_agent/internal/agent/runner.go
index f184b975..954ec6a7 100644
--- a/atsf_agent/internal/agent/runner.go
+++ b/atsf_agent/internal/agent/runner.go
@@ -35,6 +35,7 @@ type Runner struct {
Updater Updater
autoUpdate bool
+ updateNow bool
updateRepo string
}
@@ -130,16 +131,19 @@ func (r *Runner) applySettings(settings *protocol.AgentSettings) bool {
}
}
r.autoUpdate = settings.AutoUpdate
- r.updateRepo = settings.UpdateRepo
+ r.updateNow = settings.UpdateNow
+ r.updateRepo = strings.TrimSpace(settings.UpdateRepo)
return changed
}
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
}
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)
}
}
diff --git a/atsf_agent/internal/protocol/agent_api.go b/atsf_agent/internal/protocol/agent_api.go
index 22835c6e..8169872f 100644
--- a/atsf_agent/internal/protocol/agent_api.go
+++ b/atsf_agent/internal/protocol/agent_api.go
@@ -18,6 +18,7 @@ type AgentSettings struct {
SyncInterval int `json:"sync_interval"`
AutoUpdate bool `json:"auto_update"`
UpdateRepo string `json:"update_repo"`
+ UpdateNow bool `json:"update_now"`
}
type NodePayload struct {
diff --git a/atsf_server/common/constants.go b/atsf_server/common/constants.go
index 794d4f68..e10e3c16 100644
--- a/atsf_server/common/constants.go
+++ b/atsf_server/common/constants.go
@@ -53,7 +53,6 @@ var NodeOfflineThreshold = 2 * time.Minute
// V3 operational settings (hot-reloadable via Option table)
var AgentHeartbeatInterval = 30000 // milliseconds
var AgentSyncInterval = 30000 // milliseconds
-var AgentAutoUpdate = false
var AgentUpdateRepo = "Rain-kl/ATSFlare"
const (
diff --git a/atsf_server/controller/node.go b/atsf_server/controller/node.go
index 39943667..61540f8a 100644
--- a/atsf_server/controller/node.go
+++ b/atsf_server/controller/node.go
@@ -3,9 +3,10 @@ package controller
import (
"atsflare/service"
"encoding/json"
- "github.com/gin-gonic/gin"
"net/http"
"strconv"
+
+ "github.com/gin-gonic/gin"
)
func CreateNode(c *gin.Context) {
@@ -117,3 +118,27 @@ func DeleteNode(c *gin.Context) {
"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,
+ })
+}
diff --git a/atsf_server/model/node.go b/atsf_server/model/node.go
index b63a7e2b..b3b7b752 100644
--- a/atsf_server/model/node.go
+++ b/atsf_server/model/node.go
@@ -3,19 +3,21 @@ package model
import "time"
type Node struct {
- ID uint `json:"id" gorm:"primaryKey"`
- NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"`
- Name string `json:"name" gorm:"size:128;not null"`
- IP string `json:"ip" gorm:"size:64;not null"`
- AgentToken string `json:"-" gorm:"size:128;index"`
- AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
- NginxVersion string `json:"nginx_version" gorm:"size:64"`
- Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
- CurrentVersion string `json:"current_version" gorm:"size:32"`
- LastSeenAt time.Time `json:"last_seen_at"`
- LastError string `json:"last_error" gorm:"size:1024"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `json:"id" gorm:"primaryKey"`
+ NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"`
+ Name string `json:"name" gorm:"size:128;not null"`
+ IP string `json:"ip" gorm:"size:64;not null"`
+ AgentToken string `json:"-" gorm:"size:128;index"`
+ AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"`
+ UpdateRequested bool `json:"update_requested" gorm:"not null;default:false"`
+ AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
+ NginxVersion string `json:"nginx_version" gorm:"size:64"`
+ Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
+ CurrentVersion string `json:"current_version" gorm:"size:32"`
+ LastSeenAt time.Time `json:"last_seen_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) {
diff --git a/atsf_server/model/option.go b/atsf_server/model/option.go
index 9478e8fe..f1d903fa 100644
--- a/atsf_server/model/option.go
+++ b/atsf_server/model/option.go
@@ -54,7 +54,6 @@ func InitOptionMap() {
common.OptionMap["AgentHeartbeatInterval"] = strconv.Itoa(common.AgentHeartbeatInterval)
common.OptionMap["AgentSyncInterval"] = strconv.Itoa(common.AgentSyncInterval)
common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds()))
- common.OptionMap["AgentAutoUpdate"] = strconv.FormatBool(common.AgentAutoUpdate)
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
common.OptionMapRWMutex.Unlock()
options, _ := AllOption()
@@ -162,8 +161,6 @@ func updateOptionMap(key string, value string) {
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.NodeOfflineThreshold = time.Duration(v) * time.Millisecond
}
- case "AgentAutoUpdate":
- common.AgentAutoUpdate = value == "true"
case "AgentUpdateRepo":
if value != "" {
common.AgentUpdateRepo = value
diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go
index 9022426f..4a8d4f11 100644
--- a/atsf_server/router/api-router.go
+++ b/atsf_server/router/api-router.go
@@ -3,6 +3,7 @@ package router
import (
"atsflare/controller"
"atsflare/middleware"
+
"github.com/gin-gonic/gin"
)
@@ -104,6 +105,7 @@ func SetApiRouter(router *gin.Engine) {
nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken)
nodeRoute.GET("/", controller.GetNodes)
nodeRoute.POST("/", controller.CreateNode)
+ nodeRoute.POST("/:id/agent-update", controller.RequestNodeAgentUpdate)
nodeRoute.PUT("/:id", controller.UpdateNode)
nodeRoute.DELETE("/:id", controller.DeleteNode)
}
diff --git a/atsf_server/service/agent.go b/atsf_server/service/agent.go
index 4d2f44f1..f2e12fe2 100644
--- a/atsf_server/service/agent.go
+++ b/atsf_server/service/agent.go
@@ -49,6 +49,7 @@ type AgentSettings struct {
SyncInterval int `json:"sync_interval"`
AutoUpdate bool `json:"auto_update"`
UpdateRepo string `json:"update_repo"`
+ UpdateNow bool `json:"update_now"`
}
type HeartbeatResponse struct {
@@ -63,6 +64,8 @@ type NodeView struct {
IP string `json:"ip"`
AgentToken string `json:"agent_token"`
Pending bool `json:"pending"`
+ AutoUpdateEnabled bool `json:"auto_update_enabled"`
+ UpdateRequested bool `json:"update_requested"`
AgentVersion string `json:"agent_version"`
NginxVersion string `json:"nginx_version"`
Status string `json:"status"`
@@ -87,8 +90,10 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
if err := validateAgentNodePayload(payload); err != nil {
return nil, err
}
+ updateNow := node.UpdateRequested
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 &HeartbeatResponse{
@@ -96,8 +101,9 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
AgentSettings: &AgentSettings{
HeartbeatInterval: common.AgentHeartbeatInterval,
SyncInterval: common.AgentSyncInterval,
- AutoUpdate: common.AgentAutoUpdate,
+ AutoUpdate: node.AutoUpdateEnabled,
UpdateRepo: common.AgentUpdateRepo,
+ UpdateNow: updateNow,
},
}, nil
}
diff --git a/atsf_server/service/node.go b/atsf_server/service/node.go
index b6e8fc2e..77b59fac 100644
--- a/atsf_server/service/node.go
+++ b/atsf_server/service/node.go
@@ -11,7 +11,8 @@ import (
)
type NodeInput struct {
- Name string `json:"name"`
+ Name string `json:"name"`
+ AutoUpdateEnabled bool `json:"auto_update_enabled"`
}
type NodeBootstrapView struct {
@@ -30,11 +31,12 @@ func CreateNode(input NodeInput) (*NodeView, error) {
return nil, errors.New("节点名不能为空")
}
node := &model.Node{
- Name: name,
- IP: "",
- AgentVersion: "",
- NginxVersion: "",
- Status: NodeStatusPending,
+ Name: name,
+ IP: "",
+ AgentVersion: "",
+ NginxVersion: "",
+ Status: NodeStatusPending,
+ AutoUpdateEnabled: input.AutoUpdateEnabled,
}
var err error
node.NodeID, err = newServerNodeID()
@@ -65,6 +67,7 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
return nil, err
}
node.Name = name
+ node.AutoUpdateEnabled = input.AutoUpdateEnabled
if err = node.Update(); err != nil {
return nil, err
}
@@ -81,6 +84,19 @@ func DeleteNode(id uint) error {
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) {
token = strings.TrimSpace(token)
if token == "" {
@@ -149,20 +165,22 @@ func RotateGlobalDiscoveryToken() (*NodeBootstrapView, error) {
func buildNodeView(node *model.Node) *NodeView {
status := computeNodeStatus(node)
view := &NodeView{
- ID: node.ID,
- NodeID: node.NodeID,
- Name: node.Name,
- IP: node.IP,
- AgentToken: node.AgentToken,
- AgentVersion: node.AgentVersion,
- NginxVersion: node.NginxVersion,
- Status: status,
- CurrentVersion: node.CurrentVersion,
- LastSeenAt: node.LastSeenAt,
- LastError: node.LastError,
- CreatedAt: node.CreatedAt,
- UpdatedAt: node.UpdatedAt,
- Pending: status == NodeStatusPending,
+ ID: node.ID,
+ NodeID: node.NodeID,
+ Name: node.Name,
+ IP: node.IP,
+ AgentToken: node.AgentToken,
+ AgentVersion: node.AgentVersion,
+ NginxVersion: node.NginxVersion,
+ Status: status,
+ CurrentVersion: node.CurrentVersion,
+ LastSeenAt: node.LastSeenAt,
+ LastError: node.LastError,
+ CreatedAt: node.CreatedAt,
+ UpdatedAt: node.UpdatedAt,
+ Pending: status == NodeStatusPending,
+ AutoUpdateEnabled: node.AutoUpdateEnabled,
+ UpdateRequested: node.UpdateRequested,
}
return view
}
diff --git a/atsf_server/web/src/App.js b/atsf_server/web/src/App.js
index c9836599..88ba6272 100644
--- a/atsf_server/web/src/App.js
+++ b/atsf_server/web/src/App.js
@@ -9,7 +9,14 @@ import NotFound from './pages/NotFound';
import Setting from './pages/Setting';
import EditUser from './pages/User/EditUser';
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 GitHubOAuth from './components/GitHubOAuth';
import PasswordResetConfirm from './components/PasswordResetConfirm';
@@ -44,16 +51,19 @@ function App() {
console.log(`GitHub 仓库地址:https://github.com/Rain-kl/ATSFlare`);
localStorage.setItem('status', JSON.stringify(data));
statusDispatch({ type: 'set', payload: data });
- localStorage.setItem('system_name', data.system_name);
- localStorage.setItem('footer_html', data.footer_html);
- localStorage.setItem('home_page_link', data.home_page_link);
- if (
- data.version !== process.env.REACT_APP_VERSION &&
- data.version !== 'v0.0.0' &&
- process.env.REACT_APP_VERSION !== ''
- ) {
- showNotice(
- `新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
+ localStorage.setItem('system_name', data.system_name);
+ localStorage.setItem('footer_html', data.footer_html);
+ localStorage.setItem('home_page_link', data.home_page_link);
+ const currentBuildVersion = getBuildVersion();
+ const latestServerVersion = normalizeVersion(data.version);
+ if (
+ hasBuildVersion() &&
+ latestServerVersion !== '' &&
+ latestServerVersion !== '0.0.0' &&
+ latestServerVersion !== currentBuildVersion
+ ) {
+ showNotice(
+ `新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
);
}
} else {
diff --git a/atsf_server/web/src/components/OperationSetting.js b/atsf_server/web/src/components/OperationSetting.js
index 2cfc1d27..f6c0ef43 100644
--- a/atsf_server/web/src/components/OperationSetting.js
+++ b/atsf_server/web/src/components/OperationSetting.js
@@ -17,7 +17,6 @@ const OperationSetting = () => {
AgentHeartbeatInterval: '30000',
AgentSyncInterval: '30000',
NodeOfflineThreshold: '120000',
- AgentAutoUpdate: 'false',
AgentUpdateRepo: 'Rain-kl/ATSFlare',
ServerAddress: '',
AgentDiscoveryToken: '',
@@ -77,10 +76,6 @@ const OperationSetting = () => {
setInputs((prev) => ({ ...prev, [name]: value }));
};
- const handleCheckboxChange = (e, { name, checked }) => {
- setInputs((prev) => ({ ...prev, [name]: checked ? 'true' : 'false' }));
- };
-
const submitAgentIntervals = async () => {
const hb = parseInt(inputs.AgentHeartbeatInterval, 10);
const sync = parseInt(inputs.AgentSyncInterval, 10);
@@ -102,8 +97,7 @@ const OperationSetting = () => {
await updateOption('NodeOfflineThreshold', String(offline));
};
- const submitAutoUpdate = async () => {
- await updateOption('AgentAutoUpdate', inputs.AgentAutoUpdate);
+ const submitUpdateRepo = async () => {
await updateOption('AgentUpdateRepo', inputs.AgentUpdateRepo);
};
@@ -178,15 +172,13 @@ const OperationSetting = () => {
+ 自动更新开关和手动更新由节点页单独控制,这里只配置 Agent + 更新仓库。 +
+