OXYGENHUB

Reports: the Reports tab, each report's page, its frame, and checks from thresholds c8df33a6829f

A repository with reports gets one Reports tab in place of Coverage and
Tests: each report's latest run with its kind, summary and metrics and
their change against the run before, Coverage and Tests listed beside
them. A report's page shows its metrics with sparklines across runs and
their thresholds, its context and runs, and its own index.html in a
sandboxed frame (allow-scripts only); the frame's files are served under
a per-report MAC with a sandbox CSP and nothing beyond the hub to load.
Each metric with fail_above or fail_below sets a check report/NAME/METRIC
on the run's state.

refs #127
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 14:05 UTC
Signature
✔ signed by Andy McCall
Hash
c8df33a6829f7e74495229ca4180b81b6b478b21decac8b2ee965ba5936cbb71
Depends on
19bb31ff1f5c 36e5a9c15a84 3d5c825aebba 42fa95540468 4597750166e7 50d93fa443dc 554ccacfc123 756fe44a0f4d 7b26e6118bc0 a36bab6b9ffc b1e92a6233ee b5668108487f b64e2537d702 c5f3bde466d1 c987c86513df d23464c0ac60
Depended on by
fac6853c4e46 33d98147511b e57765dee61a 16ee5d19d220 a7aa76ac52d7 5d90e0e045e2 d74ce22fa80e 757195ad7a5d 20b8fe85888b 95b525b0b977 40dd3f86379a 92a797b20912 f3e5e2bc78e9
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check main oh-comment-body-file proposal-list-fast
In tags
v0.4.2 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
25 atoms, 3 tree operations, 12 files

Browse the tree at this change

