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:
parent
8019acd6b6
commit
c5c9bf4fb9
6 changed files with 398 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue