feat(ai): add real-time anomaly detection endpoint

Add /api/ai/intelligence/anomalies endpoint that compares live metrics
against learned baselines to surface deviations - all deterministic
(no LLM required).

Backend:
- Add AnomalyReport struct with severity classification
- Add CheckResourceAnomalies method to baseline store
- Add HandleGetAnomalies API handler
- Add GetStateProvider getter to AI service

Frontend:
- Add AnomalyReport and AnomaliesResponse types
- Add getAnomalies API function
- Add AnomalySeverity type

This is the first step toward surfacing deterministic intelligence
directly in the UI without requiring LLM interaction.
This commit is contained in:
rcourtman 2025-12-21 10:52:54 +00:00
parent 8019acd6b6
commit c5c9bf4fb9
6 changed files with 398 additions and 9 deletions

View file

@ -16,6 +16,7 @@ import type {
ChangesResponse,
BaselinesResponse,
RemediationsResponse,
AnomaliesResponse,
IntelligenceSummary,
ResourceIntelligence,
} from '@/types/aiIntelligence';
@ -121,6 +122,13 @@ export class AIAPI {
return apiFetchJSON(`${this.baseUrl}/ai/intelligence/remediations${query ? `?${query}` : ''}`) as Promise<RemediationsResponse>;
}
// Get current anomalies (real-time baseline deviation detection)
// Returns metrics that are currently deviating significantly from learned baselines
static async getAnomalies(resourceId?: string): Promise<AnomaliesResponse> {
const params = resourceId ? `?resource_id=${encodeURIComponent(resourceId)}` : '';
return apiFetchJSON(`${this.baseUrl}/ai/intelligence/anomalies${params}`) as Promise<AnomaliesResponse>;
}
// ============================================
// Unified Intelligence API
// Aggregates all AI subsystems into a single view

View file

@ -126,6 +126,33 @@ export interface RemediationsResponse extends LicenseGatedResponse {
stats?: RemediationStats;
}
// Real-time anomaly detection types
export type AnomalySeverity = 'low' | 'medium' | 'high' | 'critical';
export interface AnomalyReport {
resource_id: string;
resource_name: string;
resource_type: string;
metric: string;
current_value: number;
baseline_mean: number;
baseline_std_dev: number;
z_score: number;
severity: AnomalySeverity;
description: string;
}
export interface AnomaliesResponse extends LicenseGatedResponse {
anomalies: AnomalyReport[];
count: number;
severity_counts: {
critical: number;
high: number;
medium: number;
low: number;
};
}
// ============================================================================
// Unified Intelligence Types (for /api/ai/intelligence endpoint)
// ============================================================================
@ -231,12 +258,3 @@ export interface ResourceIntelligence {
note_count: number;
}
export interface AnomalyReport {
metric: string;
current_value: number;
baseline_mean: number;
z_score: number;
severity: string; // "critical", "high", "medium", "low"
description: string;
}

View file

