Pulse/internal/ai/patrol.go
rcourtman 50c029c044
Some checks failed
Build and Test / Frontend & Backend (push) Has been cancelled
Core E2E Tests / Playwright Core E2E (push) Has been cancelled
fix: exclude watch from patrol status summary
Since watch/info findings are filtered from the UI and never shown
to users, don't include them in the patrol run status summary.
This makes the summary consistent with what users actually see.
2025-12-21 23:31:21 +00:00

2523 lines
83 KiB
Go

package ai
import (
"context"
"crypto/sha256"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline"
aicontext "github.com/rcourtman/pulse-go-rewrite/internal/ai/context"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rs/zerolog/log"
)
// ThresholdProvider provides user-configured alert thresholds for patrol to use
type ThresholdProvider interface {
// GetNodeCPUThreshold returns the CPU alert trigger threshold for nodes (0-100%)
GetNodeCPUThreshold() float64
// GetNodeMemoryThreshold returns the memory alert trigger threshold for nodes (0-100%)
GetNodeMemoryThreshold() float64
// GetGuestMemoryThreshold returns the memory alert trigger threshold for guests (0-100%)
GetGuestMemoryThreshold() float64
// GetGuestDiskThreshold returns the disk alert trigger threshold for guests (0-100%)
GetGuestDiskThreshold() float64
// GetStorageThreshold returns the usage alert trigger threshold for storage (0-100%)
GetStorageThreshold() float64
}
// PatrolThresholds holds calculated thresholds for patrol (derived from alert thresholds)
type PatrolThresholds struct {
// Node thresholds
NodeCPUWatch float64 // CPU % to flag as "watch" (typically alertThreshold - 15)
NodeCPUWarning float64 // CPU % to flag as "warning" (typically alertThreshold - 5)
NodeMemWatch float64
NodeMemWarning float64
// Guest thresholds (VMs/containers)
GuestMemWatch float64
GuestMemWarning float64
GuestDiskWatch float64
GuestDiskWarn float64
GuestDiskCrit float64
// Storage thresholds
StorageWatch float64
StorageWarning float64
StorageCritical float64
}
// DefaultPatrolThresholds returns fallback thresholds when no provider is set
func DefaultPatrolThresholds() PatrolThresholds {
return PatrolThresholds{
NodeCPUWatch: 75, NodeCPUWarning: 85,
NodeMemWatch: 75, NodeMemWarning: 85,
GuestMemWatch: 80, GuestMemWarning: 88,
GuestDiskWatch: 75, GuestDiskWarn: 85, GuestDiskCrit: 92,
StorageWatch: 70, StorageWarning: 80, StorageCritical: 90,
}
}
// CalculatePatrolThresholds derives patrol thresholds from alert thresholds
// Patrol warns ~10% BEFORE the alert fires so users can take action early
func CalculatePatrolThresholds(provider ThresholdProvider) PatrolThresholds {
if provider == nil {
return DefaultPatrolThresholds()
}
// Get user's alert thresholds
nodeCPU := provider.GetNodeCPUThreshold()
nodeMem := provider.GetNodeMemoryThreshold()
guestMem := provider.GetGuestMemoryThreshold()
guestDisk := provider.GetGuestDiskThreshold()
storage := provider.GetStorageThreshold()
// Calculate patrol thresholds (watch = alert-15%, warning = alert-5%)
return PatrolThresholds{
NodeCPUWatch: clampThreshold(nodeCPU - 15),
NodeCPUWarning: clampThreshold(nodeCPU - 5),
NodeMemWatch: clampThreshold(nodeMem - 15),
NodeMemWarning: clampThreshold(nodeMem - 5),
GuestMemWatch: clampThreshold(guestMem - 12),
GuestMemWarning: clampThreshold(guestMem - 5),
GuestDiskWatch: clampThreshold(guestDisk - 15),
GuestDiskWarn: clampThreshold(guestDisk - 8),
GuestDiskCrit: clampThreshold(guestDisk - 3),
StorageWatch: clampThreshold(storage - 15),
StorageWarning: clampThreshold(storage - 8),
StorageCritical: clampThreshold(storage - 3),
}
}
// clampThreshold ensures a threshold is within valid range
func clampThreshold(v float64) float64 {
if v < 10 {
return 10 // Never go below 10%
}
if v > 99 {
return 99
}
return v
}
// PatrolConfig holds configuration for the AI patrol service
type PatrolConfig struct {
// Enabled controls whether background patrol runs
Enabled bool `json:"enabled"`
// Interval is how often to run AI patrol analysis
Interval time.Duration `json:"interval"`
// QuickCheckInterval is deprecated, kept for backwards compat with old configs
QuickCheckInterval time.Duration `json:"quick_check_interval,omitempty"`
// DeepAnalysisInterval is deprecated, kept for backwards compat with old configs
DeepAnalysisInterval time.Duration `json:"deep_analysis_interval,omitempty"`
// AnalyzeNodes controls whether to analyze Proxmox nodes
AnalyzeNodes bool `json:"analyze_nodes"`
// AnalyzeGuests controls whether to analyze VMs/containers
AnalyzeGuests bool `json:"analyze_guests"`
// AnalyzeDocker controls whether to analyze Docker hosts
AnalyzeDocker bool `json:"analyze_docker"`
// AnalyzeStorage controls whether to analyze storage
AnalyzeStorage bool `json:"analyze_storage"`
// AnalyzePBS controls whether to analyze PBS backup servers
AnalyzePBS bool `json:"analyze_pbs"`
// AnalyzeHosts controls whether to analyze agent hosts (RAID, sensors)
AnalyzeHosts bool `json:"analyze_hosts"`
}
// GetInterval returns the effective patrol interval, handling migration from old config
func (c PatrolConfig) GetInterval() time.Duration {
if c.Interval > 0 {
return c.Interval
}
// Migrate from old config: use QuickCheckInterval if set
if c.QuickCheckInterval > 0 {
return c.QuickCheckInterval
}
// Default to 15 minutes
return 15 * time.Minute
}
// DefaultPatrolConfig returns sensible defaults
func DefaultPatrolConfig() PatrolConfig {
return PatrolConfig{
Enabled: true,
Interval: 15 * time.Minute,
AnalyzeNodes: true,
AnalyzeGuests: true,
AnalyzeDocker: true,
AnalyzeStorage: true,
AnalyzePBS: true,
AnalyzeHosts: true,
}
}
// PatrolStatus represents the current state of the patrol service
type PatrolStatus struct {
Running bool `json:"running"`
Enabled bool `json:"enabled"`
LastPatrolAt *time.Time `json:"last_patrol_at,omitempty"`
NextPatrolAt *time.Time `json:"next_patrol_at,omitempty"`
LastDuration time.Duration `json:"last_duration_ms"`
ResourcesChecked int `json:"resources_checked"`
FindingsCount int `json:"findings_count"`
ErrorCount int `json:"error_count"`
Healthy bool `json:"healthy"`
IntervalMs int64 `json:"interval_ms"` // Patrol interval in milliseconds
}
// PatrolRunRecord represents a single patrol check run
type PatrolRunRecord struct {
ID string `json:"id"`
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
Duration time.Duration `json:"duration_ms"`
Type string `json:"type"` // Always "patrol" now (kept for backwards compat)
ResourcesChecked int `json:"resources_checked"`
// Breakdown by resource type
NodesChecked int `json:"nodes_checked"`
GuestsChecked int `json:"guests_checked"`
DockerChecked int `json:"docker_checked"`
StorageChecked int `json:"storage_checked"`
HostsChecked int `json:"hosts_checked"`
PBSChecked int `json:"pbs_checked"`
// Findings from this run
NewFindings int `json:"new_findings"`
ExistingFindings int `json:"existing_findings"`
ResolvedFindings int `json:"resolved_findings"`
AutoFixCount int `json:"auto_fix_count,omitempty"`
FindingsSummary string `json:"findings_summary"` // e.g., "All healthy" or "2 warnings, 1 critical"
FindingIDs []string `json:"finding_ids"` // IDs of findings from this run
ErrorCount int `json:"error_count"`
Status string `json:"status"` // "healthy", "issues_found", "error"
// AI Analysis details
AIAnalysis string `json:"ai_analysis,omitempty"` // The AI's raw response/analysis
InputTokens int `json:"input_tokens,omitempty"` // Tokens sent to AI
OutputTokens int `json:"output_tokens,omitempty"` // Tokens received from AI
}
// MaxPatrolRunHistory is the maximum number of patrol runs to keep in history
const MaxPatrolRunHistory = 100
// PatrolService runs background AI analysis of infrastructure
type PatrolService struct {
mu sync.RWMutex
aiService *Service
stateProvider StateProvider
thresholdProvider ThresholdProvider
config PatrolConfig
findings *FindingsStore
knowledgeStore *knowledge.Store // For per-resource notes in patrol context
metricsHistory MetricsHistoryProvider // For trend analysis and predictions
baselineStore *baseline.Store // For anomaly detection via learned baselines
changeDetector *ChangeDetector // For tracking infrastructure changes
remediationLog *RemediationLog // For tracking remediation actions
patternDetector *PatternDetector // For failure prediction from historical patterns
correlationDetector *CorrelationDetector // For multi-resource correlation
incidentStore *memory.IncidentStore // For incident timeline capture
// Unified intelligence facade - aggregates all subsystems for unified view
intelligence *Intelligence
// Cached thresholds (recalculated when thresholdProvider changes)
thresholds PatrolThresholds
// Runtime state
running bool
stopCh chan struct{}
configChanged chan struct{} // Signal when config changes to reset ticker
lastPatrol time.Time
lastDuration time.Duration
resourcesChecked int
errorCount int
// Patrol run history with persistence support
runHistoryStore *PatrolRunHistoryStore
// Live streaming support
streamMu sync.RWMutex
streamSubscribers map[chan PatrolStreamEvent]struct{}
currentOutput strings.Builder // Buffer for current streaming output
streamPhase string // "idle", "analyzing", "complete"
}
// PatrolStreamEvent represents a streaming update from the patrol
type PatrolStreamEvent struct {
Type string `json:"type"` // "start", "content", "phase", "complete", "error"
Content string `json:"content,omitempty"`
Phase string `json:"phase,omitempty"` // Current phase description
Tokens int `json:"tokens,omitempty"` // Token count so far
}
// NewPatrolService creates a new patrol service
func NewPatrolService(aiService *Service, stateProvider StateProvider) *PatrolService {
return &PatrolService{
aiService: aiService,
stateProvider: stateProvider,
config: DefaultPatrolConfig(),
findings: NewFindingsStore(),
thresholds: DefaultPatrolThresholds(),
stopCh: make(chan struct{}),
runHistoryStore: NewPatrolRunHistoryStore(MaxPatrolRunHistory),
streamSubscribers: make(map[chan PatrolStreamEvent]struct{}),
streamPhase: "idle",
}
}
// SetIncidentStore attaches an incident store for alert timeline capture.
func (p *PatrolService) SetIncidentStore(store *memory.IncidentStore) {
p.mu.Lock()
defer p.mu.Unlock()
p.incidentStore = store
}
// GetIncidentStore returns the incident store if configured.
func (p *PatrolService) GetIncidentStore() *memory.IncidentStore {
p.mu.RLock()
defer p.mu.RUnlock()
return p.incidentStore
}
// SetConfig updates the patrol configuration
func (p *PatrolService) SetConfig(cfg PatrolConfig) {
p.mu.Lock()
oldInterval := p.config.GetInterval()
p.config = cfg
newInterval := cfg.GetInterval()
configCh := p.configChanged
p.mu.Unlock()
// Signal config change if patrol is running and interval changed
if configCh != nil && newInterval != oldInterval {
select {
case configCh <- struct{}{}:
log.Info().
Dur("old_interval", oldInterval).
Dur("new_interval", newInterval).
Msg("Patrol interval updated, resetting ticker")
default:
// Channel full or not ready, config will be picked up on next cycle
}
}
}
// SetThresholdProvider sets the provider for user-configured alert thresholds
// This allows patrol to warn BEFORE alerts fire
func (p *PatrolService) SetThresholdProvider(provider ThresholdProvider) {
p.mu.Lock()
defer p.mu.Unlock()
p.thresholdProvider = provider
p.thresholds = CalculatePatrolThresholds(provider)
log.Debug().
Float64("storageWatch", p.thresholds.StorageWatch).
Float64("storageWarning", p.thresholds.StorageWarning).
Float64("storageCritical", p.thresholds.StorageCritical).
Msg("Patrol thresholds updated from alert config")
}
// SetFindingsPersistence enables findings persistence (load from and save to disk)
// This should be called before Start() to load any existing findings
func (p *PatrolService) SetFindingsPersistence(persistence FindingsPersistence) error {
p.mu.Lock()
findings := p.findings
p.mu.Unlock()
if findings != nil && persistence != nil {
if err := findings.SetPersistence(persistence); err != nil {
return err
}
log.Info().Msg("AI Patrol findings persistence enabled")
}
return nil
}
// SetRunHistoryPersistence enables patrol run history persistence (load from and save to disk)
// This should be called before Start() to load any existing history
func (p *PatrolService) SetRunHistoryPersistence(persistence PatrolHistoryPersistence) error {
p.mu.Lock()
store := p.runHistoryStore
p.mu.Unlock()
if store != nil && persistence != nil {
if err := store.SetPersistence(persistence); err != nil {
return err
}
log.Info().Msg("AI Patrol run history persistence enabled")
}
return nil
}
// SetKnowledgeStore sets the knowledge store for including per-resource notes in patrol context
func (p *PatrolService) SetKnowledgeStore(store *knowledge.Store) {
p.mu.Lock()
defer p.mu.Unlock()
p.knowledgeStore = store
}
// SetMetricsHistoryProvider sets the metrics history provider for enriched context
// This enables the patrol service to compute trends and predictions based on historical data
func (p *PatrolService) SetMetricsHistoryProvider(provider MetricsHistoryProvider) {
p.mu.Lock()
defer p.mu.Unlock()
p.metricsHistory = provider
log.Info().Msg("AI Patrol: Metrics history provider set for enriched context")
}
// SetBaselineStore sets the baseline store for anomaly detection
// This enables the patrol service to detect anomalies based on learned normal behavior
func (p *PatrolService) SetBaselineStore(store *baseline.Store) {
p.mu.Lock()
defer p.mu.Unlock()
p.baselineStore = store
log.Info().Msg("AI Patrol: Baseline store set for anomaly detection")
}
// GetBaselineStore returns the baseline store (for external baseline learning)
func (p *PatrolService) GetBaselineStore() *baseline.Store {
p.mu.RLock()
defer p.mu.RUnlock()
return p.baselineStore
}
// GetMetricsHistoryProvider returns the metrics history provider for trend analysis
func (p *PatrolService) GetMetricsHistoryProvider() MetricsHistoryProvider {
p.mu.RLock()
defer p.mu.RUnlock()
return p.metricsHistory
}
// SetChangeDetector sets the change detector for tracking infrastructure changes
func (p *PatrolService) SetChangeDetector(detector *ChangeDetector) {
p.mu.Lock()
defer p.mu.Unlock()
p.changeDetector = detector
log.Info().Msg("AI Patrol: Change detector set for operational memory")
}
// SetRemediationLog sets the remediation log for tracking fix attempts
func (p *PatrolService) SetRemediationLog(remLog *RemediationLog) {
p.mu.Lock()
defer p.mu.Unlock()
p.remediationLog = remLog
log.Info().Msg("AI Patrol: Remediation log set for operational memory")
}
// GetRemediationLog returns the remediation log (for logging actions)
func (p *PatrolService) GetRemediationLog() *RemediationLog {
p.mu.RLock()
defer p.mu.RUnlock()
return p.remediationLog
}
// SetPatternDetector sets the pattern detector for failure prediction
func (p *PatrolService) SetPatternDetector(detector *PatternDetector) {
p.mu.Lock()
defer p.mu.Unlock()
p.patternDetector = detector
log.Info().Msg("AI Patrol: Pattern detector set for failure prediction")
}
// GetPatternDetector returns the pattern detector
func (p *PatrolService) GetPatternDetector() *PatternDetector {
p.mu.RLock()
defer p.mu.RUnlock()
return p.patternDetector
}
// SetCorrelationDetector sets the correlation detector for multi-resource correlation
func (p *PatrolService) SetCorrelationDetector(detector *CorrelationDetector) {
p.mu.Lock()
defer p.mu.Unlock()
p.correlationDetector = detector
log.Info().Msg("AI Patrol: Correlation detector set for multi-resource analysis")
}
// GetCorrelationDetector returns the correlation detector
func (p *PatrolService) GetCorrelationDetector() *CorrelationDetector {
p.mu.RLock()
defer p.mu.RUnlock()
return p.correlationDetector
}
// GetChangeDetector returns the change detector
func (p *PatrolService) GetChangeDetector() *ChangeDetector {
p.mu.RLock()
defer p.mu.RUnlock()
return p.changeDetector
}
// GetConfig returns the current patrol configuration
func (p *PatrolService) GetConfig() PatrolConfig {
p.mu.RLock()
defer p.mu.RUnlock()
return p.config
}
// GetFindings returns the findings store
func (p *PatrolService) GetFindings() *FindingsStore {
return p.findings
}
// GetIntelligence returns the unified intelligence facade that aggregates all AI subsystems.
// This provides a single entry point for getting system-wide and resource-specific AI insights.
// The facade is lazily initialized and wires together existing subsystems.
func (p *PatrolService) GetIntelligence() *Intelligence {
p.mu.Lock()
defer p.mu.Unlock()
// Lazy initialization - build facade from existing subsystems
if p.intelligence == nil {
p.intelligence = NewIntelligence(IntelligenceConfig{})
}
// Always refresh subsystem pointers (they may have been set after intelligence was created)
p.intelligence.SetSubsystems(
p.findings,
p.patternDetector,
p.correlationDetector,
p.baselineStore,
p.incidentStore,
p.knowledgeStore,
p.changeDetector,
p.remediationLog,
)
if p.stateProvider != nil {
p.intelligence.SetStateProvider(p.stateProvider)
}
return p.intelligence
}
// GetStatus returns the current patrol status
func (p *PatrolService) GetStatus() PatrolStatus {
p.mu.RLock()
defer p.mu.RUnlock()
interval := p.config.GetInterval()
intervalMs := int64(interval / time.Millisecond)
// "Running" means an analysis is currently in progress, not just the service loop
// Check streamPhase to determine if we're actively analyzing
p.streamMu.RLock()
analysisInProgress := p.streamPhase == "analyzing"
p.streamMu.RUnlock()
status := PatrolStatus{
Running: analysisInProgress,
Enabled: p.config.Enabled,
LastDuration: p.lastDuration,
ResourcesChecked: p.resourcesChecked,
FindingsCount: len(p.findings.GetActive(FindingSeverityInfo)),
ErrorCount: p.errorCount,
IntervalMs: intervalMs,
}
if !p.lastPatrol.IsZero() {
status.LastPatrolAt = &p.lastPatrol
}
// Calculate next patrol time if we have interval and last patrol time
if interval > 0 && !p.lastPatrol.IsZero() {
next := p.lastPatrol.Add(interval)
status.NextPatrolAt = &next
}
summary := p.findings.GetSummary()
status.Healthy = summary.IsHealthy()
return status
}
// SubscribeToStream returns a channel that will receive streaming patrol events
func (p *PatrolService) SubscribeToStream() chan PatrolStreamEvent {
ch := make(chan PatrolStreamEvent, 100) // Buffered to prevent blocking
p.streamMu.Lock()
p.streamSubscribers[ch] = struct{}{}
// Send current state to new subscriber
if p.streamPhase != "idle" {
ch <- PatrolStreamEvent{
Type: "content",
Content: p.currentOutput.String(),
Phase: p.streamPhase,
}
}
p.streamMu.Unlock()
return ch
}
// UnsubscribeFromStream removes a subscriber
func (p *PatrolService) UnsubscribeFromStream(ch chan PatrolStreamEvent) {
p.streamMu.Lock()
delete(p.streamSubscribers, ch)
p.streamMu.Unlock()
close(ch)
}
// broadcast sends an event to all subscribers
func (p *PatrolService) broadcast(event PatrolStreamEvent) {
p.streamMu.RLock()
defer p.streamMu.RUnlock()
for ch := range p.streamSubscribers {
select {
case ch <- event:
default:
// Channel full, skip (don't block on slow consumers)
}
}
}
// appendStreamContent adds content to the current output and broadcasts it
func (p *PatrolService) appendStreamContent(content string) {
p.streamMu.Lock()
p.currentOutput.WriteString(content)
p.streamMu.Unlock()
p.broadcast(PatrolStreamEvent{
Type: "content",
Content: content,
})
}
// setStreamPhase updates the current phase (internal state tracking only)
// Does not broadcast phase changes - those are explicit via broadcast()
func (p *PatrolService) setStreamPhase(phase string) {
p.streamMu.Lock()
p.streamPhase = phase
if phase == "idle" {
p.currentOutput.Reset()
}
p.streamMu.Unlock()
// Note: We don't broadcast phase changes automatically
// The patrol explicitly broadcasts "start" and "complete" events
}
// GetCurrentStreamOutput returns the current buffered output (for late joiners)
func (p *PatrolService) GetCurrentStreamOutput() (string, string) {
p.streamMu.RLock()
defer p.streamMu.RUnlock()
return p.currentOutput.String(), p.streamPhase
}
// Start begins the background patrol loop
func (p *PatrolService) Start(ctx context.Context) {
p.mu.Lock()
if p.running {
p.mu.Unlock()
return
}
p.running = true
p.stopCh = make(chan struct{})
p.configChanged = make(chan struct{}, 1) // Buffered to allow non-blocking send
p.mu.Unlock()
log.Info().
Dur("interval", p.config.GetInterval()).
Msg("Starting AI Patrol Service")
go p.patrolLoop(ctx)
}
// Stop stops the patrol service
func (p *PatrolService) Stop() {
p.mu.Lock()
if !p.running {
p.mu.Unlock()
return
}
p.running = false
close(p.stopCh)
p.mu.Unlock()
log.Info().Msg("Stopping AI Patrol Service")
}
// patrolLoop is the main background loop
func (p *PatrolService) patrolLoop(ctx context.Context) {
// Run initial patrol shortly after startup, but only if one hasn't run recently
initialDelay := 30 * time.Second
select {
case <-time.After(initialDelay):
// Check if a patrol ran recently (within last hour) to avoid wasting tokens on restarts
runHistory := p.GetRunHistory(1)
skipInitial := false
if len(runHistory) > 0 {
lastRun := runHistory[0]
timeSinceLastRun := time.Since(lastRun.CompletedAt)
if timeSinceLastRun < 1*time.Hour {
log.Info().
Dur("time_since_last", timeSinceLastRun).
Msg("AI Patrol: Skipping initial patrol - recent run exists")
skipInitial = true
}
}
if !skipInitial {
p.runPatrol(ctx)
}
case <-p.stopCh:
return
case <-ctx.Done():
return
}
p.mu.RLock()
interval := p.config.GetInterval()
configCh := p.configChanged
p.mu.RUnlock()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
p.runPatrol(ctx)
case <-configCh:
// Config changed - reset ticker with new interval
p.mu.RLock()
newInterval := p.config.GetInterval()
p.mu.RUnlock()
if newInterval != interval {
interval = newInterval
ticker.Reset(interval)
log.Info().
Dur("interval", interval).
Msg("Patrol ticker reset to new interval")
}
case <-p.stopCh:
return
case <-ctx.Done():
return
}
}
}
// runPatrol executes a patrol run
func (p *PatrolService) runPatrol(ctx context.Context) {
p.mu.RLock()
cfg := p.config
p.mu.RUnlock()
if !cfg.Enabled {
return
}
// Check if AI service is enabled
if p.aiService == nil || !p.aiService.IsEnabled() {
log.Debug().Msg("AI Patrol: AI service not enabled, skipping patrol")
return
}
start := time.Now()
patrolType := "patrol" // Simplified - no longer distinguishing quick/deep
log.Debug().Msg("AI Patrol: Starting patrol run")
// Track run statistics
var runStats struct {
resourceCount int
nodesChecked int
guestsChecked int
dockerChecked int
storageChecked int
hostsChecked int
pbsChecked int
newFindings int
existingFindings int
findingIDs []string
errors int
aiAnalysis *AIAnalysisResult // Stores the AI's analysis for the run record
}
var newFindings []*Finding
// Get current state
if p.stateProvider == nil {
log.Warn().Msg("AI Patrol: No state provider available")
return
}
state := p.stateProvider.GetState()
// Helper to track findings
trackFinding := func(f *Finding) bool {
isNew := p.findings.Add(f)
if isNew {
runStats.newFindings++
newFindings = append(newFindings, f)
log.Info().
Str("finding_id", f.ID).
Str("severity", string(f.Severity)).
Str("resource", f.ResourceName).
Str("title", f.Title).
Msg("AI Patrol: New finding")
} else {
runStats.existingFindings++
}
runStats.findingIDs = append(runStats.findingIDs, f.ID)
return isNew
}
// 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
hasPatrolFeature := p.aiService == nil || p.aiService.HasLicenseFeature(FeatureAIPatrol)
// Check license before running LLM analysis (Pro feature)
if !hasPatrolFeature {
log.Debug().Msg("AI Patrol: Running heuristic analysis only - requires Pulse Pro license for LLM analysis")
for _, f := range p.runHeuristicAnalysis(state) {
trackFinding(f)
}
} else {
// Run AI analysis using the LLM - this is the ONLY analysis method for Pro users
// 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")
runStats.errors++
// Create a finding to surface this error to the user
errMsg := aiErr.Error()
var title, description, recommendation string
if strings.Contains(errMsg, "Insufficient Balance") || strings.Contains(errMsg, "402") {
title = "AI Patrol: Insufficient API credits"
description = "The AI patrol cannot analyze your infrastructure because your AI provider account has insufficient credits."
recommendation = "Add credits to your AI provider account (DeepSeek, OpenAI, etc.) or switch to a different provider in AI Settings."
} else if strings.Contains(errMsg, "401") || strings.Contains(errMsg, "Unauthorized") {
title = "AI Patrol: Invalid API key"
description = "The AI patrol cannot analyze your infrastructure because the API key is invalid or expired."
recommendation = "Check your API key in AI Settings and verify it is correct."
} else if strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "429") {
title = "AI Patrol: Rate limited"
description = "The AI patrol is being rate limited by your AI provider. Analysis will be retried on the next patrol run."
recommendation = "Wait for the rate limit to reset, or consider upgrading your API plan for higher limits."
} else {
title = "AI Patrol: Analysis failed"
description = fmt.Sprintf("The AI patrol encountered an error while analyzing your infrastructure: %s", errMsg)
recommendation = "Check your AI settings and API key. If the problem persists, check the logs for more details."
}
errorFinding := &Finding{
ID: generateFindingID("ai-service", "reliability", "ai-patrol-error"),
Key: "ai-patrol-error",
Severity: "warning",
Category: "reliability",
ResourceID: "ai-service",
ResourceName: "AI Patrol Service",
ResourceType: "service",
Title: title,
Description: description,
Recommendation: recommendation,
Evidence: fmt.Sprintf("Error: %s", errMsg),
DetectedAt: time.Now(),
LastSeenAt: time.Now(),
}
trackFinding(errorFinding)
} else if aiResult != nil {
runStats.aiAnalysis = aiResult
for _, f := range aiResult.Findings {
trackFinding(f)
}
}
}
// Auto-fix with runbooks when enabled (Pro only)
var runbookResolved int
autoFixEnabled := false
if p.aiService != nil {
if aiCfg := p.aiService.GetAIConfig(); aiCfg != nil {
autoFixEnabled = aiCfg.PatrolAutoFix
}
}
_ = autoFixEnabled // Auto-fix via runbooks removed - dynamic AI remediation handles this
// Auto-resolve findings that weren't seen in this patrol run
var resolvedCount int
if hasPatrolFeature {
resolvedCount = p.autoResolveStaleFindings(start, nil)
// Cleanup old resolved findings (only when licensed to modify AI findings)
cleaned := p.findings.Cleanup(24 * time.Hour)
if cleaned > 0 {
log.Debug().Int("cleaned", cleaned).Msg("AI Patrol: Cleaned up old findings")
}
} else {
resolvedCount = p.autoResolveStaleFindings(start, map[string]bool{"heuristic": true})
}
resolvedCount += runbookResolved
duration := time.Since(start)
completedAt := time.Now()
// Build findings summary string
summary := p.findings.GetSummary()
var findingsSummaryStr string
var status string
// Only count critical and warning as active issues (watch/info are filtered from UI)
totalActive := summary.Critical + summary.Warning
if totalActive == 0 {
findingsSummaryStr = "All healthy"
status = "healthy"
} else {
parts := []string{}
if summary.Critical > 0 {
parts = append(parts, fmt.Sprintf("%d critical", summary.Critical))
}
if summary.Warning > 0 {
parts = append(parts, fmt.Sprintf("%d warning", summary.Warning))
}
findingsSummaryStr = fmt.Sprintf("%s", joinParts(parts))
if summary.Critical > 0 {
status = "critical"
} else {
status = "issues_found"
}
}
if runStats.errors > 0 {
status = "error"
// Don't claim "All healthy" if there were errors - the patrol didn't complete properly
if findingsSummaryStr == "All healthy" {
findingsSummaryStr = fmt.Sprintf("Analysis incomplete (%d errors)", runStats.errors)
}
}
// Create run record
runRecord := PatrolRunRecord{
ID: fmt.Sprintf("%d", start.UnixNano()),
StartedAt: start,
CompletedAt: completedAt,
Duration: duration,
Type: patrolType,
ResourcesChecked: runStats.resourceCount,
NodesChecked: runStats.nodesChecked,
GuestsChecked: runStats.guestsChecked,
DockerChecked: runStats.dockerChecked,
StorageChecked: runStats.storageChecked,
HostsChecked: runStats.hostsChecked,
PBSChecked: runStats.pbsChecked,
NewFindings: runStats.newFindings,
ExistingFindings: runStats.existingFindings,
ResolvedFindings: resolvedCount,
AutoFixCount: runbookResolved,
FindingsSummary: findingsSummaryStr,
FindingIDs: runStats.findingIDs,
ErrorCount: runStats.errors,
Status: status,
}
// Add AI analysis details if available
if runStats.aiAnalysis != nil {
runRecord.AIAnalysis = runStats.aiAnalysis.Response
runRecord.InputTokens = runStats.aiAnalysis.InputTokens
runRecord.OutputTokens = runStats.aiAnalysis.OutputTokens
log.Debug().
Int("response_length", len(runStats.aiAnalysis.Response)).
Int("input_tokens", runStats.aiAnalysis.InputTokens).
Int("output_tokens", runStats.aiAnalysis.OutputTokens).
Msg("AI Patrol: Storing AI analysis in run record")
} else {
log.Debug().Msg("AI Patrol: No AI analysis to store (aiAnalysis is nil)")
}
p.mu.Lock()
p.lastPatrol = completedAt
p.lastDuration = duration
p.resourcesChecked = runStats.resourceCount
p.errorCount = runStats.errors
p.mu.Unlock()
// Add to history store (handles persistence automatically)
p.runHistoryStore.Add(runRecord)
log.Info().
Str("type", patrolType).
Dur("duration", duration).
Int("resources", runStats.resourceCount).
Int("new_findings", runStats.newFindings).
Int("resolved", resolvedCount).
Int("critical", summary.Critical).
Int("warning", summary.Warning).
Int("watch", summary.Watch).
Msg("AI Patrol: Completed patrol run")
}
// joinParts joins string parts with commas and "and" for the last element
func joinParts(parts []string) string {
if len(parts) == 0 {
return ""
}
if len(parts) == 1 {
return parts[0]
}
if len(parts) == 2 {
return parts[0] + " and " + parts[1]
}
return fmt.Sprintf("%s, and %s",
fmt.Sprintf("%s", parts[0:len(parts)-1]),
parts[len(parts)-1])
}
// generateFindingID creates a stable ID for a finding based on resource and issue
func generateFindingID(resourceID, category, issue string) string {
hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%s", resourceID, category, issue)))
return fmt.Sprintf("%x", hash[:8])
}
func (p *PatrolService) runHeuristicAnalysis(state models.StateSnapshot) []*Finding {
p.mu.RLock()
cfg := p.config
p.mu.RUnlock()
var findings []*Finding
if cfg.AnalyzeNodes {
for _, node := range state.Nodes {
findings = append(findings, p.analyzeNode(node)...)
}
}
if cfg.AnalyzeGuests {
for _, vm := range state.VMs {
var lastBackup *time.Time
if !vm.LastBackup.IsZero() {
lastBackup = &vm.LastBackup
}
findings = append(findings, p.analyzeGuest(
vm.ID, vm.Name, "vm", vm.Node, vm.Status,
vm.CPU, vm.Memory.Usage, vm.Disk.Usage,
lastBackup, vm.Template,
)...)
}
for _, ct := range state.Containers {
var lastBackup *time.Time
if !ct.LastBackup.IsZero() {
lastBackup = &ct.LastBackup
}
findings = append(findings, p.analyzeGuest(
ct.ID, ct.Name, "container", ct.Node, ct.Status,
ct.CPU, ct.Memory.Usage, ct.Disk.Usage,
lastBackup, ct.Template,
)...)
}
}
if cfg.AnalyzeDocker {
for _, host := range state.DockerHosts {
findings = append(findings, p.analyzeDockerHost(host)...)
}
}
if cfg.AnalyzeStorage {
for _, storage := range state.Storage {
findings = append(findings, p.analyzeStorage(storage)...)
}
}
if cfg.AnalyzePBS {
for _, pbs := range state.PBSInstances {
findings = append(findings, p.analyzePBSInstance(pbs, state.PBSBackups)...)
}
}
if cfg.AnalyzeHosts {
for _, host := range state.Hosts {
findings = append(findings, p.analyzeHost(host)...)
}
}
for _, finding := range findings {
if finding != nil && finding.Source == "" {
finding.Source = "heuristic"
}
}
return findings
}
// analyzeNode checks a Proxmox node for issues
func (p *PatrolService) analyzeNode(node models.Node) []*Finding {
var findings []*Finding
// Calculate memory usage from Memory struct (as percentage 0-100)
var memUsagePct float64
if node.Memory.Total > 0 {
memUsagePct = float64(node.Memory.Used) / float64(node.Memory.Total) * 100
}
// CPU as percentage (node.CPU is 0-1 ratio from Proxmox)
cpuPct := node.CPU * 100
// Check for offline nodes
if node.Status == "offline" || node.Status == "unknown" {
findings = append(findings, &Finding{
ID: generateFindingID(node.ID, "reliability", "offline"),
Key: "node-offline",
Severity: FindingSeverityCritical,
Category: FindingCategoryReliability,
ResourceID: node.ID,
ResourceName: node.Name,
ResourceType: "node",
Title: "Node offline",
Description: fmt.Sprintf("Node '%s' is not responding", node.Name),
Recommendation: "Check network connectivity, SSH access, and Proxmox services on the node",
})
}
// High CPU - use dynamic thresholds from user's alert config
if cpuPct > p.thresholds.NodeCPUWatch {
severity := FindingSeverityWatch
if cpuPct > p.thresholds.NodeCPUWarning {
severity = FindingSeverityWarning
}
findings = append(findings, &Finding{
ID: generateFindingID(node.ID, "performance", "high-cpu"),
Key: "high-cpu",
Severity: severity,
Category: FindingCategoryPerformance,
ResourceID: node.ID,
ResourceName: node.Name,
ResourceType: "node",
Title: "High CPU usage",
Description: fmt.Sprintf("Node '%s' CPU at %.0f%%", node.Name, cpuPct),
Recommendation: "Check which VMs/containers are consuming CPU. Consider load balancing.",
Evidence: fmt.Sprintf("CPU: %.1f%%", cpuPct),
})
}
// High memory - use dynamic thresholds
if memUsagePct > p.thresholds.NodeMemWatch {
severity := FindingSeverityWatch
if memUsagePct > p.thresholds.NodeMemWarning {
severity = FindingSeverityWarning
}
findings = append(findings, &Finding{
ID: generateFindingID(node.ID, "performance", "high-memory"),
Key: "high-memory",
Severity: severity,
Category: FindingCategoryPerformance,
ResourceID: node.ID,
ResourceName: node.Name,
ResourceType: "node",
Title: "High memory usage",
Description: fmt.Sprintf("Node '%s' memory at %.0f%%", node.Name, memUsagePct),
Recommendation: "Consider migrating some VMs to other nodes or increasing node RAM",
Evidence: fmt.Sprintf("Memory: %.1f%%", memUsagePct),
})
}
return findings
}
// analyzeGuest checks a VM or container for issues
// Note: cpu is 0-1 ratio, memUsage and diskUsage are already 0-100 percentages from Memory.Usage/Disk.Usage
func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string,
cpu, memUsage, diskUsage float64, lastBackup *time.Time, template bool) []*Finding {
var findings []*Finding
// Skip templates
if template {
return findings
}
// memUsage and diskUsage are already percentages (0-100)
memPct := memUsage
diskPct := diskUsage
// High memory (sustained) - use dynamic thresholds
if memPct > p.thresholds.GuestMemWatch {
severity := FindingSeverityWatch
if memPct > p.thresholds.GuestMemWarning {
severity = FindingSeverityWarning
}
findings = append(findings, &Finding{
ID: generateFindingID(id, "performance", "high-memory"),
Key: "high-memory",
Severity: severity,
Category: FindingCategoryPerformance,
ResourceID: id,
ResourceName: name,
ResourceType: guestType,
Node: node,
Title: "High memory usage",
Description: fmt.Sprintf("'%s' memory at %.0f%% - risk of OOM", name, memPct),
Recommendation: "Consider increasing allocated RAM or investigating memory-hungry processes",
Evidence: fmt.Sprintf("Memory: %.1f%%", memPct),
})
}
// High disk usage - use dynamic thresholds
if diskPct > p.thresholds.GuestDiskWatch {
severity := FindingSeverityWatch
if diskPct > p.thresholds.GuestDiskWarn {
severity = FindingSeverityWarning
}
if diskPct > p.thresholds.GuestDiskCrit {
severity = FindingSeverityCritical
}
findings = append(findings, &Finding{
ID: generateFindingID(id, "capacity", "high-disk"),
Key: "high-disk",
Severity: severity,
Category: FindingCategoryCapacity,
ResourceID: id,
ResourceName: name,
ResourceType: guestType,
Node: node,
Title: "High disk usage",
Description: fmt.Sprintf("'%s' disk at %.0f%%", name, diskPct),
Recommendation: "Clean up old files, logs, or docker images. Consider expanding disk.",
Evidence: fmt.Sprintf("Disk: %.1f%%", diskPct),
})
}
// Backup check (only for running guests)
if status == "running" && lastBackup != nil {
daysSinceBackup := time.Since(*lastBackup).Hours() / 24
if daysSinceBackup > 14 {
severity := FindingSeverityWatch
if daysSinceBackup > 30 {
severity = FindingSeverityWarning
}
findings = append(findings, &Finding{
ID: generateFindingID(id, "backup", "stale"),
Key: "backup-stale",
Severity: severity,
Category: FindingCategoryBackup,
ResourceID: id,
ResourceName: name,
ResourceType: guestType,
Node: node,
Title: "Backup overdue",
Description: fmt.Sprintf("'%s' hasn't been backed up in %.0f days", name, daysSinceBackup),
Recommendation: "Check backup job configuration or run a manual backup",
Evidence: fmt.Sprintf("Last backup: %s", lastBackup.Format("2006-01-02")),
})
}
} else if status == "running" && lastBackup == nil {
findings = append(findings, &Finding{
ID: generateFindingID(id, "backup", "never"),
Key: "backup-never",
Severity: FindingSeverityWarning,
Category: FindingCategoryBackup,
ResourceID: id,
ResourceName: name,
ResourceType: guestType,
Node: node,
Title: "Never backed up",
Description: fmt.Sprintf("'%s' has no backup history", name),
Recommendation: "Configure backup job for this guest",
})
}
return findings
}
// analyzeDockerHost checks a Docker host for issues
func (p *PatrolService) analyzeDockerHost(host models.DockerHost) []*Finding {
var findings []*Finding
hostName := host.Hostname
if host.DisplayName != "" {
hostName = host.DisplayName
}
// Host offline
if host.Status != "online" && host.Status != "connected" {
findings = append(findings, &Finding{
ID: generateFindingID(host.ID, "reliability", "offline"),
Key: "docker-host-offline",
Severity: FindingSeverityCritical,
Category: FindingCategoryReliability,
ResourceID: host.ID,
ResourceName: hostName,
ResourceType: "docker_host",
Title: "Docker host offline",
Description: fmt.Sprintf("Docker host '%s' is not responding", hostName),
Recommendation: "Check network connectivity and docker-agent service",
})
}
// Check individual containers
for _, c := range host.Containers {
// Restarting containers
if c.State == "restarting" || c.RestartCount > 3 {
findings = append(findings, &Finding{
ID: generateFindingID(c.ID, "reliability", "restart-loop"),
Key: "restart-loop",
Severity: FindingSeverityWarning,
Category: FindingCategoryReliability,
ResourceID: c.ID,
ResourceName: c.Name,
ResourceType: "docker_container",
Node: hostName,
Title: "Container restart loop",
Description: fmt.Sprintf("Container '%s' has restarted %d times", c.Name, c.RestartCount),
Recommendation: "Check container logs: docker logs " + c.Name,
Evidence: fmt.Sprintf("State: %s, Restarts: %d", c.State, c.RestartCount),
})
}
// High memory containers
if c.MemoryPercent > 90 {
findings = append(findings, &Finding{
ID: generateFindingID(c.ID, "performance", "high-memory"),
Key: "high-memory",
Severity: FindingSeverityWatch,
Category: FindingCategoryPerformance,
ResourceID: c.ID,
ResourceName: c.Name,
ResourceType: "docker_container",
Node: hostName,
Title: "High memory usage",
Description: fmt.Sprintf("Container '%s' using %.0f%% of allocated memory", c.Name, c.MemoryPercent),
Recommendation: "Consider increasing container memory limit",
Evidence: fmt.Sprintf("Memory: %.1f%%", c.MemoryPercent),
})
}
}
return findings
}
// analyzeStorage checks storage for issues
func (p *PatrolService) analyzeStorage(storage models.Storage) []*Finding {
var findings []*Finding
// Note: storage.Usage is already a percentage (0-100, e.g. 85.5 means 85.5%)
// If Usage is 0 but we have bytes data, calculate it as percentage
usage := storage.Usage
if usage == 0 && storage.Total > 0 {
usage = float64(storage.Used) / float64(storage.Total) * 100
}
// High storage usage - use dynamic thresholds from user's alert config
if usage > p.thresholds.StorageWatch {
severity := FindingSeverityWatch
if usage > p.thresholds.StorageWarning {
severity = FindingSeverityWarning
}
if usage > p.thresholds.StorageCritical {
severity = FindingSeverityCritical
}
findings = append(findings, &Finding{
ID: generateFindingID(storage.ID, "capacity", "high-usage"),
Key: "storage-high-usage",
Severity: severity,
Category: FindingCategoryCapacity,
ResourceID: storage.ID,
ResourceName: storage.Name,
ResourceType: "storage",
Title: "Storage filling up",
Description: fmt.Sprintf("Storage '%s' at %.0f%% capacity", storage.Name, usage),
Recommendation: "Clean up old backups, snapshots, or unused disk images",
Evidence: fmt.Sprintf("Usage: %.1f%%", usage),
})
}
return findings
}
// autoResolveHealthyResources marks findings as resolved when they weren't seen in the current patrol
// patrolStartTime is used to determine which findings are stale (LastSeenAt < patrolStartTime)
// Returns the count of findings that were resolved
func (p *PatrolService) autoResolveStaleFindings(patrolStartTime time.Time, sourceAllowlist map[string]bool) int {
// Get all active findings and check if they're stale
activeFindings := p.findings.GetActive(FindingSeverityInfo)
resolvedCount := 0
for _, f := range activeFindings {
if sourceAllowlist != nil {
if !sourceAllowlist[f.Source] {
continue
}
}
// If the finding wasn't updated during this patrol (LastSeenAt is before patrol started),
// it means the condition that caused it has been resolved
if f.LastSeenAt.Before(patrolStartTime) {
if p.findings.Resolve(f.ID, true) {
resolvedCount++
log.Info().
Str("finding_id", f.ID).
Str("resource", f.ResourceName).
Str("title", f.Title).
Msg("AI Patrol: Auto-resolved finding")
}
}
}
return resolvedCount
}
// GetFindingsForResource returns active findings for a specific resource
func (p *PatrolService) GetFindingsForResource(resourceID string) []*Finding {
return p.findings.GetByResource(resourceID)
}
// GetFindingsSummary returns a summary of all findings
func (p *PatrolService) GetFindingsSummary() FindingsSummary {
return p.findings.GetSummary()
}
// ResolveFinding marks a finding as resolved with a resolution note
// This is called when the AI successfully fixes an issue
func (p *PatrolService) ResolveFinding(findingID string, resolutionNote string) error {
if findingID == "" {
return fmt.Errorf("finding ID is required")
}
// Get the finding first to update its resolution note
finding := p.findings.Get(findingID)
if finding == nil {
return fmt.Errorf("finding not found: %s", findingID)
}
// Update the user note with the resolution
finding.UserNote = resolutionNote
// Mark as resolved (not auto-resolved since user/AI initiated it)
if !p.findings.Resolve(findingID, false) {
return fmt.Errorf("failed to resolve finding: %s", findingID)
}
log.Info().
Str("finding_id", findingID).
Str("resolution_note", resolutionNote).
Msg("AI resolved finding")
return nil
}
// GetRunHistory returns the history of patrol runs
// If limit is > 0, returns at most that many records
func (p *PatrolService) GetRunHistory(limit int) []PatrolRunRecord {
if limit <= 0 {
return p.runHistoryStore.GetAll()
}
return p.runHistoryStore.GetRecent(limit)
}
// GetAllFindings returns all active findings sorted by severity
// Only returns critical and warning findings - watch/info are filtered out as noise
func (p *PatrolService) GetAllFindings() []*Finding {
findings := p.findings.GetActive(FindingSeverityWarning)
// Sort by severity (critical first) then by time
severityOrder := map[FindingSeverity]int{
FindingSeverityCritical: 0,
FindingSeverityWarning: 1,
FindingSeverityWatch: 2,
FindingSeverityInfo: 3,
}
sort.Slice(findings, func(i, j int) bool {
if severityOrder[findings[i].Severity] != severityOrder[findings[j].Severity] {
return severityOrder[findings[i].Severity] < severityOrder[findings[j].Severity]
}
return findings[i].DetectedAt.After(findings[j].DetectedAt)
})
return findings
}
// GetFindingsHistory returns all findings including resolved ones for history display
// Optionally filter by startTime
func (p *PatrolService) GetFindingsHistory(startTime *time.Time) []*Finding {
findings := p.findings.GetAll(startTime)
// Sort by detected time (newest first)
sort.Slice(findings, func(i, j int) bool {
return findings[i].DetectedAt.After(findings[j].DetectedAt)
})
return findings
}
// ForcePatrol triggers an immediate patrol run
// The deep parameter is kept for API backwards compatibility but is ignored
// Uses context.Background() since this runs async after the HTTP response
func (p *PatrolService) ForcePatrol(ctx context.Context, deep bool) {
go p.runPatrol(context.Background())
}
// analyzePBSInstance checks a PBS backup server for issues
func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups []models.PBSBackup) []*Finding {
var findings []*Finding
pbsName := pbs.Name
if pbsName == "" {
pbsName = pbs.Host
}
// Check PBS connectivity
if pbs.Status != "online" && pbs.Status != "connected" && pbs.Status != "" {
findings = append(findings, &Finding{
ID: generateFindingID(pbs.ID, "reliability", "offline"),
Key: "pbs-offline",
Severity: FindingSeverityCritical,
Category: FindingCategoryReliability,
ResourceID: pbs.ID,
ResourceName: pbsName,
ResourceType: "pbs",
Title: "PBS server offline",
Description: fmt.Sprintf("Proxmox Backup Server '%s' is not responding", pbsName),
Recommendation: "Check network connectivity and PBS service status",
})
}
// Check each datastore capacity
for _, ds := range pbs.Datastores {
usage := ds.Usage
if usage == 0 && ds.Total > 0 {
usage = float64(ds.Used) / float64(ds.Total) * 100
}
// PBS datastores should trigger earlier than regular storage
// since running out of backup space is critical
if usage > p.thresholds.StorageWatch {
severity := FindingSeverityWatch
if usage > p.thresholds.StorageWarning {
severity = FindingSeverityWarning
}
if usage > p.thresholds.StorageCritical {
severity = FindingSeverityCritical
}
findings = append(findings, &Finding{
ID: generateFindingID(pbs.ID+":"+ds.Name, "capacity", "high-usage"),
Key: "pbs-datastore-high-usage",
Severity: severity,
Category: FindingCategoryCapacity,
ResourceID: pbs.ID + ":" + ds.Name,
ResourceName: fmt.Sprintf("%s/%s", pbsName, ds.Name),
ResourceType: "pbs_datastore",
Title: "PBS datastore filling up",
Description: fmt.Sprintf("Datastore '%s' on PBS '%s' at %.0f%% capacity", ds.Name, pbsName, usage),
Recommendation: "Run garbage collection, prune old backups, or expand storage",
Evidence: fmt.Sprintf("Usage: %.1f%%", usage),
})
}
// Check for datastore errors
if ds.Error != "" {
findings = append(findings, &Finding{
ID: generateFindingID(pbs.ID+":"+ds.Name, "reliability", "error"),
Key: "pbs-datastore-error",
Severity: FindingSeverityCritical,
Category: FindingCategoryReliability,
ResourceID: pbs.ID + ":" + ds.Name,
ResourceName: fmt.Sprintf("%s/%s", pbsName, ds.Name),
ResourceType: "pbs_datastore",
Title: "PBS datastore error",
Description: fmt.Sprintf("Datastore '%s' has an error: %s", ds.Name, ds.Error),
Recommendation: "Check PBS server logs and datastore configuration",
Evidence: ds.Error,
})
}
}
// Check for backup staleness per datastore
// Build a map of latest backup time per datastore
datastoreLastBackup := make(map[string]time.Time)
for _, backup := range allBackups {
if backup.Instance != pbs.ID && backup.Instance != pbs.Name {
continue
}
dsKey := backup.Datastore
if backup.BackupTime.After(datastoreLastBackup[dsKey]) {
datastoreLastBackup[dsKey] = backup.BackupTime
}
}
for _, ds := range pbs.Datastores {
lastBackup, hasBackups := datastoreLastBackup[ds.Name]
if !hasBackups {
// No backups found for this datastore - might be intentional (empty datastore)
// Only warn if datastore has actual content
if ds.Used > 0 {
findings = append(findings, &Finding{
ID: generateFindingID(pbs.ID+":"+ds.Name, "backup", "no-recent"),
Key: "pbs-backup-no-recent",
Severity: FindingSeverityWatch,
Category: FindingCategoryBackup,
ResourceID: pbs.ID + ":" + ds.Name,
ResourceName: fmt.Sprintf("%s/%s", pbsName, ds.Name),
ResourceType: "pbs_datastore",
Title: "No recent backup metadata",
Description: fmt.Sprintf("Datastore '%s' has content but no recent backup entries visible", ds.Name),
Recommendation: "Verify backup jobs are configured and running",
})
}
continue
}
hoursSinceBackup := time.Since(lastBackup).Hours()
if hoursSinceBackup > 48 {
severity := FindingSeverityWatch
if hoursSinceBackup > 72 {
severity = FindingSeverityWarning
}
if hoursSinceBackup > 168 { // 7 days
severity = FindingSeverityCritical
}
findings = append(findings, &Finding{
ID: generateFindingID(pbs.ID+":"+ds.Name, "backup", "stale"),
Key: "pbs-backup-stale",
Severity: severity,
Category: FindingCategoryBackup,
ResourceID: pbs.ID + ":" + ds.Name,
ResourceName: fmt.Sprintf("%s/%s", pbsName, ds.Name),
ResourceType: "pbs_datastore",
Title: "Stale backups",
Description: fmt.Sprintf("No backups to '%s/%s' in %.0f hours", pbsName, ds.Name, hoursSinceBackup),
Recommendation: "Check backup job schedule and logs for failures",
Evidence: fmt.Sprintf("Last backup: %s", lastBackup.Format("2006-01-02 15:04")),
})
}
}
// 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"),
Key: "pbs-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"),
Key: "pbs-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,
})
}
}
return findings
}
// analyzeHost checks an agent host for issues (RAID, sensors, connectivity)
func (p *PatrolService) analyzeHost(host models.Host) []*Finding {
var findings []*Finding
hostName := host.DisplayName
if hostName == "" {
hostName = host.Hostname
}
// Check host connectivity
if host.Status != "online" && host.Status != "connected" && host.Status != "" {
findings = append(findings, &Finding{
ID: generateFindingID(host.ID, "reliability", "offline"),
Key: "host-offline",
Severity: FindingSeverityCritical,
Category: FindingCategoryReliability,
ResourceID: host.ID,
ResourceName: hostName,
ResourceType: "host",
Title: "Host agent offline",
Description: fmt.Sprintf("Host '%s' agent is not reporting", hostName),
Recommendation: "Check network connectivity and pulse-agent service status",
})
}
// Check RAID arrays
for _, raid := range host.RAID {
raidName := raid.Device
if raid.Name != "" {
raidName = raid.Name
}
// Check for degraded/failed state
switch raid.State {
case "degraded", "DEGRADED":
findings = append(findings, &Finding{
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-degraded"),
Key: "raid-degraded",
Severity: FindingSeverityCritical,
Category: FindingCategoryReliability,
ResourceID: host.ID + ":" + raid.Device,
ResourceName: fmt.Sprintf("%s/%s", hostName, raidName),
ResourceType: "host_raid",
Title: "RAID array degraded",
Description: fmt.Sprintf("RAID array '%s' on '%s' is degraded (%d/%d devices active)", raidName, hostName, raid.ActiveDevices, raid.TotalDevices),
Recommendation: "Replace failed drive and initiate rebuild. Check dmesg for drive errors.",
Evidence: fmt.Sprintf("State: %s, Active: %d/%d, Failed: %d", raid.State, raid.ActiveDevices, raid.TotalDevices, raid.FailedDevices),
})
case "recovering", "rebuilding", "resyncing", "RECOVERING":
severity := FindingSeverityWarning
if raid.RebuildPercent < 50 {
severity = FindingSeverityWatch // Early in rebuild, less urgent
}
findings = append(findings, &Finding{
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-rebuilding"),
Key: "raid-rebuilding",
Severity: severity,
Category: FindingCategoryReliability,
ResourceID: host.ID + ":" + raid.Device,
ResourceName: fmt.Sprintf("%s/%s", hostName, raidName),
ResourceType: "host_raid",
Title: "RAID array rebuilding",
Description: fmt.Sprintf("RAID array '%s' on '%s' is rebuilding (%.1f%% complete)", raidName, hostName, raid.RebuildPercent),
Recommendation: "Monitor rebuild progress. Avoid heavy I/O if possible. Array is vulnerable until rebuild completes.",
Evidence: fmt.Sprintf("State: %s, Progress: %.1f%%, Speed: %s", raid.State, raid.RebuildPercent, raid.RebuildSpeed),
})
case "inactive", "INACTIVE":
findings = append(findings, &Finding{
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-inactive"),
Key: "raid-inactive",
Severity: FindingSeverityCritical,
Category: FindingCategoryReliability,
ResourceID: host.ID + ":" + raid.Device,
ResourceName: fmt.Sprintf("%s/%s", hostName, raidName),
ResourceType: "host_raid",
Title: "RAID array inactive",
Description: fmt.Sprintf("RAID array '%s' on '%s' is inactive", raidName, hostName),
Recommendation: "RAID array is not running. Check mdadm status and system logs.",
Evidence: fmt.Sprintf("State: %s", raid.State),
})
}
// Check for failed devices even if array state is "clean"
if raid.FailedDevices > 0 && raid.State != "degraded" {
findings = append(findings, &Finding{
ID: generateFindingID(host.ID+":"+raid.Device, "reliability", "raid-failed-devices"),
Key: "raid-failed-devices",
Severity: FindingSeverityWarning,
Category: FindingCategoryReliability,
ResourceID: host.ID + ":" + raid.Device,
ResourceName: fmt.Sprintf("%s/%s", hostName, raidName),
ResourceType: "host_raid",
Title: "RAID has failed devices",
Description: fmt.Sprintf("RAID array '%s' on '%s' has %d failed device(s)", raidName, hostName, raid.FailedDevices),
Recommendation: "Replace failed drives. Array may still be operational due to spares.",
Evidence: fmt.Sprintf("Failed: %d, Spare: %d", raid.FailedDevices, raid.SpareDevices),
})
}
}
// Check high temperature
if len(host.Sensors.TemperatureCelsius) > 0 {
for sensorName, temp := range host.Sensors.TemperatureCelsius {
if temp > 85 {
severity := FindingSeverityWarning
if temp > 95 {
severity = FindingSeverityCritical
}
findings = append(findings, &Finding{
ID: generateFindingID(host.ID+":temp:"+sensorName, "reliability", "high-temp"),
Key: "high-temp",
Severity: severity,
Category: FindingCategoryReliability,
ResourceID: host.ID + ":temp:" + sensorName,
ResourceName: fmt.Sprintf("%s/%s", hostName, sensorName),
ResourceType: "host_sensor",
Title: "High temperature",
Description: fmt.Sprintf("Sensor '%s' on '%s' reading %.0f°C", sensorName, hostName, temp),
Recommendation: "Check cooling, fans, and airflow. High temps can cause hardware damage.",
Evidence: fmt.Sprintf("Temperature: %.1f°C", temp),
})
}
}
}
return findings
}
// AIAnalysisResult contains the results of an AI analysis
type AIAnalysisResult struct {
Response string // The AI's raw response text
Findings []*Finding // Parsed findings from the response
InputTokens int
OutputTokens int
}
// runAIAnalysis uses the LLM to analyze infrastructure and identify issues
func (p *PatrolService) runAIAnalysis(ctx context.Context, state models.StateSnapshot) (*AIAnalysisResult, error) {
if p.aiService == nil {
return nil, fmt.Errorf("AI service not available")
}
// Build enriched infrastructure context with trends and predictions
// Falls back to basic summary if metrics history is not available
summary := p.buildEnrichedContext(state)
if summary == "" {
return nil, nil // Nothing to analyze
}
prompt := p.buildPatrolPrompt(summary)
log.Debug().Msg("AI Patrol: Sending infrastructure to LLM for analysis")
// Start streaming phase
p.setStreamPhase("analyzing")
p.broadcast(PatrolStreamEvent{Type: "start"})
// Use streaming to broadcast updates in real-time
var contentBuffer strings.Builder
var inputTokens, outputTokens int
resp, err := p.aiService.ExecuteStream(ctx, ExecuteRequest{
Prompt: prompt,
SystemPrompt: p.getPatrolSystemPrompt(),
UseCase: "patrol", // Use patrol model for background analysis
}, func(event StreamEvent) {
switch event.Type {
case "content":
if content, ok := event.Data.(string); ok {
contentBuffer.WriteString(content)
p.appendStreamContent(content)
}
case "thinking":
// Thinking chunks become separate blocks (like AI chat)
if thinking, ok := event.Data.(string); ok && thinking != "" {
contentBuffer.WriteString(thinking)
// Send as a "thinking" event type so frontend can style it differently
p.broadcast(PatrolStreamEvent{
Type: "thinking",
Content: thinking,
})
}
}
})
if err != nil {
p.setStreamPhase("idle")
p.broadcast(PatrolStreamEvent{Type: "error", Content: err.Error()})
return nil, fmt.Errorf("LLM analysis failed: %w", err)
}
// Use response content (streaming may have captured it already)
finalContent := resp.Content
if finalContent == "" {
finalContent = contentBuffer.String()
}
inputTokens = resp.InputTokens
outputTokens = resp.OutputTokens
log.Debug().
Int("input_tokens", inputTokens).
Int("output_tokens", outputTokens).
Int("content_length", len(finalContent)).
Msg("AI Patrol: LLM analysis complete")
// Broadcast completion
p.broadcast(PatrolStreamEvent{
Type: "complete",
Tokens: outputTokens,
})
p.setStreamPhase("idle")
// Parse findings from AI response
findings := p.parseAIFindings(finalContent)
return &AIAnalysisResult{
Response: finalContent,
Findings: findings,
InputTokens: inputTokens,
OutputTokens: outputTokens,
}, nil
}
// getPatrolSystemPrompt returns the system prompt for AI patrol analysis
// The prompt varies based on whether auto-fix mode is enabled
func (p *PatrolService) getPatrolSystemPrompt() string {
autoFix := false
if cfg := p.aiService.GetAIConfig(); cfg != nil {
autoFix = cfg.PatrolAutoFix
}
basePrompt := `You are an infrastructure analyst for Pulse, a Proxmox monitoring system. Your job is to analyze infrastructure state and identify issues, potential problems, and optimization opportunities.
IMPORTANT: You must respond in a specific structured format so findings can be parsed.
For each issue you identify, output a finding block like this:
[FINDING]
KEY: <stable issue key>
SEVERITY: critical|warning|watch|info
CATEGORY: performance|reliability|security|capacity|configuration
RESOURCE: <resource name or ID>
RESOURCE_TYPE: node|vm|container|docker_container|storage|host
TITLE: <brief issue title>
DESCRIPTION: <detailed description of the issue>
RECOMMENDATION: <specific actionable recommendation>
EVIDENCE: <specific data that supports this finding>
[/FINDING]
Guidelines:
- Use KEY as a stable identifier for the issue type (examples: high-cpu, high-memory, high-disk, backup-stale, backup-never, restart-loop, storage-high-usage, pbs-datastore-high-usage, pbs-job-failed, node-offline). Use "general" if nothing fits.
SEVERITY GUIDELINES (be conservative - fewer findings are better than noisy alerts):
- CRITICAL: Immediate action required (data loss risk, service down, disk >95%)
- WARNING: Should be addressed soon (disk >85%, memory >90%, consistent failures)
- WATCH: Only use for SIGNIFICANT trends that project to hit critical in <7 days
- INFO: Informational observations for context (stopped services, config notes)
IMPORTANT - DO NOT REPORT:
- Small baseline deviations (CPU at 7% vs typical 4% is NORMAL variance)
- Low absolute utilization (anything under 50% CPU or 60% memory is fine)
- Stopped containers UNLESS they should be running (check autostart)
- "Elevated" metrics that are still well under limits
Only flag something if an operator would actually need to take action.
Focus on:
1. Capacity issues that will become critical soon (projected disk full, memory exhaustion)
2. Actual failures or errors (service crashes, backup failures)
3. Configuration problems (missing backups, insecure settings)
4. Correlation between resources when it indicates a root cause
If everything looks healthy, respond with NO findings. Users prefer silence to noise.`
if autoFix {
return basePrompt + `
AUTO-FIX MODE ENABLED: You may use the run_command tool to attempt automatic remediation of issues you find.
Safe operations you can perform autonomously:
- Restart services (systemctl restart)
- Clear caches and temp files
- Rotate/compress logs
- Trigger garbage collection
Operations requiring extra caution:
- Deleting files (prefer moving to /tmp first)
- Installing packages
- Modifying configurations
Always:
1. Run a verification command after any fix to confirm success
2. Log what action was taken and the outcome
3. Stop and report if the fix doesn't resolve the issue`
}
return basePrompt + `
OBSERVE ONLY MODE: You are in observation mode. You may use read-only commands to gather diagnostic information (checking status, memory usage, disk space, logs, etc.) but DO NOT modify anything. Present your findings with clear recommendations for the user to review and action manually.`
}
// buildInfrastructureSummary creates a text summary of infrastructure state for the AI
func (p *PatrolService) buildInfrastructureSummary(state models.StateSnapshot) string {
var sb strings.Builder
sb.WriteString("# Infrastructure State Summary\n\n")
// Nodes
if len(state.Nodes) > 0 {
sb.WriteString("## Proxmox Nodes\n")
for _, n := range state.Nodes {
memPct := 0.0
if n.Memory.Total > 0 {
memPct = float64(n.Memory.Used) / float64(n.Memory.Total) * 100
}
sb.WriteString(fmt.Sprintf("- **%s**: Status=%s, CPU=%.1f%%, Memory=%.1f%%, Uptime=%s\n",
n.Name, n.Status, n.CPU*100, memPct, formatDurationPatrol(time.Duration(n.Uptime)*time.Second)))
}
sb.WriteString("\n")
}
// VMs
if len(state.VMs) > 0 {
sb.WriteString("## Virtual Machines\n")
for _, vm := range state.VMs {
if vm.Template {
continue // Skip templates
}
memPct := 0.0
if vm.Memory.Total > 0 {
memPct = float64(vm.Memory.Used) / float64(vm.Memory.Total) * 100
}
backupStatus := "never"
if !vm.LastBackup.IsZero() {
backupStatus = fmt.Sprintf("%s ago", time.Since(vm.LastBackup).Round(time.Hour))
}
sb.WriteString(fmt.Sprintf("- **%s** (ID:%s, Node:%s): Status=%s, CPU=%.1f%%, Memory=%.1f%%, LastBackup=%s\n",
vm.Name, vm.ID, vm.Node, vm.Status, vm.CPU*100, memPct, backupStatus))
}
sb.WriteString("\n")
}
// Containers
if len(state.Containers) > 0 {
sb.WriteString("## LXC Containers\n")
for _, ct := range state.Containers {
if ct.Template {
continue
}
memPct := 0.0
if ct.Memory.Total > 0 {
memPct = float64(ct.Memory.Used) / float64(ct.Memory.Total) * 100
}
backupStatus := "never"
if !ct.LastBackup.IsZero() {
backupStatus = fmt.Sprintf("%s ago", time.Since(ct.LastBackup).Round(time.Hour))
}
sb.WriteString(fmt.Sprintf("- **%s** (ID:%s, Node:%s): Status=%s, CPU=%.1f%%, Memory=%.1f%%, LastBackup=%s\n",
ct.Name, ct.ID, ct.Node, ct.Status, ct.CPU*100, memPct, backupStatus))
}
sb.WriteString("\n")
}
// Storage
if len(state.Storage) > 0 {
sb.WriteString("## Storage\n")
for _, st := range state.Storage {
usedPct := 0.0
if st.Total > 0 {
usedPct = float64(st.Used) / float64(st.Total) * 100
}
sb.WriteString(fmt.Sprintf("- **%s** (Node:%s, Type:%s): %.1f%% used (%s / %s)\n",
st.Name, st.Node, st.Type, usedPct, formatBytesInt64(st.Used), formatBytesInt64(st.Total)))
}
sb.WriteString("\n")
}
// Docker hosts
if len(state.DockerHosts) > 0 {
sb.WriteString("## Docker Hosts\n")
for _, dh := range state.DockerHosts {
sb.WriteString(fmt.Sprintf("- **%s**: Status=%s, Containers=%d\n",
dh.Hostname, dh.Status, len(dh.Containers)))
for _, c := range dh.Containers {
sb.WriteString(fmt.Sprintf(" - %s: State=%s, CPU=%.1f%%, Memory=%.1f%%, Restarts=%d\n",
c.Name, c.State, c.CPUPercent, c.MemoryPercent, c.RestartCount))
}
}
sb.WriteString("\n")
}
return sb.String()
}
// buildEnrichedContext creates context with historical trends and predictions
// Falls back to basic summary if metrics history is not available
func (p *PatrolService) buildEnrichedContext(state models.StateSnapshot) string {
p.mu.RLock()
metricsHistory := p.metricsHistory
knowledgeStore := p.knowledgeStore
baselineStore := p.baselineStore
changeDetector := p.changeDetector
p.mu.RUnlock()
// If no metrics history, fall back to basic summary
if metricsHistory == nil {
log.Debug().Msg("AI Patrol: No metrics history available, using basic summary")
return p.buildInfrastructureSummary(state)
}
// Build enriched context using the context package
builder := aicontext.NewBuilder().
WithMetricsHistory(&metricsHistoryShim{provider: metricsHistory})
// Add knowledge store if available
if knowledgeStore != nil {
builder = builder.WithKnowledge(&knowledgeShim{store: knowledgeStore})
}
// Add baseline provider for anomaly detection if available
if baselineStore != nil {
adapter := NewBaselineStoreAdapter(baselineStore)
if adapter != nil {
builder = builder.WithBaseline(&baselineShim{adapter: adapter})
}
}
// Build full infrastructure context with trends
infraCtx := builder.BuildForInfrastructure(state)
if infraCtx == nil {
log.Warn().Msg("AI Patrol: Failed to build enriched context, falling back")
return p.buildInfrastructureSummary(state)
}
// Format for AI consumption
formatted := aicontext.FormatInfrastructureContext(infraCtx)
// Append recent changes if change detector is available
if changeDetector != nil {
// Detect any new changes from current state
snapshots := stateToSnapshots(state)
newChanges := changeDetector.DetectChanges(snapshots)
// Get summary of recent changes (last 24 hours)
since := time.Now().Add(-24 * time.Hour)
changesSummary := changeDetector.GetChangesSummary(since, 20)
if changesSummary != "" {
formatted += "\n## Recent Infrastructure Changes (24h)\n\n" + changesSummary
}
if len(newChanges) > 0 {
log.Debug().Int("new_changes", len(newChanges)).Msg("AI Patrol: Detected infrastructure changes")
}
}
// Append failure predictions if pattern detector is available
p.mu.RLock()
patternDetector := p.patternDetector
correlationDetector := p.correlationDetector
p.mu.RUnlock()
if patternDetector != nil {
predictionsContext := patternDetector.FormatForContext("")
if predictionsContext != "" {
formatted += predictionsContext
}
}
// Append resource correlations if correlation detector is available
if correlationDetector != nil {
correlationsContext := correlationDetector.FormatForContext("")
if correlationsContext != "" {
formatted += correlationsContext
}
}
log.Debug().
Int("resources", infraCtx.TotalResources).
Int("predictions", len(infraCtx.Predictions)).
Int("anomalies", len(infraCtx.Anomalies)).
Msg("AI Patrol: Built enriched context with trends")
return formatted
}
// stateToSnapshots converts state to resource snapshots for change detection
func stateToSnapshots(state models.StateSnapshot) []ResourceSnapshot {
var snapshots []ResourceSnapshot
for _, node := range state.Nodes {
snapshots = append(snapshots, ResourceSnapshot{
ID: node.ID,
Name: node.Name,
Type: "node",
Status: node.Status,
CPUCores: node.CPUInfo.Cores,
MemoryBytes: node.Memory.Total,
})
}
for _, vm := range state.VMs {
if vm.Template {
continue
}
snapshots = append(snapshots, ResourceSnapshot{
ID: vm.ID,
Name: vm.Name,
Type: "vm",
Status: vm.Status,
Node: vm.Node,
CPUCores: vm.CPUs,
MemoryBytes: vm.Memory.Total,
DiskBytes: vm.Disk.Total,
LastBackup: vm.LastBackup,
})
}
for _, ct := range state.Containers {
if ct.Template {
continue
}
snapshots = append(snapshots, ResourceSnapshot{
ID: ct.ID,
Name: ct.Name,
Type: "container",
Status: ct.Status,
Node: ct.Node,
CPUCores: ct.CPUs,
MemoryBytes: ct.Memory.Total,
DiskBytes: ct.Disk.Total,
LastBackup: ct.LastBackup,
})
}
return snapshots
}
// metricsHistoryShim adapts ai.MetricsHistoryProvider to aicontext.MetricsHistoryProvider
type metricsHistoryShim struct {
provider MetricsHistoryProvider
}
func (s *metricsHistoryShim) GetNodeMetrics(nodeID string, metricType string, duration time.Duration) []aicontext.MetricPoint {
if s.provider == nil {
return nil
}
points := s.provider.GetNodeMetrics(nodeID, metricType, duration)
return convertToContextPoints(points)
}
func (s *metricsHistoryShim) GetGuestMetrics(guestID string, metricType string, duration time.Duration) []aicontext.MetricPoint {
if s.provider == nil {
return nil
}
points := s.provider.GetGuestMetrics(guestID, metricType, duration)
return convertToContextPoints(points)
}
func (s *metricsHistoryShim) GetAllGuestMetrics(guestID string, duration time.Duration) map[string][]aicontext.MetricPoint {
if s.provider == nil {
return nil
}
metricsMap := s.provider.GetAllGuestMetrics(guestID, duration)
return convertToContextMetricsMap(metricsMap)
}
func (s *metricsHistoryShim) GetAllStorageMetrics(storageID string, duration time.Duration) map[string][]aicontext.MetricPoint {
if s.provider == nil {
return nil
}
metricsMap := s.provider.GetAllStorageMetrics(storageID, duration)
return convertToContextMetricsMap(metricsMap)
}
// knowledgeShim adapts knowledge.Store to aicontext.KnowledgeProvider
type knowledgeShim struct {
store *knowledge.Store
}
func (k *knowledgeShim) GetNotes(guestID string) []string {
if k.store == nil {
return nil
}
knowledge, err := k.store.GetKnowledge(guestID)
if err != nil || knowledge == nil {
return nil
}
// Extract note contents
var notes []string
for _, note := range knowledge.Notes {
notes = append(notes, note.Content)
}
return notes
}
func (k *knowledgeShim) FormatAllForContext() string {
if k.store == nil {
return ""
}
return k.store.FormatAllForContext()
}
// baselineShim adapts BaselineStoreAdapter to aicontext.BaselineProvider
type baselineShim struct {
adapter *BaselineStoreAdapter
}
func (b *baselineShim) CheckAnomaly(resourceID, metric string, value float64) (severity string, zScore float64, mean float64, stddev float64, ok bool) {
if b.adapter == nil {
return "", 0, 0, 0, false
}
return b.adapter.CheckAnomaly(resourceID, metric, value)
}
func (b *baselineShim) GetBaseline(resourceID, metric string) (mean float64, stddev float64, sampleCount int, ok bool) {
if b.adapter == nil {
return 0, 0, 0, false
}
return b.adapter.GetBaseline(resourceID, metric)
}
// convertToContextPoints converts ai.MetricPoint to aicontext.MetricPoint
// Since both are aliases for types.MetricPoint, this is just a type assertion
func convertToContextPoints(points []MetricPoint) []aicontext.MetricPoint {
if points == nil {
return nil
}
// Both types are aliases for types.MetricPoint, so they're compatible
result := make([]aicontext.MetricPoint, len(points))
for i, p := range points {
result[i] = aicontext.MetricPoint{
Value: p.Value,
Timestamp: p.Timestamp,
}
}
return result
}
// convertToContextMetricsMap converts a map of metric points
func convertToContextMetricsMap(metricsMap map[string][]MetricPoint) map[string][]aicontext.MetricPoint {
if metricsMap == nil {
return nil
}
result := make(map[string][]aicontext.MetricPoint, len(metricsMap))
for key, points := range metricsMap {
result[key] = convertToContextPoints(points)
}
return result
}
// buildPatrolPrompt creates the prompt for AI analysis
// Includes user feedback context to prevent re-raising dismissed findings
func (p *PatrolService) buildPatrolPrompt(summary string) string {
// Get user feedback context (dismissed/snoozed findings)
feedbackContext := p.findings.GetDismissedForContext()
// Get resource notes from knowledge store (per-resource user notes)
var knowledgeContext string
var incidentContext string
p.mu.RLock()
knowledgeStore := p.knowledgeStore
incidentStore := p.incidentStore
p.mu.RUnlock()
if knowledgeStore != nil {
knowledgeContext = knowledgeStore.FormatAllForContext()
}
if incidentStore != nil {
incidentContext = incidentStore.FormatForPatrol(8)
}
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 or concerning trends (look for "rising" indicators)
- Predictions showing resources approaching capacity
- Anomalies flagged as unusual in the "ANOMALIES" section
- Patterns that might indicate problems over time (compare 24h vs 7d trends)
- Missing backups or stale backup schedules
- Unbalanced resource distribution
IMPORTANT: The context includes historical trends (24h and 7d) where available. Use this to provide actionable insights:
- A resource that's "growing 5%%/day" needs proactive attention
- A resource that's "stable" with high usage may just need monitoring
- A "volatile" resource may indicate workload issues
If predictions show a resource will be full within 7 days, flag it as high priority.
If everything looks healthy with stable trends, say so briefly.`, summary)
var contextAdditions strings.Builder
// Append knowledge context (user notes about resources)
if knowledgeContext != "" {
contextAdditions.WriteString("\n\n")
contextAdditions.WriteString(knowledgeContext)
contextAdditions.WriteString("\nIMPORTANT: Consider the user's saved notes above when analyzing. If a user has noted that a resource behaves a certain way (e.g., 'runs hot for transcoding'), do not flag it as an issue.\n")
}
// Append user feedback context (dismissed/snoozed findings)
if feedbackContext != "" {
contextAdditions.WriteString("\n\n")
contextAdditions.WriteString(feedbackContext)
contextAdditions.WriteString(`
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.`)
}
if incidentContext != "" {
contextAdditions.WriteString("\n\n")
contextAdditions.WriteString(incidentContext)
contextAdditions.WriteString("\nIMPORTANT: Use incident memory to avoid repeating known issues and to build on successful past investigations.")
}
if contextAdditions.Len() > 0 {
return basePrompt + contextAdditions.String()
}
return basePrompt
}
// parseAIFindings extracts structured findings from AI response
func (p *PatrolService) parseAIFindings(response string) []*Finding {
var findings []*Finding
// Find all [FINDING]...[/FINDING] blocks
findingPattern := regexp.MustCompile(`(?s)\[FINDING\](.*?)\[/FINDING\]`)
matches := findingPattern.FindAllStringSubmatch(response, -1)
for _, match := range matches {
if len(match) < 2 {
continue
}
block := match[1]
finding := p.parseFindingBlock(block)
if finding != nil {
findings = append(findings, finding)
}
}
return findings
}
// parseFindingBlock extracts a single finding from a block
func (p *PatrolService) parseFindingBlock(block string) *Finding {
extract := func(key string) string {
pattern := regexp.MustCompile(`(?i)` + key + `:\s*(.+?)(?:\n|$)`)
match := pattern.FindStringSubmatch(block)
if len(match) >= 2 {
return strings.TrimSpace(match[1])
}
return ""
}
severity := extract("SEVERITY")
category := extract("CATEGORY")
key := extract("KEY")
if key == "" {
key = extract("FINDING_KEY")
}
resource := extract("RESOURCE")
resourceType := extract("RESOURCE_TYPE")
title := extract("TITLE")
description := extract("DESCRIPTION")
recommendation := extract("RECOMMENDATION")
evidence := extract("EVIDENCE")
// Validate required fields
if title == "" || description == "" {
return nil
}
// Map severity
var sev FindingSeverity
switch strings.ToLower(severity) {
case "critical":
sev = FindingSeverityCritical
case "warning":
sev = FindingSeverityWarning
case "watch":
sev = FindingSeverityWatch
default:
sev = FindingSeverityInfo
}
// Map category
var cat FindingCategory
switch strings.ToLower(category) {
case "performance":
cat = FindingCategoryPerformance
case "reliability":
cat = FindingCategoryReliability
case "security":
cat = FindingCategorySecurity
case "capacity":
cat = FindingCategoryCapacity
case "configuration":
cat = FindingCategoryGeneral // Configuration maps to General
default:
cat = FindingCategoryPerformance
}
// Generate stable ID from resource and category ONLY (not title)
// This ensures the same issue on the same resource gets the same ID even if
// the LLM phrases it differently each time
id := generateFindingID(resource, string(cat), "llm-finding")
return &Finding{
ID: id,
Key: normalizeFindingKey(key),
Severity: sev,
Category: cat,
ResourceID: resource,
ResourceName: resource,
ResourceType: resourceType,
Title: title,
Description: description,
Recommendation: recommendation,
Evidence: evidence,
Source: "ai-analysis", // Mark as coming from AI
}
}
func normalizeFindingKey(key string) string {
if key == "" {
return ""
}
key = strings.TrimSpace(strings.ToLower(key))
if key == "" {
return ""
}
key = strings.ReplaceAll(key, "_", "-")
key = strings.ReplaceAll(key, " ", "-")
var b strings.Builder
for _, r := range key {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
b.WriteRune(r)
}
}
return strings.Trim(b.String(), "-")
}
// formatDurationPatrol formats a duration as a human-readable string for patrol
func formatDurationPatrol(d time.Duration) string {
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh", int(d.Hours()))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
// formatBytes formats bytes as a human-readable string
func formatBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
// formatBytesInt64 formats int64 bytes as a human-readable string
func formatBytesInt64(b int64) string {
if b < 0 {
return "0 B"
}
return formatBytes(uint64(b))
}