//! Reports pages (oxygenhub#127, docs/reports.md): the Reports tab, a //! report's page with its metrics across runs and its own page in a //! sandboxed frame, and the files that frame reads. use super::{filters, lookup, Error, Page, RepoShell, Shell}; use crate::auth::Viewer; use crate::reports::{self, Metric, Report}; use crate::Shared; use askama::Template; use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use serde::Deserialize; /// Runs a report's page reads for its sparklines and run list. const HISTORY: i64 = 60; #[derive(Deserialize, Default)] pub struct ReportsQuery { /// Only this bookmark's reports. bookmark: Option, /// One report, by id. id: Option, /// Another run of the same report to compare with, by id (#127, RP4). compare: Option, /// Playing: seconds per run, carried from stop to stop. play: Option, } /// A stop on the timeline: one run of the report on its bookmark. pub struct Stop { pub id: i64, pub href: String, pub label: String, pub tip: String, pub current: bool, pub compared: bool, } /// A metric shown against another run. pub struct Compared { pub key: String, pub here: String, pub there: String, pub unit: String, /// here − there, `+1.5`; empty when equal or missing. pub delta: String, pub trend: &'static str, } /// A metric as shown: its value, and its change against the run before. pub struct MetricView { pub key: String, pub value: String, pub unit: String, /// `+0.4`, empty when there is no run before or no change. pub delta: String, /// `better`, `worse` or `same`, by the metric's `better`. pub trend: &'static str, /// Its threshold, as words, when it has one. pub threshold: String, /// Whether it crosses its threshold. pub failing: bool, /// An inline SVG of its values across runs, oldest first. pub spark: String, } /// A report on the Reports tab. pub struct Item { pub report: Report, pub metrics: Vec, /// `good`, `bad` or `neutral`: its thresholds met, one crossed, or none. pub health: &'static str, /// What the badge says: "2 thresholds met", "1 over its threshold". pub badge: String, } /// The Coverage or Tests entry on the Reports tab: its latest figure. pub struct Builtin { pub big: String, pub sub: String, pub health: &'static str, pub when_ms: i64, pub bookmark: String, } /// The health and badge of a report from its metrics' thresholds. fn health_of(metrics: &[MetricView]) -> (&'static str, String) { let with = metrics.iter().filter(|m| !m.threshold.is_empty()).count(); let failing = metrics.iter().filter(|m| m.failing).count(); match (with, failing) { (0, _) => ("neutral", format!("{} metric{}", metrics.len(), if metrics.len() == 1 { "" } else { "s" })), (_, 0) => ("good", format!("{with} threshold{} met", if with == 1 { "" } else { "s" })), (_, f) => ("bad", format!("{f} over {}", if f == 1 { "its threshold" } else { "their thresholds" })), } } #[derive(Template)] #[template(path = "reports.html")] pub struct ReportsPage { shell: Shell, repo: RepoShell, bookmark: String, items: Vec, coverage: Option, tests: Option, } #[derive(Template)] #[template(path = "report.html")] pub struct ReportPage { shell: Shell, repo: RepoShell, report: Report, bookmarks: Vec, metrics: Vec, context: Vec<(String, String)>, /// Newest first, the one shown among them. runs: Vec, /// Where the frame loads the report's page from, when it has one. frame: Option, files_base: String, /// Oldest first, the run shown marked. timeline: Vec, /// The run compared with, its metrics against this one's, and its frame. compare: Option<(Report, Vec, Option)>, /// Seconds per run while playing. play: Option, /// The runs, oldest first, as JSON for the frame interface /// (`report.run()`, `report.history()`). runs_json: String, } /// A number as a person would write it: no trailing zeros. fn num(v: f64) -> String { if v.fract() == 0.0 && v.abs() < 1e15 { format!("{v:.0}") } else { let s = format!("{v:.3}"); s.trim_end_matches('0').trim_end_matches('.').to_string() } } fn spark(values: &[f64]) -> String { if values.len() < 2 { return String::new(); } let (w, h) = (120.0, 28.0); let lo = values.iter().cloned().fold(f64::INFINITY, f64::min); let hi = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let span = if hi > lo { hi - lo } else { 1.0 }; let n = values.len(); let pts: Vec = values .iter() .enumerate() .map(|(i, v)| format!("{:.1},{:.1}", w * i as f64 / (n - 1) as f64, 2.0 + (h - 4.0) * (hi - v) / span)) .collect(); let last = pts.last().cloned().unwrap_or_default(); let (lx, ly) = last.split_once(',').unwrap_or(("0", "0")); format!( r#""#, pts.join(" ") ) } /// The metrics of `now` against `before`, with each one's values across /// `series` (oldest first) for a sparkline. fn views(now: &[Metric], before: &[Metric], series: &[Vec]) -> Vec { now.iter() .map(|m| { let prev = before.iter().find(|b| b.key == m.key).map(|b| b.value); let (delta, trend) = match prev { Some(p) if (m.value - p).abs() > f64::EPSILON => { let up = m.value > p; let trend = match m.better.as_str() { "higher" if up => "better", "higher" => "worse", "lower" if up => "worse", "lower" => "better", _ => "same", }; (format!("{}{}", if up { "+" } else { "" }, num(m.value - p)), trend) } _ => (String::new(), "same"), }; let mut threshold = Vec::new(); if let Some(a) = m.fail_above { threshold.push(format!("fails above {}", num(a))); } if let Some(b) = m.fail_below { threshold.push(format!("fails below {}", num(b))); } let failing = m.fail_above.is_some_and(|a| m.value > a) || m.fail_below.is_some_and(|b| m.value < b); let values: Vec = series.iter().filter_map(|ms| ms.iter().find(|x| x.key == m.key).map(|x| x.value)).collect(); MetricView { key: m.key.clone(), value: num(m.value), unit: m.unit.clone(), delta, trend, threshold: threshold.join(", "), failing, spark: spark(&values), } }) .collect() } /// The metrics of this run against another's, every key either has. fn compared(here: &[Metric], there: &[Metric]) -> Vec { let mut keys: Vec<&str> = here.iter().chain(there).map(|m| m.key.as_str()).collect(); keys.sort(); keys.dedup(); keys.into_iter() .map(|k| { let (a, b) = (here.iter().find(|m| m.key == k), there.iter().find(|m| m.key == k)); let (delta, trend) = match (a, b) { (Some(a), Some(b)) if (a.value - b.value).abs() > f64::EPSILON => { let up = a.value > b.value; let trend = match a.better.as_str() { "higher" if up => "better", "higher" => "worse", "lower" if up => "worse", "lower" => "better", _ => "same", }; (format!("{}{}", if up { "+" } else { "" }, num(a.value - b.value)), trend) } _ => (String::new(), "same"), }; Compared { key: k.to_string(), here: a.map(|m| num(m.value)).unwrap_or_else(|| "–".into()), there: b.map(|m| num(m.value)).unwrap_or_else(|| "–".into()), unit: a.or(b).map(|m| m.unit.clone()).unwrap_or_default(), delta, trend, } }) .collect() } /// `GET /{owner}/{name}/reports`: the newest of each report, with its /// metrics against the run before on its bookmark. pub async fn overview( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/reports"), &viewer); let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let e = |e| Error::from_db(shell.clone(), e); let bookmark = q.bookmark.clone().filter(|b| !b.is_empty()); let latest = reports::latest(&app.db, row.id, bookmark.as_deref()).await.map_err(e)?; let mut items = Vec::new(); for r in latest { let history = reports::history(&app.db, row.id, &r.name, &r.bookmark, 2).await.map_err(e)?; let now = reports::metrics(&app.db, r.id).await.map_err(e)?; let before = match history.get(1) { Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?, None => vec![], }; let metrics = views(&now, &before, &[]); let (health, badge) = health_of(&metrics); items.push(Item { metrics, report: r, health, badge }); } let repo = RepoShell::new(&app, &row, "reports").with_role(role); // Coverage and Tests: their latest, on the default bookmark when it has // one, else wherever was newest. let pick = |list: Vec| bookmark.clone().or_else(|| list.iter().find(|b| **b == row.default_bookmark).or(list.first()).cloned()); let coverage = match pick(crate::coverage::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.coverage) { Some(b) => crate::coverage::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|c| Builtin { big: c.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()), sub: format!("{} of {} lines covered · {}", c.lines_hit, c.lines_found, c.source()), health: match c.percent() { Some(p) if p >= 75.0 => "good", Some(p) if p < 50.0 => "bad", _ => "neutral", }, when_ms: c.created_ms, bookmark: c.bookmark.clone(), }), None => None, }; let tests = match pick(crate::junit::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.tests) { Some(b) => crate::junit::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|t| Builtin { big: if t.broken() > 0 { format!("{} failing", t.broken()) } else { format!("{} passed", t.passed) }, sub: t.summary(), health: if t.broken() > 0 { "bad" } else { "good" }, when_ms: t.created_ms, bookmark: t.bookmark.clone(), }), None => None, }; Ok(Page(ReportsPage { shell, coverage, tests, repo, bookmark: bookmark.unwrap_or_default(), items })) } /// `GET /{owner}/{name}/reports/{report}`: one report, the newest on its /// bookmark unless `?id=` names a run, with its metrics across runs and /// its page in a sandboxed frame. pub async fn show( State(app): State, viewer: Viewer, Path((owner, name, rname)): Path<(String, String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/reports/{rname}"), &viewer); let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let e = |e| Error::from_db(shell.clone(), e); let report = match q.id { Some(id) => reports::get(&app.db, row.id, id).await.map_err(e)?.filter(|r| r.name == rname), None => { let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?; let bookmark = q .bookmark .clone() .or_else(|| bookmarks.iter().find(|b| **b == row.default_bookmark).cloned()) .or_else(|| bookmarks.first().cloned()); match bookmark { Some(b) => reports::history(&app.db, row.id, &rname, &b, 1).await.map_err(e)?.into_iter().next(), None => None, } } }; let report = report.ok_or_else(|| Error::not_found(shell.clone()))?; let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?; let runs = reports::history(&app.db, row.id, &rname, &report.bookmark, HISTORY).await.map_err(e)?; let now = reports::metrics(&app.db, report.id).await.map_err(e)?; // The run before the one shown, and every run up to it for the lines. let at = runs.iter().position(|r| r.id == report.id).unwrap_or(0); let before = match runs.get(at + 1) { Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?, None => vec![], }; let mut series = Vec::new(); for r in runs.iter().skip(at).rev() { series.push(reports::metrics(&app.db, r.id).await.map_err(e)?); } let base_of = |id: i64| format!("/{owner}/{name}/reports/files/{id}/{}", reports::file_mac(&app, id)); let files_base = base_of(report.id); let frame = reports::file(&app.db, report.id, "index.html").await.map_err(e)?.map(|_| format!("{files_base}/index.html")); let label = |r: &Report| r.run_number.map(|n| format!("run #{n}")).unwrap_or_else(|| "upload".into()); let page = format!("/{owner}/{name}/reports/{rname}"); let keep = |id: i64| { let mut url = format!("{page}?id={id}"); if let Some(c) = q.compare.filter(|c| *c != id) { url.push_str(&format!("&compare={c}")); } if let Some(p) = q.play { url.push_str(&format!("&play={p}")); } url }; let timeline: Vec = runs .iter() .rev() .map(|r| Stop { id: r.id, href: keep(r.id), label: label(r), tip: format!("{}{}", label(r), if r.summary.is_empty() { String::new() } else { format!(": {}", r.summary) }), current: r.id == report.id, compared: Some(r.id) == q.compare, }) .collect(); // Another run to compare with (RP4): every metric, both values, the // difference; and its page beside this one's. let compare = match q.compare.filter(|c| *c != report.id) { Some(cid) => match reports::get(&app.db, row.id, cid).await.map_err(e)?.filter(|r| r.name == rname) { Some(other) => { let theirs = reports::metrics(&app.db, other.id).await.map_err(e)?; let rows = compared(&now, &theirs); let f = reports::file(&app.db, other.id, "index.html").await.map_err(e)?.map(|_| format!("{}/index.html", base_of(other.id))); Some((other, rows, f)) } None => None, }, None => None, }; let runs_json = serde_json::to_string( &runs .iter() .rev() .map(|r| serde_json::json!({ "id": r.id, "run": r.run_number, "bookmark": r.bookmark, "created_ms": r.created_ms, "summary": r.summary, "files": base_of(r.id), "current": r.id == report.id })) .collect::>(), ) .unwrap_or_default(); let repo = RepoShell::new(&app, &row, "reports").with_role(role); Ok(Page(ReportPage { shell, repo, context: report.context_rows(), metrics: views(&now, &before, &series), bookmarks, runs, frame, files_base, report, timeline, compare, play: q.play.map(|p| p.clamp(1, 60)), runs_json, })) } /// `page` with the report frame script added (#127). fn with_frame_script(page: Vec, version: &str) -> Vec { let tag = format!(r#""#); let lower = page.to_ascii_lowercase(); let find = |needle: &[u8]| lower.windows(needle.len()).position(|w| w == needle); let at = find(b"").or_else(|| find(b" &'static str { match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() { "html" | "htm" => "text/html; charset=utf-8", "js" | "mjs" => "text/javascript; charset=utf-8", "css" => "text/css; charset=utf-8", "json" => "application/json", "csv" => "text/csv; charset=utf-8", "svg" => "image/svg+xml", "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", "woff2" => "font/woff2", "woff" => "font/woff", "txt" | "toml" | "md" | "info" | "xml" => "text/plain; charset=utf-8", _ => "application/octet-stream", } } /// `GET /{owner}/{name}/reports/files/{id}/{mac}/{*path}`: a report's file, /// for its frame. The frame is sandboxed (scripts, no same origin), so it /// carries no session: the MAC in the link is what lets it in. Every file /// is served sandboxed too, with nothing beyond this hub to load or call, /// and readable across origins, since the frame's own origin is opaque. pub async fn file(State(app): State, Path((owner, name, id, mac, path)): Path<(String, String, i64, String, String)>) -> Response { let not_found = || (StatusCode::NOT_FOUND, "no such report file").into_response(); if !crate::auth::mac_equal(&mac, &reports::file_mac(&app, id)) { return not_found(); } let Ok(Some(row)) = crate::db::repos::get(&app.db, &owner, &name).await else { return not_found() }; let Ok(Some(_)) = reports::get(&app.db, row.id, id).await else { return not_found() }; let Ok(Some((blob, _))) = reports::file(&app.db, id, &path).await else { return not_found() }; let (Some(repo), Some(oref)) = (crate::repos::RepoName::new(&row.owner, &row.name), oxy_core::hash::ObjectRef::from_hex(&blob)) else { return not_found(); }; let bytes = match app.repos.read(&repo, move |r, _| Ok(r.read_blob(&oref)?)).await { Ok(b) => b, Err(_) => return not_found(), }; // A page gets the frame script, which sizes the frame to it and passes // the hub's theme on: before , else , else at the end. let bytes = if content_type(&path).starts_with("text/html") { with_frame_script(bytes, super::statics::assets_version()) } else { bytes }; let origin = app.config.base_url().trim_end_matches('/').to_string(); let csp = format!( "sandbox allow-scripts; default-src 'none'; script-src 'self' {origin} 'unsafe-inline' 'unsafe-eval'; \ style-src 'self' {origin} 'unsafe-inline'; img-src 'self' {origin} data: blob:; font-src 'self' {origin} data:; \ connect-src 'self' {origin}; frame-ancestors 'self' {origin}; base-uri 'none'; form-action 'none'" ); let mut res = (StatusCode::OK, bytes).into_response(); let h = res.headers_mut(); h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type(&path))); if let Ok(v) = HeaderValue::from_str(&csp) { h.insert(header::CONTENT_SECURITY_POLICY, v); } h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*")); h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); h.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")); h.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, max-age=3600, immutable")); res } #[cfg(test)] mod tests { use super::*; #[test] fn metrics_say_which_way_they_moved() { let m = |v: f64, better: &str, above: Option| Metric { key: "frame".into(), value: v, unit: "ms".into(), better: better.into(), fail_above: above, fail_below: None, }; let v = &views(&[m(18.0, "lower", Some(20.0))], &[m(16.5, "lower", Some(20.0))], &[])[0]; assert_eq!((v.value.as_str(), v.delta.as_str(), v.trend, v.failing), ("18", "+1.5", "worse", false)); assert_eq!(v.threshold, "fails above 20"); let v = &views(&[m(21.0, "lower", Some(20.0))], &[], &[])[0]; assert!(v.failing && v.delta.is_empty()); assert_eq!(num(16.40), "16.4"); assert!(spark(&[1.0, 3.0, 2.0]).contains("polyline")); assert!(spark(&[1.0]).is_empty()); let page = with_frame_script(b"tx".to_vec(), "v1"); let page = String::from_utf8(page).unwrap(); assert!(page.starts_with(""), "the doctype stays first"); assert!(page.contains(r#""#), "{page}"); let bare = String::from_utf8(with_frame_script(b"

hi

".to_vec(), "v1")).unwrap(); assert!(bare.ends_with(""), "{bare}"); } }