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 "") || 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 |