@ -271,6 +271,131 @@ func (s *Store) CheckAnomaly(resourceID, metric string, value float64) (AnomalyS
return severity, zScore, baseline
}
// AnomalyReport represents a detected anomaly for a single metric
type AnomalyReport struct {
ResourceID string `json:"resource_id"`
ResourceName string `json:"resource_name,omitempty"`
ResourceType string `json:"resource_type,omitempty"`
Metric string `json:"metric"`
CurrentValue float64 `json:"current_value"`
BaselineMean float64 `json:"baseline_mean"`
BaselineStdDev float64 `json:"baseline_std_dev"`
ZScore float64 `json:"z_score"`
Severity AnomalySeverity `json:"severity"`
Description string `json:"description"`
}
// CheckResourceAnomalies checks multiple metrics for a resource and returns all anomalies
func (s *Store) CheckResourceAnomalies(resourceID string, metrics map[string]float64) []AnomalyReport {
var anomalies []AnomalyReport
for metric, value := range metrics {
severity, zScore, baseline := s.CheckAnomaly(resourceID, metric, value)
if severity != AnomalyNone {
report := AnomalyReport{
ResourceID: resourceID,
Metric: metric,
CurrentValue: value,
ZScore: zScore,
Severity: severity,
}
if baseline != nil {
report.BaselineMean = baseline.Mean
report.BaselineStdDev = baseline.StdDev
// Generate human-readable description
ratio := value / baseline.Mean
direction := "above"
if zScore < 0 {
direction = "below"
}
report.Description = formatAnomalyDescription(metric, ratio, direction, severity)
}
anomalies = append(anomalies, report)
}
}
return anomalies
}
// formatAnomalyDescription generates a human-readable anomaly description
func formatAnomalyDescription(metric string, ratio float64, direction string, severity AnomalySeverity) string {
metricLabel := metric
switch metric {
case "cpu":
metricLabel = "CPU usage"
case "memory":
metricLabel = "Memory usage"
case "disk":
metricLabel = "Disk usage"
case "network_in":
metricLabel = "Network inbound"
case "network_out":
metricLabel = "Network outbound"
}
severityLabel := ""
switch severity {
case AnomalyCritical:
severityLabel = "Critical anomaly: "
case AnomalyHigh:
severityLabel = "High anomaly: "
case AnomalyMedium:
severityLabel = "Moderate anomaly: "
case AnomalyLow:
severityLabel = "Minor anomaly: "
}
return severityLabel + metricLabel + " is " + formatRatio(ratio) + " " + direction + " normal baseline"
}
// formatRatio formats a ratio for display (e.g., 2.5 -> "2.5x")
func formatRatio(ratio float64) string {
if ratio < 0.01 {
return "near zero"
}
if ratio < 1 {
return "significantly below"
}
if ratio < 1.5 {
return "slightly above"
}
if ratio < 2 {
return "1.5x"
}
if ratio < 3 {
return "2x"
}
if ratio < 5 {
return "3x"
}
return "~" + string([]byte{byte('0' + int(ratio))}) + "x"
}
// GetAllAnomalies checks all resources with current metrics and returns all anomalies
// metricsProvider is a function that returns current metrics for a resource ID
func (s *Store) GetAllAnomalies(metricsProvider func(resourceID string) map[string]float64) []AnomalyReport {
s.mu.RLock()
resourceIDs := make([]string, 0, len(s.baselines))
for id := range s.baselines {
resourceIDs = append(resourceIDs, id)
}
s.mu.RUnlock()
var allAnomalies []AnomalyReport
for _, resourceID := range resourceIDs {
metrics := metricsProvider(resourceID)
if len(metrics) > 0 {
anomalies := s.CheckResourceAnomalies(resourceID, metrics)
allAnomalies = append(allAnomalies, anomalies...)
}
}
return allAnomalies
}
// ResourceCount returns the number of resources with baselines
func (s *Store) ResourceCount() int {
s.mu.RLock()

View file

@ -211,6 +211,13 @@ func (s *Service) SetStateProvider(sp StateProvider) {
}
}
// GetStateProvider returns the state provider for infrastructure context
func (s *Service) GetStateProvider() StateProvider {
s.mu.RLock()
defer s.mu.RUnlock()
return s.stateProvider
}
// GetPatrolService returns the patrol service for background monitoring
func (s *Service) GetPatrolService() *PatrolService {
s.mu.RLock()

View file

@ -529,3 +529,233 @@ func remediationStatsFromRecords(records []ai.RemediationRecord) map[string]int
return stats
}
// HandleGetAnomalies returns current baseline anomalies (GET /api/ai/intelligence/anomalies)
// This compares live metrics against learned baselines to surface deviations.
// Anomalies are deterministic (no LLM) - based on statistical z-score thresholds.
func (h *AISettingsHandler) HandleGetAnomalies(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
patrol := h.aiService.GetPatrolService()
if patrol == nil {
if err := utils.WriteJSONResponse(w, map[string]interface{}{
"anomalies": []interface{}{},
"message": "Patrol service not initialized",
}); err != nil {
log.Error().Err(err).Msg("Failed to write anomalies response")
}
return
}
baselineStore := patrol.GetBaselineStore()
if baselineStore == nil {
if err := utils.WriteJSONResponse(w, map[string]interface{}{
"anomalies": []interface{}{},
"message": "Baseline store not initialized - baselines are still learning",
}); err != nil {
log.Error().Err(err).Msg("Failed to write anomalies response")
}
return
}
// Get current metrics from state provider
stateProvider := h.aiService.GetStateProvider()
if stateProvider == nil {
if err := utils.WriteJSONResponse(w, map[string]interface{}{
"anomalies": []interface{}{},
"message": "State provider not available",
}); err != nil {
log.Error().Err(err).Msg("Failed to write anomalies response")
}
return
}
// Get resource filter if provided
resourceID := r.URL.Query().Get("resource_id")
// Collect anomalies
var result []map[string]interface{}
// Get all baselines and check current metrics
allBaselines := baselineStore.GetAllBaselines()
// Group by resource ID
resourceMetrics := make(map[string]map[string]float64)
resourceInfo := make(map[string]struct{ name, rtype string })
for _, baseline := range allBaselines {
if resourceID != "" && baseline.ResourceID != resourceID {
continue
}
if _, ok := resourceMetrics[baseline.ResourceID]; !ok {
resourceMetrics[baseline.ResourceID] = make(map[string]float64)
}
}
// Get current state to extract live metrics
state := stateProvider.GetState()
// Check VMs
for _, vm := range state.VMs {
if vm.Template {
continue // Skip templates
}
// Skip if we don't have baselines for this resource
if _, ok := resourceMetrics[vm.ID]; !ok {
if resourceID == "" {
continue
}
if vm.ID != resourceID {
continue
}
}
metrics := map[string]float64{
"cpu": vm.CPU * 100, // CPU is already 0-1, convert to percentage
"memory": vm.Memory.Usage, // Memory.Usage is already in percentage
}
if vm.Disk.Usage > 0 {
metrics["disk"] = vm.Disk.Usage
}
anomalies := baselineStore.CheckResourceAnomalies(vm.ID, metrics)
for _, anomaly := range anomalies {
result = append(result, map[string]interface{}{
"resource_id": anomaly.ResourceID,
"resource_name": vm.Name,
"resource_type": "vm",
"metric": anomaly.Metric,
"current_value": anomaly.CurrentValue,
"baseline_mean": anomaly.BaselineMean,
"baseline_std_dev": anomaly.BaselineStdDev,
"z_score": anomaly.ZScore,
"severity": anomaly.Severity,
"description": anomaly.Description,
})
}
// Store info for any additional processing
resourceInfo[vm.ID] = struct{ name, rtype string }{vm.Name, "vm"}
}
// Check Containers
for _, ct := range state.Containers {
if ct.Template {
continue // Skip templates
}
// Skip if we don't have baselines for this resource
if _, ok := resourceMetrics[ct.ID]; !ok {
if resourceID == "" {
continue
}
if ct.ID != resourceID {
continue
}
}
metrics := map[string]float64{
"cpu": ct.CPU * 100, // CPU is already 0-1, convert to percentage
"memory": ct.Memory.Usage, // Memory.Usage is already in percentage
}
if ct.Disk.Usage > 0 {
metrics["disk"] = ct.Disk.Usage
}
anomalies := baselineStore.CheckResourceAnomalies(ct.ID, metrics)
for _, anomaly := range anomalies {
result = append(result, map[string]interface{}{
"resource_id": anomaly.ResourceID,
"resource_name": ct.Name,
"resource_type": "container",
"metric": anomaly.Metric,
"current_value": anomaly.CurrentValue,
"baseline_mean": anomaly.BaselineMean,
"baseline_std_dev": anomaly.BaselineStdDev,
"z_score": anomaly.ZScore,
"severity": anomaly.Severity,
"description": anomaly.Description,
})
}
// Store info for any additional processing
resourceInfo[ct.ID] = struct{ name, rtype string }{ct.Name, "container"}
}
// Check nodes
for _, node := range state.Nodes {
nodeID := node.ID
// Skip if we don't have baselines for this resource
if _, ok := resourceMetrics[nodeID]; !ok {
if resourceID == "" {
continue
}
if nodeID != resourceID {
continue
}
}
metrics := map[string]float64{
"cpu": node.CPU * 100, // CPU is already 0-1, convert to percentage
"memory": node.Memory.Usage, // Memory.Usage is already in percentage
}
anomalies := baselineStore.CheckResourceAnomalies(nodeID, metrics)
for _, anomaly := range anomalies {
result = append(result, map[string]interface{}{
"resource_id": anomaly.ResourceID,
"resource_name": node.Name,
"resource_type": "node",
"metric": anomaly.Metric,
"current_value": anomaly.CurrentValue,
"baseline_mean": anomaly.BaselineMean,
"baseline_std_dev": anomaly.BaselineStdDev,
"z_score": anomaly.ZScore,
"severity": anomaly.Severity,
"description": anomaly.Description,
})
}
}
// License gating
locked := !h.aiService.HasLicenseFeature(license.FeatureAIPatrol)
if locked {
w.Header().Set("X-License-Required", "true")
w.Header().Set("X-License-Feature", license.FeatureAIPatrol)
}
count := len(result)
// Count by severity for summary
severityCounts := map[string]int{
"critical": 0,
"high": 0,
"medium": 0,
"low": 0,
}
for _, anomaly := range result {
if sev, ok := anomaly["severity"].(string); ok {
severityCounts[sev]++
}
}
if locked {
result = []map[string]interface{}{}
}
if err := utils.WriteJSONResponse(w, map[string]interface{}{
"anomalies": result,
"count": count,
"severity_counts": severityCounts,
"license_required": locked,
"upgrade_url": aiIntelligenceUpgradeURL,
}); err != nil {
log.Error().Err(err).Msg("Failed to write anomalies response")
}
}

View file

@ -1216,6 +1216,7 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/ai/intelligence/changes", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRecentChanges))
r.mux.HandleFunc("/api/ai/intelligence/baselines", RequireAuth(r.config, r.aiSettingsHandler.HandleGetBaselines))
r.mux.HandleFunc("/api/ai/intelligence/remediations", RequireAuth(r.config, r.aiSettingsHandler.HandleGetRemediations))
r.mux.HandleFunc("/api/ai/intelligence/anomalies", RequireAuth(r.config, r.aiSettingsHandler.HandleGetAnomalies))
// Agent WebSocket for AI command execution
r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)