// Package report builds the per-run HTML summary artifact. JSON is // written separately (by the reporting resolver in the api package); // this package only deals with the human-facing HTML. // // Design: a single self-contained HTML file — inline CSS, no external // fetches — so the artifact is portable and can be opened straight off // disk. Contents are a summary (per answer to the phase-5 design // question): run metadata, per-stage pass/fail table, spec diff list, // and measurement aggregates (min/avg/max by kind+key). package report import ( "bytes" "fmt" "html/template" "math" "sort" "time" "vetting/internal/model" "vetting/internal/spec" ) // Data is the payload fed to the HTML template. Callers assemble it // from the DB rows for a given run. type Data struct { GeneratedAt time.Time Run model.Run Host model.Host Stages []model.Stage SpecDiffs []model.SpecDiff Aggregates []Aggregate // flattened measurement summary; see Aggregate Firmware []FirmwareSnapshot // captured firmware versions, empty if none Inventory *spec.Inventory // captured inventory, nil if artifact missing } // FirmwareSnapshot is the report-facing view of one firmware row. // Package-local so the HTML template stays decoupled from store types. type FirmwareSnapshot struct { Component string Identifier string Version string Vendor string } // Aggregate is a per (kind, key) summary of a run's measurements. Min/ // Max/Avg are populated from the Measurement rows; Unit mirrors the raw // sample unit so the HTML can show "52.5 °C" etc. type Aggregate struct { Kind string Key string Unit string Count int Min float64 Max float64 Avg float64 } // AggregateMeasurements collapses a flat []Measurement into per-(kind, // key) summaries, sorted first by kind then by key so the HTML renders // deterministically. func AggregateMeasurements(rows []model.Measurement) []Aggregate { type bucket struct { unit string count int min, max float64 sum float64 } buckets := map[string]*bucket{} keyOf := func(m model.Measurement) string { return m.Kind + "\x00" + m.Key } for _, m := range rows { k := keyOf(m) b, ok := buckets[k] if !ok { b = &bucket{unit: m.Unit, min: math.Inf(1), max: math.Inf(-1)} buckets[k] = b } b.count++ b.sum += m.Value if m.Value < b.min { b.min = m.Value } if m.Value > b.max { b.max = m.Value } } out := make([]Aggregate, 0, len(buckets)) for _, m := range rows { k := keyOf(m) b, ok := buckets[k] if !ok { continue } // Emit once per bucket; delete to dedupe. delete(buckets, k) out = append(out, Aggregate{ Kind: m.Kind, Key: m.Key, Unit: b.unit, Count: b.count, Min: b.min, Max: b.max, Avg: b.sum / float64(b.count), }) } sort.Slice(out, func(i, j int) bool { if out[i].Kind != out[j].Kind { return out[i].Kind < out[j].Kind } return out[i].Key < out[j].Key }) return out } // RenderHTML produces the self-contained report HTML. func RenderHTML(d Data) ([]byte, error) { var buf bytes.Buffer if err := reportTmpl.Execute(&buf, d); err != nil { return nil, fmt.Errorf("report: render: %w", err) } return buf.Bytes(), nil } var reportTmpl = template.Must(template.New("report").Funcs(template.FuncMap{ "fmt4": func(f float64) string { return fmt.Sprintf("%.4g", f) }, "fmtTime": func(t time.Time) string { return t.UTC().Format(time.RFC3339) }, "fmtTimep": func(t *time.Time) string { if t == nil { return "—" }; return t.UTC().Format(time.RFC3339) }, "resultBadge": func(s model.StageState) string { switch s { case model.StagePassed: return "pass" case model.StageFailed: return "fail" case model.StageSkipped: return "skip" default: return "pend" } }, }).Parse(htmlTemplate)) // Single-string template kept next to the code so the package stays // self-contained. CSS is inlined; no external assets. const htmlTemplate = ` Vetting report — {{.Host.Name}} run {{.Run.ID}}

{{.Host.Name}} — run {{.Run.ID}}

State: {{.Run.State}}{{if ne .Run.Result ""}} · result: {{.Run.Result}}{{end}} · generated {{fmtTime .GeneratedAt}}

Host

{{if .Host.Notes}}{{end}}
Name{{.Host.Name}}
MAC{{.Host.MAC}}
WoL{{.Host.WoLBroadcastIP}}:{{.Host.WoLPort}}
Notes{{.Host.Notes}}

Run

{{if .Run.FailedStage}}{{end}} {{if .Run.ReportPath}}{{end}}
Run ID{{.Run.ID}}
State{{.Run.State}}
Started{{fmtTime .Run.StartedAt}}
Completed{{fmtTimep .Run.CompletedAt}}
Failed stage{{.Run.FailedStage}}
JSON report{{.Run.ReportPath}}

Stages

