From 33bfb27f9a626f4222657ae7941cdfc7acb35bdc Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Tue, 2 Sep 2025 21:11:01 +0000 Subject: [PATCH] enhance: improve mock data realism and alert system - Add dynamic metric fluctuations for VMs and containers in mock data - Fix alert acknowledgment to dim instead of hide alerts - Implement unacknowledge functionality with backend persistence - Simplify alert UI to single-click toggle (remove selection system) - Add proper hysteresis for alert resolution when metrics drop - Fix SVG icon boundaries in alert displays - Add webhook disable toggles for testing without notifications - Fix frontend directory duplication issue (addresses frontend-modern recreation) - Improve alert sorting to show most recent first - Make mock system generate realistic metric changes for proper alert lifecycle --- .gitignore | 2 + CLAUDE.md | 26 +- Dockerfile | 3 +- Makefile | 9 +- frontend-modern/src/App.tsx | 2 +- frontend-modern/src/api/alerts.ts | 6 + .../src/components/Alerts/WebhookConfig.tsx | 46 ++- .../src/components/Settings/Settings.tsx | 2 +- frontend-modern/src/pages/Alerts.tsx | 267 +++++++----------- internal/alerts/alerts.go | 104 ++++++- internal/alerts/history.go | 22 ++ internal/api/DO_NOT_EDIT_FRONTEND_HERE.md | 23 ++ internal/api/README.md | 22 ++ internal/api/alerts.go | 56 ++++ internal/config/persistence.go | 11 +- internal/mock/generator.go | 82 +++++- internal/mock/integration.go | 18 +- internal/models/converters.go | 1 + internal/models/models_frontend.go | 1 + internal/models/state_snapshot.go | 1 + internal/monitoring/monitor.go | 54 +++- scripts/build-release.sh | 10 +- scripts/hot-dev.sh | 5 +- 23 files changed, 564 insertions(+), 209 deletions(-) create mode 100644 internal/api/DO_NOT_EDIT_FRONTEND_HERE.md create mode 100644 internal/api/README.md diff --git a/.gitignore b/.gitignore index 4eed523c3..9772df806 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ build/ pulse-fixes*.tar.gz # Frontend copy for embedding (generated during build) +# Frontend build artifact for Go embedding +# This is auto-generated, DO NOT EDIT internal/api/frontend-modern/ # Claude-specific files diff --git a/CLAUDE.md b/CLAUDE.md index a6b784f5c..74c3d2a97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -368,10 +368,28 @@ sudo systemctl start pulse-dev # This is only needed at release time, not during development ``` -### IMPORTANT: Frontend Embed Location (for production builds only) -**The Go binary embeds frontend files from `/opt/pulse/internal/api/frontend-modern/dist`** -- The build-release.sh script handles this automatically at release time -- Never manually copy files unless debugging +### IMPORTANT: Frontend Directory Structure (CLAUDE READ THIS FIRST!) +**⚠️ CLAUDE: The duplicate frontend directory is INTENTIONAL and REQUIRED ⚠️** + +**When you see `/opt/pulse/internal/api/frontend-modern/`:** +- **DO NOT try to "fix" it** - It's supposed to exist +- **DO NOT remove it** - Builds will fail +- **DO NOT edit files there** - Only edit in `/opt/pulse/frontend-modern` +- **DO NOT worry about it** - It's a build artifact like .o files + +**The ONLY frontend source**: `/opt/pulse/frontend-modern` +- Edit all frontend code here +- Vite dev server runs from here +- This is the source of truth + +**Why the duplicate exists**: +- Go's `//go:embed` CANNOT access files outside the module (no `../` or symlinks) +- The Makefile copies the built frontend for embedding +- It's in .gitignore so it won't pollute git +- It gets recreated on every build (that's normal!) + +**What to tell users who ask about it**: +"That's a build artifact required by Go's embed limitations. Only edit files in `/opt/pulse/frontend-modern`. The duplicate is automatically managed by the build process." ## Development Environment (Current Machine - debian-go) - **Development Port**: 7655 (frontend with hot-reload) diff --git a/Dockerfile b/Dockerfile index a827d2a0d..a80b5131b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,8 @@ COPY pkg/ ./pkg/ COPY VERSION ./ # Copy built frontend from frontend-builder stage for embedding -COPY --from=frontend-builder /app/frontend-modern ./internal/api/frontend-modern +# Must be at internal/api/frontend-modern for Go embed +COPY --from=frontend-builder /app/frontend-modern/dist ./internal/api/frontend-modern/dist # Build the binary with embedded frontend RUN CGO_ENABLED=0 GOOS=linux go build \ diff --git a/Makefile b/Makefile index 6b88168f7..6b2525056 100644 --- a/Makefile +++ b/Makefile @@ -8,8 +8,14 @@ all: frontend backend # Build frontend only frontend: cd frontend-modern && npm run build - rm -rf internal/api/frontend-modern/dist + @echo "================================================" + @echo "Copying frontend to internal/api/ for Go embed" + @echo "This is REQUIRED - Go cannot embed external paths" + @echo "================================================" + rm -rf internal/api/frontend-modern + mkdir -p internal/api/frontend-modern cp -r frontend-modern/dist internal/api/frontend-modern/ + @echo "✓ Frontend copied for embedding" # Build backend only (includes embedded frontend) backend: @@ -30,7 +36,6 @@ dev: frontend backend clean: rm -f pulse rm -rf frontend-modern/dist - rm -rf internal/api/frontend-modern/dist # Quick rebuild and restart for development restart: frontend backend diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 27d00bf51..4db7d242d 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -614,4 +614,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App;// Test hot-reload comment $(date) diff --git a/frontend-modern/src/api/alerts.ts b/frontend-modern/src/api/alerts.ts index 14f2a79fc..772874728 100644 --- a/frontend-modern/src/api/alerts.ts +++ b/frontend-modern/src/api/alerts.ts @@ -40,6 +40,12 @@ export class AlertsAPI { }); } + static async unacknowledge(alertId: string): Promise<{ success: boolean }> { + return apiFetchJSON(`${this.baseUrl}/${alertId}/unacknowledge`, { + method: 'POST', + }); + } + // Alert configuration methods static async getConfig(): Promise { return apiFetchJSON(`${this.baseUrl}/config`); diff --git a/frontend-modern/src/components/Alerts/WebhookConfig.tsx b/frontend-modern/src/components/Alerts/WebhookConfig.tsx index e80a25443..8467fb097 100644 --- a/frontend-modern/src/components/Alerts/WebhookConfig.tsx +++ b/frontend-modern/src/components/Alerts/WebhookConfig.tsx @@ -140,11 +140,40 @@ export function WebhookConfig(props: WebhookConfigProps) { return names[service] || service; }; + const toggleAllWebhooks = (enabled: boolean) => { + props.webhooks.forEach(webhook => { + props.onUpdate({ ...webhook, enabled }); + }); + }; + + const allEnabled = () => props.webhooks.every(w => w.enabled); + const someEnabled = () => props.webhooks.some(w => w.enabled); + return (
{/* Existing Webhooks List */} 0}>
+ {/* Quick Actions Bar */} +
+
+ {props.webhooks.filter(w => w.enabled).length} of {props.webhooks.length} webhooks enabled +
+
+ + +
+
{(webhook) => (
@@ -160,20 +189,25 @@ export function WebhookConfig(props: WebhookConfigProps) { {webhook.method} - {!webhook.enabled && ( - - Disabled - - )}

{webhook.url}

+ - -
-
+
+

Active Alerts

0} @@ -936,36 +840,86 @@ function OverviewTab(props: {
} > - {/* Select All Checkbox */} -
- - -
+ {/* Simple View Toggle - only show if there are acknowledged alerts */} + 0}> +
+ +
+
- + +
+ {props.showAcknowledged() ? 'No active alerts' : 'No unacknowledged alerts'} +
+
+ {(alert) => ( -
+
{ + // Clicking always toggles acknowledge state + if (processingAlerts().has(alert.id)) return; // Prevent double-clicks + + setProcessingAlerts(prev => new Set(prev).add(alert.id)); + try { + if (alert.acknowledged) { + // Un-acknowledge + await AlertsAPI.unacknowledge(alert.id); + props.updateAlert(alert.id, { acknowledged: false }); + showSuccess('Alert restored'); + } else { + // Acknowledge + await AlertsAPI.acknowledge(alert.id); + props.updateAlert(alert.id, { acknowledged: true }); + showSuccess('Alert acknowledged'); + } + } catch (err) { + console.error('Failed to toggle alert state:', err); + showError('Failed to update alert'); + } finally { + setProcessingAlerts(prev => { + const next = new Set(prev); + next.delete(alert.id); + return next; + }); + } + }} + class={`border rounded-lg p-4 transition-all cursor-pointer hover:shadow-md ${ + processingAlerts().has(alert.id) ? 'opacity-50 cursor-wait' : '' + } ${ + alert.acknowledged + ? 'opacity-60 border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/20 hover:opacity-80' + : alert.level === 'critical' + ? 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20' + : 'border-yellow-300 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20' + }`} + title={alert.acknowledged ? 'Click to restore this alert' : 'Click to acknowledge'}>
- toggleAlertSelection(alert.id)} - /> + {/* Status icon */} +
+ {alert.acknowledged ? ( + // Checkmark for acknowledged + + + + ) : ( + // Warning/Alert icon + + + + )} +
-
diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 350bfe536..241c9135a 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -325,7 +325,10 @@ func (m *Manager) UpdateConfig(config AlertConfig) { } m.config = config - log.Info().Msg("Alert configuration updated") + log.Info(). + Bool("enabled", config.Enabled). + Interface("guestDefaults", config.GuestDefaults). + Msg("Alert configuration updated") } // isInQuietHours checks if the current time is within quiet hours @@ -394,6 +397,7 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) { m.mu.RLock() if !m.config.Enabled { m.mu.RUnlock() + log.Debug().Msg("CheckGuest: alerts disabled globally") return } m.mu.RUnlock() @@ -417,6 +421,15 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) { diskWrite = g.DiskWrite netIn = g.NetworkIn netOut = g.NetworkOut + + // Debug logging for high memory VMs + if memUsage > 85 { + log.Info(). + Str("vm", name). + Float64("memUsage", memUsage). + Str("status", status). + Msg("VM with high memory detected in CheckGuest") + } case models.Container: guestID = g.ID name = g.Name @@ -431,6 +444,9 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) { netIn = g.NetworkIn netOut = g.NetworkOut default: + log.Debug(). + Str("type", fmt.Sprintf("%T", guest)). + Msg("CheckGuest: unsupported guest type") return } @@ -953,11 +969,36 @@ func (m *Manager) AcknowledgeAlert(alertID, user string) error { return nil } +// UnacknowledgeAlert removes the acknowledged status from an alert +func (m *Manager) UnacknowledgeAlert(alertID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + alert, exists := m.activeAlerts[alertID] + if !exists { + return fmt.Errorf("alert not found: %s", alertID) + } + + alert.Acknowledged = false + alert.AckTime = nil + alert.AckUser = "" + + // Write the modified alert back to the map + m.activeAlerts[alertID] = alert + + log.Info(). + Str("alertID", alertID). + Msg("Alert unacknowledged") + + return nil +} + // GetActiveAlerts returns all active alerts func (m *Manager) GetActiveAlerts() []Alert { m.mu.RLock() defer m.mu.RUnlock() + log.Debug().Int("count", len(m.activeAlerts)).Msg("GetActiveAlerts called") alerts := make([]Alert, 0, len(m.activeAlerts)) for _, alert := range m.activeAlerts { alerts = append(alerts, *alert) @@ -1361,11 +1402,12 @@ func (m *Manager) clearStorageOfflineAlert(storage models.Storage) { Msg("Storage is back online") } -// ClearAlert manually clears an alert +// ClearAlert removes an alert from active alerts (but keeps in history) func (m *Manager) ClearAlert(alertID string) { m.mu.Lock() defer m.mu.Unlock() + // Remove from active alerts only delete(m.activeAlerts, alertID) if m.onResolved != nil { @@ -1921,6 +1963,64 @@ func (m *Manager) LoadActiveAlerts() error { return nil } +// CleanupAlertsForNodes removes alerts for nodes that no longer exist +func (m *Manager) CleanupAlertsForNodes(existingNodes map[string]bool) { + m.mu.Lock() + defer m.mu.Unlock() + + log.Info(). + Int("totalAlerts", len(m.activeAlerts)). + Int("existingNodes", len(existingNodes)). + Interface("nodes", existingNodes). + Msg("Starting alert cleanup for non-existent nodes") + + removedCount := 0 + for alertID := range m.activeAlerts { + var node string + + // Extract node from alert ID + // Format can be either "node:type/id-metric" or "node-storage-name-usage" + if strings.Contains(alertID, ":") { + // Guest alert format: "node:type/id-metric" + parts := strings.Split(alertID, ":") + if len(parts) >= 2 { + node = parts[0] + } + } else if strings.Contains(alertID, "-storage-") { + // Storage alert format: "node-storage-name-usage" + parts := strings.Split(alertID, "-storage-") + if len(parts) >= 1 { + node = parts[0] + } + } else if strings.HasPrefix(alertID, "node-offline-") { + // Node offline alert format: "node-offline-node/nodename" + // Extract the node name after the last slash + if idx := strings.LastIndex(alertID, "/"); idx != -1 { + node = alertID[idx+1:] + } + } + + // If we couldn't extract a node or the node doesn't exist, remove the alert + if node == "" || !existingNodes[node] { + delete(m.activeAlerts, alertID) + removedCount++ + log.Debug().Str("alertID", alertID).Str("node", node).Msg("Removed alert for non-existent node") + } + } + + if removedCount > 0 { + log.Info().Int("removed", removedCount).Int("remaining", len(m.activeAlerts)).Msg("Cleaned up alerts for non-existent nodes") + // Save the cleaned up state + go func() { + if err := m.SaveActiveAlerts(); err != nil { + log.Error().Err(err).Msg("Failed to save alerts after cleanup") + } + }() + } else { + log.Info().Msg("No alerts needed cleanup") + } +} + // periodicSaveAlerts saves active alerts to disk periodically func (m *Manager) periodicSaveAlerts() { ticker := time.NewTicker(1 * time.Minute) diff --git a/internal/alerts/history.go b/internal/alerts/history.go index fcf5c49f9..45cd86a38 100644 --- a/internal/alerts/history.go +++ b/internal/alerts/history.go @@ -257,6 +257,28 @@ func (hm *HistoryManager) cleanOldEntries() { } } +// RemoveAlert removes a specific alert from history by ID +func (hm *HistoryManager) RemoveAlert(alertID string) { + hm.mu.Lock() + defer hm.mu.Unlock() + + newHistory := make([]HistoryEntry, 0, len(hm.history)) + removed := false + + for _, entry := range hm.history { + if entry.Alert.ID != alertID { + newHistory = append(newHistory, entry) + } else { + removed = true + } + } + + if removed { + hm.history = newHistory + log.Debug().Str("alertID", alertID).Msg("Removed alert from history") + } +} + // ClearAllHistory clears all alert history func (hm *HistoryManager) ClearAllHistory() error { hm.mu.Lock() diff --git a/internal/api/DO_NOT_EDIT_FRONTEND_HERE.md b/internal/api/DO_NOT_EDIT_FRONTEND_HERE.md new file mode 100644 index 000000000..13b163422 --- /dev/null +++ b/internal/api/DO_NOT_EDIT_FRONTEND_HERE.md @@ -0,0 +1,23 @@ +# ⚠️ DO NOT EDIT FRONTEND FILES HERE ⚠️ + +This `frontend-modern` directory is **AUTO-GENERATED** during builds. + +## The REAL frontend location is: +### `/opt/pulse/frontend-modern` + +## Why does this exist? +- Go's `embed` directive cannot access files outside the module +- The build process copies the frontend here for embedding +- This directory is in `.gitignore` and not committed + +## What happens if you edit files here? +- **YOUR CHANGES WILL BE LOST** on the next build +- The Makefile deletes and recreates this directory + +## How to edit frontend code: +1. Edit files in `/opt/pulse/frontend-modern/src/` +2. The dev server (port 7655) will hot-reload +3. When building for production, the Makefile copies it here + +--- +This file exists to prevent confusion. The directory structure is intentional and required by Go's limitations. \ No newline at end of file diff --git a/internal/api/README.md b/internal/api/README.md new file mode 100644 index 000000000..147356494 --- /dev/null +++ b/internal/api/README.md @@ -0,0 +1,22 @@ +# Internal API Package + +This directory contains the API server implementation for Pulse. + +## Important Note About `frontend-modern/` + +The `frontend-modern/` subdirectory that appears here is: +- **AUTO-GENERATED** during builds +- **NOT the source code** - just a build artifact +- **IN .gitignore** - never committed +- **REQUIRED BY GO** - The embed directive needs it here + +### Frontend Development Location +👉 **Edit frontend files at: `/opt/pulse/frontend-modern/src/`** + +### Why This Structure? +Go's `//go:embed` directive has limitations: +1. Cannot use `../` paths to access parent directories +2. Cannot follow symbolic links +3. Must embed files within the Go module + +This is a known Go limitation and our structure works around it. \ No newline at end of file diff --git a/internal/api/alerts.go b/internal/api/alerts.go index c88c2d076..1768d8224 100644 --- a/internal/api/alerts.go +++ b/internal/api/alerts.go @@ -116,6 +116,60 @@ func (h *AlertHandlers) ClearAlertHistory(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]string{"status": "success", "message": "Alert history cleared"}) } +// UnacknowledgeAlert removes acknowledged status from an alert +func (h *AlertHandlers) UnacknowledgeAlert(w http.ResponseWriter, r *http.Request) { + // Extract alert ID from URL path: /api/alerts/{id}/unacknowledge + path := strings.TrimPrefix(r.URL.Path, "/api/alerts/") + + const suffix = "/unacknowledge" + if !strings.HasSuffix(path, suffix) { + log.Error(). + Str("path", r.URL.Path). + Msg("Path does not end with /unacknowledge") + http.Error(w, "Invalid URL", http.StatusBadRequest) + return + } + + // Extract alert ID by removing the suffix + alertID := strings.TrimSuffix(path, suffix) + if alertID == "" { + log.Error(). + Str("path", r.URL.Path). + Msg("Empty alert ID") + http.Error(w, "Invalid URL", http.StatusBadRequest) + return + } + + // Log the unacknowledge attempt + log.Debug(). + Str("alertID", alertID). + Str("path", r.URL.Path). + Msg("Attempting to unacknowledge alert") + + if err := h.monitor.GetAlertManager().UnacknowledgeAlert(alertID); err != nil { + log.Error(). + Err(err). + Str("alertID", alertID). + Msg("Failed to unacknowledge alert") + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + log.Info(). + Str("alertID", alertID). + Msg("Alert unacknowledged successfully") + + // Broadcast updated state to all WebSocket clients + if h.wsHub != nil { + state := h.monitor.GetState() + h.wsHub.BroadcastState(state) + log.Debug().Msg("Broadcasted state after alert unacknowledgment") + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"success": true}) +} + // AcknowledgeAlert acknowledges an alert func (h *AlertHandlers) AcknowledgeAlert(w http.ResponseWriter, r *http.Request) { // Extract alert ID from URL path: /api/alerts/{id}/acknowledge @@ -338,6 +392,8 @@ func (h *AlertHandlers) HandleAlerts(w http.ResponseWriter, r *http.Request) { h.BulkClearAlerts(w, r) case strings.HasSuffix(path, "/acknowledge") && r.Method == http.MethodPost: h.AcknowledgeAlert(w, r) + case strings.HasSuffix(path, "/unacknowledge") && r.Method == http.MethodPost: + h.UnacknowledgeAlert(w, r) case strings.HasSuffix(path, "/clear") && r.Method == http.MethodPost: h.ClearAlert(w, r) default: diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 7bac3cbf7..f04d3d9aa 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -138,7 +138,11 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) { return nil, err } - // Ensure critical defaults are set if missing + // For empty config files ({}), enable alerts by default + // This handles the case where the file exists but is empty + if string(data) == "{}" { + config.Enabled = true + } if config.StorageDefault.Trigger <= 0 { config.StorageDefault.Trigger = 85 config.StorageDefault.Clear = 80 @@ -168,7 +172,10 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) { config.GuestDefaults.NetworkOut = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0} } - log.Info().Str("file", c.alertFile).Msg("Alert configuration loaded") + log.Info(). + Str("file", c.alertFile). + Bool("enabled", config.Enabled). + Msg("Alert configuration loaded") return &config, nil } diff --git a/internal/mock/generator.go b/internal/mock/generator.go index fc426f8b2..682e304a2 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -391,14 +391,33 @@ func generateVM(nodeName string, vmid int, config MockConfig) models.VM { uptime := int64(0) if status == "running" { - cpu = rand.Float64() * 0.95 // 0-95% CPU + // More realistic CPU usage: mostly low with occasional spikes + cpuRand := rand.Float64() + if cpuRand < 0.7 { // 70% of VMs have low CPU + cpu = rand.Float64() * 0.3 // 0-30% + } else if cpuRand < 0.9 { // 20% moderate CPU (0.7-0.9 range) + cpu = 0.3 + rand.Float64()*0.4 // 30-70% + } else { // 10% high CPU (0.9-1.0 range) + cpu = 0.7 + rand.Float64()*0.3 // 70-100% (can trigger alerts at 80%) + } + totalMem := int64((4 + rand.Intn(28)) * 1024 * 1024 * 1024) // 4-32 GB - usedMem := int64(float64(totalMem) * rand.Float64()) + // More realistic memory usage: most VMs use 20-60% memory + var memUsage float64 + memRand := rand.Float64() + if memRand < 0.7 { // 70% typical usage + memUsage = 0.2 + rand.Float64()*0.4 // 20-60% + } else if memRand < 0.9 { // 20% moderate usage + memUsage = 0.6 + rand.Float64()*0.2 // 60-80% + } else { // 10% high memory (can trigger alerts at 85%) + memUsage = 0.8 + rand.Float64()*0.2 // 80-100% + } + usedMem := int64(float64(totalMem) * memUsage) mem = models.Memory{ Total: totalMem, Used: usedMem, Free: totalMem - usedMem, - Usage: float64(usedMem) / float64(totalMem) * 100, + Usage: memUsage * 100, } uptime = int64(3600 * (1 + rand.Intn(720))) // 1-720 hours } @@ -444,14 +463,33 @@ func generateContainer(nodeName string, vmid int, config MockConfig) models.Cont uptime := int64(0) if status == "running" { - cpu = rand.Float64() * 0.5 // Containers typically use less CPU + // More realistic CPU for containers: mostly very low + cpuRand := rand.Float64() + if cpuRand < 0.8 { // 80% of containers have minimal CPU + cpu = rand.Float64() * 0.15 // 0-15% + } else if cpuRand < 0.95 { // 15% moderate CPU (0.8-0.95 range) + cpu = 0.15 + rand.Float64()*0.25 // 15-40% + } else { // 5% higher CPU (0.95-1.0 range) + cpu = 0.4 + rand.Float64()*0.5 // 40-90% (can trigger alerts at 80%) + } + totalMem := int64((512 + rand.Intn(7680)) * 1024 * 1024) // 512 MB - 8 GB - usedMem := int64(float64(totalMem) * rand.Float64()) + // More realistic memory for containers + var memUsage float64 + memRand := rand.Float64() + if memRand < 0.8 { // 80% typical usage + memUsage = 0.3 + rand.Float64()*0.4 // 30-70% + } else if memRand < 0.95 { // 15% moderate usage + memUsage = 0.7 + rand.Float64()*0.15 // 70-85% + } else { // 5% high memory (can trigger alerts at 85%) + memUsage = 0.85 + rand.Float64()*0.15 // 85-100% + } + usedMem := int64(float64(totalMem) * memUsage) mem = models.Memory{ Total: totalMem, Used: usedMem, Free: totalMem - usedMem, - Usage: float64(usedMem) / float64(totalMem) * 100, + Usage: memUsage * 100, } uptime = int64(3600 * (1 + rand.Intn(1440))) // 1-1440 hours (up to 60 days) } @@ -1083,6 +1121,22 @@ func UpdateMetrics(data *models.StateSnapshot, config MockConfig) { vm.CPU += (rand.Float64() - 0.5) * 0.15 vm.CPU = math.Max(0.01, math.Min(0.99, vm.CPU)) + // Update memory with realistic fluctuations + memChange := (rand.Float64() - 0.5) * 0.08 // 8% swing + vm.Memory.Usage += memChange * 100 + vm.Memory.Usage = math.Max(10, math.Min(99, vm.Memory.Usage)) + vm.Memory.Used = int64(float64(vm.Memory.Total) * (vm.Memory.Usage / 100)) + vm.Memory.Free = vm.Memory.Total - vm.Memory.Used + + // Update disk usage very slowly (disks fill up gradually) + if rand.Float64() < 0.1 { // 10% chance to change disk usage + diskChange := (rand.Float64() - 0.4) * 0.5 // Slight bias toward filling + vm.Disk.Usage += diskChange + vm.Disk.Usage = math.Max(10, math.Min(95, vm.Disk.Usage)) + vm.Disk.Used = int64(float64(vm.Disk.Total) * (vm.Disk.Usage / 100)) + vm.Disk.Free = vm.Disk.Total - vm.Disk.Used + } + // Update network/disk I/O with small chance of changing if rand.Float64() < 0.2 { // 20% chance of I/O change vm.NetworkIn = generateRealisticIO("network-in") @@ -1106,6 +1160,22 @@ func UpdateMetrics(data *models.StateSnapshot, config MockConfig) { ct.CPU += (rand.Float64() - 0.5) * 0.05 ct.CPU = math.Max(0.01, math.Min(0.50, ct.CPU)) + // Update memory (containers are generally more stable) + memChange := (rand.Float64() - 0.5) * 0.05 // 5% swing + ct.Memory.Usage += memChange * 100 + ct.Memory.Usage = math.Max(5, math.Min(98, ct.Memory.Usage)) + ct.Memory.Used = int64(float64(ct.Memory.Total) * (ct.Memory.Usage / 100)) + ct.Memory.Free = ct.Memory.Total - ct.Memory.Used + + // Update disk usage very slowly + if rand.Float64() < 0.05 { // 5% chance (containers change disk less) + diskChange := (rand.Float64() - 0.45) * 0.3 // Very slight bias toward filling + ct.Disk.Usage += diskChange + ct.Disk.Usage = math.Max(5, math.Min(90, ct.Disk.Usage)) + ct.Disk.Used = int64(float64(ct.Disk.Total) * (ct.Disk.Usage / 100)) + ct.Disk.Free = ct.Disk.Total - ct.Disk.Used + } + // Update network/disk I/O with small chance of changing if rand.Float64() < 0.15 { // 15% chance of I/O change (containers change less often) ct.NetworkIn = generateRealisticIO("network-in-ct") diff --git a/internal/mock/integration.go b/internal/mock/integration.go index a9950bfa4..60485fd5c 100644 --- a/internal/mock/integration.go +++ b/internal/mock/integration.go @@ -11,7 +11,7 @@ import ( var ( mockData models.StateSnapshot - mockAlerts []models.Alert + // Removed mockAlerts - using real alert manager instead mockAlertHistory []models.Alert mockEnabled bool lastUpdate time.Time @@ -30,7 +30,7 @@ func init() { // Generate initial mock data mockData = GenerateMockData(config) - mockAlerts = GenerateAlerts(mockData.Nodes, mockData.VMs, mockData.Containers) + // Removed fake alert generation - real alert manager will handle this mockAlertHistory = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers) lastUpdate = time.Now() @@ -42,10 +42,7 @@ func init() { for range ticker.C { if mockEnabled { UpdateMetrics(&mockData, config) - // Occasionally regenerate alerts - if time.Now().Unix()%30 == 0 { - mockAlerts = GenerateAlerts(mockData.Nodes, mockData.VMs, mockData.Containers) - } + // Removed fake alert regeneration } } }() @@ -106,8 +103,9 @@ func GetMockState() models.StateSnapshot { return models.StateSnapshot{} } - // Return the current mock data with alerts - mockData.ActiveAlerts = mockAlerts + // Return the current mock data + // Don't override alerts - let the real alert manager handle them + // mockData.ActiveAlerts = mockAlerts return mockData } @@ -117,7 +115,7 @@ func ToggleMockMode(enable bool) { mockEnabled = true config := LoadMockConfig() mockData = GenerateMockData(config) - mockAlerts = GenerateAlerts(mockData.Nodes, mockData.VMs, mockData.Containers) + // Removed fake alert generation mockAlertHistory = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers) log.Info(). Int("history_count", len(mockAlertHistory)). @@ -143,7 +141,7 @@ func SetMockConfig(nodeCount, vmsPerNode, lxcsPerNode int) { } mockData = GenerateMockData(config) - mockAlerts = GenerateAlerts(mockData.Nodes, mockData.VMs, mockData.Containers) + // Removed fake alert generation mockAlertHistory = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers) log.Info(). diff --git a/internal/models/converters.go b/internal/models/converters.go index 9f5c9fde0..002b069b6 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -36,6 +36,7 @@ func (s *State) ToFrontend() StateFrontend { Containers: containers, Storage: storage, PBS: s.PBSInstances, + ActiveAlerts: s.ActiveAlerts, Metrics: make(map[string]any), PVEBackups: s.PVEBackups, Performance: make(map[string]any), diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index 1ba2f8298..40a554d3e 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -106,6 +106,7 @@ type StateFrontend struct { Containers []ContainerFrontend `json:"containers"` Storage []StorageFrontend `json:"storage"` PBS []PBSInstance `json:"pbs"` // Keep as is + ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts Metrics map[string]any `json:"metrics"` // Empty object for now PVEBackups PVEBackups `json:"pveBackups"` // Keep as is Performance map[string]any `json:"performance"` // Empty object for now diff --git a/internal/models/state_snapshot.go b/internal/models/state_snapshot.go index b6f2a10e2..9b578305f 100644 --- a/internal/models/state_snapshot.go +++ b/internal/models/state_snapshot.go @@ -87,6 +87,7 @@ func (s StateSnapshot) ToFrontend() StateFrontend { Containers: containers, Storage: storage, PBS: s.PBSInstances, + ActiveAlerts: s.ActiveAlerts, Metrics: make(map[string]any), PVEBackups: s.PVEBackups, Performance: make(map[string]any), diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 6c176f1df..8d6c2f90f 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -877,6 +877,15 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie // Update state again with corrected disk metrics m.state.UpdateNodesForInstance(instanceName, modelNodes) + // Clean up alerts for nodes that no longer exist + // Get all nodes from the global state (includes all instances) + existingNodes := make(map[string]bool) + allState := m.state.GetSnapshot() + for _, node := range allState.Nodes { + existingNodes[node.Name] = true + } + m.alertManager.CleanupAlertsForNodes(existingNodes) + // Update cluster endpoint online status if this is a cluster if instanceCfg.IsCluster && len(instanceCfg.ClusterEndpoints) > 0 { // Create a map of online nodes from our polling results @@ -2306,7 +2315,29 @@ func (m *Monitor) pollPBSInstance(ctx context.Context, instanceName string, clie func (m *Monitor) GetState() models.StateSnapshot { // Check if mock mode is enabled if mock.IsMockEnabled() { - return mock.GetMockState() + mockState := mock.GetMockState() + // Include real alerts from the alert manager + activeAlerts := m.alertManager.GetActiveAlerts() + log.Debug().Int("alertCount", len(activeAlerts)).Msg("GetState: fetching alerts for mock state") + modelAlerts := make([]models.Alert, 0, len(activeAlerts)) + for _, alert := range activeAlerts { + modelAlerts = append(modelAlerts, 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, + }) + } + mockState.ActiveAlerts = modelAlerts + return mockState } return m.state.GetSnapshot() } @@ -2902,7 +2933,9 @@ func (m *Monitor) pollPBSBackups(ctx context.Context, instanceName string, clien // checkMockAlerts checks alerts for mock data func (m *Monitor) checkMockAlerts() { + log.Info().Bool("mockEnabled", mock.IsMockEnabled()).Msg("checkMockAlerts called") if !mock.IsMockEnabled() { + log.Info().Msg("Mock mode not enabled, skipping mock alert check") return } @@ -2912,8 +2945,27 @@ func (m *Monitor) checkMockAlerts() { log.Info(). Int("vms", len(state.VMs)). Int("containers", len(state.Containers)). + Int("nodes", len(state.Nodes)). 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 + } + // Also add any real nodes from the global state + allState := m.state.GetSnapshot() + for _, node := range allState.Nodes { + existingNodes[node.Name] = true + } + log.Info(). + Int("mockNodes", len(state.Nodes)). + Int("stateNodes", len(allState.Nodes)). + Int("totalNodes", len(existingNodes)). + Msg("Collecting nodes for alert cleanup") + m.alertManager.CleanupAlertsForNodes(existingNodes) + // Check alerts for each VM for _, vm := range state.VMs { m.alertManager.CheckGuest(vm, "mock") diff --git a/scripts/build-release.sh b/scripts/build-release.sh index a213caf49..346aaf8fb 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -27,11 +27,11 @@ npm ci npm run build cd .. -# Copy frontend to api directory for embedding -echo "Copying frontend for embedding..." -sudo rm -rf internal/api/frontend-modern -sleep 1 # Give filesystem time to sync -cp -r frontend-modern internal/api/ +# Copy frontend dist for embedding (required for Go embed) +echo "Copying frontend dist for embedding..." +rm -rf internal/api/frontend-modern +mkdir -p internal/api/frontend-modern +cp -r frontend-modern/dist internal/api/frontend-modern/ # Build for different architectures declare -A builds=( diff --git a/scripts/hot-dev.sh b/scripts/hot-dev.sh index 25a5ddce3..b1fe207b5 100755 --- a/scripts/hot-dev.sh +++ b/scripts/hot-dev.sh @@ -98,12 +98,15 @@ echo "Starting backend on port 7656..." cd /opt/pulse echo "Building backend (API-only mode for development)..." # Always rebuild in dev mode to ensure we have the right build +echo "Building with PULSE_MOCK_MODE=${PULSE_MOCK_MODE}" go build -tags "dev" -o pulse ./cmd/pulse # Export all PULSE_MOCK_* variables for the backend export PULSE_MOCK_MODE PULSE_MOCK_NODES PULSE_MOCK_VMS_PER_NODE PULSE_MOCK_LXCS_PER_NODE PULSE_MOCK_RANDOM_METRICS PULSE_MOCK_STOPPED_PERCENT # Export auth variables if set export PULSE_AUTH_USER PULSE_AUTH_PASS -PORT=7656 ./pulse & +# Export PORT as well +export PORT=7656 +./pulse & BACKEND_PID=$! # Wait for backend to start