runs: add non-destructive flag + operator Cancel button
CI / Lint + build + test (push) Successful in 2m5s
Release / release (push) Successful in 3m5s

Non-destructive pre-declares "don't touch the disks" on Start: the
Storage stage skips wipe-probe, badblocks -w, and write-mode fio,
and reports a read-only summary. Runs a new non_destructive column;
threaded through Claim → agent tests.Deps → Storage stage.

Cancel halts an in-flight run. The orchestrator transitions to a
new StateCancelled via TriggerOperatorCancelled (valid from any
active state); the agent's next heartbeat returns cmd=cancel_stage,
which fires a stored CancelFunc on the per-stage context. Stage
subprocesses spawned with exec.CommandContext die with the context,
the agent posts a cancelled outcome, then powers the host off.

Destructive stages mid-run may leave the host in an intermediate
state — the UI confirm dialog warns the operator; recovery is
manual for now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 13:01:42 -04:00
parent 2c440fce8a
commit 4524ab8dc0
22 changed files with 434 additions and 230 deletions
+1
View File
@@ -117,6 +117,7 @@ type ClaimResponse struct {
Stages []string `json:"stages"` Stages []string `json:"stages"`
ExpectedDisks []ClaimExpectedDiskSpec `json:"expected_disks"` ExpectedDisks []ClaimExpectedDiskSpec `json:"expected_disks"`
IperfPort int `json:"iperf_port"` IperfPort int `json:"iperf_port"`
NonDestructive bool `json:"non_destructive"`
} }
type ClaimExpectedDiskSpec struct { type ClaimExpectedDiskSpec struct {
+75 -3
View File
@@ -27,6 +27,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sync" "sync"
"sync/atomic"
"time" "time"
"vetting/agent/bootstate" "vetting/agent/bootstate"
@@ -35,6 +36,12 @@ import (
"vetting/internal/spec" "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 // Run is the long-lived entry point. It blocks until ctx is cancelled
// or a fatal error makes progress impossible. // or a fatal error makes progress impossible.
func Run(ctx context.Context, p *bootstate.Params) error { func Run(ctx context.Context, p *bootstate.Params) error {
@@ -81,7 +88,12 @@ func Run(ctx context.Context, p *bootstate.Params) error {
default: default:
} }
fwd.info("stage: starting " + nextStage) 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) resp, err := postResult(ctx, c, nextStage, outcome)
if err != nil { if err != nil {
fwd.error("submit result for " + nextStage + ": " + err.Error()) 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 { type stageOutcome struct {
Outcome tests.Outcome Outcome tests.Outcome
Inventory *spec.Inventory // only for Inventory stage 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 { type overrideFlags struct {
@@ -180,6 +232,7 @@ func newDeps(ctx context.Context, c *Client, fwd *logForwarder, ovr overrideFlag
Warn: fwd.warn, Warn: fwd.warn,
Error: fwd.error, Error: fwd.error,
OverrideWipe: ovr.Wipe, OverrideWipe: ovr.Wipe,
NonDestructive: claim.NonDestructive,
ExpectedDisks: expected, ExpectedDisks: expected,
StageTimeout: 2 * time.Minute, StageTimeout: 2 * time.Minute,
Sensor: func(ctx context.Context, samples []tests.Sample) error { Sensor: func(ctx context.Context, samples []tests.Sample) error {
@@ -248,7 +301,12 @@ func waitForOverride(ctx context.Context, c *Client, fwd *logForwarder, hb <-cha
if len(cmd.OverrideFlags) > 0 { if len(cmd.OverrideFlags) > 0 {
_ = json.Unmarshal(cmd.OverrideFlags, &ovr) _ = 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) resp, err := postResult(ctx, c, cmd.Stage, outcome)
if err != nil { if err != nil {
fwd.error("override: submit result: " + err.Error()) fwd.error("override: submit result: " + err.Error())
@@ -272,7 +330,12 @@ func waitForOverride(ctx context.Context, c *Client, fwd *logForwarder, hb <-cha
default: default:
} }
fwd.info("stage: starting " + nextStage) 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) rr, err := postResult(ctx, c, nextStage, out)
if err != nil { if err != nil {
return err return err
@@ -380,6 +443,15 @@ func heartbeatLoop(ctx context.Context, c *Client, fwd *logForwarder, out chan<-
} }
return 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" { if resp.Cmd == "retry_stage" {
select { select {
case out <- *resp: case out <- *resp:
+1
View File
@@ -46,6 +46,7 @@ type Deps struct {
Error func(string) Error func(string)
Sensor func(ctx context.Context, samples []Sample) error Sensor func(ctx context.Context, samples []Sample) error
OverrideWipe bool OverrideWipe bool
NonDestructive bool // skip wipe-probe + writes in Storage
ExpectedDisks []ExpectedDisk // serials + sizes from host.expected_spec ExpectedDisks []ExpectedDisk // serials + sizes from host.expected_spec
StageTimeout time.Duration StageTimeout time.Duration
} }
+17
View File
@@ -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 // Wipe probe on every target. A single dirty disk halts the stage
// unless the operator has set OverrideWipe via the UI. // unless the operator has set OverrideWipe via the UI.
probes := map[string]wipeProbeResult{} probes := map[string]wipeProbeResult{}
+5
View File
@@ -212,6 +212,7 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
"stages": store.DefaultStageOrder, "stages": store.DefaultStageOrder,
"expected_disks": expectedDisks, "expected_disks": expectedDisks,
"iperf_port": iperfPort, "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: case run.State == model.StateCompleted:
// Pipeline succeeded — agent should power the host down. // Pipeline succeeded — agent should power the host down.
cmd = "shutdown" 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: case run.State == model.StateFailedHolding || run.State == model.StateReleased:
cmd = "abort" cmd = "abort"
case run.FailedStage == "Storage" && overrideWipeSet(run.OverrideFlagsJSON): case run.FailedStage == "Storage" && overrideWipeSet(run.OverrideFlagsJSON):
+1 -1
View File
@@ -46,7 +46,7 @@ func setupAgent(t *testing.T) (*api.Agent, int64, string) {
if err != nil { if err != nil {
t.Fatalf("issue token: %v", err) 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 { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
+3 -3
View File
@@ -137,7 +137,7 @@ func TestUIHeartbeat_QueuedDispatches(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("create host: %v", err) t.Fatalf("create host: %v", err)
} }
runID, err := runs.Create(ctx, hostID, "deadbeef") runID, err := runs.Create(ctx, hostID, "deadbeef", false)
if err != nil { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
@@ -184,7 +184,7 @@ func TestUIHeartbeat_WaitingRebootRetries(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("create host: %v", err) t.Fatalf("create host: %v", err)
} }
runID, err := runs.Create(ctx, hostID, "deadbeef") runID, err := runs.Create(ctx, hostID, "deadbeef", false)
if err != nil { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
@@ -241,7 +241,7 @@ func TestUIHeartbeat_CompletedRunIsIdle(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("create host: %v", err) t.Fatalf("create host: %v", err)
} }
runID, err := runs.Create(ctx, hostID, "deadbeef") runID, err := runs.Create(ctx, hostID, "deadbeef", false)
if err != nil { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
+2 -2
View File
@@ -67,7 +67,7 @@ func TestHostDetail_OK(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("create host: %v", err) t.Fatalf("create host: %v", err)
} }
runID, err := runs.Create(ctx, id, "deadbeef") runID, err := runs.Create(ctx, id, "deadbeef", false)
if err != nil { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
@@ -137,7 +137,7 @@ func TestHostDetail_LogTabsRendered(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("create host: %v", err) t.Fatalf("create host: %v", err)
} }
runID, err := runs.Create(ctx, id, "cafef00d") runID, err := runs.Create(ctx, id, "cafef00d", false)
if err != nil { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
+1 -1
View File
@@ -118,7 +118,7 @@ func fullAgent(t *testing.T) (*api.Agent, int64, string) {
if err != nil { if err != nil {
t.Fatalf("issue token: %v", err) 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 { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
+33 -5
View File
@@ -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. // 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 { if latest, err := u.Runs.LatestForHost(r.Context(), hostID); err == nil && latest != nil {
switch latest.State { if !latest.State.IsTerminal() {
case model.StateCompleted, model.StateReleased, model.StateFailed, model.StateFailedHolding:
// ok to start fresh
default:
http.Error(w, "host already has an active run", http.StatusConflict) http.Error(w, "host already has an active run", http.StatusConflict)
return return
} }
} }
nonDestructive := r.PostFormValue("non_destructive") == "1"
_, hash, err := orchestrator.IssueRunToken() _, hash, err := orchestrator.IssueRunToken()
if err != nil { if err != nil {
http.Error(w, "token: "+err.Error(), http.StatusInternalServerError) http.Error(w, "token: "+err.Error(), http.StatusInternalServerError)
return return
} }
runID, err := u.Runs.Create(r.Context(), hostID, hash) runID, err := u.Runs.Create(r.Context(), hostID, hash, nonDestructive)
if err != nil { if err != nil {
http.Error(w, "create run: "+err.Error(), http.StatusInternalServerError) http.Error(w, "create run: "+err.Error(), http.StatusInternalServerError)
return return
@@ -471,6 +470,35 @@ func (u *UI) OverrideWipeStorage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) 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) { func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64) id, err := strconv.ParseInt(idStr, 10, 64)
@@ -0,0 +1 @@
ALTER TABLE runs ADD COLUMN non_destructive INTEGER NOT NULL DEFAULT 0;
+1
View File
@@ -73,6 +73,7 @@ func NewRouter(d Deps) http.Handler {
r.Get("/hosts/{id}", d.UI.HostDetail) r.Get("/hosts/{id}", d.UI.HostDetail)
r.Post("/hosts/{id}/delete", d.UI.DeleteHost) r.Post("/hosts/{id}/delete", d.UI.DeleteHost)
r.Post("/hosts/{id}/start", d.UI.StartRun) 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.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage)
r.Get("/reports/{runID}", d.UI.Report) r.Get("/reports/{runID}", d.UI.Report)
r.Get("/register/quick.sh", d.UI.QuickRegisterScript) r.Get("/register/quick.sh", d.UI.QuickRegisterScript)
+10
View File
@@ -38,8 +38,17 @@ const (
StateFailed RunState = "Failed" StateFailed RunState = "Failed"
StateFailedHolding RunState = "FailedHolding" StateFailedHolding RunState = "FailedHolding"
StateReleased RunState = "Released" 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 { type Run struct {
ID int64 ID int64
HostID int64 HostID int64
@@ -53,6 +62,7 @@ type Run struct {
ReportPath string ReportPath string
HoldIP string HoldIP string
OverrideFlagsJSON string OverrideFlagsJSON string
NonDestructive bool
} }
type StageState string type StageState string
+3 -3
View File
@@ -103,7 +103,7 @@ func TestDispatcher_TransitionsToWaitingRebootNoWoL(t *testing.T) {
d, _, runs, hostID, cleanup := setupPickNext(t) d, _, runs, hostID, cleanup := setupPickNext(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
runID, err := runs.Create(ctx, hostID, "deadbeef") runID, err := runs.Create(ctx, hostID, "deadbeef", false)
if err != nil { if err != nil {
t.Fatalf("create run: %v", err) 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 { 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) t.Fatalf("stamp stale: %v", err)
} }
runID, err := runs.Create(ctx, hostID, "deadbeef") runID, err := runs.Create(ctx, hostID, "deadbeef", false)
if err != nil { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
@@ -181,7 +181,7 @@ func TestDispatcher_FailsNeverSeenHost(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("create host: %v", err) t.Fatalf("create host: %v", err)
} }
runID, err := runs.Create(ctx, neverID, "deadbeef") runID, err := runs.Create(ctx, neverID, "deadbeef", false)
if err != nil { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
+2 -2
View File
@@ -66,7 +66,7 @@ func TestPublishesTileAndPipelineOnTransition(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("create host: %v", err) t.Fatalf("create host: %v", err)
} }
runID, err := runs.Create(ctx, hostID, "deadbeef") runID, err := runs.Create(ctx, hostID, "deadbeef", false)
if err != nil { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
@@ -132,7 +132,7 @@ func TestCompleteStagePublishesPipeline(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("create host: %v", err) t.Fatalf("create host: %v", err)
} }
runID, err := runs.Create(ctx, hostID, "deadbeef") runID, err := runs.Create(ctx, hostID, "deadbeef", false)
if err != nil { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
+2
View File
@@ -20,6 +20,7 @@ const (
TriggerAllStagesPassed Trigger = "AllStagesPassed" // final stage passed TriggerAllStagesPassed Trigger = "AllStagesPassed" // final stage passed
TriggerOperatorReleased Trigger = "OperatorReleased" // user clicked Release on a held run TriggerOperatorReleased Trigger = "OperatorReleased" // user clicked Release on a held run
TriggerOperatorOverride Trigger = "OperatorOverride" // user overrode a held stage; re-enter it 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) // stageStates maps the canonical stage name (from DefaultStageOrder)
@@ -66,6 +67,7 @@ var table = map[Trigger]transition{
TriggerStageFailed: {from: allActiveStates(), to: model.StateFailedHolding}, TriggerStageFailed: {from: allActiveStates(), to: model.StateFailedHolding},
TriggerAllStagesPassed: {from: []model.RunState{model.StateReporting}, to: model.StateCompleted}, TriggerAllStagesPassed: {from: []model.RunState{model.StateReporting}, to: model.StateCompleted},
TriggerOperatorReleased: {from: []model.RunState{model.StateFailedHolding}, to: model.StateReleased}, 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. // Next computes the target state for a trigger against the current state.
+19 -15
View File
@@ -14,12 +14,16 @@ type Runs struct {
DB *sql.DB 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() now := time.Now().UTC()
nd := 0
if nonDestructive {
nd = 1
}
res, err := r.DB.ExecContext(ctx, ` res, err := r.DB.ExecContext(ctx, `
INSERT INTO runs(host_id, state, agent_token_hash, next_boot_target, started_at) INSERT INTO runs(host_id, state, agent_token_hash, next_boot_target, started_at, non_destructive)
VALUES(?,?,?,?,?) VALUES(?,?,?,?,?,?)
`, hostID, string(model.StateQueued), tokenHash, "linux", now) `, hostID, string(model.StateQueued), tokenHash, "linux", now, nd)
if err != nil { if err != nil {
return 0, fmt.Errorf("insert run: %w", err) 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,''), SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
COALESCE(next_boot_target,''), agent_token_hash, started_at, COALESCE(next_boot_target,''), agent_token_hash, started_at,
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''), completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
COALESCE(override_flags_json,'') COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
FROM runs WHERE id = ? FROM runs WHERE id = ?
`, id) `, id)
var run model.Run var run model.Run
var completedAt sql.NullTime var completedAt sql.NullTime
err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage, err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt, &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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound 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,''), SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
COALESCE(next_boot_target,''), agent_token_hash, started_at, COALESCE(next_boot_target,''), agent_token_hash, started_at,
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''), 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 = ? FROM runs WHERE host_id = ?
ORDER BY id DESC LIMIT 1 ORDER BY id DESC LIMIT 1
`, hostID) `, hostID)
@@ -137,7 +141,7 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
var completedAt sql.NullTime var completedAt sql.NullTime
err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage, err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt, &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) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil 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,''), SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
COALESCE(next_boot_target,''), agent_token_hash, started_at, COALESCE(next_boot_target,''), agent_token_hash, started_at,
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''), completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
COALESCE(override_flags_json,'') COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
FROM runs FROM runs
WHERE state NOT IN ('Completed','Released') WHERE state NOT IN ('Completed','Released','Cancelled')
ORDER BY id ORDER BY id
`) `)
if err != nil { if err != nil {
@@ -171,7 +175,7 @@ func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
var completedAt sql.NullTime var completedAt sql.NullTime
if err := rows.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage, if err := rows.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt, &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 return nil, err
} }
if completedAt.Valid { 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) { func (r *Runs) CompletedOlderThan(ctx context.Context, cutoff time.Time) ([]int64, error) {
rows, err := r.DB.QueryContext(ctx, ` rows, err := r.DB.QueryContext(ctx, `
SELECT id FROM runs SELECT id FROM runs
WHERE state IN ('Completed','Released','FailedHolding') WHERE state IN ('Completed','Released','FailedHolding','Cancelled')
AND COALESCE(completed_at, started_at) < ? AND COALESCE(completed_at, started_at) < ?
ORDER BY id ORDER BY id
`, cutoff) `, 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,''), 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, COALESCE(r.next_boot_target,''), r.agent_token_hash, r.started_at,
r.completed_at, COALESCE(r.report_path,''), COALESCE(r.hold_ip,''), 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 FROM runs r
JOIN hosts h ON h.id = r.host_id 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 ORDER BY r.id DESC LIMIT 1
`, mac) `, mac)
var run model.Run var run model.Run
var completedAt sql.NullTime var completedAt sql.NullTime
err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage, err := row.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt, &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) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
+1 -1
View File
@@ -36,7 +36,7 @@ func seedRun(t *testing.T, runs *store.Runs) (int64, int64) {
if err != nil { if err != nil {
t.Fatalf("create host: %v", err) 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 { if err != nil {
t.Fatalf("create run: %v", err) t.Fatalf("create run: %v", err)
} }
+10 -1
View File
@@ -89,7 +89,11 @@ templ HostDetail(d HostDetailData) {
<h2>Actions</h2> <h2>Actions</h2>
<div class="detail-actions-row"> <div class="detail-actions-row">
if canStart(d.Tile) { if canStart(d.Tile) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline"> <form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline detail-start-form">
<label class="detail-nd-toggle">
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive (skip wipe-probe + disk writes)
</label>
<button type="submit">Start vetting</button> <button type="submit">Start vetting</button>
</form> </form>
} else if canStartIfOnline(d.Tile.Latest) { } else if canStartIfOnline(d.Tile.Latest) {
@@ -97,6 +101,11 @@ templ HostDetail(d HostDetailData) {
} else { } else {
<button type="button" disabled>Run in flight</button> <button type="button" disabled>Run in flight</button>
} }
if canCancel(d.Tile.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
<button type="submit" class="danger">Cancel run</button>
</form>
}
if canOverrideWipe(d.Tile.Latest) { if canOverrideWipe(d.Tile.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline"> <form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline">
<button type="submit" class="danger">Override wipe-probe</button> <button type="submit" class="danger">Override wipe-probe</button>
+180 -161
View File
@@ -319,7 +319,7 @@ func HostDetail(d HostDetailData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"inline\"><button type=\"submit\">Start vetting</button></form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"inline detail-start-form\"><label class=\"detail-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive (skip wipe-probe + disk writes)</label> <button type=\"submit\">Start vetting</button></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -334,153 +334,172 @@ func HostDetail(d HostDetailData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if canOverrideWipe(d.Tile.Latest) { if canCancel(d.Tile.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<form method=\"post\" action=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 templ.SafeURL var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID))) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 101, Col: 104} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 105, Col: 97}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Override wipe-probe</button></form>") 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.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if hasReport(d.Tile.Latest) { if canOverrideWipe(d.Tile.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a class=\"button-like\" href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 templ.SafeURL var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID))) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 106, Col: 95} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 110, Col: 104}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" target=\"_blank\" rel=\"noopener\">View report</a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Override wipe-probe</button></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<form method=\"post\" action=\"") if hasReport(d.Tile.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<a class=\"button-like\" href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var21 templ.SafeURL var templ_7745c5c3_Var21 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID))) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 108, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 115, Col: 95}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Delete host</button></form></div></section>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(d.SpecDiffs) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<section class=\"detail-section detail-diffs\"><details")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasCriticalDiff(d.SpecDiffs) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " open")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "><summary><h2>Spec diffs (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 templ.SafeURL
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs))) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)))
if templ_7745c5c3_Err != nil { 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: 117, Col: 96}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ")</h2></summary><ul class=\"diff-list\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Delete host</button></form></div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(d.SpecDiffs) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<section class=\"detail-section detail-diffs\"><details")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasCriticalDiff(d.SpecDiffs) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " open")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "><summary><h2>Spec diffs (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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: 126, Col: 68}
}
_, 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, 41, ")</h2></summary><ul class=\"diff-list\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, diff := range d.SpecDiffs { for _, diff := range d.SpecDiffs {
var templ_7745c5c3_Var23 = []any{"diff-row", "diff-" + diff.Severity} var templ_7745c5c3_Var24 = []any{"diff-row", "diff-" + diff.Severity}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var24...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<li class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var23).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"><div class=\"diff-field\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var24).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 121, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div><div class=\"diff-expected\">expected: <code>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\"><div class=\"diff-field\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var26 string 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</code></div><div class=\"diff-actual\">actual: <code>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div><div class=\"diff-expected\">expected: <code>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var27 string 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</code></div></li>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</code></div><div class=\"diff-actual\">actual: <code>")
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, "</code></div></li>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</ul></details></section>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</ul></details></section>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -491,43 +510,43 @@ func HostDetail(d HostDetailData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<section class=\"detail-section detail-host-meta\"><details><summary><h2>Host details</h2></summary> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<section class=\"detail-section detail-host-meta\"><details><summary><h2>Host details</h2></summary> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if d.Tile.Host.Notes != "" { if d.Tile.Host.Notes != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div class=\"detail-notes\"><h3>Notes</h3><p>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"detail-notes\"><h3>Notes</h3><p>")
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)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 141, Col: 29}
}
_, 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, 48, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"detail-spec\"><h3>Expected spec</h3><pre class=\"detail-spec-yaml\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var29 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes)
if templ_7745c5c3_Err != nil { 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: 150, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</pre></div></details></section></section>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"detail-spec\"><h3>Expected spec</h3><pre class=\"detail-spec-yaml\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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: 155, Col: 66}
}
_, 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, 52, "</pre></div></details></section></section>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -573,157 +592,157 @@ func LogTabs(runID int64, replay string) templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var30 := templ.GetChildren(ctx) templ_7745c5c3_Var31 := templ.GetChildren(ctx)
if templ_7745c5c3_Var30 == nil { if templ_7745c5c3_Var31 == nil {
templ_7745c5c3_Var30 = templ.NopComponent templ_7745c5c3_Var31 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<section class=\"detail-section log-section\"><h2>Log</h2><div class=\"log-tabs\"><input type=\"radio\" name=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<section class=\"detail-section log-section\"><h2>Log</h2><div class=\"log-tabs\"><input type=\"radio\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 174, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var32 string var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-all", runID)) templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d", runID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 174, Col: 106} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 183, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" class=\"log-tab-input log-tab-all\" checked> <label for=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var33 string var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-all", runID)) templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-all", runID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 175, Col: 52} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 183, Col: 106}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" class=\"log-tab-label\">All</label> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" class=\"log-tab-input log-tab-all\" checked> <label for=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-all", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 184, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" class=\"log-tab-label\">All</label> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, s := range store.DefaultStageOrder { for _, s := range store.DefaultStageOrder {
var templ_7745c5c3_Var34 = []any{"log-tab-input", "log-tab-" + s} var templ_7745c5c3_Var35 = []any{"log-tab-input", "log-tab-" + s}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var35...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<input type=\"radio\" name=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<input type=\"radio\" name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 177, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var36 string var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-%s", runID, s)) templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d", runID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 177, Col: 109} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 186, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var37 string var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var34).String()) templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-%s", runID, s))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 186, Col: 109}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"> <label for=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var38 string var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-%s", runID, s)) templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var35).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 178, Col: 55} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" class=\"log-tab-label\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\"> <label for=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var39 string var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s) templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-%s", runID, s))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 178, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 187, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</label>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" class=\"log-tab-label\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"log-pane log-pane-all\" id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var40 string var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", runID)) templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(s)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 182, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 187, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" sse-swap=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</label>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<div class=\"log-pane log-pane-all\" id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var41 string var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", runID)) templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", runID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 183, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 191, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-swap=\"beforeend show:bottom\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", runID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 192, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" hx-swap=\"beforeend show:bottom\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -731,61 +750,61 @@ func LogTabs(runID int64, replay string) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, s := range store.DefaultStageOrder { for _, s := range store.DefaultStageOrder {
var templ_7745c5c3_Var42 = []any{"log-pane", "log-pane-" + s} var templ_7745c5c3_Var43 = []any{"log-pane", "log-pane-" + s}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...) templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var43...)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var42).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var44 string var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", runID, s)) templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var43).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 191, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" sse-swap=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var45 string var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", runID, s)) templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", runID, s))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 192, Col: 50} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 200, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" hx-swap=\"beforeend show:bottom\"></div>") 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\"></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div></section>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</div></section>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+18 -6
View File
@@ -29,11 +29,19 @@ templ HostTile(t TileData) {
</header> </header>
<div class="tile-primary-action"> <div class="tile-primary-action">
if canStart(t) { if canStart(t) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline"> <form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline tile-start-form">
<label class="tile-nd-toggle">
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive
</label>
<button type="submit">Start vetting</button> <button type="submit">Start vetting</button>
</form> </form>
} else if canStartIfOnline(t.Latest) { } else if canStartIfOnline(t.Latest) {
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button> <button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
} else if canCancel(t.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)) } class="inline tile-cancel-form" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
<button type="submit" class="danger">Cancel run</button>
</form>
} else if hasReport(t.Latest) { } else if hasReport(t.Latest) {
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)) } target="_blank" rel="noopener">View report</a> <a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)) } target="_blank" rel="noopener">View report</a>
} }
@@ -76,11 +84,15 @@ func canStartIfOnline(r *model.Run) bool {
if r == nil { if r == nil {
return true return true
} }
switch r.State { return r.State.IsTerminal()
case model.StateCompleted, model.StateReleased, model.StateFailed, model.StateFailedHolding:
return true
} }
return false
// 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 { func tileStatus(r *model.Run) string {
@@ -103,7 +115,7 @@ func tileMood(r *model.Run) string {
return "pass" return "pass"
case model.StateFailed, model.StateFailedHolding: case model.StateFailed, model.StateFailedHolding:
return "fail" return "fail"
case model.StateReleased: case model.StateReleased, model.StateCancelled:
return "idle" return "idle"
} }
return "active" return "active"
+34 -12
View File
@@ -190,7 +190,7 @@ func HostTile(t TileData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline\"><button type=\"submit\">Start vetting</button></form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline tile-start-form\"><label class=\"tile-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive</label> <button type=\"submit\">Start vetting</button></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -199,26 +199,44 @@ func HostTile(t TileData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else if hasReport(t.Latest) { } else if canCancel(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a class=\"button-like\" href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 templ.SafeURL var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID))) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 38, Col: 88} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 42, Col: 90}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" target=\"_blank\" rel=\"noopener\">View report</a>") 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.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if hasReport(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a class=\"button-like\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 46, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div></article>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></article>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -261,11 +279,15 @@ func canStartIfOnline(r *model.Run) bool {
if r == nil { if r == nil {
return true return true
} }
switch r.State { return r.State.IsTerminal()
case model.StateCompleted, model.StateReleased, model.StateFailed, model.StateFailedHolding:
return true
} }
return false
// 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 { func tileStatus(r *model.Run) string {
@@ -288,7 +310,7 @@ func tileMood(r *model.Run) string {
return "pass" return "pass"
case model.StateFailed, model.StateFailedHolding: case model.StateFailed, model.StateFailedHolding:
return "fail" return "fail"
case model.StateReleased: case model.StateReleased, model.StateCancelled:
return "idle" return "idle"
} }
return "active" return "active"