mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 08:33:03 +00:00
b2bd9d1147
Performance reports rendered the Executive Summary, Observations, and Recommendations sections from inline threshold rules in pdf.go. That narrative looked intelligent but was static templating against alert counts and metric percentiles, which felt off-brand alongside Patrol and Pulse Assistant. Introduce a Narrator interface in pkg/reporting and a FindingsProvider counterpart that the engine consults at report time. The heuristic rules are lifted into HeuristicNarrator unchanged so the deterministic fallback still produces the same observations and recommendations. The engine now also queries the comparable prior period and threads its aggregate stats through the narrator so deltas can be expressed. internal/ai.Service implements both interfaces via report_narrator.go (single-turn JSON call grounded in the structured ReportData payload, falling back to the heuristic on any error/timeout) and report_findings.go (Patrol findings whose lifecycle overlaps the report window). The reporting handler resolves the per-tenant AI service when it is configured and supplies it in the request; absent configuration, reports look identical to the prior heuristic output. Charts, stats tables, alert lists, storage and disk sections stay deterministic — sysadmins can verify every AI claim against the data tables next to it. The PDF renders the AI prose between the health card and Quick Stats, adds a Period-over-period section after Recommendations, and prints a provenance footer when the narrative came from the assistant. ai-runtime.md and api-contracts.md updates land in a follow-up commit on this branch; agent-lifecycle / performance-and-scalability / storage-recovery have no contract delta from this change (router.go is referenced in their Extension Points but their semantics are unchanged).
211 lines
6.1 KiB
Go
211 lines
6.1 KiB
Go
package reporting
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestGenerateObservations_Default(t *testing.T) {
|
|
g := NewPDFGenerator()
|
|
data := &ReportData{Summary: MetricSummary{ByMetric: make(map[string]MetricStats)}}
|
|
obs := g.generateObservations(data)
|
|
if len(obs) != 1 {
|
|
t.Fatalf("expected 1 observation, got %d", len(obs))
|
|
}
|
|
if !strings.Contains(obs[0].text, "Insufficient data") {
|
|
t.Fatalf("unexpected observation: %q", obs[0].text)
|
|
}
|
|
}
|
|
|
|
func TestGenerateObservations_MixedSignals(t *testing.T) {
|
|
g := NewPDFGenerator()
|
|
data := &ReportData{
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{
|
|
"cpu": {Avg: 50, Max: 95},
|
|
"memory": {Avg: 90, Max: 92},
|
|
"disk": {Avg: 88, Max: 90},
|
|
}},
|
|
Alerts: []AlertInfo{{ResolvedTime: ptrTime(time.Now())}},
|
|
Disks: []DiskInfo{{Device: "nvme0", WearLevel: 5, Health: "FAILED"}},
|
|
Resource: &ResourceInfo{
|
|
Uptime: 100 * 86400,
|
|
},
|
|
}
|
|
|
|
obs := g.generateObservations(data)
|
|
if len(obs) < 5 {
|
|
t.Fatalf("expected multiple observations, got %d", len(obs))
|
|
}
|
|
assertObservationContains(t, obs, "CPU peaked")
|
|
assertObservationContains(t, obs, "Memory consistently high")
|
|
assertObservationContains(t, obs, "Disk at")
|
|
assertObservationContains(t, obs, "alerts were triggered")
|
|
assertObservationContains(t, obs, "CRITICAL: Disk nvme0")
|
|
assertObservationContains(t, obs, "System uptime")
|
|
}
|
|
|
|
func TestGenerateObservations_UnderutilizedCPU(t *testing.T) {
|
|
g := NewPDFGenerator()
|
|
data := &ReportData{
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{
|
|
"cpu": {Avg: 10, Max: 15},
|
|
}},
|
|
}
|
|
|
|
obs := g.generateObservations(data)
|
|
assertObservationContains(t, obs, "underutilized")
|
|
}
|
|
|
|
func TestGenerateRecommendations(t *testing.T) {
|
|
g := NewPDFGenerator()
|
|
data := &ReportData{
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{
|
|
"cpu": {Avg: 40, Max: 95},
|
|
"memory": {Avg: 90, Max: 95},
|
|
"disk": {Avg: 90, Max: 92},
|
|
}},
|
|
Storage: []StorageInfo{{Name: "local", UsagePerc: 95}},
|
|
Disks: []DiskInfo{{Device: "sda", WearLevel: 5, Health: "FAILED"}},
|
|
Alerts: []AlertInfo{
|
|
{Level: "critical", Type: "cpu", Message: "high cpu"},
|
|
{Level: "critical", Type: "memory", Message: "high memory"},
|
|
{Level: "warning", Type: "disk", Message: "disk pressure"},
|
|
},
|
|
Resource: &ResourceInfo{
|
|
Uptime: 100 * 86400,
|
|
},
|
|
}
|
|
|
|
recs := g.generateRecommendations(data, 2, 1)
|
|
if len(recs) == 0 {
|
|
t.Fatal("expected recommendations")
|
|
}
|
|
assertStringSliceContains(t, recs, "Replace disk sda")
|
|
assertStringSliceContains(t, recs, "SMART health check failed")
|
|
assertStringSliceContains(t, recs, "critical alerts")
|
|
assertStringSliceContains(t, recs, "adding memory")
|
|
assertStringSliceContains(t, recs, "CPU-intensive")
|
|
assertStringSliceContains(t, recs, "Clean up disk space")
|
|
assertStringSliceContains(t, recs, "Expand storage pool 'local'")
|
|
assertStringSliceContains(t, recs, "Schedule maintenance window")
|
|
}
|
|
|
|
func TestGenerateRecommendations_Underutilized(t *testing.T) {
|
|
g := NewPDFGenerator()
|
|
data := &ReportData{
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{
|
|
"cpu": {Avg: 5, Max: 10},
|
|
}},
|
|
}
|
|
|
|
recs := g.generateRecommendations(data, 0, 0)
|
|
assertStringSliceContains(t, recs, "underutilized")
|
|
}
|
|
|
|
func TestGenerateRecommendations_DefaultMessage(t *testing.T) {
|
|
g := NewPDFGenerator()
|
|
data := &ReportData{}
|
|
|
|
recs := g.generateRecommendations(data, 0, 0)
|
|
assertStringSliceContains(t, recs, "No immediate action required")
|
|
}
|
|
|
|
func TestGenerateRecommendations_UsageMetric(t *testing.T) {
|
|
g := NewPDFGenerator()
|
|
data := &ReportData{
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{
|
|
"usage": {Avg: 90, Max: 95},
|
|
}},
|
|
}
|
|
|
|
recs := g.generateRecommendations(data, 0, 0)
|
|
assertStringSliceContains(t, recs, "Clean up disk space")
|
|
}
|
|
|
|
func TestGetStatColor(t *testing.T) {
|
|
if got := getStatColor(95); got != colorDanger {
|
|
t.Fatalf("expected danger color")
|
|
}
|
|
if got := getStatColor(80); got != colorWarning {
|
|
t.Fatalf("expected warning color")
|
|
}
|
|
if got := getStatColor(50); got != colorAccent {
|
|
t.Fatalf("expected accent color")
|
|
}
|
|
}
|
|
|
|
func TestGetAlertCountColor(t *testing.T) {
|
|
if got := getAlertCountColor(1); got != colorDanger {
|
|
t.Fatal("expected danger color when alerts present")
|
|
}
|
|
if got := getAlertCountColor(0); got != colorAccent {
|
|
t.Fatal("expected accent color when no alerts")
|
|
}
|
|
}
|
|
|
|
func TestCalculateTrend(t *testing.T) {
|
|
g := NewPDFGenerator()
|
|
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
makePoints := func(values []float64) []MetricDataPoint {
|
|
points := make([]MetricDataPoint, len(values))
|
|
for i, v := range values {
|
|
points[i] = MetricDataPoint{Timestamp: base.Add(time.Duration(i) * time.Minute), Value: v}
|
|
}
|
|
return points
|
|
}
|
|
|
|
dataUp := &ReportData{Metrics: map[string][]MetricDataPoint{
|
|
"cpu": makePoints([]float64{10, 10, 10, 10, 10, 20, 20, 20, 20, 20}),
|
|
}}
|
|
if trend := g.calculateTrend(dataUp, "cpu"); trend != "(trending up)" {
|
|
t.Fatalf("expected trending up, got %q", trend)
|
|
}
|
|
|
|
dataDown := &ReportData{Metrics: map[string][]MetricDataPoint{
|
|
"cpu": makePoints([]float64{20, 20, 20, 20, 20, 10, 10, 10, 10, 10}),
|
|
}}
|
|
if trend := g.calculateTrend(dataDown, "cpu"); trend != "(trending down)" {
|
|
t.Fatalf("expected trending down, got %q", trend)
|
|
}
|
|
|
|
dataStable := &ReportData{Metrics: map[string][]MetricDataPoint{
|
|
"cpu": makePoints([]float64{10, 10, 10, 10, 10, 10.1, 10.2, 10.1, 10.2, 10.1}),
|
|
}}
|
|
if trend := g.calculateTrend(dataStable, "cpu"); trend != "(stable)" {
|
|
t.Fatalf("expected stable, got %q", trend)
|
|
}
|
|
|
|
dataShort := &ReportData{Metrics: map[string][]MetricDataPoint{
|
|
"cpu": makePoints([]float64{10, 12, 11}),
|
|
}}
|
|
if trend := g.calculateTrend(dataShort, "cpu"); trend != "" {
|
|
t.Fatalf("expected empty trend, got %q", trend)
|
|
}
|
|
}
|
|
|
|
func assertObservationContains(t *testing.T, obs []observation, needle string) {
|
|
t.Helper()
|
|
for _, o := range obs {
|
|
if strings.Contains(o.text, needle) {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("expected observation containing %q", needle)
|
|
}
|
|
|
|
func assertStringSliceContains(t *testing.T, items []string, needle string) {
|
|
t.Helper()
|
|
for _, item := range items {
|
|
if strings.Contains(item, needle) {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("expected slice to contain %q", needle)
|
|
}
|
|
|
|
func ptrTime(t time.Time) *time.Time {
|
|
return &t
|
|
}
|