deep profile + threshold gating + firmware stage + Burn super-stage
CI / Lint + build + test (push) Failing after 1m57s
Release / release (push) Has been cancelled

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>
This commit is contained in:
2026-04-18 22:50:57 -04:00
parent fbb21cbafd
commit 23c689aa5b
60 changed files with 5911 additions and 527 deletions
+259 -3
View File
@@ -19,6 +19,7 @@ import (
"github.com/go-chi/chi/v5"
"vetting/internal/config"
"vetting/internal/events"
"vetting/internal/hold"
"vetting/internal/logs"
@@ -41,6 +42,9 @@ type Agent struct {
Artifacts *store.Artifacts
SpecDiffs *store.SpecDiffs
Measurements *store.Measurements
Thresholds *store.Thresholds // Phase 1: seeded per run; consulted on each /sensor batch
Firmware *store.Firmware // Phase 4: firmware snapshots (unused before then)
Profiles *config.ProfileRegistry // Phase 2: /claim resolves the run's profile → stage knobs
Runner *orchestrator.Runner
EventHub *events.Hub
Logs *logs.Hub
@@ -216,6 +220,21 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
if iperfPort == 0 {
iperfPort = 5201
}
// Resolve the run's profile → agent-visible stage knobs. The agent
// reads these to size CPUStress / Storage / Network work. An empty
// profile (legacy runs seeded before Phase 1) falls back to "quick".
profileName := run.Profile
if profileName == "" {
profileName = config.ProfileQuick
}
var stageCfg config.StageConfig
if a.Profiles != nil {
stageCfg = a.Profiles.ResolveStageConfig(profileName)
} else {
stageCfg = config.StageConfig{Profile: profileName}
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"run_id": runID,
@@ -224,6 +243,7 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
"iperf_port": iperfPort,
"non_destructive": run.NonDestructive,
"current_state": string(currentState),
"stage_config": stageCfg,
})
}
@@ -398,10 +418,24 @@ type StageResult struct {
Passed bool `json:"passed"`
Summary json.RawMessage `json:"summary,omitempty"`
Inventory *spec.Inventory `json:"inventory,omitempty"`
Firmware []FirmwareLine `json:"firmware,omitempty"`
Message string `json:"message,omitempty"`
SubSteps []SubStepResultLine `json:"sub_steps,omitempty"`
}
// FirmwareLine is a single firmware snapshot POSTed alongside the
// Firmware stage's /result body. Mirrors agent/probes.FirmwareSnapshot.
// The server converts each line to a store.FirmwareSnapshot and persists
// it under the run — SpecValidate reads these back to diff against the
// host's expected_firmware.
type FirmwareLine struct {
Component string `json:"component"`
Identifier string `json:"identifier"`
Version string `json:"version"`
Vendor string `json:"vendor,omitempty"`
Raw map[string]string `json:"raw,omitempty"`
}
// SubStepResultLine is one entry in StageResult.SubSteps. Ordinal is
// assigned from slice index server-side; the agent doesn't set it.
type SubStepResultLine struct {
@@ -476,6 +510,20 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
return
}
// Aggregate threshold gate: flip Passed=false server-side when any
// critical breach landed for this stage. The agent's verdict is
// advisory — a stage-executor can miss a runaway sample that the
// sidecar caught. We check this *before* writing the stage state
// so the DB reflects the server-side decision.
thresholdDetail := ""
if body.Passed {
if breached, detail := a.stageHadCriticalBreach(r.Context(), runID, body.Stage); breached {
body.Passed = false
thresholdDetail = detail
a.appendLog(runID, "error", fmt.Sprintf("%s reported passed but %s — flipping to failed", body.Stage, detail))
}
}
stageState := model.StagePassed
if !body.Passed {
stageState = model.StageFailed
@@ -488,6 +536,9 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
http.Error(w, "complete stage: "+err.Error(), http.StatusInternalServerError)
return
}
if thresholdDetail != "" && body.Message == "" {
body.Message = thresholdDetail
}
// Agent-authored sub-steps: persist in slice order (ordinal = index)
// and fan out a per-row SSE event each so the detail pane shows them
@@ -502,6 +553,14 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
}
}
// Firmware-specific: persist each snapshot into firmware_snapshots.
// SpecValidate reads them back to diff against expected_firmware.
if body.Stage == "Firmware" && len(body.Firmware) > 0 {
if err := a.persistFirmware(r.Context(), runID, body.Firmware); err != nil {
log.Printf("persist firmware run %d: %v", runID, err)
}
}
if !body.Passed {
if err := a.Runs.SetFailedStage(r.Context(), runID, body.Stage); err != nil {
log.Printf("set failed stage: %v", err)
@@ -615,6 +674,34 @@ func parseResultTime(s string) *time.Time {
return nil
}
// persistFirmware writes the reported snapshots. A nil/unset a.Firmware
// store is a no-op so tests that don't wire it up stay green; a mid-run
// persist error is logged but doesn't fail the stage (Firmware is
// advisory — SpecValidate is the gate).
func (a *Agent) persistFirmware(ctx context.Context, runID int64, lines []FirmwareLine) error {
if a.Firmware == nil || len(lines) == 0 {
return nil
}
rows := make([]store.FirmwareSnapshot, 0, len(lines))
for _, l := range lines {
raw := "{}"
if len(l.Raw) > 0 {
if b, err := json.Marshal(l.Raw); err == nil {
raw = string(b)
}
}
rows = append(rows, store.FirmwareSnapshot{
RunID: runID,
Component: l.Component,
Identifier: l.Identifier,
Version: l.Version,
Vendor: l.Vendor,
RawJSON: raw,
})
}
return a.Firmware.CreateBatch(ctx, rows)
}
func (a *Agent) persistInventory(r *http.Request, run *model.Run, inv *spec.Inventory) error {
dir := filepath.Join(a.ArtifactsDir, fmt.Sprintf("run-%d", run.ID))
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -667,6 +754,22 @@ func (a *Agent) resolveSpecValidate(r *http.Request, runID int64) {
return
}
diffs := spec.Diff(expected, inv)
if a.Firmware != nil && len(expected.Firmware) > 0 {
snaps, err := a.Firmware.ListForRun(r.Context(), runID)
if err != nil {
log.Printf("specvalidate: list firmware: %v", err)
} else {
observed := make([]spec.FirmwareObserved, 0, len(snaps))
for _, s := range snaps {
observed = append(observed, spec.FirmwareObserved{
Component: s.Component,
Identifier: s.Identifier,
Version: s.Version,
})
}
diffs = append(diffs, spec.DiffFirmware(expected.Firmware, observed)...)
}
}
if err := a.SpecDiffs.ReplaceForRun(r.Context(), runID, diffs); err != nil {
log.Printf("specvalidate: write diffs: %v", err)
}
@@ -884,13 +987,17 @@ type SensorSample struct {
}
// Sensor persists a batch of numeric samples. The thermal sidecar hits
// this on a tick; stage executors (iperf, fio) also drop here.
// this on a tick; stage executors (iperf, fio) also drop here. Each
// sample is evaluated against the run's seeded thresholds — critical
// breaches fail the run immediately (thermal runaway, EDAC UE, voltage
// sag); warning breaches are recorded for the report only.
func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
runID, ok := runIDFromURL(w, r)
if !ok {
return
}
if _, ok := a.authenticate(w, r, runID); !ok {
run, ok := a.authenticate(w, r, runID)
if !ok {
return
}
if a.Measurements == nil {
@@ -903,8 +1010,12 @@ func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
return
}
rows := make([]model.Measurement, 0, len(body.Samples))
sampleStages := make([]string, 0, len(body.Samples))
for _, s := range body.Samples {
ts, _ := time.Parse(time.RFC3339Nano, s.TS)
if ts.IsZero() {
ts = time.Now().UTC()
}
rows = append(rows, model.Measurement{
RunID: runID,
TS: ts,
@@ -913,12 +1024,139 @@ func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
Value: s.Value,
Unit: s.Unit,
})
// Stage the sample belongs to drives threshold selector
// matching. We use the run's current state — the agent does
// not tag samples with a stage.
sampleStages = append(sampleStages, orchestrator.StageNameForState(run.State))
}
if err := a.Measurements.CreateBatch(r.Context(), rows); err != nil {
http.Error(w, "write samples: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "written": len(rows)})
critical := a.evaluateSensorBatch(r.Context(), runID, rows, sampleStages)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"written": len(rows),
"breach": critical != "",
"breach_kind": critical,
})
if critical != "" {
a.failRunOnCriticalBreach(r, run, critical)
}
}
// evaluateSensorBatch runs each sample through the run's thresholds,
// persists evaluations, and returns a short human-readable label for
// the first critical breach it sees (empty when all samples pass or
// only hit warning-severity rules).
func (a *Agent) evaluateSensorBatch(ctx context.Context, runID int64, rows []model.Measurement, sampleStages []string) string {
if a.Thresholds == nil || len(rows) == 0 {
return ""
}
rules, err := a.Thresholds.ListForRun(ctx, runID)
if err != nil {
log.Printf("sensor: list thresholds run %d: %v", runID, err)
return ""
}
if len(rules) == 0 {
return ""
}
evalRules := make([]orchestrator.Threshold, 0, len(rules))
for _, r := range rules {
evalRules = append(evalRules, orchestrator.Threshold{
ID: r.ID,
Stage: r.Stage,
Kind: r.Kind,
Key: r.Key,
Op: orchestrator.ThresholdOp(r.Op),
Value: r.Threshold,
Nominal: r.Nominal,
Severity: orchestrator.ThresholdSeverity(r.Severity),
})
}
evals := make([]store.ThresholdEvaluation, 0, len(rows))
critical := ""
for i, m := range rows {
sample := orchestrator.Sample{
Stage: sampleStages[i],
Kind: m.Kind,
Key: m.Key,
Value: m.Value,
}
for _, res := range orchestrator.Evaluate(sample, evalRules) {
evals = append(evals, store.ThresholdEvaluation{
RunID: runID,
ThresholdID: res.Threshold.ID,
Stage: sample.Stage,
Kind: sample.Kind,
Key: sample.Key,
TS: m.TS,
Observed: res.Observed,
Passed: res.Passed,
})
if critical == "" && res.CriticalBreach() {
critical = fmt.Sprintf("%s %s=%g breached %s %g",
res.Threshold.Kind, sample.Key, res.Observed, res.Threshold.Op, res.Threshold.Value)
}
}
}
if err := a.Thresholds.RecordBatch(ctx, evals); err != nil {
log.Printf("sensor: record evals run %d: %v", runID, err)
}
return critical
}
// stageHadCriticalBreach returns true if any critical-severity
// threshold evaluation for this run matched samples attributed to the
// given stage (stage selector "*" or exact). Called at /result close
// so even an agent that reports Passed=true gets overridden when the
// aggregate view says the stage tripped a gate.
func (a *Agent) stageHadCriticalBreach(ctx context.Context, runID int64, stage string) (bool, string) {
if a.Thresholds == nil {
return false, ""
}
breaches, err := a.Thresholds.CriticalBreaches(ctx, runID)
if err != nil {
log.Printf("result: list breaches run %d: %v", runID, err)
return false, ""
}
for _, b := range breaches {
if b.Stage == stage || b.Stage == "" || b.Stage == "*" {
return true, fmt.Sprintf("critical threshold breach: %s %s=%g", b.Kind, b.Key, b.Observed)
}
}
return false, ""
}
// failRunOnCriticalBreach flips the run to FailedHolding in response
// to a live threshold breach (thermal runaway, EDAC UE, rail sag).
// The agent's pending /result for the current stage may still arrive —
// the silent-skip guard handles that by refusing to double-transition.
func (a *Agent) failRunOnCriticalBreach(r *http.Request, run *model.Run, detail string) {
stage := orchestrator.StageNameForState(run.State)
if stage == "" {
stage = "threshold"
}
if err := a.Runs.SetFailedStage(r.Context(), run.ID, stage+" (threshold)"); err != nil {
log.Printf("sensor: set failed stage run %d: %v", run.ID, err)
}
if _, err := a.Runner.Transition(r.Context(), run.ID, orchestrator.TriggerStageFailed); err != nil {
// If we're already in FailedHolding the transition errors —
// that's fine, the first breach wins.
log.Printf("sensor: fail-transition run %d: %v", run.ID, err)
return
}
hostName := a.hostNameFor(r.Context(), run.HostID)
a.dispatchEvent(notify.Event{
Kind: notify.KindStageFailed,
Severity: notify.SeverityCritical,
RunID: run.ID,
HostName: hostName,
Title: fmt.Sprintf("[vetting] %s FAILED: %s (threshold)", hostName, stage),
Body: fmt.Sprintf("Run %d on %s tripped a critical threshold during %s: %s", run.ID, hostName, stage, detail),
URL: a.runLinkURL(run.ID),
})
a.appendLog(run.ID, "error", fmt.Sprintf("threshold breach during %s: %s — run parked in FailedHolding", stage, detail))
}
// resolveReporting runs when the pipeline advances into StateReporting.
@@ -956,12 +1194,20 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) {
log.Printf("reporting: list measurements: %v", err)
}
}
var firmware []store.FirmwareSnapshot
if a.Firmware != nil {
firmware, err = a.Firmware.ListForRun(ctx, runID)
if err != nil {
log.Printf("reporting: list firmware: %v", err)
}
}
bundle := map[string]any{
"run": run,
"host": host,
"stages": stages,
"spec_diffs": diffs,
"measurements": measurements,
"firmware": firmware,
"generated_at": time.Now().UTC().Format(time.RFC3339),
}
buf, err := json.MarshalIndent(bundle, "", " ")
@@ -993,6 +1239,15 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) {
// Also render the operator-facing HTML summary alongside the JSON.
// Failures here are non-fatal — the JSON is the source of truth.
if host != nil {
fwRows := make([]report.FirmwareSnapshot, 0, len(firmware))
for _, f := range firmware {
fwRows = append(fwRows, report.FirmwareSnapshot{
Component: f.Component,
Identifier: f.Identifier,
Version: f.Version,
Vendor: f.Vendor,
})
}
htmlData := report.Data{
GeneratedAt: time.Now().UTC(),
Run: *run,
@@ -1000,6 +1255,7 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) {
Stages: stages,
SpecDiffs: diffs,
Aggregates: report.AggregateMeasurements(measurements),
Firmware: fwRows,
}
if htmlBuf, err := report.RenderHTML(htmlData); err != nil {
log.Printf("reporting: render html: %v", err)
+2 -2
View File
@@ -108,7 +108,7 @@ func TestRunPage_DefaultStep_Running(t *testing.T) {
})
runID, _ := runs.Create(ctx, id, "rr", false)
_ = ui.Stages.Seed(ctx, runID)
for _, name := range []string{"Inventory", "SpecValidate"} {
for _, name := range []string{"Inventory", "Firmware", "SpecValidate"} {
_ = ui.Stages.StartByName(ctx, runID, name)
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
}
@@ -135,7 +135,7 @@ func TestRunPage_DefaultStep_Failed(t *testing.T) {
})
runID, _ := runs.Create(ctx, id, "rf", false)
_ = ui.Stages.Seed(ctx, runID)
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
for _, name := range []string{"Inventory", "Firmware", "SpecValidate", "SMART"} {
_ = ui.Stages.StartByName(ctx, runID, name)
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
}
+169
View File
@@ -0,0 +1,169 @@
package api_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"testing"
"vetting/internal/api"
"vetting/internal/db"
"vetting/internal/events"
"vetting/internal/model"
"vetting/internal/orchestrator"
"vetting/internal/store"
)
// setupAgentWithThresholds builds an Agent wired up to the thresholds
// store + a Runner so the /sensor handler can drive the state machine.
// Seeds one critical thermal threshold and parks the run in CPUStress
// so the handler will stamp a stage-relevant failed_stage.
func setupAgentWithThresholds(t *testing.T) (*api.Agent, int64, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "vetting.db")
conn, err := db.Open(path)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
hosts := &store.Hosts{DB: conn}
runs := &store.Runs{DB: conn}
stages := &store.Stages{DB: conn}
meas := &store.Measurements{DB: conn}
thresholds := &store.Thresholds{DB: conn}
hub := events.NewHub()
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
hostID, err := hosts.Create(context.Background(), model.Host{
Name: "thresh-host",
MAC: "aa:bb:cc:dd:ee:aa",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
if err != nil {
t.Fatalf("create host: %v", err)
}
plain, hash, err := orchestrator.IssueRunToken()
if err != nil {
t.Fatalf("issue token: %v", err)
}
runID, err := runs.Create(context.Background(), hostID, hash, false)
if err != nil {
t.Fatalf("create run: %v", err)
}
if err := stages.Seed(context.Background(), runID); err != nil {
t.Fatalf("seed stages: %v", err)
}
// Park the run where a real thermal sidecar would be posting samples.
if err := runs.SetState(context.Background(), runID, model.StateCPUStress); err != nil {
t.Fatalf("set state: %v", err)
}
// Seed one critical thermal threshold.
if _, err := thresholds.SeedForRun(context.Background(), runID, []store.ThresholdSpec{
{Stage: "*", Kind: "temp", Key: "cpu/*", Op: "lt", Value: 92, Unit: "C", Severity: "critical", Source: "profile"},
}); err != nil {
t.Fatalf("seed thresholds: %v", err)
}
return &api.Agent{
Hosts: hosts,
Runs: runs,
Stages: stages,
Measurements: meas,
Thresholds: thresholds,
Runner: runner,
}, runID, plain
}
// TestSensor_ThermalRunawayFailsRun: a sample that breaches a critical
// threshold lands in threshold_evaluations (passed=0) and flips the
// run into FailedHolding with failed_stage naming the current stage.
// This is the Phase-1 behavior gate — without the evaluator, the sample
// would just sit in measurements and the run would happily march on.
func TestSensor_ThermalRunawayFailsRun(t *testing.T) {
a, runID, token := setupAgentWithThresholds(t)
batch := api.SensorBatch{Samples: []api.SensorSample{
{Kind: "temp", Key: "cpu/0", Value: 95.3, Unit: "C"},
}}
buf, _ := json.Marshal(batch)
req := routedRequest(runID, http.MethodPost,
"/api/v1/runs/"+strconv.FormatInt(runID, 10)+"/sensor", buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
a.Sensor(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Breach bool `json:"breach"`
Kind string `json:"breach_kind"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Breach {
t.Fatalf("expected breach=true, got %+v", resp)
}
run, err := a.Runs.Get(context.Background(), runID)
if err != nil {
t.Fatalf("get run: %v", err)
}
if run.State != model.StateFailedHolding {
t.Fatalf("state = %s, want FailedHolding", run.State)
}
if run.FailedStage == "" {
t.Fatalf("failed_stage empty; want stage-named breach")
}
evals, err := a.Thresholds.ListEvaluations(context.Background(), runID)
if err != nil {
t.Fatalf("list evaluations: %v", err)
}
if len(evals) != 1 {
t.Fatalf("want 1 evaluation recorded, got %d", len(evals))
}
if evals[0].Passed {
t.Fatalf("evaluation recorded as passed for 95.3C sample against <92C rule")
}
}
// TestSensor_WithinThresholdPasses: a sample comfortably inside the
// threshold writes an evaluation row with passed=1 and leaves the run
// state untouched.
func TestSensor_WithinThresholdPasses(t *testing.T) {
a, runID, token := setupAgentWithThresholds(t)
batch := api.SensorBatch{Samples: []api.SensorSample{
{Kind: "temp", Key: "cpu/0", Value: 55.0, Unit: "C"},
}}
buf, _ := json.Marshal(batch)
req := routedRequest(runID, http.MethodPost,
"/api/v1/runs/"+strconv.FormatInt(runID, 10)+"/sensor", buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
a.Sensor(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
}
run, err := a.Runs.Get(context.Background(), runID)
if err != nil {
t.Fatalf("get run: %v", err)
}
if run.State != model.StateCPUStress {
t.Fatalf("state = %s, want CPUStress unchanged", run.State)
}
evals, err := a.Thresholds.ListEvaluations(context.Background(), runID)
if err != nil {
t.Fatalf("list evaluations: %v", err)
}
if len(evals) != 1 || !evals[0].Passed {
t.Fatalf("want 1 passed evaluation, got %+v", evals)
}
}
+96 -8
View File
@@ -75,6 +75,12 @@ func newCaptureRegistry(c *captureNotifier) *notify.Registry {
// (agent, runID, plainTokenForBearer). Caller is responsible for
// transitioning the run out of Queued.
func fullAgent(t *testing.T) (*api.Agent, int64, string) {
return fullAgentWithSpec(t, "")
}
// fullAgentWithSpec is the same as fullAgent but seeds the host with
// an ExpectedSpecYAML so SpecValidate can pick up diffs in the test.
func fullAgentWithSpec(t *testing.T, expectedSpecYAML string) (*api.Agent, int64, string) {
t.Helper()
tmp := t.TempDir()
conn, err := db.Open(filepath.Join(tmp, "vetting.db"))
@@ -89,6 +95,7 @@ func fullAgent(t *testing.T) (*api.Agent, int64, string) {
artifactStore := &store.Artifacts{DB: conn}
specDiffStore := &store.SpecDiffs{DB: conn}
measurementStore := &store.Measurements{DB: conn}
firmwareStore := &store.Firmware{DB: conn}
hub := events.NewHub()
logHub, err := logs.NewHub(filepath.Join(tmp, "logs"), hub)
@@ -109,7 +116,7 @@ func fullAgent(t *testing.T) (*api.Agent, int64, string) {
MAC: "aa:bb:cc:dd:ee:10",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "", // empty spec → no diffs
ExpectedSpecYAML: expectedSpecYAML,
})
if err != nil {
t.Fatalf("create host: %v", err)
@@ -132,6 +139,7 @@ func fullAgent(t *testing.T) (*api.Agent, int64, string) {
Artifacts: artifactStore,
SpecDiffs: specDiffStore,
Measurements: measurementStore,
Firmware: firmwareStore,
Runner: runner,
EventHub: hub,
Logs: logHub,
@@ -195,20 +203,24 @@ func TestFullPipelineToCompleted(t *testing.T) {
Memory: spec.MemorySpec{TotalGiB: 16},
}
next := walkStage(t, a, runID, token, "Inventory", true, map[string]any{"inventory": inv})
// After Inventory → SpecValidate resolves inline → SMART
if next != "SMART" {
t.Fatalf("after Inventory, next_state = %q, want SMART", next)
// After Inventory → Firmware
if next != "Firmware" {
t.Fatalf("after Inventory, next_state = %q, want Firmware", next)
}
// The remaining stages advance one-for-one in order.
// The remaining stages advance one-for-one in order. After Firmware
// the inline SpecValidate resolver advances through SpecValidate to
// SMART without a dedicated /result POST for SpecValidate.
walkPlan := []struct {
stage string
expected string
}{
{"Firmware", "SMART"},
{"SMART", "CPUStress"},
{"CPUStress", "Storage"},
{"Storage", "Network"},
{"Network", "GPU"},
{"Network", "Burn"},
{"Burn", "GPU"},
{"GPU", "PSU"},
{"PSU", "Completed"}, // PSU → Reporting resolves inline → Completed
}
@@ -287,8 +299,11 @@ func TestFaultInjectionSMART(t *testing.T) {
}
inv := spec.Inventory{Memory: spec.MemorySpec{TotalGiB: 16}}
if next := walkStage(t, a, runID, token, "Inventory", true, map[string]any{"inventory": inv}); next != "SMART" {
t.Fatalf("after Inventory, next = %q want SMART", next)
if next := walkStage(t, a, runID, token, "Inventory", true, map[string]any{"inventory": inv}); next != "Firmware" {
t.Fatalf("after Inventory, next = %q want Firmware", next)
}
if next := walkStage(t, a, runID, token, "Firmware", true, nil); next != "SMART" {
t.Fatalf("after Firmware, next = %q want SMART (inline SpecValidate)", next)
}
// Fake SMART failure → expect FailedHolding.
@@ -316,3 +331,76 @@ func TestFaultInjectionSMART(t *testing.T) {
t.Errorf("StageFailed severity = %q, want critical", ev.Severity)
}
}
// TestFirmwarePersistAndSpecMismatch exercises the Phase 4 firmware
// integration: the agent POSTs Firmware snapshots; server persists; the
// following SpecValidate diff picks up a firmware mismatch and parks
// the run in FailedHolding with FailedStage=SpecValidate.
func TestFirmwarePersistAndSpecMismatch(t *testing.T) {
// Host demands BIOS 3.3; agent will POST 3.2 → one critical firmware diff.
yaml := "firmware:\n - component: bios\n version: \"3.3\"\n"
a, runID, token := fullAgentWithSpec(t, yaml)
a.Notify = newCaptureRegistry(&captureNotifier{name: "capture"})
if err := a.Runs.SetState(context.Background(), runID, model.StateInventoryCheck); err != nil {
t.Fatalf("set state: %v", err)
}
inv := spec.Inventory{Memory: spec.MemorySpec{TotalGiB: 16}}
if next := walkStage(t, a, runID, token, "Inventory", true, map[string]any{"inventory": inv}); next != "Firmware" {
t.Fatalf("after Inventory, next = %q want Firmware", next)
}
// Firmware stage: agent reports actual BIOS 3.2 → one row persisted.
fw := []map[string]any{
{"component": "bios", "identifier": "system", "version": "3.2", "vendor": "AMI"},
}
next := walkStage(t, a, runID, token, "Firmware", true, map[string]any{"firmware": fw})
// Inline SpecValidate should detect the firmware mismatch and send
// the run to FailedHolding without the agent posting SpecValidate.
if next != "FailedHolding" {
t.Fatalf("after Firmware mismatch, next = %q want FailedHolding", next)
}
run, err := a.Runs.Get(context.Background(), runID)
if err != nil {
t.Fatalf("get run: %v", err)
}
if run.State != model.StateFailedHolding {
t.Fatalf("run.State = %q, want FailedHolding", run.State)
}
if run.FailedStage != "SpecValidate" {
t.Fatalf("run.FailedStage = %q, want SpecValidate", run.FailedStage)
}
// Persistence: row landed in firmware_snapshots.
snaps, err := a.Firmware.ListForRun(context.Background(), runID)
if err != nil {
t.Fatalf("ListForRun firmware: %v", err)
}
if len(snaps) != 1 {
t.Fatalf("firmware rows = %d, want 1: %+v", len(snaps), snaps)
}
if snaps[0].Component != "bios" || snaps[0].Version != "3.2" {
t.Errorf("persisted snapshot = %+v", snaps[0])
}
// Diff row: SpecDiffs has a firmware-specific entry (rather than
// only CPU/memory/disk rows) and is critical.
diffs, err := a.SpecDiffs.ListForRun(context.Background(), runID)
if err != nil {
t.Fatalf("ListForRun specdiffs: %v", err)
}
found := false
for _, d := range diffs {
if strings.HasPrefix(d.Field, "firmware[") {
found = true
if d.Severity != "critical" {
t.Errorf("firmware diff severity = %q, want critical", d.Severity)
}
}
}
if !found {
t.Fatalf("no firmware[...] entry in spec diffs: %+v", diffs)
}
}
+64 -13
View File
@@ -16,6 +16,7 @@ import (
"github.com/go-chi/chi/v5"
"gopkg.in/yaml.v3"
"vetting/internal/config"
"vetting/internal/events"
"vetting/internal/logs"
"vetting/internal/model"
@@ -26,17 +27,19 @@ import (
)
type UI struct {
Hosts *store.Hosts
Runs *store.Runs
Stages *store.Stages
SubSteps *store.SubSteps
SpecDiffs *store.SpecDiffs
Artifacts *store.Artifacts
EventHub *events.Hub
Logs *logs.Hub
Runner *orchestrator.Runner
Tiles *TileEnricher
PublicURL string // user-visible base URL baked into the quick-register one-liner
Hosts *store.Hosts
Runs *store.Runs
Stages *store.Stages
SubSteps *store.SubSteps
SpecDiffs *store.SpecDiffs
Artifacts *store.Artifacts
Thresholds *store.Thresholds // Phase 1: seeded at StartRun from Profiles
Profiles *config.ProfileRegistry
EventHub *events.Hub
Logs *logs.Hub
Runner *orchestrator.Runner
Tiles *TileEnricher
PublicURL string // user-visible base URL baked into the quick-register one-liner
// PXE, when non-nil, gets Reload()ed after host create/delete so
// dnsmasq's dhcp-host= allowlist reflects the current registry.
// Without this, a newly-registered host PXE-boots and gets
@@ -316,23 +319,71 @@ func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) {
}
nonDestructive := r.PostFormValue("non_destructive") == "1"
profile := strings.TrimSpace(r.PostFormValue("profile"))
if profile == "" {
profile = config.ProfileQuick
}
if !config.IsValidProfile(profile) {
http.Error(w, "unknown profile: "+profile, http.StatusBadRequest)
return
}
_, hash, err := orchestrator.IssueRunToken()
if err != nil {
http.Error(w, "token: "+err.Error(), http.StatusInternalServerError)
return
}
runID, err := u.Runs.Create(r.Context(), hostID, hash, nonDestructive)
runID, err := u.Runs.CreateWithProfile(r.Context(), hostID, hash, nonDestructive, profile)
if err != nil {
http.Error(w, "create run: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("ui: created run %d for host %d (state=Queued)", runID, hostID)
if err := u.seedThresholds(r.Context(), runID, host, profile); err != nil {
// A threshold-seed failure shouldn't orphan a run row — log
// and continue. Samples will just accumulate without a gate
// until the operator retries, same as before Phase 1.
log.Printf("ui: seed thresholds run %d: %v", runID, err)
}
log.Printf("ui: created run %d for host %d profile=%s (state=Queued)", runID, hostID, profile)
// Send the operator straight to the new run — the button they clicked
// was "Start vetting", the thing they want next is to watch it.
http.Redirect(w, r, fmt.Sprintf("/runs/%d", runID), http.StatusSeeOther)
}
// seedThresholds materializes the per-run threshold table from the
// ProfileRegistry. The shared vetting.thresholds block applies to
// every profile; future per-profile overrides will layer on top here,
// and per-host overrides (Phase 1 extra) land via ExpectedSpecYAML in
// a later iteration. Safe to skip silently when Thresholds or the
// registry isn't wired — tests do not always build one.
func (u *UI) seedThresholds(ctx context.Context, runID int64, host *model.Host, profile string) error {
if u.Thresholds == nil || u.Profiles == nil {
return nil
}
_ = host // reserved for per-host override layer
_ = profile // reserved for per-profile override layer
defaults := u.Profiles.Vetting.Thresholds
if len(defaults) == 0 {
return nil
}
specs := make([]store.ThresholdSpec, 0, len(defaults))
for _, d := range defaults {
specs = append(specs, store.ThresholdSpec{
Stage: d.Stage,
Kind: d.Kind,
Key: d.Key,
Op: d.Op,
Value: d.Value,
Nominal: d.Nominal,
Unit: d.Unit,
Severity: d.Severity,
Source: "profile",
})
}
_, err := u.Thresholds.SeedForRun(ctx, runID, specs)
return err
}
func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) {
_ = templates.Registration(templates.RegistrationForm{
QuickRegisterURL: u.baseURL(r),
+21
View File
@@ -20,6 +20,13 @@ type Config struct {
Agent Agent `yaml:"agent"`
Notifiers []Notifier `yaml:"notifiers"`
Routes []Route `yaml:"routes"`
// Profiles holds the Phase-1 quick/deep/soak registry (stage order,
// threshold defaults, per-profile stage timeouts + probe knobs).
// Populated from the `vetting:` and `profiles:` top-level blocks
// during Load. Nil is never returned — Load installs a default
// registry when those blocks are absent.
Profiles *ProfileRegistry `yaml:"-"`
}
type Server struct {
@@ -111,6 +118,20 @@ func Load(path string) (*Config, error) {
if err := yaml.Unmarshal(b, &c); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
// The `vetting:` + `profiles:` blocks live alongside the existing
// fields but we decode them into the raw shape because YAML
// durations arrive as strings. Reusing the same byte buffer is
// safe: yaml.Unmarshal is happy to ignore keys the target doesn't
// know about.
var rawProfiles rawProfilesBlock
if err := yaml.Unmarshal(b, &rawProfiles); err != nil {
return nil, fmt.Errorf("parse profiles: %w", err)
}
reg, err := buildProfileRegistry(rawProfiles)
if err != nil {
return nil, fmt.Errorf("profiles: %w", err)
}
c.Profiles = reg
if c.Server.Bind == "" {
c.Server.Bind = "127.0.0.1:8080"
}
+441
View File
@@ -0,0 +1,441 @@
package config
import (
"fmt"
"strings"
"time"
)
// ProfileName is the set of legal values for a Run's profile column.
// Exposed as constants so callers (UI handler, tests, agent) don't
// sprinkle literal strings.
const (
ProfileQuick = "quick"
ProfileDeep = "deep"
ProfileSoak = "soak"
)
// AllProfiles is the canonical ordering shown in the picker. Leftmost
// is the default; rightmost is the longest-running.
var AllProfiles = []string{ProfileQuick, ProfileDeep, ProfileSoak}
// IsValidProfile returns true when name is one of the known profile
// identifiers. Used at the UI boundary to reject malformed POSTs and in
// store code as a fallback guard.
func IsValidProfile(name string) bool {
for _, p := range AllProfiles {
if p == name {
return true
}
}
return false
}
// Vetting holds the stage order + threshold defaults that are shared
// across all profiles. Only the per-stage durations/concurrency differ
// between quick/deep/soak; gates like "CPU > 92C fails the run" apply
// to a 2-minute quick run and a 12-hour soak alike.
type Vetting struct {
Stages []string `yaml:"stages"`
Thresholds []ThresholdDefaults `yaml:"thresholds"`
}
// ThresholdDefaults is the YAML shape of a threshold declaration. One
// stanza can declare a per-stage rule ("stage: Network") or a global
// rule ("stage: *") — the threshold evaluator applies both to samples
// with matching (stage, kind, key).
type ThresholdDefaults struct {
Stage string `yaml:"stage"`
Kind string `yaml:"kind"`
Key string `yaml:"key"`
Op string `yaml:"op"` // lt|lte|gt|gte|within_pct
Value float64 `yaml:"value"`
Nominal float64 `yaml:"nominal"` // only used by within_pct (e.g. 12.0 for +12V rail)
Unit string `yaml:"unit"`
Severity string `yaml:"severity"` // critical|warning
}
// ProfileRegistry is the in-memory view of the `profiles:` block in
// vetting.yaml. The orchestrator queries it at run creation time to
// seed thresholds and (in Phase 3+) to scale per-stage durations.
type ProfileRegistry struct {
// Shared stage ordering + threshold defaults. Every profile walks
// the same list; only durations/concurrency differ.
Vetting Vetting
// Profiles is keyed by name ("quick"/"deep"/"soak"). Inherit is
// already resolved at load time — a caller sees a flattened view.
Profiles map[string]Profile
}
// Profile is a loaded profile. StageTimeouts is keyed by stage name.
// Defaults carries the free-form knobs each probe reads.
type Profile struct {
Name string
Inherit string
StageTimeouts map[string]time.Duration
Defaults map[string]map[string]any
}
// StageConfig is the flat view of a profile's knobs, shipped on the
// claim response so the agent can size CPUStress/Storage/Network/Burn
// work without parsing YAML. Empty values mean "fall back to the
// agent's compile-time default" — an older orchestrator that doesn't
// set these fields keeps working unchanged.
type StageConfig struct {
Profile string `json:"profile"`
StageTimeouts map[string]string `json:"stage_timeouts,omitempty"`
CPUStress CPUStressKnobs `json:"cpustress"`
Storage StorageKnobs `json:"storage"`
Network NetworkKnobs `json:"network"`
Burn BurnKnobs `json:"burn"`
}
// CPUStressKnobs parallels the `cpustress:` block under `profiles.<name>.defaults`.
// Durations are YAML duration strings ("2m", "60m", "12h").
type CPUStressKnobs struct {
CPUPass string `json:"cpu_pass,omitempty"`
MemPass string `json:"mem_pass,omitempty"`
EDACPoll string `json:"edac_poll,omitempty"`
}
// StorageKnobs parallels `storage:` defaults. Mode is "fio_sample" (quick)
// or "full_disk" (deep/soak). Verify names the integrity mode ("md5" or "").
type StorageKnobs struct {
Mode string `json:"mode,omitempty"`
FioSize string `json:"fio_size,omitempty"`
FioTime string `json:"fio_time,omitempty"`
FioBS string `json:"fio_bs,omitempty"`
FioRW string `json:"fio_rw,omitempty"`
Verify string `json:"verify,omitempty"`
}
// NetworkKnobs parallels `network:` defaults. Duration is a YAML string.
type NetworkKnobs struct {
Duration string `json:"duration,omitempty"`
}
// BurnKnobs parallels `burn:` defaults. Duration is the total Burn window.
// CPUWorkers is "all" (agent picks runtime.NumCPU) or a numeric string.
// MemPct is a percentage of MemAvailable to stress. FioOnSpare gates
// whether fio runs inside Burn (set false if operator lacks a spare
// partition). IperfParallel is the parallel stream count fed to iperf3 -P.
type BurnKnobs struct {
Duration string `json:"duration,omitempty"`
CPUWorkers string `json:"cpu_workers,omitempty"`
MemPct int `json:"mem_pct,omitempty"`
FioOnSpare bool `json:"fio_on_spare,omitempty"`
IperfParallel int `json:"iperf_parallel,omitempty"`
}
// ResolveStageConfig flattens the named profile into the wire shape the
// claim handler ships. Missing keys render as empty strings so the agent
// falls back to its own defaults.
func (pr *ProfileRegistry) ResolveStageConfig(name string) StageConfig {
if pr == nil {
return StageConfig{Profile: name}
}
p, err := pr.Lookup(name)
if err != nil {
return StageConfig{Profile: name}
}
out := StageConfig{Profile: p.Name}
if len(p.StageTimeouts) > 0 {
out.StageTimeouts = make(map[string]string, len(p.StageTimeouts))
for k, v := range p.StageTimeouts {
out.StageTimeouts[k] = v.String()
}
}
cpu := p.Defaults["cpustress"]
out.CPUStress.CPUPass = yamlString(cpu, "cpu_pass")
out.CPUStress.MemPass = yamlString(cpu, "mem_pass")
out.CPUStress.EDACPoll = yamlString(cpu, "edac_poll")
st := p.Defaults["storage"]
out.Storage.Mode = yamlString(st, "mode")
out.Storage.FioSize = yamlString(st, "fio_size")
out.Storage.FioTime = yamlString(st, "fio_time")
out.Storage.FioBS = yamlString(st, "fio_bs")
out.Storage.FioRW = yamlString(st, "fio_rw")
out.Storage.Verify = yamlString(st, "verify")
net := p.Defaults["network"]
out.Network.Duration = yamlString(net, "duration")
burn := p.Defaults["burn"]
out.Burn.Duration = yamlString(burn, "duration")
out.Burn.CPUWorkers = yamlString(burn, "cpu_workers")
out.Burn.MemPct = yamlInt(burn, "mem_pct")
out.Burn.FioOnSpare = yamlBool(burn, "fio_on_spare")
out.Burn.IperfParallel = yamlInt(burn, "iperf_parallel")
return out
}
// yamlInt coerces a map[string]any entry to int. Accepts native int,
// float64 (JSON numbers round-trip as float), or numeric string. Missing
// / malformed values return 0 so the agent falls back to its default.
func yamlInt(m map[string]any, key string) int {
v, ok := m[key]
if !ok || v == nil {
return 0
}
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
case string:
// Best-effort string → int. Empty and non-numeric fall through
// to zero.
var n int
if _, err := fmt.Sscanf(x, "%d", &n); err == nil {
return n
}
}
return 0
}
// yamlBool accepts native bool or "true"/"false" strings. Anything else
// (missing key, numeric, typo) returns false — a safer default than
// "true" for a destructive knob like fio_on_spare.
func yamlBool(m map[string]any, key string) bool {
v, ok := m[key]
if !ok || v == nil {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
return strings.EqualFold(x, "true")
}
return false
}
// yamlString coerces a map[string]any entry to its string form. YAML
// durations like "2m" parse as strings; numeric literals like 5 parse as
// int. We format non-string scalars with fmt.Sprint so the agent can
// still interpret them.
func yamlString(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
// Lookup returns the profile with the given name. Falls back to the
// default profile (quick) if the name is empty. Returns an error when
// the name is non-empty but unknown so the caller can surface it.
func (pr *ProfileRegistry) Lookup(name string) (Profile, error) {
if name == "" {
name = ProfileQuick
}
p, ok := pr.Profiles[name]
if !ok {
return Profile{}, fmt.Errorf("unknown profile %q", name)
}
return p, nil
}
// Names returns the registry's profile names in the canonical
// picker order (quick/deep/soak). Profiles present in the config but
// unknown to AllProfiles are appended after, alphabetically.
func (pr *ProfileRegistry) Names() []string {
out := make([]string, 0, len(pr.Profiles))
seen := map[string]bool{}
for _, n := range AllProfiles {
if _, ok := pr.Profiles[n]; ok {
out = append(out, n)
seen[n] = true
}
}
for n := range pr.Profiles {
if !seen[n] {
out = append(out, n)
}
}
return out
}
// Stages returns the shared stage order, or a safe default when the
// config didn't declare one — keeps tests that don't build a full
// ProfileRegistry from tripping over a nil slice.
func (pr *ProfileRegistry) Stages() []string {
if len(pr.Vetting.Stages) == 0 {
return DefaultStages()
}
out := make([]string, len(pr.Vetting.Stages))
copy(out, pr.Vetting.Stages)
return out
}
// DefaultStages is the canonical stage list the orchestrator walks
// when no config is loaded. Mirrored in the vetting.yaml shipped with
// the repo so edits to the slice and the file stay in sync.
func DefaultStages() []string {
return []string{
"Inventory",
"Firmware",
"SpecValidate",
"SMART",
"CPUStress",
"Storage",
"Network",
"Burn",
"GPU",
"PSU",
"Reporting",
}
}
// rawProfile is the YAML shape before inherit resolution. Durations
// arrive as strings (e.g. "2h") so we can parse them with
// time.ParseDuration instead of rolling our own.
type rawProfile struct {
Inherit string `yaml:"inherit"`
StageTimeouts map[string]string `yaml:"stage_timeouts"`
Defaults map[string]map[string]any `yaml:"defaults"`
}
type rawProfilesBlock struct {
Vetting Vetting `yaml:"vetting"`
Profiles map[string]rawProfile `yaml:"profiles"`
}
// buildProfileRegistry flattens a rawProfilesBlock into a ProfileRegistry.
// Resolves `inherit:` by recursive merge (child keys win), parses
// stage_timeouts strings into time.Durations, and returns an error if
// the inherit chain loops or references an unknown profile.
func buildProfileRegistry(raw rawProfilesBlock) (*ProfileRegistry, error) {
if len(raw.Profiles) == 0 {
raw.Profiles = defaultRawProfiles()
}
out := &ProfileRegistry{
Vetting: raw.Vetting,
Profiles: make(map[string]Profile, len(raw.Profiles)),
}
if len(out.Vetting.Stages) == 0 {
out.Vetting.Stages = DefaultStages()
}
for name := range raw.Profiles {
resolved, err := resolveProfile(raw.Profiles, name, nil)
if err != nil {
return nil, err
}
out.Profiles[name] = resolved
}
return out, nil
}
// resolveProfile recursively walks inherit chains, depth-first. The
// visited slice is a cycle guard — we add the current name before
// recursing and bail if we ever see it again.
func resolveProfile(all map[string]rawProfile, name string, visited []string) (Profile, error) {
for _, v := range visited {
if v == name {
return Profile{}, fmt.Errorf("profile inherit cycle: %s -> %s", strings.Join(visited, " -> "), name)
}
}
raw, ok := all[name]
if !ok {
return Profile{}, fmt.Errorf("unknown profile %q", name)
}
base := Profile{
Name: name,
Inherit: raw.Inherit,
StageTimeouts: map[string]time.Duration{},
Defaults: map[string]map[string]any{},
}
if raw.Inherit != "" {
parent, err := resolveProfile(all, raw.Inherit, append(visited, name))
if err != nil {
return Profile{}, err
}
for k, v := range parent.StageTimeouts {
base.StageTimeouts[k] = v
}
for k, v := range parent.Defaults {
copyMap := make(map[string]any, len(v))
for kk, vv := range v {
copyMap[kk] = vv
}
base.Defaults[k] = copyMap
}
}
for stage, s := range raw.StageTimeouts {
d, err := time.ParseDuration(s)
if err != nil {
return Profile{}, fmt.Errorf("profile %s stage_timeouts[%s]: %w", name, stage, err)
}
base.StageTimeouts[stage] = d
}
for group, kv := range raw.Defaults {
dest, ok := base.Defaults[group]
if !ok {
dest = map[string]any{}
base.Defaults[group] = dest
}
for k, v := range kv {
dest[k] = v
}
}
return base, nil
}
// defaultRawProfiles returns sane per-profile durations + probe knobs
// used when vetting.yaml omits the `profiles:` block entirely. Matches
// the plan's per-stage budget table so the agent still gets coherent
// CPUStress/Storage/Network knobs without any operator-visible config.
func defaultRawProfiles() map[string]rawProfile {
return map[string]rawProfile{
ProfileQuick: {
StageTimeouts: map[string]string{
"CPUStress": "5m",
"Storage": "5m",
"Network": "2m",
"Burn": "3m",
"PSU": "1m",
},
Defaults: map[string]map[string]any{
"cpustress": {"cpu_pass": "2m", "mem_pass": "2m", "edac_poll": "10s"},
"storage": {"mode": "fio_sample", "fio_size": "1GiB", "fio_time": "3m", "fio_bs": "4k", "fio_rw": "randrw", "verify": "md5"},
"network": {"duration": "60s"},
"burn": {"duration": "2m", "cpu_workers": "all", "mem_pct": 50, "fio_on_spare": true, "iperf_parallel": 2},
},
},
ProfileDeep: {
StageTimeouts: map[string]string{
"CPUStress": "2h",
"Storage": "4h",
"Network": "35m",
"Burn": "3h",
"PSU": "10m",
},
Defaults: map[string]map[string]any{
"cpustress": {"cpu_pass": "60m", "mem_pass": "60m", "edac_poll": "10s"},
"storage": {"mode": "full_disk", "fio_time": "2h", "fio_bs": "4k", "fio_rw": "randrw", "verify": "md5"},
"network": {"duration": "30m"},
"burn": {"duration": "2h", "cpu_workers": "all", "mem_pct": 70, "fio_on_spare": true, "iperf_parallel": 4},
},
},
ProfileSoak: {
Inherit: ProfileDeep,
StageTimeouts: map[string]string{
"CPUStress": "14h",
"Storage": "8h",
"Network": "2h30m",
"Burn": "20h",
"PSU": "15m",
},
Defaults: map[string]map[string]any{
"cpustress": {"cpu_pass": "12h"},
"storage": {"mode": "full_disk", "fio_time": "6h"},
"network": {"duration": "2h"},
"burn": {"duration": "18h", "iperf_parallel": 8},
},
},
}
}
@@ -0,0 +1,57 @@
-- Phase-1 groundwork for profile-aware, threshold-gated vetting.
--
-- Adds:
-- * runs.profile — which profile the run is executing
-- (quick|deep|soak; defaults to quick for
-- backfill of older rows + tests).
-- * thresholds — seeded per run at creation from the
-- ProfileRegistry + per-host overrides;
-- immutable for that run so a late config
-- edit can't retroactively pass/fail it.
-- * threshold_evaluations — one row per observed sample vs threshold;
-- drives the report + pipeline badges.
-- * firmware_snapshots — per-run BIOS/BMC/NIC/HBA/microcode/NVMe
-- version captures used by SpecValidate
-- diffing in Phase 4.
ALTER TABLE runs ADD COLUMN profile TEXT NOT NULL DEFAULT 'quick';
CREATE TABLE IF NOT EXISTS thresholds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
stage_name TEXT NOT NULL, -- "*" matches any stage
kind TEXT NOT NULL, -- temp|psu_volt|iperf|fio_p99_us|nic_retrans|edac_ce|edac_ue|mce|...
key TEXT NOT NULL, -- "*" or glob-ish match (prefix* / *suffix / exact)
op TEXT NOT NULL, -- lt|lte|gt|gte|within_pct
threshold REAL NOT NULL,
nominal REAL NOT NULL DEFAULT 0, -- used by within_pct; 0 elsewhere
unit TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL, -- critical|warning
source TEXT NOT NULL -- profile|host_override
);
CREATE INDEX IF NOT EXISTS idx_thresholds_run ON thresholds(run_id);
CREATE INDEX IF NOT EXISTS idx_thresholds_kind ON thresholds(run_id, stage_name, kind);
CREATE TABLE IF NOT EXISTS threshold_evaluations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
threshold_id INTEGER NOT NULL REFERENCES thresholds(id) ON DELETE CASCADE,
stage_name TEXT NOT NULL,
kind TEXT NOT NULL,
key TEXT NOT NULL,
ts TIMESTAMP NOT NULL,
observed REAL NOT NULL,
passed INTEGER NOT NULL -- 1 = sample within threshold, 0 = breach
);
CREATE INDEX IF NOT EXISTS idx_threshold_evals_run ON threshold_evaluations(run_id, passed);
CREATE TABLE IF NOT EXISTS firmware_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
component TEXT NOT NULL, -- bios|bmc|nic|hba|microcode|nvme_fw
identifier TEXT NOT NULL, -- slot/serial/device path that distinguishes this component
version TEXT NOT NULL,
vendor TEXT NOT NULL DEFAULT '',
raw_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_firmware_run ON firmware_snapshots(run_id, component);
+3
View File
@@ -26,11 +26,13 @@ const (
StateWaitingReboot RunState = "WaitingReboot"
StateBooting RunState = "Booting"
StateInventoryCheck RunState = "InventoryCheck"
StateFirmware RunState = "Firmware"
StateSpecValidate RunState = "SpecValidate"
StateSMART RunState = "SMART"
StateCPUStress RunState = "CPUStress"
StateStorage RunState = "Storage"
StateNetwork RunState = "Network"
StateBurn RunState = "Burn"
StateGPU RunState = "GPU"
StatePSU RunState = "PSU"
StateReporting RunState = "Reporting"
@@ -63,6 +65,7 @@ type Run struct {
HoldIP string
OverrideFlagsJSON string
NonDestructive bool
Profile string // quick|deep|soak; empty is treated as "quick"
}
type StageState string
+2 -2
View File
@@ -119,9 +119,9 @@ func (d *Dispatcher) pickNext(ctx context.Context) {
queued = &runs[i]
}
case model.StateWaitingWoL, model.StateWaitingReboot, model.StateBooting,
model.StateInventoryCheck, model.StateSpecValidate, model.StateSMART,
model.StateInventoryCheck, model.StateFirmware, model.StateSpecValidate, model.StateSMART,
model.StateCPUStress, model.StateStorage, model.StateNetwork,
model.StateGPU, model.StatePSU, model.StateReporting:
model.StateBurn, model.StateGPU, model.StatePSU, model.StateReporting:
inFlight++
}
}
+6 -2
View File
@@ -30,11 +30,13 @@ const (
// "InventoryCheck". Later stages share a name with their state.
var stageStates = map[string]model.RunState{
"Inventory": model.StateInventoryCheck,
"Firmware": model.StateFirmware,
"SpecValidate": model.StateSpecValidate,
"SMART": model.StateSMART,
"CPUStress": model.StateCPUStress,
"Storage": model.StateStorage,
"Network": model.StateNetwork,
"Burn": model.StateBurn,
"GPU": model.StateGPU,
"PSU": model.StatePSU,
"Reporting": model.StateReporting,
@@ -44,11 +46,13 @@ var stageStates = map[string]model.RunState{
// first stage to Completed. Kept in sync with store.DefaultStageOrder.
var stageOrder = []model.RunState{
model.StateInventoryCheck,
model.StateFirmware,
model.StateSpecValidate,
model.StateSMART,
model.StateCPUStress,
model.StateStorage,
model.StateNetwork,
model.StateBurn,
model.StateGPU,
model.StatePSU,
model.StateReporting,
@@ -143,9 +147,9 @@ func nextStageState(current model.RunState) (model.RunState, error) {
func allActiveStates() []model.RunState {
return []model.RunState{
model.StateQueued, model.StateWaitingWoL, model.StateWaitingReboot, model.StateBooting,
model.StateInventoryCheck, model.StateSpecValidate, model.StateSMART,
model.StateInventoryCheck, model.StateFirmware, model.StateSpecValidate, model.StateSMART,
model.StateCPUStress, model.StateStorage, model.StateNetwork,
model.StateGPU, model.StatePSU, model.StateReporting,
model.StateBurn, model.StateGPU, model.StatePSU, model.StateReporting,
}
}
@@ -80,11 +80,13 @@ func TestTriggerAgentClaimedFromWaitingReboot(t *testing.T) {
func TestTriggerStageMismatch(t *testing.T) {
stageStates := []model.RunState{
model.StateInventoryCheck,
model.StateFirmware,
model.StateSpecValidate,
model.StateSMART,
model.StateCPUStress,
model.StateStorage,
model.StateNetwork,
model.StateBurn,
model.StateGPU,
model.StatePSU,
model.StateReporting,
@@ -114,11 +116,13 @@ func TestTriggerStageMismatch(t *testing.T) {
func TestStageNameForState(t *testing.T) {
pairs := map[string]model.RunState{
"Inventory": model.StateInventoryCheck,
"Firmware": model.StateFirmware,
"SpecValidate": model.StateSpecValidate,
"SMART": model.StateSMART,
"CPUStress": model.StateCPUStress,
"Storage": model.StateStorage,
"Network": model.StateNetwork,
"Burn": model.StateBurn,
"GPU": model.StateGPU,
"PSU": model.StatePSU,
"Reporting": model.StateReporting,
@@ -143,11 +147,13 @@ func TestNextStageWalk(t *testing.T) {
// one in the canonical order, and from Reporting onto Completed.
chain := []model.RunState{
model.StateInventoryCheck,
model.StateFirmware,
model.StateSpecValidate,
model.StateSMART,
model.StateCPUStress,
model.StateStorage,
model.StateNetwork,
model.StateBurn,
model.StateGPU,
model.StatePSU,
model.StateReporting,
+182
View File
@@ -0,0 +1,182 @@
package orchestrator
import (
"fmt"
"strings"
)
// ThresholdOp is one of the comparison operators a threshold supports.
// within_pct is the only one that cares about a "nominal" value for
// the key — used for PSU rails ("+12V within 5% of 12.0").
type ThresholdOp string
const (
OpLT ThresholdOp = "lt"
OpLTE ThresholdOp = "lte"
OpGT ThresholdOp = "gt"
OpGTE ThresholdOp = "gte"
OpWithinPct ThresholdOp = "within_pct"
)
// ThresholdSeverity routes a breach to either "fail the run" or "just
// surface a warning in the report". The evaluator returns it alongside
// the Pass flag so the caller can decide whether to transition the run.
type ThresholdSeverity string
const (
SeverityCritical ThresholdSeverity = "critical"
SeverityWarning ThresholdSeverity = "warning"
)
// Threshold is the evaluator's view of a stored threshold row. It's a
// flat, already-parsed value-object — the evaluator doesn't look at
// the DB and the store doesn't look at the evaluator.
type Threshold struct {
ID int64
Stage string // "*" matches any stage
Kind string
Key string // glob-ish: "*" / "prefix*" / "*suffix" / exact
Op ThresholdOp
Value float64
Nominal float64 // for within_pct (nominal voltage/frequency)
Severity ThresholdSeverity
}
// Sample is a single observation the evaluator tests against matching
// thresholds. Stage may be empty when the agent doesn't know which
// stage posted it (e.g. the thermal sidecar running across stages) —
// empty-stage samples only match thresholds with Stage == "*".
type Sample struct {
Stage string
Kind string
Key string
Value float64
}
// EvalResult is the per-sample outcome of a threshold evaluation:
// which threshold was consulted, whether the sample passed, and the
// severity so the caller can fast-fail on critical breaches.
type EvalResult struct {
Threshold Threshold
Passed bool
Observed float64
}
// Breached returns true when the sample violated the threshold.
func (r EvalResult) Breached() bool { return !r.Passed }
// CriticalBreach returns true only for critical-severity breaches —
// the "fail the run right now" case.
func (r EvalResult) CriticalBreach() bool {
return r.Breached() && r.Threshold.Severity == SeverityCritical
}
// Evaluate runs a single sample through every threshold that applies
// to it. A sample may match more than one threshold (a generic "*"
// rule + a stage-specific override); each match produces its own
// EvalResult in the returned slice so both get persisted.
func Evaluate(sample Sample, thresholds []Threshold) []EvalResult {
out := make([]EvalResult, 0, 1)
for _, t := range thresholds {
if !thresholdMatchesSample(t, sample) {
continue
}
passed, err := evaluateOp(t.Op, sample.Value, t.Value, t.Nominal)
if err != nil {
// Unknown operator — skip. The caller could validate on
// insert; here we prefer to drop the threshold than to
// return an error that forces every Sensor write to 500.
continue
}
out = append(out, EvalResult{
Threshold: t,
Passed: passed,
Observed: sample.Value,
})
}
return out
}
// thresholdMatchesSample applies the stage + kind + key filter. Kind
// is always literal — there's no "any kind" threshold and if there
// ever is we'll add a `kind: *` escape hatch. Stage and key both
// support glob-ish matching.
func thresholdMatchesSample(t Threshold, s Sample) bool {
if t.Kind != s.Kind {
return false
}
if !stageMatches(t.Stage, s.Stage) {
return false
}
if !keyMatches(t.Key, s.Key) {
return false
}
return true
}
// stageMatches returns true if the threshold's stage selector applies
// to the sample's stage. "*" matches everything; empty threshold
// selector is treated as "*" so a threshold declared without a stage
// key isn't accidentally inert. A sample without a stage only matches
// the "*" selector — we don't guess.
func stageMatches(selector, sampleStage string) bool {
if selector == "" || selector == "*" {
return true
}
return selector == sampleStage
}
// keyMatches handles "*", "prefix*", "*suffix", and exact match. We
// avoid pulling in filepath.Match so Windows `\`-vs-`/` rules don't
// leak into the sample namespace (key "eth0/rx_errors" is not a path).
func keyMatches(pattern, key string) bool {
if pattern == "" || pattern == "*" {
return true
}
hasPrefix := strings.HasPrefix(pattern, "*")
hasSuffix := strings.HasSuffix(pattern, "*")
switch {
case hasPrefix && hasSuffix:
inner := strings.TrimPrefix(strings.TrimSuffix(pattern, "*"), "*")
return strings.Contains(key, inner)
case hasSuffix:
return strings.HasPrefix(key, strings.TrimSuffix(pattern, "*"))
case hasPrefix:
return strings.HasSuffix(key, strings.TrimPrefix(pattern, "*"))
default:
return pattern == key
}
}
// evaluateOp does the numeric comparison. within_pct is the oddball:
// it tests |observed - nominal| <= (pct / 100) * nominal. Returns an
// error for unknown operators so the caller can log + drop.
func evaluateOp(op ThresholdOp, observed, threshold, nominal float64) (bool, error) {
switch op {
case OpLT:
return observed < threshold, nil
case OpLTE:
return observed <= threshold, nil
case OpGT:
return observed > threshold, nil
case OpGTE:
return observed >= threshold, nil
case OpWithinPct:
if nominal == 0 {
// within_pct against a 0 nominal is meaningless. Treat as
// pass so a misconfigured rule doesn't spuriously fail.
return true, nil
}
allowed := (threshold / 100.0) * nominal
if allowed < 0 {
allowed = -allowed
}
diff := observed - nominal
if diff < 0 {
diff = -diff
}
return diff <= allowed, nil
default:
return false, fmt.Errorf("unknown op %q", op)
}
}
+152
View File
@@ -0,0 +1,152 @@
package orchestrator
import "testing"
// TestEvaluate_Ops covers every operator against the boundary case
// (equal to threshold) plus one clearly-inside and one clearly-outside
// value. Table-driven because the logic is regular.
func TestEvaluate_Ops(t *testing.T) {
cases := []struct {
name string
op ThresholdOp
value float64
nominal float64
observed float64
want bool
}{
{"lt strict below", OpLT, 10, 0, 5, true},
{"lt equal fails", OpLT, 10, 0, 10, false},
{"lt above fails", OpLT, 10, 0, 15, false},
{"lte below", OpLTE, 10, 0, 5, true},
{"lte equal passes", OpLTE, 10, 0, 10, true},
{"lte above fails", OpLTE, 10, 0, 11, false},
{"gt below fails", OpGT, 900, 0, 800, false},
{"gt equal fails", OpGT, 900, 0, 900, false},
{"gt above passes", OpGT, 900, 0, 950, true},
{"gte equal passes", OpGTE, 900, 0, 900, true},
{"gte below fails", OpGTE, 900, 0, 800, false},
{"within_pct exact", OpWithinPct, 5, 12.0, 12.0, true},
{"within_pct inside", OpWithinPct, 5, 12.0, 11.7, true},
{"within_pct outside low", OpWithinPct, 5, 12.0, 11.0, false},
{"within_pct outside high", OpWithinPct, 5, 12.0, 12.7, false},
{"within_pct zero nominal passes", OpWithinPct, 5, 0, 99, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rules := []Threshold{{
Stage: "*", Kind: "k", Key: "k", Op: tc.op,
Value: tc.value, Nominal: tc.nominal, Severity: SeverityCritical,
}}
res := Evaluate(Sample{Stage: "Any", Kind: "k", Key: "k", Value: tc.observed}, rules)
if len(res) != 1 {
t.Fatalf("expected 1 match, got %d", len(res))
}
if res[0].Passed != tc.want {
t.Fatalf("op=%s observed=%v want passed=%v got %v", tc.op, tc.observed, tc.want, res[0].Passed)
}
})
}
}
// TestEvaluate_StageMatching: a Network-scoped rule ignores samples
// stamped with other stages. Global "*" catches everything.
func TestEvaluate_StageMatching(t *testing.T) {
rules := []Threshold{
{Stage: "*", Kind: "temp", Key: "cpu/*", Op: OpLT, Value: 92, Severity: SeverityCritical},
{Stage: "Burn", Kind: "temp", Key: "cpu/*", Op: OpLT, Value: 88, Severity: SeverityCritical},
}
// Sample from CPUStress — only the global rule applies.
res := Evaluate(Sample{Stage: "CPUStress", Kind: "temp", Key: "cpu/0", Value: 89}, rules)
if len(res) != 1 {
t.Fatalf("cpustress sample: expected 1 match, got %d", len(res))
}
if res[0].Threshold.Value != 92 {
t.Fatalf("cpustress sample matched wrong rule: %+v", res[0].Threshold)
}
// Sample from Burn — both rules match. The stricter one breaches.
res = Evaluate(Sample{Stage: "Burn", Kind: "temp", Key: "cpu/0", Value: 89}, rules)
if len(res) != 2 {
t.Fatalf("burn sample: expected 2 matches, got %d", len(res))
}
var globalPassed, burnPassed bool
for _, r := range res {
switch r.Threshold.Value {
case 92:
globalPassed = r.Passed
case 88:
burnPassed = r.Passed
}
}
if !globalPassed {
t.Fatalf("global 92C rule should pass at 89C")
}
if burnPassed {
t.Fatalf("burn 88C rule should breach at 89C")
}
}
// TestEvaluate_KeyWildcards covers "*" / "prefix*" / "*suffix".
func TestEvaluate_KeyWildcards(t *testing.T) {
cases := []struct {
pattern string
key string
match bool
}{
{"*", "anything", true},
{"", "anything", true},
{"cpu/*", "cpu/0", true},
{"cpu/*", "gpu/0", false},
{"*/rate", "eth0/rate", true},
{"*/rate", "eth0/count", false},
{"exact", "exact", true},
{"exact", "exactly", false},
}
for _, tc := range cases {
t.Run(tc.pattern+"_vs_"+tc.key, func(t *testing.T) {
got := keyMatches(tc.pattern, tc.key)
if got != tc.match {
t.Fatalf("keyMatches(%q, %q) = %v, want %v", tc.pattern, tc.key, got, tc.match)
}
})
}
}
// TestEvaluate_SeverityDispatch: only critical breaches flip
// CriticalBreach; warning-severity breaches stay advisory.
func TestEvaluate_SeverityDispatch(t *testing.T) {
rules := []Threshold{
{Stage: "*", Kind: "temp", Key: "cpu", Op: OpLT, Value: 92, Severity: SeverityCritical},
{Stage: "*", Kind: "fio", Key: "p99", Op: OpLT, Value: 50000, Severity: SeverityWarning},
}
res := Evaluate(Sample{Stage: "CPU", Kind: "temp", Key: "cpu", Value: 95}, rules)
if len(res) != 1 || !res[0].CriticalBreach() {
t.Fatalf("critical breach not detected: %+v", res)
}
res = Evaluate(Sample{Stage: "Storage", Kind: "fio", Key: "p99", Value: 80000}, rules)
if len(res) != 1 {
t.Fatalf("expected 1 match, got %d", len(res))
}
if res[0].CriticalBreach() {
t.Fatalf("warning-severity breach should not be critical")
}
if !res[0].Breached() {
t.Fatalf("warning-severity rule should still show breach=true")
}
}
// TestEvaluate_NoMatchingThreshold: a sample that doesn't hit any rule
// produces an empty result slice — callers treat that as "advisory".
func TestEvaluate_NoMatchingThreshold(t *testing.T) {
rules := []Threshold{
{Stage: "*", Kind: "temp", Key: "cpu/*", Op: OpLT, Value: 92, Severity: SeverityCritical},
}
res := Evaluate(Sample{Stage: "Network", Kind: "iperf", Key: "throughput", Value: 950}, rules)
if len(res) != 0 {
t.Fatalf("unmatched sample should yield 0 results, got %d", len(res))
}
}
+32 -1
View File
@@ -28,7 +28,17 @@ type Data struct {
Host model.Host
Stages []model.Stage
SpecDiffs []model.SpecDiff
Aggregates []Aggregate // flattened measurement summary; see Aggregate
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/
@@ -196,6 +206,27 @@ const htmlTemplate = `<!doctype html>
</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}}
+97 -5
View File
@@ -21,11 +21,36 @@ import (
)
type Spec struct {
CPU *CPUSpec `yaml:"cpu,omitempty"`
Memory *MemorySpec `yaml:"memory,omitempty"`
Disks []DiskSpec `yaml:"disks,omitempty"`
NICs []NICSpec `yaml:"nics,omitempty"`
GPUs []GPUSpec `yaml:"gpus,omitempty"`
CPU *CPUSpec `yaml:"cpu,omitempty"`
Memory *MemorySpec `yaml:"memory,omitempty"`
Disks []DiskSpec `yaml:"disks,omitempty"`
NICs []NICSpec `yaml:"nics,omitempty"`
GPUs []GPUSpec `yaml:"gpus,omitempty"`
Firmware []FirmwareSpec `yaml:"firmware,omitempty"`
}
// FirmwareSpec is one row in the expected-spec YAML's `firmware:` block.
// Component is one of bios|bmc|nic|hba|microcode|nvme_fw (matches the
// on-wire value from agent/probes.FirmwareSnapshot.Component). Identifier
// is optional — when empty the rule applies to every observed snapshot
// of that component (use for single-instance things like BIOS/microcode);
// when set it pins the check to a specific NIC port / NVMe controller /
// PCI address. Version is the literal string expected; comparison is
// exact after trimming whitespace.
type FirmwareSpec struct {
Component string `yaml:"component"`
Identifier string `yaml:"identifier,omitempty"`
Version string `yaml:"version"`
}
// FirmwareObserved is what the agent reported, in a spec-package-local
// shape so callers don't need to thread store types through the diff.
// The server converts store.FirmwareSnapshot → FirmwareObserved before
// calling DiffFirmware.
type FirmwareObserved struct {
Component string
Identifier string
Version string
}
type CPUSpec struct {
@@ -175,6 +200,73 @@ func diffNICs(expected, actual []NICSpec) []model.SpecDiff {
return out
}
// DiffFirmware returns a SpecDiff per firmware expectation that doesn't
// find a matching observed snapshot. Matching rules:
// - An expected rule with Identifier set matches by (component, id);
// a missing observed snapshot yields a "present=false" diff.
// - An expected rule with Identifier empty applies to every observed
// snapshot of that component — useful for "all NICs must run fw
// 8.30" without listing each port. Zero observed snapshots of the
// component yields a single "present=false" diff, not N.
// - Version mismatch emits an exact-string expected→actual diff.
// Case is preserved (firmware versions are case-sensitive in practice).
func DiffFirmware(expected []FirmwareSpec, actual []FirmwareObserved) []model.SpecDiff {
if len(expected) == 0 {
return nil
}
byCompIdent := map[string]FirmwareObserved{}
byComp := map[string][]FirmwareObserved{}
for _, o := range actual {
byCompIdent[fwKey(o.Component, o.Identifier)] = o
byComp[o.Component] = append(byComp[o.Component], o)
}
var out []model.SpecDiff
for _, exp := range expected {
comp := strings.TrimSpace(exp.Component)
if comp == "" || strings.TrimSpace(exp.Version) == "" {
continue
}
label := "firmware[" + comp
if exp.Identifier != "" {
label += "/" + exp.Identifier
}
label += "]"
if exp.Identifier != "" {
got, ok := byCompIdent[fwKey(comp, exp.Identifier)]
if !ok {
out = append(out, diff(label+".present", "true", "false"))
continue
}
if !strings.EqualFold(strings.TrimSpace(got.Version), strings.TrimSpace(exp.Version)) {
out = append(out, diff(label+".version", exp.Version, got.Version))
}
continue
}
// No identifier: fan out across every observed snapshot of this
// component. Missing is one diff; a mismatching port/controller
// emits one diff per mismatch.
observed := byComp[comp]
if len(observed) == 0 {
out = append(out, diff(label+".present", "true", "false"))
continue
}
for _, got := range observed {
if !strings.EqualFold(strings.TrimSpace(got.Version), strings.TrimSpace(exp.Version)) {
slot := got.Identifier
if slot == "" {
slot = "*"
}
out = append(out, diff("firmware["+comp+"/"+slot+"].version", exp.Version, got.Version))
}
}
}
return out
}
func fwKey(component, identifier string) string {
return strings.ToLower(component) + "|" + strings.ToLower(identifier)
}
func diffGPUs(expected, actual []GPUSpec) []model.SpecDiff {
if len(expected) == 0 {
return nil
+93
View File
@@ -119,3 +119,96 @@ func TestDiffSeverityAlwaysCritical(t *testing.T) {
}
}
}
func TestDiffFirmwareIdentifierMatch(t *testing.T) {
exp := []FirmwareSpec{{Component: "bios", Version: "3.2"}}
obs := []FirmwareObserved{{Component: "bios", Identifier: "system", Version: "3.2"}}
if d := DiffFirmware(exp, obs); len(d) != 0 {
t.Fatalf("matching bios version should produce no diff, got %+v", d)
}
}
func TestDiffFirmwareVersionMismatch(t *testing.T) {
exp := []FirmwareSpec{{Component: "bios", Version: "3.3"}}
obs := []FirmwareObserved{{Component: "bios", Identifier: "system", Version: "3.2"}}
d := DiffFirmware(exp, obs)
if len(d) != 1 {
t.Fatalf("want 1 diff, got %d: %+v", len(d), d)
}
if d[0].Expected != "3.3" || d[0].Actual != "3.2" {
t.Fatalf("diff expected/actual = %q/%q, want 3.3/3.2", d[0].Expected, d[0].Actual)
}
if d[0].Severity != "critical" {
t.Errorf("severity = %q, want critical", d[0].Severity)
}
}
func TestDiffFirmwareMissingComponentPresent(t *testing.T) {
// Expected rule with no identifier + zero observed snapshots →
// single "present=false" diff, not N.
exp := []FirmwareSpec{{Component: "bmc", Version: "1.74"}}
d := DiffFirmware(exp, nil)
if len(d) != 1 {
t.Fatalf("want 1 diff for missing BMC, got %d: %+v", len(d), d)
}
if d[0].Field != "firmware[bmc].present" || d[0].Expected != "true" || d[0].Actual != "false" {
t.Fatalf("missing-BMC diff = %+v", d[0])
}
}
func TestDiffFirmwareWildcardFanOut(t *testing.T) {
// Expected rule with empty identifier fans across every observed
// snapshot of the component — one port matches, one doesn't → one diff.
exp := []FirmwareSpec{{Component: "nic", Version: "16.32.1010"}}
obs := []FirmwareObserved{
{Component: "nic", Identifier: "eth0", Version: "16.32.1010"},
{Component: "nic", Identifier: "eth1", Version: "14.28.0000"},
}
d := DiffFirmware(exp, obs)
if len(d) != 1 {
t.Fatalf("want 1 diff (mismatched eth1 only), got %d: %+v", len(d), d)
}
if d[0].Field != "firmware[nic/eth1].version" {
t.Errorf("field = %q, want firmware[nic/eth1].version", d[0].Field)
}
}
func TestDiffFirmwareIdentifierPin(t *testing.T) {
// Identifier set: pins the rule to a specific port. Other ports
// with mismatched firmware are not evaluated by this rule.
exp := []FirmwareSpec{{Component: "nic", Identifier: "eth0", Version: "1.0"}}
obs := []FirmwareObserved{
{Component: "nic", Identifier: "eth0", Version: "1.0"},
{Component: "nic", Identifier: "eth1", Version: "9.9"},
}
if d := DiffFirmware(exp, obs); len(d) != 0 {
t.Fatalf("pinned rule should ignore other ports, got %+v", d)
}
}
func TestDiffFirmwareIdentifierPinMissing(t *testing.T) {
// Pinned rule with no matching observed snapshot → present=false diff.
exp := []FirmwareSpec{{Component: "nic", Identifier: "eth0", Version: "1.0"}}
if d := DiffFirmware(exp, nil); len(d) != 1 || d[0].Field != "firmware[nic/eth0].present" {
t.Fatalf("want present=false for pinned rule, got %+v", d)
}
}
func TestDiffFirmwareEmptyRuleSkipped(t *testing.T) {
// Empty component or empty version silently skip rather than panic.
exp := []FirmwareSpec{{Component: "", Version: "x"}, {Component: "bios", Version: ""}}
obs := []FirmwareObserved{{Component: "bios", Identifier: "system", Version: "3.2"}}
if d := DiffFirmware(exp, obs); len(d) != 0 {
t.Fatalf("empty rules should skip, got %+v", d)
}
}
func TestDiffFirmwareCaseInsensitive(t *testing.T) {
// Version match is case-insensitive after trim; avoids spurious diff
// from ethtool's "FW1234" vs expected YAML's "fw1234".
exp := []FirmwareSpec{{Component: "nvme_fw", Identifier: "nvme0", Version: "fw1234"}}
obs := []FirmwareObserved{{Component: "nvme_fw", Identifier: "nvme0", Version: "FW1234"}}
if d := DiffFirmware(exp, obs); len(d) != 0 {
t.Fatalf("case-insensitive match expected, got %+v", d)
}
}
+97
View File
@@ -0,0 +1,97 @@
package store
import (
"context"
"database/sql"
"fmt"
)
// FirmwareSnapshot is one row in firmware_snapshots. A run captures
// many (one per BIOS/BMC/NIC/HBA/microcode/NVMe) so SpecValidate can
// diff them against the host's expected spec in Phase 4.
type FirmwareSnapshot struct {
ID int64
RunID int64
Component string // bios|bmc|nic|hba|microcode|nvme_fw
Identifier string // slot/serial/device path
Version string
Vendor string
RawJSON string
}
// Firmware is the CRUD seam. The agent's Phase-4 probe POSTs captured
// rows; the orchestrator stores them. SpecValidate reads them back.
type Firmware struct {
DB *sql.DB
}
// Create inserts a single firmware snapshot. One call per (run, component,
// identifier) — the agent probe owns dedup/formatting.
func (f *Firmware) Create(ctx context.Context, s FirmwareSnapshot) (int64, error) {
raw := s.RawJSON
if raw == "" {
raw = "{}"
}
res, err := f.DB.ExecContext(ctx, `
INSERT INTO firmware_snapshots(run_id, component, identifier, version, vendor, raw_json)
VALUES(?,?,?,?,?,?)
`, s.RunID, s.Component, s.Identifier, s.Version, s.Vendor, raw)
if err != nil {
return 0, fmt.Errorf("insert firmware: %w", err)
}
return res.LastInsertId()
}
// CreateBatch persists a slice of snapshots under one transaction.
// Agent probe enumerates all components in one pass, so batching wins.
func (f *Firmware) CreateBatch(ctx context.Context, rows []FirmwareSnapshot) error {
if len(rows) == 0 {
return nil
}
tx, err := f.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO firmware_snapshots(run_id, component, identifier, version, vendor, raw_json)
VALUES(?,?,?,?,?,?)
`)
if err != nil {
return fmt.Errorf("prepare firmware insert: %w", err)
}
defer func() { _ = stmt.Close() }()
for _, s := range rows {
raw := s.RawJSON
if raw == "" {
raw = "{}"
}
if _, err := stmt.ExecContext(ctx, s.RunID, s.Component, s.Identifier, s.Version, s.Vendor, raw); err != nil {
return fmt.Errorf("insert firmware %s/%s: %w", s.Component, s.Identifier, err)
}
}
return tx.Commit()
}
// ListForRun returns every firmware snapshot for a run in stable order.
// Report page + SpecValidate both read this.
func (f *Firmware) ListForRun(ctx context.Context, runID int64) ([]FirmwareSnapshot, error) {
rows, err := f.DB.QueryContext(ctx, `
SELECT id, run_id, component, identifier, version, vendor, raw_json
FROM firmware_snapshots WHERE run_id = ? ORDER BY id
`, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []FirmwareSnapshot
for rows.Next() {
var s FirmwareSnapshot
if err := rows.Scan(&s.ID, &s.RunID, &s.Component, &s.Identifier,
&s.Version, &s.Vendor, &s.RawJSON); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
+30 -12
View File
@@ -14,16 +14,30 @@ type Runs struct {
DB *sql.DB
}
// Create inserts a new run using the default "quick" profile. Older
// call sites (and most tests) target this form — the profile column's
// DEFAULT 'quick' on runs takes care of the backfill.
func (r *Runs) Create(ctx context.Context, hostID int64, tokenHash string, nonDestructive bool) (int64, error) {
return r.CreateWithProfile(ctx, hostID, tokenHash, nonDestructive, "quick")
}
// CreateWithProfile inserts a new run with an explicit profile
// ("quick"|"deep"|"soak"). The UI handler is the authoritative caller;
// empty profile falls back to "quick" so a misconfigured form doesn't
// leave a row with a blank profile column.
func (r *Runs) CreateWithProfile(ctx context.Context, hostID int64, tokenHash string, nonDestructive bool, profile string) (int64, error) {
if profile == "" {
profile = "quick"
}
now := time.Now().UTC()
nd := 0
if nonDestructive {
nd = 1
}
res, err := r.DB.ExecContext(ctx, `
INSERT INTO runs(host_id, state, agent_token_hash, next_boot_target, started_at, non_destructive)
VALUES(?,?,?,?,?,?)
`, hostID, string(model.StateQueued), tokenHash, "linux", now, nd)
INSERT INTO runs(host_id, state, agent_token_hash, next_boot_target, started_at, non_destructive, profile)
VALUES(?,?,?,?,?,?,?)
`, hostID, string(model.StateQueued), tokenHash, "linux", now, nd, profile)
if err != nil {
return 0, fmt.Errorf("insert run: %w", err)
}
@@ -107,14 +121,15 @@ func (r *Runs) Get(ctx context.Context, id int64) (*model.Run, error) {
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
COALESCE(next_boot_target,''), agent_token_hash, started_at,
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
COALESCE(override_flags_json,''), COALESCE(non_destructive,0),
COALESCE(profile,'quick')
FROM runs WHERE id = ?
`, id)
var run model.Run
var completedAt sql.NullTime
err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive)
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive, &run.Profile)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -133,7 +148,8 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
COALESCE(next_boot_target,''), agent_token_hash, started_at,
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
COALESCE(override_flags_json,''), COALESCE(non_destructive,0),
COALESCE(profile,'quick')
FROM runs WHERE host_id = ?
ORDER BY id DESC LIMIT 1
`, hostID)
@@ -141,7 +157,7 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
var completedAt sql.NullTime
err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive)
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive, &run.Profile)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -165,7 +181,8 @@ func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]mode
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
COALESCE(next_boot_target,''), agent_token_hash, started_at,
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
COALESCE(override_flags_json,''), COALESCE(non_destructive,0),
COALESCE(profile,'quick')
FROM runs
WHERE host_id = ?
ORDER BY id DESC
@@ -181,7 +198,7 @@ func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]mode
var completedAt sql.NullTime
if err := rows.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive); err != nil {
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive, &run.Profile); err != nil {
return nil, err
}
if completedAt.Valid {
@@ -206,7 +223,8 @@ func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
COALESCE(next_boot_target,''), agent_token_hash, started_at,
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
COALESCE(override_flags_json,''), COALESCE(non_destructive,0),
COALESCE(profile,'quick')
FROM runs
WHERE state NOT IN ('Completed','Released','Cancelled')
ORDER BY id
@@ -221,7 +239,7 @@ func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
var completedAt sql.NullTime
if err := rows.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive); err != nil {
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive, &run.Profile); err != nil {
return nil, err
}
if completedAt.Valid {
@@ -275,7 +293,7 @@ func (r *Runs) FindActiveByMAC(ctx context.Context, mac string) (*model.Run, err
var completedAt sql.NullTime
err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive)
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive, &run.Profile)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
+2
View File
@@ -17,11 +17,13 @@ type Stages struct {
// reaches Inventory; later phases add more executors but the list is fixed.
var DefaultStageOrder = []string{
"Inventory",
"Firmware",
"SpecValidate",
"SMART",
"CPUStress",
"Storage",
"Network",
"Burn",
"GPU",
"PSU",
"Reporting",
+280
View File
@@ -0,0 +1,280 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
)
// Threshold is the DB view of a per-run threshold row. Mirrors the
// orchestrator.Threshold value-object but keeps Severity/Op as strings
// so callers higher up don't force this package to import orchestrator.
type Threshold struct {
ID int64
RunID int64
Stage string
Kind string
Key string
Op string
Threshold float64
Nominal float64
Unit string
Severity string
Source string // profile|host_override
}
// ThresholdEvaluation is one recorded comparison — the evaluator calls
// this for every sample that matched a threshold, whether it passed
// or breached. The report page aggregates these to show the operator
// why a run failed (or was flagged as warning-only).
type ThresholdEvaluation struct {
ID int64
RunID int64
ThresholdID int64
Stage string
Kind string
Key string
TS time.Time
Observed float64
Passed bool
}
// Thresholds is the CRUD seam. Kept intentionally narrow: seed at run
// creation, list for evaluation on each sensor batch, record eval
// results, aggregate for the report.
type Thresholds struct {
DB *sql.DB
}
// ThresholdSpec is the caller-supplied shape for seeding — a flat
// value-object that carries the threshold rule plus its source so
// the ProfileRegistry-driven seed and per-host overrides converge
// on one insert path. Kept here (not in config) so the store layer
// doesn't have to import config.
type ThresholdSpec struct {
Stage string
Kind string
Key string
Op string
Value float64
Nominal float64
Unit string
Severity string
Source string
}
// SeedForRun converts the caller's specs into Threshold rows for the
// given run and bulk-inserts them. Returns the inserted rows with IDs
// populated so the evaluator can pin evaluations without a re-read.
func (t *Thresholds) SeedForRun(ctx context.Context, runID int64, specs []ThresholdSpec) ([]Threshold, error) {
rows := make([]Threshold, 0, len(specs))
for _, s := range specs {
rows = append(rows, Threshold{
RunID: runID,
Stage: s.Stage,
Kind: s.Kind,
Key: s.Key,
Op: s.Op,
Threshold: s.Value,
Nominal: s.Nominal,
Unit: s.Unit,
Severity: s.Severity,
Source: s.Source,
})
}
return t.CreateBatch(ctx, rows)
}
// Create inserts a single threshold row — used by the seed path when
// the orchestrator materializes per-run rules from the ProfileRegistry.
// Returns the row's ID so the evaluator can pin evaluations to it.
func (t *Thresholds) Create(ctx context.Context, th Threshold) (int64, error) {
res, err := t.DB.ExecContext(ctx, `
INSERT INTO thresholds(run_id, stage_name, kind, key, op, threshold, nominal, unit, severity, source)
VALUES(?,?,?,?,?,?,?,?,?,?)
`, th.RunID, th.Stage, th.Kind, th.Key, th.Op, th.Threshold, th.Nominal, th.Unit, th.Severity, th.Source)
if err != nil {
return 0, fmt.Errorf("insert threshold: %w", err)
}
return res.LastInsertId()
}
// CreateBatch is the fast path for run seeding — one transaction per
// run, one row per threshold. Returns the inserted rows with IDs set
// so the caller can drop them into the in-memory evaluator without a
// follow-up read.
func (t *Thresholds) CreateBatch(ctx context.Context, rows []Threshold) ([]Threshold, error) {
if len(rows) == 0 {
return nil, nil
}
tx, err := t.DB.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO thresholds(run_id, stage_name, kind, key, op, threshold, nominal, unit, severity, source)
VALUES(?,?,?,?,?,?,?,?,?,?)
`)
if err != nil {
return nil, fmt.Errorf("prepare threshold insert: %w", err)
}
defer func() { _ = stmt.Close() }()
out := make([]Threshold, 0, len(rows))
for _, th := range rows {
res, err := stmt.ExecContext(ctx, th.RunID, th.Stage, th.Kind, th.Key, th.Op,
th.Threshold, th.Nominal, th.Unit, th.Severity, th.Source)
if err != nil {
return nil, fmt.Errorf("insert threshold %s/%s: %w", th.Stage, th.Key, err)
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
th.ID = id
out = append(out, th)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return out, nil
}
// ListForRun returns every threshold seeded for a run, in stable ID
// order. Evaluator expects this to be cheap (few tens of rows per run)
// and pulls it on each /sensor batch.
func (t *Thresholds) ListForRun(ctx context.Context, runID int64) ([]Threshold, error) {
rows, err := t.DB.QueryContext(ctx, `
SELECT id, run_id, stage_name, kind, key, op, threshold, nominal, unit, severity, source
FROM thresholds WHERE run_id = ? ORDER BY id
`, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Threshold
for rows.Next() {
var th Threshold
if err := rows.Scan(&th.ID, &th.RunID, &th.Stage, &th.Kind, &th.Key,
&th.Op, &th.Threshold, &th.Nominal, &th.Unit, &th.Severity, &th.Source); err != nil {
return nil, err
}
out = append(out, th)
}
return out, rows.Err()
}
// RecordEvaluation persists a single evaluation outcome. Called per
// matching sample so the run's report has a full audit trail ("temp
// hit 95 at 14:22:03" rather than just "temp failed").
func (t *Thresholds) RecordEvaluation(ctx context.Context, ev ThresholdEvaluation) error {
passed := 0
if ev.Passed {
passed = 1
}
if ev.TS.IsZero() {
ev.TS = time.Now().UTC()
}
_, err := t.DB.ExecContext(ctx, `
INSERT INTO threshold_evaluations(run_id, threshold_id, stage_name, kind, key, ts, observed, passed)
VALUES(?,?,?,?,?,?,?,?)
`, ev.RunID, ev.ThresholdID, ev.Stage, ev.Kind, ev.Key, ev.TS, ev.Observed, passed)
if err != nil {
return fmt.Errorf("record evaluation: %w", err)
}
return nil
}
// RecordBatch persists a slice of evaluations in one transaction. The
// agent-handler hot path builds these one per sample and batches them
// under the same Sensor POST so we take one round-trip rather than N.
func (t *Thresholds) RecordBatch(ctx context.Context, evals []ThresholdEvaluation) error {
if len(evals) == 0 {
return nil
}
tx, err := t.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO threshold_evaluations(run_id, threshold_id, stage_name, kind, key, ts, observed, passed)
VALUES(?,?,?,?,?,?,?,?)
`)
if err != nil {
return fmt.Errorf("prepare eval insert: %w", err)
}
defer func() { _ = stmt.Close() }()
for _, ev := range evals {
passed := 0
if ev.Passed {
passed = 1
}
if ev.TS.IsZero() {
ev.TS = time.Now().UTC()
}
if _, err := stmt.ExecContext(ctx, ev.RunID, ev.ThresholdID, ev.Stage, ev.Kind, ev.Key, ev.TS, ev.Observed, passed); err != nil {
return fmt.Errorf("insert eval: %w", err)
}
}
return tx.Commit()
}
// ListEvaluations returns the evaluation history for a run, newest
// last. Bounded at a sane cap so a pathological run with a sample-per-
// second sidecar doesn't blow up the report page.
func (t *Thresholds) ListEvaluations(ctx context.Context, runID int64) ([]ThresholdEvaluation, error) {
rows, err := t.DB.QueryContext(ctx, `
SELECT id, run_id, threshold_id, stage_name, kind, key, ts, observed, passed
FROM threshold_evaluations WHERE run_id = ?
ORDER BY id LIMIT 5000
`, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ThresholdEvaluation
for rows.Next() {
var ev ThresholdEvaluation
var passed int
if err := rows.Scan(&ev.ID, &ev.RunID, &ev.ThresholdID, &ev.Stage, &ev.Kind,
&ev.Key, &ev.TS, &ev.Observed, &passed); err != nil {
return nil, err
}
ev.Passed = passed == 1
out = append(out, ev)
}
return out, rows.Err()
}
// CriticalBreaches returns the evaluations that fire the "fail the
// run" gate — critical-severity thresholds with passed=0. The
// agent-handler calls this at /result close so an aggregate breach
// (p99 latency > bound) still flips the run to FailedHolding even if
// no single sample tripped the fast-fail path.
func (t *Thresholds) CriticalBreaches(ctx context.Context, runID int64) ([]ThresholdEvaluation, error) {
rows, err := t.DB.QueryContext(ctx, `
SELECT e.id, e.run_id, e.threshold_id, e.stage_name, e.kind, e.key, e.ts, e.observed, e.passed
FROM threshold_evaluations e
JOIN thresholds t ON t.id = e.threshold_id
WHERE e.run_id = ? AND e.passed = 0 AND t.severity = 'critical'
ORDER BY e.id
`, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ThresholdEvaluation
for rows.Next() {
var ev ThresholdEvaluation
var passed int
if err := rows.Scan(&ev.ID, &ev.RunID, &ev.ThresholdID, &ev.Stage, &ev.Kind,
&ev.Key, &ev.TS, &ev.Observed, &passed); err != nil {
return nil, err
}
ev.Passed = passed == 1
out = append(out, ev)
}
return out, rows.Err()
}
+26
View File
@@ -636,6 +636,21 @@ body.bare main { max-width: none; }
.run-failed-stage { color: var(--danger); }
.run-failed-stage strong { font-family: var(--mono); }
.run-diffs { color: var(--danger); }
.run-profile-chip {
display: inline-block;
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .04em;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.15);
background: rgba(255,255,255,.05);
color: var(--text-dim);
}
.run-profile-quick { color: var(--accent); border-color: rgba(60,130,246,.45); background: rgba(60,130,246,.08); }
.run-profile-deep { color: #e5b94f; border-color: rgba(229,185,79,.45); background: rgba(229,185,79,.08); }
.run-profile-soak { color: #d97a57; border-color: rgba(217,122,87,.45); background: rgba(217,122,87,.08); }
.hold-banner {
background: rgba(229,100,102,.1);
@@ -890,6 +905,17 @@ body.bare main { max-width: none; }
.host-actions { padding: 0; }
.host-actions-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.host-nd-toggle { display: inline-flex; gap: 6px; align-items: center; color: var(--text-dim); font-size: 13px; }
.host-profile-picker {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 10px;
display: inline-flex;
gap: 12px;
align-items: center;
margin: 0 8px 0 0;
}
.host-profile-picker legend { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; padding: 0 4px; }
.host-profile-picker label { display: inline-flex; gap: 4px; align-items: center; font-family: var(--mono); font-size: 13px; cursor: pointer; }
.in-flight-banner-wrap { display: contents; }
.in-flight-banner {
+9 -9
View File
@@ -65,7 +65,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -88,7 +88,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 28, Col: 102}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 28, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -110,7 +110,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -123,7 +123,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(d.Stage.State)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 30, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 30, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -136,7 +136,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 31, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 31, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -149,7 +149,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDurationFromStage(d.Stage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 32, Col: 64}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 32, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -182,7 +182,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 43, Col: 99}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 43, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -195,7 +195,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 47, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 47, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -208,7 +208,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 48, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 48, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
+25
View File
@@ -102,6 +102,21 @@ templ HostActions(d HostPageData) {
<div class="host-actions-row">
if hostCanStart(d) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline host-start-form">
<fieldset class="host-profile-picker">
<legend>Profile</legend>
<label title="~10 min — post-repair sanity: all probes + gates, short budgets">
<input type="radio" name="profile" value="quick" checked/>
quick
</label>
<label title="~812 h — overnight soak: long CPU/RAM, full-disk fio verify, 30 min network">
<input type="radio" name="profile" value="deep"/>
deep
</label>
<label title="≥24 h — week-long burn-in; opt-in when you suspect intermittent faults">
<input type="radio" name="profile" value="soak"/>
soak
</label>
</fieldset>
<label class="host-nd-toggle">
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive (skip wipe-probe + disk writes)
@@ -258,6 +273,16 @@ func hostCanStartIfOnline(d HostPageData) bool {
return d.ActiveRun == nil
}
// profileChipValue normalizes a Run.Profile string for display on the
// run page chip. Older runs with an empty column predate Phase 1 — show
// them as "quick" (the prior implicit default).
func profileChipValue(p string) string {
if p == "" {
return "quick"
}
return p
}
// runDuration formats the elapsed time for a run using the same buckets
// as stageDuration. In-flight runs clock from StartedAt to now so the
// run-page header + runs-table row keep ticking on each SSE push.
+27 -17
View File
@@ -361,7 +361,7 @@ func HostActions(d HostPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline host-start-form\"><label class=\"host-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive (skip wipe-probe + disk writes)</label> <button type=\"submit\" class=\"btn-primary\">Start vetting</button></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline host-start-form\"><fieldset class=\"host-profile-picker\"><legend>Profile</legend> <label title=\"~10 min — post-repair sanity: all probes + gates, short budgets\"><input type=\"radio\" name=\"profile\" value=\"quick\" checked> quick</label> <label title=\"~812 h — overnight soak: long CPU/RAM, full-disk fio verify, 30 min network\"><input type=\"radio\" name=\"profile\" value=\"deep\"> deep</label> <label title=\"≥24 h — week-long burn-in; opt-in when you suspect intermittent faults\"><input type=\"radio\" name=\"profile\" value=\"soak\"> soak</label></fieldset><label class=\"host-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive (skip wipe-probe + disk writes)</label> <button type=\"submit\" class=\"btn-primary\">Start vetting</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -383,7 +383,7 @@ func HostActions(d HostPageData) templ.Component {
var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 116, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 131, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -428,7 +428,7 @@ func InFlightBanner(d HostPageData) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-inflight-%d", d.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 128, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 143, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@@ -441,7 +441,7 @@ func InFlightBanner(d HostPageData) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-inflight-%d", d.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 130, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 145, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -459,7 +459,7 @@ func InFlightBanner(d HostPageData) templ.Component {
var templ_7745c5c3_Var23 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.ActiveRun.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 134, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 149, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
@@ -472,7 +472,7 @@ func InFlightBanner(d HostPageData) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ActiveRun.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 135, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 150, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -485,7 +485,7 @@ func InFlightBanner(d HostPageData) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.ActiveRun))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 136, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 151, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -541,7 +541,7 @@ func HostEmptyState(d HostPageData) templ.Component {
var templ_7745c5c3_Var27 templ.SafeURL
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 152, Col: 88}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 167, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
@@ -655,7 +655,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("runrow-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 204, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 219, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -681,7 +681,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("runrow-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 206, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 221, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
@@ -694,7 +694,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var34 templ.SafeURL
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 210, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 225, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
@@ -707,7 +707,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 210, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 225, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
@@ -742,7 +742,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(&d.Run))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 213, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 228, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
@@ -755,7 +755,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(relativeTime(d.Run.StartedAt))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 215, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 230, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
@@ -768,7 +768,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 216, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 231, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
@@ -805,7 +805,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 221, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 236, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
@@ -823,7 +823,7 @@ func RunRow(d RunRowData) templ.Component {
var templ_7745c5c3_Var44 templ.SafeURL
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 226, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 241, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
@@ -867,6 +867,16 @@ func hostCanStartIfOnline(d HostPageData) bool {
return d.ActiveRun == nil
}
// profileChipValue normalizes a Run.Profile string for display on the
// run page chip. Older runs with an empty column predate Phase 1 — show
// them as "quick" (the prior implicit default).
func profileChipValue(p string) string {
if p == "" {
return "quick"
}
return p
}
// runDuration formats the elapsed time for a run using the same buckets
// as stageDuration. In-flight runs clock from StartedAt to now so the
// run-page header + runs-table row keep ticking on each SSE push.
+12 -12
View File
@@ -55,7 +55,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 19, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 19, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -68,7 +68,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -81,7 +81,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("tile-%d", t.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 21, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 21, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -94,7 +94,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 24, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -107,7 +107,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 24, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -120,7 +120,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 26, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -142,7 +142,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var9).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -155,7 +155,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 28, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 28, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -168,7 +168,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 29, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -186,7 +186,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 34, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 34, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -209,7 +209,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 44, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 44, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -227,7 +227,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 48, Col: 88}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 48, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
+2 -2
View File
@@ -36,7 +36,7 @@ func Layout(title string) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 9, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 9, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -86,7 +86,7 @@ func BareLayout(title string) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 39, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 39, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
+4
View File
@@ -40,11 +40,13 @@ func runStateRank(s model.RunState) int {
model.StateWaitingReboot,
model.StateBooting,
model.StateInventoryCheck,
model.StateFirmware,
model.StateSpecValidate,
model.StateSMART,
model.StateCPUStress,
model.StateStorage,
model.StateNetwork,
model.StateBurn,
model.StateGPU,
model.StatePSU,
model.StateReporting,
@@ -205,11 +207,13 @@ func firstStageState(run *model.Run) model.RunState {
func stageStateByName(name string) (model.RunState, bool) {
m := map[string]model.RunState{
"Inventory": model.StateInventoryCheck,
"Firmware": model.StateFirmware,
"SpecValidate": model.StateSpecValidate,
"SMART": model.StateSMART,
"CPUStress": model.StateCPUStress,
"Storage": model.StateStorage,
"Network": model.StateNetwork,
"Burn": model.StateBurn,
"GPU": model.StateGPU,
"PSU": model.StatePSU,
"Reporting": model.StateReporting,
+12 -8
View File
@@ -48,11 +48,13 @@ func runStateRank(s model.RunState) int {
model.StateWaitingReboot,
model.StateBooting,
model.StateInventoryCheck,
model.StateFirmware,
model.StateSpecValidate,
model.StateSMART,
model.StateCPUStress,
model.StateStorage,
model.StateNetwork,
model.StateBurn,
model.StateGPU,
model.StatePSU,
model.StateReporting,
@@ -213,11 +215,13 @@ func firstStageState(run *model.Run) model.RunState {
func stageStateByName(name string) (model.RunState, bool) {
m := map[string]model.RunState{
"Inventory": model.StateInventoryCheck,
"Firmware": model.StateFirmware,
"SpecValidate": model.StateSpecValidate,
"SMART": model.StateSMART,
"CPUStress": model.StateCPUStress,
"Storage": model.StateStorage,
"Network": model.StateNetwork,
"Burn": model.StateBurn,
"GPU": model.StateGPU,
"PSU": model.StatePSU,
"Reporting": model.StateReporting,
@@ -312,7 +316,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -339,7 +343,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -361,7 +365,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -374,7 +378,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 275, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 279, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -387,7 +391,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(n.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 276, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 280, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -400,7 +404,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 277, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 281, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -454,7 +458,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 292, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 296, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -467,7 +471,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 294, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 298, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
+34 -30
View File
@@ -8,26 +8,28 @@ import (
)
// node indexes for the default pipeline layout: pre-stages (3) + stage
// rows (9) + terminal Completed (1) = 13 nodes.
// rows (11) + terminal Completed (1) = 15 nodes.
const (
idxQueued = 0
idxWaitingReboot = 1
idxBooting = 2
idxInventory = 3
idxSpecValidate = 4
idxSMART = 5
idxCPUStress = 6
idxStorage = 7
idxNetwork = 8
idxGPU = 9
idxPSU = 10
idxReporting = 11
idxCompleted = 12
idxFirmware = 4
idxSpecValidate = 5
idxSMART = 6
idxCPUStress = 7
idxStorage = 8
idxNetwork = 9
idxBurn = 10
idxGPU = 11
idxPSU = 12
idxReporting = 13
idxCompleted = 14
)
// seedStages returns a fresh all-pending stage slice in the canonical order.
func seedStages() []model.Stage {
names := []string{"Inventory", "SpecValidate", "SMART", "CPUStress", "Storage", "Network", "GPU", "PSU", "Reporting"}
names := []string{"Inventory", "Firmware", "SpecValidate", "SMART", "CPUStress", "Storage", "Network", "Burn", "GPU", "PSU", "Reporting"}
out := make([]model.Stage, len(names))
for i, n := range names {
out[i] = model.Stage{Name: n, Ordinal: i, State: model.StagePending}
@@ -37,10 +39,10 @@ func seedStages() []model.Stage {
func TestBuildPipeline_NoRun(t *testing.T) {
nodes := BuildPipeline(nil, nil)
// Ghost pipeline: 3 pre-stages + 9 stage ghosts + 1 terminal = 13
// Ghost pipeline: 3 pre-stages + 10 stage ghosts + 1 terminal = 14
// nodes, all pending.
if len(nodes) != 13 {
t.Fatalf("len = %d, want 13", len(nodes))
if len(nodes) != 15 {
t.Fatalf("len = %d, want 15", len(nodes))
}
for i, n := range nodes {
if n.State != "pending" {
@@ -56,8 +58,8 @@ func TestBuildPipeline_NoRun(t *testing.T) {
func TestBuildPipeline_GhostStagesBeforeClaim(t *testing.T) {
run := &model.Run{State: model.StateWaitingReboot}
nodes := BuildPipeline(run, nil)
if len(nodes) != 13 {
t.Fatalf("len = %d, want 13", len(nodes))
if len(nodes) != 15 {
t.Fatalf("len = %d, want 15", len(nodes))
}
if nodes[idxQueued].State != "passed" {
t.Errorf("Queued = %q, want passed", nodes[idxQueued].State)
@@ -65,7 +67,7 @@ func TestBuildPipeline_GhostStagesBeforeClaim(t *testing.T) {
if nodes[idxWaitingReboot].State != "running" {
t.Errorf("WaitingReboot = %q, want running", nodes[idxWaitingReboot].State)
}
// All 9 stage ghosts must be pending — nothing has started yet.
// All 11 stage ghosts must be pending — nothing has started yet.
for i := idxInventory; i <= idxReporting; i++ {
if nodes[i].State != "pending" {
t.Errorf("%s (ghost) = %q, want pending", nodes[i].Name, nodes[i].State)
@@ -81,19 +83,20 @@ func TestBuildPipeline_GhostStagesBeforeClaim(t *testing.T) {
// pending ghosts rather than silently disappearing.
func TestBuildPipeline_GhostStagesDuringStage(t *testing.T) {
run := &model.Run{State: model.StateSMART}
// Only Inventory + SpecValidate seeded; SMART onwards are ghosts.
// Only Inventory + Firmware + SpecValidate seeded; SMART onwards are ghosts.
stages := []model.Stage{
{Name: "Inventory", Ordinal: 0, State: model.StagePassed},
{Name: "SpecValidate", Ordinal: 1, State: model.StagePassed},
{Name: "Firmware", Ordinal: 1, State: model.StagePassed},
{Name: "SpecValidate", Ordinal: 2, State: model.StagePassed},
}
nodes := BuildPipeline(run, stages)
if len(nodes) != 13 {
t.Fatalf("len = %d, want 13", len(nodes))
if len(nodes) != 15 {
t.Fatalf("len = %d, want 15", len(nodes))
}
if nodes[idxSMART].State != "running" {
t.Errorf("SMART (ghost) = %q, want running", nodes[idxSMART].State)
}
for _, i := range []int{idxCPUStress, idxStorage, idxNetwork, idxGPU, idxPSU, idxReporting} {
for _, i := range []int{idxCPUStress, idxStorage, idxNetwork, idxBurn, idxGPU, idxPSU, idxReporting} {
if nodes[i].State != "pending" {
t.Errorf("%s (ghost) = %q, want pending", nodes[i].Name, nodes[i].State)
}
@@ -103,12 +106,13 @@ func TestBuildPipeline_GhostStagesDuringStage(t *testing.T) {
func TestBuildPipeline_Running(t *testing.T) {
run := &model.Run{State: model.StateSMART}
stages := seedStages()
stages[0].State = model.StagePassed
stages[1].State = model.StagePassed
stages[2].State = model.StageRunning
stages[0].State = model.StagePassed // Inventory
stages[1].State = model.StagePassed // Firmware
stages[2].State = model.StagePassed // SpecValidate
stages[3].State = model.StageRunning // SMART
nodes := BuildPipeline(run, stages)
if len(nodes) != 13 {
t.Fatalf("len = %d, want 13", len(nodes))
if len(nodes) != 15 {
t.Fatalf("len = %d, want 15", len(nodes))
}
// Pre-stages are all past for a run that has reached SMART.
for i := idxQueued; i <= idxBooting; i++ {
@@ -136,10 +140,10 @@ func TestBuildPipeline_Running(t *testing.T) {
func TestBuildPipeline_Failed(t *testing.T) {
run := &model.Run{State: model.StateFailedHolding, FailedStage: "Storage"}
stages := seedStages()
for i := 0; i <= 3; i++ {
for i := 0; i <= 4; i++ {
stages[i].State = model.StagePassed
}
stages[4].State = model.StageFailed // Storage
stages[5].State = model.StageFailed // Storage
nodes := BuildPipeline(run, stages)
// Pre-stages are past a run that reached Storage.
for i := idxQueued; i <= idxBooting; i++ {
@@ -150,7 +154,7 @@ func TestBuildPipeline_Failed(t *testing.T) {
if nodes[idxStorage].State != "failed" {
t.Errorf("Storage = %q, want failed", nodes[idxStorage].State)
}
for _, i := range []int{idxNetwork, idxGPU, idxPSU, idxReporting} {
for _, i := range []int{idxNetwork, idxBurn, idxGPU, idxPSU, idxReporting} {
if nodes[i].State != "skipped" {
t.Errorf("%s = %q, want skipped", nodes[i].Name, nodes[i].State)
}
+8 -8
View File
@@ -64,7 +64,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Error)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 22, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 22, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -83,7 +83,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 28, Col: 108}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 28, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -101,7 +101,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 38, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -114,7 +114,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.MAC)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 42, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 42, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -127,7 +127,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(form.WoLBroadcastIP)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 47, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -140,7 +140,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(defaultPort(form.WoLPort))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 51, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 51, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -153,7 +153,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(form.ExpectedSpecYAML)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 127}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 56, Col: 127}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -166,7 +166,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(form.Notes)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 60, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 60, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
+1
View File
@@ -83,6 +83,7 @@ templ RunHeader(d RunPageData) {
<div class="run-header-left">
<h1 class="run-header-name">{ fmt.Sprintf("Run #%d", d.Run.ID) }</h1>
<span class={ "run-status-badge", "run-status-" + tileMood(&d.Run) }>{ tileStatus(&d.Run) }</span>
<span class={ "run-profile-chip", "run-profile-" + profileChipValue(d.Run.Profile) }>{ profileChipValue(d.Run.Profile) }</span>
<span class="run-duration">{ runDuration(&d.Run) }</span>
if d.Run.FailedStage != "" {
<span class="run-failed-stage">failed at <strong>{ d.Run.FailedStage }</strong></span>
+242 -207
View File
@@ -286,142 +286,177 @@ func RunHeader(d RunPageData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span> <span class=\"run-duration\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 86, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
var templ_7745c5c3_Var15 = []any{"run-profile-chip", "run-profile-" + profileChipValue(d.Run.Profile)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var15).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(profileChipValue(d.Run.Profile))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 86, Col: 121}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span> <span class=\"run-duration\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 87, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Run.FailedStage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"run-failed-stage\">failed at <strong>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span class=\"run-failed-stage\">failed at <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Run.FailedStage)
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(d.Run.FailedStage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 88, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</strong></span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.SpecDiffCritical > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"run-diffs bad\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical diff", d.SpecDiffCritical))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 91, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div class=\"run-header-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canCancel(&d.Run) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 templ.SafeURL
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 96, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"inline\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"btn-danger\">Cancel run</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if canOverrideWipe(&d.Run) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 101, Col: 97}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 89, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"inline\"><button type=\"submit\" class=\"btn-danger\">Override wipe-probe</button></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</strong></span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if hasReport(&d.Run) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<a class=\"button-like\" href=\"")
if d.SpecDiffCritical > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"run-diffs bad\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Run.ID)))
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical diff", d.SpecDiffCritical))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 106, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 92, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" target=\"_blank\" rel=\"noopener\">View report</a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.Run.State.IsTerminal() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<form method=\"post\" action=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div><div class=\"run-header-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canCancel(&d.Run) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)))
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 109, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 97, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"inline\"><button type=\"submit\" class=\"btn-primary\">Start new run</button></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"inline\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"btn-danger\">Cancel run</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></header>")
if canOverrideWipe(&d.Run) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 templ.SafeURL
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 102, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" class=\"inline\"><button type=\"submit\" class=\"btn-danger\">Override wipe-probe</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if hasReport(&d.Run) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<a class=\"button-like\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Run.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 107, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" target=\"_blank\" rel=\"noopener\">View report</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.Run.State.IsTerminal() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 templ.SafeURL
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 110, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"inline\"><button type=\"submit\" class=\"btn-primary\">Start new run</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div></header>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -449,83 +484,83 @@ func HoldBanner(d RunPageData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
if templ_7745c5c3_Var22 == nil {
templ_7745c5c3_Var22 = templ.NopComponent
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
if templ_7745c5c3_Var25 == nil {
templ_7745c5c3_Var25 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if d.Run.State == model.StateFailedHolding && d.Run.HoldIP != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 124, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"hold-banner\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 126, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" hx-swap=\"outerHTML\"><span class=\"hold-banner-label\">Host is holding — SSH available:</span> <code class=\"hold-ssh\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.HoldKeyPath, d.Run.HoldIP))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 130, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</code></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<section id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 134, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 125, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" class=\"detail-hold-placeholder\" sse-swap=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"hold-banner\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 136, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 127, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" hx-swap=\"outerHTML\"></section>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" hx-swap=\"outerHTML\"><span class=\"hold-banner-label\">Host is holding — SSH available:</span> <code class=\"hold-ssh\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.HoldKeyPath, d.Run.HoldIP))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 131, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</code></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<section id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 135, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" class=\"detail-hold-placeholder\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 137, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-swap=\"outerHTML\"></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -553,138 +588,138 @@ func RunSpecDiffs(d RunPageData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
if templ_7745c5c3_Var28 == nil {
templ_7745c5c3_Var28 = templ.NopComponent
templ_7745c5c3_Var31 := templ.GetChildren(ctx)
if templ_7745c5c3_Var31 == nil {
templ_7745c5c3_Var31 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<section id=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<section id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 147, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 148, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" class=\"detail-section detail-diffs\" sse-swap=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" class=\"detail-section detail-diffs\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 149, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 150, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-swap=\"outerHTML\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(d.SpecDiffs) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<details")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<details")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasCriticalDiff(d.SpecDiffs) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " open")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " open")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "><summary><h2>Spec diffs (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "><summary><h2>Spec diffs (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 154, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 155, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ")</h2></summary><ul class=\"diff-list\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ")</h2></summary><ul class=\"diff-list\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, diff := range d.SpecDiffs {
var templ_7745c5c3_Var32 = []any{"diff-row", "diff-" + diff.Severity}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
var templ_7745c5c3_Var35 = []any{"diff-row", "diff-" + diff.Severity}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var35...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var32).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><div class=\"diff-field\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 158, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div><div class=\"diff-expected\">expected: <code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 159, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code></div><div class=\"diff-actual\">actual: <code>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var35).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 160, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</code></div></li>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"><div class=\"diff-field\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 159, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div><div class=\"diff-expected\">expected: <code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 160, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</code></div><div class=\"diff-actual\">actual: <code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 161, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</code></div></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</ul></details>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</ul></details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</section>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+7 -7
View File
@@ -99,7 +99,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 63, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 63, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -112,7 +112,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -125,7 +125,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 65, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 65, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -147,7 +147,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -160,7 +160,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(subStepMarker(ss.State))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 68, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 68, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -173,7 +173,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(ss.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 69, Col: 38}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 69, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -186,7 +186,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(subStepDuration(ss))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 70, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 70, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {