diff --git a/cmd/vetting/main.go b/cmd/vetting/main.go
index e361235..dfa286c 100644
--- a/cmd/vetting/main.go
+++ b/cmd/vetting/main.go
@@ -208,12 +208,15 @@ func main() {
ui.PXE = supervisor
}
- router := httpserver.NewRouter(httpserver.Deps{
+ router, err := httpserver.NewRouter(httpserver.Deps{
UI: ui,
Agent: agentAPI,
LiveDir: cfg.PXE.LiveDir,
AgentAssetDir: cfg.Agent.AssetDir,
})
+ if err != nil {
+ log.Fatalf("router: %v", err)
+ }
srv := &http.Server{
Addr: cfg.Server.Bind,
diff --git a/internal/api/agent_handlers.go b/internal/api/agent_handlers.go
index b5c52dd..dfb2c8b 100644
--- a/internal/api/agent_handlers.go
+++ b/internal/api/agent_handlers.go
@@ -170,7 +170,7 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
if len(mustListStages(a.Stages, r, runID)) == 0 {
if err := a.Stages.Seed(r.Context(), runID); err != nil {
log.Printf("claim: seed stages run %d: %v", runID, err)
- http.Error(w, "seed stages", http.StatusInternalServerError)
+ writeJSONErr(w, http.StatusInternalServerError, "seed stages")
return
}
}
@@ -180,7 +180,7 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
if run.State == model.StateWaitingWoL || run.State == model.StateBooting {
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerAgentClaimed); err != nil {
log.Printf("claim: transition run %d: %v", runID, err)
- http.Error(w, "transition", http.StatusConflict)
+ writeJSONErr(w, http.StatusConflict, "transition")
return
}
}
@@ -369,6 +369,10 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
_ = json.NewEncoder(w).Encode(body)
}
+func writeJSONErr(w http.ResponseWriter, status int, msg string) {
+ writeJSON(w, status, map[string]any{"ok": false, "error": msg})
+}
+
// mustListStages is a small wrapper that hides the error path from
// /claim — a DB read failure just pretends there are zero stages, and
// the subsequent Seed will surface the real error.
@@ -408,12 +412,12 @@ func (a *Agent) Log(w http.ResponseWriter, r *http.Request) {
}
var batch LogBatch
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
- http.Error(w, "bad json", http.StatusBadRequest)
+ writeJSONErr(w, http.StatusBadRequest, "bad json")
return
}
writer, err := a.Logs.WriterFor(runID)
if err != nil {
- http.Error(w, "open log: "+err.Error(), http.StatusInternalServerError)
+ writeJSONErr(w, http.StatusInternalServerError, "open log: "+err.Error())
return
}
for _, l := range batch.Lines {
@@ -470,9 +474,7 @@ type SubStepResultLine struct {
// Result receives a stage's outcome. Flow:
// 1. Mark the stage row passed/failed + record summary JSON.
// 2. For Inventory: persist the inventory artifact.
-// 3. For Inventory (on pass): run spec diff server-side, persist rows,
-// bump the run into SpecValidate and immediately resolve SpecValidate
-// from that diff — the agent isn't involved in SpecValidate at all.
+// 3. For Firmware: persist firmware snapshots.
// 4. Transition the run via StageCompleted/StageFailed.
func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
runID, ok := runIDFromURL(w, r)
@@ -485,64 +487,20 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
}
var body StageResult
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
- http.Error(w, "bad json", http.StatusBadRequest)
+ writeJSONErr(w, http.StatusBadRequest, "bad json")
return
}
body.Stage = strings.TrimSpace(body.Stage)
if _, ok := orchestrator.StateForStage(body.Stage); !ok {
- http.Error(w, "unknown stage: "+body.Stage, http.StatusBadRequest)
+ writeJSONErr(w, http.StatusBadRequest, "unknown stage: "+body.Stage)
return
}
- // Silent-skip guard. Orchestrator advances the run state via
- // TriggerStageCompleted against the *current* state, not against
- // body.Stage — so an Inventory result posted while the run is in
- // StateCPUStress would silently advance CPUStress → Storage and mark
- // CPUStress as passed without it ever running. That's exactly what
- // happened on Orion when the agent OOM-crashed mid-CPUStress,
- // systemd restarted it, and the restarted agent (which hardcoded
- // "Inventory" as its first stage) re-ran Inventory and reported it.
- // Guard: if body.Stage doesn't match the stage the run is currently
- // in, park the run in FailedHolding so the operator can investigate
- // rather than trusting the claim and cascading silent passes.
- expectedStage := orchestrator.StageNameForState(run.State)
- if expectedStage != "" && body.Stage != expectedStage {
- failedLabel := fmt.Sprintf("%s (expected %s)", body.Stage, expectedStage)
- if err := a.Runs.SetFailedStage(r.Context(), runID, failedLabel); err != nil {
- log.Printf("result: set failed stage on mismatch run %d: %v", runID, err)
- }
- if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageMismatch); err != nil {
- log.Printf("result: stage-mismatch transition run %d: %v", runID, err)
- }
- hostName := a.hostNameFor(r.Context(), run.HostID)
- a.dispatchEvent(notify.Event{
- Kind: notify.KindStageFailed,
- Severity: notify.SeverityCritical,
- RunID: runID,
- HostName: hostName,
- Title: fmt.Sprintf("[vetting] %s stage mismatch: %s", hostName, body.Stage),
- Body: fmt.Sprintf("Run %d reported stage %s while orchestrator expected %s — parked in FailedHolding to prevent silent skip.",
- runID, body.Stage, expectedStage),
- URL: a.runLinkURL(runID),
- })
- log.Printf("result: stage mismatch run=%d got=%s expected=%s — parked", runID, body.Stage, expectedStage)
- http.Error(w, "stage mismatch: got "+body.Stage+", expected "+expectedStage, http.StatusConflict)
+ if a.resultStageMismatch(w, r, runID, run, &body) {
return
}
- // Aggregate threshold gate: flip Passed=false server-side when any
- // critical breach landed for this stage. The agent's verdict is
- // advisory — a stage-executor can miss a runaway sample that the
- // sidecar caught. We check this *before* writing the stage state
- // so the DB reflects the server-side decision.
- thresholdDetail := ""
- if body.Passed {
- if breached, detail := a.stageHadCriticalBreach(r.Context(), runID, body.Stage); breached {
- body.Passed = false
- thresholdDetail = detail
- a.appendLog(runID, "error", fmt.Sprintf("%s reported passed but %s — flipping to failed", body.Stage, detail))
- }
- }
+ thresholdDetail := a.resultCheckThresholds(r.Context(), runID, &body)
stageState := model.StagePassed
if !body.Passed {
@@ -553,73 +511,122 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
summaryJSON = string(body.Summary)
}
if err := a.Runner.CompleteStage(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil {
- http.Error(w, "complete stage: "+err.Error(), http.StatusInternalServerError)
+ writeJSONErr(w, http.StatusInternalServerError, "complete stage: "+err.Error())
return
}
if thresholdDetail != "" && body.Message == "" {
body.Message = thresholdDetail
}
- // Agent-authored sub-steps: persist in slice order (ordinal = index)
- // and fan out a per-row SSE event each so the detail pane shows them
- // 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)
+ a.resultPersistArtifacts(r, run, runID, &body)
- // Inventory-specific: persist artifact + compute spec diff.
+ if !body.Passed {
+ a.resultHandleFailed(w, r, runID, run, &body)
+ return
+ }
+ a.resultAdvance(w, r, runID, &body)
+}
+
+// resultStageMismatch parks the run in FailedHolding when the reported
+// stage doesn't match what the orchestrator expects. Returns true if the
+// response has been written (caller should return).
+func (a *Agent) resultStageMismatch(w http.ResponseWriter, r *http.Request, runID int64, run *model.Run, body *StageResult) bool {
+ expectedStage := orchestrator.StageNameForState(run.State)
+ if expectedStage == "" || body.Stage == expectedStage {
+ return false
+ }
+ failedLabel := fmt.Sprintf("%s (expected %s)", body.Stage, expectedStage)
+ if err := a.Runs.SetFailedStage(r.Context(), runID, failedLabel); err != nil {
+ log.Printf("result: set failed stage on mismatch run %d: %v", runID, err)
+ }
+ if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageMismatch); err != nil {
+ log.Printf("result: stage-mismatch transition run %d: %v", runID, err)
+ }
+ hostName := a.hostNameFor(r.Context(), run.HostID)
+ a.dispatchEvent(notify.Event{
+ Kind: notify.KindStageFailed,
+ Severity: notify.SeverityCritical,
+ RunID: runID,
+ HostName: hostName,
+ Title: fmt.Sprintf("[vetting] %s stage mismatch: %s", hostName, body.Stage),
+ Body: fmt.Sprintf("Run %d reported stage %s while orchestrator expected %s — parked in FailedHolding to prevent silent skip.",
+ runID, body.Stage, expectedStage),
+ URL: a.runLinkURL(runID),
+ })
+ log.Printf("result: stage mismatch run=%d got=%s expected=%s — parked", runID, body.Stage, expectedStage)
+ writeJSONErr(w, http.StatusConflict, "stage mismatch: got "+body.Stage+", expected "+expectedStage)
+ return true
+}
+
+// resultCheckThresholds flips body.Passed to false when the server-side
+// threshold sidecar recorded a critical breach the agent missed.
+func (a *Agent) resultCheckThresholds(ctx context.Context, runID int64, body *StageResult) string {
+ if !body.Passed {
+ return ""
+ }
+ breached, detail := a.stageHadCriticalBreach(ctx, runID, body.Stage)
+ if !breached {
+ return ""
+ }
+ body.Passed = false
+ a.appendLog(runID, "error", fmt.Sprintf("%s reported passed but %s — flipping to failed", body.Stage, detail))
+ return detail
+}
+
+// resultPersistArtifacts handles stage-specific artifact persistence
+// (inventory JSON, firmware snapshots). Best-effort — errors are logged.
+func (a *Agent) resultPersistArtifacts(r *http.Request, run *model.Run, runID int64, body *StageResult) {
if body.Stage == "Inventory" && body.Inventory != nil {
if err := a.persistInventory(r, run, body.Inventory); err != nil {
log.Printf("persist inventory run %d: %v", runID, err)
}
}
-
- // Firmware-specific: persist each snapshot into firmware_snapshots.
- // SpecValidate reads them back to diff against expected_firmware.
if body.Stage == "Firmware" && len(body.Firmware) > 0 {
if err := a.persistFirmware(r.Context(), runID, body.Firmware); err != nil {
log.Printf("persist firmware run %d: %v", runID, err)
}
}
+}
- if !body.Passed {
- if err := a.Runs.SetFailedStage(r.Context(), runID, body.Stage); err != nil {
- log.Printf("set failed stage: %v", err)
- }
- if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil {
- log.Printf("result: failed-transition run %d: %v", runID, err)
- http.Error(w, "transition", http.StatusConflict)
- return
- }
- hostName := a.hostNameFor(r.Context(), run.HostID)
- detail := body.Message
- if detail == "" {
- detail = "stage reported failure"
- }
- a.dispatchEvent(notify.Event{
- Kind: notify.KindStageFailed,
- Severity: notify.SeverityCritical,
- RunID: runID,
- HostName: hostName,
- Title: fmt.Sprintf("[vetting] %s FAILED: %s", hostName, body.Stage),
- Body: fmt.Sprintf("Run %d on %s failed at stage %s.\n%s", runID, hostName, body.Stage, detail),
- URL: a.runLinkURL(runID),
- })
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "next_state": "FailedHolding"})
+// resultHandleFailed transitions a failed stage into FailedHolding and
+// fires the failure notification.
+func (a *Agent) resultHandleFailed(w http.ResponseWriter, r *http.Request, runID int64, run *model.Run, body *StageResult) {
+ if err := a.Runs.SetFailedStage(r.Context(), runID, body.Stage); err != nil {
+ log.Printf("set failed stage: %v", err)
+ }
+ if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil {
+ log.Printf("result: failed-transition run %d: %v", runID, err)
+ writeJSONErr(w, http.StatusConflict, "transition")
return
}
+ hostName := a.hostNameFor(r.Context(), run.HostID)
+ detail := body.Message
+ if detail == "" {
+ detail = "stage reported failure"
+ }
+ a.dispatchEvent(notify.Event{
+ Kind: notify.KindStageFailed,
+ Severity: notify.SeverityCritical,
+ RunID: runID,
+ HostName: hostName,
+ Title: fmt.Sprintf("[vetting] %s FAILED: %s", hostName, body.Stage),
+ Body: fmt.Sprintf("Run %d on %s failed at stage %s.\n%s", runID, hostName, body.Stage, detail),
+ URL: a.runLinkURL(runID),
+ })
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "next_state": "FailedHolding"})
+}
- // Passed: advance to the next stage in the pipeline.
+// resultAdvance transitions a passed stage to the next pipeline state,
+// auto-resolving server-owned stages (SpecValidate, Reporting).
+func (a *Agent) resultAdvance(w http.ResponseWriter, r *http.Request, runID int64, body *StageResult) {
next, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageCompleted)
if err != nil {
- http.Error(w, "advance: "+err.Error(), http.StatusConflict)
+ writeJSONErr(w, http.StatusConflict, "advance: "+err.Error())
return
}
log.Printf("result: run %d stage %s passed → %s", runID, body.Stage, next)
- // If the just-advanced-into state is SpecValidate or Reporting, the
- // orchestrator owns those stages entirely. The resolve function may
- // transition further (→ next stage on pass, → FailedHolding on fail,
- // → Completed for Reporting), so we re-read the run after each.
if next == model.StateSpecValidate {
a.resolveSpecValidate(r, runID)
if after, err := a.Runs.Get(r.Context(), runID); err == nil {
@@ -912,13 +919,13 @@ func (a *Agent) Hold(w http.ResponseWriter, r *http.Request) {
kp, err := hold.Issue(runID)
if err != nil {
- http.Error(w, "generate key: "+err.Error(), http.StatusInternalServerError)
+ writeJSONErr(w, http.StatusInternalServerError, "generate key: "+err.Error())
return
}
keyPath := filepath.Join(a.ArtifactsDir, fmt.Sprintf("run-%d", runID), "hold.key")
abs, err := kp.WritePrivateTo(keyPath)
if err != nil {
- http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
+ writeJSONErr(w, http.StatusInternalServerError, "write key: "+err.Error())
return
}
sum := sha256.Sum256(kp.PrivatePEM)
@@ -1021,12 +1028,12 @@ func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
return
}
if a.Measurements == nil {
- http.Error(w, "measurements store not wired", http.StatusInternalServerError)
+ writeJSONErr(w, http.StatusInternalServerError, "measurements store not wired")
return
}
var body SensorBatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
- http.Error(w, "bad json", http.StatusBadRequest)
+ writeJSONErr(w, http.StatusBadRequest, "bad json")
return
}
rows := make([]model.Measurement, 0, len(body.Samples))
@@ -1050,7 +1057,7 @@ func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
sampleStages = append(sampleStages, orchestrator.StageNameForState(run.State))
}
if err := a.Measurements.CreateBatch(r.Context(), rows); err != nil {
- http.Error(w, "write samples: "+err.Error(), http.StatusInternalServerError)
+ writeJSONErr(w, http.StatusInternalServerError, "write samples: "+err.Error())
return
}
critical := a.evaluateSensorBatch(r.Context(), runID, rows, sampleStages)
diff --git a/internal/events/events.go b/internal/events/events.go
index 312de07..5ac1559 100644
--- a/internal/events/events.go
+++ b/internal/events/events.go
@@ -22,6 +22,11 @@ type subscriber struct {
ch chan Event
}
+const (
+ defaultSubscriberBuffer = 32
+ heartbeatInterval = 15 * time.Second
+)
+
// Hub is an in-process fan-out for SSE subscribers.
type Hub struct {
mu sync.RWMutex
@@ -29,13 +34,16 @@ type Hub struct {
subs map[int64]*subscriber
buffer int
heartbeat time.Duration
+ done chan struct{}
+ closeOnce sync.Once
}
func NewHub() *Hub {
h := &Hub{
subs: map[int64]*subscriber{},
- buffer: 32,
- heartbeat: 15 * time.Second,
+ buffer: defaultSubscriberBuffer,
+ heartbeat: heartbeatInterval,
+ done: make(chan struct{}),
}
go h.heartbeatLoop()
return h
@@ -70,11 +78,16 @@ func (h *Hub) Subscribe() (id int64, ch <-chan Event, cancel func()) {
func (h *Hub) heartbeatLoop() {
t := time.NewTicker(h.heartbeat)
defer t.Stop()
- for range t.C {
- h.Publish(Event{
- Name: "heartbeat",
- Payload: fmt.Sprintf(``, time.Now().Unix()),
- })
+ for {
+ select {
+ case <-h.done:
+ return
+ case <-t.C:
+ h.Publish(Event{
+ Name: "heartbeat",
+ Payload: fmt.Sprintf(``, time.Now().Unix()),
+ })
+ }
}
}
@@ -140,5 +153,16 @@ func splitLines(s string) []string {
return out
}
-// Shutdown is a no-op placeholder wired into graceful shutdown.
-func (h *Hub) Shutdown(_ context.Context) error { return nil }
+// Shutdown stops the heartbeat goroutine and closes all subscriber channels.
+func (h *Hub) Shutdown(_ context.Context) error {
+ h.closeOnce.Do(func() {
+ close(h.done)
+ h.mu.Lock()
+ for id, s := range h.subs {
+ close(s.ch)
+ delete(h.subs, id)
+ }
+ h.mu.Unlock()
+ })
+ return nil
+}
diff --git a/internal/httpserver/router.go b/internal/httpserver/router.go
index e3a4c2d..2b2cc9a 100644
--- a/internal/httpserver/router.go
+++ b/internal/httpserver/router.go
@@ -4,6 +4,7 @@
package httpserver
import (
+ "fmt"
"io/fs"
"net/http"
@@ -21,7 +22,7 @@ type Deps struct {
AgentAssetDir string // directory containing vetting-agent-linux-amd64; "" disables /assets
}
-func NewRouter(d Deps) http.Handler {
+func NewRouter(d Deps) (http.Handler, error) {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
@@ -29,7 +30,7 @@ func NewRouter(d Deps) http.Handler {
staticFS, err := fs.Sub(web.Static, "static")
if err != nil {
- panic(err)
+ return nil, fmt.Errorf("extract static assets: %w", err)
}
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
@@ -80,5 +81,5 @@ func NewRouter(d Deps) http.Handler {
r.Get("/register/quick.sh", d.UI.QuickRegisterScript)
r.Get("/events", d.UI.SSE)
- return r
+ return r, nil
}
diff --git a/internal/httpserver/sse_e2e_test.go b/internal/httpserver/sse_e2e_test.go
index ee887a6..bf5d3ca 100644
--- a/internal/httpserver/sse_e2e_test.go
+++ b/internal/httpserver/sse_e2e_test.go
@@ -72,7 +72,10 @@ func TestSSE_EndToEnd(t *testing.T) {
t.Fatalf("create host: %v", err)
}
- router := NewRouter(Deps{UI: ui, Agent: agent})
+ router, err := NewRouter(Deps{UI: ui, Agent: agent})
+ if err != nil {
+ t.Fatalf("router: %v", err)
+ }
srv := httptest.NewServer(router)
t.Cleanup(srv.Close)
@@ -178,7 +181,10 @@ func TestSSE_SubStepEvent(t *testing.T) {
SpecDiffs: diffs, Runner: runner, EventHub: hub,
}
- router := NewRouter(Deps{UI: ui, Agent: agent})
+ router, err := NewRouter(Deps{UI: ui, Agent: agent})
+ if err != nil {
+ t.Fatalf("router: %v", err)
+ }
srv := httptest.NewServer(router)
t.Cleanup(srv.Close)
diff --git a/internal/orchestrator/dispatcher.go b/internal/orchestrator/dispatcher.go
index cc255bb..9cc2736 100644
--- a/internal/orchestrator/dispatcher.go
+++ b/internal/orchestrator/dispatcher.go
@@ -18,7 +18,10 @@ import (
// doesn't block dispatch. Used by the StartRun preflight and the
// dispatcher itself — both must agree or the operator's click-time
// validation wouldn't match the dispatch-time check.
-const HostHeartbeatStaleAfter = 60 * time.Second
+const (
+ HostHeartbeatStaleAfter = 60 * time.Second
+ dispatchTickInterval = 2 * time.Second
+)
// Dispatcher picks Queued runs off the DB and drives them to
// WaitingReboot — the happy path is heartbeat-first: we transition and
@@ -76,7 +79,7 @@ func (d *Dispatcher) Stop() {
}
func (d *Dispatcher) loop(ctx context.Context) {
- t := time.NewTicker(2 * time.Second)
+ t := time.NewTicker(dispatchTickInterval)
defer t.Stop()
for {
select {
diff --git a/internal/store/runs.go b/internal/store/runs.go
index 77c7ba3..2761ae6 100644
--- a/internal/store/runs.go
+++ b/internal/store/runs.go
@@ -10,6 +10,8 @@ import (
"vetting/internal/model"
)
+const defaultRunListLimit = 20
+
type Runs struct {
DB *sql.DB
}
@@ -182,7 +184,7 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
// 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
+ limit = defaultRunListLimit
}
rows, err := r.DB.QueryContext(ctx, `
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
diff --git a/internal/web/static/app.css b/internal/web/static/app.css
index 91ea11e..94a4b55 100644
--- a/internal/web/static/app.css
+++ b/internal/web/static/app.css
@@ -140,58 +140,6 @@ button.danger:hover { background: rgba(229,100,102,.1); }
.tile-last-seen.stale::before { background: var(--warn); }
.tile-last-seen.offline::before { background: var(--text-dim); opacity: .5; }
-.tile-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; margin: 0; font-size: 13px; }
-.tile-meta div { display: flex; justify-content: space-between; align-items: baseline; }
-.tile-meta dt { color: var(--text-dim); }
-.tile-meta dd { margin: 0; font-family: var(--mono); }
-
-.tile-actions { display: flex; gap: 8px; }
-.tile-actions .inline { margin: 0; flex: 0; }
-
-.tile-meta dd.bad { color: var(--danger); }
-
-.tile-hold {
- background: rgba(229,100,102,.08);
- border: 1px solid rgba(229,100,102,.35);
- border-radius: var(--radius);
- padding: 8px 10px;
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-.tile-hold .hold-title {
- font-size: 12px;
- color: var(--danger);
- text-transform: uppercase;
- letter-spacing: .5px;
-}
-.tile-hold .hold-ssh {
- font-family: var(--mono);
- font-size: 12px;
- color: var(--text);
- word-break: break-all;
- user-select: all;
-}
-
-.tile-log {
- background: #0b0d12;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 8px 10px;
- font-family: var(--mono);
- font-size: 12px;
- color: var(--text-dim);
- max-height: 160px;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-.tile-log:empty { display: none; }
-.tile-log .log-line { white-space: pre-wrap; }
-.tile-log .log-warn { color: var(--warn); }
-.tile-log .log-error { color: var(--danger); }
-
.tile-fail { border-color: rgba(229,100,102,.6); }
.tile-pass { border-color: rgba(53,194,123,.5); }
.tile-active { border-color: var(--accent); }
@@ -314,7 +262,6 @@ body.bare main { max-width: none; }
.detail-summary.tile-active { border-color: var(--accent); }
.detail-summary-head { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; }
.detail-name { margin: 0; font-size: 22px; }
-.detail-status-row { display: flex; align-items: center; gap: 12px; }
.detail-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@@ -339,11 +286,6 @@ body.bare main { max-width: none; }
.detail-section details[open] > summary::before { transform: rotate(90deg); }
.detail-section details > summary h2 { margin: 0; }
-.detail-hold {
- background: rgba(229,100,102,.08);
- border-color: rgba(229,100,102,.35);
-}
-.detail-hold h2 { color: var(--danger); }
.hold-ssh {
font-family: var(--mono);
font-size: 13px;
@@ -360,25 +302,6 @@ body.bare main { max-width: none; }
.detail-actions-row { display: flex; flex-wrap: wrap; gap: 10px; }
.detail-actions-row .inline { margin: 0; }
-.detail-log {
- background: #0b0d12;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 10px 12px;
- font-family: var(--mono);
- font-size: 12px;
- color: var(--text-dim);
- max-height: 500px;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-.detail-log:empty::before { content: "(no log output yet)"; color: var(--text-dim); opacity: .5; }
-.detail-log .log-line { white-space: pre-wrap; }
-.detail-log .log-warn { color: var(--warn); }
-.detail-log .log-error { color: var(--danger); }
-
/* ===== Log tabs (CSS-only radio switch) ===== */
/* Radios are visually hidden but still functional: checked state is read
by sibling selectors below to flip the active label + pane. */
@@ -564,37 +487,6 @@ body.bare main { max-width: none; }
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);
@@ -673,25 +565,7 @@ body.bare main { max-width: none; }
}
.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);
diff --git a/internal/web/templates/active_step.templ b/internal/web/templates/active_step.templ
index 54a293f..3d72293 100644
--- a/internal/web/templates/active_step.templ
+++ b/internal/web/templates/active_step.templ
@@ -2,7 +2,6 @@ package templates
import (
"fmt"
- "time"
"vetting/internal/model"
)
@@ -67,30 +66,9 @@ func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
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))
+ if d := elapsed(s.StartedAt, s.CompletedAt); d >= 0 {
+ return fmtElapsed(d, false)
}
+ return ""
}
diff --git a/internal/web/templates/active_step_templ.go b/internal/web/templates/active_step_templ.go
index 4c7c13b..d3fcc04 100644
--- a/internal/web/templates/active_step_templ.go
+++ b/internal/web/templates/active_step_templ.go
@@ -10,7 +10,6 @@ import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
- "time"
"vetting/internal/model"
)
@@ -88,7 +87,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 28, Col: 102}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 27, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -123,7 +122,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(d.Stage.State)))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 30, Col: 105}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 29, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -136,7 +135,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 31, Col: 41}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 30, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -149,7 +148,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDurationFromStage(d.Stage))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 32, Col: 64}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 31, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -182,7 +181,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 43, Col: 99}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 42, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -195,7 +194,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 47, Col: 56}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 46, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -208,7 +207,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 48, Col: 62}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 47, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -243,32 +242,11 @@ func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
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))
+ if d := elapsed(s.StartedAt, s.CompletedAt); d >= 0 {
+ return fmtElapsed(d, false)
}
+ return ""
}
var _ = templruntime.GeneratedTemplate
diff --git a/internal/web/templates/helpers.go b/internal/web/templates/helpers.go
new file mode 100644
index 0000000..a436e2e
--- /dev/null
+++ b/internal/web/templates/helpers.go
@@ -0,0 +1,42 @@
+package templates
+
+import (
+ "fmt"
+ "time"
+)
+
+func elapsed(start, end *time.Time) time.Duration {
+ if start == nil {
+ return -1
+ }
+ e := time.Now()
+ if end != nil {
+ e = *end
+ }
+ d := e.Sub(*start)
+ if d < 0 {
+ return 0
+ }
+ return d
+}
+
+func fmtElapsed(d time.Duration, long bool) string {
+ 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:
+ if long {
+ return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
+ }
+ return fmt.Sprintf("%dm", int(d/time.Minute))
+ default:
+ if long {
+ return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
+ }
+ return fmt.Sprintf("%dh", int(d/time.Hour))
+ }
+}
diff --git a/internal/web/templates/host_page.templ b/internal/web/templates/host_page.templ
index d0f711b..ba92027 100644
--- a/internal/web/templates/host_page.templ
+++ b/internal/web/templates/host_page.templ
@@ -283,9 +283,6 @@ func profileChipValue(p string) string {
return p
}
-// runDuration formats the elapsed time for a run using the same buckets
-// as stageDuration. In-flight runs clock from StartedAt to now so the
-// run-page header + runs-table row keep ticking on each SSE push.
func runDuration(r *model.Run) string {
if r == nil || r.StartedAt.IsZero() {
return ""
@@ -298,18 +295,7 @@ func runDuration(r *model.Run) string {
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))
- }
+ return fmtElapsed(d, true)
}
// stageForName returns the persisted Stage row for a given name, or a
diff --git a/internal/web/templates/host_page_templ.go b/internal/web/templates/host_page_templ.go
index 7dea6d0..5dcb87c 100644
--- a/internal/web/templates/host_page_templ.go
+++ b/internal/web/templates/host_page_templ.go
@@ -877,9 +877,6 @@ func profileChipValue(p string) string {
return p
}
-// runDuration formats the elapsed time for a run using the same buckets
-// as stageDuration. In-flight runs clock from StartedAt to now so the
-// run-page header + runs-table row keep ticking on each SSE push.
func runDuration(r *model.Run) string {
if r == nil || r.StartedAt.IsZero() {
return ""
@@ -892,18 +889,7 @@ func runDuration(r *model.Run) string {
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))
- }
+ return fmtElapsed(d, true)
}
// stageForName returns the persisted Stage row for a given name, or a
diff --git a/internal/web/templates/pipeline.templ b/internal/web/templates/pipeline.templ
index b8bae19..968a591 100644
--- a/internal/web/templates/pipeline.templ
+++ b/internal/web/templates/pipeline.templ
@@ -223,32 +223,11 @@ func stageStateByName(name string) (model.RunState, bool) {
return s, ok
}
-// stageDuration renders node timing as "1.2s" / "12s" / "4m". Empty
-// string when the node hasn't started or hasn't finished.
func stageDuration(n PipelineNode) string {
- if n.StartedAt == nil {
- return ""
- }
- end := time.Now()
- if n.CompletedAt != nil {
- end = *n.CompletedAt
- }
- d := end.Sub(*n.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))
+ if d := elapsed(n.StartedAt, n.CompletedAt); d >= 0 {
+ return fmtElapsed(d, false)
}
+ return ""
}
// stageDisplayName turns the internal single-word state/stage identifier
diff --git a/internal/web/templates/pipeline_templ.go b/internal/web/templates/pipeline_templ.go
index bd2a183..e3a1635 100644
--- a/internal/web/templates/pipeline_templ.go
+++ b/internal/web/templates/pipeline_templ.go
@@ -231,32 +231,11 @@ func stageStateByName(name string) (model.RunState, bool) {
return s, ok
}
-// stageDuration renders node timing as "1.2s" / "12s" / "4m". Empty
-// string when the node hasn't started or hasn't finished.
func stageDuration(n PipelineNode) string {
- if n.StartedAt == nil {
- return ""
- }
- end := time.Now()
- if n.CompletedAt != nil {
- end = *n.CompletedAt
- }
- d := end.Sub(*n.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))
+ if d := elapsed(n.StartedAt, n.CompletedAt); d >= 0 {
+ return fmtElapsed(d, false)
}
+ return ""
}
// stageDisplayName turns the internal single-word state/stage identifier
@@ -406,7 +385,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 307, Col: 77}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 286, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -419,7 +398,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDisplayName(n.Name))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 308, Col: 54}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 287, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -432,7 +411,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 309, Col: 50}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 288, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -486,7 +465,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 324, Col: 41}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 303, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -499,7 +478,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 326, Col: 47}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 305, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
diff --git a/internal/web/templates/substep_row.templ b/internal/web/templates/substep_row.templ
index 7034664..0ed4ba7 100644
--- a/internal/web/templates/substep_row.templ
+++ b/internal/web/templates/substep_row.templ
@@ -4,37 +4,15 @@ 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))
+ if d := elapsed(ss.StartedAt, ss.CompletedAt); d >= 0 {
+ return fmtElapsed(d, false)
}
+ return ""
}
// subStepMarker mirrors stageMarker — a single-char glyph used inside the
diff --git a/internal/web/templates/substep_row_templ.go b/internal/web/templates/substep_row_templ.go
index 02cae74..be1abfc 100644
--- a/internal/web/templates/substep_row_templ.go
+++ b/internal/web/templates/substep_row_templ.go
@@ -12,37 +12,15 @@ 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))
+ if d := elapsed(ss.StartedAt, ss.CompletedAt); d >= 0 {
+ return fmtElapsed(d, false)
}
+ return ""
}
// subStepMarker mirrors stageMarker — a single-char glyph used inside the
@@ -99,7 +77,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 63, Col: 74}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 41, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -125,7 +103,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 65, Col: 80}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 43, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -160,7 +138,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(subStepMarker(ss.State))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 68, Col: 96}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 46, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -173,7 +151,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(ss.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 69, Col: 38}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 47, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -186,7 +164,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(subStepDuration(ss))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 70, Col: 54}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 48, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {