OXYGENHUB

Administration: a sidebar of grouped pages, one page per section (closes #288) 737de0fac722

The admin area was one long page plus separate pages for runners, images,
Diffusion, backups and storage. Every admin page now has the same sidebar
(as repository settings, #278): Overview, People, Sign-in, Mail, Flow,
Integrations, Data and Audit, each section at its own address. /admin is
an overview of the site's numbers. Forms keep their addresses and answer
with their own page; a kept /admin#section link opens that page.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ff123dd70f37
When
· 2026-10-20 23:59 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
737de0fac722a374682d543f516fc6db72e23720c34476f3c4de1fa2921bbfcb
Depends on
155cab142b63 19bb31ff1f5c 1e8a6e8bb91b 2a83f5303458 34beb1a7e376 3556d08e17f9 3dd6582de060 42fa95540468 43e8ca33bc5d 4ac01a732552 5dead1f4f26d 6246d1e51f4f 6a1780558353 6ca0b971ff07 73ef9f767132 757195ad7a5d 83940d53664f 8fb35df7d66e 93be9ce4653e a7e6e8e6094c b349e15a3eab b5668108487f b6b7e4cf6b24 c7067c7bda56 c8684d44d549 cd46e56b1709 d23464c0ac60 e71846aa07b9 e8e7158bbc73
Depended on by
1062a0f8e985 8501fd5815b9 3b3a51afde59
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
93 atoms, 1 tree operation, 18 files

Browse the tree at this change

added templates/admin_nav.html +9 −0
1 +
{# The admin area's sidebar (oxygenhub#288): grouped pages, the current one marked. #}
2 +
{% macro nav(current) %}
3 +
<nav class="settings-nav" aria-label="Administration"{% if current == crate::web::admin::AdminSection::Overview %} data-settings-anchors{% endif %}>
4 +
  {% for (group, pages) in crate::web::admin::admin_groups() %}<div class="settings-group">
5 +
    <h2>{{ group }}</h2>
6 +
    <ul>{% for p in pages.iter() %}<li><a href="{{ p.href() }}" data-slug="{{ p.slug() }}"{% if *p == current %} class="on" aria-current="page"{% endif %}>{{ p.title() }}</a></li>{% endfor %}</ul>
7 +
  </div>{% endfor %}
8 +
</nav>
9 +
{% endmacro %}
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Administration has a sidebar, one page per section (#288):** the admin area was one long page plus separate pages for runners, images, Diffusion, backups and storage. Every page now has the same sidebar, grouped as Overview, People (accounts, registration and invitations, organisations), Sign-in (providers, security contact), Mail, Flow (runners, images, runner settings, insights, variables, secrets), Integrations (AI assistants, Diffusion), Data (storage and maintenance, backups) and Audit, each at its own address (`/admin/mail`, `/admin/audit`, …). `/admin` is an overview of the site's numbers. Forms keep their addresses and answer with their own page, and a kept `/admin#mail` link opens the Mail page.
7 8
- **Help: AI identities (#287):** a section on letting an AI agent work for you: keyless agents (a grant with `oh agent grant`, an agent home with `oxy auth grant`, ten-minute certified keys, nothing secret on disk), how its work shows (`via agent:NAME@YOU`), admitting it to a repository's trust policy (`oxy policy grant-subject`), agents with keys of their own (delegation), agent-marked tokens, approval gates, and revoking. "Letting the hub act for you" now says to export your identity again after delegating and upload that.
8 9
- **Settings › Keys says when each key was last used (#286):** like tokens, each linked key shows when it was linked, when the hub last accepted a push it signed (a change or a seal; "never" until then) and when the identity expires.
9 10
- **Settings › Security is balanced (#285):** every box runs the page's width: two-factor shows whether it is on in its head, with its field and button on one row; connected accounts are rows with Connect or Disconnect; signed-in browsers are rows with Sign out, and Sign out everywhere else sits in that box's head with the session limits under it.
…
270 unchanged lines
modified crates/hub/src/web/admin.rs +201 −23
…
9 unchanged lines
10 10
use axum::extract::{Form, State};
11 11
use serde::Deserialize;
12 12
13 +
/// A page of the admin area (oxygenhub#288): grouped in a sidebar, one
14 +
/// page per section, as a repository's settings are (#278).
15 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16 +
pub enum AdminSection {
17 +
    Overview,
18 +
    Accounts,
19 +
    Registration,
20 +
    Organisations,
21 +
    SignIn,
22 +
    SecurityContact,
23 +
    Mail,
24 +
    Runners,
25 +
    Images,
26 +
    RunnerSettings,
27 +
    Insights,
28 +
    Variables,
29 +
    Secrets,
30 +
    Assistants,
31 +
    Diffusion,
32 +
    Storage,
33 +
    Backups,
34 +
    Audit,
35 +
}
36 +
37 +
/// The sidebar: each group's heading and its pages, in order.
38 +
const ADMIN_GROUPS: &[(&str, &[AdminSection])] = &[
39 +
    ("Overview", &[AdminSection::Overview]),
40 +
    ("People", &[AdminSection::Accounts, AdminSection::Registration, AdminSection::Organisations]),
41 +
    ("Sign-in", &[AdminSection::SignIn, AdminSection::SecurityContact]),
42 +
    ("Mail", &[AdminSection::Mail]),
43 +
    (
44 +
        "Flow",
45 +
        &[
46 +
            AdminSection::Runners,
47 +
            AdminSection::Images,
48 +
            AdminSection::RunnerSettings,
49 +
            AdminSection::Insights,
50 +
            AdminSection::Variables,
51 +
            AdminSection::Secrets,
52 +
        ],
53 +
    ),
54 +
    ("Integrations", &[AdminSection::Assistants, AdminSection::Diffusion]),
55 +
    ("Data", &[AdminSection::Storage, AdminSection::Backups]),
56 +
    ("Audit", &[AdminSection::Audit]),
57 +
];
58 +
59 +
/// The admin sidebar's groups, for templates.
60 +
pub fn admin_groups() -> &'static [(&'static str, &'static [AdminSection])] {
61 +
    ADMIN_GROUPS
62 +
}
63 +
64 +
impl AdminSection {
65 +
    /// Its page's address.
66 +
    pub fn href(self) -> &'static str {
67 +
        match self {
68 +
            AdminSection::Overview => "/admin",
69 +
            AdminSection::Accounts => "/admin/accounts",
70 +
            AdminSection::Registration => "/admin/registration",
71 +
            AdminSection::Organisations => "/admin/organisations",
72 +
            AdminSection::SignIn => "/admin/signin",
73 +
            AdminSection::SecurityContact => "/admin/security",
74 +
            AdminSection::Mail => "/admin/mail",
75 +
            AdminSection::Runners => "/admin/runners",
76 +
            AdminSection::Images => "/admin/runners/images",
77 +
            AdminSection::RunnerSettings => "/admin/runners/settings",
78 +
            AdminSection::Insights => "/admin/insights",
79 +
            AdminSection::Variables => "/admin/variables",
80 +
            AdminSection::Secrets => "/admin/secrets",
81 +
            AdminSection::Assistants => "/admin/assistants",
82 +
            AdminSection::Diffusion => "/admin/diffusion",
83 +
            AdminSection::Storage => "/admin/storage",
84 +
            AdminSection::Backups => "/admin/backups",
85 +
            AdminSection::Audit => "/admin/audit",
86 +
        }
87 +
    }
88 +
89 +
    /// The anchor it had on the one-page admin, so a kept `/admin#mail`
90 +
    /// link opens its page (hub.js).
91 +
    pub fn slug(self) -> &'static str {
92 +
        match self {
93 +
            AdminSection::Overview => "overview",
94 +
            AdminSection::Accounts => "accounts",
95 +
            AdminSection::Registration => "registration",
96 +
            AdminSection::Organisations => "organisations",
97 +
            AdminSection::SignIn => "signin",
98 +
            AdminSection::SecurityContact => "security",
99 +
            AdminSection::Mail => "mail",
100 +
            AdminSection::Runners => "runners",
101 +
            AdminSection::Images => "images",
102 +
            AdminSection::RunnerSettings => "runner-settings",
103 +
            AdminSection::Insights => "insights",
104 +
            AdminSection::Variables => "variables",
105 +
            AdminSection::Secrets => "secrets",
106 +
            AdminSection::Assistants => "assistants",
107 +
            AdminSection::Diffusion => "diffusion",
108 +
            AdminSection::Storage => "storage",
109 +
            AdminSection::Backups => "backups",
110 +
            AdminSection::Audit => "audit",
111 +
        }
112 +
    }
113 +
114 +
    pub fn title(self) -> &'static str {
115 +
        match self {
116 +
            AdminSection::Overview => "Overview",
117 +
            AdminSection::Accounts => "Accounts",
118 +
            AdminSection::Registration => "Registration & invitations",
119 +
            AdminSection::Organisations => "Organisations",
120 +
            AdminSection::SignIn => "Sign in with",
121 +
            AdminSection::SecurityContact => "Security contact",
122 +
            AdminSection::Mail => "Mail",
123 +
            AdminSection::Runners => "Runners",
124 +
            AdminSection::Images => "Images",
125 +
            AdminSection::RunnerSettings => "Runner settings",
126 +
            AdminSection::Insights => "Insights",
127 +
            AdminSection::Variables => "Variables",
128 +
            AdminSection::Secrets => "Secrets",
129 +
            AdminSection::Assistants => "AI assistants",
130 +
            AdminSection::Diffusion => "Diffusion",
131 +
            AdminSection::Storage => "Storage & maintenance",
132 +
            AdminSection::Backups => "Backups",
133 +
            AdminSection::Audit => "Audit log",
134 +
        }
135 +
    }
136 +
137 +
    /// The section a page of admin.html shows, by its address.
138 +
    fn of_path(path: &str) -> Option<AdminSection> {
139 +
        ADMIN_GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.href() == path)
140 +
    }
141 +
}
142 +
13 143
#[derive(Template)]
14 144
#[template(path = "admin.html")]
15 145
pub struct AdminPage {
16 146
    shell: Shell,
147 +
    /// The page shown (oxygenhub#288).
148 +
    section: AdminSection,
17 149
    accounts: Vec<accounts::Account>,
18 150
    orgs: Vec<accounts::Organisation>,
19 151
    mode: String,
…
36 unchanged lines
56 188
    }
57 189
}
58 190
59 -
pub(super) async fn admin_page(app: &Shared, shell: Shell, notice: Option<String>) -> Result<Page<AdminPage>, Error> {
60 -
    admin_page_at(app, shell, notice, None).await
191 +
pub(super) async fn admin_page(
192 +
    app: &Shared,
193 +
    shell: Shell,
194 +
    section: AdminSection,
195 +
    notice: Option<String>,
196 +
) -> Result<Page<AdminPage>, Error> {
197 +
    admin_page_at(app, shell, section, notice, None).await
61 198
}
62 199
63 200
/// The audit log shows this many rows a page.
64 201
const AUDIT_PAGE: i64 = 50;
65 202
66 -
async fn admin_page_at(app: &Shared, shell: Shell, notice: Option<String>, audit_before: Option<i64>) -> Result<Page<AdminPage>, Error> {
203 +
async fn admin_page_at(
204 +
    app: &Shared,
205 +
    shell: Shell,
206 +
    section: AdminSection,
207 +
    notice: Option<String>,
208 +
    audit_before: Option<i64>,
209 +
) -> Result<Page<AdminPage>, Error> {
67 210
    let e = |e| Error::from_db(shell.clone(), e);
68 211
    let accounts_list = accounts::accounts(&app.db).await.map_err(e)?;
69 212
    let orgs = accounts::orgs(&app.db).await.map_err(e)?;
…
19 unchanged lines
89 232
    let (security_contact, security_policy) = super::security_txt::settings(app).await;
90 233
    let (security_email, security_page) = super::security_txt::page_settings(app).await;
91 234
    Ok(Page(AdminPage {
235 +
        section,
92 236
        providers,
93 237
        security_contact,
94 238
        security_policy,
…
83 unchanged lines
178 322
            };
179 323
            match crate::mail::compose(&asked, &current.smtp) {
180 324
                Ok(url) => url,
181 -
                Err(why) => return admin_page(&app, shell, Some(why)).await,
325 +
                Err(why) => return admin_page(&app, shell, AdminSection::Mail, Some(why)).await,
182 326
            }
183 327
        }
184 328
    };
…
2 unchanged lines
187 331
        return admin_page(
188 332
            &app,
189 333
            shell,
334 +
            AdminSection::Mail,
190 335
            Some("The SMTP URL starts with smtp:// or smtps:// (file:///dir writes messages to a directory, for testing).".into()),
191 336
        )
192 337
        .await;
193 338
    }
194 339
    if !smtp.is_empty() && from.is_empty() {
195 -
        return admin_page(&app, shell, Some("A From address is needed: OxygenHub <[email protected]>.".into())).await;
340 +
        return admin_page(&app, shell, AdminSection::Mail, Some("A From address is needed: OxygenHub <[email protected]>.".into()))
341 +
            .await;
196 342
    }
197 343
    crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?;
198 344
    accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok();
199 -
    admin_page(&app, shell, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() })).await
345 +
    admin_page(&app, shell, AdminSection::Mail, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() }))
