vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Phase 4: a runners page, built from the queue's own presence
ac43353160989ae56b6a92ae8f1df1017c2c20c4 · Keenan Nemetz · 17d ago · parent 760b523a
Phase 4: a runners page, built from the queue's own presence "Nobody is connected and eleven jobs are waiting" is the sentence somebody needs when a build does not start, and until now nothing could say it: a runner existed only as an SSH channel, invisible to both the web and the queue. `runs.db` could already answer "who holds this lease" (lease_owner, lease_until) and "who ran this" (the attestation), but not "who is connected and idle". Presence is now a row per channel in the queue — `runner_sessions` — written by events a runner already sends: connect, lease (which records the run it holds), heartbeat (a long build must not look gone), and finish (which clears it). A clean disconnect drops the row, and `watchLeases` forgets rows silent for ten minutes, which is the only trace a killed process leaves. Nothing here is authoritative: presence is a derived fact about a live channel, which is why the page applies its own two-minute window rather than trusting a stored "online" flag. GET /runners shows the connected runners (account, the job it is building and its repository, or "waiting for work") and the queue depth per repository. It follows the clone rule like every other page: a session building a private repository is not shown at all, and a private repository's queue is not counted, because naming either would confirm the repository exists. The header now links to it from every page. Measured: TestARunnerSessionIsVisibleWhileItIsConnected (a connected runner is one row, idle, then "building <run>" once it leases), TestRunnersPageShowsTheRunnerHoldingAJob AndTheQueueDepth (the page names the account, links the run it is building and counts one waiting and one running), TestRunnersPageKeepsAPrivateRepositoryOut (no repository name, account, job or run id from a private repository appears).
internal/web/runners.go +100
| @@ -0,0 +1,100 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | // The runners page. | ||
| 4 | // | ||
| 5 | // A runner is not an entity the forge stores: it is an SSH channel that leases jobs, | ||
| 6 | // authenticated by a key. So this page is built from two derived facts in the queue — | ||
| 7 | // the presence rows a session writes while it is connected, and the work it is | ||
| 8 | // holding — plus the queue depth per repository. Nothing here is authoritative; it | ||
| 9 | // answers "is my build waiting because nobody is listening", which is otherwise a | ||
| 10 | // question about a process on somebody else's machine. | ||
| 11 | // | ||
| 12 | // Visibility follows the clone rule, as every page does: a session building a private | ||
| 13 | // repository is not shown at all, because naming it would confirm that the repository | ||
| 14 | // exists, and a private repository's queue depth is not counted. | ||
| 15 | |||
| 16 | import ( | ||
| 17 | "net/http" | ||
| 18 | "sort" | ||
| 19 | "time" | ||
| 20 | ) | ||
| 21 | |||
| 22 | // presenceWindow is how long a silent runner is still shown. A resident runner | ||
| 23 | // refreshes presence at least once per lease wait (25s) or heartbeat (a third of its | ||
| 24 | // lease), so two minutes is a runner that died rather than one that is thinking. | ||
| 25 | const presenceWindow = 2 * time.Minute | ||
| 26 | |||
| 27 | type runnerView struct { | ||
| 28 | Account string | ||
| 29 | Repo string | ||
| 30 | RunID string | ||
| 31 | Job string | ||
| 32 | Seen time.Time | ||
| 33 | Idle bool | ||
| 34 | } | ||
| 35 | |||
| 36 | type queueView struct { | ||
| 37 | Name string | ||
| 38 | Queued int | ||
| 39 | Running int | ||
| 40 | } | ||
| 41 | |||
| 42 | // RunnersPage serves GET /runners: who is connected, what they are building, and how | ||
| 43 | // much work is waiting per repository. | ||
| 44 | func (w *Web) RunnersPage(rw http.ResponseWriter, r *http.Request) { | ||
| 45 | now := time.Now().UTC() | ||
| 46 | var sessions []runnerView | ||
| 47 | var queue []queueView | ||
| 48 | |||
| 49 | if w.Runs != nil { | ||
| 50 | rows, err := w.Runs.Sessions(now.Add(-presenceWindow)) | ||
| 51 | if err != nil { | ||
| 52 | w.Logger("runners page: sessions: %v", err) | ||
| 53 | http.Error(rw, "vole: cannot read the queue", http.StatusInternalServerError) | ||
| 54 | return | ||
| 55 | } | ||
| 56 | for _, s := range rows { | ||
| 57 | v := runnerView{Account: s.Account, Repo: s.Repo, RunID: s.RunID, Seen: s.LastSeen, Idle: s.RunID == ""} | ||
| 58 | if w.Ident != nil { | ||
| 59 | if name := w.Ident.Name(s.Account); name != "" { | ||
| 60 | v.Account = name | ||
| 61 | } | ||
| 62 | } | ||
| 63 | if s.Repo != "" { | ||
| 64 | m, err := w.Repos.Open(s.Repo) | ||
| 65 | if err != nil || !w.public(m) { | ||
| 66 | continue // a private repository's build is not an anonymous reader's business | ||
| 67 | } | ||
| 68 | if run, err := w.Runs.Get(s.RunID); err == nil && run != nil { | ||
| 69 | v.Job = run.Job | ||
| 70 | } | ||
| 71 | } | ||
| 72 | sessions = append(sessions, v) | ||
| 73 | } | ||
| 74 | |||
| 75 | depths, err := w.Runs.QueueByRepo() | ||
| 76 | if err != nil { | ||
| 77 | w.Logger("runners page: queue depth: %v", err) | ||
| 78 | } | ||
| 79 | for name, d := range depths { | ||
| 80 | m, err := w.Repos.Open(name) | ||
| 81 | if err != nil || !w.public(m) { | ||
| 82 | continue | ||
| 83 | } | ||
| 84 | queue = append(queue, queueView{Name: name, Queued: d.Queued, Running: d.Running}) | ||
| 85 | } | ||
| 86 | sort.Slice(queue, func(i, j int) bool { return queue[i].Name < queue[j].Name }) | ||
| 87 | } | ||
| 88 | |||
| 89 | w.render(rw, "runners", struct { | ||
| 90 | Page | ||
| 91 | Sessions []runnerView | ||
| 92 | Queue []queueView | ||
| 93 | Window string | ||
| 94 | }{ | ||
| 95 | Page: w.base("Runners"), | ||
| 96 | Sessions: sessions, | ||
| 97 | Queue: queue, | ||
| 98 | Window: presenceWindow.String(), | ||
| 99 | }, 0) | ||
| 100 | } | ||