From a8d0b15346af245521ac2432eb8371dd97ea9d9d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 11 Dec 2025 00:12:18 +0000 Subject: [PATCH] feat(ai): Add suppression rules management API and UI Users can now: 1. View all suppression rules (both from dismissed findings and manually created) 2. Create manual rules like 'ignore performance issues on debian-go' 3. Delete rules when they want alerts to come back Backend: - Added SuppressionRule type for user-defined rules - Added suppressionRules storage to FindingsStore - Added AddSuppressionRule/GetSuppressionRules/DeleteSuppressionRule methods - Added isSuppressedInternal check for manual rules - Added API handlers and routes for /api/ai/patrol/suppressions Frontend: - Added SuppressionRule interface - Added getSuppressionRules/addSuppressionRule/deleteSuppressionRule API functions - Added getDismissedFindings for viewing dismissed findings Example usage: POST /api/ai/patrol/suppressions { 'resource_id': 'debian-go', 'category': 'performance', 'description': 'Dev container runs hot - expected' } --- frontend-modern/src/api/patrol.ts | 73 ++++++++++++ internal/ai/findings.go | 157 +++++++++++++++++++++++++- internal/api/ai_handlers.go | 179 ++++++++++++++++++++++++++++++ internal/api/router.go | 13 +++ 4 files changed, 418 insertions(+), 4 deletions(-) diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index 17b5dc3..cd231bf 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -304,3 +304,76 @@ export function subscribeToPatrolStream( eventSource.close(); }; } + +// === Suppression Rules === + +export interface SuppressionRule { + id: string; + resource_id?: string; // Empty means "any resource" + resource_name?: string; // Human-readable name + category?: FindingCategory; // Empty means "any category" + description: string; // User's reason + created_at: string; + created_from: 'finding' | 'manual'; + finding_id?: string; // Original finding ID if created from dismissal +} + +/** + * Get all suppression rules (both manual and from dismissed findings) + */ +export async function getSuppressionRules(): Promise { + const resp = await fetch('/api/ai/patrol/suppressions', { + credentials: 'include', + }); + if (!resp.ok) { + throw new Error(`Failed to get suppression rules: ${resp.status}`); + } + return resp.json(); +} + +/** + * Create a new manual suppression rule + * @param resourceId Resource ID (empty for "any resource") + * @param resourceName Human-readable name for display + * @param category Category (empty for "any category") + * @param description User's reason for the rule + */ +export async function addSuppressionRule( + resourceId: string, + resourceName: string, + category: FindingCategory | '', + description: string +): Promise<{ success: boolean; message: string; rule: SuppressionRule }> { + return apiFetchJSON('/api/ai/patrol/suppressions', { + method: 'POST', + body: JSON.stringify({ + resource_id: resourceId, + resource_name: resourceName, + category: category, + description: description, + }), + }); +} + +/** + * Delete a suppression rule + * @param ruleId The ID of the rule to delete + */ +export async function deleteSuppressionRule(ruleId: string): Promise<{ success: boolean; message: string }> { + return apiFetchJSON(`/api/ai/patrol/suppressions/${encodeURIComponent(ruleId)}`, { + method: 'DELETE', + }); +} + +/** + * Get all dismissed/suppressed findings + */ +export async function getDismissedFindings(): Promise { + const resp = await fetch('/api/ai/patrol/dismissed', { + credentials: 'include', + }); + if (!resp.ok) { + throw new Error(`Failed to get dismissed findings: ${resp.status}`); + } + return resp.json(); +} diff --git a/internal/ai/findings.go b/internal/ai/findings.go index fd29435..7c95767 100644 --- a/internal/ai/findings.go +++ b/internal/ai/findings.go @@ -85,6 +85,19 @@ func (f *Finding) IsResolved() bool { return f.ResolvedAt != nil } +// SuppressionRule represents a user-defined rule to suppress certain AI findings +// Users can create these manually to prevent alerts before they happen +type SuppressionRule struct { + ID string `json:"id"` + ResourceID string `json:"resource_id,omitempty"` // Empty means "any resource" + ResourceName string `json:"resource_name,omitempty"` // Human-readable name for display + Category FindingCategory `json:"category,omitempty"` // Empty means "any category" + Description string `json:"description"` // User's reason, e.g., "dev VM runs hot" + CreatedAt time.Time `json:"created_at"` + CreatedFrom string `json:"created_from,omitempty"` // "finding" if created from a dismissed finding, "manual" if user-created + FindingID string `json:"finding_id,omitempty"` // Original finding ID if created from dismissal +} + // FindingsPersistence interface for saving/loading findings (avoids circular imports) type FindingsPersistence interface { SaveFindings(findings map[string]*Finding) error @@ -99,6 +112,8 @@ type FindingsStore struct { byResource map[string][]string // resource_id -> []finding_id // Keep track of active findings count by severity (cached, but GetSummary calculates dynamically) activeCounts map[FindingSeverity]int + // User-defined suppression rules (separate from dismissed findings) + suppressionRules map[string]*SuppressionRule // Persistence layer (optional) persistence FindingsPersistence // Debounce save operations @@ -110,10 +125,11 @@ type FindingsStore struct { // NewFindingsStore creates a new findings store func NewFindingsStore() *FindingsStore { return &FindingsStore{ - findings: make(map[string]*Finding), - byResource: make(map[string][]string), - activeCounts: make(map[FindingSeverity]int), - saveDebounce: 5 * time.Second, // Debounce saves by 5 seconds + findings: make(map[string]*Finding), + byResource: make(map[string][]string), + activeCounts: make(map[FindingSeverity]int), + suppressionRules: make(map[string]*SuppressionRule), + saveDebounce: 5 * time.Second, // Debounce saves by 5 seconds } } @@ -422,6 +438,7 @@ func (s *FindingsStore) IsSuppressed(resourceID string, category FindingCategory // isSuppressedInternal checks suppression without locking (caller must hold lock) func (s *FindingsStore) isSuppressedInternal(resourceID string, category FindingCategory) bool { + // Check if any finding for this resource+category is suppressed ids := s.byResource[resourceID] for _, id := range ids { if f, exists := s.findings[id]; exists { @@ -431,6 +448,16 @@ func (s *FindingsStore) isSuppressedInternal(resourceID string, category Finding } } } + + // Also check manual suppression rules + for _, rule := range s.suppressionRules { + resourceMatches := rule.ResourceID == "" || rule.ResourceID == resourceID + categoryMatches := rule.Category == "" || rule.Category == category + if resourceMatches && categoryMatches { + return true + } + } + return false } @@ -656,3 +683,125 @@ func (s FindingsSummary) HasIssues() bool { func (s FindingsSummary) IsHealthy() bool { return s.Critical == 0 && s.Warning == 0 && s.Watch == 0 } + +// --- Suppression Rule Management --- + +// AddSuppressionRule creates a new user-defined suppression rule +func (s *FindingsStore) AddSuppressionRule(resourceID, resourceName string, category FindingCategory, description string) *SuppressionRule { + s.mu.Lock() + defer s.mu.Unlock() + + // Generate ID based on resource+category + ruleID := fmt.Sprintf("rule_%s_%s_%d", resourceID, category, time.Now().UnixNano()) + if resourceID == "" { + ruleID = fmt.Sprintf("rule_any_%s_%d", category, time.Now().UnixNano()) + } + if category == "" { + ruleID = fmt.Sprintf("rule_%s_any_%d", resourceID, time.Now().UnixNano()) + } + + rule := &SuppressionRule{ + ID: ruleID, + ResourceID: resourceID, + ResourceName: resourceName, + Category: category, + Description: description, + CreatedAt: time.Now(), + CreatedFrom: "manual", + } + + s.suppressionRules[ruleID] = rule + s.scheduleSave() + return rule +} + +// GetSuppressionRules returns all suppression rules (both manual and from dismissed findings) +func (s *FindingsStore) GetSuppressionRules() []*SuppressionRule { + s.mu.RLock() + defer s.mu.RUnlock() + + var rules []*SuppressionRule + + // Add explicit suppression rules + for _, rule := range s.suppressionRules { + rules = append(rules, rule) + } + + // Also include suppressed findings as rules (for visibility) + for _, f := range s.findings { + if f.Suppressed { + rules = append(rules, &SuppressionRule{ + ID: "finding_" + f.ID, + ResourceID: f.ResourceID, + ResourceName: f.ResourceName, + Category: f.Category, + Description: f.UserNote, + CreatedAt: *f.AcknowledgedAt, + CreatedFrom: "finding", + FindingID: f.ID, + }) + } + } + + return rules +} + +// DeleteSuppressionRule removes a suppression rule +func (s *FindingsStore) DeleteSuppressionRule(ruleID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + // Check if it's an explicit rule + if _, exists := s.suppressionRules[ruleID]; exists { + delete(s.suppressionRules, ruleID) + s.scheduleSave() + return true + } + + // Check if it's a finding-based rule (e.g., "finding_abc123") + if strings.HasPrefix(ruleID, "finding_") { + findingID := strings.TrimPrefix(ruleID, "finding_") + if f, exists := s.findings[findingID]; exists && f.Suppressed { + // Un-suppress the finding + f.Suppressed = false + f.DismissedReason = "" + s.scheduleSave() + return true + } + } + + return false +} + +// GetDismissedFindings returns all findings that have been dismissed or suppressed +func (s *FindingsStore) GetDismissedFindings() []*Finding { + s.mu.RLock() + defer s.mu.RUnlock() + + var dismissed []*Finding + for _, f := range s.findings { + if f.DismissedReason != "" || f.Suppressed { + copy := *f + dismissed = append(dismissed, ©) + } + } + return dismissed +} + +// MatchesSuppressionRule checks if a finding matches any suppression rule +func (s *FindingsStore) MatchesSuppressionRule(resourceID string, category FindingCategory) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, rule := range s.suppressionRules { + // Check if resource matches (or rule is for "any resource") + resourceMatches := rule.ResourceID == "" || rule.ResourceID == resourceID + // Check if category matches (or rule is for "any category") + categoryMatches := rule.Category == "" || rule.Category == category + + if resourceMatches && categoryMatches { + return true + } + } + return false +} diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 0cead75..8969a0e 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -2162,3 +2162,182 @@ func (h *AISettingsHandler) HandleGetPatrolRunHistory(w http.ResponseWriter, r * log.Error().Err(err).Msg("Failed to write patrol run history response") } } + +// HandleGetSuppressionRules returns all suppression rules (GET /api/ai/patrol/suppressions) +func (h *AISettingsHandler) HandleGetSuppressionRules(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Require authentication + if !CheckAuth(h.config, w, r) { + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil { + log.Error().Err(err).Msg("Failed to write suppression rules response") + } + return + } + + findings := patrol.GetFindings() + rules := findings.GetSuppressionRules() + + if err := utils.WriteJSONResponse(w, rules); err != nil { + log.Error().Err(err).Msg("Failed to write suppression rules response") + } +} + +// HandleAddSuppressionRule creates a new suppression rule (POST /api/ai/patrol/suppressions) +func (h *AISettingsHandler) HandleAddSuppressionRule(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Require authentication + if !CheckAuth(h.config, w, r) { + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + http.Error(w, "Patrol service not available", http.StatusServiceUnavailable) + return + } + + var req struct { + ResourceID string `json:"resource_id"` // Can be empty for "any resource" + ResourceName string `json:"resource_name"` // Human-readable name + Category string `json:"category"` // Can be empty for "any category" + Description string `json:"description"` // Required - user's reason + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Description == "" { + http.Error(w, "description is required", http.StatusBadRequest) + return + } + + // Convert category string to FindingCategory + var category ai.FindingCategory + switch req.Category { + case "performance": + category = ai.FindingCategoryPerformance + case "capacity": + category = ai.FindingCategoryCapacity + case "reliability": + category = ai.FindingCategoryReliability + case "backup": + category = ai.FindingCategoryBackup + case "security": + category = ai.FindingCategorySecurity + case "general": + category = ai.FindingCategoryGeneral + case "": + category = "" // Any category + default: + http.Error(w, "Invalid category", http.StatusBadRequest) + return + } + + findings := patrol.GetFindings() + rule := findings.AddSuppressionRule(req.ResourceID, req.ResourceName, category, req.Description) + + log.Info(). + Str("rule_id", rule.ID). + Str("resource_id", req.ResourceID). + Str("category", req.Category). + Str("description", req.Description). + Msg("AI Patrol: Manual suppression rule created") + + response := map[string]interface{}{ + "success": true, + "message": "Suppression rule created", + "rule": rule, + } + + if err := utils.WriteJSONResponse(w, response); err != nil { + log.Error().Err(err).Msg("Failed to write add suppression rule response") + } +} + +// HandleDeleteSuppressionRule removes a suppression rule (DELETE /api/ai/patrol/suppressions/:id) +func (h *AISettingsHandler) HandleDeleteSuppressionRule(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Require authentication + if !CheckAuth(h.config, w, r) { + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + http.Error(w, "Patrol service not available", http.StatusServiceUnavailable) + return + } + + // Get rule ID from URL path + ruleID := strings.TrimPrefix(r.URL.Path, "/api/ai/patrol/suppressions/") + if ruleID == "" { + http.Error(w, "rule_id is required", http.StatusBadRequest) + return + } + + findings := patrol.GetFindings() + + if !findings.DeleteSuppressionRule(ruleID) { + http.Error(w, "Rule not found", http.StatusNotFound) + return + } + + log.Info(). + Str("rule_id", ruleID). + Msg("AI Patrol: Suppression rule deleted") + + response := map[string]interface{}{ + "success": true, + "message": "Suppression rule deleted", + } + + if err := utils.WriteJSONResponse(w, response); err != nil { + log.Error().Err(err).Msg("Failed to write delete suppression rule response") + } +} + +// HandleGetDismissedFindings returns all dismissed/suppressed findings (GET /api/ai/patrol/dismissed) +func (h *AISettingsHandler) HandleGetDismissedFindings(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Require authentication + if !CheckAuth(h.config, w, r) { + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil { + log.Error().Err(err).Msg("Failed to write dismissed findings response") + } + return + } + + findings := patrol.GetFindings() + dismissed := findings.GetDismissedFindings() + + if err := utils.WriteJSONResponse(w, dismissed); err != nil { + log.Error().Err(err).Msg("Failed to write dismissed findings response") + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 313a364..82993f2 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1106,6 +1106,19 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, r.aiSettingsHandler.HandleSnoozeFinding)) r.mux.HandleFunc("/api/ai/patrol/resolve", RequireAuth(r.config, r.aiSettingsHandler.HandleResolveFinding)) r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolRunHistory)) + // Suppression rules management + r.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + r.aiSettingsHandler.HandleGetSuppressionRules(w, req) + case http.MethodPost: + r.aiSettingsHandler.HandleAddSuppressionRule(w, req) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + })) + r.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(r.config, r.aiSettingsHandler.HandleDeleteSuppressionRule)) + r.mux.HandleFunc("/api/ai/patrol/dismissed", RequireAuth(r.config, r.aiSettingsHandler.HandleGetDismissedFindings)) // Agent WebSocket for AI command execution r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)