diff --git a/internal/web/templates/host_tile.templ b/internal/web/templates/host_tile.templ index 56b92d5..86c31f6 100644 --- a/internal/web/templates/host_tile.templ +++ b/internal/web/templates/host_tile.templ @@ -113,6 +113,9 @@ func tileStatus(r *model.Run) string { case model.StateWaitingReboot: return "Waiting for reboot" } + if cancelledFromHold(r) { + return "Failed (cancelled)" + } return string(r.State) } @@ -120,6 +123,9 @@ func tileMood(r *model.Run) string { if r == nil { return "idle" } + if cancelledFromHold(r) { + return "fail" + } switch r.State { case model.StateCompleted: return "pass" @@ -131,6 +137,15 @@ func tileMood(r *model.Run) string { return "active" } +// cancelledFromHold is true when a FailedHolding run was later Cancelled +// by the operator (tracked by State=Cancelled with FailedStage still +// set — mid-stage cancels don't stamp FailedStage). These deserve a +// fail-colored tile because the run did fail; the cancel was just the +// operator choosing not to recover. +func cancelledFromHold(r *model.Run) bool { + return r != nil && r.State == model.StateCancelled && r.FailedStage != "" +} + func sshInvocation(keyPath, ip string) string { if keyPath == "" { return "ssh root@" + ip + " (hold key not yet recorded)" diff --git a/internal/web/templates/host_tile_test.go b/internal/web/templates/host_tile_test.go index e331d4e..b7fd510 100644 --- a/internal/web/templates/host_tile_test.go +++ b/internal/web/templates/host_tile_test.go @@ -131,6 +131,48 @@ func TestHostTile_NoStageStrip(t *testing.T) { } } +// TestTileStatusCancelledFromHold: a run that entered FailedHolding and +// was later Cancelled by the operator should read as a failure, not a +// plain cancel. The discriminator is FailedStage being set — mid-stage +// cancels leave it empty. +func TestTileStatusCancelledFromHold(t *testing.T) { + cases := []struct { + name string + run *model.Run + wantStatus string + wantMood string + }{ + { + name: "cancelled from hold shows failed", + run: &model.Run{State: model.StateCancelled, FailedStage: "Storage"}, + wantStatus: "Failed (cancelled)", + wantMood: "fail", + }, + { + name: "mid-stage cancel stays plain cancelled", + run: &model.Run{State: model.StateCancelled}, + wantStatus: "Cancelled", + wantMood: "idle", + }, + { + name: "failed-holding itself still reads as FailedHolding", + run: &model.Run{State: model.StateFailedHolding, FailedStage: "Storage"}, + wantStatus: string(model.StateFailedHolding), + wantMood: "fail", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tileStatus(tc.run); got != tc.wantStatus { + t.Errorf("tileStatus = %q, want %q", got, tc.wantStatus) + } + if got := tileMood(tc.run); got != tc.wantMood { + t.Errorf("tileMood = %q, want %q", got, tc.wantMood) + } + }) + } +} + func TestLastSeenLabelAndClass(t *testing.T) { if got := lastSeenLabel(nil); got != "never" { t.Fatalf("label nil = %q, want never", got)