From e0e454abfcbf2654639f462a71f78423bf30f606 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 18 Jul 2026 12:14:12 +0100 Subject: [PATCH] Expose Patrol finding notification settings in the API and Patrol settings page Completes #1369. The delivery path landed in 23695681a with config-only gating; this surfaces the two fields so operators can turn finding notifications off or restrict them to critical findings without editing config by hand. The AI settings GET response and update request carry patrol_finding_notifications_enabled and patrol_finding_notify_min_severity, with the same warning-or-critical validation the alert-trigger severity field uses. The Patrol settings page gains a Notifications card between Triggers and Model readiness, mirroring the alert-trigger toggle-plus-severity pattern, with the severity select shown only while notifications are enabled. Exercised end to end against a scratch backend in mock mode. A severity change and an explicit opt-out both survive save, reload, and the GET round trip, and the opt-out hides the severity select immediately. --- .../src/components/Settings/AISettings.tsx | 43 +++++++++ .../components/Settings/useAISettingsState.ts | 19 ++++ frontend-modern/src/types/ai.ts | 4 + internal/api/ai_handlers.go | 95 ++++++++++++------- .../api/ai_handlers_finding_notify_test.go | 82 ++++++++++++++++ 5 files changed, 207 insertions(+), 36 deletions(-) create mode 100644 internal/api/ai_handlers_finding_notify_test.go diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index ab40d4a52..de7e5ee56 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -252,6 +252,49 @@ const PatrolSettingsContent: Component<{ state: ReturnType +
+

Notifications

+
+
+
+

Finding notifications

+

+ Send new findings to your alert notification channels (email, webhooks). +

+
+ + props.state.setForm('patrolFindingNotifications', event.currentTarget.checked) + } + disabled={props.state.saving()} + ariaLabel="Enable finding notifications" + /> +
+ + + + props.state.setForm( + 'patrolFindingNotifyMinSeverity', + event.currentTarget.value === 'critical' ? 'critical' : 'warning', + ) + } + disabled={props.state.saving()} + fieldClass="mt-3 gap-2" + labelClass="text-xs font-medium text-muted" + selectBaseClass="w-full min-h-10 rounded-md border border-border bg-surface px-3 py-2 text-sm" + > + + + + +
+
+

Model readiness

