diff --git a/agent/client.go b/agent/client.go
index 313368e..56dc64c 100644
--- a/agent/client.go
+++ b/agent/client.go
@@ -150,6 +150,19 @@ type ResultResponse struct {
NextState string `json:"next_state"`
}
+// SubStepReport is the wire shape the agent POSTs inside /result for
+// each granular sub-step (CPU/Memory pass, per-disk SMART, per-device
+// GPU, …). Ordinal is assigned by the server in slice order; the agent
+// doesn't set it. Summary is opaque JSON the UI may render later.
+type SubStepReport struct {
+ Name string `json:"name"`
+ Passed bool `json:"passed"`
+ Skipped bool `json:"skipped,omitempty"`
+ StartedAt string `json:"started_at,omitempty"`
+ CompletedAt string `json:"completed_at,omitempty"`
+ Summary json.RawMessage `json:"summary,omitempty"`
+}
+
type HoldResponse struct {
AuthorizedKey string `json:"authorized_key"`
RunID int64 `json:"run_id"`
diff --git a/agent/runner.go b/agent/runner.go
index 0c482e2..5141cd2 100644
--- a/agent/runner.go
+++ b/agent/runner.go
@@ -276,6 +276,25 @@ func postResult(ctx context.Context, c *Client, stage string, s stageOutcome) (*
if s.Inventory != nil {
body["inventory"] = s.Inventory
}
+ if len(s.Outcome.SubSteps) > 0 {
+ wire := make([]SubStepReport, 0, len(s.Outcome.SubSteps))
+ for _, ss := range s.Outcome.SubSteps {
+ w := SubStepReport{
+ Name: ss.Name,
+ Passed: ss.Passed,
+ Skipped: ss.Skipped,
+ Summary: ss.SummaryJSON,
+ }
+ if !ss.StartedAt.IsZero() {
+ w.StartedAt = ss.StartedAt.UTC().Format(time.RFC3339Nano)
+ }
+ if !ss.CompletedAt.IsZero() {
+ w.CompletedAt = ss.CompletedAt.UTC().Format(time.RFC3339Nano)
+ }
+ wire = append(wire, w)
+ }
+ body["sub_steps"] = wire
+ }
return c.Result(ctx, body)
}
diff --git a/agent/tests/cpustress.go b/agent/tests/cpustress.go
index 88ff1bc..857d007 100644
--- a/agent/tests/cpustress.go
+++ b/agent/tests/cpustress.go
@@ -3,6 +3,7 @@ package tests
import (
"bufio"
"context"
+ "encoding/json"
"fmt"
"io"
"os"
@@ -52,6 +53,7 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
cores := runtime.NumCPU()
extras := map[string]any{"cores": cores}
+ var subs []SubStepReport
// Pass 1: CPU
cpu := runStressPass(ctx, d, "CPU", cpuPassDuration, []string{
@@ -62,12 +64,14 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
"--verify",
})
extras["cpu_pass"] = cpu
+ subs = append(subs, subStepFromPass("CPU pass", cpu))
if !cpu.Passed {
return Outcome{
- Passed: false,
- Message: "CPU pass failed: " + cpu.Err,
- Summary: fmt.Sprintf("CPU pass failed after %ds", cpu.ElapsedSecs),
- Extras: extras,
+ Passed: false,
+ Message: "CPU pass failed: " + cpu.Err,
+ Summary: fmt.Sprintf("CPU pass failed after %ds", cpu.ElapsedSecs),
+ Extras: extras,
+ SubSteps: subs,
}
}
@@ -77,10 +81,11 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
if err != nil {
d.Error("CPUStress: read MemAvailable: " + err.Error())
return Outcome{
- Passed: false,
- Message: "read MemAvailable: " + err.Error(),
- Summary: "failed (meminfo unreadable)",
- Extras: extras,
+ Passed: false,
+ Message: "read MemAvailable: " + err.Error(),
+ Summary: "failed (meminfo unreadable)",
+ Extras: extras,
+ SubSteps: subs,
}
}
cap := avail - memHeadroomBytes
@@ -92,10 +97,11 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
avail, memFloorBytes, memHeadroomBytes)
d.Error("CPUStress: " + msg)
return Outcome{
- Passed: false,
- Message: msg,
- Summary: "failed (insufficient free RAM for memory pass)",
- Extras: extras,
+ Passed: false,
+ Message: msg,
+ Summary: "failed (insufficient free RAM for memory pass)",
+ Extras: extras,
+ SubSteps: subs,
}
}
mem := runStressPass(ctx, d, "memory", memPassDuration, []string{
@@ -107,12 +113,14 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
"--verify",
})
extras["mem_pass"] = mem
+ subs = append(subs, subStepFromPass(fmt.Sprintf("Memory pass (cap %s)", humanBytes(cap)), mem))
if !mem.Passed {
return Outcome{
- Passed: false,
- Message: "memory pass failed: " + mem.Err,
- Summary: fmt.Sprintf("memory pass failed after %ds", mem.ElapsedSecs),
- Extras: extras,
+ Passed: false,
+ Message: "memory pass failed: " + mem.Err,
+ Summary: fmt.Sprintf("memory pass failed after %ds", mem.ElapsedSecs),
+ Extras: extras,
+ SubSteps: subs,
}
}
@@ -120,7 +128,26 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
Passed: true,
Summary: fmt.Sprintf("CPU+RAM PASSED (%d cores, %s cap)",
cores, humanBytes(cap)),
- Extras: extras,
+ Extras: extras,
+ SubSteps: subs,
+ }
+}
+
+// subStepFromPass projects a stressPass into a SubStepReport — shared by
+// both passes and by the mid-stage early-return paths so the UI always
+// sees exactly one row per pass, even on failure.
+func subStepFromPass(name string, p stressPass) SubStepReport {
+ summary, _ := json.Marshal(map[string]any{
+ "elapsed_secs": p.ElapsedSecs,
+ "target_secs": p.TargetSecs,
+ "err": p.Err,
+ })
+ return SubStepReport{
+ Name: name,
+ Passed: p.Passed,
+ StartedAt: p.StartedAt,
+ CompletedAt: p.CompletedAt,
+ SummaryJSON: summary,
}
}
@@ -140,12 +167,16 @@ const (
// stressPass is the per-pass result embedded in CPUStress's Extras.
// Passed==true and Elapsed close to target is the only happy path.
+// StartedAt/CompletedAt are not serialized (the summary already has
+// ElapsedSecs) but are used by the caller to emit SubStepReport rows.
type stressPass struct {
- Passed bool `json:"passed"`
- Err string `json:"err,omitempty"`
- ElapsedSecs int `json:"elapsed_secs"`
- TargetSecs int `json:"target_secs"`
- OutputTail string `json:"output_tail,omitempty"`
+ Passed bool `json:"passed"`
+ Err string `json:"err,omitempty"`
+ ElapsedSecs int `json:"elapsed_secs"`
+ TargetSecs int `json:"target_secs"`
+ OutputTail string `json:"output_tail,omitempty"`
+ StartedAt time.Time `json:"-"`
+ CompletedAt time.Time `json:"-"`
}
// runStressPass invokes stress-ng and validates both exit code and
@@ -159,12 +190,15 @@ func runStressPass(ctx context.Context, d Deps, label string, target time.Durati
cmd := exec.CommandContext(runCtx, "stress-ng", args...)
start := time.Now()
out, err := cmd.CombinedOutput()
- elapsed := time.Since(start)
+ end := time.Now()
+ elapsed := end.Sub(start)
res := stressPass{
ElapsedSecs: int(elapsed.Round(time.Second).Seconds()),
TargetSecs: int(target.Round(time.Second).Seconds()),
OutputTail: tailLines(string(out), 20),
+ StartedAt: start,
+ CompletedAt: end,
}
if err != nil {
res.Err = err.Error()
diff --git a/agent/tests/gpu.go b/agent/tests/gpu.go
index 04963a6..5778751 100644
--- a/agent/tests/gpu.go
+++ b/agent/tests/gpu.go
@@ -2,8 +2,11 @@ package tests
import (
"context"
+ "encoding/json"
+ "fmt"
"os/exec"
"strings"
+ "time"
)
// GPU enumerates VGA / 3D PCI devices. No devices → skip cleanly (a
@@ -11,7 +14,9 @@ import (
// stress). Devices present → try nvidia-smi for NVIDIA cards, else
// accept PCI presence.
func GPU(ctx context.Context, d Deps) Outcome {
+ pciStart := time.Now()
devices := listGPUPCI(ctx)
+ pciEnd := time.Now()
if len(devices) == 0 {
d.Info("GPU: no VGA/3D PCI devices found — skipping stage")
return Outcome{
@@ -22,7 +27,9 @@ func GPU(ctx context.Context, d Deps) Outcome {
}
d.Info("GPU: found " + joinDevices(devices))
+ nvStart := time.Now()
nvidia := nvidiaSmiList(ctx)
+ nvEnd := time.Now()
extras := map[string]any{
"pci_devices": devices,
"skipped": false,
@@ -31,10 +38,39 @@ func GPU(ctx context.Context, d Deps) Outcome {
extras["nvidia"] = nvidia
d.Info("GPU: nvidia-smi reports: " + strings.Join(nvidia, ", "))
}
+
+ // Sub-step rows: one per enumerated PCI device, plus (optionally) one
+ // per NVIDIA card when nvidia-smi sees anything. PCI enumeration runs
+ // once for all devices — we bracket that single invocation by
+ // pciStart/pciEnd and attribute the window to each device row so the
+ // UI can still slice the log per row by time.
+ var subs []SubStepReport
+ for i, dev := range devices {
+ summary, _ := json.Marshal(map[string]any{"pci": dev, "ordinal": i})
+ subs = append(subs, SubStepReport{
+ Name: fmt.Sprintf("pci #%d", i),
+ Passed: true,
+ StartedAt: pciStart,
+ CompletedAt: pciEnd,
+ SummaryJSON: summary,
+ })
+ }
+ for i, line := range nvidia {
+ summary, _ := json.Marshal(map[string]any{"nvidia_smi": line})
+ subs = append(subs, SubStepReport{
+ Name: fmt.Sprintf("nvidia #%d", i),
+ Passed: true,
+ StartedAt: nvStart,
+ CompletedAt: nvEnd,
+ SummaryJSON: summary,
+ })
+ }
+
return Outcome{
- Passed: true,
- Summary: formatCount(len(devices), "GPU present"),
- Extras: extras,
+ Passed: true,
+ Summary: formatCount(len(devices), "GPU present"),
+ Extras: extras,
+ SubSteps: subs,
}
}
diff --git a/agent/tests/smart.go b/agent/tests/smart.go
index ca3888d..dedec16 100644
--- a/agent/tests/smart.go
+++ b/agent/tests/smart.go
@@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"strings"
+ "time"
)
// SMART runs smartctl -a on each block device the kernel exposes. We
@@ -46,25 +47,21 @@ func SMART(ctx context.Context, d Deps) Outcome {
return Outcome{Passed: true, Summary: "skipped (no disks)", Extras: map[string]any{"skipped": true}}
}
- type diskReport struct {
- Device string `json:"device"`
- Passed bool `json:"passed"`
- Skipped bool `json:"skipped,omitempty"`
- Reason string `json:"reason,omitempty"`
- Raw map[string]any `json:"raw,omitempty"`
- }
-
- var reports []diskReport
+ var reports []smartDiskReport
+ var subs []SubStepReport
failed := 0
usable := 0
for _, dev := range disks {
- rep := diskReport{Device: dev}
+ rep := smartDiskReport{Device: dev}
+ started := time.Now()
out, err := runSmartctl(ctx, dev)
+ ended := time.Now()
if err != nil {
rep.Skipped = true
rep.Reason = err.Error()
reports = append(reports, rep)
d.Info("SMART: " + dev + " skipped (" + err.Error() + ")")
+ subs = append(subs, subStepFromSMART(dev, rep, started, ended))
continue
}
usable++
@@ -82,6 +79,7 @@ func SMART(ctx context.Context, d Deps) Outcome {
rep.Reason = "no smart_status in output"
}
reports = append(reports, rep)
+ subs = append(subs, subStepFromSMART(dev, rep, started, ended))
}
extras := map[string]any{
@@ -91,10 +89,11 @@ func SMART(ctx context.Context, d Deps) Outcome {
}
if failed > 0 {
return Outcome{
- Passed: false,
- Message: fmt.Sprintf("%d disk(s) report SMART FAILED", failed),
- Summary: fmt.Sprintf("%d/%d failing", failed, usable),
- Extras: extras,
+ Passed: false,
+ Message: fmt.Sprintf("%d disk(s) report SMART FAILED", failed),
+ Summary: fmt.Sprintf("%d/%d failing", failed, usable),
+ Extras: extras,
+ SubSteps: subs,
}
}
summary := fmt.Sprintf("%d disks, %d SMART-reporting, all PASSED", len(disks), usable)
@@ -102,7 +101,36 @@ func SMART(ctx context.Context, d Deps) Outcome {
summary = "skipped (no smartctl data on any disk)"
extras["skipped"] = true
}
- return Outcome{Passed: true, Summary: summary, Extras: extras}
+ return Outcome{Passed: true, Summary: summary, Extras: extras, SubSteps: subs}
+}
+
+// smartDiskReport is the per-disk probe result. Lifted to package scope
+// so subStepFromSMART can accept it by value.
+type smartDiskReport struct {
+ Device string `json:"device"`
+ Passed bool `json:"passed"`
+ Skipped bool `json:"skipped,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Raw map[string]any `json:"raw,omitempty"`
+}
+
+// subStepFromSMART builds a per-disk sub-step row from the in-flight
+// report. "skipped" takes precedence over passed so virtio-blk etc.
+// render as skipped rather than failed in the UI.
+func subStepFromSMART(dev string, rep smartDiskReport, started, ended time.Time) SubStepReport {
+ summary, _ := json.Marshal(map[string]any{
+ "device": rep.Device,
+ "reason": rep.Reason,
+ "skipped": rep.Skipped,
+ })
+ return SubStepReport{
+ Name: fmt.Sprintf("smartctl %s", dev),
+ Passed: rep.Passed || rep.Skipped,
+ Skipped: rep.Skipped,
+ StartedAt: started,
+ CompletedAt: ended,
+ SummaryJSON: summary,
+ }
}
func listBlockDisks() ([]string, error) {
diff --git a/agent/tests/stage.go b/agent/tests/stage.go
index 9066357..4acffdd 100644
--- a/agent/tests/stage.go
+++ b/agent/tests/stage.go
@@ -16,11 +16,30 @@ import (
// - Message is only used on failure; the UI displays it in the log.
// - Extras is merged into the posted summary so stages can add
// their own shape (e.g. Storage returns per-disk probe results).
+// - SubSteps carries agent-authored sub-step rows (CPU/Memory passes,
+// per-disk SMART, per-device GPU, …). Empty for stages with no
+// natural breakdown; persisted verbatim by the /result handler.
type Outcome struct {
- Passed bool
- Message string
- Summary string // short human-readable one-liner
- Extras map[string]any // merged into posted summary JSON
+ Passed bool
+ Message string
+ Summary string // short human-readable one-liner
+ Extras map[string]any // merged into posted summary JSON
+ SubSteps []SubStepReport // agent-authored granular rows
+}
+
+// SubStepReport is one entry a stage contributes to its sub-step list.
+// Ordinal is assigned in the order entries appear in the slice — the
+// agent shouldn't set it manually. State is derived from Passed/Skipped
+// the same way Outcome is: Skipped wins if set, else Passed ? passed :
+// failed. StartedAt/CompletedAt are required so the UI can order rows
+// and slice the stage log by time window.
+type SubStepReport struct {
+ Name string
+ Passed bool
+ Skipped bool
+ StartedAt time.Time
+ CompletedAt time.Time
+ SummaryJSON json.RawMessage
}
// MarshalSummary builds the summary JSON body POSTed to /result.
diff --git a/agent/tests/storage.go b/agent/tests/storage.go
index 681cb72..0c5e78e 100644
--- a/agent/tests/storage.go
+++ b/agent/tests/storage.go
@@ -91,12 +91,35 @@ func Storage(ctx context.Context, d Deps) Outcome {
// Per target: short badblocks write sample + fio random-read/write.
var samples []Sample
+ var subs []SubStepReport
perDisk := map[string]any{}
for _, t := range targets {
d.Info("Storage: running badblocks write sample on " + t.Device)
+ bbStart := time.Now()
bb := runBadblocks(ctx, t.Device)
+ bbEnd := time.Now()
+ bbSummary, _ := json.Marshal(bb)
+ subs = append(subs, SubStepReport{
+ Name: fmt.Sprintf("badblocks %s", t.Device),
+ Passed: bb.OK,
+ StartedAt: bbStart,
+ CompletedAt: bbEnd,
+ SummaryJSON: bbSummary,
+ })
+
d.Info(fmt.Sprintf("Storage: running fio random rw on %s", t.Device))
+ fioStart := time.Now()
fr := runFio(ctx, t.Device)
+ fioEnd := time.Now()
+ fioSummary, _ := json.Marshal(fr)
+ subs = append(subs, SubStepReport{
+ Name: fmt.Sprintf("fio %s", t.Device),
+ Passed: fr.Error == "",
+ StartedAt: fioStart,
+ CompletedAt: fioEnd,
+ SummaryJSON: fioSummary,
+ })
+
perDisk[t.Device] = map[string]any{
"badblocks": bb,
"fio": fr,
@@ -107,10 +130,11 @@ func Storage(ctx context.Context, d Deps) Outcome {
)
if !bb.OK {
return Outcome{
- Passed: false,
- Message: "badblocks found errors on " + t.Device,
- Summary: "badblocks failed on " + t.Device,
- Extras: map[string]any{"per_disk": perDisk, "wipe_probe": probes},
+ Passed: false,
+ Message: "badblocks found errors on " + t.Device,
+ Summary: "badblocks failed on " + t.Device,
+ Extras: map[string]any{"per_disk": perDisk, "wipe_probe": probes},
+ SubSteps: subs,
}
}
}
@@ -120,9 +144,10 @@ func Storage(ctx context.Context, d Deps) Outcome {
d.Info(fmt.Sprintf("Storage: %d disk(s) passed badblocks + fio", len(targets)))
return Outcome{
- Passed: true,
- Summary: fmt.Sprintf("%d disks passed", len(targets)),
- Extras: map[string]any{"per_disk": perDisk, "wipe_probe": probes},
+ Passed: true,
+ Summary: fmt.Sprintf("%d disks passed", len(targets)),
+ Extras: map[string]any{"per_disk": perDisk, "wipe_probe": probes},
+ SubSteps: subs,
}
}
diff --git a/cmd/vetting/main.go b/cmd/vetting/main.go
index 326c4c2..8a36159 100644
--- a/cmd/vetting/main.go
+++ b/cmd/vetting/main.go
@@ -56,6 +56,7 @@ func main() {
hostStore := &store.Hosts{DB: conn}
runStore := &store.Runs{DB: conn}
stageStore := &store.Stages{DB: conn}
+ subStepStore := &store.SubSteps{DB: conn}
artifactStore := &store.Artifacts{DB: conn}
specDiffStore := &store.SpecDiffs{DB: conn}
measurementStore := &store.Measurements{DB: conn}
@@ -77,6 +78,7 @@ func main() {
tiles := &api.TileEnricher{
Runs: runStore,
+ Stages: stageStore,
Artifacts: artifactStore,
SpecDiffs: specDiffStore,
}
@@ -90,6 +92,7 @@ func main() {
return templates.RenderTileString(tiles.Build(ctx, host, latest))
}
orchestrator.PipelineRenderer = templates.RenderPipelineString
+ orchestrator.SubStepRenderer = templates.RenderSubStepRowString
notifyReg, err := notify.BuildRegistry(cfg.Notifiers, cfg.Routes)
if err != nil {
@@ -100,6 +103,7 @@ func main() {
Hosts: hostStore,
Runs: runStore,
Stages: stageStore,
+ SubSteps: subStepStore,
SpecDiffs: specDiffStore,
Artifacts: artifactStore,
EventHub: hub,
@@ -114,7 +118,10 @@ func main() {
// reload-rendered page byte-for-byte, then hands each region to
// its Render*String helper.
orchestrator.HostDetailRenderer = func(ctx context.Context, hostID int64) (orchestrator.HostDetailFragments, bool) {
- d, err := ui.LoadHostDetailData(ctx, hostID)
+ // Orchestrator-side publishes always reference the latest run —
+ // SSE topics are keyed by runID, so a stale ?run=N bookmark
+ // doesn't affect what the server pushes.
+ d, err := ui.LoadHostDetailData(ctx, hostID, 0)
if err != nil {
return orchestrator.HostDetailFragments{}, false
}
@@ -134,6 +141,7 @@ func main() {
Hosts: hostStore,
Runs: runStore,
Stages: stageStore,
+ SubSteps: subStepStore,
Artifacts: artifactStore,
SpecDiffs: specDiffStore,
Measurements: measurementStore,
diff --git a/internal/api/agent_handlers.go b/internal/api/agent_handlers.go
index 66263b1..04215f6 100644
--- a/internal/api/agent_handlers.go
+++ b/internal/api/agent_handlers.go
@@ -37,6 +37,7 @@ type Agent struct {
Hosts *store.Hosts
Runs *store.Runs
Stages *store.Stages
+ SubSteps *store.SubSteps
Artifacts *store.Artifacts
SpecDiffs *store.SpecDiffs
Measurements *store.Measurements
@@ -386,12 +387,30 @@ func (a *Agent) Log(w http.ResponseWriter, r *http.Request) {
// DefaultStageOrder); Passed drives StageCompleted vs StageFailed.
// Inventory is optional and only set when kind == "Inventory" — the
// orchestrator persists it as an artifact and feeds it to spec.Diff.
+//
+// SubSteps is agent-authored granular rows (CPU/Memory pass, per-disk
+// SMART, per-device GPU, …). Empty for stages with no natural
+// breakdown. Persisted after the mismatch guard fires; per-row SSE is
+// emitted at the same time so the detail pane can surface them without
+// a full page reload.
type StageResult struct {
- Stage string `json:"stage"`
- Passed bool `json:"passed"`
- Summary json.RawMessage `json:"summary,omitempty"`
- Inventory *spec.Inventory `json:"inventory,omitempty"`
- Message string `json:"message,omitempty"`
+ Stage string `json:"stage"`
+ Passed bool `json:"passed"`
+ Summary json.RawMessage `json:"summary,omitempty"`
+ Inventory *spec.Inventory `json:"inventory,omitempty"`
+ Message string `json:"message,omitempty"`
+ SubSteps []SubStepResultLine `json:"sub_steps,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 {
+ Name string `json:"name"`
+ Passed bool `json:"passed"`
+ Skipped bool `json:"skipped,omitempty"`
+ StartedAt string `json:"started_at,omitempty"`
+ CompletedAt string `json:"completed_at,omitempty"`
+ Summary json.RawMessage `json:"summary,omitempty"`
}
// Result receives a stage's outcome. Flow:
@@ -470,6 +489,12 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
return
}
+ // 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
+ // without a reload. Best-effort — a persistence error is logged but
+ // doesn't fail the whole /result.
+ a.persistSubSteps(r.Context(), runID, body.Stage, body.SubSteps)
+
// Inventory-specific: persist artifact + compute spec diff.
if body.Stage == "Inventory" && body.Inventory != nil {
if err := a.persistInventory(r, run, body.Inventory); err != nil {
@@ -531,6 +556,65 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "next_state": string(next)})
}
+// persistSubSteps writes each reported sub-step as a row keyed by
+// (runID, stage, ordinal) where ordinal is the slice index, then emits
+// a per-row SSE event so an open detail page updates without a reload.
+// Silently no-ops when SubSteps is unwired (tests that don't supply a
+// store) or the slice is empty.
+func (a *Agent) persistSubSteps(ctx context.Context, runID int64, stage string, lines []SubStepResultLine) {
+ if a.SubSteps == nil || len(lines) == 0 {
+ return
+ }
+ for i, line := range lines {
+ state := model.StagePassed
+ switch {
+ case line.Skipped:
+ state = model.StageSkipped
+ case !line.Passed:
+ state = model.StageFailed
+ }
+ started := parseResultTime(line.StartedAt)
+ completed := parseResultTime(line.CompletedAt)
+ summaryJSON := ""
+ if len(line.Summary) > 0 {
+ summaryJSON = string(line.Summary)
+ }
+ ss := model.SubStep{
+ RunID: runID,
+ StageName: stage,
+ Ordinal: i,
+ Name: line.Name,
+ State: state,
+ StartedAt: started,
+ CompletedAt: completed,
+ SummaryJSON: summaryJSON,
+ }
+ if err := a.SubSteps.Upsert(ctx, ss); err != nil {
+ log.Printf("substep upsert run=%d stage=%s ord=%d: %v", runID, stage, i, err)
+ continue
+ }
+ if a.Runner != nil {
+ a.Runner.PublishSubStepUpdate(ctx, ss)
+ }
+ }
+}
+
+// parseResultTime tolerates RFC3339 / RFC3339Nano and returns nil for
+// empty or unparseable values so a missing timestamp doesn't block the
+// persist path.
+func parseResultTime(s string) *time.Time {
+ if s == "" {
+ return nil
+ }
+ if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
+ return &t
+ }
+ if t, err := time.Parse(time.RFC3339, s); err == nil {
+ return &t
+ }
+ return nil
+}
+
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 {
diff --git a/internal/api/agent_handlers_test.go b/internal/api/agent_handlers_test.go
index c705919..f9be593 100644
--- a/internal/api/agent_handlers_test.go
+++ b/internal/api/agent_handlers_test.go
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strconv"
"testing"
+ "time"
"github.com/go-chi/chi/v5"
@@ -32,6 +33,7 @@ func setupAgent(t *testing.T) (*api.Agent, int64, string) {
hosts := &store.Hosts{DB: conn}
runs := &store.Runs{DB: conn}
meas := &store.Measurements{DB: conn}
+ subSteps := &store.SubSteps{DB: conn}
hostID, err := hosts.Create(context.Background(), model.Host{
Name: "t-host",
@@ -55,6 +57,7 @@ func setupAgent(t *testing.T) (*api.Agent, int64, string) {
Hosts: hosts,
Runs: runs,
Measurements: meas,
+ SubSteps: subSteps,
}, runID, plain
}
@@ -215,3 +218,73 @@ func TestResult_AcceptsMatchingStage(t *testing.T) {
t.Fatalf("run state = %q, want CPUStress after SMART pass", after.State)
}
}
+
+// TestResult_PersistsSubSteps covers the /result handler's contract for
+// the new sub_steps table: when the agent includes a sub_steps array in
+// the POST body, each entry lands in the table with an ordinal equal to
+// its slice index, state derived from passed/skipped, and timestamps
+// parsed from RFC3339. The guard must let the call through (matching
+// stage) and sub-steps are written *after* CompleteStage so a persistence
+// error doesn't wedge the whole run.
+func TestResult_PersistsSubSteps(t *testing.T) {
+ a, runID, token := setupAgent(t)
+ a.Runner = &orchestrator.Runner{Runs: a.Runs, Hosts: a.Hosts, Stages: &store.Stages{DB: a.Runs.DB}, EventHub: events.NewHub()}
+ stages := &store.Stages{DB: a.Runs.DB}
+ if err := stages.Seed(context.Background(), runID); err != nil {
+ t.Fatalf("seed stages: %v", err)
+ }
+ if err := a.Runs.SetState(context.Background(), runID, model.StateCPUStress); err != nil {
+ t.Fatalf("set state: %v", err)
+ }
+
+ start := time.Date(2026, 4, 18, 13, 0, 0, 0, time.UTC)
+ end := start.Add(3 * time.Minute)
+ body, _ := json.Marshal(map[string]any{
+ "stage": "CPUStress",
+ "passed": true,
+ "sub_steps": []map[string]any{
+ {
+ "name": "CPU pass",
+ "passed": true,
+ "started_at": start.Format(time.RFC3339Nano),
+ "completed_at": end.Format(time.RFC3339Nano),
+ "summary": json.RawMessage(`{"elapsed_secs":180}`),
+ },
+ {
+ "name": "Memory pass",
+ "passed": false,
+ "started_at": end.Format(time.RFC3339Nano),
+ "completed_at": end.Add(2 * time.Minute).Format(time.RFC3339Nano),
+ },
+ },
+ })
+ req := routedRequest(runID, http.MethodPost, "/api/v1/runs/"+strconv.FormatInt(runID, 10)+"/result", body)
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ a.Result(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
+ }
+
+ rows, err := a.SubSteps.ListForRun(context.Background(), runID)
+ if err != nil {
+ t.Fatalf("ListForRun: %v", err)
+ }
+ if len(rows) != 2 {
+ t.Fatalf("got %d sub-steps, want 2", len(rows))
+ }
+ if rows[0].Ordinal != 0 || rows[0].Name != "CPU pass" || rows[0].State != model.StagePassed {
+ t.Fatalf("row[0] = %+v", rows[0])
+ }
+ if rows[1].Ordinal != 1 || rows[1].Name != "Memory pass" || rows[1].State != model.StageFailed {
+ t.Fatalf("row[1] = %+v", rows[1])
+ }
+ if rows[0].StartedAt == nil || !rows[0].StartedAt.Equal(start) {
+ t.Fatalf("row[0].StartedAt = %v, want %v", rows[0].StartedAt, start)
+ }
+ if rows[0].SummaryJSON != `{"elapsed_secs":180}` {
+ t.Fatalf("row[0].SummaryJSON = %q", rows[0].SummaryJSON)
+ }
+}
diff --git a/internal/api/host_detail_test.go b/internal/api/host_detail_test.go
index e5b7daf..9e5ef29 100644
--- a/internal/api/host_detail_test.go
+++ b/internal/api/host_detail_test.go
@@ -6,14 +6,17 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
+ "regexp"
"strings"
"testing"
+ "time"
"github.com/go-chi/chi/v5"
"vetting/internal/api"
"vetting/internal/db"
"vetting/internal/events"
+ "vetting/internal/logs"
"vetting/internal/model"
"vetting/internal/orchestrator"
"vetting/internal/store"
@@ -21,7 +24,8 @@ import (
func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
t.Helper()
- conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
+ tmp := t.TempDir()
+ conn, err := db.Open(filepath.Join(tmp, "vetting.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
@@ -29,18 +33,26 @@ func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
hosts := &store.Hosts{DB: conn}
runs := &store.Runs{DB: conn}
stages := &store.Stages{DB: conn}
+ subSteps := &store.SubSteps{DB: conn}
diffs := &store.SpecDiffs{DB: conn}
arts := &store.Artifacts{DB: conn}
hub := events.NewHub()
+ logsHub, err := logs.NewHub(filepath.Join(tmp, "logs"), hub)
+ if err != nil {
+ t.Fatalf("logs hub: %v", err)
+ }
+ t.Cleanup(logsHub.Close)
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs}
ui := &api.UI{
Hosts: hosts,
Runs: runs,
Stages: stages,
+ SubSteps: subSteps,
SpecDiffs: diffs,
Artifacts: arts,
EventHub: hub,
+ Logs: logsHub,
Runner: runner,
Tiles: tiles,
}
@@ -54,6 +66,16 @@ func detailReq(id int64) *http.Request {
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
+// detailReqWithQuery is detailReq with an optional ?run= query string.
+// Used by TestHostDetail_RunQueryParam so we can drive the selected-run
+// branch without routing through the real router.
+func detailReqWithQuery(id int64, rawQuery string) *http.Request {
+ req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d?%s", id, rawQuery), nil)
+ rctx := chi.NewRouteContext()
+ rctx.URLParams.Add("id", fmt.Sprintf("%d", id))
+ return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
+}
+
func TestHostDetail_OK(t *testing.T) {
ui, hosts, runs := setupDetail(t)
ctx := context.Background()
@@ -71,6 +93,9 @@ func TestHostDetail_OK(t *testing.T) {
if err != nil {
t.Fatalf("create run: %v", err)
}
+ if err := ui.Stages.Seed(ctx, runID); err != nil {
+ t.Fatalf("seed stages: %v", err)
+ }
rr := httptest.NewRecorder()
ui.HostDetail(rr, detailReq(id))
@@ -85,7 +110,8 @@ func TestHostDetail_OK(t *testing.T) {
if !strings.Contains(body, wantPipelineID) {
t.Fatalf("body missing %s", wantPipelineID)
}
- wantLogID := fmt.Sprintf(`id="log-%d"`, runID)
+ // Each stage owns its own log pane; assert one of them is present.
+ wantLogID := fmt.Sprintf(`id="log-%d-Inventory"`, runID)
if !strings.Contains(body, wantLogID) {
t.Fatalf("body missing %s", wantLogID)
}
@@ -121,14 +147,16 @@ func TestHostDetail_NeverRun(t *testing.T) {
}
}
-// TestHostDetail_LogTabsRendered: when a run exists, the detail page
-// emits the log-tabs scaffold with one radio per stage + an "All" tab
-// checked by default. CSS sibling selectors drive visibility — no JS.
-func TestHostDetail_LogTabsRendered(t *testing.T) {
+// TestHostDetail_ActiveStepsRendered: every canonical stage gets its own
+// panel with a matching log pane id, replacing
+// the old flat log-tab scaffold. Also confirms the sub-step SSE swap
+// target exists when sub-steps are seeded for a stage (so Phase 1's
+// substep-* event path has a DOM home).
+func TestHostDetail_ActiveStepsRendered(t *testing.T) {
ui, hosts, runs := setupDetail(t)
ctx := context.Background()
id, err := hosts.Create(ctx, model.Host{
- Name: "tabs-host",
+ Name: "steps-host",
MAC: "aa:bb:cc:dd:ee:40",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
@@ -141,6 +169,19 @@ func TestHostDetail_LogTabsRendered(t *testing.T) {
if err != nil {
t.Fatalf("create run: %v", err)
}
+ if err := ui.Stages.Seed(ctx, runID); err != nil {
+ t.Fatalf("seed stages: %v", err)
+ }
+ // Seed one CPUStress sub-step so the SubStepRow swap target lands.
+ if err := ui.SubSteps.Upsert(ctx, model.SubStep{
+ RunID: runID,
+ StageName: "CPUStress",
+ Ordinal: 0,
+ Name: "CPU pass",
+ State: model.StagePending,
+ }); err != nil {
+ t.Fatalf("upsert sub-step: %v", err)
+ }
rr := httptest.NewRecorder()
ui.HostDetail(rr, detailReq(id))
@@ -149,23 +190,246 @@ func TestHostDetail_LogTabsRendered(t *testing.T) {
}
body := rr.Body.String()
- // All tab: the default-checked radio, plus its pane.
- wantAllID := fmt.Sprintf(`id="log-tab-%d-all"`, runID)
- if !strings.Contains(body, wantAllID) {
- t.Fatalf("body missing All tab radio %s", wantAllID)
- }
- // Per-stage tabs: every entry in DefaultStageOrder must have its own
- // radio + pane so tabs switch purely via sibling CSS.
+ // Every stage in DefaultStageOrder owns a collapsible panel + log pane.
for _, s := range store.DefaultStageOrder {
- wantRadio := fmt.Sprintf(`id="log-tab-%d-%s"`, runID, s)
- if !strings.Contains(body, wantRadio) {
- t.Fatalf("body missing stage tab radio %s", wantRadio)
+ wantPanel := fmt.Sprintf(`data-stage="%s"`, s)
+ if !strings.Contains(body, wantPanel) {
+ t.Fatalf("body missing active-step panel %s", wantPanel)
}
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
if !strings.Contains(body, wantPane) {
- t.Fatalf("body missing stage pane %s", wantPane)
+ t.Fatalf("body missing stage log pane %s", wantPane)
}
}
+ // Sub-step row for CPUStress/0 is present and SSE-bound.
+ wantSub := fmt.Sprintf(`id="substep-%d-CPUStress-0"`, runID)
+ if !strings.Contains(body, wantSub) {
+ t.Fatalf("body missing sub-step row %s", wantSub)
+ }
+ wantSubSwap := fmt.Sprintf(`sse-swap="substep-%d-CPUStress-0"`, runID)
+ if !strings.Contains(body, wantSubSwap) {
+ t.Fatalf("body missing sub-step sse-swap %s", wantSubSwap)
+ }
+}
+
+// defaultOpenStage returns the value of data-stage on the single
+// `` emitted by ActiveStep. Returns
+// "" if no stage is currently open. The rendered attribute order is
+// fixed by active_step.templ (class, then open?, then data-stage), so
+// a tight substring match is safe.
+func defaultOpenStage(body string) string {
+ re := regexp.MustCompile(`open data-stage="([^"]+)"`)
+ m := re.FindStringSubmatch(body)
+ if len(m) < 2 {
+ return ""
+ }
+ return m[1]
+}
+
+func TestHostDetail_DefaultStep_Running(t *testing.T) {
+ ui, hosts, runs := setupDetail(t)
+ ctx := context.Background()
+ id, err := hosts.Create(ctx, model.Host{
+ Name: "default-running",
+ MAC: "aa:bb:cc:dd:ee:50",
+ WoLBroadcastIP: "10.0.0.255",
+ WoLPort: 9,
+ ExpectedSpecYAML: "memory:\n total_gib: 16\n",
+ })
+ if err != nil {
+ t.Fatalf("create host: %v", err)
+ }
+ runID, err := runs.Create(ctx, id, "t-running", false)
+ if err != nil {
+ t.Fatalf("create run: %v", err)
+ }
+ if err := ui.Stages.Seed(ctx, runID); err != nil {
+ t.Fatalf("seed stages: %v", err)
+ }
+ // Earlier stages passed; SMART is running.
+ for _, name := range []string{"Inventory", "SpecValidate"} {
+ if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
+ t.Fatalf("start %s: %v", name, err)
+ }
+ if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
+ t.Fatalf("complete %s: %v", name, err)
+ }
+ }
+ if err := ui.Stages.StartByName(ctx, runID, "SMART"); err != nil {
+ t.Fatalf("start SMART: %v", err)
+ }
+
+ rr := httptest.NewRecorder()
+ ui.HostDetail(rr, detailReq(id))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
+ }
+ if got := defaultOpenStage(rr.Body.String()); got != "SMART" {
+ t.Fatalf("default step = %q, want SMART", got)
+ }
+}
+
+func TestHostDetail_DefaultStep_Failed(t *testing.T) {
+ ui, hosts, runs := setupDetail(t)
+ ctx := context.Background()
+ id, err := hosts.Create(ctx, model.Host{
+ Name: "default-failed",
+ MAC: "aa:bb:cc:dd:ee:51",
+ WoLBroadcastIP: "10.0.0.255",
+ WoLPort: 9,
+ ExpectedSpecYAML: "memory:\n total_gib: 16\n",
+ })
+ if err != nil {
+ t.Fatalf("create host: %v", err)
+ }
+ runID, err := runs.Create(ctx, id, "t-failed", false)
+ if err != nil {
+ t.Fatalf("create run: %v", err)
+ }
+ if err := ui.Stages.Seed(ctx, runID); err != nil {
+ t.Fatalf("seed stages: %v", err)
+ }
+ // Inventory + SpecValidate + SMART passed; CPUStress failed; nothing
+ // running. Default must land on CPUStress.
+ for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
+ if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
+ t.Fatalf("start %s: %v", name, err)
+ }
+ if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
+ t.Fatalf("complete %s: %v", name, err)
+ }
+ }
+ if err := ui.Stages.StartByName(ctx, runID, "CPUStress"); err != nil {
+ t.Fatalf("start CPUStress: %v", err)
+ }
+ if err := ui.Stages.CompleteByName(ctx, runID, "CPUStress", model.StageFailed, `{"reason":"thermal"}`); err != nil {
+ t.Fatalf("complete CPUStress: %v", err)
+ }
+
+ rr := httptest.NewRecorder()
+ ui.HostDetail(rr, detailReq(id))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d", rr.Code)
+ }
+ if got := defaultOpenStage(rr.Body.String()); got != "CPUStress" {
+ t.Fatalf("default step = %q, want CPUStress", got)
+ }
+}
+
+func TestHostDetail_DefaultStep_Completed(t *testing.T) {
+ ui, hosts, runs := setupDetail(t)
+ ctx := context.Background()
+ id, err := hosts.Create(ctx, model.Host{
+ Name: "default-done",
+ MAC: "aa:bb:cc:dd:ee:52",
+ WoLBroadcastIP: "10.0.0.255",
+ WoLPort: 9,
+ ExpectedSpecYAML: "memory:\n total_gib: 16\n",
+ })
+ if err != nil {
+ t.Fatalf("create host: %v", err)
+ }
+ runID, err := runs.Create(ctx, id, "t-done", false)
+ if err != nil {
+ t.Fatalf("create run: %v", err)
+ }
+ if err := ui.Stages.Seed(ctx, runID); err != nil {
+ t.Fatalf("seed stages: %v", err)
+ }
+ // All stages passed → default lands on Reporting.
+ for _, name := range store.DefaultStageOrder {
+ if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
+ t.Fatalf("start %s: %v", name, err)
+ }
+ if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
+ t.Fatalf("complete %s: %v", name, err)
+ }
+ }
+
+ rr := httptest.NewRecorder()
+ ui.HostDetail(rr, detailReq(id))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d", rr.Code)
+ }
+ if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
+ t.Fatalf("default step = %q, want Reporting", got)
+ }
+}
+
+// TestHostDetail_RunQueryParam: ?run=N selects a specific past run
+// instead of the latest. The history sidebar's links rely on this.
+func TestHostDetail_RunQueryParam(t *testing.T) {
+ ui, hosts, runs := setupDetail(t)
+ ctx := context.Background()
+ id, err := hosts.Create(ctx, model.Host{
+ Name: "query-run",
+ MAC: "aa:bb:cc:dd:ee:53",
+ WoLBroadcastIP: "10.0.0.255",
+ WoLPort: 9,
+ ExpectedSpecYAML: "memory:\n total_gib: 16\n",
+ })
+ if err != nil {
+ t.Fatalf("create host: %v", err)
+ }
+ // Older run: failed at CPUStress. Newer run: fully passed.
+ oldRun, err := runs.Create(ctx, id, "old", false)
+ if err != nil {
+ t.Fatalf("create old run: %v", err)
+ }
+ if err := ui.Stages.Seed(ctx, oldRun); err != nil {
+ t.Fatalf("seed old: %v", err)
+ }
+ for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
+ _ = ui.Stages.StartByName(ctx, oldRun, name)
+ _ = ui.Stages.CompleteByName(ctx, oldRun, name, model.StagePassed, "")
+ }
+ _ = ui.Stages.StartByName(ctx, oldRun, "CPUStress")
+ _ = ui.Stages.CompleteByName(ctx, oldRun, "CPUStress", model.StageFailed, "")
+
+ // Newer run lands after a tiny gap so Runs.LatestForHost picks it.
+ time.Sleep(10 * time.Millisecond)
+ newRun, err := runs.Create(ctx, id, "new", false)
+ if err != nil {
+ t.Fatalf("create new run: %v", err)
+ }
+ if err := ui.Stages.Seed(ctx, newRun); err != nil {
+ t.Fatalf("seed new: %v", err)
+ }
+ for _, name := range store.DefaultStageOrder {
+ _ = ui.Stages.StartByName(ctx, newRun, name)
+ _ = ui.Stages.CompleteByName(ctx, newRun, name, model.StagePassed, "")
+ }
+
+ // Sanity: with no ?run=, default is Reporting (latest run is green).
+ rr := httptest.NewRecorder()
+ ui.HostDetail(rr, detailReq(id))
+ if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
+ t.Fatalf("latest default = %q, want Reporting", got)
+ }
+
+ // With ?run={old}, we view the failed run → default is CPUStress and
+ // the pipeline section references the old run's ID.
+ rr = httptest.NewRecorder()
+ ui.HostDetail(rr, detailReqWithQuery(id, fmt.Sprintf("run=%d", oldRun)))
+ body := rr.Body.String()
+ if got := defaultOpenStage(body); got != "CPUStress" {
+ t.Fatalf("?run=old default = %q, want CPUStress", got)
+ }
+ wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, oldRun)
+ if !strings.Contains(body, wantPipelineID) {
+ t.Fatalf("?run=old body missing %s", wantPipelineID)
+ }
+
+ // A ?run= value that belongs to no host at all falls back to latest
+ // silently (stale bookmark should never 4xx).
+ rr = httptest.NewRecorder()
+ ui.HostDetail(rr, detailReqWithQuery(id, "run=9999"))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("bogus run fallback status = %d", rr.Code)
+ }
+ if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
+ t.Fatalf("bogus run default = %q, want Reporting (fall back to latest)", got)
+ }
}
func TestHostDetail_UnknownID(t *testing.T) {
diff --git a/internal/api/tile.go b/internal/api/tile.go
index 5151668..9d82823 100644
--- a/internal/api/tile.go
+++ b/internal/api/tile.go
@@ -19,6 +19,7 @@ import (
// place that renders a tile shows the same data.
type TileEnricher struct {
Runs *store.Runs
+ Stages *store.Stages
Artifacts *store.Artifacts
SpecDiffs *store.SpecDiffs
}
@@ -53,6 +54,16 @@ func (e *TileEnricher) Build(ctx context.Context, host model.Host, latest *model
log.Printf("tile: list artifacts run %d: %v", latest.ID, err)
}
}
+ // Stage row per canonical stage drives the dashboard tile's mini
+ // run-view strip. Fail-soft: a DB hiccup renders the tile without
+ // dots rather than breaking the whole dashboard.
+ if e.Stages != nil {
+ if stages, err := e.Stages.ListForRun(ctx, latest.ID); err == nil {
+ t.Stages = stages
+ } else {
+ log.Printf("tile: list stages run %d: %v", latest.ID, err)
+ }
+ }
return t
}
diff --git a/internal/api/ui_handlers.go b/internal/api/ui_handlers.go
index 4718ee0..6f36e82 100644
--- a/internal/api/ui_handlers.go
+++ b/internal/api/ui_handlers.go
@@ -28,6 +28,7 @@ type UI struct {
Hosts *store.Hosts
Runs *store.Runs
Stages *store.Stages
+ SubSteps *store.SubSteps
SpecDiffs *store.SpecDiffs
Artifacts *store.Artifacts
EventHub *events.Hub
@@ -118,7 +119,16 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad host id", http.StatusBadRequest)
return
}
- data, err := u.LoadHostDetailData(r.Context(), id)
+ // Optional ?run=N: select a specific past run instead of the latest.
+ // Rejected runs (bad parse, wrong host) fall back to latest silently
+ // so a stale bookmark doesn't 404.
+ var selectedRunID int64
+ if q := r.URL.Query().Get("run"); q != "" {
+ if parsed, err := strconv.ParseInt(q, 10, 64); err == nil {
+ selectedRunID = parsed
+ }
+ }
+ data, err := u.LoadHostDetailData(r.Context(), id, selectedRunID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
@@ -139,7 +149,12 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
// diffs, replay, and tile enrichment are fail-soft (empty on error) —
// mirrors the original inline behaviour so a transient DB hiccup on one
// relation doesn't blank the whole page.
-func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64) (templates.HostDetailData, error) {
+//
+// selectedRunID == 0 means "use the latest run". A positive value picks
+// a specific past run for the hosts/{id}?run=N history-sidebar navigation;
+// if that run doesn't exist or belongs to another host we fall back to
+// the latest so a stale URL doesn't error out.
+func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64, selectedRunID int64) (templates.HostDetailData, error) {
host, err := u.Hosts.Get(ctx, hostID)
if err != nil {
return templates.HostDetailData{}, err
@@ -148,29 +163,74 @@ func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64) (templates.Ho
if err != nil {
return templates.HostDetailData{}, err
}
+ // Resolve the viewed run: selectedRunID wins when it matches this
+ // host; otherwise fall back to latest. A run that belongs to a
+ // different host is silently ignored — no operator action should be
+ // able to render another host's run under this page.
+ viewed := latest
+ if selectedRunID > 0 && u.Runs != nil {
+ if r, err := u.Runs.Get(ctx, selectedRunID); err == nil && r != nil && r.HostID == hostID {
+ viewed = r
+ }
+ }
var stages []model.Stage
var diffs []model.SpecDiff
- if latest != nil {
+ var subSteps []model.SubStep
+ if viewed != nil {
if u.Stages != nil {
- stages, _ = u.Stages.ListForRun(ctx, latest.ID)
+ stages, _ = u.Stages.ListForRun(ctx, viewed.ID)
}
if u.SpecDiffs != nil {
- diffs, _ = u.SpecDiffs.ListForRun(ctx, latest.ID)
+ diffs, _ = u.SpecDiffs.ListForRun(ctx, viewed.ID)
+ }
+ if u.SubSteps != nil {
+ subSteps, _ = u.SubSteps.ListForRun(ctx, viewed.ID)
}
}
- t := u.Tiles.Build(ctx, *host, latest)
+ // Sidebar: last 20 runs for this host, newest first. Fail-soft so a
+ // transient DB error doesn't blank the whole page.
+ var history []model.Run
+ if u.Runs != nil {
+ history, _ = u.Runs.ListForHost(ctx, hostID, 20)
+ }
+ t := u.Tiles.Build(ctx, *host, viewed)
replay := ""
- if latest != nil && u.Logs != nil {
- replay = u.Logs.Replay(latest.ID)
+ replayByStage := map[string]string{}
+ if viewed != nil && u.Logs != nil {
+ replay = u.Logs.Replay(viewed.ID)
+ replayByStage = u.Logs.ReplayByStage(viewed.ID)
}
return templates.HostDetailData{
- Tile: t,
- Stages: stages,
- SpecDiffs: diffs,
- LogReplay: replay,
+ Tile: t,
+ Stages: stages,
+ SpecDiffs: diffs,
+ SubSteps: subSteps,
+ History: history,
+ DefaultStepStage: pickDefaultStep(stages),
+ LogReplay: replay,
+ LogReplayByStage: replayByStage,
}, nil
}
+// pickDefaultStep chooses which stage the detail page opens expanded by
+// default. Rule: running → first-failed → Reporting. The operator is
+// almost always most interested in the thing currently happening (or
+// the thing that just failed); Reporting is the sensible terminal fallback
+// because it's where the report link lives.
+func pickDefaultStep(stages []model.Stage) string {
+ for _, s := range stages {
+ if s.State == model.StageRunning {
+ return s.Name
+ }
+ }
+ for _, s := range stages {
+ if s.State == model.StageFailed {
+ return s.Name
+ }
+ }
+ return "Reporting"
+}
+
// StartRun creates a new Run for the host, issues an agent token, and
// transitions Registered→Queued. The dispatcher goroutine picks it up
// on its next tick; the happy path is heartbeat-driven (the reporter's
diff --git a/internal/db/migrations/0004_add_sub_steps.sql b/internal/db/migrations/0004_add_sub_steps.sql
new file mode 100644
index 0000000..32050d7
--- /dev/null
+++ b/internal/db/migrations/0004_add_sub_steps.sql
@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS sub_steps (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
+ stage_name TEXT NOT NULL,
+ ordinal INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ state TEXT NOT NULL DEFAULT 'pending',
+ started_at TIMESTAMP,
+ completed_at TIMESTAMP,
+ summary_json TEXT NOT NULL DEFAULT '{}',
+ UNIQUE (run_id, stage_name, ordinal)
+);
+CREATE INDEX IF NOT EXISTS idx_sub_steps_run ON sub_steps(run_id, stage_name, ordinal);
diff --git a/internal/httpserver/sse_e2e_test.go b/internal/httpserver/sse_e2e_test.go
index eb1d253..ee887a6 100644
--- a/internal/httpserver/sse_e2e_test.go
+++ b/internal/httpserver/sse_e2e_test.go
@@ -143,3 +143,69 @@ func waitForSSEEvent(r *bufio.Reader, name string, timeout time.Duration) error
type timeoutErr struct{}
func (e *timeoutErr) Error() string { return "timeout waiting for sse event" }
+
+// TestSSE_SubStepEvent confirms PublishSubStepUpdate lands on the wire
+// with the exact "substep-{runID}-{stage}-{ordinal}" event name that
+// detail-page swap targets key on. Without this, the template renders
+// the right attribute but a middleware or renderer regression silently
+// drops the payload.
+func TestSSE_SubStepEvent(t *testing.T) {
+ conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
+ 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}
+ diffs := &store.SpecDiffs{DB: conn}
+ arts := &store.Artifacts{DB: conn}
+ hub := events.NewHub()
+ runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
+ tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs}
+
+ orchestrator.SubStepRenderer = func(_ model.SubStep) string {
+ return `
row
`
+ }
+
+ ui := &api.UI{
+ Hosts: hosts, Runs: runs, Stages: stages, SpecDiffs: diffs, Artifacts: arts,
+ EventHub: hub, Runner: runner, Tiles: tiles,
+ }
+ agent := &api.Agent{
+ Hosts: hosts, Runs: runs, Stages: stages, Artifacts: arts,
+ SpecDiffs: diffs, Runner: runner, EventHub: hub,
+ }
+
+ router := NewRouter(Deps{UI: ui, Agent: agent})
+ srv := httptest.NewServer(router)
+ t.Cleanup(srv.Close)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET /events: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ reader := bufio.NewReader(resp.Body)
+ if err := waitForSSEEvent(reader, "hello", 1*time.Second); err != nil {
+ t.Fatalf("hello preamble: %v", err)
+ }
+ time.Sleep(50 * time.Millisecond)
+
+ runner.PublishSubStepUpdate(context.Background(), model.SubStep{
+ RunID: 42,
+ StageName: "CPUStress",
+ Ordinal: 1,
+ Name: "Memory pass",
+ State: model.StagePassed,
+ })
+
+ if err := waitForSSEEvent(reader, "substep-42-CPUStress-1", 2*time.Second); err != nil {
+ t.Fatalf("substep event: %v", err)
+ }
+}
diff --git a/internal/logs/logs.go b/internal/logs/logs.go
index 32a1638..e62db1f 100644
--- a/internal/logs/logs.go
+++ b/internal/logs/logs.go
@@ -30,6 +30,10 @@ type Writer struct {
mu sync.Mutex
f *os.File
hub *events.Hub
+ // counters keyed by Stage (empty key = orphan/framing lines) so each
+ // stage's rendered output numbers from 1. Shared with Replay via
+ // seedCounters on Writer open so restarts don't reset to 1 mid-run.
+ counters map[string]int
}
// Hub owns the per-run Writers. The orchestrator creates one Hub at
@@ -62,11 +66,47 @@ func (h *Hub) WriterFor(runID int64) (*Writer, error) {
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
- w := &Writer{runID: runID, f: f, hub: h.events}
+ w := &Writer{runID: runID, f: f, hub: h.events, counters: seedCounters(path)}
h.writers[runID] = w
return w, nil
}
+// seedCounters scans the on-disk log and returns the per-stage line
+// counts that were already rendered for this run. Called on Writer open
+// so a mid-run process restart continues numbering where it left off
+// — otherwise Append(line) would emit "1" but Replay already shows
+// "42" on the reload, and anchor permalinks would collide.
+func seedCounters(path string) map[string]int {
+ counters := map[string]int{}
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return counters
+ }
+ for _, raw := range strings.Split(string(b), "\n") {
+ if raw == "" {
+ continue
+ }
+ tsEnd := strings.IndexByte(raw, ' ')
+ if tsEnd < 0 {
+ continue
+ }
+ rest := strings.TrimLeft(raw[tsEnd+1:], " ")
+ lvEnd := strings.IndexByte(rest, ' ')
+ if lvEnd < 0 {
+ continue
+ }
+ text := rest[lvEnd+1:]
+ stage := ""
+ if strings.HasPrefix(text, "[") {
+ if end := strings.Index(text, "] "); end > 1 {
+ stage = text[1:end]
+ }
+ }
+ counters[stage]++
+ }
+ return counters
+}
+
// Close flushes and closes all open run files. Called from main on
// shutdown so the logs aren't left with buffered data.
func (h *Hub) Close() {
@@ -92,6 +132,10 @@ func (h *Hub) PathFor(runID int64) string {
// the pane just stays empty until live events arrive. Does not subscribe
// to the SSE hub — callers are expected to pair this with a live
// sse-swap target on the same element.
+//
+// Line numbers are rebuilt from scratch here so a page reload shows
+// stable IDs that match what Append will emit for future lines (the
+// Writer's counters are seeded from disk on open).
func (h *Hub) Replay(runID int64) string {
path := h.PathFor(runID)
b, err := os.ReadFile(path)
@@ -99,6 +143,7 @@ func (h *Hub) Replay(runID int64) string {
return ""
}
var out strings.Builder
+ counters := map[string]int{}
for _, raw := range strings.Split(string(b), "\n") {
if raw == "" {
continue
@@ -129,7 +174,111 @@ func (h *Hub) Replay(runID int64) string {
text = text[end+2:]
}
}
- out.WriteString(renderLogSSE(Line{TS: ts, Level: level, Stage: stage, Text: text}))
+ counters[stage]++
+ out.WriteString(renderLogSSE(runID, counters[stage], Line{TS: ts, Level: level, Stage: stage, Text: text}))
+ }
+ return out.String()
+}
+
+// ReplayByStage scans the on-disk log once and returns a per-stage map
+// of pre-rendered HTML strings, keyed by stage name (orphan/framing
+// lines are keyed under ""). This is the one-pass alternative to
+// calling ReplayForStage per stage: the detail-page renders nine stage
+// panels, and doing nine file scans per page load is wasteful. Line
+// numbers are per-stage so they agree with the counters Append uses
+// for the same run.
+func (h *Hub) ReplayByStage(runID int64) map[string]string {
+ path := h.PathFor(runID)
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return map[string]string{}
+ }
+ bufs := map[string]*strings.Builder{}
+ counters := map[string]int{}
+ for _, raw := range strings.Split(string(b), "\n") {
+ if raw == "" {
+ continue
+ }
+ tsEnd := strings.IndexByte(raw, ' ')
+ if tsEnd < 0 {
+ continue
+ }
+ ts, err := time.Parse(time.RFC3339Nano, raw[:tsEnd])
+ if err != nil {
+ continue
+ }
+ rest := strings.TrimLeft(raw[tsEnd+1:], " ")
+ lvEnd := strings.IndexByte(rest, ' ')
+ if lvEnd < 0 {
+ continue
+ }
+ level := strings.ToLower(rest[:lvEnd])
+ text := rest[lvEnd+1:]
+ stage := ""
+ if strings.HasPrefix(text, "[") {
+ if end := strings.Index(text, "] "); end > 1 {
+ stage = text[1:end]
+ text = text[end+2:]
+ }
+ }
+ counters[stage]++
+ sb, ok := bufs[stage]
+ if !ok {
+ sb = &strings.Builder{}
+ bufs[stage] = sb
+ }
+ sb.WriteString(renderLogSSE(runID, counters[stage], Line{TS: ts, Level: level, Stage: stage, Text: text}))
+ }
+ out := make(map[string]string, len(bufs))
+ for k, sb := range bufs {
+ out[k] = sb.String()
+ }
+ return out
+}
+
+// ReplayForStage returns only the log lines whose Stage matches stageName
+// (pass "" for orphan/framing lines). Used by the detail-page ActiveStep
+// renderer so each expanded step shows only its own log history. Line
+// numbers here are per-stage so they agree with what Append emits live.
+func (h *Hub) ReplayForStage(runID int64, stageName string) string {
+ path := h.PathFor(runID)
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ var out strings.Builder
+ ord := 0
+ for _, raw := range strings.Split(string(b), "\n") {
+ if raw == "" {
+ continue
+ }
+ tsEnd := strings.IndexByte(raw, ' ')
+ if tsEnd < 0 {
+ continue
+ }
+ ts, err := time.Parse(time.RFC3339Nano, raw[:tsEnd])
+ if err != nil {
+ continue
+ }
+ rest := strings.TrimLeft(raw[tsEnd+1:], " ")
+ lvEnd := strings.IndexByte(rest, ' ')
+ if lvEnd < 0 {
+ continue
+ }
+ level := strings.ToLower(rest[:lvEnd])
+ text := rest[lvEnd+1:]
+ stage := ""
+ if strings.HasPrefix(text, "[") {
+ if end := strings.Index(text, "] "); end > 1 {
+ stage = text[1:end]
+ text = text[end+2:]
+ }
+ }
+ if stage != stageName {
+ continue
+ }
+ ord++
+ out.WriteString(renderLogSSE(runID, ord, Line{TS: ts, Level: level, Stage: stage, Text: text}))
}
return out.String()
}
@@ -154,8 +303,13 @@ func (w *Writer) Append(line Line) {
if _, err := w.f.WriteString(stamped); err != nil {
log.Printf("logs: write run-%d: %v", w.runID, err)
}
+ if w.counters == nil {
+ w.counters = map[string]int{}
+ }
+ w.counters[line.Stage]++
+ ord := w.counters[line.Stage]
if w.hub != nil {
- payload := renderLogSSE(line)
+ payload := renderLogSSE(w.runID, ord, line)
w.hub.Publish(events.Event{
Name: fmt.Sprintf("log-%d", w.runID),
Payload: payload,
@@ -182,18 +336,41 @@ func (w *Writer) Close() error {
// renderLogSSE returns an HTMX-compatible fragment. The detail-page
// panes contain
: each event
-// appends one
to them. Stage, if set,
-// is rendered as a dim prefix so the "All" pane stays disambiguable
-// even with multiple stages interleaved.
-func renderLogSSE(l Line) string {
+// appends one
to them. ord is the
+// per-(run, stage) 1-based line number; combined with runID + stage it
+// forms a stable permalink id of the form L{run}-{stage}-{ord} (stage
+// defaults to "all" when the line has no stage, so orphan/framing lines
+// still anchor uniquely).
+//
+// Shape:
+//
+//
`,
html.EscapeString(level),
+ anchorID,
+ html.EscapeString(l.TS.Format(time.RFC3339Nano)),
+ anchorID,
+ ord,
+ html.EscapeString(strings.ToUpper(level)),
html.EscapeString(l.TS.Format("15:04:05")),
stagePrefix,
html.EscapeString(l.Text),
diff --git a/internal/model/model.go b/internal/model/model.go
index cb1dbe6..a896d01 100644
--- a/internal/model/model.go
+++ b/internal/model/model.go
@@ -86,6 +86,24 @@ type Stage struct {
SummaryJSON string
}
+// SubStep is a finer-grained unit within a Stage, authored by the agent.
+// Not every stage has sub-steps; those that do (CPUStress, SMART per-disk,
+// Storage per-disk, GPU per-device) surface them so the UI can render a
+// GitHub-Actions-style collapsible list. Sub-steps share the StageState
+// enum with Stage; Ordinal is 0-based within StageName for a given RunID
+// and is how the UI and SSE events key each row.
+type SubStep struct {
+ ID int64
+ RunID int64
+ StageName string
+ Ordinal int
+ Name string
+ State StageState
+ StartedAt *time.Time
+ CompletedAt *time.Time
+ SummaryJSON string
+}
+
type Measurement struct {
ID int64
RunID int64
diff --git a/internal/orchestrator/runner.go b/internal/orchestrator/runner.go
index 5024625..d8c3e0e 100644
--- a/internal/orchestrator/runner.go
+++ b/internal/orchestrator/runner.go
@@ -154,6 +154,25 @@ var TileRenderer func(ctx context.Context, host model.Host, latest *model.Run) s
// orchestrator stays free of template imports.
var PipelineRenderer func(run *model.Run, stages []model.Stage) string
+// SubStepRenderer renders a single sub-step row fragment. Fires on
+// every sub-step state transition (running → passed/failed) so the
+// detail page's `
`
+// target updates without reloading.
+var SubStepRenderer func(ss model.SubStep) string
+
+// PublishSubStepUpdate broadcasts a single sub-step row. Callers give
+// the just-persisted SubStep; we render + fan out. Safe to call when
+// no renderer is wired; drops silently.
+func (r *Runner) PublishSubStepUpdate(ctx context.Context, ss model.SubStep) {
+ if SubStepRenderer == nil || r.EventHub == nil {
+ return
+ }
+ r.EventHub.Publish(events.Event{
+ Name: fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal),
+ Payload: SubStepRenderer(ss),
+ })
+}
+
// HostDetailFragments is the pre-rendered bundle of HTML fragments a
// single PublishHostDetail call broadcasts over SSE. Summary and Actions
// are always set; SpecDiffs and Hold are empty strings when there is no
diff --git a/internal/store/runs.go b/internal/store/runs.go
index 63f844e..d388f50 100644
--- a/internal/store/runs.go
+++ b/internal/store/runs.go
@@ -154,6 +154,45 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
return &run, nil
}
+// ListForHost returns the most recent `limit` runs for a host, newest
+// first. Caller uses this to drive the host-detail runs sidebar (last 20
+// by default, Phase 2). Zero/negative limit falls back to a safe cap so
+// a mistaken call can't scan the whole history into memory.
+func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]model.Run, error) {
+ if limit <= 0 {
+ limit = 20
+ }
+ rows, err := r.DB.QueryContext(ctx, `
+ 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)
+ FROM runs
+ WHERE host_id = ?
+ ORDER BY id DESC
+ LIMIT ?
+ `, hostID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []model.Run
+ for rows.Next() {
+ var run model.Run
+ 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 {
+ return nil, err
+ }
+ if completedAt.Valid {
+ run.CompletedAt = &completedAt.Time
+ }
+ out = append(out, run)
+ }
+ return out, rows.Err()
+}
+
// Active returns all runs in non-terminal states.
func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
rows, err := r.DB.QueryContext(ctx, `
diff --git a/internal/store/substeps.go b/internal/store/substeps.go
new file mode 100644
index 0000000..840394f
--- /dev/null
+++ b/internal/store/substeps.go
@@ -0,0 +1,140 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ "vetting/internal/model"
+)
+
+// SubSteps is the persistence layer for the sub_steps table. Sub-steps
+// are authored by the agent (one per disk in SMART, one per disk-operation
+// in Storage, CPU+Memory passes in CPUStress, etc.) and surfaced in the
+// detail page as a collapsible list under each stage.
+type SubSteps struct {
+ DB *sql.DB
+}
+
+// Upsert inserts-or-replaces a sub-step row keyed by (run_id, stage_name,
+// ordinal). Replace semantics let the agent push a row twice — once when
+// the sub-step starts (state=running, completed_at=nil) and again when
+// it finishes (state=passed|failed, completed_at set) — without the
+// caller needing to track whether the row already exists.
+func (s *SubSteps) Upsert(ctx context.Context, ss model.SubStep) error {
+ // Preserve the original started_at if the caller didn't supply one
+ // (a "complete" call that only knows the end time still needs to
+ // leave the start time alone). ON CONFLICT DO UPDATE with COALESCE
+ // handles the merge in one round-trip.
+ _, err := s.DB.ExecContext(ctx, `
+ INSERT INTO sub_steps(run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json)
+ VALUES(?,?,?,?,?,?,?,?)
+ ON CONFLICT(run_id, stage_name, ordinal) DO UPDATE SET
+ name = excluded.name,
+ state = excluded.state,
+ started_at = COALESCE(excluded.started_at, sub_steps.started_at),
+ completed_at = COALESCE(excluded.completed_at, sub_steps.completed_at),
+ summary_json = CASE WHEN excluded.summary_json = '{}' THEN sub_steps.summary_json ELSE excluded.summary_json END
+ `,
+ ss.RunID, ss.StageName, ss.Ordinal, ss.Name, string(ss.State),
+ nullTime(ss.StartedAt), nullTime(ss.CompletedAt), jsonOrDefault(ss.SummaryJSON))
+ if err != nil {
+ return fmt.Errorf("upsert sub-step: %w", err)
+ }
+ return nil
+}
+
+// Get returns a single sub-step by its natural key. Returns ErrNotFound
+// if none exists.
+func (s *SubSteps) Get(ctx context.Context, runID int64, stageName string, ordinal int) (*model.SubStep, error) {
+ row := s.DB.QueryRowContext(ctx, `
+ SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
+ FROM sub_steps WHERE run_id = ? AND stage_name = ? AND ordinal = ?
+ `, runID, stageName, ordinal)
+ ss, err := scanSubStep(row)
+ if err == sql.ErrNoRows {
+ return nil, ErrNotFound
+ }
+ return ss, err
+}
+
+// ListForRun returns every sub-step for a run, ordered by (stage_name,
+// ordinal). The caller groups by StageName to build the UI.
+func (s *SubSteps) ListForRun(ctx context.Context, runID int64) ([]model.SubStep, error) {
+ rows, err := s.DB.QueryContext(ctx, `
+ SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
+ FROM sub_steps WHERE run_id = ? ORDER BY stage_name, ordinal
+ `, runID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []model.SubStep
+ for rows.Next() {
+ ss, err := scanSubStep(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *ss)
+ }
+ return out, rows.Err()
+}
+
+// ListForStage returns every sub-step for one stage of a run, in ordinal
+// order. Convenience over filtering ListForRun client-side.
+func (s *SubSteps) ListForStage(ctx context.Context, runID int64, stageName string) ([]model.SubStep, error) {
+ rows, err := s.DB.QueryContext(ctx, `
+ SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
+ FROM sub_steps WHERE run_id = ? AND stage_name = ? ORDER BY ordinal
+ `, runID, stageName)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []model.SubStep
+ for rows.Next() {
+ ss, err := scanSubStep(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *ss)
+ }
+ return out, rows.Err()
+}
+
+type scanner interface {
+ Scan(dest ...any) error
+}
+
+func scanSubStep(r scanner) (*model.SubStep, error) {
+ var ss model.SubStep
+ var started, completed sql.NullTime
+ if err := r.Scan(&ss.ID, &ss.RunID, &ss.StageName, &ss.Ordinal, &ss.Name,
+ &ss.State, &started, &completed, &ss.SummaryJSON); err != nil {
+ return nil, err
+ }
+ if started.Valid {
+ t := started.Time
+ ss.StartedAt = &t
+ }
+ if completed.Valid {
+ t := completed.Time
+ ss.CompletedAt = &t
+ }
+ return &ss, nil
+}
+
+func nullTime(t *time.Time) any {
+ if t == nil || t.IsZero() {
+ return nil
+ }
+ return t.UTC()
+}
+
+func jsonOrDefault(s string) string {
+ if s == "" {
+ return "{}"
+ }
+ return s
+}
diff --git a/internal/store/substeps_test.go b/internal/store/substeps_test.go
new file mode 100644
index 0000000..beb311b
--- /dev/null
+++ b/internal/store/substeps_test.go
@@ -0,0 +1,126 @@
+package store_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "vetting/internal/model"
+ "vetting/internal/store"
+)
+
+// TestSubStepsUpsertAndList covers the two-phase life cycle the agent
+// is meant to exercise: an initial "started" upsert with state=running
+// and started_at set, then a terminal upsert that flips state to passed
+// and fills completed_at without clobbering started_at. Ordering falls
+// out of the (stage_name, ordinal) index — ListForRun must return rows
+// in that deterministic order even when they were inserted out of order.
+func TestSubStepsUpsertAndList(t *testing.T) {
+ runs := newDB(t)
+ _, runID := seedRun(t, runs)
+ ss := &store.SubSteps{DB: runs.DB}
+ ctx := context.Background()
+
+ start := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC)
+ end := start.Add(3 * time.Minute)
+
+ // Insert CPUStress ordinals out of order to prove ListForRun orders
+ // by (stage, ordinal) rather than insertion time.
+ if err := ss.Upsert(ctx, model.SubStep{
+ RunID: runID, StageName: "CPUStress", Ordinal: 1,
+ Name: "Memory pass", State: model.StageRunning, StartedAt: &start,
+ }); err != nil {
+ t.Fatalf("upsert CPUStress/1 running: %v", err)
+ }
+ if err := ss.Upsert(ctx, model.SubStep{
+ RunID: runID, StageName: "CPUStress", Ordinal: 0,
+ Name: "CPU pass", State: model.StagePassed, StartedAt: &start, CompletedAt: &end,
+ SummaryJSON: `{"elapsed_secs":180}`,
+ }); err != nil {
+ t.Fatalf("upsert CPUStress/0 passed: %v", err)
+ }
+ // Terminal update for ordinal 1 — only completed_at + state; started_at
+ // intentionally omitted so COALESCE preserves the original value.
+ if err := ss.Upsert(ctx, model.SubStep{
+ RunID: runID, StageName: "CPUStress", Ordinal: 1,
+ Name: "Memory pass", State: model.StagePassed, CompletedAt: &end,
+ }); err != nil {
+ t.Fatalf("upsert CPUStress/1 passed: %v", err)
+ }
+
+ list, err := ss.ListForRun(ctx, runID)
+ if err != nil {
+ t.Fatalf("ListForRun: %v", err)
+ }
+ if len(list) != 2 {
+ t.Fatalf("got %d rows, want 2", len(list))
+ }
+ if list[0].Name != "CPU pass" || list[1].Name != "Memory pass" {
+ t.Fatalf("order: %+v", list)
+ }
+ if list[1].State != model.StagePassed {
+ t.Fatalf("memory pass state = %q, want passed", list[1].State)
+ }
+ if list[1].StartedAt == nil {
+ t.Fatalf("memory pass started_at was wiped by terminal upsert")
+ }
+ if !list[1].StartedAt.Equal(start) {
+ t.Fatalf("started_at = %v, want %v", list[1].StartedAt, start)
+ }
+ if list[0].SummaryJSON != `{"elapsed_secs":180}` {
+ t.Fatalf("CPU pass summary = %q, want {\"elapsed_secs\":180}", list[0].SummaryJSON)
+ }
+
+ // ListForStage scopes to a single stage.
+ only, err := ss.ListForStage(ctx, runID, "CPUStress")
+ if err != nil {
+ t.Fatalf("ListForStage: %v", err)
+ }
+ if len(only) != 2 {
+ t.Fatalf("ListForStage CPUStress: got %d, want 2", len(only))
+ }
+ other, err := ss.ListForStage(ctx, runID, "Storage")
+ if err != nil {
+ t.Fatalf("ListForStage Storage: %v", err)
+ }
+ if len(other) != 0 {
+ t.Fatalf("ListForStage Storage should be empty, got %d", len(other))
+ }
+}
+
+// TestSubStepsSummaryPreserveOnDefault verifies the intentional special-
+// case: when the second upsert supplies the default empty-object summary
+// ('{}'), the prior non-default summary is kept. This lets a "complete"
+// call that only carries timing update the row without wiping the rich
+// summary the "start" call persisted.
+func TestSubStepsSummaryPreserveOnDefault(t *testing.T) {
+ runs := newDB(t)
+ _, runID := seedRun(t, runs)
+ ss := &store.SubSteps{DB: runs.DB}
+ ctx := context.Background()
+
+ if err := ss.Upsert(ctx, model.SubStep{
+ RunID: runID, StageName: "SMART", Ordinal: 0,
+ Name: "smartctl /dev/sda", State: model.StageRunning,
+ SummaryJSON: `{"device":"/dev/sda"}`,
+ }); err != nil {
+ t.Fatalf("first upsert: %v", err)
+ }
+ // Terminal with empty summary — should not blow away the device field.
+ if err := ss.Upsert(ctx, model.SubStep{
+ RunID: runID, StageName: "SMART", Ordinal: 0,
+ Name: "smartctl /dev/sda", State: model.StagePassed,
+ }); err != nil {
+ t.Fatalf("terminal upsert: %v", err)
+ }
+ got, err := ss.Get(ctx, runID, "SMART", 0)
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got.SummaryJSON != `{"device":"/dev/sda"}` {
+ t.Fatalf("summary wiped: %q", got.SummaryJSON)
+ }
+ if got.State != model.StagePassed {
+ t.Fatalf("state = %q, want passed", got.State)
+ }
+}
diff --git a/internal/web/static/app.css b/internal/web/static/app.css
index 0f284e7..566958a 100644
--- a/internal/web/static/app.css
+++ b/internal/web/static/app.css
@@ -202,8 +202,14 @@ button.danger:hover { background: rgba(229,100,102,.1); }
.tile-pass { border-color: rgba(53,194,123,.5); }
.tile-active { border-color: var(--accent); }
-.form-wrap { max-width: 640px; }
-.form-wrap h1 { font-size: 20px; }
+.form-wrap { max-width: 640px; display: flex; flex-direction: column; gap: 16px; }
+.form-wrap h1 { font-size: 20px; margin: 0; }
+.form-wrap-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+}
.host-form { display: flex; flex-direction: column; gap: 14px; }
.host-form label { display: flex; flex-direction: column; gap: 4px; color: var(--text-dim); font-size: 13px; }
@@ -245,14 +251,10 @@ button.danger:hover { background: rgba(229,100,102,.1); }
body.bare main { max-width: none; }
-.quick-register {
- background: var(--bg-elev);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 16px 18px;
- margin-bottom: 20px;
-}
-.quick-register h2 { margin: 0 0 8px; font-size: 16px; }
+/* .quick-register now inherits card shell from .detail-section; these
+ rules only cover its own content. */
+.quick-register h2 { margin: 0 0 8px; font-size: 15px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-dim); font-weight: 600; }
+.quick-register h2 .muted { text-transform: none; letter-spacing: 0; }
.quick-register p { margin: 6px 0; font-size: 13px; color: var(--text-dim); }
.quick-register p b { color: var(--text); }
.quick-register .muted { color: var(--text-dim); font-weight: 400; }
@@ -270,14 +272,31 @@ body.bare main { max-width: none; }
}
.quick-register .one-liner code { white-space: pre; }
-.manual-register { margin-top: 16px; }
+.manual-register-card { padding-top: 10px; padding-bottom: 14px; }
.manual-register summary {
+ list-style: none;
cursor: pointer;
- color: var(--text-dim);
- font-size: 13px;
- padding: 6px 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 0;
}
-.manual-register summary:hover { color: var(--text); }
+.manual-register summary::before {
+ content: "▸";
+ color: var(--text-dim);
+ font-size: 12px;
+ transition: transform .1s ease;
+}
+.manual-register[open] > summary::before { transform: rotate(90deg); }
+.manual-register summary h2 {
+ margin: 0;
+ font-size: 15px;
+ text-transform: uppercase;
+ letter-spacing: .5px;
+ color: var(--text-dim);
+ font-weight: 600;
+}
+.manual-register summary:hover h2 { color: var(--text); }
.manual-register[open] summary { margin-bottom: 12px; }
/* ===== Host detail page ===== */
@@ -549,3 +568,377 @@ body.bare main { max-width: none; }
0%, 100% { box-shadow: 0 0 0 0 rgba(60,130,246,.55); }
50% { box-shadow: 0 0 0 8px rgba(60,130,246,0); }
}
+
+/* ===== Host detail v2 — GitHub-Actions-style layout ===== */
+
+.detail-v2 { gap: 12px; }
+
+.host-meta-drawer {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 8px 16px;
+}
+.host-meta-drawer > summary {
+ list-style: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ font-size: 13px;
+ color: var(--text-dim);
+ padding: 4px 0;
+}
+.host-meta-drawer > summary::before {
+ content: "▸";
+ color: var(--text-dim);
+ font-size: 11px;
+ transition: transform .1s ease;
+}
+.host-meta-drawer[open] > summary::before { transform: rotate(90deg); }
+.host-meta-drawer .meta-summary-label { color: var(--text); font-weight: 600; }
+.host-meta-drawer .meta-summary-mac { font-family: var(--mono); margin-left: auto; }
+.host-meta-drawer[open] > summary { margin-bottom: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
+
+.run-header {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 16px 20px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 20px;
+ flex-wrap: wrap;
+}
+.run-header.tile-fail { border-color: rgba(229,100,102,.6); }
+.run-header.tile-pass { border-color: rgba(53,194,123,.5); }
+.run-header.tile-active { border-color: var(--accent); }
+.run-header-left { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
+.run-header-right { display: flex; align-items: center; gap: 14px; font-size: 13px; }
+.run-header .detail-name { margin: 0; font-size: 22px; }
+.run-number { font-family: var(--mono); font-size: 15px; color: var(--text-dim); }
+.run-status-badge {
+ padding: 3px 10px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: .5px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: var(--bg-elev-2);
+ color: var(--text-dim);
+ font-weight: 600;
+}
+.run-status-pass { background: rgba(53,194,123,.15); border-color: rgba(53,194,123,.5); color: var(--success); }
+.run-status-fail { background: rgba(229,100,102,.12); border-color: rgba(229,100,102,.5); color: var(--danger); }
+.run-status-active { background: rgba(60,130,246,.15); border-color: rgba(60,130,246,.5); color: var(--accent); }
+.run-duration { font-family: var(--mono); font-size: 13px; color: var(--text-dim); }
+.run-failed-stage { color: var(--danger); }
+.run-failed-stage strong { font-family: var(--mono); }
+.run-diffs { color: var(--danger); }
+
+.hold-banner {
+ background: rgba(229,100,102,.1);
+ border: 1px solid rgba(229,100,102,.5);
+ border-left: 4px solid var(--danger);
+ border-radius: var(--radius);
+ padding: 10px 14px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.hold-banner-label { color: var(--danger); font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: .5px; }
+.hold-banner .hold-ssh {
+ font-family: var(--mono);
+ font-size: 13px;
+ user-select: all;
+ padding: 6px 10px;
+ background: rgba(0,0,0,.3);
+ border: 1px solid rgba(229,100,102,.3);
+ border-radius: 4px;
+ flex: 1 1 auto;
+ min-width: 0;
+ word-break: break-all;
+}
+.detail-hold-placeholder { display: none; }
+
+.detail-body {
+ display: grid;
+ grid-template-columns: 1fr 260px;
+ gap: 16px;
+ align-items: start;
+}
+@media (max-width: 900px) {
+ .detail-body { grid-template-columns: 1fr; }
+}
+.active-step-pane { display: flex; flex-direction: column; gap: 8px; }
+.detail-empty {
+ padding: 24px;
+ background: var(--bg-elev);
+ border: 1px dashed var(--border);
+ border-radius: var(--radius);
+ color: var(--text-dim);
+ text-align: center;
+}
+
+.step {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+.step[open] { border-color: var(--accent); }
+.step-passed { border-left: 3px solid var(--success); }
+.step-running { border-left: 3px solid var(--accent); }
+.step-failed { border-left: 3px solid var(--danger); }
+.step-skipped { opacity: .55; }
+.step-pending { opacity: .7; }
+.step > summary {
+ list-style: none;
+ cursor: pointer;
+ padding: 10px 14px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.step > summary::-webkit-details-marker { display: none; }
+.step-summary .stage-dot { width: 20px; height: 20px; font-size: 12px; flex-shrink: 0; }
+.step-name { font-weight: 600; color: var(--text); flex: 1; }
+.step-duration { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
+.step-body {
+ padding: 8px 14px 14px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ background: var(--bg);
+}
+
+.substep-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.substep {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-elev);
+ font-size: 13px;
+}
+.substep-badge {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ font-weight: 700;
+ background: var(--bg-elev-2);
+ border: 1px solid var(--border);
+ color: var(--text-dim);
+ flex-shrink: 0;
+}
+.substep-badge-passed { background: var(--success); border-color: var(--success); color: #0b0d12; }
+.substep-badge-running { background: var(--accent-strong); border-color: var(--accent); color: #fff; animation: pulse 1.2s ease-in-out infinite; }
+.substep-badge-failed { background: var(--danger); border-color: var(--danger); color: #fff; }
+.substep-badge-skipped { opacity: .5; }
+.substep-name { flex: 1; }
+.substep-duration { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
+.substep-failed { border-color: rgba(229,100,102,.5); }
+.substep-running { border-color: rgba(60,130,246,.5); }
+
+.log-search-wrap { display: flex; }
+.log-search {
+ flex: 1;
+ font: inherit;
+ font-size: 12px;
+ font-family: var(--mono);
+ padding: 6px 10px;
+ background: var(--bg-elev-2);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+}
+.log-search::placeholder { color: var(--text-dim); }
+.log-search:focus { outline: none; border-color: var(--accent); }
+
+/* Log pane: now a standalone block (no tabs). Each step owns its pane. */
+.step .log-pane {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ padding: 6px 0;
+ background: #0b0d12;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--text-dim);
+ max-height: 480px;
+ overflow-y: auto;
+ order: unset;
+}
+.step .log-pane:empty::before {
+ content: "(no log output for this step yet)";
+ padding: 10px 12px;
+ color: var(--text-dim);
+ opacity: .5;
+}
+.log-line {
+ display: grid;
+ grid-template-columns: 24px 48px 56px 72px auto;
+ align-items: baseline;
+ gap: 8px;
+ padding: 2px 10px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ border-left: 3px solid transparent;
+}
+.log-line:target { background: rgba(60,130,246,.12); border-left-color: var(--accent); }
+.log-line .log-anchor {
+ color: var(--text-dim);
+ opacity: 0;
+ font-weight: 700;
+ text-decoration: none;
+ text-align: center;
+}
+.log-line:hover .log-anchor { opacity: 1; }
+.log-line .ln { color: var(--text-dim); opacity: .6; text-align: right; user-select: none; }
+.log-line .lvl { color: var(--text-dim); text-transform: uppercase; font-size: 10px; font-weight: 700; letter-spacing: .5px; }
+.log-line .log-ts { color: var(--text-dim); opacity: .75; }
+.log-line .log-stage { color: var(--text-dim); opacity: .75; margin-right: 4px; }
+.log-line .log-text { color: var(--text); white-space: pre-wrap; word-break: break-word; }
+.log-line.log-warn .log-text { color: var(--warn); }
+.log-line.log-warn .lvl { color: var(--warn); }
+.log-line.log-error .log-text { color: var(--danger); }
+.log-line.log-error .lvl { color: var(--danger); }
+.log-line.log-debug { opacity: .6; }
+.log-line.log-hit { background: rgba(228,169,75,.08); }
+
+.runs-sidebar {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 12px;
+ position: sticky;
+ top: 16px;
+ max-height: calc(100vh - 32px);
+ overflow-y: auto;
+}
+.runs-sidebar-heading {
+ margin: 0 0 10px;
+ font-size: 12px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: .5px;
+ font-weight: 600;
+}
+.runs-sidebar-empty { color: var(--text-dim); font-size: 13px; margin: 0; }
+.runs-sidebar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
+.runs-sidebar-item a {
+ display: grid;
+ grid-template-columns: 16px auto 1fr auto;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ border-radius: 6px;
+ color: var(--text);
+ text-decoration: none;
+ font-size: 12px;
+}
+.runs-sidebar-item a:hover { background: var(--bg-elev-2); text-decoration: none; }
+.runs-sidebar-active a { background: rgba(60,130,246,.12); border: 1px solid rgba(60,130,246,.5); }
+.runs-sidebar-dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 9px;
+ font-weight: 700;
+ background: var(--bg-elev-2);
+ border: 1px solid var(--border);
+ color: var(--text-dim);
+}
+.runs-sidebar-dot-pass { background: var(--success); border-color: var(--success); color: #0b0d12; }
+.runs-sidebar-dot-fail { background: var(--danger); border-color: var(--danger); color: #fff; }
+.runs-sidebar-dot-active { background: var(--accent-strong); border-color: var(--accent); color: #fff; }
+.runs-sidebar-id { font-family: var(--mono); font-weight: 600; }
+.runs-sidebar-started { color: var(--text-dim); }
+.runs-sidebar-duration { font-family: var(--mono); color: var(--text-dim); font-size: 11px; }
+
+.btn-primary {
+ background: var(--accent-strong);
+ border-color: var(--accent-strong);
+ color: #fff;
+}
+.btn-primary:hover { background: var(--accent); border-color: var(--accent); }
+.btn-danger {
+ border-color: var(--danger);
+ color: var(--danger);
+ background: transparent;
+}
+.btn-danger:hover { background: rgba(229,100,102,.1); }
+
+/* ---------- Dashboard tile mini run-view (Phase 3) ---------------- */
+
+/* Small variant of stage-dot for the compact step list. Same colour
+ rules as the full-size pipeline dot so operators read one language
+ everywhere; only the geometry shrinks. */
+.stage-dot-sm {
+ width: 14px;
+ height: 14px;
+ font-size: 9px;
+ border-width: 1px;
+ flex-shrink: 0;
+}
+
+.tile-meta-row {
+ display: flex;
+ gap: 8px;
+ align-items: baseline;
+ font-size: 12px;
+ color: var(--text-dim);
+ padding: 4px 0 6px;
+}
+.tile-run-id { font-variant-numeric: tabular-nums; }
+.tile-run-duration { margin-left: auto; font-variant-numeric: tabular-nums; }
+
+.tile-steplist {
+ list-style: none;
+ margin: 0 0 8px;
+ padding: 0;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 2px 10px;
+}
+.tile-steplist .tile-step {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ line-height: 1.4;
+ color: var(--text-dim);
+ min-width: 0;
+}
+.tile-steplist .tile-step-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+/* Passed/failed/running steps keep full-strength text so the eye jumps
+ to active work; pending/skipped fade back into the background. */
+.tile-step-passed .tile-step-name,
+.tile-step-failed .tile-step-name,
+.tile-step-running .tile-step-name { color: var(--text); }
+.tile-step-skipped { opacity: .5; }
diff --git a/internal/web/static/app.js b/internal/web/static/app.js
new file mode 100644
index 0000000..6ca1871
--- /dev/null
+++ b/internal/web/static/app.js
@@ -0,0 +1,115 @@
+// Detail-page client behaviors. Loaded in layout.templ with `defer` so the
+// DOM is parsed before any listeners fire. Three jobs:
+//
+// 1. Auto-advance: when a substep-* SSE event lands with state=running,
+// open the parent step panel and collapse any previously-running step
+// that's now completed. Keeps the operator's attention on the thing
+// that's currently moving without manual clicks.
+// 2. In-step search: filter `.log-line` rows inside the current step by
+// substring match. Client-side only — the log pane's `` ancestor
+// scopes the filter naturally.
+// 3. Permalink scroll + highlight: when the URL carries `#L{run}-{stage}-{ord}`
+// on load, scroll that log line into view; anchor clicks update
+// `location.hash` without a reload.
+(function () {
+ 'use strict';
+
+ // --- 1. auto-advance on substep SSE ---------------------------------
+
+ document.body.addEventListener('htmx:sseMessage', function (ev) {
+ var name = ev.detail && ev.detail.type;
+ if (!name || name.indexOf('substep-') !== 0) {
+ return;
+ }
+ // After htmx has applied the swap, check which step the just-updated
+ // substep belongs to. We scan *after* the swap so we see the new
+ // class ("substep-running" / "substep-passed") rather than the old.
+ setTimeout(function () {
+ autoAdvance();
+ }, 0);
+ });
+
+ function autoAdvance() {
+ var steps = document.querySelectorAll('.step[data-stage]');
+ var runningStep = null;
+ steps.forEach(function (step) {
+ if (step.querySelector('.substep-running')) {
+ runningStep = step;
+ }
+ });
+ if (!runningStep) {
+ return;
+ }
+ // Open the running step; collapse any other open step that no longer
+ // has a running substep. The default-open step picked server-side
+ // stays open if nothing is running yet.
+ steps.forEach(function (step) {
+ if (step === runningStep) {
+ if (!step.open) { step.open = true; }
+ return;
+ }
+ if (step.open && !step.querySelector('.substep-running')) {
+ // Leave the "currently-failed" step open even when we
+ // auto-advance — operator still wants to see what broke.
+ if (step.classList.contains('step-failed')) { return; }
+ step.open = false;
+ }
+ });
+ }
+
+ // --- 2. in-step search ----------------------------------------------
+
+ document.body.addEventListener('input', function (ev) {
+ var el = ev.target;
+ if (!el.classList || !el.classList.contains('log-search')) {
+ return;
+ }
+ var step = el.closest('.step');
+ if (!step) { return; }
+ var query = el.value.trim().toLowerCase();
+ step.querySelectorAll('.log-line').forEach(function (line) {
+ if (!query) {
+ line.style.display = '';
+ line.classList.remove('log-hit');
+ return;
+ }
+ var text = (line.textContent || '').toLowerCase();
+ if (text.indexOf(query) === -1) {
+ line.style.display = 'none';
+ line.classList.remove('log-hit');
+ } else {
+ line.style.display = '';
+ line.classList.add('log-hit');
+ }
+ });
+ });
+
+ // --- 3. permalink scroll + highlight on load ------------------------
+
+ function scrollToHash() {
+ var hash = (location.hash || '').replace(/^#/, '');
+ if (!hash) { return; }
+ var target = document.getElementById(hash);
+ if (!target) { return; }
+ // Open the enclosing step so the target is actually visible.
+ var step = target.closest('.step');
+ if (step && !step.open) { step.open = true; }
+ target.scrollIntoView({ block: 'center' });
+ }
+
+ window.addEventListener('load', scrollToHash);
+ window.addEventListener('hashchange', scrollToHash);
+
+ // Anchor clicks update location.hash without triggering navigation;
+ // the hashchange listener above handles the scroll + highlight.
+ document.body.addEventListener('click', function (ev) {
+ var a = ev.target.closest && ev.target.closest('.log-anchor');
+ if (!a) { return; }
+ ev.preventDefault();
+ var href = a.getAttribute('href') || '';
+ if (href.indexOf('#') === 0) {
+ history.replaceState(null, '', href);
+ scrollToHash();
+ }
+ });
+})();
diff --git a/internal/web/templates/active_step.templ b/internal/web/templates/active_step.templ
new file mode 100644
index 0000000..54a293f
--- /dev/null
+++ b/internal/web/templates/active_step.templ
@@ -0,0 +1,96 @@
+package templates
+
+import (
+ "fmt"
+ "time"
+
+ "vetting/internal/model"
+)
+
+// ActiveStepData is the per-stage payload for the expanded step panel.
+// The handler builds one per stage in DefaultStageOrder and hands it to
+// ActiveStep so the template stays free of any slicing logic.
+type ActiveStepData struct {
+ RunID int64
+ Stage model.Stage
+ SubSteps []model.SubStep
+ LogReplay string
+ Open bool
+}
+
+// ActiveStep renders one stage's expanded panel: the header summary
+// (state badge, stage name, duration), any sub-step rows, a per-step
+// search box, and a live log pane scoped to that stage's SSE topic.
+// Uses so the server-picked default stage
+// opens automatically on page load; app.js takes over after that for
+// SSE-driven auto-advance.
+templ ActiveStep(d ActiveStepData) {
+
+
+ { stageMarker(string(d.Stage.State)) }
+ { d.Stage.Name }
+ { stageDurationFromStage(d.Stage) }
+
+
+ if len(d.SubSteps) > 0 {
+
+ for _, ss := range d.SubSteps {
+ @SubStepRow(ss)
+ }
+
+ }
+
+
+
+
+ @templ.Raw(d.LogReplay)
+
+
+
+}
+
+// SubStepsForStage filters a flat []SubStep to just the entries for one
+// stage. Used by host_detail when wiring ActiveStepData — keeps the
+// filtering logic testable and off the template surface.
+func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
+ out := make([]model.SubStep, 0, len(all))
+ for _, ss := range all {
+ if ss.StageName == stageName {
+ out = append(out, ss)
+ }
+ }
+ return out
+}
+
+// stageDurationFromStage is stageDuration adapted to a model.Stage — same
+// formatting rules, different input shape.
+func stageDurationFromStage(s model.Stage) string {
+ if s.StartedAt == nil {
+ return ""
+ }
+ end := time.Now()
+ if s.CompletedAt != nil {
+ end = *s.CompletedAt
+ }
+ d := end.Sub(*s.StartedAt)
+ if d < 0 {
+ d = 0
+ }
+ switch {
+ case d < time.Second:
+ return fmt.Sprintf("%dms", int(d/time.Millisecond))
+ case d < 10*time.Second:
+ return fmt.Sprintf("%.1fs", d.Seconds())
+ case d < time.Minute:
+ return fmt.Sprintf("%ds", int(d/time.Second))
+ case d < time.Hour:
+ return fmt.Sprintf("%dm", int(d/time.Minute))
+ default:
+ return fmt.Sprintf("%dh", int(d/time.Hour))
+ }
+}
diff --git a/internal/web/templates/active_step_templ.go b/internal/web/templates/active_step_templ.go
new file mode 100644
index 0000000..4c7c13b
--- /dev/null
+++ b/internal/web/templates/active_step_templ.go
@@ -0,0 +1,274 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1001
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "time"
+
+ "vetting/internal/model"
+)
+
+// ActiveStepData is the per-stage payload for the expanded step panel.
+// The handler builds one per stage in DefaultStageOrder and hands it to
+// ActiveStep so the template stays free of any slicing logic.
+type ActiveStepData struct {
+ RunID int64
+ Stage model.Stage
+ SubSteps []model.SubStep
+ LogReplay string
+ Open bool
+}
+
+// ActiveStep renders one stage's expanded panel: the header summary
+// (state badge, stage name, duration), any sub-step rows, a per-step
+// search box, and a live log pane scoped to that stage's SSE topic.
+// Uses so the server-picked default stage
+// opens automatically on page load; app.js takes over after that for
+// SSE-driven auto-advance.
+func ActiveStep(d ActiveStepData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var templ_7745c5c3_Var2 = []any{"step", "step-" + string(d.Stage.State)}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 = []any{"stage-dot", "stage-dot-" + string(d.Stage.State)}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ 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}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ 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}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ 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}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// SubStepsForStage filters a flat []SubStep to just the entries for one
+// stage. Used by host_detail when wiring ActiveStepData — keeps the
+// filtering logic testable and off the template surface.
+func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
+ out := make([]model.SubStep, 0, len(all))
+ for _, ss := range all {
+ if ss.StageName == stageName {
+ out = append(out, ss)
+ }
+ }
+ return out
+}
+
+// stageDurationFromStage is stageDuration adapted to a model.Stage — same
+// formatting rules, different input shape.
+func stageDurationFromStage(s model.Stage) string {
+ if s.StartedAt == nil {
+ return ""
+ }
+ end := time.Now()
+ if s.CompletedAt != nil {
+ end = *s.CompletedAt
+ }
+ d := end.Sub(*s.StartedAt)
+ if d < 0 {
+ d = 0
+ }
+ switch {
+ case d < time.Second:
+ return fmt.Sprintf("%dms", int(d/time.Millisecond))
+ case d < 10*time.Second:
+ return fmt.Sprintf("%.1fs", d.Seconds())
+ case d < time.Minute:
+ return fmt.Sprintf("%ds", int(d/time.Second))
+ case d < time.Hour:
+ return fmt.Sprintf("%dm", int(d/time.Minute))
+ default:
+ return fmt.Sprintf("%dh", int(d/time.Hour))
+ }
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/web/templates/dashboard.templ b/internal/web/templates/dashboard.templ
index b2fe522..052502a 100644
--- a/internal/web/templates/dashboard.templ
+++ b/internal/web/templates/dashboard.templ
@@ -9,10 +9,14 @@ import (
// TileData pairs a host with its latest run and the derived fields the
// tile needs to render: spec-diff count (server-side diff result) and
// the on-disk path to the hold-key artifact when the run is holding.
-// LastSeenAt is the host-mode agent's most recent heartbeat.
+// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is
+// the list of canonical-order stage rows for Latest, used by HostTile
+// to render the mini run-view; nil/empty for never-run hosts (a ghost
+// dot strip is rendered from DefaultStageOrder).
type TileData struct {
Host model.Host
Latest *model.Run
+ Stages []model.Stage
SpecDiffCritical int
HoldKeyPath string
LastSeenAt *time.Time
diff --git a/internal/web/templates/dashboard_templ.go b/internal/web/templates/dashboard_templ.go
index 32deb7f..638853b 100644
--- a/internal/web/templates/dashboard_templ.go
+++ b/internal/web/templates/dashboard_templ.go
@@ -17,10 +17,14 @@ import (
// TileData pairs a host with its latest run and the derived fields the
// tile needs to render: spec-diff count (server-side diff result) and
// the on-disk path to the hold-key artifact when the run is holding.
-// LastSeenAt is the host-mode agent's most recent heartbeat.
+// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is
+// the list of canonical-order stage rows for Latest, used by HostTile
+// to render the mini run-view; nil/empty for never-run hosts (a ghost
+// dot strip is rendered from DefaultStageOrder).
type TileData struct {
Host model.Host
Latest *model.Run
+ Stages []model.Stage
SpecDiffCritical int
HoldKeyPath string
LastSeenAt *time.Time
diff --git a/internal/web/templates/host_detail.templ b/internal/web/templates/host_detail.templ
index 2261447..68b6c85 100644
--- a/internal/web/templates/host_detail.templ
+++ b/internal/web/templates/host_detail.templ
@@ -4,34 +4,56 @@ import (
"bytes"
"context"
"fmt"
+ "time"
"vetting/internal/model"
"vetting/internal/store"
)
// HostDetailData is the full payload the detail handler hands to the
-// HostDetail template. Tile carries host + latest-run enrichment (same
-// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
-// and diff list. LogReplay is the pre-rendered history fragment
-// produced by logs.Hub.Replay on the initial page render so the operator
-// sees prior output without waiting for a fresh SSE event.
+// HostDetail template. Tile carries host + viewed-run enrichment (same
+// shape the dashboard tile uses), Stages/SpecDiffs/SubSteps drive the
+// pipeline, diff list, and expanded step panel. History backs the runs
+// sidebar (last 20, newest first). DefaultStepStage is the stage name
+// whose opens by default on page load — running → failed →
+// Reporting. LogReplay is the pre-rendered history fragment produced
+// by logs.Hub.Replay on the initial page render so the operator sees
+// prior output without waiting for a fresh SSE event.
type HostDetailData struct {
- Tile TileData
- Stages []model.Stage
- SpecDiffs []model.SpecDiff
- LogReplay string
+ Tile TileData
+ Stages []model.Stage
+ SpecDiffs []model.SpecDiff
+ SubSteps []model.SubStep
+ History []model.Run
+ DefaultStepStage string
+ LogReplay string
+ // LogReplayByStage is the pre-rendered log HTML grouped by stage
+ // name. Each ActiveStep panel picks its own bucket so the detail
+ // page doesn't fire nine disk scans per reload. The "" key holds
+ // orphan/framing lines (no stage set), surfaced under the "Run"
+ // pseudo-step at the top of the page.
+ LogReplayByStage map[string]string
}
+// HostDetail is the GitHub-Actions-style run view. Layout is: meta
+// drawer (collapsed) → run header + actions → hold banner → horizontal
+// pipeline → two-column body (active-step pane + runs sidebar) → spec
+// diffs at the bottom. Each section keeps its own sse-swap target so
+// live updates don't trigger whole-page reflows.
templ HostDetail(d HostDetailData) {
@Layout(d.Tile.Host.Name) {
-
+
+ @HostMetaDrawer(d)
+
@DetailSummary(d)
+ @DetailActions(d)
+ @DetailHold(d)
if d.Tile.Latest != nil {
@PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages))
@@ -42,51 +64,41 @@ templ HostDetail(d HostDetailData) {
}
- @DetailHold(d)
- @DetailActions(d)
- @DetailSpecDiffs(d)
-
- if d.Tile.Latest != nil {
- @LogTabs(d.Tile.Latest.ID, d.LogReplay)
- }
-
-
-
-
+
+ @DetailSpecDiffs(d)
}
}
-// DetailSummary is the status header at the top of the detail page:
-// name, last-seen badge, run status, MAC/WoL/failed-stage/spec-diffs
-// meta grid. Keyed on host ID so the SSE event name is stable across
-// run turnover.
-templ DetailSummary(d HostDetailData) {
-
-
+// HostMetaDrawer is the collapsed "host details" block at the top of the
+// page: MAC, WoL, last-seen, expected spec, and notes. defaults
+// to closed so the run itself stays above the fold; operators open it
+// when they need the provisioning info.
+templ HostMetaDrawer(d HostDetailData) {
+
+
+ Host details
+ { lastSeenLabel(d.Tile.LastSeenAt) }
+ { d.Tile.Host.MAC }
+
+
+}
+
+// DetailSummary is the run header: host name on the left, run number,
+// status icon, and elapsed/total duration. Keyed on host ID so the SSE
+// event name is stable across run turnover.
+templ DetailSummary(d HostDetailData) {
+
+
if canStart(d.Tile) {
} else if canStartIfOnline(d.Tile.Latest) {
@@ -141,19 +181,19 @@ templ DetailActions(d HostDetailData) {
}
if canCancel(d.Tile.Latest) {
}
if canOverrideWipe(d.Tile.Latest) {
}
if hasReport(d.Tile.Latest) {
View report
}
-
@@ -192,20 +232,21 @@ templ DetailSpecDiffs(d HostDetailData) {
}
}
-// DetailHold renders the "Host is holding — SSH available" block while
-// a run is in FailedHolding with an IP recorded. Otherwise it emits an
-// empty wrapper so the first push when the hold actually fires has a
-// target. Keyed on run ID for the same reason as DetailSpecDiffs.
+// DetailHold renders the "Host is holding — SSH available" strip across
+// the top when a run is in FailedHolding with an IP recorded. Otherwise
+// it emits an empty wrapper so the first SSE push when the hold actually
+// fires has a target. Keyed on run ID for the same reason as
+// DetailSpecDiffs.
templ DetailHold(d HostDetailData) {
if d.Tile.Latest != nil {
if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
-
Host is holding — SSH available
+ Host is holding — SSH available:{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }
} else {
@@ -219,6 +260,32 @@ templ DetailHold(d HostDetailData) {
}
}
+// RunsSidebar is the right-rail history list: last 20 runs for this
+// host, newest first. Each entry links back to /hosts/{id}?run=N for
+// navigation into a past run. The row for the currently-viewed run is
+// flagged so CSS can highlight it.
+templ RunsSidebar(d HostDetailData) {
+
+}
+
// RenderDetailSummaryString, RenderDetailActionsString,
// RenderDetailSpecDiffsString, RenderDetailHoldString each render one
// component to a string so the orchestrator can publish SSE fragments
@@ -259,37 +326,98 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
return false
}
-// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder.
-// Switching is pure CSS: hidden radio inputs drive sibling-selector
-// visibility on the panes. Each pane carries its own sse-swap target so
-// live events append only to the relevant pane. The All pane is seeded
-// with replay HTML so reload on an in-flight run still shows history.
-templ LogTabs(runID int64, replay string) {
-
-
Log
-
-
-
- for _, s := range store.DefaultStageOrder {
-
-
- }
-
- @templ.Raw(replay)
-
- for _, s := range store.DefaultStageOrder {
-
- }
-
-
+// stageForName returns the persisted Stage row for a given name, or a
+// synthetic pending-state stub when no row has been seeded yet (e.g.
+// the run is still in a pre-stage). Keeps the template free of nil
+// checks and ghost logic — ActiveStep always gets a concrete Stage.
+func stageForName(stages []model.Stage, name string) model.Stage {
+ for _, s := range stages {
+ if s.Name == name {
+ return s
+ }
+ }
+ return model.Stage{Name: name, State: model.StagePending}
+}
+
+// runSidebarActiveClass marks the row for the currently-viewed run so
+// CSS can highlight it. Empty string (no class added) when the row isn't
+// the active one.
+func runSidebarActiveClass(viewed *model.Run, rowID int64) string {
+ if viewed != nil && viewed.ID == rowID {
+ return "runs-sidebar-active"
+ }
+ return ""
+}
+
+// runDuration formats the elapsed time for a run using the same buckets
+// as stageDuration. In-flight runs clock from StartedAt to now so the
+// header duration keeps updating on each SSE tick.
+func runDuration(r *model.Run) string {
+ if r == nil || r.StartedAt.IsZero() {
+ return ""
+ }
+ end := time.Now()
+ if r.CompletedAt != nil {
+ end = *r.CompletedAt
+ }
+ d := end.Sub(r.StartedAt)
+ if d < 0 {
+ d = 0
+ }
+ switch {
+ case d < time.Second:
+ return fmt.Sprintf("%dms", int(d/time.Millisecond))
+ case d < 10*time.Second:
+ return fmt.Sprintf("%.1fs", d.Seconds())
+ case d < time.Minute:
+ return fmt.Sprintf("%ds", int(d/time.Second))
+ case d < time.Hour:
+ return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
+ default:
+ return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
+ }
+}
+
+// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago"
+// for the runs-sidebar. Future times (clock skew on the host) render as
+// "now" so the sidebar never shows nonsense.
+func relativeTime(t time.Time) string {
+ if t.IsZero() {
+ return ""
+ }
+ d := time.Since(t)
+ if d < 0 {
+ return "now"
+ }
+ if d < time.Minute {
+ return "just now"
+ }
+ if d < time.Hour {
+ return fmt.Sprintf("%dm ago", int(d/time.Minute))
+ }
+ if d < 24*time.Hour {
+ return fmt.Sprintf("%dh ago", int(d/time.Hour))
+ }
+ return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
+}
+
+// runSidebarGlyph mirrors stageMarker for run-state: ✓ / ! / ● / –.
+// Used inside the sidebar dot so the color + glyph carry redundant
+// meaning.
+func runSidebarGlyph(r *model.Run) string {
+ if r == nil {
+ return ""
+ }
+ switch r.State {
+ case model.StateCompleted:
+ return "✓"
+ case model.StateFailed, model.StateFailedHolding:
+ return "!"
+ case model.StateReleased, model.StateCancelled:
+ return "–"
+ }
+ if r.State.IsTerminal() {
+ return ""
+ }
+ return "●"
}
diff --git a/internal/web/templates/host_detail_templ.go b/internal/web/templates/host_detail_templ.go
index 00a6e4e..7e69059 100644
--- a/internal/web/templates/host_detail_templ.go
+++ b/internal/web/templates/host_detail_templ.go
@@ -12,24 +12,42 @@ import (
"bytes"
"context"
"fmt"
+ "time"
"vetting/internal/model"
"vetting/internal/store"
)
// HostDetailData is the full payload the detail handler hands to the
-// HostDetail template. Tile carries host + latest-run enrichment (same
-// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
-// and diff list. LogReplay is the pre-rendered history fragment
-// produced by logs.Hub.Replay on the initial page render so the operator
-// sees prior output without waiting for a fresh SSE event.
+// HostDetail template. Tile carries host + viewed-run enrichment (same
+// shape the dashboard tile uses), Stages/SpecDiffs/SubSteps drive the
+// pipeline, diff list, and expanded step panel. History backs the runs
+// sidebar (last 20, newest first). DefaultStepStage is the stage name
+// whose opens by default on page load — running → failed →
+// Reporting. LogReplay is the pre-rendered history fragment produced
+// by logs.Hub.Replay on the initial page render so the operator sees
+// prior output without waiting for a fresh SSE event.
type HostDetailData struct {
- Tile TileData
- Stages []model.Stage
- SpecDiffs []model.SpecDiff
- LogReplay string
+ Tile TileData
+ Stages []model.Stage
+ SpecDiffs []model.SpecDiff
+ SubSteps []model.SubStep
+ History []model.Run
+ DefaultStepStage string
+ LogReplay string
+ // LogReplayByStage is the pre-rendered log HTML grouped by stage
+ // name. Each ActiveStep panel picks its own bucket so the detail
+ // page doesn't fire nine disk scans per reload. The "" key holds
+ // orphan/framing lines (no stage set), surfaced under the "Run"
+ // pseudo-step at the top of the page.
+ LogReplayByStage map[string]string
}
+// HostDetail is the GitHub-Actions-style run view. Layout is: meta
+// drawer (collapsed) → run header + actions → hold banner → horizontal
+// pipeline → two-column body (active-step pane + runs sidebar) → spec
+// diffs at the bottom. Each section keeps its own sse-swap target so
+// live updates don't trigger whole-page reflows.
func HostDetail(d HostDetailData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -63,14 +81,14 @@ func HostDetail(d HostDetailData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -171,10 +186,152 @@ func HostDetail(d HostDetailData) templ.Component {
})
}
-// DetailSummary is the status header at the top of the detail page:
-// name, last-seen badge, run status, MAC/WoL/failed-stage/spec-diffs
-// meta grid. Keyed on host ID so the SSE event name is stable across
-// run turnover.
+// HostMetaDrawer is the collapsed "host details" block at the top of the
+// page: MAC, WoL, last-seen, expected spec, and notes. defaults
+// to closed so the run itself stays above the fold; operators open it
+// when they need the provisioning info.
+func HostMetaDrawer(d HostDetailData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var4 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var4 == nil {
+ templ_7745c5c3_Var4 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Host details ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 = []any{"tile-last-seen", lastSeenClass(d.Tile.LastSeenAt)}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.Tile.LastSeenAt))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 99, Col: 104}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.MAC)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 100, Col: 51}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -704,10 +864,11 @@ func DetailSpecDiffs(d HostDetailData) templ.Component {
})
}
-// DetailHold renders the "Host is holding — SSH available" block while
-// a run is in FailedHolding with an IP recorded. Otherwise it emits an
-// empty wrapper so the first push when the hold actually fires has a
-// target. Keyed on run ID for the same reason as DetailSpecDiffs.
+// DetailHold renders the "Host is holding — SSH available" strip across
+// the top when a run is in FailedHolding with an IP recorded. Otherwise
+// it emits an empty wrapper so the first SSE push when the hold actually
+// fires has a target. Keyed on run ID for the same reason as
+// DetailSpecDiffs.
func DetailHold(d HostDetailData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -724,84 +885,84 @@ func DetailHold(d HostDetailData) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var37 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var37 == nil {
- templ_7745c5c3_Var37 = templ.NopComponent
+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var43 == nil {
+ templ_7745c5c3_Var43 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if d.Tile.Latest != nil {
if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
Host is holding — SSH available
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" hx-swap=\"outerHTML\">Host is holding — SSH available:")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var40 string
- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP))
+ var templ_7745c5c3_Var46 string
+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 209, Col: 84}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 250, Col: 84}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -811,6 +972,169 @@ func DetailHold(d HostDetailData) templ.Component {
})
}
+// RunsSidebar is the right-rail history list: last 20 runs for this
+// host, newest first. Each entry links back to /hosts/{id}?run=N for
+// navigation into a past run. The row for the currently-viewed run is
+// flagged so CSS can highlight it.
+func RunsSidebar(d HostDetailData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var49 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var49 == nil {
+ templ_7745c5c3_Var49 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
// RenderDetailSummaryString, RenderDetailActionsString,
// RenderDetailSpecDiffsString, RenderDetailHoldString each render one
// component to a string so the orchestrator can publish SSE fragments
@@ -851,245 +1175,100 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
return false
}
-// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder.
-// Switching is pure CSS: hidden radio inputs drive sibling-selector
-// visibility on the panes. Each pane carries its own sse-swap target so
-// live events append only to the relevant pane. The All pane is seeded
-// with replay HTML so reload on an in-flight run still shows history.
-func LogTabs(runID int64, replay string) templ.Component {
- return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
- templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
- if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
- return templ_7745c5c3_CtxErr
+// stageForName returns the persisted Stage row for a given name, or a
+// synthetic pending-state stub when no row has been seeded yet (e.g.
+// the run is still in a pre-stage). Keeps the template free of nil
+// checks and ghost logic — ActiveStep always gets a concrete Stage.
+func stageForName(stages []model.Stage, name string) model.Stage {
+ for _, s := range stages {
+ if s.Name == name {
+ return s
}
- templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
- if !templ_7745c5c3_IsBuffer {
- defer func() {
- templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
- if templ_7745c5c3_Err == nil {
- templ_7745c5c3_Err = templ_7745c5c3_BufErr
- }
- }()
- }
- ctx = templ.InitializeContext(ctx)
- templ_7745c5c3_Var43 := templ.GetChildren(ctx)
- if templ_7745c5c3_Var43 == nil {
- templ_7745c5c3_Var43 = templ.NopComponent
- }
- ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- for _, s := range store.DefaultStageOrder {
- var templ_7745c5c3_Var55 = []any{"log-pane", "log-pane-" + s}
- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var55...)
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- return nil
- })
+ }
+ return model.Stage{Name: name, State: model.StagePending}
+}
+
+// runSidebarActiveClass marks the row for the currently-viewed run so
+// CSS can highlight it. Empty string (no class added) when the row isn't
+// the active one.
+func runSidebarActiveClass(viewed *model.Run, rowID int64) string {
+ if viewed != nil && viewed.ID == rowID {
+ return "runs-sidebar-active"
+ }
+ return ""
+}
+
+// runDuration formats the elapsed time for a run using the same buckets
+// as stageDuration. In-flight runs clock from StartedAt to now so the
+// header duration keeps updating on each SSE tick.
+func runDuration(r *model.Run) string {
+ if r == nil || r.StartedAt.IsZero() {
+ return ""
+ }
+ end := time.Now()
+ if r.CompletedAt != nil {
+ end = *r.CompletedAt
+ }
+ d := end.Sub(r.StartedAt)
+ if d < 0 {
+ d = 0
+ }
+ switch {
+ case d < time.Second:
+ return fmt.Sprintf("%dms", int(d/time.Millisecond))
+ case d < 10*time.Second:
+ return fmt.Sprintf("%.1fs", d.Seconds())
+ case d < time.Minute:
+ return fmt.Sprintf("%ds", int(d/time.Second))
+ case d < time.Hour:
+ return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
+ default:
+ return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
+ }
+}
+
+// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago"
+// for the runs-sidebar. Future times (clock skew on the host) render as
+// "now" so the sidebar never shows nonsense.
+func relativeTime(t time.Time) string {
+ if t.IsZero() {
+ return ""
+ }
+ d := time.Since(t)
+ if d < 0 {
+ return "now"
+ }
+ if d < time.Minute {
+ return "just now"
+ }
+ if d < time.Hour {
+ return fmt.Sprintf("%dm ago", int(d/time.Minute))
+ }
+ if d < 24*time.Hour {
+ return fmt.Sprintf("%dh ago", int(d/time.Hour))
+ }
+ return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
+}
+
+// runSidebarGlyph mirrors stageMarker for run-state: ✓ / ! / ● / –.
+// Used inside the sidebar dot so the color + glyph carry redundant
+// meaning.
+func runSidebarGlyph(r *model.Run) string {
+ if r == nil {
+ return ""
+ }
+ switch r.State {
+ case model.StateCompleted:
+ return "✓"
+ case model.StateFailed, model.StateFailedHolding:
+ return "!"
+ case model.StateReleased, model.StateCancelled:
+ return "–"
+ }
+ if r.State.IsTerminal() {
+ return ""
+ }
+ return "●"
}
var _ = templruntime.GeneratedTemplate
diff --git a/internal/web/templates/host_tile.templ b/internal/web/templates/host_tile.templ
index 0fcf381..33e38d3 100644
--- a/internal/web/templates/host_tile.templ
+++ b/internal/web/templates/host_tile.templ
@@ -5,13 +5,18 @@ import (
"context"
"fmt"
"time"
+
"vetting/internal/model"
+ "vetting/internal/store"
)
-// HostTile renders a single dashboard card. The whole tile is a link
-// to /hosts/{id} (via a CSS-overlay ) — every control beyond the one
-// primary action lives on the detail page. It's the SSE-swap target
-// for per-host tile refreshes (`tile-N`).
+// HostTile renders a single dashboard card as a mini run-view. The whole
+// tile is a link to /hosts/{id} (via a CSS-overlay ) — every control
+// beyond the one primary action lives on the detail page. It's the SSE-
+// swap target for per-host tile refreshes (`tile-N`). The step list is
+// a compact vertical strip of the 9 canonical stages with just a
+// coloured dot per stage; operators can read run health at a glance
+// across the whole dashboard without drilling in.
templ HostTile(t TileData) {
{ tileStatus(t.Latest) }
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/internal/web/templates/substep_row.templ b/internal/web/templates/substep_row.templ
new file mode 100644
index 0000000..7034664
--- /dev/null
+++ b/internal/web/templates/substep_row.templ
@@ -0,0 +1,81 @@
+package templates
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "time"
+
+ "vetting/internal/model"
+)
+
+// subStepDuration formats a sub-step's elapsed time the same way
+// stageDuration does for pipeline nodes. Empty string when not started.
+func subStepDuration(ss model.SubStep) string {
+ if ss.StartedAt == nil {
+ return ""
+ }
+ end := time.Now()
+ if ss.CompletedAt != nil {
+ end = *ss.CompletedAt
+ }
+ d := end.Sub(*ss.StartedAt)
+ if d < 0 {
+ d = 0
+ }
+ switch {
+ case d < time.Second:
+ return fmt.Sprintf("%dms", int(d/time.Millisecond))
+ case d < 10*time.Second:
+ return fmt.Sprintf("%.1fs", d.Seconds())
+ case d < time.Minute:
+ return fmt.Sprintf("%ds", int(d/time.Second))
+ case d < time.Hour:
+ return fmt.Sprintf("%dm", int(d/time.Minute))
+ default:
+ return fmt.Sprintf("%dh", int(d/time.Hour))
+ }
+}
+
+// subStepMarker mirrors stageMarker — a single-char glyph used inside the
+// state badge. StageState values reused verbatim for sub-steps.
+func subStepMarker(s model.StageState) string {
+ switch s {
+ case model.StagePassed:
+ return "✓"
+ case model.StageFailed:
+ return "!"
+ case model.StageRunning:
+ return "●"
+ case model.StageSkipped:
+ return "–"
+ }
+ return ""
+}
+
+// SubStepRow renders one sub-step entry for the expanded-step pane. The
+// outer
carries the sse-swap target keyed by (runID, stage,
+// ordinal) so the orchestrator's PublishSubStepUpdate can swap just this
+// row without touching the rest of the stage panel. hx-swap="outerHTML"
+// keeps the attributes intact across repeat swaps.
+templ SubStepRow(ss model.SubStep) {
+