Files
josh 8acef92a60
CI / Lint + build + test (push) Successful in 1m35s
Release / release (push) Successful in 9m34s
feat(inventory): deep hardware capture + per-probe substeps + verbose logs
Extend Inventory stage from a one-liner summary to a per-probe substep
emitter with ~20-30 narrative log lines per run.

- spec: per-DIMM memory (slot/size/speed/manufacturer/part_number),
  richer CPU (vendor/stepping/physical_cores/flags), disk
  model/transport/rotational, NIC driver/pci_addr, GPU vram/pci/driver,
  new System/Baseboard/PSU/OS top-level sections. All fields omitempty
  so existing expected-spec YAML and artifacts stay compatible.
- spec.Diff: new diffDIMMs/diffSystem/diffBaseboard/diffPSU/diffOS
  helpers; extended diffDisks/diffNICs/diffGPUs for new fields. GPU
  diff gains PCIAddr-pinned matching alongside count-by-model.
- agent/probes/inventory: CPU (/proc/cpuinfo extended), Memory
  (dmidecode -t 17 multi-block), Disks (+model/transport/rotational),
  NICs (+driver/pci from sysfs), GPUs (VRAM from lspci -vv),
  new System/Baseboard (dmidecode -t system/baseboard), PSU
  (dmidecode -t 39), OS (/proc/sys/kernel/osrelease + /etc/os-release).
  All probes accept a Logger and emit per-finding info/warn lines.
- agent/probes/firmware: parseDmidecodeAllSections for multi-block
  fixtures (memory / PSU).
- agent/runner: Inventory case becomes 9 substep rows (CPU / Memory /
  Disks / NICs / GPUs / System / Baseboard / PSU / OS) with per-probe
  start/complete timestamps.
- report: new Inventory HTML section between Stages and Firmware;
  resolveReporting loads the inventory.json artifact.
- agent/tests/fakes/dmidecode: dispatches on -t flag to serve bios /
  memory / system / baseboard / 39 fixtures for unit tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:21:17 -04:00

419 lines
12 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"
"vetting/internal/spec"
)
// 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
Inventory *spec.Inventory // captured inventory, nil if artifact missing
}
// 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>
{{if .Inventory}}
<section>
<h2>Inventory</h2>
{{with .Inventory.System}}{{if or .Manufacturer .ProductName .SerialNumber .UUID}}
<h3>System</h3>
<table>
{{if .Manufacturer}}<tr><th>Manufacturer</th><td>{{.Manufacturer}}</td></tr>{{end}}
{{if .ProductName}}<tr><th>Product</th><td>{{.ProductName}}</td></tr>{{end}}
{{if .SerialNumber}}<tr><th>Serial</th><td><code>{{.SerialNumber}}</code></td></tr>{{end}}
{{if .UUID}}<tr><th>UUID</th><td><code>{{.UUID}}</code></td></tr>{{end}}
</table>
{{end}}{{end}}
{{with .Inventory.Baseboard}}{{if or .Manufacturer .ProductName .SerialNumber}}
<h3>Baseboard</h3>
<table>
{{if .Manufacturer}}<tr><th>Manufacturer</th><td>{{.Manufacturer}}</td></tr>{{end}}
{{if .ProductName}}<tr><th>Product</th><td>{{.ProductName}}</td></tr>{{end}}
{{if .SerialNumber}}<tr><th>Serial</th><td><code>{{.SerialNumber}}</code></td></tr>{{end}}
</table>
{{end}}{{end}}
{{with .Inventory.CPU}}{{if .Model}}
<h3>CPU</h3>
<table>
<tr><th>Model</th><td>{{.Model}}</td></tr>
{{if .Vendor}}<tr><th>Vendor</th><td>{{.Vendor}}</td></tr>{{end}}
{{if .PhysicalCores}}<tr><th>Physical cores</th><td>{{.PhysicalCores}}</td></tr>{{end}}
{{if .LogicalCores}}<tr><th>Logical cores</th><td>{{.LogicalCores}}</td></tr>{{end}}
{{if .Stepping}}<tr><th>Stepping</th><td>{{.Stepping}}</td></tr>{{end}}
{{if .Flags}}<tr><th>Flags</th><td>{{range $i, $f := .Flags}}{{if $i}}, {{end}}<code>{{$f}}</code>{{end}}</td></tr>{{end}}
</table>
{{end}}{{end}}
{{with .Inventory.Memory}}{{if or .TotalGiB .Modules}}
<h3>Memory</h3>
<table>
<tr><th>Total</th><td>{{.TotalGiB}} GiB</td></tr>
</table>
{{if .Modules}}
<table>
<thead><tr><th>Slot</th><th>Size</th><th>Speed</th><th>Manufacturer</th><th>Part number</th><th>Populated</th></tr></thead>
<tbody>
{{range .Modules}}
<tr>
<td><code>{{.Slot}}</code></td>
<td>{{if .SizeGB}}{{.SizeGB}} GiB{{else}}{{end}}</td>
<td>{{if .SpeedMTS}}{{.SpeedMTS}} MT/s{{else}}{{end}}</td>
<td>{{.Manufacturer}}</td>
<td><code>{{.PartNumber}}</code></td>
<td>{{if .Populated}}<span class="pass">yes</span>{{else}}<span class="skip">no</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{end}}{{end}}
{{if .Inventory.Disks}}
<h3>Disks</h3>
<table>
<thead><tr><th>Serial</th><th>Model</th><th>Size</th><th>Transport</th><th>Rotational</th></tr></thead>
<tbody>
{{range .Inventory.Disks}}
<tr>
<td><code>{{.Serial}}</code></td>
<td>{{.Model}}</td>
<td>{{.SizeGB}} GB</td>
<td>{{.Transport}}</td>
<td>{{if .Rotational}}yes{{else}}no{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .Inventory.NICs}}
<h3>NICs</h3>
<table>
<thead><tr><th>MAC</th><th>Speed</th><th>Driver</th><th>PCI</th></tr></thead>
<tbody>
{{range .Inventory.NICs}}
<tr>
<td><code>{{.MAC}}</code></td>
<td>{{if .SpeedGbps}}{{.SpeedGbps}} Gbps{{else}}{{end}}</td>
<td>{{.Driver}}</td>
<td><code>{{.PCIAddr}}</code></td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .Inventory.GPUs}}
<h3>GPUs</h3>
<table>
<thead><tr><th>Model</th><th>VRAM</th><th>PCI</th><th>Driver</th></tr></thead>
<tbody>
{{range .Inventory.GPUs}}
<tr>
<td>{{.Model}}</td>
<td>{{if .VRAMGiB}}{{.VRAMGiB}} GiB{{else}}{{end}}</td>
<td><code>{{.PCIAddr}}</code></td>
<td>{{.Driver}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .Inventory.PSU}}
<h3>Power supplies</h3>
<table>
<thead><tr><th>Slot</th><th>Manufacturer</th><th>Model</th><th>Max watts</th><th>Status</th></tr></thead>
<tbody>
{{range .Inventory.PSU}}
<tr>
<td>{{.Slot}}</td>
<td>{{.Manufacturer}}</td>
<td>{{.Model}}</td>
<td>{{if .MaxWatts}}{{.MaxWatts}} W{{else}}{{end}}</td>
<td>{{.Status}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{with .Inventory.OS}}{{if or .Kernel .Distribution .Version}}
<h3>OS</h3>
<table>
{{if .Kernel}}<tr><th>Kernel</th><td><code>{{.Kernel}}</code></td></tr>{{end}}
{{if .Distribution}}<tr><th>Distribution</th><td>{{.Distribution}}</td></tr>{{end}}
{{if .Version}}<tr><th>Version</th><td>{{.Version}}</td></tr>{{end}}
</table>
{{end}}{{end}}
</section>
{{end}}
<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>
`