Drive reporting default ranges from catalog

This commit is contained in:
rcourtman 2026-03-25 22:56:20 +00:00
parent 2f2c650409
commit 3d85fde176
8 changed files with 121 additions and 34 deletions

View file

@ -184,8 +184,9 @@ redefine reporting or inventory schema locally.
That same API-owned performance-report definition also governs transport-side
validation and attachment naming. Lifecycle-adjacent fleet surfaces may depend
on those downloads, but they must treat allowed formats, multi-resource caps,
optional metric/title support, and filename prefixes as API-owned reporting
contract rather than mirroring local constants.
optional metric/title support, default fallback range windows, and filename
prefixes as API-owned reporting contract rather than mirroring local
constants.
That adjacent export contract now also carries canonical Proxmox pool
membership for each VM row. Lifecycle-adjacent install and fleet surfaces may
reuse those current-state facts, but they must still treat the pool column as

View file

@ -234,8 +234,9 @@ emit unsupported metric-filter or custom-title fields from local assumptions.
The same performance-report catalog definition also owns backend transport
validation and download semantics. `internal/api/metrics_reporting_handlers.go`
must derive allowed formats, default format selection, multi-resource limits,
optional metric/title field emission, and attachment filename prefixes from the
canonical reporting definition instead of hardcoding a second local contract.
optional metric/title field emission, default fallback range window, and
attachment filename prefixes from the canonical reporting definition instead of
hardcoding a second local contract.
The `/api/resources` serializer now also refreshes canonical identity and
policy metadata through the shared unified-resource helper before it writes
the payload, so backend and frontend contract tests stay aligned on one

View file

@ -230,8 +230,8 @@ inventory column contract.
That same API-owned performance-report definition also governs transport-side
validation and attachment naming. Storage and recovery flows may consume those
downloads, but they must treat allowed formats, multi-resource caps, optional
metric/title support, and filename prefixes as API/reporting contract rather
than rebuilding local reporting constants.
metric/title support, default fallback range windows, and filename prefixes as
API/reporting contract rather than rebuilding local reporting constants.
That adjacent export contract now also includes canonical Proxmox pool
membership for each VM row. Storage and recovery flows may use those current-
state facts when they need fleet context, but they must consume the API-owned

View file

