diff --git a/frontend-modern/src/components/Backups/UnifiedBackups.tsx b/frontend-modern/src/components/Backups/UnifiedBackups.tsx index b14d165b3..e71eb7995 100644 --- a/frontend-modern/src/components/Backups/UnifiedBackups.tsx +++ b/frontend-modern/src/components/Backups/UnifiedBackups.tsx @@ -133,7 +133,7 @@ const UnifiedBackups: Component = () => { backupType: 'remote', vmid: parseInt(backup.vmid) || 0, name: backup.comment || '', - type: backup.backupType === 'vm' ? 'VM' : 'LXC', + type: (backup.backupType === 'vm' || backup.backupType === 'VM') ? 'VM' : 'LXC', node: backup.instance || 'PBS', backupTime: backupTimeSeconds, backupName: backupName, diff --git a/internal/api/alerts.go b/internal/api/alerts.go index b5d8d3ffe..7776cded5 100644 --- a/internal/api/alerts.go +++ b/internal/api/alerts.go @@ -118,7 +118,7 @@ func (h *AlertHandlers) AcknowledgeAlert(w http.ResponseWriter, r *http.Request) } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) + json.NewEncoder(w).Encode(map[string]bool{"success": true}) } // ClearAlert manually clears an alert @@ -134,7 +134,7 @@ func (h *AlertHandlers) ClearAlert(w http.ResponseWriter, r *http.Request) { h.monitor.GetAlertManager().ClearAlert(alertID) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) + json.NewEncoder(w).Encode(map[string]bool{"success": true}) } // HandleAlerts routes alert requests to appropriate handlers diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 7973e1f77..c1df87a59 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -1076,12 +1076,15 @@ func (m *Monitor) pollStorage(ctx context.Context, instanceName string, client P // Use appropriate node name nodeID := node.Node + storageID := fmt.Sprintf("%s-%s-%s", instanceName, nodeID, storage.Storage) if shared { nodeID = "shared" + // Use a consistent ID for shared storage across all instances + storageID = fmt.Sprintf("shared-%s", storage.Storage) } modelStorage := models.Storage{ - ID: fmt.Sprintf("%s-%s-%s", instanceName, nodeID, storage.Storage), + ID: storageID, Name: storage.Storage, Node: nodeID, Instance: instanceName, @@ -1500,9 +1503,9 @@ func (m *Monitor) pollStorageBackups(ctx context.Context, instanceName string, c backupType = "vztmpl" } else if content.Content == "iso" { backupType = "iso" - } else if strings.Contains(content.Volid, "qemu") { + } else if strings.Contains(content.Volid, "qemu") || strings.Contains(content.Volid, "/vm/") { backupType = "qemu" - } else if strings.Contains(content.Volid, "lxc") { + } else if strings.Contains(content.Volid, "lxc") || strings.Contains(content.Volid, "/ct/") { backupType = "lxc" } diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index c2be9f3e8..b8e9be8c5 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -8,6 +8,7 @@ import ( "net/smtp" "strings" "sync" + "text/template" "time" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" @@ -69,6 +70,7 @@ type WebhookConfig struct { Method string `json:"method"` Headers map[string]string `json:"headers"` Enabled bool `json:"enabled"` + Service string `json:"service"` // discord, slack, teams, etc. } // NewNotificationManager creates a new notification manager @@ -353,23 +355,65 @@ func (n *NotificationManager) sendEmailWithContent(subject, body string, config // sendGroupedWebhook sends a grouped webhook notification func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertList []*alerts.Alert) { - // Create webhook payload - payload := map[string]interface{}{ - "alerts": alertList, - "count": len(alertList), - "timestamp": time.Now().Unix(), - "source": "pulse-monitoring", - "grouped": true, - } + var jsonData []byte + var err error - jsonData, err := json.Marshal(payload) - if err != nil { - log.Error(). - Err(err). - Str("webhook", webhook.Name). - Int("alertCount", len(alertList)). - Msg("Failed to marshal grouped webhook payload") - return + // For Discord, send individual embeds for each alert + if webhook.Service == "discord" && len(alertList) > 0 { + // For simplicity, send the first alert with a note about others + // Discord webhooks work better with single embeds + alert := alertList[0] + + // Convert to enhanced webhook to use template + enhanced := EnhancedWebhookConfig{ + WebhookConfig: webhook, + Service: "discord", + } + + // Get Discord template + templates := GetWebhookTemplates() + for _, tmpl := range templates { + if tmpl.Service == "discord" { + enhanced.PayloadTemplate = tmpl.PayloadTemplate + break + } + } + + // Modify message if multiple alerts + if len(alertList) > 1 { + alert.Message = fmt.Sprintf("%s (and %d more alerts)", alert.Message, len(alertList)-1) + } + + // Prepare data and generate payload + data := n.prepareWebhookData(alert, nil) + jsonData, err = n.generatePayloadFromTemplate(enhanced.PayloadTemplate, data) + if err != nil { + log.Error(). + Err(err). + Str("webhook", webhook.Name). + Int("alertCount", len(alertList)). + Msg("Failed to generate Discord payload for grouped alerts") + return + } + } else { + // Use generic payload for other services + payload := map[string]interface{}{ + "alerts": alertList, + "count": len(alertList), + "timestamp": time.Now().Unix(), + "source": "pulse-monitoring", + "grouped": true, + } + + jsonData, err = json.Marshal(payload) + if err != nil { + log.Error(). + Err(err). + Str("webhook", webhook.Name). + Int("alertCount", len(alertList)). + Msg("Failed to marshal grouped webhook payload") + return + } } // Send using same request logic @@ -434,27 +478,121 @@ func (n *NotificationManager) sendWebhookRequest(webhook WebhookConfig, jsonData // sendWebhook sends a webhook notification func (n *NotificationManager) sendWebhook(webhook WebhookConfig, alert *alerts.Alert) { - // Create webhook payload - payload := map[string]interface{}{ - "alert": alert, - "timestamp": time.Now().Unix(), - "source": "pulse-monitoring", - } + var jsonData []byte + var err error - jsonData, err := json.Marshal(payload) - if err != nil { - log.Error(). - Err(err). - Str("webhook", webhook.Name). - Str("alertID", alert.ID). - Msg("Failed to marshal webhook payload") - return + // Check if this is a Discord webhook and use the proper template + if webhook.Service == "discord" { + // Convert to enhanced webhook to use template + enhanced := EnhancedWebhookConfig{ + WebhookConfig: webhook, + Service: "discord", + } + + // Get Discord template + templates := GetWebhookTemplates() + for _, tmpl := range templates { + if tmpl.Service == "discord" { + enhanced.PayloadTemplate = tmpl.PayloadTemplate + break + } + } + + // Prepare data and generate payload + data := n.prepareWebhookData(alert, nil) + jsonData, err = n.generatePayloadFromTemplate(enhanced.PayloadTemplate, data) + if err != nil { + log.Error(). + Err(err). + Str("webhook", webhook.Name). + Str("alertID", alert.ID). + Msg("Failed to generate Discord payload") + return + } + } else { + // Use generic payload for other services + payload := map[string]interface{}{ + "alert": alert, + "timestamp": time.Now().Unix(), + "source": "pulse-monitoring", + } + + jsonData, err = json.Marshal(payload) + if err != nil { + log.Error(). + Err(err). + Str("webhook", webhook.Name). + Str("alertID", alert.ID). + Msg("Failed to marshal webhook payload") + return + } } // Send using common request logic n.sendWebhookRequest(webhook, jsonData, fmt.Sprintf("alert-%s", alert.ID)) } +// prepareWebhookData prepares data for template rendering +func (n *NotificationManager) prepareWebhookData(alert *alerts.Alert, customFields map[string]interface{}) WebhookPayloadData { + duration := time.Since(alert.StartTime) + + return WebhookPayloadData{ + ID: alert.ID, + Level: string(alert.Level), + Type: alert.Type, + ResourceName: alert.ResourceName, + ResourceID: alert.ResourceID, + Node: alert.Node, + Instance: alert.Instance, + Message: alert.Message, + Value: alert.Value, + Threshold: alert.Threshold, + StartTime: alert.StartTime.Format(time.RFC3339), + Duration: formatWebhookDuration(duration), + Timestamp: time.Now().Format(time.RFC3339), + CustomFields: customFields, + AlertCount: 1, + } +} + +// generatePayloadFromTemplate renders the payload using Go templates +func (n *NotificationManager) generatePayloadFromTemplate(templateStr string, data WebhookPayloadData) ([]byte, error) { + // Create template with helper functions + funcMap := template.FuncMap{ + "title": strings.Title, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "printf": fmt.Sprintf, + } + + tmpl, err := template.New("webhook").Funcs(funcMap).Parse(templateStr) + if err != nil { + return nil, fmt.Errorf("invalid template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("template execution failed: %w", err) + } + + return buf.Bytes(), nil +} + +// formatWebhookDuration formats a duration in a human-readable way +func formatWebhookDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } else if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } else if d < 24*time.Hour { + return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) + } else { + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + return fmt.Sprintf("%dd %dh", days, hours) + } +} + // groupAlerts groups alerts based on configuration func (n *NotificationManager) groupAlerts(alertList []*alerts.Alert) map[string][]*alerts.Alert { groups := make(map[string][]*alerts.Alert) diff --git a/internal/notifications/webhook_enhanced.go b/internal/notifications/webhook_enhanced.go index f67ab9656..302944876 100644 --- a/internal/notifications/webhook_enhanced.go +++ b/internal/notifications/webhook_enhanced.go @@ -2,11 +2,8 @@ package notifications import ( "bytes" - "encoding/json" "fmt" "net/http" - "strings" - "text/template" "time" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" @@ -85,6 +82,8 @@ func (n *NotificationManager) SendEnhancedWebhook(webhook EnhancedWebhookConfig, } // prepareWebhookData prepares data for template rendering +// NOTE: This function is now defined in notifications.go to be shared +/* func (n *NotificationManager) prepareWebhookData(alert *alerts.Alert, customFields map[string]interface{}) WebhookPayloadData { duration := time.Since(alert.StartTime) @@ -106,8 +105,11 @@ func (n *NotificationManager) prepareWebhookData(alert *alerts.Alert, customFiel AlertCount: 1, } } +*/ // generatePayloadFromTemplate renders the payload using Go templates +// NOTE: This function is now defined in notifications.go to be shared +/* func (n *NotificationManager) generatePayloadFromTemplate(templateStr string, data WebhookPayloadData) ([]byte, error) { // Create template with helper functions funcMap := template.FuncMap{ @@ -135,6 +137,7 @@ func (n *NotificationManager) generatePayloadFromTemplate(templateStr string, da return buf.Bytes(), nil } +*/ // shouldSendWebhook checks if alert matches webhook filter rules func (n *NotificationManager) shouldSendWebhook(webhook EnhancedWebhookConfig, alert *alerts.Alert) bool { @@ -300,6 +303,8 @@ func (n *NotificationManager) sendWebhookOnce(webhook EnhancedWebhookConfig, pay } // formatWebhookDuration formats a duration in a human-readable way +// NOTE: This function is now defined in notifications.go to be shared +/* func formatWebhookDuration(d time.Duration) string { if d < time.Minute { return fmt.Sprintf("%ds", int(d.Seconds())) @@ -313,6 +318,7 @@ func formatWebhookDuration(d time.Duration) string { return fmt.Sprintf("%dd %dh", days, hours) } } +*/ // TestEnhancedWebhook tests a webhook with a specific payload func (n *NotificationManager) TestEnhancedWebhook(webhook EnhancedWebhookConfig) (int, string, error) {