346 +
        .await
200 347
}
201 348
202 349
/// `POST /admin/mail/test`: a message to the administrator's own address.
…
6 unchanged lines
209 356
    let me = require_admin(&viewer, &shell)?;
210 357
    csrf(&viewer, &f.csrf, &shell)?;
211 358
    if !crate::mail::enabled(&app) {
212 -
        return admin_page(&app, shell, Some("Mail is off; save an SMTP URL and a From address first.".into())).await;
359 +
        return admin_page(&app, shell, AdminSection::Mail, Some("Mail is off; save an SMTP URL and a From address first.".into())).await;
213 360
    }
214 361
    if me.email.is_empty() {
215 -
        return admin_page(&app, shell, Some("Your account has no email address to send the test to.".into())).await;
362 +
        return admin_page(&app, shell, AdminSection::Mail, Some("Your account has no email address to send the test to.".into())).await;
216 363
    }
217 364
    let base = app.config.base_url().to_string();
218 365
    let notice = match crate::mail::send_letter(
…
10 unchanged lines
229 376
        Ok(()) => format!("Sent a test message to {}.", me.email),
230 377
        Err(e) => format!("Sending failed: {e}"),
231 378
    };
232 -
    admin_page(&app, shell, Some(notice)).await
379 +
    admin_page(&app, shell, AdminSection::Mail, Some(notice)).await
