mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user