diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go
index 4b16237f605..f174bfb1cd4 100644
--- a/routers/web/shared/actions/runners.go
+++ b/routers/web/shared/actions/runners.go
@@ -4,6 +4,7 @@
package actions
import (
+ stdctx "context"
"errors"
"fmt"
"net/http"
@@ -158,6 +159,7 @@ func Runners(ctx *context.Context) {
ctx.Data["RunnerOwnerID"] = opts.OwnerID
ctx.Data["RunnerRepoID"] = opts.RepoID
ctx.Data["SortType"] = opts.Sort
+ ctx.Data["AllowBulkActions"] = rCtx.IsAdmin
pager := context.NewPagination(count, opts.PageSize, opts.Page, 5)
@@ -362,6 +364,76 @@ func RunnerUpdatePost(ctx *context.Context) {
ctx.JSONRedirect("")
}
+// RunnerBulkActionPost performs a bulk action (delete/disable/enable) on multiple runners.
+// Admin-only: route must be mounted inside the admin runners group; defense-in-depth check below.
+func RunnerBulkActionPost(ctx *context.Context) {
+ rCtx, err := getRunnersCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getRunnersCtx", err)
+ return
+ }
+
+ var runnerIDs []int64
+ if rCtx.IsAdmin {
+ // ATTENTION: it completely depends on the assumption that the doer is "site admin"
+ // So it doesn't do extra permission check to the runner IDs
+ // In the future, if you need to support such operation on non-admin pages, be careful!
+ runnerIDs = ctx.FormStringInt64s("ids")
+ } else {
+ ctx.HTTPError(http.StatusForbidden, "bulk actions are admin-only")
+ return
+ }
+
+ action := ctx.FormString("action")
+ var successKey, failedKey string
+ switch action {
+ case "delete":
+ successKey, failedKey = "actions.runners.delete_runner_success", "actions.runners.delete_runner_failed"
+ case "disable":
+ successKey, failedKey = "actions.runners.disable_runner_success", "actions.runners.disable_runner_failed"
+ case "enable":
+ successKey, failedKey = "actions.runners.enable_runner_success", "actions.runners.enable_runner_failed"
+ default:
+ ctx.HTTPError(http.StatusBadRequest, "invalid action")
+ return
+ }
+
+ runners, err := db.Find[actions_model.ActionRunner](ctx, &actions_model.FindRunnerOptions{IDs: runnerIDs})
+ if err != nil {
+ ctx.ServerError("FindRunners", err)
+ return
+ }
+
+ err = db.WithTx(ctx, func(txCtx stdctx.Context) error {
+ for _, r := range runners {
+ switch action {
+ case "delete":
+ if err := actions_model.DeleteRunner(txCtx, r.ID); err != nil {
+ return err
+ }
+ case "disable":
+ if err := actions_model.SetRunnerDisabled(txCtx, r, true); err != nil {
+ return err
+ }
+ case "enable":
+ if err := actions_model.SetRunnerDisabled(txCtx, r, false); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ log.Warn("RunnerBulkActionPost.%s failed: %v, url: %s", action, err, ctx.Req.URL)
+ ctx.Flash.Error(ctx.Tr(failedKey))
+ ctx.JSONRedirect(rCtx.RedirectLink)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr(successKey))
+ ctx.JSONRedirect(rCtx.RedirectLink)
+}
+
func findActionsRunner(ctx *context.Context, rCtx *runnersCtx) *actions_model.ActionRunner {
runnerID := ctx.PathParamInt64("runnerid")
opts := &actions_model.FindRunnerOptions{
diff --git a/routers/web/web.go b/routers/web/web.go
index d02f7442649..49a83c1fae5 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -863,6 +863,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/actions", func() {
m.Get("", misc.LocationRedirect("./actions/runners"))
addSettingsRunnersRoutes()
+ m.Post("/runners/bulk", shared_actions.RunnerBulkActionPost)
addSettingsVariablesRoutes()
})
}, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled))
diff --git a/templates/shared/actions/runner_list.tmpl b/templates/shared/actions/runner_list.tmpl
index 90eb4591d7a..1b287cdabc2 100644
--- a/templates/shared/actions/runner_list.tmpl
+++ b/templates/shared/actions/runner_list.tmpl
@@ -40,10 +40,28 @@
{{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.runner_kind")}}
+ {{if .AllowBulkActions}}
+
+
+
+ {{end}}
+ {{if .Runners}}
+ {{else}}
+ {{ctx.Locale.Tr "actions.runners.none"}}
+ {{end}}
{{template "base/paginate" .}}
-
diff --git a/tests/integration/actions_runner_modify_test.go b/tests/integration/actions_runner_modify_test.go
index 4fffbddeb26..f9614d69873 100644
--- a/tests/integration/actions_runner_modify_test.go
+++ b/tests/integration/actions_runner_modify_test.go
@@ -6,6 +6,7 @@ package integration
import (
"fmt"
"net/http"
+ "strings"
"testing"
actions_model "gitea.dev/models/actions"
@@ -13,6 +14,7 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
+ "gitea.dev/modules/base"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
@@ -163,4 +165,55 @@ func TestActionsRunnerModify(t *testing.T) {
assertSuccess(t, sessionAdmin, adminWebURL, globalRunner.ID)
})
})
+
+ t.Run("BulkAction", func(t *testing.T) {
+ // Previous subtests deleted all runners; create a fresh set scoped to this subtest.
+ require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-1", TokenHash: "e", UUID: "e"}))
+ require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-2", TokenHash: "f", UUID: "f"}))
+ require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-3", TokenHash: "g", UUID: "g"}))
+ r1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-1"})
+ r2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-2"})
+ r3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-3"})
+ allIDs := []int64{r1.ID, r2.ID, r3.ID}
+ bulkURL := adminWebURL + "/bulk"
+ doBulk := func(t *testing.T, sess *TestSession, action string, ids []int64, expectedStatus int) {
+ req := NewRequestWithValues(t, "POST", bulkURL, map[string]string{
+ "action": action,
+ "ids": strings.Join(base.Int64sToStrings(ids), ","),
+ })
+ sess.MakeRequest(t, req, expectedStatus)
+ }
+
+ t.Run("NonAdminForbidden", func(t *testing.T) {
+ doBulk(t, sessionUser2, "disable", allIDs, http.StatusForbidden)
+ for _, id := range allIDs {
+ v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
+ assert.False(t, v.IsDisabled, "runner %d should not have been disabled", id)
+ }
+ })
+
+ t.Run("InvalidAction", func(t *testing.T) {
+ doBulk(t, sessionAdmin, "evict", allIDs, http.StatusBadRequest)
+ })
+
+ t.Run("DisableEnable", func(t *testing.T) {
+ doBulk(t, sessionAdmin, "disable", allIDs, http.StatusOK)
+ for _, id := range allIDs {
+ v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
+ assert.True(t, v.IsDisabled, "runner %d should be disabled", id)
+ }
+ doBulk(t, sessionAdmin, "enable", allIDs, http.StatusOK)
+ for _, id := range allIDs {
+ v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
+ assert.False(t, v.IsDisabled, "runner %d should be enabled", id)
+ }
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ doBulk(t, sessionAdmin, "delete", allIDs, http.StatusOK)
+ for _, id := range allIDs {
+ unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: id})
+ }
+ })
+ })
}
diff --git a/web_src/js/features/admin/common.ts b/web_src/js/features/admin/common.ts
index 5753aad2b2c..6125578a7a0 100644
--- a/web_src/js/features/admin/common.ts
+++ b/web_src/js/features/admin/common.ts
@@ -3,6 +3,7 @@ import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {pathEscape} from '../../utils/url.ts';
+import {registerGlobalInitFunc} from '../../modules/observer.ts';
const {appSubUrl} = window.config;
@@ -23,6 +24,41 @@ export function initAdminCommon(): void {
initAdminUser();
initAdminAuthentication();
initAdminNotice();
+ registerGlobalInitFunc('initRunnerBulkToolbar', initAdminRunnerBulk);
+}
+
+function initAdminRunnerBulk(toolbar: HTMLElement) {
+ const actionButtons = toolbar.querySelectorAll('.runner-bulk-action');
+ const formRunnerIds = toolbar.querySelector('form input[name="ids"]')!;
+ const rowCheckboxes = document.querySelectorAll('.runner-bulk-select');
+ const selectAll = document.querySelector('.runner-bulk-select-all');
+ if (!selectAll) return;
+
+ const refresh = () => {
+ const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
+ toggleElem(toolbar, checked.length > 0);
+ for (const btn of actionButtons) {
+ btn.querySelector('.runner-bulk-count')!.textContent = `(${checked.length})`;
+ }
+ selectAll.checked = checked.length > 0 && checked.length === rowCheckboxes.length;
+ selectAll.indeterminate = checked.length > 0 && checked.length < rowCheckboxes.length;
+ };
+
+ selectAll.addEventListener('change', () => {
+ for (const cb of rowCheckboxes) cb.checked = selectAll.checked;
+ refresh();
+ });
+ for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
+ refresh();
+
+ const collectSelectedIds = () => {
+ const ids = [];
+ for (const cb of rowCheckboxes) {
+ if (cb.checked) ids.push(cb.getAttribute('data-runner-id')!);
+ }
+ return ids.join(',');
+ };
+ formRunnerIds.value = collectSelectedIds();
}
function initAdminUser() {
diff --git a/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts
index 0f65f780acb..3481d240f27 100644
--- a/web_src/js/features/common-fetch-action.ts
+++ b/web_src/js/features/common-fetch-action.ts
@@ -16,6 +16,7 @@ type FetchActionOpts = {
url: string;
headers?: HeadersInit;
body?: FormData;
+ formSubmitter?: HTMLElement | null;
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
@@ -122,7 +123,7 @@ function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
const attrIsLoading = 'data-fetch-is-loading';
if (el.getAttribute(attrIsLoading)) return;
- if (!await confirmFetchAction(el)) return;
+ if (!await confirmFetchAction(opt.formSubmitter ?? el)) return;
el.setAttribute(attrIsLoading, 'true');
toggleLoadingIndicator(el, opt, true);
@@ -181,6 +182,7 @@ function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFet
method: formMethodUpper,
url: reqUrl,
body: reqBody,
+ formSubmitter: opts.formSubmitter,
loadingIndicator: '$this', // for form submit, by default, the loading indicator is the whole form
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
};