feat: 添加节点管理功能,支持全局 discovery token 生成与旋转,更新节点注册流程

This commit is contained in:
ryan
2026-03-10 15:48:34 +08:00
parent 05e75549d2
commit 92d22fc02c
14 changed files with 321 additions and 124 deletions
+1
View File
@@ -47,6 +47,7 @@ var WeChatAccountQRCodeImageURL = ""
var TurnstileSiteKey = ""
var TurnstileSecretKey = ""
var AgentToken = ""
var AgentDiscoveryToken = ""
var NodeOfflineThreshold = 2 * time.Minute
const (
+8 -8
View File
@@ -17,15 +17,15 @@ func AgentRegister(c *gin.Context) {
})
return
}
discoveryNode, ok := c.Get("discovery_node")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,Discovery Token 无效",
})
return
var (
result *service.AgentRegistrationResponse
err error
)
if authNode, ok := c.Get("agent_node"); ok {
result, err = service.RegisterNodeWithAgentToken(authNode.(*model.Node), payload)
} else {
result, err = service.RegisterNodeWithDiscovery(payload)
}
result, err := service.RegisterNode(discoveryNode.(*model.Node), payload)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
+32
View File
@@ -32,6 +32,38 @@ func CreateNode(c *gin.Context) {
})
}
func GetNodeBootstrapToken(c *gin.Context) {
bootstrap, err := service.GetNodeBootstrapView()
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": bootstrap,
})
}
func RotateNodeBootstrapToken(c *gin.Context) {
bootstrap, err := service.RotateGlobalDiscoveryToken()
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": bootstrap,
})
}
func UpdateNode(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
+9 -5
View File
@@ -23,19 +23,23 @@ func AgentAuth() func(c *gin.Context) {
}
}
func AgentDiscoveryAuth() func(c *gin.Context) {
func AgentRegisterAuth() func(c *gin.Context) {
return func(c *gin.Context) {
token := c.GetHeader("X-Agent-Token")
node, err := service.AuthenticateDiscoveryToken(token)
if err != nil {
if node, err := service.AuthenticateAgentToken(token); err == nil {
c.Set("agent_node", node)
c.Next()
return
}
if err := service.ValidateDiscoveryToken(token); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,Discovery Token 无效",
"message": "无权进行此操作,注册 Token 无效",
})
c.Abort()
return
}
c.Set("discovery_node", node)
c.Set("discovery_enabled", true)
c.Next()
}
}
-7
View File
@@ -8,7 +8,6 @@ type Node struct {
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'"`
@@ -42,12 +41,6 @@ func GetNodeByAgentToken(token string) (*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
}
+3
View File
@@ -49,6 +49,7 @@ func InitOptionMap() {
common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
common.OptionMap["TurnstileSiteKey"] = ""
common.OptionMap["TurnstileSecretKey"] = ""
common.OptionMap["AgentDiscoveryToken"] = ""
common.OptionMapRWMutex.Unlock()
options, _ := AllOption()
for _, option := range options {
@@ -141,5 +142,7 @@ func updateOptionMap(key string, value string) {
common.TurnstileSiteKey = value
case "TurnstileSecretKey":
common.TurnstileSecretKey = value
case "AgentDiscoveryToken":
common.AgentDiscoveryToken = value
}
}
+3 -1
View File
@@ -98,6 +98,8 @@ func SetApiRouter(router *gin.Engine) {
nodeRoute := apiRouter.Group("/nodes")
nodeRoute.Use(middleware.AdminAuth())
{
nodeRoute.GET("/bootstrap-token", controller.GetNodeBootstrapToken)
nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken)
nodeRoute.GET("/", controller.GetNodes)
nodeRoute.POST("/", controller.CreateNode)
nodeRoute.PUT("/:id", controller.UpdateNode)
@@ -111,7 +113,7 @@ func SetApiRouter(router *gin.Engine) {
agentRoute := apiRouter.Group("/agent")
{
discoveryRoute := agentRoute.Group("/")
discoveryRoute.Use(middleware.AgentDiscoveryAuth())
discoveryRoute.Use(middleware.AgentRegisterAuth())
{
discoveryRoute.POST("/nodes/register", controller.AgentRegister)
}
+55 -25
View File
@@ -41,24 +41,8 @@ func TestPhase2AgentLifecycle(t *testing.T) {
})
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": "local-node-id",
"name": "shanghai-edge-1",
"ip": "10.0.0.8",
"agent_version": "0.1.0",
"nginx_version": "1.25.5",
"current_version": "",
"last_error": "",
}
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")
if createdNode.AgentToken == "" || !createdNode.Pending {
t.Fatal("expected created node to expose agent token while pending")
}
heartbeatPayload := map[string]any{
@@ -70,21 +54,21 @@ func TestPhase2AgentLifecycle(t *testing.T) {
"current_version": "",
"last_error": "",
}
resp = performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
resp := performAgentJSONRequestWithToken(t, engine, createdNode.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" || registeredNode.NodeID != createdNode.NodeID {
t.Fatal("expected heartbeat to update node metadata")
}
activeConfigResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
activeConfigResp := performAgentJSONRequestWithToken(t, engine, createdNode.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 := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
successApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
"node_id": "spoofed-node-id",
"version": activeConfig.Version,
"result": service.ApplyResultOK,
@@ -96,7 +80,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
t.Fatal("expected apply log success to be recorded")
}
failedApplyResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
failedApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
"node_id": "spoofed-node-id",
"version": activeConfig.Version,
"result": service.ApplyResultFailed,
@@ -114,8 +98,11 @@ 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].Pending {
t.Fatal("expected registered node to clear pending state")
}
if nodes[0].AgentToken != createdNode.AgentToken {
t.Fatal("expected node auth token to remain stable after occupancy")
}
if nodes[0].LatestApplyResult != service.ApplyResultFailed || nodes[0].LatestApplyMessage != "nginx reload failed" {
t.Fatal("expected node list to expose latest apply status")
@@ -159,7 +146,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
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)
deniedReq.Header.Set("X-Agent-Token", createdNode.AgentToken)
deniedRecorder := httptest.NewRecorder()
engine.ServeHTTP(deniedRecorder, deniedReq)
if deniedRecorder.Code != http.StatusUnauthorized {
@@ -167,6 +154,49 @@ func TestPhase2AgentLifecycle(t *testing.T) {
}
}
func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
adminToken := prepareRootToken(t)
bootstrapResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/bootstrap-token", nil)
var bootstrap service.NodeBootstrapView
decodeResponseData(t, bootstrapResp, &bootstrap)
if bootstrap.DiscoveryToken == "" {
t.Fatal("expected global discovery token to be available")
}
resp := performAgentJSONRequestWithToken(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{
"node_id": "local-node-id",
"name": "bulk-edge-1",
"ip": "10.0.0.18",
"agent_version": "0.2.0",
"nginx_version": "1.25.5",
"current_version": "",
"last_error": "",
})
var registration service.AgentRegistrationResponse
decodeResponseData(t, resp, &registration)
if registration.AgentToken == "" || registration.NodeID == "" {
t.Fatal("expected discovery registration to issue node-specific agent token")
}
nodesResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/", nil)
var nodes []service.NodeView
decodeResponseData(t, nodesResp, &nodes)
if len(nodes) != 1 {
t.Fatalf("expected 1 discovered node, got %d", len(nodes))
}
if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Pending {
t.Fatal("expected discovered node to be created online with issued agent token")
}
}
func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
t.Helper()
var payload []byte
+6 -48
View File
@@ -49,7 +49,7 @@ type NodeView struct {
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
DiscoveryToken string `json:"discovery_token,omitempty"`
AgentToken string `json:"agent_token"`
Pending bool `json:"pending"`
AgentVersion string `json:"agent_version"`
NginxVersion string `json:"nginx_version"`
@@ -65,54 +65,15 @@ type NodeView struct {
}
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
return RegisterNodeWithAgentToken(node, 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 不能为空")
payload = normalizeAgentNodePayload(payload)
if err := validateAgentNodePayload(payload); err != nil {
return nil, err
}
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 {
@@ -237,11 +198,8 @@ func computeNodeStatus(node *model.Node) string {
if node == nil {
return NodeStatusOffline
}
if strings.TrimSpace(node.AgentToken) == "" && strings.TrimSpace(node.DiscoveryToken) != "" {
return NodeStatusPending
}
if node.LastSeenAt.IsZero() {
return NodeStatusOffline
return NodeStatusPending
}
if time.Since(node.LastSeenAt) > common.NodeOfflineThreshold {
return NodeStatusOffline
+135 -7
View File
@@ -14,6 +14,10 @@ type NodeInput struct {
Name string `json:"name"`
}
type NodeBootstrapView struct {
DiscoveryToken string `json:"discovery_token"`
}
type AgentRegistrationResponse struct {
NodeID string `json:"node_id"`
AgentToken string `json:"agent_token"`
@@ -37,7 +41,7 @@ func CreateNode(input NodeInput) (*NodeView, error) {
if err != nil {
return nil, err
}
node.DiscoveryToken, err = newRandomToken()
node.AgentToken, err = newRandomToken()
if err != nil {
return nil, err
}
@@ -85,12 +89,61 @@ func AuthenticateAgentToken(token string) (*model.Node, error) {
return model.GetNodeByAgentToken(token)
}
func AuthenticateDiscoveryToken(token string) (*model.Node, error) {
func ValidateDiscoveryToken(token string) error {
token = strings.TrimSpace(token)
if token == "" {
return nil, errors.New("缺少 Discovery Token")
return errors.New("缺少 Discovery Token")
}
return model.GetNodeByDiscoveryToken(token)
discoveryToken, err := EnsureGlobalDiscoveryToken()
if err != nil {
return err
}
if token != discoveryToken {
return errors.New("Discovery Token 无效")
}
return nil
}
func EnsureGlobalDiscoveryToken() (string, error) {
common.OptionMapRWMutex.RLock()
needsInit := common.OptionMap == nil
common.OptionMapRWMutex.RUnlock()
if needsInit {
model.InitOptionMap()
}
common.OptionMapRWMutex.RLock()
token := strings.TrimSpace(common.OptionMap["AgentDiscoveryToken"])
common.OptionMapRWMutex.RUnlock()
if token != "" {
return token, nil
}
token, err := newRandomToken()
if err != nil {
return "", err
}
if err = model.UpdateOption("AgentDiscoveryToken", token); err != nil {
return "", err
}
return token, nil
}
func GetNodeBootstrapView() (*NodeBootstrapView, error) {
token, err := EnsureGlobalDiscoveryToken()
if err != nil {
return nil, err
}
return &NodeBootstrapView{DiscoveryToken: token}, nil
}
func RotateGlobalDiscoveryToken() (*NodeBootstrapView, error) {
token, err := newRandomToken()
if err != nil {
return nil, err
}
if err = model.UpdateOption("AgentDiscoveryToken", token); err != nil {
return nil, err
}
return &NodeBootstrapView{DiscoveryToken: token}, nil
}
func buildNodeView(node *model.Node) *NodeView {
@@ -100,6 +153,7 @@ func buildNodeView(node *model.Node) *NodeView {
NodeID: node.NodeID,
Name: node.Name,
IP: node.IP,
AgentToken: node.AgentToken,
AgentVersion: node.AgentVersion,
NginxVersion: node.NginxVersion,
Status: status,
@@ -110,12 +164,86 @@ func buildNodeView(node *model.Node) *NodeView {
UpdatedAt: node.UpdatedAt,
Pending: status == NodeStatusPending,
}
if status == NodeStatusPending {
view.DiscoveryToken = node.DiscoveryToken
}
return view
}
func RegisterNodeWithAgentToken(node *model.Node, payload AgentNodePayload) (*AgentRegistrationResponse, error) {
payload = normalizeAgentNodePayload(payload)
if node == nil {
return nil, errors.New("节点不存在")
}
if err := validateAgentNodePayload(payload); err != nil {
return nil, err
}
applyNodeRuntime(node, payload, true)
if err := node.Update(); err != nil {
return nil, err
}
common.SysLog("agent register succeeded on reserved node: node_id=" + node.NodeID + " name=" + node.Name)
return &AgentRegistrationResponse{
NodeID: node.NodeID,
AgentToken: node.AgentToken,
Name: node.Name,
}, nil
}
func RegisterNodeWithDiscovery(payload AgentNodePayload) (*AgentRegistrationResponse, error) {
payload = normalizeAgentNodePayload(payload)
if err := validateAgentNodePayload(payload); err != nil {
return nil, err
}
nodeID, err := newServerNodeID()
if err != nil {
return nil, err
}
agentToken, err := newRandomToken()
if err != nil {
return nil, err
}
nodeName := payload.Name
if nodeName == "" {
nodeName = nodeID
}
node := &model.Node{
NodeID: nodeID,
Name: nodeName,
AgentToken: agentToken,
}
applyNodeRuntime(node, payload, false)
if err = node.Insert(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("节点标识生成冲突,请重试")
}
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 normalizeAgentNodePayload(payload AgentNodePayload) AgentNodePayload {
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)
return payload
}
func validateAgentNodePayload(payload AgentNodePayload) error {
if payload.IP == "" {
return errors.New("ip 不能为空")
}
if payload.AgentVersion == "" {
return errors.New("agent_version 不能为空")
}
return nil
}
func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName bool) {
if !preserveName || strings.TrimSpace(node.Name) == "" {
if strings.TrimSpace(payload.Name) != "" {
+44 -6
View File
@@ -28,6 +28,7 @@ const renderApply = (result) => {
const Node = () => {
const [nodes, setNodes] = useState([]);
const [bootstrap, setBootstrap] = useState({ discovery_token: '' });
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState(initialForm);
@@ -45,7 +46,31 @@ const Node = () => {
setLoading(false);
};
const loadBootstrapToken = async () => {
const res = await API.get('/api/nodes/bootstrap-token');
const { success, message, data } = res.data;
if (success) {
setBootstrap(data || { discovery_token: '' });
} else {
showError(message);
}
};
const rotateBootstrapToken = async () => {
setLoading(true);
const res = await API.post('/api/nodes/bootstrap-token/rotate');
const { success, message, data } = res.data;
if (success) {
setBootstrap(data || { discovery_token: '' });
showSuccess('全局 discovery token 已重新生成');
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadBootstrapToken().then();
loadNodes().then();
}, []);
@@ -100,7 +125,20 @@ const Node = () => {
return (
<Segment loading={loading}>
<Header as='h3'>节点管理</Header>
<p className='page-subtitle'>创建节点、查看接入令牌,并跟踪节点在线状态、版本与最近一次应用结果。</p>
<p className='page-subtitle'>创建节点后会直接生成节点专属 auth token;批量部署时可复用全局 discovery token 自动注册。</p>
<Form>
<Form.Group widths='equal'>
<Form.Input
label='全局 Discovery Token'
readOnly
value={bootstrap.discovery_token || ''}
/>
</Form.Group>
<Button type='button' onClick={rotateBootstrapToken}>
重新生成 Discovery Token
</Button>
</Form>
<Form onSubmit={submitNode}>
<Form.Group widths='equal'>
@@ -126,7 +164,7 @@ const Node = () => {
<Table.Row>
<Table.HeaderCell>节点名</Table.HeaderCell>
<Table.HeaderCell>Node ID</Table.HeaderCell>
<Table.HeaderCell>Discovery Token</Table.HeaderCell>
<Table.HeaderCell>Auth Token</Table.HeaderCell>
<Table.HeaderCell>IP</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
@@ -143,12 +181,12 @@ const Node = () => {
<Table.Cell>{node.name}</Table.Cell>
<Table.Cell>{node.node_id}</Table.Cell>
<Table.Cell>
{node.pending && node.discovery_token ? (
{node.agent_token ? (
<>
<Label color='orange'>待接入</Label>
<div className='table-meta' style={{ wordBreak: 'break-all' }}>{node.discovery_token}</div>
{node.pending ? <Label color='orange'>未占用</Label> : <Label color='green'>已绑定</Label>}
<div className='table-meta' style={{ wordBreak: 'break-all' }}>{node.agent_token}</div>
</>
) : '已激活'}
) : '暂无'}
</Table.Cell>
<Table.Cell>{node.ip}</Table.Cell>
<Table.Cell>{renderStatus(node.status)}</Table.Cell>