@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
@ -498,6 +499,35 @@ func TestContract_PerformanceReportTransportUsesCatalogDefinition(t *testing.T)
}
}
func TestContract_PerformanceReportTransportUsesCatalogDefaultRange(t *testing.T) {
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
original := reporting.GetEngine()
reporting.SetEngine(engine)
t.Cleanup(func() { reporting.SetEngine(original) })
handler := NewReportingHandlers(nil, nil)
end := time.Date(2026, 3, 25, 15, 0, 0, 0, time.UTC)
req := httptest.NewRequest(
http.MethodGet,
"/api/reporting?resourceType=node&resourceId=node-1&end="+url.QueryEscape(end.Format(time.RFC3339)),
nil,
)
rec := httptest.NewRecorder()
handler.HandleGenerateReport(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
definition := reporting.DescribePerformanceReport()
if got := engine.lastReq.Start; !got.Equal(end.Add(-definition.DefaultRangeDuration())) {
t.Fatalf("expected canonical default start time, got %s", got)
}
if !engine.lastReq.End.Equal(end) {
t.Fatalf("expected canonical end time, got %s", engine.lastReq.End)
}
}
func TestContract_VMInventoryExportDefinitionJSONSnapshot(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/definition", nil)

View file

@ -75,6 +75,29 @@ func normalizePerformanceReportOptionalFields(
return normalizedMetricType, normalizedTitle
}
func normalizePerformanceReportTimeRange(
definition reporting.PerformanceReportDefinition,
startRaw string,
endRaw string,
now time.Time,
) (time.Time, time.Time) {
end := now
if strings.TrimSpace(endRaw) != "" {
if parsed, err := time.Parse(time.RFC3339, endRaw); err == nil {
end = parsed
}
}
start := end.Add(-definition.DefaultRangeDuration())
if strings.TrimSpace(startRaw) != "" {
if parsed, err := time.Parse(time.RFC3339, startRaw); err == nil {
start = parsed
}
}
return start, end
}
type reportingEnrichmentSnapshot struct {
Nodes []models.Node
VMs []models.VM
@ -326,20 +349,12 @@ func (h *ReportingHandlers) HandleGenerateReport(w http.ResponseWriter, r *http.
q.Get("title"),
)
// Parse range
end := time.Now()
if q.Get("end") != "" {
if t, err := time.Parse(time.RFC3339, q.Get("end")); err == nil {
end = t
}
}
start := end.Add(-24 * time.Hour)
if q.Get("start") != "" {
if t, err := time.Parse(time.RFC3339, q.Get("start")); err == nil {
start = t
}
}
start, end := normalizePerformanceReportTimeRange(
definition,
q.Get("start"),
q.Get("end"),
time.Now(),
)
req := reporting.MetricReportRequest{
ResourceType: resourceType,
@ -671,19 +686,7 @@ func (h *ReportingHandlers) HandleGenerateMultiReport(w http.ResponseWriter, r *
}
metricType, title := normalizePerformanceReportOptionalFields(definition, body.MetricType, body.Title)
// Parse time range
end := time.Now()
if body.End != "" {
if t, err := time.Parse(time.RFC3339, body.End); err == nil {
end = t
}
}
start := end.Add(-24 * time.Hour)
if body.Start != "" {
if t, err := time.Parse(time.RFC3339, body.Start); err == nil {
start = t
}
}
start, end := normalizePerformanceReportTimeRange(definition, body.Start, body.End, time.Now())
// Build multi-report request
multiReq := reporting.MultiReportRequest{

View file

@ -417,6 +417,34 @@ func TestReportingHandlers_GenerateMultiReport_TrimsOptionalFieldsAndUsesCanonic
}
}
func TestReportingHandlers_GenerateReport_UsesCatalogDefaultRangeDuration(t *testing.T) {
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
original := reporting.GetEngine()
reporting.SetEngine(engine)
t.Cleanup(func() { reporting.SetEngine(original) })
handler := NewReportingHandlers(nil, nil)
end := time.Date(2026, 3, 25, 15, 0, 0, 0, time.UTC)
req := httptest.NewRequest(
http.MethodGet,
"/api/reporting?format=pdf&resourceType=node&resourceId=node-1&end="+url.QueryEscape(end.Format(time.RFC3339)),
nil,
)
rr := httptest.NewRecorder()
handler.HandleGenerateReport(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, rr.Code, rr.Body.String())
}
if got := engine.lastReq.Start; !got.Equal(end.Add(-reporting.DescribePerformanceReport().DefaultRangeDuration())) {
t.Fatalf("expected canonical default start, got %s", got)
}
if !engine.lastReq.End.Equal(end) {
t.Fatalf("expected canonical end time, got %s", engine.lastReq.End)
}
}
func TestReportingHandlers_ExportVMInventory_EmptySnapshotStillReturnsCSVHeader(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export", nil)

View file

@ -1,5 +1,7 @@
package reporting
import "time"
// ReportingFormatDefinition describes one supported output format for an
// operator-facing reporting surface.
type ReportingFormatDefinition struct {
@ -56,6 +58,17 @@ func (d PerformanceReportDefinition) SupportsFormat(format ReportFormat) bool {
return false
}
// DefaultRangeDuration returns the canonical fallback time window for the
// performance reporting surface.
func (d PerformanceReportDefinition) DefaultRangeDuration() time.Duration {
for _, candidate := range d.Ranges {
if candidate.Key == d.DefaultRange {
return time.Duration(candidate.WindowHours) * time.Hour
}
}
return 24 * time.Hour
}
// DescribePerformanceReport returns the canonical definition for Pulse's
// historical performance reporting surface.
func DescribePerformanceReport() PerformanceReportDefinition {

View file

@ -1,6 +1,9 @@
package reporting
import "testing"
import (
"testing"
"time"
)
func TestDescribeReportingCatalog_DefinesCanonicalSurfaces(t *testing.T) {
catalog := DescribeReportingCatalog()
@ -35,3 +38,11 @@ func TestPerformanceReportDefinition_SupportsFormat(t *testing.T) {
t.Fatalf("did not expect xlsx to be supported")
}
}
func TestPerformanceReportDefinition_DefaultRangeDuration(t *testing.T) {
definition := DescribePerformanceReport()
if got := definition.DefaultRangeDuration(); got != 24*time.Hour {
t.Fatalf("default range duration = %s, want %s", got, 24*time.Hour)
}
}