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.
This commit is contained in:
rcourtman
2026-07-18 12:14:12 +01:00
parent 23695681ac
commit e0e454abfc
5 changed files with 207 additions and 36 deletions
@@ -252,6 +252,49 @@ const PatrolSettingsContent: Component<{ state: ReturnType<typeof useAISettingsS
</div>
</div>
<div class="space-y-3">
<h3 class="text-sm font-semibold text-base-content">Notifications</h3>
<div class="rounded-md border border-border bg-surface-alt p-3">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-base-content">Finding notifications</p>
<p class="mt-1 text-xs text-muted">
Send new findings to your alert notification channels (email, webhooks).
</p>
</div>
<Toggle
checked={props.state.form.patrolFindingNotifications}
onChange={(event) =>
props.state.setForm('patrolFindingNotifications', event.currentTarget.checked)
}
disabled={props.state.saving()}
ariaLabel="Enable finding notifications"
/>
</div>
<Show when={props.state.form.patrolFindingNotifications}>
<FormSelect
id="ai-patrol-finding-notify-min-severity"
label="Notify for findings at or above"
value={props.state.form.patrolFindingNotifyMinSeverity}
onChange={(event) =>
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"
>
<option value="warning">Warning and critical</option>
<option value="critical">Critical only</option>
</FormSelect>
</Show>
</div>
</div>
<div class="space-y-3">
<h3 class="text-sm font-semibold text-base-content">Model readiness</h3>
<AIModelOverrideField state={props.state} kind="patrol" includePatrolPreflight />
@@ -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;
}
+4
View File
@@ -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
+59 -36
View File
@@ -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),
@@ -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)
}