added crates/hub/src/web/reports.rs +317 −0
1 +
//! Reports pages (oxygenhub#127, docs/reports.md): the Reports tab, a
2 +
//! report's page with its metrics across runs and its own page in a
3 +
//! sandboxed frame, and the files that frame reads.
4 +
5 +
use super::{filters, lookup, Error, Page, RepoShell, Shell};
6 +
use crate::auth::Viewer;
7 +
use crate::reports::{self, Metric, Report};
8 +
use crate::Shared;
9 +
use askama::Template;
10 +
use axum::extract::{Path, Query, State};
11 +
use axum::http::{header, HeaderValue, StatusCode};
12 +
use axum::response::{IntoResponse, Response};
13 +
use serde::Deserialize;
14 +
15 +
/// Runs a report's page reads for its sparklines and run list.
16 +
const HISTORY: i64 = 60;
17 +
18 +
#[derive(Deserialize, Default)]
19 +
pub struct ReportsQuery {
20 +
    /// Only this bookmark's reports.
21 +
    bookmark: Option<String>,
22 +
    /// One report, by id.
23 +
    id: Option<i64>,
24 +
}
25 +
26 +
/// A metric as shown: its value, and its change against the run before.
27 +
pub struct MetricView {
28 +
    pub key: String,
29 +
    pub value: String,
30 +
    pub unit: String,
31 +
    /// `+0.4`, empty when there is no run before or no change.
32 +
    pub delta: String,
33 +
    /// `better`, `worse` or `same`, by the metric's `better`.
34 +
    pub trend: &'static str,
35 +
    /// Its threshold, as words, when it has one.
36 +
    pub threshold: String,
37 +
    /// Whether it crosses its threshold.
38 +
    pub failing: bool,
39 +
    /// An inline SVG of its values across runs, oldest first.
40 +
    pub spark: String,
41 +
}
42 +
43 +
/// A report on the Reports tab.
44 +
pub struct Item {
45 +
    pub report: Report,
46 +
    pub metrics: Vec<MetricView>,
47 +
}
48 +
49 +
#[derive(Template)]
50 +
#[template(path = "reports.html")]
51 +
pub struct ReportsPage {
52 +
    shell: Shell,
53 +
    repo: RepoShell,
54 +
    bookmark: String,
55 +
    items: Vec<Item>,
56 +
    coverage: bool,
57 +
    tests: bool,
58 +
}
59 +
60 +
#[derive(Template)]
61 +
#[template(path = "report.html")]
62 +
pub struct ReportPage {
63 +
    shell: Shell,
64 +
    repo: RepoShell,
65 +
    report: Report,
66 +
    bookmarks: Vec<String>,
67 +
    metrics: Vec<MetricView>,
68 +
    context: Vec<(String, String)>,
69 +
    /// Newest first, the one shown among them.
70 +
    runs: Vec<Report>,
71 +
    /// Where the frame loads the report's page from, when it has one.
72 +
    frame: Option<String>,
73 +
    files_base: String,
74 +
}
75 +
76 +
/// A number as a person would write it: no trailing zeros.
77 +
fn num(v: f64) -> String {
78 +
    if v.fract() == 0.0 && v.abs() < 1e15 {
79 +
        format!("{v:.0}")
80 +
    } else {
81 +
        let s = format!("{v:.3}");
82 +
        s.trim_end_matches('0').trim_end_matches('.').to_string()
83 +
    }
84 +
}
85 +
86 +
fn spark(values: &[f64]) -> String {
87 +
    if values.len() < 2 {
88 +
        return String::new();
89 +
    }
90 +
    let (w, h) = (120.0, 28.0);
91 +
    let lo = values.iter().cloned().fold(f64::INFINITY, f64::min);
92 +
    let hi = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
93 +
    let span = if hi > lo { hi - lo } else { 1.0 };
94 +
    let n = values.len();
95 +
    let pts: Vec<String> = values
96 +
        .iter()
97 +
        .enumerate()
98 +
        .map(|(i, v)| format!("{:.1},{:.1}", w * i as f64 / (n - 1) as f64, 2.0 + (h - 4.0) * (hi - v) / span))
99 +
        .collect();
100 +
    let last = pts.last().cloned().unwrap_or_default();
101 +
    let (lx, ly) = last.split_once(',').unwrap_or(("0", "0"));
102 +
    format!(
103 +
        r#"<svg class="rep-spark" viewBox="0 0 {w} {h}" width="{w}" height="{h}" aria-hidden="true"><polyline points="{}"/><circle cx="{lx}" cy="{ly}" r="2.5"/></svg>"#,
104 +
        pts.join(" ")
105 +
    )
106 +
}
107 +
108 +
/// The metrics of `now` against `before`, with each one's values across
109 +
/// `series` (oldest first) for a sparkline.
110 +
fn views(now: &[Metric], before: &[Metric], series: &[Vec<Metric>]) -> Vec<MetricView> {
111 +
    now.iter()
112 +
        .map(|m| {
113 +
            let prev = before.iter().find(|b| b.key == m.key).map(|b| b.value);
114 +
            let (delta, trend) = match prev {
115 +
                Some(p) if (m.value - p).abs() > f64::EPSILON => {
116 +
                    let up = m.value > p;
117 +
                    let trend = match m.better.as_str() {
118 +
                        "higher" if up => "better",
119 +
                        "higher" => "worse",
120 +
                        "lower" if up => "worse",
121 +
                        "lower" => "better",
122 +
                        _ => "same",
123 +
                    };
124 +
                    (format!("{}{}", if up { "+" } else { "" }, num(m.value - p)), trend)
125 +
                }
126 +
                _ => (String::new(), "same"),
127 +
            };
128 +
            let mut threshold = Vec::new();
129 +
            if let Some(a) = m.fail_above {
130 +
                threshold.push(format!("fails above {}", num(a)));
131 +
            }
132 +
            if let Some(b) = m.fail_below {
133 +
                threshold.push(format!("fails below {}", num(b)));
134 +
            }
135 +
            let failing = m.fail_above.is_some_and(|a| m.value > a) || m.fail_below.is_some_and(|b| m.value < b);
136 +
            let values: Vec<f64> = series.iter().filter_map(|ms| ms.iter().find(|x| x.key == m.key).map(|x| x.value)).collect();
137 +
            MetricView {
138 +
                key: m.key.clone(),
139 +
                value: num(m.value),
140 +
                unit: m.unit.clone(),
141 +
                delta,
142 +
                trend,
143 +
                threshold: threshold.join(", "),
144 +
                failing,
145 +
                spark: spark(&values),
146 +
            }
147 +
        })
148 +
        .collect()
149 +
}
150 +
151 +
/// `GET /{owner}/{name}/reports`: the newest of each report, with its
152 +
/// metrics against the run before on its bookmark.
153 +
pub async fn overview(
154 +
    State(app): State<Shared>,
155 +
    viewer: Viewer,
156 +
    Path((owner, name)): Path<(String, String)>,
157 +
    Query(q): Query<ReportsQuery>,
158 +
) -> Result<Page<ReportsPage>, Error> {
159 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/reports"), &viewer);
160 +
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
161 +
    let e = |e| Error::from_db(shell.clone(), e);
162 +
    let bookmark = q.bookmark.clone().filter(|b| !b.is_empty());
163 +
    let latest = reports::latest(&app.db, row.id, bookmark.as_deref()).await.map_err(e)?;
164 +
    let mut items = Vec::new();
165 +
    for r in latest {
166 +
        let history = reports::history(&app.db, row.id, &r.name, &r.bookmark, 2).await.map_err(e)?;
167 +
        let now = reports::metrics(&app.db, r.id).await.map_err(e)?;
168 +
        let before = match history.get(1) {
169 +
            Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?,
170 +
            None => vec![],
171 +
        };
172 +
        items.push(Item { metrics: views(&now, &before, &[]), report: r });
173 +
    }
174 +
    let repo = RepoShell::new(&app, &row, "reports").with_role(role);
175 +
    Ok(Page(ReportsPage { shell, coverage: repo.coverage, tests: repo.tests, repo, bookmark: bookmark.unwrap_or_default(), items }))
176 +
}
177 +
178 +
/// `GET /{owner}/{name}/reports/{report}`: one report, the newest on its
179 +
/// bookmark unless `?id=` names a run, with its metrics across runs and
180 +
/// its page in a sandboxed frame.
181 +
pub async fn show(
182 +
    State(app): State<Shared>,
183 +
    viewer: Viewer,
184 +
    Path((owner, name, rname)): Path<(String, String, String)>,
185 +
    Query(q): Query<ReportsQuery>,
186 +
) -> Result<Page<ReportPage>, Error> {
187 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/reports/{rname}"), &viewer);
188 +
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
189 +
    let e = |e| Error::from_db(shell.clone(), e);
190 +
    let report = match q.id {
191 +
        Some(id) => reports::get(&app.db, row.id, id).await.map_err(e)?.filter(|r| r.name == rname),
192 +
        None => {
193 +
            let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?;
194 +
            let bookmark = q
195 +
                .bookmark
196 +
                .clone()
197 +
                .or_else(|| bookmarks.iter().find(|b| **b == row.default_bookmark).cloned())
198 +
                .or_else(|| bookmarks.first().cloned());
199 +
            match bookmark {
200 +
                Some(b) => reports::history(&app.db, row.id, &rname, &b, 1).await.map_err(e)?.into_iter().next(),
201 +
                None => None,
202 +
            }
203 +
        }
204 +
    };
205 +
    let report = report.ok_or_else(|| Error::not_found(shell.clone()))?;
206 +
    let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?;
207 +
    let runs = reports::history(&app.db, row.id, &rname, &report.bookmark, HISTORY).await.map_err(e)?;
208 +
    let now = reports::metrics(&app.db, report.id).await.map_err(e)?;
209 +
    // The run before the one shown, and every run up to it for the lines.
210 +
    let at = runs.iter().position(|r| r.id == report.id).unwrap_or(0);
211 +
    let before = match runs.get(at + 1) {
212 +
        Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?,
213 +
        None => vec![],
214 +
    };
215 +
    let mut series = Vec::new();
216 +
    for r in runs.iter().skip(at).rev() {
217 +
        series.push(reports::metrics(&app.db, r.id).await.map_err(e)?);
218 +
    }
219 +
    let files_base = format!("/{owner}/{name}/reports/files/{}/{}", report.id, reports::file_mac(&app, report.id));
220 +
    let frame = reports::file(&app.db, report.id, "index.html").await.map_err(e)?.map(|_| format!("{files_base}/index.html"));
221 +
    let repo = RepoShell::new(&app, &row, "reports").with_role(role);
222 +
    Ok(Page(ReportPage {
223 +
        shell,
224 +
        repo,
225 +
        context: report.context_rows(),
226 +
        metrics: views(&now, &before, &series),
227 +
        bookmarks,
228 +
        runs,
229 +
        frame,
230 +
        files_base,
231 +
        report,
232 +
    }))
233 +
}
234 +
235 +
/// A file's type, by its extension.
236 +
fn content_type(path: &str) -> &'static str {
237 +
    match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
238 +
        "html" | "htm" => "text/html; charset=utf-8",
239 +
        "js" | "mjs" => "text/javascript; charset=utf-8",
240 +
        "css" => "text/css; charset=utf-8",
241 +
        "json" => "application/json",
242 +
        "csv" => "text/csv; charset=utf-8",
243 +
        "svg" => "image/svg+xml",
244 +
        "png" => "image/png",
245 +
        "jpg" | "jpeg" => "image/jpeg",
246 +
        "gif" => "image/gif",
247 +
        "webp" => "image/webp",
248 +
        "woff2" => "font/woff2",
249 +
        "woff" => "font/woff",
250 +
        "txt" | "toml" | "md" | "info" | "xml" => "text/plain; charset=utf-8",
251 +
        _ => "application/octet-stream",
252 +
    }
253 +
}
254 +
255 +
/// `GET /{owner}/{name}/reports/files/{id}/{mac}/{*path}`: a report's file,
256 +
/// for its frame. The frame is sandboxed (scripts, no same origin), so it
257 +
/// carries no session: the MAC in the link is what lets it in. Every file
258 +
/// is served sandboxed too, with nothing beyond this hub to load or call,
259 +
/// and readable across origins, since the frame's own origin is opaque.
260 +
pub async fn file(State(app): State<Shared>, Path((owner, name, id, mac, path)): Path<(String, String, i64, String, String)>) -> Response {
261 +
    let not_found = || (StatusCode::NOT_FOUND, "no such report file").into_response();
262 +
    if !crate::auth::mac_equal(&mac, &reports::file_mac(&app, id)) {
263 +
        return not_found();
264 +
    }
265 +
    let Ok(Some(row)) = crate::db::repos::get(&app.db, &owner, &name).await else { return not_found() };
266 +
    let Ok(Some(_)) = reports::get(&app.db, row.id, id).await else { return not_found() };
267 +
    let Ok(Some((blob, _))) = reports::file(&app.db, id, &path).await else { return not_found() };
268 +
    let (Some(repo), Some(oref)) = (crate::repos::RepoName::new(&row.owner, &row.name), oxy_core::hash::ObjectRef::from_hex(&blob)) else {
269 +
        return not_found();
270 +
    };
271 +
    let bytes = match app.repos.read(&repo, move |r, _| Ok(r.read_blob(&oref)?)).await {
272 +
        Ok(b) => b,
273 +
        Err(_) => return not_found(),
274 +
    };
275 +
    let origin = app.config.base_url().trim_end_matches('/').to_string();
276 +
    let csp = format!(
277 +
        "sandbox allow-scripts; default-src 'none'; script-src 'self' {origin} 'unsafe-inline' 'unsafe-eval'; \
278 +
         style-src 'self' {origin} 'unsafe-inline'; img-src 'self' {origin} data: blob:; font-src 'self' {origin} data:; \
279 +
         connect-src 'self' {origin}; frame-ancestors 'self' {origin}; base-uri 'none'; form-action 'none'"
280 +
    );
281 +
    let mut res = (StatusCode::OK, bytes).into_response();
282 +
    let h = res.headers_mut();
283 +
    h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type(&path)));
284 +
    if let Ok(v) = HeaderValue::from_str(&csp) {
285 +
        h.insert(header::CONTENT_SECURITY_POLICY, v);
286 +
    }
287 +
    h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
288 +
    h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
289 +
    h.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
290 +
    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, max-age=3600, immutable"));
291 +
    res
292 +
}
293 +
294 +
#[cfg(test)]
295 +
mod tests {
296 +
    use super::*;
297 +
298 +
    #[test]
299 +
    fn metrics_say_which_way_they_moved() {
300 +
        let m = |v: f64, better: &str, above: Option<f64>| Metric {
301 +
            key: "frame".into(),
302 +
            value: v,
303 +
            unit: "ms".into(),
304 +
            better: better.into(),
305 +
            fail_above: above,
306 +
            fail_below: None,
307 +
        };
308 +
        let v = &views(&[m(18.0, "lower", Some(20.0))], &[m(16.5, "lower", Some(20.0))], &[])[0];
309 +
        assert_eq!((v.value.as_str(), v.delta.as_str(), v.trend, v.failing), ("18", "+1.5", "worse", false));
310 +
        assert_eq!(v.threshold, "fails above 20");
311 +
        let v = &views(&[m(21.0, "lower", Some(20.0))], &[], &[])[0];
312 +
        assert!(v.failing && v.delta.is_empty());
313 +
        assert_eq!(num(16.40), "16.4");
314 +
        assert!(spark(&[1.0, 3.0, 2.0]).contains("polyline"));
315 +
        assert!(spark(&[1.0]).is_empty());
316 +
    }
317 +
}
added templates/report.html +60 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}{{ report.title }} · Reports · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block tabs %}{% call m::tabs(repo, "reports") %}{% endblock %}
5 +
{% block content %}
6 +
<section class="cov-hero">
7 +
  <div class="cov-hero-head">
8 +
    <div>
9 +
      <div class="small"><a href="{{ repo.url }}/reports">Reports</a> /</div>
10 +
      <h1>{{ report.title }} <span class="tag rep-kind rep-{{ report.kind }}">{{ report.kind }}</span></h1>
11 +
      <div class="cov-sub">{% if bookmarks.len() > 1 %}<form class="inline" method="get" action="{{ repo.url }}/reports/{{ report.name }}"><label for="rep-bm" class="mute">bookmark</label> <select id="rep-bm" name="bookmark" onchange="this.form.submit()">{% for b in bookmarks %}<option value="{{ b }}"{% if b.as_str() == report.bookmark.as_str() %} selected{% endif %}>{{ b }}</option>{% endfor %}</select> <noscript><button>Show</button></noscript></form>{% else %}<span class="mute">on</span> <strong>{{ report.bookmark }}</strong>{% endif %}
