mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-26 23:56:37 +09:00
3875db1974
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>
188 lines
6.0 KiB
Go
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
|
|
}
|