// 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
| Name | {{.Host.Name}} |
| MAC | {{.Host.MAC}} |
| WoL | {{.Host.WoLBroadcastIP}}:{{.Host.WoLPort}} |
{{if .Host.Notes}}| Notes | {{.Host.Notes}} |
{{end}}
Run
| Run ID | {{.Run.ID}} |
| State | {{.Run.State}} |
| Started | {{fmtTime .Run.StartedAt}} |
| Completed | {{fmtTimep .Run.CompletedAt}} |
{{if .Run.FailedStage}}| Failed stage | {{.Run.FailedStage}} |
{{end}}
{{if .Run.ReportPath}}| JSON report | {{.Run.ReportPath}} |
{{end}}
Stages
| Stage | State | Started | Completed |
{{range .Stages}}
| {{.Name}} |
{{.State}} |
{{fmtTimep .StartedAt}} |
{{fmtTimep .CompletedAt}} |
{{end}}
{{if .Inventory}}
Inventory
{{with .Inventory.System}}{{if or .Manufacturer .ProductName .SerialNumber .UUID}}
System
{{if .Manufacturer}}| Manufacturer | {{.Manufacturer}} |
{{end}}
{{if .ProductName}}| Product | {{.ProductName}} |
{{end}}
{{if .SerialNumber}}| Serial | {{.SerialNumber}} |
{{end}}
{{if .UUID}}| UUID | {{.UUID}} |
{{end}}
{{end}}{{end}}
{{with .Inventory.Baseboard}}{{if or .Manufacturer .ProductName .SerialNumber}}
Baseboard
{{if .Manufacturer}}| Manufacturer | {{.Manufacturer}} |
{{end}}
{{if .ProductName}}| Product | {{.ProductName}} |
{{end}}
{{if .SerialNumber}}| Serial | {{.SerialNumber}} |
{{end}}
{{end}}{{end}}
{{with .Inventory.CPU}}{{if .Model}}
CPU
| Model | {{.Model}} |
{{if .Vendor}}| Vendor | {{.Vendor}} |
{{end}}
{{if .PhysicalCores}}| Physical cores | {{.PhysicalCores}} |
{{end}}
{{if .LogicalCores}}| Logical cores | {{.LogicalCores}} |
{{end}}
{{if .Stepping}}| Stepping | {{.Stepping}} |
{{end}}
{{if .Flags}}| Flags | {{range $i, $f := .Flags}}{{if $i}}, {{end}}{{$f}}{{end}} |
{{end}}
{{end}}{{end}}
{{with .Inventory.Memory}}{{if or .TotalGiB .Modules}}
Memory
{{if .Modules}}
| Slot | Size | Speed | Manufacturer | Part number | Populated |
{{range .Modules}}
{{.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}}{{end}}
{{if .Inventory.Disks}}
Disks
| Serial | Model | Size | Transport | Rotational |
{{range .Inventory.Disks}}
{{.Serial}} |
{{.Model}} |
{{.SizeGB}} GB |
{{.Transport}} |
{{if .Rotational}}yes{{else}}no{{end}} |
{{end}}
{{end}}
{{if .Inventory.NICs}}
NICs
| MAC | Speed | Driver | PCI |
{{range .Inventory.NICs}}
{{.MAC}} |
{{if .SpeedGbps}}{{.SpeedGbps}} Gbps{{else}}—{{end}} |
{{.Driver}} |
{{.PCIAddr}} |
{{end}}
{{end}}
{{if .Inventory.GPUs}}
GPUs
| Model | VRAM | PCI | Driver |
{{range .Inventory.GPUs}}
| {{.Model}} |
{{if .VRAMGiB}}{{.VRAMGiB}} GiB{{else}}—{{end}} |
{{.PCIAddr}} |
{{.Driver}} |
{{end}}
{{end}}
{{if .Inventory.PSU}}
Power supplies
| Slot | Manufacturer | Model | Max watts | Status |
{{range .Inventory.PSU}}
| {{.Slot}} |
{{.Manufacturer}} |
{{.Model}} |
{{if .MaxWatts}}{{.MaxWatts}} W{{else}}—{{end}} |
{{.Status}} |
{{end}}
{{end}}
{{with .Inventory.OS}}{{if or .Kernel .Distribution .Version}}
OS
{{if .Kernel}}| Kernel | {{.Kernel}} |
{{end}}
{{if .Distribution}}| Distribution | {{.Distribution}} |
{{end}}
{{if .Version}}| Version | {{.Version}} |
{{end}}
{{end}}{{end}}
{{end}}
Firmware ({{len .Firmware}})
{{if .Firmware}}
| Component | Identifier | Version | Vendor |
{{range .Firmware}}
| {{.Component}} |
{{.Identifier}} |
{{.Version}} |
{{.Vendor}} |
{{end}}
{{else}}
No firmware snapshots captured.
{{end}}
Spec diffs ({{len .SpecDiffs}})
{{if .SpecDiffs}}
| Field | Expected | Actual | Severity |
{{range .SpecDiffs}}
{{.Field}} |
{{.Expected}} |
{{.Actual}} |
{{.Severity}} |
{{end}}
{{else}}
No differences between expected and actual hardware.
{{end}}
Measurements ({{len .Aggregates}} series)
{{if .Aggregates}}
| Kind | Key | Samples | Min | Avg | Max | Unit |
{{range .Aggregates}}
| {{.Kind}} |
{{.Key}} |
{{.Count}} |
{{fmt4 .Min}} |
{{fmt4 .Avg}} |
{{fmt4 .Max}} |
{{.Unit}} |
{{end}}
{{else}}
No measurements recorded.
{{end}}
`