Files
Nukumizu/internal/node/tracker.go
T
NanamiAdmin fcb0751267 refactor(internal/komari/ws): streamline status request logic and enhance read loop handling
refactor(internal/node/tracker): improve initial refresh gating and snapshot handling
2026-08-07 18:33:34 +08:00

396 lines
11 KiB
Go

package node
import (
"fmt"
"sync"
"time"
"nukumizu-backend/postLog"
)
// Report represents the latest server status report from Komari WebSocket.
type Report struct {
CPU struct {
Usage float64 `json:"usage"`
} `json:"cpu"`
RAM struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
} `json:"ram"`
Swap struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
} `json:"swap"`
Load struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
} `json:"load"`
Disk struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
} `json:"disk"`
Network struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
TotalUp int64 `json:"totalUp"`
TotalDown int64 `json:"totalDown"`
} `json:"network"`
Connections struct {
TCP int `json:"tcp"`
UDP int `json:"udp"`
} `json:"connections"`
Uptime int `json:"uptime"`
Process int `json:"process"`
Message string `json:"message"`
UpdatedAt string `json:"updated_at"`
}
// Node holds all tracked information about a single server node.
type Node struct {
UUID string
Name string
Online bool
LatestReport *Report
LastUpdated time.Time
}
// StatusChange represents a node status transition.
type StatusChange struct {
Event string // "Online" or "Offline"
UUID string
Name string
Message string
OldOnline bool
NewOnline bool
}
// StatusChangeCallback is called when a node's online status changes.
type StatusChangeCallback func(change StatusChange)
// Tracker maintains the in-memory state of all monitored nodes.
// All methods are thread-safe.
type Tracker struct {
mu sync.RWMutex
nodes map[string]*Node // uuid → Node
uuidToName map[string]string // uuid → name (from node list)
onlineSet map[string]bool // which uuids are currently online
callbacks []StatusChangeCallback
// Initial-refresh gating: status-change notifications are suppressed until
// the initial status snapshot has been received, so the startup state is
// treated as a baseline rather than a flood of changes.
knownUUIDs map[string]bool // uuids from the Komari node list
reportedUUIDs map[string]bool // node-list uuids that have reported status
receivedSnapshot bool // true once at least one snapshot has arrived
bootstrapDone bool // true once the initial refresh has finished
}
var globalTracker *Tracker
// InitTracker initializes the global node tracker.
func InitTracker() {
globalTracker = &Tracker{
nodes: make(map[string]*Node),
uuidToName: make(map[string]string),
onlineSet: make(map[string]bool),
knownUUIDs: make(map[string]bool),
reportedUUIDs: make(map[string]bool),
}
postLog.Info("Node tracker initialized")
}
// GetTracker returns the global tracker instance.
func GetTracker() *Tracker {
return globalTracker
}
// OnStatusChange registers a callback for status change events.
func (t *Tracker) OnStatusChange(cb StatusChangeCallback) {
t.mu.Lock()
defer t.mu.Unlock()
t.callbacks = append(t.callbacks, cb)
}
// fireCallbacks notifies all registered callbacks of a status change.
// Must be called with the lock NOT held (it acquires RLocks as needed).
func (t *Tracker) fireCallbacks(change StatusChange) {
t.mu.RLock()
cbs := make([]StatusChangeCallback, len(t.callbacks))
copy(cbs, t.callbacks)
t.mu.RUnlock()
for _, cb := range cbs {
cb(change)
}
}
// UpdateNodeList replaces the full node list and updates the uuid→name map.
// nodeNames maps UUID → node name from the Komari node list.
func (t *Tracker) UpdateNodeList(nodeNames map[string]string) {
t.mu.Lock()
defer t.mu.Unlock()
// The node list defines the set of nodes to wait for during the initial
// status refresh.
t.knownUUIDs = make(map[string]bool, len(nodeNames))
for uuid := range nodeNames {
t.knownUUIDs[uuid] = true
}
t.uuidToName = make(map[string]string, len(nodeNames))
for uuid, name := range nodeNames {
t.uuidToName[uuid] = name
// Ensure node entry exists.
if _, exists := t.nodes[uuid]; !exists {
t.nodes[uuid] = &Node{
UUID: uuid,
Name: name,
Online: false,
}
} else {
// Update name in case it changed.
t.nodes[uuid].Name = name
}
}
// postLog.Debug(fmt.Sprintf("Node list updated: %d nodes", len(nodeNames)))
}
// UpdateStatus processes a WebSocket status update from Komari.
// It returns a list of status changes (Online/Offline) that were detected.
func (t *Tracker) UpdateStatus(onlineUUIDs []string, reports map[string]Report) []StatusChange {
t.mu.Lock()
newOnlineSet := make(map[string]bool, len(onlineUUIDs))
for _, uuid := range onlineUUIDs {
newOnlineSet[uuid] = true
}
var changes []StatusChange
// Detect newly online nodes.
for uuid := range newOnlineSet {
if !t.onlineSet[uuid] {
name := t.uuidToName[uuid]
changes = append(changes, StatusChange{
Event: "Online",
UUID: uuid,
Name: name,
Message: "Server came online",
OldOnline: false,
NewOnline: true,
})
}
// Update or create node entry.
node, exists := t.nodes[uuid]
if !exists {
node = &Node{UUID: uuid, Name: t.uuidToName[uuid]}
t.nodes[uuid] = node
}
node.Online = true
node.LastUpdated = time.Now()
}
// Detect newly offline nodes.
for uuid := range t.onlineSet {
if !newOnlineSet[uuid] {
name := t.uuidToName[uuid]
message := "Server went offline"
if node, exists := t.nodes[uuid]; exists {
node.Online = false
node.LastUpdated = time.Now()
if node.LatestReport != nil && node.LatestReport.Message != "" {
message = node.LatestReport.Message
}
}
changes = append(changes, StatusChange{
Event: "Offline",
UUID: uuid,
Name: name,
Message: message,
OldOnline: true,
NewOnline: false,
})
}
}
// Update report data for online nodes.
for uuid, report := range reports {
reportCopy := report
node, exists := t.nodes[uuid]
if !exists {
node = &Node{UUID: uuid, Name: t.uuidToName[uuid]}
t.nodes[uuid] = node
}
node.LatestReport = &reportCopy
node.LastUpdated = time.Now()
}
t.onlineSet = newOnlineSet
// Track which node-list nodes have delivered a status update.
for _, uuid := range onlineUUIDs {
if t.knownUUIDs[uuid] {
t.reportedUUIDs[uuid] = true
}
}
for uuid := range reports {
if t.knownUUIDs[uuid] {
t.reportedUUIDs[uuid] = true
}
}
// The first received snapshot establishes the startup baseline, so its
// changes are always suppressed. Afterward, changes are suppressed only
// until the initial refresh is judged complete.
firstSnapshot := !t.receivedSnapshot
if len(onlineUUIDs) > 0 || len(reports) > 0 {
t.receivedSnapshot = true
}
suppressChanges := !t.bootstrapDone || firstSnapshot
if !t.bootstrapDone && t.refreshComplete(reports) {
t.bootstrapDone = true
postLog.Info(fmt.Sprintf("Initial node status refresh complete (%d/%d nodes reported); status notifications enabled",
len(t.reportedUUIDs), len(t.knownUUIDs)))
}
t.mu.Unlock()
// Fire callbacks for each change (outside the lock).
for _, change := range changes {
if suppressChanges {
continue
}
postLog.Info(fmt.Sprintf("Node status change: %s [%s] - %s", change.Name, change.UUID, change.Event))
t.fireCallbacks(change)
}
return changes
}
// allKnownNodesReported returns true when every node from the Komari node list
// has delivered at least one status update. Must be called with the lock held.
func (t *Tracker) allKnownNodesReported() bool {
for uuid := range t.knownUUIDs {
if !t.reportedUUIDs[uuid] {
return false
}
}
return true
}
// refreshComplete reports whether the initial status refresh is finished.
// Komari returns the full latest-status batch in a single snapshot, so the
// refresh is complete once a snapshot covering every node we have ever seen
// has been processed. Node-list nodes that never report a status are treated
// as permanently offline rather than blocking notifications forever.
// Must be called with the lock held.
func (t *Tracker) refreshComplete(currentReports map[string]Report) bool {
if t.allKnownNodesReported() {
return true
}
if len(currentReports) == 0 {
return false
}
for uuid := range t.reportedUUIDs {
if _, ok := currentReports[uuid]; !ok {
return false
}
}
return true
}
// CompleteBootstrap force-completes the initial status refresh gate. It is a
// safety net so that notifications cannot be blocked forever when no status
// snapshot is ever received. It does not mark a snapshot as received, so the
// first snapshot that does arrive is still treated as the baseline. Calling it
// again is a no-op.
func (t *Tracker) CompleteBootstrap() {
t.mu.Lock()
defer t.mu.Unlock()
if !t.bootstrapDone {
t.bootstrapDone = true
postLog.Info("Initial node status refresh timeout reached; status notifications enabled")
}
}
// GetNode returns a copy of the node data for the given UUID.
func (t *Tracker) GetNode(uuid string) (*Node, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
node, exists := t.nodes[uuid]
if !exists {
return nil, false
}
// Return a copy to avoid data races.
nodeCopy := *node
if node.LatestReport != nil {
reportCopy := *node.LatestReport
nodeCopy.LatestReport = &reportCopy
}
return &nodeCopy, true
}
// GetAllNodes returns a copy of all tracked nodes.
func (t *Tracker) GetAllNodes() []*Node {
t.mu.RLock()
defer t.mu.RUnlock()
result := make([]*Node, 0, len(t.nodes))
for _, node := range t.nodes {
nodeCopy := *node
if node.LatestReport != nil {
reportCopy := *node.LatestReport
nodeCopy.LatestReport = &reportCopy
}
result = append(result, &nodeCopy)
}
return result
}
// GetNodeName returns the name for a given UUID.
func (t *Tracker) GetNodeName(uuid string) string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.uuidToName[uuid]
}
// GetOnlineServers returns a list of formatted strings for online servers.
// Format: "- ServerName (uuid)" or "- uuid" if name is empty.
func (t *Tracker) GetOnlineServers() []string {
t.mu.RLock()
defer t.mu.RUnlock()
var result []string
for uuid := range t.onlineSet {
name := t.uuidToName[uuid]
if name != "" {
result = append(result, fmt.Sprintf("- %s (%s)", name, uuid))
} else {
result = append(result, fmt.Sprintf("- %s", uuid))
}
}
return result
}
// GetOfflineServers returns a list of formatted strings for offline servers.
func (t *Tracker) GetOfflineServers() []string {
t.mu.RLock()
defer t.mu.RUnlock()
offlineSet := make(map[string]bool)
for uuid, node := range t.nodes {
if !node.Online {
offlineSet[uuid] = true
}
}
var result []string
for uuid := range offlineSet {
name := t.uuidToName[uuid]
if name != "" {
result = append(result, fmt.Sprintf("- %s (%s)", name, uuid))
} else {
result = append(result, fmt.Sprintf("- %s", uuid))
}
}
return result
}