From dd59c6848660ffa31db0db8c14c2a610076a7a8b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 29 May 2026 22:16:47 +0200 Subject: [PATCH] feat(actions): bulk delete, disable and enable runners in admin UI (#37869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bulk actions on the site-admin runner list (`/-/admin/actions/runners`). Site admins can now select multiple runners and **Delete**, **Disable**, or **Enable** them in one go instead of clicking through each runner's edit page. Scope is intentionally limited to the admin page. The user, org, and repo runner pages keep their existing per-row UX — the shared list template gates the bulk UI behind an `AllowBulkActions` flag set only by the admin handler. ## Screenshots --------- Signed-off-by: Nicolas Co-authored-by: wxiaoguang --- routers/web/shared/actions/runners.go | 72 +++++++++++++++++++ routers/web/web.go | 1 + templates/shared/actions/runner_list.tmpl | 29 ++++++-- .../integration/actions_runner_modify_test.go | 53 ++++++++++++++ web_src/js/features/admin/common.ts | 36 ++++++++++ web_src/js/features/common-fetch-action.ts | 4 +- 6 files changed, 189 insertions(+), 6 deletions(-) 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}}
+ {{if .AllowBulkActions}} + + {{end}} {{range .Runners}} + {{if $.AllowBulkActions}} + + {{end}} - {{else}} - - - {{end}}
{{ctx.Locale.Tr "actions.runners.status"}} {{SortArrow "online" "offline" .SortType false}} @@ -66,6 +84,9 @@
{{.StatusLocaleName ctx.Locale}} {{if .IsDisabled}}{{ctx.Locale.Tr "actions.runners.disabled"}}{{end}} @@ -84,15 +105,13 @@ {{end}}
{{ctx.Locale.Tr "actions.runners.none"}}
+ {{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 };