233 380
}
234 381
235 382
#[derive(Deserialize)]
…
7 unchanged lines
243 390
    axum::extract::Query(q): axum::extract::Query<IndexQuery>,
244 391
) -> Result<Page<AdminPage>, Error> {
245 392
    let shell = Shell::new(&app, "/admin", &viewer);
393 +
    require_admin(&viewer, &shell)?;
394 +
    admin_page_at(&app, shell, AdminSection::Overview, None, q.audit_before).await
395 +
}
396 +
397 +
/// `GET /admin/{page}` for the sections admin.html shows (oxygenhub#288):
398 +
/// accounts, registration, organisations, sign-in, the security contact,
399 +
/// mail, variables, secrets, AI assistants and the audit log.
400 +
pub async fn section(
401 +
    State(app): State<Shared>,
402 +
    viewer: Viewer,
403 +
    uri: axum::http::Uri,
404 +
    axum::extract::Query(q): axum::extract::Query<IndexQuery>,
405 +
) -> Result<Page<AdminPage>, Error> {
406 +
    let shell = Shell::new(&app, uri.path(), &viewer);
246 407
    require_admin(&viewer, &shell)?;
247 -
    admin_page_at(&app, shell, None, q.audit_before).await
408 +
    let section = AdminSection::of_path(uri.path()).ok_or_else(|| Error::not_found(shell.clone()))?;
409 +
    admin_page_at(&app, shell, section, None, q.audit_before).await
248 410
}
249 411
250 412
#[derive(Deserialize)]
…
12 unchanged lines
263 425
    }
264 426
    accounts::set_setting(&app.db, "registration_mode", &f.mode).await.map_err(|e| Error::from_db(shell.clone(), e))?;
265 427
    accounts::audit(&app.db, Some(&me), "admin.registration", &f.mode, "").await.ok();
266 -
    admin_page(&app, shell, Some(format!("Registration is {}.", f.mode))).await
428 +
    admin_page(&app, shell, AdminSection::Registration, Some(format!("Registration is {}.", f.mode))).await
267 429
}
268 430
269 431
pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> {
…
3 unchanged lines
273 435
    let code = auth::random_hex(12);
274 436
    accounts::create_invitation(&app.db, &code, Some(me.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
275 437
    accounts::audit(&app.db, Some(&me), "admin.invite", &code, "").await.ok();
276 -
    admin_page(&app, shell, Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url()))).await
438 +
    admin_page(
439 +
        &app,
440 +
        shell,
441 +
        AdminSection::Registration,
442 +
        Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url())),
443 +
    )
444 +
    .await
277 445
}
278 446
279 447
#[derive(Deserialize)]
…
12 unchanged lines
292 460
    let me = require_admin(&viewer, &shell)?;
293 461
    csrf(&viewer, &f.csrf_token, &shell)?;
294 462
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
295 -
        return admin_page(&app, shell, Some("No such account.".into())).await;
463 +
        return admin_page(&app, shell, AdminSection::Accounts, Some("No such account.".into())).await;
296 464
    };
297 465
    if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote" | "disable" | "delete") {
298 -
        return admin_page(&app, shell, Some("Not on yourself.".into())).await;
466 +
        return admin_page(&app, shell, AdminSection::Accounts, Some("Not on yourself.".into())).await;
299 467
    }
300 468
    let e = |e| Error::from_db(shell.clone(), e);
301 469
    // The hub must keep an admin who can sign in (oxygenhub#81).
…
1 unchanged lines
303 471
        let others =
304 472
            accounts::accounts(&app.db).await.map_err(e)?.into_iter().filter(|a| a.is_admin && !a.blocked() && a.id != target.id).count();
305 473
        if others == 0 {
306 -
            return admin_page(&app, shell, Some(format!("{} is the last admin who can sign in.", target.username))).await;
474 +
            return admin_page(
475 +
                &app,
476 +
                shell,
477 +
                AdminSection::Accounts,
478 +
                Some(format!("{} is the last admin who can sign in.", target.username)),
479 +
            )
480 +
            .await;
307 481
        }
308 482
    }
309 483
    if f.action == "delete" {
310 484
        if f.confirm.trim() != target.username {
311 -
            return admin_page(&app, shell, Some(format!("Not deleted: type {} to confirm.", target.username))).await;
485 +
            return admin_page(&app, shell, AdminSection::Accounts, Some(format!("Not deleted: type {} to confirm.", target.username)))
486 +
                .await;
312 487
        }
313 488
        let repos = crate::db::repos::list_by_owner(&app.db, &target.username).await.map_err(e)?;
314 489
        let orgs = accounts::orgs_owned_only_by(&app.db, target.id).await.map_err(e)?;
…
3 unchanged lines
318 493
            return admin_page(
319 494
                &app,
320 495
                shell,
496 +
                AdminSection::Accounts,
321 497
                Some(format!("Not deleted: {} still owns {}. Transfer or delete them first.", target.username, held.join(", "))),
322 498
            )
323 499
            .await;
324 500
        }
325 501
        accounts::delete_account(&app.db, target.id, &target.username).await.map_err(e)?;
326 502
        accounts::audit(&app.db, Some(&me), "admin.account.delete", &target.username, "").await.ok();
327 -
        return admin_page(&app, shell, Some(format!("{} deleted; the name stays retired.", target.username))).await;
503 +
        return admin_page(&app, shell, AdminSection::Accounts, Some(format!("{} deleted; the name stays retired.", target.username)))
504 +
            .await;
328 505
    }
329 506
    if f.action == "reset_link" {
330 507
        if target.blocked() {
331 -
            return admin_page(&app, shell, Some("A banned or disabled account cannot be reset.".into())).await;
508 +
            return admin_page(&app, shell, AdminSection::Accounts, Some("A banned or disabled account cannot be reset.".into())).await;
332 509
        }
333 510
        let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?;
334 511
        return admin_page(
335 512
            &app,
336 513
            shell,
514 +
            AdminSection::Accounts,
337 515
            Some(format!("Reset link for {} (works once, for 24 hours; shown only now): {url}", target.username)),
338 516
        )
339 517
        .await;
…
8 unchanged lines
348 526
        _ => return Err(Error::bad(shell, "unknown action")),
349 527
    }
350 528
    accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok();
351 -
    admin_page(&app, shell, Some(format!("{}: {}.", target.username, f.action))).await
529 +
    admin_page(&app, shell, AdminSection::Accounts, Some(format!("{}: {}.", target.username, f.action))).await
352 530
}
353 531
354 532
// ------------------------------------------------------------------ storage
…
134 unchanged lines
489 667
    require_admin(&viewer, &shell)?;
490 668
    csrf(&viewer, &f.csrf_token, &shell)?;
491 669
    let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
492 -
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
670 +
    admin_page(&app, shell, AdminSection::Assistants, Some(outcome.unwrap_or_else(|e| e))).await
493 671
}
494 672
495 673
/// `POST /admin/variables`: the site's Flow variables (#102).
…
6 unchanged lines
502 680
    require_admin(&viewer, &shell)?;
503 681
    csrf(&viewer, &f.csrf_token, &shell)?;
504 682
    let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
505 -
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
683 +
    admin_page(&app, shell, AdminSection::Variables, Some(outcome.unwrap_or_else(|e| e))).await
506 684
}
507 685
508 686
/// `POST /admin/secrets`: the site's Flow secrets (#107).
…
6 unchanged lines
515 693
    require_admin(&viewer, &shell)?;
516 694
    csrf(&viewer, &f.csrf_token, &shell)?;
517 695
    let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
518 -
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
696 +
    admin_page(&app, shell, AdminSection::Secrets, Some(outcome.unwrap_or_else(|e| e))).await
519 697
}
520 698
521 699
/// `POST /admin/signin`: a sign-in provider's client id, secret and switch (#106).
…
6 unchanged lines
528 706
    require_admin(&viewer, &shell)?;
529 707
    csrf(&viewer, &f.csrf_token, &shell)?;
530 708
    let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
531 -
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
709 +
    admin_page(&app, shell, AdminSection::SignIn, Some(outcome.unwrap_or_else(|e| e))).await
532 710
}
533 711
534 712
/// `POST /admin/assistants/models`: what a provider offers, as JSON (#109).
…
10 unchanged lines
modified crates/hub/src/web/mod.rs +10 −7
…
151 unchanged lines
152 152
        .route("/settings/notifications", get(inbox::unsubscribe).post(inbox::prefs))
153 153
        .route("/search", get(inbox::search))
154 154
        .route("/admin", get(admin::index))
155 -
        .route("/admin/registration", post(admin::set_mode))
155 +
        .route("/admin/accounts", get(admin::section))
156 +
        .route("/admin/organisations", get(admin::section))
157 +
        .route("/admin/audit", get(admin::section))
158 +
        .route("/admin/registration", get(admin::section).post(admin::set_mode))
156 159
        .route("/admin/invite", post(admin::invite))
157 160
        .route("/admin/account", post(admin::account))
158 161
        .route("/admin/storage", get(admin::storage).post(admin::maintain))
…
11 unchanged lines
170 173
        .route("/admin/runners/{id}/state", post(runners::set_state))
171 174
        .route("/admin/runners/{id}/scope", post(runners::set_scope))
172 175
        .route("/admin/runners/{id}/limits", post(runners::set_limits))
173 -
        .route("/admin/mail", post(admin::set_mail))
176 +
        .route("/admin/mail", get(admin::section).post(admin::set_mail))
174 177
        .route("/admin/mail/test", post(admin::test_mail))
175 -
        .route("/admin/assistants", post(admin::assistants))
178 +
        .route("/admin/assistants", get(admin::section).post(admin::assistants))
176 179
        .route("/admin/assistants/models", post(admin::assistant_models))
177 -
        .route("/admin/signin", post(admin::signin))
178 -
        .route("/admin/security", post(security_txt::save))
180 +
        .route("/admin/signin", get(admin::section).post(admin::signin))
181 +
        .route("/admin/security", get(admin::section).post(security_txt::save))
179 182
        .route("/login/{provider}", get(signin::start))
180 183
        .route("/login/{provider}/callback", get(signin::callback))
181 184
        .route("/settings/security/connect/{provider}", get(signin::connect))
182 185
        .route("/settings/security/disconnect/{provider}", post(signin::disconnect))
183 -
        .route("/admin/variables", post(admin::variables))
184 -
        .route("/admin/secrets", post(admin::secrets))
186 +
        .route("/admin/variables", get(admin::section).post(admin::variables))
187 +
        .route("/admin/secrets", get(admin::section).post(admin::secrets))
185 188
        .route("/admin/runners/{id}/remove", post(runners::remove))
186 189
        .nest("/api/v1", crate::api::router(app.config.limits.asset_limit.0 as usize))
187 190
        // Drops (#279): `drops` is reserved, as an owner and as a repository name.
…
711 unchanged lines
modified crates/hub/src/web/security_txt.rs +1 −1
…
143 unchanged lines
144 144
    } else {
145 145
        "Security contact saved: /.well-known/security.txt names it.".to_string()
146 146
    };
147 -
    super::admin::admin_page(&app, shell, Some(notice)).await
147 +
    super::admin::admin_page(&app, shell, super::admin::AdminSection::SecurityContact, Some(notice)).await
148 148
}
149 149
150 150
#[cfg(test)]
…
26 unchanged lines
modified crates/hub/tests/accounts.rs +36 −10
…
799 unchanged lines
800 800
    let h = hub().await;
801 801
    let app = &h.app;
802 802
    let andy = register(app, "andy", "").await;
803 -
    let page = get(app, "/admin", Some(&andy)).await.body;
803 +
    let page = get(app, "/admin/mail", Some(&andy)).await.body;
804 804
    assert!(page.contains("Mail is off"), "{page}");
805 805
    let csrf = csrf_of(&page);
806 806
    // A directory store for messages, as hub.toml's file:// would be.
…
99 unchanged lines
906 906
    let (endpoint, seen) = mock_assistant().await;
907 907
908 908
    // The site level: an administrator adds Claude with a token.
909 -
    let page = get(app, "/admin", Some(&andy)).await.body;
909 +
    let page = get(app, "/admin/assistants", Some(&andy)).await.body;
910 910
    assert!(page.contains("AI assistants") && page.contains("Add an assistant"), "{page}");
911 911
    let csrf = csrf_of(&page);
912 912
    let add = |name: &str, extra: &str| {
…
246 unchanged lines
1159 1159
1160 1160
    // Nothing to sign in with until an administrator sets a provider up.
1161 1161
    assert!(!get(app, "/login", None).await.body.contains("/login/github"));
1162 -
    let admin = get(app, "/admin", Some(&andy)).await.body;
1162 +
    let admin = get(app, "/admin/signin", Some(&andy)).await.body;
1163 1163
    assert!(admin.contains("Sign in with") && admin.contains(&format!("http://127.0.0.1:{port}/login/github/callback")), "{admin}");
1164 1164
    let csrf = csrf_of(&admin);
1165 1165
    let r =
…
117 unchanged lines
1283 1283
    let andy_row = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap();
1284 1284
    for i in 0..60 {
1285 1285
        db::accounts::audit(&app.db, Some(&andy_row), "test.event", &format!("t{i}"), "").await.unwrap();
1286 +
    }
1287 +
    // oxygenhub#288: a sidebar of grouped pages; each section its own page,
1288 +
    // marked current there; the overview forwards a kept /admin#section.
1289 +
    let overview = get(app, "/admin", Some(&andy)).await.body;
1290 +
    assert!(overview.contains("class=\"settings-nav\"") && overview.contains("data-settings-anchors"), "{overview}");
1291 +
    for (id, path) in [
1292 +
        ("accounts", "/admin/accounts"),
1293 +
        ("registration", "/admin/registration"),
1294 +
        ("organisations", "/admin/organisations"),
1295 +
        ("signin", "/admin/signin"),
1296 +
        ("mail", "/admin/mail"),
1297 +
        ("security", "/admin/security"),
1298 +
        ("variables", "/admin/variables"),
1299 +
        ("secrets", "/admin/secrets"),
1300 +
        ("assistants", "/admin/assistants"),
1301 +
        ("audit", "/admin/audit"),
1302 +
    ] {
1303 +
        assert!(overview.contains(&format!("href=\"{path}\" data-slug=\"{id}\"")), "{id} in the sidebar: {overview}");
1304 +
        let page = get(app, path, Some(&andy)).await.body;
1305 +
        assert!(page.contains(&format!("id=\"{id}\"")), "section {id}: {page}");
1306 +
        assert!(page.contains(&format!("href=\"{path}\" data-slug=\"{id}\" class=\"on\" aria-current=\"page\"")), "{id} marked: {page}");
1286 1307
    }
1287 -
    let page = get(app, "/admin", Some(&andy)).await.body;
1288 -
    for id in ["accounts", "registration", "organisations", "signin", "mail", "variables", "secrets", "assistants", "audit"] {
1289 -
        assert!(page.contains(&format!("href=\"#{id}\"")) && page.contains(&format!("id=\"{id}\"")), "section {id}: {page}");
1308 +
    for path in
1309 +
        ["/admin/runners", "/admin/runners/images", "/admin/runners/settings", "/admin/storage", "/admin/diffusion", "/admin/backups"]
1310 +
    {
1311 +
        let page = get(app, path, Some(&andy)).await.body;
1312 +
        assert!(page.contains("class=\"settings-nav\"") && page.contains(&format!("href=\"{path}\"")), "{path} has the sidebar");
1290 1313
    }
1291 -
    assert!(page.contains("class=\"section-nav\""));
1314 +
    // Others are not shown it.
1315 +
    assert_eq!(get(app, "/admin/mail", None).await.status, StatusCode::NOT_FOUND);
1316 +
    assert_eq!(get(app, "/admin/audit", None).await.status, StatusCode::NOT_FOUND);
1317 +
    let page = get(app, "/admin/audit", Some(&andy)).await.body;
1292 1318
    assert!(page.contains(">t59<") && !page.contains(">t5<"), "the newest page only");
1293 1319
    let i = page.find("audit_before=").expect("an older link") + "audit_before=".len();
1294 1320
    let before: String = page[i..].chars().take_while(|c| c.is_ascii_digit()).collect();
1295 -
    let older = get(app, &format!("/admin?audit_before={before}"), Some(&andy)).await.body;
1321 +
    let older = get(app, &format!("/admin/audit?audit_before={before}"), Some(&andy)).await.body;
1296 1322
    assert!(older.contains(">t5<") && !older.contains(">t59<"), "the next page: {older}");
1297 1323
}
1298 1324
…
21 unchanged lines
1320 1346
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
1321 1347
    let ep = format!("http%3A%2F%2F127.0.0.1%3A{port}%2Fv1%2Fchat%2Fcompletions");
1322 1348
1323 -
    let page = get(app, "/admin", Some(&andy)).await.body;
1349 +
    let page = get(app, "/admin/assistants", Some(&andy)).await.body;
1324 1350
    assert!(page.contains("data-preset-select") && page.contains("Google Gemini") && page.contains("Other (enter the endpoint)"), "{page}");
1325 1351
    assert!(page.contains("LIST MODELS"));
1326 1352
    let csrf = csrf_of(&page);
…
243 unchanged lines
1570 1596
    let app = &h.app;
1571 1597
    assert_eq!(get(app, "/.well-known/security.txt", None).await.status, StatusCode::NOT_FOUND, "no contact, no file");
1572 1598
    let andy = register(app, "andy", "").await;
1573 -
    let page = get(app, "/admin", Some(&andy)).await.body;
1599 +
    let page = get(app, "/admin/security", Some(&andy)).await.body;
1574 1600
    assert!(page.contains("No contact yet"), "the panel");
1575 1601
    let csrf = csrf_of(&page);
1576 1602
    let set = |contact: &'static str, policy: &'static str| {
…
197 unchanged lines
modified crates/hub/tests/flow.rs +1 −1
…
2213 unchanged lines
2214 2214
    assert_eq!(got["env"], serde_json::json!({ "CC": "clang", "LANG": "C", "RUST_LOG": "debug" }), "{got}");
2215 2215
2216 2216
    // Removing a site variable from the admin page takes effect at the next claim.
2217 -
    let (_, admin) = page("/admin", None).await;
2217 +
    let (_, admin) = page("/admin/variables", None).await;
2218 2218
    assert!(admin.contains("Variables") && admin.contains("<code>gcc</code>"), "{admin}");
2219 2219
    let (_, body) = page("/admin/variables", Some(format!("_csrf={}&action=delete&name=LANG", csrf_of(&admin)))).await;
2220 2220
    assert!(body.contains("LANG removed."), "{body}");
…
3985 unchanged lines
modified crates/hub/tests/recovery.rs +1 −1
…
201 unchanged lines
202 202
203 203
    // An administrator's link, for an account without a verified email.
204 204
    post(app, "/register", None, "username=bob&email=&password=bob-password-long-enough&setup=test-setup").await;
205 -
    let admin = get(app, "/admin", Some(&session)).await;
205 +
    let admin = get(app, "/admin/accounts", Some(&session)).await;
206 206
    assert!(admin.body.contains("value=\"reset_link\""));
207 207
    let r = post(app, "/admin/account", Some(&session), &format!("_csrf={}&username=bob&action=reset_link", csrf_of(&admin.body))).await;
208 208
    assert!(r.body.contains("Reset link for bob"), "{}", r.body);
…
17 unchanged lines
modified docs/spec.md +1 −1
…
298 unchanged lines
299 299
| `/projects/{owner}`, `/projects/{owner}/{n}` | Projects of a person or organisation (§8.2) |
300 300
| `/search` | §11 |
301 301
| `/help` | Getting started with this hub's own address, remote name and certificate filled in (identity, pushing, cloning, proposals, delegating to the hub, pipelines and stamps), the keyboard, and a glossary for people coming from GitHub. Linked from the header band, the footer and the keyboard sheet |
302 -
| `/admin/*` | Site: accounts, registration mode, invitations, audit log; `/admin/storage` lists every repository's size on disk, loose objects and packs, and runs maintenance (`oxy gc` with the grace period kept, under the repository's write lock) on one repository or all, audited |
302 +
| `/admin/*` | Site administration (site administrators only; others get not found), a sidebar of grouped pages beside the page shown, the current one marked (`aria-current`), as a repository's settings (oxygenhub#288): **Overview** (`/admin`: the site's numbers and a card per group), **People** (Accounts `/admin/accounts`, Registration & invitations `/admin/registration`, Organisations `/admin/organisations`), **Sign-in** (Sign in with `/admin/signin`, Security contact `/admin/security`), **Mail** (`/admin/mail`), **Flow** (Runners, Images, Runner settings, Insights, Variables `/admin/variables`, Secrets `/admin/secrets`), **Integrations** (AI assistants `/admin/assistants`, Diffusion), **Data** (Storage & maintenance, Backups), **Audit** (`/admin/audit`, paged with `?audit_before=`). Each form posts to its own path, as before, and answers with its own page; a kept `/admin#section` link opens that section's page (`hub.js`). Accounts, registration mode, invitations, audit log; `/admin/storage` lists every repository's size on disk, loose objects and packs, and runs maintenance (`oxy gc` with the grace period kept, under the repository's write lock) on one repository or all, audited |
303 303
304 304
### 6.4 Repository overview
305 305
…
582 unchanged lines
modified templates/admin.html +38 −21
…
1 unchanged lines
2 2
{% import "assistants_panel.html" as ai %}
3 3
{% import "variables_panel.html" as va %}
4 4
{% import "secrets_panel.html" as se %}
5 -
{% block title %}Administration{% endblock %}
5 +
{% import "admin_nav.html" as an %}
6 +
{% block title %}{% if section != crate::web::admin::AdminSection::Overview %}{{ section.title() }} · {% endif %}Administration{% endblock %}
6 7
{% block content %}
7 8
<div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div>
8 -
<div class="sectioned">
9 -
<nav class="section-nav" aria-label="Sections">
10 -
  <a href="#accounts">Accounts</a>
11 -
  <a href="#registration">Registration</a>
12 -
  <a href="#organisations">Organisations</a>
13 -
  <a href="#signin">Sign in with</a>
14 -
  <a href="#mail">Mail</a>
15 -
  <a href="#security">Security contact</a>
16 -
  <a href="#variables">Variables</a>
17 -
  <a href="#secrets">Secrets</a>
18 -
  <a href="#assistants">AI assistants</a>
19 -
  <a href="#audit">Audit log</a>
20 -
  <a href="/admin/runners">Runners ↗</a>
21 -
  <a href="/admin/runners/images">Images ↗</a>
22 -
  <a href="/admin/diffusion">Diffusion ↗</a>
23 -
  <a href="/admin/backups">Backups ↗</a>
24 -
  <a href="/admin/storage">Storage and maintenance ↗</a>
25 -
</nav>
26 -
<div class="sections">
9 +
<div class="settings-layout">
10 +
{% call an::nav(section) %}
11 +
<div class="sections settings-body">
27 12
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
13 +
{% match section %}
14 +
{% when crate::web::admin::AdminSection::Overview %}
15 +
<section class="card-box admin-overview">
16 +
  <div class="card-head"><h2>This site</h2></div>
17 +
  <div class="pad admin-counts">
18 +
    <a href="/admin/accounts"><strong>{{ accounts.len() }}</strong> accounts</a>
19 +
    <a href="/admin/organisations"><strong>{{ orgs.len() }}</strong> organisations</a>
20 +
    <span><strong>{{ repos }}</strong> repositories</span>
21 +
    <a href="/admin/registration">registration <strong>{{ mode }}</strong></a>
22 +
    <a href="/admin/mail">mail <strong>{% if mail_enabled %}on{% else %}off{% endif %}</strong></a>
23 +
  </div>
24 +
</section>
25 +
<div class="admin-groups">
26 +
{% for (group, pages) in crate::web::admin::admin_groups() %}{% if *group != "Overview" %}
27 +
<section class="card-box">
28 +
  <div class="card-head"><h2>{{ group }}</h2></div>
29 +
  <ul class="card-list plain">{% for p in pages.iter() %}<li><a href="{{ p.href() }}">{{ p.title() }}</a></li>{% endfor %}</ul>
30 +
</section>
31 +
{% endif %}{% endfor %}
32 +
</div>
33 +
{% when crate::web::admin::AdminSection::Accounts %}
28 34
<section class="panel" id="accounts">
29 35
  <h2>Accounts</h2>
30 36
  <div class="table"><table class="small"><tr><th>Username</th><th>Email</th><th>Flags</th><th>Last seen</th><th></th></tr>
…
11 unchanged lines
42 48
  {% endfor %}
43 49
  </table></div>
44 50
</section>
51 +
{% when crate::web::admin::AdminSection::Registration %}
45 52
<section class="panel" id="registration">
46 53
  <h2>Registration and invitations</h2>
47 54
  <form class="card" method="post" action="/admin/registration">
…
5 unchanged lines
53 60
  <form class="card" method="post" action="/admin/invite"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">NEW INVITATION</button></form>
54 61
  {% if !invitations.is_empty() %}<ul class="plain small">{% for (code, used, at) in invitations %}<li><code>{{ code }}</code> {% if used.is_some() %}<span class="mute">used</span>{% else %}<a href="/register?invite={{ code }}">link</a>{% endif %} · {{ at|ago_i }}</li>{% endfor %}</ul>{% endif %}
55 62
</section>
63 +
{% when crate::web::admin::AdminSection::Organisations %}
56 64
<section class="panel" id="organisations">
57 65
  <h2>Organisations</h2>
58 66
  {% if orgs.is_empty() %}<p class="mute small">None yet.</p>{% else %}<ul class="plain">{% for o in orgs %}<li><img class="avatar xs" src="/avatar/{{ o.name }}" alt=""> <a href="/{{ o.name }}">{{ o.name }}</a> · <a class="small" href="/orgs/{{ o.name }}/settings">settings</a></li>{% endfor %}</ul>{% endif %}
59 67
</section>
68 +
{% when crate::web::admin::AdminSection::SignIn %}
60 69
<section class="panel" id="signin">
61 70
  <h2>Sign in with</h2>
62 71
  <p class="small mute">A first sign-in links the account with the same verified email, or makes an account when registration is open. Secrets are kept sealed.</p>
…
11 unchanged lines
74 83
  </form>
75 84
  {% endfor %}
76 85
</section>
86 +
{% when crate::web::admin::AdminSection::Mail %}
77 87
<section class="panel" id="mail">
78 88
  <h2>Mail</h2>
79 89
  <form class="card mail-panel" method="post" action="/admin/mail">
…
16 unchanged lines
96 106
  <form class="card" method="post" action="/admin/mail/test"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit"{% if !mail_enabled || my_email.is_empty() %} disabled{% endif %}>SEND A TEST{% if !my_email.is_empty() %} TO {{ my_email }}{% endif %}</button></form>
97 107
  <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} Off turns it off. Passwords and keys stay on the server and are never shown again. Every service is SMTP underneath, so any provider works: <code>smtps://USER:PASSWORD@HOST:465</code> for TLS from the start, or <code>smtp://USER:PASSWORD@HOST:587</code>, which the hub sends with STARTTLS required (add <code>?tls=opportunistic</code> or <code>?tls=none</code> to say otherwise; a relay on this machine needs none).</p>
98 108
</section>
109 +
{% when crate::web::admin::AdminSection::SecurityContact %}
99 110
<section class="panel" id="security">
100 111
  <h2>Security contact</h2>
101 112
  <form class="card" method="post" action="/admin/security">
…
6 unchanged lines
108 119
  </form>
109 120
  <p class="small mute">{% if security_contact.is_empty() %}No contact yet, so there is no <code>security.txt</code>.{% else %}Published at <a href="/.well-known/security.txt">/.well-known/security.txt</a>.{% endif %} Researchers look there (RFC 9116) for where to report a vulnerability in this hub. Its expiry date renews itself.</p>
110 121
</section>
122 +
{% when crate::web::admin::AdminSection::Variables %}
111 123
{% call va::panel("/admin", variables, shell.csrf, "Every repository's jobs get these, unless an organisation or repository sets the same name.") %}
124 +
{% when crate::web::admin::AdminSection::Secrets %}
112 125
{% call se::panel("/admin", secrets, shell.csrf, "Every repository's jobs may use these, unless an organisation or repository sets the same name.") %}
126 +
{% when crate::web::admin::AdminSection::Assistants %}
113 127
{% call ai::panel("/admin", assistants, shell.csrf, "Every repository on the site may ask these, unless an organisation or repository turns one off.") %}
128 +
{% when crate::web::admin::AdminSection::Audit %}
114 129
<section class="panel" id="audit">
115 130
  <h2>Audit log</h2>
