package mock import ( "os" "strconv" "sync" "sync/atomic" "time" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rs/zerolog/log" ) var ( dataMu sync.RWMutex mockData models.StateSnapshot mockAlerts []models.Alert mockConfig = DefaultConfig enabled atomic.Bool updateTicker *time.Ticker stopUpdatesCh chan struct{} updateLoopWg sync.WaitGroup ) const updateInterval = 2 * time.Second func init() { initialEnabled := os.Getenv("PULSE_MOCK_MODE") == "true" if initialEnabled { log.Info().Msg("Mock mode enabled at startup") } setEnabled(initialEnabled, true) } // IsMockEnabled returns whether mock mode is enabled. func IsMockEnabled() bool { return enabled.Load() } // SetEnabled enables or disables mock mode. func SetEnabled(enable bool) { setEnabled(enable, false) } // ToggleMockMode enables or disables mock mode at runtime (backwards-compatible helper). func ToggleMockMode(enable bool) { SetEnabled(enable) } func setEnabled(enable bool, fromInit bool) { current := enabled.Load() if current == enable { // Still update env so other processes see the latest value when not invoked from init. if !fromInit { setEnvFlag(enable) } return } if enable { enableMockMode(fromInit) } else { disableMockMode() } if !fromInit { setEnvFlag(enable) } } func setEnvFlag(enable bool) { if enable { _ = os.Setenv("PULSE_MOCK_MODE", "true") } else { _ = os.Setenv("PULSE_MOCK_MODE", "false") } } func enableMockMode(fromInit bool) { config := LoadMockConfig() dataMu.Lock() mockConfig = config mockData = GenerateMockData(config) mockAlerts = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers) mockData.LastUpdate = time.Now() enabled.Store(true) startUpdateLoopLocked() dataMu.Unlock() log.Info(). Int("nodes", config.NodeCount). Int("vms_per_node", config.VMsPerNode). Int("lxcs_per_node", config.LXCsPerNode). Int("host_agents", config.GenericHostCount). Int("docker_hosts", config.DockerHostCount). Int("docker_containers_per_host", config.DockerContainersPerHost). Bool("random_metrics", config.RandomMetrics). Float64("stopped_percent", config.StoppedPercent). Msg("Mock mode enabled") if !fromInit { log.Info().Msg("Mock data generator started") } } func disableMockMode() { dataMu.Lock() if !enabled.Load() { dataMu.Unlock() return } enabled.Store(false) stopUpdateLoopLocked() mockData = models.StateSnapshot{} mockAlerts = nil dataMu.Unlock() log.Info().Msg("Mock mode disabled") } func startUpdateLoopLocked() { stopUpdateLoopLocked() stopCh := make(chan struct{}) ticker := time.NewTicker(updateInterval) stopUpdatesCh = stopCh updateTicker = ticker updateLoopWg.Add(1) go func(stop <-chan struct{}, tick *time.Ticker) { defer updateLoopWg.Done() for { select { case <-tick.C: cfg := GetConfig() updateMetrics(cfg) case <-stop: return } } }(stopCh, ticker) } func stopUpdateLoopLocked() { if ch := stopUpdatesCh; ch != nil { close(ch) stopUpdatesCh = nil } if ticker := updateTicker; ticker != nil { ticker.Stop() updateTicker = nil } // Wait for the update goroutine to exit updateLoopWg.Wait() } func updateMetrics(cfg MockConfig) { if !IsMockEnabled() { return } dataMu.Lock() defer dataMu.Unlock() UpdateMetrics(&mockData, cfg) mockData.LastUpdate = time.Now() } // GetConfig returns the current mock configuration. func GetConfig() MockConfig { dataMu.RLock() defer dataMu.RUnlock() return mockConfig } // LoadMockConfig loads mock configuration from environment variables. func LoadMockConfig() MockConfig { config := DefaultConfig if val := os.Getenv("PULSE_MOCK_NODES"); val != "" { if n, err := strconv.Atoi(val); err == nil && n > 0 { config.NodeCount = n } } if val := os.Getenv("PULSE_MOCK_VMS_PER_NODE"); val != "" { if n, err := strconv.Atoi(val); err == nil && n >= 0 { config.VMsPerNode = n } } if val := os.Getenv("PULSE_MOCK_LXCS_PER_NODE"); val != "" { if n, err := strconv.Atoi(val); err == nil && n >= 0 { config.LXCsPerNode = n } } if val := os.Getenv("PULSE_MOCK_DOCKER_HOSTS"); val != "" { if n, err := strconv.Atoi(val); err == nil && n >= 0 { config.DockerHostCount = n } } if val := os.Getenv("PULSE_MOCK_DOCKER_CONTAINERS"); val != "" { if n, err := strconv.Atoi(val); err == nil && n >= 0 { config.DockerContainersPerHost = n } } if val := os.Getenv("PULSE_MOCK_GENERIC_HOSTS"); val != "" { if n, err := strconv.Atoi(val); err == nil && n >= 0 { config.GenericHostCount = n } } if val := os.Getenv("PULSE_MOCK_RANDOM_METRICS"); val != "" { config.RandomMetrics = val == "true" } if val := os.Getenv("PULSE_MOCK_STOPPED_PERCENT"); val != "" { if f, err := strconv.ParseFloat(val, 64); err == nil { config.StoppedPercent = f / 100.0 } } return config } // SetMockConfig updates the mock configuration dynamically and regenerates data when enabled. func SetMockConfig(cfg MockConfig) { dataMu.Lock() mockConfig = cfg if enabled.Load() { mockData = GenerateMockData(cfg) mockAlerts = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers) mockData.LastUpdate = time.Now() } dataMu.Unlock() log.Info(). Int("nodes", cfg.NodeCount). Int("vms_per_node", cfg.VMsPerNode). Int("lxcs_per_node", cfg.LXCsPerNode). Int("docker_hosts", cfg.DockerHostCount). Int("docker_containers_per_host", cfg.DockerContainersPerHost). Bool("random_metrics", cfg.RandomMetrics). Float64("stopped_percent", cfg.StoppedPercent). Msg("Mock configuration updated") } // GetMockState returns the current mock state snapshot. func GetMockState() models.StateSnapshot { if !IsMockEnabled() { return models.StateSnapshot{} } dataMu.RLock() defer dataMu.RUnlock() return cloneState(mockData) } // UpdateAlertSnapshots replaces the active and recently resolved alert lists used for mock mode. // This lets other components read alert data without querying the live alert manager, which can // be locked while alerts are being generated. Keeping a snapshot here prevents any blocking when // the API serves /api/state or WebSocket clients request the initial state. func UpdateAlertSnapshots(active []alerts.Alert, resolved []models.ResolvedAlert) { dataMu.Lock() defer dataMu.Unlock() converted := make([]models.Alert, 0, len(active)) for _, alert := range active { converted = append(converted, models.Alert{ ID: alert.ID, Type: alert.Type, Level: string(alert.Level), ResourceID: alert.ResourceID, ResourceName: alert.ResourceName, Node: alert.Node, Instance: alert.Instance, Message: alert.Message, Value: alert.Value, Threshold: alert.Threshold, StartTime: alert.StartTime, Acknowledged: alert.Acknowledged, }) } mockData.ActiveAlerts = converted mockData.RecentlyResolved = append([]models.ResolvedAlert(nil), resolved...) } // GetMockAlertHistory returns mock alert history. func GetMockAlertHistory(limit int) []models.Alert { if !IsMockEnabled() { return []models.Alert{} } dataMu.RLock() defer dataMu.RUnlock() if limit > 0 && limit < len(mockAlerts) { return append([]models.Alert(nil), mockAlerts[:limit]...) } return append([]models.Alert(nil), mockAlerts...) } func cloneState(state models.StateSnapshot) models.StateSnapshot { copyState := models.StateSnapshot{ Nodes: append([]models.Node(nil), state.Nodes...), VMs: append([]models.VM(nil), state.VMs...), Containers: append([]models.Container(nil), state.Containers...), DockerHosts: append([]models.DockerHost(nil), state.DockerHosts...), Hosts: append([]models.Host(nil), state.Hosts...), PMGInstances: append([]models.PMGInstance(nil), state.PMGInstances...), Storage: append([]models.Storage(nil), state.Storage...), CephClusters: append([]models.CephCluster(nil), state.CephClusters...), PhysicalDisks: append([]models.PhysicalDisk(nil), state.PhysicalDisks...), PBSInstances: append([]models.PBSInstance(nil), state.PBSInstances...), PBSBackups: append([]models.PBSBackup(nil), state.PBSBackups...), PMGBackups: append([]models.PMGBackup(nil), state.PMGBackups...), ReplicationJobs: append([]models.ReplicationJob(nil), state.ReplicationJobs...), Metrics: append([]models.Metric(nil), state.Metrics...), Performance: state.Performance, Stats: state.Stats, ActiveAlerts: append([]models.Alert(nil), state.ActiveAlerts...), RecentlyResolved: append([]models.ResolvedAlert(nil), state.RecentlyResolved...), LastUpdate: state.LastUpdate, ConnectionHealth: make(map[string]bool, len(state.ConnectionHealth)), } copyState.PVEBackups = models.PVEBackups{ BackupTasks: append([]models.BackupTask(nil), state.PVEBackups.BackupTasks...), StorageBackups: append([]models.StorageBackup(nil), state.PVEBackups.StorageBackups...), GuestSnapshots: append([]models.GuestSnapshot(nil), state.PVEBackups.GuestSnapshots...), } copyState.Backups = models.Backups{ PVE: models.PVEBackups{ BackupTasks: append([]models.BackupTask(nil), state.Backups.PVE.BackupTasks...), StorageBackups: append([]models.StorageBackup(nil), state.Backups.PVE.StorageBackups...), GuestSnapshots: append([]models.GuestSnapshot(nil), state.Backups.PVE.GuestSnapshots...), }, PBS: append([]models.PBSBackup(nil), state.Backups.PBS...), PMG: append([]models.PMGBackup(nil), state.Backups.PMG...), } for k, v := range state.ConnectionHealth { copyState.ConnectionHealth[k] = v } return copyState }