feat(ai): Add LLM memory system for patrol findings
Implements a comprehensive feedback system that allows the LLM to 'remember' user decisions about findings, preventing repetitive/annoying alerts. Backend changes: - Extended Finding struct with dismissed_reason, user_note, times_raised, suppressed - Added Dismiss(), Suppress(), SetUserNote(), IsSuppressed() methods to FindingsStore - Added GetDismissedForContext() to format dismissed findings for LLM context - Enhanced buildPatrolPrompt() to inject user feedback context - Added POST /api/ai/patrol/dismiss and /api/ai/patrol/suppress endpoints - Updated IsActive() to exclude suppressed findings Frontend changes: - Added Dismiss dropdown with options: Not an Issue, Expected Behavior, Will Fix Later - Added Never Alert Again option for permanent suppression - Expected Behavior prompts for optional note to help LLM understand context - Added visual badges: recurrence count (×N), dismissed status, suppressed indicator - Display user notes in expanded finding view Also fixes: - Fixed 403 error on Run Patrol (compilation errors from partial refactoring) - Removed non-LLM patrol checks - patrol now uses LLM analysis only - Fixed function signature mismatches in alert_triggered.go The LLM now receives context about previously dismissed findings and is instructed not to re-raise them unless severity has significantly worsened.
This commit is contained in:
parent
c88e2db7b4
commit
a3d953172c
8 changed files with 543 additions and 247 deletions
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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: {
|
|||
<span class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate flex-1">
|
||||
{finding.title}
|
||||
</span>
|
||||
{/* Recurrence badge - show if raised multiple times */}
|
||||
<Show when={finding.times_raised > 1}>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 flex-shrink-0"
|
||||
title={`This issue has been detected ${finding.times_raised} times`}
|
||||
>
|
||||
×{finding.times_raised}
|
||||
</span>
|
||||
</Show>
|
||||
{/* Dismissed status badge */}
|
||||
<Show when={finding.dismissed_reason}>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
{finding.dismissed_reason === 'not_an_issue' && '✓ Dismissed'}
|
||||
{finding.dismissed_reason === 'expected_behavior' && '✓ Expected'}
|
||||
{finding.dismissed_reason === 'will_fix_later' && '⏱ Noted'}
|
||||
</span>
|
||||
</Show>
|
||||
{/* Suppressed badge */}
|
||||
<Show when={finding.suppressed}>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 flex-shrink-0">
|
||||
🔇 Suppressed
|
||||
</span>
|
||||
</Show>
|
||||
{/* Resource name pill */}
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 flex-shrink-0 hidden sm:inline">
|
||||
{finding.resource_name}
|
||||
|
|
@ -2571,6 +2594,15 @@ function OverviewTab(props: {
|
|||
</p>
|
||||
</Show>
|
||||
|
||||
{/* User note if present */}
|
||||
<Show when={finding.user_note}>
|
||||
<div class="mt-2 p-2 rounded bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
<span class="font-medium">Your note:</span> {finding.user_note}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Footer with time and actions */}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mt-3 pt-2 border-t border-gray-200 dark:border-gray-600">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500">
|
||||
|
|
@ -2610,6 +2642,94 @@ function OverviewTab(props: {
|
|||
</svg>
|
||||
I Fixed It
|
||||
</button>
|
||||
{/* Not an Issue dropdown - LLM memory system */}
|
||||
<div class="relative group">
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium border rounded-lg transition-all bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-1.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Dismiss this finding - AI won't re-raise it"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
Dismiss
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Dropdown menu */}
|
||||
<div class="absolute right-0 mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50">
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg transition-colors"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await dismissFinding(finding.id, 'not_an_issue');
|
||||
showSuccess('Dismissed - AI will not raise this again');
|
||||
fetchAiData();
|
||||
} catch (err) {
|
||||
showError('Failed to dismiss finding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">Not an Issue</span>
|
||||
<p class="text-gray-500 dark:text-gray-500 mt-0.5">This isn't actually a problem</p>
|
||||
</button>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const note = prompt('Why is this expected? (optional - helps AI understand your environment)');
|
||||
try {
|
||||
await dismissFinding(finding.id, 'expected_behavior', note || undefined);
|
||||
showSuccess('Dismissed - AI will not raise this again');
|
||||
fetchAiData();
|
||||
} catch (err) {
|
||||
showError('Failed to dismiss finding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">Expected Behavior</span>
|
||||
<p class="text-gray-500 dark:text-gray-500 mt-0.5">This is intentional/by design</p>
|
||||
</button>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await dismissFinding(finding.id, 'will_fix_later');
|
||||
showSuccess('Acknowledged - AI will check again later');
|
||||
fetchAiData();
|
||||
} catch (err) {
|
||||
showError('Failed to dismiss finding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">Will Fix Later</span>
|
||||
<p class="text-gray-500 dark:text-gray-500 mt-0.5">I know about it, will address</p>
|
||||
</button>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-xs hover:bg-red-50 dark:hover:bg-red-900/30 rounded-b-lg transition-colors text-red-600 dark:text-red-400"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Permanently suppress this type of finding for this resource?\n\nThe AI will never raise this issue again.')) {
|
||||
try {
|
||||
await suppressFinding(finding.id);
|
||||
showSuccess('Suppressed - AI will never raise this again');
|
||||
fetchAiData();
|
||||
} catch (err) {
|
||||
showError('Failed to suppress finding');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="font-medium">Never Alert Again</span>
|
||||
<p class="text-red-500/80 dark:text-red-400/80 mt-0.5">Permanently suppress for this resource</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue