Versions on the hub: About page, /api/v1/version, footer, oh --version e9bfea8e26db
The hub reports its version and build (oxy describe at deploy, which make deploy now passes as OXY_BUILD; a Flow build's heads; or "local build") with the oxy-core and flow-core versions it is built on: on a new /about page (with each runner's reported version for administrators), at /api/v1/version, in /health, and in the footer, which links to /about. oh --version names its build the same way. The oxygen and oxygenflow pins move to their build identity. Asked for by Andy as well: the repository bar is as wide as its contents need and wraps only when the window is too narrow, and the page always keeps room for the scroll bar so nothing shifts when it appears. closes #55
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 17:47 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
e9bfea8e26dbe8e3ff057ed872f624628459fafb29eb268032c53bff5c7a83fc- Depends on
- 18e381ea88c2 19bb31ff1f5c 380996db84e5 38833ed78707 42a0ba4bac4e 42fa95540468 5c6e92fd8d71 756fe44a0f4d 8d2caf122517 8f3d2ccfe6fc 90326c9601f9 b64e2537d702 b6b7e4cf6b24 d23464c0ac60
- Depended on by
- aeed63c00ad7 166f6e9bf4f1 3a01077da29a c9aacc0e7dc1 b210727578e8 745d225de344 967ef74f6fd9 60f11b71a740 df71c7892d27 3d3d29fbcb00
- 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.1.0 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
- 24 atoms, 3 tree operations, 12 files
Browse the tree at this change
added crates/hub/build.rs +20 −0
| 1 | + | //! The build's identity, for the version it reports: `OXY_BUILD` when a release or |
|
| 2 | + | //! an install sets it (from `oxy describe`), else the heads a Flow job is |
|
| 3 | + | //! building, else "local build". |
|
| 4 | + | fn main() { |
|
| 5 | + | println!("cargo:rerun-if-env-changed=OXY_BUILD"); |
|
| 6 | + | println!("cargo:rerun-if-env-changed=FLOW_HEADS"); |
|
| 7 | + | let build = std::env::var("OXY_BUILD") |
|
| 8 | + | .ok() |
|
| 9 | + | .map(|b| b.trim().to_string()) |
|
| 10 | + | .filter(|b| !b.is_empty()) |
|
| 11 | + | .or_else(|| { |
|
| 12 | + | std::env::var("FLOW_HEADS") |
|
| 13 | + | .ok() |
|
| 14 | + | .and_then(|h| h.split('+').next().map(|x| format!("flow #{}", &x[..12.min(x.len())]))) |
|
| 15 | + | .filter(|b| b.len() > "flow #".len()) |
|
| 16 | + | }) |
|
| 17 | + | .unwrap_or_else(|| "local build".into()); |
|
| 18 | + | println!("cargo:rustc-env=OXY_BUILD_ID={build}"); |
|
| 19 | + | println!("cargo:rustc-env=OXY_VERSION_LONG={} ({build})", std::env::var("CARGO_PKG_VERSION").unwrap()); |
|
| 20 | + | } |
added crates/oh/build.rs +20 −0
| 1 | + | //! The build's identity, for the version it reports: `OXY_BUILD` when a release or |
|
| 2 | + | //! an install sets it (from `oxy describe`), else the heads a Flow job is |
|
| 3 | + | //! building, else "local build". |
|
| 4 | + | fn main() { |
|
| 5 | + | println!("cargo:rerun-if-env-changed=OXY_BUILD"); |
|
| 6 | + | println!("cargo:rerun-if-env-changed=FLOW_HEADS"); |
|
| 7 | + | let build = std::env::var("OXY_BUILD") |
|
| 8 | + | .ok() |
|
| 9 | + | .map(|b| b.trim().to_string()) |
|
| 10 | + | .filter(|b| !b.is_empty()) |
|
| 11 | + | .or_else(|| { |
|
| 12 | + | std::env::var("FLOW_HEADS") |
|
| 13 | + | .ok() |
|
| 14 | + | .and_then(|h| h.split('+').next().map(|x| format!("flow #{}", &x[..12.min(x.len())]))) |
|
| 15 | + | .filter(|b| b.len() > "flow #".len()) |
|
| 16 | + | }) |
|
| 17 | + | .unwrap_or_else(|| "local build".into()); |
|
| 18 | + | println!("cargo:rustc-env=OXY_BUILD_ID={build}"); |
|
| 19 | + | println!("cargo:rustc-env=OXY_VERSION_LONG={} ({build})", std::env::var("CARGO_PKG_VERSION").unwrap()); |
|
| 20 | + | } |
added templates/about.html +22 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}About{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>About <small>{{ shell.site }}</small></h1></div> |
|
| 5 | + | <section class="panel"> |
|
| 6 | + | <h2>This hub</h2> |
|
| 7 | + | <dl class="facts"> |
|
| 8 | + | <dt>OxygenHub</dt><dd><strong>{{ shell.version }}</strong> <span class="mute">({{ shell.build }})</span></dd> |
|
| 9 | + | <dt>Built on</dt><dd>oxy-core {{ oxy_core }} · flow-core {{ flow_core }}</dd> |
|
| 10 | + | </dl> |
|
| 11 | + | <p class="small mute">The build names the state it was built from (<code>oxy describe</code>), the heads of a Flow build, or says "local build". The same is at <a href="/api/v1/version">/api/v1/version</a>; <code>oxy --version</code>, <code>oh --version</code> and <code>flow --version</code> report theirs, and the VS Code extensions show theirs in the Extensions view.</p> |
|
| 12 | + | </section> |
|
| 13 | + | {% if !runners.is_empty() %} |
|
| 14 | + | <section class="panel"> |
|
| 15 | + | <h2>Runners</h2> |
|
| 16 | + | <div class="table"><table> |
|
| 17 | + | <tr><th>Runner</th><th>Status</th><th>flow</th><th>Machine</th></tr> |
|
| 18 | + | {% for r in runners %}<tr><td><a href="/admin/runners/{{ r.id }}">{{ r.name }}</a></td><td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td><td>{% if r.version.is_empty() %}<span class="mute">not reported</span>{% else %}{{ r.version }}{% endif %}</td><td class="small">{{ r.arch }} {{ r.os }}</td></tr>{% endfor %} |
|
| 19 | + | </table></div> |
|
| 20 | + | </section> |
|
| 21 | + | {% endif %} |
|
| 22 | + | {% endblock %} |
modified Makefile +1 −1
| … | 23 unchanged lines |
||
| 24 | 24 | # Developer edition on Earth (docs/deploy.md). |
|
| 25 | 25 | deploy: |
|
| 26 | 26 | rsync -a --delete --exclude target --exclude node_modules --exclude .oxy/objects/loose ../oxygen ../oxygenflow ../oxygenhub claude@earth:src/ |
|
| 27 | - | ssh claude@earth 'source ~/.cargo/env && cd src/oxygenhub && cargo build --release && sudo install -m 0755 target/release/oxygenhub /usr/local/bin/oxygenhub && sudo systemctl restart oxygenhub && systemctl is-active oxygenhub' |
|
| 27 | + | B="$$(oxy --plain describe 2>/dev/null)"; ssh claude@earth "source ~/.cargo/env && cd src/oxygenhub && OXY_BUILD='$$B' cargo build --release && sudo install -m 0755 target/release/oxygenhub /usr/local/bin/oxygenhub && sudo systemctl restart oxygenhub && systemctl is-active oxygenhub" |
|
| 28 | 28 | ||
| 29 | 29 | # Move the pinned oxygenflow (deps/oxygenflow) to its main on Earth, with the |
|
| 30 | 30 | # oxygen it pins; the next commit records the new pin. Push oxygen, then |
|
| … | 4 unchanged lines |
modified crates/hub/src/api/mod.rs +7 −0
| … | 17 unchanged lines |
||
| 18 | 18 | mod projects; |
|
| 19 | 19 | ||
| 20 | 20 | /// Any other path under `/api/v1`: a problem document, not the site's page. |
|
| 21 | + | /// `GET /api/v1/version`: the hub's version and build, and the Oxygen and |
|
| 22 | + | /// OxygenFlow library versions it is built on. |
|
| 23 | + | async fn version() -> Json<serde_json::Value> { |
|
| 24 | + | Json(crate::web::home::versions()) |
|
| 25 | + | } |
|
| 26 | + | ||
| 21 | 27 | async fn no_route() -> ApiError { |
|
| 22 | 28 | ApiError(StatusCode::NOT_FOUND, "no such API route".into()) |
|
| 23 | 29 | } |
|
| … | 14 unchanged lines |
||
| 38 | 44 | .route("/projects/{owner}/{number}/cards", post(projects::add_card)) |
|
| 39 | 45 | .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card)) |
|
| 40 | 46 | .route("/user", get(me)) |
|
| 47 | + | .route("/version", get(version)) |
|
| 41 | 48 | .route("/notifications", get(notifications)) |
|
| 42 | 49 | .route("/notifications/read", post(notifications_read_all)) |
|
| 43 | 50 | .route("/notifications/{id}/read", post(notification_read)) |
|
| … | 1626 unchanged lines |
modified crates/hub/src/web/home.rs +29 −0
| … | 226 unchanged lines |
||
| 227 | 227 | let body = serde_json::json!({ |
|
| 228 | 228 | "ok": true, |
|
| 229 | 229 | "version": env!("CARGO_PKG_VERSION"), |
|
| 230 | + | "build": env!("OXY_BUILD_ID"), |
|
| 230 | 231 | "uptime_s": app.started.elapsed().as_secs(), |
|
| 231 | 232 | }); |
|
| 232 | 233 | axum::Json(body).into_response() |
|
| … | 24 unchanged lines |
||
| 257 | 258 | .into_response(), |
|
| 258 | 259 | Err(_) => (axum::http::StatusCode::NOT_FOUND, "no certificate").into_response(), |
|
| 259 | 260 | } |
|
| 261 | + | } |
|
| 262 | + | ||
| 263 | + | /// What this hub is built from (hub spec §15): its version and build, and |
|
| 264 | + | /// the versions of the Oxygen and OxygenFlow libraries it links. |
|
| 265 | + | pub fn versions() -> serde_json::Value { |
|
| 266 | + | serde_json::json!({ |
|
| 267 | + | "oxygenhub": { "version": env!("CARGO_PKG_VERSION"), "build": env!("OXY_BUILD_ID") }, |
|
| 268 | + | "oxy_core": oxy_core::VERSION, |
|
| 269 | + | "flow_core": flow_core::VERSION, |
|
| 270 | + | }) |
|
| 271 | + | } |
|
| 272 | + | ||
| 273 | + | #[derive(askama::Template)] |
|
| 274 | + | #[template(path = "about.html")] |
|
| 275 | + | pub struct AboutPage { |
|
| 276 | + | shell: Shell, |
|
| 277 | + | oxy_core: &'static str, |
|
| 278 | + | flow_core: &'static str, |
|
| 279 | + | /// Runners and the versions they report, for administrators. |
|
| 280 | + | runners: Vec<crate::db::runners::Runner>, |
|
| 281 | + | } |
|
| 282 | + | ||
| 283 | + | /// `GET /about`: the hub's version and what it is built on; runners too, for |
|
| 284 | + | /// administrators. |
|
| 285 | + | pub async fn about(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AboutPage>, Error> { |
|
| 286 | + | let shell = Shell::new(&app, "/about", &viewer); |
|
| 287 | + | let runners = if viewer.is_admin() { crate::db::runners::list(&app.db).await.unwrap_or_default() } else { vec![] }; |
|
| 288 | + | Ok(Page(AboutPage { shell, oxy_core: oxy_core::VERSION, flow_core: flow_core::VERSION, runners })) |
|
| 260 | 289 | } |
modified crates/hub/src/web/mod.rs +6 −1
| … | 11 unchanged lines |
||
| 12 | 12 | mod forks; |
|
| 13 | 13 | pub mod help; |
|
| 14 | 14 | pub mod history; |
|
| 15 | - | mod home; |
|
| 15 | + | pub mod home; |
|
| 16 | 16 | mod inbox; |
|
| 17 | 17 | pub mod issues; |
|
| 18 | 18 | mod orgs; |
|
| … | 31 unchanged lines |
||
| 50 | 50 | .route("/explore", get(home::explore)) |
|
| 51 | 51 | .route("/avatar/{name}", get(avatar::avatar)) |
|
| 52 | 52 | .route("/health", get(home::health)) |
|
| 53 | + | .route("/about", get(home::about)) |
|
| 53 | 54 | .route("/.well-known/oxyhub-key", get(home::hub_key)) |
|
| 54 | 55 | .route("/.well-known/oxyhub-ca.pem", get(home::hub_ca)) |
|
| 55 | 56 | .route("/style.css", get(statics::style)) |
|
| … | 238 unchanged lines |
||
| 294 | 295 | pub base_url: String, |
|
| 295 | 296 | pub path: String, |
|
| 296 | 297 | pub version: &'static str, |
|
| 298 | + | /// What was built: `oxy describe` at deploy, a Flow build's heads, or "local build". |
|
| 299 | + | pub build: &'static str, |
|
| 297 | 300 | /// The signed-in username, if any. |
|
| 298 | 301 | pub user: Option<String>, |
|
| 299 | 302 | pub is_admin: bool, |
|
| … | 8 unchanged lines |
||
| 308 | 311 | base_url: app.config.base_url().to_string(), |
|
| 309 | 312 | path: path.to_string(), |
|
| 310 | 313 | version: env!("CARGO_PKG_VERSION"), |
|
| 314 | + | build: env!("OXY_BUILD_ID"), |
|
| 311 | 315 | user: viewer.account().map(|a| a.username.clone()), |
|
| 312 | 316 | is_admin: viewer.is_admin(), |
|
| 313 | 317 | csrf: viewer.csrf().to_string(), |
|
| … | 192 unchanged lines |
||
| 506 | 510 | base_url: String::new(), |
|
| 507 | 511 | path: String::new(), |
|
| 508 | 512 | version: "", |
|
| 513 | + | build: "", |
|
| 509 | 514 | user: None, |
|
| 510 | 515 | is_admin: false, |
|
| 511 | 516 | csrf: String::new(), |
|
| … | 91 unchanged lines |
modified crates/hub/tests/api.rs +18 −0
| … | 1112 unchanged lines |
||
| 1113 | 1113 | let chain = &two[two.find("class=\"chain\"").expect("a chain on issue 2")..]; |
|
| 1114 | 1114 | assert!(chain.contains("proposal #3"), "{chain}"); |
|
| 1115 | 1115 | } |
|
| 1116 | + | ||
| 1117 | + | /// What the hub is: its version and build, and the libraries it is built on, |
|
| 1118 | + | /// on /about, in the API and in /health (hub spec §15). |
|
| 1119 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1120 | + | async fn the_hub_says_what_version_it_is() { |
|
| 1121 | + | let h = hub().await; |
|
| 1122 | + | let app = &h.app; |
|
| 1123 | + | let (st, v) = api(app, "GET", "/api/v1/version", None, None).await; |
|
| 1124 | + | assert_eq!(st, StatusCode::OK); |
|
| 1125 | + | assert_eq!(v["oxygenhub"]["version"], env!("CARGO_PKG_VERSION")); |
|
| 1126 | + | assert!(v["oxygenhub"]["build"].as_str().is_some_and(|b| !b.is_empty()), "{v}"); |
|
| 1127 | + | assert!(v["oxy_core"].as_str().is_some() && v["flow_core"].as_str().is_some()); |
|
| 1128 | + | let health = call(app, "GET", "/health", None, None, None, None).await.body; |
|
| 1129 | + | assert!(health.contains("\"build\""), "{health}"); |
|
| 1130 | + | let about = call(app, "GET", "/about", None, None, None, None).await.body; |
|
| 1131 | + | assert!(about.contains(&format!("<strong>{}</strong>", env!("CARGO_PKG_VERSION"))) && about.contains("oxy-core"), "{about}"); |
|
| 1132 | + | assert!(about.contains("href=\"/about\""), "the footer links here"); |
|
| 1133 | + | } |
modified crates/oh/src/main.rs +1 −1
| … | 13 unchanged lines |
||
| 14 | 14 | use target::{Choice, Config, Host, Target}; |
|
| 15 | 15 | ||
| 16 | 16 | #[derive(Parser)] |
|
| 17 | - | #[command(name = "oh", version, about = "OxygenHub from the command line: issues, proposals, Flow runs and releases")] |
|
| 17 | + | #[command(name = "oh", version = env!("OXY_VERSION_LONG"), about = "OxygenHub from the command line: issues, proposals, Flow runs and releases")] |
|
| 18 | 18 | struct Cli { |
|
| 19 | 19 | /// The repository, when not inside an Oxygen checkout |
|
| 20 | 20 | #[arg(long, short = 'R', global = true, value_name = "OWNER/NAME")] |
|
| … | 697 unchanged lines |
replaced deps/oxygenflow +0 −0
No line changes.
modified templates/base.html +1 −1
| … | 32 unchanged lines |
||
| 33 | 33 | {% if shell.user.is_some() %}<a href="/notifications">Notifications</a><a href="/settings">Settings</a>{% endif %} |
|
| 34 | 34 | <span class="grow"></span> |
|
| 35 | 35 | {% if shell.is_admin %}<a class="admin" href="/admin">Admin</a>{% endif %} |
|
| 36 | - | <a class="dim" href="/{{ shell.version }}" title="OxygenHub {{ shell.version }}" tabindex="-1">v{{ shell.version }}</a> |
|
| 36 | + | <a class="dim" href="/about" title="OxygenHub {{ shell.version }} ({{ shell.build }})">v{{ shell.version }}</a> |
|
| 37 | 37 | </nav> |
|
| 38 | 38 | {% block tabs %}{% endblock %} |
|
| 39 | 39 | <main id="main" tabindex="-1"{% block mainclass %}{% endblock %}> |
|
| … | 22 unchanged lines |
modified templates/style.css +7 −2
| … | 30 unchanged lines |
||
| 31 | 31 | } |
|
| 32 | 32 | ||
| 33 | 33 | * { box-sizing: border-box; } |
|
| 34 | - | html { background: var(--bg); } |
|
| 34 | + | /* Room for the scroll bar always, so nothing shifts when it comes and goes. */ |
|
| 35 | + | html { background: var(--bg); scrollbar-gutter: stable; } |
|
| 35 | 36 | body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; background: var(--bg); color: var(--ink); font: 15px/1.45 "Barlow", "IBM Plex Sans", system-ui, sans-serif; } |
|
| 36 | 37 | a { color: var(--acc); text-decoration: none; } |
|
| 37 | 38 | a:hover { text-decoration: underline; } |
|
| … | 39 unchanged lines |
||
| 77 | 78 | .band .grow { flex: 1; } |
|
| 78 | 79 | .band a.dim { opacity: .6; border: 0; } |
|
| 79 | 80 | .tabs { background: var(--panel); border-bottom: 1px solid var(--line); } |
|
| 80 | - | .tabs-in { max-width: 1180px; margin: 0 auto; padding: 0 22px; display: flex; align-items: center; gap: 2px; flex-wrap: wrap; } |
|
| 81 | + | /* The repository bar is as wide as its contents need: at least the page |
|
| 82 | + | column (so it lines up with it), wider for a long name, and it wraps only |
|
| 83 | + | when the window itself is too narrow. */ |
|
| 84 | + | .tabs-in { width: fit-content; min-width: min(1180px, 100%); max-width: 100%; box-sizing: border-box; margin: 0 auto; padding: 0 22px; display: flex; align-items: center; gap: 2px; flex-wrap: wrap; } |
|
| 85 | + | .tabs .crumb { white-space: nowrap; } |
|
| 81 | 86 | .tabs .crumb { margin-right: 18px; padding: 10px 0; font-size: 16px; } |
|
| 82 | 87 | .tabs a:not(.crumb a) { padding: 10px 12px; color: var(--ink); font-size: 13px; } |
|
| 83 | 88 | .tabs a.on { border-bottom: 2px solid var(--acc); font-weight: 600; } |
|
| … | 513 unchanged lines |