OXYGENHUB
# 44a3d2ebaddd oxygenhub / crates / hub / src / web / reports.rs
16.8 KiB · Rust · last changed Coverage from a workstation: oh coverage upload, shown as uploaded 24 days ago source blame history raw
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
    /// `good`, `bad` or `neutral`: its thresholds met, one crossed, or none.
48
    pub health: &'static str,
49
    /// What the badge says: "2 thresholds met", "1 over its threshold".
50
    pub badge: String,
51
}
52
53
/// The Coverage or Tests entry on the Reports tab: its latest figure.
54
pub struct Builtin {
55
    pub big: String,
56
    pub sub: String,
57
    pub health: &'static str,
58
    pub when_ms: i64,
59
    pub bookmark: String,
60
}
61
62
/// The health and badge of a report from its metrics' thresholds.
63
fn health_of(metrics: &[MetricView]) -> (&'static str, String) {
64
    let with = metrics.iter().filter(|m| !m.threshold.is_empty()).count();
65
    let failing = metrics.iter().filter(|m| m.failing).count();
66
    match (with, failing) {
67
        (0, _) => ("neutral", format!("{} metric{}", metrics.len(), if metrics.len() == 1 { "" } else { "s" })),
68
        (_, 0) => ("good", format!("{with} threshold{} met", if with == 1 { "" } else { "s" })),
69
        (_, f) => ("bad", format!("{f} over {}", if f == 1 { "its threshold" } else { "their thresholds" })),
70
    }
71
}
72
73
#[derive(Template)]
74
#[template(path = "reports.html")]
75
pub struct ReportsPage {
76
    shell: Shell,
77
    repo: RepoShell,
78
    bookmark: String,
79
    items: Vec<Item>,
80
    coverage: Option<Builtin>,
81
    tests: Option<Builtin>,
82
}
83
84
#[derive(Template)]
85
#[template(path = "report.html")]
86
pub struct ReportPage {
87
    shell: Shell,
88
    repo: RepoShell,
89
    report: Report,
90
    bookmarks: Vec<String>,
91
    metrics: Vec<MetricView>,
92
    context: Vec<(String, String)>,
93
    /// Newest first, the one shown among them.
94
    runs: Vec<Report>,
95
    /// Where the frame loads the report's page from, when it has one.
96
    frame: Option<String>,
97
    files_base: String,
98
}
99
100
/// A number as a person would write it: no trailing zeros.
101
fn num(v: f64) -> String {
102
    if v.fract() == 0.0 && v.abs() < 1e15 {
103
        format!("{v:.0}")
104
    } else {
105
        let s = format!("{v:.3}");
106
        s.trim_end_matches('0').trim_end_matches('.').to_string()
107
    }
108
}
109
110
fn spark(values: &[f64]) -> String {
111
    if values.len() < 2 {
112
        return String::new();
113
    }
114
    let (w, h) = (120.0, 28.0);
115
    let lo = values.iter().cloned().fold(f64::INFINITY, f64::min);
116
    let hi = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
117
    let span = if hi > lo { hi - lo } else { 1.0 };
118
    let n = values.len();
119
    let pts: Vec<String> = values
120
        .iter()
121
        .enumerate()
122
        .map(|(i, v)| format!("{:.1},{:.1}", w * i as f64 / (n - 1) as f64, 2.0 + (h - 4.0) * (hi - v) / span))
123
        .collect();
124
    let last = pts.last().cloned().unwrap_or_default();
125
    let (lx, ly) = last.split_once(',').unwrap_or(("0", "0"));
126
    format!(
127
        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>"#,
128
        pts.join(" ")
129
    )
130
}
131
132
/// The metrics of `now` against `before`, with each one's values across
133
/// `series` (oldest first) for a sparkline.
134
fn views(now: &[Metric], before: &[Metric], series: &[Vec<Metric>]) -> Vec<MetricView> {
135
    now.iter()
136
        .map(|m| {
137
            let prev = before.iter().find(|b| b.key == m.key).map(|b| b.value);
138
            let (delta, trend) = match prev {
139
                Some(p) if (m.value - p).abs() > f64::EPSILON => {
140
                    let up = m.value > p;
141
                    let trend = match m.better.as_str() {
142
                        "higher" if up => "better",
143
                        "higher" => "worse",
144
                        "lower" if up => "worse",
145
                        "lower" => "better",
146
                        _ => "same",
147
                    };
148
                    (format!("{}{}", if up { "+" } else { "" }, num(m.value - p)), trend)
149
                }
150
                _ => (String::new(), "same"),
151
            };
152
            let mut threshold = Vec::new();
153
            if let Some(a) = m.fail_above {
154
                threshold.push(format!("fails above {}", num(a)));
155
            }
156
            if let Some(b) = m.fail_below {
157
                threshold.push(format!("fails below {}", num(b)));
158
            }
159
            let failing = m.fail_above.is_some_and(|a| m.value > a) || m.fail_below.is_some_and(|b| m.value < b);
160
            let values: Vec<f64> = series.iter().filter_map(|ms| ms.iter().find(|x| x.key == m.key).map(|x| x.value)).collect();
161
            MetricView {
162
                key: m.key.clone(),
163
                value: num(m.value),
164
                unit: m.unit.clone(),
165
                delta,
166
                trend,
167
                threshold: threshold.join(", "),
168
                failing,
169
                spark: spark(&values),
170
            }
171
        })
172
        .collect()
173
}
174
175
/// `GET /{owner}/{name}/reports`: the newest of each report, with its
176
/// metrics against the run before on its bookmark.
177
pub async fn overview(
178
    State(app): State<Shared>,
179
    viewer: Viewer,
180
    Path((owner, name)): Path<(String, String)>,
181
    Query(q): Query<ReportsQuery>,
182
) -> Result<Page<ReportsPage>, Error> {
183
    let shell = Shell::new(&app, &format!("/{owner}/{name}/reports"), &viewer);
184
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
185
    let e = |e| Error::from_db(shell.clone(), e);
186
    let bookmark = q.bookmark.clone().filter(|b| !b.is_empty());
187
    let latest = reports::latest(&app.db, row.id, bookmark.as_deref()).await.map_err(e)?;
188
    let mut items = Vec::new();
189
    for r in latest {
190
        let history = reports::history(&app.db, row.id, &r.name, &r.bookmark, 2).await.map_err(e)?;
191
        let now = reports::metrics(&app.db, r.id).await.map_err(e)?;
192
        let before = match history.get(1) {
193
            Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?,
194
            None => vec![],
195
        };
196
        let metrics = views(&now, &before, &[]);
197
        let (health, badge) = health_of(&metrics);
198
        items.push(Item { metrics, report: r, health, badge });
199
    }
200
    let repo = RepoShell::new(&app, &row, "reports").with_role(role);
201
    // Coverage and Tests: their latest, on the default bookmark when it has
202
    // one, else wherever was newest.
203
    let pick = |list: Vec<String>| bookmark.clone().or_else(|| list.iter().find(|b| **b == row.default_bookmark).or(list.first()).cloned());
204
    let coverage = match pick(crate::coverage::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.coverage) {
205
        Some(b) => crate::coverage::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|c| Builtin {
206
            big: c.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()),
207
            sub: format!("{} of {} lines covered · {}", c.lines_hit, c.lines_found, c.source()),
208
            health: match c.percent() {
209
                Some(p) if p >= 75.0 => "good",
210
                Some(p) if p < 50.0 => "bad",
211
                _ => "neutral",
212
            },
213
            when_ms: c.created_ms,
214
            bookmark: c.bookmark.clone(),
215
        }),
216
        None => None,
217
    };
218
    let tests = match pick(crate::junit::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.tests) {
219
        Some(b) => crate::junit::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|t| Builtin {
220
            big: if t.broken() > 0 { format!("{} failing", t.broken()) } else { format!("{} passed", t.passed) },
221
            sub: t.summary(),
222
            health: if t.broken() > 0 { "bad" } else { "good" },
223
            when_ms: t.created_ms,
224
            bookmark: t.bookmark.clone(),
225
        }),
226
        None => None,
227
    };
228
    Ok(Page(ReportsPage { shell, coverage, tests, repo, bookmark: bookmark.unwrap_or_default(), items }))
229
}
230
231
/// `GET /{owner}/{name}/reports/{report}`: one report, the newest on its
232
/// bookmark unless `?id=` names a run, with its metrics across runs and
233
/// its page in a sandboxed frame.
234
pub async fn show(
235
    State(app): State<Shared>,
236
    viewer: Viewer,
237
    Path((owner, name, rname)): Path<(String, String, String)>,
238
    Query(q): Query<ReportsQuery>,
239
) -> Result<Page<ReportPage>, Error> {
240
    let shell = Shell::new(&app, &format!("/{owner}/{name}/reports/{rname}"), &viewer);
241
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
242
    let e = |e| Error::from_db(shell.clone(), e);
243
    let report = match q.id {
244
        Some(id) => reports::get(&app.db, row.id, id).await.map_err(e)?.filter(|r| r.name == rname),
245
        None => {
246
            let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?;
247
            let bookmark = q
248
                .bookmark
249
                .clone()
250
                .or_else(|| bookmarks.iter().find(|b| **b == row.default_bookmark).cloned())
251
                .or_else(|| bookmarks.first().cloned());
252
            match bookmark {
253
                Some(b) => reports::history(&app.db, row.id, &rname, &b, 1).await.map_err(e)?.into_iter().next(),
254
                None => None,
255
            }
256
        }
257
    };
258
    let report = report.ok_or_else(|| Error::not_found(shell.clone()))?;
259
    let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?;
260
    let runs = reports::history(&app.db, row.id, &rname, &report.bookmark, HISTORY).await.map_err(e)?;
261
    let now = reports::metrics(&app.db, report.id).await.map_err(e)?;
262
    // The run before the one shown, and every run up to it for the lines.
263
    let at = runs.iter().position(|r| r.id == report.id).unwrap_or(0);
264
    let before = match runs.get(at + 1) {
265
        Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?,
266
        None => vec![],
267
    };
268
    let mut series = Vec::new();
269
    for r in runs.iter().skip(at).rev() {
270
        series.push(reports::metrics(&app.db, r.id).await.map_err(e)?);
271
    }
272
    let files_base = format!("/{owner}/{name}/reports/files/{}/{}", report.id, reports::file_mac(&app, report.id));
273
    let frame = reports::file(&app.db, report.id, "index.html").await.map_err(e)?.map(|_| format!("{files_base}/index.html"));
274
    let repo = RepoShell::new(&app, &row, "reports").with_role(role);
275
    Ok(Page(ReportPage {
276
        shell,
277
        repo,
278
        context: report.context_rows(),
279
        metrics: views(&now, &before, &series),
280
        bookmarks,
281
        runs,
282
        frame,
283
        files_base,
284
        report,
285
    }))
286
}
287
288
/// `page` with the report frame script added (#127).
289
fn with_frame_script(page: Vec<u8>, version: &str) -> Vec<u8> {
290
    let tag = format!(r#"<script src="/static/report-frame.js?v={version}"></script>"#);
291
    let lower = page.to_ascii_lowercase();
292
    let find = |needle: &[u8]| lower.windows(needle.len()).position(|w| w == needle);
293
    let at = find(b"</head>").or_else(|| find(b"<body")).unwrap_or(page.len());
294
    let mut out = Vec::with_capacity(page.len() + tag.len());
295
    out.extend_from_slice(&page[..at]);
296
    out.extend_from_slice(tag.as_bytes());
297
    out.extend_from_slice(&page[at..]);
298
    out
299
}
300
301
/// A file's type, by its extension.
302
fn content_type(path: &str) -> &'static str {
303
    match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
304
        "html" | "htm" => "text/html; charset=utf-8",
305
        "js" | "mjs" => "text/javascript; charset=utf-8",
306
        "css" => "text/css; charset=utf-8",
307
        "json" => "application/json",
308
        "csv" => "text/csv; charset=utf-8",
309
        "svg" => "image/svg+xml",
310
        "png" => "image/png",
311
        "jpg" | "jpeg" => "image/jpeg",
312
        "gif" => "image/gif",
313
        "webp" => "image/webp",
314
        "woff2" => "font/woff2",
315
        "woff" => "font/woff",
316
        "txt" | "toml" | "md" | "info" | "xml" => "text/plain; charset=utf-8",
317
        _ => "application/octet-stream",
318
    }
319
}
320
321
/// `GET /{owner}/{name}/reports/files/{id}/{mac}/{*path}`: a report's file,
322
/// for its frame. The frame is sandboxed (scripts, no same origin), so it
323
/// carries no session: the MAC in the link is what lets it in. Every file
324
/// is served sandboxed too, with nothing beyond this hub to load or call,
325
/// and readable across origins, since the frame's own origin is opaque.
326
pub async fn file(State(app): State<Shared>, Path((owner, name, id, mac, path)): Path<(String, String, i64, String, String)>) -> Response {
327
    let not_found = || (StatusCode::NOT_FOUND, "no such report file").into_response();
328
    if !crate::auth::mac_equal(&mac, &reports::file_mac(&app, id)) {
329
        return not_found();
330
    }
331
    let Ok(Some(row)) = crate::db::repos::get(&app.db, &owner, &name).await else { return not_found() };
332
    let Ok(Some(_)) = reports::get(&app.db, row.id, id).await else { return not_found() };
333
    let Ok(Some((blob, _))) = reports::file(&app.db, id, &path).await else { return not_found() };
334
    let (Some(repo), Some(oref)) = (crate::repos::RepoName::new(&row.owner, &row.name), oxy_core::hash::ObjectRef::from_hex(&blob)) else {
335
        return not_found();
336
    };
337
    let bytes = match app.repos.read(&repo, move |r, _| Ok(r.read_blob(&oref)?)).await {
338
        Ok(b) => b,
339
        Err(_) => return not_found(),
340
    };
341
    // A page gets the frame script, which sizes the frame to it and passes
342
    // the hub's theme on: before </head>, else <body>, else at the end.
343
    let bytes =
344
        if content_type(&path).starts_with("text/html") { with_frame_script(bytes, super::statics::assets_version()) } else { bytes };
345
    let origin = app.config.base_url().trim_end_matches('/').to_string();
346
    let csp = format!(
347
        "sandbox allow-scripts; default-src 'none'; script-src 'self' {origin} 'unsafe-inline' 'unsafe-eval'; \
348
         style-src 'self' {origin} 'unsafe-inline'; img-src 'self' {origin} data: blob:; font-src 'self' {origin} data:; \
349
         connect-src 'self' {origin}; frame-ancestors 'self' {origin}; base-uri 'none'; form-action 'none'"
350
    );
351
    let mut res = (StatusCode::OK, bytes).into_response();
352
    let h = res.headers_mut();
353
    h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type(&path)));
354
    if let Ok(v) = HeaderValue::from_str(&csp) {
355
        h.insert(header::CONTENT_SECURITY_POLICY, v);
356
    }
357
    h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
358
    h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
359
    h.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
360
    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, max-age=3600, immutable"));
361
    res
362
}
363
364
#[cfg(test)]
365
mod tests {
366
    use super::*;
367
368
    #[test]
369
    fn metrics_say_which_way_they_moved() {
370
        let m = |v: f64, better: &str, above: Option<f64>| Metric {
371
            key: "frame".into(),
372
            value: v,
373
            unit: "ms".into(),
374
            better: better.into(),
375
            fail_above: above,
376
            fail_below: None,
377
        };
378
        let v = &views(&[m(18.0, "lower", Some(20.0))], &[m(16.5, "lower", Some(20.0))], &[])[0];
379
        assert_eq!((v.value.as_str(), v.delta.as_str(), v.trend, v.failing), ("18", "+1.5", "worse", false));
380
        assert_eq!(v.threshold, "fails above 20");
381
        let v = &views(&[m(21.0, "lower", Some(20.0))], &[], &[])[0];
382
        assert!(v.failing && v.delta.is_empty());
383
        assert_eq!(num(16.40), "16.4");
384
        assert!(spark(&[1.0, 3.0, 2.0]).contains("polyline"));
385
        assert!(spark(&[1.0]).is_empty());
386
        let page = with_frame_script(b"<!doctype html><html><HEAD><title>t</title></HEAD><body>x</body></html>".to_vec(), "v1");
387
        let page = String::from_utf8(page).unwrap();
388
        assert!(page.starts_with("<!doctype html>"), "the doctype stays first");
389
        assert!(page.contains(r#"<script src="/static/report-frame.js?v=v1"></script></HEAD>"#), "{page}");
390
        let bare = String::from_utf8(with_frame_script(b"<p>hi</p>".to_vec(), "v1")).unwrap();
391
        assert!(bare.ends_with("</script>"), "{bare}");
392
    }
393
}