12 +
        {% if let Some(n) = report.run_number %}· <a href="{{ repo.url }}/flow/{{ n }}">run #{{ n }}</a>{% endif %} · job {{ report.job }} · <time title="{{ report.created_ms|when_i }}">{{ report.created_ms|ago_i }}</time> · {{ report.files }} files, {{ report.bytes|bytes }} · <a href="{{ files_base }}/report.toml" target="_blank" rel="noopener">report.toml</a></div>
13 +
      {% if !report.summary.is_empty() %}<p class="rep-summary">{{ report.summary }}</p>{% endif %}
14 +
    </div>
15 +
  </div>
16 +
</section>
17 +
18 +
{% if !metrics.is_empty() %}
19 +
<section class="panel">
20 +
  <table class="rep-table">
21 +
    <thead><tr><th>Metric</th><th class="num">Value</th><th class="num">Change</th><th>Across runs</th><th>Threshold</th></tr></thead>
22 +
    <tbody>
23 +
    {% for v in metrics %}
24 +
      <tr class="{% if v.failing %}rep-failing{% endif %}">
25 +
        <td><code>{{ v.key }}</code></td>
26 +
        <td class="num"><strong>{{ v.value }}</strong>{% if !v.unit.is_empty() %} <small class="mute">{{ v.unit }}</small>{% endif %}</td>
27 +
        <td class="num">{% if !v.delta.is_empty() %}<span class="rep-delta {{ v.trend }}">{{ v.delta }}</span>{% else %}<span class="mute">–</span>{% endif %}</td>
28 +
        <td>{{ v.spark|safe }}</td>
29 +
        <td class="small">{% if v.failing %}<span class="error">{{ v.threshold }}: failing</span>{% else %}<span class="mute">{{ v.threshold }}</span>{% endif %}</td>
30 +
      </tr>
31 +
    {% endfor %}
32 +
    </tbody>
33 +
  </table>
34 +
</section>
35 +
{% endif %}
36 +
37 +
{% if let Some(src) = frame %}
38 +
<section class="rep-frame-wrap">
39 +
  <iframe class="rep-frame" src="{{ src }}" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" title="{{ report.title }}"></iframe>
40 +
  <p class="small mute">The report's own page, in a sandbox: its scripts run with no access to your session or this hub. <a href="{{ src }}" target="_blank" rel="noopener">Open it alone</a>.</p>
41 +
</section>
42 +
{% endif %}
43 +
44 +
<div class="cov-grid">
45 +
  {% if !context.is_empty() %}
46 +
  <section class="panel cov-card">
47 +
    <h2>Context</h2>
48 +
    <dl class="rep-context">{% for (k, v) in context %}<dt>{{ k }}</dt><dd>{{ v }}</dd>{% endfor %}</dl>
49 +
  </section>
50 +
  {% endif %}
51 +
  <section class="panel cov-card">
52 +
    <h2>Runs on {{ report.bookmark }}</h2>
53 +
    <ul class="rep-runs">
54 +
    {% for r in runs.iter().take(15) %}
55 +
      <li{% if r.id == report.id %} class="on"{% endif %}><a href="{{ repo.url }}/reports/{{ report.name }}?id={{ r.id }}">{% if let Some(n) = r.run_number %}run #{{ n }}{% else %}upload{% endif %}</a> <span class="mute small"><time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time>{% if !r.summary.is_empty() %} · {{ r.summary }}{% endif %}</span></li>
56 +
    {% endfor %}
57 +
    </ul>
58 +
  </section>
59 +
</div>
60 +
{% endblock %}
added templates/reports.html +34 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Reports · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block tabs %}{% call m::tabs(repo, "reports") %}{% endblock %}
5 +
{% block content %}
6 +
<h1>Reports</h1>
7 +
<p class="mute small">What this repository's Flow jobs measure, newest run first{% if !bookmark.is_empty() %}, on <strong>{{ bookmark }}</strong> (<a href="{{ repo.url }}/reports">all bookmarks</a>){% endif %}. Each metric shows its change against the run before on the same bookmark.</p>
8 +
<div class="rep-list">
9 +
{% for i in items %}
10 +
  <a class="panel rep-card" href="{{ repo.url }}/reports/{{ i.report.name }}?id={{ i.report.id }}">
11 +
    <div class="rep-head">
12 +
      <span class="tag rep-kind rep-{{ i.report.kind }}">{{ i.report.kind }}</span>
13 +
      <strong class="rep-title">{{ i.report.title }}</strong>
14 +
    </div>
15 +
    {% if !i.report.summary.is_empty() %}<p class="rep-summary">{{ i.report.summary }}</p>{% endif %}
16 +
    {% if !i.metrics.is_empty() %}<ul class="rep-metrics">{% for v in i.metrics %}<li class="{% if v.failing %}rep-failing{% endif %}"><span class="k">{{ v.key }}</span> <span class="v">{{ v.value }}{% if !v.unit.is_empty() %} <small>{{ v.unit }}</small>{% endif %}</span>{% if !v.delta.is_empty() %} <span class="rep-delta {{ v.trend }}">{{ v.delta }}</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
17 +
    <div class="small mute">{% if !i.report.bookmark.is_empty() %}on {{ i.report.bookmark }} · {% endif %}{% if let Some(n) = i.report.run_number %}run #{{ n }} · {% endif %}job {{ i.report.job }} · <time title="{{ i.report.created_ms|when_i }}">{{ i.report.created_ms|ago_i }}</time></div>
18 +
  </a>
19 +
{% endfor %}
20 +
{% if coverage %}
21 +
  <a class="panel rep-card" href="{{ repo.url }}/coverage">
22 +
    <div class="rep-head"><span class="tag rep-kind rep-coverage">coverage</span> <strong class="rep-title">Coverage</strong></div>
23 +
    <p class="rep-summary">Line coverage from the jobs' <code>coverage</code> artifacts, by file and across runs.</p>
24 +
  </a>
25 +
{% endif %}
26 +
{% if tests %}
27 +
  <a class="panel rep-card" href="{{ repo.url }}/tests">
28 +
    <div class="rep-head"><span class="tag rep-kind rep-tests">tests</span> <strong class="rep-title">Tests</strong></div>
29 +
    <p class="rep-summary">Test results from the jobs' <code>junit</code> artifacts, each case and its history.</p>
30 +
  </a>
31 +
{% endif %}
32 +
</div>
33 +
{% if items.is_empty() && !coverage && !tests %}<p class="empty">No reports yet. A Flow job keeps one with <code>report "NAME" { path "/src/out/report" }</code> in its output; see the OxygenFlow wiki's Reports page.</p>{% endif %}
34 +
{% endblock %}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Reports (#127).** A Flow job's `report "NAME" { path … }` output is kept file by file with every run, and a repository with reports gets one **Reports** tab in place of Coverage and Tests: each report's latest run with its kind, summary and metrics, and how each moved against the run before. A report's page shows its metrics with sparklines across runs, its thresholds, its `[context]`, its runs, and its own page (`index.html`) in a sandboxed frame that carries no session. Each metric with `fail_above` or `fail_below` sets a check named `report/NAME/METRIC` on the change.
8 +
7 9
- **Flow's artifacts survive the nightly gc (#126).** They are named only in the hub's database, so garbage collection found them unreachable and would have deleted each one 14 days after its run: coverage files, test reports and dist files alike. The nightly gc now keeps every artifact a repository's runs name.
8 10
9 11
- **Transfer a project (#125).** A board's Settings move it to another owner you may make boards under, such as one of your organisations; so do the API (`owner` on `PATCH /projects/{owner}/{n}`) and `oh project transfer N --to OWNER`. It takes the next number there, with its columns, cards and privacy, and its old address redirects to it.
…
151 unchanged lines
modified crates/hub/src/flow.rs +44 −1
…
969 unchanged lines
970 970
        };
971 971
        let said = match declaration {
972 972
            Ok(d) => match crate::reports::keep(&app, &origin, &a.name, &d, &manifest, &a.blob).await {
973 -
                Ok(_) => {
973 +
                Ok(id) => {
974 +
                    report_threshold_checks(&app, &row, &run, &a.name, id, &d.metrics).await;
974 975
                    let s = if d.metrics.len() == 1 { "" } else { "s" };
975 976
                    format!("report {}: {} \"{}\", {} metric{s}", a.name, d.kind, d.title, d.metrics.len())
976 977
                }
…
28 unchanged lines
1005 1006
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
1006 1007
}
1007 1008
1009 +
/// A check per metric with a threshold (#127, docs/reports.md §5), named
1010 +
/// `report/NAME/METRIC` on the run's state: failed when the value crosses
1011 +
/// `fail_above` or `fail_below`, which a proposal may require.
1012 +
async fn report_threshold_checks(
1013 +
    app: &Shared,
1014 +
    row: &db::repos::RepoRow,
1015 +
    run: &fdb::Run,
1016 +
    name: &str,
1017 +
    id: i64,
1018 +
    metrics: &[crate::reports::Metric],
1019 +
) {
1020 +
    let url = format!("{}/{}/{}/reports/{name}?id={id}", app.config.base_url(), row.owner, row.name);
1021 +
    for m in metrics.iter().filter(|m| m.fail_above.is_some() || m.fail_below.is_some()) {
1022 +
        let unit = if m.unit.is_empty() { String::new() } else { format!(" {}", m.unit) };
1023 +
        let above = m.fail_above.filter(|a| m.value > *a);
1024 +
        let below = m.fail_below.filter(|b| m.value < *b);
1025 +
        let (status, summary) = match (above, below) {
1026 +
            (Some(a), _) => ("failed", format!("{}{unit}, above {a}", m.value)),
1027 +
            (_, Some(b)) => ("failed", format!("{}{unit}, below {b}", m.value)),
1028 +
            _ => {
1029 +
                let limits: Vec<String> = [m.fail_above.map(|a| format!("at most {a}")), m.fail_below.map(|b| format!("at least {b}"))]
1030 +
                    .into_iter()
1031 +
                    .flatten()
1032 +
                    .collect();
1033 +
                ("passed", format!("{}{unit} ({})", m.value, limits.join(", ")))
1034 +
            }
1035 +
        };
1036 +
        let check = format!("report/{name}/{}", m.key);
1037 +
        if let Ok(c) = db::hooks::upsert_check(
1038 +
            &app.db,
1039 +
            row.id,
1040 +
            db::hooks::CheckReport { state: &run.state, name: &check, status, summary: &summary, url: &url, reporter: None, seal: "" },
1041 +
        )
1042 +
        .await
1043 +
        {
1044 +
            let api = crate::api::check_of(&c);
1045 +
            app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1046 +
            crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1047 +
        }
1048 +
    }
1049 +
}
1050 +
1008 1051
/// Check report manifest `body` (#127) and that the repository holds every
1009 1052
/// file it names; answers it with the report's declaration, or why that
1010 1053
/// could not be read.
…
642 unchanged lines
modified crates/hub/src/lib.rs +1 −0
…
117 unchanged lines
118 118
    storage::load(&app).await?;
119 119
    coverage::load(&app).await?;
120 120
    junit::load(&app).await?;
121 +
    reports::load(&app).await?;
121 122
    tokio::spawn(hooks::worker(app.clone()));
122 123
    tokio::spawn(flow::reaper(app.clone()));
123 124
    tokio::spawn(flow::scheduler(app.clone()));
…
46 unchanged lines
modified crates/hub/src/reports.rs +114 −0
…
299 unchanged lines
300 300
            .await?;
301 301
    }
302 302
    tx.commit().await?;
303 +
    mark(app, o.repo_id);
303 304
    Ok(id)
304 305
}
305 306
307 +
static HAS: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
308 +
309 +
/// Per hub (tests run several in one process) and repository.
310 +
fn key(app: &Shared, repo_id: i64) -> String {
311 +
    format!("{}#{repo_id}", app.repos.root().display())
312 +
}
313 +
314 +
/// Whether a repository has reports, for the Reports tab.
315 +
pub fn has_reports(app: &Shared, repo_id: i64) -> bool {
316 +
    HAS.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id)))
317 +
}
318 +
319 +
fn mark(app: &Shared, repo_id: i64) {
320 +
    HAS.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id));
321 +
}
322 +
323 +
/// At start: which repositories have reports.
324 +
pub async fn load(app: &Shared) -> sqlx::Result<()> {
325 +
    let ids: Vec<i64> = sqlx::query_scalar("SELECT DISTINCT repo_id FROM reports").fetch_all(&app.db).await?;
326 +
    for id in ids {
327 +
        mark(app, id);
328 +
    }
329 +
    Ok(())
330 +
}
331 +
332 +
/// A kept report (one job's, or one upload's).
333 +
#[derive(Clone, Debug, sqlx::FromRow)]
334 +
pub struct Report {
335 +
    pub id: i64,
336 +
    pub repo_id: i64,
337 +
    pub name: String,
338 +
    pub kind: String,
339 +
    pub title: String,
340 +
    pub summary: String,
341 +
    /// The `[context]` table as JSON.
342 +
    pub context: String,
343 +
    pub run_id: Option<i64>,
344 +
    pub job_id: Option<i64>,
345 +
    pub run_number: Option<i64>,
346 +
    pub job: String,
347 +
    pub bookmark: String,
348 +
    pub state: String,
349 +
    pub manifest: Option<String>,
350 +
    pub files: i64,
351 +
    pub bytes: i64,
352 +
    pub created_ms: i64,
353 +
}
354 +
355 +
impl Report {
356 +
    /// The `[context]` table's entries, as text, in order.
357 +
    pub fn context_rows(&self) -> Vec<(String, String)> {
358 +
        let v: serde_json::Value = serde_json::from_str(&self.context).unwrap_or_default();
359 +
        v.as_object()
360 +
            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.as_str().map(String::from).unwrap_or_else(|| v.to_string()))).collect())
361 +
            .unwrap_or_default()
362 +
    }
363 +
}
364 +
365 +
/// The newest report of each name in a repository, on `bookmark` when
366 +
/// given (else on any), newest first.
367 +
pub async fn latest(pool: &Pool, repo_id: i64, bookmark: Option<&str>) -> sqlx::Result<Vec<Report>> {
368 +
    sqlx::query_as(
369 +
        "SELECT * FROM reports r WHERE repo_id = ? AND (? IS NULL OR bookmark = ?) \
370 +
         AND id = (SELECT id FROM reports s WHERE s.repo_id = r.repo_id AND s.name = r.name AND (? IS NULL OR s.bookmark = ?) \
371 +
                   ORDER BY created_ms DESC, id DESC LIMIT 1) \
372 +
         ORDER BY created_ms DESC",
373 +
    )
374 +
    .bind(repo_id)
375 +
    .bind(bookmark)
376 +
    .bind(bookmark)
377 +
    .bind(bookmark)
378 +
    .bind(bookmark)
379 +
    .fetch_all(pool)
380 +
    .await
381 +
}
382 +
383 +
/// Report `name`'s history on `bookmark`, newest first.
384 +
pub async fn history(pool: &Pool, repo_id: i64, name: &str, bookmark: &str, limit: i64) -> sqlx::Result<Vec<Report>> {
385 +
    sqlx::query_as("SELECT * FROM reports WHERE repo_id = ? AND name = ? AND bookmark = ? ORDER BY created_ms DESC, id DESC LIMIT ?")
386 +
        .bind(repo_id)
387 +
        .bind(name)
388 +
        .bind(bookmark)
389 +
        .bind(limit)
390 +
        .fetch_all(pool)
391 +
        .await
392 +
}
393 +
394 +
/// The bookmarks report `name` has runs on, most recent first.
395 +
pub async fn bookmarks_of(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<Vec<String>> {
396 +
    sqlx::query_scalar("SELECT bookmark FROM reports WHERE repo_id = ? AND name = ? GROUP BY bookmark ORDER BY MAX(created_ms) DESC")
397 +
        .bind(repo_id)
398 +
        .bind(name)
399 +
        .fetch_all(pool)
400 +
        .await
401 +
}
402 +
403 +
/// One report of a repository.
404 +
pub async fn get(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Report>> {
405 +
    sqlx::query_as("SELECT * FROM reports WHERE repo_id = ? AND id = ?").bind(repo_id).bind(id).fetch_optional(pool).await
406 +
}
407 +
408 +
/// A file of report `id`: its blob and size.
409 +
pub async fn file(pool: &Pool, id: i64, path: &str) -> sqlx::Result<Option<(String, i64)>> {
410 +
    sqlx::query_as("SELECT blob, size FROM report_files WHERE report_id = ? AND path = ?").bind(id).bind(path).fetch_optional(pool).await
411 +
}
412 +
413 +
/// The capability a report's files are served under: they are read from a
414 +
/// sandboxed frame, which carries no session, so the link itself is what
415 +
/// lets it in, and only readers of the repository are given it.
416 +
pub fn file_mac(app: &Shared, id: i64) -> String {
417 +
    app.cookie_key.mac(format!("report-files:{id}").as_bytes())[..32].to_string()
418 +
}
419 +
306 420
/// Report `id`'s metrics, by key.
307 421
pub async fn metrics(pool: &Pool, id: i64) -> sqlx::Result<Vec<Metric>> {
308 422
    sqlx::query_as("SELECT key, value, unit, better, fail_above, fail_below FROM report_metrics WHERE report_id = ? ORDER BY key")
…
74 unchanged lines
modified crates/hub/src/web/mod.rs +8 −0
…
26 unchanged lines
27 27
pub mod releases;
28 28
mod repo;
29 29
mod repo_settings;
30 +
pub mod reports;
30 31
mod resolve;
31 32
pub mod runners;
32 33
mod secrets;
…
185 unchanged lines
218 219
        .route("/{owner}/{name}/flow/badge.svg", get(flow::badge))
219 220
        .route("/{owner}/{name}/tests", get(test_reports::overview))
220 221
        .route("/{owner}/{name}/tests/case", get(test_reports::case))
222 +
        .route("/{owner}/{name}/reports", get(reports::overview))
223 +
        .route("/{owner}/{name}/reports/files/{id}/{mac}/{*path}", get(reports::file))
224 +
        .route("/{owner}/{name}/reports/{report}", get(reports::show))
221 225
        .route("/{owner}/{name}/coverage", get(coverage::overview))
222 226
        .route("/{owner}/{name}/coverage/badge.svg", get(coverage::badge))
223 227
        .route("/{owner}/{name}/coverage/{id}/file/{*path}", get(coverage::file))
…
196 unchanged lines
420 424
    pub coverage: bool,
421 425
    /// Whether it has test reports, for the Tests tab (#114).
422 426
    pub tests: bool,
427 +
    /// Whether it has reports of its own (#127); with coverage or tests,
428 +
    /// the Reports tab.
429 +
    pub reports: bool,
423 430
}
424 431
425 432
impl RepoShell {
…
15 unchanged lines
441 448
            stored_in: crate::storage::location(app, &row.owner, &row.name).and_then(|u| crate::storage::StoreBadge::of(&u)),
442 449
            coverage: crate::coverage::has_reports(app, row.id),
443 450
            tests: crate::junit::has_reports(app, row.id),
451 +
            reports: crate::reports::has_reports(app, row.id),
444 452
        }
445 453
    }
446 454
…
276 unchanged lines
modified crates/hub/tests/flow.rs +33 −4
…
990 unchanged lines
991 991
    let toml = b"kind = \"performance\"\ntitle = \"Frame timing\"\nsummary = \"Median 16.4 ms\"\n\n[metrics]\nmedian_ms = { value = 16.4, unit = \"ms\", better = \"lower\", fail_above = 20 }\n\n[context]\ntarget_fps = 30\n";
992 992
    let page = b"<p>frames</p>".to_vec();
993 993
    let data: Vec<u8> = (0..100_000u32).map(|i| (i % 241) as u8).collect();
994 +
    let (page_bytes, data_bytes) = (page.clone(), data.clone());
994 995
    let files: [(&str, &[u8]); 3] = [("data/frames.csv", &data), ("index.html", &page), ("report.toml", toml)];
995 996
996 997
    assert_eq!(upload_report_file(&h.app, id, "tool", "report.toml", toml).await.0, StatusCode::BAD_REQUEST, "not a report");
…
29 unchanged lines
1026 1027
    assert_eq!((m[0].key.as_str(), m[0].value, m[0].fail_above), ("median_ms", 16.4, Some(20.0)));
1027 1028
    let log = db::flow::log(&h.app.db, id, 0, 1000).await.unwrap();
1028 1029
    assert!(log.iter().any(|r| r.text.contains("report frame-timing: performance \"Frame timing\", 1 metric")), "{log:?}");
1030 +
1031 +
    // The threshold is a check on the run's state, passing at 16.4.
1032 +
    let checks = db::hooks::checks_for_state(&h.app.db, run.repo_id, &run.state).await.unwrap();
1033 +
    let c = checks.iter().find(|c| c.name == "report/frame-timing/median_ms").expect("a check per threshold");
1034 +
    assert_eq!(c.status, "passed", "{c:?}");
1035 +
    assert!(c.summary.contains("16.4 ms") && c.url.contains("/reports/frame-timing?id="), "{c:?}");
1036 +
1037 +
    // The Reports tab, the report's page, and its own page in a sandbox.
1038 +
    let (_, page) = call(&h.app, "GET", "/andy/demo", None, None).await;
1039 +
    assert!(page.contains(">Reports</a>"), "the tab");
1040 +
    let (st, page) = call(&h.app, "GET", "/andy/demo/reports", None, None).await;
1041 +
    assert!(st == StatusCode::OK && page.contains("Frame timing") && page.contains("median_ms"), "{st}");
1042 +
    let (st, page) = call(&h.app, "GET", "/andy/demo/reports/frame-timing", None, None).await;
1043 +
    assert!(st == StatusCode::OK && page.contains("fails above 20") && page.contains("sandbox=\"allow-scripts\""), "{st}");
1044 +
    let src = page.split("class=\"rep-frame\" src=\"").nth(1).unwrap().split('"').next().unwrap().to_string();
1045 +
    assert!(src.ends_with("/index.html"), "{src}");
1046 +
    let res = oxygenhub::router(h.app.clone()).oneshot(Request::get(&src).body(Body::empty()).unwrap()).await.unwrap();
1047 +
    assert_eq!(res.status(), StatusCode::OK);
1048 +
    let csp = res.headers()[header::CONTENT_SECURITY_POLICY].to_str().unwrap().to_string();
1049 +
    assert!(csp.starts_with("sandbox allow-scripts;") && csp.contains("default-src 'none'"), "{csp}");
1050 +
    assert_eq!(res.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*");
1051 +
    assert_eq!(res.into_body().collect().await.unwrap().to_bytes().as_ref(), page_bytes.as_slice());
1052 +
    let data = src.replace("/index.html", "/data/frames.csv");
1053 +
    assert_eq!(get_bytes(&h.app, &data).await, (StatusCode::OK, data_bytes.clone()));
1054 +
    // A link with another report's MAC, or a file it lacks, finds nothing.
1055 +
    let forged = src.replacen("/files/", "/files/9", 1);
1056 +
    assert_eq!(get_bytes(&h.app, &forged).await.0, StatusCode::NOT_FOUND);
1057 +
    assert_eq!(get_bytes(&h.app, &src.replace("/index.html", "/secret.txt")).await.0, StatusCode::NOT_FOUND);
1029 1058
    let _ = run;
1030 1059
1031 1060
    // gc keeps every file: only the database names them.
…
1266 unchanged lines
2298 2327
    c.commit("Code and coverage", &[]).unwrap();
2299 2328
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2300 2329
    let run = runs_of(&h.app, "andy", "cov").await.remove(0);
2301 -
    assert!(!call(&h.app, "GET", "/andy/cov", None, None).await.1.contains(">Coverage</a>"), "no tab before a report");
2330 +
    assert!(!call(&h.app, "GET", "/andy/cov", None, None).await.1.contains(">Reports</a>"), "no tab before a report");
2302 2331
    let id = claim(&h.app).await.unwrap()["job_id"].as_i64().unwrap();
2303 2332
2304 2333
    let (st, text) = upload(&h.app, id, "coverage", b"this is not a report").await;
…
12 unchanged lines
2317 2346
    let (st, page) = call(&h.app, "GET", "/andy/cov/coverage", None, None).await;
2318 2347
    assert_eq!(st, StatusCode::OK);
2319 2348
    assert!(
2320 -
        page.contains(">Coverage</a>")
2349 +
        page.contains(">Reports</a>")
2321 2350
            && page.contains("50.0%")
2322 2351
            && page.contains("<strong>2</strong> of <strong>4</strong> relevant lines covered"),
2323 2352
        "{page}"
…
51 unchanged lines
2375 2404
            "schema-version 1\nsandbox \"alpine\" {{\n    rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"test\" {{\n    sandbox \"alpine\"\n    exec \"make\" \"test\"\n    output {{\n        artifact \"junit\" {{\n            path \"/src/junit.xml\"\n        }}\n    }}\n}}\n"
2376 2405
        ),
2377 2406
    );
2378 -
    assert!(!call(&h.app, "GET", "/andy/t", None, None).await.1.contains(">Tests</a>"));
2407 +
    assert!(!call(&h.app, "GET", "/andy/t", None, None).await.1.contains(">Reports</a>"));
2379 2408
    let case = |name: &str, ok: bool, secs: &str| {
2380 2409
        if ok {
2381 2410
            format!(r#"<testcase classname="t.suite" name="{name}" time="{secs}"/>"#)
…
42 unchanged lines
2424 2453
    if let Some(out) = std::env::var_os("OXYHUB_DUMP_TESTS_PAGE") {
2425 2454
        std::fs::write(out, &page).unwrap();
2426 2455
    }
2427 -
    assert!(page.contains(">Tests</a>"), "the tab");
2456 +
    assert!(page.contains(">Reports</a>"), "the tab");
2428 2457
    assert!(page.contains("100%") && page.contains("of 4 tests passed"), "the latest run passes");
2429 2458
    assert!(page.contains("1 fixed") && page.contains("t.suite::b"), "b fixed against the run before");
2430 2459
    assert!(page.contains("1 ✕") && page.contains("2 ✓"), "b is flaky: {page}");
…
48 unchanged lines
modified docs/reports.md +11 −0
…
175 unchanged lines
176 176
177 177
## 8. Order of work
178 178
179 +
*Status, 2026-10-11:* storage and index (1) are deployed; the Reports tab,
180 +
each report's page with its metrics, changes and sparklines, its own page
181 +
in a sandboxed frame, and checks from thresholds (2) are built, Coverage
182 +
and Tests listed on the tab while their own pages stay. The frame is
183 +
sandboxed (`allow-scripts` only) rather than on a second origin: it
184 +
carries no session, so its files are served under a per-report MAC in the
185 +
link, with `Content-Security-Policy: sandbox allow-scripts` and nothing
186 +
beyond this hub to load. Retention (§3.3), the timeline (3) and `oh
187 +
report` (4) follow.
188 +
189 +
179 190
1. Storage and index: the `report` output kind in OxygenFlow, the runner's
180 191
   bundle upload, the hub's tree, tables and gc roots; coverage and junit
181 192
   read through it.
…
3 unchanged lines
modified templates/repo_tabs.html +1 −2
…
8 unchanged lines
9 9
    <a href="/projects/{{ repo.owner }}" title="{{ repo.owner }}'s project boards">Projects</a>
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 -
    {% if repo.tests %}<a href="{{ repo.url }}/tests"{% if tab == "tests" %} class="on" aria-current="page"{% endif %}>Tests</a>{% endif %}
13 -
    {% if repo.coverage %}<a href="{{ repo.url }}/coverage"{% if tab == "coverage" %} class="on" aria-current="page"{% endif %}>Coverage</a>{% endif %}
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 %}
14 13
    <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on" aria-current="page"{% endif %}>Bookmarks</a>
15 14
    <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on" aria-current="page"{% endif %}>Releases</a>
16 15
    <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on" aria-current="page"{% endif %}>Tags</a>
…
62 unchanged lines
modified templates/style.css +29 −0
…
907 unchanged lines
908 908
.proj-title .vis { margin-left: 2px; }
909 909
.proj-desc { color: var(--ink); font-size: 14px; }
910 910
.proj-list .empty { border: 0; margin: 0; }
911 +
912 +
/* Reports (oxygenhub#127) */
913 +
.rep-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(22em, 1fr)); gap: 1em; margin-top: 1em; }
914 +
.rep-card { display: flex; flex-direction: column; gap: .5em; color: var(--ink); text-decoration: none; padding: 1em; }
915 +
.rep-card:hover { border-color: var(--acc); }
916 +
.rep-head { display: flex; align-items: center; gap: .5em; }
917 +
.rep-title { font-size: 1.05em; }
918 +
.rep-kind { text-transform: lowercase; }
919 +
.rep-performance { color: var(--acc); } .rep-coverage { color: var(--ok); } .rep-tests { color: var(--warn); } .rep-custom { color: var(--mute); }
920 +
.rep-summary { margin: .2em 0; }
921 +
.rep-metrics { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: .4em 1.2em; }
922 +
.rep-metrics .k { color: var(--mute); font-size: .85em; }
923 +
.rep-metrics .v { font-weight: 600; font-variant-numeric: tabular-nums; }
924 +
.rep-delta { font-size: .85em; font-weight: 600; font-variant-numeric: tabular-nums; }
925 +
.rep-delta.better { color: var(--ok); } .rep-delta.worse { color: var(--bad); } .rep-delta.same { color: var(--mute); }
926 +
.rep-failing .v, .rep-failing strong { color: var(--bad); }
927 +
.rep-table { width: 100%; border-collapse: collapse; }
928 +
.rep-table th, .rep-table td { padding: .45em .6em; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
929 +
.rep-table .num { text-align: right; font-variant-numeric: tabular-nums; }
930 +
.rep-spark polyline { fill: none; stroke: var(--acc); stroke-width: 1.5; }
931 +
.rep-spark circle { fill: var(--acc); }
932 +
.rep-frame-wrap { margin: 1em 0; }
933 +
.rep-frame { width: 100%; height: 80vh; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
934 +
.rep-context { display: grid; grid-template-columns: max-content 1fr; gap: .3em 1em; margin: 0; }
935 +
.rep-context dt { color: var(--mute); }
936 +
.rep-context dd { margin: 0; font-variant-numeric: tabular-nums; }
937 +
.rep-runs { list-style: none; padding: 0; margin: 0; }
938 +
.rep-runs li { padding: .25em 0; }
939 +
.rep-runs li.on a { font-weight: 700; }