OXYGENHUB

Settings → Runners for repository admins and organisation owners (refs #41, part 2b) 7974adb0be66

A repository's admins and an organisation's owners see the runners that
may take its jobs and what each serves, make a registration token for a
runner of their own scope, and remove such runners while they run
nothing. Site runners stay the site administrators'.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 02:48 UTC
Signature
✔ signed by Andy McCall
Hash
7974adb0be66458030ee0f2094ba198f12cca1d1765f5c0e03d6816bfcedc1e1
Depends on
18e381ea88c2 19bb31ff1f5c 3f4b0ea6eabf 42fa95540468 74be97962ed8 8fb35df7d66e 9008bf6c2b8c 93be9ce4653e 9756c67d3aec a160b98fb922 b6b7e4cf6b24 d23464c0ac60
Depended on by
dc5c64296b0a 5714347a6233 c7067c7bda56 8dcaeeb848c6 a7aa76ac52d7 76f93eaefe45 c8684d44d549 5d90e0e045e2 1870e4fa9808 7edaff01d506 8ddebaa7e856 1f78655a0466 4ac01a732552 2a83f5303458 e71846aa07b9 c4c87f481d05
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 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
16 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified crates/hub/src/db/runners.rs +22 −0
…
169 unchanged lines
170 170
    Ok(())
171 171
}
172 172
173 +
/// The runners that may take a repository's jobs: the site's, its owner's
174 +
/// and its own (oxygenhub#41).
175 +
pub async fn serving(pool: &Pool, owner: &str, repo_id: i64) -> sqlx::Result<Vec<Runner>> {
176 +
    sqlx::query_as(
177 +
        "SELECT * FROM flow_runners WHERE (scope_owner = '' AND scope_repo_id IS NULL) \
178 +
         OR (scope_owner = ? AND scope_repo_id IS NULL) OR scope_repo_id = ? ORDER BY name",
179 +
    )
180 +
    .bind(owner)
181 +
    .bind(repo_id)
182 +
    .fetch_all(pool)
183 +
    .await
184 +
}
185 +
186 +
/// The runners that serve an owner: the site's, and those scoped to the
187 +
/// owner or one of its repositories.
188 +
pub async fn serving_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Runner>> {
189 +
    sqlx::query_as("SELECT * FROM flow_runners WHERE (scope_owner = '' AND scope_repo_id IS NULL) OR scope_owner = ? ORDER BY name")
190 +
        .bind(owner)
191 +
        .fetch_all(pool)
192 +
        .await
193 +
}
194 +
173 195
/// Change the repositories a runner serves.
174 196
pub async fn set_scope(pool: &Pool, id: i64, scope: &Scope) -> sqlx::Result<()> {
175 197
    sqlx::query("UPDATE flow_runners SET scope_owner = ?, scope_repo_id = ? WHERE id = ?")
…
112 unchanged lines
modified crates/hub/src/web/mod.rs +4 −0
…
76 unchanged lines
77 77
        .route("/orgs/{name}/settings/members", post(orgs::member))
78 78
        .route("/orgs/{name}/settings/teams", post(orgs::team))
79 79
        .route("/orgs/{name}/settings/hooks", post(orgs::hooks))
80 +
        .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token))
81 +
        .route("/orgs/{name}/settings/runners/{id}/remove", post(orgs::runner_remove))
80 82
        .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries))
81 83
        .route("/orgs/{name}/settings/hooks/{id}/redeliver/{delivery}", post(orgs::redeliver))
82 84
        .route("/orgs/{name}/settings/avatar", post(orgs::avatar).layer(DefaultBodyLimit::max(avatar::MAX_BYTES + (64 << 10))))
…
97 unchanged lines
180 182
        .route("/{owner}/{name}/git-receive-pack", post(git_receive_pack))
181 183
        .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror))
182 184
        .route("/{owner}/{name}/settings/flow", post(repo_settings::flow))
185 +
        .route("/{owner}/{name}/settings/runners/token", post(repo_settings::runner_token))
186 +
        .route("/{owner}/{name}/settings/runners/{id}/remove", post(repo_settings::runner_remove))
183 187
        .route("/{owner}/{name}/issues", get(issues::list))
184 188
        .route("/{owner}/{name}/issues/new", get(issues::new_form).post(issues::create))
185 189
        .route("/{owner}/{name}/issue/{number}", get(issues::show))
…
413 unchanged lines
modified crates/hub/src/web/orgs.rs +62 −1
…
63 unchanged lines
64 64
    hooks: Vec<super::webhooks::HookRow>,
65 65
    notice: Option<String>,
66 66
    error: Option<String>,
67 +
    /// Runners serving the organisation: (runner, what it serves, whether
68 +
    /// it is the organisation's own).
69 +
    runners: Vec<(crate::db::runners::Runner, String, bool)>,
70 +
    /// A registration token just made, shown once.
71 +
    runner_token: Option<String>,
67 72
}
68 73
69 74
async fn org_page(
…
11 unchanged lines
81 86
    }
82 87
    let hooks = crate::db::hooks::hooks_for_org(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
83 88
    let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?;
84 -
    Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error }))
89 +
    let mut runners = Vec::new();
90 +
    for r in crate::db::runners::serving_owner(&app.db, &org.name).await.map_err(|e| Error::from_db(shell.clone(), e))? {
91 +
        let own = r.scope_owner == org.name;
92 +
        let serves = match r.scope_repo_id {
93 +
            Some(id) => crate::db::repos::get_by_id(&app.db, id).await.ok().flatten().map(|p| p.slug()).unwrap_or_default(),
94 +
            None if own => "the organisation's repositories".into(),
95 +
            None => "the whole site".into(),
96 +
        };
97 +
        runners.push((r, serves, own));
98 +
    }
99 +
    Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None }))
85 100
}
86 101
87 102
async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> {
…
208 unchanged lines
296 311
    let scope = super::webhooks::Scope::Org { id: org.id, name: org.name };
297 312
    super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?;
298 313
    super::webhooks::deliveries_page(&app, shell, &scope, None, id).await
314 +
}
315 +
316 +
/// `POST /orgs/{name}/settings/runners/token`: a one-time registration
317 +
/// token for a runner that serves the organisation's repositories
318 +
/// (oxygenhub#41).
319 +
pub async fn runner_token(
320 +
    State(app): State<Shared>,
321 +
    viewer: Viewer,
322 +
    Path(name): Path<String>,
323 +
    Form(f): Form<super::session::CsrfForm>,
324 +
) -> Result<Page<OrgSettingsPage>, Error> {
325 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
326 +
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
327 +
    csrf(&viewer, &f.csrf, &shell)?;
328 +
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
329 +
    let scope = crate::db::runners::Scope { owner: org.name.clone(), repo_id: None };
330 +
    crate::db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me.username, &scope)
331 +
        .await
332 +
        .map_err(|e| Error::from_db(shell.clone(), e))?;
333 +
    accounts::audit(&app.db, Some(&me), "flow.runner.token", &org.name, "registration token for the organisation").await.ok();
334 +
    let mut page = org_page(&app, shell, org, Some("Copy the token now; it is not shown again.".into()), None).await?;
335 +
    page.0.runner_token = Some(token);
336 +
    Ok(page)
337 +
}
338 +
339 +
/// `POST /orgs/{name}/settings/runners/{id}/remove`: remove a runner scoped
340 +
/// to the organisation or one of its repositories, when it runs nothing.
341 +
pub async fn runner_remove(
342 +
    State(app): State<Shared>,
343 +
    viewer: Viewer,
344 +
    Path((name, id)): Path<(String, i64)>,
345 +
    Form(f): Form<super::session::CsrfForm>,
346 +
) -> Result<Page<OrgSettingsPage>, Error> {
347 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
348 +
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
349 +
    csrf(&viewer, &f.csrf, &shell)?;
350 +
    let e = |e| Error::from_db(shell.clone(), e);
351 +
    let Some(r) = crate::db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_owner == org.name) else {
352 +
        return Err(Error::not_found(shell));
353 +
    };
