chore: cleanup sprint — dead CSS, dedup helpers, handler refactor
CI / Lint + build + test (push) Successful in 1m34s
Release / detect (push) Successful in 4s
Release / build-live-image (push) Has been skipped
Release / bundle (push) Successful in 1m5s

Remove ~126 lines of orphaned CSS from tile slim-down and old detail
layout. Consolidate 4 duplicate duration formatters into shared
elapsed()/fmtElapsed() helpers. Break 160-line Result handler into
focused sub-functions. Implement real Hub.Shutdown() (was a no-op).
Standardize agent error responses to JSON. Replace panic() in router
init with error return. Extract magic numbers as named constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 20:39:38 -04:00
parent c11573eeeb
commit 17ec55cb85
17 changed files with 242 additions and 438 deletions
+4 -1
View File
@@ -208,12 +208,15 @@ func main() {
ui.PXE = supervisor ui.PXE = supervisor
} }
router := httpserver.NewRouter(httpserver.Deps{ router, err := httpserver.NewRouter(httpserver.Deps{
UI: ui, UI: ui,
Agent: agentAPI, Agent: agentAPI,
LiveDir: cfg.PXE.LiveDir, LiveDir: cfg.PXE.LiveDir,
AgentAssetDir: cfg.Agent.AssetDir, AgentAssetDir: cfg.Agent.AssetDir,
}) })
if err != nil {
log.Fatalf("router: %v", err)
}
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Server.Bind, Addr: cfg.Server.Bind,
+79 -72
View File
@@ -170,7 +170,7 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
if len(mustListStages(a.Stages, r, runID)) == 0 { if len(mustListStages(a.Stages, r, runID)) == 0 {
if err := a.Stages.Seed(r.Context(), runID); err != nil { if err := a.Stages.Seed(r.Context(), runID); err != nil {
log.Printf("claim: seed stages run %d: %v", runID, err) log.Printf("claim: seed stages run %d: %v", runID, err)
http.Error(w, "seed stages", http.StatusInternalServerError) writeJSONErr(w, http.StatusInternalServerError, "seed stages")
return 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 run.State == model.StateWaitingWoL || run.State == model.StateBooting {
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerAgentClaimed); err != nil { if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerAgentClaimed); err != nil {
log.Printf("claim: transition run %d: %v", runID, err) log.Printf("claim: transition run %d: %v", runID, err)
http.Error(w, "transition", http.StatusConflict) writeJSONErr(w, http.StatusConflict, "transition")
return return
} }
} }
@@ -369,6 +369,10 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
_ = json.NewEncoder(w).Encode(body) _ = 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 // mustListStages is a small wrapper that hides the error path from
// /claim — a DB read failure just pretends there are zero stages, and // /claim — a DB read failure just pretends there are zero stages, and
// the subsequent Seed will surface the real error. // 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 var batch LogBatch
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil { if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
http.Error(w, "bad json", http.StatusBadRequest) writeJSONErr(w, http.StatusBadRequest, "bad json")
return return
} }
writer, err := a.Logs.WriterFor(runID) writer, err := a.Logs.WriterFor(runID)
if err != nil { if err != nil {
http.Error(w, "open log: "+err.Error(), http.StatusInternalServerError) writeJSONErr(w, http.StatusInternalServerError, "open log: "+err.Error())
return return
} }
for _, l := range batch.Lines { for _, l := range batch.Lines {
@@ -470,9 +474,7 @@ type SubStepResultLine struct {
// Result receives a stage's outcome. Flow: // Result receives a stage's outcome. Flow:
// 1. Mark the stage row passed/failed + record summary JSON. // 1. Mark the stage row passed/failed + record summary JSON.
// 2. For Inventory: persist the inventory artifact. // 2. For Inventory: persist the inventory artifact.
// 3. For Inventory (on pass): run spec diff server-side, persist rows, // 3. For Firmware: persist firmware snapshots.
// bump the run into SpecValidate and immediately resolve SpecValidate
// from that diff — the agent isn't involved in SpecValidate at all.
// 4. Transition the run via StageCompleted/StageFailed. // 4. Transition the run via StageCompleted/StageFailed.
func (a *Agent) Result(w http.ResponseWriter, r *http.Request) { func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
runID, ok := runIDFromURL(w, r) runID, ok := runIDFromURL(w, r)
@@ -485,28 +487,55 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
} }
var body StageResult var body StageResult
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest) writeJSONErr(w, http.StatusBadRequest, "bad json")
return return
} }
body.Stage = strings.TrimSpace(body.Stage) body.Stage = strings.TrimSpace(body.Stage)
if _, ok := orchestrator.StateForStage(body.Stage); !ok { 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 return
} }
// Silent-skip guard. Orchestrator advances the run state via if a.resultStageMismatch(w, r, runID, run, &body) {
// TriggerStageCompleted against the *current* state, not against return
// 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 thresholdDetail := a.resultCheckThresholds(r.Context(), runID, &body)
// happened on Orion when the agent OOM-crashed mid-CPUStress,
// systemd restarted it, and the restarted agent (which hardcoded stageState := model.StagePassed
// "Inventory" as its first stage) re-ran Inventory and reported it. if !body.Passed {
// Guard: if body.Stage doesn't match the stage the run is currently stageState = model.StageFailed
// in, park the run in FailedHolding so the operator can investigate }
// rather than trusting the claim and cascading silent passes. summaryJSON := ""
if len(body.Summary) > 0 {
summaryJSON = string(body.Summary)
}
if err := a.Runner.CompleteStage(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil {
writeJSONErr(w, http.StatusInternalServerError, "complete stage: "+err.Error())
return
}
if thresholdDetail != "" && body.Message == "" {
body.Message = thresholdDetail
}
a.persistSubSteps(r.Context(), runID, body.Stage, body.SubSteps)
a.resultPersistArtifacts(r, run, runID, &body)
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) expectedStage := orchestrator.StageNameForState(run.State)
if expectedStage != "" && body.Stage != expectedStage { if expectedStage == "" || body.Stage == expectedStage {
return false
}
failedLabel := fmt.Sprintf("%s (expected %s)", body.Stage, expectedStage) failedLabel := fmt.Sprintf("%s (expected %s)", body.Stage, expectedStage)
if err := a.Runs.SetFailedStage(r.Context(), runID, failedLabel); err != nil { if err := a.Runs.SetFailedStage(r.Context(), runID, failedLabel); err != nil {
log.Printf("result: set failed stage on mismatch run %d: %v", runID, err) log.Printf("result: set failed stage on mismatch run %d: %v", runID, err)
@@ -526,68 +555,49 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
URL: a.runLinkURL(runID), URL: a.runLinkURL(runID),
}) })
log.Printf("result: stage mismatch run=%d got=%s expected=%s — parked", runID, body.Stage, expectedStage) 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) writeJSONErr(w, http.StatusConflict, "stage mismatch: got "+body.Stage+", expected "+expectedStage)
return return true
} }
// Aggregate threshold gate: flip Passed=false server-side when any // resultCheckThresholds flips body.Passed to false when the server-side
// critical breach landed for this stage. The agent's verdict is // threshold sidecar recorded a critical breach the agent missed.
// advisory — a stage-executor can miss a runaway sample that the func (a *Agent) resultCheckThresholds(ctx context.Context, runID int64, body *StageResult) string {
// 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))
}
}
stageState := model.StagePassed
if !body.Passed { if !body.Passed {
stageState = model.StageFailed return ""
} }
summaryJSON := "" breached, detail := a.stageHadCriticalBreach(ctx, runID, body.Stage)
if len(body.Summary) > 0 { if !breached {
summaryJSON = string(body.Summary) return ""
} }
if err := a.Runner.CompleteStage(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil { body.Passed = false
http.Error(w, "complete stage: "+err.Error(), http.StatusInternalServerError) a.appendLog(runID, "error", fmt.Sprintf("%s reported passed but %s — flipping to failed", body.Stage, detail))
return return detail
}
if thresholdDetail != "" && body.Message == "" {
body.Message = thresholdDetail
} }
// Agent-authored sub-steps: persist in slice order (ordinal = index) // resultPersistArtifacts handles stage-specific artifact persistence
// and fan out a per-row SSE event each so the detail pane shows them // (inventory JSON, firmware snapshots). Best-effort — errors are logged.
// without a reload. Best-effort — a persistence error is logged but func (a *Agent) resultPersistArtifacts(r *http.Request, run *model.Run, runID int64, body *StageResult) {
// doesn't fail the whole /result.
a.persistSubSteps(r.Context(), runID, body.Stage, body.SubSteps)
// Inventory-specific: persist artifact + compute spec diff.
if body.Stage == "Inventory" && body.Inventory != nil { if body.Stage == "Inventory" && body.Inventory != nil {
if err := a.persistInventory(r, run, body.Inventory); err != nil { if err := a.persistInventory(r, run, body.Inventory); err != nil {
log.Printf("persist inventory run %d: %v", runID, err) 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 body.Stage == "Firmware" && len(body.Firmware) > 0 {
if err := a.persistFirmware(r.Context(), runID, body.Firmware); err != nil { if err := a.persistFirmware(r.Context(), runID, body.Firmware); err != nil {
log.Printf("persist firmware run %d: %v", runID, err) log.Printf("persist firmware run %d: %v", runID, err)
} }
} }
}
if !body.Passed { // 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 { if err := a.Runs.SetFailedStage(r.Context(), runID, body.Stage); err != nil {
log.Printf("set failed stage: %v", err) log.Printf("set failed stage: %v", err)
} }
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil { if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil {
log.Printf("result: failed-transition run %d: %v", runID, err) log.Printf("result: failed-transition run %d: %v", runID, err)
http.Error(w, "transition", http.StatusConflict) writeJSONErr(w, http.StatusConflict, "transition")
return return
} }
hostName := a.hostNameFor(r.Context(), run.HostID) hostName := a.hostNameFor(r.Context(), run.HostID)
@@ -605,21 +615,18 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
URL: a.runLinkURL(runID), URL: a.runLinkURL(runID),
}) })
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "next_state": "FailedHolding"}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "next_state": "FailedHolding"})
return
} }
// 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) next, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageCompleted)
if err != nil { if err != nil {
http.Error(w, "advance: "+err.Error(), http.StatusConflict) writeJSONErr(w, http.StatusConflict, "advance: "+err.Error())
return return
} }
log.Printf("result: run %d stage %s passed → %s", runID, body.Stage, next) 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 { if next == model.StateSpecValidate {
a.resolveSpecValidate(r, runID) a.resolveSpecValidate(r, runID)
if after, err := a.Runs.Get(r.Context(), runID); err == nil { 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) kp, err := hold.Issue(runID)
if err != nil { if err != nil {
http.Error(w, "generate key: "+err.Error(), http.StatusInternalServerError) writeJSONErr(w, http.StatusInternalServerError, "generate key: "+err.Error())
return return
} }
keyPath := filepath.Join(a.ArtifactsDir, fmt.Sprintf("run-%d", runID), "hold.key") keyPath := filepath.Join(a.ArtifactsDir, fmt.Sprintf("run-%d", runID), "hold.key")
abs, err := kp.WritePrivateTo(keyPath) abs, err := kp.WritePrivateTo(keyPath)
if err != nil { if err != nil {
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError) writeJSONErr(w, http.StatusInternalServerError, "write key: "+err.Error())
return return
} }
sum := sha256.Sum256(kp.PrivatePEM) sum := sha256.Sum256(kp.PrivatePEM)
@@ -1021,12 +1028,12 @@ func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
return return
} }
if a.Measurements == nil { if a.Measurements == nil {
http.Error(w, "measurements store not wired", http.StatusInternalServerError) writeJSONErr(w, http.StatusInternalServerError, "measurements store not wired")
return return
} }
var body SensorBatch var body SensorBatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest) writeJSONErr(w, http.StatusBadRequest, "bad json")
return return
} }
rows := make([]model.Measurement, 0, len(body.Samples)) 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)) sampleStages = append(sampleStages, orchestrator.StageNameForState(run.State))
} }
if err := a.Measurements.CreateBatch(r.Context(), rows); err != nil { 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 return
} }
critical := a.evaluateSensorBatch(r.Context(), runID, rows, sampleStages) critical := a.evaluateSensorBatch(r.Context(), runID, rows, sampleStages)
+29 -5
View File
@@ -22,6 +22,11 @@ type subscriber struct {
ch chan Event ch chan Event
} }
const (
defaultSubscriberBuffer = 32
heartbeatInterval = 15 * time.Second
)
// Hub is an in-process fan-out for SSE subscribers. // Hub is an in-process fan-out for SSE subscribers.
type Hub struct { type Hub struct {
mu sync.RWMutex mu sync.RWMutex
@@ -29,13 +34,16 @@ type Hub struct {
subs map[int64]*subscriber subs map[int64]*subscriber
buffer int buffer int
heartbeat time.Duration heartbeat time.Duration
done chan struct{}
closeOnce sync.Once
} }
func NewHub() *Hub { func NewHub() *Hub {
h := &Hub{ h := &Hub{
subs: map[int64]*subscriber{}, subs: map[int64]*subscriber{},
buffer: 32, buffer: defaultSubscriberBuffer,
heartbeat: 15 * time.Second, heartbeat: heartbeatInterval,
done: make(chan struct{}),
} }
go h.heartbeatLoop() go h.heartbeatLoop()
return h return h
@@ -70,13 +78,18 @@ func (h *Hub) Subscribe() (id int64, ch <-chan Event, cancel func()) {
func (h *Hub) heartbeatLoop() { func (h *Hub) heartbeatLoop() {
t := time.NewTicker(h.heartbeat) t := time.NewTicker(h.heartbeat)
defer t.Stop() defer t.Stop()
for range t.C { for {
select {
case <-h.done:
return
case <-t.C:
h.Publish(Event{ h.Publish(Event{
Name: "heartbeat", Name: "heartbeat",
Payload: fmt.Sprintf(`<span data-heartbeat="%d"></span>`, time.Now().Unix()), Payload: fmt.Sprintf(`<span data-heartbeat="%d"></span>`, time.Now().Unix()),
}) })
} }
} }
}
// ServeSSE writes server-sent events for a single subscriber for the // ServeSSE writes server-sent events for a single subscriber for the
// lifetime of the request. Each Event becomes one SSE message. // lifetime of the request. Each Event becomes one SSE message.
@@ -140,5 +153,16 @@ func splitLines(s string) []string {
return out return out
} }
// Shutdown is a no-op placeholder wired into graceful shutdown. // Shutdown stops the heartbeat goroutine and closes all subscriber channels.
func (h *Hub) Shutdown(_ context.Context) error { return nil } 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
}
+4 -3
View File
@@ -4,6 +4,7 @@
package httpserver package httpserver
import ( import (
"fmt"
"io/fs" "io/fs"
"net/http" "net/http"
@@ -21,7 +22,7 @@ type Deps struct {
AgentAssetDir string // directory containing vetting-agent-linux-amd64; "" disables /assets 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 := chi.NewRouter()
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
@@ -29,7 +30,7 @@ func NewRouter(d Deps) http.Handler {
staticFS, err := fs.Sub(web.Static, "static") staticFS, err := fs.Sub(web.Static, "static")
if err != nil { 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)))) 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("/register/quick.sh", d.UI.QuickRegisterScript)
r.Get("/events", d.UI.SSE) r.Get("/events", d.UI.SSE)
return r return r, nil
} }
+8 -2
View File
@@ -72,7 +72,10 @@ func TestSSE_EndToEnd(t *testing.T) {
t.Fatalf("create host: %v", err) 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) srv := httptest.NewServer(router)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
@@ -178,7 +181,10 @@ func TestSSE_SubStepEvent(t *testing.T) {
SpecDiffs: diffs, Runner: runner, EventHub: hub, 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) srv := httptest.NewServer(router)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
+5 -2
View File
@@ -18,7 +18,10 @@ import (
// doesn't block dispatch. Used by the StartRun preflight and the // doesn't block dispatch. Used by the StartRun preflight and the
// dispatcher itself — both must agree or the operator's click-time // dispatcher itself — both must agree or the operator's click-time
// validation wouldn't match the dispatch-time check. // 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 // Dispatcher picks Queued runs off the DB and drives them to
// WaitingReboot — the happy path is heartbeat-first: we transition and // 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) { func (d *Dispatcher) loop(ctx context.Context) {
t := time.NewTicker(2 * time.Second) t := time.NewTicker(dispatchTickInterval)
defer t.Stop() defer t.Stop()
for { for {
select { select {
+3 -1
View File
@@ -10,6 +10,8 @@ import (
"vetting/internal/model" "vetting/internal/model"
) )
const defaultRunListLimit = 20
type Runs struct { type Runs struct {
DB *sql.DB 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. // can't scan the whole history into memory.
func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]model.Run, error) { func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]model.Run, error) {
if limit <= 0 { if limit <= 0 {
limit = 20 limit = defaultRunListLimit
} }
rows, err := r.DB.QueryContext(ctx, ` rows, err := r.DB.QueryContext(ctx, `
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''), SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
-126
View File
@@ -140,58 +140,6 @@ button.danger:hover { background: rgba(229,100,102,.1); }
.tile-last-seen.stale::before { background: var(--warn); } .tile-last-seen.stale::before { background: var(--warn); }
.tile-last-seen.offline::before { background: var(--text-dim); opacity: .5; } .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-fail { border-color: rgba(229,100,102,.6); }
.tile-pass { border-color: rgba(53,194,123,.5); } .tile-pass { border-color: rgba(53,194,123,.5); }
.tile-active { border-color: var(--accent); } .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.tile-active { border-color: var(--accent); }
.detail-summary-head { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; } .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-name { margin: 0; font-size: 22px; }
.detail-status-row { display: flex; align-items: center; gap: 12px; }
.detail-meta { .detail-meta {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); 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[open] > summary::before { transform: rotate(90deg); }
.detail-section details > summary h2 { margin: 0; } .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 { .hold-ssh {
font-family: var(--mono); font-family: var(--mono);
font-size: 13px; 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 { display: flex; flex-wrap: wrap; gap: 10px; }
.detail-actions-row .inline { margin: 0; } .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) ===== */ /* ===== Log tabs (CSS-only radio switch) ===== */
/* Radios are visually hidden but still functional: checked state is read /* Radios are visually hidden but still functional: checked state is read
by sibling selectors below to flip the active label + pane. */ 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); } 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 { .run-header {
background: var(--bg-elev); background: var(--bg-elev);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -673,25 +565,7 @@ body.bare main { max-width: none; }
} }
.detail-hold-placeholder { display: 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; } .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 { .step {
background: var(--bg-elev); background: var(--bg-elev);
border: 1px solid var(--border); border: 1px solid var(--border);
+3 -25
View File
@@ -2,7 +2,6 @@ package templates
import ( import (
"fmt" "fmt"
"time"
"vetting/internal/model" "vetting/internal/model"
) )
@@ -67,30 +66,9 @@ func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
return out return out
} }
// stageDurationFromStage is stageDuration adapted to a model.Stage — same
// formatting rules, different input shape.
func stageDurationFromStage(s model.Stage) string { func stageDurationFromStage(s model.Stage) string {
if s.StartedAt == nil { if d := elapsed(s.StartedAt, s.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
return "" 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))
}
}
+10 -32
View File
@@ -10,7 +10,6 @@ import templruntime "github.com/a-h/templ/runtime"
import ( import (
"fmt" "fmt"
"time"
"vetting/internal/model" "vetting/internal/model"
) )
@@ -88,7 +87,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -123,7 +122,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(d.Stage.State))) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(d.Stage.State)))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -136,7 +135,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -149,7 +148,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDurationFromStage(d.Stage)) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDurationFromStage(d.Stage))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -182,7 +181,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -195,7 +194,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name)) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -208,7 +207,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name)) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -243,32 +242,11 @@ func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
return out return out
} }
// stageDurationFromStage is stageDuration adapted to a model.Stage — same
// formatting rules, different input shape.
func stageDurationFromStage(s model.Stage) string { func stageDurationFromStage(s model.Stage) string {
if s.StartedAt == nil { if d := elapsed(s.StartedAt, s.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
return "" return ""
} }
end := time.Now()
if s.CompletedAt != nil {
end = *s.CompletedAt
}
d := end.Sub(*s.StartedAt)
if d < 0 {
d = 0
}
switch {
case d < time.Second:
return fmt.Sprintf("%dms", int(d/time.Millisecond))
case d < 10*time.Second:
return fmt.Sprintf("%.1fs", d.Seconds())
case d < time.Minute:
return fmt.Sprintf("%ds", int(d/time.Second))
case d < time.Hour:
return fmt.Sprintf("%dm", int(d/time.Minute))
default:
return fmt.Sprintf("%dh", int(d/time.Hour))
}
}
var _ = templruntime.GeneratedTemplate var _ = templruntime.GeneratedTemplate
+42
View File
@@ -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))
}
}
+1 -15
View File
@@ -283,9 +283,6 @@ func profileChipValue(p string) string {
return p 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 { func runDuration(r *model.Run) string {
if r == nil || r.StartedAt.IsZero() { if r == nil || r.StartedAt.IsZero() {
return "" return ""
@@ -298,18 +295,7 @@ func runDuration(r *model.Run) string {
if d < 0 { if d < 0 {
d = 0 d = 0
} }
switch { return fmtElapsed(d, true)
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))
}
} }
// stageForName returns the persisted Stage row for a given name, or a // stageForName returns the persisted Stage row for a given name, or a
+1 -15
View File
@@ -877,9 +877,6 @@ func profileChipValue(p string) string {
return p 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 { func runDuration(r *model.Run) string {
if r == nil || r.StartedAt.IsZero() { if r == nil || r.StartedAt.IsZero() {
return "" return ""
@@ -892,18 +889,7 @@ func runDuration(r *model.Run) string {
if d < 0 { if d < 0 {
d = 0 d = 0
} }
switch { return fmtElapsed(d, true)
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))
}
} }
// stageForName returns the persisted Stage row for a given name, or a // stageForName returns the persisted Stage row for a given name, or a
+3 -24
View File
@@ -223,33 +223,12 @@ func stageStateByName(name string) (model.RunState, bool) {
return s, ok 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 { func stageDuration(n PipelineNode) string {
if n.StartedAt == nil { if d := elapsed(n.StartedAt, n.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
return "" 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))
}
}
// stageDisplayName turns the internal single-word state/stage identifier // stageDisplayName turns the internal single-word state/stage identifier
// into a human-readable label by inserting spaces before interior capital // into a human-readable label by inserting spaces before interior capital
+8 -29
View File
@@ -231,33 +231,12 @@ func stageStateByName(name string) (model.RunState, bool) {
return s, ok 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 { func stageDuration(n PipelineNode) string {
if n.StartedAt == nil { if d := elapsed(n.StartedAt, n.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
return "" 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))
}
}
// stageDisplayName turns the internal single-word state/stage identifier // stageDisplayName turns the internal single-word state/stage identifier
// into a human-readable label by inserting spaces before interior capital // into a human-readable label by inserting spaces before interior capital
@@ -406,7 +385,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -419,7 +398,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDisplayName(n.Name)) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDisplayName(n.Name))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -432,7 +411,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n)) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -486,7 +465,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID)) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -499,7 +478,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID)) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+3 -25
View File
@@ -4,38 +4,16 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"time"
"vetting/internal/model" "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 { func subStepDuration(ss model.SubStep) string {
if ss.StartedAt == nil { if d := elapsed(ss.StartedAt, ss.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
return "" return ""
} }
end := time.Now()
if ss.CompletedAt != nil {
end = *ss.CompletedAt
}
d := end.Sub(*ss.StartedAt)
if d < 0 {
d = 0
}
switch {
case d < time.Second:
return fmt.Sprintf("%dms", int(d/time.Millisecond))
case d < 10*time.Second:
return fmt.Sprintf("%.1fs", d.Seconds())
case d < time.Minute:
return fmt.Sprintf("%ds", int(d/time.Second))
case d < time.Hour:
return fmt.Sprintf("%dm", int(d/time.Minute))
default:
return fmt.Sprintf("%dh", int(d/time.Hour))
}
}
// subStepMarker mirrors stageMarker — a single-char glyph used inside the // subStepMarker mirrors stageMarker — a single-char glyph used inside the
// state badge. StageState values reused verbatim for sub-steps. // state badge. StageState values reused verbatim for sub-steps.
+8 -30
View File
@@ -12,38 +12,16 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"time"
"vetting/internal/model" "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 { func subStepDuration(ss model.SubStep) string {
if ss.StartedAt == nil { if d := elapsed(ss.StartedAt, ss.CompletedAt); d >= 0 {
return fmtElapsed(d, false)
}
return "" return ""
} }
end := time.Now()
if ss.CompletedAt != nil {
end = *ss.CompletedAt
}
d := end.Sub(*ss.StartedAt)
if d < 0 {
d = 0
}
switch {
case d < time.Second:
return fmt.Sprintf("%dms", int(d/time.Millisecond))
case d < 10*time.Second:
return fmt.Sprintf("%.1fs", d.Seconds())
case d < time.Minute:
return fmt.Sprintf("%ds", int(d/time.Second))
case d < time.Hour:
return fmt.Sprintf("%dm", int(d/time.Minute))
default:
return fmt.Sprintf("%dh", int(d/time.Hour))
}
}
// subStepMarker mirrors stageMarker — a single-char glyph used inside the // subStepMarker mirrors stageMarker — a single-char glyph used inside the
// state badge. StageState values reused verbatim for sub-steps. // state badge. StageState values reused verbatim for sub-steps.
@@ -99,7 +77,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var3 string 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)) 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -125,7 +103,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var5 string 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)) 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -160,7 +138,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(subStepMarker(ss.State)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(subStepMarker(ss.State))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -173,7 +151,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(ss.Name) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(ss.Name)
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -186,7 +164,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(subStepDuration(ss)) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(subStepDuration(ss))
if templ_7745c5c3_Err != nil { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {