OXYGENHUB

Repository bar: an Artifacts menu and a More menu 16ee5d19d220

The bar had 16 links and wrapped. Now: Code, Changes, Proposals, Issues,
Flow, Releases, Wiki, Artifacts, More, Settings.

- Artifacts: Releases, Build artifacts (every run's outputs in one list,
  new), Images (the runner images the repository's jobs used, new),
  Terraform state (its own page, out of Settings, with how a pipeline
  and a person authenticate) and Reports.
- More: Bookmarks, Tags, Diffusion, Trust, Pushes, Conflicts.
- Tags is also Releases' second view; Projects leaves the bar (the site
  band and every profile have it: the profile showed it only to members
  of an organisation).
- The menus are <details>, working without JavaScript; hub.js closes
  them on a click outside, on Escape and when another opens. On narrow
  screens they open inline in the scrolling bar.

Closes #254
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f7c8b4ecfd84
When
· 2026-10-18 10:23 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
16ee5d19d22064bdc73a1e69524dada66100b0d2ae068d40f7814e1f3d5d81ba
Depends on
101740454617 10708ab8f54d 145306044896 18e381ea88c2 19bb31ff1f5c 1ac84c62ba53 3f4b0ea6eabf 42fa95540468 61ecbd299f22 62ceb2eec18c 66fd9d6b1452 6ae3fa0af4f6 6ca0b971ff07 749d9a5fcd9f 756fe44a0f4d 757195ad7a5d 8dcaeeb848c6 8fb35df7d66e 9008bf6c2b8c 92a797b20912 93be9ce4653e a024e800af4e a3c8288b0141 c8df33a6829f c9aacc0e7dc1 d23464c0ac60 d3b048b27277 e1ec9fbf538e
Depended on by
e4203e141e90 ba0a55bb5b3c 166f6e9bf4f1 e97500b70f9a 67523e06ed86 bb018142cca7
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
32 atoms, 4 tree operations, 21 files

Browse the tree at this change

added crates/hub/src/web/artifacts.rs +103 −0
1 +
//! A repository's Artifacts menu (oxygenhub#254): every run's build
2 +
//! artifacts in one list, the runner images its jobs use, and its
3 +
//! Terraform state. Releases and reports keep their own pages.
4 +
5 +
use super::{filters, lookup, Error, Page, RepoShell, Shell};
6 +
use crate::auth::Viewer;
7 +
use crate::db::flow as fdb;
8 +
use crate::{db, Shared};
9 +
use askama::Template;
10 +
use axum::extract::{Path, State};
11 +
12 +
/// At most this many artifacts are listed, newest first.
13 +
const ARTIFACTS_SHOWN: i64 = 200;
14 +
/// Images used within this many days are listed.
15 +
const IMAGE_DAYS: i64 = 90;
16 +
17 +
#[derive(Template)]
18 +
#[template(path = "repo_artifacts.html")]
19 +
pub struct ArtifactsPage {
20 +
    shell: Shell,
21 +
    repo: RepoShell,
22 +
    artifacts: Vec<fdb::RepoArtifact>,
23 +
    limit: i64,
24 +
}
25 +
26 +
/// `GET /{owner}/{name}/artifacts`.
27 +
pub async fn artifacts(
28 +
    State(app): State<Shared>,
29 +
    viewer: Viewer,
30 +
    Path((owner, name)): Path<(String, String)>,
31 +
) -> Result<Page<ArtifactsPage>, Error> {
32 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/artifacts"), &viewer);
33 +
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
34 +
    let artifacts = fdb::repo_artifacts(&app.db, row.id, ARTIFACTS_SHOWN).await.map_err(|e| Error::from_db(shell.clone(), e))?;
35 +
    Ok(Page(ArtifactsPage { shell, repo: RepoShell::new(&app, &row, "artifacts").with_role(role), artifacts, limit: ARTIFACTS_SHOWN }))
36 +
}
37 +
38 +
/// One image this repository's jobs ran on.
39 +
pub struct ImageView {
40 +
    /// The image's BLAKE3, or what the pipeline named when it is not a hash.
41 +
    pub image: String,
42 +
    pub hashed: bool,
43 +
    pub jobs: i64,
44 +
    pub last_ms: i64,
45 +
    /// Kept by the hub: its size and architecture.
46 +
    pub kept: Option<(i64, String)>,
47 +
}
48 +
49 +
#[derive(Template)]
50 +
#[template(path = "repo_images.html")]
51 +
pub struct ImagesPage {
52 +
    shell: Shell,
53 +
    repo: RepoShell,
54 +
    images: Vec<ImageView>,
55 +
    days: i64,
56 +
}
57 +
58 +
/// `GET /{owner}/{name}/images`.
59 +
pub async fn images(
60 +
    State(app): State<Shared>,
61 +
    viewer: Viewer,
62 +
    Path((owner, name)): Path<(String, String)>,
63 +
) -> Result<Page<ImagesPage>, Error> {
64 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/images"), &viewer);
65 +
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
66 +
    let e = |e| Error::from_db(shell.clone(), e);
67 +
    let uses = crate::images::uses_of(&app.db, row.id, db::now_ms() - IMAGE_DAYS * 24 * 3600 * 1000).await.map_err(e)?;
68 +
    let kept = crate::images::kept(&app.db).await.map_err(e)?;
69 +
    let images = uses
70 +
        .into_iter()
71 +
        .map(|u| {
72 +
            let kept = kept.iter().find(|k| k.hash.eq_ignore_ascii_case(&u.image)).map(|k| (k.size, k.arch.clone()));
73 +
            ImageView { hashed: jet::images::is_hash(&u.image), image: u.image, jobs: u.jobs, last_ms: u.last_ms, kept }
74 +
        })
75 +
        .collect();
76 +
    Ok(Page(ImagesPage { shell, repo: RepoShell::new(&app, &row, "images").with_role(role), images, days: IMAGE_DAYS }))
77 +
}
78 +
79 +
#[derive(Template)]
80 +
#[template(path = "repo_terraform.html")]
81 +
pub struct TerraformPage {
82 +
    shell: Shell,
83 +
    repo: RepoShell,
84 +
    tf_states: Vec<(db::terraform::TfState, i64)>,
85 +
}
86 +
87 +
/// `GET /{owner}/{name}/terraform`: the states the hub keeps for this
88 +
/// repository (oxygenhub#252), and the backend block that uses them.
89 +
pub async fn terraform(
90 +
    State(app): State<Shared>,
91 +
    viewer: Viewer,
92 +
    Path((owner, name)): Path<(String, String)>,
93 +
) -> Result<Page<TerraformPage>, Error> {
94 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer);
95 +
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
96 +
    let e = |e| Error::from_db(shell.clone(), e);
97 +
    let mut tf_states = Vec::new();
98 +
    for s in db::terraform::of_repo(&app.db, row.id).await.map_err(e)? {
99 +
        let n = db::terraform::versions(&app.db, row.id, &s.name).await.map_err(e)?;
100 +
        tf_states.push((s, n));
101 +
    }
102 +
    Ok(Page(TerraformPage { shell, repo: RepoShell::new(&app, &row, "terraform").with_role(role), tf_states }))
103 +
}
added templates/repo_artifacts.html +24 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Build artifacts · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "artifacts") %}{% endblock %}
6 +
{% block content %}
7 +
<div class="head"><h1>Build artifacts <small>{{ artifacts.len() }}</small></h1></div>
8 +
<p class="small mute">What this repository's OxygenFlow jobs kept (the <code>artifact</code> blocks of a job's <code>output</code>), newest first{% if artifacts.len() as i64 == limit %}, the latest {{ limit }}{% endif %}. Each run's page has its own; a release can attach one as an asset.</p>
9 +
{% if artifacts.is_empty() %}<p class="empty">No artifacts yet. A job keeps a file with an <code>output { artifact "NAME" { path "/src/…" } }</code> block in <code>.flow/pipeline.kdl</code>.</p>{% else %}
10 +
<div class="table"><table>
11 +
<tr><th>Artifact</th><th>Run</th><th>Job</th><th>Size</th><th>BLAKE3</th><th>Made</th></tr>
12 +
{% for a in artifacts %}
13 +
<tr>
14 +
  <td><a href="{{ repo.url }}/flow/{{ a.run_number }}/artifacts/{{ a.job|urlencode }}/{{ a.name|urlencode }}" title="{{ a.path }}">{{ a.name }}</a></td>
15 +
  <td><a href="{{ repo.url }}/flow/{{ a.run_number }}">#{{ a.run_number }}</a> <span class="small mute">{{ a.bookmark }}</span></td>
16 +
  <td>{{ a.job }}</td>
17 +
  <td class="small">{{ a.size|bytes }}</td>
18 +
  <td><code class="small" title="{{ a.content }}">{{ a.content|short }}</code></td>
19 +
  <td class="small"><time title="{{ a.created_ms|when_i }}">{{ a.created_ms|ago_i }}</time></td>
20 +
</tr>
21 +
{% endfor %}
22 +
</table></div>
23 +
{% endif %}
24 +
{% endblock %}
added templates/repo_images.html +22 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Images · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "images") %}{% endblock %}
6 +
{% block content %}
7 +
<div class="head"><h1>Images <small>{{ images.len() }}</small></h1></div>
8 +
<p class="small mute">The runner images (root file systems) this repository's jobs ran on in the last {{ days }} days, named by their BLAKE3 as <code>sandbox</code> blocks pin them (<code>rootfs "cas://blob/…"</code>). A runner without one fetches it from the hub, when the hub keeps it.</p>
9 +
{% if images.is_empty() %}<p class="empty">No jobs ran on a pinned image in the last {{ days }} days.</p>{% else %}
10 +
<div class="table"><table>
11 +
<tr><th>Image</th><th>Architecture</th><th>Size</th><th>On the hub</th><th>Jobs</th><th>Last used</th></tr>
12 +
{% for i in images %}
13 +
<tr>
14 +
  <td>{% if i.hashed %}<code title="{{ i.image }}">{{ i.image|short }}</code>{% else %}<code>{{ i.image }}</code>{% endif %}</td>
15 +
  {% if let Some((size, arch)) = i.kept %}<td class="small">{% if arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ arch }}{% endif %}</td><td class="small">{{ size|bytes }}</td><td><span class="sig sig-valid">✔</span> kept</td>{% else %}<td class="small mute">unknown</td><td class="small mute">unknown</td><td><span class="sig sig-unsigned">○</span> <span class="small mute">runners only</span></td>{% endif %}
16 +
  <td>{{ i.jobs }}</td>
17 +
  <td class="small"><time title="{{ i.last_ms|when_i }}">{{ i.last_ms|ago_i }}</time></td>
18 +
</tr>
19 +
{% endfor %}
20 +
</table></div>
21 +
{% endif %}
22 +
{% endblock %}
added templates/repo_terraform.html +27 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Terraform state · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "terraform") %}{% endblock %}
6 +
{% block content %}
7 +
<div class="head"><h1>Terraform state <small>{{ tf_states.len() }}</small></h1></div>
8 +
<p>The hub keeps this repository's Terraform and OpenTofu state, sealed at rest, with its last {{ crate::terraform::VERSIONS_KEPT }} versions, outside the repository's history. Point a configuration at it with this backend block, naming the state in place of <code>NAME</code> (a repository may keep several):</p>
9 +
<pre class="snippet">terraform {
10 +
  backend "http" {
11 +
    address        = "{{ shell.base_url }}/api/v1/repos/{{ repo.owner }}/{{ repo.name }}/terraform/state/NAME"
12 +
    lock_address   = "{{ shell.base_url }}/api/v1/repos/{{ repo.owner }}/{{ repo.name }}/terraform/state/NAME"
13 +
    unlock_address = "{{ shell.base_url }}/api/v1/repos/{{ repo.owner }}/{{ repo.name }}/terraform/state/NAME"
14 +
    username       = "flow"
15 +
  }
16 +
}</pre>
17 +
<h2>From a pipeline</h2>
18 +
<p>A job uses it with an identity token of this hub's, so it needs no cloud credential for the state. Give the job <code>identity "{{ shell.base_url }}/terraform" {% raw %}{{% endraw %} mount-path "/run/secrets/tf" {% raw %}}{% endraw %}</code>, run <code>export TF_HTTP_PASSWORD=$(cat /run/secrets/tf)</code> before <code>tofu init</code>, and let its sandbox reach this hub (<code>network "egress" {% raw %}{{% endraw %} allow "{{ shell.base_url|host }}" {% raw %}}{% endraw %}</code>, beside whatever else it reaches).</p>
19 +
<h2>From your machine</h2>
20 +
<p>Set <code>TF_HTTP_PASSWORD</code> to one of your tokens: <code>repo:write</code> to change a state, <code>repo:read</code> to read one; the repository role it acts with decides the rest.</p>
21 +
<h2>States</h2>
22 +
{% if tf_states.is_empty() %}<p class="empty">No state yet: the first <code>tofu apply</code> through the backend makes one.</p>{% else %}
23 +
<div class="table"><table>
24 +
<tr><th>State</th><th>Serial</th><th>Last written</th><th>Lock</th><th>Versions kept</th></tr>
25 +
{% for (s, n) in tf_states %}<tr><td><strong>{{ s.name }}</strong></td><td>{{ s.serial }}</td><td class="small">{% if s.updated_by.is_empty() %}<span class="mute">never</span>{% else %}by {{ s.updated_by }} <time title="{{ s.updated_ms|when_i }}">{{ s.updated_ms|ago_i }}</time>{% endif %}</td><td class="small">{% if s.lock_id.is_empty() %}<span class="mute">free</span>{% else %}held by {{ s.locked_by }}{% if let Some(t) = s.locked_ms %} <time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}{% endif %}</td><td>{{ n }}</td></tr>{% endfor %}
26 +
</table></div>{% endif %}
27 +
{% endblock %}
modified assets/github.css +5 −0
…
76 unchanged lines
77 77
:root[data-look="github"] .tabs a:not(.crumb a) { padding: 8px 10px; font-size: 14px; border-radius: 6px; }
78 78
:root[data-look="github"] .tabs a.on { border-bottom: 0; box-shadow: inset 0 -2px 0 var(--gh-tab); border-radius: 0; font-weight: 600; }
79 79
:root[data-look="github"] .tabs a:hover { background: var(--gh-btn-hover); }
80 +
:root[data-look="github"] .tab-menu > summary { padding: 8px 10px; font-size: 14px; border-radius: 6px; }
81 +
:root[data-look="github"] .tab-menu > summary.on { border-bottom: 0; box-shadow: inset 0 -2px 0 var(--gh-tab); border-radius: 0; font-weight: 600; }
82 +
:root[data-look="github"] .tab-menu > summary:hover { background: var(--gh-btn-hover); }
83 +
:root[data-look="github"] .tab-menu-list { border-radius: 6px; padding: 4px; }
84 +
:root[data-look="github"] .tabs .tab-menu-list a[aria-current] { box-shadow: inset 2px 0 0 var(--gh-tab); border-radius: 0 6px 6px 0; }
80 85
:root[data-look="github"] main { padding: 24px 24px 32px; }
81 86
82 87
/* Page headings. */
…
127 unchanged lines
modified assets/hub.js +21 −0
…
8 unchanged lines
9 9
  });
10 10
  if (best) best.classList.add("on");
11 11
12 +
  /* The repository bar's menus (oxygenhub#254) are <details>, open without
13 +
     this file; here one open menu closes the others, and a click outside or
14 +
     Escape closes it. */
15 +
  var menus = document.querySelectorAll("[data-tab-menu]");
16 +
  function closeMenus(except) {
17 +
    menus.forEach(function (m) { if (m !== except) m.open = false; });
18 +
  }
19 +
  menus.forEach(function (m) {
20 +
    m.addEventListener("toggle", function () { if (m.open) closeMenus(m); });
21 +
  });
22 +
  if (menus.length) {
23 +
    document.addEventListener("click", function (ev) {
24 +
      if (!ev.target.closest || !ev.target.closest("[data-tab-menu]")) closeMenus(null);
25 +
    });
26 +
    document.addEventListener("keydown", function (ev) {
27 +
      if (ev.key !== "Escape") return;
28 +
      var open = document.querySelector("[data-tab-menu][open]");
29 +
      if (open) { open.open = false; open.querySelector("summary").focus(); }
30 +
    });
31 +
  }
32 +
12 33
  /* Off-the-shelf editors, loaded only where a page asks for them:
13 34
     CodeMirror 6 over textarea.code, EasyMDE over textarea.md. */
14 35
  function load(src, css) {
…
749 unchanged lines
modified crates/hub/src/db/flow.rs +27 −0
…
1140 unchanged lines
1141 1141
    sqlx::query_as("SELECT * FROM flow_artifacts WHERE job_id = $1 ORDER BY name").bind(job_id).fetch_all(pool).await
1142 1142
}
1143 1143
1144 +
/// An artifact with the run and job that made it, for a repository's list
1145 +
/// of every run's outputs (oxygenhub#254).
1146 +
#[derive(Clone, Debug, FromRow)]
1147 +
pub struct RepoArtifact {
1148 +
    pub run_number: i64,
1149 +
    pub bookmark: String,
1150 +
    pub job: String,
1151 +
    pub name: String,
1152 +
    pub path: String,
1153 +
    pub size: i64,
1154 +
    pub content: String,
1155 +
    pub created_ms: i64,
1156 +
}
1157 +
1158 +
/// A repository's newest artifacts, at most `limit`.
1159 +
pub async fn repo_artifacts(pool: &Pool, repo_id: i64, limit: i64) -> sqlx::Result<Vec<RepoArtifact>> {
1160 +
    sqlx::query_as(
1161 +
        "SELECT r.number AS run_number, r.bookmark, j.name AS job, a.name, a.path, a.size, a.content, a.created_ms
1162 +
         FROM flow_artifacts a JOIN flow_jobs j ON j.id = a.job_id JOIN flow_runs r ON r.id = j.run_id
1163 +
         WHERE r.repo_id = $1 ORDER BY a.created_ms DESC, r.number DESC, j.name, a.name LIMIT $2",
1164 +
    )
1165 +
    .bind(repo_id)
1166 +
    .bind(limit)
1167 +
    .fetch_all(pool)
1168 +
    .await
1169 +
}
1170 +
1144 1171
/// Where an artifact with given content came from: the run and job that
1145 1172
/// built it, and the job's signed receipt when one was kept.
1146 1173
#[derive(Clone, Debug, FromRow)]
…
86 unchanged lines
modified crates/hub/src/images.rs +17 −0
…
256 unchanged lines
257 257
    .await
258 258
}
259 259
260 +
/// What one repository's jobs ran on since `since_ms`, newest first
261 +
/// (its Images page, oxygenhub#254).
262 +
pub async fn uses_of(pool: &Pool, repo_id: i64, since_ms: i64) -> sqlx::Result<Vec<Use>> {
263 +
    let rootfs = crate::db::json_text("j.sandbox", "rootfs");
264 +
    sqlx::query_as(&format!(
265 +
        "SELECT lower({rootfs}) AS image, p.owner, p.name, COUNT(*) AS jobs,
266 +
                MAX(COALESCE(j.started_ms, r.created_ms)) AS last_ms
267 +
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id
268 +
         WHERE r.created_ms >= $1 AND p.id = $2 AND COALESCE({rootfs}, '') != ''
269 +
         GROUP BY lower({rootfs}), p.id, p.owner, p.name ORDER BY last_ms DESC"
270 +
    ))
271 +
    .bind(since_ms)
272 +
    .bind(repo_id)
273 +
    .fetch_all(pool)
274 +
    .await
275 +
}
276 +
260 277
/// The architecture runners reported for each image (`flow_images`).
261 278
pub async fn reported_archs(pool: &Pool) -> sqlx::Result<std::collections::HashMap<String, String>> {
262 279
    Ok(sqlx::query_as::<_, (String, String)>("SELECT hash, arch FROM flow_images").fetch_all(pool).await?.into_iter().collect())
…
1 unchanged lines
modified crates/hub/src/web/filters.rs +5 −0
…
49 unchanged lines
50 50
    Ok(crate::web::state_label(text))
51 51
}
52 52
53 +
/// A URL's host, without its scheme, port or path (an egress allow line).
54 +
pub fn host(url: &str, _: &dyn askama::Values) -> askama::Result<String> {
55 +
    Ok(url.split_once("://").map_or(url, |(_, rest)| rest).split(['/', ':']).next().unwrap_or("").to_string())
56 +
}
57 +
53 58
pub fn short(hash: &str, _: &dyn askama::Values) -> askama::Result<String> {
54 59
    Ok(hash.chars().take(12).collect())
55 60
}
…
132 unchanged lines
modified crates/hub/src/web/mod.rs +4 −0
…
3 unchanged lines
4 4
//! render.
5 5
6 6
mod admin;
7 +
mod artifacts;
7 8
pub(crate) mod assistants;
8 9
mod avatar;
9 10
pub(crate) mod backups;
…
194 unchanged lines
204 205
        .route("/{owner}/{name}/bookmarks", get(refs::bookmarks))
205 206
        .route("/{owner}/{name}/bookmarks/delete", post(refs::delete_bookmark))
206 207
        .route("/{owner}/{name}/tags", get(refs::tags))
208 +
        .route("/{owner}/{name}/artifacts", get(artifacts::artifacts))
209 +
        .route("/{owner}/{name}/images", get(artifacts::images))
210 +
        .route("/{owner}/{name}/terraform", get(artifacts::terraform))
207 211
        .route("/{owner}/{name}/conflicts", get(refs::conflicts))
208 212
        .route("/{owner}/{name}/trust", get(refs::trust))
209 213
        .route("/{owner}/{name}/pushes", get(refs::pushes))
…
589 unchanged lines
modified crates/hub/src/web/repo_settings.rs +0 −8
…
195 unchanged lines
196 196
    coverage: crate::coverage::Rules,
197 197
    /// Its owner's backup, when one is set up (docs/backups.md §5).
198 198
    backup_owner: Option<crate::backups::Setting>,
199 -
    /// Terraform states the hub keeps for it, with their kept versions (#252).
200 -
    tf_states: Vec<(db::terraform::TfState, i64)>,
201 199
}
202 200
203 201
impl RepoSettingsPage {
…
47 unchanged lines
251 249
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
252 250
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
253 251
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
254 -
    let mut tf_states = Vec::new();
255 -
    for s in db::terraform::of_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
256 -
        let n = db::terraform::versions(&app.db, row.id, &s.name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
257 -
        tf_states.push((s, n));
258 -
    }
259 252
    Ok(Page(RepoSettingsPage {
260 -
        tf_states,
261 253
        backup_owner,
262 254
        coverage,
263 255
        storage,
…
849 unchanged lines
modified crates/hub/tests/browse.rs +22 −0
…
240 unchanged lines
241 241
    assert!(bm.contains("feature") && bm.contains("default"));
242 242
    let tags = page(&h.app, "/andy/demo/tags").await;
243 243
    assert!(tags.contains("v0.1") && tags.contains("First tag"));
244 +
    // The bar (#254): Tags is Releases' second view and in the More menu;
245 +
    // the less used pages sit in More, the outputs in Artifacts; Projects
246 +
    // and Conflicts are off the bar.
247 +
    assert!(tags.contains("href=\"/andy/demo/releases\" class=\"on\" aria-current=\"page\">Releases</a>"), "{tags}");
248 +
    assert!(tags.contains("href=\"/andy/demo/tags\" class=\"on\" aria-current=\"page\">tags</a>"), "the sub-tab");
249 +
    let more = &tags[tags.find(">More</summary>").expect("the More menu")..];
250 +
    let more = &more[..more.find("</details>").unwrap()];
251 +
    for p in ["bookmarks", "tags", "trust", "pushes", "conflicts"] {
252 +
        assert!(more.contains(&format!("href=\"/andy/demo/{p}\"")), "More has {p}: {more}");
253 +
    }
254 +
    let artifacts = &tags[tags.find(">Artifacts</summary>").expect("the Artifacts menu")..];
255 +
    let artifacts = &artifacts[..artifacts.find("</details>").unwrap()];
256 +
    for p in ["releases", "artifacts", "images", "terraform"] {
257 +
        assert!(artifacts.contains(&format!("href=\"/andy/demo/{p}\"")), "Artifacts has {p}: {artifacts}");
258 +
    }
259 +
    assert!(!tags.contains("project boards\">Projects</a>"), "Projects is not a repository tab");
260 +
    let releases = page(&h.app, "/andy/demo/releases").await;
261 +
    assert!(releases.contains("href=\"/andy/demo/tags\">tags</a>"), "{releases}");
262 +
    for p in ["artifacts", "images", "terraform"] {
263 +
        let body = page(&h.app, &format!("/andy/demo/{p}")).await;
264 +
        assert!(body.contains("<summary class=\"on\">Artifacts</summary>"), "{p}: {body}");
265 +
    }
244 266
    assert!(page(&h.app, "/andy/demo/conflicts").await.contains("No bookmark carries a conflict"));
245 267
    let trust = page(&h.app, "/andy/demo/trust").await;
246 268
    assert!(trust.contains("No trust policy") && trust.contains(&h.app.hub_key.key.to_hex()));
…
59 unchanged lines
modified crates/hub/tests/flow.rs +10 −0
…
901 unchanged lines
902 902
    let (_, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", run.number), None, None).await;
903 903
    let link = format!("/andy/demo/flow/{}/artifacts/build/tool", run.number);
904 904
    assert!(page.contains(&link), "run page lists the artifact");
905 +
    // So does the repository's Build artifacts page (#254), and the bar's
906 +
    // Artifacts menu leads there.
907 +
    let (_, list) = call(&h.app, "GET", "/andy/demo/artifacts", None, None).await;
908 +
    assert!(list.contains(&link) && list.contains(&format!("#{}</a>", run.number)), "{list}");
909 +
    assert!(list.contains("<summary class=\"on\">Artifacts</summary>") && list.contains("href=\"/andy/demo/images\""), "{list}");
905 910
    let (st, got) = get_bytes(&h.app, &link).await;
906 911
    assert_eq!((st, got == body), (StatusCode::OK, true));
907 912
    assert_eq!(get_bytes(&h.app, &format!("/andy/demo/flow/{}/artifacts/build/none", run.number)).await.0, StatusCode::NOT_FOUND);
…
4328 unchanged lines
5236 5241
    assert_eq!(send("GET", path.into(), Some(format!("Bearer {other}")), "").await.0, StatusCode::FORBIDDEN);
5237 5242
    let elsewhere = issuer.mint_for(h.app.config.base_url(), "repo:andy/infra:bookmark:main", "sts.amazonaws.com", now, 600);
5238 5243
    assert_eq!(send("GET", path.into(), Some(format!("Bearer {elsewhere}")), "").await.0, StatusCode::UNAUTHORIZED);
5244 +
    // Its page under Artifacts (#254) lists the state and the backend block.
5245 +
    let (st, page) = call(&h.app, "GET", "/andy/infra/terraform", None, None).await;
5246 +
    assert_eq!(st, StatusCode::OK, "{page}");
5247 +
    assert!(page.contains("<strong>prod</strong>") && page.contains("/api/v1/repos/andy/infra/terraform/state/NAME"), "{page}");
5248 +
    assert!(page.contains("allow &quot;") || page.contains("allow \""), "the egress line names the hub");
5239 5249
}
5240 5250
5241 5251
/// The real OpenTofu against the backend (oxygenhub#252): init, apply,
…
236 unchanged lines
modified crates/hub/tests/issues.rs +6 −6
…
866 unchanged lines
867 867
        .await
868 868
        .unwrap();
869 869
    }
870 -
    // Found from anywhere (#119): the site bar, every repository's tabs, and
871 -
    // a New project button on the list; a stranger is told who can make one.
870 +
    // Found from anywhere (#119): the site bar and the owner's profile (no
871 +
    // longer the repository's tabs, #254), and a New project button on the
872 +
    // list; a stranger is told who can make one.
872 873
    let home = get(app, "/andy/demo", Some(&andy)).await.body;
873 -
    assert!(
874 -
        home.contains("href=\"/projects/andy\"><svg class=\"ic\"") && home.contains("title=\"andy's project boards\">Projects</a>"),
875 -
        "site bar and tab"
876 -
    );
874 +
    assert!(home.contains("href=\"/projects/andy\"><svg class=\"ic\""), "site bar");
875 +
    assert!(!home.contains("project boards\">Projects</a>"), "not a repository tab");
876 +
    assert!(get(app, "/andy", Some(&bob)).await.body.contains("href=\"/projects/andy\">Projects</a>"), "the owner's profile");
877 877
    let list = get(app, "/projects/andy", Some(&andy)).await.body;
878 878
    assert!(list.contains("href=\"#new\">+ NEW PROJECT</a>") && list.contains("id=\"new\""), "the button and the form");
879 879
    assert!(get(app, "/projects/andy", Some(&bob)).await.body.contains("Only andy"), "who can make one");
…
320 unchanged lines
modified docs/spec.md +6 −3
…
245 unchanged lines
246 246
247 247
### 6.2 Page shell
248 248
249 -
`header` (wordmark, global search box, "+" menu for new repository/issue/proposal, notifications bell with unread count, avatar menu) · `.band` (Repositories · Explore · Proposals · Issues · Notifications, with admin links pushed right) · repository `.tabs` (Code · Changes · Proposals · Issues · Releases · Trust · Settings, each with a count pill) · `main` at 1180px max (wider than retroplay's 980 because diffs need it; tree and file pages use 100% with 22px gutters) · `footer`.
249 +
`header` (wordmark, global search box, "+" menu for new repository/issue/proposal, notifications bell with unread count, avatar menu) · `.band` (Repositories · Explore · Proposals · Issues · Notifications, with admin links pushed right) · repository `.tabs` (Code · Changes · Proposals · Issues · Flow · Releases · Wiki · Artifacts ▾ · More ▾ · Settings, open proposals and issues with a count pill; oxygenhub#254). **Artifacts** holds Releases, Build artifacts, Images, Terraform state and Reports (when there are any); **More** holds Bookmarks, Tags, Diffusion (when used), Trust, Pushes and Conflicts. The menus are `<details>`, so they open without JavaScript; hub.js closes one when another opens, on a click outside and on Escape. Under 760 pixels, where the bar scrolls sideways, a menu opens inline in the bar. Projects belong to the owner and are reached from the site band and the owner's profile; a state with conflicts says so on its overview · `main` at 1180px max (wider than retroplay's 980 because diffs need it; tree and file pages use 100% with 22px gutters) · `footer`.
250 250
251 251
Every page has a `<title>` of the form `<thing> · <owner>/<name> · OxygenHub`.
252 252
…
16 unchanged lines
269 269
| `/{owner}/{name}/conflicts` | Every bookmark whose state carries conflicts, each with its regions (§6.10) |
270 270
| `/{owner}/{name}/proposals`, `/proposal/{n}` | §7. The list filters by state and by `q` (`author:`, `label:`, `target:` and words), sorts by recently updated (default), newest or oldest, and pages 25 at a time |
271 271
| `/{owner}/{name}/issues`, `/issue/{n}` | §8. The list filters by state and by `q` (`author:`, `label:`, `assignee:`, `milestone:` and words), sorts by newest (pinned first, default), oldest, recently updated or most comments, and pages 25 at a time. The Proposals and Issues tabs show how many are open |
272 -
| `/{owner}/{name}/releases`, `/tags` | §9 |
272 +
| `/{owner}/{name}/releases`, `/tags` | §9; the two are views of the Releases tab |
273 +
| `/{owner}/{name}/artifacts` | Build artifacts: the newest 200 artifacts of every run, with the run, job, size, BLAKE3 and when, each linking its download (oxygenhub#254) |
274 +
| `/{owner}/{name}/images` | The runner images the repository's jobs ran on in the last 90 days: architecture and size when the hub keeps the image, the jobs that used it and when last |
275 +
| `/{owner}/{name}/terraform` | Terraform state (§13.5.4): the backend block, how a pipeline and a person authenticate, and the states with their serials, last writer, lock and versions kept |
273 276
| `/{owner}/{name}/trust` | Policy, roots, rules, hub delegate status, verification summary (§6.11) |
274 277
| `/{owner}/{name}/pushes` | Reference update log: who pushed what, when, with which key, result (§6.12) |
275 278
| `/{owner}/{name}/settings/*` | General, collaborators, bookmarks (default, delete), tokens, webhooks, mirror, danger zone |
…
358 unchanged lines
634 637
635 638
The token goes as the bearer or as Basic auth's password, which is what the backend sends (`TF_HTTP_PASSWORD`).
636 639
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.
640 +
**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 Artifacts › Terraform state page (`/{owner}/{name}/terraform`) 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 641
639 642
### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06)
640 643
…
168 unchanged lines
modified templates/profile.html +1 −1
…
15 unchanged lines
16 16
      <li class="mute">joined <time title="{{ account.created_ms|when_i }}">{{ account.created_ms|ago_i }}</time></li>
17 17
    </ul>
18 18
    {% if is_me %}<a class="go small" href="/settings">edit profile</a>{% endif %}
19 +
    <p><a class="go small" href="/projects/{{ account.username }}">Projects</a></p>
19 20
    {% if !orgs.is_empty() %}
20 -
    <p><a class="go small" href="/projects/{{ account.username }}">Projects</a></p>
21 21
    <section class="side-card"><h3>Organisations <span class="count-chip">{{ orgs.len() }}</span></h3><div class="body">
22 22
    <ul class="plain">{% for (o, role) in orgs %}<li><img class="avatar xs" src="/avatar/{{ o.name }}" alt=""> <a href="/{{ o.name }}">{{ o.name }}</a>{% if role == "owner" %} <span class="small mute">owner</span>{% endif %}</li>{% endfor %}</ul>
23 23
    </div></section>
…
17 unchanged lines
modified templates/releases.html +1 −0
…
4 unchanged lines
5 5
{% block tabs %}{% call m::tabs(repo, "releases") %}{% endblock %}
6 6
{% block content %}
7 7
<div class="head"><h1>Releases <small>{{ releases.len() }}</small></h1></div>
8 +
{% if !one %}<div class="bar"><span class="modes"><a href="{{ repo.url }}/releases" class="on" aria-current="page">releases</a><a href="{{ repo.url }}/tags">tags</a></span></div>{% endif %}
8 9
{% if !one && repo.can(crate::auth::Access::Write) %}
9 10
<div class="bar"><a class="go small" href="{{ repo.url }}/releases/new">draft a release</a>{% if !unreleased.is_empty() %}<span class="small mute">tags without a release: {% for t in unreleased %}<a href="{{ repo.url }}/releases/new?tag={{ t }}">{{ t }}</a>{% if !loop.last %}, {% endif %}{% endfor %}</span>{% endif %}</div>
10 11
{% endif %}
…
32 unchanged lines
modified templates/repo_settings.html +0 −17
…
151 unchanged lines
152 152
  </table></div>{% endif %}
153 153
  <form method="post" action="{{ repo.url }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for this repository</button></form>
154 154
</section>
155 -
<section class="panel" id="terraform">
156 -
  <h2>Terraform state</h2>
157 -
  <p class="small">The hub keeps this repository's Terraform and OpenTofu state, sealed at rest, with its last {{ crate::terraform::VERSIONS_KEPT }} versions. A pipeline's job uses it with an identity token of this hub's (no cloud credential): give the job <code>identity "{{ shell.base_url }}/terraform" {% raw %}{{% endraw %} mount-path "/run/secrets/tf" {% raw %}}{% endraw %}</code>, and <code>TF_HTTP_PASSWORD=$(cat /run/secrets/tf)</code> beside:</p>
158 -
  <pre class="snippet">terraform {
159 -
  backend "http" {
160 -
    address        = "{{ shell.base_url }}/api/v1/repos/{{ row.owner }}/{{ row.name }}/terraform/state/NAME"
161 -
    lock_address   = "{{ shell.base_url }}/api/v1/repos/{{ row.owner }}/{{ row.name }}/terraform/state/NAME"
162 -
    unlock_address = "{{ shell.base_url }}/api/v1/repos/{{ row.owner }}/{{ row.name }}/terraform/state/NAME"
163 -
    username       = "flow"
164 -
  }
165 -
}</pre>
166 -
  {% if tf_states.is_empty() %}<p class="mute small">No state yet.</p>{% else %}
167 -
  <div class="table"><table>
168 -
  <tr><th>State</th><th>Serial</th><th>Last written</th><th>Lock</th><th>Versions kept</th></tr>
169 -
  {% for (s, n) in tf_states %}<tr><td><strong>{{ s.name }}</strong></td><td>{{ s.serial }}</td><td class="small">{% if s.updated_by.is_empty() %}<span class="mute">never</span>{% else %}by {{ s.updated_by }} <time title="{{ s.updated_ms|when_i }}">{{ s.updated_ms|ago_i }}</time>{% endif %}</td><td class="small">{% if s.lock_id.is_empty() %}<span class="mute">free</span>{% else %}held by {{ s.locked_by }}{% if let Some(t) = s.locked_ms %} <time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}{% endif %}</td><td>{{ n }}</td></tr>{% endfor %}
170 -
  </table></div>{% endif %}
171 -
</section>
172 155
{% call w::panel(repo.url ~ "/settings", hooks, shell.csrf, "Each delivery is signed with the secret (X-Oxyhub-Signature) and retried with backoff for a day.") %}
173 156
{% call va::panel(repo.url ~ "/settings", variables, shell.csrf, "This repository's jobs get these, beside the ones the site and its owner set.") %}
174 157
{% call se::panel(repo.url ~ "/settings", secrets, shell.csrf, "This repository's jobs may use these, beside the ones the site and its owner set.") %}
…
100 unchanged lines
modified templates/repo_tabs.html +24 −10
…
5 unchanged lines
6 6
    <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>Changes</a>
7 7
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on" aria-current="page"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a>
8 8
    <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on" aria-current="page"{% endif %}>Issues{% if repo.open_issues > 0 %} <span class="count-chip">{{ repo.open_issues }}</span>{% endif %}</a>
9 -
    <a href="/projects/{{ repo.owner }}" title="{{ repo.owner }}'s project boards">Projects</a>
9 +
    <a href="{{ repo.url }}/flow"{% if tab == "flow" %} class="on" aria-current="page"{% endif %}>Flow</a>
10 +
    <a href="{{ repo.url }}/releases"{% if tab == "releases" || tab == "tags" %} class="on" aria-current="page"{% endif %}>Releases</a>
10 11
    {% 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 -
    <a href="{{ repo.url }}/flow"{% if tab == "flow" %} class="on" aria-current="page"{% endif %}>Flow</a>
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 %}
14 -
    <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on" aria-current="page"{% endif %}>Bookmarks</a>
15 -
    <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on" aria-current="page"{% endif %}>Releases</a>
16 -
    <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on" aria-current="page"{% endif %}>Tags</a>
17 -
    <a href="{{ repo.url }}/conflicts"{% if tab == "conflicts" %} class="on" aria-current="page"{% endif %}>Conflicts</a>
18 -
    <a href="{{ repo.url }}/trust"{% if tab == "trust" %} class="on" aria-current="page"{% endif %}>Trust</a>
19 -
    <a href="{{ repo.url }}/pushes"{% if tab == "pushes" %} class="on" aria-current="page"{% endif %}>Pushes</a>
12 +
    {# Menus are <details>: they open without JavaScript; hub.js only closes them (oxygenhub#254). #}
13 +
    <details class="tab-menu" data-tab-menu>
14 +
      <summary{% if tab == "artifacts" || tab == "images" || tab == "terraform" || tab == "reports" || tab == "tests" || tab == "coverage" %} class="on"{% endif %}>Artifacts</summary>
15 +
      <div class="tab-menu-list">
16 +
        <a href="{{ repo.url }}/releases"{% if tab == "releases" %} aria-current="page"{% endif %}>Releases</a>
17 +
        <a href="{{ repo.url }}/artifacts"{% if tab == "artifacts" %} aria-current="page"{% endif %}>Build artifacts</a>
18 +
        <a href="{{ repo.url }}/images"{% if tab == "images" %} aria-current="page"{% endif %}>Images</a>
19 +
        <a href="{{ repo.url }}/terraform"{% if tab == "terraform" %} aria-current="page"{% endif %}>Terraform state</a>
20 +
        {% if repo.reports || repo.tests || repo.coverage %}<a href="{{ repo.url }}/reports"{% if tab == "reports" || tab == "tests" || tab == "coverage" %} aria-current="page"{% endif %}>Reports</a>{% endif %}
21 +
      </div>
22 +
    </details>
23 +
    <details class="tab-menu" data-tab-menu>
24 +
      <summary{% if tab == "bookmarks" || tab == "diffusion" || tab == "trust" || tab == "pushes" || tab == "conflicts" %} class="on"{% endif %}>More</summary>
25 +
      <div class="tab-menu-list">
26 +
        <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} aria-current="page"{% endif %}>Bookmarks</a>
27 +
        <a href="{{ repo.url }}/tags"{% if tab == "tags" %} aria-current="page"{% endif %}>Tags</a>
28 +
        {% if repo.diffusion %}<a href="{{ repo.url }}/diffusion"{% if tab == "diffusion" %} aria-current="page"{% endif %}>Diffusion</a>{% endif %}
29 +
        <a href="{{ repo.url }}/trust"{% if tab == "trust" %} aria-current="page"{% endif %}>Trust</a>
30 +
        <a href="{{ repo.url }}/pushes"{% if tab == "pushes" %} aria-current="page"{% endif %}>Pushes</a>
31 +
        <a href="{{ repo.url }}/conflicts"{% if tab == "conflicts" %} aria-current="page"{% endif %}>Conflicts</a>
32 +
      </div>
33 +
    </details>
20 34
    {% if repo.can_maintain() %}<a href="{{ repo.url }}/settings"{% if tab == "settings" %} class="on" aria-current="page"{% endif %}>Settings</a>{% endif %}
21 35
  </div>
22 36
</nav>
…
56 unchanged lines
modified templates/style.css +15 −0
…
89 unchanged lines
90 90
.tabs a:not(.crumb a) { padding: 10px 12px; color: var(--ink); font-size: 13px; }
91 91
.tabs a.on { border-bottom: 2px solid var(--acc); font-weight: 600; }
92 92
.tabs a:hover { text-decoration: none; background: var(--soft); }
93 +
/* The bar's menus (oxygenhub#254): a summary that looks like a tab, its
94 +
   links in a panel below it; where the bar scrolls (narrow screens) they
95 +
   open inline instead, so the panel is never clipped. */
96 +
.tab-menu { position: relative; }
97 +
.tab-menu > summary { list-style: none; cursor: pointer; padding: 10px 12px; color: var(--ink); font-size: 13px; }
98 +
.tab-menu > summary::-webkit-details-marker { display: none; }
99 +
.tab-menu > summary::after { content: " \25BE"; color: var(--mute); }
100 +
.tab-menu > summary.on { border-bottom: 2px solid var(--acc); font-weight: 600; }
101 +
.tab-menu > summary:hover { background: var(--soft); }
102 +
.tab-menu-list { position: absolute; top: 100%; left: 0; z-index: 30; min-width: 190px; display: flex; flex-direction: column; padding: 4px 0; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 18px rgba(0,0,0,.18); }
103 +
.tabs .tab-menu-list a { padding: 8px 14px; white-space: nowrap; }
104 +
.tabs .tab-menu-list a[aria-current] { font-weight: 600; box-shadow: inset 2px 0 0 var(--acc); }
93 105
main { flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; padding: 14px 22px 28px; }
94 106
main.wide { max-width: none; }
95 107
footer { background: var(--panel); border-top: 1px solid var(--line); margin-top: 28px; }
…
453 unchanged lines
549 561
  article.doc, .panel { overflow-x: auto; }
550 562
  article.doc pre, article.doc table { max-width: 100%; overflow-x: auto; }
551 563
  .tabs-in > * { flex-shrink: 0; }
564 +
  .tab-menu { position: static; }
565 +
  .tab-menu > summary { display: inline-block; }
566 +
  .tab-menu-list { position: static; display: inline-flex; flex-direction: row; min-width: 0; padding: 0; border: 0; box-shadow: none; background: var(--soft); }
552 567
  .band { overflow-x: auto; white-space: nowrap; }
553 568
  main table.list, main table.flow-runs { display: block; overflow-x: auto; }
554 569
  .sectioned { grid-template-columns: 1fr; }
…
745 unchanged lines
modified templates/tags.html +1 −0
…
4 unchanged lines
5 5
{% block tabs %}{% call m::tabs(repo, "tags") %}{% endblock %}
6 6
{% block content %}
7 7
<div class="head"><h1>Tags <small>{{ tags.len() }}</small></h1></div>
8 +
<div class="bar"><span class="modes"><a href="{{ repo.url }}/releases">releases</a><a href="{{ repo.url }}/tags" class="on" aria-current="page">tags</a></span></div>
8 9
{% if tags.is_empty() %}<p class="empty">No tags. <code>oxy tag create NAME -m "message"</code> then push.</p>{% else %}
9 10
<div class="table"><table>
10 11
<tr><th>Tag</th><th>State</th><th>Message</th><th>Signed</th></tr>
…
11 unchanged lines