116 131
  <div class="table"><table class="small"><tr><th>When</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr>
117 132
  {% for r in audit %}<tr><td><time title="{{ r.at_ms|when_i }}">{{ r.at_ms|ago_i }}</time></td><td>{{ r.actor }}</td><td><code>{{ r.action }}</code></td><td>{{ r.target }}</td><td class="mute">{{ r.detail }}</td></tr>{% endfor %}
118 133
  </table></div>
119 -
  <p class="pager small"><a href="/admin#audit">newest</a>{% if let Some(o) = audit_older %} <a class="go small" href="/admin?audit_before={{ o }}#audit">older</a>{% endif %}</p>
134 +
  <p class="pager small"><a href="/admin/audit">newest</a>{% if let Some(o) = audit_older %} <a class="go small" href="/admin/audit?audit_before={{ o }}">older</a>{% endif %}</p>
120 135
</section>
136 +
{% else %}
137 +
{% endmatch %}
121 138
</div>
122 139
</div>
123 140
{% endblock %}
modified templates/admin_backups.html +6 −0
1 1
{% extends "base.html" %}
2 +
{% import "admin_nav.html" as an %}
2 3
{% block title %}Backups · Administration{% endblock %}
3 4
{% block mainclass %} class="wide"{% endblock %}
4 5
{% block content %}
5 6
<div class="head"><h1>Backups <small>{{ owners.len()|plural("owner", "owners") }} · <a href="/admin">Administration</a></small></h1></div>
7 +
<div class="settings-layout">
8 +
{% call an::nav(crate::web::admin::AdminSection::Backups) %}
9 +
<div class="sections settings-body">
6 10
<p class="small mute">Each user and organisation backs up its own repositories, to a place it controls (docs/backups.md). Here is whether each one ran and how it went; what a run wrote stays with its owner. This site's own backup runs on its host, outside the hub.</p>
7 11
<section class="card-box">
8 12
  <div class="card-head"><h2>Owners' backups</h2><span class="count-chip">{{ owners.len() }}</span></div>
…
23 unchanged lines
32 36
  <ul class="plain">{% for (owner, n) in uncovered %}<li><a href="/{{ owner }}">{{ owner }}</a> <span class="mute small">{{ n|plural("repository", "repositories") }}</span></li>{% endfor %}</ul>
33 37
  {% endif %}
