OXYGENHUB
# c8df33a6829f oxygenhub / crates / hub / src / web / reports.rs
12.8 KiB · Rust · last changed Reports: the Reports tab, each report's page, its frame, and checks from thresholds 24 days ago source blame history raw
1
//! Reports pages (oxygenhub#127, docs/reports.md): the Reports tab, a
2
//! report's page with its metrics across runs and its own page in a
3
//! sandboxed frame, and the files that frame reads.
4
5
use super::{filters, lookup, Error, Page, RepoShell, Shell};
6
use crate::auth::Viewer;
7
use crate::reports::{self, Metric, Report};
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Path, Query, State};
11
use axum::http::{header, HeaderValue, StatusCode};
12
use axum::response::{IntoResponse, Response};
13
use serde::Deserialize;
14
15
/// Runs a report's page reads for its sparklines and run list.
16
const HISTORY: i64 = 60;
17
18
#[derive(Deserialize, Default)]
19
pub struct ReportsQuery {
20
    /// Only this bookmark's reports.
21
    bookmark: Option<String>,
22
    /// One report, by id.
23
    id: Option<i64>,
24
}
25
26
/// A metric as shown: its value, and its change against the run before.
27
pub struct MetricView {
28
    pub key: String,
29
    pub value: String,
30
    pub unit: String,
31
    /// `+0.4`, empty when there is no run before or no change.
32
    pub delta: String,
33
    /// `better`, `worse` or `same`, by the metric's `better`.
34
    pub trend: &'static str,
35
    /// Its threshold, as words, when it has one.
36
    pub threshold: String,
37
    /// Whether it crosses its threshold.
38
    pub failing: bool,
39
    /// An inline SVG of its values across runs, oldest first.
40
    pub spark: String,
41
}
42
43
/// A report on the Reports tab.
44
pub struct Item {
45
    pub report: Report,
46
    pub metrics: Vec<MetricView>,
47
}
48
49
#[derive(Template)]
50
#[template(path = "reports.html")]
51
pub struct ReportsPage {
52
    shell: Shell,
53
    repo: RepoShell,
54
    bookmark: String,
55
    items: Vec<Item>,
56
    coverage: bool,
57
    tests: bool,
58
}
59
60
#[derive(Template)]
61
#[template(path = "report.html")]
62
pub struct ReportPage {
63
    shell: Shell,
64
    repo: RepoShell,
65
    report: Report,
66
    bookmarks: Vec<String>,
67
    metrics: Vec<MetricView>,
68
    context: Vec<(String, String)>,
69
    /// Newest first, the one shown among them.
70
    runs: Vec<Report>,
71
    /// Where the frame loads the report's page from, when it has one.
72
    frame: Option<String>,
73
    files_base: String,
74
}
75
76
/// A number as a person would write it: no trailing zeros.
77
fn num(v: f64) -> String {
78
    if v.fract() == 0.0 && v.abs() < 1e15 {
79
        format!("{v:.0}")
80
    } else {
81
        let s = format!("{v:.3}");
82
        s.trim_end_matches('0').trim_end_matches('.').to_string()
83
    }
84
}
85
86
fn spark(values: &[f64]) -> String {
87
    if values.len() < 2 {
88
        return String::new();
89
    }
90
    let (w, h) = (120.0, 28.0);
91
    let lo = values.iter().cloned().fold(f64::INFINITY, f64::min);
92
    let hi = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
93
    let span = if hi > lo { hi - lo } else { 1.0 };
94
    let n = values.len();
95
    let pts: Vec<String> = values
96
        .iter()
97
        .enumerate()
98
        .map(|(i, v)| format!("{:.1},{:.1}", w * i as f64 / (n - 1) as f64, 2.0 + (h - 4.0) * (hi - v) / span))
99
        .collect();
100
    let last = pts.last().cloned().unwrap_or_default();
101
    let (lx, ly) = last.split_once(',').unwrap_or(("0", "0"));
102
    format!(
103
        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>"#,
104
        pts.join(" ")
105
    )
106
}
107
108
/// The metrics of `now` against `before`, with each one's values across
109
/// `series` (oldest first) for a sparkline.
110
fn views(now: &[Metric], before: &[Metric], series: &[Vec<Metric>]) -> Vec<MetricView> {
111
    now.iter()
112
        .map(|m| {
113
            let prev = before.iter().find(|b| b.key == m.key).map(|b| b.value);
114
            let (delta, trend) = match prev {
115
                Some(p) if (m.value - p).abs() > f64::EPSILON => {
116
                    let up = m.value > p;
117
                    let trend = match m.better.as_str() {
118
                        "higher" if up => "better",
119
                        "higher" => "worse",
120
                        "lower" if up => "worse",
121
                        "lower" => "better",
122
                        _ => "same",
123
                    };
124
                    (format!("{}{}", if up { "+" } else { "" }, num(m.value - p)), trend)
125
                }
126
                _ => (String::new(), "same"),
127
            };
128
            let mut threshold = Vec::new();
129
            if let Some(a) = m.fail_above {
130
                threshold.push(format!("fails above {}", num(a)));
131
            }
132
            if let Some(b) = m.fail_below {
133
                threshold.push(format!("fails below {}", num(b)));
134
            }
135
            let failing = m.fail_above.is_some_and(|a| m.value > a) || m.fail_below.is_some_and(|b| m.value < b);
136
            let values: Vec<f64> = series.iter().filter_map(|ms| ms.iter().find(|x| x.key == m.key).map(|x| x.value)).collect();
137
            MetricView {
138
                key: m.key.clone(),
139
                value: num(m.value),
140
                unit: m.unit.clone(),
141
                delta,
142
                trend,
143
                threshold: threshold.join(", "),
144
                failing,
145
                spark: spark(&values),
146
            }
147
        })
148
        .collect()
149
}
150
151
/// `GET /{owner}/{name}/reports`: the newest of each report, with its
152
/// metrics against the run before on its bookmark.
153
pub async fn overview(
154
    State(app): State<Shared>,
155
    viewer: Viewer,
156
    Path((owner, name)): Path<(String, String)>,
157
    Query(q): Query<ReportsQuery>,
158
) -> Result<Page<ReportsPage>, Error> {
159
    let shell = Shell::new(&app, &format!("/{owner}/{name}/reports"), &viewer);
160
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
161
    let e = |e| Error::from_db(shell.clone(), e);
162
    let bookmark = q.bookmark.clone().filter(|b| !b.is_empty());
163
    let latest = reports::latest(&app.db, row.id, bookmark.as_deref()).await.map_err(e)?;
164
    let mut items = Vec::new();
165
    for r in latest {
166
        let history = reports::history(&app.db, row.id, &r.name, &r.bookmark, 2).await.map_err(e)?;
167
        let now = reports::metrics(&app.db, r.id).await.map_err(e)?;
168
        let before = match history.get(1) {
169
            Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?,
170
            None => vec![],
171
        };
172
        items.push(Item { metrics: views(&now, &before, &[]), report: r });
173
    }
174
    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 }))
