mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
feat: 添加节点自动更新功能,支持手动触发更新
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+15
-13
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+38
-20
@@ -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
|
||||
}
|
||||
|
||||
+21
-11
@@ -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 {
|
||||
|
||||
@@ -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 = () => {
|
||||
</Form.Button>
|
||||
|
||||
<Divider />
|
||||
<Header as='h3'>Agent 自动更新</Header>
|
||||
<Form.Group>
|
||||
<Form.Checkbox
|
||||
label='启用 Agent 自动更新'
|
||||
checked={inputs.AgentAutoUpdate === 'true'}
|
||||
name='AgentAutoUpdate'
|
||||
onChange={handleCheckboxChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Header as='h3'>Agent 更新源</Header>
|
||||
<Message info size='small'>
|
||||
<p>
|
||||
自动更新开关和手动更新由节点页单独控制,这里只配置 Agent
|
||||
更新仓库。
|
||||
</p>
|
||||
</Message>
|
||||
<Form.Group widths='equal'>
|
||||
<Form.Input
|
||||
label='更新仓库'
|
||||
@@ -196,7 +188,7 @@ const OperationSetting = () => {
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Button onClick={submitAutoUpdate} primary>
|
||||
<Form.Button onClick={submitUpdateRepo} primary>
|
||||
保存更新设置
|
||||
</Form.Button>
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import React, { useEffect, useState } from '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';
|
||||
|
||||
const OtherSetting = () => {
|
||||
@@ -81,15 +88,18 @@ const OtherSetting = () => {
|
||||
window.location = 'https://github.com/Rain-kl/ATSFlare/releases/latest';
|
||||
};
|
||||
|
||||
const checkUpdate = async () => {
|
||||
const res = await API.get(
|
||||
'https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest'
|
||||
);
|
||||
const { tag_name, body } = res.data;
|
||||
if (tag_name === process.env.REACT_APP_VERSION) {
|
||||
showSuccess(`已是最新版本:${tag_name}`);
|
||||
} else {
|
||||
setUpdateData({
|
||||
const checkUpdate = async () => {
|
||||
const res = await API.get(
|
||||
'https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest'
|
||||
);
|
||||
const { tag_name, body } = res.data;
|
||||
if (
|
||||
hasBuildVersion() &&
|
||||
normalizeVersion(tag_name) === getBuildVersion()
|
||||
) {
|
||||
showSuccess(`已是最新版本:${tag_name}`);
|
||||
} else {
|
||||
setUpdateData({
|
||||
tag_name: tag_name,
|
||||
content: marked.parse(body),
|
||||
});
|
||||
|
||||
@@ -25,6 +25,19 @@ export function getFooterHTML() {
|
||||
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) {
|
||||
let okay = true;
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { API, showError, showSuccess, timeAgo } from '../../helpers';
|
||||
|
||||
const initialForm = {
|
||||
name: '',
|
||||
auto_update_enabled: false,
|
||||
};
|
||||
|
||||
const renderStatus = (status) => {
|
||||
@@ -26,6 +27,16 @@ const renderApply = (result) => {
|
||||
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 [nodes, setNodes] = useState([]);
|
||||
const [bootstrap, setBootstrap] = useState({ discovery_token: '' });
|
||||
@@ -91,6 +102,7 @@ const Node = () => {
|
||||
setSubmitting(true);
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
auto_update_enabled: form.auto_update_enabled,
|
||||
};
|
||||
const res = editingId
|
||||
? await API.put(`/api/nodes/${editingId}`, payload)
|
||||
@@ -110,9 +122,21 @@ const Node = () => {
|
||||
setEditingId(node.id);
|
||||
setForm({
|
||||
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) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
@@ -163,6 +187,14 @@ const Node = () => {
|
||||
value={form.name}
|
||||
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>
|
||||
<Button primary type='submit' loading={submitting}>
|
||||
{editingId ? '保存修改' : '新增节点'}
|
||||
@@ -182,6 +214,7 @@ const Node = () => {
|
||||
<Table.HeaderCell>Auth Token</Table.HeaderCell>
|
||||
<Table.HeaderCell>IP</Table.HeaderCell>
|
||||
<Table.HeaderCell>状态</Table.HeaderCell>
|
||||
<Table.HeaderCell>Agent 更新</Table.HeaderCell>
|
||||
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
|
||||
<Table.HeaderCell>当前版本</Table.HeaderCell>
|
||||
<Table.HeaderCell>最近应用</Table.HeaderCell>
|
||||
@@ -216,6 +249,7 @@ const Node = () => {
|
||||
</Table.Cell>
|
||||
<Table.Cell>{node.ip}</Table.Cell>
|
||||
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
|
||||
<Table.Cell>{renderUpdateMode(node)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{node.agent_version} / {node.nginx_version || 'unknown'}
|
||||
</Table.Cell>
|
||||
@@ -231,6 +265,14 @@ const Node = () => {
|
||||
</Table.Cell>
|
||||
<Table.Cell>{node.last_error || '无'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Button
|
||||
size='small'
|
||||
primary
|
||||
disabled={node.update_requested}
|
||||
onClick={() => requestAgentUpdate(node)}
|
||||
>
|
||||
立即更新
|
||||
</Button>
|
||||
<Button size='small' onClick={() => beginEdit(node)}>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
+15
-2
@@ -229,7 +229,14 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/insta
|
||||
|
||||
### 7.3 Agent 自动更新
|
||||
|
||||
在管理端 **运维设置** 中启用「Agent 自动更新」后,Agent 会在每次心跳周期检查 GitHub Releases,发现新版本时自动下载并重启。
|
||||
Agent 自动更新默认为关闭。
|
||||
|
||||
在管理端 **节点管理** 页面中可以:
|
||||
|
||||
* 为单个节点开启「自动更新」
|
||||
* 为单个节点点击「立即更新」,下发一次性更新指令
|
||||
|
||||
节点在收到对应心跳响应后,会检查 GitHub Releases,发现新版本时自动下载并重启。
|
||||
|
||||
---
|
||||
|
||||
@@ -252,9 +259,15 @@ GitHub Release 中的 Agent 二进制命名格式:
|
||||
| AgentHeartbeatInterval | 心跳间隔(毫秒) | heartbeat_interval |
|
||||
| AgentSyncInterval | 同步间隔(毫秒) | sync_interval |
|
||||
| NodeOfflineThreshold | 节点离线阈值(毫秒) | - |
|
||||
| AgentAutoUpdate | 是否启用自动更新 | auto_update |
|
||||
| AgentUpdateRepo | 自动更新仓库 | update_repo |
|
||||
|
||||
节点管理页下发的字段:
|
||||
|
||||
| 参数 | 说明 | Agent 字段 |
|
||||
| ---------------------- | ------------------ | ----------- |
|
||||
| Node.AutoUpdateEnabled | 节点是否自动更新 | auto_update |
|
||||
| Node.UpdateRequested | 节点一次性更新请求 | update_now |
|
||||
|
||||
---
|
||||
|
||||
## 10. 当前已知限制
|
||||
|
||||
+7
-4
@@ -347,7 +347,6 @@ Agent 接口当前覆盖:
|
||||
* `AgentHeartbeatInterval`:Agent 心跳上报间隔(毫秒),默认 30000
|
||||
* `AgentSyncInterval`:Agent 配置同步间隔(毫秒),默认 30000
|
||||
* `NodeOfflineThreshold`:节点离线判定阈值(毫秒),默认 120000
|
||||
* `AgentAutoUpdate`:是否允许 Agent 自动更新(`true`/`false`),默认 `false`
|
||||
* `AgentUpdateRepo`:Agent 自动更新 GitHub 仓库地址,默认 `Rain-kl/ATSFlare`
|
||||
* 环境变量类设置(`SESSION_SECRET`、`SQLITE_PATH`、`PORT`)不迁移,保留原有方式
|
||||
* 前端在设置页面新增「运维设置」Tab
|
||||
@@ -364,8 +363,9 @@ Agent 接口当前覆盖:
|
||||
* 心跳响应新增 `agent_settings` 字段,包含 Server 端可控的运行时参数:
|
||||
* `heartbeat_interval`(毫秒)
|
||||
* `sync_interval`(毫秒)
|
||||
* `auto_update`(布尔值)
|
||||
* `auto_update`(节点级布尔值)
|
||||
* `update_repo`(GitHub 仓库名)
|
||||
* `update_now`(一次性手动更新指令)
|
||||
* Agent 收到心跳响应后,动态调整本地定时器间隔
|
||||
* 当 Server 未返回 `agent_settings` 或字段为空时,Agent 保持本地值不变
|
||||
* 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
|
||||
* 比较本地 `agent_version` 与远端 tag
|
||||
* 若存在更新,下载对应平台的二进制文件
|
||||
@@ -387,6 +387,8 @@ Agent 接口当前覆盖:
|
||||
* 更新过程中不中断当前同步任务
|
||||
* 更新失败不影响正常心跳与同步
|
||||
* Agent 二进制文件命名约定:`atsflare-agent-{os}-{arch}`
|
||||
* `auto_update` 默认关闭,由节点管理页逐节点开启
|
||||
* `update_now` 由节点管理页手动触发,一次心跳消费一次
|
||||
|
||||
### 11.5 Agent 一键部署
|
||||
|
||||
@@ -443,13 +445,14 @@ Agent 接口当前覆盖:
|
||||
* Agent 心跳间隔
|
||||
* Agent 同步间隔
|
||||
* 节点离线阈值
|
||||
* Agent 自动更新开关
|
||||
* Agent 更新仓库
|
||||
* 全局 Discovery Token 展示与重新生成
|
||||
* Agent 一键部署命令展示(根据当前 ServerAddress 和 DiscoveryToken 动态生成 curl 命令)
|
||||
* 节点列表页优化:
|
||||
* 时间显示改为友好的相对时间格式
|
||||
* 节点状态使用颜色标识
|
||||
* 支持逐节点开启自动更新
|
||||
* 支持逐节点手动触发一次 Agent 更新
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ Agent 必须满足:
|
||||
V3 新增行为:
|
||||
|
||||
* 心跳响应包含 `agent_settings` 时,动态调整定时器间隔
|
||||
* `auto_update=true` 时在每次心跳后检查 GitHub Releases 更新
|
||||
* `auto_update=true` 或 `update_now=true` 时在每次心跳后检查 GitHub Releases 更新
|
||||
* 自我更新失败不影响心跳与同步
|
||||
* Server 下发的间隔值不持久化到 `agent.json`,重启后以本地为准
|
||||
* Agent 新增 `internal/updater` 模块处理自我更新逻辑
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
* `AgentHeartbeatInterval`(默认 30000ms)
|
||||
* `AgentSyncInterval`(默认 30000ms)
|
||||
* `NodeOfflineThreshold`(默认 120000ms)
|
||||
* `AgentAutoUpdate`(默认 false)
|
||||
* `AgentUpdateRepo`(默认 `Rain-kl/ATSFlare`)
|
||||
2. 在 `model/option.go` 的 `InitOptionMap()` 注册新选项
|
||||
3. 在 `model/option.go` 的 `updateOptionMap()` 增加对新选项的同步
|
||||
@@ -101,13 +100,14 @@
|
||||
* 下载对应平台二进制
|
||||
* 替换自身并重启(exec syscall)
|
||||
2. 在 `runner.go` 心跳循环中集成更新检查
|
||||
3. 更新触发条件:`auto_update=true` 且存在新版本
|
||||
3. 更新触发条件:节点 `auto_update=true` 或收到一次性 `update_now=true` 且存在新版本
|
||||
|
||||
验收标准:
|
||||
|
||||
* Agent 能正确检测新版本
|
||||
* 能下载并替换自身二进制
|
||||
* 更新后自动重启并恢复心跳
|
||||
* 默认不自动更新,需由控制面板逐节点开启或手动触发
|
||||
* 更新失败不影响正常运行
|
||||
|
||||
### 3.4 阶段四:GitHub Actions 完善与 Agent 一键部署
|
||||
|
||||
Reference in New Issue
Block a user