From 11d7f4fd4e4ddbcce3e6ef7e91f37a29930ced43 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 20 Nov 2025 17:54:20 +0000 Subject: [PATCH] Add Apprise test support for notifications Related to #584 --- frontend-modern/src/api/notifications.ts | 4 +- frontend-modern/src/pages/Alerts.tsx | 88 +++++++++++++--- internal/api/notifications.go | 52 ++++++---- internal/notifications/notifications.go | 100 ++++++++++++------- internal/notifications/notifications_test.go | 42 ++++++++ 5 files changed, 216 insertions(+), 70 deletions(-) diff --git a/frontend-modern/src/api/notifications.ts b/frontend-modern/src/api/notifications.ts index 3f5c32447..9ea531d1e 100644 --- a/frontend-modern/src/api/notifications.ts +++ b/frontend-modern/src/api/notifications.ts @@ -72,7 +72,7 @@ export interface AppriseConfig { export interface NotificationTestRequest { type: 'email' | 'webhook' | 'apprise'; - config?: Record; // Backend expects different format than frontend types + config?: Record | AppriseConfig; // Backend expects different format than frontend types webhookId?: string; } @@ -169,7 +169,7 @@ export class NotificationsAPI { static async testNotification( request: NotificationTestRequest, ): Promise<{ success: boolean; message?: string }> { - const body: { method: string; config?: Record; webhookId?: string } = { + const body: { method: string; config?: Record | AppriseConfig; webhookId?: string } = { method: request.type, }; diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 2440df085..c4e48737d 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -2650,11 +2650,29 @@ interface DestinationsTabProps { function DestinationsTab(props: DestinationsTabProps) { const [webhooks, setWebhooks] = createSignal([]); const [testingEmail, setTestingEmail] = createSignal(false); + const [testingApprise, setTestingApprise] = createSignal(false); const [testingWebhook, setTestingWebhook] = createSignal(null); const appriseState = () => props.appriseConfig(); const updateApprise = (partial: Partial) => { props.setAppriseConfig({ ...props.appriseConfig(), ...partial }); }; + const buildAppriseRequestConfig = (): AppriseConfig => { + const config = appriseState(); + const serverUrl = (config.serverUrl || '').trim(); + const apiKeyHeader = (config.apiKeyHeader || '').trim() || 'X-API-KEY'; + return { + enabled: config.enabled, + mode: config.mode, + targets: parseAppriseTargets(config.targetsText), + cliPath: config.cliPath?.trim() || 'apprise', + timeoutSeconds: config.timeoutSeconds, + serverUrl, + configKey: config.configKey.trim(), + apiKey: config.apiKey, + apiKeyHeader, + skipTlsVerify: config.skipTlsVerify, + }; + }; // Load webhooks on mount (email config is now loaded in parent) onMount(async () => { try { @@ -2689,6 +2707,39 @@ function DestinationsTab(props: DestinationsTabProps) { } }; + const testApprise = async () => { + setTestingApprise(true); + try { + const config = buildAppriseRequestConfig(); + + if (!config.enabled) { + throw new Error('Enable Apprise notifications before sending a test.'); + } + + const targets = config.targets || []; + if (config.mode === 'cli' && targets.length === 0) { + throw new Error('Add at least one Apprise target to test CLI delivery.'); + } + if (config.mode === 'http' && !config.serverUrl) { + throw new Error('Enter an Apprise API server URL to test API delivery.'); + } + + await NotificationsAPI.testNotification({ + type: 'apprise', + config, + }); + showSuccess('Test Apprise notification sent successfully!'); + } catch (err) { + logger.error('Failed to send test Apprise notification:', err); + showError( + 'Failed to send test Apprise notification', + err instanceof Error ? err.message : 'Unknown error', + ); + } finally { + setTestingApprise(false); + } + }; + const testWebhook = async (webhookId: string, webhookData?: Omit) => { setTestingWebhook(webhookId); try { @@ -2750,21 +2801,30 @@ function DestinationsTab(props: DestinationsTabProps) { { - updateApprise({ enabled: e.currentTarget.checked }); - props.setHasUnsavedChanges(true); - }} - containerClass="sm:self-start" - label={ - - {appriseState().enabled ? 'Enabled' : 'Disabled'} - - } - /> +
+ { + updateApprise({ enabled: e.currentTarget.checked }); + props.setHasUnsavedChanges(true); + }} + containerClass="" + label={ + + {appriseState().enabled ? 'Enabled' : 'Disabled'} + + } + /> + +
} class="min-w-0" bodyClass="space-y-4" diff --git a/internal/api/notifications.go b/internal/api/notifications.go index d04eb5f06..cb519249c 100644 --- a/internal/api/notifications.go +++ b/internal/api/notifications.go @@ -330,10 +330,10 @@ func (h *NotificationHandlers) TestNotification(w http.ResponseWriter, r *http.R Msg("Test notification request received") var req struct { - Method string `json:"method"` // "email" or "webhook" - Type string `json:"type"` // Alternative field name used by frontend - Config *notifications.EmailConfig `json:"config,omitempty"` // Optional config for testing - WebhookID string `json:"webhookId,omitempty"` // For webhook testing + Method string `json:"method"` // "email", "webhook", or "apprise" + Type string `json:"type"` // Alternative field name used by frontend + Config json.RawMessage `json:"config,omitempty"` // Optional config for testing (email or apprise) + WebhookID string `json:"webhookId,omitempty"` // For webhook testing } if err := json.Unmarshal(body, &req); err != nil { @@ -387,24 +387,40 @@ func (h *NotificationHandlers) TestNotification(w http.ResponseWriter, r *http.R http.Error(w, err.Error(), http.StatusBadRequest) return } - } else if req.Method == "email" && req.Config != nil { - // If config is provided, use it for testing (without saving) + } else if req.Method == "email" && len(req.Config) > 0 { + var emailConfig notifications.EmailConfig + if err := json.Unmarshal(req.Config, &emailConfig); err != nil { + http.Error(w, fmt.Sprintf("Invalid email config: %v", err), http.StatusBadRequest) + return + } + // If password is empty, use the saved password - if req.Config.Password == "" { + if emailConfig.Password == "" { savedConfig := h.monitor.GetNotificationManager().GetEmailConfig() - req.Config.Password = savedConfig.Password + emailConfig.Password = savedConfig.Password } log.Info(). - Bool("enabled", req.Config.Enabled). - Str("smtp", req.Config.SMTPHost). - Str("from", req.Config.From). - Int("toCount", len(req.Config.To)). - Strs("to", req.Config.To). - Bool("hasPassword", req.Config.Password != ""). + Bool("enabled", emailConfig.Enabled). + Str("smtp", emailConfig.SMTPHost). + Str("from", emailConfig.From). + Int("toCount", len(emailConfig.To)). + Strs("to", emailConfig.To). + Bool("hasPassword", emailConfig.Password != ""). Msg("Testing email with provided config") - if err := h.monitor.GetNotificationManager().SendTestNotificationWithConfig(req.Method, req.Config, nodeInfo); err != nil { + if err := h.monitor.GetNotificationManager().SendTestNotificationWithConfig(req.Method, &emailConfig, nodeInfo); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } else if req.Method == "apprise" && len(req.Config) > 0 { + var appriseConfig notifications.AppriseConfig + if err := json.Unmarshal(req.Config, &appriseConfig); err != nil { + http.Error(w, fmt.Sprintf("Invalid Apprise config: %v", err), http.StatusBadRequest) + return + } + + if err := h.monitor.GetNotificationManager().SendTestAppriseWithConfig(appriseConfig); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -631,10 +647,10 @@ func (h *NotificationHandlers) GetNotificationHealth(w http.ResponseWriter, r *h webhooks := nm.GetWebhooks() health := map[string]interface{}{ - "queue": queueStats, + "queue": queueStats, "email": map[string]interface{}{ - "enabled": emailCfg.Enabled, - "configured": emailCfg.SMTPHost != "", + "enabled": emailCfg.Enabled, + "configured": emailCfg.SMTPHost != "", }, "webhooks": map[string]interface{}{ "total": len(webhooks), diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 26e54a3a9..c2fd2768c 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -105,29 +105,29 @@ type webhookRateLimit struct { // NotificationManager handles sending notifications type NotificationManager struct { - mu sync.RWMutex - emailConfig EmailConfig - emailManager *EnhancedEmailManager // Shared email manager for rate limiting - webhooks []WebhookConfig - appriseConfig AppriseConfig - enabled bool - cooldown time.Duration - lastNotified map[string]notificationRecord - groupWindow time.Duration - pendingAlerts []*alerts.Alert - groupTimer *time.Timer - groupByNode bool - publicURL string // Full URL to access Pulse - groupByGuest bool - webhookHistory []WebhookDelivery // Keep last 100 webhook deliveries for debugging - webhookRateLimits map[string]*webhookRateLimit // Track rate limits per webhook URL - webhookRateMu sync.Mutex // Separate mutex for webhook rate limiting - appriseExec appriseExecFunc - queue *NotificationQueue // Persistent notification queue - webhookClient *http.Client // Shared HTTP client for webhooks - stopCleanup chan struct{} // Signal to stop cleanup goroutine - allowedPrivateNets []*net.IPNet // Parsed CIDR ranges allowed for private webhook targets - allowedPrivateMu sync.RWMutex // Protects allowedPrivateNets + mu sync.RWMutex + emailConfig EmailConfig + emailManager *EnhancedEmailManager // Shared email manager for rate limiting + webhooks []WebhookConfig + appriseConfig AppriseConfig + enabled bool + cooldown time.Duration + lastNotified map[string]notificationRecord + groupWindow time.Duration + pendingAlerts []*alerts.Alert + groupTimer *time.Timer + groupByNode bool + publicURL string // Full URL to access Pulse + groupByGuest bool + webhookHistory []WebhookDelivery // Keep last 100 webhook deliveries for debugging + webhookRateLimits map[string]*webhookRateLimit // Track rate limits per webhook URL + webhookRateMu sync.Mutex // Separate mutex for webhook rate limiting + appriseExec appriseExecFunc + queue *NotificationQueue // Persistent notification queue + webhookClient *http.Client // Shared HTTP client for webhooks + stopCleanup chan struct{} // Signal to stop cleanup goroutine + allowedPrivateNets []*net.IPNet // Parsed CIDR ranges allowed for private webhook targets + allowedPrivateMu sync.RWMutex // Protects allowedPrivateNets } type appriseExecFunc func(ctx context.Context, path string, args []string) ([]byte, error) @@ -2066,14 +2066,14 @@ func (n *NotificationManager) ValidateWebhookURL(webhookURL string) error { func isPrivateIP(ip net.IP) bool { // Private IPv4 ranges privateRanges := []string{ - "10.0.0.0/8", // RFC1918 - "172.16.0.0/12", // RFC1918 - "192.168.0.0/16", // RFC1918 - "127.0.0.0/8", // Loopback - "169.254.0.0/16", // Link-local - "::1/128", // IPv6 loopback - "fe80::/10", // IPv6 link-local - "fc00::/7", // IPv6 unique local + "10.0.0.0/8", // RFC1918 + "172.16.0.0/12", // RFC1918 + "192.168.0.0/16", // RFC1918 + "127.0.0.0/8", // Loopback + "169.254.0.0/16", // Link-local + "::1/128", // IPv6 loopback + "fe80::/10", // IPv6 link-local + "fc00::/7", // IPv6 unique local } for _, cidr := range privateRanges { @@ -2263,9 +2263,8 @@ func (n *NotificationManager) groupAlerts(alertList []*alerts.Alert) map[string] return groups } -// SendTestNotification sends a test notification -func (n *NotificationManager) SendTestNotification(method string) error { - testAlert := &alerts.Alert{ +func buildNotificationTestAlert() *alerts.Alert { + return &alerts.Alert{ ID: "test-alert", Type: "cpu", Level: "warning", @@ -2282,6 +2281,11 @@ func (n *NotificationManager) SendTestNotification(method string) error { "resourceType": "vm", }, } +} + +// SendTestNotification sends a test notification +func (n *NotificationManager) SendTestNotification(method string) error { + testAlert := buildNotificationTestAlert() switch method { case "email": @@ -2336,13 +2340,37 @@ func (n *NotificationManager) SendTestNotification(method string) error { } // Use sendGroupedApprise with a single test alert - n.sendGroupedApprise(appriseConfig, []*alerts.Alert{testAlert}) - return nil + return n.sendGroupedApprise(appriseConfig, []*alerts.Alert{testAlert}) default: return fmt.Errorf("unknown notification method: %s", method) } } +// SendTestAppriseWithConfig sends a test Apprise notification using provided config +func (n *NotificationManager) SendTestAppriseWithConfig(config AppriseConfig) error { + cfg := NormalizeAppriseConfig(config) + + log.Info(). + Bool("enabled", cfg.Enabled). + Str("mode", string(cfg.Mode)). + Int("targetCount", len(cfg.Targets)). + Str("serverURL", cfg.ServerURL). + Msg("Testing Apprise notification with provided config") + + if !cfg.Enabled { + switch cfg.Mode { + case AppriseModeCLI: + return fmt.Errorf("apprise notifications are not enabled in the provided configuration: at least one target is required for CLI mode") + case AppriseModeHTTP: + return fmt.Errorf("apprise notifications are not enabled in the provided configuration: server URL is required for API mode") + default: + return fmt.Errorf("apprise notifications are not enabled in the provided configuration") + } + } + + return n.sendGroupedApprise(cfg, []*alerts.Alert{buildNotificationTestAlert()}) +} + // SendTestWebhook sends a test notification to a specific webhook func (n *NotificationManager) SendTestWebhook(webhook WebhookConfig) error { // Create a test alert for webhook testing with realistic values diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index cf1993763..393e32b11 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -570,6 +570,48 @@ func TestSendTestNotificationApprise(t *testing.T) { } } +func TestSendTestAppriseWithConfig(t *testing.T) { + nm := NewNotificationManager("") + defer nm.Stop() + + // Disabled config should fail + err := nm.SendTestAppriseWithConfig(AppriseConfig{ + Enabled: false, + Targets: []string{"discord://token"}, + }) + if err == nil || !strings.Contains(err.Error(), "not enabled") { + t.Fatalf("expected not enabled error, got %v", err) + } + + done := make(chan struct{}) + var cliPath string + + nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) { + cliPath = path + close(done) + return []byte("ok"), nil + } + + err = nm.SendTestAppriseWithConfig(AppriseConfig{ + Enabled: true, + Mode: AppriseModeCLI, + Targets: []string{"discord://token"}, + }) + if err != nil { + t.Fatalf("expected no error for valid Apprise config, got %v", err) + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("timeout waiting for Apprise test execution") + } + + if cliPath != "apprise" { + t.Fatalf("expected default CLI path 'apprise', got %q", cliPath) + } +} + func TestSendTestNotificationAppriseHTTP(t *testing.T) { t.Setenv("PULSE_DATA_DIR", t.TempDir())