mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-16 23:55:09 +00:00
2c8e114aeb
- 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
245 lines
8.1 KiB
Go
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)")
|
|
}
|
|
}
|