176
}
177
178
/// `GET /{owner}/{name}/reports/{report}`: one report, the newest on its
179
/// bookmark unless `?id=` names a run, with its metrics across runs and
180
/// its page in a sandboxed frame.
181
pub async fn show(
182
    State(app): State<Shared>,
183
    viewer: Viewer,
184
    Path((owner, name, rname)): Path<(String, String, String)>,
185
    Query(q): Query<ReportsQuery>,
186
) -> Result<Page<ReportPage>, Error> {
187
    let shell = Shell::new(&app, &format!("/{owner}/{name}/reports/{rname}"), &viewer);
188
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
189
    let e = |e| Error::from_db(shell.clone(), e);
190
    let report = match q.id {
191
        Some(id) => reports::get(&app.db, row.id, id).await.map_err(e)?.filter(|r| r.name == rname),
192
        None => {
193
            let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?;
194
            let bookmark = q
195
                .bookmark
196
                .clone()
197
                .or_else(|| bookmarks.iter().find(|b| **b == row.default_bookmark).cloned())
198
                .or_else(|| bookmarks.first().cloned());
199
            match bookmark {
200
                Some(b) => reports::history(&app.db, row.id, &rname, &b, 1).await.map_err(e)?.into_iter().next(),
201
                None => None,
202
            }
203
        }
204
    };
205
    let report = report.ok_or_else(|| Error::not_found(shell.clone()))?;
206
    let bookmarks = reports::bookmarks_of(&app.db, row.id, &rname).await.map_err(e)?;
207
    let runs = reports::history(&app.db, row.id, &rname, &report.bookmark, HISTORY).await.map_err(e)?;
208
    let now = reports::metrics(&app.db, report.id).await.map_err(e)?;
209
    // The run before the one shown, and every run up to it for the lines.
210
    let at = runs.iter().position(|r| r.id == report.id).unwrap_or(0);
211
    let before = match runs.get(at + 1) {
212
        Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?,
213
        None => vec![],
214
    };
215
    let mut series = Vec::new();
216
    for r in runs.iter().skip(at).rev() {
217
        series.push(reports::metrics(&app.db, r.id).await.map_err(e)?);
218
    }
219
    let files_base = format!("/{owner}/{name}/reports/files/{}/{}", report.id, reports::file_mac(&app, report.id));
220
    let frame = reports::file(&app.db, report.id, "index.html").await.map_err(e)?.map(|_| format!("{files_base}/index.html"));
221
    let repo = RepoShell::new(&app, &row, "reports").with_role(role);
222
    Ok(Page(ReportPage {
223
        shell,
224
        repo,
225
        context: report.context_rows(),
226
        metrics: views(&now, &before, &series),
227
        bookmarks,
228
        runs,
229
        frame,
230
        files_base,
231
        report,
232
    }))
233
}
234
235
/// A file's type, by its extension.
236
fn content_type(path: &str) -> &'static str {
237
    match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
238
        "html" | "htm" => "text/html; charset=utf-8",
239
        "js" | "mjs" => "text/javascript; charset=utf-8",
240
        "css" => "text/css; charset=utf-8",
241
        "json" => "application/json",
242
        "csv" => "text/csv; charset=utf-8",
243
        "svg" => "image/svg+xml",
244
        "png" => "image/png",
245
        "jpg" | "jpeg" => "image/jpeg",
246
        "gif" => "image/gif",
247
        "webp" => "image/webp",
248
        "woff2" => "font/woff2",
249
        "woff" => "font/woff",
250
        "txt" | "toml" | "md" | "info" | "xml" => "text/plain; charset=utf-8",
251
        _ => "application/octet-stream",
252
    }
253
}
254
255
/// `GET /{owner}/{name}/reports/files/{id}/{mac}/{*path}`: a report's file,
256
/// for its frame. The frame is sandboxed (scripts, no same origin), so it
257
/// carries no session: the MAC in the link is what lets it in. Every file
258
/// is served sandboxed too, with nothing beyond this hub to load or call,
259
/// and readable across origins, since the frame's own origin is opaque.
260
pub async fn file(State(app): State<Shared>, Path((owner, name, id, mac, path)): Path<(String, String, i64, String, String)>) -> Response {
261
    let not_found = || (StatusCode::NOT_FOUND, "no such report file").into_response();
262
    if !crate::auth::mac_equal(&mac, &reports::file_mac(&app, id)) {
263
        return not_found();
264
    }
265
    let Ok(Some(row)) = crate::db::repos::get(&app.db, &owner, &name).await else { return not_found() };
266
    let Ok(Some(_)) = reports::get(&app.db, row.id, id).await else { return not_found() };
267
    let Ok(Some((blob, _))) = reports::file(&app.db, id, &path).await else { return not_found() };
268
    let (Some(repo), Some(oref)) = (crate::repos::RepoName::new(&row.owner, &row.name), oxy_core::hash::ObjectRef::from_hex(&blob)) else {
269
        return not_found();
270
    };
271
    let bytes = match app.repos.read(&repo, move |r, _| Ok(r.read_blob(&oref)?)).await {
272
        Ok(b) => b,
273
        Err(_) => return not_found(),
274
    };
275
    let origin = app.config.base_url().trim_end_matches('/').to_string();
276
    let csp = format!(
277
        "sandbox allow-scripts; default-src 'none'; script-src 'self' {origin} 'unsafe-inline' 'unsafe-eval'; \
278
         style-src 'self' {origin} 'unsafe-inline'; img-src 'self' {origin} data: blob:; font-src 'self' {origin} data:; \
279
         connect-src 'self' {origin}; frame-ancestors 'self' {origin}; base-uri 'none'; form-action 'none'"
280
    );
281
    let mut res = (StatusCode::OK, bytes).into_response();
282
    let h = res.headers_mut();
283
    h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type(&path)));
284
    if let Ok(v) = HeaderValue::from_str(&csp) {
285
        h.insert(header::CONTENT_SECURITY_POLICY, v);
286
    }
287
    h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
288
    h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
289
    h.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
290
    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("private, max-age=3600, immutable"));
291
    res
292
}
293
294
#[cfg(test)]
295
mod tests {
296
    use super::*;
297
298
    #[test]
299
    fn metrics_say_which_way_they_moved() {
300
        let m = |v: f64, better: &str, above: Option<f64>| Metric {
301
            key: "frame".into(),
302
            value: v,
303
            unit: "ms".into(),
304
            better: better.into(),
305
            fail_above: above,
306
            fail_below: None,
307
        };
308
        let v = &views(&[m(18.0, "lower", Some(20.0))], &[m(16.5, "lower", Some(20.0))], &[])[0];
309
        assert_eq!((v.value.as_str(), v.delta.as_str(), v.trend, v.failing), ("18", "+1.5", "worse", false));
310
        assert_eq!(v.threshold, "fails above 20");
311
        let v = &views(&[m(21.0, "lower", Some(20.0))], &[], &[])[0];
312
        assert!(v.failing && v.delta.is_empty());
313
        assert_eq!(num(16.40), "16.4");
314
        assert!(spark(&[1.0, 3.0, 2.0]).contains("polyline"));
315
        assert!(spark(&[1.0]).is_empty());
316
    }
317
}