From 6676b7f722dcd15741680838e8d1620c656a75f2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 30 Sep 2025 10:02:26 +0000 Subject: [PATCH] Add runtime mock toggles and auth-safe dev assets --- internal/alerts/alerts.go | 29 +++ internal/api/config_handlers.go | 106 ++++++++++- internal/api/router.go | 22 ++- internal/config/persistence.go | 3 +- internal/mock/integration.go | 309 ++++++++++++++++++++++---------- internal/monitoring/monitor.go | 117 +++++++++--- 6 files changed, 460 insertions(+), 126 deletions(-) diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 5f752a1c7..a6fdfe610 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -2271,6 +2271,35 @@ func (m *Manager) CleanupAlertsForNodes(existingNodes map[string]bool) { } } +// ClearActiveAlerts removes all active and pending alerts, resetting the manager state. +func (m *Manager) ClearActiveAlerts() { + m.mu.Lock() + if len(m.activeAlerts) == 0 && len(m.pendingAlerts) == 0 { + m.mu.Unlock() + return + } + m.activeAlerts = make(map[string]*Alert) + m.pendingAlerts = make(map[string]time.Time) + m.recentAlerts = make(map[string]*Alert) + m.suppressedUntil = make(map[string]time.Time) + m.alertRateLimit = make(map[string][]time.Time) + m.nodeOfflineCount = make(map[string]int) + m.offlineConfirmations = make(map[string]int) + m.mu.Unlock() + + m.resolvedMutex.Lock() + m.recentlyResolved = make(map[string]*ResolvedAlert) + m.resolvedMutex.Unlock() + + log.Info().Msg("Cleared all active and pending alerts") + + go func() { + if err := m.SaveActiveAlerts(); err != nil { + log.Error().Err(err).Msg("Failed to persist cleared alerts") + } + }() +} + // periodicSaveAlerts saves active alerts to disk periodically func (m *Manager) periodicSaveAlerts() { ticker := time.NewTicker(1 * time.Minute) diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index d4d520515..ce0c9f89c 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -18,6 +18,7 @@ import ( internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" @@ -620,7 +621,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse { // HandleGetNodes returns all configured nodes func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request) { // Check if mock mode is enabled - if os.Getenv("PULSE_MOCK_MODE") == "true" { + if mock.IsMockEnabled() { // Return mock nodes for settings page mockNodes := []NodeResponse{} @@ -788,7 +789,7 @@ func extractHostAndPort(hostStr string) (string, string, error) { // HandleAddNode adds a new node func (h *ConfigHandlers) HandleAddNode(w http.ResponseWriter, r *http.Request) { // Prevent node modifications in mock mode - if os.Getenv("PULSE_MOCK_MODE") == "true" { + if mock.IsMockEnabled() { http.Error(w, "Cannot modify nodes in mock mode. Please disable mock mode first: /opt/pulse/scripts/toggle-mock.sh off", http.StatusForbidden) return } @@ -1270,7 +1271,7 @@ func (h *ConfigHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Req // HandleUpdateNode updates an existing node func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request) { // Prevent node modifications in mock mode - if os.Getenv("PULSE_MOCK_MODE") == "true" { + if mock.IsMockEnabled() { http.Error(w, "Cannot modify nodes in mock mode", http.StatusForbidden) return } @@ -1511,7 +1512,7 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request // HandleDeleteNode deletes a node func (h *ConfigHandlers) HandleDeleteNode(w http.ResponseWriter, r *http.Request) { // Prevent node modifications in mock mode - if os.Getenv("PULSE_MOCK_MODE") == "true" { + if mock.IsMockEnabled() { http.Error(w, "Cannot modify nodes in mock mode", http.StatusForbidden) return } @@ -3182,6 +3183,103 @@ func (h *ConfigHandlers) HandleSetupScriptURL(w http.ResponseWriter, r *http.Req json.NewEncoder(w).Encode(response) } +// HandleGetMockMode returns the current mock mode state and configuration. +func (h *ConfigHandlers) HandleGetMockMode(w http.ResponseWriter, r *http.Request) { + status := struct { + Enabled bool `json:"enabled"` + Config mock.MockConfig `json:"config"` + }{ + Enabled: mock.IsMockEnabled(), + Config: mock.GetConfig(), + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(status); err != nil { + log.Error().Err(err).Msg("Failed to encode mock mode status") + } +} + +type mockModeRequest struct { + Enabled *bool `json:"enabled"` + Config struct { + NodeCount *int `json:"nodeCount"` + VMsPerNode *int `json:"vmsPerNode"` + LXCsPerNode *int `json:"lxcsPerNode"` + RandomMetrics *bool `json:"randomMetrics"` + HighLoadNodes []string `json:"highLoadNodes"` + StoppedPercent *float64 `json:"stoppedPercent"` + } `json:"config"` +} + +// HandleUpdateMockMode updates mock mode and optionally its configuration. +func (h *ConfigHandlers) HandleUpdateMockMode(w http.ResponseWriter, r *http.Request) { + var req mockModeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Error().Err(err).Msg("Failed to decode mock mode request") + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Update configuration first if provided. + currentCfg := mock.GetConfig() + if req.Config.NodeCount != nil { + if *req.Config.NodeCount <= 0 { + http.Error(w, "nodeCount must be greater than zero", http.StatusBadRequest) + return + } + currentCfg.NodeCount = *req.Config.NodeCount + } + if req.Config.VMsPerNode != nil { + if *req.Config.VMsPerNode < 0 { + http.Error(w, "vmsPerNode cannot be negative", http.StatusBadRequest) + return + } + currentCfg.VMsPerNode = *req.Config.VMsPerNode + } + if req.Config.LXCsPerNode != nil { + if *req.Config.LXCsPerNode < 0 { + http.Error(w, "lxcsPerNode cannot be negative", http.StatusBadRequest) + return + } + currentCfg.LXCsPerNode = *req.Config.LXCsPerNode + } + if req.Config.RandomMetrics != nil { + currentCfg.RandomMetrics = *req.Config.RandomMetrics + } + if req.Config.HighLoadNodes != nil { + currentCfg.HighLoadNodes = req.Config.HighLoadNodes + } + if req.Config.StoppedPercent != nil { + if *req.Config.StoppedPercent < 0 || *req.Config.StoppedPercent > 1 { + http.Error(w, "stoppedPercent must be between 0 and 1", http.StatusBadRequest) + return + } + currentCfg.StoppedPercent = *req.Config.StoppedPercent + } + + mock.SetMockConfig(currentCfg) + + if req.Enabled != nil { + if h.monitor != nil { + h.monitor.SetMockMode(*req.Enabled) + } else { + mock.SetEnabled(*req.Enabled) + } + } + + w.Header().Set("Content-Type", "application/json") + status := struct { + Enabled bool `json:"enabled"` + Config mock.MockConfig `json:"config"` + }{ + Enabled: mock.IsMockEnabled(), + Config: mock.GetConfig(), + } + if err := json.NewEncoder(w).Encode(status); err != nil { + log.Error().Err(err).Msg("Failed to encode mock mode response") + } +} + // AutoRegisterRequest represents a request from the setup script to auto-register a node type AutoRegisterRequest struct { Type string `json:"type"` // "pve" or "pbs" diff --git a/internal/api/router.go b/internal/api/router.go index c96b7ee68..65d5b2116 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -184,6 +184,18 @@ func (r *Router) setupRoutes() { } }) + // Mock mode toggle routes + r.mux.HandleFunc("/api/system/mock-mode", func(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + configHandlers.HandleGetMockMode(w, req) + case http.MethodPost, http.MethodPut: + RequireAdmin(configHandlers.config, configHandlers.HandleUpdateMockMode)(w, req) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + // Registration token routes removed - feature deprecated // Security routes @@ -884,13 +896,21 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Also allow static assets without auth (JS, CSS, etc) // These MUST be accessible for the login page to work isStaticAsset := strings.HasPrefix(req.URL.Path, "/assets/") || + strings.HasPrefix(req.URL.Path, "/@vite/") || + strings.HasPrefix(req.URL.Path, "/@solid-refresh") || + strings.HasPrefix(req.URL.Path, "/src/") || + strings.HasPrefix(req.URL.Path, "/node_modules/") || req.URL.Path == "/" || req.URL.Path == "/index.html" || req.URL.Path == "/favicon.ico" || req.URL.Path == "/logo.svg" || strings.HasSuffix(req.URL.Path, ".js") || strings.HasSuffix(req.URL.Path, ".css") || - strings.HasSuffix(req.URL.Path, ".map") + strings.HasSuffix(req.URL.Path, ".map") || + strings.HasSuffix(req.URL.Path, ".ts") || + strings.HasSuffix(req.URL.Path, ".tsx") || + strings.HasSuffix(req.URL.Path, ".mjs") || + strings.HasSuffix(req.URL.Path, ".jsx") isPublic := isStaticAsset for _, path := range publicPaths { diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 409b80ac9..a1cf88f37 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -11,6 +11,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/crypto" + "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/notifications" "github.com/rs/zerolog/log" ) @@ -446,7 +447,7 @@ type SystemSettings struct { func (c *ConfigPersistence) SaveNodesConfig(pveInstances []PVEInstance, pbsInstances []PBSInstance) error { // CRITICAL: Prevent saving empty nodes when in mock mode // Mock mode should NEVER modify real node configuration - if os.Getenv("PULSE_MOCK_MODE") == "true" { + if mock.IsMockEnabled() { log.Warn().Msg("Skipping nodes save - mock mode is enabled") return nil // Silently succeed to prevent errors but don't save } diff --git a/internal/mock/integration.go b/internal/mock/integration.go index 67d9b0baa..a1ab086d1 100644 --- a/internal/mock/integration.go +++ b/internal/mock/integration.go @@ -3,6 +3,8 @@ package mock import ( "os" "strconv" + "sync" + "sync/atomic" "time" "github.com/rcourtman/pulse-go-rewrite/internal/models" @@ -10,63 +12,175 @@ import ( ) var ( - mockData models.StateSnapshot - // Removed mockAlerts - using real alert manager instead - mockAlertHistory []models.Alert - mockEnabled bool - lastUpdate time.Time - updateInterval = 2 * time.Second + dataMu sync.RWMutex + mockData models.StateSnapshot + mockAlerts []models.Alert + mockConfig = DefaultConfig + enabled atomic.Bool + updateTicker *time.Ticker + stopUpdatesCh chan struct{} ) +const updateInterval = 2 * time.Second + func init() { - // Check if mock mode is enabled - mockEnabled = os.Getenv("PULSE_MOCK_MODE") == "true" + initialEnabled := os.Getenv("PULSE_MOCK_MODE") == "true" + if initialEnabled { + log.Info().Msg("Mock mode enabled at startup") + } + setEnabled(initialEnabled, true) +} - if mockEnabled { - log.Info().Msg("Mock mode enabled - using simulated data") +// IsMockEnabled returns whether mock mode is enabled. +func IsMockEnabled() bool { + return enabled.Load() +} - // Load configuration from env vars or use defaults - config := LoadMockConfig() +// SetEnabled enables or disables mock mode. +func SetEnabled(enable bool) { + setEnabled(enable, false) +} - // Generate initial mock data - mockData = GenerateMockData(config) - // Removed fake alert generation - real alert manager will handle this - mockAlertHistory = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers) - lastUpdate = time.Now() +// ToggleMockMode enables or disables mock mode at runtime (backwards-compatible helper). +func ToggleMockMode(enable bool) { + SetEnabled(enable) +} - // Start update ticker - go func() { - ticker := time.NewTicker(updateInterval) - defer ticker.Stop() +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 + } - for range ticker.C { - if mockEnabled { - UpdateMetrics(&mockData, config) - // Removed fake alert regeneration - } - } - }() + if enable { + enableMockMode(fromInit) + } else { + disableMockMode() + } + + if !fromInit { + setEnvFlag(enable) } } -// LoadMockConfig loads mock configuration from environment variables +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). + 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() + stopUpdatesCh = make(chan struct{}) + updateTicker = time.NewTicker(updateInterval) + + go func() { + for { + select { + case <-updateTicker.C: + cfg := GetConfig() + updateMetrics(cfg) + case <-stopUpdatesCh: + return + } + } + }() +} + +func stopUpdateLoopLocked() { + if updateTicker != nil { + updateTicker.Stop() + updateTicker = nil + } + if stopUpdatesCh != nil { + close(stopUpdatesCh) + stopUpdatesCh = nil + } +} + +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 { + 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 { + 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 { + if n, err := strconv.Atoi(val); err == nil && n >= 0 { config.LXCsPerNode = n } } @@ -81,84 +195,83 @@ func LoadMockConfig() MockConfig { } } - log.Info(). - Int("nodes", config.NodeCount). - Int("vms_per_node", config.VMsPerNode). - Int("lxcs_per_node", config.LXCsPerNode). - Bool("random_metrics", config.RandomMetrics). - Float64("stopped_percent", config.StoppedPercent). - Msg("Mock configuration loaded") - return config } -// IsMockEnabled returns whether mock mode is enabled -func IsMockEnabled() bool { - return mockEnabled -} - -// GetMockState returns the current mock state snapshot -func GetMockState() models.StateSnapshot { - if !mockEnabled { - return models.StateSnapshot{} +// 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() } - - // Return the current mock data - // Don't override alerts - let the real alert manager handle them - // mockData.ActiveAlerts = mockAlerts - return mockData -} - -// ToggleMockMode enables or disables mock mode at runtime -func ToggleMockMode(enable bool) { - if enable && !mockEnabled { - mockEnabled = true - config := LoadMockConfig() - mockData = GenerateMockData(config) - // Removed fake alert generation - mockAlertHistory = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers) - log.Info(). - Int("history_count", len(mockAlertHistory)). - Msg("Mock mode enabled dynamically with alert history") - } else if !enable && mockEnabled { - mockEnabled = false - log.Info().Msg("Mock mode disabled dynamically") - } -} - -// SetMockConfig updates the mock configuration dynamically -func SetMockConfig(nodeCount, vmsPerNode, lxcsPerNode int) { - if !mockEnabled { - return - } - - config := MockConfig{ - NodeCount: nodeCount, - VMsPerNode: vmsPerNode, - LXCsPerNode: lxcsPerNode, - RandomMetrics: true, - StoppedPercent: 0.2, - } - - mockData = GenerateMockData(config) - // Removed fake alert generation - mockAlertHistory = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers) + dataMu.Unlock() log.Info(). - Int("nodes", nodeCount). - Int("vms", vmsPerNode). - Int("lxcs", lxcsPerNode). + Int("nodes", cfg.NodeCount). + Int("vms_per_node", cfg.VMsPerNode). + Int("lxcs_per_node", cfg.LXCsPerNode). + Bool("random_metrics", cfg.RandomMetrics). + Float64("stopped_percent", cfg.StoppedPercent). Msg("Mock configuration updated") } -// GetMockAlertHistory returns mock alert history +// GetMockState returns the current mock state snapshot. +func GetMockState() models.StateSnapshot { + if !IsMockEnabled() { + return models.StateSnapshot{} + } + + dataMu.RLock() + defer dataMu.RUnlock() + + return cloneState(mockData) +} + +// GetMockAlertHistory returns mock alert history. func GetMockAlertHistory(limit int) []models.Alert { - if !mockEnabled { + if !IsMockEnabled() { return []models.Alert{} } - if limit > 0 && limit < len(mockAlertHistory) { - return mockAlertHistory[:limit] + dataMu.RLock() + defer dataMu.RUnlock() + + if limit > 0 && limit < len(mockAlerts) { + return append([]models.Alert(nil), mockAlerts[:limit]...) } - return mockAlertHistory + 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...), + Storage: append([]models.Storage(nil), state.Storage...), + PhysicalDisks: append([]models.PhysicalDisk(nil), state.PhysicalDisks...), + PBSInstances: append([]models.PBSInstance(nil), state.PBSInstances...), + PBSBackups: append([]models.PBSBackup(nil), state.PBSBackups...), + 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...), + } + + for k, v := range state.ConnectionHealth { + copyState.ConnectionHealth[k] = v + } + + return copyState } diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 593df1bac..9fd29d03c 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -5,7 +5,6 @@ import ( "fmt" "math" "net" - "os" "sort" "strconv" "strings" @@ -68,6 +67,8 @@ type Monitor struct { lastAuthAttempt map[string]time.Time // Track last auth attempt time lastClusterCheck map[string]time.Time // Track last cluster check for standalone nodes persistence *config.ConfigPersistence // Add persistence for saving updated configs + runtimeCtx context.Context // Context used while monitor is running + wsHub *websocket.Hub // Hub used for broadcasting state } // safePercentage calculates percentage safely, returning 0 if divisor is 0 @@ -110,6 +111,26 @@ func sortContent(content string) string { // GetConnectionStatuses returns the current connection status for all nodes func (m *Monitor) GetConnectionStatuses() map[string]bool { + if mock.IsMockEnabled() { + statuses := make(map[string]bool) + state := mock.GetMockState() + for _, node := range state.Nodes { + key := "pve-" + node.Name + statuses[key] = strings.ToLower(node.Status) == "online" + if node.Host != "" { + statuses[node.Host] = strings.ToLower(node.Status) == "online" + } + } + for _, pbsInst := range state.PBSInstances { + key := "pbs-" + pbsInst.Name + statuses[key] = strings.ToLower(pbsInst.Status) != "offline" + if pbsInst.Host != "" { + statuses[pbsInst.Host] = strings.ToLower(pbsInst.Status) != "offline" + } + } + return statuses + } + m.mu.RLock() defer m.mu.RUnlock() @@ -212,7 +233,7 @@ func New(cfg *config.Config) (*Monitor, error) { } // Check if mock mode is enabled before initializing clients - mockEnabled := os.Getenv("PULSE_MOCK_MODE") == "true" + mockEnabled := mock.IsMockEnabled() if mockEnabled { log.Info().Msg("Mock mode enabled - skipping PVE/PBS client initialization") @@ -373,6 +394,11 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) { Dur("pollingInterval", 10*time.Second). Msg("Starting monitoring loop") + m.mu.Lock() + m.runtimeCtx = ctx + m.wsHub = wsHub + m.mu.Unlock() + // Initialize and start discovery service if enabled if m.config.DiscoveryEnabled { discoverySubnet := m.config.DiscoverySubnet @@ -455,25 +481,23 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) { broadcastTicker := time.NewTicker(pollingInterval) defer broadcastTicker.Stop() - // Check if mock mode is enabled - mockEnabled := os.Getenv("PULSE_MOCK_MODE") == "true" - // Do an immediate poll on start (only if not in mock mode) - if !mockEnabled { - go m.poll(ctx, wsHub) - } else { + if mock.IsMockEnabled() { log.Info().Msg("Mock mode enabled - skipping real node polling") + go m.checkMockAlerts() + } else { + go m.poll(ctx, wsHub) } for { select { case <-pollTicker.C: - // Start polling in a goroutine so it doesn't block the ticker (only if not in mock mode) - if !mockEnabled { - go m.poll(ctx, wsHub) - } else { - // In mock mode, still check alerts for mock data + if mock.IsMockEnabled() { + // In mock mode, keep synthetic alerts fresh go m.checkMockAlerts() + } else { + // Poll real infrastructure + go m.poll(ctx, wsHub) } case <-broadcastTicker.C: @@ -2759,6 +2783,53 @@ func (m *Monitor) GetState() models.StateSnapshot { return m.state.GetSnapshot() } +// SetMockMode switches between mock data and real infrastructure data at runtime. +func (m *Monitor) SetMockMode(enable bool) { + current := mock.IsMockEnabled() + if current == enable { + log.Info().Bool("mockMode", enable).Msg("Mock mode already in desired state") + return + } + + if enable { + mock.SetEnabled(true) + m.alertManager.ClearActiveAlerts() + m.mu.Lock() + m.resetStateLocked() + m.mu.Unlock() + log.Info().Msg("Switched monitor to mock mode") + } else { + mock.SetEnabled(false) + m.alertManager.ClearActiveAlerts() + m.mu.Lock() + m.resetStateLocked() + m.mu.Unlock() + log.Info().Msg("Switched monitor to real data mode") + } + + m.mu.RLock() + ctx := m.runtimeCtx + hub := m.wsHub + m.mu.RUnlock() + + if hub != nil { + hub.BroadcastState(m.GetState()) + } + + if !enable && ctx != nil && hub != nil { + // Kick off an immediate poll to repopulate state with live data + go m.poll(ctx, hub) + } +} + +func (m *Monitor) resetStateLocked() { + m.state = models.NewState() + m.state.Stats = models.Stats{ + StartTime: m.startTime, + Version: "2.0.0-go", + } +} + // GetStartTime returns the monitor start time func (m *Monitor) GetStartTime() time.Time { return m.startTime @@ -3362,21 +3433,23 @@ func (m *Monitor) checkMockAlerts() { Msg("Checking alerts for mock data") // Clean up alerts for nodes that no longer exist - // Use the mock state nodes since we haven't updated global state yet existingNodes := make(map[string]bool) for _, node := range state.Nodes { existingNodes[node.Name] = true + if node.Host != "" { + existingNodes[node.Host] = true + } } - // Also add any real nodes from the global state - allState := m.state.GetSnapshot() - for _, node := range allState.Nodes { - existingNodes[node.Name] = true + for _, pbsInst := range state.PBSInstances { + existingNodes[pbsInst.Name] = true + existingNodes["pbs-"+pbsInst.Name] = true + if pbsInst.Host != "" { + existingNodes[pbsInst.Host] = true + } } log.Info(). - Int("mockNodes", len(state.Nodes)). - Int("stateNodes", len(allState.Nodes)). - Int("totalNodes", len(existingNodes)). - Msg("Collecting nodes for alert cleanup") + Int("trackedNodes", len(existingNodes)). + Msg("Collecting resources for alert cleanup in mock mode") m.alertManager.CleanupAlertsForNodes(existingNodes) // Limit how many guests we check per cycle to prevent blocking with large datasets