Runners: Connectivity and Status apart, a heartbeat, a filterable queue e9b9619cd857
A runner whose slots are full claims nothing, so a quiet job (an image fetch, a silent test suite) left it 'offline' while it worked (mars-1 and earth-1 on 2026-10-16). Admin > Runners now shows Connectivity (online or offline: when the hub last heard from it) apart from Status (disabled, draining, idle, or running N of M, the jobs and their minutes in its tooltip); POST /api/v1/flow/runners/heartbeat lets a busy runner say it is there (the runner's side is OxygenFlow's). A runner's status opens the queue filtered to it; the queue filters by repository, status and runner, kept in the URL, and shows when each job started. Andy's design. Refs #243
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 22:20 UTC
- Signature
- ✔ signed by Claude Code
- Hash
e9b9619cd85737c567054334f753cb0793f98f97d929b61cbd933ac9a3b4bc22- Depends on
- 42fa95540468 4d2e44d79b03 74be97962ed8 750579ecc721 a46d15e72101 b66e70b7b6c7 b6b7e4cf6b24 bf3d07541368 c4c87f481d05 d23464c0ac60 efd29a0033e8
- Depended on by
- 8501fd5815b9 03bfb945f5c1 e97500b70f9a 107eaba52c9d
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 25 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified crates/hub/src/api/mod.rs +1 −0
| … | 43 unchanged lines |
||
| 44 | 44 | .fallback(no_route) |
|
| 45 | 45 | .route("/flow/claim", post(crate::flow::claim)) |
|
| 46 | 46 | .route("/flow/runners/register", post(crate::flow::register)) |
|
| 47 | + | .route("/flow/runners/heartbeat", post(crate::flow::heartbeat)) |
|
| 47 | 48 | .route("/flow/images/missing", post(images::missing).layer(axum::extract::DefaultBodyLimit::max(images::MAX_ASK_BYTES))) |
|
| 48 | 49 | .route("/flow/swarm/announce", post(crate::swarm::announce)) |
|
| 49 | 50 | .route("/flow/images/chunks/{hash}", axum::routing::put(images::put_chunk).get(images::get_chunk)) |
|
| … | 2358 unchanged lines |
modified crates/hub/src/db/flow.rs +3 −1
| … | 264 unchanged lines |
||
| 265 | 265 | pub runner: String, |
|
| 266 | 266 | pub spec: String, |
|
| 267 | 267 | pub sandbox: String, |
|
| 268 | + | /// When it started running. |
|
| 269 | + | pub started_ms: Option<i64>, |
|
| 268 | 270 | } |
|
| 269 | 271 | ||
| 270 | 272 | impl QueueEntry { |
|
| … | 12 unchanged lines |
||
| 283 | 285 | /// then queued ones in the order runners claim them (hub spec §13.2). |
|
| 284 | 286 | pub async fn queue(pool: &Pool) -> sqlx::Result<Vec<QueueEntry>> { |
|
| 285 | 287 | sqlx::query_as( |
|
| 286 | - | "SELECT j.id AS job_id, j.run_id, r.repo_id, p.owner, p.name, p.visibility, r.number AS run_number, j.name AS job, j.status, j.runner, j.spec, j.sandbox \ |
|
| 288 | + | "SELECT j.id AS job_id, j.run_id, r.repo_id, p.owner, p.name, p.visibility, r.number AS run_number, j.name AS job, j.status, j.runner, j.spec, j.sandbox, j.started_ms \ |
|
| 287 | 289 | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id \ |
|
| 288 | 290 | WHERE j.status IN ('queued', 'running') AND r.status IN ('queued', 'running') \ |
|
| 289 | 291 | ORDER BY CASE j.status WHEN 'running' THEN 0 ELSE 1 END, j.run_id, j.depth, j.id", |
|
| … | 678 unchanged lines |
modified crates/hub/src/db/runners.rs +10 −0
| … | 70 unchanged lines |
||
| 71 | 71 | self.last_seen_ms.is_some_and(|t| now_ms() - t < ONLINE_MS) |
|
| 72 | 72 | } |
|
| 73 | 73 | ||
| 74 | + | /// Whether the hub has heard from it lately (oxygenhub#243): any call it |
|
| 75 | + | /// makes counts, a busy runner's heartbeat included. |
|
| 76 | + | pub fn connectivity(&self) -> &'static str { |
|
| 77 | + | if self.online() { |
|
| 78 | + | "online" |
|
| 79 | + | } else { |
|
| 80 | + | "offline" |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | ||
| 74 | 84 | /// `online`, `offline`, `draining` or `disabled`. |
|
| 75 | 85 | pub fn status(&self) -> &'static str { |
|
| 76 | 86 | match self.state.as_str() { |
|
| … | 350 unchanged lines |
modified crates/hub/src/flow.rs +9 −0
| … | 757 unchanged lines |
||
| 758 | 758 | /// [`fdb::DECLINE_HOLDS_MS`] at most); once [`UNPLACEABLE_AFTER`] |
|
| 759 | 759 | /// different runners have declined it, it ends as unplaceable with their |
|
| 760 | 760 | /// reasons. A decline is never a failed check. |
|
| 761 | + | /// `POST /api/v1/flow/runners/heartbeat` (oxygenhub#243): a runner whose |
|
| 762 | + | /// job slots are all full claims nothing, so it says it is there. |
|
| 763 | + | pub async fn heartbeat(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> { |
|
| 764 | + | let me = runner_auth(&app, &headers).await?; |
|
| 765 | + | db::runners::seen(&app.db, me.id, None, None).await?; |
|
| 766 | + | Ok(StatusCode::NO_CONTENT) |
|
| 767 | + | } |
|
| 768 | + | ||
| 761 | 769 | pub async fn decline( |
|
| 762 | 770 | State(app): State<Shared>, |
|
| 763 | 771 | headers: HeaderMap, |
|
| … | 1052 unchanged lines |
||
| 1816 | 1824 | runner: String::new(), |
|
| 1817 | 1825 | spec: String::new(), |
|
| 1818 | 1826 | sandbox: String::new(), |
|
| 1827 | + | started_ms: None, |
|
| 1819 | 1828 | } |
|
| 1820 | 1829 | } |
|
| 1821 | 1830 | ||
| … | 96 unchanged lines |
modified crates/hub/src/web/runners.rs +102 −6
| … | 28 unchanged lines |
||
| 29 | 29 | /// Where its peers reach it in the image swarm, and its site |
|
| 30 | 30 | /// (oxygenhub#144); empty when it is not in one. |
|
| 31 | 31 | pub swarm: String, |
|
| 32 | + | /// The jobs it runs now: (repository, run, job, started). |
|
| 33 | + | pub jobs: Vec<(String, i64, String, Option<i64>)>, |
|
| 32 | 34 | } |
|
| 33 | 35 | ||
| 36 | + | impl RunnerRow { |
|
| 37 | + | /// What it is doing (oxygenhub#243): `disabled`, `draining`, `idle`, |
|
| 38 | + | /// or `running N of M`. |
|
| 39 | + | pub fn doing(&self) -> String { |
|
| 40 | + | match self.r.state.as_str() { |
|
| 41 | + | "disabled" => "disabled".into(), |
|
| 42 | + | "draining" if self.running > 0 => format!("draining, {} left", self.running), |
|
| 43 | + | "draining" => "draining".into(), |
|
| 44 | + | _ if self.running > 0 => format!("running {} of {}", self.running, self.r.max_jobs), |
|
| 45 | + | _ => "idle".into(), |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | /// The jobs it runs, for the status's tooltip. |
|
| 50 | + | pub fn jobs_line(&self) -> String { |
|
| 51 | + | let now = crate::db::now_ms(); |
|
| 52 | + | self.jobs |
|
| 53 | + | .iter() |
|
| 54 | + | .map(|(repo, run, job, started)| match started { |
|
| 55 | + | Some(t) => format!("{repo} #{run} {job}, {} min", (now - t).max(0) / 60_000), |
|
| 56 | + | None => format!("{repo} #{run} {job}"), |
|
| 57 | + | }) |
|
| 58 | + | .collect::<Vec<_>>() |
|
| 59 | + | .join("; ") |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | /// The queue's filters (oxygenhub#243), kept in the URL. |
|
| 64 | + | #[derive(Deserialize, Default, Clone)] |
|
| 65 | + | pub struct QueueFilter { |
|
| 66 | + | #[serde(default)] |
|
| 67 | + | pub repo: String, |
|
| 68 | + | #[serde(default)] |
|
| 69 | + | pub status: String, |
|
| 70 | + | #[serde(default)] |
|
| 71 | + | pub runner: String, |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | impl QueueFilter { |
|
| 75 | + | fn keeps(&self, q: &db::flow::QueueEntry) -> bool { |
|
| 76 | + | (self.repo.is_empty() || format!("{}/{}", q.owner, q.name) == self.repo) |
|
| 77 | + | && (self.status.is_empty() || q.status == self.status) |
|
| 78 | + | && (self.runner.is_empty() || q.runner == self.runner) |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | pub fn any(&self) -> bool { |
|
| 82 | + | !(self.repo.is_empty() && self.status.is_empty() && self.runner.is_empty()) |
|
| 83 | + | } |
|
| 84 | + | } |
|
| 85 | + | ||
| 34 | 86 | /// A scope in words: the whole site, an owner's repositories, or one |
|
| 35 | 87 | /// repository. |
|
| 36 | 88 | async fn scope_words(app: &Shared, s: &runners::Scope) -> String { |
|
| … | 41 unchanged lines |
||
| 78 | 130 | hub: String, |
|
| 79 | 131 | notice: Option<String>, |
|
| 80 | 132 | error: Option<String>, |
|
| 81 | - | /// Running jobs, then queued ones in the order runners claim them. |
|
| 133 | + | /// Running jobs, then queued ones in the order runners claim them, |
|
| 134 | + | /// as the filter keeps them. |
|
| 82 | 135 | queue: Vec<db::flow::QueueEntry>, |
|
| 136 | + | /// How many there are, unfiltered. |
|
| 137 | + | queue_total: usize, |
|
| 138 | + | filter: QueueFilter, |
|
| 139 | + | /// The repositories and runners in the queue, for the filter's choices. |
|
| 140 | + | queue_repos: Vec<String>, |
|
| 141 | + | queue_runners: Vec<String>, |
|
| 83 | 142 | } |
|
| 84 | 143 | ||
| 85 | 144 | async fn page( |
|
| … | 3 unchanged lines |
||
| 89 | 148 | notice: Option<String>, |
|
| 90 | 149 | error: Option<String>, |
|
| 91 | 150 | ) -> Result<Page<RunnersPage>, Error> { |
|
| 151 | + | page_filtered(app, shell, new_token, notice, error, QueueFilter::default()).await |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | async fn page_filtered( |
|
| 155 | + | app: &Shared, |
|
| 156 | + | shell: Shell, |
|
| 157 | + | new_token: Option<String>, |
|
| 158 | + | notice: Option<String>, |
|
| 159 | + | error: Option<String>, |
|
| 160 | + | filter: QueueFilter, |
|
| 161 | + | ) -> Result<Page<RunnersPage>, Error> { |
|
| 92 | 162 | let e = |e| Error::from_db(shell.clone(), e); |
|
| 93 | 163 | let list = runners::list(&app.db).await.map_err(e)?; |
|
| 94 | 164 | let running: HashMap<i64, i64> = runners::running(&app.db).await.map_err(e)?.into_iter().collect(); |
|
| 95 | 165 | let pending = runners::pending_registrations(&app.db).await.map_err(e)?; |
|
| 166 | + | let all = db::flow::queue(&app.db).await.map_err(e)?; |
|
| 96 | 167 | let mut rows = Vec::with_capacity(list.len()); |
|
| 97 | 168 | for r in list { |
|
| 98 | 169 | let scope = scope_words(app, &r.scope()).await; |
|
| … | 2 unchanged lines |
||
| 101 | 172 | .addr(r.id) |
|
| 102 | 173 | .map(|s| if s.site.is_empty() { s.addr } else { format!("{} · site {}", s.addr, s.site) }) |
|
| 103 | 174 | .unwrap_or_default(); |
|
| 104 | - | rows.push(RunnerRow { running: running.get(&r.id).copied().unwrap_or(0), scope, swarm, r }); |
|
| 175 | + | let jobs = all |
|
| 176 | + | .iter() |
|
| 177 | + | .filter(|q| q.status == "running" && q.runner == r.name) |
|
| 178 | + | .map(|q| (format!("{}/{}", q.owner, q.name), q.run_number, q.job.clone(), q.started_ms)) |
|
| 179 | + | .collect(); |
|
| 180 | + | rows.push(RunnerRow { running: running.get(&r.id).copied().unwrap_or(0), scope, swarm, jobs, r }); |
|
| 105 | 181 | } |
|
| 106 | - | let queue = db::flow::queue(&app.db).await.map_err(e)?; |
|
| 107 | - | Ok(Page(RunnersPage { hub: app.config.base_url().to_string(), shell, runners: rows, pending, new_token, notice, error, queue })) |
|
| 182 | + | let mut queue_repos: Vec<String> = all.iter().map(|q| format!("{}/{}", q.owner, q.name)).collect(); |
|
| 183 | + | queue_repos.sort(); |
|
| 184 | + | queue_repos.dedup(); |
|
| 185 | + | let mut queue_runners: Vec<String> = all.iter().filter(|q| !q.runner.is_empty()).map(|q| q.runner.clone()).collect(); |
|
| 186 | + | queue_runners.sort(); |
|
| 187 | + | queue_runners.dedup(); |
|
| 188 | + | let queue_total = all.len(); |
|
| 189 | + | let queue = all.into_iter().filter(|q| filter.keeps(q)).collect(); |
|
| 190 | + | Ok(Page(RunnersPage { |
|
| 191 | + | hub: app.config.base_url().to_string(), |
|
| 192 | + | shell, |
|
| 193 | + | runners: rows, |
|
| 194 | + | pending, |
|
| 195 | + | new_token, |
|
| 196 | + | notice, |
|
| 197 | + | error, |
|
| 198 | + | queue, |
|
| 199 | + | queue_total, |
|
| 200 | + | filter, |
|
| 201 | + | queue_repos, |
|
| 202 | + | queue_runners, |
|
| 203 | + | })) |
|
| 108 | 204 | } |
|
| 109 | 205 | ||
| 110 | 206 | /// `GET /admin/runners`. |
|
| 111 | - | pub async fn list(State(app): State<Shared>, viewer: Viewer) -> Result<Page<RunnersPage>, Error> { |
|
| 207 | + | pub async fn list(State(app): State<Shared>, viewer: Viewer, Query(filter): Query<QueueFilter>) -> Result<Page<RunnersPage>, Error> { |
|
| 112 | 208 | let shell = Shell::new(&app, "/admin/runners", &viewer); |
|
| 113 | 209 | require_admin(&viewer, &shell)?; |
|
| 114 | - | page(&app, shell, None, None, None).await |
|
| 210 | + | page_filtered(&app, shell, None, None, None, filter).await |
|
| 115 | 211 | } |
|
| 116 | 212 | ||
| 117 | 213 | #[derive(Deserialize)] |
|
| … | 299 unchanged lines |
modified crates/hub/tests/flow.rs +16 −1
| … | 1364 unchanged lines |
||
| 1365 | 1365 | ); |
|
| 1366 | 1366 | // The queue on Admin → Runners and through the API (#41, part 4). |
|
| 1367 | 1367 | let (_, page) = web("GET", "/admin/runners".into(), None).await; |
|
| 1368 | - | assert!(page.contains("<h2>Queue</h2>") && page.contains("<td>plex</td>"), "{page}"); |
|
| 1368 | + | assert!(page.contains("id=\"queue\"") && page.contains(">plex</a></td>"), "{page}"); |
|
| 1369 | + | // Connectivity and what it is doing, kept apart (#243); its status |
|
| 1370 | + | // opens the queue filtered to it, and the filters keep and drop jobs. |
|
| 1371 | + | assert!(page.contains("running 1 of ") && page.contains("/admin/runners?runner=plex#queue"), "{page}"); |
|
| 1372 | + | let (_, only) = web("GET", "/admin/runners?runner=plex".into(), None).await; |
|
| 1373 | + | assert!(only.contains(">plex</a></td>") && only.contains(" of ") && only.contains(">all</a>"), "{only}"); |
|
| 1374 | + | let (_, none) = web("GET", "/admin/runners?runner=nobody&status=running".into(), None).await; |
|
| 1375 | + | assert!(none.contains("Nothing matches."), "{none}"); |
|
| 1376 | + | // A busy runner claims nothing; its heartbeat keeps it online. |
|
| 1377 | + | sqlx::query("UPDATE flow_runners SET last_seen_ms = 0 WHERE id = $1").bind(plex.id).execute(&h.app.db).await.unwrap(); |
|
| 1378 | + | let (_, page) = web("GET", "/admin/runners".into(), None).await; |
|
| 1379 | + | assert!(page.contains("st-offline"), "quiet for long: offline"); |
|
| 1380 | + | assert_eq!(call(&h.app, "POST", "/api/v1/flow/runners/heartbeat", Some(&credential), None).await.0, StatusCode::NO_CONTENT); |
|
| 1381 | + | assert_eq!(call(&h.app, "POST", "/api/v1/flow/runners/heartbeat", Some("flowrunner_nope"), None).await.0, StatusCode::UNAUTHORIZED); |
|
| 1382 | + | let plex_now = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "plex").unwrap(); |
|
| 1383 | + | assert_eq!(plex_now.connectivity(), "online"); |
|
| 1369 | 1384 | let (_, tokens) = web("GET", "/settings/tokens".into(), None).await; |
|
| 1370 | 1385 | let (_, made) = web("POST", "/settings/tokens".into(), Some(format!("_csrf={}&name=admin&scopes=user%3Aread", csrf_of(&tokens)))).await; |
|
| 1371 | 1386 | let i = made.find("oxyhub_").unwrap(); |
|
| … | 1660 unchanged lines |
modified templates/admin_runners.html +14 −8
| … | 17 unchanged lines |
||
| 18 | 18 | <section class="panel"> |
|
| 19 | 19 | {% if runners.is_empty() %}<p class="mute">No runners yet. Create a registration token below and run <code>flow runner register</code> on the runner.</p>{% else %} |
|
| 20 | 20 | <div class="table"><table> |
|
| 21 | - | <tr><th>Runner</th><th>Status</th><th>Serves</th><th>Signing key</th><th>Machine</th><th>Labels</th><th>Capacity</th><th>Running</th><th>Last seen</th><th></th></tr> |
|
| 21 | + | <tr><th>Runner</th><th>Connectivity</th><th>Status</th><th>Serves</th><th>Signing key</th><th>Machine</th><th>Labels</th><th>Capacity</th><th>Last seen</th><th></th></tr> |
|
| 22 | 22 | {% for row in runners %}{% let r = row.r %} |
|
| 23 | 23 | <tr> |
|
| 24 | 24 | <td><a href="/admin/runners/{{ r.id }}"><strong>{{ r.name }}</strong></a>{% if r.source == "config" %} <span class="tag" title="Configured with a hub.toml token">hub.toml</span>{% endif %}</td> |
|
| 25 | - | <td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td> |
|
| 25 | + | <td><span class="runner-status st-{{ r.connectivity() }}">{{ r.connectivity() }}</span></td> |
|
| 26 | + | <td class="small"><a href="/admin/runners?runner={{ r.name|urlencode }}#queue" title="{% if row.jobs.is_empty() %}Its jobs in the queue{% else %}{{ row.jobs_line() }}{% endif %}">{{ row.doing() }}</a></td> |
|
| 26 | 27 | <td class="small">{{ row.scope }} |
|
| 27 | 28 | <form class="tiny" method="post" action="/admin/runners/{{ r.id }}/scope" title="Empty for the whole site, an owner, or owner/repository"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="text" name="scope" size="14" placeholder="owner or owner/repo"> <button>set</button></form></td> |
|
| 28 | 29 | <td>{% if r.key.is_empty() %}<span class="mute" title="Bound on its first signed receipt">not yet</span>{% else %}<code title="{{ r.key }} · {{ r.key_name }}">{{ r.key|short }}</code>{% endif %}</td> |
|
| 29 | 30 | <td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }} · flow {{ r.version }}{% endif %}{% let f = r.facts_line() %}{% if !f.is_empty() %}<br><span class="mute" title="Measured by the runner at its last claim{% if !r.kernel.is_empty() %}; kernel {{ r.kernel }}{% endif %}">{{ f }}</span>{% endif %}{% if !row.swarm.is_empty() %}<br><span class="mute" title="Where other runners fetch image chunks from it">swarm {{ row.swarm }}</span>{% endif %}</td> |
|
| 30 | 31 | <td class="small">{% for l in r.label_list() %}<span class="lbl">{{ l }}</span> {% endfor %}</td> |
|
| 31 | 32 | <td class="small">{{ r.max_jobs|plural("job", "jobs") }}{% if let Some(m) = r.memory_mib %} · {{ m }} MiB{% endif %}{% if let Some(c) = r.cpus %} · {{ c }} vCPU{% endif %}</td> |
|
| 32 | - | <td>{{ row.running }}</td> |
|
| 33 | 33 | <td class="small">{% if let Some(t) = r.last_seen_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td> |
|
| 34 | 34 | <td><form class="tiny" method="post" action="/admin/runners/{{ r.id }}/state"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 35 | 35 | {% if r.state == "active" %}<button name="state" value="draining" title="Finish current jobs, take no new ones">drain</button> <button name="state" value="disabled" title="Refuse its requests at once">disable</button>{% else %}<button name="state" value="active">resume</button>{% endif %}</form> |
|
| … | 3 unchanged lines |
||
| 39 | 39 | </table></div> |
|
| 40 | 40 | {% endif %} |
|
| 41 | 41 | </section> |
|
| 42 | - | <section class="panel"> |
|
| 43 | - | <h2>Queue</h2> |
|
| 44 | - | {% if queue.is_empty() %}<p class="mute small">Nothing running or waiting.</p>{% else %} |
|
| 42 | + | <section class="panel" id="queue"> |
|
| 43 | + | <h2>Queue <small>{% if filter.any() %}{{ queue.len() }} of {{ queue_total }} · <a href="/admin/runners#queue">all</a>{% else %}{{ queue_total|plural("job", "jobs") }}{% endif %}</small></h2> |
|
| 44 | + | <form class="filters" method="get" action="/admin/runners#queue"> |
|
| 45 | + | <select name="repo" aria-label="Repository"><option value="">every repository</option>{% for r in queue_repos %}<option{% if *r == filter.repo %} selected{% endif %}>{{ r }}</option>{% endfor %}</select> |
|
| 46 | + | <select name="status" aria-label="Status"><option value="">running and queued</option><option value="running"{% if filter.status == "running" %} selected{% endif %}>running</option><option value="queued"{% if filter.status == "queued" %} selected{% endif %}>queued</option></select> |
|
| 47 | + | <select name="runner" aria-label="Runner"><option value="">every runner</option>{% for r in queue_runners %}<option{% if *r == filter.runner %} selected{% endif %}>{{ r }}</option>{% endfor %}{% if !filter.runner.is_empty() && !queue_runners.contains(filter.runner) %}<option selected>{{ filter.runner }}</option>{% endif %}</select> |
|
| 48 | + | <button>filter</button> |
|
| 49 | + | </form> |
|
| 50 | + | {% if queue.is_empty() %}<p class="mute small">{% if filter.any() %}Nothing matches.{% else %}Nothing running or waiting.{% endif %}</p>{% else %} |
|
| 45 | 51 | <div class="table"><table> |
|
| 46 | - | <tr><th>Repository</th><th>Run</th><th>Job</th><th>Status</th><th>Runner</th></tr> |
|
| 47 | - | {% for q in queue %}<tr><td><a href="/{{ q.owner }}/{{ q.name }}">{{ q.owner }}/{{ q.name }}</a></td><td><a href="/{{ q.owner }}/{{ q.name }}/flow/{{ q.run_number }}">#{{ q.run_number }}</a></td><td>{{ q.job }}</td><td>{{ q.status }}</td><td>{% if q.runner.is_empty() %}<span class="mute">waiting{% let needs = q.needs() %}{% if !needs.is_empty() %} for a runner with {% for l in needs %}<span class="lbl">{{ l }}</span> {% endfor %}{% endif %}{% if let Some(a) = q.arch() %} on <span class="lbl">{{ a }}</span>{% endif %}</span>{% else %}{{ q.runner }}{% endif %}</td></tr>{% endfor %} |
|
| 52 | + | <tr><th>Repository</th><th>Run</th><th>Job</th><th>Status</th><th>Started</th><th>Runner</th></tr> |
|
| 53 | + | {% for q in queue %}<tr><td><a href="/{{ q.owner }}/{{ q.name }}">{{ q.owner }}/{{ q.name }}</a></td><td><a href="/{{ q.owner }}/{{ q.name }}/flow/{{ q.run_number }}">#{{ q.run_number }}</a></td><td>{{ q.job }}</td><td>{{ q.status }}</td><td class="small">{% if let Some(t) = q.started_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}</td><td>{% if q.runner.is_empty() %}<span class="mute">waiting{% let needs = q.needs() %}{% if !needs.is_empty() %} for a runner with {% for l in needs %}<span class="lbl">{{ l }}</span> {% endfor %}{% endif %}{% if let Some(a) = q.arch() %} on <span class="lbl">{{ a }}</span>{% endif %}</span>{% else %}<a href="/admin/runners?runner={{ q.runner|urlencode }}#queue">{{ q.runner }}</a>{% endif %}</td></tr>{% endfor %} |
|
| 48 | 54 | </table></div> |
|
| 49 | 55 | <p class="small mute">Queued jobs are in the order runners claim them; a job waits for its dependencies and for a runner whose scope, tier and room fit it.</p> |
|
| 50 | 56 | {% endif %} |
|
| … | 12 unchanged lines |