From f79fe0f0db7bacedcb3940d2096bab58691179f5 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 18 Apr 2026 19:00:11 -0400 Subject: [PATCH] ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshapes the detail page into a run-view: hybrid horizontal pipeline + expanded active-step pane with sub-steps, a per-step log pane with line-numbered permalinks and client-side search, and a runs-history sidebar that navigates via ?run=N. Default step is server-picked (running → failed → Reporting) so the operator lands on the thing that's moving. Adds a sub_steps table + SSE topic (substep-{run}-{stage}-{ordinal}) so per-disk and per-pass work (SMART, CPUStress CPU/RAM, Storage, GPU) is visible in the UI instead of buried in stage summary JSON. Agent emits sub-step reports from existing per-iteration loops. Dashboard tiles become a mini run-view with a 9-dot step strip so the operator reads run health across the whole grid at a glance. Register page gets the same card shell + button styling. Co-Authored-By: Claude Opus 4.7 --- agent/client.go | 13 + agent/runner.go | 19 + agent/tests/cpustress.go | 80 +- agent/tests/gpu.go | 42 +- agent/tests/smart.go | 58 +- agent/tests/stage.go | 27 +- agent/tests/storage.go | 39 +- cmd/vetting/main.go | 10 +- internal/api/agent_handlers.go | 94 +- internal/api/agent_handlers_test.go | 73 + internal/api/host_detail_test.go | 300 +++- internal/api/tile.go | 11 + internal/api/ui_handlers.go | 84 +- internal/db/migrations/0004_add_sub_steps.sql | 13 + internal/httpserver/sse_e2e_test.go | 66 + internal/logs/logs.go | 193 ++- internal/model/model.go | 18 + internal/orchestrator/runner.go | 19 + internal/store/runs.go | 39 + internal/store/substeps.go | 140 ++ internal/store/substeps_test.go | 126 ++ internal/web/static/app.css | 423 ++++- internal/web/static/app.js | 115 ++ internal/web/templates/active_step.templ | 96 ++ internal/web/templates/active_step_templ.go | 274 ++++ internal/web/templates/dashboard.templ | 6 +- internal/web/templates/dashboard_templ.go | 6 +- internal/web/templates/host_detail.templ | 336 ++-- internal/web/templates/host_detail_templ.go | 1435 +++++++++-------- internal/web/templates/host_tile.templ | 35 +- internal/web/templates/host_tile_templ.go | 226 ++- internal/web/templates/host_tile_test.go | 95 ++ internal/web/templates/layout.templ | 1 + internal/web/templates/layout_templ.go | 4 +- internal/web/templates/registration.templ | 73 +- internal/web/templates/registration_templ.go | 26 +- internal/web/templates/substep_row.templ | 81 + internal/web/templates/substep_row_templ.go | 212 +++ 38 files changed, 3972 insertions(+), 936 deletions(-) create mode 100644 internal/db/migrations/0004_add_sub_steps.sql create mode 100644 internal/store/substeps.go create mode 100644 internal/store/substeps_test.go create mode 100644 internal/web/static/app.js create mode 100644 internal/web/templates/active_step.templ create mode 100644 internal/web/templates/active_step_templ.go create mode 100644 internal/web/templates/substep_row.templ create mode 100644 internal/web/templates/substep_row_templ.go 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: +// +//
+// # +// {ord} +// {LEVEL} +// 15:04:05 +// (optional) [{stage}] +// {text} +//
+func renderLogSSE(runID int64, ord int, l Line) string { level := strings.ToLower(l.Level) + stageKey := l.Stage + if stageKey == "" { + stageKey = "all" + } + anchorID := fmt.Sprintf("L%d-%s-%d", runID, html.EscapeString(stageKey), ord) stagePrefix := "" if l.Stage != "" { stagePrefix = fmt.Sprintf(`[%s] `, html.EscapeString(l.Stage)) } return fmt.Sprintf( - `
%s %s%s
`, + `
#%d%s%s%s%s
`, 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 + } + if len(d.SubSteps) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, ss := range d.SubSteps { + templ_7745c5c3_Err = SubStepRow(ss).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + 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 + } + templ_7745c5c3_Err = templ.Raw(d.LogReplay).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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) - } - -
-
-

Host details

