- Fix SimulateCSIData to accept []*Walker instead of []*SimWalker - Remove unused imports (path/filepath, time) from virtual_state_test.go - Fix assignment mismatch for CreateVirtualNode error handling - Fix deadlock in VirtualNodeStore by using saveLocked() when mutex is held - Refactor CreateAPNode to avoid race condition with state modification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
776 lines
18 KiB
Go
776 lines
18 KiB
Go
// Package simulator provides virtual node state management for the virtual space.
|
|
// This module handles creation, persistence, and state management of virtual nodes
|
|
// within the simulation space.
|
|
package simulator
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// VirtualNodeState represents the persistent state of a virtual node
|
|
type VirtualNodeState struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type NodeType `json:"type"`
|
|
Role NodeRole `json:"role"`
|
|
Position Point `json:"position"`
|
|
Enabled bool `json:"enabled"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
// For AP nodes
|
|
APBSSID string `json:"ap_bssid,omitempty"`
|
|
APChannel int `json:"ap_channel,omitempty"`
|
|
// State metadata
|
|
Description string `json:"description,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// VirtualNodeStore manages the persistence of virtual node states
|
|
type VirtualNodeStore struct {
|
|
mu sync.RWMutex
|
|
nodes map[string]*VirtualNodeState
|
|
path string
|
|
space *Space
|
|
closed bool
|
|
}
|
|
|
|
// StoreConfig holds configuration for the virtual node store
|
|
type StoreConfig struct {
|
|
DataDir string // Directory for storing node state files
|
|
Space *Space // The virtual space these nodes belong to
|
|
}
|
|
|
|
// NewVirtualNodeStore creates a new virtual node store with persistence
|
|
func NewVirtualNodeStore(config StoreConfig) (*VirtualNodeStore, error) {
|
|
if config.DataDir == "" {
|
|
config.DataDir = "./data"
|
|
}
|
|
if config.Space == nil {
|
|
config.Space = DefaultSpace()
|
|
}
|
|
|
|
// Ensure data directory exists
|
|
if err := os.MkdirAll(config.DataDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("create data dir: %w", err)
|
|
}
|
|
|
|
storePath := filepath.Join(config.DataDir, "virtual_nodes.json")
|
|
|
|
store := &VirtualNodeStore{
|
|
nodes: make(map[string]*VirtualNodeState),
|
|
path: storePath,
|
|
space: config.Space,
|
|
}
|
|
|
|
// Load existing state if available
|
|
if err := store.load(); err != nil {
|
|
// If file doesn't exist, that's okay for new store
|
|
if !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("load virtual nodes: %w", err)
|
|
}
|
|
}
|
|
|
|
return store, nil
|
|
}
|
|
|
|
// CreateNode creates a new virtual node at the specified position
|
|
func (s *VirtualNodeStore) CreateNode(id, name string, nodeType NodeType, position Point) (*VirtualNodeState, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return nil, fmt.Errorf("store is closed")
|
|
}
|
|
|
|
if _, exists := s.nodes[id]; exists {
|
|
return nil, fmt.Errorf("node %s already exists", id)
|
|
}
|
|
|
|
// Validate position is within space bounds
|
|
minX, minY, minZ, maxX, maxY, maxZ := s.space.Bounds()
|
|
if position.X < minX || position.X > maxX ||
|
|
position.Y < minY || position.Y > maxY ||
|
|
position.Z < minZ || position.Z > maxZ {
|
|
return nil, fmt.Errorf("position (%f, %f, %f) is outside space bounds [%f, %f, %f] to [%f, %f, %f]",
|
|
position.X, position.Y, position.Z, minX, minY, minZ, maxX, maxY, maxZ)
|
|
}
|
|
|
|
now := time.Now()
|
|
state := &VirtualNodeState{
|
|
ID: id,
|
|
Name: name,
|
|
Type: nodeType,
|
|
Role: RoleTXRX,
|
|
Position: position,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Metadata: make(map[string]interface{}),
|
|
Tags: make([]string, 0),
|
|
}
|
|
|
|
s.nodes[id] = state
|
|
|
|
// Persist to disk (mutex already held)
|
|
if err := s.saveLocked(); err != nil {
|
|
delete(s.nodes, id)
|
|
return nil, fmt.Errorf("save node: %w", err)
|
|
}
|
|
|
|
return state, nil
|
|
}
|
|
|
|
// CreateVirtualNode creates a new virtual planning node
|
|
func (s *VirtualNodeStore) CreateVirtualNode(id, name string, position Point) (*VirtualNodeState, error) {
|
|
return s.CreateNode(id, name, NodeTypeVirtual, position)
|
|
}
|
|
|
|
// CreateAPNode creates a new access point node (for passive radar)
|
|
func (s *VirtualNodeStore) CreateAPNode(id, name, bssid string, channel int, position Point) (*VirtualNodeState, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return nil, fmt.Errorf("store is closed")
|
|
}
|
|
|
|
if _, exists := s.nodes[id]; exists {
|
|
return nil, fmt.Errorf("node %s already exists", id)
|
|
}
|
|
|
|
// Validate position is within space bounds
|
|
minX, minY, minZ, maxX, maxY, maxZ := s.space.Bounds()
|
|
if position.X < minX || position.X > maxX ||
|
|
position.Y < minY || position.Y > maxY ||
|
|
position.Z < minZ || position.Z > maxZ {
|
|
return nil, fmt.Errorf("position (%f, %f, %f) is outside space bounds [%f, %f, %f] to [%f, %f, %f]",
|
|
position.X, position.Y, position.Z, minX, minY, minZ, maxX, maxY, maxZ)
|
|
}
|
|
|
|
now := time.Now()
|
|
state := &VirtualNodeState{
|
|
ID: id,
|
|
Name: name,
|
|
Type: NodeTypeAP,
|
|
Role: RoleTX,
|
|
Position: position,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Metadata: make(map[string]interface{}),
|
|
Tags: make([]string, 0),
|
|
APBSSID: bssid,
|
|
APChannel: channel,
|
|
}
|
|
|
|
s.nodes[id] = state
|
|
|
|
// Persist to disk (mutex already held)
|
|
if err := s.saveLocked(); err != nil {
|
|
delete(s.nodes, id)
|
|
return nil, fmt.Errorf("save AP node: %w", err)
|
|
}
|
|
|
|
return state, nil
|
|
}
|
|
|
|
// GetNode retrieves a node by ID
|
|
func (s *VirtualNodeStore) GetNode(id string) (*VirtualNodeState, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.closed {
|
|
return nil, fmt.Errorf("store is closed")
|
|
}
|
|
|
|
state, exists := s.nodes[id]
|
|
if !exists {
|
|
return nil, fmt.Errorf("node %s not found", id)
|
|
}
|
|
|
|
// Return a copy to prevent external mutations
|
|
return s.copyState(state), nil
|
|
}
|
|
|
|
// UpdateNodePosition updates a node's position
|
|
func (s *VirtualNodeStore) UpdateNodePosition(id string, position Point) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return fmt.Errorf("store is closed")
|
|
}
|
|
|
|
state, exists := s.nodes[id]
|
|
if !exists {
|
|
return fmt.Errorf("node %s not found", id)
|
|
}
|
|
|
|
// Validate position is within space bounds
|
|
minX, minY, minZ, maxX, maxY, maxZ := s.space.Bounds()
|
|
if position.X < minX || position.X > maxX ||
|
|
position.Y < minY || position.Y > maxY ||
|
|
position.Z < minZ || position.Z > maxZ {
|
|
return fmt.Errorf("position (%f, %f, %f) is outside space bounds [%f, %f, %f] to [%f, %f, %f]",
|
|
position.X, position.Y, position.Z, minX, minY, minZ, maxX, maxY, maxZ)
|
|
}
|
|
|
|
state.Position = position
|
|
state.UpdatedAt = time.Now()
|
|
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// UpdateNodeRole updates a node's role
|
|
func (s *VirtualNodeStore) UpdateNodeRole(id string, role NodeRole) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return fmt.Errorf("store is closed")
|
|
}
|
|
|
|
state, exists := s.nodes[id]
|
|
if !exists {
|
|
return fmt.Errorf("node %s not found", id)
|
|
}
|
|
|
|
state.Role = role
|
|
state.UpdatedAt = time.Now()
|
|
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// SetNodeEnabled enables or disables a node
|
|
func (s *VirtualNodeStore) SetNodeEnabled(id string, enabled bool) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return fmt.Errorf("store is closed")
|
|
}
|
|
|
|
state, exists := s.nodes[id]
|
|
if !exists {
|
|
return fmt.Errorf("node %s not found", id)
|
|
}
|
|
|
|
state.Enabled = enabled
|
|
state.UpdatedAt = time.Now()
|
|
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// UpdateNodeMetadata updates a node's metadata
|
|
func (s *VirtualNodeStore) UpdateNodeMetadata(id string, metadata map[string]interface{}) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return fmt.Errorf("store is closed")
|
|
}
|
|
|
|
state, exists := s.nodes[id]
|
|
if !exists {
|
|
return fmt.Errorf("node %s not found", id)
|
|
}
|
|
|
|
state.Metadata = metadata
|
|
state.UpdatedAt = time.Now()
|
|
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// AddTag adds a tag to a node
|
|
func (s *VirtualNodeStore) AddTag(id, tag string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return fmt.Errorf("store is closed")
|
|
}
|
|
|
|
state, exists := s.nodes[id]
|
|
if !exists {
|
|
return fmt.Errorf("node %s not found", id)
|
|
}
|
|
|
|
// Check if tag already exists
|
|
for _, t := range state.Tags {
|
|
if t == tag {
|
|
return nil // Already has this tag
|
|
}
|
|
}
|
|
|
|
state.Tags = append(state.Tags, tag)
|
|
state.UpdatedAt = time.Now()
|
|
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// RemoveTag removes a tag from a node
|
|
func (s *VirtualNodeStore) RemoveTag(id, tag string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return fmt.Errorf("store is closed")
|
|
}
|
|
|
|
state, exists := s.nodes[id]
|
|
if !exists {
|
|
return fmt.Errorf("node %s not found", id)
|
|
}
|
|
|
|
// Filter out the tag
|
|
newTags := make([]string, 0, len(state.Tags))
|
|
for _, t := range state.Tags {
|
|
if t != tag {
|
|
newTags = append(newTags, t)
|
|
}
|
|
}
|
|
|
|
if len(newTags) == len(state.Tags) {
|
|
return nil // Tag wasn't present
|
|
}
|
|
|
|
state.Tags = newTags
|
|
state.UpdatedAt = time.Now()
|
|
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// DeleteNode removes a node from the store
|
|
func (s *VirtualNodeStore) DeleteNode(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return fmt.Errorf("store is closed")
|
|
}
|
|
|
|
if _, exists := s.nodes[id]; !exists {
|
|
return fmt.Errorf("node %s not found", id)
|
|
}
|
|
|
|
delete(s.nodes, id)
|
|
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// ListNodes returns all nodes
|
|
func (s *VirtualNodeStore) ListNodes() []*VirtualNodeState {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.closed {
|
|
return nil
|
|
}
|
|
|
|
result := make([]*VirtualNodeState, 0, len(s.nodes))
|
|
for _, state := range s.nodes {
|
|
result = append(result, s.copyState(state))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ListEnabledNodes returns only enabled nodes
|
|
func (s *VirtualNodeStore) ListEnabledNodes() []*VirtualNodeState {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.closed {
|
|
return nil
|
|
}
|
|
|
|
result := make([]*VirtualNodeState, 0)
|
|
for _, state := range s.nodes {
|
|
if state.Enabled {
|
|
result = append(result, s.copyState(state))
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ListNodesByType returns nodes of a specific type
|
|
func (s *VirtualNodeStore) ListNodesByType(nodeType NodeType) []*VirtualNodeState {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.closed {
|
|
return nil
|
|
}
|
|
|
|
result := make([]*VirtualNodeState, 0)
|
|
for _, state := range s.nodes {
|
|
if state.Type == nodeType {
|
|
result = append(result, s.copyState(state))
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ListNodesByTag returns nodes with a specific tag
|
|
func (s *VirtualNodeStore) ListNodesByTag(tag string) []*VirtualNodeState {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.closed {
|
|
return nil
|
|
}
|
|
|
|
result := make([]*VirtualNodeState, 0)
|
|
for _, state := range s.nodes {
|
|
for _, t := range state.Tags {
|
|
if t == tag {
|
|
result = append(result, s.copyState(state))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Count returns the total number of nodes
|
|
func (s *VirtualNodeStore) Count() int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
return len(s.nodes)
|
|
}
|
|
|
|
// GetSpace returns the space associated with this store
|
|
func (s *VirtualNodeStore) GetSpace() *Space {
|
|
return s.space
|
|
}
|
|
|
|
// UpdateSpace updates the space bounds for this store
|
|
func (s *VirtualNodeStore) UpdateSpace(space *Space) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := space.Validate(); err != nil {
|
|
return fmt.Errorf("validate space: %w", err)
|
|
}
|
|
|
|
s.space = space
|
|
|
|
// Re-validate all node positions are still within bounds
|
|
for _, state := range s.nodes {
|
|
minX, minY, minZ, maxX, maxY, maxZ := s.space.Bounds()
|
|
if state.Position.X < minX || state.Position.X > maxX ||
|
|
state.Position.Y < minY || state.Position.Y > maxY ||
|
|
state.Position.Z < minZ || state.Position.Z > maxZ {
|
|
// Disable nodes that are now outside bounds
|
|
state.Enabled = false
|
|
}
|
|
}
|
|
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// Clear removes all nodes from the store
|
|
func (s *VirtualNodeStore) Clear() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return fmt.Errorf("store is closed")
|
|
}
|
|
|
|
s.nodes = make(map[string]*VirtualNodeState)
|
|
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// ToNodeSet converts the stored nodes to a NodeSet for simulation
|
|
func (s *VirtualNodeStore) ToNodeSet() *NodeSet {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
ns := NewNodeSet()
|
|
|
|
for _, state := range s.nodes {
|
|
if !state.Enabled {
|
|
continue
|
|
}
|
|
|
|
if state.Type == NodeTypeAP {
|
|
ns.AddAPNode(state.ID, state.Name, state.APBSSID, state.APChannel, state.Position)
|
|
// Update role from AddAPNode default
|
|
for _, n := range ns.nodes {
|
|
if n.ID == state.ID {
|
|
n.Role = state.Role
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
ns.AddNode(state.ID, state.Name, state.Type, state.Position)
|
|
// Update role
|
|
for _, n := range s.nodes {
|
|
if n.ID == state.ID {
|
|
n.Role = state.Role
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ns
|
|
}
|
|
|
|
// ImportFromNodeSet imports nodes from a NodeSet
|
|
func (s *VirtualNodeStore) ImportFromNodeSet(nodeSet *NodeSet) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return fmt.Errorf("store is closed")
|
|
}
|
|
|
|
now := time.Now()
|
|
for _, node := range nodeSet.All() {
|
|
state := &VirtualNodeState{
|
|
ID: node.ID,
|
|
Name: node.Name,
|
|
Type: node.Type,
|
|
Role: node.Role,
|
|
Position: node.Position,
|
|
Enabled: node.Enabled,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Metadata: make(map[string]interface{}),
|
|
Tags: make([]string, 0),
|
|
}
|
|
|
|
if node.IsAP() {
|
|
state.APBSSID = node.APBSSID
|
|
state.APChannel = node.APChannel
|
|
}
|
|
|
|
// Merge with existing if present
|
|
if existing, exists := s.nodes[node.ID]; exists {
|
|
state.CreatedAt = existing.CreatedAt
|
|
state.Metadata = existing.Metadata
|
|
state.Tags = existing.Tags
|
|
}
|
|
|
|
s.nodes[node.ID] = state
|
|
}
|
|
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// Close closes the store and releases resources
|
|
func (s *VirtualNodeStore) Close() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.closed {
|
|
return nil
|
|
}
|
|
|
|
// Mark as closed to prevent new operations during final save
|
|
s.closed = true
|
|
|
|
// Final save before closing (saveLocked checks closed flag, so we need special handling)
|
|
data, err := json.MarshalIndent(s.nodes, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal nodes: %w", err)
|
|
}
|
|
|
|
// Write to temporary file first
|
|
tmpPath := s.path + ".tmp"
|
|
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
|
return fmt.Errorf("write temp file: %w", err)
|
|
}
|
|
|
|
// Atomic rename
|
|
if err := os.Rename(tmpPath, s.path); err != nil {
|
|
return fmt.Errorf("rename file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// save persists the current state to disk
|
|
func (s *VirtualNodeStore) save() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return s.saveLocked()
|
|
}
|
|
|
|
// saveLocked saves state without acquiring lock (caller must hold lock)
|
|
func (s *VirtualNodeStore) saveLocked() error {
|
|
if s.closed {
|
|
return fmt.Errorf("store is closed")
|
|
}
|
|
|
|
data, err := json.MarshalIndent(s.nodes, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal nodes: %w", err)
|
|
}
|
|
|
|
// Write to temporary file first
|
|
tmpPath := s.path + ".tmp"
|
|
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
|
return fmt.Errorf("write temp file: %w", err)
|
|
}
|
|
|
|
// Atomic rename
|
|
if err := os.Rename(tmpPath, s.path); err != nil {
|
|
return fmt.Errorf("rename file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// load restores state from disk
|
|
func (s *VirtualNodeStore) load() error {
|
|
data, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &s.nodes); err != nil {
|
|
return fmt.Errorf("unmarshal nodes: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// copyState creates a deep copy of a node state
|
|
func (s *VirtualNodeStore) copyState(state *VirtualNodeState) *VirtualNodeState {
|
|
// Copy metadata
|
|
metadata := make(map[string]interface{})
|
|
for k, v := range state.Metadata {
|
|
metadata[k] = v
|
|
}
|
|
|
|
// Copy tags
|
|
tags := make([]string, len(state.Tags))
|
|
copy(tags, state.Tags)
|
|
|
|
return &VirtualNodeState{
|
|
ID: state.ID,
|
|
Name: state.Name,
|
|
Type: state.Type,
|
|
Role: state.Role,
|
|
Position: state.Position,
|
|
Enabled: state.Enabled,
|
|
CreatedAt: state.CreatedAt,
|
|
UpdatedAt: state.UpdatedAt,
|
|
APBSSID: state.APBSSID,
|
|
APChannel: state.APChannel,
|
|
Description: state.Description,
|
|
Tags: tags,
|
|
Metadata: metadata,
|
|
}
|
|
}
|
|
|
|
// VirtualNodeSummary provides a summary of virtual nodes in the space
|
|
type VirtualNodeSummary struct {
|
|
TotalCount int `json:"total_count"`
|
|
EnabledCount int `json:"enabled_count"`
|
|
VirtualCount int `json:"virtual_count"`
|
|
APCount int `json:"ap_count"`
|
|
ByType map[string]int `json:"by_type"`
|
|
ByTag map[string]int `json:"by_tag"`
|
|
BoundingBox BoundingBox `json:"bounding_box"`
|
|
FirstCreated *time.Time `json:"first_created,omitempty"`
|
|
LastUpdated *time.Time `json:"last_updated,omitempty"`
|
|
}
|
|
|
|
// BoundingBox represents the axis-aligned bounding box of all nodes
|
|
type BoundingBox struct {
|
|
MinX, MinY, MinZ float64
|
|
MaxX, MaxY, MaxZ float64
|
|
}
|
|
|
|
// Summary returns a summary of all nodes in the store
|
|
func (s *VirtualNodeStore) Summary() *VirtualNodeSummary {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
summary := &VirtualNodeSummary{
|
|
ByType: make(map[string]int),
|
|
ByTag: make(map[string]int),
|
|
BoundingBox: BoundingBox{MinX: 1e9, MinY: 1e9, MinZ: 1e9, MaxX: -1e9, MaxY: -1e9, MaxZ: -1e9},
|
|
}
|
|
|
|
var firstCreated, lastUpdated time.Time
|
|
|
|
for _, state := range s.nodes {
|
|
summary.TotalCount++
|
|
summary.ByType[string(state.Type)]++
|
|
|
|
if state.Enabled {
|
|
summary.EnabledCount++
|
|
}
|
|
|
|
if state.Type == NodeTypeVirtual {
|
|
summary.VirtualCount++
|
|
}
|
|
|
|
if state.Type == NodeTypeAP {
|
|
summary.APCount++
|
|
}
|
|
|
|
for _, tag := range state.Tags {
|
|
summary.ByTag[tag]++
|
|
}
|
|
|
|
// Update bounding box
|
|
if state.Position.X < summary.BoundingBox.MinX {
|
|
summary.BoundingBox.MinX = state.Position.X
|
|
}
|
|
if state.Position.X > summary.BoundingBox.MaxX {
|
|
summary.BoundingBox.MaxX = state.Position.X
|
|
}
|
|
if state.Position.Y < summary.BoundingBox.MinY {
|
|
summary.BoundingBox.MinY = state.Position.Y
|
|
}
|
|
if state.Position.Y > summary.BoundingBox.MaxY {
|
|
summary.BoundingBox.MaxY = state.Position.Y
|
|
}
|
|
if state.Position.Z < summary.BoundingBox.MinZ {
|
|
summary.BoundingBox.MinZ = state.Position.Z
|
|
}
|
|
if state.Position.Z > summary.BoundingBox.MaxZ {
|
|
summary.BoundingBox.MaxZ = state.Position.Z
|
|
}
|
|
|
|
// Track timestamps
|
|
if firstCreated.IsZero() || state.CreatedAt.Before(firstCreated) {
|
|
firstCreated = state.CreatedAt
|
|
}
|
|
if lastUpdated.IsZero() || state.UpdatedAt.After(lastUpdated) {
|
|
lastUpdated = state.UpdatedAt
|
|
}
|
|
}
|
|
|
|
if !firstCreated.IsZero() {
|
|
summary.FirstCreated = &firstCreated
|
|
}
|
|
if !lastUpdated.IsZero() {
|
|
summary.LastUpdated = &lastUpdated
|
|
}
|
|
|
|
// Handle empty case
|
|
if summary.TotalCount == 0 {
|
|
summary.BoundingBox = BoundingBox{}
|
|
}
|
|
|
|
return summary
|
|
}
|