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) +}