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:
parent
7ef96919d3
commit
a8d0b15346
4 changed files with 418 additions and 4 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue