Files
Vetting/internal/report/report.go
T
josh 9bb4b09a04
CI / Lint + build + test (push) Has been cancelled
Initial commit: full Phases 1-6 implementation
Post-repair hardware validation pipeline for Proxmox cluster hosts.
Go orchestrator + in-image agent + mkosi live image + bundled dnsmasq
PXE + SQLite + HTMX/SSE UI + notify registry + janitor + full docs.
2026-04-17 21:32:10 -04:00

246 lines
6.7 KiB
Go

// 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
}
// 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 = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Vetting report — {{.Host.Name}} run {{.Run.ID}}</title>
<style>
:root { color-scheme: light dark; }
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 2rem; max-width: 960px; }
h1 { margin-bottom: 0; }
.sub { color: #666; margin-top: .2rem; }
section { margin-top: 2rem; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: .35rem .6rem; border-bottom: 1px solid #ccc3; vertical-align: top; }
th { background: #0001; }
.pass { color: #0a0; font-weight: 600; }
.fail { color: #c33; font-weight: 600; }
.skip { color: #888; }
.pend { color: #888; }
.critical { color: #c33; font-weight: 600; }
.warning { color: #c80; }
.info { color: #666; }
code { background: #0001; padding: .05rem .25rem; border-radius: 3px; }
</style>
</head>
<body>
<h1>{{.Host.Name}} — run {{.Run.ID}}</h1>
<div class="sub">State: <b>{{.Run.State}}</b>{{if ne .Run.Result ""}} · result: <b>{{.Run.Result}}</b>{{end}} · generated {{fmtTime .GeneratedAt}}</div>
<section>
<h2>Host</h2>
<table>
<tr><th>Name</th><td>{{.Host.Name}}</td></tr>
<tr><th>MAC</th><td><code>{{.Host.MAC}}</code></td></tr>
<tr><th>WoL</th><td>{{.Host.WoLBroadcastIP}}:{{.Host.WoLPort}}</td></tr>
{{if .Host.Notes}}<tr><th>Notes</th><td>{{.Host.Notes}}</td></tr>{{end}}
</table>
</section>
<section>
<h2>Run</h2>
<table>
<tr><th>Run ID</th><td>{{.Run.ID}}</td></tr>
<tr><th>State</th><td>{{.Run.State}}</td></tr>
<tr><th>Started</th><td>{{fmtTime .Run.StartedAt}}</td></tr>
<tr><th>Completed</th><td>{{fmtTimep .Run.CompletedAt}}</td></tr>
{{if .Run.FailedStage}}<tr><th>Failed stage</th><td class="fail">{{.Run.FailedStage}}</td></tr>{{end}}
{{if .Run.ReportPath}}<tr><th>JSON report</th><td><code>{{.Run.ReportPath}}</code></td></tr>{{end}}
</table>
</section>
<section>
<h2>Stages</h2>
<table>
<thead><tr><th>Stage</th><th>State</th><th>Started</th><th>Completed</th></tr></thead>
<tbody>
{{range .Stages}}
<tr>
<td>{{.Name}}</td>
<td class="{{resultBadge .State}}">{{.State}}</td>
<td>{{fmtTimep .StartedAt}}</td>
<td>{{fmtTimep .CompletedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
<section>
<h2>Spec diffs ({{len .SpecDiffs}})</h2>
{{if .SpecDiffs}}
<table>
<thead><tr><th>Field</th><th>Expected</th><th>Actual</th><th>Severity</th></tr></thead>
<tbody>
{{range .SpecDiffs}}
<tr>
<td><code>{{.Field}}</code></td>
<td>{{.Expected}}</td>
<td>{{.Actual}}</td>
<td class="{{.Severity}}">{{.Severity}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No differences between expected and actual hardware.</p>
{{end}}
</section>
<section>
<h2>Measurements ({{len .Aggregates}} series)</h2>
{{if .Aggregates}}
<table>
<thead><tr><th>Kind</th><th>Key</th><th>Samples</th><th>Min</th><th>Avg</th><th>Max</th><th>Unit</th></tr></thead>
<tbody>
{{range .Aggregates}}
<tr>
<td>{{.Kind}}</td>
<td>{{.Key}}</td>
<td>{{.Count}}</td>
<td>{{fmt4 .Min}}</td>
<td>{{fmt4 .Avg}}</td>
<td>{{fmt4 .Max}}</td>
<td>{{.Unit}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No measurements recorded.</p>
{{end}}
</section>
</body>
</html>
`