feat: openresty状态上报

This commit is contained in:
ryan
2026-03-12 15:09:34 +08:00
parent ecc71428e4
commit 42ca18681c
26 changed files with 1232 additions and 165 deletions
+33
View File
@@ -216,6 +216,39 @@ func RequestNodeAgentUpdate(c *gin.Context) {
})
}
// RequestNodeOpenrestyRestart godoc
// @Summary Request openresty restart on node
// @Tags Nodes
// @Produce json
// @Security BearerAuth
// @Param id path int true "Node ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/nodes/{id}/openresty-restart [post]
func RequestNodeOpenrestyRestart(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.RequestNodeOpenrestyRestart(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,
})
}
// GetNodeAgentRelease godoc
// @Summary Check latest agent release for node
// @Tags Nodes
+162 -2
View File
@@ -783,6 +783,53 @@ const docTemplate = `{
}
}
},
"/api/nodes/{id}/agent-release": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Nodes"
],
"summary": "Check latest agent release for node",
"parameters": [
{
"type": "integer",
"description": "Node ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "stable or preview",
"name": "channel",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/nodes/{id}/agent-update": {
"post": {
"security": [
@@ -824,6 +871,47 @@ const docTemplate = `{
}
}
},
"/api/nodes/{id}/openresty-restart": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Nodes"
],
"summary": "Request openresty restart on node",
"parameters": [
{
"type": "integer",
"description": "Node ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/option/": {
"get": {
"produces": [
@@ -1257,6 +1345,72 @@ const docTemplate = `{
}
}
}
},
"/api/update/manual-upgrade": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Update"
],
"summary": "Confirm upgrade with previously uploaded server binary",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/update/manual-upload": {
"post": {
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Update"
],
"summary": "Upload server binary and inspect version before upgrade",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/update/upgrade": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Update"
],
"summary": "Upgrade server binary from latest GitHub release",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"definitions": {
@@ -1294,6 +1448,12 @@ const docTemplate = `{
},
"node_id": {
"type": "string"
},
"openresty_message": {
"type": "string"
},
"openresty_status": {
"type": "string"
}
}
},
@@ -1429,8 +1589,8 @@ var SwaggerInfo = &swag.Spec{
Description: "ATSFlare Server 管理端与 Agent API 文档。",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
//LeftDelim: "{{",
//RightDelim: "}}",
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
+160
View File
@@ -780,6 +780,53 @@
}
}
},
"/api/nodes/{id}/agent-release": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Nodes"
],
"summary": "Check latest agent release for node",
"parameters": [
{
"type": "integer",
"description": "Node ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "stable or preview",
"name": "channel",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/nodes/{id}/agent-update": {
"post": {
"security": [
@@ -821,6 +868,47 @@
}
}
},
"/api/nodes/{id}/openresty-restart": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Nodes"
],
"summary": "Request openresty restart on node",
"parameters": [
{
"type": "integer",
"description": "Node ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/option/": {
"get": {
"produces": [
@@ -1254,6 +1342,72 @@
}
}
}
},
"/api/update/manual-upgrade": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Update"
],
"summary": "Confirm upgrade with previously uploaded server binary",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/update/manual-upload": {
"post": {
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Update"
],
"summary": "Upload server binary and inspect version before upgrade",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/update/upgrade": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Update"
],
"summary": "Upgrade server binary from latest GitHub release",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"definitions": {
@@ -1291,6 +1445,12 @@
},
"node_id": {
"type": "string"
},
"openresty_message": {
"type": "string"
},
"openresty_status": {
"type": "string"
}
}
},
+103
View File
@@ -23,6 +23,10 @@ definitions:
type: string
node_id:
type: string
openresty_message:
type: string
openresty_status:
type: string
type: object
service.ApplyLogPayload:
properties:
@@ -546,6 +550,36 @@ paths:
summary: Update node
tags:
- Nodes
/api/nodes/{id}/agent-release:
get:
parameters:
- description: Node ID
in: path
name: id
required: true
type: integer
- description: stable or preview
in: query
name: channel
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Check latest agent release for node
tags:
- Nodes
/api/nodes/{id}/agent-update:
post:
parameters:
@@ -572,6 +606,32 @@ paths:
summary: Request agent self-update on node
tags:
- Nodes
/api/nodes/{id}/openresty-restart:
post:
parameters:
- description: Node ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Request openresty restart on node
tags:
- Nodes
/api/nodes/bootstrap-token:
get:
produces:
@@ -880,6 +940,49 @@ paths:
summary: Get latest GitHub release
tags:
- Update
/api/update/manual-upgrade:
post:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
summary: Confirm upgrade with previously uploaded server binary
tags:
- Update
/api/update/manual-upload:
post:
consumes:
- multipart/form-data
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
summary: Upload server binary and inspect version before upgrade
tags:
- Update
/api/update/upgrade:
post:
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
summary: Upgrade server binary from latest GitHub release
tags:
- Update
schemes:
- http
- https
+20 -17
View File
@@ -3,23 +3,26 @@ 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"`
AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"`
UpdateRequested bool `json:"update_requested" gorm:"not null;default:false"`
UpdateChannel string `json:"update_channel" gorm:"size:16;not null;default:'stable'"`
UpdateTag string `json:"update_tag" gorm:"size:64"`
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"`
UpdateChannel string `json:"update_channel" gorm:"size:16;not null;default:'stable'"`
UpdateTag string `json:"update_tag" gorm:"size:64"`
RestartOpenrestyRequested bool `json:"restart_openresty_requested" gorm:"not null;default:false"`
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
NginxVersion string `json:"nginx_version" gorm:"size:64"`
OpenrestyStatus string `json:"openresty_status" gorm:"size:16;not null;default:'unknown'"`
OpenrestyMessage string `json:"openresty_message" gorm:"size:2048"`
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) {
+1
View File
@@ -115,6 +115,7 @@ func SetApiRouter(router *gin.Engine) {
nodeRoute.POST("/", controller.CreateNode)
nodeRoute.GET("/:id/agent-release", controller.GetNodeAgentRelease)
nodeRoute.POST("/:id/agent-update", controller.RequestNodeAgentUpdate)
nodeRoute.POST("/:id/openresty-restart", controller.RequestNodeOpenrestyRestart)
nodeRoute.PUT("/:id", controller.UpdateNode)
nodeRoute.DELETE("/:id", controller.DeleteNode)
}
+51 -7
View File
@@ -186,13 +186,15 @@ func TestPhase2AgentLifecycle(t *testing.T) {
}
heartbeatPayload := map[string]any{
"node_id": "spoofed-node-id",
"name": "shanghai-edge-1",
"ip": "10.0.0.9",
"agent_version": "0.1.1",
"nginx_version": "1.27.1.2",
"current_version": "",
"last_error": "",
"node_id": "spoofed-node-id",
"name": "shanghai-edge-1",
"ip": "10.0.0.9",
"agent_version": "0.1.1",
"nginx_version": "1.27.1.2",
"openresty_status": service.OpenrestyStatusUnhealthy,
"openresty_message": "docker run openresty failed: bind 80 already allocated",
"current_version": "",
"last_error": "",
}
resp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
var registeredNode model.Node
@@ -200,6 +202,9 @@ func TestPhase2AgentLifecycle(t *testing.T) {
if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
t.Fatal("expected heartbeat to update node metadata")
}
if registeredNode.OpenrestyStatus != service.OpenrestyStatusUnhealthy {
t.Fatal("expected heartbeat to update openresty status")
}
activeConfigResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
var activeConfig service.AgentConfigResponse
@@ -253,6 +258,45 @@ func TestPhase2AgentLifecycle(t *testing.T) {
if nodes[0].LastError != "openresty reload failed" {
t.Fatal("expected node last_error to reflect failed apply")
}
if nodes[0].OpenrestyStatus != service.OpenrestyStatusUnhealthy {
t.Fatal("expected node list to expose openresty status")
}
if nodes[0].OpenrestyMessage != "docker run openresty failed: bind 80 already allocated" {
t.Fatal("expected node list to expose openresty message")
}
restartResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/openresty-restart", nil)
decodeResponseData(t, restartResp, &createdNode)
if !createdNode.RestartOpenrestyRequested {
t.Fatal("expected openresty restart request flag to be set")
}
rawHeartbeatPayload, err := json.Marshal(heartbeatPayload)
if err != nil {
t.Fatalf("failed to marshal heartbeat payload: %v", err)
}
restartHeartbeatReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader(rawHeartbeatPayload))
restartHeartbeatReq.Header.Set("Content-Type", "application/json")
restartHeartbeatReq.Header.Set("X-Agent-Token", createdNode.AgentToken)
restartHeartbeatRecorder := httptest.NewRecorder()
engine.ServeHTTP(restartHeartbeatRecorder, restartHeartbeatReq)
if restartHeartbeatRecorder.Code != http.StatusOK {
t.Fatalf("unexpected heartbeat status %d: %s", restartHeartbeatRecorder.Code, restartHeartbeatRecorder.Body.String())
}
var restartHeartbeatBody struct {
Success bool `json:"success"`
Message string `json:"message"`
AgentSettings service.AgentSettings `json:"agent_settings"`
}
if err = json.Unmarshal(restartHeartbeatRecorder.Body.Bytes(), &restartHeartbeatBody); err != nil {
t.Fatalf("failed to decode heartbeat response: %v", err)
}
if !restartHeartbeatBody.Success {
t.Fatalf("expected heartbeat request success, got %s", restartHeartbeatBody.Message)
}
if !restartHeartbeatBody.AgentSettings.RestartOpenrestyNow {
t.Fatal("expected heartbeat response to instruct openresty restart")
}
logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil)
var logs []model.ApplyLog
+59 -47
View File
@@ -12,21 +12,26 @@ import (
)
const (
NodeStatusOnline = "online"
NodeStatusOffline = "offline"
NodeStatusPending = "pending"
ApplyResultOK = "success"
ApplyResultFailed = "failed"
NodeStatusOnline = "online"
NodeStatusOffline = "offline"
NodeStatusPending = "pending"
ApplyResultOK = "success"
ApplyResultFailed = "failed"
OpenrestyStatusHealthy = "healthy"
OpenrestyStatusUnhealthy = "unhealthy"
OpenrestyStatusUnknown = "unknown"
)
type AgentNodePayload struct {
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
AgentVersion string `json:"agent_version"`
NginxVersion string `json:"nginx_version"`
CurrentVersion string `json:"current_version"`
LastError string `json:"last_error"`
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
AgentVersion string `json:"agent_version"`
NginxVersion string `json:"nginx_version"`
CurrentVersion string `json:"current_version"`
LastError string `json:"last_error"`
OpenrestyStatus string `json:"openresty_status"`
OpenrestyMessage string `json:"openresty_message"`
}
type ApplyLogPayload struct {
@@ -45,13 +50,14 @@ type AgentConfigResponse struct {
}
type AgentSettings struct {
HeartbeatInterval int `json:"heartbeat_interval"`
SyncInterval int `json:"sync_interval"`
AutoUpdate bool `json:"auto_update"`
UpdateRepo string `json:"update_repo"`
UpdateNow bool `json:"update_now"`
UpdateChannel string `json:"update_channel"`
UpdateTag string `json:"update_tag"`
HeartbeatInterval int `json:"heartbeat_interval"`
SyncInterval int `json:"sync_interval"`
AutoUpdate bool `json:"auto_update"`
UpdateRepo string `json:"update_repo"`
UpdateNow bool `json:"update_now"`
UpdateChannel string `json:"update_channel"`
UpdateTag string `json:"update_tag"`
RestartOpenrestyNow bool `json:"restart_openresty_now"`
}
type HeartbeatResponse struct {
@@ -60,26 +66,29 @@ type HeartbeatResponse struct {
}
type NodeView struct {
ID uint `json:"id"`
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
AgentToken string `json:"agent_token"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
UpdateRequested bool `json:"update_requested"`
UpdateChannel string `json:"update_channel"`
UpdateTag string `json:"update_tag"`
AgentVersion string `json:"agent_version"`
NginxVersion string `json:"nginx_version"`
Status string `json:"status"`
CurrentVersion string `json:"current_version"`
LastSeenAt time.Time `json:"last_seen_at"`
LastError string `json:"last_error"`
LatestApplyResult string `json:"latest_apply_result"`
LatestApplyMessage string `json:"latest_apply_message"`
LatestApplyAt *time.Time `json:"latest_apply_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id"`
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
AgentToken string `json:"agent_token"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
UpdateRequested bool `json:"update_requested"`
UpdateChannel string `json:"update_channel"`
UpdateTag string `json:"update_tag"`
RestartOpenrestyRequested bool `json:"restart_openresty_requested"`
AgentVersion string `json:"agent_version"`
NginxVersion string `json:"nginx_version"`
OpenrestyStatus string `json:"openresty_status"`
OpenrestyMessage string `json:"openresty_message"`
Status string `json:"status"`
CurrentVersion string `json:"current_version"`
LastSeenAt time.Time `json:"last_seen_at"`
LastError string `json:"last_error"`
LatestApplyResult string `json:"latest_apply_result"`
LatestApplyMessage string `json:"latest_apply_message"`
LatestApplyAt *time.Time `json:"latest_apply_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func RegisterNode(node *model.Node, payload AgentNodePayload) (*AgentRegistrationResponse, error) {
@@ -94,25 +103,28 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
return nil, err
}
updateNow := node.UpdateRequested
restartOpenrestyNow := node.RestartOpenrestyRequested
updateChannel := normalizeReleaseChannel(node.UpdateChannel)
updateTag := strings.TrimSpace(node.UpdateTag)
applyNodeRuntime(node, payload, true)
node.UpdateRequested = false
node.UpdateChannel = ReleaseChannelStable.String()
node.UpdateTag = ""
if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error", "update_requested", "update_channel", "update_tag").Updates(node).Error; err != nil {
node.RestartOpenrestyRequested = false
if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "openresty_status", "openresty_message", "status", "current_version", "last_seen_at", "last_error", "update_requested", "update_channel", "update_tag", "restart_openresty_requested").Updates(node).Error; err != nil {
return nil, err
}
return &HeartbeatResponse{
Node: node,
AgentSettings: &AgentSettings{
HeartbeatInterval: common.AgentHeartbeatInterval,
SyncInterval: common.AgentSyncInterval,
AutoUpdate: node.AutoUpdateEnabled,
UpdateRepo: common.AgentUpdateRepo,
UpdateNow: updateNow,
UpdateChannel: updateChannel.String(),
UpdateTag: updateTag,
HeartbeatInterval: common.AgentHeartbeatInterval,
SyncInterval: common.AgentSyncInterval,
AutoUpdate: node.AutoUpdateEnabled,
UpdateRepo: common.AgentUpdateRepo,
UpdateNow: updateNow,
UpdateChannel: updateChannel.String(),
UpdateTag: updateTag,
RestartOpenrestyNow: restartOpenrestyNow,
},
}, nil
}
+48 -17
View File
@@ -145,6 +145,19 @@ func RequestNodeAgentUpdate(id uint, input NodeAgentUpdateInput) (*NodeView, err
return buildNodeView(node), nil
}
func RequestNodeOpenrestyRestart(id uint) (*NodeView, error) {
node, err := model.GetNodeByID(id)
if err != nil {
return nil, err
}
node.RestartOpenrestyRequested = true
if err = model.DB.Model(node).Select("restart_openresty_requested").Updates(node).Error; err != nil {
return nil, err
}
common.SysLog("openresty restart 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 == "" {
@@ -213,23 +226,26 @@ 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,
UpdateChannel: strings.TrimSpace(node.UpdateChannel),
UpdateTag: strings.TrimSpace(node.UpdateTag),
AgentVersion: node.AgentVersion,
NginxVersion: node.NginxVersion,
Status: status,
CurrentVersion: node.CurrentVersion,
LastSeenAt: node.LastSeenAt,
LastError: node.LastError,
CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt,
AutoUpdateEnabled: node.AutoUpdateEnabled,
UpdateRequested: node.UpdateRequested,
ID: node.ID,
NodeID: node.NodeID,
Name: node.Name,
IP: node.IP,
AgentToken: node.AgentToken,
UpdateChannel: strings.TrimSpace(node.UpdateChannel),
UpdateTag: strings.TrimSpace(node.UpdateTag),
RestartOpenrestyRequested: node.RestartOpenrestyRequested,
AgentVersion: node.AgentVersion,
NginxVersion: node.NginxVersion,
OpenrestyStatus: normalizeOpenrestyStatus(node.OpenrestyStatus),
OpenrestyMessage: strings.TrimSpace(node.OpenrestyMessage),
Status: status,
CurrentVersion: node.CurrentVersion,
LastSeenAt: node.LastSeenAt,
LastError: node.LastError,
CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt,
AutoUpdateEnabled: node.AutoUpdateEnabled,
UpdateRequested: node.UpdateRequested,
}
if view.UpdateChannel == "" {
view.UpdateChannel = ReleaseChannelStable.String()
@@ -322,6 +338,8 @@ func normalizeAgentNodePayload(payload AgentNodePayload) AgentNodePayload {
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
payload.LastError = strings.TrimSpace(payload.LastError)
payload.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus)
payload.OpenrestyMessage = strings.TrimSpace(payload.OpenrestyMessage)
return payload
}
@@ -344,12 +362,25 @@ func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName b
node.IP = strings.TrimSpace(payload.IP)
node.AgentVersion = strings.TrimSpace(payload.AgentVersion)
node.NginxVersion = strings.TrimSpace(payload.NginxVersion)
node.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus)
node.OpenrestyMessage = strings.TrimSpace(payload.OpenrestyMessage)
node.Status = NodeStatusOnline
node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
node.LastSeenAt = time.Now()
node.LastError = strings.TrimSpace(payload.LastError)
}
func normalizeOpenrestyStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case OpenrestyStatusHealthy:
return OpenrestyStatusHealthy
case OpenrestyStatusUnhealthy:
return OpenrestyStatusUnhealthy
default:
return OpenrestyStatusUnknown
}
}
func newRandomToken() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
+48 -16
View File
@@ -62,28 +62,31 @@ func TestHeartbeatNodeReturnsPreviewUpdateSettings(t *testing.T) {
setupServiceTestDB(t)
node := &model.Node{
NodeID: "node-preview-1",
Name: "preview-edge-1",
IP: "10.0.0.8",
AgentToken: "agent-token",
AgentVersion: "v0.4.0",
NginxVersion: "1.27.1.2",
Status: NodeStatusOnline,
UpdateRequested: true,
UpdateChannel: "preview",
UpdateTag: "v0.5.0-rc.1",
AutoUpdateEnabled: false,
NodeID: "node-preview-1",
Name: "preview-edge-1",
IP: "10.0.0.8",
AgentToken: "agent-token",
AgentVersion: "v0.4.0",
NginxVersion: "1.27.1.2",
Status: NodeStatusOnline,
UpdateRequested: true,
UpdateChannel: "preview",
UpdateTag: "v0.5.0-rc.1",
RestartOpenrestyRequested: true,
AutoUpdateEnabled: false,
}
if err := node.Insert(); err != nil {
t.Fatalf("failed to seed node: %v", err)
}
resp, err := HeartbeatNode(node, AgentNodePayload{
NodeID: node.NodeID,
Name: node.Name,
IP: node.IP,
AgentVersion: node.AgentVersion,
NginxVersion: node.NginxVersion,
NodeID: node.NodeID,
Name: node.Name,
IP: node.IP,
AgentVersion: node.AgentVersion,
NginxVersion: node.NginxVersion,
OpenrestyStatus: OpenrestyStatusUnhealthy,
OpenrestyMessage: "port 80 already allocated",
})
if err != nil {
t.Fatalf("expected heartbeat to succeed: %v", err)
@@ -100,6 +103,15 @@ func TestHeartbeatNodeReturnsPreviewUpdateSettings(t *testing.T) {
if resp.AgentSettings.UpdateTag != "v0.5.0-rc.1" {
t.Fatalf("unexpected update tag: %s", resp.AgentSettings.UpdateTag)
}
if !resp.AgentSettings.RestartOpenrestyNow {
t.Fatal("expected restart_openresty_now to be true")
}
if resp.Node.OpenrestyStatus != OpenrestyStatusUnhealthy {
t.Fatalf("expected unhealthy openresty status, got %s", resp.Node.OpenrestyStatus)
}
if resp.Node.OpenrestyMessage != "port 80 already allocated" {
t.Fatalf("unexpected openresty message: %s", resp.Node.OpenrestyMessage)
}
storedNode, err := model.GetNodeByID(node.ID)
if err != nil {
@@ -114,4 +126,24 @@ func TestHeartbeatNodeReturnsPreviewUpdateSettings(t *testing.T) {
if storedNode.UpdateTag != "" {
t.Fatalf("expected update tag to be cleared, got %s", storedNode.UpdateTag)
}
if storedNode.RestartOpenrestyRequested {
t.Fatal("expected restart_openresty_requested to be reset after heartbeat")
}
}
func TestRequestNodeOpenrestyRestart(t *testing.T) {
setupServiceTestDB(t)
node, err := CreateNode(NodeInput{Name: "restart-edge-1"})
if err != nil {
t.Fatalf("failed to create node: %v", err)
}
updated, err := RequestNodeOpenrestyRestart(node.ID)
if err != nil {
t.Fatalf("expected openresty restart request to succeed: %v", err)
}
if !updated.RestartOpenrestyRequested {
t.Fatal("expected restart_openresty_requested to be true")
}
}
@@ -61,3 +61,9 @@ export function requestNodeAgentUpdate(
body: JSON.stringify(payload ?? {}),
});
}
export function requestNodeOpenrestyRestart(id: number) {
return apiRequest<NodeItem>(`/nodes/${id}/openresty-restart`, {
method: 'POST',
});
}
@@ -21,6 +21,7 @@ import {
deleteNode,
getNodeAgentRelease,
getNodes,
requestNodeOpenrestyRestart,
requestNodeAgentUpdate,
updateNode,
} from '@/features/nodes/api/nodes';
@@ -45,6 +46,8 @@ import {
getApplyVariant,
getNodeStatusLabel,
getNodeStatusVariant,
getOpenrestyStatusLabel,
getOpenrestyStatusVariant,
getServerUrl,
getUpdateMode,
isMeaningfulTime,
@@ -202,6 +205,20 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
},
});
const restartOpenrestyMutation = useMutation({
mutationFn: () => requestNodeOpenrestyRestart(Number(nodeId)),
onSuccess: async (updatedNode) => {
setFeedback({
tone: 'success',
message: `已向节点 ${updatedNode.name} 下发 OpenResty 重启指令。`,
});
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteNode(Number(nodeId)),
onSuccess: async () => {
@@ -236,6 +253,23 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
deleteMutation.mutate();
};
const handleRestartOpenresty = () => {
if (!node) {
return;
}
if (
!window.confirm(
`确认向节点“${node.name}”下发 OpenResty 重启指令吗?该指令会在下一次心跳后执行。`,
)
) {
return;
}
setFeedback(null);
restartOpenrestyMutation.mutate();
};
const handleCopy = async (value: string, message: string) => {
try {
await copyToClipboard(value);
@@ -337,6 +371,17 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
>
{node.update_requested ? '查看 Agent 更新' : 'Agent 更新'}
</PrimaryButton>
<SecondaryButton
type="button"
onClick={handleRestartOpenresty}
disabled={restartOpenrestyMutation.isPending}
>
{restartOpenrestyMutation.isPending
? '下发重启中...'
: node.restart_openresty_requested
? '等待 OpenResty 重启'
: '重启 OpenResty'}
</SecondaryButton>
<DangerButton
type="button"
onClick={handleDelete}
@@ -378,6 +423,20 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</div>
</AppCard>
<AppCard title="OpenResty 健康">
<div className="space-y-3">
<StatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
variant={getOpenrestyStatusVariant(node.openresty_status)}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{node.restart_openresty_requested
? '已等待节点在下一次心跳后执行 OpenResty 重启。'
: node.openresty_message || '当前未上报额外错误。'}
</p>
</div>
</AppCard>
<AppCard title="更新模式">
<div className="space-y-3">
<StatusBadge
@@ -485,6 +544,14 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
{node.last_error || '无'}
</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
OpenResty 状态消息
</p>
<p className="mt-1 break-words whitespace-pre-wrap">
{node.openresty_message || '无'}
</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
创建时间
@@ -36,6 +36,8 @@ import {
getApplyVariant,
getNodeStatusLabel,
getNodeStatusVariant,
getOpenrestyStatusLabel,
getOpenrestyStatusVariant,
isMeaningfulTime,
} from '@/features/nodes/utils';
@@ -236,7 +238,8 @@ export function NodesPage() {
<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">Agent / Nginx</th>
<th className="px-3 py-3 font-medium">Agent / OpenResty</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>
@@ -266,6 +269,21 @@ export function NodesPage() {
{node.agent_version || 'unknown'} /{' '}
{node.nginx_version || 'unknown'}
</td>
<td className="px-3 py-4">
<div className="space-y-2">
<StatusBadge
label={getOpenrestyStatusLabel(
node.openresty_status,
)}
variant={getOpenrestyStatusVariant(
node.openresty_status,
)}
/>
<p className="max-w-64 text-xs break-words whitespace-pre-wrap text-[var(--foreground-secondary)]">
{node.openresty_message || '无额外错误'}
</p>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{node.current_version || '未应用'}
</td>
+3
View File
@@ -10,8 +10,11 @@ export interface NodeItem {
update_requested: boolean;
update_channel: ReleaseChannel;
update_tag: string;
restart_openresty_requested: boolean;
agent_version: string;
nginx_version: string;
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
openresty_message: string;
status: 'online' | 'offline' | 'pending';
current_version: string;
last_seen_at: string;
+24
View File
@@ -68,6 +68,30 @@ export function getUpdateMode(node: NodeItem) {
return { label: '手动更新', variant: 'info' as const };
}
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return 'success';
}
if (status === 'unhealthy') {
return 'danger';
}
return 'warning';
}
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return '健康';
}
if (status === 'unhealthy') {
return '异常';
}
return '未知';
}
function parseVersionParts(version: string) {
const normalized = version.trim().replace(/^v/i, '');
if (!normalized || normalized.toLowerCase() === 'unknown') {