Add Apprise test support for notifications

Related to #584
This commit is contained in:
rcourtman
2025-11-20 17:54:20 +00:00
parent 1f7a880808
commit 11d7f4fd4e
5 changed files with 216 additions and 70 deletions

View File

@@ -72,7 +72,7 @@ export interface AppriseConfig {
export interface NotificationTestRequest {
type: 'email' | 'webhook' | 'apprise';
config?: Record<string, unknown>; // Backend expects different format than frontend types
config?: Record<string, unknown> | 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<string, unknown>; webhookId?: string } = {
const body: { method: string; config?: Record<string, unknown> | AppriseConfig; webhookId?: string } = {
method: request.type,
};

View File

@@ -2650,11 +2650,29 @@ interface DestinationsTabProps {
function DestinationsTab(props: DestinationsTabProps) {
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
const [testingEmail, setTestingEmail] = createSignal(false);
const [testingApprise, setTestingApprise] = createSignal(false);
const [testingWebhook, setTestingWebhook] = createSignal<string | null>(null);
const appriseState = () => props.appriseConfig();
const updateApprise = (partial: Partial<UIAppriseConfig>) => {
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<Webhook, 'id'>) => {
setTestingWebhook(webhookId);
try {
@@ -2750,21 +2801,30 @@ function DestinationsTab(props: DestinationsTabProps) {
<SettingsPanel
title="Apprise notifications"
description="Relay grouped alerts through the Apprise CLI."
description="Relay grouped alerts through Apprise via CLI or remote API."
action={
<Toggle
checked={appriseState().enabled}
onChange={(e) => {
updateApprise({ enabled: e.currentTarget.checked });
props.setHasUnsavedChanges(true);
}}
containerClass="sm:self-start"
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
{appriseState().enabled ? 'Enabled' : 'Disabled'}
</span>
}
/>
<div class="flex items-center gap-3 sm:self-start">
<Toggle
checked={appriseState().enabled}
onChange={(e) => {
updateApprise({ enabled: e.currentTarget.checked });
props.setHasUnsavedChanges(true);
}}
containerClass=""
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
{appriseState().enabled ? 'Enabled' : 'Disabled'}
</span>
}
/>
<button
class="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
disabled={!appriseState().enabled || testingApprise()}
onClick={testApprise}
>
{testingApprise() ? 'Testing...' : 'Send test'}
</button>
</div>
}
class="min-w-0"
bodyClass="space-y-4"

View File

@@ -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),

View File

@@ -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

View File

@@ -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())