| 1 | |
|
| 2 | |
|
| 3 | |
|
| 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 | |
|
| 16 | | const HISTORY: i64 = 60; |
| 17 | |
|
| 18 | | #[derive(Deserialize, Default)] |
| 19 | | pub struct ReportsQuery { |
| 20 | | |
| 21 | | bookmark: Option<String>, |
| 22 | | |
| 23 | | id: Option<i64>, |
| 24 | | |
| 25 | | compare: Option<i64>, |
| 26 | | |
| 27 | | play: Option<u32>, |
| 28 | | } |
| 29 | |
|
| 30 | |
|
| 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 | |
|
| 41 | | pub struct Compared { |
| 42 | | pub key: String, |
| 43 | | pub here: String, |
| 44 | | pub there: String, |
| 45 | | pub unit: String, |
| 46 | | |
| 47 | | pub delta: String, |
| 48 | | pub trend: &'static str, |
| 49 | | } |
| 50 | |
|
| 51 | |
|
| 52 | | pub struct MetricView { |
| 53 | | pub key: String, |
| 54 | | pub value: String, |
| 55 | | pub unit: String, |
| 56 | | |
| 57 | | pub delta: String, |
| 58 | | |
| 59 | | pub trend: &'static str, |
| 60 | | |
| 61 | | pub threshold: String, |
| 62 | | |
| 63 | | pub failing: bool, |
| 64 | | |
| 65 | | pub spark: String, |
| 66 | | } |
| 67 | |
|
| 68 | |
|
| 69 | | pub struct Item { |
| 70 | | pub report: Report, |
| 71 | | pub metrics: Vec<MetricView>, |
| 72 | | |
| 73 | | pub health: &'static str, |
| 74 | | |
| 75 | | pub badge: String, |
| 76 | | } |
| 77 | |
|
| 78 | |
|
| 79 | | pub struct Builtin { |
| 80 | | pub big: String, |
| 81 | | pub sub: String, |
| 82 | | pub health: &'static str, |
| 83 | | pub when_ms: i64, |
| 84 | | pub bookmark: String, |
| 85 | | } |
| 86 | |
|
| 87 | |
|
| 88 | 1× | fn health_of(metrics: &[MetricView]) -> (&'static str, String) { |
| 89 | 1× | let with = metrics.iter().filter(|m| !m.threshold.is_empty()).count(); |
| 90 | 1× | let failing = metrics.iter().filter(|m| m.failing).count(); |
| 91 | 1× | match (with, failing) { |
| 92 | 0× | (0, _) => ("neutral", format!("{} metric{}", metrics.len(), if metrics.len() == 1 { "" } else { "s" })), |
| 93 | 1× | (_, 0) => ("good", format!("{with} threshold{} met", if with == 1 { "" } else { "s" })), |
| 94 | 0× | (_, f) => ("bad", format!("{f} over {}", if f == 1 { "its threshold" } else { "their thresholds" })), |
| 95 | | } |
| 96 | 1× | } |
| 97 | |
|
| 98 | | #[derive(Template)] |
| 99 | | #[template(path = "reports.html")] |
| 100 | | pub struct ReportsPage { |
| 101 | | shell: Shell, |
| 102 | | repo: RepoShell, |
| 103 | | bookmark: String, |
| 104 | | items: Vec<Item>, |
| 105 | | coverage: Option<Builtin>, |
| 106 | | tests: Option<Builtin>, |
| 107 | | } |
| 108 | |
|
| 109 | | #[derive(Template)] |
| 110 | | #[template(path = "report.html")] |
| 111 | | pub struct ReportPage { |
| 112 | | shell: Shell, |
| 113 | | repo: RepoShell, |
| 114 | | report: Report, |
| 115 | | bookmarks: Vec<String>, |
| 116 | | metrics: Vec<MetricView>, |
| 117 | | context: Vec<(String, String)>, |
| 118 | | |
| 119 | | runs: Vec<Report>, |
| 120 | | |
| 121 | | frame: Option<String>, |
| 122 | | files_base: String, |
| 123 | | |
| 124 | | timeline: Vec<Stop>, |
| 125 | | |
| 126 | | compare: Option<(Report, Vec<Compared>, Option<String>)>, |
| 127 | | |
| 128 | | play: Option<u32>, |
| 129 | | |
| 130 | | |
| 131 | | runs_json: String, |
| 132 | | } |
| 133 | |
|
| 134 | |
|
| 135 | 19× | fn num(v: f64) -> String { |
| 136 | 19× | if v.fract() == 0.0 && v.abs() < 1e15 { |
| 137 | 11× | format!("{v:.0}") |
| 138 | | } else { |
| 139 | 8× | let s = format!("{v:.3}"); |
| 140 | 8× | s.trim_end_matches('0').trim_end_matches('.').to_string() |
| 141 | | } |
| 142 | 19× | } |
| 143 | |
|
| 144 | 9× | fn spark(values: &[f64]) -> String { |
| 145 | 9× | if values.len() < 2 { |
| 146 | 7× | return String::new(); |
| 147 | 2× | } |
| 148 | 2× | let (w, h) = (120.0, 28.0); |
| 149 | 2× | let lo = values.iter().cloned().fold(f64::INFINITY, f64::min); |
| 150 | 2× | let hi = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); |
| 151 | 2× | let span = if hi > lo { hi - lo } else { 1.0 }; |
| 152 | 2× | let n = values.len(); |
| 153 | 2× | let pts: Vec<String> = values |
| 154 | 2× | .iter() |
| 155 | 2× | .enumerate() |
| 156 | 5× | .map(|(i, v)| format!("{:.1},{:.1}", w * i as f64 / (n - 1) as f64, 2.0 + (h - 4.0) * (hi - v) / span)) |
| 157 | 2× | .collect(); |
| 158 | 2× | let last = pts.last().cloned().unwrap_or_default(); |
| 159 | 2× | let (lx, ly) = last.split_once(',').unwrap_or(("0", "0")); |
| 160 | 2× | format!( |
| 161 | | 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>"#, |
| 162 | 2× | pts.join(" ") |
| 163 | | ) |
| 164 | 9× | } |
| 165 | |
|
| 166 | |
|
| 167 | |
|
| 168 | 7× | fn views(now: &[Metric], before: &[Metric], series: &[Vec<Metric>]) -> Vec<MetricView> { |
| 169 | 7× | now.iter() |
| 170 | 7× | .map(|m| { |
| 171 | 7× | let prev = before.iter().find(|b| b.key == m.key).map(|b| b.value); |
| 172 | 7× | let (delta, trend) = match prev { |
| 173 | 2× | Some(p) if (m.value - p).abs() > f64::EPSILON => { |
| 174 | 2× | let up = m.value > p; |
| 175 | 2× | let trend = match m.better.as_str() { |
| 176 | 2× | "higher" if up => "better", |
| 177 | 0× | "higher" => "worse", |
| 178 | 2× | "lower" if up => "worse", |
| 179 | 0× | "lower" => "better", |
| 180 | 0× | _ => "same", |
| 181 | | }; |
| 182 | 2× | (format!("{}{}", if up { "+" } else { "" }, num(m.value - p)), trend) |
| 183 | | } |
| 184 | 5× | _ => (String::new(), "same"), |
| 185 | | }; |
| 186 | 7× | let mut threshold = Vec::new(); |
| 187 | 7× | if let Some(a) = m.fail_above { |
| 188 | 6× | threshold.push(format!("fails above {}", num(a))); |
| 189 | 6× | } |
| 190 | 7× | if let Some(b) = m.fail_below { |
| 191 | 0× | threshold.push(format!("fails below {}", num(b))); |
| 192 | 7× | } |
| 193 | 7× | let failing = m.fail_above.is_some_and(|a| m.value > a) || m.fail_below.is_some_and(|b| m.value < b); |
| 194 | 7× | let values: Vec<f64> = series.iter().filter_map(|ms| ms.iter().find(|x| x.key == m.key).map(|x| x.value)).collect(); |
| 195 | 7× | MetricView { |
| 196 | 7× | key: m.key.clone(), |
| 197 | 7× | value: num(m.value), |
| 198 | 7× | unit: m.unit.clone(), |
| 199 | 7× | delta, |
| 200 | 7× | trend, |
| 201 | 7× | threshold: threshold.join(", "), |
| 202 | 7× | failing, |
| 203 | 7× | spark: spark(&values), |
| 204 | 7× | } |
| 205 | 7× | }) |
| 206 | 7× | .collect() |
| 207 | 7× | } |
| 208 | |
|
| 209 | |
|
| 210 | 1× | fn compared(here: &[Metric], there: &[Metric]) -> Vec<Compared> { |
| 211 | 2× | let mut keys: Vec<&str> = here.iter().chain(there).map(|m| m.key.as_str()).collect(); |
| 212 | 1× | keys.sort(); |
| 213 | 1× | keys.dedup(); |
| 214 | 1× | keys.into_iter() |
| 215 | 1× | .map(|k| { |
| 216 | 1× | let (a, b) = (here.iter().find(|m| m.key == k), there.iter().find(|m| m.key == k)); |
| 217 | 1× | let (delta, trend) = match (a, b) { |
| 218 | 1× | (Some(a), Some(b)) if (a.value - b.value).abs() > f64::EPSILON => { |
| 219 | 1× | let up = a.value > b.value; |
| 220 | 1× | let trend = match a.better.as_str() { |
| 221 | 1× | "higher" if up => "better", |
| 222 | 0× | "higher" => "worse", |
| 223 | 1× | "lower" if up => "worse", |
| 224 | 1× | "lower" => "better", |
| 225 | 0× | _ => "same", |
| 226 | | }; |
| 227 | 1× | (format!("{}{}", if up { "+" } else { "" }, num(a.value - b.value)), trend) |
| 228 | | } |
| 229 | 0× | _ => (String::new(), "same"), |
| 230 | | }; |
| 231 | | Compared { |
| 232 | 1× | key: k.to_string(), |
| 233 | 1× | here: a.map(|m| num(m.value)).unwrap_or_else(|| "–".into()), |
| 234 | 1× | there: b.map(|m| num(m.value)).unwrap_or_else(|| "–".into()), |
| 235 | 1× | unit: a.or(b).map(|m| m.unit.clone()).unwrap_or_default(), |
| 236 | 1× | delta, |
| 237 | 1× | trend, |
| 238 | | } |
| 239 | 1× | }) |
| 240 | 1× | .collect() |
| 241 | 1× | } |
| 242 | |
|
| 243 | |
|
| 244 | |
|
| 245 | 2× | pub async fn overview( |
| 246 | 2× | State(app): State<Shared>, |
| 247 | 2× | viewer: Viewer, |
| 248 | 2× | Path((owner, name)): Path<(String, String)>, |
| 249 | 2× | Query(q): Query<ReportsQuery>, |
| 250 | 2× | ) -> Result<Page<ReportsPage>, Error> { |
| 251 | 2× | let shell = Shell::new(&app, &format!("/{owner}/{name}/reports"), &viewer); |
| 252 | 2× | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 253 | 2× | let e = |e| Error::from_db(shell.clone(), e); |
| 254 | 2× | let bookmark = q.bookmark.clone().filter(|b| !b.is_empty()); |
| 255 | 2× | let latest = reports::latest(&app.db, row.id, bookmark.as_deref()).await.map_err(e)?; |
| 256 | 2× | let mut items = Vec::new(); |
| 257 | 2× | for r in latest { |
| 258 | 1× | let history = reports::history(&app.db, row.id, &r.name, &r.bookmark, 2).await.map_err(e)?; |
| 259 | 1× | let now = reports::metrics(&app.db, r.id).await.map_err(e)?; |
| 260 | 1× | let before = match history.get(1) { |
| 261 | 0× | Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?, |
| 262 | 1× | None => vec![], |
| 263 | | }; |
| 264 | 1× | let metrics = views(&now, &before, &[]); |
| 265 | 1× | let (health, badge) = health_of(&metrics); |
| 266 | 1× | items.push(Item { metrics, report: r, health, badge }); |
| 267 | | } |
| 268 | 2× | let repo = RepoShell::new(&app, &row, "reports").with_role(role); |
| 269 | | |
| 270 | | |
| 271 | 4× | let pick = |list: Vec<String>| bookmark.clone().or_else(|| list.iter().find(|b| **b == row.default_bookmark).or(list.first()).cloned()); |
| 272 | 2× | let coverage = match pick(crate::coverage::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.coverage) { |
| 273 | 1× | Some(b) => crate::coverage::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|c| Builtin { |
| 274 | 1× | big: c.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()), |
| 275 | 1× | sub: format!("{} of {} lines covered · {}", c.lines_hit, c.lines_found, c.source()), |
| 276 | 1× | health: match c.percent() { |
| 277 | 1× | Some(p) if p >= 75.0 => "good", |
| 278 | 0× | Some(p) if p < 50.0 => "bad", |
| 279 | 0× | _ => "neutral", |
| 280 | | }, |
| 281 | 1× | when_ms: c.created_ms, |
| 282 | 1× | bookmark: c.bookmark.clone(), |
| 283 | 1× | }), |
| 284 | 1× | None => None, |
| 285 | | }; |
| 286 | 2× | let tests = match pick(crate::junit::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.tests) { |
| 287 | 0× | Some(b) => crate::junit::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|t| Builtin { |
| 288 | 0× | big: if t.broken() > 0 { format!("{} failing", t.broken()) } else { format!("{} passed", t.passed) }, |
| 289 | 0× | sub: t.summary(), |
| 290 | 0× | health: if t.broken() > 0 { "bad" } else { "good" }, |
| 291 | 0× | when_ms: t.created_ms, |
| 292 | 0× | bookmark: t.bookmark.clone(), |
| 293 | 0× | }), |
| 294 | 2× | None => None, |
| 295 | | }; |
| 296 | 2× | Ok(Page(ReportsPage { shell, coverage, tests, repo, bookmark: bookmark.unwrap_or_default(), items })) |
| 297 | 2× | } |
| 298 | |
|
| 299 | |
|
| 300 | |
|
| 301 | |
|
| 302 | 4× | pub async fn show( |
| 303 | 4× | State(app): State<Shared>, |
| 304 | 4× | viewer: Viewer, |
| 305 | 4× | Path((owner, name, rname)): Path<(String, String, String)>, |
| 306 | 4× | Query(q): Query<ReportsQuery>, |
| 307 | 4× | ) -> Result<Page<ReportPage>, Error> { |
| 308 | 4× | let shell = Shell::new(&app, &format!("/{owner}/{name}/reports/{rname}"), &viewer); |
| 309 | 4× | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
| 310 | 4× | let e = |e| Error::from_db(shell.clone(), e); |
| 311 | 4× | let report = match q.id { |
| 312 | 2× | Some(id) => reports::get(&app.db, row.id, id).await.map_err(e)?.filter(|r| r.name == rname), |
| 313 | | None => { |
| 314 | 2× | let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?; |
| 315 | 2× | let bookmark = q |
| 316 | 2× | .bookmark |
| 317 | 2× | .clone() |
| 318 | 2× | .or_else(|| bookmarks.iter().find(|b| **b == row.default_bookmark).cloned()) |
| 319 | 2× | .or_else(|| bookmarks.first().cloned()); |
| 320 | 2× | match bookmark { |
| 321 | 2× | Some(b) => reports::history(&app.db, row.id, &rname, &b, 1).await.map_err(e)?.into_iter().next(), |
| 322 | 0× | None => None, |
| 323 | | } |
| 324 | | } |
| 325 | | }; |
| 326 | 4× | let report = report.ok_or_else(|| Error::not_found(shell.clone()))?; |
| 327 | 4× | let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?; |
| 328 | 4× | let runs = reports::history(&app.db, row.id, &rname, &report.bookmark, HISTORY).await.map_err(e)?; |
| 329 | 4× | let now = reports::metrics(&app.db, report.id).await.map_err(e)?; |
| 330 | | |
| 331 | 56× | let at = runs.iter().position(|r| r.id == report.id).unwrap_or(0); |
| 332 | 4× | let before = match runs.get(at + 1) { |
| 333 | 1× | Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?, |
| 334 | 3× | None => vec![], |
| 335 | | }; |
| 336 | 4× | let mut series = Vec::new(); |
| 337 | 5× | for r in runs.iter().skip(at).rev() { |
| 338 | 5× | series.push(reports::metrics(&app.db, r.id).await.map_err(e)?); |
| 339 | | } |
| 340 | 61× | let base_of = |id: i64| format!("/{owner}/{name}/reports/files/{id}/{}", reports::file_mac(&app, id)); |
| 341 | 4× | let files_base = base_of(report.id); |
| 342 | 4× | let frame = reports::file(&app.db, report.id, "index.html").await.map_err(e)?.map(|_| format!("{files_base}/index.html")); |
| 343 | 114× | let label = |r: &Report| r.run_number.map(|n| format!("run #{n}")).unwrap_or_else(|| "upload".into()); |
| 344 | 4× | let page = format!("/{owner}/{name}/reports/{rname}"); |
| 345 | 57× | let keep = |id: i64| { |
| 346 | 57× | let mut url = format!("{page}?id={id}"); |
| 347 | 57× | if let Some(c) = q.compare.filter(|c| *c != id) { |
| 348 | 1× | url.push_str(&format!("&compare={c}")); |
| 349 | 56× | } |
| 350 | 57× | if let Some(p) = q.play { |
| 351 | 2× | url.push_str(&format!("&play={p}")); |
| 352 | 55× | } |
| 353 | 57× | url |
| 354 | 57× | }; |
| 355 | 4× | let timeline: Vec<Stop> = runs |
| 356 | 4× | .iter() |
| 357 | 4× | .rev() |
| 358 | 4× | .map(|r| Stop { |
| 359 | 57× | id: r.id, |
| 360 | 57× | href: keep(r.id), |
| 361 | 57× | label: label(r), |
| 362 | 57× | tip: format!("{}{}", label(r), if r.summary.is_empty() { String::new() } else { format!(": {}", r.summary) }), |
| 363 | 57× | current: r.id == report.id, |
| 364 | 57× | compared: Some(r.id) == q.compare, |
| 365 | 57× | }) |
| 366 | 4× | .collect(); |
| 367 | | |
| 368 | | |
| 369 | 4× | let compare = match q.compare.filter(|c| *c != report.id) { |
| 370 | 1× | Some(cid) => match reports::get(&app.db, row.id, cid).await.map_err(e)?.filter(|r| r.name == rname) { |
| 371 | 1× | Some(other) => { |
| 372 | 1× | let theirs = reports::metrics(&app.db, other.id).await.map_err(e)?; |
| 373 | 1× | let rows = compared(&now, &theirs); |
| 374 | 1× | let f = |
| 375 | 1× | reports::file(&app.db, other.id, "index.html").await.map_err(e)?.map(|_| format!("{}/index.html", base_of(other.id))); |
| 376 | 1× | Some((other, rows, f)) |
| 377 | | } |
| 378 | 0× | None => None, |
| 379 | | }, |
| 380 | 3× | None => None, |
| 381 | | }; |
| 382 | 4× | let runs_json = serde_json::to_string( |
| 383 | 4× | &runs |
| 384 | 4× | .iter() |
| 385 | 4× | .rev() |
| 386 | 57× | .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 | 4× | .collect::<Vec<_>>(), |
| 388 | | ) |
| 389 | 4× | .unwrap_or_default(); |
| 390 | 4× | let repo = RepoShell::new(&app, &row, "reports").with_role(role); |
| 391 | | Ok(Page(ReportPage { |
| 392 | 4× | shell, |
| 393 | 4× | repo, |
| 394 | 4× | context: report.context_rows(), |
| 395 | 4× | metrics: views(&now, &before, &series), |
| 396 | 4× | bookmarks, |
| 397 | 4× | runs, |
| 398 | 4× | frame, |
| 399 | 4× | files_base, |
| 400 | 4× | report, |
| 401 | 4× | timeline, |
| 402 | 4× | compare, |
| 403 | 4× | play: q.play.map(|p| p.clamp(1, 60)), |
| 404 | 4× | runs_json, |
| 405 | | })) |
| 406 | 4× | } |
| 407 | |
|
| 408 | |
|
| 409 | 3× | fn with_frame_script(page: Vec<u8>, version: &str) -> Vec<u8> { |
| 410 | 3× | let tag = format!(r#"<script src="/static/report-frame.js?v={version}"></script>"#); |
| 411 | 3× | let lower = page.to_ascii_lowercase(); |
| 412 | 68× | let find = |needle: &[u8]| lower.windows(needle.len()).position(|w| w == needle); |
| 413 | 3× | let at = find(b"</head>").or_else(|| find(b"<body")).unwrap_or(page.len()); |
| 414 | 3× | let mut out = Vec::with_capacity(page.len() + tag.len()); |
| 415 | 3× | out.extend_from_slice(&page[..at]); |
| 416 | 3× | out.extend_from_slice(tag.as_bytes()); |
| 417 | 3× | out.extend_from_slice(&page[at..]); |
| 418 | 3× | out |
| 419 | 3× | } |
| 420 | |
|
| 421 | |
|
| 422 | 7× | pub(crate) fn content_type(path: &str) -> &'static str { |
| 423 | 7× | match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() { |
| 424 | 7× | "html" | "htm" => "text/html; charset=utf-8", |
| 425 | 4× | "js" | "mjs" => "text/javascript; charset=utf-8", |
| 426 | 4× | "css" => "text/css; charset=utf-8", |
| 427 | 4× | "json" => "application/json", |
| 428 | 4× | "csv" => "text/csv; charset=utf-8", |
| 429 | 1× | "svg" => "image/svg+xml", |
| 430 | 1× | "png" => "image/png", |
| 431 | 1× | "jpg" | "jpeg" => "image/jpeg", |
| 432 | 1× | "gif" => "image/gif", |
| 433 | 1× | "webp" => "image/webp", |
| 434 | 1× | "woff2" => "font/woff2", |
| 435 | 1× | "woff" => "font/woff", |
| 436 | 1× | "txt" | "toml" | "md" | "info" | "xml" => "text/plain; charset=utf-8", |
| 437 | 0× | _ => "application/octet-stream", |
| 438 | | } |
| 439 | 7× | } |
| 440 | |
|
| 441 | |
|
| 442 | |
|
| 443 | |
|
| 444 | |
|
| 445 | |
|
| 446 | 4× | pub async fn file(State(app): State<Shared>, Path((owner, name, id, mac, path)): Path<(String, String, i64, String, String)>) -> Response { |
| 447 | 4× | let not_found = || (StatusCode::NOT_FOUND, "no such report file").into_response(); |
| 448 | 4× | if !crate::auth::mac_equal(&mac, &reports::file_mac(&app, id)) { |
| 449 | 1× | return not_found(); |
| 450 | 3× | } |
| 451 | 3× | let Ok(Some(row)) = crate::db::repos::get(&app.db, &owner, &name).await else { return not_found() }; |
| 452 | 3× | let Ok(Some(_)) = reports::get(&app.db, row.id, id).await else { return not_found() }; |
| 453 | 3× | let Ok(Some((blob, _))) = reports::file(&app.db, id, &path).await else { return not_found() }; |
| 454 | 2× | let (Some(repo), Some(oref)) = (crate::repos::RepoName::new(&row.owner, &row.name), oxy_core::hash::ObjectRef::from_hex(&blob)) else { |
| 455 | 0× | return not_found(); |
| 456 | | }; |
| 457 | 2× | let bytes = match app.repos.read(&repo, move |r, _| Ok(r.read_blob(&oref)?)).await { |
| 458 | 2× | Ok(b) => b, |
| 459 | 0× | Err(_) => return not_found(), |
| 460 | | }; |
| 461 | | |
| 462 | | |
| 463 | 2× | let bytes = |
| 464 | 2× | if content_type(&path).starts_with("text/html") { with_frame_script(bytes, super::statics::assets_version()) } else { bytes }; |
| 465 | 2× | let origin = app.config.base_url().trim_end_matches('/').to_string(); |
| 466 | 2× | let csp = format!( |
| 467 | | "sandbox allow-scripts; default-src 'none'; script-src 'self' {origin} 'unsafe-inline' 'unsafe-eval'; \ |
| 468 | | style-src 'self' {origin} 'unsafe-inline'; img-src 'self' {origin} data: blob:; font-src 'self' {origin} data:; \ |
| 469 | | connect-src 'self' {origin}; frame-ancestors 'self' {origin}; base-uri 'none'; form-action 'none'" |
| 470 | | ); |
| 471 | 2× | let mut res = (StatusCode::OK, bytes).into_response(); |
| 472 | 2× | let h = res.headers_mut(); |
| 473 | 2× | h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type(&path))); |
| 474 | 2× | if let Ok(v) = HeaderValue::from_str(&csp) { |
| 475 | 2× | h.insert(header::CONTENT_SECURITY_POLICY, v); |
| 476 | 2× | } |
| 477 | 2× | h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*")); |
| 478 | 2× | h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); |
| 479 | 2× | h.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")); |
| 480 | 2× | h.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, max-age=3600, immutable")); |
| 481 | 2× | res |
| 482 | 4× | } |
| 483 | |
|
| 484 | | #[cfg(test)] |
| 485 | | mod tests { |
| 486 | | use super::*; |
| 487 | |
|
| 488 | | #[test] |
| 489 | 1× | fn metrics_say_which_way_they_moved() { |
| 490 | 1× | let m = |v: f64, better: &str, above: Option<f64>| Metric { |
| 491 | 3× | key: "frame".into(), |
| 492 | 3× | value: v, |
| 493 | 3× | unit: "ms".into(), |
| 494 | 3× | better: better.into(), |
| 495 | 3× | fail_above: above, |
| 496 | 3× | fail_below: None, |
| 497 | 3× | }; |
| 498 | 1× | let v = &views(&[m(18.0, "lower", Some(20.0))], &[m(16.5, "lower", Some(20.0))], &[])[0]; |
| 499 | 1× | assert_eq!((v.value.as_str(), v.delta.as_str(), v.trend, v.failing), ("18", "+1.5", "worse", false)); |
| 500 | 1× | assert_eq!(v.threshold, "fails above 20"); |
| 501 | 1× | let v = &views(&[m(21.0, "lower", Some(20.0))], &[], &[])[0]; |
| 502 | 1× | assert!(v.failing && v.delta.is_empty()); |
| 503 | 1× | assert_eq!(num(16.40), "16.4"); |
| 504 | 1× | assert!(spark(&[1.0, 3.0, 2.0]).contains("polyline")); |
| 505 | 1× | assert!(spark(&[1.0]).is_empty()); |
| 506 | 1× | let page = with_frame_script(b"<!doctype html><html><HEAD><title>t</title></HEAD><body>x</body></html>".to_vec(), "v1"); |
| 507 | 1× | let page = String::from_utf8(page).unwrap(); |
| 508 | 1× | assert!(page.starts_with("<!doctype html>"), "the doctype stays first"); |
| 509 | 1× | assert!(page.contains(r#"<script src="/static/report-frame.js?v=v1"></script></HEAD>"#), "{page}"); |
| 510 | 1× | let bare = String::from_utf8(with_frame_script(b"<p>hi</p>".to_vec(), "v1")).unwrap(); |
| 511 | 1× | assert!(bare.ends_with("</script>"), "{bare}"); |
| 512 | 1× | } |
| 513 | | } |