Files
gitea/routers/web/shared/actions/job_queue.go
T
bircni 3875db1974 feat(actions): add build queue view (#38585)
Adds a read-only Actions job queue: running jobs first, then waiting
jobs in the order a runner picks them up. It is shown instance-wide in
the admin Actions section with owner, repository and status filters, and
per repository in the Actions tab. Both lists refresh in place.

Pending work is currently only visible per repository and newest-first,
so nothing shows what is queued, in which order, or what occupies the
runners. Reordering the queue will be proposed separately.

A migration adds indexes for the runner pickup query and
repository-scoped status lookups.

* Fix #34198

<img width="1345" height="451" alt="image"
src="https://github.com/user-attachments/assets/7d52ff76-81b4-44e8-b583-d7d89c9dffcd"
/>
<img width="1809" height="1134" alt="image"
src="https://github.com/user-attachments/assets/4d56c0cb-bae7-4ce2-8f3c-75163b2bc7f4"
/>

---------

Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-26 08:01:41 +00:00

188 lines
6.0 KiB
Go

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