{{range .Stages}} {{end}}
StageStateStartedCompleted
{{.Name}} {{.State}} {{fmtTimep .StartedAt}} {{fmtTimep .CompletedAt}}
{{if .Inventory}}

Inventory

{{with .Inventory.System}}{{if or .Manufacturer .ProductName .SerialNumber .UUID}}

System

{{if .Manufacturer}}{{end}} {{if .ProductName}}{{end}} {{if .SerialNumber}}{{end}} {{if .UUID}}{{end}}
Manufacturer{{.Manufacturer}}
Product{{.ProductName}}
Serial{{.SerialNumber}}
UUID{{.UUID}}
{{end}}{{end}} {{with .Inventory.Baseboard}}{{if or .Manufacturer .ProductName .SerialNumber}}

Baseboard

{{if .Manufacturer}}{{end}} {{if .ProductName}}{{end}} {{if .SerialNumber}}{{end}}
Manufacturer{{.Manufacturer}}
Product{{.ProductName}}
Serial{{.SerialNumber}}
{{end}}{{end}} {{with .Inventory.CPU}}{{if .Model}}

CPU

{{if .Vendor}}{{end}} {{if .PhysicalCores}}{{end}} {{if .LogicalCores}}{{end}} {{if .Stepping}}{{end}} {{if .Flags}}{{end}}
Model{{.Model}}
Vendor{{.Vendor}}
Physical cores{{.PhysicalCores}}
Logical cores{{.LogicalCores}}
Stepping{{.Stepping}}
Flags{{range $i, $f := .Flags}}{{if $i}}, {{end}}{{$f}}{{end}}
{{end}}{{end}} {{with .Inventory.Memory}}{{if or .TotalGiB .Modules}}

Memory

Total{{.TotalGiB}} GiB
{{if .Modules}} {{range .Modules}} {{end}}
SlotSizeSpeedManufacturerPart numberPopulated
{{.Slot}} {{if .SizeGB}}{{.SizeGB}} GiB{{else}}—{{end}} {{if .SpeedMTS}}{{.SpeedMTS}} MT/s{{else}}—{{end}} {{.Manufacturer}} {{.PartNumber}} {{if .Populated}}yes{{else}}no{{end}}
{{end}} {{end}}{{end}} {{if .Inventory.Disks}}

Disks

{{range .Inventory.Disks}} {{end}}
SerialModelSizeTransportRotational
{{.Serial}} {{.Model}} {{.SizeGB}} GB {{.Transport}} {{if .Rotational}}yes{{else}}no{{end}}
{{end}} {{if .Inventory.NICs}}

NICs

{{range .Inventory.NICs}} {{end}}
MACSpeedDriverPCI
{{.MAC}} {{if .SpeedGbps}}{{.SpeedGbps}} Gbps{{else}}—{{end}} {{.Driver}} {{.PCIAddr}}
{{end}} {{if .Inventory.GPUs}}

GPUs

{{range .Inventory.GPUs}} {{end}}
ModelVRAMPCIDriver
{{.Model}} {{if .VRAMGiB}}{{.VRAMGiB}} GiB{{else}}—{{end}} {{.PCIAddr}} {{.Driver}}
{{end}} {{if .Inventory.PSU}}

Power supplies

{{range .Inventory.PSU}} {{end}}
SlotManufacturerModelMax wattsStatus
{{.Slot}} {{.Manufacturer}} {{.Model}} {{if .MaxWatts}}{{.MaxWatts}} W{{else}}—{{end}} {{.Status}}
{{end}} {{with .Inventory.OS}}{{if or .Kernel .Distribution .Version}}

OS

{{if .Kernel}}{{end}} {{if .Distribution}}{{end}} {{if .Version}}{{end}}
Kernel{{.Kernel}}
Distribution{{.Distribution}}
Version{{.Version}}
{{end}}{{end}}
{{end}}

Firmware ({{len .Firmware}})

{{if .Firmware}} {{range .Firmware}} {{end}}
ComponentIdentifierVersionVendor
{{.Component}} {{.Identifier}} {{.Version}} {{.Vendor}}
{{else}}

No firmware snapshots captured.

{{end}}

Spec diffs ({{len .SpecDiffs}})

{{if .SpecDiffs}} {{range .SpecDiffs}} {{end}}
FieldExpectedActualSeverity
{{.Field}} {{.Expected}} {{.Actual}} {{.Severity}}
{{else}}

No differences between expected and actual hardware.

{{end}}

Measurements ({{len .Aggregates}} series)

{{if .Aggregates}} {{range .Aggregates}} {{end}}
KindKeySamplesMinAvgMaxUnit
{{.Kind}} {{.Key}} {{.Count}} {{fmt4 .Min}} {{fmt4 .Avg}} {{fmt4 .Max}} {{.Unit}}
{{else}}

No measurements recorded.

{{end}}
`