Reports: cards with a kind icon and health, frames that fit and share the hub's look f3e5e2bc78e9
The Reports tab's cards get an icon per kind, a tone and badge from their thresholds (met, crossed, or none), metric tiles, and Coverage and Tests with their latest figure. A report's HTML pages get a small frame script that reports their height, so the frame grows to fit instead of scrolling, and passes the hub's theme on; /static/report.css gives a report the hub's fonts and colour tokens to use instead of its own. Static assets are readable across origins for the sandboxed frame. refs #127
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-11 14:31 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
f3e5e2bc78e936e8d327871445ab4baeb5a2de73081514efa21f26a11c47d4aa- Depends on
- 145306044896 42fa95540468 50d93fa443dc 8475176972e8 8e820b4ae875 c40c2d3edb51 c8df33a6829f d23464c0ac60
- Depended on by
- a7aa76ac52d7 17ca6cddc534 95b525b0b977 40dd3f86379a
- 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
- 33 atoms, 2 tree operations, 9 files
Browse the tree at this change
added assets/report-frame.js +25 −0
| 1 | + | /* OxygenHub report frame (oxygenhub#127): the hub adds this to a report's |
|
| 2 | + | HTML pages. It tells the page around the frame how tall the report is, |
|
| 3 | + | so the frame grows to fit instead of scrolling, and takes the hub's |
|
| 4 | + | theme (data-theme="dark" or "light" on <html>) for report.css. It sends |
|
| 5 | + | nothing but sizes, and reads nothing but the theme. */ |
|
| 6 | + | (function () { |
|
| 7 | + | if (window.parent === window) return; |
|
| 8 | + | var root = document.documentElement, last = 0; |
|
| 9 | + | var send = function () { |
|
| 10 | + | var h = Math.ceil(Math.max(root.scrollHeight, document.body ? document.body.scrollHeight : 0)); |
|
| 11 | + | if (h && h !== last) { |
|
| 12 | + | last = h; |
|
| 13 | + | window.parent.postMessage({ oxygenhubReport: { height: h } }, "*"); |
|
| 14 | + | } |
|
| 15 | + | }; |
|
| 16 | + | window.addEventListener("message", function (e) { |
|
| 17 | + | if (e.source !== window.parent) return; |
|
| 18 | + | var t = e.data && e.data.oxygenhubTheme; |
|
| 19 | + | if (t === "dark" || t === "light") root.dataset.theme = t; |
|
| 20 | + | }); |
|
| 21 | + | window.addEventListener("load", send); |
|
| 22 | + | if (window.ResizeObserver) new ResizeObserver(send).observe(root); |
|
| 23 | + | else setInterval(send, 500); |
|
| 24 | + | window.parent.postMessage({ oxygenhubReport: { ready: true } }, "*"); |
|
| 25 | + | })(); |
added assets/report.css +33 −0
| 1 | + | /* OxygenHub report stylesheet (oxygenhub#127). A report's page links it |
|
| 2 | + | to look like the hub around it, with no fonts or colours of its own: |
|
| 3 | + | ||
| 4 | + | <link rel="stylesheet" href="/static/report.css"> |
|
| 5 | + | ||
| 6 | + | It sets the hub's fonts (Barlow; IBM Plex Mono for code) and its colour |
|
| 7 | + | tokens, following the hub's theme as the frame script passes it on: |
|
| 8 | + | --bg, --panel, --soft, --ink, --mute, --line, --acc, --acc-2, --ok, |
|
| 9 | + | --bad, --warn, and --m1..--m5 for chart series. The page's background |
|
| 10 | + | is the hub's panel. */ |
|
| 11 | + | @font-face { font-family: "Barlow"; font-weight: 400; font-display: swap; src: url(/static/fonts/Barlow-400.woff2) format("woff2"); } |
|
| 12 | + | @font-face { font-family: "Barlow"; font-weight: 500; font-display: swap; src: url(/static/fonts/Barlow-500.woff2) format("woff2"); } |
|
| 13 | + | @font-face { font-family: "Barlow"; font-weight: 600; font-display: swap; src: url(/static/fonts/Barlow-600.woff2) format("woff2"); } |
|
| 14 | + | :root { |
|
| 15 | + | color-scheme: dark; |
|
| 16 | + | --bg:#070c18; --panel:#0e1526; --soft:#141d33; --ink:#e6ecf7; --mute:#8592ad; --line:#1f2b47; |
|
| 17 | + | --acc:#00e0f0; --acc-2:#0072ff; --ok:#5fe3a1; --bad:#ff5e7a; --warn:#b48cff; |
|
| 18 | + | --m1:#00f0ff; --m2:#d6dcea; --m3:#5aa8ff; --m4:#0072ff; --m5:#8592ad; |
|
| 19 | + | } |
|
| 20 | + | :root[data-theme="light"] { |
|
| 21 | + | color-scheme: light; |
|
| 22 | + | --bg:#f4f7fc; --panel:#ffffff; --soft:#e9eef8; --ink:#1b2333; --mute:#5f6a81; --line:#d6dded; |
|
| 23 | + | --acc:#0064e0; --acc-2:#00b8d4; --ok:#1a7850; --bad:#c62d47; --warn:#6b3fd1; |
|
| 24 | + | --m1:#0090a8; --m2:#7a8290; --m3:#2f6fcf; --m4:#0055c0; --m5:#8a8f9c; |
|
| 25 | + | } |
|
| 26 | + | html { background: var(--panel); } |
|
| 27 | + | body { margin: 0; padding: 16px; background: var(--panel); color: var(--ink); font: 15px/1.45 "Barlow", system-ui, sans-serif; } |
|
| 28 | + | code, pre, kbd { font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; } |
|
| 29 | + | a { color: var(--acc); } |
|
| 30 | + | h1, h2, h3 { font-weight: 600; line-height: 1.2; } |
|
| 31 | + | table { border-collapse: collapse; } |
|
| 32 | + | th, td { border-bottom: 1px solid var(--line); padding: .35em .6em; text-align: left; } |
|
| 33 | + | th { color: var(--mute); font-weight: 500; } |
modified assets/hub.js +21 −0
| … | 54 unchanged lines |
||
| 55 | 55 | }); |
|
| 56 | 56 | }); |
|
| 57 | 57 | ||
| 58 | + | /* Report frames (#127): a report's page says how tall it is, and the |
|
| 59 | + | frame grows to fit; the frame is told the theme, at start and on a |
|
| 60 | + | change. Only messages from the frame itself are taken. */ |
|
| 61 | + | var frames = document.querySelectorAll("iframe.rep-frame"); |
|
| 62 | + | var themeToFrames = function () { |
|
| 63 | + | frames.forEach(function (f) { if (f.contentWindow) f.contentWindow.postMessage({ oxygenhubTheme: document.documentElement.dataset.theme || "dark" }, "*"); }); |
|
| 64 | + | }; |
|
| 65 | + | if (frames.length) window.addEventListener("message", function (e) { |
|
| 66 | + | frames.forEach(function (f) { |
|
| 67 | + | if (e.source !== f.contentWindow) return; |
|
| 68 | + | var r = e.data && e.data.oxygenhubReport; |
|
| 69 | + | if (!r) return; |
|
| 70 | + | if (r.ready) themeToFrames(); |
|
| 71 | + | if (typeof r.height === "number" && r.height > 0) { |
|
| 72 | + | f.style.height = Math.min(Math.max(r.height, 120), 40000) + "px"; |
|
| 73 | + | f.classList.add("sized"); |
|
| 74 | + | } |
|
| 75 | + | }); |
|
| 76 | + | }); |
|
| 77 | + | ||
| 58 | 78 | /* Theme switch in the header: dark, light, auto (follows the system); kept in the theme cookie. */ |
|
| 59 | 79 | var toggle = document.querySelector("[data-theme-toggle]"); |
|
| 60 | 80 | if (toggle) { |
|
| … | 11 unchanged lines |
||
| 72 | 92 | root.dataset.themePref = p; |
|
| 73 | 93 | root.dataset.theme = p === "auto" ? (window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p; |
|
| 74 | 94 | show(); |
|
| 95 | + | themeToFrames(); |
|
| 75 | 96 | }); |
|
| 76 | 97 | } |
|
| 77 | 98 | ||
| … | 434 unchanged lines |
modified crates/hub/src/web/reports.rs +80 −4
| … | 43 unchanged lines |
||
| 44 | 44 | pub struct Item { |
|
| 45 | 45 | pub report: Report, |
|
| 46 | 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, |
|
| 47 | 51 | } |
|
| 48 | 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 | + | ||
| 49 | 73 | #[derive(Template)] |
|
| 50 | 74 | #[template(path = "reports.html")] |
|
| 51 | 75 | pub struct ReportsPage { |
|
| … | 1 unchanged lines |
||
| 53 | 77 | repo: RepoShell, |
|
| 54 | 78 | bookmark: String, |
|
| 55 | 79 | items: Vec<Item>, |
|
| 56 | - | coverage: bool, |
|
| 57 | - | tests: bool, |
|
| 80 | + | coverage: Option<Builtin>, |
|
| 81 | + | tests: Option<Builtin>, |
|
| 58 | 82 | } |
|
| 59 | 83 | ||
| 60 | 84 | #[derive(Template)] |
|
| … | 108 unchanged lines |
||
| 169 | 193 | Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?, |
|
| 170 | 194 | None => vec![], |
|
| 171 | 195 | }; |
|
| 172 | - | items.push(Item { metrics: views(&now, &before, &[]), report: r }); |
|
| 196 | + | let metrics = views(&now, &before, &[]); |
|
| 197 | + | let (health, badge) = health_of(&metrics); |
|
| 198 | + | items.push(Item { metrics, report: r, health, badge }); |
|
| 173 | 199 | } |
|
| 174 | 200 | 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 })) |
|
| 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), |
|
| 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 })) |
|
| 176 | 229 | } |
|
| 177 | 230 | ||
| 178 | 231 | /// `GET /{owner}/{name}/reports/{report}`: one report, the newest on its |
|
| … | 51 unchanged lines |
||
| 230 | 283 | files_base, |
|
| 231 | 284 | report, |
|
| 232 | 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 |
|
| 233 | 299 | } |
|
| 234 | 300 | ||
| 235 | 301 | /// A file's type, by its extension. |
|
| … | 36 unchanged lines |
||
| 272 | 338 | Ok(b) => b, |
|
| 273 | 339 | Err(_) => return not_found(), |
|
| 274 | 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 }; |
|
| 275 | 345 | let origin = app.config.base_url().trim_end_matches('/').to_string(); |
|
| 276 | 346 | let csp = format!( |
|
| 277 | 347 | "sandbox allow-scripts; default-src 'none'; script-src 'self' {origin} 'unsafe-inline' 'unsafe-eval'; \ |
|
| … | 35 unchanged lines |
||
| 313 | 383 | assert_eq!(num(16.40), "16.4"); |
|
| 314 | 384 | assert!(spark(&[1.0, 3.0, 2.0]).contains("polyline")); |
|
| 315 | 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}"); |
|
| 316 | 392 | } |
|
| 317 | 393 | } |
modified crates/hub/src/web/statics.rs +16 −4
| … | 15 unchanged lines |
||
| 16 | 16 | V.get_or_init(|| { |
|
| 17 | 17 | let mut h = blake3::Hasher::new(); |
|
| 18 | 18 | h.update(CSS.as_bytes()); |
|
| 19 | - | for a in ASSETS.iter().filter(|a| a.path == "github.css" || a.path == "hub.js") { |
|
| 19 | + | for a in ASSETS.iter().filter(|a| matches!(a.path, "github.css" | "hub.js" | "report-frame.js" | "report.css")) { |
|
| 20 | 20 | h.update(a.bytes); |
|
| 21 | 21 | } |
|
| 22 | 22 | h.finalize().to_hex()[..12].to_string() |
|
| … | 18 unchanged lines |
||
| 41 | 41 | }, |
|
| 42 | 42 | Asset { path: "github.css", mime: "text/css; charset=utf-8", bytes: include_bytes!("../../../../assets/github.css") }, |
|
| 43 | 43 | Asset { path: "hub.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../../../../assets/hub.js") }, |
|
| 44 | + | // For report frames (#127): the size and theme bridge the hub adds to |
|
| 45 | + | // a report's pages, and the stylesheet a report may link. |
|
| 46 | + | Asset { path: "report-frame.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../../../../assets/report-frame.js") }, |
|
| 47 | + | Asset { path: "report.css", mime: "text/css; charset=utf-8", bytes: include_bytes!("../../../../assets/report.css") }, |
|
| 44 | 48 | Asset { |
|
| 45 | 49 | path: "vendor/editor.js", |
|
| 46 | 50 | mime: "text/javascript; charset=utf-8", |
|
| … | 31 unchanged lines |
||
| 78 | 82 | ||
| 79 | 83 | pub async fn asset(Path(path): Path<String>) -> Response { |
|
| 80 | 84 | match ASSETS.iter().find(|a| a.path == path) { |
|
| 81 | - | Some(a) => { |
|
| 82 | - | ([(header::CONTENT_TYPE, a.mime), (header::CACHE_CONTROL, "public, max-age=31536000, immutable")], a.bytes).into_response() |
|
| 83 | - | } |
|
| 85 | + | // Readable across origins: a report's sandboxed frame has an |
|
| 86 | + | // opaque origin, and loads fonts in CORS mode (#127). |
|
| 87 | + | Some(a) => ( |
|
| 88 | + | [ |
|
| 89 | + | (header::CONTENT_TYPE, a.mime), |
|
| 90 | + | (header::CACHE_CONTROL, "public, max-age=31536000, immutable"), |
|
| 91 | + | (header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"), |
|
| 92 | + | ], |
|
| 93 | + | a.bytes, |
|
| 94 | + | ) |
|
| 95 | + | .into_response(), |
|
| 84 | 96 | None => StatusCode::NOT_FOUND.into_response(), |
|
| 85 | 97 | } |
|
| 86 | 98 | } |
modified crates/hub/tests/flow.rs +6 −1
| … | 1047 unchanged lines |
||
| 1048 | 1048 | let csp = res.headers()[header::CONTENT_SECURITY_POLICY].to_str().unwrap().to_string(); |
|
| 1049 | 1049 | assert!(csp.starts_with("sandbox allow-scripts;") && csp.contains("default-src 'none'"), "{csp}"); |
|
| 1050 | 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()); |
|
| 1051 | + | // The page as kept, with the frame script that sizes the frame added. |
|
| 1052 | + | let served = res.into_body().collect().await.unwrap().to_bytes().to_vec(); |
|
| 1053 | + | assert!(served.starts_with(&page_bytes), "the report's page"); |
|
| 1054 | + | assert!(String::from_utf8_lossy(&served).contains("/static/report-frame.js"), "with the frame script"); |
|
| 1055 | + | let (st, _) = get_bytes(&h.app, "/static/report.css").await; |
|
| 1056 | + | assert_eq!(st, StatusCode::OK, "the stylesheet a report may link"); |
|
| 1052 | 1057 | let data = src.replace("/index.html", "/data/frames.csv"); |
|
| 1053 | 1058 | assert_eq!(get_bytes(&h.app, &data).await, (StatusCode::OK, data_bytes.clone())); |
|
| 1054 | 1059 | // A link with another report's MAC, or a file it lacks, finds nothing. |
|
| … | 1453 unchanged lines |
modified docs/reports.md +22 −0
| … | 173 unchanged lines |
||
| 174 | 174 | job's output. |
|
| 175 | 175 | 3. Push. The report appears on the Reports tab after the run. |
|
| 176 | 176 | ||
| 177 | + | ### Fitting the hub |
|
| 178 | + | ||
| 179 | + | - **Height:** the hub adds a small script to a report's HTML pages |
|
| 180 | + | (`/static/report-frame.js`) that tells the page around the frame how |
|
| 181 | + | tall the report is, so the frame grows to fit and the report never |
|
| 182 | + | scrolls inside it. Lay the page out by its content: nothing sized to the |
|
| 183 | + | viewport (`100vh`, `height: 100%` on `html`/`body`), or it grows with |
|
| 184 | + | the frame. |
|
| 185 | + | - **Look:** link the hub's stylesheet, and drop any fonts or colours of the |
|
| 186 | + | report's own: |
|
| 187 | + | ||
| 188 | + | ```html |
|
| 189 | + | <link rel="stylesheet" href="/static/report.css"> |
|
| 190 | + | ``` |
|
| 191 | + | ||
| 192 | + | It sets the hub's fonts and colour tokens (`--bg`, `--panel`, `--soft`, |
|
| 193 | + | `--ink`, `--mute`, `--line`, `--acc`, `--ok`, `--bad`, `--warn`, |
|
| 194 | + | `--m1`..`--m5` for series), dark or light as the hub is, switching when |
|
| 195 | + | the viewer switches. Charts read them with |
|
| 196 | + | `getComputedStyle(document.documentElement).getPropertyValue("--m1")` |
|
| 197 | + | and redraw on a `data-theme` change if they cache colours. |
|
| 198 | + | ||
| 177 | 199 | ## 8. Order of work |
|
| 178 | 200 | ||
| 179 | 201 | *Status, 2026-10-11:* storage and index (1) are deployed; the Reports tab, |
|
| … | 16 unchanged lines |
modified templates/reports.html +34 −19
| … | 1 unchanged lines |
||
| 2 | 2 | {% import "repo_tabs.html" as m %} |
|
| 3 | 3 | {% block title %}Reports · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | 4 | {% block tabs %}{% call m::tabs(repo, "reports") %}{% endblock %} |
|
| 5 | + | {% macro icon(kind) %}<span class="rep-icon rep-{{ kind }}" aria-hidden="true"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{% if kind == "performance" %}<path d="M4 17a8 8 0 1 1 16 0"/><path d="M12 17l4.5-6"/><circle cx="12" cy="17" r="1.2" fill="currentColor"/>{% else if kind == "coverage" %}<path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M8.5 12l2.5 2.5 4.5-5"/>{% else if kind == "tests" %}<path d="M9 3h6"/><path d="M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/><path d="M7.5 15h9"/>{% else %}<path d="M5 20V11"/><path d="M12 20V4"/><path d="M19 20v-6"/>{% endif %}</svg></span>{% endmacro %} |
|
| 5 | 6 | {% 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> |
|
| 7 | + | <div class="rep-top"> |
|
| 8 | + | <h1>Reports</h1> |
|
| 9 | + | <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 %}. Changes are against the run before on the same bookmark.</p> |
|
| 10 | + | </div> |
|
| 8 | 11 | <div class="rep-list"> |
|
| 9 | 12 | {% for i in items %} |
|
| 10 | - | <a class="panel rep-card" href="{{ repo.url }}/reports/{{ i.report.name }}?id={{ i.report.id }}"> |
|
| 13 | + | <article class="rep-card rep-{{ i.health }}"> |
|
| 11 | 14 | <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> |
|
| 15 | + | {% call icon(i.report.kind) %} |
|
| 16 | + | <div class="rep-name"> |
|
| 17 | + | <a class="rep-link" href="{{ repo.url }}/reports/{{ i.report.name }}?id={{ i.report.id }}">{{ i.report.title }}</a> |
|
| 18 | + | <span class="rep-kindname">{{ i.report.kind }}</span> |
|
| 19 | + | </div> |
|
| 20 | + | <span class="rep-badge">{% if i.health == "good" %}✓ {% else if i.health == "bad" %}✗ {% endif %}{{ i.badge }}</span> |
|
| 14 | 21 | </div> |
|
| 15 | 22 | {% 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> |
|
| 23 | + | {% if !i.metrics.is_empty() %}<ul class="rep-tiles">{% for v in i.metrics %}<li class="{% if v.failing %}rep-failing{% endif %}"><span class="v">{{ v.value }}{% if !v.unit.is_empty() %}<small> {{ v.unit }}</small>{% endif %}</span><span class="k">{{ v.key }}</span>{% if !v.delta.is_empty() %}<span class="rep-delta {{ v.trend }}">{{ v.delta }}</span>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 24 | + | <div class="rep-foot">{% if !i.report.bookmark.is_empty() %}<span class="lbl lbl-bm">{{ i.report.bookmark }}</span>{% endif %}{% if let Some(n) = i.report.run_number %} run #{{ n }} ·{% endif %} {{ i.report.job }} · <time title="{{ i.report.created_ms|when_i }}">{{ i.report.created_ms|ago_i }}</time></div> |
|
| 25 | + | </article> |
|
| 19 | 26 | {% 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> |
|
| 27 | + | {% if let Some(c) = coverage %} |
|
| 28 | + | <article class="rep-card rep-{{ c.health }}"> |
|
| 29 | + | <div class="rep-head"> |
|
| 30 | + | {% call icon("coverage") %} |
|
| 31 | + | <div class="rep-name"><a class="rep-link" href="{{ repo.url }}/coverage">Coverage</a><span class="rep-kindname">coverage</span></div> |
|
| 32 | + | </div> |
|
| 33 | + | <div class="rep-hero"><span class="rep-big">{{ c.big }}</span><span class="mute">{{ c.sub }}</span></div> |
|
| 34 | + | <div class="rep-foot"><span class="lbl lbl-bm">{{ c.bookmark }}</span> from the jobs' <code>coverage</code> artifacts · <time title="{{ c.when_ms|when_i }}">{{ c.when_ms|ago_i }}</time></div> |
|
| 35 | + | </article> |
|
| 25 | 36 | {% 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> |
|
| 37 | + | {% if let Some(t) = tests %} |
|
| 38 | + | <article class="rep-card rep-{{ t.health }}"> |
|
| 39 | + | <div class="rep-head"> |
|
| 40 | + | {% call icon("tests") %} |
|
| 41 | + | <div class="rep-name"><a class="rep-link" href="{{ repo.url }}/tests">Tests</a><span class="rep-kindname">tests</span></div> |
|
| 42 | + | </div> |
|
| 43 | + | <div class="rep-hero"><span class="rep-big">{{ t.big }}</span><span class="mute">{{ t.sub }}</span></div> |
|
| 44 | + | <div class="rep-foot"><span class="lbl lbl-bm">{{ t.bookmark }}</span> from the jobs' <code>junit</code> artifacts · <time title="{{ t.when_ms|when_i }}">{{ t.when_ms|ago_i }}</time></div> |
|
| 45 | + | </article> |
|
| 31 | 46 | {% endif %} |
|
| 32 | 47 | </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 %} |
|
| 48 | + | {% if items.is_empty() && coverage.is_none() && tests.is_none() %}<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 | 49 | {% endblock %} |
modified templates/style.css +27 −11
| … | 909 unchanged lines |
||
| 910 | 910 | .proj-list .empty { border: 0; margin: 0; } |
|
| 911 | 911 | ||
| 912 | 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; } |
|
| 913 | + | .rep-top h1 { margin-bottom: .2em; } |
|
| 914 | + | .rep-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(21em, 1fr)); gap: 1em; margin-top: 1.2em; } |
|
| 915 | + | .rep-card { --tone: var(--mute); position: relative; display: flex; flex-direction: column; gap: .7em; padding: 1.1em 1.2em 1em; border: 1px solid var(--line); border-top: 3px solid var(--tone); border-radius: 12px; |
|
| 916 | + | background: linear-gradient(180deg, color-mix(in srgb, var(--tone) 9%, var(--panel)), var(--panel) 70%); transition: border-color .15s, transform .15s, box-shadow .15s; } |
|
| 917 | + | .rep-card:hover { transform: translateY(-1px); box-shadow: 0 6px 18px color-mix(in srgb, var(--tone) 18%, transparent); border-color: color-mix(in srgb, var(--tone) 55%, var(--line)); } |
|
| 918 | + | .rep-card.rep-good { --tone: var(--ok); } .rep-card.rep-bad { --tone: var(--bad); } .rep-card.rep-neutral { --tone: var(--acc); } |
|
| 919 | + | .rep-head { display: flex; align-items: center; gap: .7em; } |
|
| 920 | + | .rep-icon { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; color: var(--kind); background: color-mix(in srgb, var(--kind) 14%, transparent); } |
|
| 921 | + | .rep-icon.rep-performance { --kind: var(--acc); } .rep-icon.rep-coverage { --kind: var(--ok); } .rep-icon.rep-tests { --kind: var(--warn); } .rep-icon.rep-custom { --kind: var(--mute); } |
|
| 922 | + | .rep-name { display: flex; flex-direction: column; min-width: 0; flex: 1; } |
|
| 923 | + | .rep-link { color: var(--ink); font-weight: 600; font-size: 1.08em; } |
|
| 924 | + | .rep-link::after { content: ""; position: absolute; inset: 0; border-radius: 12px; } |
|
| 925 | + | .rep-kindname { color: var(--mute); font-size: .8em; text-transform: uppercase; letter-spacing: .06em; } |
|
| 926 | + | .rep-badge { flex: none; font-size: .8em; font-weight: 600; padding: .2em .65em; border-radius: 999px; color: var(--tone); background: color-mix(in srgb, var(--tone) 14%, transparent); } |
|
| 927 | + | .rep-summary { margin: 0; color: var(--ink); } |
|
| 928 | + | .rep-tiles { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .5em; } |
|
| 929 | + | .rep-tiles li { display: flex; flex-direction: column; padding: .5em .65em; border-radius: 8px; background: var(--soft); min-width: 0; } |
|
| 930 | + | .rep-tiles .v { font-size: 1.15em; font-weight: 600; font-variant-numeric: tabular-nums; } |
|
| 931 | + | .rep-tiles .v small { font-weight: 400; color: var(--mute); font-size: .7em; } |
|
| 932 | + | .rep-tiles .k { font-size: .78em; color: var(--mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
|
| 933 | + | .rep-tiles li.rep-failing { box-shadow: inset 3px 0 0 var(--bad); } |
|
| 934 | + | .rep-hero { display: flex; flex-direction: column; gap: .1em; } |
|
| 935 | + | .rep-big { font-size: 2em; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--tone); line-height: 1.1; } |
|
| 936 | + | .rep-foot { margin-top: auto; font-size: .82em; color: var(--mute); display: flex; flex-wrap: wrap; align-items: center; gap: .35em; } |
|
| 918 | 937 | .rep-kind { text-transform: lowercase; } |
|
| 919 | 938 | .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; } |
|
| 939 | + | .rep-delta { font-size: .8em; font-weight: 600; font-variant-numeric: tabular-nums; } |
|
| 925 | 940 | .rep-delta.better { color: var(--ok); } .rep-delta.worse { color: var(--bad); } .rep-delta.same { color: var(--mute); } |
|
| 926 | 941 | .rep-failing .v, .rep-failing strong { color: var(--bad); } |
|
| 927 | 942 | .rep-table { width: 100%; border-collapse: collapse; } |
|
| … | 2 unchanged lines |
||
| 930 | 945 | .rep-spark polyline { fill: none; stroke: var(--acc); stroke-width: 1.5; } |
|
| 931 | 946 | .rep-spark circle { fill: var(--acc); } |
|
| 932 | 947 | .rep-frame-wrap { margin: 1em 0; } |
|
| 933 | - | .rep-frame { width: 100%; height: 80vh; border: 1px solid var(--line); border-radius: 8px; background: #fff; } |
|
| 948 | + | .rep-frame { display: block; width: 100%; height: 70vh; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); } |
|
| 949 | + | .rep-frame.sized { overflow: hidden; } |
|
| 934 | 950 | .rep-context { display: grid; grid-template-columns: max-content 1fr; gap: .3em 1em; margin: 0; } |
|
| 935 | 951 | .rep-context dt { color: var(--mute); } |
|
| 936 | 952 | .rep-context dd { margin: 0; font-variant-numeric: tabular-nums; } |
|
| … | 3 unchanged lines |