mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 05:37:29 +08:00
694 lines
16 KiB
Go
694 lines
16 KiB
Go
package nodes
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const defaultNodeTTL = 60 * time.Second
|
|
|
|
// Manager keeps paired node metadata and basic routing helpers.
|
|
type Handler func(req Request) Response
|
|
type WireSender interface {
|
|
Send(msg WireMessage) error
|
|
}
|
|
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
nodes map[string]NodeInfo
|
|
handlers map[string]Handler
|
|
senders map[string]WireSender
|
|
pending map[string]chan WireMessage
|
|
nextWire uint64
|
|
ttl time.Duration
|
|
auditPath string
|
|
statePath string
|
|
policy DispatchPolicy
|
|
}
|
|
|
|
type DispatchPolicy struct {
|
|
PreferLocal bool
|
|
PreferP2P bool
|
|
AllowRelayFallback bool
|
|
ActionTags map[string][]string
|
|
AgentTags map[string][]string
|
|
AllowActions map[string][]string
|
|
DenyActions map[string][]string
|
|
AllowAgents map[string][]string
|
|
DenyAgents map[string][]string
|
|
}
|
|
|
|
var defaultManager = NewManager()
|
|
|
|
func DefaultManager() *Manager { return defaultManager }
|
|
|
|
func NewManager() *Manager {
|
|
m := &Manager{
|
|
nodes: map[string]NodeInfo{},
|
|
handlers: map[string]Handler{},
|
|
senders: map[string]WireSender{},
|
|
pending: map[string]chan WireMessage{},
|
|
ttl: defaultNodeTTL,
|
|
policy: DispatchPolicy{
|
|
PreferP2P: true,
|
|
AllowRelayFallback: true,
|
|
ActionTags: map[string][]string{},
|
|
AgentTags: map[string][]string{},
|
|
AllowActions: map[string][]string{},
|
|
DenyActions: map[string][]string{},
|
|
AllowAgents: map[string][]string{},
|
|
DenyAgents: map[string][]string{},
|
|
},
|
|
}
|
|
go m.reaperLoop()
|
|
return m
|
|
}
|
|
|
|
func (m *Manager) SetAuditPath(path string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.auditPath = strings.TrimSpace(path)
|
|
}
|
|
|
|
func (m *Manager) SetStatePath(path string) {
|
|
m.mu.Lock()
|
|
m.statePath = strings.TrimSpace(path)
|
|
m.mu.Unlock()
|
|
m.loadState()
|
|
}
|
|
|
|
func (m *Manager) SetDispatchPolicy(policy DispatchPolicy) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.policy = normalizeDispatchPolicy(policy)
|
|
}
|
|
|
|
func (m *Manager) DispatchPolicy() DispatchPolicy {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return cloneDispatchPolicy(m.policy)
|
|
}
|
|
|
|
func (m *Manager) Upsert(info NodeInfo) {
|
|
m.mu.Lock()
|
|
now := time.Now().UTC()
|
|
old, existed := m.nodes[info.ID]
|
|
info.LastSeenAt = now
|
|
info.Online = true
|
|
if existed {
|
|
if info.RegisteredAt.IsZero() {
|
|
info.RegisteredAt = old.RegisteredAt
|
|
}
|
|
if len(info.Tags) == 0 && len(old.Tags) > 0 {
|
|
info.Tags = append([]string(nil), old.Tags...)
|
|
}
|
|
if strings.TrimSpace(info.Endpoint) == "" {
|
|
info.Endpoint = old.Endpoint
|
|
}
|
|
if strings.TrimSpace(info.Token) == "" {
|
|
info.Token = old.Token
|
|
}
|
|
} else if info.RegisteredAt.IsZero() {
|
|
info.RegisteredAt = now
|
|
}
|
|
m.nodes[info.ID] = info
|
|
m.mu.Unlock()
|
|
m.saveState()
|
|
m.appendAudit("upsert", info.ID, map[string]interface{}{"existed": existed, "endpoint": info.Endpoint, "version": info.Version})
|
|
}
|
|
|
|
func (m *Manager) MarkOffline(id string) {
|
|
m.mu.Lock()
|
|
changed := false
|
|
if n, ok := m.nodes[id]; ok {
|
|
n.Online = false
|
|
m.nodes[id] = n
|
|
changed = true
|
|
}
|
|
m.mu.Unlock()
|
|
if changed {
|
|
m.saveState()
|
|
}
|
|
}
|
|
|
|
func (m *Manager) Get(id string) (NodeInfo, bool) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
n, ok := m.nodes[id]
|
|
return n, ok
|
|
}
|
|
|
|
func (m *Manager) List() []NodeInfo {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
out := make([]NodeInfo, 0, len(m.nodes))
|
|
for _, n := range m.nodes {
|
|
out = append(out, n)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].LastSeenAt.After(out[j].LastSeenAt) })
|
|
return out
|
|
}
|
|
|
|
func (m *Manager) Remove(id string) bool {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return false
|
|
}
|
|
m.mu.Lock()
|
|
_, exists := m.nodes[id]
|
|
if exists {
|
|
delete(m.nodes, id)
|
|
delete(m.handlers, id)
|
|
}
|
|
m.mu.Unlock()
|
|
if exists {
|
|
m.saveState()
|
|
m.appendAudit("delete", id, nil)
|
|
}
|
|
return exists
|
|
}
|
|
|
|
func (m *Manager) RegisterHandler(nodeID string, h Handler) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if strings.TrimSpace(nodeID) == "" || h == nil {
|
|
return
|
|
}
|
|
m.handlers[nodeID] = h
|
|
}
|
|
|
|
func (m *Manager) RegisterWireSender(nodeID string, sender WireSender) {
|
|
nodeID = strings.TrimSpace(nodeID)
|
|
if nodeID == "" {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if sender == nil {
|
|
delete(m.senders, nodeID)
|
|
return
|
|
}
|
|
m.senders[nodeID] = sender
|
|
}
|
|
|
|
func (m *Manager) HandleWireMessage(msg WireMessage) bool {
|
|
switch strings.ToLower(strings.TrimSpace(msg.Type)) {
|
|
case "node_response":
|
|
if strings.TrimSpace(msg.ID) == "" {
|
|
return false
|
|
}
|
|
m.mu.Lock()
|
|
ch := m.pending[msg.ID]
|
|
if ch != nil {
|
|
delete(m.pending, msg.ID)
|
|
}
|
|
m.mu.Unlock()
|
|
if ch == nil {
|
|
return false
|
|
}
|
|
select {
|
|
case ch <- msg:
|
|
default:
|
|
}
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (m *Manager) SendWireRequest(ctx context.Context, nodeID string, req Request) (Response, error) {
|
|
nodeID = strings.TrimSpace(nodeID)
|
|
if nodeID == "" {
|
|
return Response{}, fmt.Errorf("node id required")
|
|
}
|
|
m.mu.Lock()
|
|
sender := m.senders[nodeID]
|
|
if sender == nil {
|
|
m.mu.Unlock()
|
|
return Response{}, fmt.Errorf("node %s websocket sender unavailable", nodeID)
|
|
}
|
|
m.nextWire++
|
|
wireID := fmt.Sprintf("wire-%d", m.nextWire)
|
|
ch := make(chan WireMessage, 1)
|
|
m.pending[wireID] = ch
|
|
m.mu.Unlock()
|
|
|
|
msg := WireMessage{
|
|
Type: "node_request",
|
|
ID: wireID,
|
|
To: nodeID,
|
|
Request: &req,
|
|
}
|
|
if err := sender.Send(msg); err != nil {
|
|
m.mu.Lock()
|
|
delete(m.pending, wireID)
|
|
m.mu.Unlock()
|
|
return Response{}, err
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
m.mu.Lock()
|
|
delete(m.pending, wireID)
|
|
m.mu.Unlock()
|
|
return Response{}, ctx.Err()
|
|
case incoming := <-ch:
|
|
if incoming.Response == nil {
|
|
return Response{}, fmt.Errorf("node %s returned empty response", nodeID)
|
|
}
|
|
return *incoming.Response, nil
|
|
}
|
|
}
|
|
|
|
func (m *Manager) Invoke(req Request) (Response, bool) {
|
|
m.mu.RLock()
|
|
h, ok := m.handlers[req.Node]
|
|
m.mu.RUnlock()
|
|
if !ok {
|
|
return Response{}, false
|
|
}
|
|
resp := h(req)
|
|
if strings.TrimSpace(resp.Node) == "" {
|
|
resp.Node = req.Node
|
|
}
|
|
if strings.TrimSpace(resp.Action) == "" {
|
|
resp.Action = req.Action
|
|
}
|
|
return resp, true
|
|
}
|
|
|
|
func (m *Manager) SupportsAction(nodeID, action string) bool {
|
|
n, ok := m.Get(nodeID)
|
|
if !ok || !n.Online {
|
|
return false
|
|
}
|
|
return nodeSupportsRequest(n, Request{Action: action})
|
|
}
|
|
|
|
func (m *Manager) SupportsRequest(nodeID string, req Request) bool {
|
|
n, ok := m.Get(nodeID)
|
|
if !ok || !n.Online {
|
|
return false
|
|
}
|
|
return nodeSupportsRequest(n, req)
|
|
}
|
|
|
|
func nodeSupportsRequest(n NodeInfo, req Request) bool {
|
|
action := strings.ToLower(strings.TrimSpace(req.Action))
|
|
if len(n.Actions) > 0 {
|
|
allowed := false
|
|
for _, a := range n.Actions {
|
|
if strings.ToLower(strings.TrimSpace(a)) == action {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if !allowed {
|
|
return false
|
|
}
|
|
}
|
|
switch action {
|
|
case "run":
|
|
return n.Capabilities.Run
|
|
case "agent_task":
|
|
return n.Capabilities.Model
|
|
case "camera_snap", "camera_clip":
|
|
return n.Capabilities.Camera
|
|
case "screen_record", "screen_snapshot":
|
|
return n.Capabilities.Screen
|
|
case "location_get":
|
|
return n.Capabilities.Location
|
|
case "canvas_snapshot", "canvas_action":
|
|
return n.Capabilities.Canvas
|
|
default:
|
|
return n.Capabilities.Invoke
|
|
}
|
|
}
|
|
|
|
func (m *Manager) PickFor(action string) (NodeInfo, bool) {
|
|
return m.PickRequest(Request{Action: action}, "auto")
|
|
}
|
|
|
|
func (m *Manager) PickRequest(req Request, mode string) (NodeInfo, bool) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
bestScore := -1
|
|
bestNode := NodeInfo{}
|
|
policy := normalizeDispatchPolicy(m.policy)
|
|
for _, n := range m.nodes {
|
|
score, ok := scoreNodeCandidate(n, req, mode, m.senders[strings.TrimSpace(n.ID)] != nil, policy)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if score > bestScore || (score == bestScore && bestNode.ID != "" && n.LastSeenAt.After(bestNode.LastSeenAt)) {
|
|
bestScore = score
|
|
bestNode = n
|
|
}
|
|
}
|
|
if bestScore < 0 || strings.TrimSpace(bestNode.ID) == "" {
|
|
return NodeInfo{}, false
|
|
}
|
|
return bestNode, true
|
|
}
|
|
|
|
func scoreNodeCandidate(n NodeInfo, req Request, mode string, hasWireSender bool, policy DispatchPolicy) (int, bool) {
|
|
if !n.Online {
|
|
return 0, false
|
|
}
|
|
if !nodeSupportsRequest(n, req) {
|
|
return 0, false
|
|
}
|
|
if !matchesDispatchPolicy(n, req, policy) {
|
|
return 0, false
|
|
}
|
|
|
|
mode = strings.ToLower(strings.TrimSpace(mode))
|
|
if mode == "p2p" && !hasWireSender {
|
|
return 0, false
|
|
}
|
|
if !policy.AllowRelayFallback && strings.TrimSpace(n.ID) != "local" && !hasWireSender {
|
|
return 0, false
|
|
}
|
|
|
|
score := 100
|
|
if hasWireSender {
|
|
score += 30
|
|
}
|
|
if policy.PreferP2P {
|
|
if hasWireSender {
|
|
score += 35
|
|
} else {
|
|
score -= 10
|
|
}
|
|
}
|
|
if policy.PreferLocal && strings.EqualFold(strings.TrimSpace(n.ID), "local") {
|
|
score += 60
|
|
}
|
|
if prefersRealtimeTransport(req.Action) && hasWireSender {
|
|
score += 40
|
|
}
|
|
if mode == "relay" && hasWireSender {
|
|
score -= 10
|
|
}
|
|
if mode == "p2p" && hasWireSender {
|
|
score += 80
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(req.Action), "agent_task") {
|
|
remoteAgentID := requestedRemoteAgentID(req.Args)
|
|
switch {
|
|
case remoteAgentID == "", remoteAgentID == "main":
|
|
score += 20
|
|
case nodeHasAgent(n, remoteAgentID):
|
|
score += 80
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
if !n.LastSeenAt.IsZero() {
|
|
ageSeconds := int(time.Since(n.LastSeenAt).Seconds())
|
|
if ageSeconds < 0 {
|
|
ageSeconds = 0
|
|
}
|
|
if ageSeconds < 60 {
|
|
score += 20
|
|
} else if ageSeconds < 300 {
|
|
score += 5
|
|
}
|
|
}
|
|
return score, true
|
|
}
|
|
|
|
func normalizeDispatchPolicy(policy DispatchPolicy) DispatchPolicy {
|
|
normalized := DispatchPolicy{
|
|
PreferLocal: policy.PreferLocal,
|
|
PreferP2P: policy.PreferP2P,
|
|
AllowRelayFallback: policy.AllowRelayFallback,
|
|
ActionTags: map[string][]string{},
|
|
AgentTags: map[string][]string{},
|
|
AllowActions: map[string][]string{},
|
|
DenyActions: map[string][]string{},
|
|
AllowAgents: map[string][]string{},
|
|
DenyAgents: map[string][]string{},
|
|
}
|
|
for key, tags := range policy.ActionTags {
|
|
trimmed := normalizeStringList(tags)
|
|
if len(trimmed) > 0 {
|
|
normalized.ActionTags[strings.ToLower(strings.TrimSpace(key))] = trimmed
|
|
}
|
|
}
|
|
for key, tags := range policy.AgentTags {
|
|
trimmed := normalizeStringList(tags)
|
|
if len(trimmed) > 0 {
|
|
normalized.AgentTags[strings.ToLower(strings.TrimSpace(key))] = trimmed
|
|
}
|
|
}
|
|
for key, tags := range policy.AllowActions {
|
|
trimmed := normalizeStringList(tags)
|
|
if len(trimmed) > 0 {
|
|
normalized.AllowActions[strings.ToLower(strings.TrimSpace(key))] = trimmed
|
|
}
|
|
}
|
|
for key, tags := range policy.DenyActions {
|
|
trimmed := normalizeStringList(tags)
|
|
if len(trimmed) > 0 {
|
|
normalized.DenyActions[strings.ToLower(strings.TrimSpace(key))] = trimmed
|
|
}
|
|
}
|
|
for key, tags := range policy.AllowAgents {
|
|
trimmed := normalizeStringList(tags)
|
|
if len(trimmed) > 0 {
|
|
normalized.AllowAgents[strings.ToLower(strings.TrimSpace(key))] = trimmed
|
|
}
|
|
}
|
|
for key, tags := range policy.DenyAgents {
|
|
trimmed := normalizeStringList(tags)
|
|
if len(trimmed) > 0 {
|
|
normalized.DenyAgents[strings.ToLower(strings.TrimSpace(key))] = trimmed
|
|
}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func cloneDispatchPolicy(policy DispatchPolicy) DispatchPolicy {
|
|
return normalizeDispatchPolicy(policy)
|
|
}
|
|
|
|
func normalizeStringList(values []string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(values))
|
|
for _, raw := range values {
|
|
trimmed := strings.ToLower(strings.TrimSpace(raw))
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[trimmed]; ok {
|
|
continue
|
|
}
|
|
seen[trimmed] = struct{}{}
|
|
out = append(out, trimmed)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func matchesDispatchPolicy(n NodeInfo, req Request, policy DispatchPolicy) bool {
|
|
if !isNodePermittedByPolicy(n, req, policy) {
|
|
return false
|
|
}
|
|
if tags := policy.ActionTags[strings.ToLower(strings.TrimSpace(req.Action))]; len(tags) > 0 && !nodeMatchesAnyTag(n, tags) {
|
|
return false
|
|
}
|
|
remoteAgentID := requestedRemoteAgentID(req.Args)
|
|
if remoteAgentID != "" {
|
|
if tags := policy.AgentTags[remoteAgentID]; len(tags) > 0 && !nodeMatchesAnyTag(n, tags) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isNodePermittedByPolicy(n NodeInfo, req Request, policy DispatchPolicy) bool {
|
|
nodeID := strings.ToLower(strings.TrimSpace(n.ID))
|
|
action := strings.ToLower(strings.TrimSpace(req.Action))
|
|
remoteAgentID := requestedRemoteAgentID(req.Args)
|
|
if remoteAgentID == "" && action == "agent_task" {
|
|
remoteAgentID = "main"
|
|
}
|
|
if deny := policy.DenyActions[nodeID]; len(deny) > 0 && containsNormalized(deny, action) {
|
|
return false
|
|
}
|
|
if allow := policy.AllowActions[nodeID]; len(allow) > 0 && !containsNormalized(allow, action) {
|
|
return false
|
|
}
|
|
if remoteAgentID != "" {
|
|
if deny := policy.DenyAgents[nodeID]; len(deny) > 0 && containsNormalized(deny, remoteAgentID) {
|
|
return false
|
|
}
|
|
if allow := policy.AllowAgents[nodeID]; len(allow) > 0 && !containsNormalized(allow, remoteAgentID) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func containsNormalized(items []string, target string) bool {
|
|
target = strings.ToLower(strings.TrimSpace(target))
|
|
for _, item := range items {
|
|
if strings.ToLower(strings.TrimSpace(item)) == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func nodeMatchesAnyTag(n NodeInfo, tags []string) bool {
|
|
if len(tags) == 0 {
|
|
return true
|
|
}
|
|
nodeTags := normalizeStringList(n.Tags)
|
|
if len(nodeTags) == 0 {
|
|
return false
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for _, tag := range nodeTags {
|
|
seen[tag] = struct{}{}
|
|
}
|
|
for _, tag := range tags {
|
|
if _, ok := seen[strings.ToLower(strings.TrimSpace(tag))]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func requestedRemoteAgentID(args map[string]interface{}) string {
|
|
if len(args) == 0 {
|
|
return ""
|
|
}
|
|
value, ok := args["remote_agent_id"]
|
|
if !ok || value == nil {
|
|
return ""
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(fmt.Sprint(value)))
|
|
}
|
|
|
|
func nodeHasAgent(n NodeInfo, agentID string) bool {
|
|
agentID = strings.ToLower(strings.TrimSpace(agentID))
|
|
if agentID == "" {
|
|
return false
|
|
}
|
|
for _, agent := range n.Agents {
|
|
if strings.ToLower(strings.TrimSpace(agent.ID)) == agentID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func prefersRealtimeTransport(action string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(action)) {
|
|
case "camera_snap", "camera_clip", "screen_record", "screen_snapshot", "canvas_snapshot", "canvas_action":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (m *Manager) reaperLoop() {
|
|
t := time.NewTicker(15 * time.Second)
|
|
defer t.Stop()
|
|
for range t.C {
|
|
cutoff := time.Now().UTC().Add(-m.ttl)
|
|
m.mu.Lock()
|
|
offlined := make([]string, 0)
|
|
for id, n := range m.nodes {
|
|
if n.Online && !n.LastSeenAt.IsZero() && n.LastSeenAt.Before(cutoff) {
|
|
n.Online = false
|
|
m.nodes[id] = n
|
|
offlined = append(offlined, id)
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
if len(offlined) > 0 {
|
|
m.saveState()
|
|
}
|
|
for _, id := range offlined {
|
|
m.appendAudit("offline_ttl", id, nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) appendAudit(event, nodeID string, data map[string]interface{}) {
|
|
m.mu.RLock()
|
|
path := m.auditPath
|
|
m.mu.RUnlock()
|
|
if strings.TrimSpace(path) == "" {
|
|
return
|
|
}
|
|
_ = os.MkdirAll(filepath.Dir(path), 0755)
|
|
row := map[string]interface{}{"time": time.Now().UTC().Format(time.RFC3339), "event": event, "node": nodeID}
|
|
for k, v := range data {
|
|
row[k] = v
|
|
}
|
|
b, _ := json.Marshal(row)
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
_, _ = f.Write(append(b, '\n'))
|
|
}
|
|
|
|
func (m *Manager) saveState() {
|
|
m.mu.RLock()
|
|
path := m.statePath
|
|
items := make([]NodeInfo, 0, len(m.nodes))
|
|
for _, n := range m.nodes {
|
|
items = append(items, n)
|
|
}
|
|
m.mu.RUnlock()
|
|
if strings.TrimSpace(path) == "" {
|
|
return
|
|
}
|
|
_ = os.MkdirAll(filepath.Dir(path), 0755)
|
|
b, err := json.MarshalIndent(items, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = os.WriteFile(path, b, 0644)
|
|
}
|
|
|
|
func (m *Manager) loadState() {
|
|
m.mu.RLock()
|
|
path := m.statePath
|
|
m.mu.RUnlock()
|
|
if strings.TrimSpace(path) == "" {
|
|
return
|
|
}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var items []NodeInfo
|
|
if err := json.Unmarshal(b, &items); err != nil {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
for _, n := range items {
|
|
if strings.TrimSpace(n.ID) == "" {
|
|
continue
|
|
}
|
|
m.nodes[n.ID] = n
|
|
}
|
|
m.mu.Unlock()
|
|
}
|