Files
Vetting/internal/report/report.go
T
josh 23c689aa5b
CI / Lint + build + test (push) Failing after 1m57s
Release / release (push) Has been cancelled
deep profile + threshold gating + firmware stage + Burn super-stage
Ships all five phases of the deep-profile overhaul together. Runs now
carry a profile (quick/deep/soak); every profile walks the same
11-stage order — Inventory → Firmware → SpecValidate → SMART →
CPUStress → Storage → Network → Burn → GPU → PSU → Reporting —
with only per-stage durations and concurrency scaled.

Phase 1: profiles.ProfileRegistry loaded from vetting.yaml; runs.profile
column + CreateWithProfile; threshold table + evaluator seeded per-run
from the shared vetting.thresholds block; breach flips result at
/sensor + /result.

Phase 2: upgraded CPUStress (stress-ng --cpu-method=all --verify +
EDAC/MCE poll), Storage (fio --verify=md5 + SMART start/end delta),
Network (sustained iperf + /proc/net/dev deltas) with per-profile
knobs from Deps.

Phase 3: Burn super-stage with goroutine fan-out for CPU + memory +
fio + iperf, PSU rails sampled across the Burn window, SensorMux
(2 s flush, 500-sample cap) to absorb backpressure.

Phase 4: Firmware stage + firmware_snapshots table; probes dmidecode
(BIOS), ipmitool (BMC), ethtool -i (NIC), nvme (sysfs + id-ctrl),
lspci (HBA), /proc/cpuinfo (microcode). spec.DiffFirmware folds into
SpecValidate with pin-by-identifier and fan-out-across-component
matching; mismatches park the run in FailedHolding.

Phase 5: profile radio on the host start form, profile chip on the
run header, Firmware section in the HTML report, coverage artifact
uploaded from CI, agent/tests/fakes/ scaffold with Deps.LookPath
seam + stress_ng and dmidecode example fakes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 22:50:57 -04:00

277 lines
7.5 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
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 = `<!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>Firmware ({{len .Firmware}})</h2>
{{if .Firmware}}
<table>
<thead><tr><th>Component</th><th>Identifier</th><th>Version</th><th>Vendor</th></tr></thead>
<tbody>
{{range .Firmware}}
<tr>
<td>{{.Component}}</td>
<td><code>{{.Identifier}}</code></td>
<td><code>{{.Version}}</code></td>
<td>{{.Vendor}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No firmware snapshots captured.</p>
{{end}}
</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>
`