mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
e2b6fe3b16
Filter canonical history before selecting occurrences, preserve source evidence and expose bounded reads and failures. Reconcile duplicate saved shells without splitting one alert lifecycle, and keep note identity and canonical risk intact. Carry attributed operator notes into Assistant. Preserve mobile investigations across layout changes, transfer composer focus on handoff and keep long event text readable. Record scoped qualification and its unresolved wider limits. Refs #1782
1555 lines
48 KiB
Go
1555 lines
48 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/unified"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type proxmoxWorkload interface {
|
|
ID() string
|
|
Name() string
|
|
Status() unifiedresources.ResourceStatus
|
|
Template() bool
|
|
MemoryPercent() float64
|
|
CPUPercent() float64
|
|
DiskPercent() float64
|
|
}
|
|
|
|
func checkWorkloadAnomalies(
|
|
workload proxmoxWorkload,
|
|
resourceID string,
|
|
resourceMetrics map[string]map[string]float64,
|
|
baselineStore *baseline.Store,
|
|
result *[]map[string]interface{},
|
|
resourceInfo map[string]struct{ name, rtype string },
|
|
resourceType string,
|
|
) {
|
|
if workload == nil {
|
|
return
|
|
}
|
|
if workload.Template() {
|
|
return
|
|
}
|
|
if workload.Status() != unifiedresources.StatusOnline {
|
|
return
|
|
}
|
|
|
|
if _, ok := resourceMetrics[workload.ID()]; !ok {
|
|
if resourceID == "" {
|
|
return
|
|
}
|
|
if workload.ID() != resourceID {
|
|
return
|
|
}
|
|
}
|
|
|
|
metrics := map[string]float64{
|
|
"memory": workload.MemoryPercent(),
|
|
}
|
|
if cpu := workload.CPUPercent(); cpu > 0 {
|
|
metrics["cpu"] = cpu
|
|
}
|
|
if disk := workload.DiskPercent(); disk > 0 {
|
|
metrics["disk"] = disk
|
|
}
|
|
|
|
anomalies := baselineStore.CheckResourceAnomaliesReadOnly(workload.ID(), metrics)
|
|
for _, anomaly := range anomalies {
|
|
*result = append(*result, map[string]interface{}{
|
|
"resource_id": anomaly.ResourceID,
|
|
"resource_name": workload.Name(),
|
|
"resource_type": resourceType,
|
|
"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,
|
|
})
|
|
}
|
|
|
|
resourceInfo[workload.ID()] = struct{ name, rtype string }{workload.Name(), resourceType}
|
|
}
|
|
|
|
func aiIntelligenceUpgradeURL() string {
|
|
return upgradeURLForFeatureFromLicensing(featureAIPatrolValue)
|
|
}
|
|
|
|
func writeIncidentDataUnavailableResponse(w http.ResponseWriter, resourceID, message string) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
|
"resource_id": resourceID,
|
|
"incidents": []interface{}{},
|
|
"formatted_context": "",
|
|
"message": message,
|
|
}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write incident data unavailable response")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
intel := patrol.GetIntelligence()
|
|
if intel == nil || !intel.HasCorrelationsSource() {
|
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
|
"correlations": []interface{}{},
|
|
"message": "Correlation intelligence 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")
|
|
correlations := intel.GetCorrelations(resourceID)
|
|
|
|
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
|
|
}
|
|
|
|
intel := patrol.GetIntelligence()
|
|
if intel == nil || !intel.HasRecentChangesSource() {
|
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
|
"changes": []interface{}{},
|
|
"message": "Recent changes 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 := intel.GetRecentChanges(since, 100)
|
|
|
|
var result []map[string]interface{}
|
|
for _, change := range changes {
|
|
resourceName, resourceType := intel.DescribeResource(change.ResourceID)
|
|
if strings.TrimSpace(resourceName) == "" {
|
|
resourceName = change.ResourceID
|
|
}
|
|
result = append(result, map[string]interface{}{
|
|
"id": change.ID,
|
|
"resource_id": change.ResourceID,
|
|
"resource_name": resourceName,
|
|
"resource_type": resourceType,
|
|
"change_type": string(change.Kind),
|
|
"before": change.From,
|
|
"after": change.To,
|
|
"detected_at": change.ObservedAt,
|
|
"description": unifiedresources.FormatResourceChangeSummary(change),
|
|
})
|
|
}
|
|
|
|
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(featureAIAutoFixValue)
|
|
if locked {
|
|
w.Header().Set("X-License-Required", "true")
|
|
w.Header().Set("X-License-Feature", featureAIAutoFixValue)
|
|
}
|
|
|
|
// 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{}{}
|
|
count = 0
|
|
stats = remediationStatsFromRecords(nil)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ReadState is the canonical resource read surface for AI intelligence endpoints.
|
|
// If this isn't wired, we can't reliably join baselines to live resources.
|
|
rs := h.readState
|
|
if rs == nil {
|
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
|
"anomalies": []interface{}{},
|
|
"message": "ReadState 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)
|
|
}
|
|
}
|
|
|
|
// Check VMs
|
|
for _, vm := range rs.VMs() {
|
|
checkWorkloadAnomalies(vm, resourceID, resourceMetrics, baselineStore, &result, resourceInfo, "vm")
|
|
}
|
|
|
|
// Check Containers
|
|
for _, ct := range rs.Containers() {
|
|
checkWorkloadAnomalies(ct, resourceID, resourceMetrics, baselineStore, &result, resourceInfo, "container")
|
|
}
|
|
|
|
// Check nodes
|
|
for _, node := range rs.Nodes() {
|
|
if node == nil {
|
|
continue
|
|
}
|
|
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.CPUPercent(),
|
|
"memory": node.MemoryPercent(),
|
|
}
|
|
|
|
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.GetForecastServiceForOrg(GetOrgID(r.Context()))
|
|
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 {
|
|
log.Error().Err(err).Str("resource_id", resourceID).Str("metric", metric).Msg("Forecast failed")
|
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
|
"forecast": nil,
|
|
"error": "Forecast generation failed",
|
|
}); 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.GetForecastServiceForOrg(GetOrgID(r.Context()))
|
|
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 {
|
|
log.Error().Err(err).Str("metric", metric).Msg("Forecast overview failed")
|
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
|
"forecasts": []interface{}{},
|
|
"error": "Forecast generation failed",
|
|
"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.GetLearningStoreForOrg(GetOrgID(r.Context()))
|
|
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.GetUnifiedStoreForOrg(GetOrgID(r.Context()))
|
|
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"`
|
|
Impact string `json:"impact,omitempty"`
|
|
Recommendation string `json:"recommendation,omitempty"`
|
|
Evidence string `json:"evidence,omitempty"`
|
|
AlertIdentifier string `json:"alert_identifier,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"`
|
|
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:"investigation_session_id,omitempty"`
|
|
InvestigationStatus string `json:"investigation_status,omitempty"`
|
|
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
|
|
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
|
|
InvestigationAttempts int `json:"investigation_attempts,omitempty"`
|
|
InvestigationRecord *aicontracts.InvestigationRecord `json:"investigation_record,omitempty"`
|
|
LoopState string `json:"loop_state,omitempty"`
|
|
Lifecycle []unified.UnifiedFindingLifecycleEvent `json:"lifecycle"`
|
|
RegressionCount int `json:"regression_count,omitempty"`
|
|
LastRegressionAt *time.Time `json:"last_regression_at,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
|
|
normalizeFindingView := func(view findingView) findingView {
|
|
if view.CorrelatedIDs == nil {
|
|
view.CorrelatedIDs = []string{}
|
|
}
|
|
if view.Lifecycle == nil {
|
|
view.Lifecycle = []unified.UnifiedFindingLifecycleEvent{}
|
|
}
|
|
return view
|
|
}
|
|
|
|
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, normalizeFindingView(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,
|
|
Impact: f.Impact,
|
|
Recommendation: f.Recommendation,
|
|
Evidence: f.Evidence,
|
|
AlertIdentifier: f.AlertIdentifier,
|
|
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,
|
|
InvestigationRecord: f.InvestigationRecord,
|
|
LoopState: f.LoopState,
|
|
Lifecycle: f.Lifecycle,
|
|
RegressionCount: f.RegressionCount,
|
|
LastRegressionAt: f.LastRegressionAt,
|
|
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.GetProxmoxCorrelatorForOrg(GetOrgID(r.Context()))
|
|
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.GetProxmoxCorrelatorForOrg(GetOrgID(r.Context()))
|
|
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")
|
|
}
|
|
}
|
|
|
|
// 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.GetCircuitBreakerForOrg(GetOrgID(r.Context()))
|
|
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 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": nil,
|
|
"active_count_status": "not_measured",
|
|
"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": nil,
|
|
"active_count_status": "not_measured",
|
|
"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": nil,
|
|
"active_count_status": "not_measured",
|
|
"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")
|
|
|
|
page, err := incidentStore.QueryIncidents(memory.IncidentQuery{ResourceID: resourceID, Limit: limit})
|
|
if err != nil {
|
|
http.Error(w, "Incident history unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
|
"incidents": page.Incidents,
|
|
"history": page.History,
|
|
"incident_summary": memory.FormatIncidentPageForResource(page),
|
|
"active_count": nil,
|
|
"active_count_status": "not_measured",
|
|
}); 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 {
|
|
writeIncidentDataUnavailableResponse(w, resourceID, "Pulse Patrol service not available")
|
|
return
|
|
}
|
|
|
|
patrol := svc.GetPatrolService()
|
|
if patrol == nil {
|
|
writeIncidentDataUnavailableResponse(w, resourceID, "Patrol service not available")
|
|
return
|
|
}
|
|
|
|
// Get incidents from incident store
|
|
incidentStore := patrol.GetIncidentStore()
|
|
if incidentStore == nil {
|
|
writeIncidentDataUnavailableResponse(w, resourceID, "Incident store not available")
|
|
return
|
|
}
|
|
|
|
page, err := incidentStore.QueryIncidents(memory.IncidentQuery{ResourceID: resourceID, Limit: limit})
|
|
if err != nil {
|
|
http.Error(w, "Incident history unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
incidents := page.Incidents
|
|
formattedContext := memory.FormatIncidentPageForResource(page)
|
|
|
|
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
|
"resource_id": resourceID,
|
|
"incidents": incidents,
|
|
"formatted_context": formattedContext,
|
|
"history": page.History,
|
|
}); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write incident data response")
|
|
}
|
|
}
|