OXYGENHUB

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 %}