Skip to content

Commit f8da20b

Browse files
committed
feat(actions): add build queue view
Add a "Queue" page to the Actions settings section at repo, org, user and admin scopes. It lists queued jobs in the exact order runners pick them up (status=waiting, unclaimed, non-reusable-caller, ordered by updated,id) plus the jobs currently running, and auto-refreshes the list in place.
1 parent 1d698f1 commit f8da20b

16 files changed

Lines changed: 407 additions & 4 deletions

File tree

models/actions/run_job_list.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ func (jobs ActionJobList) LoadAttributes(ctx context.Context, withRepo bool) err
8888
return jobs.LoadRuns(ctx, withRepo)
8989
}
9090

91+
// QueuedJobsOrderBy mirrors the runner pickup order (see CreateTaskForRunner): waiting jobs are
92+
// claimed oldest-ready-first, keyed on (updated, id). Use it to render the build queue in pickup order.
93+
const QueuedJobsOrderBy db.SearchOrderBy = "`action_run_job`.updated ASC, `action_run_job`.id ASC"
94+
95+
// RunningJobsOrderBy lists currently running jobs longest-running-first.
96+
const RunningJobsOrderBy db.SearchOrderBy = "`action_run_job`.started ASC, `action_run_job`.id ASC"
97+
9198
type FindRunJobOptions struct {
9299
db.ListOptions
93100
RunID int64
@@ -96,6 +103,8 @@ type FindRunJobOptions struct {
96103
OwnerID int64
97104
CommitSHA string
98105
Statuses []Status
106+
IsReusableCaller optional.Option[bool] // use optional to filter reusable-caller rows in/out; nil means no restriction
107+
HasTask optional.Option[bool] // false: task_id = 0 (unclaimed); true: task_id != 0 (claimed); nil: no restriction
99108
UpdatedBefore timeutil.TimeStamp
100109
ConcurrencyGroup string
101110
OrderBy db.SearchOrderBy
@@ -127,6 +136,16 @@ func (opts FindRunJobOptions) ToConds() builder.Cond {
127136
if len(opts.Statuses) > 0 {
128137
cond = cond.And(builder.In("`action_run_job`.status", opts.Statuses))
129138
}
139+
if opts.IsReusableCaller.Has() {
140+
cond = cond.And(builder.Eq{"`action_run_job`.is_reusable_caller": opts.IsReusableCaller.Value()})
141+
}
142+
if opts.HasTask.Has() {
143+
if opts.HasTask.Value() {
144+
cond = cond.And(builder.Neq{"`action_run_job`.task_id": 0})
145+
} else {
146+
cond = cond.And(builder.Eq{"`action_run_job`.task_id": 0})
147+
}
148+
}
130149
if opts.UpdatedBefore > 0 {
131150
cond = cond.And(builder.Lt{"`action_run_job`.updated": opts.UpdatedBefore})
132151
}

models/actions/run_job_list_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ package actions
66
import (
77
"testing"
88

9+
"gitea.dev/models/db"
10+
"gitea.dev/models/unittest"
11+
"gitea.dev/modules/optional"
12+
913
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
1015
)
1116

1217
func TestActionJobList_SortMatrixGroupsByName(t *testing.T) {
@@ -59,3 +64,61 @@ func TestActionJobList_SortMatrixGroupsByName(t *testing.T) {
5964
assert.Equal(t, []string{"only"}, names(jobs))
6065
})
6166
}
67+
68+
// TestFindRunJobOptions_Queue verifies the build-queue query mirrors the runner pickup predicate:
69+
// waiting + unclaimed + non-reusable jobs, ordered by (updated ASC, id ASC).
70+
func TestFindRunJobOptions_Queue(t *testing.T) {
71+
require.NoError(t, unittest.PrepareTestDatabase())
72+
ctx := t.Context()
73+
74+
// A repo id no fixture or other test uses, so the counts/order below are not polluted.
75+
const repoID int64 = 987654
76+
77+
insert := func(name string, status Status, taskID int64, reusable bool) *ActionRunJob {
78+
job := &ActionRunJob{
79+
RepoID: repoID,
80+
OwnerID: 1,
81+
Name: name,
82+
JobID: name,
83+
Status: status,
84+
TaskID: taskID,
85+
IsReusableCaller: reusable,
86+
}
87+
require.NoError(t, db.Insert(ctx, job))
88+
return job
89+
}
90+
91+
// Genuinely queued jobs: waiting, unclaimed (task_id=0), not reusable callers.
92+
jA := insert("a", StatusWaiting, 0, false)
93+
jB := insert("b", StatusWaiting, 0, false)
94+
jC := insert("c", StatusWaiting, 0, false)
95+
// Rows that must be excluded from the queue.
96+
insert("claimed", StatusWaiting, 999, false) // already has a task
97+
insert("reusable", StatusWaiting, 0, true) // reusable caller never runs on a runner
98+
insert("running", StatusRunning, 998, false) // running, no longer queued
99+
100+
// Force `updated` so pickup order (updated ASC, id ASC) differs from insertion/id order: C < A < B.
101+
setUpdated := func(id, ts int64) {
102+
_, err := db.GetEngine(ctx).Exec("UPDATE `action_run_job` SET updated = ? WHERE id = ?", ts, id)
103+
require.NoError(t, err)
104+
}
105+
setUpdated(jC.ID, 100)
106+
setUpdated(jA.ID, 200)
107+
setUpdated(jB.ID, 300)
108+
109+
jobs, total, err := db.FindAndCount[ActionRunJob](ctx, FindRunJobOptions{
110+
RepoID: repoID,
111+
Statuses: []Status{StatusWaiting},
112+
IsReusableCaller: optional.Some(false),
113+
HasTask: optional.Some(false),
114+
OrderBy: QueuedJobsOrderBy,
115+
})
116+
require.NoError(t, err)
117+
assert.EqualValues(t, 3, total, "only waiting, unclaimed, non-reusable jobs are queued")
118+
119+
gotIDs := make([]int64, len(jobs))
120+
for i, j := range jobs {
121+
gotIDs[i] = j.ID
122+
}
123+
assert.Equal(t, []int64{jC.ID, jA.ID, jB.ID}, gotIDs, "queue is ordered by (updated ASC, id ASC)")
124+
}

options/locale/locale_en-US.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3872,6 +3872,17 @@
38723872
"actions.workflow.has_no_workflow_dispatch": "Workflow '%s' has no workflow_dispatch event trigger.",
38733873
"actions.need_approval_desc": "Need approval to run workflows for fork pull request.",
38743874
"actions.approve_all_success": "All workflow runs are approved successfully.",
3875+
"actions.queue": "Queue",
3876+
"actions.queue.queued_jobs": "Queued jobs",
3877+
"actions.queue.running_jobs": "Running jobs",
3878+
"actions.queue.position": "#",
3879+
"actions.queue.job": "Job",
3880+
"actions.queue.runs_on": "Runs on",
3881+
"actions.queue.waiting_since": "Waiting since",
3882+
"actions.queue.started": "Started",
3883+
"actions.queue.duration": "Duration",
3884+
"actions.queue.no_queued_jobs": "No jobs are waiting to be picked up.",
3885+
"actions.queue.no_running_jobs": "No jobs are currently running.",
38753886
"actions.variables": "Variables",
38763887
"actions.variables.management": "Variables Management",
38773888
"actions.variables.creation": "Add Variable",

routers/web/shared/actions/runners.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import (
1212

1313
actions_model "gitea.dev/models/actions"
1414
"gitea.dev/models/db"
15+
"gitea.dev/modules/container"
1516
"gitea.dev/modules/log"
17+
"gitea.dev/modules/optional"
1618
"gitea.dev/modules/setting"
1719
"gitea.dev/modules/templates"
1820
"gitea.dev/modules/util"
@@ -168,6 +170,95 @@ func Runners(ctx *context.Context) {
168170
ctx.HTML(http.StatusOK, rCtx.RunnersTemplate)
169171
}
170172

173+
// Queue renders the Actions build queue: waiting jobs in runner pickup order plus currently running jobs.
174+
func Queue(ctx *context.Context) {
175+
ctx.Data["PageIsSharedSettingsQueue"] = true
176+
ctx.Data["Title"] = ctx.Tr("actions.actions")
177+
ctx.Data["PageType"] = "queue"
178+
179+
rCtx, err := getRunnersCtx(ctx)
180+
if err != nil {
181+
ctx.ServerError("getRunnersCtx", err)
182+
return
183+
}
184+
185+
// scope narrows the query to the current context. Admin (both 0) sees the whole instance, which is
186+
// the only scope where the order is globally exact; repo/org/user scopes show the correct relative
187+
// order of their own jobs, but not their absolute position within the instance-wide queue.
188+
scope := func(opts *actions_model.FindRunJobOptions) {
189+
if rCtx.IsRepo {
190+
opts.RepoID = rCtx.RepoID
191+
} else if rCtx.IsOrg || rCtx.IsUser {
192+
opts.OwnerID = rCtx.OwnerID
193+
}
194+
}
195+
196+
page := max(ctx.FormInt("page"), 1)
197+
198+
// Mirror the runner pickup predicate (see CreateTaskForRunner): waiting, unclaimed, non-reusable-caller jobs.
199+
queuedOpts := actions_model.FindRunJobOptions{
200+
ListOptions: db.ListOptions{Page: page, PageSize: 50},
201+
Statuses: []actions_model.Status{actions_model.StatusWaiting},
202+
IsReusableCaller: optional.Some(false), // reusable callers never run on a runner, so they are not queued
203+
HasTask: optional.Some(false), // a claimed job already has a task and is no longer queued
204+
OrderBy: actions_model.QueuedJobsOrderBy,
205+
}
206+
scope(&queuedOpts)
207+
queuedJobs, queuedTotal, err := db.FindAndCount[actions_model.ActionRunJob](ctx, queuedOpts)
208+
if err != nil {
209+
ctx.ServerError("FindQueuedJobs", err)
210+
return
211+
}
212+
if err := actions_model.ActionJobList(queuedJobs).LoadAttributes(ctx, true); err != nil {
213+
ctx.ServerError("LoadAttributes", err)
214+
return
215+
}
216+
217+
// running jobs are bounded by the number of online runners, so a single capped page is enough.
218+
runningOpts := actions_model.FindRunJobOptions{
219+
ListOptions: db.ListOptions{Page: 1, PageSize: 100},
220+
Statuses: []actions_model.Status{actions_model.StatusRunning},
221+
OrderBy: actions_model.RunningJobsOrderBy,
222+
}
223+
scope(&runningOpts)
224+
runningJobs, err := db.Find[actions_model.ActionRunJob](ctx, runningOpts)
225+
if err != nil {
226+
ctx.ServerError("FindRunningJobs", err)
227+
return
228+
}
229+
if err := actions_model.ActionJobList(runningJobs).LoadAttributes(ctx, true); err != nil {
230+
ctx.ServerError("LoadAttributes", err)
231+
return
232+
}
233+
234+
ctx.Data["QueuedJobs"] = queuedJobs
235+
ctx.Data["QueuedTotal"] = queuedTotal
236+
ctx.Data["QueueOffset"] = (page - 1) * queuedOpts.PageSize // absolute position of the first row on this page
237+
ctx.Data["RunningJobs"] = runningJobs
238+
ctx.Data["ShowRepoColumn"] = !rCtx.IsRepo
239+
240+
pager := context.NewPagination(queuedTotal, queuedOpts.PageSize, page, 5)
241+
pager.AddParamFromRequest(ctx.Req)
242+
pager.RemoveParam(container.SetOf("refresh")) // keep the auto-refresh flag out of the page links
243+
ctx.Data["Page"] = pager
244+
245+
// The list auto-refreshes by re-fetching just the fragment: poll faster while there is activity,
246+
// slower when idle so quiet pages don't hammer the server.
247+
hasActivity := len(queuedJobs) > 0 || len(runningJobs) > 0
248+
refreshInterval := util.Iif[int64](hasActivity, 3000, 10000)
249+
if !setting.IsProd {
250+
refreshInterval = util.Iif[int64](hasActivity, 1000, 2000) // faster in dev for easier debugging
251+
}
252+
ctx.Data["QueueRefreshIntervalMs"] = refreshInterval
253+
ctx.Data["QueueRefreshLink"] = templates.QueryBuild(setting.AppSubURL+ctx.Req.RequestURI, "refresh", "1")
254+
255+
if ctx.FormBool("refresh") {
256+
ctx.HTML(http.StatusOK, "shared/actions/queue_list")
257+
return
258+
}
259+
ctx.HTML(http.StatusOK, rCtx.RunnersTemplate)
260+
}
261+
171262
// RunnersEdit renders runner edit page for repository level
172263
func RunnersEdit(ctx *context.Context) {
173264
ctx.Data["PageIsSharedSettingsRunners"] = true

routers/web/web.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
509509
})
510510
}
511511

512+
addSettingsQueueRoutes := func() {
513+
m.Group("/queue", func() {
514+
m.Get("", shared_actions.Queue)
515+
})
516+
}
517+
512518
// FIXME: not all routes need go through same middleware.
513519
// Especially some AJAX requests, we can reduce middleware number to improve performance.
514520

@@ -712,6 +718,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
712718
addSettingsSecretsRoutes()
713719
addSettingsVariablesRoutes()
714720
addSettingsScopedWorkflowsRoutes()
721+
addSettingsQueueRoutes()
715722
}, actions.MustEnableActions)
716723

