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'
}
This commit is contained in:
rcourtman 2025-12-11 00:12:18 +00:00
parent 7ef96919d3
commit a8d0b15346
4 changed files with 418 additions and 4 deletions

View file

@ -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<SuppressionRule[]> {
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<Finding[]> {
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();
}

View file

@ -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, &copy)
}
}
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
}

View file

@ -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")
}
}

View file

@ -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)