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:
parent
db7b385287
commit
2ba8538de3
2 changed files with 255 additions and 29 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Reference in a new issue