feat(ai): enhance intelligence status and add trend prediction

AIStatusIndicator:
- Now shows BOTH patrol findings AND baseline anomalies
- Displays even when only anomaly detection is active (no patrol)
- Badge count includes both findings + anomalies
- Tooltip provides detailed breakdown by severity

Trend Prediction (backend):
- Add TrendPrediction struct for resource exhaustion forecasting
- CalculateTrend() uses linear regression on sample history
- Predicts days until resource is full (or if declining/stable)
- Severity: critical (<7 days), warning (<30 days), info (>30 days)
- Human-readable descriptions like 'full in ~2 weeks (+0.5% per day)'

This creates a more cohesive intelligence experience where anomaly
detection works independently of the pro/patrol features, making
value visible immediately to all users.
This commit is contained in:
rcourtman 2025-12-21 11:29:44 +00:00
parent db7b385287
commit 2ba8538de3
2 changed files with 255 additions and 29 deletions

View file

@ -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 (
<Show when={status()?.enabled}>
<Show when={showIndicator()}>
<button
class={`ai-status-indicator ${statusClass()}`}
onClick={handleClick}
title={tooltipText()}
>
<span class="ai-status-icon">
<Show when={hasIssues()} fallback={
<Show when={hasWatch()} fallback={
<Show when={hasIssues() || hasAnomalies()} fallback={
<Show when={hasWatch() || hasMildAnomalies()} fallback={
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
<path d="M9 12l2 2 4-4" />
@ -112,8 +163,8 @@ export function AIStatusIndicator() {
</svg>
</Show>
</span>
<Show when={totalFindings() > 0}>
<span class="ai-status-count">{totalFindings()}</span>
<Show when={badgeCount() > 0}>
<span class="ai-status-count">{badgeCount()}</span>
</Show>
</button>
</Show>
@ -121,3 +172,4 @@ export function AIStatusIndicator() {
}
export default AIStatusIndicator;

View file

@ -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"`