Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Ran gofmt across the entire mothership codebase to ensure consistent code formatting per Go standards. All tests pass after formatting.
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
|
|
}
|