354 +
    if crate::db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
355 +
        return org_page(&app, shell, org, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await;
356 +
    }
357 +
    crate::db::runners::remove(&app.db, id).await.map_err(e)?;
358 +
    accounts::audit(&app.db, Some(&me), "flow.runner.removed", &r.name, &org.name).await.ok();
359 +
    org_page(&app, shell, org, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
299 360
}
modified crates/hub/src/web/repo_settings.rs +69 −0
…
94 unchanged lines
95 95
    site_admin: bool,
96 96
    notice: Option<String>,
97 97
    error: Option<String>,
98 +
    /// Runners that may take this repository's jobs: (runner, what it
99 +
    /// serves, whether it is this repository's own).
100 +
    runners: Vec<(db::runners::Runner, String, bool)>,
101 +
    /// A registration token just made, shown once.
102 +
    runner_token: Option<String>,
98 103
}
99 104
100 105
async fn settings_page(
…
17 unchanged lines
118 123
    let flow_default = app.config.flow.allows(&row.owner, &row.name);
119 124
    let flow_enabled = crate::flow::enabled(app, &row);
120 125
    let site_admin = shell.is_admin;
126 +
    let runners = db::runners::serving(&app.db, &row.owner, row.id)
127 +
        .await
128 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
129 +
        .into_iter()
130 +
        .map(|r| {
131 +
            let own = r.scope_repo_id == Some(row.id);
132 +
            let serves = if own {
133 +
                "this repository".to_string()
134 +
            } else if r.scope_owner.is_empty() {
135 +
                "the whole site".to_string()
136 +
            } else {
137 +
                format!("{}'s repositories", r.scope_owner)
138 +
            };
139 +
            (r, serves, own)
140 +
        })
141 +
        .collect();
121 142
    Ok(Page(RepoSettingsPage {
143 +
        runners,
144 +
        runner_token: None,
122 145
        shell,
123 146
        repo,
124 147
        row,
…
322 unchanged lines
447 470
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
448 471
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
449 472
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
473 +
}
474 +
475 +
/// `POST /{owner}/{name}/settings/runners/token`: a one-time registration
476 +
/// token for a runner that serves this repository only (oxygenhub#41).
477 +
pub async fn runner_token(
478 +
    State(app): State<Shared>,
479 +
    viewer: Viewer,
480 +
    Path((owner, name)): Path<(String, String)>,
481 +
    Form(f): Form<super::session::CsrfForm>,
482 +
) -> Result<Page<RepoSettingsPage>, Error> {
483 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
484 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
485 +
    csrf(&viewer, &f.csrf, &shell)?;
486 +
    let me = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
487 +
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
488 +
    let scope = db::runners::Scope { owner: row.owner.clone(), repo_id: Some(row.id) };
489 +
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me, &scope)
490 +
        .await
491 +
        .map_err(|e| Error::from_db(shell.clone(), e))?;
492 +
    accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok();
493 +
    let mut page = settings_page(&app, shell, row, role, Some("Copy the token now; it is not shown again.".into()), None).await?;
494 +
    page.0.runner_token = Some(token);
495 +
    Ok(page)
496 +
}
497 +
498 +
/// `POST /{owner}/{name}/settings/runners/{id}/remove`: remove one of this
499 +
/// repository's own runners, when it is running nothing.
500 +
pub async fn runner_remove(
501 +
    State(app): State<Shared>,
502 +
    viewer: Viewer,
503 +
    Path((owner, name, id)): Path<(String, String, i64)>,
504 +
    Form(f): Form<super::session::CsrfForm>,
505 +
) -> Result<Page<RepoSettingsPage>, Error> {
506 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
507 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
508 +
    csrf(&viewer, &f.csrf, &shell)?;
509 +
    let e = |e| Error::from_db(shell.clone(), e);
510 +
    let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else {
511 +
        return Err(Error::not_found(shell));
512 +
    };
513 +
    if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) {
514 +
        return settings_page(&app, shell, row, role, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await;
515 +
    }
516 +
    db::runners::remove(&app.db, id).await.map_err(e)?;
517 +
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
518 +
    settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
450 519
}
modified crates/hub/tests/flow.rs +80 −0
…
1159 unchanged lines
1160 1160
    assert_eq!(claim(&h.app).await.unwrap()["repo"], "andy/other");
1161 1161
    assert_eq!(claim_as(only_demo).await, None, "still nothing of its own");
1162 1162
}
1163 +
1164 +
/// Settings → Runners (oxygenhub#41, part 2b): a repository's admin adds a
1165 +
/// runner for that repository alone, sees which runners serve it, and
1166 +
/// removes only its own.
1167 +
#[tokio::test(flavor = "multi_thread")]
1168 +
async fn repository_admins_add_and_remove_their_own_runners() {
1169 +
    use oxy_core::identity::SecretKey;
1170 +
    let h = hub().await;
1171 +
    let res = oxygenhub::router(h.app.clone())
1172 +
        .oneshot(
1173 +
            Request::post("/register")
1174 +
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1175 +
                .body(Body::from("username=andy&email=andy%40example.com&password=correct-horse-battery"))
1176 +
                .unwrap(),
1177 +
        )
1178 +
        .await
1179 +
        .unwrap();
1180 +
    let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string();
1181 +
    create(&h.app, "andy", "demo").await;
1182 +
    let demo = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap();
1183 +
    let web = |method: &'static str, path: String, body: Option<String>| {
1184 +
        let (app, cookie) = (h.app.clone(), cookie.clone());
1185 +
        async move {
1186 +
            let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie);
1187 +
            if body.is_some() {
1188 +
                req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
1189 +
            }
1190 +
            let res = oxygenhub::router(app).oneshot(req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()).await.unwrap();
1191 +
            let st = res.status();
1192 +
            (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned())
1193 +
        }
1194 +
    };
1195 +
    let csrf_of = |page: &str| {
1196 +
        let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len();
1197 +
        page[i..].split('"').next().unwrap().to_string()
1198 +
    };
1199 +
    let (st, page) = web("GET", "/andy/demo/settings".into(), None).await;
1200 +
    assert_eq!(st, StatusCode::OK);
1201 +
    assert!(page.contains("<h2>Runners</h2>"), "the owner sees the section");
1202 +
    let (_, page) = web("POST", "/andy/demo/settings/runners/token".into(), Some(format!("_csrf={}", csrf_of(&page)))).await;
1203 +
    let i = page.find("--token ").unwrap() + "--token ".len();
1204 +
    let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect();
1205 +
    let identity = SecretKey::generate().identity("mine", "", 1);
1206 +
    let info = serde_json::json!({ "arch": "x86_64", "os": "linux", "version": "0.1.0", "labels": [], "max_jobs": 1 });
1207 +
    let reg = serde_json::json!({ "token": token, "name": "mine", "identity": hex::encode(identity.encode()), "info": info });
1208 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await;
1209 +
    assert_eq!(st, StatusCode::OK, "{body}");
1210 +
    let mine = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "mine").unwrap();
1211 +
    assert_eq!(mine.scope(), db::runners::Scope { owner: "andy".into(), repo_id: Some(demo.id) });
1212 +
    // The site's runner (hub.toml, from its first claim) serves it too, but is not the owner's to remove.
1213 +
    claim(&h.app).await;
1214 +
    let site = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "test-1").unwrap();
1215 +
    let (_, page) = web("GET", "/andy/demo/settings".into(), None).await;
1216 +
    assert!(page.contains("<strong>mine</strong>") && page.contains("this repository"), "{page}");
1217 +
    assert!(page.contains("<strong>test-1</strong>") && page.contains("the whole site"), "{page}");
1218 +
    let csrf = csrf_of(&page);
1219 +
    let (st, _) = web("POST", format!("/andy/demo/settings/runners/{}/remove", site.id), Some(format!("_csrf={csrf}"))).await;
1220 +
    assert_eq!(st, StatusCode::NOT_FOUND, "not the repository's own");
1221 +
    let (_, page) = web("POST", format!("/andy/demo/settings/runners/{}/remove", mine.id), Some(format!("_csrf={csrf}"))).await;
1222 +
    assert!(page.contains("mine removed"), "{page}");
1223 +
    assert!(db::runners::get(&h.app.db, mine.id).await.unwrap().is_none());
1224 +
1225 +
    // The same for an organisation's owner.
1226 +
    let (_, page) = web("GET", "/orgs/new".into(), None).await;
1227 +
    web("POST", "/orgs/new".into(), Some(format!("_csrf={}&name=acme&display_name=Acme", csrf_of(&page)))).await;
1228 +
    let (st, page) = web("GET", "/orgs/acme/settings".into(), None).await;
1229 +
    assert_eq!(st, StatusCode::OK);
1230 +
    let (_, page) = web("POST", "/orgs/acme/settings/runners/token".into(), Some(format!("_csrf={}", csrf_of(&page)))).await;
1231 +
    let i = page.find("--token ").unwrap() + "--token ".len();
1232 +
    let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect();
1233 +
    let identity = SecretKey::generate().identity("acme-runner", "", 1);
1234 +
    let reg = serde_json::json!({ "token": token, "name": "acme-runner", "identity": hex::encode(identity.encode()), "info": info });
1235 +
    assert_eq!(call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await.0, StatusCode::OK);
1236 +
    let acme = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "acme-runner").unwrap();
1237 +
    assert_eq!(acme.scope(), db::runners::Scope { owner: "acme".into(), repo_id: None });
1238 +
    let (_, page) = web("GET", "/orgs/acme/settings".into(), None).await;
1239 +
    assert!(page.contains("<strong>acme-runner</strong>") && page.contains("the organisation's repositories"), "{page}");
1240 +
    let (_, page) = web("POST", format!("/orgs/acme/settings/runners/{}/remove", acme.id), Some(format!("_csrf={}", csrf_of(&page)))).await;
1241 +
    assert!(page.contains("acme-runner removed"), "{page}");
1242 +
}
modified docs/spec.md +1 −1
…
447 unchanged lines
448 448
    - On the runner, `flow runner register https://hub --token …` sends the runner's signing identity, architecture, OS, version and labels (`POST /api/v1/flow/runners/register`) and gets its own credential (`flowrunner_…`, stored hashed).
449 449
    - The runner's key is bound at registration: receipts from that runner signed by another key are not kept.
450 450
    - Runners configured with a `hub.toml` token still work. They are tracked from their first request under the name they claim with, and their key is bound on their first signed receipt.
451 -
  - **Scopes** (oxygenhub#41): a runner serves the whole site, one owner's repositories (a user or an organisation), or one repository. The registration token carries the scope (typed as empty, `owner` or `owner/repository` when it is made) and the runner gets it; an administrator can change it on the Runners page. A claim returns only queued jobs of repositories in the runner's scope, filtered before the queue is cut short, so a scoped runner never receives another scope's jobs and never waits behind them. `hub.toml` runners serve the whole site. Changes are audited (`flow.runner.scope`).
451 +
  - **Scopes** (oxygenhub#41): a runner serves the whole site, one owner's repositories (a user or an organisation), or one repository. The registration token carries the scope (typed as empty, `owner` or `owner/repository` when it is made) and the runner gets it; an administrator can change it on the Runners page. A claim returns only queued jobs of repositories in the runner's scope, filtered before the queue is cut short, so a scoped runner never receives another scope's jobs and never waits behind them. `hub.toml` runners serve the whole site. Changes are audited (`flow.runner.scope`). **Settings → Runners** on a repository (its admins) and on an organisation (its owners) lists the runners that may take its jobs, with what each serves; there they make a registration token for a runner of their own scope (the repository, or the organisation's repositories) and remove such runners when they run nothing. Only site administrators manage site runners.
452 452
  - **Each claim** carries the runner's description and marks it seen.
453 453
  - **The Runners page** lists each runner: online (seen in the last 90 s), offline, draining or disabled; what it serves; signing key; machine; labels; capacity; jobs running now; and when it was last seen.
454 454
  - **Actions:**
…
177 unchanged lines
modified templates/org_settings.html +12 −0
…
59 unchanged lines
60 60
    <div class="choice"><button type="submit" name="action" value="create">CREATE TEAM</button> <button type="submit" name="action" value="add">ADD MEMBER</button> <button type="submit" name="action" value="remove">REMOVE MEMBER</button></div>
61 61
  </form>
62 62
</section>
63 +
<section class="panel" id="runners">
64 +
  <h2>Runners</h2>
65 +
  <p class="small">The OxygenFlow runners that may take this organisation's jobs: the site's, the organisation's, and those of its repositories.</p>
66 +
  {% if let Some(t) = runner_token %}<p class="note">Register a runner for the organisation with:</p><pre class="snippet">flow runner register {{ shell.base_url }} --token {{ t }} --name NAME</pre>{% endif %}
67 +
  {% if runners.is_empty() %}<p class="mute small">None yet: runs wait until a runner that serves them is added.</p>{% else %}
68 +
  <div class="table"><table>
69 +
  <tr><th>Runner</th><th>Status</th><th>Serves</th><th>Machine</th><th></th></tr>
70 +
  {% for (r, serves, own) in runners %}<tr><td><strong>{{ r.name }}</strong></td><td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td><td class="small">{{ serves }}</td><td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }}{% endif %}</td>
