23c689aa5b
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>
277 lines
7.5 KiB
Go
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>
|
|
`
|