Keep only the simple AI-powered approach: - set_resource_url tool lets AI save discovered URLs - Users ask AI directly: 'Find URLs for my containers' - AI uses its intelligence to discover and set URLs Removed: - URLDiscoveryService (rigid port scanning) - Bulk discovery API endpoints - Frontend discovery button The AI itself is smart enough to iterate through resources and discover URLs when asked.
1243 lines
40 KiB
Go
1243 lines
40 KiB
Go
package ai
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"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"`
|
|
// QuickCheckInterval is how often to do quick health checks
|
|
QuickCheckInterval time.Duration `json:"quick_check_interval"`
|
|
// DeepAnalysisInterval is how often to do thorough analysis
|
|
DeepAnalysisInterval time.Duration `json:"deep_analysis_interval"`
|
|
// 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"`
|
|
}
|
|
|
|
// DefaultPatrolConfig returns sensible defaults
|
|
func DefaultPatrolConfig() PatrolConfig {
|
|
return PatrolConfig{
|
|
Enabled: true,
|
|
QuickCheckInterval: 15 * time.Minute,
|
|
DeepAnalysisInterval: 6 * time.Hour,
|
|
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"`
|
|
LastPatrolAt *time.Time `json:"last_patrol_at,omitempty"`
|
|
LastDeepAnalysis *time.Time `json:"last_deep_analysis_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"`
|
|
}
|
|
|
|
// PatrolService runs background AI analysis of infrastructure
|
|
type PatrolService struct {
|
|
mu sync.RWMutex
|
|
|
|
aiService *Service
|
|
stateProvider StateProvider
|
|
thresholdProvider ThresholdProvider
|
|
config PatrolConfig
|
|
findings *FindingsStore
|
|
|
|
// Cached thresholds (recalculated when thresholdProvider changes)
|
|
thresholds PatrolThresholds
|
|
|
|
// Runtime state
|
|
running bool
|
|
stopCh chan struct{}
|
|
lastPatrol time.Time
|
|
lastDeepAnalysis time.Time
|
|
lastDuration time.Duration
|
|
resourcesChecked int
|
|
errorCount int
|
|
}
|
|
|
|
// 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{}),
|
|
}
|
|
}
|
|
|
|
// SetConfig updates the patrol configuration
|
|
func (p *PatrolService) SetConfig(cfg PatrolConfig) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.config = cfg
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// GetStatus returns the current patrol status
|
|
func (p *PatrolService) GetStatus() PatrolStatus {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
|
|
status := PatrolStatus{
|
|
Running: p.running,
|
|
LastDuration: p.lastDuration,
|
|
ResourcesChecked: p.resourcesChecked,
|
|
FindingsCount: len(p.findings.GetActive(FindingSeverityInfo)),
|
|
ErrorCount: p.errorCount,
|
|
}
|
|
|
|
if !p.lastPatrol.IsZero() {
|
|
status.LastPatrolAt = &p.lastPatrol
|
|
}
|
|
if !p.lastDeepAnalysis.IsZero() {
|
|
status.LastDeepAnalysis = &p.lastDeepAnalysis
|
|
}
|
|
|
|
if p.running && p.config.QuickCheckInterval > 0 {
|
|
next := p.lastPatrol.Add(p.config.QuickCheckInterval)
|
|
status.NextPatrolAt = &next
|
|
}
|
|
|
|
summary := p.findings.GetSummary()
|
|
status.Healthy = summary.IsHealthy()
|
|
|
|
return status
|
|
}
|
|
|
|
// 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.mu.Unlock()
|
|
|
|
log.Info().
|
|
Dur("quick_interval", p.config.QuickCheckInterval).
|
|
Dur("deep_interval", p.config.DeepAnalysisInterval).
|
|
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 quick patrol shortly after startup
|
|
initialDelay := 30 * time.Second
|
|
select {
|
|
case <-time.After(initialDelay):
|
|
p.runPatrol(ctx, false)
|
|
case <-p.stopCh:
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
|
|
quickTicker := time.NewTicker(p.config.QuickCheckInterval)
|
|
defer quickTicker.Stop()
|
|
|
|
// Deep analysis ticker (if configured)
|
|
var deepTicker *time.Ticker
|
|
if p.config.DeepAnalysisInterval > 0 {
|
|
deepTicker = time.NewTicker(p.config.DeepAnalysisInterval)
|
|
defer deepTicker.Stop()
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-quickTicker.C:
|
|
p.runPatrol(ctx, false)
|
|
|
|
case <-func() <-chan time.Time {
|
|
if deepTicker != nil {
|
|
return deepTicker.C
|
|
}
|
|
return make(chan time.Time) // never fires
|
|
}():
|
|
p.runPatrol(ctx, true)
|
|
|
|
case <-p.stopCh:
|
|
return
|
|
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// runPatrol executes a patrol run
|
|
func (p *PatrolService) runPatrol(ctx context.Context, deep bool) {
|
|
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 := "quick"
|
|
if deep {
|
|
patrolType = "deep"
|
|
}
|
|
|
|
log.Debug().Str("type", patrolType).Msg("AI Patrol: Starting patrol run")
|
|
|
|
var resourceCount int
|
|
var errors int
|
|
|
|
// Get current state
|
|
if p.stateProvider == nil {
|
|
log.Warn().Msg("AI Patrol: No state provider available")
|
|
return
|
|
}
|
|
|
|
state := p.stateProvider.GetState()
|
|
|
|
// Analyze nodes
|
|
if cfg.AnalyzeNodes {
|
|
for _, node := range state.Nodes {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
resourceCount++
|
|
findings := p.analyzeNode(node, deep)
|
|
for _, f := range findings {
|
|
if p.findings.Add(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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Analyze VMs and containers
|
|
if cfg.AnalyzeGuests {
|
|
for _, vm := range state.VMs {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
resourceCount++
|
|
// 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)
|
|
}
|
|
if vm.Disk.Total > 0 {
|
|
diskUsage = float64(vm.Disk.Used) / float64(vm.Disk.Total)
|
|
}
|
|
// 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, deep)
|
|
for _, f := range findings {
|
|
if p.findings.Add(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")
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, ct := range state.Containers {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
resourceCount++
|
|
// 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)
|
|
}
|
|
if ct.Disk.Total > 0 {
|
|
diskUsage = float64(ct.Disk.Used) / float64(ct.Disk.Total)
|
|
}
|
|
// 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, deep)
|
|
for _, f := range findings {
|
|
if p.findings.Add(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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Analyze Docker hosts
|
|
if cfg.AnalyzeDocker {
|
|
for _, dh := range state.DockerHosts {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
resourceCount++
|
|
findings := p.analyzeDockerHost(dh, deep)
|
|
for _, f := range findings {
|
|
if p.findings.Add(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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Analyze storage
|
|
if cfg.AnalyzeStorage {
|
|
for _, st := range state.Storage {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
resourceCount++
|
|
findings := p.analyzeStorage(st, deep)
|
|
for _, f := range findings {
|
|
if p.findings.Add(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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Analyze PBS instances (backup servers)
|
|
if cfg.AnalyzePBS {
|
|
for _, pbs := range state.PBSInstances {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
resourceCount++
|
|
findings := p.analyzePBSInstance(pbs, state.PBSBackups, deep)
|
|
for _, f := range findings {
|
|
if p.findings.Add(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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Analyze agent hosts (RAID, sensors)
|
|
if cfg.AnalyzeHosts {
|
|
for _, host := range state.Hosts {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
resourceCount++
|
|
findings := p.analyzeHost(host, deep)
|
|
for _, f := range findings {
|
|
if p.findings.Add(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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-resolve findings that weren't seen in this patrol run
|
|
p.autoResolveStaleFindings(start)
|
|
|
|
// Cleanup old resolved findings
|
|
cleaned := p.findings.Cleanup(24 * time.Hour)
|
|
if cleaned > 0 {
|
|
log.Debug().Int("cleaned", cleaned).Msg("AI Patrol: Cleaned up old findings")
|
|
}
|
|
|
|
duration := time.Since(start)
|
|
|
|
p.mu.Lock()
|
|
p.lastPatrol = time.Now()
|
|
p.lastDuration = duration
|
|
p.resourcesChecked = resourceCount
|
|
p.errorCount = errors
|
|
if deep {
|
|
p.lastDeepAnalysis = time.Now()
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
summary := p.findings.GetSummary()
|
|
log.Info().
|
|
Str("type", patrolType).
|
|
Dur("duration", duration).
|
|
Int("resources", resourceCount).
|
|
Int("critical", summary.Critical).
|
|
Int("warning", summary.Warning).
|
|
Int("watch", summary.Watch).
|
|
Msg("AI Patrol: Completed patrol run")
|
|
}
|
|
|
|
// 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])
|
|
}
|
|
|
|
// analyzeNode checks a Proxmox node for issues
|
|
func (p *PatrolService) analyzeNode(node models.Node, deep bool) []*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"),
|
|
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"),
|
|
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"),
|
|
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
|
|
func (p *PatrolService) analyzeGuest(id, name, guestType, node, status string,
|
|
cpu, memUsage, diskUsage float64, lastBackup *time.Time, template, deep bool) []*Finding {
|
|
var findings []*Finding
|
|
|
|
// Skip templates
|
|
if template {
|
|
return findings
|
|
}
|
|
|
|
// Convert ratios to percentages for comparison with thresholds
|
|
memPct := memUsage * 100
|
|
diskPct := diskUsage * 100
|
|
|
|
// 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"),
|
|
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"),
|
|
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"),
|
|
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"),
|
|
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, deep bool) []*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"),
|
|
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"),
|
|
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"),
|
|
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, deep bool) []*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"),
|
|
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)
|
|
func (p *PatrolService) autoResolveStaleFindings(patrolStartTime time.Time) {
|
|
// Get all active findings and check if they're stale
|
|
activeFindings := p.findings.GetActive(FindingSeverityInfo)
|
|
|
|
for _, f := range activeFindings {
|
|
// 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) {
|
|
log.Info().
|
|
Str("finding_id", f.ID).
|
|
Str("resource", f.ResourceName).
|
|
Str("title", f.Title).
|
|
Msg("AI Patrol: Auto-resolved finding")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// GetAllFindings returns all active findings sorted by severity
|
|
func (p *PatrolService) GetAllFindings() []*Finding {
|
|
findings := p.findings.GetActive(FindingSeverityInfo)
|
|
|
|
// 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
|
|
func (p *PatrolService) ForcePatrol(ctx context.Context, deep bool) {
|
|
go p.runPatrol(ctx, deep)
|
|
}
|
|
|
|
// analyzePBSInstance checks a PBS backup server for issues
|
|
func (p *PatrolService) analyzePBSInstance(pbs models.PBSInstance, allBackups []models.PBSBackup, deep bool) []*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"),
|
|
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"),
|
|
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"),
|
|
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"),
|
|
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"),
|
|
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 (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,
|
|
})
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
// analyzeHost checks an agent host for issues (RAID, sensors, connectivity)
|
|
func (p *PatrolService) analyzeHost(host models.Host, deep bool) []*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"),
|
|
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"),
|
|
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"),
|
|
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"),
|
|
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"),
|
|
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 (during deep analysis)
|
|
if deep && 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"),
|
|
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
|
|
}
|