feat: Implement node management features including creation, updating, and deletion

- Added CreateNode, UpdateNode, and DeleteNode functions in the controller for managing nodes.
- Introduced NodeInput struct for input validation during node creation and updates.
- Enhanced the Node model to include DiscoveryToken and Pending status.
- Updated the AgentRegister and AgentHeartbeat functions to utilize the new node management logic.
- Refactored the API router to include new routes for node management.
- Improved the frontend Node component to support node creation, editing, and deletion with appropriate UI feedback.
- Added tests to ensure the new functionality works as expected.
This commit is contained in:
ryan
2026-03-10 15:33:06 +08:00
parent 861d759f97
commit 05e75549d2
16 changed files with 916 additions and 296 deletions
+68 -16
View File
@@ -2,7 +2,9 @@ package agent
import (
"context"
"errors"
"log"
"strings"
"time"
"atsflare-agent/internal/config"
@@ -11,8 +13,9 @@ import (
)
type HeartbeatService interface {
Register(ctx context.Context, payload protocol.NodePayload) error
Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error)
Heartbeat(ctx context.Context, payload protocol.NodePayload) error
SetToken(token string)
}
type SyncService interface {
@@ -33,21 +36,20 @@ func (r *Runner) Run(ctx context.Context) error {
return err
}
log.Printf("agent runner started: node_id=%s node=%s ip=%s", nodeID, r.Config.NodeName, r.Config.NodeIP)
if err = r.HeartbeatService.Register(ctx, r.nodePayload(nodeID)); err != nil {
log.Printf("agent register failed: %v", err)
} else {
log.Printf("agent register succeeded: node_id=%s", nodeID)
}
if err = r.SyncService.SyncOnStartup(ctx); err != nil {
r.recordSyncError(err)
log.Printf("agent startup sync failed: %v", err)
} else {
log.Printf("agent startup sync completed")
}
if err = r.HeartbeatService.Heartbeat(ctx, r.nodePayload(nodeID)); err != nil {
log.Printf("agent startup heartbeat failed: %v", err)
} else {
log.Printf("agent startup heartbeat succeeded: node_id=%s", nodeID)
if r.hasAgentToken() {
if err = r.SyncService.SyncOnStartup(ctx); err != nil {
r.recordSyncError(err)
log.Printf("agent startup sync failed: %v", err)
} else {
log.Printf("agent startup sync completed")
}
if err = r.HeartbeatService.Heartbeat(ctx, r.nodePayload(nodeID)); err != nil {
log.Printf("agent startup heartbeat failed: %v", err)
} else {
log.Printf("agent startup heartbeat succeeded: node_id=%s", nodeID)
}
} else if err = r.tryRegister(ctx, &nodeID); err != nil {
log.Printf("agent initial discovery register failed: %v", err)
}
heartbeatTicker := time.NewTicker(r.Config.HeartbeatInterval)
@@ -61,10 +63,19 @@ func (r *Runner) Run(ctx context.Context) error {
log.Printf("agent runner shutting down: %v", ctx.Err())
return ctx.Err()
case <-heartbeatTicker.C:
if !r.hasAgentToken() {
if err = r.tryRegister(ctx, &nodeID); err != nil {
log.Printf("agent discovery register failed: %v", err)
}
continue
}
if err = r.HeartbeatService.Heartbeat(ctx, r.nodePayload(nodeID)); err != nil {
log.Printf("agent heartbeat failed: %v", err)
}
case <-syncTicker.C:
if !r.hasAgentToken() {
continue
}
log.Printf("agent sync tick: node_id=%s", nodeID)
if err = r.SyncService.SyncOnce(ctx); err != nil {
r.recordSyncError(err)
@@ -76,6 +87,47 @@ func (r *Runner) Run(ctx context.Context) error {
}
}
func (r *Runner) hasAgentToken() bool {
return strings.TrimSpace(r.Config.AgentToken) != ""
}
func (r *Runner) tryRegister(ctx context.Context, nodeID *string) error {
if strings.TrimSpace(r.Config.DiscoveryToken) == "" {
return errors.New("agent_token 为空且未配置 discovery_token")
}
log.Printf("agent discovery registration started")
response, err := r.HeartbeatService.Register(ctx, r.nodePayload(*nodeID))
if err != nil {
return err
}
if response == nil || strings.TrimSpace(response.AgentToken) == "" || strings.TrimSpace(response.NodeID) == "" {
return errors.New("discovery register response 缺少 node_id 或 agent_token")
}
snapshot, err := r.StateStore.Load()
if err != nil {
return err
}
snapshot.NodeID = response.NodeID
if err = r.StateStore.Save(snapshot); err != nil {
return err
}
r.Config.AgentToken = response.AgentToken
r.Config.DiscoveryToken = ""
if err = r.Config.Save(); err != nil {
return err
}
r.HeartbeatService.SetToken(response.AgentToken)
*nodeID = response.NodeID
log.Printf("agent discovery registration succeeded: node_id=%s", response.NodeID)
if err = r.SyncService.SyncOnStartup(ctx); err != nil {
r.recordSyncError(err)
log.Printf("agent post-register startup sync failed: %v", err)
} else {
log.Printf("agent post-register startup sync completed")
}
return nil
}
func (r *Runner) recordSyncError(err error) {
if err == nil || r.StateStore == nil {
return
+86 -6
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
@@ -18,15 +19,17 @@ type fakeHeartbeatService struct {
registerCalls int
heartbeatCalls int
registerErr error
registerResp *protocol.RegisterNodeResponse
heartbeatErrs []error
onHeartbeat func(int)
lastToken string
}
func (f *fakeHeartbeatService) Register(ctx context.Context, payload protocol.NodePayload) error {
func (f *fakeHeartbeatService) Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.registerCalls++
return f.registerErr
return f.registerResp, f.registerErr
}
func (f *fakeHeartbeatService) Heartbeat(ctx context.Context, payload protocol.NodePayload) error {
@@ -45,6 +48,12 @@ func (f *fakeHeartbeatService) Heartbeat(ctx context.Context, payload protocol.N
return err
}
func (f *fakeHeartbeatService) SetToken(token string) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastToken = token
}
type fakeSyncService struct {
mu sync.Mutex
startupErr error
@@ -90,6 +99,7 @@ func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) {
}
runner := &Runner{
Config: &config.Config{
AgentToken: "agent-token",
NodeName: "edge-01",
NodeIP: "10.0.0.8",
AgentVersion: "0.1.0",
@@ -106,8 +116,8 @@ func TestRunnerKeepsHeartbeatWhenStartupSyncFails(t *testing.T) {
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context cancellation, got %v", err)
}
if heartbeatService.registerCalls != 1 {
t.Fatalf("expected 1 register call, got %d", heartbeatService.registerCalls)
if heartbeatService.registerCalls != 0 {
t.Fatalf("expected no discovery register call, got %d", heartbeatService.registerCalls)
}
if heartbeatService.heartbeatCalls < 2 {
t.Fatalf("expected heartbeat loop to continue, got %d heartbeat calls", heartbeatService.heartbeatCalls)
@@ -140,6 +150,7 @@ func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) {
}
runner := &Runner{
Config: &config.Config{
AgentToken: "agent-token",
NodeName: "edge-01",
NodeIP: "10.0.0.8",
AgentVersion: "0.1.0",
@@ -156,8 +167,8 @@ func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) {
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context cancellation, got %v", err)
}
if heartbeatService.registerCalls != 1 {
t.Fatalf("expected register attempt, got %d", heartbeatService.registerCalls)
if heartbeatService.registerCalls != 0 {
t.Fatalf("expected no register attempt, got %d", heartbeatService.registerCalls)
}
if syncService.syncOnceCalls == 0 {
t.Fatal("expected sync loop to continue after heartbeat/register errors")
@@ -170,3 +181,72 @@ func TestRunnerDoesNotExitOnHeartbeatOrSyncError(t *testing.T) {
t.Fatalf("expected sync error to be recorded, got %q", snapshot.LastError)
}
}
func TestRunnerDiscoveryRegisterUpdatesTokenAndNodeID(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
heartbeatService := &fakeHeartbeatService{
registerResp: &protocol.RegisterNodeResponse{
NodeID: "node-server-assigned",
AgentToken: "agent-token-issued",
Name: "edge-01",
},
onHeartbeat: func(callCount int) {
if callCount >= 1 {
cancel()
}
},
}
syncService := &fakeSyncService{}
configPath := filepath.Join(t.TempDir(), "agent.json")
if err := os.WriteFile(configPath, []byte(`{"server_url":"http://127.0.0.1:3000","discovery_token":"discovery-token","node_name":"edge-01","node_ip":"10.0.0.8"}`), 0o644); err != nil {
t.Fatalf("failed to seed config file: %v", err)
}
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
runner := &Runner{
Config: &config.Config{
ServerURL: cfg.ServerURL,
DiscoveryToken: cfg.DiscoveryToken,
NodeName: cfg.NodeName,
NodeIP: cfg.NodeIP,
AgentVersion: "0.1.0",
NginxVersion: "1.25.5",
HeartbeatInterval: 10 * time.Millisecond,
SyncInterval: 20 * time.Millisecond,
},
StateStore: stateStore,
HeartbeatService: heartbeatService,
SyncService: syncService,
}
runner.Config = cfg
runner.Config.AgentVersion = "0.1.0"
runner.Config.NginxVersion = "1.25.5"
runner.Config.HeartbeatInterval = 10 * time.Millisecond
runner.Config.SyncInterval = 20 * time.Millisecond
err = runner.Run(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context cancellation, got %v", err)
}
if heartbeatService.registerCalls == 0 {
t.Fatal("expected discovery register to be attempted")
}
if heartbeatService.lastToken != "agent-token-issued" {
t.Fatalf("expected client token to be updated, got %q", heartbeatService.lastToken)
}
snapshot, loadErr := stateStore.Load()
if loadErr != nil {
t.Fatalf("failed to load state: %v", loadErr)
}
if snapshot.NodeID != "node-server-assigned" {
t.Fatalf("expected node id to be replaced, got %q", snapshot.NodeID)
}
if runner.Config.AgentToken != "agent-token-issued" || runner.Config.DiscoveryToken != "" {
t.Fatal("expected config token rotation to complete")
}
}
+61 -2
View File
@@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"errors"
"net"
"os"
pathpkg "path"
"path/filepath"
@@ -20,6 +21,7 @@ const (
type Config struct {
ServerURL string `json:"server_url"`
AgentToken string `json:"agent_token"`
DiscoveryToken string `json:"discovery_token"`
NodeName string `json:"node_name"`
NodeIP string `json:"node_ip"`
AgentVersion string `json:"agent_version"`
@@ -36,6 +38,7 @@ type Config struct {
HeartbeatInterval time.Duration `json:"heartbeat_interval"`
SyncInterval time.Duration `json:"sync_interval"`
RequestTimeout time.Duration `json:"request_timeout"`
configPath string
}
func Load(path string) (*Config, error) {
@@ -47,6 +50,7 @@ func Load(path string) (*Config, error) {
if err = json.Unmarshal(data, cfg); err != nil {
return nil, err
}
cfg.configPath = path
applyDefaults(cfg, filepath.Dir(path))
if err = validate(cfg); err != nil {
return nil, err
@@ -71,6 +75,12 @@ func applyDefaults(cfg *Config, baseDir string) {
if cfg.DataDir == "" {
cfg.DataDir = filepath.Join(baseDir, "data")
}
if cfg.NodeName == "" {
cfg.NodeName = detectHostname()
}
if cfg.NodeIP == "" {
cfg.NodeIP = detectNodeIP()
}
if cfg.NginxPath == "" {
cfg.RouteConfigPath = joinManagedPath(cfg.DataDir, defaultDockerRouteConfigRelativePath)
cfg.StatePath = joinManagedPath(cfg.DataDir, defaultDockerStateRelativePath)
@@ -137,8 +147,8 @@ func validate(cfg *Config) error {
if cfg.ServerURL == "" {
return errors.New("server_url 不能为空")
}
if cfg.AgentToken == "" {
return errors.New("agent_token 不能为空")
if strings.TrimSpace(cfg.AgentToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" {
return errors.New("agent_token 和 discovery_token 不能同时为空")
}
if cfg.NodeName == "" {
return errors.New("node_name 不能为空")
@@ -148,3 +158,52 @@ func validate(cfg *Config) error {
}
return nil
}
func (cfg *Config) Save() error {
if cfg == nil {
return errors.New("config 不能为空")
}
if cfg.configPath == "" {
return errors.New("config path 未初始化")
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(cfg.configPath, data, 0o644)
}
func detectHostname() string {
host, err := os.Hostname()
if err != nil {
return ""
}
return strings.TrimSpace(host)
}
func detectNodeIP() string {
interfaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() {
continue
}
ipv4 := ipNet.IP.To4()
if ipv4 != nil {
return ipv4.String()
}
}
}
return ""
}
+7 -2
View File
@@ -7,8 +7,9 @@ import (
)
type Client interface {
RegisterNode(ctx context.Context, payload protocol.NodePayload) error
RegisterNode(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error)
Heartbeat(ctx context.Context, payload protocol.NodePayload) error
SetToken(token string)
}
type Service struct {
@@ -19,10 +20,14 @@ func New(client Client) *Service {
return &Service{client: client}
}
func (s *Service) Register(ctx context.Context, payload protocol.NodePayload) error {
func (s *Service) Register(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) {
return s.client.RegisterNode(ctx, payload)
}
func (s *Service) Heartbeat(ctx context.Context, payload protocol.NodePayload) error {
return s.client.Heartbeat(ctx, payload)
}
func (s *Service) SetToken(token string) {
s.client.SetToken(token)
}
+15 -2
View File
@@ -29,9 +29,17 @@ func New(baseURL string, token string, timeout time.Duration) *Client {
}
}
func (c *Client) RegisterNode(ctx context.Context, payload protocol.NodePayload) error {
func (c *Client) RegisterNode(ctx context.Context, payload protocol.NodePayload) (*protocol.RegisterNodeResponse, error) {
log.Printf("http register node request: node_id=%s current_version=%s", payload.NodeID, payload.CurrentVersion)
return c.postJSON(ctx, "/api/agent/nodes/register", payload, nil)
resp := protocol.APIResponse[protocol.RegisterNodeResponse]{}
if err := c.postJSON(ctx, "/api/agent/nodes/register", payload, &resp); err != nil {
return nil, err
}
if !resp.Success {
return nil, errors.New(resp.Message)
}
log.Printf("http register node response: node_id=%s", resp.Data.NodeID)
return &resp.Data, nil
}
func (c *Client) Heartbeat(ctx context.Context, payload protocol.NodePayload) error {
@@ -56,6 +64,11 @@ func (c *Client) ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPa
return c.postJSON(ctx, "/api/agent/apply-logs", payload, nil)
}
func (c *Client) SetToken(token string) {
c.token = strings.TrimSpace(token)
log.Printf("http client token updated")
}
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
@@ -16,6 +16,12 @@ type NodePayload struct {
LastError string `json:"last_error"`
}
type RegisterNodeResponse struct {
NodeID string `json:"node_id"`
AgentToken string `json:"agent_token"`
Name string `json:"name"`
}
type ApplyLogPayload struct {
NodeID string `json:"node_id"`
Version string `json:"version"`
+24 -3
View File
@@ -2,6 +2,7 @@ package controller
import (
"encoding/json"
"gin-template/model"
"gin-template/service"
"github.com/gin-gonic/gin"
"net/http"
@@ -16,7 +17,15 @@ func AgentRegister(c *gin.Context) {
})
return
}
node, err := service.RegisterNode(payload)
discoveryNode, ok := c.Get("discovery_node")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,Discovery Token 无效",
})
return
}
result, err := service.RegisterNode(discoveryNode.(*model.Node), payload)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -27,7 +36,7 @@ func AgentRegister(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": node,
"data": result,
})
}
@@ -40,7 +49,15 @@ func AgentHeartbeat(c *gin.Context) {
})
return
}
node, err := service.HeartbeatNode(payload)
authNode, ok := c.Get("agent_node")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,Agent Token 无效",
})
return
}
node, err := service.HeartbeatNode(authNode.(*model.Node), payload)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -80,6 +97,10 @@ func AgentReportApplyLog(c *gin.Context) {
})
return
}
authNode, ok := c.Get("agent_node")
if ok {
payload.NodeID = authNode.(*model.Node).NodeID
}
log, err := service.ReportApplyLog(payload)
if err != nil {
c.JSON(http.StatusOK, gin.H{
+87
View File
@@ -0,0 +1,87 @@
package controller
import (
"encoding/json"
"gin-template/service"
"github.com/gin-gonic/gin"
"net/http"
"strconv"
)
func CreateNode(c *gin.Context) {
var input service.NodeInput
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
node, err := service.CreateNode(input)
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,
})
}
func UpdateNode(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
}
var input service.NodeInput
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
node, err := service.UpdateNode(uint(id), input)
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,
})
}
func DeleteNode(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
}
if err = service.DeleteNode(uint(id)); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
+21 -10
View File
@@ -1,7 +1,7 @@
package middleware
import (
"gin-template/common"
"gin-template/service"
"github.com/gin-gonic/gin"
"net/http"
)
@@ -9,15 +9,8 @@ import (
func AgentAuth() func(c *gin.Context) {
return func(c *gin.Context) {
token := c.GetHeader("X-Agent-Token")
if common.AgentToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "Agent Token 未配置",
})
c.Abort()
return
}
if token == "" || token != common.AgentToken {
node, err := service.AuthenticateAgentToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,Agent Token 无效",
@@ -25,6 +18,24 @@ func AgentAuth() func(c *gin.Context) {
c.Abort()
return
}
c.Set("agent_node", node)
c.Next()
}
}
func AgentDiscoveryAuth() func(c *gin.Context) {
return func(c *gin.Context) {
token := c.GetHeader("X-Agent-Token")
node, err := service.AuthenticateDiscoveryToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,Discovery Token 无效",
})
c.Abort()
return
}
c.Set("discovery_node", node)
c.Next()
}
}
+61 -61
View File
@@ -1,61 +1,61 @@
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;uniqueIndex"`
DiscoveryToken string `json:"-" gorm:"size:128;uniqueIndex"`
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) {
err = DB.Order("id desc").Find(&nodes).Error
return nodes, err
}
func GetNodeByNodeID(nodeID string) (*Node, error) {
node := &Node{}
err := DB.Where("node_id = ?", nodeID).First(node).Error
return node, err
}
func GetNodeByID(id uint) (*Node, error) {
node := &Node{}
err := DB.First(node, id).Error
return node, err
}
func GetNodeByAgentToken(token string) (*Node, error) {
node := &Node{}
err := DB.Where("agent_token = ?", token).First(node).Error
return node, err
}
func GetNodeByDiscoveryToken(token string) (*Node, error) {
node := &Node{}
err := DB.Where("discovery_token = ?", token).First(node).Error
return node, err
}
func (node *Node) Insert() error {
return DB.Create(node).Error
}
func (node *Node) Update() error {
return DB.Save(node).Error
}
func (node *Node) Delete() error {
return DB.Delete(node).Error
}
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"`
DiscoveryToken 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"`
}
func ListNodes() (nodes []*Node, err error) {
err = DB.Order("id desc").Find(&nodes).Error
return nodes, err
}
func GetNodeByNodeID(nodeID string) (*Node, error) {
node := &Node{}
err := DB.Where("node_id = ?", nodeID).First(node).Error
return node, err
}
func GetNodeByID(id uint) (*Node, error) {
node := &Node{}
err := DB.First(node, id).Error
return node, err
}
func GetNodeByAgentToken(token string) (*Node, error) {
node := &Node{}
err := DB.Where("agent_token = ?", token).First(node).Error
return node, err
}
func GetNodeByDiscoveryToken(token string) (*Node, error) {
node := &Node{}
err := DB.Where("discovery_token = ?", token).First(node).Error
return node, err
}
func (node *Node) Insert() error {
return DB.Create(node).Error
}
func (node *Node) Update() error {
return DB.Save(node).Error
}
func (node *Node) Delete() error {
return DB.Delete(node).Error
}
+15 -5
View File
@@ -99,6 +99,9 @@ func SetApiRouter(router *gin.Engine) {
nodeRoute.Use(middleware.AdminAuth())
{
nodeRoute.GET("/", controller.GetNodes)
nodeRoute.POST("/", controller.CreateNode)
nodeRoute.PUT("/:id", controller.UpdateNode)
nodeRoute.DELETE("/:id", controller.DeleteNode)
}
applyLogRoute := apiRouter.Group("/apply-logs")
applyLogRoute.Use(middleware.AdminAuth())
@@ -106,12 +109,19 @@ func SetApiRouter(router *gin.Engine) {
applyLogRoute.GET("/", controller.GetApplyLogs)
}
agentRoute := apiRouter.Group("/agent")
agentRoute.Use(middleware.AgentAuth())
{
agentRoute.POST("/nodes/register", controller.AgentRegister)
agentRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat)
agentRoute.GET("/config-versions/active", controller.AgentGetActiveConfig)
agentRoute.POST("/apply-logs", controller.AgentReportApplyLog)
discoveryRoute := agentRoute.Group("/")
discoveryRoute.Use(middleware.AgentDiscoveryAuth())
{
discoveryRoute.POST("/nodes/register", controller.AgentRegister)
}
authorizedRoute := agentRoute.Group("/")
authorizedRoute.Use(middleware.AgentAuth())
{
authorizedRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat)
authorizedRoute.GET("/config-versions/active", controller.AgentGetActiveConfig)
authorizedRoute.POST("/apply-logs", controller.AgentReportApplyLog)
}
}
}
}
+14 -1
View File
@@ -11,6 +11,7 @@ import (
"gin-template/common"
"gin-template/model"
"gin-template/router"
"gin-template/service"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
@@ -199,8 +200,20 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
if !strings.Contains(version.SupportFilesJSON, ".crt") || !strings.Contains(version.SupportFilesJSON, ".key") {
t.Fatal("expected support files json to contain certificate artifacts")
}
if err := (&model.Node{
NodeID: "phase1-node",
Name: "phase1-node",
IP: "10.0.0.8",
AgentToken: common.AgentToken,
AgentVersion: "0.1.0",
NginxVersion: "1.25.5",
Status: service.NodeStatusOnline,
LastSeenAt: time.Now(),
}).Insert(); err != nil {
t.Fatalf("failed to seed phase1 node: %v", err)
}
agentResp := performAgentJSONRequest(t, engine, http.MethodGet, "/api/agent/config-versions/active", nil)
agentResp := performAgentJSONRequestWithToken(t, engine, common.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
var activeConfig map[string]any
decodeResponseData(t, agentResp, &activeConfig)
supportFiles, ok := activeConfig["support_files"].([]any)
+54 -20
View File
@@ -19,7 +19,6 @@ import (
func TestPhase2AgentLifecycle(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
common.AgentToken = "phase2-agent-token"
setupTestDB(t)
engine := gin.New()
@@ -34,11 +33,20 @@ func TestPhase2AgentLifecycle(t *testing.T) {
unauthorizedRecorder := httptest.NewRecorder()
engine.ServeHTTP(unauthorizedRecorder, unauthorizedRequest)
if unauthorizedRecorder.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized status for missing agent token, got %d", unauthorizedRecorder.Code)
t.Fatalf("expected unauthorized status for missing discovery token, got %d", unauthorizedRecorder.Code)
}
createdNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/", map[string]any{
"name": "shanghai-edge-1",
})
var createdNode service.NodeView
decodeResponseData(t, createdNodeResp, &createdNode)
if createdNode.DiscoveryToken == "" || !createdNode.Pending {
t.Fatal("expected created node to expose discovery token while pending")
}
nodePayload := map[string]any{
"node_id": "node-001",
"node_id": "local-node-id",
"name": "shanghai-edge-1",
"ip": "10.0.0.8",
"agent_version": "0.1.0",
@@ -46,15 +54,15 @@ func TestPhase2AgentLifecycle(t *testing.T) {
"current_version": "",
"last_error": "",
}
resp := performAgentJSONRequest(t, engine, http.MethodPost, "/api/agent/nodes/register", nodePayload)
var registeredNode model.Node
decodeResponseData(t, resp, &registeredNode)
if registeredNode.NodeID != "node-001" || registeredNode.Status != service.NodeStatusOnline {
t.Fatal("expected node registration to persist online node state")
resp := performAgentJSONRequestWithToken(t, engine, createdNode.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", nodePayload)
var registration service.AgentRegistrationResponse
decodeResponseData(t, resp, &registration)
if registration.NodeID != createdNode.NodeID || registration.AgentToken == "" {
t.Fatal("expected discovery registration to return assigned node_id and agent token")
}
heartbeatPayload := map[string]any{
"node_id": "node-001",
"node_id": "spoofed-node-id",
"name": "shanghai-edge-1",
"ip": "10.0.0.9",
"agent_version": "0.1.1",
@@ -62,21 +70,22 @@ func TestPhase2AgentLifecycle(t *testing.T) {
"current_version": "",
"last_error": "",
}
resp = performAgentJSONRequest(t, engine, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
resp = performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
var registeredNode model.Node
decodeResponseData(t, resp, &registeredNode)
if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" {
if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
t.Fatal("expected heartbeat to update node metadata")
}
activeConfigResp := performAgentJSONRequest(t, engine, http.MethodGet, "/api/agent/config-versions/active", nil)
activeConfigResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
var activeConfig service.AgentConfigResponse
decodeResponseData(t, activeConfigResp, &activeConfig)
if activeConfig.Version == "" || activeConfig.RenderedConfig == "" || activeConfig.Checksum == "" {
t.Fatal("expected active config response to contain version payload")
}
successApplyResp := performAgentJSONRequest(t, engine, http.MethodPost, "/api/agent/apply-logs", map[string]any{
"node_id": "node-001",
successApplyResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
"node_id": "spoofed-node-id",
"version": activeConfig.Version,
"result": service.ApplyResultOK,
"message": "apply ok",
@@ -87,8 +96,8 @@ func TestPhase2AgentLifecycle(t *testing.T) {
t.Fatal("expected apply log success to be recorded")
}
failedApplyResp := performAgentJSONRequest(t, engine, http.MethodPost, "/api/agent/apply-logs", map[string]any{
"node_id": "node-001",
failedApplyResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
"node_id": "spoofed-node-id",
"version": activeConfig.Version,
"result": service.ApplyResultFailed,
"message": "nginx reload failed",
@@ -105,6 +114,9 @@ func TestPhase2AgentLifecycle(t *testing.T) {
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0].Pending || nodes[0].DiscoveryToken != "" {
t.Fatal("expected registered node to clear pending discovery state")
}
if nodes[0].LatestApplyResult != service.ApplyResultFailed || nodes[0].LatestApplyMessage != "nginx reload failed" {
t.Fatal("expected node list to expose latest apply status")
}
@@ -115,15 +127,23 @@ func TestPhase2AgentLifecycle(t *testing.T) {
t.Fatal("expected node last_error to reflect failed apply")
}
logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id=node-001", nil)
logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil)
var logs []model.ApplyLog
decodeResponseData(t, logsResp, &logs)
if len(logs) != 2 {
t.Fatalf("expected 2 apply logs, got %d", len(logs))
}
updatedNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPut, "/api/nodes/"+toString(createdNode.ID), map[string]any{
"name": "shanghai-edge-1-renamed",
})
decodeResponseData(t, updatedNodeResp, &createdNode)
if createdNode.Name != "shanghai-edge-1-renamed" {
t.Fatal("expected node name to be editable")
}
oldTime := time.Now().Add(-common.NodeOfflineThreshold - time.Minute)
if err := model.DB.Model(&model.Node{}).Where("node_id = ?", "node-001").Update("last_seen_at", oldTime).Error; err != nil {
if err := model.DB.Model(&model.Node{}).Where("node_id = ?", createdNode.NodeID).Update("last_seen_at", oldTime).Error; err != nil {
t.Fatalf("failed to update node last_seen_at: %v", err)
}
nodesResp = performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/", nil)
@@ -131,9 +151,23 @@ func TestPhase2AgentLifecycle(t *testing.T) {
if nodes[0].Status != service.NodeStatusOffline {
t.Fatal("expected node to be shown as offline after timeout")
}
deleteResp := performJSONRequest(t, engine, adminToken, http.MethodDelete, "/api/nodes/"+toString(createdNode.ID), nil)
if !deleteResp.Success {
t.Fatalf("expected delete node success, got %s", deleteResp.Message)
}
deniedReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader([]byte(`{"ip":"10.0.0.9","agent_version":"0.1.1"}`)))
deniedReq.Header.Set("Content-Type", "application/json")
deniedReq.Header.Set("X-Agent-Token", registration.AgentToken)
deniedRecorder := httptest.NewRecorder()
engine.ServeHTTP(deniedRecorder, deniedReq)
if deniedRecorder.Code != http.StatusUnauthorized {
t.Fatalf("expected deleted node token to be rejected, got %d", deniedRecorder.Code)
}
}
func performAgentJSONRequest(t *testing.T, engine http.Handler, method string, path string, body any) apiResponse {
func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
t.Helper()
var payload []byte
var err error
@@ -147,7 +181,7 @@ func performAgentJSONRequest(t *testing.T, engine http.Handler, method string, p
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("X-Agent-Token", common.AgentToken)
req.Header.Set("X-Agent-Token", token)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
+70 -84
View File
@@ -14,6 +14,7 @@ import (
const (
NodeStatusOnline = "online"
NodeStatusOffline = "offline"
NodeStatusPending = "pending"
ApplyResultOK = "success"
ApplyResultFailed = "failed"
)
@@ -48,6 +49,8 @@ type NodeView struct {
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
DiscoveryToken string `json:"discovery_token,omitempty"`
Pending bool `json:"pending"`
AgentVersion string `json:"agent_version"`
NginxVersion string `json:"nginx_version"`
Status string `json:"status"`
@@ -61,14 +64,61 @@ type NodeView struct {
UpdatedAt time.Time `json:"updated_at"`
}
func RegisterNode(payload AgentNodePayload) (*model.Node, error) {
common.SysLog("agent register request received: node_id=" + strings.TrimSpace(payload.NodeID) + " name=" + strings.TrimSpace(payload.Name) + " ip=" + strings.TrimSpace(payload.IP))
return upsertNode(payload)
func RegisterNode(node *model.Node, payload AgentNodePayload) (*AgentRegistrationResponse, error) {
common.SysLog("agent discovery register request received: name=" + strings.TrimSpace(payload.Name) + " ip=" + strings.TrimSpace(payload.IP))
payload.Name = strings.TrimSpace(payload.Name)
payload.IP = strings.TrimSpace(payload.IP)
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
payload.LastError = strings.TrimSpace(payload.LastError)
if node == nil {
return nil, errors.New("节点不存在")
}
if payload.IP == "" {
return nil, errors.New("ip 不能为空")
}
if payload.AgentVersion == "" {
return nil, errors.New("agent_version 不能为空")
}
agentToken, err := newRandomToken()
if err != nil {
return nil, err
}
applyNodeRuntime(node, payload, true)
node.AgentToken = agentToken
node.DiscoveryToken = ""
if err = node.Update(); err != nil {
return nil, err
}
common.SysLog("agent discovery register succeeded: node_id=" + node.NodeID + " name=" + node.Name)
return &AgentRegistrationResponse{
NodeID: node.NodeID,
AgentToken: node.AgentToken,
Name: node.Name,
}, nil
}
func HeartbeatNode(payload AgentNodePayload) (*model.Node, error) {
common.SysLog("agent heartbeat received: node_id=" + strings.TrimSpace(payload.NodeID) + " current_version=" + strings.TrimSpace(payload.CurrentVersion))
return upsertNode(payload)
func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*model.Node, error) {
common.SysLog("agent heartbeat received: node_id=" + node.NodeID + " current_version=" + strings.TrimSpace(payload.CurrentVersion))
payload.NodeID = node.NodeID
payload.Name = strings.TrimSpace(payload.Name)
payload.IP = strings.TrimSpace(payload.IP)
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
payload.LastError = strings.TrimSpace(payload.LastError)
if payload.IP == "" {
return nil, errors.New("ip 不能为空")
}
if payload.AgentVersion == "" {
return nil, errors.New("agent_version 不能为空")
}
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 {
return nil, err
}
return node, nil
}
func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
@@ -153,30 +203,18 @@ func ListNodeViews() ([]*NodeView, error) {
}
views := make([]*NodeView, 0, len(nodes))
for _, node := range nodes {
computedStatus := computeNodeStatus(node.LastSeenAt)
computedStatus := computeNodeStatus(node)
if node.Status != computedStatus {
if computedStatus == NodeStatusOffline {
common.SysError("node offline: node_id=" + node.NodeID + " name=" + node.Name + " ip=" + node.IP + " last_seen_at=" + node.LastSeenAt.Format(time.RFC3339))
} else {
} else if computedStatus == NodeStatusOnline {
common.SysLog("node online: node_id=" + node.NodeID + " name=" + node.Name + " ip=" + node.IP)
}
_ = model.DB.Model(node).Update("status", computedStatus).Error
node.Status = computedStatus
}
view := &NodeView{
ID: node.ID,
NodeID: node.NodeID,
Name: node.Name,
IP: node.IP,
AgentVersion: node.AgentVersion,
NginxVersion: node.NginxVersion,
Status: computedStatus,
CurrentVersion: node.CurrentVersion,
LastSeenAt: node.LastSeenAt,
LastError: node.LastError,
CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt,
}
view := buildNodeView(node)
view.Status = computedStatus
if log, err := model.GetLatestApplyLog(node.NodeID); err == nil {
view.LatestApplyResult = log.Result
view.LatestApplyMessage = log.Message
@@ -192,72 +230,20 @@ func ListApplyLogs(nodeID string) ([]*model.ApplyLog, error) {
}
func upsertNode(payload AgentNodePayload) (*model.Node, error) {
now := time.Now()
payload.NodeID = strings.TrimSpace(payload.NodeID)
payload.Name = strings.TrimSpace(payload.Name)
payload.IP = strings.TrimSpace(payload.IP)
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
payload.LastError = strings.TrimSpace(payload.LastError)
if payload.NodeID == "" {
return nil, errors.New("node_id 不能为空")
}
if payload.Name == "" {
return nil, errors.New("name 不能为空")
}
if payload.IP == "" {
return nil, errors.New("ip 不能为空")
}
if payload.AgentVersion == "" {
return nil, errors.New("agent_version 不能为空")
}
node := &model.Node{}
err := model.DB.Where("node_id = ?", payload.NodeID).First(node).Error
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
node = &model.Node{
NodeID: payload.NodeID,
}
}
previousStatus := node.Status
previousIP := node.IP
previousVersion := node.CurrentVersion
previousAgentVersion := node.AgentVersion
node.Name = payload.Name
node.IP = payload.IP
node.AgentVersion = payload.AgentVersion
node.NginxVersion = payload.NginxVersion
node.Status = NodeStatusOnline
node.CurrentVersion = payload.CurrentVersion
node.LastSeenAt = now
node.LastError = payload.LastError
if node.ID == 0 {
if err = model.DB.Create(node).Error; err != nil {
return nil, err
}
common.SysLog("node online: node_id=" + node.NodeID + " name=" + node.Name + " ip=" + node.IP + " agent_version=" + node.AgentVersion)
return node, nil
}
if err = model.DB.Model(node).Select("name", "ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error").Updates(node).Error; err != nil {
return nil, err
}
if previousStatus != NodeStatusOnline {
common.SysLog("node online: node_id=" + node.NodeID + " name=" + node.Name + " ip=" + node.IP + " agent_version=" + node.AgentVersion)
} else if previousIP != node.IP || previousVersion != node.CurrentVersion || previousAgentVersion != node.AgentVersion {
common.SysLog("node metadata updated: node_id=" + node.NodeID + " ip=" + previousIP + "->" + node.IP + " agent_version=" + previousAgentVersion + "->" + node.AgentVersion + " current_version=" + previousVersion + "->" + node.CurrentVersion)
}
return node, nil
return nil, errors.New("不再支持匿名自动注册")
}
func computeNodeStatus(lastSeenAt time.Time) string {
if lastSeenAt.IsZero() {
func computeNodeStatus(node *model.Node) string {
if node == nil {
return NodeStatusOffline
}
if time.Since(lastSeenAt) > common.NodeOfflineThreshold {
if strings.TrimSpace(node.AgentToken) == "" && strings.TrimSpace(node.DiscoveryToken) != "" {
return NodeStatusPending
}
if node.LastSeenAt.IsZero() {
return NodeStatusOffline
}
if time.Since(node.LastSeenAt) > common.NodeOfflineThreshold {
return NodeStatusOffline
}
return NodeStatusOnline
+148
View File
@@ -0,0 +1,148 @@
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"gin-template/common"
"gin-template/model"
"strings"
"time"
)
type NodeInput struct {
Name string `json:"name"`
}
type AgentRegistrationResponse struct {
NodeID string `json:"node_id"`
AgentToken string `json:"agent_token"`
Name string `json:"name"`
}
func CreateNode(input NodeInput) (*NodeView, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, errors.New("节点名不能为空")
}
node := &model.Node{
Name: name,
IP: "",
AgentVersion: "",
NginxVersion: "",
Status: NodeStatusPending,
}
var err error
node.NodeID, err = newServerNodeID()
if err != nil {
return nil, err
}
node.DiscoveryToken, err = newRandomToken()
if err != nil {
return nil, err
}
if err := node.Insert(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("节点标识生成冲突,请重试")
}
return nil, err
}
common.SysLog("node created: name=" + node.Name + " node_id=" + node.NodeID)
return buildNodeView(node), nil
}
func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, errors.New("节点名不能为空")
}
node, err := model.GetNodeByID(id)
if err != nil {
return nil, err
}
node.Name = name
if err = node.Update(); err != nil {
return nil, err
}
common.SysLog("node updated: name=" + node.Name + " node_id=" + node.NodeID)
return buildNodeView(node), nil
}
func DeleteNode(id uint) error {
node, err := model.GetNodeByID(id)
if err != nil {
return err
}
common.SysLog("node deleted: name=" + node.Name + " node_id=" + node.NodeID)
return node.Delete()
}
func AuthenticateAgentToken(token string) (*model.Node, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, errors.New("缺少 Agent Token")
}
return model.GetNodeByAgentToken(token)
}
func AuthenticateDiscoveryToken(token string) (*model.Node, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, errors.New("缺少 Discovery Token")
}
return model.GetNodeByDiscoveryToken(token)
}
func buildNodeView(node *model.Node) *NodeView {
status := computeNodeStatus(node)
view := &NodeView{
ID: node.ID,
NodeID: node.NodeID,
Name: node.Name,
IP: node.IP,
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,
}
if status == NodeStatusPending {
view.DiscoveryToken = node.DiscoveryToken
}
return view
}
func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName bool) {
if !preserveName || strings.TrimSpace(node.Name) == "" {
if strings.TrimSpace(payload.Name) != "" {
node.Name = strings.TrimSpace(payload.Name)
}
}
node.IP = strings.TrimSpace(payload.IP)
node.AgentVersion = strings.TrimSpace(payload.AgentVersion)
node.NginxVersion = strings.TrimSpace(payload.NginxVersion)
node.Status = NodeStatusOnline
node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
node.LastSeenAt = time.Now()
node.LastError = strings.TrimSpace(payload.LastError)
}
func newRandomToken() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func newServerNodeID() (string, error) {
token, err := newRandomToken()
if err != nil {
return "", err
}
return "node-" + token, nil
}
+179 -84
View File
@@ -1,84 +1,179 @@
import React, { useEffect, useState } from 'react';
import { Header, Label, Segment, Table } from 'semantic-ui-react';
import { API, formatDateTime, showError } from '../../helpers';
const renderStatus = (status) => {
if (status === 'online') {
return <Label color='green'>在线</Label>;
}
return <Label color='grey'>离线</Label>;
};
const renderApply = (result) => {
if (result === 'success') {
return <Label color='green'>成功</Label>;
}
if (result === 'failed') {
return <Label color='red'>失败</Label>;
}
return <Label>暂无</Label>;
};
const Node = () => {
const [nodes, setNodes] = useState([]);
const [loading, setLoading] = useState(false);
const loadNodes = async () => {
setLoading(true);
const res = await API.get('/api/nodes/');
const { success, message, data } = res.data;
if (success) {
setNodes(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadNodes().then();
}, []);
return (
<Segment loading={loading}>
<Header as='h3'>节点状态</Header>
<p className='page-subtitle'>查看节点在线状态、当前版本和最近一次应用结果。</p>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>节点名</Table.HeaderCell>
<Table.HeaderCell>Node ID</Table.HeaderCell>
<Table.HeaderCell>IP</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
<Table.HeaderCell>当前版本</Table.HeaderCell>
<Table.HeaderCell>最近应用</Table.HeaderCell>
<Table.HeaderCell>最近心跳</Table.HeaderCell>
<Table.HeaderCell>错误</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{nodes.map((node) => (
<Table.Row key={node.id}>
<Table.Cell>{node.name}</Table.Cell>
<Table.Cell>{node.node_id}</Table.Cell>
<Table.Cell>{node.ip}</Table.Cell>
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
<Table.Cell>{node.agent_version} / {node.nginx_version || 'unknown'}</Table.Cell>
<Table.Cell>{node.current_version || '未应用'}</Table.Cell>
<Table.Cell>
{renderApply(node.latest_apply_result)}
<div className='table-meta'>{node.latest_apply_message || '暂无记录'}</div>
</Table.Cell>
<Table.Cell>{formatDateTime(node.last_seen_at)}</Table.Cell>
<Table.Cell>{node.last_error || '无'}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Segment>
);
};
export default Node;
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Label, Segment, Table } from 'semantic-ui-react';
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
const initialForm = {
name: '',
};
const renderStatus = (status) => {
if (status === 'online') {
return <Label color='green'>在线</Label>;
}
if (status === 'pending') {
return <Label color='orange'>待接入</Label>;
}
return <Label color='grey'>离线</Label>;
};
const renderApply = (result) => {
if (result === 'success') {
return <Label color='green'>成功</Label>;
}
if (result === 'failed') {
return <Label color='red'>失败</Label>;
}
return <Label>暂无</Label>;
};
const Node = () => {
const [nodes, setNodes] = useState([]);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState(initialForm);
const [editingId, setEditingId] = useState(null);
const loadNodes = async () => {
setLoading(true);
const res = await API.get('/api/nodes/');
const { success, message, data } = res.data;
if (success) {
setNodes(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadNodes().then();
}, []);
const resetForm = () => {
setForm(initialForm);
setEditingId(null);
};
const submitNode = async () => {
setSubmitting(true);
const payload = {
name: form.name.trim(),
};
const res = editingId
? await API.put(`/api/nodes/${editingId}`, payload)
: await API.post('/api/nodes/', payload);
const { success, message } = res.data;
if (success) {
showSuccess(editingId ? '节点已更新' : '节点已创建');
resetForm();
await loadNodes();
} else {
showError(message);
}
setSubmitting(false);
};
const beginEdit = (node) => {
setEditingId(node.id);
setForm({
name: node.name || '',
});
};
const deleteNode = async (node) => {
if (!window.confirm(`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`)) {
return;
}
const res = await API.delete(`/api/nodes/${node.id}`);
const { success, message } = res.data;
if (success) {
showSuccess('节点已删除');
if (editingId === node.id) {
resetForm();
}
await loadNodes();
} else {
showError(message);
}
};
return (
<Segment loading={loading}>
<Header as='h3'>节点管理</Header>
<p className='page-subtitle'>创建节点、查看接入令牌,并跟踪节点在线状态、版本与最近一次应用结果。</p>
<Form onSubmit={submitNode}>
<Form.Group widths='equal'>
<Form.Input
label='节点名'
placeholder='例如 shanghai-edge-1'
value={form.name}
onChange={(e, { value }) => setForm({ ...form, name: value })}
/>
</Form.Group>
<Button primary type='submit' loading={submitting}>
{editingId ? '保存修改' : '新增节点'}
</Button>
{editingId ? (
<Button type='button' onClick={resetForm}>
取消编辑
</Button>
) : null}
</Form>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>节点名</Table.HeaderCell>
<Table.HeaderCell>Node ID</Table.HeaderCell>
<Table.HeaderCell>Discovery Token</Table.HeaderCell>
<Table.HeaderCell>IP</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
<Table.HeaderCell>当前版本</Table.HeaderCell>
<Table.HeaderCell>最近应用</Table.HeaderCell>
<Table.HeaderCell>最近心跳</Table.HeaderCell>
<Table.HeaderCell>错误</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{nodes.map((node) => (
<Table.Row key={node.id}>
<Table.Cell>{node.name}</Table.Cell>
<Table.Cell>{node.node_id}</Table.Cell>
<Table.Cell>
{node.pending && node.discovery_token ? (
<>
<Label color='orange'>待接入</Label>
<div className='table-meta' style={{ wordBreak: 'break-all' }}>{node.discovery_token}</div>
</>
) : '已激活'}
</Table.Cell>
<Table.Cell>{node.ip}</Table.Cell>
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
<Table.Cell>{node.agent_version} / {node.nginx_version || 'unknown'}</Table.Cell>
<Table.Cell>{node.current_version || '未应用'}</Table.Cell>
<Table.Cell>
{renderApply(node.latest_apply_result)}
<div className='table-meta'>{node.latest_apply_message || '暂无记录'}</div>
</Table.Cell>
<Table.Cell>{node.last_seen_at ? formatDateTime(node.last_seen_at) : '暂无'}</Table.Cell>
<Table.Cell>{node.last_error || '无'}</Table.Cell>
<Table.Cell>
<Button size='small' onClick={() => beginEdit(node)}>
编辑
</Button>
<Button size='small' negative onClick={() => deleteNode(node)}>
删除
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Segment>
);
};
export default Node;