Pulse/internal/ai/context/trends.go
rcourtman 41004ebcea feat(thresholds): add collapsible accordion sections and UX improvements
- Add CollapsibleSection component with animated expand/collapse
- Wrap all 6 resource sections (Nodes, VMs, PBS, Storage, Backups, Snapshots) with accordion UI
- Add section icons and resource counts in headers
- Add expand all / collapse all buttons for quick navigation
- Make help banner dismissible with localStorage persistence
- Add Ctrl/Cmd+F keyboard shortcut to focus search
- Add keyboard shortcut hint badge on search input
- Add icons to tab navigation for quick identification
- Improve mobile tab labels with shorter text on small screens
- Create reusable components: ThresholdBadge, ResourceCard, GlobalDefaultsRow
- Create useCollapsedSections hook with localStorage persistence
- Default less-used sections (Storage, Backups, Snapshots, PBS) to collapsed
2025-12-18 15:47:44 +00:00

402 lines
11 KiB
Go

package context
import (
"math"
"sort"
"time"
)
// ComputeTrend calculates trend from historical data points.
// This is the core function that transforms raw metrics into meaningful insights.
func ComputeTrend(points []MetricPoint, metricName string, period time.Duration) Trend {
trend := Trend{
Metric: metricName,
Direction: TrendStable,
Period: period,
DataPoints: len(points),
}
if len(points) < 2 {
trend.Confidence = 0
return trend
}
// Sort by timestamp to ensure correct order
sorted := make([]MetricPoint, len(points))
copy(sorted, points)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
})
// Calculate basic statistics
stats := computeStats(sorted)
trend.Average = stats.Mean
trend.Min = stats.Min
trend.Max = stats.Max
trend.StdDev = stats.StdDev
trend.Current = sorted[len(sorted)-1].Value
// Perform linear regression to get slope and fit quality
regression := linearRegression(sorted)
trend.Confidence = regression.R2
// Calculate actual time span of the data
actualSpan := sorted[len(sorted)-1].Timestamp.Sub(sorted[0].Timestamp)
// Convert slope from "per second" to "per hour" and "per day"
// Slope is in units/second
trend.RatePerHour = regression.Slope * 3600
trend.RatePerDay = regression.Slope * 86400
// Apply sanity checks for short time spans and percentage metrics
trend = applyTrendSanityChecks(trend, actualSpan, metricName, stats.Mean)
// Classify the trend direction
trend.Direction = classifyTrend(regression.Slope, stats.Mean, stats.StdDev)
return trend
}
// applyTrendSanityChecks applies corrections to prevent unrealistic extrapolated rates.
// This addresses issues like a 1-minute data span being extrapolated to 700%/day.
func applyTrendSanityChecks(trend Trend, actualSpan time.Duration, metricName string, mean float64) Trend {
// Minimum time span required for meaningful extrapolation
// Without at least 1 hour of data, daily rate extrapolation is very unreliable
minSpanForDailyRate := time.Hour
minSpanForHourlyRate := 10 * time.Minute
// If we don't have enough time span for meaningful extrapolation,
// reduce confidence and cap the rates
if actualSpan < minSpanForHourlyRate {
// Very short time span - rates are unreliable
trend.Confidence *= 0.1 // Heavily penalize confidence
// Don't extrapolate short blips to large rates
trend.RatePerHour = 0
trend.RatePerDay = 0
} else if actualSpan < minSpanForDailyRate {
// Medium time span - hourly is somewhat reliable, daily is not
spanRatio := float64(actualSpan) / float64(minSpanForDailyRate)
trend.Confidence *= spanRatio // Scale confidence by how much data we have
// Cap daily rate extrapolation for short spans
// Scale down the daily rate by how much of the day we actually observed
dailyScaleFactor := float64(actualSpan) / float64(24*time.Hour)
if dailyScaleFactor < 0.1 {
dailyScaleFactor = 0.1
}
// Cap absurd rates - if extrapolated rate is much higher than observed change, it's noise
observedChange := math.Abs(trend.Max - trend.Min)
maxReasonableDaily := observedChange * 10 // Allow 10x the observed change as max daily
if math.Abs(trend.RatePerDay) > maxReasonableDaily && maxReasonableDaily > 0 {
if trend.RatePerDay > 0 {
trend.RatePerDay = maxReasonableDaily
} else {
trend.RatePerDay = -maxReasonableDaily
}
}
}
// For percentage metrics (0-100 range), apply physical limits
// A metric bounded 0-100 can't grow more than 100% per day
isPercentageMetric := metricName == "cpu" || metricName == "memory" || metricName == "disk" ||
metricName == "usage" || mean <= 100
if isPercentageMetric {
// Maximum physically possible rate for a 0-100% metric
// Even a runaway process can't grow more than 100 percentage points per day
maxRate := 100.0
if math.Abs(trend.RatePerDay) > maxRate {
if trend.RatePerDay > 0 {
trend.RatePerDay = maxRate
} else {
trend.RatePerDay = -maxRate
}
// Also cap hourly rate
if math.Abs(trend.RatePerHour) > maxRate/24 {
if trend.RatePerHour > 0 {
trend.RatePerHour = maxRate / 24
} else {
trend.RatePerHour = -maxRate / 24
}
}
// Reduce confidence as the raw calculation was clearly wrong
trend.Confidence *= 0.5
}
}
return trend
}
// computeStats calculates basic statistics for a set of metric points
func computeStats(points []MetricPoint) Stats {
if len(points) == 0 {
return Stats{}
}
stats := Stats{
Count: len(points),
Min: points[0].Value,
Max: points[0].Value,
}
for _, p := range points {
stats.Sum += p.Value
if p.Value < stats.Min {
stats.Min = p.Value
}
if p.Value > stats.Max {
stats.Max = p.Value
}
}
stats.Mean = stats.Sum / float64(stats.Count)
// Calculate standard deviation
var sumSquares float64
for _, p := range points {
diff := p.Value - stats.Mean
sumSquares += diff * diff
}
stats.StdDev = math.Sqrt(sumSquares / float64(stats.Count))
return stats
}
// linearRegression performs simple linear regression on time-series data.
// Returns slope (change per second), intercept, and R² (goodness of fit).
func linearRegression(points []MetricPoint) LinearRegressionResult {
if len(points) < 2 {
return LinearRegressionResult{}
}
n := float64(len(points))
// Use time relative to first point for numerical stability
baseTime := points[0].Timestamp
var sumX, sumY, sumXY, sumX2, sumY2 float64
for _, p := range points {
x := p.Timestamp.Sub(baseTime).Seconds() // seconds since start
y := p.Value
sumX += x
sumY += y
sumXY += x * y
sumX2 += x * x
sumY2 += y * y
}
// Calculate slope and intercept using least squares
denominator := n*sumX2 - sumX*sumX
if math.Abs(denominator) < 1e-10 {
// All x values are the same (no time span)
return LinearRegressionResult{R2: 0}
}
slope := (n*sumXY - sumX*sumY) / denominator
intercept := (sumY - slope*sumX) / n
// Calculate R² (coefficient of determination)
meanY := sumY / n
var ssRes, ssTot float64 // Sum of squares residual and total
for _, p := range points {
x := p.Timestamp.Sub(baseTime).Seconds()
yPred := slope*x + intercept
ssRes += (p.Value - yPred) * (p.Value - yPred)
ssTot += (p.Value - meanY) * (p.Value - meanY)
}
r2 := 0.0
if ssTot > 0 {
r2 = 1 - (ssRes / ssTot)
}
// Clamp R² to [0, 1] (can be negative for very bad fits)
if r2 < 0 {
r2 = 0
}
return LinearRegressionResult{
Slope: slope,
Intercept: intercept,
R2: r2,
}
}
// classifyTrend determines the trend direction based on slope and statistics.
// We normalize the slope relative to the metric's magnitude to avoid
// false positives on high-value metrics.
func classifyTrend(slopePerSecond, mean, stdDev float64) TrendDirection {
// If there's no significant variation, it's stable
if stdDev < 0.01 && math.Abs(slopePerSecond) < 1e-10 {
return TrendStable
}
// If standard deviation is high relative to mean, it's volatile
if mean > 0 && stdDev/mean > 0.3 {
return TrendVolatile
}
// Convert slope to hourly rate for easier reasoning
hourlyRate := slopePerSecond * 3600
// Determine significance threshold based on the metric's scale
// For percentage metrics (0-100), we care about ~0.1% per hour (~2.4% per day)
// This catches slow-growing issues before they become critical
// For absolute metrics, we care about ~0.5% of mean per hour
threshold := 0.1 // Default threshold for percentage metrics
if mean > 100 {
// For larger absolute values, use relative threshold
threshold = mean * 0.005
}
// Check if the hourly change is significant
if hourlyRate > threshold {
return TrendGrowing
}
if hourlyRate < -threshold {
return TrendDeclining
}
return TrendStable
}
// ComputePercentiles calculates percentile values from a sorted slice of points
func ComputePercentiles(points []MetricPoint, percentiles ...int) map[int]float64 {
result := make(map[int]float64)
if len(points) == 0 {
return result
}
// Extract values and sort
values := make([]float64, len(points))
for i, p := range points {
values[i] = p.Value
}
sort.Float64s(values)
for _, p := range percentiles {
if p < 0 || p > 100 {
continue
}
// Calculate index for percentile
idx := float64(p) / 100.0 * float64(len(values)-1)
lower := int(math.Floor(idx))
upper := int(math.Ceil(idx))
if lower >= len(values) {
lower = len(values) - 1
}
if upper >= len(values) {
upper = len(values) - 1
}
if lower == upper {
result[p] = values[lower]
} else {
// Linear interpolation between adjacent values
frac := idx - float64(lower)
result[p] = values[lower]*(1-frac) + values[upper]*frac
}
}
return result
}
// TrendSummary generates a human-readable summary of a trend
func TrendSummary(t Trend) string {
if t.DataPoints < 2 {
return "insufficient data"
}
directionStr := ""
switch t.Direction {
case TrendGrowing:
directionStr = "growing"
case TrendDeclining:
directionStr = "declining"
case TrendVolatile:
directionStr = "volatile"
case TrendStable:
directionStr = "stable"
}
// Format rate based on magnitude
rateStr := ""
if t.Direction == TrendGrowing || t.Direction == TrendDeclining {
absRate := math.Abs(t.RatePerDay)
if absRate > 1 {
rateStr = formatFloat(absRate, 1) + "/day"
} else {
rateStr = formatFloat(math.Abs(t.RatePerHour), 2) + "/hr"
}
}
if rateStr != "" {
return directionStr + " " + rateStr
}
return directionStr
}
// formatFloat formats a float with the given precision, trimming trailing zeros
func formatFloat(v float64, precision int) string {
return trimTrailingZeros(floatToString(v, precision))
}
func floatToString(v float64, precision int) string {
switch precision {
case 0:
return intToString(int(math.Round(v)))
case 1:
return intToString(int(v)) + "." + intToString(int(math.Round((v-float64(int(v)))*10)))
case 2:
return intToString(int(v)) + "." + padLeft(intToString(int(math.Round((v-float64(int(v)))*100))), 2, '0')
default:
mult := math.Pow(10, float64(precision))
return intToString(int(v)) + "." + padLeft(intToString(int(math.Round((v-float64(int(v)))*mult))), precision, '0')
}
}
func intToString(i int) string {
if i < 0 {
return "-" + intToString(-i)
}
if i < 10 {
return string(rune('0' + i))
}
return intToString(i/10) + string(rune('0'+i%10))
}
func padLeft(s string, length int, pad rune) string {
for len(s) < length {
s = string(pad) + s
}
return s
}
func trimTrailingZeros(s string) string {
if s == "" {
return s
}
// Find decimal point
dotIdx := -1
for i, c := range s {
if c == '.' {
dotIdx = i
break
}
}
if dotIdx == -1 {
return s // No decimal point
}
// Trim trailing zeros after decimal
end := len(s)
for end > dotIdx+1 && s[end-1] == '0' {
end--
}
// Also trim decimal if nothing after it
if end == dotIdx+1 {
end = dotIdx
}
return s[:end]
}