71 +
  <td>{% if *own %}<form class="tiny" method="post" action="/orgs/{{ org.name }}/settings/runners/{{ r.id }}/remove" onsubmit="return confirm('Remove {{ r.name }}? Its credential stops working.')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove</button></form>{% endif %}</td></tr>{% endfor %}
72 +
  </table></div>{% endif %}
73 +
  <form method="post" action="/orgs/{{ org.name }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for the organisation</button></form>
74 +
</section>
63 75
{% call w::panel("/orgs/" ~ org.name ~ "/settings", hooks, shell.csrf, "These hear every repository of the organisation, as well as each repository's own webhooks.") %}
64 76
</div>
65 77
</div>
…
1 unchanged lines
modified templates/repo_settings.html +12 −0
…
75 unchanged lines
76 76
  {% endif %}
77 77
</form>
78 78
{% if role == crate::auth::Role::Admin %}
79 +
<section class="panel" id="runners">
80 +
  <h2>Runners</h2>
81 +
  <p class="small">The OxygenFlow runners that may take this repository's jobs: the site's, {{ row.owner }}'s, and this repository's own.</p>
82 +
  {% if let Some(t) = runner_token %}<p class="note">Register a runner for this repository with:</p><pre class="snippet">flow runner register {{ shell.base_url }} --token {{ t }} --name NAME</pre>{% endif %}
83 +
  {% if runners.is_empty() %}<p class="mute small">None yet: runs wait until a runner that serves this repository is added.</p>{% else %}
84 +
  <div class="table"><table>
85 +
  <tr><th>Runner</th><th>Status</th><th>Serves</th><th>Machine</th><th></th></tr>
86 +
  {% for (r, serves, own) in runners %}<tr><td><strong>{{ r.name }}</strong></td><td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td><td class="small">{{ serves }}</td><td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }}{% endif %}</td>
87 +
  <td>{% if *own %}<form class="tiny" method="post" action="{{ repo.url }}/settings/runners/{{ r.id }}/remove" onsubmit="return confirm('Remove {{ r.name }}? Its credential stops working.')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove</button></form>{% endif %}</td></tr>{% endfor %}
88 +
  </table></div>{% endif %}
89 +
  <form method="post" action="{{ repo.url }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for this repository</button></form>
90 +
</section>
79 91
{% call w::panel(repo.url ~ "/settings", hooks, shell.csrf, "Each delivery is signed with the secret (X-Oxyhub-Signature) and retried with backoff for a day.") %}
80 92
<section class="panel" id="collaborators">
81 93
  <h2>Collaborators</h2>
…
30 unchanged lines