diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts
index e79d3d3..17b5dc3 100644
--- a/frontend-modern/src/api/patrol.ts
+++ b/frontend-modern/src/api/patrol.ts
@@ -27,6 +27,11 @@ export interface Finding {
acknowledged_at?: string;
snoozed_until?: string; // Finding hidden until this time
alert_id?: string;
+ // User feedback fields (LLM memory system)
+ dismissed_reason?: 'not_an_issue' | 'expected_behavior' | 'will_fix_later';
+ user_note?: string;
+ times_raised: number;
+ suppressed: boolean;
}
export interface FindingsSummary {
@@ -188,6 +193,36 @@ export async function resolveFinding(findingId: string): Promise<{ success: bool
});
}
+/**
+ * Dismiss a finding with a reason (LLM memory feature)
+ * The LLM will be told not to re-raise this issue in future patrols.
+ * @param findingId The ID of the finding to dismiss
+ * @param reason One of: "not_an_issue", "expected_behavior", "will_fix_later"
+ * @param note Optional freeform explanation
+ */
+export async function dismissFinding(
+ findingId: string,
+ reason: 'not_an_issue' | 'expected_behavior' | 'will_fix_later',
+ note?: string
+): Promise<{ success: boolean; message: string }> {
+ return apiFetchJSON('/api/ai/patrol/dismiss', {
+ method: 'POST',
+ body: JSON.stringify({ finding_id: findingId, reason, note }),
+ });
+}
+
+/**
+ * Permanently suppress a finding type (LLM memory feature)
+ * The LLM will never re-raise this type of finding for this resource.
+ * @param findingId The ID of the finding to suppress
+ */
+export async function suppressFinding(findingId: string): Promise<{ success: boolean; message: string }> {
+ return apiFetchJSON('/api/ai/patrol/suppress', {
+ method: 'POST',
+ body: JSON.stringify({ finding_id: findingId }),
+ });
+}
+
/**
* Severity color mapping for UI
*/
diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx
index be100ba..19d47f0 100644
--- a/frontend-modern/src/pages/Alerts.tsx
+++ b/frontend-modern/src/pages/Alerts.tsx
@@ -28,7 +28,7 @@ import History from 'lucide-solid/icons/history';
import Gauge from 'lucide-solid/icons/gauge';
import Send from 'lucide-solid/icons/send';
import Calendar from 'lucide-solid/icons/calendar';
-import { getPatrolStatus, getFindings, getFindingsHistory, getPatrolRunHistory, forcePatrol, subscribeToPatrolStream, type Finding, type PatrolStatus, type PatrolRunRecord, severityColors, formatTimestamp } from '@/api/patrol';
+import { getPatrolStatus, getFindings, getFindingsHistory, getPatrolRunHistory, forcePatrol, subscribeToPatrolStream, dismissFinding, suppressFinding, type Finding, type PatrolStatus, type PatrolRunRecord, severityColors, formatTimestamp } from '@/api/patrol';
import { aiChatStore } from '@/stores/aiChat';
type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history';
@@ -2528,6 +2528,29 @@ function OverviewTab(props: {
{finding.title}
+ {/* Recurrence badge - show if raised multiple times */}
+ 1}>
+
+ ×{finding.times_raised}
+
+
+ {/* Dismissed status badge */}
+
+
+ {finding.dismissed_reason === 'not_an_issue' && '✓ Dismissed'}
+ {finding.dismissed_reason === 'expected_behavior' && '✓ Expected'}
+ {finding.dismissed_reason === 'will_fix_later' && '⏱ Noted'}
+
+
+ {/* Suppressed badge */}
+
+
+ 🔇 Suppressed
+
+
{/* Resource name pill */}
{finding.resource_name}
@@ -2571,6 +2594,15 @@ function OverviewTab(props: {
+ {/* User note if present */}
+
+
+
+ Your note: {finding.user_note}
+
+
+
+
{/* Footer with time and actions */}
@@ -2610,6 +2642,94 @@ function OverviewTab(props: {
I Fixed It
+ {/* Not an Issue dropdown - LLM memory system */}
+
+
+ {/* Dropdown menu */}
+
+
+
+
+
+
+
+
+
diff --git a/internal/ai/alert_triggered.go b/internal/ai/alert_triggered.go
index d4e7814..35588ee 100644
--- a/internal/ai/alert_triggered.go
+++ b/internal/ai/alert_triggered.go
@@ -238,7 +238,7 @@ func (a *AlertTriggeredAnalyzer) analyzeNodeFromAlert(_ context.Context, alert *
}
// Use patrol service's node analysis
- return a.patrolService.analyzeNode(*targetNode, true) // deep=true for triggered analysis
+ return a.patrolService.analyzeNode(*targetNode)
}
// analyzeGuestFromAlert analyzes a VM/Container triggered by an alert
@@ -255,7 +255,7 @@ func (a *AlertTriggeredAnalyzer) analyzeGuestFromAlert(_ context.Context, alert
return a.patrolService.analyzeGuest(
vm.ID, vm.Name, "vm", vm.Node, vm.Status,
vm.CPU, vm.Memory.Usage, vm.Disk.Usage,
- nil, vm.Template, true, // deep=true for triggered analysis
+ nil, vm.Template,
)
}
}
@@ -266,7 +266,7 @@ func (a *AlertTriggeredAnalyzer) analyzeGuestFromAlert(_ context.Context, alert
return a.patrolService.analyzeGuest(
ct.ID, ct.Name, "container", ct.Node, ct.Status,
ct.CPU, ct.Memory.Usage, ct.Disk.Usage,
- nil, ct.Template, true, // deep=true for triggered analysis
+ nil, ct.Template,
)
}
}
@@ -291,13 +291,13 @@ func (a *AlertTriggeredAnalyzer) analyzeDockerFromAlert(_ context.Context, alert
for _, dh := range state.DockerHosts {
// Check if this is a host alert
if dh.ID == alert.ResourceID || dh.Hostname == alert.ResourceName {
- return a.patrolService.analyzeDockerHost(dh, true) // deep=true
+ return a.patrolService.analyzeDockerHost(dh)
}
// Check containers within this host
for _, container := range dh.Containers {
if container.ID == alert.ResourceID || container.Name == alert.ResourceName {
- return a.patrolService.analyzeDockerHost(dh, true) // deep=true
+ return a.patrolService.analyzeDockerHost(dh)
}
}
}
@@ -320,7 +320,7 @@ func (a *AlertTriggeredAnalyzer) analyzeStorageFromAlert(_ context.Context, aler
// Storage is at the top level of StateSnapshot
for _, storage := range state.Storage {
if storage.ID == alert.ResourceID || storage.Name == alert.ResourceName {
- return a.patrolService.analyzeStorage(storage, true)
+ return a.patrolService.analyzeStorage(storage)
}
}
diff --git a/internal/ai/findings.go b/internal/ai/findings.go
index 7126ff6..ee0a7df 100644
--- a/internal/ai/findings.go
+++ b/internal/ai/findings.go
@@ -2,6 +2,8 @@
package ai
import (
+ "fmt"
+ "strings"
"sync"
"time"
)
@@ -54,11 +56,23 @@ type Finding struct {
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"` // Finding hidden until this time
// Link to alert if this finding was triggered by or attached to an alert
AlertID string `json:"alert_id,omitempty"`
+
+ // User feedback fields - enables LLM "memory" by tracking how users respond
+ // This helps prevent the LLM from repeatedly raising the same dismissed issues
+ DismissedReason string `json:"dismissed_reason,omitempty"` // "not_an_issue", "expected_behavior", "will_fix_later"
+ UserNote string `json:"user_note,omitempty"` // Freeform user explanation, included in LLM context
+ TimesRaised int `json:"times_raised"` // How many times this finding has been detected
+ Suppressed bool `json:"suppressed"` // Permanently suppress similar findings for this resource
}
-// IsActive returns true if the finding is still active (not resolved and not snoozed)
+// IsActive returns true if the finding is still active (not resolved, not snoozed, not suppressed)
func (f *Finding) IsActive() bool {
- return f.ResolvedAt == nil && !f.IsSnoozed()
+ return f.ResolvedAt == nil && !f.IsSnoozed() && !f.Suppressed
+}
+
+// IsDismissed returns true if the user has dismissed this finding with a reason
+func (f *Finding) IsDismissed() bool {
+ return f.DismissedReason != ""
}
// IsSnoozed returns true if the finding is currently snoozed
@@ -186,7 +200,7 @@ func (s *FindingsStore) ForceSave() error {
}
// Add adds or updates a finding
-// If a finding with the same ID exists, it updates LastSeenAt
+// If a finding with the same ID exists, it updates LastSeenAt and increments TimesRaised
// Returns true if this is a new finding
func (s *FindingsStore) Add(f *Finding) bool {
s.mu.Lock()
@@ -199,6 +213,7 @@ func (s *FindingsStore) Add(f *Finding) bool {
existing.Recommendation = f.Recommendation
existing.Evidence = f.Evidence
existing.Severity = f.Severity
+ existing.TimesRaised++ // Track recurrence
s.mu.Unlock()
s.scheduleSave()
return false
@@ -300,6 +315,83 @@ func (s *FindingsStore) Unsnooze(id string) bool {
return true
}
+// Dismiss marks a finding as dismissed with a reason and optional note
+// Reasons: "not_an_issue", "expected_behavior", "will_fix_later"
+func (s *FindingsStore) Dismiss(id, reason, note string) bool {
+ s.mu.Lock()
+
+ f, exists := s.findings[id]
+ if !exists {
+ s.mu.Unlock()
+ return false
+ }
+
+ f.DismissedReason = reason
+ if note != "" {
+ f.UserNote = note
+ }
+ // Also mark as acknowledged
+ now := time.Now()
+ f.AcknowledgedAt = &now
+
+ s.mu.Unlock()
+ s.scheduleSave()
+ return true
+}
+
+// SetUserNote updates the user note on a finding
+func (s *FindingsStore) SetUserNote(id, note string) bool {
+ s.mu.Lock()
+
+ f, exists := s.findings[id]
+ if !exists {
+ s.mu.Unlock()
+ return false
+ }
+
+ f.UserNote = note
+ s.mu.Unlock()
+ s.scheduleSave()
+ return true
+}
+
+// Suppress marks a finding type as permanently suppressed for a resource
+// Future findings with the same resource+category will be auto-dismissed
+func (s *FindingsStore) Suppress(id string) bool {
+ s.mu.Lock()
+
+ f, exists := s.findings[id]
+ if !exists {
+ s.mu.Unlock()
+ return false
+ }
+
+ f.Suppressed = true
+ f.DismissedReason = "suppressed"
+ now := time.Now()
+ f.AcknowledgedAt = &now
+
+ s.mu.Unlock()
+ s.scheduleSave()
+ return true
+}
+
+// IsSuppressed checks if findings of this type for this resource are suppressed
+func (s *FindingsStore) IsSuppressed(resourceID string, category FindingCategory, title string) bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ ids := s.byResource[resourceID]
+ for _, id := range ids {
+ if f, exists := s.findings[id]; exists {
+ if f.Suppressed && f.Category == category && f.Title == title {
+ return true
+ }
+ }
+ }
+ return false
+}
+
// Get returns a finding by ID
func (s *FindingsStore) Get(id string) *Finding {
s.mu.RLock()
@@ -425,6 +517,85 @@ func (s *FindingsStore) Cleanup(maxAge time.Duration) int {
return removed
}
+// GetDismissedForContext returns findings that the user has dismissed/acknowledged,
+// formatted for injection into LLM prompts. This is the core of the "memory" system -
+// it tells the LLM what not to re-raise.
+func (s *FindingsStore) GetDismissedForContext() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ var suppressed, dismissed, snoozed []string
+
+ for _, f := range s.findings {
+ // Skip very old findings (more than 30 days)
+ if time.Since(f.LastSeenAt) > 30*24*time.Hour {
+ continue
+ }
+
+ // Collect suppressed findings
+ if f.Suppressed {
+ note := ""
+ if f.UserNote != "" {
+ note = " - User note: " + f.UserNote
+ }
+ suppressed = append(suppressed,
+ fmt.Sprintf("- %s on %s: %s%s", f.Title, f.ResourceName, f.DismissedReason, note))
+ continue
+ }
+
+ // Collect dismissed/acknowledged findings
+ if f.DismissedReason != "" {
+ note := ""
+ if f.UserNote != "" {
+ note = " - User note: " + f.UserNote
+ }
+ dismissed = append(dismissed,
+ fmt.Sprintf("- %s on %s (%s)%s", f.Title, f.ResourceName, f.DismissedReason, note))
+ continue
+ }
+
+ // Collect snoozed findings
+ if f.IsSnoozed() {
+ snoozed = append(snoozed,
+ fmt.Sprintf("- %s on %s (snoozed until %s)",
+ f.Title, f.ResourceName, f.SnoozedUntil.Format("Jan 2")))
+ }
+ }
+
+ if len(suppressed) == 0 && len(dismissed) == 0 && len(snoozed) == 0 {
+ return ""
+ }
+
+ var result strings.Builder
+ result.WriteString("\n## Previous Findings - User Feedback\n")
+ result.WriteString("The following findings have been addressed by the user. Do NOT re-raise these unless the situation has significantly worsened:\n\n")
+
+ if len(suppressed) > 0 {
+ result.WriteString("### Permanently Suppressed (never re-raise):\n")
+ for _, s := range suppressed {
+ result.WriteString(s + "\n")
+ }
+ result.WriteString("\n")
+ }
+
+ if len(dismissed) > 0 {
+ result.WriteString("### Dismissed by User:\n")
+ for _, d := range dismissed {
+ result.WriteString(d + "\n")
+ }
+ result.WriteString("\n")
+ }
+
+ if len(snoozed) > 0 {
+ result.WriteString("### Temporarily Snoozed:\n")
+ for _, s := range snoozed {
+ result.WriteString(s + "\n")
+ }
+ }
+
+ return result.String()
+}
+
// FindingsSummary provides a quick count of findings by severity
type FindingsSummary struct {
Critical int `json:"critical"`
diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go
index a582557..7cd5109 100644
--- a/internal/ai/patrol.go
+++ b/internal/ai/patrol.go
@@ -485,7 +485,7 @@ func (p *PatrolService) patrolLoop(ctx context.Context) {
initialDelay := 30 * time.Second
select {
case <-time.After(initialDelay):
- p.runPatrol(ctx, false)
+ p.runPatrol(ctx)
case <-p.stopCh:
return
case <-ctx.Done():
@@ -591,198 +591,18 @@ func (p *PatrolService) runPatrol(ctx context.Context) {
return isNew
}
- // Analyze nodes
- if cfg.AnalyzeNodes {
- for _, node := range state.Nodes {
- select {
- case <-ctx.Done():
- return
- default:
- }
- runStats.resourceCount++
- runStats.nodesChecked++
- findings := p.analyzeNode(node)
- for _, f := range findings {
- trackFinding(f)
- }
- }
- }
+ // Count resources for statistics (but analysis is done by LLM only)
+ runStats.nodesChecked = len(state.Nodes)
+ runStats.guestsChecked = len(state.VMs) + len(state.Containers)
+ runStats.dockerChecked = len(state.DockerHosts)
+ runStats.storageChecked = len(state.Storage)
+ runStats.pbsChecked = len(state.PBSInstances)
+ runStats.hostsChecked = len(state.Hosts)
+ runStats.resourceCount = runStats.nodesChecked + runStats.guestsChecked +
+ runStats.dockerChecked + runStats.storageChecked + runStats.pbsChecked + runStats.hostsChecked
- // Analyze VMs and containers
- if cfg.AnalyzeGuests {
- for _, vm := range state.VMs {
- select {
- case <-ctx.Done():
- return
- default:
- }
- runStats.resourceCount++
- runStats.guestsChecked++
- // Calculate usage percentages from Memory/Disk structs
- var memUsage, diskUsage float64
- if vm.Memory.Total > 0 {
- memUsage = float64(vm.Memory.Used) / float64(vm.Memory.Total)
- // Cap at 1.0 to prevent impossible percentages from data anomalies
- if memUsage > 1.0 {
- log.Debug().
- Str("guest", vm.Name).
- Str("type", "vm").
- Int64("memUsed", vm.Memory.Used).
- Int64("memTotal", vm.Memory.Total).
- Float64("rawRatio", memUsage).
- Msg("AI Patrol: Capping memory usage ratio > 1.0")
- memUsage = 1.0
- }
- }
- if vm.Disk.Total > 0 {
- diskUsage = float64(vm.Disk.Used) / float64(vm.Disk.Total)
- // Cap at 1.0 to prevent impossible percentages from data anomalies
- if diskUsage > 1.0 {
- log.Debug().
- Str("guest", vm.Name).
- Str("type", "vm").
- Int64("diskUsed", vm.Disk.Used).
- Int64("diskTotal", vm.Disk.Total).
- Float64("rawRatio", diskUsage).
- Msg("AI Patrol: Capping disk usage ratio > 1.0")
- diskUsage = 1.0
- }
- }
- // Handle LastBackup - pass nil if zero time
- var lastBackup *time.Time
- if !vm.LastBackup.IsZero() {
- t := vm.LastBackup
- lastBackup = &t
- }
- findings := p.analyzeGuest(vm.ID, vm.Name, "vm", vm.Node, vm.Status,
- vm.CPU, memUsage, diskUsage, lastBackup, vm.Template)
- for _, f := range findings {
- trackFinding(f)
- }
- }
-
- for _, ct := range state.Containers {
- select {
- case <-ctx.Done():
- return
- default:
- }
- runStats.resourceCount++
- runStats.guestsChecked++
- // Calculate usage percentages from Memory/Disk structs
- var memUsage, diskUsage float64
- if ct.Memory.Total > 0 {
- memUsage = float64(ct.Memory.Used) / float64(ct.Memory.Total)
- // Cap at 1.0 to prevent impossible percentages from data anomalies
- if memUsage > 1.0 {
- log.Debug().
- Str("guest", ct.Name).
- Str("type", "container").
- Int64("memUsed", ct.Memory.Used).
- Int64("memTotal", ct.Memory.Total).
- Float64("rawRatio", memUsage).
- Msg("AI Patrol: Capping memory usage ratio > 1.0")
- memUsage = 1.0
- }
- }
- if ct.Disk.Total > 0 {
- diskUsage = float64(ct.Disk.Used) / float64(ct.Disk.Total)
- // Cap at 1.0 to prevent impossible percentages from data anomalies
- if diskUsage > 1.0 {
- log.Debug().
- Str("guest", ct.Name).
- Str("type", "container").
- Int64("diskUsed", ct.Disk.Used).
- Int64("diskTotal", ct.Disk.Total).
- Float64("rawRatio", diskUsage).
- Msg("AI Patrol: Capping disk usage ratio > 1.0")
- diskUsage = 1.0
- }
- }
- // Handle LastBackup - pass nil if zero time
- var lastBackup *time.Time
- if !ct.LastBackup.IsZero() {
- t := ct.LastBackup
- lastBackup = &t
- }
- findings := p.analyzeGuest(ct.ID, ct.Name, "container", ct.Node, ct.Status,
- ct.CPU, memUsage, diskUsage, lastBackup, ct.Template)
- for _, f := range findings {
- trackFinding(f)
- }
- }
- }
-
- // Analyze Docker hosts
- if cfg.AnalyzeDocker {
- for _, dh := range state.DockerHosts {
- select {
- case <-ctx.Done():
- return
- default:
- }
- runStats.resourceCount++
- runStats.dockerChecked++
- findings := p.analyzeDockerHost(dh)
- for _, f := range findings {
- trackFinding(f)
- }
- }
- }
-
- // Analyze storage
- if cfg.AnalyzeStorage {
- for _, st := range state.Storage {
- select {
- case <-ctx.Done():
- return
- default:
- }
- runStats.resourceCount++
- runStats.storageChecked++
- findings := p.analyzeStorage(st)
- for _, f := range findings {
- trackFinding(f)
- }
- }
- }
-
- // Analyze PBS instances (backup servers)
- if cfg.AnalyzePBS {
- for _, pbs := range state.PBSInstances {
- select {
- case <-ctx.Done():
- return
- default:
- }
- runStats.resourceCount++
- runStats.pbsChecked++
- findings := p.analyzePBSInstance(pbs, state.PBSBackups)
- for _, f := range findings {
- trackFinding(f)
- }
- }
- }
-
- // Analyze agent hosts (RAID, sensors)
- if cfg.AnalyzeHosts {
- for _, host := range state.Hosts {
- select {
- case <-ctx.Done():
- return
- default:
- }
- runStats.resourceCount++
- runStats.hostsChecked++
- findings := p.analyzeHost(host)
- for _, f := range findings {
- trackFinding(f)
- }
- }
- }
-
- // Run real AI analysis using the LLM
- // This asks the AI to analyze the infrastructure and identify issues
+ // Run AI analysis using the LLM - this is the ONLY analysis method
+ // The LLM analyzes the infrastructure and identifies issues
aiResult, aiErr := p.runAIAnalysis(ctx, state)
if aiErr != nil {
log.Warn().Err(aiErr).Msg("AI Patrol: LLM analysis failed")
@@ -878,9 +698,6 @@ func (p *PatrolService) runPatrol(ctx context.Context) {
p.lastDuration = duration
p.resourcesChecked = runStats.resourceCount
p.errorCount = runStats.errors
- if deep {
- p.lastDeepAnalysis = completedAt
- }
p.mu.Unlock()
// Add to history store (handles persistence automatically)
@@ -1410,40 +1227,38 @@ func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups []
}
}
- // Check backup jobs for failures (only during deep analysis)
- if deep {
- for _, job := range pbs.BackupJobs {
- if job.Status == "error" || job.Error != "" {
- findings = append(findings, &Finding{
- ID: generateFindingID(pbs.ID+":job:"+job.ID, "backup", "job-failed"),
- Severity: FindingSeverityWarning,
- Category: FindingCategoryBackup,
- ResourceID: pbs.ID + ":job:" + job.ID,
- ResourceName: fmt.Sprintf("%s/job/%s", pbsName, job.ID),
- ResourceType: "pbs_job",
- Title: "Backup job failed",
- Description: fmt.Sprintf("Backup job '%s' on PBS '%s' is failing", job.ID, pbsName),
- Recommendation: "Check PBS task logs for error details",
- Evidence: job.Error,
- })
- }
+ // Check backup jobs for failures
+ for _, job := range pbs.BackupJobs {
+ if job.Status == "error" || job.Error != "" {
+ findings = append(findings, &Finding{
+ ID: generateFindingID(pbs.ID+":job:"+job.ID, "backup", "job-failed"),
+ Severity: FindingSeverityWarning,
+ Category: FindingCategoryBackup,
+ ResourceID: pbs.ID + ":job:" + job.ID,
+ ResourceName: fmt.Sprintf("%s/job/%s", pbsName, job.ID),
+ ResourceType: "pbs_job",
+ Title: "Backup job failed",
+ Description: fmt.Sprintf("Backup job '%s' on PBS '%s' is failing", job.ID, pbsName),
+ Recommendation: "Check PBS task logs for error details",
+ Evidence: job.Error,
+ })
}
+ }
- for _, job := range pbs.VerifyJobs {
- if job.Status == "error" || job.Error != "" {
- findings = append(findings, &Finding{
- ID: generateFindingID(pbs.ID+":verify:"+job.ID, "backup", "verify-failed"),
- Severity: FindingSeverityWarning,
- Category: FindingCategoryBackup,
- ResourceID: pbs.ID + ":verify:" + job.ID,
- ResourceName: fmt.Sprintf("%s/verify/%s", pbsName, job.ID),
- ResourceType: "pbs_job",
- Title: "Verify job failed",
- Description: fmt.Sprintf("Verify job '%s' on PBS '%s' is failing", job.ID, pbsName),
- Recommendation: "Check PBS task logs - verify failures may indicate backup corruption",
- Evidence: job.Error,
- })
- }
+ for _, job := range pbs.VerifyJobs {
+ if job.Status == "error" || job.Error != "" {
+ findings = append(findings, &Finding{
+ ID: generateFindingID(pbs.ID+":verify:"+job.ID, "backup", "verify-failed"),
+ Severity: FindingSeverityWarning,
+ Category: FindingCategoryBackup,
+ ResourceID: pbs.ID + ":verify:" + job.ID,
+ ResourceName: fmt.Sprintf("%s/verify/%s", pbsName, job.ID),
+ ResourceType: "pbs_job",
+ Title: "Verify job failed",
+ Description: fmt.Sprintf("Verify job '%s' on PBS '%s' is failing", job.ID, pbsName),
+ Recommendation: "Check PBS task logs - verify failures may indicate backup corruption",
+ Evidence: job.Error,
+ })
}
}
@@ -1547,8 +1362,8 @@ func (p *PatrolService) analyzeHost(host models.Host) []*Finding {
}
}
- // Check high temperature (during deep analysis)
- if deep && len(host.Sensors.TemperatureCelsius) > 0 {
+ // Check high temperature
+ if len(host.Sensors.TemperatureCelsius) > 0 {
for sensorName, temp := range host.Sensors.TemperatureCelsius {
if temp > 85 {
severity := FindingSeverityWarning
@@ -1594,10 +1409,9 @@ func (p *PatrolService) runAIAnalysis(ctx context.Context, state models.StateSna
return nil, nil // Nothing to analyze
}
- // Build the patrol analysis prompt
- prompt := p.buildPatrolPrompt(summary)
+ prompt := p.buildPatrolPrompt(summary, false)
- Msg("AI Patrol: Sending infrastructure to LLM for analysis")
+ log.Debug().Msg("AI Patrol: Sending infrastructure to LLM for analysis")
// Start streaming phase
p.setStreamPhase("analyzing")
@@ -1813,20 +1627,37 @@ func (p *PatrolService) buildInfrastructureSummary(state models.StateSnapshot) s
}
// buildPatrolPrompt creates the prompt for AI analysis
+// Includes user feedback context to prevent re-raising dismissed findings
func (p *PatrolService) buildPatrolPrompt(summary string, deep bool) string {
- // Always run comprehensive analysis (deep flag kept for API backwards compat but ignored)
- return fmt.Sprintf(`Please perform a comprehensive analysis of the following infrastructure and identify any issues, potential problems, or optimization opportunities.
+ // Get user feedback context (dismissed/snoozed findings)
+ feedbackContext := p.findings.GetDismissedForContext()
+
+ basePrompt := fmt.Sprintf(`Please perform a comprehensive analysis of the following infrastructure and identify any issues, potential problems, or optimization opportunities.
%s
Analyze the above and report any findings using the structured format. Focus on:
- Resources showing high utilization
- Patterns that might indicate problems
-- Missing backups or stale backup schedules
+- Missing backups or stale backup schedules
- Unbalanced resource distribution
- Any anomalies or concerns
If everything looks healthy, say so briefly.`, summary)
+
+ // If there's user feedback, append instructions
+ if feedbackContext != "" {
+ return basePrompt + "\n\n" + feedbackContext + `
+
+IMPORTANT: Respect the user's feedback above. Do NOT re-raise findings that are:
+- Permanently suppressed - the user has explicitly said to never mention these again
+- Dismissed as "not_an_issue" or "expected_behavior" - the user knows about these
+- Currently snoozed - only re-raise if the severity has significantly worsened
+
+Only report NEW issues or issues where the severity has clearly escalated.`
+ }
+
+ return basePrompt
}
// parseAIFindings extracts structured findings from AI response
diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go
index 2f2354e..0cead75 100644
--- a/internal/api/ai_handlers.go
+++ b/internal/api/ai_handlers.go
@@ -1661,7 +1661,6 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
Running: status.Running,
Enabled: h.aiService.IsEnabled(),
LastPatrolAt: status.LastPatrolAt,
- LastDeepAnalysis: status.LastDeepAnalysis,
NextPatrolAt: status.NextPatrolAt,
LastDurationMs: status.LastDuration.Milliseconds(),
ResourcesChecked: status.ResourcesChecked,
@@ -1973,6 +1972,128 @@ func (h *AISettingsHandler) HandleResolveFinding(w http.ResponseWriter, r *http.
}
}
+// HandleDismissFinding dismisses a finding with a reason and optional note (POST /api/ai/patrol/dismiss)
+// This is part of the LLM memory system - dismissed findings are included in context to prevent re-raising
+// Valid reasons: "not_an_issue", "expected_behavior", "will_fix_later"
+func (h *AISettingsHandler) HandleDismissFinding(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 {
+ FindingID string `json:"finding_id"`
+ Reason string `json:"reason"` // "not_an_issue", "expected_behavior", "will_fix_later"
+ Note string `json:"note"` // Optional freeform note
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.FindingID == "" {
+ http.Error(w, "finding_id is required", http.StatusBadRequest)
+ return
+ }
+
+ // Validate reason
+ validReasons := map[string]bool{
+ "not_an_issue": true,
+ "expected_behavior": true,
+ "will_fix_later": true,
+ }
+ if req.Reason != "" && !validReasons[req.Reason] {
+ http.Error(w, "Invalid reason. Valid values: not_an_issue, expected_behavior, will_fix_later", http.StatusBadRequest)
+ return
+ }
+
+ findings := patrol.GetFindings()
+
+ if !findings.Dismiss(req.FindingID, req.Reason, req.Note) {
+ http.Error(w, "Finding not found", http.StatusNotFound)
+ return
+ }
+
+ log.Info().
+ Str("finding_id", req.FindingID).
+ Str("reason", req.Reason).
+ Bool("has_note", req.Note != "").
+ Msg("AI Patrol: Finding dismissed by user with reason")
+
+ response := map[string]interface{}{
+ "success": true,
+ "message": "Finding dismissed",
+ }
+
+ if err := utils.WriteJSONResponse(w, response); err != nil {
+ log.Error().Err(err).Msg("Failed to write dismiss response")
+ }
+}
+
+// HandleSuppressFinding permanently suppresses similar findings for a resource (POST /api/ai/patrol/suppress)
+// The LLM will be told never to re-raise findings of this type for this resource
+func (h *AISettingsHandler) HandleSuppressFinding(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 {
+ FindingID string `json:"finding_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.FindingID == "" {
+ http.Error(w, "finding_id is required", http.StatusBadRequest)
+ return
+ }
+
+ findings := patrol.GetFindings()
+
+ if !findings.Suppress(req.FindingID) {
+ http.Error(w, "Finding not found", http.StatusNotFound)
+ return
+ }
+
+ log.Info().
+ Str("finding_id", req.FindingID).
+ Msg("AI Patrol: Finding type permanently suppressed by user")
+
+ response := map[string]interface{}{
+ "success": true,
+ "message": "Finding type suppressed - similar issues will not be raised again",
+ }
+
+ if err := utils.WriteJSONResponse(w, response); err != nil {
+ log.Error().Err(err).Msg("Failed to write suppress response")
+ }
+}
+
// HandleGetFindingsHistory returns all findings including resolved for history (GET /api/ai/patrol/history)
func (h *AISettingsHandler) HandleGetFindingsHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
diff --git a/internal/api/router.go b/internal/api/router.go
index 41b73e7..313a364 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -1101,7 +1101,8 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, r.aiSettingsHandler.HandleGetFindingsHistory))
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, r.aiSettingsHandler.HandleForcePatrol))
r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding))
- r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding)) // Backward compat
+ r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, r.aiSettingsHandler.HandleDismissFinding)) // Dismiss with reason (LLM memory)
+ r.mux.HandleFunc("/api/ai/patrol/suppress", RequireAuth(r.config, r.aiSettingsHandler.HandleSuppressFinding)) // Permanently suppress (LLM memory)
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))
diff --git a/internal/api/security.go b/internal/api/security.go
index 89f83b2..21a5bbc 100644
--- a/internal/api/security.go
+++ b/internal/api/security.go
@@ -32,11 +32,13 @@ func CheckCSRF(w http.ResponseWriter, r *http.Request) bool {
// Skip CSRF for API token auth (API clients don't have sessions)
if r.Header.Get("X-API-Token") != "" {
+ log.Debug().Str("path", r.URL.Path).Msg("CSRF check skipped: API token auth")
return true
}
// Skip CSRF for Basic Auth (doesn't use sessions, not vulnerable to CSRF)
if r.Header.Get("Authorization") != "" {
+ log.Debug().Str("path", r.URL.Path).Msg("CSRF check skipped: Basic Auth header present")
return true
}
@@ -45,6 +47,7 @@ func CheckCSRF(w http.ResponseWriter, r *http.Request) bool {
if err != nil {
// No session cookie means no CSRF check needed
// (either no auth configured or using basic auth which doesn't use sessions)
+ log.Debug().Str("path", r.URL.Path).Msg("CSRF check skipped: no session cookie")
return true
}
@@ -54,6 +57,14 @@ func CheckCSRF(w http.ResponseWriter, r *http.Request) bool {
csrfToken = r.FormValue("csrf_token")
}
+ // Log CSRF validation attempt for debugging
+ log.Debug().
+ Str("path", r.URL.Path).
+ Str("method", r.Method).
+ Str("session", cookie.Value[:8]+"...").
+ Bool("has_csrf_token", csrfToken != "").
+ Msg("CSRF validation attempt")
+
// No CSRF token means request is not eligible for mutation
if csrfToken == "" {
log.Warn().
@@ -63,6 +74,7 @@ func CheckCSRF(w http.ResponseWriter, r *http.Request) bool {
clearCSRFCookie(w)
if newToken := issueNewCSRFCookie(w, r, cookie.Value); newToken != "" {
w.Header().Set("X-CSRF-Token", newToken)
+ log.Debug().Str("new_token", newToken[:8]+"...").Msg("Issued new CSRF token after missing")
}
return false
}
@@ -77,10 +89,15 @@ func CheckCSRF(w http.ResponseWriter, r *http.Request) bool {
clearCSRFCookie(w)
if newToken := issueNewCSRFCookie(w, r, cookie.Value); newToken != "" {
w.Header().Set("X-CSRF-Token", newToken)
+ log.Debug().Str("new_token", newToken[:8]+"...").Msg("Issued new CSRF token after invalid")
}
return false
}
+ log.Debug().
+ Str("path", r.URL.Path).
+ Str("session", cookie.Value[:8]+"...").
+ Msg("CSRF validation successful")
return true
}