Live L4: the bell, the inbox and the dashboard (closes #206); /metrics, first slice (refs #212) 8a48dd830178
Live (docs/live.md L4): - every signed-in page's notification count listens on the viewer's user:<id> topic (the Shell carries it); - the inbox's list listens on it too; - the dashboard's cards and activity feed listen on it and on up to 15 of the viewer's repositories' topics. The client refetches the page and swaps each marked part, so no route changes. Test: the bell, inbox and dashboard are marked for a signed-in viewer, nothing for a signed-out one. /metrics (#212, hub spec §13.5.5): Prometheus text, only to a bearer of [server] metrics_token (unset: 404). It reports the build, uptime, memory, state cache, bucket requests by kind, Flow jobs (queued, waiting for approval, running), runners online, and the transparency log's entries. Test: off by default; the families render; a run's jobs show as queued. Pins oxygen 051f6efc (a bucket store's stat is a HEAD; no stat per pack on open). Closes #206
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 99b21c29b743
- When
- · 2026-10-18 02:25 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
8a48dd83017887dd6e31775ac1ad4684d223e9932110c29841b8eb63fbd21449- Depends on
- 101740454617 17ca6cddc534 18e381ea88c2 19bb31ff1f5c 25ab8c73514a 2cbf6f348595 380996db84e5 3eb491e9489b 3f4b0ea6eabf 3ff52534f69b 42fa95540468 617c2840446c 8a3334b7a364 8dcaeeb848c6 93be9ce4653e 965b950f9b96 b5a0a69b3622 c0e39717895d d23464c0ac60 da8f7826a645 eb0c44a9ad37 f8cf54e99879 ffb67a81188c
- Depended on by
- 40a43921ef42 a22e4aec2e28 0468394734e9 9e8cbfc3ad00 7c06f5b28ade e97500b70f9a 67523e06ed86 85ec74fd82d8 2c47eecfeab6 a6d824c5ebf5 b9b74d997cec a10446c68043
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 23 atoms, 1 tree operation, 13 files
Browse the tree at this change
added crates/hub/src/metrics.rs +148 −0
| 1 | + | //! `/metrics` (oxygenhub#212): the hub's numbers in the Prometheus text |
|
| 2 | + | //! format, for a monitoring stack (oxygenhub-gcp's Prometheus and Grafana). |
|
| 3 | + | //! Private: served only to a bearer of `[server] metrics_token`, and not |
|
| 4 | + | //! at all while that is unset. |
|
| 5 | + | //! |
|
| 6 | + | //! First slice: the process, the state cache, repositories kept in buckets, |
|
| 7 | + | //! OxygenFlow's jobs and runners, and the transparency log. Per-route |
|
| 8 | + | //! request histograms and the rest the issue lists come next. |
|
| 9 | + | ||
| 10 | + | use crate::{db, Shared}; |
|
| 11 | + | use axum::extract::State; |
|
| 12 | + | use axum::http::{header, HeaderMap, StatusCode}; |
|
| 13 | + | use axum::response::{IntoResponse, Response}; |
|
| 14 | + | use std::fmt::Write; |
|
| 15 | + | ||
| 16 | + | /// One metric family: its help, type and samples (label set, value). |
|
| 17 | + | fn family(out: &mut String, name: &str, help: &str, kind: &str, samples: &[(String, f64)]) { |
|
| 18 | + | let _ = writeln!(out, "# HELP {name} {help}"); |
|
| 19 | + | let _ = writeln!(out, "# TYPE {name} {kind}"); |
|
| 20 | + | for (labels, v) in samples { |
|
| 21 | + | if labels.is_empty() { |
|
| 22 | + | let _ = writeln!(out, "{name} {v}"); |
|
| 23 | + | } else { |
|
| 24 | + | let _ = writeln!(out, "{name}{{{labels}}} {v}"); |
|
| 25 | + | } |
|
| 26 | + | } |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | /// A label value, escaped as the text format asks. |
|
| 30 | + | fn label(v: &str) -> String { |
|
| 31 | + | v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n") |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | /// The process's resident memory, from /proc (Linux), in bytes. |
|
| 35 | + | fn resident_bytes() -> Option<f64> { |
|
| 36 | + | let statm = std::fs::read_to_string("/proc/self/statm").ok()?; |
|
| 37 | + | let pages: f64 = statm.split_whitespace().nth(1)?.parse().ok()?; |
|
| 38 | + | Some(pages * 4096.0) |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | /// The metrics as text, for whoever is let in. |
|
| 42 | + | pub async fn render(app: &Shared) -> String { |
|
| 43 | + | let mut out = String::new(); |
|
| 44 | + | family( |
|
| 45 | + | &mut out, |
|
| 46 | + | "oxyhub_build_info", |
|
| 47 | + | "The hub's version and build.", |
|
| 48 | + | "gauge", |
|
| 49 | + | &[(format!("version=\"{}\",build=\"{}\"", env!("CARGO_PKG_VERSION"), label(env!("OXY_BUILD_ID"))), 1.0)], |
|
| 50 | + | ); |
|
| 51 | + | family( |
|
| 52 | + | &mut out, |
|
| 53 | + | "oxyhub_uptime_seconds", |
|
| 54 | + | "Seconds since the hub started.", |
|
| 55 | + | "gauge", |
|
| 56 | + | &[(String::new(), app.started.elapsed().as_secs_f64())], |
|
| 57 | + | ); |
|
| 58 | + | if let Some(rss) = resident_bytes() { |
|
| 59 | + | family(&mut out, "oxyhub_resident_memory_bytes", "The process's resident memory.", "gauge", &[(String::new(), rss)]); |
|
| 60 | + | } |
|
| 61 | + | let (states, weight, summaries) = app.repos.cache().stats(); |
|
| 62 | + | family(&mut out, "oxyhub_state_cache_states", "States the state cache holds.", "gauge", &[(String::new(), states as f64)]); |
|
| 63 | + | family( |
|
| 64 | + | &mut out, |
|
| 65 | + | "oxyhub_state_cache_bytes", |
|
| 66 | + | "What the state cache's states are estimated to weigh.", |
|
| 67 | + | "gauge", |
|
| 68 | + | &[(String::new(), weight as f64)], |
|
| 69 | + | ); |
|
| 70 | + | family( |
|
| 71 | + | &mut out, |
|
| 72 | + | "oxyhub_state_cache_budget_bytes", |
|
| 73 | + | "What the state cache may hold.", |
|
| 74 | + | "gauge", |
|
| 75 | + | &[(String::new(), crate::repos::cache::budget() as f64)], |
|
| 76 | + | ); |
|
| 77 | + | family(&mut out, "oxyhub_state_cache_summaries", "Summaries the state cache holds.", "gauge", &[(String::new(), summaries as f64)]); |
|
| 78 | + | let strata = oxy_strata::stats(); |
|
| 79 | + | family( |
|
| 80 | + | &mut out, |
|
| 81 | + | "oxyhub_bucket_clients_opened_total", |
|
| 82 | + | "Bucket store clients opened.", |
|
| 83 | + | "counter", |
|
| 84 | + | &[(String::new(), strata.opens as f64)], |
|
| 85 | + | ); |
|
| 86 | + | family( |
|
| 87 | + | &mut out, |
|
| 88 | + | "oxyhub_bucket_requests_total", |
|
| 89 | + | "Requests to bucket stores, by kind.", |
|
| 90 | + | "counter", |
|
| 91 | + | &strata.ops().map(|(k, n)| (format!("kind=\"{k}\""), n as f64)).collect::<Vec<_>>(), |
|
| 92 | + | ); |
|
| 93 | + | family( |
|
| 94 | + | &mut out, |
|
| 95 | + | "oxyhub_bucket_request_seconds_total", |
|
| 96 | + | "Time spent in requests to bucket stores.", |
|
| 97 | + | "counter", |
|
| 98 | + | &[(String::new(), strata.millis as f64 / 1000.0)], |
|
| 99 | + | ); |
|
| 100 | + | let jobs: Vec<(String, i64)> = |
|
| 101 | + | sqlx::query_as("SELECT status, COUNT(*) FROM flow_jobs WHERE status IN ('queued', 'running') GROUP BY status") |
|
| 102 | + | .fetch_all(&app.db) |
|
| 103 | + | .await |
|
| 104 | + | .unwrap_or_default(); |
|
| 105 | + | let count = |s: &str| jobs.iter().find(|(k, _)| k == s).map(|(_, n)| *n).unwrap_or(0) as f64; |
|
| 106 | + | let waiting: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_jobs WHERE status = 'queued' AND approval = 'waiting'") |
|
| 107 | + | .fetch_one(&app.db) |
|
| 108 | + | .await |
|
| 109 | + | .unwrap_or(0); |
|
| 110 | + | family( |
|
| 111 | + | &mut out, |
|
| 112 | + | "oxyhub_flow_jobs", |
|
| 113 | + | "OxygenFlow jobs now, by state (waiting: for a person's approval).", |
|
| 114 | + | "gauge", |
|
| 115 | + | &[ |
|
| 116 | + | ("state=\"queued\"".into(), count("queued") - waiting as f64), |
|
| 117 | + | ("state=\"waiting\"".into(), waiting as f64), |
|
| 118 | + | ("state=\"running\"".into(), count("running")), |
|
| 119 | + | ], |
|
| 120 | + | ); |
|
| 121 | + | let runners = db::runners::list(&app.db).await.unwrap_or_default(); |
|
| 122 | + | family( |
|
| 123 | + | &mut out, |
|
| 124 | + | "oxyhub_flow_runners", |
|
| 125 | + | "Registered OxygenFlow runners, by whether they reported in the last minutes.", |
|
| 126 | + | "gauge", |
|
| 127 | + | &[ |
|
| 128 | + | ("online=\"true\"".into(), runners.iter().filter(|r| r.online()).count() as f64), |
|
| 129 | + | ("online=\"false\"".into(), runners.iter().filter(|r| !r.online()).count() as f64), |
|
| 130 | + | ], |
|
| 131 | + | ); |
|
| 132 | + | family(&mut out, "oxyhub_tlog_entries", "Entries in the transparency log.", "gauge", &[(String::new(), app.tlog.size() as f64)]); |
|
| 133 | + | out |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | /// `GET /metrics`. |
|
| 137 | + | pub async fn metrics(State(app): State<Shared>, headers: HeaderMap) -> Response { |
|
| 138 | + | let want = &app.config.server.metrics_token; |
|
| 139 | + | if want.is_empty() { |
|
| 140 | + | return (StatusCode::NOT_FOUND, "metrics are off: set [server] metrics_token in hub.toml\n").into_response(); |
|
| 141 | + | } |
|
| 142 | + | let given = oxy_server::bearer_token(&headers).unwrap_or(""); |
|
| 143 | + | if !crate::auth::mac_equal(given, want) { |
|
| 144 | + | return (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Bearer")], "a bearer token is needed\n").into_response(); |
|
| 145 | + | } |
|
| 146 | + | ([(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8"), (header::CACHE_CONTROL, "no-store")], render(&app).await) |
|
| 147 | + | .into_response() |
|
| 148 | + | } |
modified crates/hub/src/config.rs +4 −0
| … | 231 unchanged lines |
||
| 232 | 232 | /// `base_url` is https; 0 sends none (oxygenhub#231). Raise it in |
|
| 233 | 233 | /// stages: browsers keep it, so it is hard to take back. |
|
| 234 | 234 | pub hsts_max_age: u64, |
|
| 235 | + | /// The bearer token `/metrics` asks for (oxygenhub#212); empty, the |
|
| 236 | + | /// metrics are off. |
|
| 237 | + | pub metrics_token: String, |
|
| 235 | 238 | } |
|
| 236 | 239 | ||
| 237 | 240 | #[derive(Clone, Debug, Deserialize)] |
|
| … | 89 unchanged lines |
||
| 327 | 330 | base_url: "http://127.0.0.1:7300".into(), |
|
| 328 | 331 | behind_proxy: false, |
|
| 329 | 332 | hsts_max_age: 0, |
|
| 333 | + | metrics_token: String::new(), |
|
| 330 | 334 | } |
|
| 331 | 335 | } |
|
| 332 | 336 | } |
|
| … | 176 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 23 unchanged lines |
||
| 24 | 24 | pub mod live; |
|
| 25 | 25 | pub mod mail; |
|
| 26 | 26 | pub mod maintenance; |
|
| 27 | + | pub mod metrics; |
|
| 27 | 28 | pub mod mirror; |
|
| 28 | 29 | pub mod net; |
|
| 29 | 30 | pub mod notify; |
|
| … | 324 unchanged lines |
modified crates/hub/src/web/home.rs +9 −1
| … | 57 unchanged lines |
||
| 58 | 58 | feed: Vec<db::activity::Activity>, |
|
| 59 | 59 | /// Your sky's data (docs/backgrounds.md §5), JSON safe inside a script. |
|
| 60 | 60 | backdrop: String, |
|
| 61 | + | /// What the cards and the feed listen on (docs/live.md L4): the |
|
| 62 | + | /// viewer's topic and their repositories'. |
|
| 63 | + | live: String, |
|
| 61 | 64 | } |
|
| 62 | 65 | ||
| 63 | 66 | /// How many activity lines the dashboard shows. |
|
| … | 109 unchanged lines |
||
| 173 | 176 | let now = db::now_ms(); |
|
| 174 | 177 | let sky = db::activity::sky(&app.db, &mine_ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?; |
|
| 175 | 178 | let backdrop = sky_json(&mine, &sky, now, "mine"); |
|
| 176 | - | Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed, backdrop }).into_response()) |
|
| 179 | + | let live = std::iter::once(shell.live_user.clone()) |
|
| 180 | + | .chain(mine.iter().take(15).map(|r| crate::events::topic::repo(r.id))) |
|
| 181 | + | .filter(|t| !t.is_empty()) |
|
| 182 | + | .collect::<Vec<_>>() |
|
| 183 | + | .join(" "); |
|
| 184 | + | Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed, backdrop, live }).into_response()) |
|
| 177 | 185 | } |
|
| 178 | 186 | ||
| 179 | 187 | /// `/explore`: every repository the viewer may see, filtered by text and |
|
| … | 307 unchanged lines |
modified crates/hub/src/web/mod.rs +6 −0
| … | 79 unchanged lines |
||
| 80 | 80 | .route("/health", get(home::health)) |
|
| 81 | 81 | .route("/about", get(home::about)) |
|
| 82 | 82 | .route("/.well-known/oxyhub-key", get(home::hub_key)) |
|
| 83 | + | .route("/metrics", get(crate::metrics::metrics)) |
|
| 83 | 84 | .route("/log", get(transparency::page)) |
|
| 84 | 85 | .route("/log/checkpoint", get(crate::tlog::checkpoint)) |
|
| 85 | 86 | .route("/log/tile/{*path}", get(crate::tlog::tile)) |
|
| … | 338 unchanged lines |
||
| 424 | 425 | pub is_admin: bool, |
|
| 425 | 426 | pub csrf: String, |
|
| 426 | 427 | pub unread: i64, |
|
| 428 | + | /// The signed-in viewer's live topic, `user:<id>` (docs/live.md L4), |
|
| 429 | + | /// or empty. |
|
| 430 | + | pub live_user: String, |
|
| 427 | 431 | } |
|
| 428 | 432 | ||
| 429 | 433 | impl Shell { |
|
| … | 13 unchanged lines |
||
| 443 | 447 | is_admin: viewer.is_admin(), |
|
| 444 | 448 | csrf: viewer.csrf().to_string(), |
|
| 445 | 449 | unread: viewer.0.as_ref().map(|v| v.unread).unwrap_or(0), |
|
| 450 | + | live_user: viewer.account().map(|a| format!("user:{}", a.id)).unwrap_or_default(), |
|
| 446 | 451 | } |
|
| 447 | 452 | } |
|
| 448 | 453 | ||
| … | 228 unchanged lines |
||
| 677 | 682 | is_admin: false, |
|
| 678 | 683 | csrf: String::new(), |
|
| 679 | 684 | unread: 0, |
|
| 685 | + | live_user: String::new(), |
|
| 680 | 686 | }); |
|
| 681 | 687 | let page = ErrorPage { shell, status: self.status.as_u16(), title: title.into(), detail: self.detail }; |
|
| 682 | 688 | match page.render() { |
|
| … | 142 unchanged lines |
modified crates/hub/tests/accounts.rs +22 −0
| … | 1561 unchanged lines |
||
| 1562 | 1562 | let r = post(&app, "/register", None, &form(&code)).await; |
|
| 1563 | 1563 | assert!(r.body.contains("invitation"), "{}", r.body); |
|
| 1564 | 1564 | } |
|
| 1565 | + | ||
| 1566 | + | /// Live L4 (docs/live.md, oxygenhub#206): a signed-in page's bell listens |
|
| 1567 | + | /// on the viewer's own topic, the inbox list on it too, and the dashboard's |
|
| 1568 | + | /// cards and feed on it and the viewer's repositories'; a signed-out page |
|
| 1569 | + | /// listens on nothing. |
|
| 1570 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1571 | + | async fn the_bell_inbox_and_dashboard_listen_on_the_viewer_s_topics() { |
|
| 1572 | + | let h = hub().await; |
|
| 1573 | + | let app = &h.app; |
|
| 1574 | + | let andy = register(app, "andy", "").await; |
|
| 1575 | + | let id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| 1576 | + | let topic = format!("user:{id}"); |
|
| 1577 | + | let page = get(app, "/explore", Some(&andy)).await.body; |
|
| 1578 | + | assert!(page.contains(&format!("data-live=\"{topic}\" data-fragment=\"bell\"")), "the bell on every page"); |
|
| 1579 | + | let inbox = get(app, "/notifications", Some(&andy)).await.body; |
|
| 1580 | + | assert!(inbox.contains(&format!("data-live=\"{topic}\" data-fragment=\"inbox\"")), "the inbox list"); |
|
| 1581 | + | let dash = get(app, "/", Some(&andy)).await.body; |
|
| 1582 | + | assert!(dash.contains("data-fragment=\"cards\"") && dash.contains("data-fragment=\"feed\""), "{dash}"); |
|
| 1583 | + | assert!(dash.contains(&format!("data-live=\"{topic}")), "the dashboard listens on the viewer"); |
|
| 1584 | + | let anon = get(app, "/explore", None).await.body; |
|
| 1585 | + | assert!(!anon.contains("data-fragment=\"bell\""), "nothing to hear signed out"); |
|
| 1586 | + | } |
modified crates/hub/tests/flow.rs +29 −0
| … | 5290 unchanged lines |
||
| 5291 | 5291 | assert!(kept.serial >= 1); |
|
| 5292 | 5292 | } |
|
| 5293 | 5293 | ||
| 5294 | + | /// `/metrics` (oxygenhub#212): off until a token is set, then served only |
|
| 5295 | + | /// to its bearer, in the Prometheus text format, with Flow's jobs counted. |
|
| 5296 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 5297 | + | async fn metrics_are_private_and_count_flow_jobs() { |
|
| 5298 | + | let h = hub().await; |
|
| 5299 | + | let (st, _) = call(&h.app, "GET", "/metrics", None, None).await; |
|
| 5300 | + | assert_eq!(st, StatusCode::NOT_FOUND, "off by default"); |
|
| 5301 | + | let text = oxygenhub::metrics::render(&h.app).await; |
|
| 5302 | + | for want in [ |
|
| 5303 | + | "# TYPE oxyhub_uptime_seconds gauge", |
|
| 5304 | + | "oxyhub_flow_jobs{state=\"queued\"} 0", |
|
| 5305 | + | "oxyhub_bucket_requests_total{kind=\"list\"}", |
|
| 5306 | + | "oxyhub_tlog_entries", |
|
| 5307 | + | ] { |
|
| 5308 | + | assert!(text.contains(want), "missing {want}:\n{text}"); |
|
| 5309 | + | } |
|
| 5310 | + | create(&h.app, "andy", "demo").await; |
|
| 5311 | + | let work = tempfile::tempdir().unwrap(); |
|
| 5312 | + | let c = client(&format!("{}/andy/demo", h.base), work.path()); |
|
| 5313 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 5314 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 5315 | + | c.snapshot("test").unwrap(); |
|
| 5316 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 5317 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 5318 | + | assert_eq!(runs_of(&h.app, "andy", "demo").await.len(), 1); |
|
| 5319 | + | let text = oxygenhub::metrics::render(&h.app).await; |
|
| 5320 | + | assert!(!text.contains("oxyhub_flow_jobs{state=\"queued\"} 0"), "the run's jobs are queued:\n{text}"); |
|
| 5321 | + | } |
|
| 5322 | + | ||
| 5294 | 5323 | /// Keyless step 4c (oxygenhub#241): when a key the hub certified moves a |
|
| 5295 | 5324 | /// bookmark, the hub signs when it accepted the update and serves that |
|
| 5296 | 5325 | /// with the bookmark, so a fetch checks the certificate at the hub's time |
|
| … | 181 unchanged lines |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/live.md +7 −0
| … | 121 unchanged lines |
||
| 122 | 122 | A comment, an assistant asked, answering or not answering, reviews, state |
|
| 123 | 123 | changes and landings publish on the proposal's topic. |
|
| 124 | 124 | ||
| 125 | + | - **L4 (done, #206):** the top bar's notification count listens on the |
|
| 126 | + | viewer's `user:<id>` topic on every page (the Shell carries it), the |
|
| 127 | + | inbox's list on it too, and the dashboard's cards and activity feed on |
|
| 128 | + | it and the topics of up to 15 of the viewer's repositories (a stream |
|
| 129 | + | takes 16). Nothing new on the server: the client refetches the page and |
|
| 130 | + | swaps each marked part, so every route already serves them. |
|
| 131 | + | ||
| 125 | 132 | ## 6. Order |
|
| 126 | 133 | ||
| 127 | 134 | | Step | What | Issue | |
|
| … | 8 unchanged lines |
modified docs/spec.md +11 −0
| … | 635 unchanged lines |
||
| 636 | 636 | ||
| 637 | 637 | **Storage:** each state is sealed at rest with the hub's secrets key, and the last 20 versions before it are kept (migration 0079: `terraform_states`, `terraform_state_versions`). Backups carry the sealed bodies, which open only with this hub's key. A repository's Settings › Terraform state lists its states, serials, writers, locks and versions, with the backend block to use. Not for the hub's own infrastructure (oxygenhub-gcp), whose state cannot live on the hub it deploys. |
|
| 638 | 638 | ||
| 639 | + | ### 13.5.5 Metrics (oxygenhub#212, first slice 2026-10-18) |
|
| 640 | + | ||
| 641 | + | `GET /metrics` serves the hub's numbers in the Prometheus text format, only to a bearer of `[server] metrics_token` in hub.toml. While the token is unset the route answers 404. It reports: |
|
| 642 | + | - the build (`oxyhub_build_info`), uptime and resident memory; |
|
| 643 | + | - the state cache's states, estimated weight, budget and summaries; |
|
| 644 | + | - requests to bucket stores by kind (with pack listings sent and kept apart), and the time spent in them; |
|
| 645 | + | - OxygenFlow jobs queued, waiting for approval and running, and runners online and not; |
|
| 646 | + | - the transparency log's entries. |
|
| 647 | + | ||
| 648 | + | Per-route request counts and latency histograms, pushes and fetches, backups, Diffusion, mail and the database pool are the next slices; the runner's own metrics too. |
|
| 649 | + | ||
| 639 | 650 | ### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06) |
|
| 640 | 651 | ||
| 641 | 652 | Coverage is built in, as Coveralls or Codecov would be, fed by OxygenFlow. |
|
| … | 167 unchanged lines |
modified templates/base.html +1 −1
| … | 18 unchanged lines |
||
| 19 | 19 | <button type="button" class="theme-toggle" data-theme-toggle aria-label="Theme" title="Theme"><span aria-hidden="true">◐</span></button> |
|
| 20 | 20 | {% if let Some(u) = shell.user %} |
|
| 21 | 21 | <a class="go primary" href="/new" title="New repository">+ NEW</a> |
|
| 22 | - | <a class="bell" href="/notifications" title="Notifications">✉{% if shell.unread > 0 %}<span class="count">{{ shell.unread }}</span>{% endif %}</a> |
|
| 22 | + | <a class="bell" href="/notifications" title="Notifications"{% if !shell.live_user.is_empty() %} data-live="{{ shell.live_user }}" data-fragment="bell"{% endif %}>✉{% if shell.unread > 0 %}<span class="count">{{ shell.unread }}</span>{% endif %}</a> |
|
| 23 | 23 | <a class="me" href="/{{ u }}"><img class="avatar xs" src="/avatar/{{ u }}" alt=""> {{ u }}</a> |
|
| 24 | 24 | <form class="inline" method="post" action="/logout"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>SIGN OUT</button></form> |
|
| 25 | 25 | {% else %} |
|
| … | 38 unchanged lines |
modified templates/dashboard.html +3 −1
| … | 7 unchanged lines |
||
| 8 | 8 | </div> |
|
| 9 | 9 | <div class="side-layout rev dash" data-backdrop-avoid> |
|
| 10 | 10 | <div> |
|
| 11 | + | <div data-live="{{ live }}" data-fragment="cards"> |
|
| 11 | 12 | <section class="card-box"> |
|
| 12 | 13 | <div class="card-head"><h2>Waiting for your review</h2><span class="count-chip">{{ reviews.len() }}</span></div> |
|
| 13 | 14 | {% if reviews.is_empty() %} |
|
| … | 46 unchanged lines |
||
| 60 | 61 | </section> |
|
| 61 | 62 | {% endif %} |
|
| 62 | 63 | ||
| 63 | - | <section class="card-box"> |
|
| 64 | + | </div> |
|
| 65 | + | <section class="card-box" data-live="{{ live }}" data-fragment="feed"> |
|
| 64 | 66 | <div class="card-head"><h2>Recent activity</h2></div> |
|
| 65 | 67 | {% if feed.is_empty() %} |
|
| 66 | 68 | <p class="empty small">No activity yet. Push to a repository and it shows here.</p> |
|
| … | 32 unchanged lines |
modified templates/notifications.html +2 −0
| … | 23 unchanged lines |
||
| 24 | 24 | <form method="post" action="/notifications/read"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">MARK ALL READ</button></form> |
|
| 25 | 25 | <form method="post" action="/settings/notifications"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label class="small"><input type="checkbox" name="email" value="1"{% if email_on %} checked{% endif %}> email me too</label> <button type="submit" class="small">SAVE</button></form> |
|
| 26 | 26 | </div> |
|
| 27 | + | <div data-live="{{ shell.live_user }}" data-fragment="inbox"> |
|
| 27 | 28 | {% if threads.is_empty() %} |
|
| 28 | 29 | <p class="empty">{% if q.view == "done" %}Nothing set aside.{% else if q.view == "unread" %}All read.{% else if !q.reason.is_empty() || !q.kind.is_empty() || !q.repo.is_empty() %}Nothing matches these filters.{% else %}Nothing yet. Watch a repository or take part in a thread and its events land here.{% endif %}</p> |
|
| 29 | 30 | {% else %} |
|
| … | 16 unchanged lines |
||
| 46 | 47 | {% endif %} |
|
| 47 | 48 | </div> |
|
| 48 | 49 | </div> |
|
| 50 | + | </div> |
|
| 49 | 51 | {% endblock %} |