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
|
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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,''),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user