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 {