From c5c9bf4fb9017ae7a9ec11d9404883f52328f67d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 21 Dec 2025 10:52:54 +0000 Subject: [PATCH] feat(ai): add real-time anomaly detection endpoint Add /api/ai/intelligence/anomalies endpoint that compares live metrics against learned baselines to surface deviations - all deterministic (no LLM required). Backend: - Add AnomalyReport struct with severity classification - Add CheckResourceAnomalies method to baseline store - Add HandleGetAnomalies API handler - Add GetStateProvider getter to AI service Frontend: - Add AnomalyReport and AnomaliesResponse types - Add getAnomalies API function - Add AnomalySeverity type This is the first step toward surfacing deterministic intelligence directly in the UI without requiring LLM interaction. --- frontend-modern/src/api/ai.ts | 8 + frontend-modern/src/types/aiIntelligence.ts | 36 ++- internal/ai/baseline/store.go | 125 +++++++++++ internal/ai/service.go | 7 + internal/api/ai_intelligence_handlers.go | 230 ++++++++++++++++++++ internal/api/router.go | 1 + 6 files changed, 398 insertions(+), 9 deletions(-) diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index 7548fa4..5b6555f 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -16,6 +16,7 @@ import type { ChangesResponse, BaselinesResponse, RemediationsResponse, + AnomaliesResponse, IntelligenceSummary, ResourceIntelligence, } from '@/types/aiIntelligence'; @@ -121,6 +122,13 @@ export class AIAPI { return apiFetchJSON(`${this.baseUrl}/ai/intelligence/remediations${query ? `?${query}` : ''}`) as Promise; } + // Get current anomalies (real-time baseline deviation detection) + // Returns metrics that are currently deviating significantly from learned baselines + static async getAnomalies(resourceId?: string): Promise { + const params = resourceId ? `?resource_id=${encodeURIComponent(resourceId)}` : ''; + return apiFetchJSON(`${this.baseUrl}/ai/intelligence/anomalies${params}`) as Promise; + } + // ============================================ // Unified Intelligence API // Aggregates all AI subsystems into a single view diff --git a/frontend-modern/src/types/aiIntelligence.ts b/frontend-modern/src/types/aiIntelligence.ts index 03e8ef9..ca137b2 100644 --- a/frontend-modern/src/types/aiIntelligence.ts +++ b/frontend-modern/src/types/aiIntelligence.ts @@ -126,6 +126,33 @@ export interface RemediationsResponse extends LicenseGatedResponse { stats?: RemediationStats; } +// Real-time anomaly detection types +export type AnomalySeverity = 'low' | 'medium' | 'high' | 'critical'; + +export interface AnomalyReport { + resource_id: string; + resource_name: string; + resource_type: string; + metric: string; + current_value: number; + baseline_mean: number; + baseline_std_dev: number; + z_score: number; + severity: AnomalySeverity; + description: string; +} + +export interface AnomaliesResponse extends LicenseGatedResponse { + anomalies: AnomalyReport[]; + count: number; + severity_counts: { + critical: number; + high: number; + medium: number; + low: number; + }; +} + // ============================================================================ // Unified Intelligence Types (for /api/ai/intelligence endpoint) // ============================================================================ @@ -231,12 +258,3 @@ export interface ResourceIntelligence { note_count: number; } -export interface AnomalyReport { - metric: string; - current_value: number; - baseline_mean: number; - z_score: number; - severity: string; // "critical", "high", "medium", "low" - description: string; -} - diff --git a/internal/ai/baseline/store.go b/internal/ai/baseline/store.go index 9b96e10..d8d95f3 100644 --- a/internal/ai/baseline/store.go +++ b/internal/ai/baseline/store.go @@ -271,6 +271,131 @@ func (s *Store) CheckAnomaly(resourceID, metric string, value float64) (AnomalyS return severity, zScore, baseline } +// AnomalyReport represents a detected anomaly for a single metric +type AnomalyReport struct { + ResourceID string `json:"resource_id"` + ResourceName string `json:"resource_name,omitempty"` + ResourceType string `json:"resource_type,omitempty"` + Metric string `json:"metric"` + CurrentValue float64 `json:"current_value"` + BaselineMean float64 `json:"baseline_mean"` + BaselineStdDev float64 `json:"baseline_std_dev"` + ZScore float64 `json:"z_score"` + Severity AnomalySeverity `json:"severity"` + Description string `json:"description"` +} + +// CheckResourceAnomalies checks multiple metrics for a resource and returns all anomalies +func (s *Store) CheckResourceAnomalies(resourceID string, metrics map[string]float64) []AnomalyReport { + var anomalies []AnomalyReport + + for metric, value := range metrics { + severity, zScore, baseline := s.CheckAnomaly(resourceID, metric, value) + if severity != AnomalyNone { + report := AnomalyReport{ + ResourceID: resourceID, + Metric: metric, + CurrentValue: value, + ZScore: zScore, + Severity: severity, + } + + if baseline != nil { + report.BaselineMean = baseline.Mean + report.BaselineStdDev = baseline.StdDev + + // Generate human-readable description + ratio := value / baseline.Mean + direction := "above" + if zScore < 0 { + direction = "below" + } + report.Description = formatAnomalyDescription(metric, ratio, direction, severity) + } + + anomalies = append(anomalies, report) + } + } + + return anomalies +} + +// formatAnomalyDescription generates a human-readable anomaly description +func formatAnomalyDescription(metric string, ratio float64, direction string, severity AnomalySeverity) string { + metricLabel := metric + switch metric { + case "cpu": + metricLabel = "CPU usage" + case "memory": + metricLabel = "Memory usage" + case "disk": + metricLabel = "Disk usage" + case "network_in": + metricLabel = "Network inbound" + case "network_out": + metricLabel = "Network outbound" + } + + severityLabel := "" + switch severity { + case AnomalyCritical: + severityLabel = "Critical anomaly: " + case AnomalyHigh: + severityLabel = "High anomaly: " + case AnomalyMedium: + severityLabel = "Moderate anomaly: " + case AnomalyLow: + severityLabel = "Minor anomaly: " + } + + return severityLabel + metricLabel + " is " + formatRatio(ratio) + " " + direction + " normal baseline" +} + +// formatRatio formats a ratio for display (e.g., 2.5 -> "2.5x") +func formatRatio(ratio float64) string { + if ratio < 0.01 { + return "near zero" + } + if ratio < 1 { + return "significantly below" + } + if ratio < 1.5 { + return "slightly above" + } + if ratio < 2 { + return "1.5x" + } + if ratio < 3 { + return "2x" + } + if ratio < 5 { + return "3x" + } + return "~" + string([]byte{byte('0' + int(ratio))}) + "x" +} + +// GetAllAnomalies checks all resources with current metrics and returns all anomalies +// metricsProvider is a function that returns current metrics for a resource ID +func (s *Store) GetAllAnomalies(metricsProvider func(resourceID string) map[string]float64) []AnomalyReport { + s.mu.RLock() + resourceIDs := make([]string, 0, len(s.baselines)) + for id := range s.baselines { + resourceIDs = append(resourceIDs, id) + } + s.mu.RUnlock() + + var allAnomalies []AnomalyReport + for _, resourceID := range resourceIDs { + metrics := metricsProvider(resourceID) + if len(metrics) > 0 { + anomalies := s.CheckResourceAnomalies(resourceID, metrics) + allAnomalies = append(allAnomalies, anomalies...) + } + } + + return allAnomalies +} + // ResourceCount returns the number of resources with baselines func (s *Store) ResourceCount() int { s.mu.RLock() diff --git a/internal/ai/service.go b/internal/ai/service.go index 9cc0b49..5f6df02 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -211,6 +211,13 @@ func (s *Service) SetStateProvider(sp StateProvider) { } } +// GetStateProvider returns the state provider for infrastructure context +func (s *Service) GetStateProvider() StateProvider { + s.mu.RLock() + defer s.mu.RUnlock() + return s.stateProvider +} + // GetPatrolService returns the patrol service for background monitoring func (s *Service) GetPatrolService() *PatrolService { s.mu.RLock() diff --git a/internal/api/ai_intelligence_handlers.go b/internal/api/ai_intelligence_handlers.go index 00461b4..9a46bba 100644 --- a/internal/api/ai_intelligence_handlers.go +++ b/internal/api/ai_intelligence_handlers.go @@ -529,3 +529,233 @@ func remediationStatsFromRecords(records []ai.RemediationRecord) map[string]int return stats } + +// HandleGetAnomalies returns current baseline anomalies (GET /api/ai/intelligence/anomalies) +// This compares live metrics against learned baselines to surface deviations. +// Anomalies are deterministic (no LLM) - based on statistical z-score thresholds. +func (h *AISettingsHandler) HandleGetAnomalies(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + patrol := h.aiService.GetPatrolService() + if patrol == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "anomalies": []interface{}{}, + "message": "Patrol service not initialized", + }); err != nil { + log.Error().Err(err).Msg("Failed to write anomalies response") + } + return + } + + baselineStore := patrol.GetBaselineStore() + if baselineStore == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "anomalies": []interface{}{}, + "message": "Baseline store not initialized - baselines are still learning", + }); err != nil { + log.Error().Err(err).Msg("Failed to write anomalies response") + } + return + } + + // Get current metrics from state provider + stateProvider := h.aiService.GetStateProvider() + if stateProvider == nil { + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "anomalies": []interface{}{}, + "message": "State provider not available", + }); err != nil { + log.Error().Err(err).Msg("Failed to write anomalies response") + } + return + } + + // Get resource filter if provided + resourceID := r.URL.Query().Get("resource_id") + + // Collect anomalies + var result []map[string]interface{} + + // Get all baselines and check current metrics + allBaselines := baselineStore.GetAllBaselines() + + // Group by resource ID + resourceMetrics := make(map[string]map[string]float64) + resourceInfo := make(map[string]struct{ name, rtype string }) + + for _, baseline := range allBaselines { + if resourceID != "" && baseline.ResourceID != resourceID { + continue + } + if _, ok := resourceMetrics[baseline.ResourceID]; !ok { + resourceMetrics[baseline.ResourceID] = make(map[string]float64) + } + } + + // Get current state to extract live metrics + state := stateProvider.GetState() + + // Check VMs + for _, vm := range state.VMs { + if vm.Template { + continue // Skip templates + } + + // Skip if we don't have baselines for this resource + if _, ok := resourceMetrics[vm.ID]; !ok { + if resourceID == "" { + continue + } + if vm.ID != resourceID { + continue + } + } + + metrics := map[string]float64{ + "cpu": vm.CPU * 100, // CPU is already 0-1, convert to percentage + "memory": vm.Memory.Usage, // Memory.Usage is already in percentage + } + if vm.Disk.Usage > 0 { + metrics["disk"] = vm.Disk.Usage + } + + anomalies := baselineStore.CheckResourceAnomalies(vm.ID, metrics) + for _, anomaly := range anomalies { + result = append(result, map[string]interface{}{ + "resource_id": anomaly.ResourceID, + "resource_name": vm.Name, + "resource_type": "vm", + "metric": anomaly.Metric, + "current_value": anomaly.CurrentValue, + "baseline_mean": anomaly.BaselineMean, + "baseline_std_dev": anomaly.BaselineStdDev, + "z_score": anomaly.ZScore, + "severity": anomaly.Severity, + "description": anomaly.Description, + }) + } + + // Store info for any additional processing + resourceInfo[vm.ID] = struct{ name, rtype string }{vm.Name, "vm"} + } + + // Check Containers + for _, ct := range state.Containers { + if ct.Template { + continue // Skip templates + } + + // Skip if we don't have baselines for this resource + if _, ok := resourceMetrics[ct.ID]; !ok { + if resourceID == "" { + continue + } + if ct.ID != resourceID { + continue + } + } + + metrics := map[string]float64{ + "cpu": ct.CPU * 100, // CPU is already 0-1, convert to percentage + "memory": ct.Memory.Usage, // Memory.Usage is already in percentage + } + if ct.Disk.Usage > 0 { + metrics["disk"] = ct.Disk.Usage + } + + anomalies := baselineStore.CheckResourceAnomalies(ct.ID, metrics) + for _, anomaly := range anomalies { + result = append(result, map[string]interface{}{ + "resource_id": anomaly.ResourceID, + "resource_name": ct.Name, + "resource_type": "container", + "metric": anomaly.Metric, + "current_value": anomaly.CurrentValue, + "baseline_mean": anomaly.BaselineMean, + "baseline_std_dev": anomaly.BaselineStdDev, + "z_score": anomaly.ZScore, + "severity": anomaly.Severity, + "description": anomaly.Description, + }) + } + + // Store info for any additional processing + resourceInfo[ct.ID] = struct{ name, rtype string }{ct.Name, "container"} + } + + // Check nodes + for _, node := range state.Nodes { + nodeID := node.ID + + // Skip if we don't have baselines for this resource + if _, ok := resourceMetrics[nodeID]; !ok { + if resourceID == "" { + continue + } + if nodeID != resourceID { + continue + } + } + + metrics := map[string]float64{ + "cpu": node.CPU * 100, // CPU is already 0-1, convert to percentage + "memory": node.Memory.Usage, // Memory.Usage is already in percentage + } + + anomalies := baselineStore.CheckResourceAnomalies(nodeID, metrics) + for _, anomaly := range anomalies { + result = append(result, map[string]interface{}{ + "resource_id": anomaly.ResourceID, + "resource_name": node.Name, + "resource_type": "node", + "metric": anomaly.Metric, + "current_value": anomaly.CurrentValue, + "baseline_mean": anomaly.BaselineMean, + "baseline_std_dev": anomaly.BaselineStdDev, + "z_score": anomaly.ZScore, + "severity": anomaly.Severity, + "description": anomaly.Description, + }) + } + } + + // License gating + locked := !h.aiService.HasLicenseFeature(license.FeatureAIPatrol) + if locked { + w.Header().Set("X-License-Required", "true") + w.Header().Set("X-License-Feature", license.FeatureAIPatrol) + } + + count := len(result) + + // Count by severity for summary + severityCounts := map[string]int{ + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + } + for _, anomaly := range result { + if sev, ok := anomaly["severity"].(string); ok { + severityCounts[sev]++ + } + } + + if locked { + result = []map[string]interface{}{} + } + + if err := utils.WriteJSONResponse(w, map[string]interface{}{ + "anomalies": result, + "count": count, + "severity_counts": severityCounts, + "license_required": locked, + "upgrade_url": aiIntelligenceUpgradeURL, + }); err != nil { + log.Error().Err(err).Msg("Failed to write anomalies response") + } +} + diff --git a/internal/api/router.go b/internal/api/router.go index 1efac41..bad16ae 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1216,6 +1216,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/ai/intelligence/changes", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRecentChanges)) r.mux.HandleFunc("/api/ai/intelligence/baselines", RequireAuth(r.config, r.aiSettingsHandler.HandleGetBaselines)) r.mux.HandleFunc("/api/ai/intelligence/remediations", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRemediations)) + r.mux.HandleFunc("/api/ai/intelligence/anomalies", RequireAuth(r.config, r.aiSettingsHandler.HandleGetAnomalies)) // Agent WebSocket for AI command execution r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)