- if d.Tile.Host.Notes != "" { -
-

Notes

-

{ d.Tile.Host.Notes }

-
+
+
+ if d.Tile.Latest != nil { + for _, stageName := range store.DefaultStageOrder { + @ActiveStep(ActiveStepData{ + RunID: d.Tile.Latest.ID, + Stage: stageForName(d.Stages, stageName), + SubSteps: SubStepsForStage(d.SubSteps, stageName), + LogReplay: d.LogReplayByStage[stageName], + Open: stageName == d.DefaultStepStage, + }) + } + } else { +

No run yet. Click Start vetting to begin.

} -
-

Expected spec

-
{ d.Tile.Host.ExpectedSpecYAML }
-
-
-
+
+ @RunsSidebar(d) +
+ + @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) { -
-
-

{ d.Tile.Host.Name }

-
- { lastSeenLabel(d.Tile.LastSeenAt) } - { tileStatus(d.Tile.Latest) } -
-
+// 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 } +
MAC
@@ -96,19 +108,48 @@ templ DetailSummary(d HostDetailData) {
WoL
{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }
+
+ if d.Tile.Host.Notes != "" { +
+

Notes

+

{ d.Tile.Host.Notes }

+
+ } +
+

Expected spec

+
{ d.Tile.Host.ExpectedSpecYAML }
+
+
+} + +// 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) { +
+
+

{ d.Tile.Host.Name }

+ if d.Tile.Latest != nil { + { fmt.Sprintf("run #%d", d.Tile.Latest.ID) } + } + { tileStatus(d.Tile.Latest) } + if d.Tile.Latest != nil { + { runDuration(d.Tile.Latest) } + } +
+
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" { -
-
Failed at
-
{ d.Tile.Latest.FailedStage }
-
+ failed at { d.Tile.Latest.FailedStage } } if d.Tile.SpecDiffCritical > 0 { -
-
Spec diffs
-
{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }
-
+ { fmt.Sprintf("%d critical diff", d.Tile.SpecDiffCritical) } } - +
} @@ -124,7 +165,6 @@ templ DetailActions(d HostDetailData) { sse-swap={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) } hx-swap="outerHTML" > -

Actions

if canStart(d.Tile) {
@@ -132,7 +172,7 @@ templ DetailActions(d HostDetailData) { Non-destructive (skip wipe-probe + disk writes) - +
} 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, "
MAC
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, 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: 105, Col: 25} + } + _, 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, 15, "
WoL
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 109, Col: 79} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Tile.Host.Notes != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

Notes

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 115, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

Expected spec

")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var12 string
+		templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 120, Col: 63}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// 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. func DetailSummary(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 @@ -191,185 +348,188 @@ func DetailSummary(d HostDetailData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var6 := templ.GetChildren(ctx) - if templ_7745c5c3_Var6 == nil { - templ_7745c5c3_Var6 = templ.NopComponent + templ_7745c5c3_Var13 := templ.GetChildren(ctx) + if templ_7745c5c3_Var13 == nil { + templ_7745c5c3_Var13 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var7 = []any{"detail-summary", "tile-" + tileMood(d.Tile.Latest)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) + var templ_7745c5c3_Var14 = []any{"run-header", "tile-" + tileMood(d.Tile.Latest)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...) 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 - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 84, Col: 45} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var12 = []any{"tile-last-seen", lastSeenClass(d.Tile.LastSeenAt)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, 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: 86, Col: 105} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
MAC
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" class=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.MAC) + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var14).String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 93, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
WoL
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" sse-swap=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort)) + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 97, Col: 79} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 132, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-swap=\"outerHTML\">

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
Failed at
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Latest.FailedStage) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 102, Col: 48} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 136, Col: 45} } - if d.Tile.SpecDiffCritical > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
Spec diffs
") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Tile.Latest != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical)) + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("run #%d", d.Tile.Latest.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 108, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 138, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + var templ_7745c5c3_Var20 = []any{"run-status-badge", "run-status-" + tileMood(d.Tile.Latest)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.Tile.Latest)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 140, Col: 106} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Tile.Latest != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(d.Tile.Latest)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 142, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "failed at ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Latest.FailedStage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 147, Col: 80} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if d.Tile.SpecDiffCritical > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical diff", d.Tile.SpecDiffCritical)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 150, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -398,141 +558,141 @@ func DetailActions(d HostDetailData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var20 := templ.GetChildren(ctx) - if templ_7745c5c3_Var20 == nil { - templ_7745c5c3_Var20 = templ.NopComponent + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "

Actions

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if canStart(d.Tile) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if canStartIfOnline(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if canCancel(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if canOverrideWipe(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if hasReport(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "View report") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" class=\"detail-section detail-actions\" sse-swap=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 165, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" hx-swap=\"outerHTML\">
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if canStart(d.Tile) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if canStartIfOnline(d.Tile.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if canCancel(d.Tile.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if canOverrideWipe(d.Tile.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if hasReport(d.Tile.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "View report") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -563,139 +723,139 @@ func DetailSpecDiffs(d HostDetailData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var28 := templ.GetChildren(ctx) - if templ_7745c5c3_Var28 == nil { - templ_7745c5c3_Var28 = templ.NopComponent + templ_7745c5c3_Var34 := templ.GetChildren(ctx) + if templ_7745c5c3_Var34 == nil { + templ_7745c5c3_Var34 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if d.Tile.Latest != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" hx-swap=\"outerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(d.SpecDiffs) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "

Spec diffs (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">

Spec diffs (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs))) + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 179, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 219, Col: 67} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ")

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, ")

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, diff := range d.SpecDiffs { - var templ_7745c5c3_Var32 = []any{"diff-row", "diff-" + diff.Severity} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...) + var templ_7745c5c3_Var38 = []any{"diff-row", "diff-" + diff.Severity} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\">
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field) + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 183, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 223, Col: 44} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
    expected: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
    expected: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected) + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 184, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 224, Col: 66} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
    actual: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
    actual: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual) + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 185, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 225, Col: 60} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + 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, "

Log

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, s := range store.DefaultStageOrder { - var templ_7745c5c3_Var47 = []any{"log-tab-input", "log-tab-" + s} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - 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, 72, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templ.Raw(replay).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") - 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 t.Latest != nil { +
+ { fmt.Sprintf("#%d", t.Latest.ID) } + { runDuration(t.Latest) } +
+ } +
    + for _, name := range store.DefaultStageOrder { + @tileStep(stageForName(t.Stages, name)) + } +
if canStart(t) {
@@ -49,6 +65,17 @@ templ HostTile(t TileData) {
} +// tileStep renders one entry of the tile's mini step-list: a small +// coloured dot plus the short stage name. Kept as its own templ so the +// markup stays consistent with the detail page's larger stage-dot +// elements (same class prefix, different size via the `-sm` modifier). +templ tileStep(s model.Stage) { +
  • + { stageMarker(string(s.State)) } + { s.Name } +
  • +} + func canOverrideWipe(r *model.Run) bool { if r == nil { return false diff --git a/internal/web/templates/host_tile_templ.go b/internal/web/templates/host_tile_templ.go index ed5a7cf..620b470 100644 --- a/internal/web/templates/host_tile_templ.go +++ b/internal/web/templates/host_tile_templ.go @@ -13,13 +13,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. func HostTile(t TileData) 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 @@ -53,7 +58,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 17, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -79,7 +84,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("tile-%d", t.Host.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 19, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -92,7 +97,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var6 templ.SafeURL templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -105,7 +110,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -118,7 +123,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -153,7 +158,7 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 95} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 31, Col: 95} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -166,77 +171,222 @@ func HostTile(t TileData) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 32, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if canStart(t) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 templ.SafeURL - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID))) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#%d", t.Latest.ID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 32, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 37, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline tile-start-form\"> ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else if canStartIfOnline(t.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(t.Latest)) if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if canCancel(t.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else if hasReport(t.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, name := range store.DefaultStageOrder { + templ_7745c5c3_Err = tileStep(stageForName(t.Stages, name)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if canStart(t) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
    View report") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"inline tile-start-form\">
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if canStartIfOnline(t.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if canCancel(t.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if hasReport(t.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "View report") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// tileStep renders one entry of the tile's mini step-list: a small +// coloured dot plus the short stage name. Kept as its own templ so the +// markup stays consistent with the detail page's larger stage-dot +// elements (same class prefix, different size via the `-sm` modifier). +func tileStep(s model.Stage) 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_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var19 = []any{"tile-step", "tile-step-" + string(s.State)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 = []any{"stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var21...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(s.State))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 74, Col: 108} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 75, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
  • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/web/templates/host_tile_test.go b/internal/web/templates/host_tile_test.go index 8b8006f..108d463 100644 --- a/internal/web/templates/host_tile_test.go +++ b/internal/web/templates/host_tile_test.go @@ -2,11 +2,13 @@ package templates import ( "context" + "fmt" "strings" "testing" "time" "vetting/internal/model" + "vetting/internal/store" ) func TestHumanAgoFrom(t *testing.T) { @@ -96,6 +98,99 @@ func TestHostTile_DisabledStartWhenOffline(t *testing.T) { } } +// TestHostTile_MiniRunView asserts the tile renders a step-list entry +// for every canonical stage, colours the dots according to the mixed +// stage states in the fixture, and surfaces the run id + duration in +// the meta row. This is the contract the dashboard leans on: the +// operator should be able to read run health across all tiles without +// drilling into any of them. +func TestHostTile_MiniRunView(t *testing.T) { + now := time.Now() + started := now.Add(-3 * time.Minute) + latest := &model.Run{ + ID: 17, + State: model.StateSMART, + StartedAt: started, + } + // Mixed states: first two stages passed, SMART running, rest pending. + stages := []model.Stage{ + {Name: "Inventory", State: model.StagePassed}, + {Name: "SpecValidate", State: model.StagePassed}, + {Name: "SMART", State: model.StageRunning}, + } + data := TileData{ + Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"}, + Latest: latest, + Stages: stages, + LastSeenAt: &now, + } + var buf strings.Builder + if err := HostTile(data).Render(context.Background(), &buf); err != nil { + t.Fatalf("render: %v", err) + } + html := buf.String() + + // Step list exists and contains every canonical stage name so the + // operator reads a full 9-dot strip regardless of how far the run got. + if !strings.Contains(html, `
      `) { + t.Fatalf("tile missing step list: %s", html) + } + for _, s := range store.DefaultStageOrder { + want := fmt.Sprintf(`%s`, s) + if !strings.Contains(html, want) { + t.Fatalf("tile missing step name %q: %s", s, html) + } + } + // Colours: the two passed stages got passed dots; SMART got a running + // dot; CPUStress (no fixture row) falls back to pending. + mustContain := []string{ + `stage-dot stage-dot-sm stage-dot-passed`, + `stage-dot stage-dot-sm stage-dot-running`, + `stage-dot stage-dot-sm stage-dot-pending`, + } + for _, c := range mustContain { + if !strings.Contains(html, c) { + t.Fatalf("tile missing expected dot classes %q: %s", c, html) + } + } + // Meta row: run id + a duration string (minutes for a 3m-old run). + if !strings.Contains(html, `#17`) { + t.Fatalf("tile missing run id #17: %s", html) + } + if !strings.Contains(html, `class="tile-run-duration"`) { + t.Fatalf("tile missing duration element: %s", html) + } +} + +// TestHostTile_GhostSteplist: a never-run host still gets a 9-dot +// ghost strip (all pending). Keeps the tile height stable so the +// dashboard grid doesn't reflow as hosts gain their first run. +func TestHostTile_GhostSteplist(t *testing.T) { + now := time.Now() + data := TileData{ + Host: model.Host{ID: 1, Name: "fresh", MAC: "aa:bb:cc:dd:ee:01"}, + LastSeenAt: &now, + } + var buf strings.Builder + if err := HostTile(data).Render(context.Background(), &buf); err != nil { + t.Fatalf("render: %v", err) + } + html := buf.String() + for _, s := range store.DefaultStageOrder { + want := fmt.Sprintf(`%s`, s) + if !strings.Contains(html, want) { + t.Fatalf("ghost tile missing stage %q: %s", s, html) + } + } + if strings.Contains(html, `stage-dot-passed`) || strings.Contains(html, `stage-dot-running`) || strings.Contains(html, `stage-dot-failed`) { + t.Fatalf("ghost tile should have only pending dots: %s", html) + } + // No run → no meta row (suppresses "#0 · 0s" when no run exists). + if strings.Contains(html, `class="tile-run-id"`) { + t.Fatalf("ghost tile should omit run id: %s", html) + } +} + func TestLastSeenLabelAndClass(t *testing.T) { if got := lastSeenLabel(nil); got != "never" { t.Fatalf("label nil = %q, want never", got) diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ index c9acdb2..05a2310 100644 --- a/internal/web/templates/layout.templ +++ b/internal/web/templates/layout.templ @@ -10,6 +10,7 @@ templ Layout(title string) { +
      diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go index 142fd91..0d5ce70 100644 --- a/internal/web/templates/layout_templ.go +++ b/internal/web/templates/layout_templ.go @@ -42,7 +42,7 @@ func Layout(title string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting
      Vetting
      ·
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting
      Vetting
      ·
      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -86,7 +86,7 @@ func BareLayout(title string) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 38, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 39, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { diff --git a/internal/web/templates/registration.templ b/internal/web/templates/registration.templ index af68930..49794d7 100644 --- a/internal/web/templates/registration.templ +++ b/internal/web/templates/registration.templ @@ -14,53 +14,58 @@ type RegistrationForm struct { templ Registration(form RegistrationForm) { @Layout("Register host") {
      -

      Register host

      +
      +

      Register host

      + Back to dashboard +
      if form.Error != "" {
      { form.Error }
      } if form.QuickRegisterURL != "" { -
      +

      Quick register (recommended)

      Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:

      { "curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash" }

      After the script prints OK, refresh the dashboard and click Start vetting on the new host.

      -
      +
      } -
      - Register manually -
      - - -
      +
      +
      +

      Register manually

      + -
      - - -
      - - Cancel -
      -
      -
      +
      + + +
      + + +
      + + Cancel +
      + + + } } diff --git a/internal/web/templates/registration_templ.go b/internal/web/templates/registration_templ.go index 1d2c0d4..ed0cfeb 100644 --- a/internal/web/templates/registration_templ.go +++ b/internal/web/templates/registration_templ.go @@ -52,7 +52,7 @@ func Registration(form RegistrationForm) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

      Register host

      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

      Register host

      Back to dashboard
      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -64,7 +64,7 @@ func Registration(form RegistrationForm) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 19, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 22, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -76,32 +76,32 @@ func Registration(form RegistrationForm) templ.Component { } } if form.QuickRegisterURL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

      Quick register (recommended)

      Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:

      ")
      +				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

      Quick register (recommended)

      Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:

      ")
       				if templ_7745c5c3_Err != nil {
       					return templ_7745c5c3_Err
       				}
       				var templ_7745c5c3_Var4 string
       				templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash")
       				if templ_7745c5c3_Err != nil {
      -					return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 25, Col: 108}
      +					return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 28, Col: 108}
       				}
       				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
       				if templ_7745c5c3_Err != nil {
       					return templ_7745c5c3_Err
       				}
      -				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

      After the script prints OK, refresh the dashboard and click Start vetting on the new host.

      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

      After the script prints OK, refresh the dashboard and click Start vetting on the new host.

      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
      Register manually
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
      Cancel
      ") 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) { +
      + { subStepMarker(ss.State) } + { ss.Name } + { subStepDuration(ss) } +
      +} + +// RenderSubStepRowString is the one-shot renderer the orchestrator +// registers as SubStepRenderer so it can emit substep-* SSE payloads +// without importing the templates package directly. +func RenderSubStepRowString(ss model.SubStep) string { + var buf bytes.Buffer + _ = SubStepRow(ss).Render(context.Background(), &buf) + return buf.String() +} diff --git a/internal/web/templates/substep_row_templ.go b/internal/web/templates/substep_row_templ.go new file mode 100644 index 0000000..02cae74 --- /dev/null +++ b/internal/web/templates/substep_row_templ.go @@ -0,0 +1,212 @@ +// 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 ( + "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. +func SubStepRow(ss model.SubStep) 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{"substep", "substep-" + string(ss.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_Var6 = []any{"substep-badge", "substep-badge-" + string(ss.State)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(subStepMarker(ss.State)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 68, Col: 96} + } + _, 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, 7, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(ss.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 69, Col: 38} + } + _, 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, 8, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(subStepDuration(ss)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 70, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + 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 + } + return nil + }) +} + +// RenderSubStepRowString is the one-shot renderer the orchestrator +// registers as SubStepRenderer so it can emit substep-* SSE payloads +// without importing the templates package directly. +func RenderSubStepRowString(ss model.SubStep) string { + var buf bytes.Buffer + _ = SubStepRow(ss).Render(context.Background(), &buf) + return buf.String() +} + +var _ = templruntime.GeneratedTemplate