diff --git a/frontend-modern/src/components/Settings/useAISettingsState.ts b/frontend-modern/src/components/Settings/useAISettingsState.ts index b9a874ad1..54aabd6cd 100644 --- a/frontend-modern/src/components/Settings/useAISettingsState.ts +++ b/frontend-modern/src/components/Settings/useAISettingsState.ts @@ -314,6 +314,8 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => { patrolAlertTriggers: true, patrolAnomalyTriggers: true, patrolAlertTriggerMinSeverity: 'critical' as 'warning' | 'critical', + patrolFindingNotifications: true, + patrolFindingNotifyMinSeverity: 'warning' as 'warning' | 'critical', patrolAutoFix: false, anthropicApiKey: '', openaiApiKey: '', @@ -410,6 +412,8 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => { patrolAlertTriggers: true, patrolAnomalyTriggers: true, patrolAlertTriggerMinSeverity: 'critical', + patrolFindingNotifications: true, + patrolFindingNotifyMinSeverity: 'warning', patrolAutoFix: false, anthropicApiKey: '', openaiApiKey: '', @@ -455,6 +459,9 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => { patrolAnomalyTriggers: data.patrol_anomaly_triggers_enabled ?? legacyEventTriggersEnabled, patrolAlertTriggerMinSeverity: data.patrol_alert_trigger_min_severity === 'warning' ? 'warning' : 'critical', + patrolFindingNotifications: data.patrol_finding_notifications_enabled !== false, + patrolFindingNotifyMinSeverity: + data.patrol_finding_notify_min_severity === 'critical' ? 'critical' : 'warning', patrolAutoFix: data.patrol_auto_fix || false, anthropicApiKey: '', openaiApiKey: '', @@ -974,6 +981,18 @@ export const useAISettingsState = (options: AISettingsStateOptions = {}) => { ) { payload.patrol_alert_trigger_min_severity = form.patrolAlertTriggerMinSeverity; } + if ( + form.patrolFindingNotifications !== + (settings()?.patrol_finding_notifications_enabled ?? true) + ) { + payload.patrol_finding_notifications_enabled = form.patrolFindingNotifications; + } + if ( + form.patrolFindingNotifyMinSeverity !== + (settings()?.patrol_finding_notify_min_severity ?? 'warning') + ) { + payload.patrol_finding_notify_min_severity = form.patrolFindingNotifyMinSeverity; + } if (form.patrolAutoFix !== settings()?.patrol_auto_fix) { payload.patrol_auto_fix = form.patrolAutoFix; } diff --git a/frontend-modern/src/types/ai.ts b/frontend-modern/src/types/ai.ts index e27edc185..4a01bd541 100644 --- a/frontend-modern/src/types/ai.ts +++ b/frontend-modern/src/types/ai.ts @@ -94,6 +94,8 @@ export interface AISettings { patrol_anomaly_triggers_enabled?: boolean; // true if anomaly-driven scoped Patrol triggers are enabled patrol_alert_trigger_min_severity?: 'warning' | 'critical'; // minimum alert level that triggers a scoped investigation patrol_alert_trigger_types?: string[]; // optional allowlist of alert types (empty = all types) + patrol_finding_notifications_enabled?: boolean; // true if new findings notify the alert channels (email, webhooks) + patrol_finding_notify_min_severity?: 'warning' | 'critical'; // minimum finding severity that notifies patrol_auto_fix?: boolean; // true if Patrol can remediate without approval // Multi-provider configuration anthropic_configured: boolean; // true if Anthropic API key is set @@ -173,6 +175,8 @@ export interface AISettingsUpdateRequest { patrol_anomaly_triggers_enabled?: boolean; // true if anomaly-driven scoped Patrol triggers are enabled patrol_alert_trigger_min_severity?: 'warning' | 'critical'; // minimum alert level that triggers a scoped investigation patrol_alert_trigger_types?: string[]; // optional allowlist of alert types (empty = all types) + patrol_finding_notifications_enabled?: boolean; // true if new findings notify the alert channels (email, webhooks) + patrol_finding_notify_min_severity?: 'warning' | 'critical'; // minimum finding severity that notifies patrol_auto_fix?: boolean; // true if Patrol can remediate without approval // Multi-provider credentials anthropic_api_key?: string; // Set Anthropic API key diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 92aaa9336..fb4d4382e 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -2355,10 +2355,13 @@ type AISettingsResponse struct { PatrolAlertTriggersEnabled bool `json:"patrol_alert_triggers_enabled"` // true if alert-driven scoped Patrol triggers are enabled PatrolAnomalyTriggersEnabled bool `json:"patrol_anomaly_triggers_enabled"` // true if anomaly-driven scoped Patrol triggers are enabled // Per-rule policy for alert-driven scoped Patrol triggers. - PatrolAlertTriggerMinSeverity string `json:"patrol_alert_trigger_min_severity"` // "warning" | "critical"; minimum alert level that warrants investigation - PatrolAlertTriggerTypes []string `json:"patrol_alert_trigger_types"` // optional allowlist of alert types (empty = all types) - UseProactiveThresholds bool `json:"use_proactive_thresholds"` // true if patrol warns before thresholds (false = use exact thresholds) - AvailableModels []providers.ModelInfo `json:"available_models"` // List of models for current provider + PatrolAlertTriggerMinSeverity string `json:"patrol_alert_trigger_min_severity"` // "warning" | "critical"; minimum alert level that warrants investigation + PatrolAlertTriggerTypes []string `json:"patrol_alert_trigger_types"` // optional allowlist of alert types (empty = all types) + // Finding notification routing (email, webhooks, Apprise) + PatrolFindingNotificationsEnabled bool `json:"patrol_finding_notifications_enabled"` // true if new warning+ findings notify the alert channels + PatrolFindingNotifyMinSeverity string `json:"patrol_finding_notify_min_severity"` // "warning" | "critical"; minimum finding severity that notifies + UseProactiveThresholds bool `json:"use_proactive_thresholds"` // true if patrol warns before thresholds (false = use exact thresholds) + AvailableModels []providers.ModelInfo `json:"available_models"` // List of models for current provider // Multi-provider credentials - shows which providers are configured AnthropicConfigured bool `json:"anthropic_configured"` // true if Anthropic API key is set OpenAIConfigured bool `json:"openai_configured"` // true if OpenAI API key is set @@ -2520,7 +2523,10 @@ type AISettingsUpdateRequest struct { // Per-rule policy for alert-driven scoped Patrol triggers. PatrolAlertTriggerMinSeverity *string `json:"patrol_alert_trigger_min_severity,omitempty"` // "warning" | "critical" PatrolAlertTriggerTypes *[]string `json:"patrol_alert_trigger_types,omitempty"` // allowlist of alert types (empty slice = all types) - UseProactiveThresholds *bool `json:"use_proactive_thresholds,omitempty"` // true if patrol warns before thresholds (default: false = exact thresholds) + // Finding notification routing (email, webhooks, Apprise) + PatrolFindingNotificationsEnabled *bool `json:"patrol_finding_notifications_enabled,omitempty"` // true if new warning+ findings notify the alert channels + PatrolFindingNotifyMinSeverity *string `json:"patrol_finding_notify_min_severity,omitempty"` // "warning" | "critical" + UseProactiveThresholds *bool `json:"use_proactive_thresholds,omitempty"` // true if patrol warns before thresholds (default: false = exact thresholds) // Multi-provider credentials AnthropicAPIKey *string `json:"anthropic_api_key,omitempty"` // Set Anthropic API key OpenAIAPIKey *string `json:"openai_api_key,omitempty"` // Set OpenAI API key @@ -2663,17 +2669,19 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R AuthMethod: authMethod, OAuthConnected: settings.OAuthAccessToken != "", // Patrol settings - PatrolIntervalMinutes: settings.PatrolIntervalMinutes, - PatrolEnabled: settings.PatrolEnabled, - PatrolAutoFix: settings.PatrolAutoFix && hasAutoFixFeature, - AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis && hasAlertAnalysisFeature, - PatrolEventTriggersEnabled: triggerSettings.AlertTriggersEnabled || triggerSettings.AnomalyTriggersEnabled, - PatrolAlertTriggersEnabled: triggerSettings.AlertTriggersEnabled, - PatrolAnomalyTriggersEnabled: triggerSettings.AnomalyTriggersEnabled, - PatrolAlertTriggerMinSeverity: settings.GetPatrolAlertTriggerMinSeverity(), - PatrolAlertTriggerTypes: settings.PatrolAlertTriggerTypes, - UseProactiveThresholds: settings.UseProactiveThresholds, - AvailableModels: nil, // Now populated via /api/ai/models endpoint + PatrolIntervalMinutes: settings.PatrolIntervalMinutes, + PatrolEnabled: settings.PatrolEnabled, + PatrolAutoFix: settings.PatrolAutoFix && hasAutoFixFeature, + AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis && hasAlertAnalysisFeature, + PatrolEventTriggersEnabled: triggerSettings.AlertTriggersEnabled || triggerSettings.AnomalyTriggersEnabled, + PatrolAlertTriggersEnabled: triggerSettings.AlertTriggersEnabled, + PatrolAnomalyTriggersEnabled: triggerSettings.AnomalyTriggersEnabled, + PatrolAlertTriggerMinSeverity: settings.GetPatrolAlertTriggerMinSeverity(), + PatrolAlertTriggerTypes: settings.PatrolAlertTriggerTypes, + PatrolFindingNotificationsEnabled: settings.PatrolFindingNotificationsEnabled, + PatrolFindingNotifyMinSeverity: settings.GetPatrolFindingNotifyMinSeverity(), + UseProactiveThresholds: settings.UseProactiveThresholds, + AvailableModels: nil, // Now populated via /api/ai/models endpoint // Multi-provider configuration AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), @@ -3014,6 +3022,19 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt settings.PatrolAlertTriggerTypes = cleaned } + // Handle finding notification routing + if req.PatrolFindingNotificationsEnabled != nil { + settings.PatrolFindingNotificationsEnabled = *req.PatrolFindingNotificationsEnabled + } + if req.PatrolFindingNotifyMinSeverity != nil { + sev := strings.ToLower(strings.TrimSpace(*req.PatrolFindingNotifyMinSeverity)) + if sev != config.AlertTriggerSeverityWarning && sev != config.AlertTriggerSeverityCritical { + http.Error(w, "patrol_finding_notify_min_severity must be 'warning' or 'critical'", http.StatusBadRequest) + return + } + settings.PatrolFindingNotifyMinSeverity = sev + } + // Handle request timeout (for slow hardware) if req.RequestTimeoutSeconds != nil { if *req.RequestTimeoutSeconds < 0 { @@ -3156,26 +3177,28 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt // Return updated settings response := AISettingsResponse{ - Enabled: settings.Enabled, - Model: settings.GetModel(), - ChatModel: config.NormalizeQuickstartModelString(settings.ChatModel), - PatrolModel: config.NormalizeQuickstartModelString(settings.PatrolModel), - AutoFixModel: config.NormalizeQuickstartModelString(settings.AutoFixModel), - Configured: settings.IsConfigured(), - CustomContext: settings.CustomContext, - AuthMethod: authMethod, - OAuthConnected: settings.OAuthAccessToken != "", - PatrolIntervalMinutes: settings.PatrolIntervalMinutes, - PatrolEnabled: settings.PatrolEnabled, - PatrolAutoFix: settings.PatrolAutoFix && hasAutoFixFeature, - AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis && hasAlertAnalysisFeature, - PatrolEventTriggersEnabled: triggerSettings.AlertTriggersEnabled || triggerSettings.AnomalyTriggersEnabled, - PatrolAlertTriggersEnabled: triggerSettings.AlertTriggersEnabled, - PatrolAnomalyTriggersEnabled: triggerSettings.AnomalyTriggersEnabled, - PatrolAlertTriggerMinSeverity: settings.GetPatrolAlertTriggerMinSeverity(), - PatrolAlertTriggerTypes: settings.PatrolAlertTriggerTypes, - UseProactiveThresholds: settings.UseProactiveThresholds, - AvailableModels: nil, // Now populated via /api/ai/models endpoint + Enabled: settings.Enabled, + Model: settings.GetModel(), + ChatModel: config.NormalizeQuickstartModelString(settings.ChatModel), + PatrolModel: config.NormalizeQuickstartModelString(settings.PatrolModel), + AutoFixModel: config.NormalizeQuickstartModelString(settings.AutoFixModel), + Configured: settings.IsConfigured(), + CustomContext: settings.CustomContext, + AuthMethod: authMethod, + OAuthConnected: settings.OAuthAccessToken != "", + PatrolIntervalMinutes: settings.PatrolIntervalMinutes, + PatrolEnabled: settings.PatrolEnabled, + PatrolAutoFix: settings.PatrolAutoFix && hasAutoFixFeature, + AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis && hasAlertAnalysisFeature, + PatrolEventTriggersEnabled: triggerSettings.AlertTriggersEnabled || triggerSettings.AnomalyTriggersEnabled, + PatrolAlertTriggersEnabled: triggerSettings.AlertTriggersEnabled, + PatrolAnomalyTriggersEnabled: triggerSettings.AnomalyTriggersEnabled, + PatrolAlertTriggerMinSeverity: settings.GetPatrolAlertTriggerMinSeverity(), + PatrolAlertTriggerTypes: settings.PatrolAlertTriggerTypes, + PatrolFindingNotificationsEnabled: settings.PatrolFindingNotificationsEnabled, + PatrolFindingNotifyMinSeverity: settings.GetPatrolFindingNotifyMinSeverity(), + UseProactiveThresholds: settings.UseProactiveThresholds, + AvailableModels: nil, // Now populated via /api/ai/models endpoint // Multi-provider configuration AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), diff --git a/internal/api/ai_handlers_finding_notify_test.go b/internal/api/ai_handlers_finding_notify_test.go new file mode 100644 index 000000000..06a724d14 --- /dev/null +++ b/internal/api/ai_handlers_finding_notify_test.go @@ -0,0 +1,82 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/stretchr/testify/require" +) + +func TestAISettingsHandler_UpdateSettingsPersistsFindingNotificationPolicy(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := newTestAISettingsHandler(cfg, persistence, nil) + + body, err := json.Marshal(AISettingsUpdateRequest{ + PatrolFindingNotificationsEnabled: ptr(false), + PatrolFindingNotifyMinSeverity: ptr("Critical"), + }) + require.NoError(t, err) + + req := newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleUpdateAISettings(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var resp AISettingsResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.False(t, resp.PatrolFindingNotificationsEnabled) + require.Equal(t, "critical", resp.PatrolFindingNotifyMinSeverity) + + saved, err := persistence.LoadAIConfig() + require.NoError(t, err) + require.False(t, saved.PatrolFindingNotificationsEnabled) + require.Equal(t, "critical", saved.PatrolFindingNotifyMinSeverity) +} + +func TestAISettingsHandler_UpdateSettingsRejectsInvalidFindingNotifySeverity(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := newTestAISettingsHandler(cfg, persistence, nil) + + body, err := json.Marshal(AISettingsUpdateRequest{ + PatrolFindingNotifyMinSeverity: ptr("emergency"), + }) + require.NoError(t, err) + + req := newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleUpdateAISettings(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + require.Contains(t, rec.Body.String(), "patrol_finding_notify_min_severity") +} + +func TestAISettingsHandler_GetSettingsReportsFindingNotificationDefaults(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := newTestAISettingsHandler(cfg, persistence, nil) + + req := newLoopbackRequest(http.MethodGet, "/api/settings/ai", nil) + rec := httptest.NewRecorder() + handler.HandleGetAISettings(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var resp AISettingsResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.True(t, resp.PatrolFindingNotificationsEnabled) + require.Equal(t, "warning", resp.PatrolFindingNotifyMinSeverity) +}