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.
|
* Clicking navigates to the Alerts page where AI Insights are displayed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createResource, Show, createMemo, onCleanup } from 'solid-js';
|
import { createResource, Show, createMemo, onCleanup } from 'solid-js';
|
||||||
import { useNavigate } from '@solidjs/router';
|
import { useNavigate } from '@solidjs/router';
|
||||||
import { getPatrolStatus, type PatrolStatus } from '../../api/patrol';
|
import { getPatrolStatus, type PatrolStatus } from '../../api/patrol';
|
||||||
|
import { useAllAnomalies } from '@/hooks/useAnomalies';
|
||||||
import './AIStatusIndicator.css';
|
import './AIStatusIndicator.css';
|
||||||
|
|
||||||
export function AIStatusIndicator() {
|
export function AIStatusIndicator() {
|
||||||
|
|
@ -25,10 +26,25 @@ export function AIStatusIndicator() {
|
||||||
{ initialValue: undefined }
|
{ 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);
|
const intervalId = setInterval(() => refetch(), 30000);
|
||||||
onCleanup(() => clearInterval(intervalId));
|
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 hasIssues = createMemo(() => {
|
||||||
const s = status();
|
const s = status();
|
||||||
if (!s) return false;
|
if (!s) return false;
|
||||||
|
|
@ -41,6 +57,16 @@ export function AIStatusIndicator() {
|
||||||
return s.summary.watch > 0 && !hasIssues();
|
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 totalFindings = createMemo(() => {
|
||||||
const s = status();
|
const s = status();
|
||||||
if (!s) return 0;
|
if (!s) return 0;
|
||||||
|
|
@ -48,32 +74,48 @@ export function AIStatusIndicator() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const tooltipText = createMemo(() => {
|
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[] = [];
|
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';
|
// Patrol status
|
||||||
return `AI: ${parts.join(', ')}`;
|
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(() => {
|
const statusClass = createMemo(() => {
|
||||||
if (hasIssues()) return 'ai-status--issues';
|
if (hasIssues() || hasAnomalies()) return 'ai-status--issues';
|
||||||
if (hasWatch()) return 'ai-status--watch';
|
if (hasWatch() || hasMildAnomalies()) return 'ai-status--watch';
|
||||||
return 'ai-status--healthy';
|
return 'ai-status--healthy';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -82,16 +124,25 @@ export function AIStatusIndicator() {
|
||||||
navigate('/alerts?subtab=ai-insights');
|
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 (
|
return (
|
||||||
<Show when={status()?.enabled}>
|
<Show when={showIndicator()}>
|
||||||
<button
|
<button
|
||||||
class={`ai-status-indicator ${statusClass()}`}
|
class={`ai-status-indicator ${statusClass()}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
title={tooltipText()}
|
title={tooltipText()}
|
||||||
>
|
>
|
||||||
<span class="ai-status-icon">
|
<span class="ai-status-icon">
|
||||||
<Show when={hasIssues()} fallback={
|
<Show when={hasIssues() || hasAnomalies()} fallback={
|
||||||
<Show when={hasWatch()} fallback={
|
<Show when={hasWatch() || hasMildAnomalies()} fallback={
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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="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" />
|
<path d="M9 12l2 2 4-4" />
|
||||||
|
|
@ -112,8 +163,8 @@ export function AIStatusIndicator() {
|
||||||
</svg>
|
</svg>
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
<Show when={totalFindings() > 0}>
|
<Show when={badgeCount() > 0}>
|
||||||
<span class="ai-status-count">{totalFindings()}</span>
|
<span class="ai-status-count">{badgeCount()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -121,3 +172,4 @@ export function AIStatusIndicator() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AIStatusIndicator;
|
export default AIStatusIndicator;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,179 @@ func (s *Store) GetAllAnomalies(metricsProvider func(resourceID string) map[stri
|
||||||
return allAnomalies
|
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
|
// ResourceCount returns the number of resources with baselines
|
||||||
func (s *Store) ResourceCount() int {
|
func (s *Store) ResourceCount() int {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|
@ -403,6 +576,7 @@ func (s *Store) ResourceCount() int {
|
||||||
return len(s.baselines)
|
return len(s.baselines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// FlatBaseline is a flattened representation of a single metric baseline for API responses
|
// FlatBaseline is a flattened representation of a single metric baseline for API responses
|
||||||
type FlatBaseline struct {
|
type FlatBaseline struct {
|
||||||
ResourceID string `json:"resource_id"`
|
ResourceID string `json:"resource_id"`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue