OXYGENHUB

Diffusion: see it, and landing refreshes the pin index 757195ad7a5d

A repository that pins, or is pinned by, another on the hub gets a
Diffusion tab: who uses it and what it follows, each with its pin, how
far behind, its open bump and that bump's run, its last landed bump, and a
status: green (upstream checked, the consumer's run passed), amber (run
passed, upstream has no checks), red (run failed), grey (running) or up to
date. The overview shows the same in brief, and Admin › Diffusion lists the
whole hub.

Landing a proposal on the hub moved the bookmark without the push path, so
the pin index went stale (metalgear still showed 17 behind after its bump
landed) and an upstream landed on the hub did not propagate. Landing now
does both, as a push does.

closes #150
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 09:47 UTC
Signature
✔ signed by Andy McCall
Hash
757195ad7a5db0029af1a41507434077abedd0825b245f8e129a7bc23fb6302a
Depends on
145306044896 19bb31ff1f5c 1f1eb06a9009 3776b70d9b17 42fa95540468 4edcf24025cf 6ca0b971ff07 756fe44a0f4d 8620925e5c53 93be9ce4653e b6b7e4cf6b24 c4e432da4bc9 c8df33a6829f cc19697f8871 d23464c0ac60 de505df5cf8d df71c7892d27 e8e7158bbc73 f677d6270298
Depended on by
5d66f27cebe2 dd0788b756f2 737de0fac722 166f6e9bf4f1 e97500b70f9a 16ee5d19d220 a10446c68043 5d90e0e045e2 0e04dfc20211 5d54cf746e00 ffb67a81188c 1e8a6e8bb91b 6246d1e51f4f eb2a3502601d c9aacc0e7dc1 acc85ac0f70b 73ef9f767132 180dc43ad0e0
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
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
35 atoms, 5 tree operations, 16 files

Browse the tree at this change

added crates/hub/migrations/0059_diffusion_upstream_row.sql +4 −0
1 +
-- Diffusion (#150): each indexed pin also names the upstream's repository
2 +
-- on this hub, when it is one, so the Diffusion views and the tab need no
3 +
-- lookup by Oxygen id.
4 +
ALTER TABLE diffusion_pins ADD COLUMN upstream_repo_id INTEGER;
added crates/hub/src/web/diffusion.rs +54 −0
1 +
//! Diffusion's views (#150, docs/diffusion.md): a repository's Diffusion
2 +
//! tab (the consumers that pin it, and what it follows itself), and Admin ›
3 +
//! Diffusion across the hub. Each consumer shows green, amber, red, grey
4 +
//! or up to date (`diffusion::Colour`).
5 +
6 +
use super::{filters, lookup, Error, Page, RepoShell, Shell};
7 +
use crate::auth::Viewer;
8 +
use crate::db::repos::RepoRow;
9 +
use crate::diffusion::Consumer;
10 +
use crate::Shared;
11 +
use askama::Template;
12 +
use axum::extract::{Path, State};
13 +
14 +
#[derive(Template)]
15 +
#[template(path = "diffusion.html")]
16 +
pub struct DiffusionPage {
17 +
    shell: Shell,
18 +
    repo: RepoShell,
19 +
    used_by: Vec<Consumer>,
20 +
    follows: Vec<(RepoRow, Consumer)>,
21 +
    /// This repository has no checks of its own: its bumps are amber.
22 +
    unverified: bool,
23 +
}
24 +
25 +
/// `GET /{owner}/{name}/diffusion`.
26 +
pub async fn page(
27 +
    State(app): State<Shared>,
28 +
    viewer: Viewer,
29 +
    Path((owner, name)): Path<(String, String)>,
30 +
) -> Result<Page<DiffusionPage>, Error> {
31 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/diffusion"), &viewer);
32 +
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
33 +
    let used_by = crate::diffusion::consumers(&app, &row, viewer.account()).await.unwrap_or_default();
34 +
    let follows = crate::diffusion::follows(&app, &row, viewer.account()).await;
35 +
    let unverified = crate::diffusion::unverified(&app, &row).await;
36 +
    Ok(Page(DiffusionPage { shell, repo: RepoShell::new(&app, &row, "diffusion").with_role(role), used_by, follows, unverified }))
37 +
}
38 +
39 +
#[derive(Template)]
40 +
#[template(path = "admin_diffusion.html")]
41 +
pub struct AdminPage {
42 +
    shell: Shell,
43 +
    upstreams: Vec<(RepoRow, Vec<Consumer>, bool)>,
44 +
}
45 +
46 +
/// `GET /admin/diffusion`: every upstream on the hub and its consumers.
47 +
pub async fn admin(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AdminPage>, Error> {
48 +
    let shell = Shell::new(&app, "/admin/diffusion", &viewer);
49 +
    if !viewer.is_admin() {
50 +
        return Err(Error::not_found(shell));
51 +
    }
52 +
    let upstreams = crate::diffusion::everything(&app, viewer.account()).await;
53 +
    Ok(Page(AdminPage { shell, upstreams }))
54 +
}
added templates/admin_diffusion.html +13 −0
1 +
{% extends "base.html" %}
2 +
{% import "diffusion_rows.html" as d %}
3 +
{% block title %}Diffusion · Administration{% endblock %}
4 +
{% block mainclass %} class="wide"{% endblock %}
5 +
{% block content %}
6 +
<div class="head"><h1>Diffusion <small>{{ upstreams.len()|plural("upstream", "upstreams") }} · <a href="/admin">Administration</a></small></h1></div>
7 +
<p class="small mute df-key"><span class="df-chip df-green">green</span> checked upstream, passed in the consumer · <span class="df-chip df-amber">amber</span> passed, upstream has no checks · <span class="df-chip df-red">red</span> the consumer's run failed · <span class="df-chip df-grey">testing</span> running · <span class="df-chip df-current">up to date</span></p>
8 +
{% if upstreams.is_empty() %}<p class="empty">No repository on this hub is pinned by another yet.</p>{% endif %}
9 +
{% for (up, list, unverified) in upstreams %}
10 +
<h2><a href="/{{ up.owner }}/{{ up.name }}/diffusion">{{ up.owner }}/{{ up.name }}</a> <small>{{ list.len()|plural("consumer", "consumers") }}{% if *unverified %} · <span class="df-chip df-amber">no checks of its own</span>{% endif %}</small></h2>
11 +
{% call d::rows(list) %}
12 +
{% endfor %}
13 +
{% endblock %}
added templates/diffusion.html +33 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% import "diffusion_rows.html" as d %}
4 +
{% block title %}Diffusion · {{ repo.owner }}/{{ repo.name }}{% endblock %}
5 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
6 +
{% block tabs %}{% call m::tabs(repo, "diffusion") %}{% endblock %}
7 +
{% block content %}
8 +
<div class="head"><h1>Diffusion <small>how changes move between this repository and the ones that pin it</small></h1></div>
9 +
<p class="small mute df-key"><span class="df-chip df-green">green</span> checked here and passed there · <span class="df-chip df-amber">amber</span> passed there, but the upstream has no checks · <span class="df-chip df-red">red</span> its run on the bump failed · <span class="df-chip df-grey">testing</span> running · <span class="df-chip df-current">up to date</span> nothing to bump</p>
10 +
{% if !used_by.is_empty() %}
11 +
<h2>Used by <span class="count-chip">{{ used_by.len() }}</span></h2>
12 +
{% if unverified %}<div class="strip warn">{{ repo.owner }}/{{ repo.name }} has no checks of its own, so its consumers' bumps are amber: only their own pipelines test them. A <code>.flow/pipeline.kdl</code> here that passes turns them green.</div>{% endif %}
13 +
{% call d::rows(used_by) %}
14 +
{% endif %}
15 +
{% if !follows.is_empty() %}
16 +
<h2>Follows <span class="count-chip">{{ follows.len() }}</span></h2>
17 +
<div class="table"><table class="diffusion">
18 +
  <tr><th>Upstream</th><th>Subrepo</th><th>Behind</th><th>Status</th><th>Open bump</th><th>Last landed</th></tr>
19 +
  {% for (up, c) in follows %}
20 +
  <tr>
21 +
    <td><a href="/{{ up.owner }}/{{ up.name }}/diffusion">{{ up.owner }}/{{ up.name }}</a></td>
22 +
    <td class="small">{{ c.path }} <span class="lbl lbl-bm">{{ c.bookmark }}</span></td>
23 +
    <td class="small">{% match c.behind %}{% when Some(n) %}{{ n }}{% when None %}<span class="mute">?</span>{% endmatch %}</td>
24 +
    <td>{% call d::chip(c) %}</td>
25 +
    <td class="small">{% if let Some(n) = c.bump %}<a href="{{ repo.url }}/proposal/{{ n }}">#{{ n }}</a>{% if let Some((r, st)) = c.run %} · <a href="{{ repo.url }}/flow/{{ r }}">run #{{ r }}</a> <span class="mute">{{ st }}</span>{% endif %}{% else %}<span class="mute">none</span>{% endif %}</td>
26 +
    <td class="small">{% if let Some((n, at)) = c.landed %}<a href="{{ repo.url }}/proposal/{{ n }}">#{{ n }}</a> <time class="mute" title="{{ at|when_i }}">{{ at|ago_i }}</time>{% else %}<span class="mute">—</span>{% endif %}</td>
27 +
  </tr>
28 +
  {% endfor %}
29 +
</table></div>
30 +
{% endif %}
31 +
{% if used_by.is_empty() && follows.is_empty() %}<p class="empty">No repository on this hub pins this one, and it pins none that this hub holds.</p>{% endif %}
32 +
<p class="small mute">Diffusion opens a proposal in a consumer when its upstream moves, and never lands one itself. How each repository follows is under its Settings › Diffusion. <a href="/help#diffusion">How it works</a></p>
33 +
{% endblock %}
added templates/diffusion_rows.html +18 −0
1 +
{% macro chip(c) %}<span class="df-chip {{ c.colour.class() }}" title="{{ c.colour.label() }}">{{ c.colour.label() }}</span>{% endmacro %}
2 +
3 +
{% macro rows(list) %}
4 +
<div class="table"><table class="diffusion">
5 +
  <tr><th>Repository</th><th>Pin on</th><th>Behind</th><th>Status</th><th>Open bump</th><th>Last landed</th><th>Follows</th></tr>
6 +
  {% for c in list %}
7 +
  <tr>
8 +
    <td><a href="/{{ c.owner }}/{{ c.name }}">{{ c.owner }}/{{ c.name }}</a> <span class="small mute">{{ c.path }}</span></td>
9 +
    <td class="small"><span class="lbl lbl-bm">{{ c.bookmark }}</span></td>
10 +
    <td class="small">{% match c.behind %}{% when Some(0) %}<span class="mute">0</span>{% when Some(n) %}{{ n }}{% when None %}<span class="mute" title="Its pin names changes this repository does not hold">?</span>{% endmatch %}</td>
11 +
    <td>{% call chip(c) %}</td>
12 +
    <td class="small">{% if let Some(n) = c.bump %}<a href="/{{ c.owner }}/{{ c.name }}/proposal/{{ n }}">#{{ n }}</a>{% if let Some((r, st)) = c.run %} · <a href="/{{ c.owner }}/{{ c.name }}/flow/{{ r }}">run #{{ r }}</a> <span class="mute">{{ st }}</span>{% endif %}{% else %}<span class="mute">none</span>{% endif %}</td>
13 +
    <td class="small">{% if let Some((n, at)) = c.landed %}<a href="/{{ c.owner }}/{{ c.name }}/proposal/{{ n }}">#{{ n }}</a> <time class="mute" title="{{ at|when_i }}">{{ at|ago_i }}</time>{% else %}<span class="mute">—</span>{% endif %}</td>
14 +
    <td class="small mute">{% match c.follow %}{% when crate::diffusion::Follow::Off %}off{% when crate::diffusion::Follow::Green %}when green{% when crate::diffusion::Follow::Any %}every move{% endmatch %}</td>
15 +
  </tr>
16 +
  {% endfor %}
17 +
</table></div>
18 +
{% endmacro %}
modified crates/hub/src/diffusion.rs +181 −13
…
50 unchanged lines
51 51
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return };
52 52
    let pins = if heads.is_empty() { Vec::new() } else { crate::flow::pins_in(app, name, heads.to_vec()).await };
53 53
    let now = db::now_ms();
54 +
    // The upstream's repository on this hub, when it is one.
55 +
    let mut upstreams = Vec::new();
56 +
    for (_, id, _) in &pins {
57 +
        upstreams.push(repo_by_oxy_id(app, &hex::encode(id)).await.map(|r| r.id));
58 +
    }
54 59
    let Ok(mut tx) = app.db.begin().await else { return };
55 60
    let mut ok = sqlx::query("DELETE FROM diffusion_pins WHERE consumer_id = ? AND bookmark = ?")
56 61
        .bind(row.id)
…
1 unchanged lines
58 63
        .execute(&mut *tx)
59 64
        .await
60 65
        .is_ok();
61 -
    for (path, id, pinned) in &pins {
66 +
    for ((path, id, pinned), upstream_row) in pins.iter().zip(&upstreams) {
62 67
        let heads: Vec<String> = pinned.iter().map(|h| h.to_hex()).collect();
63 -
        ok &=
64 -
            sqlx::query("INSERT INTO diffusion_pins (consumer_id, bookmark, path, upstream, heads, updated_ms) VALUES (?, ?, ?, ?, ?, ?)")
65 -
                .bind(row.id)
66 -
                .bind(bookmark)
67 -
                .bind(path)
68 -
                .bind(hex::encode(id))
69 -
                .bind(heads.join("+"))
70 -
                .bind(now)
71 -
                .execute(&mut *tx)
72 -
                .await
73 -
                .is_ok();
68 +
        ok &= sqlx::query(
69 +
            "INSERT INTO diffusion_pins (consumer_id, bookmark, path, upstream, heads, updated_ms, upstream_repo_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
70 +
        )
71 +
        .bind(row.id)
72 +
        .bind(bookmark)
73 +
        .bind(path)
74 +
        .bind(hex::encode(id))
75 +
        .bind(heads.join("+"))
76 +
        .bind(now)
77 +
        .bind(upstream_row)
78 +
        .execute(&mut *tx)
79 +
        .await
80 +
        .is_ok();
81 +
        if let Some(u) = upstream_row {
82 +
            mark(app, row.id);
83 +
            mark(app, *u);
84 +
        }
74 85
    }
75 86
    if ok {
76 87
        tx.commit().await.ok();
…
8 unchanged lines
85 96
            record(app, row, bookmark, heads).await;
86 97
        }
87 98
    }
99 +
}
100 +
101 +
static INVOLVED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
102 +
103 +
fn key(app: &Shared, repo_id: i64) -> String {
104 +
    format!("{}#{repo_id}", app.repos.root().display())
105 +
}
106 +
107 +
fn mark(app: &Shared, repo_id: i64) {
108 +
    INVOLVED.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id));
88 109
}
89 110
111 +
/// Whether a repository takes part in Diffusion, as an upstream or a
112 +
/// consumer on this hub: its Diffusion tab shows (#150).
113 +
pub fn involved(app: &Shared, repo_id: i64) -> bool {
114 +
    INVOLVED.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id)))
115 +
}
116 +
90 117
/// Index every repository's followed bookmarks, for a hub that had no
91 118
/// index before (run once at start, in the background).
92 119
pub async fn backfill(app: &Shared) {
…
28 unchanged lines
121 148
    /// Upstream changes on its default bookmark the pin lacks; `None` when
122 149
    /// the hub cannot tell (the pinned changes are not in the upstream).
123 150
    pub behind: Option<usize>,
151 +
    /// How it follows the upstream (from its setting, or the site's).
152 +
    pub follow: Follow,
153 +
    /// Its open bump proposal's number, if it has one.
154 +
    pub bump: Option<i64>,
155 +
    /// The newest run of its bump bookmark: number and status.
156 +
    pub run: Option<(i64, String)>,
157 +
    /// Its last landed bump: number and when.
158 +
    pub landed: Option<(i64, i64)>,
159 +
    /// What the views show (#150).
160 +
    pub colour: Colour,
161 +
}
162 +
163 +
/// A consumer's Diffusion status at a glance (#150, Andy's colours):
164 +
/// green when the upstream was checked and the consumer's run on the bump
165 +
/// passed; amber when the run passed but the upstream has no checks; red
166 +
/// when the run failed; grey while it runs; up to date when there is
167 +
/// nothing to bump; off when it does not follow.
168 +
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
169 +
#[serde(rename_all = "kebab-case")]
170 +
pub enum Colour {
171 +
    Green,
172 +
    Amber,
173 +
    Red,
174 +
    Grey,
175 +
    UpToDate,
176 +
    Off,
124 177
}
125 178
179 +
impl Colour {
180 +
    pub fn class(&self) -> &'static str {
181 +
        match self {
182 +
            Colour::Green => "df-green",
183 +
            Colour::Amber => "df-amber",
184 +
            Colour::Red => "df-red",
185 +
            Colour::Grey => "df-grey",
186 +
            Colour::UpToDate => "df-current",
187 +
            Colour::Off => "df-off",
188 +
        }
189 +
    }
190 +
191 +
    pub fn label(&self) -> &'static str {
192 +
        match self {
193 +
            Colour::Green => "green",
194 +
            Colour::Amber => "amber: upstream unverified",
195 +
            Colour::Red => "red: its run failed",
196 +
            Colour::Grey => "testing",
197 +
            Colour::UpToDate => "up to date",
198 +
            Colour::Off => "not following",
199 +
        }
200 +
    }
201 +
202 +
    /// The colour from what is known of a consumer.
203 +
    pub fn of(follow: Follow, behind: Option<usize>, bump: Option<i64>, run: Option<&str>, unverified: bool) -> Colour {
204 +
        if follow == Follow::Off {
205 +
            return Colour::Off;
206 +
        }
207 +
        match (bump, run) {
208 +
            (Some(_), Some("passed")) if unverified => Colour::Amber,
209 +
            (Some(_), Some("passed")) => Colour::Green,
210 +
            (Some(_), Some("failed" | "error" | "cancelled")) => Colour::Red,
211 +
            (Some(_), _) => Colour::Grey,
212 +
            (None, _) if behind == Some(0) => Colour::UpToDate,
213 +
            (None, _) => Colour::Grey,
214 +
        }
215 +
    }
216 +
}
217 +
126 218
/// The consumers of `row` that `account` may read, with how far behind
127 219
/// the upstream's default bookmark each pin is.
128 220
pub async fn consumers(app: &Shared, row: &RepoRow, account: Option<&Account>) -> Result<Vec<Consumer>, crate::repos::RepoError> {
…
8 unchanged lines
137 229
    .map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e)))?;
138 230
    let mut out = Vec::new();
139 231
    let mut pinned_sets = Vec::new();
232 +
    let unverified = unverified(app, row).await;
140 233
    for (consumer_id, bookmark, path, heads) in found {
141 234
        let Ok(Some(c)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
142 235
        if !roles::allows(roles::role_for(&app.db, account, &c).await.ok().flatten(), crate::auth::Access::Read) {
236 +
            continue;
237 +
        }
238 +
        // One row per consumer and subrepo: the bookmark its work lands on.
239 +
        if bookmark != target(&app.db, &c).await {
143 240
            continue;
144 241
        }
145 242
        let pinned: Vec<String> = heads.split('+').filter(|h| !h.is_empty()).map(String::from).collect();
146 243
        pinned_sets.push(pinned.iter().filter_map(|h| ChangeHash::from_hex(h)).collect::<Vec<_>>());
147 -
        out.push(Consumer { owner: c.owner, name: c.name, bookmark, path, pinned, behind: None });
244 +
        let bump_bm = bump_bookmark(&path);
245 +
        let mine: Vec<db::proposals::ProposalRow> = db::proposals::list(&app.db, c.id, &[])
246 +
            .await
247 +
            .unwrap_or_default()
248 +
            .into_iter()
249 +
            .filter(|p| p.source_bookmark == bump_bm)
250 +
            .collect();
251 +
        let bump = mine.iter().find(|p| p.state == "open" || p.state == "draft").map(|p| p.number);
252 +
        let landed = mine.iter().filter(|p| p.state == "landed").max_by_key(|p| p.landed_ms).map(|p| (p.number, p.landed_ms));
253 +
        let run: Option<(i64, String)> =
254 +
            sqlx::query_as("SELECT number, status FROM flow_runs WHERE repo_id = ? AND bookmark = ? ORDER BY id DESC LIMIT 1")
255 +
                .bind(c.id)
256 +
                .bind(&bump_bm)
257 +
                .fetch_optional(&app.db)
258 +
                .await
259 +
                .ok()
260 +
                .flatten();
261 +
        let follow = rule(&app.config.diffusion.follow, &c, None, &path).follow;
262 +
        out.push(Consumer {
263 +
            owner: c.owner,
264 +
            name: c.name,
265 +
            bookmark,
266 +
            path,
267 +
            pinned,
268 +
            behind: None,
269 +
            follow,
270 +
            bump,
271 +
            run,
272 +
            landed,
273 +
            colour: Colour::Off,
274 +
        });
148 275
    }
149 276
    if out.is_empty() {
150 277
        return Ok(out);
…
12 unchanged lines
163 290
        .await?;
164 291
    for (c, b) in out.iter_mut().zip(behind) {
165 292
        c.behind = b;
293 +
        c.colour = Colour::of(c.follow, b, c.bump, c.run.as_ref().map(|r| r.1.as_str()), unverified);
166 294
    }
167 295
    Ok(out)
296 +
}
297 +
298 +
/// What `consumer` follows, as its Follows panel shows: each upstream on
299 +
/// this hub it pins, with its status there.
300 +
pub async fn follows(app: &Shared, consumer: &RepoRow, account: Option<&Account>) -> Vec<(RepoRow, Consumer)> {
301 +
    let ups: Vec<i64> =
302 +
        sqlx::query_scalar("SELECT DISTINCT upstream_repo_id FROM diffusion_pins WHERE consumer_id = ? AND upstream_repo_id IS NOT NULL")
303 +
            .bind(consumer.id)
304 +
            .fetch_all(&app.db)
305 +
            .await
306 +
            .unwrap_or_default();
307 +
    let mut out = Vec::new();
308 +
    for id in ups {
309 +
        let Ok(Some(up)) = db::repos::get_by_id(&app.db, id).await else { continue };
310 +
        if !roles::allows(roles::role_for(&app.db, account, &up).await.ok().flatten(), crate::auth::Access::Read) {
311 +
            continue;
312 +
        }
313 +
        for c in consumers(app, &up, account).await.unwrap_or_default() {
314 +
            if c.owner == consumer.owner && c.name == consumer.name {
315 +
                out.push((up.clone(), c));
316 +
            }
317 +
        }
318 +
    }
319 +
    out
320 +
}
321 +
322 +
/// Every upstream on this hub with its consumers, for Admin › Diffusion.
323 +
pub async fn everything(app: &Shared, account: Option<&Account>) -> Vec<(RepoRow, Vec<Consumer>, bool)> {
324 +
    let ups: Vec<i64> = sqlx::query_scalar("SELECT DISTINCT upstream_repo_id FROM diffusion_pins WHERE upstream_repo_id IS NOT NULL")
325 +
        .fetch_all(&app.db)
326 +
        .await
327 +
        .unwrap_or_default();
328 +
    let mut out = Vec::new();
329 +
    for id in ups {
330 +
        let Ok(Some(up)) = db::repos::get_by_id(&app.db, id).await else { continue };
331 +
        let list = consumers(app, &up, account).await.unwrap_or_default();
332 +
        let unverified = unverified(app, &up).await;
333 +
        out.push((up, list, unverified));
334 +
    }
335 +
    out
168 336
}
169 337
170 338
/// The bookmark a consumer's bump for the subrepo at `path` lives on.
…
701 unchanged lines
modified crates/hub/src/web/mod.rs +7 −0
…
8 unchanged lines
9 9
pub mod chain;
10 10
pub mod coverage;
11 11
mod device;
12 +
mod diffusion;
12 13
pub mod edit;
13 14
pub mod filters;
14 15
pub mod flow;
…
101 unchanged lines
116 117
        .route("/admin/runners", get(runners::list))
117 118
        .route("/admin/runners/settings", get(runners::settings).post(runners::save_settings))
118 119
        .route("/admin/runners/images", get(runners::images))
120 +
        .route("/admin/diffusion", get(diffusion::admin))
121 +
        .route("/{owner}/{name}/diffusion", get(diffusion::page))
119 122
        .route("/admin/runners/token", post(runners::new_token))
120 123
        .route("/admin/runners/{id}", get(runners::show))
121 124
        .route("/admin/runners/{id}/state", post(runners::set_state))
…
312 unchanged lines
434 437
    /// Whether it has reports of its own (#127); with coverage or tests,
435 438
    /// the Reports tab.
436 439
    pub reports: bool,
440 +
    /// Whether it pins, or is pinned by, another repository here: the
441 +
    /// Diffusion tab (#150).
442 +
    pub diffusion: bool,
437 443
}
438 444
439 445
impl RepoShell {
…
16 unchanged lines
456 462
            coverage: crate::coverage::has_reports(app, row.id),
457 463
            tests: crate::junit::has_reports(app, row.id),
458 464
            reports: crate::reports::has_reports(app, row.id),
465 +
            diffusion: crate::diffusion::involved(app, row.id),
459 466
        }
460 467
    }
461 468
…
276 unchanged lines
modified crates/hub/src/web/proposals.rs +10 −0
…
1188 unchanged lines
1189 1189
    )
1190 1190
    .await
1191 1191
    .ok();
1192 +
    // Diffusion (#150): a landing moves the target as a push would, so the
1193 +
    // pin index follows it, and the consumers of a moved default bookmark
1194 +
    // are bumped.
1195 +
    let heads = loaded.heads.clone();
1196 +
    let moved = vec![(p.target.clone(), state.clone(), heads.clone())];
1197 +
    crate::diffusion::after_push(app, row, &moved).await;
1198 +
    if p.target == row.default_bookmark {
1199 +
        let (app, row) = (app.clone(), row.clone());
1200 +
        tokio::spawn(async move { crate::diffusion::propagate(&app, &row, heads, false).await });
1201 +
    }
1192 1202
    Ok(state)
1193 1203
}
1194 1204
…
476 unchanged lines
modified crates/hub/src/web/repo.rs +4 −0
…
29 unchanged lines
30 30
    /// Repositories that pin this one as a subrepo, and how far behind
31 31
    /// each is (Diffusion, #148).
32 32
    used_by: Vec<crate::diffusion::Consumer>,
33 +
    /// The upstreams it pins, with its status there (#150).
34 +
    follows: Vec<(crate::db::repos::RepoRow, crate::diffusion::Consumer)>,
33 35
}
34 36
35 37
/// A remote name from the hub's host: its first label when that is a
…
111 unchanged lines
147 149
    let (stars, watchers) = crate::db::repos::counts(&app.db, row.id).await.unwrap_or((0, 0));
148 150
    let topics = row.topic_list().into_iter().map(String::from).collect();
149 151
    let used_by = crate::diffusion::consumers(&app, &row, viewer.account()).await.unwrap_or_default();
152 +
    let follows = crate::diffusion::follows(&app, &row, viewer.account()).await;
150 153
    Ok(Page(OverviewPage {
151 154
        shell,
152 155
        repo: rs,
…
8 unchanged lines
161 164
        watchers,
162 165
        topics,
163 166
        used_by,
167 +
        follows,
164 168
    }))
165 169
}
166 170
…
377 unchanged lines
modified crates/hub/tests/flow.rs +31 −0
…
3374 unchanged lines
3375 3375
    assert!(p2.title.contains("3 changes"), "{}", p2.title);
3376 3376
    assert_eq!(db::proposals::list(&h.app.db, game_row.id, &[]).await.unwrap().len(), 1, "never a pile of bumps");
3377 3377
3378 +
    // The views (#150): the upstream's Diffusion tab lists the game with its
3379 +
    // status; the game's tab and overview say what it follows.
3380 +
    pass_all().await;
3381 +
    let (st, page) = call(&h.app, "GET", "/andy/api/diffusion", None, None).await;
3382 +
    assert_eq!(st, StatusCode::OK);
3383 +
    assert!(page.contains("Used by") && page.contains("andy/game") && page.contains(&format!("#{}", p2.number)), "the bump is listed");
3384 +
    assert!(page.contains("df-green"), "a passing run of a checked upstream is green");
3385 +
    let (_, page) = call(&h.app, "GET", "/andy/game/diffusion", None, None).await;
3386 +
    assert!(page.contains("Follows") && page.contains("andy/api"));
3387 +
    let (_, overview) = call(&h.app, "GET", "/andy/game", None, None).await;
3388 +
    assert!(overview.contains("Follows") && overview.contains(">Diffusion</a>"), "the tab and the panel");
3389 +
    let (st, _) = call(&h.app, "GET", "/admin/diffusion", None, None).await;
3390 +
    assert_eq!(st, StatusCode::NOT_FOUND, "administrators only");
3391 +
3392 +
    // Landing the bump on the hub moves the game's pin as a push would:
3393 +
    // the index follows, so it is no longer behind.
3394 +
    let (st, body) =
3395 +
        call(&h.app, "POST", &format!("/api/v1/repos/andy/game/proposals/{}/land", p2.number), Some("site"), Some("{}".into())).await;
3396 +
    assert_eq!(st, StatusCode::OK, "{body}");
3397 +
    let mut behind = serde_json::Value::Null;
3398 +
    for _ in 0..100 {
3399 +
        let (_, list) = call(&h.app, "GET", "/api/v1/repos/andy/api/consumers", Some("site"), None).await;
3400 +
        let list: Vec<serde_json::Value> = serde_json::from_str(&list).unwrap();
3401 +
        behind = list.iter().find(|c| c["name"] == "game").map(|c| c["behind"].clone()).unwrap_or_default();
3402 +
        if behind == 0 {
3403 +
            break;
3404 +
        }
3405 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3406 +
    }
3407 +
    assert_eq!(behind, 0, "the index followed the landing");
3408 +
3378 3409
    // A consumer turning Diffusion on is bumped at once to the upstream's
3379 3410
    // newest passing state, without waiting for it to move again.
3380 3411
    create(&h.app, "andy", "game2").await;
…
112 unchanged lines
modified docs/diffusion.md +9 −0
…
107 unchanged lines
108 108
- Its checks, as for any proposal. A red bump stays open, with the
109 109
  failing check; D2 then says which upstream change broke it.
110 110
111 +
### 4.5 Seeing it (#150)
112 +
113 +
A repository's **Diffusion** tab lists who pins it (Used by) and what it
114 +
pins (Follows), each with a status Andy chose on 2026-10-13: **green**
115 +
(the upstream was checked and the consumer's run on the bump passed),
116 +
**amber** (the run passed but the upstream has no checks), **red** (the run
117 +
failed), **grey** (running), **up to date**. The overview shows the same in
118 +
brief; Admin › Diffusion shows the whole hub.
119 +
111 120
## 5. Across hubs
112 121
113 122
Each hub only ever writes to its own repositories. What one hub tells
…
100 unchanged lines
modified docs/spec.md +1 −1
…
514 unchanged lines
515 515
  - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`.
516 516
517 517
  Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`.
518 -
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them as Used by. A consumer follows a subrepo's upstream as its `.oxymodules` entry says (`follow = off | green | any`, `land = propose | auto`, `target` naming the bookmark to bump, `watch` listing paths whose upstream changes are flagged), else as its repository setting says (Settings › Diffusion, or `PUT /repos/{owner}/{name}/diffusion {"follow": ...}` by its owners and admins; migration 0058), else as `[diffusion] follow` in hub.toml says (default `off`). A **bump** moves a consumer's pin at a path to a newer upstream state: one change on the target bookmark's heads that replaces only the pin (oxy-core `build_pin_change`), signed with the hub's key, on the bookmark `diffusion/<path>`, which the hub owns and rebuilds on the target each time (a forced update). When the pin has changes the upstream's state lacks (say, from an upstream proposal not landed yet), the bump moves to both, so a consumer never loses changes it relies on; it is skipped when that combination would conflict, when the pin names changes the upstream does not hold, when the pin already holds the state, or when the bump bookmark already moves it there, and the hub logs why. A bump is **proposed** once per bump bookmark, by the hub's `diffusion` account (made on first use, never able to sign in), into the target, labelled `dependencies`, its body the upstream changes the pin lacks (newest first, linking to the upstream's change pages); a later bump edits the same open proposal. It is triggered when a push moves the upstream's default bookmark (for consumers following `any`) or when a run of that bookmark passes (for `green`). Diffusion never lands a proposal. An upstream with no checks of its own (Flow off, or no run ever of its default bookmark) is **unverified**: it never turns green, so its moves also bump consumers following `green`, and those proposals are marked amber with the `upstream unverified` label and a note in the body; a later bump from a passing run removes the label. A bump already in place refreshes its proposal's text and labels. A consumer's `watch` patterns (`*` within a path segment, `**` across them, `?` one character; a plain path covers everything below it) flag the upstream changes touching a matching file: they are listed first under "Touches what you watch", with the files, and marked ⚑ in the changelog. Turning Diffusion on for a consumer (the setting or the API) catches it up at once: it is bumped to each upstream's newest state whose default-bookmark run passed, or to its newest state when it follows every move. **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409).
518 +
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them as Used by. A repository that pins, or is pinned by, another on the hub has a **Diffusion tab** (#150): Used by (each consumer's pin bookmark, how far behind, its status, its open bump and that bump's newest run, its last landed bump, how it follows) and Follows (each upstream it pins, with the same); its overview shows both in brief, and Admin › Diffusion lists every upstream with its consumers. The status is green when the upstream is checked and the consumer's run on the bump passed, amber when that run passed but the upstream has no checks, red when the run failed, grey while it runs, and up to date when there is nothing to bump. Landing a proposal on the hub refreshes the pin index and propagates as a push does. A consumer follows a subrepo's upstream as its `.oxymodules` entry says (`follow = off | green | any`, `land = propose | auto`, `target` naming the bookmark to bump, `watch` listing paths whose upstream changes are flagged), else as its repository setting says (Settings › Diffusion, or `PUT /repos/{owner}/{name}/diffusion {"follow": ...}` by its owners and admins; migration 0058), else as `[diffusion] follow` in hub.toml says (default `off`). A **bump** moves a consumer's pin at a path to a newer upstream state: one change on the target bookmark's heads that replaces only the pin (oxy-core `build_pin_change`), signed with the hub's key, on the bookmark `diffusion/<path>`, which the hub owns and rebuilds on the target each time (a forced update). When the pin has changes the upstream's state lacks (say, from an upstream proposal not landed yet), the bump moves to both, so a consumer never loses changes it relies on; it is skipped when that combination would conflict, when the pin names changes the upstream does not hold, when the pin already holds the state, or when the bump bookmark already moves it there, and the hub logs why. A bump is **proposed** once per bump bookmark, by the hub's `diffusion` account (made on first use, never able to sign in), into the target, labelled `dependencies`, its body the upstream changes the pin lacks (newest first, linking to the upstream's change pages); a later bump edits the same open proposal. It is triggered when a push moves the upstream's default bookmark (for consumers following `any`) or when a run of that bookmark passes (for `green`). Diffusion never lands a proposal. An upstream with no checks of its own (Flow off, or no run ever of its default bookmark) is **unverified**: it never turns green, so its moves also bump consumers following `green`, and those proposals are marked amber with the `upstream unverified` label and a note in the body; a later bump from a passing run removes the label. A bump already in place refreshes its proposal's text and labels. A consumer's `watch` patterns (`*` within a path segment, `**` across them, `?` one character; a plain path covers everything below it) flag the upstream changes touching a matching file: they are listed first under "Touches what you watch", with the files, and marked ⚑ in the changelog. Turning Diffusion on for a consumer (the setting or the API) catches it up at once: it is bumped to each upstream's newest state whose default-bookmark run passed, or to its newest state when it follows every move. **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409).
519 519
- **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same.
520 520
- **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value.
521 521
- **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`.
…
231 unchanged lines
modified templates/admin.html +1 −0
…
17 unchanged lines
18 18
  <a href="#audit">Audit log</a>
19 19
  <a href="/admin/runners">Runners ↗</a>
20 20
  <a href="/admin/runners/images">Images ↗</a>
21 +
  <a href="/admin/diffusion">Diffusion ↗</a>
21 22
  <a href="/admin/storage">Storage and maintenance ↗</a>
22 23
</nav>
23 24
<div class="sections">
…
74 unchanged lines
modified templates/overview.html +5 −1
…
60 unchanged lines
61 61
    <ul class="plain">{% for b in o.bookmarks %}<li><a href="{{ repo.url }}/tree/{{ b.name }}">{{ b.name }}</a>{% if b.heads.len() > 1 %} <span class="tag warn">{{ b.heads.len() }} heads</span>{% endif %}{% if b.conflicted %} <span class="tag bad">conflicts</span>{% endif %}</li>{% endfor %}</ul>
62 62
    {% if let Some(rel) = latest_release %}<h3>Latest release</h3><p><a href="{{ repo.url }}/releases/tag/{{ rel.tag }}">{{ rel.title }}</a> <span class="small mute">{{ rel.created_ms|ago_i }}</span></p>{% endif %}
63 63
    {% if !used_by.is_empty() %}<h3>Used by <span class="count-chip">{{ used_by.len() }}</span></h3>
64 -
    <ul class="plain used-by">{% for c in used_by %}<li><a href="/{{ c.owner }}/{{ c.name }}">{{ c.owner }}/{{ c.name }}</a> <span class="small mute">{{ c.bookmark }} · {{ c.path }}</span><br><span class="small">{% match c.behind %}{% when Some(0) %}<span class="tag">up to date</span>{% when Some(n) %}<span class="tag warn" title="Changes on {{ repo.default_bookmark }} its pin lacks">{{ n }} behind</span>{% when None %}<span class="mute" title="Its pin names changes this repository does not hold">pin not here</span>{% endmatch %}</span></li>{% endfor %}</ul>{% endif %}
64 +
    <ul class="plain used-by">{% for c in used_by %}<li><a href="/{{ c.owner }}/{{ c.name }}">{{ c.owner }}/{{ c.name }}</a> <span class="df-chip {{ c.colour.class() }}" title="{{ c.colour.label() }}">{% match c.behind %}{% when Some(0) %}up to date{% when Some(n) %}{{ n }} behind{% when None %}?{% endmatch %}</span></li>{% endfor %}</ul>
65 +
    <p class="small"><a href="{{ repo.url }}/diffusion">Diffusion ›</a></p>{% endif %}
66 +
    {% if !follows.is_empty() %}<h3>Follows</h3>
67 +
    <ul class="plain used-by">{% for (up, c) in follows %}<li><a href="/{{ up.owner }}/{{ up.name }}">{{ up.owner }}/{{ up.name }}</a> <span class="df-chip {{ c.colour.class() }}" title="{{ c.colour.label() }}">{{ c.colour.label() }}</span>{% if let Some(n) = c.bump %} <a class="small" href="{{ repo.url }}/proposal/{{ n }}">#{{ n }}</a>{% endif %}</li>{% endfor %}</ul>
68 +
    <p class="small"><a href="{{ repo.url }}/diffusion">Diffusion ›</a></p>{% endif %}
65 69
    <h3>Tags</h3>
66 70
    {% if o.tags.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain">{% for t in o.tags %}<li><a href="{{ repo.url }}/tree/{{ t.name }}">{{ t.name }}</a>{% if t.signer.is_some() %} <span class="sig sig-valid" title="signed">✔</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
67 71
    <h3>Trust</h3>
…
9 unchanged lines
modified templates/repo_tabs.html +1 −0
…
9 unchanged lines
10 10
    {% if repo.wiki || repo.can(crate::auth::Access::Write) %}<a href="{{ repo.url }}/wiki"{% if tab == "wiki" %} class="on" aria-current="page"{% endif %}>Wiki</a>{% endif %}
11 11
    <a href="{{ repo.url }}/flow"{% if tab == "flow" %} class="on" aria-current="page"{% endif %}>Flow</a>
12 12
    {% if repo.reports || repo.tests || repo.coverage %}<a href="{{ repo.url }}/reports"{% if tab == "reports" || tab == "tests" || tab == "coverage" %} class="on" aria-current="page"{% endif %}>Reports</a>{% endif %}
13 +
    {% if repo.diffusion %}<a href="{{ repo.url }}/diffusion"{% if tab == "diffusion" %} class="on" aria-current="page"{% endif %}>Diffusion</a>{% endif %}
13 14
    <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on" aria-current="page"{% endif %}>Bookmarks</a>
14 15
    <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on" aria-current="page"{% endif %}>Releases</a>
15 16
    <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on" aria-current="page"{% endif %}>Tags</a>
…
62 unchanged lines
modified templates/style.css +11 −0
…
993 unchanged lines
994 994
.lbl.img-hub { color: var(--acc); border-color: var(--acc); }
995 995
.lbl.img-holds { color: var(--ok); border-color: var(--ok); }
996 996
.lbl.img-fetching { color: var(--warn); border-color: var(--warn); }
997 +
998 +
/* Diffusion (#150): each consumer's status at a glance. */
999 +
.df-chip { display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 10px; border: 1px solid; white-space: nowrap; }
1000 +
.df-green { color: var(--ok); border-color: var(--ok); }
1001 +
.df-amber { color: #b7791f; border-color: #e3a008; background: color-mix(in srgb, #e3a008 12%, transparent); }
1002 +
.df-red { color: var(--bad); border-color: var(--bad); }
1003 +
.df-grey { color: var(--mute); border-color: var(--line); }
1004 +
.df-current { color: var(--acc); border-color: var(--acc); }
1005 +
.df-off { color: var(--dim); border-color: var(--line); border-style: dashed; }
1006 +
.df-key .df-chip { margin-right: 2px; }
1007 +
table.diffusion td { vertical-align: middle; }