Files
Anand 2c8e114aeb Wave 3 (1/3): capacity forecasting + fleet health score
- internal/api/forecast.go: OLS trend fit over node RRD history, per-node and fleet-wide capacity-warning endpoints
- internal/api/health.go: 0-100 health score (reachability, alerts, node/guest status, backup reliability) per-connection and fleet-wide
- web: CapacityForecastWidget, HealthScoreWidget dashboard widgets, registered in the widget catalog
2026-09-10 15:02:59 +05:30

245 lines
8.1 KiB
Go

package api
import (
"math"
"testing"
"time"
"ferrum/internal/pve"
)
// genMemPoints builds a synthetic node RRD series for the "mem" metric:
// one sample per day starting at startPct, moving by dailyDeltaPct% per
// day (plus optional noise), against a fixed maxMem.
func genMemPoints(days int, startPct, dailyDeltaPct float64, noise []float64) []pve.RRDPoint {
const maxMem = int64(1_000_000_000) // 1 GB, arbitrary
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Unix()
points := make([]pve.RRDPoint, days)
for i := 0; i < days; i++ {
pct := startPct + dailyDeltaPct*float64(i)
if noise != nil {
pct += noise[i%len(noise)]
}
if pct < 0 {
pct = 0
}
used := int64(pct / 100 * float64(maxMem))
points[i] = pve.RRDPoint{
Time: base + int64(i)*86400,
Mem: used,
MaxMem: maxMem,
}
}
return points
}
func TestBuildForecastRisingLinearly(t *testing.T) {
// 30 days, climbing from 50% to 50%+29*1.5% ≈ 93.5% at 1.5%/day.
points := genMemPoints(30, 50, 1.5, nil)
now := time.Unix(points[len(points)-1].Time, 0).UTC()
result, err := buildForecast(points, "mem", now)
if err != nil {
t.Fatalf("buildForecast: %v", err)
}
if result.Trend != "rising" {
t.Fatalf("Trend = %q, want rising", result.Trend)
}
if result.Confidence != "high" {
t.Errorf("Confidence = %q, want high for a clean linear series with 30 points", result.Confidence)
}
if result.RSquared < 0.99 {
t.Errorf("RSquared = %v, want ~1.0 for a perfectly linear series", result.RSquared)
}
if result.DaysToWarning == nil {
t.Fatal("DaysToWarning is nil, want a projection since the series already crossed 90%")
}
// Current pct (last sample, day 29) is 50 + 29*1.5 = 93.5, already past
// 90 — so days-to-warning should be ~0, not some far-future number.
if *result.DaysToWarning != 0 {
t.Errorf("DaysToWarning = %v, want 0 (already past 90%%)", *result.DaysToWarning)
}
if result.ProjectedDate90 == nil {
t.Error("ProjectedDate90 is nil, want a timestamp")
}
if result.DaysToCritical == nil {
t.Fatal("DaysToCritical is nil, want a projection")
}
// 100% is reached (100-50)/1.5 ≈ 33.3 days from day 0; day 29 is "now",
// so about 4.3 more days.
if *result.DaysToCritical < 3 || *result.DaysToCritical > 6 {
t.Errorf("DaysToCritical = %v, want roughly 4.3", *result.DaysToCritical)
}
}
func TestBuildForecastFlat(t *testing.T) {
points := genMemPoints(30, 40, 0, nil) // constant 40% for 30 days
now := time.Unix(points[len(points)-1].Time, 0).UTC()
result, err := buildForecast(points, "mem", now)
if err != nil {
t.Fatalf("buildForecast: %v", err)
}
if result.Trend != "flat" {
t.Fatalf("Trend = %q, want flat", result.Trend)
}
if result.DaysToWarning != nil || result.DaysToCritical != nil {
t.Error("a flat trend must not report projected days to any threshold")
}
if result.ProjectedDate90 != nil || result.ProjectedDate100 != nil {
t.Error("a flat trend must not report a projected crossing date")
}
}
func TestBuildForecastFalling(t *testing.T) {
points := genMemPoints(30, 80, -1.0, nil) // draining from 80% down
now := time.Unix(points[len(points)-1].Time, 0).UTC()
result, err := buildForecast(points, "mem", now)
if err != nil {
t.Fatalf("buildForecast: %v", err)
}
if result.Trend != "falling" {
t.Fatalf("Trend = %q, want falling", result.Trend)
}
if result.DaysToWarning != nil || result.DaysToCritical != nil {
t.Error("a falling trend must never report a nonsense future exhaustion date")
}
}
func TestBuildForecastInsufficientData(t *testing.T) {
// Only 5 points (< forecastMinPoints) even though they'd otherwise look
// like a clean, sharply rising line — must not be trusted.
points := genMemPoints(5, 10, 20, nil)
now := time.Unix(points[len(points)-1].Time, 0).UTC()
result, err := buildForecast(points, "mem", now)
if err != nil {
t.Fatalf("buildForecast: %v", err)
}
if result.Trend != "flat" {
t.Errorf("Trend = %q, want flat when history is too sparse to trust", result.Trend)
}
if result.Confidence != "low" {
t.Errorf("Confidence = %q, want low with only %d points", result.Confidence, len(points))
}
if result.DaysToWarning != nil || result.DaysToCritical != nil || result.ProjectedDate90 != nil || result.ProjectedDate100 != nil {
t.Error("insufficient data must never produce a projected date")
}
}
func TestBuildForecastNoisyButRising(t *testing.T) {
// A genuine upward trend (1%/day) with meaningful day-to-day noise
// layered on top — the fit should still detect "rising" but confidence
// should reflect the noise, not report "high" as if it were a clean line.
noise := []float64{18, -22, 14, -9, 25, -19, 11, -16, 21, -24}
points := genMemPoints(40, 30, 1.0, noise)
now := time.Unix(points[len(points)-1].Time, 0).UTC()
result, err := buildForecast(points, "mem", now)
if err != nil {
t.Fatalf("buildForecast: %v", err)
}
if result.Trend != "rising" {
t.Fatalf("Trend = %q, want rising (the underlying signal is a genuine upward trend)", result.Trend)
}
if result.Confidence == "high" {
t.Errorf("Confidence = high, want low/medium — the series has meaningful noise, R²=%v", result.RSquared)
}
if result.RSquared >= 0.99 {
t.Errorf("RSquared = %v suspiciously high for a noisy series", result.RSquared)
}
}
func TestBuildForecastUnsupportedMetric(t *testing.T) {
if _, err := buildForecast(nil, "bogus", time.Now()); err == nil {
t.Fatal("want an error for an unsupported metric")
}
}
func TestBuildForecastEmptySeries(t *testing.T) {
result, err := buildForecast(nil, "disk", time.Now())
if err != nil {
t.Fatalf("buildForecast: %v", err)
}
if result.Trend != "flat" || result.Confidence != "low" {
t.Errorf("empty series should report flat/low, got trend=%q confidence=%q", result.Trend, result.Confidence)
}
if result.CurrentPct != 0 {
t.Errorf("CurrentPct = %v, want 0 for no data", result.CurrentPct)
}
}
func TestBuildForecastCPUMetricUsesFractionDirectly(t *testing.T) {
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Unix()
points := make([]pve.RRDPoint, 10)
for i := range points {
points[i] = pve.RRDPoint{Time: base + int64(i)*86400, CPU: 0.5} // 50% flat
}
now := time.Unix(points[len(points)-1].Time, 0).UTC()
result, err := buildForecast(points, "cpu", now)
if err != nil {
t.Fatalf("buildForecast: %v", err)
}
if math.Abs(result.CurrentPct-50) > 0.001 {
t.Errorf("CurrentPct = %v, want 50 (cpu fraction * 100)", result.CurrentPct)
}
}
func TestLinearRegressionDivByZeroGuards(t *testing.T) {
// All identical x values — must not panic or divide by zero.
if _, _, _, ok := linearRegression([]float64{5, 5, 5}, []float64{1, 2, 3}); ok {
t.Error("want ok=false when every x is identical (can't fit a line)")
}
// Too few points.
if _, _, _, ok := linearRegression([]float64{1}, []float64{1}); ok {
t.Error("want ok=false with a single point")
}
// Mismatched lengths.
if _, _, _, ok := linearRegression([]float64{1, 2}, []float64{1}); ok {
t.Error("want ok=false with mismatched slice lengths")
}
}
func TestLinearRegressionPerfectFit(t *testing.T) {
xs := []float64{0, 1, 2, 3, 4}
ys := []float64{10, 12, 14, 16, 18} // y = 2x + 10
slope, intercept, r2, ok := linearRegression(xs, ys)
if !ok {
t.Fatal("want ok=true")
}
if math.Abs(slope-2) > 1e-9 {
t.Errorf("slope = %v, want 2", slope)
}
if math.Abs(intercept-10) > 1e-9 {
t.Errorf("intercept = %v, want 10", intercept)
}
if math.Abs(r2-1) > 1e-9 {
t.Errorf("r2 = %v, want 1 for a perfect fit", r2)
}
}
func TestDaysUntilThresholdAlreadyPast(t *testing.T) {
now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
days, date, ok := daysUntilThreshold(1.0, 0, 95, 90, 10, now)
if !ok {
t.Fatal("want ok=true")
}
if days != 0 {
t.Errorf("days = %v, want 0 (already past threshold)", days)
}
if date != now.UTC().Format(time.RFC3339) {
t.Errorf("date = %q, want now formatted as RFC3339", date)
}
}
func TestDaysUntilThresholdNonPositiveSlope(t *testing.T) {
now := time.Now()
if _, _, ok := daysUntilThreshold(0, 50, 50, 90, 0, now); ok {
t.Error("want ok=false for a zero slope (never reaches threshold)")
}
if _, _, ok := daysUntilThreshold(-0.5, 50, 50, 90, 0, now); ok {
t.Error("want ok=false for a negative slope (draining, never reaches threshold)")
}
}