From 99d0edb04ae27420a8fed69d3f708aa3ff3724ad Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 13 May 2026 05:03:13 +0100 Subject: [PATCH] wire PDM alert bridge into PatrolService and seed demo --- internal/ai/demo.go | 17 +++++++++++++++++ internal/ai/demo_test.go | 4 ++-- internal/ai/patrol.go | 6 ++++++ internal/ai/patrol_run.go | 15 +++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/ai/demo.go b/internal/ai/demo.go index 08fd80b4f..b35aa1d3f 100644 --- a/internal/ai/demo.go +++ b/internal/ai/demo.go @@ -159,6 +159,23 @@ func (p *PatrolService) InjectDemoFindings() { TimesRaised: 2, Source: updateSafetySource, }, + { + ID: "demo-pdm-alert-node-offline", + Key: PDMAlertFindingPrefix + ":datacenter-a/node/pve-edge-01", + Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, + Title: "PDM: node \"pve-edge-01\" is offline", + Description: "Proxmox Datacenter Manager reports node \"pve-edge-01\" in remote \"datacenter-a\" is offline.", + ResourceID: "datacenter-a/node/pve-edge-01", + ResourceName: "pve-edge-01", + ResourceType: "node", + Node: "datacenter-a", + Evidence: "status=offline remote=datacenter-a", + DetectedAt: now.Add(-8 * time.Minute), + LastSeenAt: now.Add(-2 * time.Minute), + TimesRaised: 1, + Source: pdmAlertSourceLabel, + }, } // Add findings to the store diff --git a/internal/ai/demo_test.go b/internal/ai/demo_test.go index 6ac4fd6b2..21e4b5e82 100644 --- a/internal/ai/demo_test.go +++ b/internal/ai/demo_test.go @@ -32,8 +32,8 @@ func TestPatrolService_InjectDemoFindings(t *testing.T) { service.InjectDemoFindings() findings := service.findings.GetAll(nil) - if len(findings) != 6 { - t.Fatalf("expected 6 demo findings, got %d", len(findings)) + if len(findings) != 7 { + t.Fatalf("expected 7 demo findings, got %d", len(findings)) } if service.runHistoryStore.Count() != 13 { t.Fatalf("expected 13 demo run history entries, got %d", service.runHistoryStore.Count()) diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index 65601b69b..bba716ed5 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -464,6 +464,11 @@ type PatrolService struct { // lifetime; in-memory only, no goroutine. stormThrottler *findingStormThrottler updateSafetyWatcher *UpdateSafetyWatcher + // PDM alert bridge -- polls the PDM resource list on each patrol cycle + // and emits reliability findings for offline nodes and failed guests. + // Nil source at MVP makes Observe a no-op; the real HTTP client is a + // follow-on. + pdmAlertBridge *pdmAlertBridge // ReadState provides typed read-only views over resource state (VMs, nodes, hosts, etc.). // This is injected separately from stateProvider since stateProvider also contains // non-resource telemetry (alerts, backups, connection health) that isn't modeled as resources yet. @@ -687,5 +692,6 @@ func NewPatrolService(aiService *Service, stateProvider StateProvider) *PatrolSe p.stormThrottler = newFindingStormThrottler() p.findings.SetStormThrottler(p.stormThrottler) p.updateSafetyWatcher = newUpdateSafetyWatcher() + p.pdmAlertBridge = newPDMAlertBridge(nil) return p } diff --git a/internal/ai/patrol_run.go b/internal/ai/patrol_run.go index 2ea159179..ff779b8e0 100644 --- a/internal/ai/patrol_run.go +++ b/internal/ai/patrol_run.go @@ -423,6 +423,21 @@ func (p *PatrolService) runPatrolWithTrigger(ctx context.Context, trigger Trigge } } + // Run PDM alert bridge on every patrol cycle if a source is configured. + // Nil source guard mirrors the MVP no-op behavior; the real HTTP client + // is wired in a follow-on. Direct FindingsStore.Add bypasses push. + if p.pdmAlertBridge != nil && p.pdmAlertBridge.source != nil { + pdmEmit, pdmResolve := p.pdmAlertBridge.Observe(ctx, time.Now()) + for _, f := range pdmEmit { + if !p.findings.Add(f) { + log.Debug().Str("finding_id", f.ID).Msg("AI Patrol: PDM alert finding already in store") + } + } + for _, s := range pdmResolve { + p.findings.ResolveWithReason(s.DedupKey, s.Reason) + } + } + // Determine if we can run LLM analysis (requires AI service + circuit breaker not open) aiServiceEnabled := p.aiService != nil && p.aiService.IsEnabled() canRunLLM := aiServiceEnabled && llmAllowed