mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-25 08:52:06 +00:00
Two regressions surfaced by generating a real-mode agent report after a backend restart: - Report subject lookups used Monitor.GetUnifiedResources (the raw resource store), but the raw store's canonical IDs depend on per-boot ingest order for merged-source hosts: after a restart the same host resolved to a different agent-<hash> than the one the UI and /api/state advertise, so reports lost the resource name, availability, and metrics translation entirely. Subject enrichment now reads Monitor.UnifiedResourceSnapshot and MetricsTargetForResource resolves through GetUnifiedReadStateOrSnapshot first (raw store as fallback) - the same re-ingested registry every other read surface uses. - The performance summary card grid positions cards absolutely and never paginated: an agent host reporting 8+ metric families walked off the page bottom, fought fpdf's auto page break, and scattered one orphan element per page (a 7-day delly report rendered 18 pages, ten of them near-blank). The grid now starts a new page before a row that will not fit; the same report renders 9 pages with intact cards. Verified live: real-mode delly report shows name, 288 data points, availability, charts, and correctly paginated cards. The underlying canonical-ID instability (raw store vs re-ingested view, and the resource_changes journal fragmenting across boot eras) is a separate root issue tracked for its own fix.
512 lines
18 KiB
Go
512 lines
18 KiB
Go
package reporting
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/zlib"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-pdf/fpdf"
|
|
)
|
|
|
|
// TestExecutiveSummary_EmptyDataShowsNoData asserts that when a report
|
|
// runs against a window that produced zero data points and no alerts,
|
|
// the executive summary renders a muted "NO DATA" card rather than the
|
|
// green HEALTHY card it shipped with originally. This was a real UX
|
|
// bug found by generating an actual PDF: a user looking at an
|
|
// empty-window report would see "All systems operating normally" and
|
|
// believe their resource was fine when really Pulse had no metrics to
|
|
// evaluate.
|
|
func TestExecutiveSummary_EmptyDataShowsNoData(t *testing.T) {
|
|
data := &ReportData{
|
|
Title: "Empty",
|
|
ResourceType: "node",
|
|
ResourceID: "empty",
|
|
Start: time.Now().Add(-time.Hour),
|
|
End: time.Now(),
|
|
GeneratedAt: time.Now(),
|
|
Metrics: map[string][]MetricDataPoint{},
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{}},
|
|
TotalPoints: 0,
|
|
}
|
|
text := renderExecutiveSummaryText(t, data)
|
|
if !strings.Contains(text, "NO DATA") {
|
|
t.Errorf("expected 'NO DATA' card on empty data, got:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "All systems operating normally") {
|
|
t.Errorf("HEALTHY message should not appear on empty data, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestExecutiveSummary_HealthyWhenDataPresent_NoAlerts confirms the
|
|
// HEALTHY path still works when there IS data and no alerts. This is
|
|
// the regression guard: the empty-data fix above must not change the
|
|
// behavior for actually-quiet resources.
|
|
func TestExecutiveSummary_HealthyWhenDataPresent_NoAlerts(t *testing.T) {
|
|
data := &ReportData{
|
|
Title: "Quiet",
|
|
ResourceType: "node",
|
|
ResourceID: "quiet",
|
|
Start: time.Now().Add(-time.Hour),
|
|
End: time.Now(),
|
|
GeneratedAt: time.Now(),
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{
|
|
"cpu": {Avg: 5, Max: 12, Count: 60},
|
|
"memory": {Avg: 30, Max: 35, Count: 60},
|
|
}},
|
|
TotalPoints: 120,
|
|
}
|
|
text := renderExecutiveSummaryText(t, data)
|
|
if !strings.Contains(text, "HEALTHY") {
|
|
t.Errorf("expected HEALTHY card on quiet resource, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestExecutiveSummary_HeuristicSourceShowsDiscoverabilityTip asserts
|
|
// that when the narrative came from the heuristic narrator (no AI
|
|
// configured or AI failed), the executive summary surfaces a one-line
|
|
// tip pointing operators at Pulse Assistant. Without this nudge a
|
|
// user has no signal that AI-narrated reports are a separate
|
|
// capability they could enable.
|
|
func TestExecutiveSummary_HeuristicSourceShowsDiscoverabilityTip(t *testing.T) {
|
|
data := &ReportData{
|
|
Title: "Quiet",
|
|
ResourceType: "node",
|
|
ResourceID: "quiet",
|
|
Start: time.Now().Add(-time.Hour),
|
|
End: time.Now(),
|
|
GeneratedAt: time.Now(),
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{
|
|
"cpu": {Avg: 10, Max: 20, Count: 60},
|
|
}},
|
|
TotalPoints: 60,
|
|
Narrative: &Narrative{
|
|
Source: NarrativeSourceHeuristic,
|
|
HealthStatus: "HEALTHY",
|
|
HealthMessage: "OK",
|
|
Observations: []NarrativeBullet{{Text: "Looks fine", Severity: NarrativeSeverityOK}},
|
|
},
|
|
}
|
|
text := renderExecutiveSummaryText(t, data)
|
|
if !strings.Contains(text, "Configure Pulse Assistant") {
|
|
t.Errorf("expected discoverability tip on heuristic-source narrative, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestExecutiveSummary_AISourceDoesNotShowDiscoverabilityTip is the
|
|
// converse: when AI actually fired, the disclaimer footer (which the
|
|
// AI narrator populates) replaces the tip. Showing both would be
|
|
// noisy and contradict the AI provenance line.
|
|
func TestExecutiveSummary_AISourceDoesNotShowDiscoverabilityTip(t *testing.T) {
|
|
data := &ReportData{
|
|
Title: "Quiet",
|
|
ResourceType: "node",
|
|
ResourceID: "quiet",
|
|
Start: time.Now().Add(-time.Hour),
|
|
End: time.Now(),
|
|
GeneratedAt: time.Now(),
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{
|
|
"cpu": {Avg: 10, Max: 20, Count: 60},
|
|
}},
|
|
TotalPoints: 60,
|
|
Narrative: &Narrative{
|
|
Source: NarrativeSourceAI,
|
|
HealthStatus: "HEALTHY",
|
|
HealthMessage: "OK",
|
|
Observations: []NarrativeBullet{{Text: "AI prose here", Severity: NarrativeSeverityOK}},
|
|
Disclaimer: "Narrative generated by Pulse Assistant.",
|
|
},
|
|
}
|
|
text := renderExecutiveSummaryText(t, data)
|
|
if strings.Contains(text, "Configure Pulse Assistant") {
|
|
t.Errorf("discoverability tip should not appear when AI fired, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "Narrative generated by Pulse Assistant") {
|
|
t.Errorf("expected AI provenance disclaimer when source is ai, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestFleetSummary_HeuristicSourceShowsDiscoverabilityTip mirrors the
|
|
// single-resource test for the fleet path. The fleet narrative has a
|
|
// distinct nudge (mentions outliers / patterns) so the copy doesn't
|
|
// over-promise single-resource synthesis.
|
|
func TestFleetSummary_HeuristicSourceShowsDiscoverabilityTip(t *testing.T) {
|
|
now := time.Now()
|
|
multi := &MultiReportData{
|
|
Title: "Fleet",
|
|
Start: now.Add(-time.Hour),
|
|
End: now,
|
|
GeneratedAt: now,
|
|
Resources: []*ReportData{
|
|
{
|
|
ResourceID: "a",
|
|
ResourceType: "node",
|
|
Resource: &ResourceInfo{Name: "alpha", Status: "online"},
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Max: 15, Count: 60}}},
|
|
TotalPoints: 60,
|
|
},
|
|
},
|
|
FleetNarrative: &FleetNarrative{
|
|
Source: NarrativeSourceHeuristic,
|
|
HealthStatus: "HEALTHY",
|
|
HealthMessage: "Fleet quiet",
|
|
},
|
|
}
|
|
text := renderFleetSummaryText(t, multi)
|
|
if !strings.Contains(text, "Configure Pulse Assistant") {
|
|
t.Errorf("expected fleet discoverability tip on heuristic source, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "outliers") && !strings.Contains(text, "patterns") {
|
|
t.Errorf("fleet tip should mention outliers/patterns, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestFleetSummary_EmptyDataShowsNoData mirrors the single-resource
|
|
// empty-window guard for fleet reports. A fleet PDF whose every resource
|
|
// returned zero data points must not render the green HEALTHY card —
|
|
// "All systems operating normally" over no evidence is false assurance,
|
|
// which is the worst failure mode for a report whose job is to prove
|
|
// stability to a client.
|
|
func TestFleetSummary_EmptyDataShowsNoData(t *testing.T) {
|
|
now := time.Now()
|
|
multi := &MultiReportData{
|
|
Title: "Fleet",
|
|
Start: now.Add(-time.Hour),
|
|
End: now,
|
|
GeneratedAt: now,
|
|
Resources: []*ReportData{
|
|
{
|
|
ResourceID: "vm-aaaa",
|
|
ResourceType: "vm",
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{}},
|
|
TotalPoints: 0,
|
|
},
|
|
{
|
|
ResourceID: "vm-bbbb",
|
|
ResourceType: "vm",
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{}},
|
|
TotalPoints: 0,
|
|
},
|
|
},
|
|
}
|
|
text := renderFleetSummaryText(t, multi)
|
|
if !strings.Contains(text, "NO DATA") {
|
|
t.Errorf("expected 'NO DATA' card on empty fleet data, got:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "All systems operating normally") {
|
|
t.Errorf("HEALTHY message should not appear on empty fleet data, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestCoverPage_PrefersResourceName asserts the cover page and the page
|
|
// header lead with the human-readable resource name when enrichment
|
|
// resolved one. Canonical v6 resource IDs are opaque hashes
|
|
// (vm-7f8b2b6cd98c2089); a client reading a monthly report cannot map
|
|
// those to their machines.
|
|
func TestCoverPage_PrefersResourceName(t *testing.T) {
|
|
data := &ReportData{
|
|
Title: "Named",
|
|
ResourceType: "vm",
|
|
ResourceID: "vm-7f8b2b6cd98c2089",
|
|
Resource: &ResourceInfo{Name: "checkout-web-01", Status: "running"},
|
|
Start: time.Now().Add(-time.Hour),
|
|
End: time.Now(),
|
|
GeneratedAt: time.Now(),
|
|
Metrics: map[string][]MetricDataPoint{},
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{}},
|
|
}
|
|
text := renderExecutiveSummaryText(t, data)
|
|
if !strings.Contains(text, "checkout-web-01") {
|
|
t.Errorf("expected resource name on cover, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// renderExecutiveSummaryText runs writeExecutiveSummary against a
|
|
// fresh fpdf, extracts text by parsing the resulting PDF, and returns
|
|
// the rendered text content. Used by the UX assertions above.
|
|
func renderExecutiveSummaryText(t *testing.T, data *ReportData) string {
|
|
t.Helper()
|
|
gen := NewPDFGenerator()
|
|
bytes, err := gen.Generate(data)
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
return extractPDFText(t, bytes)
|
|
}
|
|
|
|
func renderFleetSummaryText(t *testing.T, data *MultiReportData) string {
|
|
t.Helper()
|
|
gen := NewPDFGenerator()
|
|
bytes, err := gen.GenerateMulti(data)
|
|
if err != nil {
|
|
t.Fatalf("GenerateMulti: %v", err)
|
|
}
|
|
return extractPDFText(t, bytes)
|
|
}
|
|
|
|
// extractPDFText pulls plain text out of a PDF blob by finding every
|
|
// FlateDecode'd content stream, inflating it, and harvesting the
|
|
// parenthesised string literals used by Tj operators. fpdf always
|
|
// compresses its content streams, so a substring scan over the raw
|
|
// bytes misses everything visible to a reader.
|
|
var streamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)\r?\nendstream`)
|
|
var literalRe = regexp.MustCompile(`\(([^()\\]*(?:\\.[^()\\]*)*)\)`)
|
|
|
|
func extractPDFText(t *testing.T, data []byte) string {
|
|
t.Helper()
|
|
var out bytes.Buffer
|
|
for _, m := range streamRe.FindAllSubmatch(data, -1) {
|
|
raw := m[1]
|
|
decoded, err := inflateStream(raw)
|
|
if err != nil {
|
|
// Stream may not be Flate'd (e.g. xref tables) — skip.
|
|
continue
|
|
}
|
|
for _, lit := range literalRe.FindAllSubmatch(decoded, -1) {
|
|
out.Write(lit[1])
|
|
out.WriteByte(' ')
|
|
}
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func inflateStream(b []byte) ([]byte, error) {
|
|
r, err := zlib.NewReader(bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer r.Close()
|
|
return io.ReadAll(r)
|
|
}
|
|
|
|
// Compile-time assertion the fpdf import isn't dropped by goimports
|
|
// when this test file is processed in isolation; the import lives in
|
|
// pdf.go but tests referencing fpdf.New constants confirm we still
|
|
// resolve the package.
|
|
var _ = fpdf.New
|
|
|
|
// TestExecutiveSummary_AvailabilitySectionRendersUptime asserts the
|
|
// availability block renders the headline uptime number, outage detail,
|
|
// and the partial-observation disclosure. This is the number an MSP's
|
|
// client reads the report for.
|
|
func TestExecutiveSummary_AvailabilitySectionRendersUptime(t *testing.T) {
|
|
data := &ReportData{
|
|
Title: "Avail",
|
|
ResourceType: "vm",
|
|
ResourceID: "vm-1",
|
|
Start: time.Now().Add(-30 * 24 * time.Hour),
|
|
End: time.Now(),
|
|
GeneratedAt: time.Now(),
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 10}}},
|
|
TotalPoints: 10,
|
|
Availability: &AvailabilityInfo{
|
|
UptimePercent: 99.42,
|
|
ObservedPercent: 87.5,
|
|
TotalDowntime: 4 * time.Hour,
|
|
LongestOutage: 3 * time.Hour,
|
|
DownIncidents: 2,
|
|
},
|
|
}
|
|
text := renderExecutiveSummaryText(t, data)
|
|
if !strings.Contains(text, "Availability") {
|
|
t.Errorf("expected Availability section, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "99.42%") {
|
|
t.Errorf("expected uptime percentage, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "2 outages") || !strings.Contains(text, "4 hours total downtime") {
|
|
t.Errorf("expected outage detail, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "87.5% of this period") {
|
|
t.Errorf("expected partial-observation disclosure, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestExecutiveSummary_AvailabilityOmittedWhenUnavailable asserts reports
|
|
// without a resource timeline render no availability section at all (no
|
|
// fabricated 100%).
|
|
func TestExecutiveSummary_AvailabilityOmittedWhenUnavailable(t *testing.T) {
|
|
data := &ReportData{
|
|
Title: "NoAvail",
|
|
ResourceType: "vm",
|
|
ResourceID: "vm-1",
|
|
Start: time.Now().Add(-time.Hour),
|
|
End: time.Now(),
|
|
GeneratedAt: time.Now(),
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 10}}},
|
|
TotalPoints: 10,
|
|
}
|
|
text := renderExecutiveSummaryText(t, data)
|
|
if strings.Contains(text, "Availability") {
|
|
t.Errorf("expected no availability section without data, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestFleetSummary_UptimeColumn asserts the fleet table carries the
|
|
// per-resource uptime column, with a dash for unobserved resources.
|
|
func TestFleetSummary_UptimeColumn(t *testing.T) {
|
|
now := time.Now()
|
|
multi := &MultiReportData{
|
|
Title: "Fleet",
|
|
Start: now.Add(-30 * 24 * time.Hour),
|
|
End: now,
|
|
GeneratedAt: now,
|
|
Resources: []*ReportData{
|
|
{
|
|
ResourceID: "vm-a",
|
|
ResourceType: "vm",
|
|
Resource: &ResourceInfo{Name: "alpha", Status: "online"},
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 60}}},
|
|
TotalPoints: 60,
|
|
Availability: &AvailabilityInfo{UptimePercent: 99.95, ObservedPercent: 100, TotalDowntime: 20 * time.Minute, DownIncidents: 1},
|
|
},
|
|
{
|
|
ResourceID: "vm-b",
|
|
ResourceType: "vm",
|
|
Resource: &ResourceInfo{Name: "beta", Status: "online"},
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 60}}},
|
|
TotalPoints: 60,
|
|
},
|
|
},
|
|
}
|
|
text := renderFleetSummaryText(t, multi)
|
|
if !strings.Contains(text, "Uptime") {
|
|
t.Errorf("expected Uptime column header, got:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "99.95%") {
|
|
t.Errorf("expected uptime value for observed resource, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestAvailabilityUptimeLabel_NeverOverstates pins the rounding clamp: a
|
|
// window with any downtime must not round up to a clean 100%.
|
|
func TestAvailabilityUptimeLabel_NeverOverstates(t *testing.T) {
|
|
if got := availabilityUptimeLabel(99.999); got != "99.99%" {
|
|
t.Fatalf("availabilityUptimeLabel(99.999) = %q, want 99.99%%", got)
|
|
}
|
|
if got := availabilityUptimeLabel(100); got != "100%" {
|
|
t.Fatalf("availabilityUptimeLabel(100) = %q, want 100%%", got)
|
|
}
|
|
if got := availabilityUptimeLabel(99.4249); got != "99.42%" {
|
|
t.Fatalf("availabilityUptimeLabel(99.4249) = %q, want 99.42%%", got)
|
|
}
|
|
}
|
|
|
|
// TestMetricFormatting_RateMetricsAreHumanReadable pins the display
|
|
// vocabulary for the rate metrics that previously rendered as raw keys
|
|
// with unformatted values ("diskread 880000.00") in client-facing
|
|
// reports.
|
|
func TestMetricFormatting_RateMetricsAreHumanReadable(t *testing.T) {
|
|
if got := GetMetricTypeDisplayName("diskread"); got != "Disk Read" {
|
|
t.Fatalf("GetMetricTypeDisplayName(diskread) = %q", got)
|
|
}
|
|
if got := GetMetricTypeDisplayName("netout"); got != "Network Out" {
|
|
t.Fatalf("GetMetricTypeDisplayName(netout) = %q", got)
|
|
}
|
|
if got := GetMetricUnit("diskwrite"); got != "bytes/s" {
|
|
t.Fatalf("GetMetricUnit(diskwrite) = %q", got)
|
|
}
|
|
if got := formatMetricValue(880000, "bytes/s"); got != "859.38 KiB/s" {
|
|
t.Fatalf("formatMetricValue(880000, bytes/s) = %q", got)
|
|
}
|
|
if got := formatMetricValue(22.1, "%"); got != "22.10%" {
|
|
t.Fatalf("formatMetricValue(22.1, %%) = %q", got)
|
|
}
|
|
// Byte units are self-describing; the old +unit concatenation
|
|
// produced "12.00 GiBbytes".
|
|
if got := formatMetricValue(12884901888, "bytes"); got != "12.00 GiB" {
|
|
t.Fatalf("formatMetricValue(12GiB, bytes) = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestGenerateMulti_FlowsResourceBlocksOntoSharedPages asserts the fleet
|
|
// report no longer spends one near-empty A4 page per resource: six sparse
|
|
// resources must fit on a handful of pages, not eight.
|
|
func TestGenerateMulti_FlowsResourceBlocksOntoSharedPages(t *testing.T) {
|
|
now := time.Now()
|
|
multi := &MultiReportData{
|
|
Title: "Fleet",
|
|
Start: now.Add(-24 * time.Hour),
|
|
End: now,
|
|
GeneratedAt: now,
|
|
}
|
|
for i := 0; i < 6; i++ {
|
|
multi.Resources = append(multi.Resources, &ReportData{
|
|
ResourceID: fmt.Sprintf("vm-%d", i),
|
|
ResourceType: "vm",
|
|
Resource: &ResourceInfo{Name: fmt.Sprintf("guest-%02d", i), Status: "online"},
|
|
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Max: 20, Count: 1}}},
|
|
TotalPoints: 1,
|
|
Availability: &AvailabilityInfo{UptimePercent: 100, ObservedPercent: 100},
|
|
})
|
|
}
|
|
|
|
gen := NewPDFGenerator()
|
|
out, err := gen.GenerateMulti(multi)
|
|
if err != nil {
|
|
t.Fatalf("GenerateMulti: %v", err)
|
|
}
|
|
text := extractPDFText(t, out)
|
|
|
|
pageTotal := regexp.MustCompile(`Page \d+ of (\d+)`).FindStringSubmatch(text)
|
|
if pageTotal == nil {
|
|
t.Fatalf("no page footer found in:\n%s", text)
|
|
}
|
|
if pages, _ := strconv.Atoi(pageTotal[1]); pages > 4 {
|
|
t.Fatalf("6 sparse resources rendered %d pages; blocks must flow onto shared pages", pages)
|
|
}
|
|
for i := 0; i < 6; i++ {
|
|
name := fmt.Sprintf("guest-%02d", i)
|
|
if !strings.Contains(text, name) {
|
|
t.Fatalf("resource %s missing from flowed detail pages:\n%s", name, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSummarySection_PaginatesCardGridForManyMetrics pins the card grid's
|
|
// self-pagination. Agent hosts report 8+ metric families; the absolutely
|
|
// positioned grid previously walked off the page bottom and fought fpdf's
|
|
// auto page break, scattering one orphan element per page for the rest of
|
|
// the section (observed live as ten near-blank pages).
|
|
func TestSummarySection_PaginatesCardGridForManyMetrics(t *testing.T) {
|
|
byMetric := map[string]MetricStats{}
|
|
for _, m := range []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout", "temperature", "iops", "usage"} {
|
|
byMetric[m] = MetricStats{Min: 1, Max: 9, Avg: 5, Current: 5, Count: 100}
|
|
}
|
|
data := &ReportData{
|
|
Title: "Many metrics",
|
|
ResourceType: "agent",
|
|
ResourceID: "agent-1",
|
|
Resource: &ResourceInfo{Name: "host-01", Status: "online"},
|
|
Start: time.Now().Add(-7 * 24 * time.Hour),
|
|
End: time.Now(),
|
|
GeneratedAt: time.Now(),
|
|
Summary: MetricSummary{ByMetric: byMetric},
|
|
TotalPoints: 1000,
|
|
}
|
|
|
|
gen := NewPDFGenerator()
|
|
out, err := gen.Generate(data)
|
|
if err != nil {
|
|
t.Fatalf("Generate: %v", err)
|
|
}
|
|
text := extractPDFText(t, out)
|
|
|
|
pageTotal := regexp.MustCompile(`Page \d+ of (\d+)`).FindStringSubmatch(text)
|
|
if pageTotal == nil {
|
|
t.Fatalf("no page footer found in:\n%s", text)
|
|
}
|
|
if pages, _ := strconv.Atoi(pageTotal[1]); pages > 6 {
|
|
t.Fatalf("10-metric report rendered %d pages; the card grid must paginate compactly", pages)
|
|
}
|
|
for _, label := range []string{"Disk Read", "Network Out", "Temperature"} {
|
|
if !strings.Contains(text, label) {
|
|
t.Fatalf("metric card %q missing after pagination:\n%s", label, text)
|
|
}
|
|
}
|
|
}
|