717724
m.Get("/organization", user_setting.Organization)
@@ -876,6 +883,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
876883
m.Post("/runners/bulk", shared_actions.RunnerBulkActionPost)
877884
addSettingsVariablesRoutes()
878885
addSettingsScopedWorkflowsRoutes()
886+
addSettingsQueueRoutes()
879887
})
880888
}, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled))
881889
// ***** END: Admin *****
@@ -1034,6 +1042,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
10341042
addSettingsSecretsRoutes()
10351043
addSettingsVariablesRoutes()
10361044
addSettingsScopedWorkflowsRoutes()
1045+
addSettingsQueueRoutes()
10371046
}, actions.MustEnableActions)
10381047

10391048
m.Post("/rename", web.Bind(forms.RenameOrgForm{}), org.SettingsRenamePost)
@@ -1245,6 +1254,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
12451254
addSettingsRunnersRoutes()
12461255
addSettingsSecretsRoutes()
12471256
addSettingsVariablesRoutes()
1257+
addSettingsQueueRoutes()
12481258
m.Group("/general", func() {
12491259
m.Group("/collaborative_owner", func() {
12501260
m.Post("/add", repo_setting.AddCollaborativeOwner)

templates/admin/actions.tmpl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,8 @@
99
{{if eq .PageType "scoped-workflows"}}
1010
{{template "shared/actions/scoped_workflows" .}}
1111
{{end}}
12+
{{if eq .PageType "queue"}}
13+
{{template "shared/actions/queue_list" .}}
14+
{{end}}
1215
</div>
1316
{{template "admin/layout_footer" .}}

templates/admin/navbar.tmpl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
{{end}}
7373
{{end}}
7474
{{if .EnableActions}}
75-
<details class="item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsVariables .PageIsSharedSettingsScopedWorkflows}}open{{end}}>
75+
<details class="item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsVariables .PageIsSharedSettingsScopedWorkflows .PageIsSharedSettingsQueue}}open{{end}}>
7676
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
7777
<div class="menu">
7878
<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{AppSubUrl}}/-/admin/actions/runners">
@@ -84,6 +84,9 @@
8484
<a class="{{if .PageIsSharedSettingsScopedWorkflows}}active {{end}}item" href="{{AppSubUrl}}/-/admin/actions/scoped-workflows">
8585
{{ctx.Locale.Tr "actions.scoped_workflows"}}
8686
</a>
87+
<a class="{{if .PageIsSharedSettingsQueue}}active {{end}}item" href="{{AppSubUrl}}/-/admin/actions/queue">
88+
{{ctx.Locale.Tr "actions.queue"}}
89+
</a>
8790
</div>
8891
</details>
8992
{{end}}

templates/org/settings/actions.tmpl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
{{template "shared/variables/variable_list" .}}
99
{{else if eq .PageType "scoped-workflows"}}
1010
{{template "shared/actions/scoped_workflows" .}}
11+
{{else if eq .PageType "queue"}}
12+
{{template "shared/actions/queue_list" .}}
1113
{{end}}
1214
</div>
1315
{{template "org/settings/layout_footer" .}}

templates/org/settings/navbar.tmpl

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
</a>
2727
{{end}}
2828
{{if .EnableActions}}
29-
<details class="item" {{if or .PageIsOrgSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables .PageIsSharedSettingsScopedWorkflows}}open{{end}}>
29+
<details class="item" {{if or .PageIsOrgSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables .PageIsSharedSettingsScopedWorkflows .PageIsSharedSettingsQueue}}open{{end}}>
3030
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
3131
<div class="menu">
3232
<a class="{{if .PageIsOrgSettingsActionsGeneral}}active {{end}}item" href="{{.OrgLink}}/settings/actions">
@@ -44,6 +44,9 @@
4444
<a class="{{if .PageIsSharedSettingsScopedWorkflows}}active {{end}}item" href="{{.OrgLink}}/settings/actions/scoped-workflows">
4545
{{ctx.Locale.Tr "actions.scoped_workflows"}}
4646
</a>
47+
<a class="{{if .PageIsSharedSettingsQueue}}active {{end}}item" href="{{.OrgLink}}/settings/actions/queue">
48+
{{ctx.Locale.Tr "actions.queue"}}
49+
</a>
4750
</div>
4851
</details>
4952
{{end}}

templates/repo/settings/actions.tmpl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
{{template "shared/variables/variable_list" .}}
99
{{else if eq .PageType "general"}}
1010
{{template "repo/settings/actions_general" .}}
11+
{{else if eq .PageType "queue"}}
12+
{{template "shared/actions/queue_list" .}}
1113
{{end}}
1214
</div>
1315
{{template "repo/settings/layout_footer" .}}

0 commit comments

Comments
 (0)