diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 6a655ebb98a..8cdcddd43df 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -427,6 +427,7 @@ func prepareMigrationTasks() []*migration { newMigration(351, "Track transfer recipient access grants", v28.AddRecipientAccessGrantedToRepoTransfer), newMigration(352, "Add token columns to deploy_key", v28.AddTokenToDeployKey), newMigration(353, "Add audit event table", v28.AddAuditEventTable), + newMigration(354, "Add Actions job queue indexes", v28.AddActionQueueIndexes), } return preparedMigrations } diff --git a/modelmigration/v28/v354.go b/modelmigration/v28/v354.go new file mode 100644 index 00000000000..da919068955 --- /dev/null +++ b/modelmigration/v28/v354.go @@ -0,0 +1,34 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v28 + +import ( + "context" + + "gitea.dev/modelmigration/base" + "gitea.dev/modules/timeutil" + + "xorm.io/xorm" +) + +// AddActionQueueIndexes indexes the runner pickup query and repository-scoped status lookups. +func AddActionQueueIndexes(_ context.Context, x base.EngineMigration) error { + type ActionRunJob struct { + RepoID int64 `xorm:"index(repo_status)"` + TaskID int64 `xorm:"index(pickup)"` + Status int `xorm:"index(pickup) index(repo_status)"` + Updated timeutil.TimeStamp `xorm:"index(pickup)"` + } + + type ActionRun struct { + RepoID int64 `xorm:"index(repo_status)"` + Status int `xorm:"index(repo_status)"` + } + + _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + IgnoreConstrains: true, + }, new(ActionRunJob), new(ActionRun)) + return err +} diff --git a/modelmigration/v28/v354_test.go b/modelmigration/v28/v354_test.go new file mode 100644 index 00000000000..bab21888d57 --- /dev/null +++ b/modelmigration/v28/v354_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v28 + +import ( + "testing" + + "gitea.dev/modelmigration/migrationtest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddActionQueueIndexes(t *testing.T) { + type ActionRunJob struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 + TaskID int64 + Status int + Updated int64 `xorm:"updated"` + } + type ActionRun struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 + Status int + } + + x, deferable := migrationtest.PrepareTestEnv(t, 0, new(ActionRunJob), new(ActionRun)) + defer deferable() + if x == nil || t.Failed() { + return + } + + require.NoError(t, AddActionQueueIndexes(t.Context(), x)) + + tables := migrationtest.LoadTableSchemasMap(t, x) + indexCols := func(table string) [][]string { + schema, ok := tables[table] + require.True(t, ok) + var cols [][]string + for _, idx := range schema.Indexes { + cols = append(cols, idx.Cols) + } + return cols + } + + assert.Contains(t, indexCols("action_run_job"), []string{"task_id", "status", "updated"}) + assert.Contains(t, indexCols("action_run_job"), []string{"repo_id", "status"}) + assert.Contains(t, indexCols("action_run"), []string{"repo_id", "status"}) +} diff --git a/models/actions/run.go b/models/actions/run.go index d4cc9665d43..733d21b0cfa 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -30,7 +30,7 @@ import ( type ActionRun struct { ID int64 Title string - RepoID int64 `xorm:"unique(repo_index)"` + RepoID int64 `xorm:"unique(repo_index) index(repo_status)"` Repo *repo_model.Repository `xorm:"-"` OwnerID int64 `xorm:"index"` WorkflowID string `xorm:"index"` // the name of workflow file @@ -47,7 +47,7 @@ type ActionRun struct { Event webhook_module.HookEventType // the webhook event that causes the workflow to run EventPayload string `xorm:"LONGTEXT"` TriggerEvent string // the trigger event defined in the `on` configuration of the triggered workflow - Status Status `xorm:"index"` + Status Status `xorm:"index index(repo_status)"` Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed RawConcurrency string // raw concurrency diff --git a/models/actions/run_job.go b/models/actions/run_job.go index c32ce538f8a..13a77ea954f 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -33,7 +33,7 @@ type ActionRunJob struct { ID int64 RunID int64 `xorm:"index"` Run *ActionRun `xorm:"-"` - RepoID int64 `xorm:"index(repo_concurrency)"` + RepoID int64 `xorm:"index(repo_concurrency) index(repo_status)"` Repo *repo_model.Repository `xorm:"-"` OwnerID int64 `xorm:"index"` CommitSHA string `xorm:"index"` @@ -52,10 +52,10 @@ type ActionRunJob struct { Needs []string `xorm:"JSON TEXT"` RunsOn []string `xorm:"JSON TEXT"` - TaskID int64 // the task created by this job in its own attempt + TaskID int64 `xorm:"index(pickup)"` // the task created by this job in its own attempt SourceTaskID int64 `xorm:"NOT NULL DEFAULT 0"` // SourceTaskID points to a historical task when this job reuses an earlier attempt's result. - Status Status `xorm:"index"` + Status Status `xorm:"index index(pickup) index(repo_status)"` RawConcurrency string // raw concurrency from job YAML's "concurrency" section @@ -131,7 +131,7 @@ type ActionRunJob struct { Started timeutil.TimeStamp Stopped timeutil.TimeStamp Created timeutil.TimeStamp `xorm:"created"` - Updated timeutil.TimeStamp `xorm:"updated index"` + Updated timeutil.TimeStamp `xorm:"updated index index(pickup)"` } // ActionRunAttemptJobIDIndex backs the run-wide AttemptJobID counter, keyed by ActionRun.ID. diff --git a/models/actions/run_job_list_test.go b/models/actions/run_job_list_test.go index 271031b4cdd..053114c3107 100644 --- a/models/actions/run_job_list_test.go +++ b/models/actions/run_job_list_test.go @@ -6,7 +6,12 @@ package actions import ( "testing" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/timeutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestActionJobList_SortMatrixGroupsByName(t *testing.T) { @@ -59,3 +64,49 @@ func TestActionJobList_SortMatrixGroupsByName(t *testing.T) { assert.Equal(t, []string{"only"}, names(jobs)) }) } + +func TestFindJobQueueJobs(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + const repoID int64 = 987654 + + insert := func(status Status, taskID int64, reusable bool, updated timeutil.TimeStamp) int64 { + job := &ActionRunJob{RepoID: repoID, Status: status, TaskID: taskID, IsReusableCaller: reusable, Updated: updated} + _, err := db.GetEngine(ctx).NoAutoTime().Insert(job) + require.NoError(t, err) + return job.ID + } + queuedA := insert(StatusWaiting, 0, false, 200) + queuedB := insert(StatusWaiting, 0, false, 300) + queuedC := insert(StatusWaiting, 0, false, 100) + running := insert(StatusRunning, 998, false, 0) + cancelling := insert(StatusCancelling, 999, false, 0) + insert(StatusWaiting, 999, false, 0) + insert(StatusWaiting, 0, true, 0) + + find := func(status Status, page, pageSize int) (ids []int64, total int64) { + jobs, total, err := FindJobQueueJobs(ctx, JobQueueOptions{RepoID: repoID, Status: status}, page, pageSize) + require.NoError(t, err) + for _, job := range jobs { + ids = append(ids, job.ID) + } + return ids, total + } + + ids, total := find(StatusUnknown, 1, 10) + assert.EqualValues(t, 5, total) + assert.Equal(t, []int64{running, cancelling, queuedC, queuedA, queuedB}, ids) + + ids, _ = find(StatusWaiting, 1, 10) + assert.Equal(t, []int64{queuedC, queuedA, queuedB}, ids) + + ids, _ = find(StatusRunning, 1, 10) + assert.Equal(t, []int64{running, cancelling}, ids) + + ids, _ = find(StatusUnknown, 99, 3) + assert.Equal(t, []int64{queuedA, queuedB}, ids) + + repoIDs, err := JobQueueFilterRepoIDs(ctx, 1000) + require.NoError(t, err) + assert.Contains(t, repoIDs, repoID) +} diff --git a/models/actions/run_job_queue.go b/models/actions/run_job_queue.go new file mode 100644 index 00000000000..86a5276b799 --- /dev/null +++ b/models/actions/run_job_queue.go @@ -0,0 +1,81 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + + "gitea.dev/models/db" + + "xorm.io/builder" + "xorm.io/xorm" +) + +// JobQueueOptions scopes the job queue to a repo, an owner or, when both are zero, the instance. +type JobQueueOptions struct { + RepoID int64 + OwnerID int64 + Status Status +} + +func (opts JobQueueOptions) session(ctx context.Context) *xorm.Session { + // a reusable-workflow caller only tracks its children, it never occupies a runner itself + sess := db.GetEngine(ctx).Table("action_run_job").Where(builder.Eq{"`action_run_job`.is_reusable_caller": false}) + if opts.RepoID > 0 { + sess = sess.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID}) + } + if opts.OwnerID > 0 { + sess = sess.Join("INNER", "repository", "repository.id = `action_run_job`.repo_id AND repository.owner_id = ?", opts.OwnerID) + } + return sess.And(opts.statusCond()) +} + +var ( + // keep in sync with CreateTaskForRunner + queuedJobsCond = builder.Eq{"`action_run_job`.status": StatusWaiting, "`action_run_job`.task_id": 0} + // a cancelling job still occupies its runner + runningJobsCond = builder.In("`action_run_job`.status", StatusRunning, StatusCancelling) +) + +func (opts JobQueueOptions) statusCond() builder.Cond { + switch opts.Status { + case StatusRunning: + return runningJobsCond + case StatusWaiting: + return queuedJobsCond + default: + return builder.Or(runningJobsCond, queuedJobsCond) + } +} + +// active jobs first by start time, then queued jobs in pickup order +var jobQueueOrderBy = fmt.Sprintf( + "CASE WHEN `action_run_job`.status IN (%d, %d) THEN 0 ELSE 1 END ASC, CASE WHEN `action_run_job`.status IN (%d, %d) THEN `action_run_job`.started ELSE `action_run_job`.updated END ASC, `action_run_job`.id ASC", + StatusRunning, StatusCancelling, StatusRunning, StatusCancelling) + +// FindJobQueueJobs returns one page of the job queue and its total count. +func FindJobQueueJobs(ctx context.Context, opts JobQueueOptions, page, pageSize int) ([]*ActionRunJob, int64, error) { + total, err := opts.session(ctx).Count(new(ActionRunJob)) + if err != nil || total == 0 { + return nil, total, err + } + + // Auto-refresh can shrink the queue under a user still on page 2; show the last page instead of empty. + page = min(page, int((total+int64(pageSize)-1)/int64(pageSize))) + + jobs := make([]*ActionRunJob, 0, pageSize) + return jobs, total, opts.session(ctx). + Cols("`action_run_job`.id", "`action_run_job`.repo_id", "`action_run_job`.name", "`action_run_job`.status", // skip the payload columns + "`action_run_job`.run_id", "`action_run_job`.runs_on", "`action_run_job`.updated", "`action_run_job`.started", "`action_run_job`.task_id"). + OrderBy(jobQueueOrderBy). + Limit(pageSize, (page-1)*pageSize). + Find(&jobs) +} + +// JobQueueFilterRepoIDs returns up to limit ids of the repositories with queued or running jobs. +func JobQueueFilterRepoIDs(ctx context.Context, limit int) ([]int64, error) { + var ids []int64 + return ids, JobQueueOptions{}.session(ctx).Distinct("`action_run_job`.repo_id").Limit(limit).Find(&ids) +} diff --git a/models/actions/task.go b/models/actions/task.go index f4b8a4a54fe..0e922be83bf 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -160,6 +160,29 @@ func GetTasksMapByIDs(ctx context.Context, ids []int64) (map[int64]*ActionTask, return tasks, db.GetEngine(ctx).In("id", ids).Find(&tasks) } +// GetTaskRunnerNames returns runner names keyed by task ID without loading task logs. +func GetTaskRunnerNames(ctx context.Context, taskIDs []int64) (map[int64]string, error) { + names := make(map[int64]string, len(taskIDs)) + if len(taskIDs) == 0 { + return names, nil + } + var rows []struct { + ID int64 + Name string + } + err := db.GetEngine(ctx).Table("action_task"). + Join("INNER", "action_runner", "action_runner.id = action_task.runner_id"). + In("action_task.id", taskIDs). + Select("action_task.id, action_runner.name").Find(&rows) + if err != nil { + return nil, err + } + for _, row := range rows { + names[row.ID] = row.Name + } + return names, nil +} + func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, error) { errNotExist := fmt.Errorf("task with token %q: %w", token, util.ErrNotExist) if token == "" { diff --git a/models/actions/task_test.go b/models/actions/task_test.go index 0ee7e028657..ca872089c49 100644 --- a/models/actions/task_test.go +++ b/models/actions/task_test.go @@ -38,6 +38,18 @@ func TestActionTask_GetRunJobLink(t *testing.T) { assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42, Run: &ActionRun{ID: 10}}}).GetRunJobLink()) } +func TestGetTaskRunnerNames(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + runner := &ActionRunner{Name: "queue-runner"} + require.NoError(t, db.Insert(ctx, runner)) + task := &ActionTask{RunnerID: runner.ID, TokenHash: "queue-test-task"} + require.NoError(t, db.Insert(ctx, task)) + names, err := GetTaskRunnerNames(ctx, []int64{task.ID, 987654321}) + require.NoError(t, err) + assert.Equal(t, map[int64]string{task.ID: runner.Name}, names) +} + func TestMakeTaskStepDisplayName(t *testing.T) { tests := []struct { name string diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 7c376dbbb0e..fbdfe2eb965 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3936,6 +3936,13 @@ "actions.artifacts.back_to_run_attempt": "Back to action run #%d (attempt %d)", "actions.need_approval_desc": "Need approval to run workflows for fork pull request.", "actions.approve_all_success": "All workflow runs are approved successfully.", + "actions.management": "Management", + "actions.job_queue.title": "Job queue", + "actions.job_queue.runs_on": "Runs on", + "actions.job_queue.waiting_or_started": "Waiting / started", + "actions.job_queue.no_jobs": "No jobs are running or waiting to be picked up.", + "actions.job_queue.filter_owner_no_select": "All owners", + "actions.job_queue.filter_repo_no_select": "All repositories", "actions.variables": "Variables", "actions.variables.management": "Variables Management", "actions.variables.creation": "Add Variable", diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index a4336727260..c73f413c953 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -29,6 +29,7 @@ import ( "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/modules/util" + shared_actions "gitea.dev/routers/web/shared/actions" shared_user "gitea.dev/routers/web/shared/user" actions_service "gitea.dev/services/actions" "gitea.dev/services/context" @@ -595,10 +596,7 @@ func (data *actionRunListData) fillRefreshMeta(ctx *context.Context) bool { } actionRunIDs = append(actionRunIDs, run.ID) } - data.RefreshIntervalMs = util.Iif[int64](hasActiveRuns, 3*1000, 12*1000) - if !setting.IsProd { - data.RefreshIntervalMs = util.Iif[int64](hasActiveRuns, 1000, 2*1000) // faster in dev mode to make debug easier - } + data.RefreshIntervalMs = shared_actions.RefreshIntervalMs(hasActiveRuns) if len(data.ActionRuns) == 0 { data.RefreshIntervalMs = 0 } diff --git a/routers/web/repo/actions/job_queue.go b/routers/web/repo/actions/job_queue.go new file mode 100644 index 00000000000..44f1ef4369f --- /dev/null +++ b/routers/web/repo/actions/job_queue.go @@ -0,0 +1,55 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + + "gitea.dev/modules/util" + shared_actions "gitea.dev/routers/web/shared/actions" + "gitea.dev/services/context" +) + +// JobQueue renders this repository's Actions job queue. +func JobQueue(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageIsActions"] = true + ctx.Data["PageIsActionsJobQueue"] = true + if !ctx.FormBool("refresh") { + prepareActionsSidebar(ctx) + if ctx.Written() { + return + } + } + shared_actions.RenderJobQueue(ctx, ctx.Repo.Repository.ID, "repo/actions/job_queue") +} + +// prepareActionsSidebar lists the workflows without binding the runs list filters. +func prepareActionsSidebar(ctx *context.Context) { + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx, ctx.Repo.Repository.DefaultBranch) + if errors.Is(err, util.ErrNotExist) { + ctx.Data["NotFoundPrompt"] = ctx.Tr("repo.branch.default_branch_not_exist", ctx.Repo.Repository.DefaultBranch) + ctx.NotFound(nil) + return + } else if err != nil { + ctx.ServerError("GetBranchCommit", err) + return + } + + workflows, _ := prepareWorkflowTemplate(ctx, commit) + if ctx.Written() { + return + } + ctx.Data["CurWorkflow"] = "" + ctx.Data["CurWorkflowScopedRepoID"] = int64(0) + ctx.Data["CurActor"] = int64(0) + ctx.Data["CurStatus"] = 0 + ctx.Data["CurBranch"] = "" + + scopedNames := prepareScopedWorkflows(ctx, "", 0) + if ctx.Written() { + return + } + prepareOtherWorkflows(ctx, workflows, scopedNames, "") +} diff --git a/routers/web/shared/actions/job_queue.go b/routers/web/shared/actions/job_queue.go new file mode 100644 index 00000000000..a0710983e46 --- /dev/null +++ b/routers/web/shared/actions/job_queue.go @@ -0,0 +1,187 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "maps" + "net/http" + "net/url" + "slices" + "strconv" + + actions_model "gitea.dev/models/actions" + repo_model "gitea.dev/models/repo" + user_model "gitea.dev/models/user" + "gitea.dev/modules/base" + "gitea.dev/modules/container" + "gitea.dev/modules/setting" + "gitea.dev/modules/templates" + "gitea.dev/modules/util" + "gitea.dev/services/context" +) + +// JobQueue renders the instance-wide Actions job queue on the admin settings page. +func JobQueue(ctx *context.Context) { + ctx.Data["PageIsSharedSettingsActionsJobQueue"] = true + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageType"] = "job_queue" + + RenderJobQueue(ctx, 0, "admin/actions") +} + +const jobQueuePageSize = 50 + +// RefreshIntervalMs is how often an auto-refreshing Actions list re-fetches itself. +func RefreshIntervalMs(hasActivity bool) int64 { + if !setting.IsProd { + return util.Iif[int64](hasActivity, 1000, 2*1000) + } + return util.Iif[int64](hasActivity, 3*1000, 12*1000) +} + +// RenderJobQueue renders the job queue of one repository, or of the instance when repoID is 0. +func RenderJobQueue(ctx *context.Context, repoID int64, fullTemplate templates.TplName) { + page := max(ctx.FormInt("page"), 1) + + filterStatus := ctx.FormString("status") + status := actions_model.StatusUnknown + switch filterStatus { + case actions_model.StatusRunning.String(): + status = actions_model.StatusRunning + case actions_model.StatusWaiting.String(): + status = actions_model.StatusWaiting + default: + filterStatus = "" + } + var filterOwnerID, filterRepoID int64 + if repoID == 0 { + var err error + if filterOwnerID, filterRepoID, err = renderJobQueueFilterOptions(ctx); err != nil { + ctx.ServerError("renderJobQueueFilterOptions", err) + return + } + } + ctx.Data["JobQueueFilterOwnerID"], ctx.Data["JobQueueFilterRepoID"] = filterOwnerID, filterRepoID + + jobs, total, err := actions_model.FindJobQueueJobs(ctx, actions_model.JobQueueOptions{ + RepoID: util.Iif(filterRepoID > 0, filterRepoID, repoID), + OwnerID: filterOwnerID, + Status: status, + }, page, jobQueuePageSize) + if err != nil { + ctx.ServerError("FindJobQueueJobs", err) + return + } + if err := actions_model.ActionJobList(jobs).LoadAttributes(ctx, true); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + runners, err := actions_model.GetTaskRunnerNames(ctx, container.FilterSlice(jobs, func(job *actions_model.ActionRunJob) (int64, bool) { + return job.TaskID, job.TaskID > 0 + })) + if err != nil { + ctx.ServerError("GetTaskRunnerNames", err) + return + } + + ctx.Data["JobQueueJobs"] = jobs + ctx.Data["JobQueueRunners"] = runners + ctx.Data["JobQueueTotal"] = total + if !setting.IsProd && !ctx.FormBool("refresh") { + // for dev mode, force the first screen to be blank to debug more edge cases + ctx.Data["JobQueueJobs"], ctx.Data["JobQueueRunners"], ctx.Data["JobQueueTotal"] = nil, nil, 0 + } + ctx.Data["ShowRepoColumn"] = repoID == 0 + ctx.Data["JobQueueFilterStatus"] = filterStatus + ctx.Data["JobQueueFilterStatuses"] = []string{actions_model.StatusRunning.String(), actions_model.StatusWaiting.String()} + + pager := context.NewPagerBuilder(ctx).TotalCount(total).PerPageLimit(jobQueuePageSize).CurPage(page).Build() + query := url.Values{} + if filterOwnerID > 0 { + query.Set("owner_id", strconv.FormatInt(filterOwnerID, 10)) + } + if filterRepoID > 0 { + query.Set("repo_id", strconv.FormatInt(filterRepoID, 10)) + } + if filterStatus != "" { + query.Set("status", filterStatus) + } + pager.RemoveParam(container.SetOf("refresh", "owner_id", "repo_id", "status")) + pager.AddParamFromQuery(query) + ctx.Data["Page"] = pager + + ctx.Data["JobQueueRefreshIntervalMs"] = RefreshIntervalMs(len(jobs) > 0) + query.Set("page", strconv.Itoa(pager.Paginator.Current())) + query.Set("refresh", "1") + ctx.Data["JobQueueRefreshLink"] = setting.AppSubURL + ctx.Req.URL.EscapedPath() + "?" + query.Encode() + + if ctx.FormBool("refresh") { + ctx.HTML(http.StatusOK, "shared/actions/job_queue_list") + return + } + ctx.HTML(http.StatusOK, fullTemplate) +} + +// JobQueueFilterOwner is one entry of the job queue's owner filter. +type JobQueueFilterOwner struct { + ID int64 + Name string +} + +// renderJobQueueFilterOptions includes pending work and the selected scope, even when its queue is empty. +func renderJobQueueFilterOptions(ctx *context.Context) (filterOwnerID, filterRepoID int64, _ error) { + repoIDs, err := actions_model.JobQueueFilterRepoIDs(ctx, 200) + if err != nil { + return 0, 0, err + } + reqRepoID := ctx.FormInt64("repo_id") + if reqRepoID > 0 { + repoIDs = append(repoIDs, reqRepoID) + } + repoMap, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs) + if err != nil { + return 0, 0, err + } + + repos := slices.Collect(maps.Values(repoMap)) + slices.SortFunc(repos, func(a, b *repo_model.Repository) int { + return base.NaturalSortCompare(a.FullName(), b.FullName()) + }) + + owners := make([]*JobQueueFilterOwner, 0, len(repos)) + seenOwners := make(container.Set[int64], len(repos)) + for _, repo := range repos { + if seenOwners.Add(repo.OwnerID) { + owners = append(owners, &JobQueueFilterOwner{ID: repo.OwnerID, Name: repo.OwnerName}) + } + } + + if repo := repoMap[reqRepoID]; repo != nil { + filterRepoID = repo.ID + ctx.Data["JobQueueFilterRepoName"] = repo.FullName() + } else if reqOwnerID := ctx.FormInt64("owner_id"); reqOwnerID > 0 { + owner, err := user_model.GetUserByID(ctx, reqOwnerID) + if err != nil && !user_model.IsErrUserNotExist(err) { + return 0, 0, err + } + if owner != nil { + filterOwnerID = owner.ID + ctx.Data["JobQueueFilterOwnerName"] = owner.Name + if seenOwners.Add(owner.ID) { + owners = append(owners, &JobQueueFilterOwner{ID: owner.ID, Name: owner.Name}) + } + } + } + slices.SortFunc(owners, func(a, b *JobQueueFilterOwner) int { + return base.NaturalSortCompare(a.Name, b.Name) + }) + + if filterOwnerID > 0 { + repos = slices.DeleteFunc(repos, func(repo *repo_model.Repository) bool { return repo.OwnerID != filterOwnerID }) + } + ctx.Data["JobQueueFilterOwners"] = owners + ctx.Data["JobQueueFilterRepos"] = repos + return filterOwnerID, filterRepoID, nil +} diff --git a/routers/web/web.go b/routers/web/web.go index 3e2740b1ad6..0aecc64c476 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -907,6 +907,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/runners/bulk", shared_actions.RunnerBulkActionPost) addSettingsVariablesRoutes() addSettingsScopedWorkflowsRoutes() + m.Get("/job_queue", shared_actions.JobQueue) }) }, adminReq, ctxDataSet(reqctx.ContextData{"EnableOAuth2": setting.OAuth2.Enabled, "EnablePackages": setting.Packages.Enabled})) // ***** END: Admin ***** @@ -1563,6 +1564,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Group("/{username}/{reponame}/actions", func() { m.Get("", actions.List) + m.Get("/job_queue", actions.JobQueue) m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile) m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) m.Post("/run", reqRepoActionsWriter, actions.Run) diff --git a/templates/admin/actions.tmpl b/templates/admin/actions.tmpl index 6be9bdf73b0..a20ce650100 100644 --- a/templates/admin/actions.tmpl +++ b/templates/admin/actions.tmpl @@ -9,5 +9,8 @@ {{if eq .PageType "scoped-workflows"}} {{template "shared/actions/scoped_workflows" .}} {{end}} + {{if eq .PageType "job_queue"}} + {{template "shared/actions/job_queue_list" .}} + {{end}} {{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index b7c28d17e5a..4f821a9e660 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -72,7 +72,7 @@ {{end}} {{end}} {{if .EnableActions}} -
+
{{ctx.Locale.Tr "actions.actions"}}
{{end}} diff --git a/templates/repo/actions/job_queue.tmpl b/templates/repo/actions/job_queue.tmpl new file mode 100644 index 00000000000..0b16bd0fb41 --- /dev/null +++ b/templates/repo/actions/job_queue.tmpl @@ -0,0 +1,14 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+ {{template "base/alert" .}} +
+ {{template "repo/actions/sidebar" .}} +
+ {{template "shared/actions/job_queue_list" .}} +
+
+
+
+{{template "base/footer" .}} diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index ab5aa5f8b3d..981bafcff6c 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -10,59 +10,7 @@ {{if .HasWorkflowsOrRuns}}
-
- -
+ {{template "repo/actions/sidebar" .}}
{{ctx.Locale.TrN .Page.Paginator.Total "actions.runs.workflow_run_count_1" "actions.runs.workflow_run_count_n" .Page.Paginator.Total}} diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index e65143ccf35..a5c403d5781 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -10,7 +10,7 @@
{{end}} {{range $run := $data.ActionRuns}} -
+
{{template "repo/icons/action_status" (dict "Status" $run.Status.String "IconVariant" "circle-fill")}} diff --git a/templates/repo/actions/sidebar.tmpl b/templates/repo/actions/sidebar.tmpl new file mode 100644 index 00000000000..4a2dfafdc01 --- /dev/null +++ b/templates/repo/actions/sidebar.tmpl @@ -0,0 +1,63 @@ +
+ + + +
diff --git a/templates/shared/actions/job_queue_list.tmpl b/templates/shared/actions/job_queue_list.tmpl new file mode 100644 index 00000000000..af21d454e79 --- /dev/null +++ b/templates/shared/actions/job_queue_list.tmpl @@ -0,0 +1,93 @@ +
+
+ {{ctx.Locale.Tr "actions.job_queue.title"}} ({{ctx.Locale.Tr "admin.total" .JobQueueTotal}}) + +
+
+ {{if .JobQueueJobs}} + + + + + {{if .ShowRepoColumn}}{{end}} + + + + + + + + + {{range $job := .JobQueueJobs}} + + + {{if $.ShowRepoColumn}}{{end}} + + + + + + + {{end}} + +
{{ctx.Locale.Tr "actions.runs.status"}}{{ctx.Locale.Tr "repository"}}{{ctx.Locale.Tr "actions.runners.task_list.job"}}{{ctx.Locale.Tr "actions.job_queue.runs_on"}}{{ctx.Locale.Tr "actions.runners.runner_title"}}{{ctx.Locale.Tr "actions.runs.commit"}}{{ctx.Locale.Tr "actions.job_queue.waiting_or_started"}}
+ + {{template "repo/icons/action_status" (dict "Status" $job.Status.String)}} + + {{if $job.Repo}}{{$job.Repo.FullName}}{{end}}{{if $job.Run}}{{$job.Name}} #{{$job.Run.Index}}{{else}}{{$job.Name}}{{end}}{{range $job.RunsOn}}{{.}}{{end}}{{index $.JobQueueRunners $job.TaskID}}{{if and $job.Run $job.Repo}}{{ShortSha $job.Run.CommitSHA}}{{end}}{{if $job.Started}}{{DateUtils.TimeSince $job.Started}}{{else}}{{DateUtils.TimeSince $job.Updated}}{{end}}
+ {{else}} +
{{ctx.Locale.Tr "actions.job_queue.no_jobs"}}
+ {{end}} + {{template "base/paginate" .}} +
+ +
diff --git a/tests/e2e/actions.test.ts b/tests/e2e/actions.test.ts new file mode 100644 index 00000000000..ce0edf08ab2 --- /dev/null +++ b/tests/e2e/actions.test.ts @@ -0,0 +1,29 @@ +import {env} from 'node:process'; +import {expect, test} from '@playwright/test'; +import {apiCreateFiles, apiCreateRepo, apiHeaders, randomString} from './utils.ts'; + +test('job queue refreshes with filter open', async ({page, request}) => { + const owner = env.GITEA_TEST_E2E_USER; + const repo = `e2e-queue-${randomString(8)}`; + await apiCreateRepo(request, {name: repo, autoInit: false}); + await apiCreateFiles(request, owner, repo, [{ + path: '.gitea/workflows/queue.yml', + content: 'on: workflow_dispatch\njobs:\n queued:\n runs-on: no-runner\n steps:\n - run: exit 0\n', + }]); + const dispatch = async () => { + const response = await request.post(`/api/v1/repos/${owner}/${repo}/actions/workflows/queue.yml/dispatches`, {headers: apiHeaders(), data: {ref: 'main'}}); + expect(response.ok()).toBe(true); + }; + await dispatch(); + + await page.clock.install(); + await page.goto(`/${owner}/${repo}/actions/job_queue`); + await page.getByRole('menu').getByText('Status', {exact: true}).click(); + const waitingFilter = page.getByRole('menuitem', {name: 'Waiting'}); + await expect(waitingFilter).toBeVisible(); + + await dispatch(); + await page.clock.fastForward(3000); + await expect(page.getByRole('link', {name: 'queued #2'})).toBeVisible(); + await expect(waitingFilter).toBeVisible(); +}); diff --git a/tests/integration/actions_job_queue_test.go b/tests/integration/actions_job_queue_test.go new file mode 100644 index 00000000000..2ab1d69971a --- /dev/null +++ b/tests/integration/actions_job_queue_test.go @@ -0,0 +1,112 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "strconv" + "strings" + "testing" + + actions_model "gitea.dev/models/actions" + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unittest" + "gitea.dev/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActionsJobQueue(t *testing.T) { + defer tests.PrepareTestEnv(t)() + ctx := t.Context() + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + + insertQueuedJob := func(repo *repo_model.Repository, index int64, jobName string) *actions_model.ActionRunJob { + run := &actions_model.ActionRun{RepoID: repo.ID, OwnerID: repo.OwnerID, Index: index, Status: actions_model.StatusWaiting} + require.NoError(t, db.Insert(ctx, run)) + job := &actions_model.ActionRunJob{RunID: run.ID, RepoID: repo.ID, Name: jobName, Status: actions_model.StatusWaiting} + require.NoError(t, db.Insert(ctx, job)) + return job + } + const queuedJobName, otherJobName, callerJobName = "queued-job-marker", "queued-job-other-owner", "reusable-caller-marker" + job := insertQueuedJob(repo1, 8801, queuedJobName) + insertQueuedJob(repo3, 8802, otherJobName) + require.NoError(t, db.Insert(ctx, &actions_model.ActionRunJob{ + RunID: job.RunID, + RepoID: repo1.ID, + Name: callerJobName, + Status: actions_model.StatusRunning, + IsReusableCaller: true, + })) + + const repoJobQueue = "/user2/repo1/actions/job_queue" + sessionUser2 := loginUser(t, "user2") + repoDoc := NewHTMLParser(t, sessionUser2.MakeRequest(t, NewRequest(t, "GET", repoJobQueue+"?workflow=test.yaml"), http.StatusOK).Body) + assert.Contains(t, repoDoc.Find("#actions-job-queue tbody").Text(), queuedJobName) + assert.NotContains(t, repoDoc.Find("#actions-job-queue tbody").Text(), callerJobName) + assert.Equal(t, 1, repoDoc.Find(`.flex-container-nav a.active[href="`+repoJobQueue+`"]`).Length()) + assert.Zero(t, repoDoc.Find(`.flex-container-nav a.active:not([href="`+repoJobQueue+`"])`).Length()) + + listDoc := NewHTMLParser(t, sessionUser2.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/actions"), http.StatusOK).Body) + assert.Equal(t, 1, listDoc.Find(`.flex-container-nav a:not(.active)[href="`+repoJobQueue+`"]`).Length()) + + assert.Contains(t, MakeRequest(t, NewRequest(t, "GET", repoJobQueue), http.StatusOK).Body.String(), queuedJobName) + + sessionAdmin := loginUser(t, "user1") + adminGet := func(link string) (string, *HTMLDoc) { + body := sessionAdmin.MakeRequest(t, NewRequest(t, "GET", link), http.StatusOK).Body.String() + return body, NewHTMLParser(t, strings.NewReader(body)) + } + refreshLinkOf := func(doc *HTMLDoc) string { + link, ok := doc.Find("#actions-job-queue").Attr("data-job-queue-refresh-link") + require.True(t, ok) + return link + } + repoFilterSelector := func(repoID int64) string { + return `#actions-job-queue-filter a[href^="?repo_id=` + strconv.FormatInt(repoID, 10) + `&"]` + } + + const adminJobQueue = "/-/admin/actions/job_queue" + unfiltered, unfilteredDoc := adminGet(adminJobQueue) + assert.Contains(t, unfiltered, queuedJobName) + assert.Contains(t, unfiltered, otherJobName) + assert.Equal(t, 1, unfilteredDoc.Find(repoFilterSelector(repo1.ID)).Length()) + + refresh, refreshDoc := adminGet(refreshLinkOf(unfilteredDoc)) + assert.NotContains(t, refresh, " boolean, + }, + }): Node[]; } export const Idiomorph: Idiomorph; } diff --git a/web_src/css/base.css b/web_src/css/base.css index 535cac150c6..3df9cea3691 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -844,8 +844,8 @@ table th[data-sortt-desc] .svg { min-width: 0; } -.ui.list.flex-items-block > .item, -.ui.vertical.menu .item.flex-text-block, +.ui.ui.ui.ui .item.flex-text-block, /* override .ui.list and .ui.vertical.menu */ +.ui.ui.ui.ui.flex-items-block > .item, .ui.form .field > label.flex-text-block, /* override fomantic "block" style */ .flex-items-block > .item, .flex-text-block { diff --git a/web_src/css/modules/dropdown.css b/web_src/css/modules/dropdown.css index 28c4c27414c..cbd00aecccb 100644 --- a/web_src/css/modules/dropdown.css +++ b/web_src/css/modules/dropdown.css @@ -817,10 +817,6 @@ select.ui.dropdown { max-height: 2em; } -.ui.dropdown .menu > .item > svg { - margin-right: 0.78rem; -} - /* extend fomantic style '.ui.dropdown > .text > img' to include svg.img */ .ui.dropdown > .text > .img { margin-left: 0; @@ -893,7 +889,8 @@ select.ui.dropdown { } /* to override Fomantic's default display: block for ".menu .item", and use a slightly larger gap for menu item content -the "!important" is necessary to override Fomantic UI menu item styles, meanwhile we should keep the "hidden" items still hidden */ +the "!important" is necessary to override Fomantic UI menu item styles, meanwhile we should keep the "hidden" items still hidden +TODO: rename it to "flex-items-dropdown" in the future since it is only used for dropdown menu items, not for other menu items */ .ui.dropdown .menu.flex-items-menu > .item:not(.hidden, .filtered, .tw-hidden) { display: flex !important; align-items: center; @@ -901,6 +898,11 @@ the "!important" is necessary to override Fomantic UI menu item styles, meanwhil min-width: 0; } +/* TODO: MENU-ITEM-FLEX-MARGIN: need to refactor: flex menu items don't need svg margin, too many patches (see below ...) */ +.ui.dropdown .menu:not(.flex-items-menu) > .item:not(.flex-text-block, .flex-text-inline) > .svg { + margin-right: 10px; +} + .ui.dropdown .menu.flex-items-menu > .item img, .ui.dropdown .menu.flex-items-menu > .item svg { margin: 0; diff --git a/web_src/css/modules/menu.css b/web_src/css/modules/menu.css index 418b4eb5216..cbeb4210f33 100644 --- a/web_src/css/modules/menu.css +++ b/web_src/css/modules/menu.css @@ -55,8 +55,9 @@ background: var(--color-secondary); } -.ui.menu .item > .svg { - margin-right: 0.35em; +/* TODO: MENU-ITEM-FLEX-MARGIN: need to refactor: flex menu items don't need svg margin */ +.ui.menu:not(.flex-items-block) .item:not(.flex-text-block, .flex-text-inline) > .svg { + margin-right: 5px; } .ui.menu .item > a:not(.ui) { diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index 3f4f1bc3355..9d3fd0f5445 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -3,8 +3,7 @@ import RepoActionView from '../components/RepoActionView.vue'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {html} from '../utils/html.ts'; import {GET} from '../modules/fetch.ts'; -import {activePageTimerRefresh, createElementFromHTML, protectMorphElements, recoverMorphElements} from '../utils/dom.ts'; -import {Idiomorph} from 'idiomorph'; +import {activePageTimerRefresh, createElementFromHTML, morphElementWithProtection} from '../utils/dom.ts'; export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void { const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!); @@ -31,6 +30,7 @@ export function initRepositoryActions() { registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm); initRepositoryActionsView(); registerGlobalInitFunc('initActionRunsList', initActionRunsList); + registerGlobalInitFunc('initActionJobQueueList', initActionJobQueueList); } function initRepositoryActionsView() { @@ -115,22 +115,19 @@ function initActionRunsList(el: HTMLElement) { interval: () => Number(el.getAttribute('data-action-runs-refresh-interval')), async callback() { const resp = await GET(el.getAttribute('data-action-runs-refresh-link')!); - if (!resp.ok || resp.status !== 200) return; - - const newEl = createElementFromHTML(await resp.text()); - for (const attr of newEl.attributes) el.setAttribute(attr.name, attr.value); - for (const newItem of newEl.querySelectorAll(':scope > .item')) { - const oldItem = el.querySelector(`#${newItem.id}`); - if (!oldItem) continue; - - // If the end user is operating the row, then don't refresh its content. - // Otherwise, there will be more edge cases and inconsistencies, e.g.: dropdown still shows old items but the icon has changed. - if (oldItem.querySelector('.ui.dropdown.active')) continue; - - const protectedElems = protectMorphElements(newItem); - Idiomorph.morph(oldItem, newItem, {morphStyle: 'outerHTML'}); - recoverMorphElements(el.querySelector(`#${newItem.id}`)!, protectedElems); - } + if (!resp.ok) return; + morphElementWithProtection(el, createElementFromHTML(await resp.text()), {morphStyle: 'outerHTML'}); + }, + }); +} + +function initActionJobQueueList(el: HTMLElement) { + activePageTimerRefresh({ + interval: () => Number(el.getAttribute('data-job-queue-refresh-interval')), + async callback() { + const resp = await GET(el.getAttribute('data-job-queue-refresh-link')!); + if (!resp.ok) return; + morphElementWithProtection(el, createElementFromHTML(await resp.text()), {morphStyle: 'outerHTML'}); }, }); } diff --git a/web_src/js/utils/dom.test.ts b/web_src/js/utils/dom.test.ts index d047eac92b2..4a9c92d2564 100644 --- a/web_src/js/utils/dom.test.ts +++ b/web_src/js/utils/dom.test.ts @@ -2,7 +2,7 @@ import { createElementFromAttrs, createElementFromHTML, queryElemChildren, querySingleVisibleElem, protectMorphElements, recoverMorphElements, - toggleElem, + toggleElem, morphElementWithProtection, } from './dom.ts'; test('createElementFromHTML', () => { @@ -70,3 +70,33 @@ test('protectMorphElements', () => { recoverMorphElements(el, protectedElems); expect(el.outerHTML).toEqual('
foo
'); }); + +describe('morphElementWithProtection', () => { + const newElHtml = '
new
'; + it('morph normal', () => { + const el = createElementFromHTML('
old
'); + const newEl = createElementFromHTML(newElHtml); + morphElementWithProtection(el, newEl, {morphStyle: 'outerHTML'}); + // span is changed, but dropdown is skipped because it is active + expect(el.outerHTML).toEqual('
new
'); + }); + it('morph whole', () => { + const el = createElementFromHTML('
old
'); + const newEl = createElementFromHTML(newElHtml); + morphElementWithProtection(el, newEl, {morphStyle: 'outerHTML'}); + // span is not changed, because the parent div is marked as data-morph-whole and there is an active dropdown, so it is skipped + expect(el.outerHTML).toEqual('
old
'); + }); + it('morph protection', () => { + const el = createElementFromHTML('
'); + const newEl = createElementFromHTML('
new
'); + const elSpanOld = el.querySelector('span'); + const elDropdownOld = el.querySelector('.ui.dropdown'); + const morphedEl = morphElementWithProtection(el, newEl, {morphStyle: 'outerHTML'}); + expect(el.outerHTML).toEqual('
new
'); + const elSpanNew = morphedEl.querySelector('span'); + const elDropdownNew = morphedEl.querySelector('.ui.dropdown'); + expect(elSpanNew).toBe(elSpanOld); // span is morphed in place, so it is the same element + expect(elDropdownNew).not.toBe(elDropdownOld); // dropdown is protected and fully replaced, so it is a new element + }); +}); diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index bceda40acc2..936c2187772 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -1,6 +1,7 @@ import {debounce} from './func.ts'; import type {Promisable} from '../types.ts'; import type $ from 'jquery'; +import {Idiomorph} from 'idiomorph'; type ArrayLikeIterable = ArrayLike & Iterable; // for NodeListOf and Array type ElementArg = Element | string | ArrayLikeIterable | ReturnType; @@ -424,6 +425,36 @@ export function recoverMorphElements(el: Element, protectedElems: ProtectedMorph for (const [id, html] of Object.entries(protectedElems)) { const it = el.querySelector(`[data-morph-protect="${CSS.escape(id)}"]`); if (!it) continue; - it.outerHTML = html; + it.replaceWith(createElementFromHTML(html)); } } + +export type MorphElementOptions = { + morphStyle: 'innerHTML' | 'outerHTML'; +}; + +export function morphElementWithProtection(el: Element, newEl: Element, opts: MorphElementOptions): Element { + const protectedElems = protectMorphElements(newEl); + const selectorSkipElems = '.ui.dropdown.active'; + const nodes = Idiomorph.morph(el, newEl, { + morphStyle: opts.morphStyle, + callbacks: { + beforeNodeMorphed: (oldNode /* , newNode */) => { + if (!(oldNode instanceof Element)) return true; + + // If the end user is operating a row, then don't refresh its content. + // Otherwise, there will be more edge cases and inconsistencies, e.g.: dropdown still shows old items but the icon has changed. + const oldNodeMorphWholeAndSkipChild = oldNode.matches('[data-morph-whole]') && oldNode.querySelector(selectorSkipElems); + + // If the element should be skipped, don't morph it + const oldNodeShouldSkip = oldNode.matches(selectorSkipElems); + + const shouldSkip = oldNodeMorphWholeAndSkipChild || oldNodeShouldSkip; + return !shouldSkip; + }, + }, + }); + const morphedElem = nodes[0] as Element; + recoverMorphElements(morphedElem, protectedElems); + return morphedElem; +}