2
0

feat(actions): add bulk cancel functionality for waiting jobs
All checks were successful
Build and Release / Create Release (push) Successful in 1s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 2m38s
Build and Release / Lint (push) Successful in 5m26s
Build and Release / Unit Tests (push) Successful in 5m42s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m2s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 4m34s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h4m22s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 4m55s
Build and Release / Build Binary (linux/arm64) (push) Successful in 6m55s

Add "Cancel All" button to waiting jobs view to cancel all waiting/blocked jobs at once, with optional label filtering. Improve individual job cancellation to preserve label filter in redirect URL and provide better error feedback.
This commit is contained in:
2026-01-25 17:25:08 -05:00
parent 340038cce8
commit 06ee40b413
5 changed files with 112 additions and 14 deletions

View File

@@ -518,6 +518,10 @@
"actions.runners.task_list.actions": "Actions",
"actions.runners.job_cancelled": "Job cancelled successfully",
"actions.runners.job_not_cancelable": "Job cannot be cancelled (not in waiting or blocked state)",
"actions.runners.cancel_job": "Cancel Job",
"actions.runners.cancel_all_jobs": "Cancel All",
"actions.runners.confirm_cancel_all": "Are you sure you want to cancel all waiting jobs?",
"actions.runners.jobs_cancelled": "%d job(s) cancelled successfully",
"actions.runners.waiting_duration": "Waiting",
"actions.runners.runner_match": "Runners",
"actions.runners.stuck": "Stuck",

View File

@@ -3778,6 +3778,10 @@
"actions.runners.task_list.actions": "Actions",
"actions.runners.job_cancelled": "Job cancelled successfully",
"actions.runners.job_not_cancelable": "Job cannot be cancelled (not in waiting or blocked state)",
"actions.runners.cancel_job": "Cancel Job",
"actions.runners.cancel_all_jobs": "Cancel All",
"actions.runners.confirm_cancel_all": "Are you sure you want to cancel all waiting jobs?",
"actions.runners.jobs_cancelled": "%d job(s) cancelled successfully",
"actions.runners.waiting_duration": "Waiting",
"actions.runners.runner_match": "Runners",
"actions.runners.stuck": "Stuck",

View File

@@ -936,37 +936,114 @@ func canRunnerSatisfyLabels(runner *actions_model.ActionRunner, requiredLabels [
// CancelWaitingJob cancels a waiting job from the queue
func CancelWaitingJob(ctx *context.Context) {
jobID := ctx.FormInt64("job_id")
label := ctx.FormString("label")
// Build redirect URL preserving label filter
redirectURL := setting.AppSubURL + "/-/admin/actions/runners/queue"
if label != "" {
redirectURL += "?label=" + url.QueryEscape(label)
}
if jobID == 0 {
ctx.Flash.Error("Invalid job ID")
ctx.Redirect(setting.AppSubURL + "/-/admin/actions/runners/queue")
ctx.Redirect(redirectURL)
return
}
job, err := actions_model.GetRunJobByID(ctx, jobID)
if err != nil {
ctx.Flash.Error("Job not found")
ctx.Redirect(setting.AppSubURL + "/-/admin/actions/runners/queue")
ctx.Redirect(redirectURL)
return
}
// Only cancel jobs that are waiting or blocked
if job.Status != actions_model.StatusWaiting && job.Status != actions_model.StatusBlocked {
ctx.Flash.Warning(ctx.Tr("actions.runners.job_not_cancelable"))
ctx.Redirect(setting.AppSubURL + "/-/admin/actions/runners/queue")
ctx.Redirect(redirectURL)
return
}
// Cancel the job
// Cancel the job directly - set status to cancelled
var cancelledCount int64
if err := db.WithTx(ctx, func(txCtx go_context.Context) error {
_, err := actions_model.CancelJobs(txCtx, []*actions_model.ActionRunJob{job})
return err
job.Status = actions_model.StatusCancelled
job.Stopped = timeutil.TimeStampNow()
n, err := actions_model.UpdateRunJob(txCtx, job, nil, "status", "stopped")
if err != nil {
return err
}
cancelledCount = n
return nil
}); err != nil {
log.Error("CancelWaitingJob: failed to cancel job %d: %v", jobID, err)
ctx.Flash.Error("Failed to cancel job")
ctx.Redirect(setting.AppSubURL + "/-/admin/actions/runners/queue")
ctx.Flash.Error("Failed to cancel job: " + err.Error())
ctx.Redirect(redirectURL)
return
}
if cancelledCount == 0 {
ctx.Flash.Warning("Job was not cancelled - it may have already started or been modified")
ctx.Redirect(redirectURL)
return
}
ctx.Flash.Success(ctx.Tr("actions.runners.job_cancelled"))
ctx.Redirect(setting.AppSubURL + "/-/admin/actions/runners/queue")
ctx.Redirect(redirectURL)
}
// CancelAllWaitingJobs cancels all waiting jobs, optionally filtered by label
func CancelAllWaitingJobs(ctx *context.Context) {
label := ctx.FormString("label")
// Build redirect URL preserving label filter
redirectURL := setting.AppSubURL + "/-/admin/actions/runners/queue"
if label != "" {
redirectURL += "?label=" + url.QueryEscape(label)
}
// Get all waiting jobs for the label
jobs, err := actions_model.GetWaitingJobsByLabel(ctx, label)
if err != nil {
log.Error("CancelAllWaitingJobs: failed to get jobs: %v", err)
ctx.Flash.Error("Failed to get waiting jobs")
ctx.Redirect(redirectURL)
return
}
if len(jobs) == 0 {
ctx.Flash.Info(ctx.Tr("actions.runners.no_waiting_jobs"))
ctx.Redirect(redirectURL)
return
}
// Cancel all jobs
var cancelledCount int64
if err := db.WithTx(ctx, func(txCtx go_context.Context) error {
for _, job := range jobs {
// Only cancel waiting or blocked jobs
if job.Status != actions_model.StatusWaiting && job.Status != actions_model.StatusBlocked {
continue
}
job.Status = actions_model.StatusCancelled
job.Stopped = timeutil.TimeStampNow()
n, err := actions_model.UpdateRunJob(txCtx, job, nil, "status", "stopped")
if err != nil {
return err
}
cancelledCount += n
}
return nil
}); err != nil {
log.Error("CancelAllWaitingJobs: failed to cancel jobs: %v", err)
ctx.Flash.Error("Failed to cancel jobs: " + err.Error())
ctx.Redirect(redirectURL)
return
}
ctx.Flash.Success(ctx.Tr("actions.runners.jobs_cancelled", cancelledCount))
ctx.Redirect(redirectURL)
}

View File

@@ -519,6 +519,7 @@ func registerWebRoutes(m *web.Router) {
m.Get("/queue-depth", shared_actions.QueueDepthJSON)
m.Get("/queue", shared_actions.WaitingJobs)
m.Post("/queue/cancel", shared_actions.CancelWaitingJob)
m.Post("/queue/cancel-all", shared_actions.CancelAllWaitingJobs)
m.Get("/{runnerid}/status", shared_actions.RunnerStatusJSON)
m.Post("/reset_registration_token", shared_actions.ResetRunnerRegistrationToken)
})

View File

@@ -5,9 +5,20 @@
{{if .Label}}
<span class="ui blue label">{{.Label}}</span>
{{end}}
<a href="{{.Link}}" class="ui right">
<button class="ui tiny button">{{svg "octicon-arrow-left" 14}} {{ctx.Locale.Tr "actions.runners.back_to_runners"}}</button>
</a>
<div class="ui right">
{{if .Jobs}}
<form action="{{AppSubUrl}}/-/admin/actions/runners/queue/cancel-all" method="post" class="tw-inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="label" value="{{.Label}}">
<button class="ui tiny red button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "actions.runners.confirm_cancel_all"}}');">
{{svg "octicon-x" 14}} {{ctx.Locale.Tr "actions.runners.cancel_all_jobs"}}
</button>
</form>
{{end}}
<a href="{{.Link}}">
<button class="ui tiny button">{{svg "octicon-arrow-left" 14}} {{ctx.Locale.Tr "actions.runners.back_to_runners"}}</button>
</a>
</div>
</h4>
<div class="ui attached segment">
{{if .Jobs}}
@@ -77,8 +88,9 @@
<form action="{{AppSubUrl}}/-/admin/actions/runners/queue/cancel" method="post" class="tw-inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="job_id" value="{{.ID}}">
<button class="ui tiny red button" type="submit" title="{{ctx.Locale.Tr "cancel"}}">
{{svg "octicon-x" 14}}
<input type="hidden" name="label" value="{{$.Label}}">
<button class="ui tiny red button" type="submit">
{{ctx.Locale.Tr "actions.runners.cancel_job"}}
</button>
</form>
</td>