// 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" ) // 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 } // 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}}

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}}
`