9bb4b09a04
CI / Lint + build + test (push) Has been cancelled
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.
246 lines
6.7 KiB
Go
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>
|
|
`
|