diff --git a/agent/client.go b/agent/client.go index 9b6b7c4..e1195ad 100644 --- a/agent/client.go +++ b/agent/client.go @@ -112,11 +112,12 @@ type SensorSample struct { } type ClaimResponse struct { - OK bool `json:"ok"` - RunID int64 `json:"run_id"` - Stages []string `json:"stages"` - ExpectedDisks []ClaimExpectedDiskSpec `json:"expected_disks"` - IperfPort int `json:"iperf_port"` + OK bool `json:"ok"` + RunID int64 `json:"run_id"` + Stages []string `json:"stages"` + ExpectedDisks []ClaimExpectedDiskSpec `json:"expected_disks"` + IperfPort int `json:"iperf_port"` + NonDestructive bool `json:"non_destructive"` } type ClaimExpectedDiskSpec struct { diff --git a/agent/runner.go b/agent/runner.go index 821fac5..7b939f8 100644 --- a/agent/runner.go +++ b/agent/runner.go @@ -27,6 +27,7 @@ import ( "os/exec" "path/filepath" "sync" + "sync/atomic" "time" "vetting/agent/bootstate" @@ -35,6 +36,12 @@ import ( "vetting/internal/spec" ) +// stageCancel holds the cancel func for the in-flight stage ctx so the +// heartbeat loop can fire it when the orchestrator returns +// cmd=cancel_stage. Stored as an atomic.Value so the heartbeat goroutine +// can read without locking; writes happen only on the main loop. +var stageCancel atomic.Value // context.CancelFunc + // Run is the long-lived entry point. It blocks until ctx is cancelled // or a fatal error makes progress impossible. func Run(ctx context.Context, p *bootstate.Params) error { @@ -81,7 +88,12 @@ func Run(ctx context.Context, p *bootstate.Params) error { default: } fwd.info("stage: starting " + nextStage) - outcome := runStage(ctx, nextStage, claim, fwd, c, overrideFlags{}) + outcome := runStageCancellable(ctx, nextStage, claim, fwd, c, overrideFlags{}) + if outcome.Cancelled { + fwd.warn("stage cancelled by operator; posting result and exiting") + _, _ = postResult(ctx, c, nextStage, outcome) + return powerOffAndReturn(fwd) + } resp, err := postResult(ctx, c, nextStage, outcome) if err != nil { fwd.error("submit result for " + nextStage + ": " + err.Error()) @@ -164,6 +176,46 @@ func runStage(ctx context.Context, stage string, claim *ClaimResponse, fwd *logF type stageOutcome struct { Outcome tests.Outcome Inventory *spec.Inventory // only for Inventory stage + Cancelled bool // set when the stage was cut short by operator cancel +} + +// runStageCancellable wraps runStage in a per-stage context so the +// heartbeat loop's cancel_stage directive can kill whatever subprocess +// is currently running. If the derived context was cancelled while the +// stage executed, the outcome is rewritten as a cancellation record so +// the orchestrator has something to persist. +func runStageCancellable(parent context.Context, stage string, claim *ClaimResponse, fwd *logForwarder, c *Client, ovr overrideFlags) stageOutcome { + stageCtx, cancel := context.WithCancel(parent) + stageCancel.Store(cancel) + defer func() { + cancel() + stageCancel.Store(context.CancelFunc(nil)) + }() + out := runStage(stageCtx, stage, claim, fwd, c, ovr) + // If the parent is still live but the stage ctx was cancelled, the + // operator fired a cancel — mark the outcome so the caller can exit + // the pipeline cleanly. Plain ctx-cancel on ctx.Done (e.g. shutdown) + // is handled elsewhere by the main loop's select. + if parent.Err() == nil && stageCtx.Err() != nil { + out.Cancelled = true + out.Outcome.Passed = false + if out.Outcome.Message == "" { + out.Outcome.Message = "stage cancelled by operator" + } + out.Outcome.Summary = "cancelled" + } + return out +} + +// powerOffAndReturn shuts the host down after an operator cancel. Same +// best-effort poweroff path as the shutdown heartbeat cmd. +func powerOffAndReturn(fwd *logForwarder) error { + fwd.info("cancel: powering off host") + if err := exec.Command("systemctl", "poweroff").Run(); err != nil { + fwd.warn("systemctl poweroff failed: " + err.Error()) + _ = exec.Command("shutdown", "-h", "now").Run() + } + return nil } type overrideFlags struct { @@ -176,12 +228,13 @@ func newDeps(ctx context.Context, c *Client, fwd *logForwarder, ovr overrideFlag expected = append(expected, tests.ExpectedDisk{Serial: e.Serial, SizeGB: e.SizeGB}) } return tests.Deps{ - Info: fwd.info, - Warn: fwd.warn, - Error: fwd.error, - OverrideWipe: ovr.Wipe, - ExpectedDisks: expected, - StageTimeout: 2 * time.Minute, + Info: fwd.info, + Warn: fwd.warn, + Error: fwd.error, + OverrideWipe: ovr.Wipe, + NonDestructive: claim.NonDestructive, + ExpectedDisks: expected, + StageTimeout: 2 * time.Minute, Sensor: func(ctx context.Context, samples []tests.Sample) error { out := make([]SensorSample, 0, len(samples)) for _, s := range samples { @@ -248,7 +301,12 @@ func waitForOverride(ctx context.Context, c *Client, fwd *logForwarder, hb <-cha if len(cmd.OverrideFlags) > 0 { _ = json.Unmarshal(cmd.OverrideFlags, &ovr) } - outcome := runStage(ctx, cmd.Stage, claim, fwd, c, ovr) + outcome := runStageCancellable(ctx, cmd.Stage, claim, fwd, c, ovr) + if outcome.Cancelled { + fwd.warn("stage cancelled by operator; posting result and exiting") + _, _ = postResult(ctx, c, cmd.Stage, outcome) + return powerOffAndReturn(fwd) + } resp, err := postResult(ctx, c, cmd.Stage, outcome) if err != nil { fwd.error("override: submit result: " + err.Error()) @@ -272,7 +330,12 @@ func waitForOverride(ctx context.Context, c *Client, fwd *logForwarder, hb <-cha default: } fwd.info("stage: starting " + nextStage) - out := runStage(ctx, nextStage, claim, fwd, c, overrideFlags{}) + out := runStageCancellable(ctx, nextStage, claim, fwd, c, overrideFlags{}) + if out.Cancelled { + fwd.warn("stage cancelled by operator; posting result and exiting") + _, _ = postResult(ctx, c, nextStage, out) + return powerOffAndReturn(fwd) + } rr, err := postResult(ctx, c, nextStage, out) if err != nil { return err @@ -380,6 +443,15 @@ func heartbeatLoop(ctx context.Context, c *Client, fwd *logForwarder, out chan<- } return } + if resp.Cmd == "cancel_stage" { + fwd.warn("orchestrator said cancel_stage; cancelling in-flight stage ctx") + if v := stageCancel.Load(); v != nil { + if fn, ok := v.(context.CancelFunc); ok && fn != nil { + fn() + } + } + continue + } if resp.Cmd == "retry_stage" { select { case out <- *resp: diff --git a/agent/tests/stage.go b/agent/tests/stage.go index 03b8b71..9066357 100644 --- a/agent/tests/stage.go +++ b/agent/tests/stage.go @@ -46,6 +46,7 @@ type Deps struct { Error func(string) Sensor func(ctx context.Context, samples []Sample) error OverrideWipe bool + NonDestructive bool // skip wipe-probe + writes in Storage ExpectedDisks []ExpectedDisk // serials + sizes from host.expected_spec StageTimeout time.Duration } diff --git a/agent/tests/storage.go b/agent/tests/storage.go index dcd8015..681cb72 100644 --- a/agent/tests/storage.go +++ b/agent/tests/storage.go @@ -44,6 +44,23 @@ func Storage(ctx context.Context, d Deps) Outcome { } } + // Non-destructive runs skip wipe-probe (nothing to refuse), badblocks + // -w, and write-mode fio. Every expected disk is still asserted + // present + readable by listing /sys/block and reading SMART-accessible + // identity; the per-disk map flags the shortcut so the report is clear. + if d.NonDestructive { + perDisk := map[string]any{} + for _, t := range targets { + perDisk[t.Device] = map[string]any{"mode": "non_destructive", "serial": t.Serial} + } + d.Info(fmt.Sprintf("Storage: non-destructive — verified %d disk(s) present", len(targets))) + return Outcome{ + Passed: true, + Summary: fmt.Sprintf("non-destructive: read-only checks only (%d disks)", len(targets)), + Extras: map[string]any{"per_disk": perDisk, "non_destructive": true}, + } + } + // Wipe probe on every target. A single dirty disk halts the stage // unless the operator has set OverrideWipe via the UI. probes := map[string]wipeProbeResult{} diff --git a/internal/api/agent_handlers.go b/internal/api/agent_handlers.go index 841980d..e3a66be 100644 --- a/internal/api/agent_handlers.go +++ b/internal/api/agent_handlers.go @@ -207,11 +207,12 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) { iperfPort = 5201 } writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "run_id": runID, - "stages": store.DefaultStageOrder, - "expected_disks": expectedDisks, - "iperf_port": iperfPort, + "ok": true, + "run_id": runID, + "stages": store.DefaultStageOrder, + "expected_disks": expectedDisks, + "iperf_port": iperfPort, + "non_destructive": run.NonDestructive, }) } @@ -236,6 +237,10 @@ func (a *Agent) Heartbeat(w http.ResponseWriter, r *http.Request) { case run.State == model.StateCompleted: // Pipeline succeeded — agent should power the host down. cmd = "shutdown" + case run.State == model.StateCancelled: + // Operator clicked Cancel — agent cancels the active stage ctx, + // posts a cancelled outcome, and powers off. + cmd = "cancel_stage" case run.State == model.StateFailedHolding || run.State == model.StateReleased: cmd = "abort" case run.FailedStage == "Storage" && overrideWipeSet(run.OverrideFlagsJSON): diff --git a/internal/api/agent_handlers_test.go b/internal/api/agent_handlers_test.go index ed15faf..67ee41f 100644 --- a/internal/api/agent_handlers_test.go +++ b/internal/api/agent_handlers_test.go @@ -46,7 +46,7 @@ func setupAgent(t *testing.T) (*api.Agent, int64, string) { if err != nil { t.Fatalf("issue token: %v", err) } - runID, err := runs.Create(context.Background(), hostID, hash) + runID, err := runs.Create(context.Background(), hostID, hash, false) if err != nil { t.Fatalf("create run: %v", err) } diff --git a/internal/api/heartbeat_test.go b/internal/api/heartbeat_test.go index 8e4a9e9..a64a0c8 100644 --- a/internal/api/heartbeat_test.go +++ b/internal/api/heartbeat_test.go @@ -137,7 +137,7 @@ func TestUIHeartbeat_QueuedDispatches(t *testing.T) { if err != nil { t.Fatalf("create host: %v", err) } - runID, err := runs.Create(ctx, hostID, "deadbeef") + runID, err := runs.Create(ctx, hostID, "deadbeef", false) if err != nil { t.Fatalf("create run: %v", err) } @@ -184,7 +184,7 @@ func TestUIHeartbeat_WaitingRebootRetries(t *testing.T) { if err != nil { t.Fatalf("create host: %v", err) } - runID, err := runs.Create(ctx, hostID, "deadbeef") + runID, err := runs.Create(ctx, hostID, "deadbeef", false) if err != nil { t.Fatalf("create run: %v", err) } @@ -241,7 +241,7 @@ func TestUIHeartbeat_CompletedRunIsIdle(t *testing.T) { if err != nil { t.Fatalf("create host: %v", err) } - runID, err := runs.Create(ctx, hostID, "deadbeef") + runID, err := runs.Create(ctx, hostID, "deadbeef", false) if err != nil { t.Fatalf("create run: %v", err) } diff --git a/internal/api/host_detail_test.go b/internal/api/host_detail_test.go index d22b4d7..e5b7daf 100644 --- a/internal/api/host_detail_test.go +++ b/internal/api/host_detail_test.go @@ -67,7 +67,7 @@ func TestHostDetail_OK(t *testing.T) { if err != nil { t.Fatalf("create host: %v", err) } - runID, err := runs.Create(ctx, id, "deadbeef") + runID, err := runs.Create(ctx, id, "deadbeef", false) if err != nil { t.Fatalf("create run: %v", err) } @@ -137,7 +137,7 @@ func TestHostDetail_LogTabsRendered(t *testing.T) { if err != nil { t.Fatalf("create host: %v", err) } - runID, err := runs.Create(ctx, id, "cafef00d") + runID, err := runs.Create(ctx, id, "cafef00d", false) if err != nil { t.Fatalf("create run: %v", err) } diff --git a/internal/api/smoke_test.go b/internal/api/smoke_test.go index 9fb64a0..cba8ea4 100644 --- a/internal/api/smoke_test.go +++ b/internal/api/smoke_test.go @@ -118,7 +118,7 @@ func fullAgent(t *testing.T) (*api.Agent, int64, string) { if err != nil { t.Fatalf("issue token: %v", err) } - runID, err := runStore.Create(context.Background(), hostID, hash) + runID, err := runStore.Create(context.Background(), hostID, hash, false) if err != nil { t.Fatalf("create run: %v", err) } diff --git a/internal/api/ui_handlers.go b/internal/api/ui_handlers.go index 3b3cc91..df43c33 100644 --- a/internal/api/ui_handlers.go +++ b/internal/api/ui_handlers.go @@ -191,21 +191,20 @@ func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) { // Guard: refuse to start a second run while one is still active. if latest, err := u.Runs.LatestForHost(r.Context(), hostID); err == nil && latest != nil { - switch latest.State { - case model.StateCompleted, model.StateReleased, model.StateFailed, model.StateFailedHolding: - // ok to start fresh - default: + if !latest.State.IsTerminal() { http.Error(w, "host already has an active run", http.StatusConflict) return } } + nonDestructive := r.PostFormValue("non_destructive") == "1" + _, hash, err := orchestrator.IssueRunToken() if err != nil { http.Error(w, "token: "+err.Error(), http.StatusInternalServerError) return } - runID, err := u.Runs.Create(r.Context(), hostID, hash) + runID, err := u.Runs.Create(r.Context(), hostID, hash, nonDestructive) if err != nil { http.Error(w, "create run: "+err.Error(), http.StatusInternalServerError) return @@ -471,6 +470,35 @@ func (u *UI) OverrideWipeStorage(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) } +// CancelRun halts an in-flight run. Transitions the run to +// StateCancelled; the next agent heartbeat receives cmd=cancel_stage +// which cancels the stage ctx on the agent side. Destructive stages +// mid-run can leave the host in an intermediate state — the confirm +// dialog in the UI warns the operator. +func (u *UI) CancelRun(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + hostID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "bad host id", http.StatusBadRequest) + return + } + latest, err := u.Runs.LatestForHost(r.Context(), hostID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if latest == nil || latest.State.IsTerminal() { + http.Error(w, "no active run to cancel", http.StatusConflict) + return + } + if _, err := u.Runner.Transition(r.Context(), latest.ID, orchestrator.TriggerOperatorCancelled); err != nil { + http.Error(w, "cancel: "+err.Error(), http.StatusInternalServerError) + return + } + log.Printf("ui: cancelled run %d for host %d", latest.ID, hostID) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/db/migrations/0003_add_runs_non_destructive.sql b/internal/db/migrations/0003_add_runs_non_destructive.sql new file mode 100644 index 0000000..7dc997c --- /dev/null +++ b/internal/db/migrations/0003_add_runs_non_destructive.sql @@ -0,0 +1 @@ +ALTER TABLE runs ADD COLUMN non_destructive INTEGER NOT NULL DEFAULT 0; diff --git a/internal/httpserver/router.go b/internal/httpserver/router.go index 37a5b1e..b8c9d08 100644 --- a/internal/httpserver/router.go +++ b/internal/httpserver/router.go @@ -73,6 +73,7 @@ func NewRouter(d Deps) http.Handler { r.Get("/hosts/{id}", d.UI.HostDetail) r.Post("/hosts/{id}/delete", d.UI.DeleteHost) r.Post("/hosts/{id}/start", d.UI.StartRun) + r.Post("/hosts/{id}/cancel", d.UI.CancelRun) r.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage) r.Get("/reports/{runID}", d.UI.Report) r.Get("/register/quick.sh", d.UI.QuickRegisterScript) diff --git a/internal/model/model.go b/internal/model/model.go index 9f8528e..cb1dbe6 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -38,8 +38,17 @@ const ( StateFailed RunState = "Failed" StateFailedHolding RunState = "FailedHolding" StateReleased RunState = "Released" + StateCancelled RunState = "Cancelled" ) +func (s RunState) IsTerminal() bool { + switch s { + case StateCompleted, StateFailed, StateFailedHolding, StateReleased, StateCancelled: + return true + } + return false +} + type Run struct { ID int64 HostID int64 @@ -53,6 +62,7 @@ type Run struct { ReportPath string HoldIP string OverrideFlagsJSON string + NonDestructive bool } type StageState string diff --git a/internal/orchestrator/dispatcher_test.go b/internal/orchestrator/dispatcher_test.go index 699c697..8fb20b7 100644 --- a/internal/orchestrator/dispatcher_test.go +++ b/internal/orchestrator/dispatcher_test.go @@ -103,7 +103,7 @@ func TestDispatcher_TransitionsToWaitingRebootNoWoL(t *testing.T) { d, _, runs, hostID, cleanup := setupPickNext(t) defer cleanup() ctx := context.Background() - runID, err := runs.Create(ctx, hostID, "deadbeef") + runID, err := runs.Create(ctx, hostID, "deadbeef", false) if err != nil { t.Fatalf("create run: %v", err) } @@ -141,7 +141,7 @@ func TestDispatcher_FailsStaleHeartbeat(t *testing.T) { if err := hosts.UpdateLastSeen(ctx, "aa:bb:cc:dd:ee:50", time.Now().UTC().Add(-5*time.Minute)); err != nil { t.Fatalf("stamp stale: %v", err) } - runID, err := runs.Create(ctx, hostID, "deadbeef") + runID, err := runs.Create(ctx, hostID, "deadbeef", false) if err != nil { t.Fatalf("create run: %v", err) } @@ -181,7 +181,7 @@ func TestDispatcher_FailsNeverSeenHost(t *testing.T) { if err != nil { t.Fatalf("create host: %v", err) } - runID, err := runs.Create(ctx, neverID, "deadbeef") + runID, err := runs.Create(ctx, neverID, "deadbeef", false) if err != nil { t.Fatalf("create run: %v", err) } diff --git a/internal/orchestrator/runner_test.go b/internal/orchestrator/runner_test.go index eab109a..3e28f2b 100644 --- a/internal/orchestrator/runner_test.go +++ b/internal/orchestrator/runner_test.go @@ -66,7 +66,7 @@ func TestPublishesTileAndPipelineOnTransition(t *testing.T) { if err != nil { t.Fatalf("create host: %v", err) } - runID, err := runs.Create(ctx, hostID, "deadbeef") + runID, err := runs.Create(ctx, hostID, "deadbeef", false) if err != nil { t.Fatalf("create run: %v", err) } @@ -132,7 +132,7 @@ func TestCompleteStagePublishesPipeline(t *testing.T) { if err != nil { t.Fatalf("create host: %v", err) } - runID, err := runs.Create(ctx, hostID, "deadbeef") + runID, err := runs.Create(ctx, hostID, "deadbeef", false) if err != nil { t.Fatalf("create run: %v", err) } diff --git a/internal/orchestrator/statemachine.go b/internal/orchestrator/statemachine.go index b40eaf9..233b5c8 100644 --- a/internal/orchestrator/statemachine.go +++ b/internal/orchestrator/statemachine.go @@ -18,8 +18,9 @@ const ( TriggerStageFailed Trigger = "StageFailed" // a stage reported failure TriggerStageCompleted Trigger = "StageCompleted" // a stage reported success → advance TriggerAllStagesPassed Trigger = "AllStagesPassed" // final stage passed - TriggerOperatorReleased Trigger = "OperatorReleased" // user clicked Release on a held run - TriggerOperatorOverride Trigger = "OperatorOverride" // user overrode a held stage; re-enter it + TriggerOperatorReleased Trigger = "OperatorReleased" // user clicked Release on a held run + TriggerOperatorOverride Trigger = "OperatorOverride" // user overrode a held stage; re-enter it + TriggerOperatorCancelled Trigger = "OperatorCancelled" // user clicked Cancel on an active run ) // stageStates maps the canonical stage name (from DefaultStageOrder) @@ -63,9 +64,10 @@ var table = map[Trigger]transition{ TriggerRebootCommanded: {from: []model.RunState{model.StateQueued}, to: model.StateWaitingReboot}, TriggerPXEObserved: {from: []model.RunState{model.StateWaitingReboot, model.StateWaitingWoL, model.StateBooting}, to: model.StateBooting}, TriggerAgentClaimed: {from: []model.RunState{model.StateBooting, model.StateWaitingReboot, model.StateWaitingWoL}, to: model.StateInventoryCheck}, - TriggerStageFailed: {from: allActiveStates(), to: model.StateFailedHolding}, - TriggerAllStagesPassed: {from: []model.RunState{model.StateReporting}, to: model.StateCompleted}, - TriggerOperatorReleased: {from: []model.RunState{model.StateFailedHolding}, to: model.StateReleased}, + TriggerStageFailed: {from: allActiveStates(), to: model.StateFailedHolding}, + TriggerAllStagesPassed: {from: []model.RunState{model.StateReporting}, to: model.StateCompleted}, + TriggerOperatorReleased: {from: []model.RunState{model.StateFailedHolding}, to: model.StateReleased}, + TriggerOperatorCancelled: {from: allActiveStates(), to: model.StateCancelled}, } // Next computes the target state for a trigger against the current state. diff --git a/internal/store/runs.go b/internal/store/runs.go index 3897e83..63f844e 100644 --- a/internal/store/runs.go +++ b/internal/store/runs.go @@ -14,12 +14,16 @@ type Runs struct { DB *sql.DB } -func (r *Runs) Create(ctx context.Context, hostID int64, tokenHash string) (int64, error) { +func (r *Runs) Create(ctx context.Context, hostID int64, tokenHash string, nonDestructive bool) (int64, error) { now := time.Now().UTC() + nd := 0 + if nonDestructive { + nd = 1 + } res, err := r.DB.ExecContext(ctx, ` - INSERT INTO runs(host_id, state, agent_token_hash, next_boot_target, started_at) - VALUES(?,?,?,?,?) - `, hostID, string(model.StateQueued), tokenHash, "linux", now) + INSERT INTO runs(host_id, state, agent_token_hash, next_boot_target, started_at, non_destructive) + VALUES(?,?,?,?,?,?) + `, hostID, string(model.StateQueued), tokenHash, "linux", now, nd) if err != nil { return 0, fmt.Errorf("insert run: %w", err) } @@ -103,14 +107,14 @@ func (r *Runs) Get(ctx context.Context, id int64) (*model.Run, error) { SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''), COALESCE(next_boot_target,''), agent_token_hash, started_at, completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''), - COALESCE(override_flags_json,'') + COALESCE(override_flags_json,''), COALESCE(non_destructive,0) FROM runs WHERE id = ? `, id) var run model.Run var completedAt sql.NullTime err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage, &run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt, - &completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON) + &completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -129,7 +133,7 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''), COALESCE(next_boot_target,''), agent_token_hash, started_at, completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''), - COALESCE(override_flags_json,'') + COALESCE(override_flags_json,''), COALESCE(non_destructive,0) FROM runs WHERE host_id = ? ORDER BY id DESC LIMIT 1 `, hostID) @@ -137,7 +141,7 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err var completedAt sql.NullTime err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage, &run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt, - &completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON) + &completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -156,9 +160,9 @@ func (r *Runs) Active(ctx context.Context) ([]model.Run, error) { SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''), COALESCE(next_boot_target,''), agent_token_hash, started_at, completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''), - COALESCE(override_flags_json,'') + COALESCE(override_flags_json,''), COALESCE(non_destructive,0) FROM runs - WHERE state NOT IN ('Completed','Released') + WHERE state NOT IN ('Completed','Released','Cancelled') ORDER BY id `) if err != nil { @@ -171,7 +175,7 @@ func (r *Runs) Active(ctx context.Context) ([]model.Run, error) { var completedAt sql.NullTime if err := rows.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage, &run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt, - &completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON); err != nil { + &completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive); err != nil { return nil, err } if completedAt.Valid { @@ -189,7 +193,7 @@ func (r *Runs) Active(ctx context.Context) ([]model.Run, error) { func (r *Runs) CompletedOlderThan(ctx context.Context, cutoff time.Time) ([]int64, error) { rows, err := r.DB.QueryContext(ctx, ` SELECT id FROM runs - WHERE state IN ('Completed','Released','FailedHolding') + WHERE state IN ('Completed','Released','FailedHolding','Cancelled') AND COALESCE(completed_at, started_at) < ? ORDER BY id `, cutoff) @@ -215,17 +219,17 @@ func (r *Runs) FindActiveByMAC(ctx context.Context, mac string) (*model.Run, err SELECT r.id, r.host_id, r.state, COALESCE(r.result,''), COALESCE(r.failed_stage,''), COALESCE(r.next_boot_target,''), r.agent_token_hash, r.started_at, r.completed_at, COALESCE(r.report_path,''), COALESCE(r.hold_ip,''), - COALESCE(r.override_flags_json,'') + COALESCE(r.override_flags_json,''), COALESCE(r.non_destructive,0) FROM runs r JOIN hosts h ON h.id = r.host_id - WHERE h.mac = ? AND r.state NOT IN ('Completed','Released') + WHERE h.mac = ? AND r.state NOT IN ('Completed','Released','Cancelled') ORDER BY r.id DESC LIMIT 1 `, mac) var run model.Run var completedAt sql.NullTime err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage, &run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt, - &completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON) + &completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive) if errors.Is(err, sql.ErrNoRows) { return nil, nil } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index d012d33..3d580be 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -36,7 +36,7 @@ func seedRun(t *testing.T, runs *store.Runs) (int64, int64) { if err != nil { t.Fatalf("create host: %v", err) } - runID, err := runs.Create(context.Background(), hostID, "deadbeef") + runID, err := runs.Create(context.Background(), hostID, "deadbeef", false) if err != nil { t.Fatalf("create run: %v", err) } diff --git a/internal/web/templates/host_detail.templ b/internal/web/templates/host_detail.templ index f6f68d0..c30743b 100644 --- a/internal/web/templates/host_detail.templ +++ b/internal/web/templates/host_detail.templ @@ -89,7 +89,11 @@ templ HostDetail(d HostDetailData) {

Actions

if canStart(d.Tile) { -
+ +
} else if canStartIfOnline(d.Tile.Latest) { @@ -97,6 +101,11 @@ templ HostDetail(d HostDetailData) { } else { } + if canCancel(d.Tile.Latest) { +
+ +
+ } if canOverrideWipe(d.Tile.Latest) {
diff --git a/internal/web/templates/host_detail_templ.go b/internal/web/templates/host_detail_templ.go index a278a6c..a3dad87 100644 --- a/internal/web/templates/host_detail_templ.go +++ b/internal/web/templates/host_detail_templ.go @@ -319,7 +319,7 @@ func HostDetail(d HostDetailData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"inline\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"inline detail-start-form\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -334,153 +334,172 @@ func HostDetail(d HostDetailData) templ.Component { return templ_7745c5c3_Err } } - if canOverrideWipe(d.Tile.Latest) { + if canCancel(d.Tile.Latest) { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"inline\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if hasReport(d.Tile.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "View report") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"inline\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
View report") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" class=\"inline\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(d.SpecDiffs) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

Spec diffs (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">

Spec diffs (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs))) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 117, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 126, Col: 68} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ")

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ")

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, diff := range d.SpecDiffs { - var templ_7745c5c3_Var23 = []any{"diff-row", "diff-" + diff.Severity} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...) + var templ_7745c5c3_Var24 = []any{"diff-row", "diff-" + diff.Severity} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
  • expected: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected) + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 122, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 130, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
    actual: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
    expected: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual) + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 123, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 131, Col: 67} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
    actual: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 132, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -491,43 +510,43 @@ func HostDetail(d HostDetailData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "

Host details

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

Host details

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if d.Tile.Host.Notes != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "

Notes

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

Notes

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes) + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 141, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 150, Col: 29} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