34 38
</section>
39 +
</div>
40 +
</div>
35 41
{% endblock %}
modified templates/admin_diffusion.html +6 −0
1 1
{% extends "base.html" %}
2 +
{% import "admin_nav.html" as an %}
2 3
{% import "diffusion_rows.html" as d %}
3 4
{% block title %}Diffusion · Administration{% endblock %}
4 5
{% block mainclass %} class="wide"{% endblock %}
5 6
{% block content %}
6 7
<div class="head"><h1>Diffusion <small>{{ upstreams.len()|plural("upstream", "upstreams") }} · <a href="/admin">Administration</a></small></h1></div>
8 +
<div class="settings-layout">
9 +
{% call an::nav(crate::web::admin::AdminSection::Diffusion) %}
10 +
<div class="sections settings-body">
7 11
<p class="small mute df-key">Each pill has two halves. Left, the upstream: <span class="df-pill"><span class="df-half df-green">checked</span></span> its own run passed, <span class="df-pill"><span class="df-half df-amber">untested</span></span> it has no checks, <span class="df-pill"><span class="df-half df-red">failing</span></span> its run failed. Right, the repository that pins it: <span class="df-pill"><span class="df-half df-green">passes</span></span> its run on the bump passed, <span class="df-pill"><span class="df-half df-red">fails</span></span> it failed, <span class="df-pill"><span class="df-half df-grey">testing</span></span> still running, <span class="df-pill"><span class="df-half df-green">up to date</span></span> nothing to bump.</p>
8 12
{% if upstreams.is_empty() %}<p class="empty">No repository on this hub is pinned by another yet.</p>{% endif %}
9 13
{% for (up, list, unverified) in upstreams %}
10 14
<h2><a href="/{{ up.owner }}/{{ up.name }}/diffusion">{{ up.owner }}/{{ up.name }}</a> <small>{{ list.len()|plural("consumer", "consumers") }}{% if *unverified %} · <span class="df-pill"><span class="df-half df-amber">untested</span></span>{% endif %}</small></h2>
11 15
{% call d::rows(list) %}
12 16
{% endfor %}
17 +
</div>
18 +
</div>
13 19
{% endblock %}
modified templates/admin_images.html +6 −0
1 1
{% extends "base.html" %}
2 +
{% import "admin_nav.html" as an %}
2 3
{% block title %}Images · Administration{% endblock %}
3 4
{% block content %}
4 5
{# The swarm (docs/backgrounds.md §2): the hub and the runners, and the
…
2 unchanged lines
7 8
{% include "backdrop.html" %}
8 9
<div data-backdrop-avoid>
9 10
<div class="head"><h1>Runners <small>{{ images.len()|plural("image", "images") }} · {{ on_hub }} on the hub · <a href="/admin">Administration</a></small></h1></div>
11 +
<div class="settings-layout">
12 +
{% call an::nav(crate::web::admin::AdminSection::Images) %}
13 +
<div class="sections settings-body">
10 14
<div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images" class="on">images</a><a href="/admin/runners/settings">settings</a></span></div>
11 15
<p class="small mute">Every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days, newest use first. {% if swarm.is_empty() %}No runner shares images peer to peer yet: give one a <code>[runner.swarm]</code> section.{% else %}Sharing images peer to peer: {% for s in swarm %}{{ s }}{% if !loop.last %}, {% endif %}{% endfor %}.{% endif %} Send an image to the hub with <code>flow rootfs push HASH</code>.</p>
12 16
{% if images.is_empty() %}
…
13 unchanged lines
26 30
</table></div>
27 31
{% endif %}
28 32
</div>
33 +
</div>
34 +
</div>
29 35
{% endblock %}
modified templates/admin_runner.html +6 −0
1 1
{% extends "base.html" %}
2 +
{% import "admin_nav.html" as an %}
2 3
{% block title %}{{ r.name }} · Runners{% endblock %}
3 4
{% block mainclass %} class="wide"{% endblock %}
4 5
{% block content %}
5 6
<div class="head"><h1>{{ r.name }} <small><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span> · <a href="/admin/runners">Runners</a></small></h1></div>
7 +
<div class="settings-layout">
8 +
{% call an::nav(crate::web::admin::AdminSection::Runners) %}
9 +
<div class="sections settings-body">
6 10
<section class="panel">
7 11
  <dl class="facts">
8 12
    <dt>Signing key</dt><dd>{% if r.key.is_empty() %}<span class="mute">bound on its first signed receipt</span>{% else %}<code class="full">{{ r.key }}</code> {{ r.key_name }}{% endif %}</dd>
…
51 unchanged lines
60 64
  {% if let Some(b) = older %}<p><a href="?before={{ b }}">Older jobs</a></p>{% endif %}
61 65
  {% endif %}
62 66
</section>
67 +
</div>
68 +
</div>
63 69
{% endblock %}
modified templates/admin_runner_settings.html +6 −0
1 1
{% extends "base.html" %}
2 +
{% import "admin_nav.html" as an %}
2 3
{% block title %}Runner settings · Administration{% endblock %}
3 4
{% block content %}
4 5
<div class="head"><h1>Runners <small><a href="/admin">Administration</a></small></h1></div>
6 +
<div class="settings-layout">
7 +
{% call an::nav(crate::web::admin::AdminSection::RunnerSettings) %}
8 +
<div class="sections settings-body">
5 9
<div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images">images</a><a href="/admin/runners/settings" class="on">settings</a></span></div>
6 10
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
7 11
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
…
24 unchanged lines
32 36
  <h2>Repositories that run Flow</h2>
33 37
  {% if flow_repos.is_empty() %}<p class="mute">None listed in hub.toml; each repository's administrator switches Flow on in its Settings.</p>{% else %}<ul class="plain">{% for r in flow_repos %}<li><code>{{ r }}</code></li>{% endfor %}</ul><p class="small mute">From hub.toml's <code>[flow] repos</code>; others are switched on in each repository's Settings.</p>{% endif %}
34 38
</section>
39 +
</div>
40 +
</div>
35 41
{% endblock %}
modified templates/admin_runners.html +6 −0
1 1
{% extends "base.html" %}
2 +
{% import "admin_nav.html" as an %}
2 3
{% block title %}Runners · Administration{% endblock %}
3 4
{% block mainclass %} class="wide"{% endblock %}
4 5
{% block content %}
5 6
<div class="head"><h1>Runners <small>{{ runners.len()|plural("runner", "runners") }} · <a href="/admin">Administration</a></small></h1></div>
7 +
<div class="settings-layout">
8 +
{% call an::nav(crate::web::admin::AdminSection::Runners) %}
9 +
<div class="sections settings-body">
6 10
<div class="bar"><span class="modes"><a href="/admin/runners" class="on">runners</a><a href="/admin/runners/images">images</a><a href="/admin/runners/settings">settings</a></span></div>
7 11
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
8 12
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
…
57 unchanged lines
66 70
    <button type="submit" class="primary-btn">Create a registration token</button></form>
67 71
  {% if !pending.is_empty() %}<p class="small mute">Unused tokens: {% for (by, made, expires) in pending %}by {{ by }} {{ made|ago_i }}, expires {{ expires|when_i }}{% if !loop.last %}; {% endif %}{% endfor %}</p>{% endif %}
68 72
</section>
73 +
</div>
74 +
</div>
69 75
{% endblock %}
modified templates/admin_storage.html +6 −0
1 1
{% extends "base.html" %}
2 +
{% import "admin_nav.html" as an %}
2 3
{% block title %}Storage · Administration{% endblock %}
3 4
{% block content %}
4 5
<div class="head"><h1>Storage <small>{{ rows.len() }} repositories · {{ total|bytes }}</small></h1></div>
6 +
<div class="settings-layout">
7 +
{% call an::nav(crate::web::admin::AdminSection::Storage) %}
8 +
<div class="sections settings-body">
5 9
<p><a href="/admin">← administration</a></p>
6 10
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
7 11
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
…
8 unchanged lines
16 20
  {% endfor %}
17 21
  </tbody>
18 22
</table>
23 +
</div>
24 +
</div>
19 25
{% endblock %}
modified templates/style.css +5 −0
…
450 unchanged lines
451 451
.sections .panel form.card { max-width: 720px; }
452 452
.sections > details > form.card { max-width: none; }
453 453
/* A repository's settings (#278): grouped pages in a sidebar, one page beside it. */
454 +
/* The admin overview (oxygenhub#288): the site's numbers, then a card per
455 +
   group of pages. */
456 +
.admin-counts { display: flex; flex-wrap: wrap; gap: 8px 24px; align-items: baseline; }
457 +
.admin-counts strong { font-size: 18px; margin-right: 4px; }
458 +
.admin-groups { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0 16px; }
454 459
.settings-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 28px; align-items: start; }
455 460
.settings-nav { position: sticky; top: 14px; display: flex; flex-direction: column; gap: 14px; }
456 461
.settings-group h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--mute); margin: 0 0 4px; padding: 0 12px; }
…
969 unchanged lines