diff --git a/frontend-modern/src/components/AI/AIStatusIndicator.tsx b/frontend-modern/src/components/AI/AIStatusIndicator.tsx index 34e4842..5c7167a 100644 --- a/frontend-modern/src/components/AI/AIStatusIndicator.tsx +++ b/frontend-modern/src/components/AI/AIStatusIndicator.tsx @@ -1,13 +1,14 @@ /** - * AIStatusIndicator - Subtle header component showing AI patrol health + * AIStatusIndicator - Subtle header component showing AI patrol health and anomalies * - * Design: Minimal presence when healthy, highlighted when issues detected. + * Design: Minimal presence when healthy, highlighted when issues or anomalies detected. * Clicking navigates to the Alerts page where AI Insights are displayed. */ import { createResource, Show, createMemo, onCleanup } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import { getPatrolStatus, type PatrolStatus } from '../../api/patrol'; +import { useAllAnomalies } from '@/hooks/useAnomalies'; import './AIStatusIndicator.css'; export function AIStatusIndicator() { @@ -25,10 +26,25 @@ export function AIStatusIndicator() { { initialValue: undefined } ); - // Refetch every 30 seconds with proper cleanup + // Get anomaly data (also polls every 30 seconds via the hook) + const anomalyData = useAllAnomalies(); + + // Refetch patrol status every 30 seconds with proper cleanup const intervalId = setInterval(() => refetch(), 30000); onCleanup(() => clearInterval(intervalId)); + // Count anomalies by severity + const anomalyCounts = createMemo(() => { + const anomalies = anomalyData.anomalies(); + const counts = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const a of anomalies) { + counts[a.severity]++; + } + return counts; + }); + + const totalAnomalies = createMemo(() => anomalyData.count()); + const hasIssues = createMemo(() => { const s = status(); if (!s) return false; @@ -41,6 +57,16 @@ export function AIStatusIndicator() { return s.summary.watch > 0 && !hasIssues(); }); + const hasAnomalies = createMemo(() => { + const counts = anomalyCounts(); + return counts.critical > 0 || counts.high > 0; + }); + + const hasMildAnomalies = createMemo(() => { + const counts = anomalyCounts(); + return !hasAnomalies() && (counts.medium > 0 || counts.low > 0); + }); + const totalFindings = createMemo(() => { const s = status(); if (!s) return 0; @@ -48,32 +74,48 @@ export function AIStatusIndicator() { }); const tooltipText = createMemo(() => { - const s = status(); - if (!s) return 'AI Patrol status unavailable'; - if (!s.enabled) return 'AI Patrol disabled'; - if (s.license_required) { - if (s.license_status === 'active') { - return 'AI Patrol is not included in this license tier'; - } - if (s.license_status === 'expired') { - return 'AI Patrol license expired - upgrade to restore'; - } - return 'AI Patrol requires Pulse Pro'; - } - if (!s.running) return 'AI Patrol not running'; - const parts: string[] = []; - if (s.summary.critical > 0) parts.push(`${s.summary.critical} critical`); - if (s.summary.warning > 0) parts.push(`${s.summary.warning} warning`); - if (s.summary.watch > 0) parts.push(`${s.summary.watch} watch`); - if (parts.length === 0) return 'AI: All systems healthy'; - return `AI: ${parts.join(', ')}`; + // Patrol status + const s = status(); + if (s?.enabled && s?.running) { + if (s.summary.critical > 0) parts.push(`${s.summary.critical} critical findings`); + if (s.summary.warning > 0) parts.push(`${s.summary.warning} warnings`); + if (s.summary.watch > 0) parts.push(`${s.summary.watch} watching`); + } + + // Anomaly status + const counts = anomalyCounts(); + const anomalyTotal = totalAnomalies(); + if (anomalyTotal > 0) { + const anomalyParts: string[] = []; + if (counts.critical > 0) anomalyParts.push(`${counts.critical} critical`); + if (counts.high > 0) anomalyParts.push(`${counts.high} high`); + if (counts.medium > 0) anomalyParts.push(`${counts.medium} medium`); + if (counts.low > 0) anomalyParts.push(`${counts.low} low`); + parts.push(`Anomalies: ${anomalyParts.join(', ')}`); + } + + if (parts.length === 0) { + if (!s?.enabled) return 'AI Patrol disabled'; + if (s?.license_required) { + if (s.license_status === 'active') { + return 'AI Patrol is not included in this license tier'; + } + if (s.license_status === 'expired') { + return 'AI Patrol license expired'; + } + return 'AI Patrol requires Pulse Pro'; + } + return 'AI: All systems healthy'; + } + + return `AI Intelligence: ${parts.join(' | ')}`; }); const statusClass = createMemo(() => { - if (hasIssues()) return 'ai-status--issues'; - if (hasWatch()) return 'ai-status--watch'; + if (hasIssues() || hasAnomalies()) return 'ai-status--issues'; + if (hasWatch() || hasMildAnomalies()) return 'ai-status--watch'; return 'ai-status--healthy'; }); @@ -82,16 +124,25 @@ export function AIStatusIndicator() { navigate('/alerts?subtab=ai-insights'); }; + // Combined total for badge + const badgeCount = createMemo(() => totalFindings() + totalAnomalies()); + + // Show indicator if patrol is enabled OR if we have anomalies (anomalies work without patrol) + const showIndicator = createMemo(() => { + const s = status(); + return s?.enabled || totalAnomalies() > 0; + }); + return ( - + @@ -121,3 +172,4 @@ export function AIStatusIndicator() { } export default AIStatusIndicator; + diff --git a/internal/ai/baseline/store.go b/internal/ai/baseline/store.go index d8d95f3..1246463 100644 --- a/internal/ai/baseline/store.go +++ b/internal/ai/baseline/store.go @@ -396,6 +396,179 @@ func (s *Store) GetAllAnomalies(metricsProvider func(resourceID string) map[stri return allAnomalies } +// TrendPrediction represents a forecast for when a resource might be exhausted +type TrendPrediction 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"` // Current % usage + DailyChange float64 `json:"daily_change"` // Average change per day + DaysToFull int `json:"days_to_full"` // Estimated days until 100% (or -1 if decreasing/stable) + Severity string `json:"severity"` // "critical", "warning", "info" + Description string `json:"description"` + ConfidenceNote string `json:"confidence_note,omitempty"` +} + +// CalculateTrend analyzes a time series of values and predicts future exhaustion +// samples should be ordered oldest to newest, with at least 2 days of data +// currentValue is the current percentage usage (0-100) +// capacity represents 100% (for percentage-based predictions) +func CalculateTrend(samples []float64, currentValue float64) *TrendPrediction { + if len(samples) < 5 { + return nil // Not enough data for meaningful trend + } + + // Simple linear regression to find slope + n := float64(len(samples)) + + // Calculate means + sumX := 0.0 + sumY := 0.0 + for i, v := range samples { + sumX += float64(i) + sumY += v + } + meanX := sumX / n + meanY := sumY / n + + // Calculate slope (least squares) + numerator := 0.0 + denominator := 0.0 + for i, v := range samples { + x := float64(i) + numerator += (x - meanX) * (v - meanY) + denominator += (x - meanX) * (x - meanX) + } + + if denominator == 0 { + return nil // Can't calculate slope + } + + slope := numerator / denominator + + // slope is change per sample, convert to daily change + // Assume samples are taken regularly; if 24 samples per day, divide by 24 + // For now, assume hourly samples = 24 per day + samplesPerDay := 24.0 + dailyChange := slope * samplesPerDay + + prediction := &TrendPrediction{ + CurrentValue: currentValue, + DailyChange: dailyChange, + } + + // Calculate days to full if trending upward + if dailyChange > 0.1 { // More than 0.1% increase per day + remaining := 100.0 - currentValue + if remaining > 0 { + daysToFull := remaining / dailyChange + prediction.DaysToFull = int(math.Ceil(daysToFull)) + + // Set severity based on time to full + if prediction.DaysToFull <= 7 { + prediction.Severity = "critical" + prediction.Description = formatTrendDescription(prediction.DaysToFull, dailyChange, "critical") + } else if prediction.DaysToFull <= 30 { + prediction.Severity = "warning" + prediction.Description = formatTrendDescription(prediction.DaysToFull, dailyChange, "warning") + } else { + prediction.Severity = "info" + prediction.Description = formatTrendDescription(prediction.DaysToFull, dailyChange, "info") + } + } else { + prediction.DaysToFull = 0 + prediction.Severity = "critical" + prediction.Description = "Resource at capacity" + } + } else if dailyChange < -0.1 { + // Decreasing trend + prediction.DaysToFull = -1 + prediction.Severity = "info" + daysToEmpty := currentValue / (-dailyChange) + prediction.Description = "Usage declining - at current rate, will reach 0% in " + formatDays(int(math.Ceil(daysToEmpty))) + } else { + // Stable + prediction.DaysToFull = -1 + prediction.Severity = "info" + prediction.Description = "Usage stable - no significant trend detected" + } + + return prediction +} + +// formatTrendDescription creates human-readable trend descriptions +func formatTrendDescription(daysToFull int, dailyChange float64, severity string) string { + timeFrame := formatDays(daysToFull) + changeDesc := "" + if dailyChange >= 1 { + changeDesc = " (+" + floatToStr(dailyChange, 1) + "% per day)" + } else { + changeDesc = " (+" + floatToStr(dailyChange, 2) + "% per day)" + } + + switch severity { + case "critical": + return "⚠️ Resource will be full in " + timeFrame + changeDesc + case "warning": + return "Resource approaching capacity - full in " + timeFrame + changeDesc + default: + return "Trending toward full in " + timeFrame + changeDesc + } +} + +// formatDays converts days to human readable format +func formatDays(days int) string { + if days <= 0 { + return "now" + } + if days == 1 { + return "1 day" + } + if days < 7 { + return string([]byte{'0' + byte(days)}) + " days" + } + if days < 14 { + return "~1 week" + } + if days < 30 { + weeks := days / 7 + return "~" + string([]byte{'0' + byte(weeks)}) + " weeks" + } + months := days / 30 + if months == 1 { + return "~1 month" + } + if months < 12 { + return "~" + string([]byte{'0' + byte(months)}) + " months" + } + return ">1 year" +} + +// floatToStr converts float to string with given precision +func floatToStr(f float64, precision int) string { + // Simple implementation for small numbers + intPart := int(f) + fracPart := f - float64(intPart) + + if precision == 1 { + fracPart = math.Round(fracPart*10) / 10 + if fracPart < 0.1 { + return string([]byte{'0' + byte(intPart)}) + } + d := byte('0' + int(fracPart*10)) + return string([]byte{'0' + byte(intPart), '.', d}) + } + + fracPart = math.Round(fracPart*100) / 100 + if fracPart < 0.01 { + return string([]byte{'0' + byte(intPart)}) + } + d1 := byte('0' + int(fracPart*10)) + d2 := byte('0' + int(fracPart*100)%10) + return string([]byte{'0' + byte(intPart), '.', d1, d2}) +} + // ResourceCount returns the number of resources with baselines func (s *Store) ResourceCount() int { s.mu.RLock() @@ -403,6 +576,7 @@ func (s *Store) ResourceCount() int { return len(s.baselines) } + // FlatBaseline is a flattened representation of a single metric baseline for API responses type FlatBaseline struct { ResourceID string `json:"resource_id"`