ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
CI / Lint + build + test (push) Successful in 1m26s
Release / release (push) Successful in 6m47s

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 19:00:11 -04:00
parent 5c00edd7b6
commit f79fe0f0db
38 changed files with 3972 additions and 936 deletions
+13
View File
@@ -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"`
+19
View File
@@ -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)
}
+35 -1
View File
@@ -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,
SubSteps: subs,
}
}
@@ -81,6 +85,7 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
Message: "read MemAvailable: " + err.Error(),
Summary: "failed (meminfo unreadable)",
Extras: extras,
SubSteps: subs,
}
}
cap := avail - memHeadroomBytes
@@ -96,6 +101,7 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
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,
SubSteps: subs,
}
}
@@ -121,6 +129,25 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
Summary: fmt.Sprintf("CPU+RAM PASSED (%d cores, %s cap)",
cores, humanBytes(cap)),
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"`
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()
+36
View File
@@ -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,
SubSteps: subs,
}
}
+39 -11
View File
@@ -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{
@@ -95,6 +93,7 @@ func SMART(ctx context.Context, d Deps) Outcome {
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) {
+19
View File
@@ -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
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.
+25
View File
@@ -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,
@@ -111,6 +134,7 @@ func Storage(ctx context.Context, d Deps) Outcome {
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,
}
}
}
@@ -123,6 +147,7 @@ func Storage(ctx context.Context, d Deps) Outcome {
Passed: true,
Summary: fmt.Sprintf("%d disks passed", len(targets)),
Extras: map[string]any{"per_disk": perDisk, "wipe_probe": probes},
SubSteps: subs,
}
}
+9 -1
View File
@@ -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,
+84
View File
@@ -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"`
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 {
+73
View File
@@ -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)
}
}
+282 -18
View File
@@ -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
// <details data-stage="..."> 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
// `<details ... open data-stage="...">` 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) {
+11
View File
@@ -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
}
+68 -8
View File
@@ -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,
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
@@ -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);
+66
View File
@@ -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 `<div class="substep">row</div>`
}
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)
}
}
+185 -8
View File
@@ -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 <div id="log-N-..." hx-swap="beforeend">: each event
// appends one <div class="log-line log-LEVEL"> 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 <div class="log-line log-LEVEL"> 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:
//
// <div class="log-line log-{level}" id="L{run}-{stage}-{ord}" data-ts="RFC3339Nano">
// <a class="log-anchor" href="#L{run}-{stage}-{ord}">#</a>
// <span class="ln">{ord}</span>
// <span class="lvl">{LEVEL}</span>
// <span class="log-ts">15:04:05</span>
// (optional) <span class="log-stage">[{stage}]</span>
// <span class="log-text">{text}</span>
// </div>
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(`<span class="log-stage">[%s]</span> `, html.EscapeString(l.Stage))
}
return fmt.Sprintf(
`<div class="log-line log-%s">%s %s%s</div>`,
`<div class="log-line log-%s" id="%s" data-ts="%s"><a class="log-anchor" href="#%s">#</a><span class="ln">%d</span><span class="lvl">%s</span><span class="log-ts">%s</span>%s<span class="log-text">%s</span></div>`,
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),
+18
View File
@@ -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
+19
View File
@@ -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 `<div sse-swap="substep-{runID}-{stage}-{ordinal}">`
// 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
+39
View File
@@ -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, `
+140
View File
@@ -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
}
+126
View File
@@ -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)
}
}
+408 -15
View File
@@ -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; }
+115
View File
@@ -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 `<details>` 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();
}
});
})();
+96
View File
@@ -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 <details open?={ d.Open }> 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) {
<details class={ "step", "step-" + string(d.Stage.State) } open?={ d.Open } data-stage={ d.Stage.Name }>
<summary class="step-summary">
<span class={ "stage-dot", "stage-dot-" + string(d.Stage.State) }>{ stageMarker(string(d.Stage.State)) }</span>
<span class="step-name">{ d.Stage.Name }</span>
<span class="step-duration">{ stageDurationFromStage(d.Stage) }</span>
</summary>
<div class="step-body">
if len(d.SubSteps) > 0 {
<ol class="substep-list">
for _, ss := range d.SubSteps {
@SubStepRow(ss)
}
</ol>
}
<div class="log-search-wrap">
<input class="log-search" type="search" placeholder="Search this step" data-step={ d.Stage.Name }/>
</div>
<div
class="log-pane"
id={ fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name) }
sse-swap={ fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name) }
hx-swap="beforeend show:bottom"
>
@templ.Raw(d.LogReplay)
</div>
</div>
</details>
}
// 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))
}
}
+274
View File
@@ -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 <details open?={ d.Open }> 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, "<details class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Open {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " open")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " data-stage=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 28, Col: 102}
}
_, 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, "\"><summary class=\"step-summary\">")
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, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
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_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, "</span> <span class=\"step-name\">")
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, "</span> <span class=\"step-duration\">")
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, "</span></summary><div class=\"step-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(d.SubSteps) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<ol class=\"substep-list\">")
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, "</ol>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"log-search-wrap\"><input class=\"log-search\" type=\"search\" placeholder=\"Search this step\" data-step=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 43, Col: 99}
}
_, 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, 14, "\"></div><div class=\"log-pane\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 47, Col: 56}
}
_, 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, 15, "\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 48, Col: 62}
}
_, 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, 16, "\" hx-swap=\"beforeend show:bottom\">")
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, "</div></div></details>")
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
+5 -1
View File
@@ -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
+5 -1
View File
@@ -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
+216 -88
View File
@@ -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 <details> 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
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) {
<section class="detail" hx-ext="sse" sse-connect="/events">
<section class="detail detail-v2" hx-ext="sse" sse-connect="/events">
<nav class="breadcrumb">
<a href="/">Dashboard</a>
<span class="breadcrumb-sep">/</span>
<span>{ d.Tile.Host.Name }</span>
</nav>
@HostMetaDrawer(d)
@DetailSummary(d)
@DetailActions(d)
@DetailHold(d)
if d.Tile.Latest != nil {
@PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages))
@@ -42,17 +64,51 @@ templ HostDetail(d HostDetailData) {
</section>
}
@DetailHold(d)
@DetailActions(d)
@DetailSpecDiffs(d)
<div class="detail-body">
<div class="active-step-pane">
if d.Tile.Latest != nil {
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
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 {
<p class="detail-empty">No run yet. Click <strong>Start vetting</strong> to begin.</p>
}
</div>
@RunsSidebar(d)
</div>
<section class="detail-section detail-host-meta">
<details>
<summary><h2>Host details</h2></summary>
@DetailSpecDiffs(d)
</section>
}
}
// HostMetaDrawer is the collapsed "host details" block at the top of the
// page: MAC, WoL, last-seen, expected spec, and notes. <details> defaults
// to closed so the run itself stays above the fold; operators open it
// when they need the provisioning info.
templ HostMetaDrawer(d HostDetailData) {
<details class="host-meta-drawer">
<summary>
<span class="meta-summary-label">Host details</span>
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
<span class="meta-summary-mac">{ d.Tile.Host.MAC }</span>
</summary>
<dl class="detail-meta">
<div>
<dt>MAC</dt>
<dd>{ d.Tile.Host.MAC }</dd>
</div>
<div>
<dt>WoL</dt>
<dd>{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }</dd>
</div>
</dl>
if d.Tile.Host.Notes != "" {
<div class="detail-notes">
<h3>Notes</h3>
@@ -64,51 +120,36 @@ templ HostDetail(d HostDetailData) {
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
</div>
</details>
</section>
</section>
}
}
// 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.
// 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) {
<header
id={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
class={ "detail-summary", "tile-" + tileMood(d.Tile.Latest) }
class={ "run-header", "tile-" + tileMood(d.Tile.Latest) }
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
hx-swap="outerHTML"
>
<div class="detail-summary-head">
<div class="run-header-left">
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
<div class="detail-status-row">
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
<span class="tile-status">{ tileStatus(d.Tile.Latest) }</span>
</div>
</div>
<dl class="detail-meta">
<div>
<dt>MAC</dt>
<dd>{ d.Tile.Host.MAC }</dd>
</div>
<div>
<dt>WoL</dt>
<dd>{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }</dd>
if d.Tile.Latest != nil {
<span class="run-number">{ fmt.Sprintf("run #%d", d.Tile.Latest.ID) }</span>
}
<span class={ "run-status-badge", "run-status-" + tileMood(d.Tile.Latest) }>{ tileStatus(d.Tile.Latest) }</span>
if d.Tile.Latest != nil {
<span class="run-duration">{ runDuration(d.Tile.Latest) }</span>
}
</div>
<div class="run-header-right">
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
<div>
<dt>Failed at</dt>
<dd class="bad">{ d.Tile.Latest.FailedStage }</dd>
</div>
<span class="run-failed-stage">failed at <strong>{ d.Tile.Latest.FailedStage }</strong></span>
}
if d.Tile.SpecDiffCritical > 0 {
<div>
<dt>Spec diffs</dt>
<dd class="bad">{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }</dd>
</div>
<span class="run-diffs bad">{ fmt.Sprintf("%d critical diff", d.Tile.SpecDiffCritical) }</span>
}
</dl>
</div>
</header>
}
@@ -124,7 +165,6 @@ templ DetailActions(d HostDetailData) {
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
hx-swap="outerHTML"
>
<h2>Actions</h2>
<div class="detail-actions-row">
if canStart(d.Tile) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline detail-start-form">
@@ -132,7 +172,7 @@ templ DetailActions(d HostDetailData) {
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive (skip wipe-probe + disk writes)
</label>
<button type="submit">Start vetting</button>
<button type="submit" class="btn-primary">Start vetting</button>
</form>
} else if canStartIfOnline(d.Tile.Latest) {
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
@@ -141,19 +181,19 @@ templ DetailActions(d HostDetailData) {
}
if canCancel(d.Tile.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
<button type="submit" class="danger">Cancel run</button>
<button type="submit" class="btn-danger">Cancel run</button>
</form>
}
if canOverrideWipe(d.Tile.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline">
<button type="submit" class="danger">Override wipe-probe</button>
<button type="submit" class="btn-danger">Override wipe-probe</button>
</form>
}
if hasReport(d.Tile.Latest) {
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)) } target="_blank" rel="noopener">View report</a>
}
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline">
<button type="submit" class="danger">Delete host</button>
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Delete host and all its runs?');">
<button type="submit" class="btn-danger">Delete host</button>
</form>
</div>
</section>
@@ -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 != "" {
<section
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
class="detail-section detail-hold"
class="hold-banner"
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
hx-swap="outerHTML"
>
<h2>Host is holding — SSH available</h2>
<span class="hold-banner-label">Host is holding — SSH available:</span>
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
</section>
} 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) {
<aside class="runs-sidebar">
<h2 class="runs-sidebar-heading">History</h2>
if len(d.History) == 0 {
<p class="runs-sidebar-empty">No runs yet.</p>
} else {
<ul class="runs-sidebar-list">
for _, r := range d.History {
<li class={ "runs-sidebar-item", "runs-sidebar-" + tileMood(&r), runSidebarActiveClass(d.Tile.Latest, r.ID) }>
<a href={ templ.SafeURL(fmt.Sprintf("/hosts/%d?run=%d", d.Tile.Host.ID, r.ID)) }>
<span class={ "runs-sidebar-dot", "runs-sidebar-dot-" + tileMood(&r) }>{ runSidebarGlyph(&r) }</span>
<span class="runs-sidebar-id">{ fmt.Sprintf("#%d", r.ID) }</span>
<span class="runs-sidebar-started">{ relativeTime(r.StartedAt) }</span>
<span class="runs-sidebar-duration">{ runDuration(&r) }</span>
</a>
</li>
}
</ul>
}
</aside>
}
// 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) {
<section class="detail-section log-section">
<h2>Log</h2>
<div class="log-tabs">
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-input log-tab-all" checked/>
<label for={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-label">All</label>
for _, s := range store.DefaultStageOrder {
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class={ "log-tab-input", "log-tab-" + s }/>
<label for={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class="log-tab-label">{ s }</label>
// 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
}
<div
class="log-pane log-pane-all"
id={ fmt.Sprintf("log-%d", runID) }
sse-swap={ fmt.Sprintf("log-%d", runID) }
hx-swap="beforeend show:bottom"
>
@templ.Raw(replay)
</div>
for _, s := range store.DefaultStageOrder {
<div
class={ "log-pane", "log-pane-" + s }
id={ fmt.Sprintf("log-%d-%s", runID, s) }
sse-swap={ fmt.Sprintf("log-%d-%s", runID, s) }
hx-swap="beforeend show:bottom"
></div>
}
</div>
</section>
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 "●"
}
File diff suppressed because it is too large Load Diff
+31 -4
View File
@@ -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 <a>) — 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 <a>) — 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) {
<article
id={ fmt.Sprintf("host-%d", t.Host.ID) }
@@ -27,6 +32,17 @@ templ HostTile(t TileData) {
<div class="tile-status">{ tileStatus(t.Latest) }</div>
</div>
</header>
if t.Latest != nil {
<div class="tile-meta-row">
<span class="tile-run-id">{ fmt.Sprintf("#%d", t.Latest.ID) }</span>
<span class="tile-run-duration">{ runDuration(t.Latest) }</span>
</div>
}
<ol class="tile-steplist">
for _, name := range store.DefaultStageOrder {
@tileStep(stageForName(t.Stages, name))
}
</ol>
<div class="tile-primary-action">
if canStart(t) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline tile-start-form">
@@ -49,6 +65,17 @@ templ HostTile(t TileData) {
</article>
}
// 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) {
<li class={ "tile-step", "tile-step-" + string(s.State) }>
<span class={ "stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State) }>{ stageMarker(string(s.State)) }</span>
<span class="tile-step-name">{ s.Name }</span>
</li>
}
func canOverrideWipe(r *model.Run) bool {
if r == nil {
return false
+188 -38
View File
@@ -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 <a>) — 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 <a>) — 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, "</div></div></header><div class=\"tile-primary-action\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></header>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canStart(t) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form method=\"post\" action=\"")
if t.Latest != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"tile-meta-row\"><span class=\"tile-run-id\">")
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\"><label class=\"tile-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive</label> <button type=\"submit\">Start vetting</button></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> <span class=\"tile-run-duration\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if canStartIfOnline(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
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, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 42, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 38, Col: 59}
}
_, 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, 16, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if hasReport(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a class=\"button-like\" href=\"")
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<ol class=\"tile-steplist\">")
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, "</ol><div class=\"tile-primary-action\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canStart(t) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 46, Col: 88}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 48, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"inline tile-start-form\"><label class=\"tile-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive</label> <button type=\"submit\">Start vetting</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if canStartIfOnline(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if canCancel(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 58, Col: 90}
}
_, 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, 21, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if hasReport(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a class=\"button-like\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 62, Col: 88}
}
_, 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, 23, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></article>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></article>")
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, "<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var19).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
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, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var21).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
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, "</span> <span class=\"tile-step-name\">")
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, "</span></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+95
View File
@@ -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, `<ol class="tile-steplist">`) {
t.Fatalf("tile missing step list: %s", html)
}
for _, s := range store.DefaultStageOrder {
want := fmt.Sprintf(`<span class="tile-step-name">%s</span>`, 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(`<span class="tile-step-name">%s</span>`, 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)
+1
View File
@@ -10,6 +10,7 @@ templ Layout(title string) {
<link rel="stylesheet" href="/static/app.css"/>
<script src="https://unpkg.com/htmx.org@2.0.2" integrity="sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2" integrity="sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI" crossorigin="anonymous"></script>
<script src="/static/app.js" defer></script>
</head>
<body hx-boost="true">
<header class="topbar">
+2 -2
View File
@@ -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</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI\" crossorigin=\"anonymous\"></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\">·</span></div></header><main>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI\" crossorigin=\"anonymous\"></script><script src=\"/static/app.js\" defer></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\">·</span></div></header><main>")
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 {
+9 -4
View File
@@ -14,20 +14,24 @@ type RegistrationForm struct {
templ Registration(form RegistrationForm) {
@Layout("Register host") {
<section class="form-wrap">
<header class="form-wrap-head">
<h1>Register host</h1>
<a class="button-secondary" href="/">Back to dashboard</a>
</header>
if form.Error != "" {
<div class="error">{ form.Error }</div>
}
if form.QuickRegisterURL != "" {
<div class="quick-register">
<section class="detail-section quick-register">
<h2>Quick register <span class="muted">(recommended)</span></h2>
<p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p>
<pre class="one-liner"><code>{ "curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash" }</code></pre>
<p class="muted">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p>
</div>
</section>
}
<section class="detail-section manual-register-card">
<details class="manual-register">
<summary>Register manually</summary>
<summary><h2>Register manually</h2></summary>
<form method="post" action="/hosts" class="host-form">
<label>
Name
@@ -56,12 +60,13 @@ templ Registration(form RegistrationForm) {
<textarea name="notes" rows="3">{ form.Notes }</textarea>
</label>
<div class="actions">
<button type="submit">Register</button>
<button type="submit" class="btn-primary">Register</button>
<a class="button-secondary" href="/">Cancel</a>
</div>
</form>
</details>
</section>
</section>
}
}
+13 -13
View File
@@ -52,7 +52,7 @@ func Registration(form RegistrationForm) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"form-wrap\"><h1>Register host</h1>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"form-wrap\"><header class=\"form-wrap-head\"><h1>Register host</h1><a class=\"button-secondary\" href=\"/\">Back to dashboard</a></header>")
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, "<div class=\"quick-register\"><h2>Quick register <span class=\"muted\">(recommended)</span></h2><p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p><pre class=\"one-liner\"><code>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<section class=\"detail-section quick-register\"><h2>Quick register <span class=\"muted\">(recommended)</span></h2><p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p><pre class=\"one-liner\"><code>")
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, "</code></pre><p class=\"muted\">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</code></pre><p class=\"muted\">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<details class=\"manual-register\"><summary>Register manually</summary><form method=\"post\" action=\"/hosts\" class=\"host-form\"><label>Name <input type=\"text\" name=\"name\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<section class=\"detail-section manual-register-card\"><details class=\"manual-register\"><summary><h2>Register manually</h2></summary><form method=\"post\" action=\"/hosts\" class=\"host-form\"><label>Name <input type=\"text\" name=\"name\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 34, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -114,7 +114,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.MAC)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 52}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 42, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -127,7 +127,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(form.WoLBroadcastIP)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 43, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -140,7 +140,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(defaultPort(form.WoLPort))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 51, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -153,7 +153,7 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(form.ExpectedSpecYAML)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 52, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 127}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -166,13 +166,13 @@ func Registration(form RegistrationForm) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(form.Notes)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 60, Col: 51}
}
_, 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, 12, "</textarea></label><div class=\"actions\"><button type=\"submit\">Register</button> <a class=\"button-secondary\" href=\"/\">Cancel</a></div></form></details></section>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</textarea></label><div class=\"actions\"><button type=\"submit\" class=\"btn-primary\">Register</button> <a class=\"button-secondary\" href=\"/\">Cancel</a></div></form></details></section></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+81
View File
@@ -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 <div> 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) {
<div
id={ fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal) }
class={ "substep", "substep-" + string(ss.State) }
sse-swap={ fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal) }
hx-swap="outerHTML"
>
<span class={ "substep-badge", "substep-badge-" + string(ss.State) }>{ subStepMarker(ss.State) }</span>
<span class="substep-name">{ ss.Name }</span>
<span class="substep-duration">{ subStepDuration(ss) }</span>
</div>
}
// 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()
}
+212
View File
@@ -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 <div> 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, "<div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 63, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 1, Col: 0}
}
_, 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, 3, "\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 65, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-swap=\"outerHTML\">")
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, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 1, Col: 0}
}
_, 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, 6, "\">")
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, "</span> <span class=\"substep-name\">")
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, "</span> <span class=\"substep-duration\">")
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, "</span></div>")
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