diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index cc4b7fa..d668e89 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -32,7 +32,7 @@ import History from 'lucide-solid/icons/history'; import Gauge from 'lucide-solid/icons/gauge'; import Send from 'lucide-solid/icons/send'; import Calendar from 'lucide-solid/icons/calendar'; -import { getPatrolStatus, getFindings, getFindingsHistory, getPatrolRunHistory, forcePatrol, subscribeToPatrolStream, dismissFinding, suppressFinding, getSuppressionRules, addSuppressionRule, deleteSuppressionRule, type Finding, type PatrolStatus, type PatrolRunRecord, type SuppressionRule, severityColors, formatTimestamp, categoryLabels } from '@/api/patrol'; +import { getPatrolStatus, getFindings, getFindingsHistory, getPatrolRunHistory, forcePatrol, subscribeToPatrolStream, dismissFinding, suppressFinding, resolveFinding, getSuppressionRules, addSuppressionRule, deleteSuppressionRule, type Finding, type PatrolStatus, type PatrolRunRecord, type SuppressionRule, severityColors, formatTimestamp, categoryLabels } from '@/api/patrol'; import { aiChatStore } from '@/stores/aiChat'; type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history'; @@ -2180,6 +2180,8 @@ function OverviewTab(props: { const [licenseLoading, setLicenseLoading] = createSignal(false); const [remediationsByFinding, setRemediationsByFinding] = createSignal>({}); const [remediationLoadingByFinding, setRemediationLoadingByFinding] = createSignal>({}); + // Track which findings are expanded - lifted to parent to persist across API updates + const [expandedFindingIds, setExpandedFindingIds] = createSignal>(new Set()); const hasAIAlertsFeature = createMemo(() => { const status = licenseFeatures(); if (!status) return true; @@ -2822,7 +2824,22 @@ function OverviewTab(props: { !pendingFixFindings().has(f.id))}> {(finding) => { const colors = severityColors[finding.severity]; - const [isExpanded, setIsExpanded] = createSignal(false); + const isExpanded = () => expandedFindingIds().has(finding.id); + const toggleExpanded = () => { + setExpandedFindingIds((prev) => { + const next = new Set(prev); + if (next.has(finding.id)) { + next.delete(finding.id); + } else { + next.add(finding.id); + // Load remediations when expanding (if not already loaded) + if (remediationsByFinding()[finding.id] === undefined && !remediationLoadingByFinding()[finding.id]) { + void loadRemediationsForFinding(finding.id); + } + } + return next; + }); + }; return (
{ - const nextExpanded = !isExpanded(); - setIsExpanded(nextExpanded); - if (nextExpanded && remediationsByFinding()[finding.id] === undefined && !remediationLoadingByFinding()[finding.id]) { - void loadRemediationsForFinding(finding.id); - } - }} + onClick={toggleExpanded} > {/* Expand chevron */} { + onClick={async (e) => { e.stopPropagation(); + // Immediately hide the finding locally setPendingFixFindings(prev => { const next = new Set(prev); next.add(finding.id); return next; }); + try { + await resolveFinding(finding.id); + showSuccess('Marked as fixed - the next patrol will verify'); + fetchAiData(); + } catch (_err) { + // Still keep it hidden locally since user said they fixed it + showError('Failed to mark as fixed on server'); + } }} - title="Hide until next patrol verifies the fix" + title="Mark as fixed - the next patrol will verify" > diff --git a/internal/ai/context/builder.go b/internal/ai/context/builder.go index 345b019..1a4d345 100644 --- a/internal/ai/context/builder.go +++ b/internal/ai/context/builder.go @@ -115,6 +115,8 @@ func (b *Builder) BuildForInfrastructure(state models.StateSnapshot) *Infrastruc vm.CPU, vm.Memory.Usage, vm.Disk.Usage, vm.Uptime, vm.LastBackup, trends, ) + // Add raw metric samples for LLM interpretation + resourceCtx.MetricSamples = b.computeGuestMetricSamples(vm.ID) b.enrichWithNotes(&resourceCtx) b.enrichWithAnomalies(&resourceCtx) ctx.VMs = append(ctx.VMs, resourceCtx) @@ -139,6 +141,10 @@ func (b *Builder) BuildForInfrastructure(state models.StateSnapshot) *Infrastruc ct.Uptime, ct.LastBackup, trends, ) + // Add raw metric samples for LLM interpretation + // This lets the LLM see actual patterns without pre-computed heuristics + resourceCtx.MetricSamples = b.computeGuestMetricSamples(ct.ID) + // Add OCI image info for AI context if ct.IsOCI && ct.OSTemplate != "" { if resourceCtx.Metadata == nil { @@ -462,6 +468,34 @@ func formatAnomalyDescription(metric string, current, mean, stddev float64, seve return sb.String() } +// computeGuestMetricSamples gets downsampled raw metrics for LLM interpretation +// Returns ~24 samples from the last 7 days, letting the LLM see patterns and determine if behavior is normal +// With modern context windows (128k+ tokens), this is a small cost for much better insights +func (b *Builder) computeGuestMetricSamples(guestID string) map[string][]MetricPoint { + samples := make(map[string][]MetricPoint) + + if b.metricsHistory == nil { + return samples + } + + // Get 7 days of data - enough to see weekly patterns and determine normalcy + allMetrics := b.metricsHistory.GetAllGuestMetrics(guestID, b.trendWindow7d) + + for metric, points := range allMetrics { + if len(points) < 3 { + continue + } + // Downsample to ~24 points (roughly 3 per day over 7 days) + // This lets the LLM see: daily patterns, weekly cycles, and recent changes + sampled := DownsampleMetrics(points, 24) + if len(sampled) >= 3 { + samples[metric] = sampled + } + } + + return samples +} + // filterRecentPoints filters points to only include those within duration func filterRecentPoints(points []MetricPoint, duration time.Duration) []MetricPoint { cutoff := time.Now().Add(-duration) diff --git a/internal/ai/context/formatter.go b/internal/ai/context/formatter.go index f4b9021..ccd9f19 100644 --- a/internal/ai/context/formatter.go +++ b/internal/ai/context/formatter.go @@ -42,7 +42,7 @@ func FormatResourceContext(ctx ResourceContext) string { sb.WriteString("**Current**: " + strings.Join(metrics, " | ") + "\n") } - // Trends section (the differentiating context) + // Trends section (computed summaries - kept for backwards compatibility) if len(ctx.Trends) > 0 { var trendLines []string for metric, trend := range ctx.Trends { @@ -61,6 +61,25 @@ func FormatResourceContext(ctx ResourceContext) string { } } + // Raw metric samples - let the LLM interpret patterns directly + // This is more reliable than pre-computed trends for edge cases + if len(ctx.MetricSamples) > 0 { + sb.WriteString("**History (7d sampled, oldest→newest)**: ") + var sampleLines []string + for metric, points := range ctx.MetricSamples { + if len(points) >= 3 { + line := formatMetricSamples(metric, points) + if line != "" { + sampleLines = append(sampleLines, line) + } + } + } + if len(sampleLines) > 0 { + sb.WriteString(strings.Join(sampleLines, " | ")) + } + sb.WriteString("\n") + } + // Anomalies (high value - what's unusual) if len(ctx.Anomalies) > 0 { sb.WriteString("**ANOMALIES**: ") @@ -157,6 +176,68 @@ func formatRate(ratePerDay float64) string { return "slow" } +// formatMetricSamples creates a compact representation of sampled values +// Example output: "Disk: 26→26→26→31→31→31" (shows step change visually) +// This lets the LLM interpret patterns directly rather than relying on computed rates +func formatMetricSamples(metric string, points []MetricPoint) string { + if len(points) < 3 { + return "" + } + + metricLabel := strings.Title(metric) + + // Build compact arrow-separated value list + var values []string + prevValue := -1.0 + for _, p := range points { + roundedValue := float64(int(p.Value + 0.5)) // Round to nearest integer + // Skip consecutive duplicates for compactness + if roundedValue == prevValue && len(values) > 0 { + continue + } + values = append(values, fmt.Sprintf("%.0f", roundedValue)) + prevValue = roundedValue + } + + // If all values are the same, just show "stable at X%" + if len(values) == 1 { + return fmt.Sprintf("%s: stable at %.0f%%", metricLabel, prevValue) + } + + // Join with arrows to show progression + return fmt.Sprintf("%s: %s%%", metricLabel, strings.Join(values, "→")) +} + +// DownsampleMetrics takes raw metric points and returns a smaller set for LLM consumption +// It aims for about 10-15 samples across the time range, picking representative values +func DownsampleMetrics(points []MetricPoint, targetSamples int) []MetricPoint { + if len(points) <= targetSamples { + return points + } + + if targetSamples < 3 { + targetSamples = 3 + } + + // Calculate step size + step := len(points) / targetSamples + if step < 1 { + step = 1 + } + + var sampled []MetricPoint + for i := 0; i < len(points); i += step { + sampled = append(sampled, points[i]) + } + + // Always include the last point (current value) + if len(sampled) > 0 && sampled[len(sampled)-1].Timestamp != points[len(points)-1].Timestamp { + sampled = append(sampled, points[len(points)-1]) + } + + return sampled +} + // FormatInfrastructureContext formats full infrastructure context for AI func FormatInfrastructureContext(ctx *InfrastructureContext) string { var sb strings.Builder diff --git a/internal/ai/context/formatter_test.go b/internal/ai/context/formatter_test.go index 255bfdd..67d35fd 100644 --- a/internal/ai/context/formatter_test.go +++ b/internal/ai/context/formatter_test.go @@ -663,3 +663,134 @@ func containsStr(s, substr string) bool { } return false } + +func TestFormatMetricSamples_StepChange(t *testing.T) { + // Simulate a step change: stable at 26%, then jump to 31%, then stable at 31% + now := time.Now() + points := []MetricPoint{ + {Value: 26.2, Timestamp: now.Add(-6 * time.Hour)}, + {Value: 26.1, Timestamp: now.Add(-5 * time.Hour)}, + {Value: 26.3, Timestamp: now.Add(-4 * time.Hour)}, + {Value: 30.7, Timestamp: now.Add(-2 * time.Hour)}, // Jump + {Value: 30.8, Timestamp: now.Add(-1 * time.Hour)}, + {Value: 30.7, Timestamp: now}, + } + + result := formatMetricSamples("disk", points) + + // Should show the step change: 26→31 (deduped) + if !containsStr(result, "Disk:") { + t.Error("Expected result to contain 'Disk:'") + } + // Should show the progression, not just the rate + if !containsStr(result, "26") || !containsStr(result, "31") { + t.Errorf("Expected result to show both values (26 and 31), got: %s", result) + } +} + +func TestFormatMetricSamples_Stable(t *testing.T) { + // All values the same + now := time.Now() + points := []MetricPoint{ + {Value: 50.0, Timestamp: now.Add(-3 * time.Hour)}, + {Value: 50.1, Timestamp: now.Add(-2 * time.Hour)}, + {Value: 49.9, Timestamp: now.Add(-1 * time.Hour)}, + {Value: 50.0, Timestamp: now}, + } + + result := formatMetricSamples("memory", points) + + // All values round to 50, should show "stable at 50%" + if !containsStr(result, "stable at 50%") { + t.Errorf("Expected 'stable at 50%%' for consistent values, got: %s", result) + } +} + +func TestFormatMetricSamples_InsufficientData(t *testing.T) { + points := []MetricPoint{ + {Value: 50.0, Timestamp: time.Now()}, + } + + result := formatMetricSamples("cpu", points) + + if result != "" { + t.Errorf("Expected empty string for insufficient data, got: %s", result) + } +} + +func TestDownsampleMetrics(t *testing.T) { + now := time.Now() + + // Create 100 points + points := make([]MetricPoint, 100) + for i := 0; i < 100; i++ { + points[i] = MetricPoint{ + Value: float64(i), + Timestamp: now.Add(time.Duration(-100+i) * time.Minute), + } + } + + // Downsample to 10 + sampled := DownsampleMetrics(points, 10) + + // Should have roughly 10-11 points (plus potentially the last one) + if len(sampled) < 10 || len(sampled) > 15 { + t.Errorf("Expected ~10-15 samples, got %d", len(sampled)) + } + + // Last point should be included + if sampled[len(sampled)-1].Timestamp != points[99].Timestamp { + t.Error("Expected last point to be included") + } + + // First point should be included + if sampled[0].Timestamp != points[0].Timestamp { + t.Error("Expected first point to be included") + } +} + +func TestDownsampleMetrics_SmallInput(t *testing.T) { + now := time.Now() + + // Create 5 points - less than target + points := []MetricPoint{ + {Value: 10, Timestamp: now.Add(-4 * time.Minute)}, + {Value: 20, Timestamp: now.Add(-3 * time.Minute)}, + {Value: 30, Timestamp: now.Add(-2 * time.Minute)}, + {Value: 40, Timestamp: now.Add(-1 * time.Minute)}, + {Value: 50, Timestamp: now}, + } + + // Downsample to 10 should return all 5 + sampled := DownsampleMetrics(points, 10) + + if len(sampled) != 5 { + t.Errorf("Expected all 5 points when target > input, got %d", len(sampled)) + } +} + +func TestFormatResourceContext_WithMetricSamples(t *testing.T) { + now := time.Now() + ctx := ResourceContext{ + ResourceID: "ct-105", + ResourceType: "container", + ResourceName: "frigate", + Status: "running", + CurrentDisk: 30.7, + MetricSamples: map[string][]MetricPoint{ + "disk": { + {Value: 26.2, Timestamp: now.Add(-3 * time.Hour)}, + {Value: 30.7, Timestamp: now.Add(-1 * time.Hour)}, + {Value: 30.7, Timestamp: now}, + }, + }, + } + + result := FormatResourceContext(ctx) + + // Should contain the History section with sampled data + if !containsStr(result, "History") { + t.Error("Expected result to contain History section with metric samples") + } +} + diff --git a/internal/ai/context/types.go b/internal/ai/context/types.go index d980108..36ceef1 100644 --- a/internal/ai/context/types.go +++ b/internal/ai/context/types.go @@ -129,6 +129,11 @@ type ResourceContext struct { Baselines map[string]Baseline // metric -> baseline Anomalies []Anomaly // Current anomalies + // Raw metric samples - downsampled for LLM interpretation + // Key is metric name (cpu, memory, disk), value is sampled points + // This lets the LLM see actual patterns without pre-computed heuristics + MetricSamples map[string][]MetricPoint + // Predictions Predictions []Prediction