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.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
// 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>
|
||||
`
|
||||
Reference in New Issue
Block a user