chore: cleanup sprint — dead CSS, dedup helpers, handler refactor
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:
+4
-1
@@ -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,
|
||||
|
||||
@@ -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,28 +487,55 @@ 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.
|
||||
if a.resultStageMismatch(w, r, runID, run, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
thresholdDetail := a.resultCheckThresholds(r.Context(), runID, &body)
|
||||
|
||||
stageState := model.StagePassed
|
||||
if !body.Passed {
|
||||
stageState = model.StageFailed
|
||||
}
|
||||
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)
|
||||
if expectedStage != "" && body.Stage != expectedStage {
|
||||
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)
|
||||
@@ -526,68 +555,49 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
writeJSONErr(w, http.StatusConflict, "stage mismatch: got "+body.Stage+", expected "+expectedStage)
|
||||
return true
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
stageState := model.StagePassed
|
||||
// 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 {
|
||||
stageState = model.StageFailed
|
||||
return ""
|
||||
}
|
||||
summaryJSON := ""
|
||||
if len(body.Summary) > 0 {
|
||||
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)
|
||||
return
|
||||
}
|
||||
if thresholdDetail != "" && body.Message == "" {
|
||||
body.Message = thresholdDetail
|
||||
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
|
||||
}
|
||||
|
||||
// Agent-authored sub-steps: persist in slice order (ordinal = index)
|
||||
// and fan out a per-row SSE event each so the detail pane shows them
|
||||
// without a reload. Best-effort — a persistence error is logged but
|
||||
// doesn't fail the whole /result.
|
||||
a.persistSubSteps(r.Context(), runID, body.Stage, body.SubSteps)
|
||||
|
||||
// Inventory-specific: persist artifact + compute spec diff.
|
||||
// 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 {
|
||||
// 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)
|
||||
http.Error(w, "transition", http.StatusConflict)
|
||||
writeJSONErr(w, http.StatusConflict, "transition")
|
||||
return
|
||||
}
|
||||
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),
|
||||
})
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -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,12 +78,17 @@ 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 {
|
||||
for {
|
||||
select {
|
||||
case <-h.done:
|
||||
return
|
||||
case <-t.C:
|
||||
h.Publish(Event{
|
||||
Name: "heartbeat",
|
||||
Payload: fmt.Sprintf(`<span data-heartbeat="%d"></span>`, time.Now().Unix()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServeSSE writes server-sent events for a single subscriber for the
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,''),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
if d := elapsed(s.StartedAt, s.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
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,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 {
|
||||
if d := elapsed(s.StartedAt, s.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
if d := elapsed(n.StartedAt, n.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
if d := elapsed(n.StartedAt, n.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
if d := elapsed(ss.StartedAt, ss.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
if d := elapsed(ss.StartedAt, ss.CompletedAt); d >= 0 {
|
||||
return fmtElapsed(d, false)
|
||||
}
|
||||
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
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user