Reports: the timeline, play, compare, and the frame interface 95b525b0b977
A report's page gets a timeline of its runs on the bookmark: a stop per
run, arrow keys to step, Play at 1 to 10 s a run (carried in the URL),
and Compare against any other run, with every metric's two values and
difference and the two pages side by side. Inside the frame,
report.run() and report.history("file") are answered by the hub page
over postMessage, history reading that data file from each run.
refs #127
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-11 23:01 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
95b525b0b977ed1a639e28a01dd3321709fa1f99312b6d446a53369ad764a510- Depends on
- 42fa95540468 50d93fa443dc c40c2d3edb51 c8df33a6829f d23464c0ac60 f3e5e2bc78e9
- Depended on by
- fac6853c4e46 a7aa76ac52d7 3b1bc27fe805 16626c0d69c0 20b8fe85888b
- 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.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
- 17 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified assets/hub.js +57 −0
| … | 74 unchanged lines |
||
| 75 | 75 | }); |
|
| 76 | 76 | }); |
|
| 77 | 77 | ||
| 78 | + | /* Report frames ask for runs (#127, RP4): report.run() is the run on |
|
| 79 | + | screen, report.history(dataset) that file of data/ across the runs, |
|
| 80 | + | oldest first. Answered only to the frame itself, from the files of |
|
| 81 | + | this report's own runs. */ |
|
| 82 | + | if (frames.length) window.addEventListener("message", function (e) { |
|
| 83 | + | frames.forEach(function (f) { |
|
| 84 | + | if (e.source !== f.contentWindow) return; |
|
| 85 | + | var ask = e.data && e.data.oxygenhubAsk; |
|
| 86 | + | if (!ask) return; |
|
| 87 | + | var runs = []; try { runs = JSON.parse(f.dataset.reportRuns || "[]"); } catch (_) {} |
|
| 88 | + | var current = Number(f.dataset.reportCurrent); |
|
| 89 | + | var reply = function (body) { f.contentWindow.postMessage({ oxygenhubAnswer: { n: ask.n, value: body.value, error: body.error } }, "*"); }; |
|
| 90 | + | var meta = function (r) { return { run: r.run, bookmark: r.bookmark, created_ms: r.created_ms, summary: r.summary, current: r.id === current }; }; |
|
| 91 | + | if (ask.what === "run") { |
|
| 92 | + | var r = runs.filter(function (x) { return x.id === current; })[0]; |
|
| 93 | + | reply({ value: r ? meta(r) : null }); |
|
| 94 | + | } else if (ask.what === "history" && typeof ask.dataset === "string" && /^[A-Za-z0-9._+-]+(\/[A-Za-z0-9._+-]+)*$/.test(ask.dataset)) { |
|
| 95 | + | Promise.all(runs.map(function (r) { |
|
| 96 | + | return fetch(r.files + "/data/" + ask.dataset).then(function (res) { return res.ok ? res.text() : null; }).catch(function () { return null; }) |
|
| 97 | + | .then(function (text) { var m = meta(r); m.text = text; return m; }); |
|
| 98 | + | })).then(function (all) { reply({ value: all.filter(function (x) { return x.text !== null; }) }); }); |
|
| 99 | + | } else { |
|
| 100 | + | reply({ error: "ask for run, or history with a file name under data/" }); |
|
| 101 | + | } |
|
| 102 | + | }); |
|
| 103 | + | }); |
|
| 104 | + | ||
| 105 | + | /* The report timeline (#127, RP4): arrow keys step through the runs, |
|
| 106 | + | Play steps on by itself at the chosen speed, carried in the URL. */ |
|
| 107 | + | var tl = document.querySelector("[data-timeline]"); |
|
| 108 | + | if (tl) { |
|
| 109 | + | var stops = Array.prototype.slice.call(tl.querySelectorAll("[data-tl-stop]")); |
|
| 110 | + | var at = stops.findIndex(function (a) { return a.classList.contains("on"); }); |
|
| 111 | + | var go = function (i, play) { |
|
| 112 | + | if (i < 0 || i >= stops.length) return; |
|
| 113 | + | var u = new URL(stops[i].href, location.href); |
|
| 114 | + | if (play) u.searchParams.set("play", play); else u.searchParams.delete("play"); |
|
| 115 | + | location.href = u.toString(); |
|
| 116 | + | }; |
|
| 117 | + | document.addEventListener("keydown", function (e) { |
|
| 118 | + | if (e.target.closest("input, select, textarea, [contenteditable]")) return; |
|
| 119 | + | if (e.key === "ArrowLeft") { e.preventDefault(); go(at - 1, tl.dataset.play); } |
|
| 120 | + | if (e.key === "ArrowRight") { e.preventDefault(); go(at + 1, tl.dataset.play); } |
|
| 121 | + | }); |
|
| 122 | + | var speed = tl.querySelector("[data-tl-speed]"), button = tl.querySelector("[data-tl-play]"); |
|
| 123 | + | if (button) button.addEventListener("click", function () { |
|
| 124 | + | if (tl.dataset.play) { go(at, null); return; } |
|
| 125 | + | go(at >= stops.length - 1 ? 0 : at + 1, speed ? speed.value : "2"); |
|
| 126 | + | }); |
|
| 127 | + | if (speed) speed.addEventListener("change", function () { if (tl.dataset.play) go(at, speed.value); }); |
|
| 128 | + | if (tl.dataset.play) { |
|
| 129 | + | var next = at + 1; |
|
| 130 | + | if (next < stops.length) setTimeout(function () { go(next, tl.dataset.play); }, Number(tl.dataset.play) * 1000); |
|
| 131 | + | else setTimeout(function () { go(at, null); }, Number(tl.dataset.play) * 1000); |
|
| 132 | + | } |
|
| 133 | + | } |
|
| 134 | + | ||
| 78 | 135 | /* Theme switch in the header: dark, light, auto (follows the system); kept in the theme cookie. */ |
|
| 79 | 136 | var toggle = document.querySelector("[data-theme-toggle]"); |
|
| 80 | 137 | if (toggle) { |
|
| … | 452 unchanged lines |
modified assets/report-frame.js +20 −0
| … | 20 unchanged lines |
||
| 21 | 21 | window.addEventListener("load", send); |
|
| 22 | 22 | if (window.ResizeObserver) new ResizeObserver(send).observe(root); |
|
| 23 | 23 | else setInterval(send, 500); |
|
| 24 | + | // The frame interface (RP4): report.run() is the run on screen, and |
|
| 25 | + | // report.history("frames.csv") that file of data/ across the report's |
|
| 26 | + | // runs on its bookmark, oldest first, each { run, created_ms, current, |
|
| 27 | + | // text }. Both are promises; outside the hub there is no report object. |
|
| 28 | + | var asked = 0, waiting = {}; |
|
| 29 | + | window.addEventListener("message", function (e) { |
|
| 30 | + | var a = e.source === window.parent && e.data && e.data.oxygenhubAnswer; |
|
| 31 | + | if (!a || !waiting[a.n]) return; |
|
| 32 | + | var w = waiting[a.n]; delete waiting[a.n]; |
|
| 33 | + | if (a.error) w.reject(new Error(a.error)); else w.resolve(a.value); |
|
| 34 | + | }); |
|
| 35 | + | var ask = function (what, dataset) { |
|
| 36 | + | return new Promise(function (resolve, reject) { |
|
| 37 | + | var n = ++asked; |
|
| 38 | + | waiting[n] = { resolve: resolve, reject: reject }; |
|
| 39 | + | window.parent.postMessage({ oxygenhubAsk: { n: n, what: what, dataset: dataset } }, "*"); |
|
| 40 | + | setTimeout(function () { if (waiting[n]) { delete waiting[n]; reject(new Error("no answer from the hub")); } }, 30000); |
|
| 41 | + | }); |
|
| 42 | + | }; |
|
| 43 | + | window.report = { run: function () { return ask("run"); }, history: function (dataset) { return ask("history", dataset); } }; |
|
| 24 | 44 | window.parent.postMessage({ oxygenhubReport: { ready: true } }, "*"); |
|
| 25 | 45 | })(); |
modified crates/hub/src/web/reports.rs +121 −1
| … | 20 unchanged lines |
||
| 21 | 21 | bookmark: Option<String>, |
|
| 22 | 22 | /// One report, by id. |
|
| 23 | 23 | id: Option<i64>, |
|
| 24 | + | /// Another run of the same report to compare with, by id (#127, RP4). |
|
| 25 | + | compare: Option<i64>, |
|
| 26 | + | /// Playing: seconds per run, carried from stop to stop. |
|
| 27 | + | play: Option<u32>, |
|
| 24 | 28 | } |
|
| 25 | 29 | ||
| 30 | + | /// A stop on the timeline: one run of the report on its bookmark. |
|
| 31 | + | pub struct Stop { |
|
| 32 | + | pub id: i64, |
|
| 33 | + | pub href: String, |
|
| 34 | + | pub label: String, |
|
| 35 | + | pub tip: String, |
|
| 36 | + | pub current: bool, |
|
| 37 | + | pub compared: bool, |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | /// A metric shown against another run. |
|
| 41 | + | pub struct Compared { |
|
| 42 | + | pub key: String, |
|
| 43 | + | pub here: String, |
|
| 44 | + | pub there: String, |
|
| 45 | + | pub unit: String, |
|
| 46 | + | /// here − there, `+1.5`; empty when equal or missing. |
|
| 47 | + | pub delta: String, |
|
| 48 | + | pub trend: &'static str, |
|
| 49 | + | } |
|
| 50 | + | ||
| 26 | 51 | /// A metric as shown: its value, and its change against the run before. |
|
| 27 | 52 | pub struct MetricView { |
|
| 28 | 53 | pub key: String, |
|
| … | 66 unchanged lines |
||
| 95 | 120 | /// Where the frame loads the report's page from, when it has one. |
|
| 96 | 121 | frame: Option<String>, |
|
| 97 | 122 | files_base: String, |
|
| 123 | + | /// Oldest first, the run shown marked. |
|
| 124 | + | timeline: Vec<Stop>, |
|
| 125 | + | /// The run compared with, its metrics against this one's, and its frame. |
|
| 126 | + | compare: Option<(Report, Vec<Compared>, Option<String>)>, |
|
| 127 | + | /// Seconds per run while playing. |
|
| 128 | + | play: Option<u32>, |
|
| 129 | + | /// The runs, oldest first, as JSON for the frame interface |
|
| 130 | + | /// (`report.run()`, `report.history()`). |
|
| 131 | + | runs_json: String, |
|
| 98 | 132 | } |
|
| 99 | 133 | ||
| 100 | 134 | /// A number as a person would write it: no trailing zeros. |
|
| … | 66 unchanged lines |
||
| 167 | 201 | threshold: threshold.join(", "), |
|
| 168 | 202 | failing, |
|
| 169 | 203 | spark: spark(&values), |
|
| 204 | + | } |
|
| 205 | + | }) |
|
| 206 | + | .collect() |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | /// The metrics of this run against another's, every key either has. |
|
| 210 | + | fn compared(here: &[Metric], there: &[Metric]) -> Vec<Compared> { |
|
| 211 | + | let mut keys: Vec<&str> = here.iter().chain(there).map(|m| m.key.as_str()).collect(); |
|
| 212 | + | keys.sort(); |
|
| 213 | + | keys.dedup(); |
|
| 214 | + | keys.into_iter() |
|
| 215 | + | .map(|k| { |
|
| 216 | + | let (a, b) = (here.iter().find(|m| m.key == k), there.iter().find(|m| m.key == k)); |
|
| 217 | + | let (delta, trend) = match (a, b) { |
|
| 218 | + | (Some(a), Some(b)) if (a.value - b.value).abs() > f64::EPSILON => { |
|
| 219 | + | let up = a.value > b.value; |
|
| 220 | + | let trend = match a.better.as_str() { |
|
| 221 | + | "higher" if up => "better", |
|
| 222 | + | "higher" => "worse", |
|
| 223 | + | "lower" if up => "worse", |
|
| 224 | + | "lower" => "better", |
|
| 225 | + | _ => "same", |
|
| 226 | + | }; |
|
| 227 | + | (format!("{}{}", if up { "+" } else { "" }, num(a.value - b.value)), trend) |
|
| 228 | + | } |
|
| 229 | + | _ => (String::new(), "same"), |
|
| 230 | + | }; |
|
| 231 | + | Compared { |
|
| 232 | + | key: k.to_string(), |
|
| 233 | + | here: a.map(|m| num(m.value)).unwrap_or_else(|| "–".into()), |
|
| 234 | + | there: b.map(|m| num(m.value)).unwrap_or_else(|| "–".into()), |
|
| 235 | + | unit: a.or(b).map(|m| m.unit.clone()).unwrap_or_default(), |
|
| 236 | + | delta, |
|
| 237 | + | trend, |
|
| 170 | 238 | } |
|
| 171 | 239 | }) |
|
| 172 | 240 | .collect() |
|
| … | 96 unchanged lines |
||
| 269 | 337 | for r in runs.iter().skip(at).rev() { |
|
| 270 | 338 | series.push(reports::metrics(&app.db, r.id).await.map_err(e)?); |
|
| 271 | 339 | } |
|
| 272 | - | let files_base = format!("/{owner}/{name}/reports/files/{}/{}", report.id, reports::file_mac(&app, report.id)); |
|
| 340 | + | let base_of = |id: i64| format!("/{owner}/{name}/reports/files/{id}/{}", reports::file_mac(&app, id)); |
|
| 341 | + | let files_base = base_of(report.id); |
|
| 273 | 342 | let frame = reports::file(&app.db, report.id, "index.html").await.map_err(e)?.map(|_| format!("{files_base}/index.html")); |
|
| 343 | + | let label = |r: &Report| r.run_number.map(|n| format!("run #{n}")).unwrap_or_else(|| "upload".into()); |
|
| 344 | + | let page = format!("/{owner}/{name}/reports/{rname}"); |
|
| 345 | + | let keep = |id: i64| { |
|
| 346 | + | let mut url = format!("{page}?id={id}"); |
|
| 347 | + | if let Some(c) = q.compare.filter(|c| *c != id) { |
|
| 348 | + | url.push_str(&format!("&compare={c}")); |
|
| 349 | + | } |
|
| 350 | + | if let Some(p) = q.play { |
|
| 351 | + | url.push_str(&format!("&play={p}")); |
|
| 352 | + | } |
|
| 353 | + | url |
|
| 354 | + | }; |
|
| 355 | + | let timeline: Vec<Stop> = runs |
|
| 356 | + | .iter() |
|
| 357 | + | .rev() |
|
| 358 | + | .map(|r| Stop { |
|
| 359 | + | id: r.id, |
|
| 360 | + | href: keep(r.id), |
|
| 361 | + | label: label(r), |
|
| 362 | + | tip: format!("{}{}", label(r), if r.summary.is_empty() { String::new() } else { format!(": {}", r.summary) }), |
|
| 363 | + | current: r.id == report.id, |
|
| 364 | + | compared: Some(r.id) == q.compare, |
|
| 365 | + | }) |
|
| 366 | + | .collect(); |
|
| 367 | + | // Another run to compare with (RP4): every metric, both values, the |
|
| 368 | + | // difference; and its page beside this one's. |
|
| 369 | + | let compare = match q.compare.filter(|c| *c != report.id) { |
|
| 370 | + | Some(cid) => match reports::get(&app.db, row.id, cid).await.map_err(e)?.filter(|r| r.name == rname) { |
|
| 371 | + | Some(other) => { |
|
| 372 | + | let theirs = reports::metrics(&app.db, other.id).await.map_err(e)?; |
|
| 373 | + | let rows = compared(&now, &theirs); |
|
| 374 | + | let f = |
|
| 375 | + | reports::file(&app.db, other.id, "index.html").await.map_err(e)?.map(|_| format!("{}/index.html", base_of(other.id))); |
|
| 376 | + | Some((other, rows, f)) |
|
| 377 | + | } |
|
| 378 | + | None => None, |
|
| 379 | + | }, |
|
| 380 | + | None => None, |
|
| 381 | + | }; |
|
| 382 | + | let runs_json = serde_json::to_string( |
|
| 383 | + | &runs |
|
| 384 | + | .iter() |
|
| 385 | + | .rev() |
|
| 386 | + | .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 })) |
|
| 387 | + | .collect::<Vec<_>>(), |
|
| 388 | + | ) |
|
| 389 | + | .unwrap_or_default(); |
|
| 274 | 390 | let repo = RepoShell::new(&app, &row, "reports").with_role(role); |
|
| 275 | 391 | Ok(Page(ReportPage { |
|
| 276 | 392 | shell, |
|
| … | 5 unchanged lines |
||
| 282 | 398 | frame, |
|
| 283 | 399 | files_base, |
|
| 284 | 400 | report, |
|
| 401 | + | timeline, |
|
| 402 | + | compare, |
|
| 403 | + | play: q.play.map(|p| p.clamp(1, 60)), |
|
| 404 | + | runs_json, |
|
| 285 | 405 | })) |
|
| 286 | 406 | } |
|
| 287 | 407 | ||
| … | 106 unchanged lines |
modified crates/hub/tests/flow.rs +27 −0
| … | 1056 unchanged lines |
||
| 1057 | 1057 | let data = src.replace("/index.html", "/data/frames.csv"); |
|
| 1058 | 1058 | assert_eq!(get_bytes(&h.app, &data).await, (StatusCode::OK, data_bytes.clone())); |
|
| 1059 | 1059 | // A link with another report's MAC, or a file it lacks, finds nothing. |
|
| 1060 | + | // A second run of the report: the timeline has two stops, and the |
|
| 1061 | + | // first can be compared with it (RP4). |
|
| 1062 | + | let first: i64 = sqlx::query_scalar("SELECT id FROM reports WHERE name = 'frame-timing'").fetch_one(&h.app.db).await.unwrap(); |
|
| 1063 | + | let second: i64 = sqlx::query_scalar( |
|
| 1064 | + | "INSERT INTO reports (repo_id, name, kind, title, summary, context, run_id, job_id, run_number, job, bookmark, state, manifest, files, bytes, created_ms) \ |
|
| 1065 | + | SELECT repo_id, name, kind, title, 'Median 18.0 ms', context, run_id, NULL, 2, job, bookmark, state, manifest, files, bytes, created_ms + 1000 FROM reports WHERE id = ? RETURNING id", |
|
| 1066 | + | ) |
|
| 1067 | + | .bind(first) |
|
| 1068 | + | .fetch_one(&h.app.db) |
|
| 1069 | + | .await |
|
| 1070 | + | .unwrap(); |
|
| 1071 | + | sqlx::query( |
|
| 1072 | + | "INSERT INTO report_metrics (report_id, key, value, unit, better, fail_above) VALUES (?, 'median_ms', 18.0, 'ms', 'lower', 20)", |
|
| 1073 | + | ) |
|
| 1074 | + | .bind(second) |
|
| 1075 | + | .execute(&h.app.db) |
|
| 1076 | + | .await |
|
| 1077 | + | .unwrap(); |
|
| 1078 | + | let (_, page) = call(&h.app, "GET", "/andy/demo/reports/frame-timing", None, None).await; |
|
| 1079 | + | assert_eq!(page.matches("data-tl-stop").count(), 2, "a stop per run"); |
|
| 1080 | + | assert!(page.contains("Median 18.0 ms"), "the newest run is shown"); |
|
| 1081 | + | let (_, page) = call(&h.app, "GET", &format!("/andy/demo/reports/frame-timing?id={first}&compare={second}&play=3"), None, None).await; |
|
| 1082 | + | assert!(page.contains("This run against run #2") && page.contains("-1.6"), "every metric against the other run"); |
|
| 1083 | + | let stop = page.split("data-tl-stop").next().unwrap_or("").rsplit("href=").next().unwrap_or("").to_string(); |
|
| 1084 | + | assert!(page.contains("data-play=\"3\""), "playing"); |
|
| 1085 | + | assert!(stop.contains("play") && stop.contains('3'), "playing carries on from stop to stop: {stop}"); |
|
| 1086 | + | assert!(page.contains("data-report-runs="), "the frame interface knows the runs"); |
|
| 1060 | 1087 | let forged = src.replacen("/files/", "/files/9", 1); |
|
| 1061 | 1088 | assert_eq!(get_bytes(&h.app, &forged).await.0, StatusCode::NOT_FOUND); |
|
| 1062 | 1089 | assert_eq!(get_bytes(&h.app, &src.replace("/index.html", "/secret.txt")).await.0, StatusCode::NOT_FOUND); |
|
| … | 1450 unchanged lines |
modified docs/reports.md +8 −2
| … | 204 unchanged lines |
||
| 205 | 205 | sandboxed (`allow-scripts` only) rather than on a second origin: it |
|
| 206 | 206 | carries no session, so its files are served under a per-report MAC in the |
|
| 207 | 207 | link, with `Content-Security-Policy: sandbox allow-scripts` and nothing |
|
| 208 | - | beyond this hub to load. Retention (§3.3), the timeline (3) and `oh |
|
| 209 | - | report` (4) follow. |
|
| 208 | + | beyond this hub to load. The timeline (3) is built too: one stop per |
|
| 209 | + | run on the bookmark, arrow keys to step, Play at 1 to 10 s a run (carried |
|
| 210 | + | in the URL as `play`), Compare against any other run (`compare`) with |
|
| 211 | + | every metric's difference and the two pages side by side, and the frame |
|
| 212 | + | interface: inside the frame, `report.run()` and `report.history("file")` |
|
| 213 | + | answer from the hub page by postMessage, `history` reading that file of |
|
| 214 | + | `data/` from each run's capability URL. Retention (§3.3) and `oh report` |
|
| 215 | + | (4) follow. |
|
| 210 | 216 | ||
| 211 | 217 | ||
| 212 | 218 | 1. Storage and index: the `report` output kind in OxygenFlow, the runner's |
|
| … | 5 unchanged lines |
modified templates/report.html +37 −3
| … | 14 unchanged lines |
||
| 15 | 15 | </div> |
|
| 16 | 16 | </section> |
|
| 17 | 17 | ||
| 18 | - | {% if !metrics.is_empty() %} |
|
| 18 | + | {% if timeline.len() > 1 %} |
|
| 19 | + | <section class="rep-timeline" data-timeline{% if let Some(p) = play %} data-play="{{ p }}"{% endif %}> |
|
| 20 | + | <div class="rep-tl-bar"> |
|
| 21 | + | <button type="button" class="rep-tl-btn" data-tl-play aria-label="{% if play.is_some() %}Stop playing{% else %}Play through the runs{% endif %}">{% if play.is_some() %}■ Stop{% else %}▶ Play{% endif %}</button> |
|
| 22 | + | <label class="small mute">every <select data-tl-speed aria-label="Seconds per run">{% for s in [1u32, 2, 3, 5, 10] %}<option value="{{ s }}"{% if play.unwrap_or(2) == *s %} selected{% endif %}>{{ s }} s</option>{% endfor %}</select></label> |
|
| 23 | + | <form class="inline small" method="get" action="{{ repo.url }}/reports/{{ report.name }}"> |
|
| 24 | + | <input type="hidden" name="id" value="{{ report.id }}"> |
|
| 25 | + | <label class="mute">compare with <select name="compare" onchange="this.form.submit()" aria-label="Run to compare with"><option value="">none</option>{% for t in timeline %}{% if !t.current %}<option value="{{ t.id }}"{% if t.compared %} selected{% endif %}>{{ t.label }}</option>{% endif %}{% endfor %}</select></label> |
|
| 26 | + | <noscript><button>Compare</button></noscript> |
|
| 27 | + | </form> |
|
| 28 | + | <span class="small mute rep-tl-keys">← → step through runs</span> |
|
| 29 | + | </div> |
|
| 30 | + | <ol class="rep-tl-stops"> |
|
| 31 | + | {% for t in timeline %}<li><a href="{{ t.href }}" class="rep-tl-stop{% if t.current %} on{% endif %}{% if t.compared %} cmp{% endif %}" title="{{ t.tip }}"{% if t.current %} aria-current="true"{% endif %} data-tl-stop><span class="sr">{{ t.label }}</span></a></li>{% endfor %} |
|
| 32 | + | </ol> |
|
| 33 | + | <div class="rep-tl-ends small mute"><span>{% if let Some(f) = timeline.first() %}{{ f.label }}{% endif %}</span><span>{% if let Some(l) = timeline.last() %}{{ l.label }}{% endif %}</span></div> |
|
| 34 | + | </section> |
|
| 35 | + | {% endif %} |
|
| 36 | + | ||
| 37 | + | {% if let Some((other, rows, _)) = compare %} |
|
| 38 | + | <section class="panel"> |
|
| 39 | + | <h2 class="rep-cmp-h">This run against {% if let Some(n) = other.run_number %}run #{{ n }}{% else %}an upload{% endif %} <a class="small" href="{{ repo.url }}/reports/{{ report.name }}?id={{ report.id }}">stop comparing</a></h2> |
|
| 40 | + | <table class="rep-table"> |
|
| 41 | + | <thead><tr><th>Metric</th><th class="num">{% if let Some(n) = report.run_number %}run #{{ n }}{% else %}this{% endif %}</th><th class="num">{% if let Some(n) = other.run_number %}run #{{ n }}{% else %}that{% endif %}</th><th class="num">Difference</th></tr></thead> |
|
| 42 | + | <tbody> |
|
| 43 | + | {% for c in rows %} |
|
| 44 | + | <tr><td><code>{{ c.key }}</code></td><td class="num"><strong>{{ c.here }}</strong>{% if !c.unit.is_empty() %} <small class="mute">{{ c.unit }}</small>{% endif %}</td><td class="num">{{ c.there }}</td><td class="num">{% if !c.delta.is_empty() %}<span class="rep-delta {{ c.trend }}">{{ c.delta }}</span>{% else %}<span class="mute">–</span>{% endif %}</td></tr> |
|
| 45 | + | {% endfor %} |
|
| 46 | + | </tbody> |
|
| 47 | + | </table> |
|
| 48 | + | </section> |
|
| 49 | + | {% endif %} |
|
| 50 | + | ||
| 51 | + | {% if !metrics.is_empty() && compare.is_none() %} |
|
| 19 | 52 | <section class="panel"> |
|
| 20 | 53 | <table class="rep-table"> |
|
| 21 | 54 | <thead><tr><th>Metric</th><th class="num">Value</th><th class="num">Change</th><th>Across runs</th><th>Threshold</th></tr></thead> |
|
| … | 13 unchanged lines |
||
| 35 | 68 | {% endif %} |
|
| 36 | 69 | ||
| 37 | 70 | {% 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> |
|
| 71 | + | <section class="rep-frame-wrap{% if let Some((_, _, Some(_))) = compare %} rep-side{% endif %}"> |
|
| 72 | + | <iframe class="rep-frame" src="{{ src }}" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" title="{{ report.title }}" data-report-runs="{{ runs_json }}" data-report-current="{{ report.id }}"></iframe> |
|
| 73 | + | {% if let Some((other, _, Some(osrc))) = compare %}<iframe class="rep-frame" src="{{ osrc }}" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" title="{{ report.title }}, {% if let Some(n) = other.run_number %}run #{{ n }}{% else %}upload{% endif %}" data-report-runs="{{ runs_json }}" data-report-current="{{ other.id }}"></iframe>{% endif %} |
|
| 40 | 74 | <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 | 75 | </section> |
|
| 42 | 76 | {% endif %} |
|
| … | 18 unchanged lines |
modified templates/style.css +18 −0
| … | 952 unchanged lines |
||
| 953 | 953 | .rep-runs { list-style: none; padding: 0; margin: 0; } |
|
| 954 | 954 | .rep-runs li { padding: .25em 0; } |
|
| 955 | 955 | .rep-runs li.on a { font-weight: 700; } |
|
| 956 | + | ||
| 957 | + | /* Reports timeline (oxygenhub#127, RP4) */ |
|
| 958 | + | .rep-timeline { margin: 1em 0; padding: .8em 1em; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); } |
|
| 959 | + | .rep-tl-bar { display: flex; flex-wrap: wrap; align-items: center; gap: .6em 1.2em; margin-bottom: .7em; } |
|
| 960 | + | .rep-tl-btn { font: inherit; font-weight: 600; padding: .3em .9em; border-radius: 999px; border: 1px solid var(--acc); color: var(--acc); background: transparent; cursor: pointer; } |
|
| 961 | + | .rep-tl-btn:hover { background: color-mix(in srgb, var(--acc) 12%, transparent); } |
|
| 962 | + | .rep-tl-keys { margin-left: auto; } |
|
| 963 | + | .rep-tl-stops { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: 2px; } |
|
| 964 | + | .rep-tl-stops li { flex: 1; min-width: 4px; } |
|
| 965 | + | .rep-tl-stop { display: block; height: 14px; border-radius: 3px; background: color-mix(in srgb, var(--acc) 22%, var(--soft)); } |
|
| 966 | + | .rep-tl-stop:hover { background: color-mix(in srgb, var(--acc) 55%, var(--soft)); } |
|
| 967 | + | .rep-tl-stop.on { background: var(--acc); height: 22px; } |
|
| 968 | + | .rep-tl-stop.cmp { background: var(--warn); height: 22px; } |
|
| 969 | + | .rep-tl-ends { display: flex; justify-content: space-between; margin-top: .3em; } |
|
| 970 | + | .rep-cmp-h { display: flex; align-items: baseline; gap: 1em; font-size: 1.05em; margin: 0 0 .6em; } |
|
| 971 | + | .rep-frame-wrap.rep-side { display: grid; grid-template-columns: 1fr 1fr; gap: 1em; } |
|
| 972 | + | .rep-frame-wrap.rep-side > p { grid-column: 1 / -1; } |
|
| 973 | + | .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } |