package api import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/ai/remediation" "github.com/rcourtman/pulse-go-rewrite/internal/ai/unified" "github.com/rcourtman/pulse-go-rewrite/internal/license" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rs/zerolog/log" ) const aiIntelligenceUpgradeURL = "https://pulserelay.pro/" // HandleGetPatterns returns detected failure patterns (GET /api/ai/intelligence/patterns) func (h *AISettingsHandler) HandleGetPatterns(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) if aiService == nil || !aiService.IsEnabled() { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "patterns": []interface{}{}, "message": "Pulse Patrol is not enabled", }); err != nil { log.Error().Err(err).Msg("Failed to write patterns response") } return } patrol := aiService.GetPatrolService() if patrol == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "patterns": []interface{}{}, "message": "Patrol service not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write patterns response") } return } detector := patrol.GetPatternDetector() if detector == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "patterns": []interface{}{}, "message": "Pattern detector not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write patterns response") } return } // Get resource filter if provided resourceID := r.URL.Query().Get("resource_id") patterns := detector.GetPatterns() var result []map[string]interface{} for key, pattern := range patterns { if resourceID != "" && pattern.ResourceID != resourceID { continue } result = append(result, map[string]interface{}{ "key": key, "resource_id": pattern.ResourceID, "event_type": pattern.EventType, "occurrences": pattern.Occurrences, "average_interval": pattern.AverageInterval.String(), "average_duration": pattern.AverageDuration.String(), "last_occurrence": pattern.LastOccurrence, "confidence": pattern.Confidence, }) } locked := aiService == nil count := len(result) if locked { result = []map[string]interface{}{} } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "patterns": result, "count": count, "license_required": locked, "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write patterns response") } } // HandleGetPredictions returns failure predictions (GET /api/ai/intelligence/predictions) func (h *AISettingsHandler) HandleGetPredictions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) // AI must be enabled to return intelligence data if aiService == nil || !aiService.IsEnabled() { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "predictions": []interface{}{}, "message": "Pulse Patrol is not enabled", }); err != nil { log.Error().Err(err).Msg("Failed to write predictions response") } return } patrol := aiService.GetPatrolService() if patrol == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "predictions": []interface{}{}, "message": "Patrol service not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write predictions response") } return } detector := patrol.GetPatternDetector() if detector == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "predictions": []interface{}{}, "message": "Pattern detector not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write predictions response") } return } // Get resource filter if provided resourceID := r.URL.Query().Get("resource_id") var predictions []ai.FailurePrediction if resourceID != "" { predictions = detector.GetPredictionsForResource(resourceID) } else { predictions = detector.GetPredictions() } var result []map[string]interface{} for _, pred := range predictions { isOverdue := pred.DaysUntil < 0 result = append(result, map[string]interface{}{ "resource_id": pred.ResourceID, "event_type": pred.EventType, "predicted_at": pred.PredictedAt, "days_until": pred.DaysUntil, "confidence": pred.Confidence, "basis": pred.Basis, "is_overdue": isOverdue, }) } locked := aiService == nil count := len(result) if locked { result = []map[string]interface{}{} } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "predictions": result, "count": count, "license_required": locked, "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write predictions response") } } // HandleGetCorrelations returns detected resource correlations (GET /api/ai/intelligence/correlations) func (h *AISettingsHandler) HandleGetCorrelations(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) // AI must be enabled to return intelligence data if aiService == nil || !aiService.IsEnabled() { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "correlations": []interface{}{}, "message": "Pulse Patrol is not enabled", }); err != nil { log.Error().Err(err).Msg("Failed to write correlations response") } return } patrol := aiService.GetPatrolService() if patrol == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "correlations": []interface{}{}, "message": "Patrol service not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write correlations response") } return } detector := patrol.GetCorrelationDetector() if detector == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "correlations": []interface{}{}, "message": "Correlation detector not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write correlations response") } return } // Get resource filter if provided resourceID := r.URL.Query().Get("resource_id") var correlations []*ai.Correlation if resourceID != "" { correlations = detector.GetCorrelationsForResource(resourceID) } else { correlations = detector.GetCorrelations() } var result []map[string]interface{} for _, corr := range correlations { result = append(result, map[string]interface{}{ "source_id": corr.SourceID, "source_name": corr.SourceName, "source_type": corr.SourceType, "target_id": corr.TargetID, "target_name": corr.TargetName, "target_type": corr.TargetType, "event_pattern": corr.EventPattern, "occurrences": corr.Occurrences, "avg_delay": corr.AvgDelay.String(), "confidence": corr.Confidence, "last_seen": corr.LastSeen, "description": corr.Description, }) } locked := aiService == nil count := len(result) if locked { result = []map[string]interface{}{} } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "correlations": result, "count": count, "license_required": locked, "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write correlations response") } } // HandleGetRecentChanges returns recent infrastructure changes (GET /api/ai/intelligence/changes) func (h *AISettingsHandler) HandleGetRecentChanges(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) // AI must be enabled to return intelligence data if aiService == nil || !aiService.IsEnabled() { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "changes": []interface{}{}, "message": "Pulse Patrol is not enabled", }); err != nil { log.Error().Err(err).Msg("Failed to write changes response") } return } patrol := aiService.GetPatrolService() if patrol == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "changes": []interface{}{}, "message": "Patrol service not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write changes response") } return } detector := patrol.GetChangeDetector() if detector == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "changes": []interface{}{}, "message": "Change detector not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write changes response") } return } // Get time range - default to 24 hours hoursStr := r.URL.Query().Get("hours") hours := 24 if hoursStr != "" { if h, err := strconv.Atoi(hoursStr); err == nil && h > 0 { hours = h } } since := time.Now().Add(-time.Duration(hours) * time.Hour) changes := detector.GetRecentChanges(100, since) var result []map[string]interface{} for _, change := range changes { result = append(result, map[string]interface{}{ "id": change.ID, "resource_id": change.ResourceID, "resource_name": change.ResourceName, "resource_type": change.ResourceType, "change_type": change.ChangeType, "before": change.Before, "after": change.After, "detected_at": change.DetectedAt, "description": change.Description, }) } locked := aiService == nil count := len(result) if locked { result = []map[string]interface{}{} } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "changes": result, "count": count, "hours": hours, "license_required": locked, "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write changes response") } } // HandleGetBaselines returns learned resource baselines (GET /api/ai/intelligence/baselines) func (h *AISettingsHandler) HandleGetBaselines(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) // AI must be enabled to return intelligence data if aiService == nil || !aiService.IsEnabled() { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "baselines": []interface{}{}, "message": "Pulse Patrol is not enabled", }); err != nil { log.Error().Err(err).Msg("Failed to write baselines response") } return } patrol := aiService.GetPatrolService() if patrol == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "baselines": []interface{}{}, "message": "Patrol service not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write baselines response") } return } store := patrol.GetBaselineStore() if store == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "baselines": []interface{}{}, "message": "Baseline store not initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write baselines response") } return } // Get resource filter if provided resourceID := r.URL.Query().Get("resource_id") baselines := store.GetAllBaselines() var result []map[string]interface{} for key, baseline := range baselines { if resourceID != "" && baseline.ResourceID != resourceID { continue } result = append(result, map[string]interface{}{ "key": key, "resource_id": baseline.ResourceID, "metric": baseline.Metric, "mean": baseline.Mean, "std_dev": baseline.StdDev, "min": baseline.Min, "max": baseline.Max, "samples": baseline.Samples, "last_update": baseline.LastUpdate, }) } locked := aiService == nil count := len(result) if locked { result = []map[string]interface{}{} } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "baselines": result, "count": count, "license_required": locked, "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write baselines response") } } // HandleGetRemediations returns remediation history (GET /api/ai/intelligence/remediations) func (h *AISettingsHandler) HandleGetRemediations(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) // Check for Pulse Pro license (soft-lock) locked := aiService == nil || !aiService.HasLicenseFeature(license.FeatureAIAutoFix) if locked { w.Header().Set("X-License-Required", "true") w.Header().Set("X-License-Feature", license.FeatureAIAutoFix) } // AI must be enabled to return intelligence data if aiService == nil || !aiService.IsEnabled() { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "remediations": []interface{}{}, "message": "Pulse Patrol is not enabled", "license_required": locked, "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write remediations response") } return } patrol := aiService.GetPatrolService() if patrol == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "remediations": []interface{}{}, "message": "Patrol service not initialized", "license_required": locked, "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write remediations response") } return } remediationLog := patrol.GetRemediationLog() if remediationLog == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "remediations": []interface{}{}, "message": "Remediation log not initialized", "license_required": locked, "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write remediations response") } return } resourceID := r.URL.Query().Get("resource_id") findingID := r.URL.Query().Get("finding_id") limit := 20 if limitStr := r.URL.Query().Get("limit"); limitStr != "" { if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { limit = parsed } } if limit > 100 { limit = 100 } hours := 168 if hoursStr := r.URL.Query().Get("hours"); hoursStr != "" { if parsed, err := strconv.Atoi(hoursStr); err == nil && parsed > 0 { hours = parsed } } since := time.Now().Add(-time.Duration(hours) * time.Hour) var records []ai.RemediationRecord switch { case findingID != "": records = remediationLog.GetForFinding(findingID, limit) case resourceID != "": records = remediationLog.GetForResource(resourceID, limit) default: records = remediationLog.GetRecentRemediations(limit, since) } stats := remediationStatsFromRecords(records) if findingID == "" && resourceID == "" { stats = remediationLog.GetRecentRemediationStats(since) } result := make([]map[string]interface{}, 0, len(records)) for _, rec := range records { durationMs := int64(0) if rec.Duration > 0 { durationMs = rec.Duration.Milliseconds() } result = append(result, map[string]interface{}{ "id": rec.ID, "timestamp": rec.Timestamp, "resource_id": rec.ResourceID, "resource_type": rec.ResourceType, "resource_name": rec.ResourceName, "finding_id": rec.FindingID, "problem": rec.Problem, "action": rec.Action, "output": rec.Output, "outcome": rec.Outcome, "duration_ms": durationMs, "note": rec.Note, "automatic": rec.Automatic, }) } count := len(result) if locked { result = []map[string]interface{}{} } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "remediations": result, "count": count, "stats": stats, "license_required": locked, "upgrade_url": aiIntelligenceUpgradeURL, }); err != nil { log.Error().Err(err).Msg("Failed to write remediations response") } } func remediationStatsFromRecords(records []ai.RemediationRecord) map[string]int { stats := map[string]int{ "total": len(records), "resolved": 0, "partial": 0, "failed": 0, "unknown": 0, "automatic": 0, "manual": 0, } for _, rec := range records { switch rec.Outcome { case ai.OutcomeResolved: stats["resolved"]++ case ai.OutcomePartial: stats["partial"]++ case ai.OutcomeFailed: stats["failed"]++ default: stats["unknown"]++ } if rec.Automatic { stats["automatic"]++ } else { stats["manual"]++ } } 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 } aiService := h.GetAIService(r.Context()) // AI must be enabled to return intelligence data if aiService == nil || !aiService.IsEnabled() { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "anomalies": []interface{}{}, "message": "Pulse Patrol is not enabled", }); err != nil { log.Error().Err(err).Msg("Failed to write anomalies response") } return } patrol := 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 := 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 VMs that aren't running - stopped VMs with 0% usage is expected, not an anomaly if vm.Status != "running" { continue } // 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.CheckResourceAnomaliesReadOnly(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 containers that aren't running - stopped containers with 0% usage is expected, not an anomaly if ct.Status != "running" { continue } // 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.CheckResourceAnomaliesReadOnly(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.CheckResourceAnomaliesReadOnly(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, }) } } 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]++ } } // NOTE: Anomaly detection is FREE (no license required) // It's purely deterministic statistical analysis with no LLM costs // This provides value to all users and encourages Pro upgrades for patrol if err := utils.WriteJSONResponse(w, map[string]interface{}{ "anomalies": result, "count": count, "severity_counts": severityCounts, "license_required": false, }); err != nil { log.Error().Err(err).Msg("Failed to write anomalies response") } } // HandleGetLearningStatus returns the current state of baseline learning (GET /api/ai/intelligence/learning) // This is FREE (no license required) and shows users how much the system has learned func (h *AISettingsHandler) HandleGetLearningStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) // AI must be enabled to return learning status if aiService == nil || !aiService.IsEnabled() { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "resources_baselined": 0, "total_metrics": 0, "status": "ai_disabled", "message": "Pulse Patrol is not enabled", }); err != nil { log.Error().Err(err).Msg("Failed to write learning status response") } return } patrol := aiService.GetPatrolService() if patrol == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "resources_baselined": 0, "total_metrics": 0, "status": "patrol_not_initialized", "message": "Baseline learning requires Pulse Patrol to be initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write learning status response") } return } baselineStore := patrol.GetBaselineStore() if baselineStore == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "resources_baselined": 0, "total_metrics": 0, "status": "baseline_store_not_initialized", "message": "Baseline store not yet initialized", }); err != nil { log.Error().Err(err).Msg("Failed to write learning status response") } return } // Get all baselines and count metrics baselines := baselineStore.GetAllBaselines() resourceCount := baselineStore.ResourceCount() // Count unique resources and total metrics resourceIDs := make(map[string]bool) totalMetrics := 0 metricCounts := make(map[string]int) // cpu, memory, disk counts for _, baseline := range baselines { resourceIDs[baseline.ResourceID] = true totalMetrics++ metricCounts[baseline.Metric]++ } // Determine status status := "learning" message := "Actively learning baseline patterns" if resourceCount == 0 { status = "waiting" message = "Waiting for metric data to learn from" } else if resourceCount >= 5 { status = "active" message = "Baselines established and anomaly detection is active" } locked := aiService == nil if err := utils.WriteJSONResponse(w, map[string]interface{}{ "resources_baselined": resourceCount, "total_metrics": totalMetrics, "metric_breakdown": metricCounts, "status": status, "message": message, "license_required": locked, }); err != nil { log.Error().Err(err).Msg("Failed to write learning status response") } } // ============================================================================ // Phase 6: AI Intelligence Handlers // ============================================================================ // HandleGetForecast returns trend forecast for a resource metric (GET /api/ai/forecast) func (h *AISettingsHandler) HandleGetForecast(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } forecastSvc := h.GetForecastService() if forecastSvc == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "forecast": nil, "message": "Forecast service not available", }); err != nil { log.Error().Err(err).Msg("Failed to write forecast response") } return } resourceID := r.URL.Query().Get("resource_id") resourceName := r.URL.Query().Get("resource_name") metric := r.URL.Query().Get("metric") horizonStr := r.URL.Query().Get("horizon_hours") thresholdStr := r.URL.Query().Get("threshold") if resourceID == "" || metric == "" { http.Error(w, "resource_id and metric parameters are required", http.StatusBadRequest) return } // Default horizon to 24 hours horizon := 24 * time.Hour if horizonStr != "" { hours, err := strconv.Atoi(horizonStr) if err == nil && hours > 0 { horizon = time.Duration(hours) * time.Hour } } // Default threshold to 90% threshold := 90.0 if thresholdStr != "" { if t, err := strconv.ParseFloat(thresholdStr, 64); err == nil && t > 0 { threshold = t } } forecast, err := forecastSvc.Forecast(resourceID, resourceName, metric, horizon, threshold) if err != nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "forecast": nil, "error": err.Error(), }); err != nil { log.Error().Err(err).Msg("Failed to write forecast error response") } return } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "forecast": forecast, }); err != nil { log.Error().Err(err).Msg("Failed to write forecast response") } } // HandleGetForecastOverview returns forecasts for all resources sorted by urgency (GET /api/ai/forecasts/overview) func (h *AISettingsHandler) HandleGetForecastOverview(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } forecastSvc := h.GetForecastService() if forecastSvc == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "forecasts": []interface{}{}, "message": "Forecast service not available", "metric": "", "threshold": 0, "horizon_hours": 0, }); err != nil { log.Error().Err(err).Msg("Failed to write forecast overview response") } return } // Parse query parameters metric := r.URL.Query().Get("metric") if metric == "" { metric = "cpu" // Default to CPU } // Default horizon to 168 hours (7 days) horizonStr := r.URL.Query().Get("horizon_hours") horizon := 168 * time.Hour if horizonStr != "" { hours, err := strconv.Atoi(horizonStr) if err == nil && hours > 0 { horizon = time.Duration(hours) * time.Hour } } // Default threshold to 90% thresholdStr := r.URL.Query().Get("threshold") threshold := 90.0 if thresholdStr != "" { if t, err := strconv.ParseFloat(thresholdStr, 64); err == nil && t > 0 { threshold = t } } overview, err := forecastSvc.ForecastAll(metric, horizon, threshold) if err != nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "forecasts": []interface{}{}, "error": err.Error(), "metric": metric, "threshold": threshold, "horizon_hours": int(horizon.Hours()), }); err != nil { log.Error().Err(err).Msg("Failed to write forecast overview error response") } return } if err := utils.WriteJSONResponse(w, overview); err != nil { log.Error().Err(err).Msg("Failed to write forecast overview response") } } // HandleGetLearningPreferences returns learned user preferences (GET /api/ai/learning/preferences) func (h *AISettingsHandler) HandleGetLearningPreferences(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } learningStore := h.GetLearningStore() if learningStore == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "preferences": nil, "message": "Learning store not available", }); err != nil { log.Error().Err(err).Msg("Failed to write learning preferences response") } return } resourceID := r.URL.Query().Get("resource_id") var response map[string]interface{} if resourceID != "" { // Get preferences for specific resource prefs := learningStore.GetResourcePreference(resourceID) response = map[string]interface{}{ "resource_id": resourceID, "preferences": prefs, "context": learningStore.FormatForContext(), } } else { // Get overall statistics stats := learningStore.GetStatistics() response = map[string]interface{}{ "statistics": stats, "context": learningStore.FormatForContext(), } } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write learning preferences response") } } // HandleGetUnifiedFindings returns unified findings from alerts and AI (GET /api/ai/unified/findings) func (h *AISettingsHandler) HandleGetUnifiedFindings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } store := h.GetUnifiedStore() if store == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "findings": []interface{}{}, "message": "Unified store not available", }); err != nil { log.Error().Err(err).Msg("Failed to write unified findings response") } return } resourceID := strings.TrimSpace(r.URL.Query().Get("resource_id")) source := strings.TrimSpace(r.URL.Query().Get("source")) includeResolved := false if includeStr := strings.TrimSpace(r.URL.Query().Get("include_resolved")); includeStr != "" { switch strings.ToLower(includeStr) { case "1", "true", "yes", "y": includeResolved = true } } var findings []*unified.UnifiedFinding if includeResolved { findings = store.GetAll() } else { findings = store.GetActive() } type findingView struct { ID string `json:"id"` Source string `json:"source"` Severity string `json:"severity"` Category string `json:"category"` ResourceID string `json:"resource_id"` ResourceName string `json:"resource_name"` ResourceType string `json:"resource_type"` Node string `json:"node,omitempty"` Title string `json:"title"` Description string `json:"description"` Recommendation string `json:"recommendation,omitempty"` Evidence string `json:"evidence,omitempty"` AlertID string `json:"alert_id,omitempty"` AlertType string `json:"alert_type,omitempty"` Value float64 `json:"value,omitempty"` Threshold float64 `json:"threshold,omitempty"` IsThreshold bool `json:"is_threshold,omitempty"` AIContext string `json:"ai_context,omitempty"` RootCauseID string `json:"root_cause_id,omitempty"` CorrelatedIDs []string `json:"correlated_ids,omitempty"` RemediationID string `json:"remediation_id,omitempty"` AIConfidence float64 `json:"ai_confidence,omitempty"` EnhancedByAI bool `json:"enhanced_by_ai,omitempty"` AIEnhancedAt *time.Time `json:"ai_enhanced_at,omitempty"` // Investigation fields InvestigationSessionID string `json:"investigationSessionId,omitempty"` InvestigationStatus string `json:"investigationStatus,omitempty"` InvestigationOutcome string `json:"investigationOutcome,omitempty"` LastInvestigatedAt *time.Time `json:"lastInvestigatedAt,omitempty"` InvestigationAttempts int `json:"investigationAttempts,omitempty"` // Timestamps and user feedback DetectedAt time.Time `json:"detected_at"` LastSeenAt time.Time `json:"last_seen_at"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` SnoozedUntil *time.Time `json:"snoozed_until,omitempty"` DismissedReason string `json:"dismissed_reason,omitempty"` UserNote string `json:"user_note,omitempty"` Suppressed bool `json:"suppressed,omitempty"` TimesRaised int `json:"times_raised,omitempty"` Status string `json:"status"` } now := time.Now() result := make([]findingView, 0, len(findings)) activeCount := 0 for _, f := range findings { if f == nil { continue } if resourceID != "" && f.ResourceID != resourceID { continue } if source != "" && string(f.Source) != source { continue } status := "active" if f.ResolvedAt != nil { status = "resolved" } else if f.SnoozedUntil != nil && now.Before(*f.SnoozedUntil) { status = "snoozed" } else if f.DismissedReason != "" || f.Suppressed { status = "dismissed" } if status == "active" { activeCount++ } result = append(result, findingView{ ID: f.ID, Source: string(f.Source), Severity: string(f.Severity), Category: string(f.Category), ResourceID: f.ResourceID, ResourceName: f.ResourceName, ResourceType: f.ResourceType, Node: f.Node, Title: f.Title, Description: f.Description, Recommendation: f.Recommendation, Evidence: f.Evidence, AlertID: f.AlertID, AlertType: f.AlertType, Value: f.Value, Threshold: f.Threshold, IsThreshold: f.IsThreshold, AIContext: f.AIContext, RootCauseID: f.RootCauseID, CorrelatedIDs: f.CorrelatedIDs, RemediationID: f.RemediationID, AIConfidence: f.AIConfidence, EnhancedByAI: f.EnhancedByAI, AIEnhancedAt: f.AIEnhancedAt, InvestigationSessionID: f.InvestigationSessionID, InvestigationStatus: f.InvestigationStatus, InvestigationOutcome: f.InvestigationOutcome, LastInvestigatedAt: f.LastInvestigatedAt, InvestigationAttempts: f.InvestigationAttempts, DetectedAt: f.DetectedAt, LastSeenAt: f.LastSeenAt, ResolvedAt: f.ResolvedAt, AcknowledgedAt: f.AcknowledgedAt, SnoozedUntil: f.SnoozedUntil, DismissedReason: f.DismissedReason, UserNote: f.UserNote, Suppressed: f.Suppressed, TimesRaised: f.TimesRaised, Status: status, }) } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "findings": result, "count": len(result), "active_count": activeCount, }); err != nil { log.Error().Err(err).Msg("Failed to write unified findings response") } } // HandleGetProxmoxEvents returns recent Proxmox events (GET /api/ai/proxmox/events) func (h *AISettingsHandler) HandleGetProxmoxEvents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } correlator := h.GetProxmoxCorrelator() if correlator == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "events": []interface{}{}, "message": "Proxmox event correlator not available", }); err != nil { log.Error().Err(err).Msg("Failed to write proxmox events response") } return } durationStr := r.URL.Query().Get("duration") duration := 30 * time.Minute if durationStr != "" { if mins, err := strconv.Atoi(durationStr); err == nil && mins > 0 { duration = time.Duration(mins) * time.Minute } } limitStr := r.URL.Query().Get("limit") limit := 50 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 { limit = l } } resourceID := r.URL.Query().Get("resource_id") var events interface{} if resourceID != "" { events = correlator.GetEventsForResource(resourceID, limit) } else { events = correlator.GetRecentEventsWithLimit(duration, limit) } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "events": events, "active_operations": correlator.GetActiveOperations(), }); err != nil { log.Error().Err(err).Msg("Failed to write proxmox events response") } } // HandleGetProxmoxCorrelations returns Proxmox event correlations (GET /api/ai/proxmox/correlations) func (h *AISettingsHandler) HandleGetProxmoxCorrelations(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } correlator := h.GetProxmoxCorrelator() if correlator == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "correlations": []interface{}{}, "message": "Proxmox event correlator not available", }); err != nil { log.Error().Err(err).Msg("Failed to write proxmox correlations response") } return } limitStr := r.URL.Query().Get("limit") limit := 20 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } } resourceID := r.URL.Query().Get("resource_id") var correlations interface{} if resourceID != "" { correlations = correlator.GetCorrelationsForResource(resourceID) } else { correlations = correlator.GetCorrelations(limit) } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "correlations": correlations, }); err != nil { log.Error().Err(err).Msg("Failed to write proxmox correlations response") } } // HandleGetRemediationPlans returns remediation plans with status (GET /api/ai/remediation/plans) // Note: Plans are transient and stored in memory with their executions func (h *AISettingsHandler) HandleGetRemediationPlans(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } engine := h.GetRemediationEngine() if engine == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "plans": []interface{}{}, "message": "Remediation engine not available", }); err != nil { log.Error().Err(err).Msg("Failed to write remediation plans response") } return } limitStr := r.URL.Query().Get("limit") limit := 50 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { limit = l } } plans := engine.ListPlans(limit) type stepView struct { Order int `json:"order"` Action string `json:"action"` Command string `json:"command,omitempty"` RollbackCommand string `json:"rollback_command,omitempty"` RiskLevel string `json:"risk_level"` } type planView struct { ID string `json:"id"` FindingID string `json:"finding_id"` ResourceID string `json:"resource_id"` Title string `json:"title"` Description string `json:"description"` Steps []stepView `json:"steps"` RiskLevel string `json:"risk_level"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } result := make([]planView, 0, len(plans)) for _, plan := range plans { if plan == nil { continue } riskLevel := string(plan.RiskLevel) if plan.RiskLevel == remediation.RiskCritical { riskLevel = string(remediation.RiskHigh) } status := "pending" if exec := engine.GetLatestExecutionForPlan(plan.ID); exec != nil { switch exec.Status { case remediation.StatusApproved: status = "approved" case remediation.StatusRunning: status = "executing" case remediation.StatusCompleted: status = "completed" case remediation.StatusFailed: status = "failed" case remediation.StatusRolledBack: status = "rolled_back" default: status = "pending" } } steps := make([]stepView, 0, len(plan.Steps)) for _, step := range plan.Steps { action := step.Description if action == "" { action = step.Command } steps = append(steps, stepView{ Order: step.Order, Action: action, Command: step.Command, RollbackCommand: step.Rollback, RiskLevel: riskLevel, }) } result = append(result, planView{ ID: plan.ID, FindingID: plan.FindingID, ResourceID: plan.ResourceID, Title: plan.Title, Description: plan.Description, Steps: steps, RiskLevel: riskLevel, Status: status, CreatedAt: plan.CreatedAt, }) } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "plans": result, }); err != nil { log.Error().Err(err).Msg("Failed to write remediation plans response") } } // HandleGetRemediationPlan returns a specific remediation plan (GET /api/ai/remediation/plans/{id}) func (h *AISettingsHandler) HandleGetRemediationPlan(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } engine := h.GetRemediationEngine() if engine == nil { http.Error(w, "Remediation engine not available", http.StatusServiceUnavailable) return } planID := r.URL.Query().Get("plan_id") if planID == "" { http.Error(w, "plan_id is required", http.StatusBadRequest) return } plan := engine.GetPlan(planID) if plan == nil { http.Error(w, "Plan not found", http.StatusNotFound) return } if err := utils.WriteJSONResponse(w, plan); err != nil { log.Error().Err(err).Msg("Failed to write remediation plan response") } } // HandleApproveRemediationPlan approves a remediation plan (POST /api/ai/remediation/plans/{id}/approve) func (h *AISettingsHandler) HandleApproveRemediationPlan(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } engine := h.GetRemediationEngine() if engine == nil { http.Error(w, "Remediation engine not available", http.StatusServiceUnavailable) return } var req struct { PlanID string `json:"plan_id"` ApprovedBy string `json:"approved_by"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.PlanID == "" { http.Error(w, "plan_id is required", http.StatusBadRequest) return } if req.ApprovedBy == "" { req.ApprovedBy = "api" } execution, err := engine.ApprovePlan(req.PlanID, req.ApprovedBy) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "success": true, "message": "Plan approved", "execution": execution, }); err != nil { log.Error().Err(err).Msg("Failed to write remediation approval response") } } // HandleExecuteRemediationPlan executes an approved remediation plan (POST /api/ai/remediation/plans/{id}/execute) func (h *AISettingsHandler) HandleExecuteRemediationPlan(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } engine := h.GetRemediationEngine() if engine == nil { http.Error(w, "Remediation engine not available", http.StatusServiceUnavailable) return } var req struct { ExecutionID string `json:"execution_id"` PlanID string `json:"plan_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.ExecutionID == "" { if req.PlanID == "" { http.Error(w, "execution_id or plan_id is required", http.StatusBadRequest) return } // Auto-approve the plan if only plan_id is provided exec, err := engine.ApprovePlan(req.PlanID, "api") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } req.ExecutionID = exec.ID } if err := engine.Execute(r.Context(), req.ExecutionID); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } execution := engine.GetExecution(req.ExecutionID) // Launch background verification if execution completed successfully if execution != nil && execution.Status == remediation.StatusCompleted { plan := engine.GetPlan(execution.PlanID) aiSvc := h.GetAIService(r.Context()) if plan != nil && plan.FindingID != "" && aiSvc != nil { go func() { time.Sleep(30 * time.Second) patrol := aiSvc.GetPatrolService() if patrol == nil { log.Warn().Str("findingID", plan.FindingID).Msg("[Remediation] Post-fix verification skipped: no patrol service") return } finding := patrol.GetFindings().Get(plan.FindingID) if finding == nil { log.Warn().Str("findingID", plan.FindingID).Msg("[Remediation] Post-fix verification skipped: finding not found") return } bgCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() verified, verifyErr := patrol.VerifyFixResolved(bgCtx, finding.ResourceID, finding.ResourceType, finding.Key, finding.ID) if verifyErr != nil { log.Error().Err(verifyErr).Str("findingID", plan.FindingID).Msg("[Remediation] Post-fix verification failed with error") } else if !verified { log.Warn().Str("findingID", plan.FindingID).Msg("[Remediation] Post-fix verification: issue persists") } else { log.Info().Str("findingID", plan.FindingID).Msg("[Remediation] Post-fix verification: issue resolved") } // Update execution status based on verification result if verifyErr != nil { engine.SetExecutionVerification(execution.ID, false, fmt.Sprintf("Verification error: %v", verifyErr)) } else if !verified { engine.SetExecutionVerification(execution.ID, false, "Issue persists after fix") } else { engine.SetExecutionVerification(execution.ID, true, "Issue resolved") } }() } } if err := utils.WriteJSONResponse(w, execution); err != nil { log.Error().Err(err).Msg("Failed to write remediation execution response") } } // HandleRollbackRemediationPlan rolls back an executed remediation (POST /api/ai/remediation/plans/{id}/rollback) func (h *AISettingsHandler) HandleRollbackRemediationPlan(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } engine := h.GetRemediationEngine() if engine == nil { http.Error(w, "Remediation engine not available", http.StatusServiceUnavailable) return } var req struct { ExecutionID string `json:"execution_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.ExecutionID == "" { http.Error(w, "execution_id is required", http.StatusBadRequest) return } if err := engine.Rollback(r.Context(), req.ExecutionID); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "success": true, "message": "Rollback initiated", }); err != nil { log.Error().Err(err).Msg("Failed to write remediation rollback response") } } // HandleGetCircuitBreakerStatus returns the circuit breaker status (GET /api/ai/circuit/status) func (h *AISettingsHandler) HandleGetCircuitBreakerStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } breaker := h.GetCircuitBreaker() if breaker == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "status": "unknown", "message": "Circuit breaker not available", }); err != nil { log.Error().Err(err).Msg("Failed to write circuit breaker status response") } return } status := breaker.GetStatus() if err := utils.WriteJSONResponse(w, map[string]interface{}{ "state": status.State, "can_patrol": breaker.CanAllow(), // Use read-only check to avoid state transitions "consecutive_failures": status.ConsecutiveFailures, "total_successes": status.TotalSuccesses, "total_failures": status.TotalFailures, }); err != nil { log.Error().Err(err).Msg("Failed to write circuit breaker status response") } } // ============================================ // Phase 7: Incident Recording API Handlers // ============================================ // HandleGetRecentIncidents returns recent incident recording windows (GET /api/ai/incidents) func (h *AISettingsHandler) HandleGetRecentIncidents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Get limit from query params limitStr := r.URL.Query().Get("limit") limit := 20 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } // Get coordinator status coordinator := h.GetIncidentCoordinator() var activeCount int if coordinator != nil { activeCount = coordinator.GetActiveIncidentCount() } // Get incident data from patrol service svc := h.GetAIService(r.Context()) if svc == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "incidents": []interface{}{}, "active_count": activeCount, "message": "Pulse Patrol service not available", }); err != nil { log.Error().Err(err).Msg("Failed to write incidents response") } return } patrol := svc.GetPatrolService() if patrol == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "incidents": []interface{}{}, "active_count": activeCount, "message": "Patrol service not available", }); err != nil { log.Error().Err(err).Msg("Failed to write incidents response") } return } // Get incidents from incident store incidentStore := patrol.GetIncidentStore() if incidentStore == nil { if err := utils.WriteJSONResponse(w, map[string]interface{}{ "incidents": []interface{}{}, "active_count": activeCount, "message": "Incident store not available", }); err != nil { log.Error().Err(err).Msg("Failed to write incidents response") } return } // Get the resource ID filter if provided resourceID := r.URL.Query().Get("resource_id") var incidents interface{} if resourceID != "" { incidents = incidentStore.ListIncidentsByResource(resourceID, limit) } else { // No direct method to list all incidents, use FormatForPatrol for now // This is a limitation - we may want to add ListRecentIncidents to the store incidentSummary := incidentStore.FormatForPatrol(limit) if err := utils.WriteJSONResponse(w, map[string]interface{}{ "incidents": []interface{}{}, "incident_summary": incidentSummary, "active_count": activeCount, }); err != nil { log.Error().Err(err).Msg("Failed to write incidents response") } return } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "incidents": incidents, "active_count": activeCount, }); err != nil { log.Error().Err(err).Msg("Failed to write incidents response") } } // HandleGetIncidentData returns incident data for a specific resource (GET /api/ai/incidents/{resourceID}) func (h *AISettingsHandler) HandleGetIncidentData(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Extract resource ID from URL path path := r.URL.Path prefix := "/api/ai/incidents/" if !strings.HasPrefix(path, prefix) { http.Error(w, "Invalid path", http.StatusBadRequest) return } resourceID := strings.TrimPrefix(path, prefix) if resourceID == "" { http.Error(w, "resource_id is required", http.StatusBadRequest) return } // URL-decode the resource ID (handles IDs with slashes like "node/pve") if decoded, err := url.PathUnescape(resourceID); err == nil { resourceID = decoded } // Get limit from query params limitStr := r.URL.Query().Get("limit") limit := 10 if limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 { limit = l } } // Get incident data from patrol service svc := h.GetAIService(r.Context()) if svc == nil { http.Error(w, "Pulse Patrol service not available", http.StatusServiceUnavailable) return } patrol := svc.GetPatrolService() if patrol == nil { http.Error(w, "Patrol service not available", http.StatusServiceUnavailable) return } // Get incidents from incident store incidentStore := patrol.GetIncidentStore() if incidentStore == nil { http.Error(w, "Incident store not available", http.StatusServiceUnavailable) return } incidents := incidentStore.ListIncidentsByResource(resourceID, limit) // Also get formatted context for AI formattedContext := incidentStore.FormatForResource(resourceID, limit) if err := utils.WriteJSONResponse(w, map[string]interface{}{ "resource_id": resourceID, "incidents": incidents, "formatted_context": formattedContext, }); err != nil { log.Error().Err(err).Msg("Failed to write incident data response") } }