fix(actions): improve runner list status sorting, labels and task job links (#38586) (#38633)

Backport #38586 by @bircni

Several small fixes to the Actions runner management UI.

### Runner task list links to the job, not the workflow run
Relabeled the first column from "Run" to "Job"; it now shows the job ID
and links to the specific job (`/actions/runs/{runID}/jobs/{jobID}`).
Renamed locale key `task_list.run` to `task_list.job`.

### Missing "Disabled" translation
The runner list rendered a grey label via `actions.runners.disabled`,
but that key
did not exist in `locale_en-US.json`, so the raw key string leaked into
the UI.
Replaced `"actions.runners.disabled"` with `"disabled"`.

### Status column sorting ignored active vs idle
Sorting by status ordered purely on `last_online`, but the displayed
status is
computed from both `last_online` (offline) and `last_active` (idle vs
active).
As a result idle runners were interleaved with active ones. Sorting now
ranks by
the computed status (active → idle → offline). Disabled runners sink to
the bottom
of their status group (`is_disabled` as a secondary key), with
`last_online`/`id`
as stable tiebreakers so pagination stays deterministic.

### Status label colors
Active and idle both rendered green. Idle is now yellow, active green,
and
offline/unknown grey; the separate grey "Disabled" badge is unchanged.
This keeps
connectivity visible even for disabled runners (e.g. a disabled runner
still shows
whether it is idle or offline).

<img width="715" height="406" alt="image"
src="https://github.com/user-attachments/assets/9ef06aa8-a870-4de5-9d94-603a58186908"
/>

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
Giteabot
2026-07-25 15:02:40 -07:00
committed by GitHub
parent 31c435454b
commit 375e6ea038
8 changed files with 184 additions and 84 deletions
+21 -5
View File
@@ -270,15 +270,31 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
return cond
}
// runnerStatusOrderExpr builds an ORDER BY fragment that ranks runners by their
// computed status (see ActionRunner.Status): active (0), idle (1), offline (2).
// The thresholds are evaluated against the current time, mirroring ToConds, so
// sorting by status groups active and idle runners instead of interleaving them
// by raw last_online.
func runnerStatusOrderExpr() string {
now := time.Now()
offlineThreshold := now.Add(-RunnerOfflineTime).Unix()
idleThreshold := now.Add(-RunnerIdleTime).Unix()
return fmt.Sprintf("CASE WHEN last_online <= %d THEN 2 WHEN last_active <= %d THEN 1 ELSE 0 END", offlineThreshold, idleThreshold)
}
func (opts FindRunnerOptions) ToOrders() string {
// A unique tiebreaker (id) is appended so that runners sharing the same
// last_online or name keep a deterministic order across paginated queries,
// otherwise the same runner may appear on more than one page.
// status, last_online or name keep a deterministic order across paginated
// queries, otherwise the same runner may appear on more than one page.
statusRank := runnerStatusOrderExpr()
switch opts.Sort {
case "online":
return "last_online DESC, id ASC"
// Rank by computed status first so idle runners are not interleaved with
// active ones; disabled runners sink to the bottom of their status group
// (is_disabled ASC), then last_online breaks ties within a group.
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
case "offline":
return "last_online ASC, id ASC"
return statusRank + " DESC, is_disabled ASC, last_online ASC, id ASC"
case "alphabetically":
return "name ASC, id ASC"
case "reversealphabetically":
@@ -288,7 +304,7 @@ func (opts FindRunnerOptions) ToOrders() string {
case "oldest":
return "id ASC"
}
return "last_online DESC, id ASC"
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
}
// GetRunnerByUUID returns a runner via uuid
-66
View File
@@ -4,16 +4,12 @@
package actions
import (
"fmt"
"testing"
"time"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldPersistLastOnline(t *testing.T) {
@@ -85,65 +81,3 @@ func TestShouldPersistLastActive(t *testing.T) {
})
}
}
func TestFindRunnerOptions_ToOrders_StableTiebreaker(t *testing.T) {
// Sorts on a non-unique column must end with the unique id tiebreaker so
// pagination is deterministic; without it, runners sharing the same
// last_online or name can appear on more than one page. Sorts already on
// the unique id need no tiebreaker.
expected := map[string]string{
"": "last_online DESC, id ASC",
"online": "last_online DESC, id ASC",
"offline": "last_online ASC, id ASC",
"alphabetically": "name ASC, id ASC",
"reversealphabetically": "name DESC, id ASC",
"newest": "id DESC",
"oldest": "id ASC",
}
for sort, want := range expected {
assert.Equal(t, want, FindRunnerOptions{Sort: sort}.ToOrders(), "sort %q", sort)
}
}
func TestFindRunners_PaginationNoDuplicates(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// Create several runners that all share the same last_online value so the
// primary sort key (last_online) is tied for all of them.
const ownerID = 1000
const count = 6
for i := range count {
runner := &ActionRunner{
Name: "paginated-runner",
UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i),
TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i),
OwnerID: ownerID,
RepoID: 0,
LastOnline: 42,
}
require.NoError(t, db.Insert(ctx, runner))
}
// Page through the runners and ensure every id is returned exactly once.
seen := make(map[int64]int)
const pageSize = 2
for page := 1; ; page++ {
runners, err := db.Find[ActionRunner](ctx, FindRunnerOptions{
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
OwnerID: ownerID,
})
require.NoError(t, err)
if len(runners) == 0 {
break
}
for _, r := range runners {
seen[r.ID]++
}
}
assert.Len(t, seen, count, "each runner should be returned exactly once across all pages")
for id, n := range seen {
assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n)
}
}
+7 -3
View File
@@ -85,11 +85,15 @@ func (task *ActionTask) IsStopped() bool {
return task.Stopped > 0
}
func (task *ActionTask) GetRunLink() string {
if task.Job == nil || task.Job.Run == nil {
func (task *ActionTask) GetRunJobLink() string {
// Run.Repo can be nil when the repository was deleted while task/run rows remain
// (TaskList.LoadAttributes copies job.Repo into run.Repo, leaving it nil on a miss).
// Run.Link() already returns "" in that case, so guard here to avoid emitting a
// broken relative "/jobs/N" link from the Sprintf below.
if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil {
return ""
}
return task.Job.Run.Link()
return fmt.Sprintf("%s/jobs/%d", task.Job.Run.Link(), task.Job.ID)
}
func (task *ActionTask) GetCommitLink() string {
+16
View File
@@ -9,6 +9,7 @@ import (
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/timeutil"
@@ -18,6 +19,21 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestActionTask_GetRunJobLink(t *testing.T) {
repo := &repo_model.Repository{OwnerName: "org", Name: "consumer"}
run := &ActionRun{ID: 10, Repo: repo}
job := &ActionRunJob{ID: 42, Run: run}
// a task with a loaded job links to that specific job, not just the run
task := &ActionTask{Job: job}
assert.Equal(t, run.Link()+"/jobs/42", task.GetRunJobLink())
// missing job, run or repo yields an empty link instead of a broken URL
assert.Empty(t, (&ActionTask{}).GetRunJobLink())
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42}}).GetRunJobLink())
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42, Run: &ActionRun{ID: 10}}}).GetRunJobLink())
}
func TestMakeTaskStepDisplayName(t *testing.T) {
tests := []struct {
name string