Expected spec

")
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "

Expected spec

")
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var29 string
-			templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
+			var templ_7745c5c3_Var30 string
+			templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
 			if templ_7745c5c3_Err != nil {
-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 146, Col: 66}
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 155, Col: 66}
 			}
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -573,157 +592,157 @@ func LogTabs(runID int64, replay string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var30 := templ.GetChildren(ctx) - if templ_7745c5c3_Var30 == nil { - templ_7745c5c3_Var30 = templ.NopComponent + templ_7745c5c3_Var31 := templ.GetChildren(ctx) + if templ_7745c5c3_Var31 == nil { + templ_7745c5c3_Var31 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "

Log

Log

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" class=\"log-tab-input log-tab-all\" checked> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, s := range store.DefaultStageOrder { - var templ_7745c5c3_Var34 = []any{"log-tab-input", "log-tab-" + s} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...) + var templ_7745c5c3_Var35 = []any{"log-tab-input", "log-tab-" + s} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var35...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, s := range store.DefaultStageOrder { - var templ_7745c5c3_Var42 = []any{"log-pane", "log-pane-" + s} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...) + var templ_7745c5c3_Var43 = []any{"log-pane", "log-pane-" + s} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var43...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" sse-swap=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", runID, s)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 201, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" hx-swap=\"beforeend show:bottom\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/web/templates/host_tile.templ b/internal/web/templates/host_tile.templ index b23df4b..0fcf381 100644 --- a/internal/web/templates/host_tile.templ +++ b/internal/web/templates/host_tile.templ @@ -29,11 +29,19 @@ templ HostTile(t TileData) {
if canStart(t) { -
+ +
} else if canStartIfOnline(t.Latest) { + } else if canCancel(t.Latest) { +
+ +
} else if hasReport(t.Latest) { View report } @@ -76,11 +84,15 @@ func canStartIfOnline(r *model.Run) bool { if r == nil { return true } - switch r.State { - case model.StateCompleted, model.StateReleased, model.StateFailed, model.StateFailedHolding: - return true - } - return false + return r.State.IsTerminal() +} + +// canCancel is true for any non-terminal run — the Cancel button shows +// whenever the pipeline is live (Queued through the stage states). The +// handler refuses the action once the run enters a terminal state, so +// the render decision just has to mirror that. +func canCancel(r *model.Run) bool { + return r != nil && !r.State.IsTerminal() } func tileStatus(r *model.Run) string { @@ -103,7 +115,7 @@ func tileMood(r *model.Run) string { return "pass" case model.StateFailed, model.StateFailedHolding: return "fail" - case model.StateReleased: + case model.StateReleased, model.StateCancelled: return "idle" } return "active" diff --git a/internal/web/templates/host_tile_templ.go b/internal/web/templates/host_tile_templ.go index 95ed279..ed5a7cf 100644 --- a/internal/web/templates/host_tile_templ.go +++ b/internal/web/templates/host_tile_templ.go @@ -190,7 +190,7 @@ func HostTile(t TileData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline tile-start-form\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -199,26 +199,44 @@ func HostTile(t TileData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else if hasReport(t.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "View report") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if hasReport(t.Latest) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "View report") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -261,11 +279,15 @@ func canStartIfOnline(r *model.Run) bool { if r == nil { return true } - switch r.State { - case model.StateCompleted, model.StateReleased, model.StateFailed, model.StateFailedHolding: - return true - } - return false + return r.State.IsTerminal() +} + +// canCancel is true for any non-terminal run — the Cancel button shows +// whenever the pipeline is live (Queued through the stage states). The +// handler refuses the action once the run enters a terminal state, so +// the render decision just has to mirror that. +func canCancel(r *model.Run) bool { + return r != nil && !r.State.IsTerminal() } func tileStatus(r *model.Run) string { @@ -288,7 +310,7 @@ func tileMood(r *model.Run) string { return "pass" case model.StateFailed, model.StateFailedHolding: return "fail" - case model.StateReleased: + case model.StateReleased, model.StateCancelled: return "idle" } return "active"