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 };