OXYGENHUB

Coverage / crates/hub/src/web/insights.rs 88.2%

142 of 161 lines run, 19 of 52 functions · run #485 on main · run missed

1
//! Insights pages (oxygenhub#257): one page at three scopes, so the numbers
2
//! always agree: the site (`/admin/insights`, administrators), an owner
3
//! (`/insights/{owner}`, the owner, its members and administrators) and a
4
//! repository (`/{owner}/{name}/insights`, anyone who reads it). `?range=`
5
//! is `1h`, `24h` (default) or `7d`; the site page also takes `repo` and
6
//! `runner`; `?wall=1` is the second-monitor view. The page refreshes its
7
//! numbers every 30 seconds (hub.js), and works as a snapshot without it.
8
9
use super::admin::require_admin;
10
use super::charts;
11
use super::{filters, lookup, Error, Page, RepoShell, Shell};
12
use crate::auth::Viewer;
13
use crate::insights::{self, Insights, Range, Scope};
14
use crate::{db, Shared};
15
use askama::Template;
16
use axum::extract::{Path, Query, State};
17
use serde::Deserialize;
18
19
#[derive(Deserialize, Default)]
20
pub struct InsightsQuery {
21
    #[serde(default)]
22
    range: String,
23
    #[serde(default)]
24
    repo: String,
25
    #[serde(default)]
26
    runner: String,
27
    #[serde(default)]
28
    wall: String,
29
}
30
31
/// The charts, drawn.
32
pub struct Charts {
33
    pub pickup: String,
34
    pub results: String,
35
    pub durations: String,
36
    pub load: String,
37
    pub utilisation: String,
38
}
39
40
#[derive(Template)]
41
#[template(path = "insights.html")]
42
pub struct InsightsPage {
43
    shell: Shell,
44
    /// The repository's bar, on a repository's page.
45
    repo: Option<RepoShell>,
46
    /// What the page covers: `the site`, an owner, `owner/name`.
47
    scope: String,
48
    /// This page's path, for the range links.
49
    base: String,
50
    /// The filters to keep in the range links (`&repo=…&runner=…`).
51
    keep: String,
52
    repo_filter: String,
53
    runner_filter: String,
54
    /// The site page's filter choices.
55
    repo_choices: Vec<String>,
56
    runner_choices: Vec<String>,
57
    i: Insights,
58
    charts: Charts,
59
    wall: bool,
60
    grafana: String,
61
    /// The wider scope's page, when there is one the viewer may see.
62
    wider: Option<(String, String)>,
63
}
64
65
impl InsightsPage {
6620×
    pub fn secs(&self, s: &Option<f64>) -> String {
6720×
        s.map(insights::seconds).unwrap_or_else(|| "–".into())
6820×
    }
69
704×
    pub fn pass_rate(&self) -> String {
714×
        if self.i.passed + self.i.failed == 0 {
720×
            "–".into()
73
        } else {
744×
            format!("{:.0}%", self.i.passed as f64 * 100.0 / (self.i.passed + self.i.failed) as f64)
75
        }
764×
    }
77
7826×
    pub fn range_on(&self, key: &str) -> bool {
7926×
        self.i.range.key() == key
8026×
    }
81
}
82
834×
fn draw(i: &Insights) -> Charts {
8424×
    let secs = |v: f64| insights::seconds(v);
8528×
    let count = |v: f64| format!("{v:.0}");
8612×
    let pct = |v: f64| format!("{v:.0}%");
874×
    Charts {
884×
        pickup: charts::lines("Time to pickup", i.range, &i.pickup, &secs),
894×
        results: charts::stacked("Jobs finished", i.range, &i.results, &count),
904×
        durations: charts::lines("Job duration", i.range, &i.durations, &secs),
914×
        load: charts::lines("Queued and running", i.range, &i.load, &count),
924×
        utilisation: charts::lines("Runner utilisation", i.range, &i.utilisation, &pct),
934×
    }
944×
}
95
964×
fn keep(q: &InsightsQuery) -> String {
974×
    let mut k = String::new();
988×
    for (name, v) in [("repo", &q.repo), ("runner", &q.runner)] {
998×
        if !v.is_empty() {
1001×
            k.push_str(&format!("&{name}={}", super::settings::urlencode(v)));
1017×
        }
102
    }
1034×
    if q.wall == "1" {
1041×
        k.push_str("&wall=1");
1053×
    }
1064×
    k
1074×
}
108
109
#[allow(clippy::too_many_arguments)]
1104×
async fn page(
1114×
    app: &Shared,
1124×
    shell: Shell,
1134×
    repo: Option<RepoShell>,
1144×
    scope_label: String,
1154×
    base: String,
1164×
    scope: Scope,
1174×
    q: &InsightsQuery,
1184×
    choices: (Vec<String>, Vec<String>),
1194×
    wider: Option<(String, String)>,
1204×
) -> Result<Page<InsightsPage>, Error> {
1214×
    let range = Range::parse(&q.range);
1224×
    let i = insights::gather(&app.db, &scope, range).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1234×
    let charts = draw(&i);
1244×
    Ok(Page(InsightsPage {
1254×
        shell,
1264×
        repo,
1274×
        scope: scope_label,
1284×
        base,
1294×
        keep: keep(q),
1304×
        repo_filter: q.repo.clone(),
1314×
        runner_filter: q.runner.clone(),
1324×
        repo_choices: choices.0,
1334×
        runner_choices: choices.1,
1344×
        i,
1354×
        charts,
1364×
        wall: q.wall == "1",
1374×
        grafana: app.config.server.grafana_url.clone(),
1384×
        wider,
1394×
    }))
1404×
}
141
142
/// `GET /admin/insights`: the whole site, for administrators.
1432×
pub async fn site(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InsightsQuery>) -> Result<Page<InsightsPage>, Error> {
1442×
    let shell = Shell::new(&app, "/admin/insights", &viewer);
1452×
    require_admin(&viewer, &shell)?;
1461×
    let mut scope = Scope::default();
1471×
    if let Some((owner, name)) = q.repo.split_once('/') {
1480×
        let row = db::repos::get(&app.db, owner, name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1490×
        scope.repo_id = Some(row.map_or(-1, |r| r.id));
1501×
    } else if !q.repo.is_empty() {
1510×
        scope.owner = Some(q.repo.clone());
1521×
    }
1531×
    if !q.runner.is_empty() {
1541×
        scope.runner = Some(q.runner.clone());
1551×
    }
1561×
    let runners: Vec<String> =
1571×
        db::runners::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().map(|r| r.name).collect();
1581×
    let repos: Vec<String> = sqlx::query_scalar(
1591×
        "SELECT DISTINCT p.owner || '/' || p.name FROM flow_runs r JOIN repositories p ON p.id = r.repo_id WHERE r.created_ms >= $1 ORDER BY 1",
1601×
    )
1611×
    .bind(db::now_ms() - Range::Week.ms())
1621×
    .fetch_all(&app.db)
1631×
    .await
1641×
    .map_err(|e| Error::from_db(shell.clone(), e))?;
1651×
    let label = match (q.repo.is_empty(), q.runner.is_empty()) {
1660×
        (true, true) => "the site".to_string(),
1670×
        (false, true) => q.repo.clone(),
1681×
        (true, false) => format!("runner {}", q.runner),
1690×
        (false, false) => format!("{} on {}", q.repo, q.runner),
170
    };
1711×
    page(&app, shell, None, label, "/admin/insights".into(), scope, &q, (repos, runners), None).await
1722×
}
173
174
/// Whether the viewer may see an owner's numbers: the owner, a member of
175
/// the organisation, or an administrator.
1764×
async fn sees_owner(app: &Shared, viewer: &Viewer, owner: &str) -> sqlx::Result<bool> {
1774×
    let Some(me) = viewer.account() else { return Ok(false) };
1781×
    if me.is_admin || me.username.eq_ignore_ascii_case(owner) {
1791×
        return Ok(true);
1800×
    }
1810×
    match db::accounts::org_by_name(&app.db, owner).await? {
1820×
        Some(org) => Ok(db::accounts::org_role(&app.db, org.id, me.id).await?.is_some()),
1830×
        None => Ok(false),
184
    }
1854×
}
186
187
/// `GET /insights/{owner}`: every repository of an owner.
1882×
pub async fn owner(
1892×
    State(app): State<Shared>,
1902×
    viewer: Viewer,
1912×
    Path(owner): Path<String>,
1922×
    Query(q): Query<InsightsQuery>,
1932×
) -> Result<Page<InsightsPage>, Error> {
1942×
    let shell = Shell::new(&app, &format!("/insights/{owner}"), &viewer);
1952×
    if !sees_owner(&app, &viewer, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1961×
        return Err(Error::not_found(shell));
1971×
    }
1981×
    let q = InsightsQuery { repo: String::new(), runner: String::new(), ..q };
1991×
    let scope = Scope { owner: Some(owner.clone()), ..Default::default() };
2001×
    let wider = viewer.is_admin().then(|| ("the site".to_string(), "/admin/insights".to_string()));
2011×
    page(&app, shell, None, owner.clone(), format!("/insights/{owner}"), scope, &q, Default::default(), wider).await
2022×
}
203
204
/// `GET /{owner}/{name}/insights`: one repository, for anyone who reads it.
2052×
pub async fn repo(
2062×
    State(app): State<Shared>,
2072×
    viewer: Viewer,
2082×
    Path((owner, name)): Path<(String, String)>,
2092×
    Query(q): Query<InsightsQuery>,
2102×
) -> Result<Page<InsightsPage>, Error> {
2112×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/insights"), &viewer);
2122×
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
2132×
    let q = InsightsQuery { repo: String::new(), runner: String::new(), ..q };
2142×
    let scope = Scope { repo_id: Some(row.id), ..Default::default() };
2152×
    let wider = if sees_owner(&app, &viewer, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
2160×
        Some((format!("all of {owner}'s repositories"), format!("/insights/{owner}")))
217
    } else {
2182×
        None
219
    };
2202×
    let repo_shell = RepoShell::new(&app, &row, "insights").with_role(role);
2212×
    page(
2222×
        &app,
2232×
        shell,
2242×
        Some(repo_shell),
2252×
        format!("{owner}/{name}"),
2262×
        format!("/{owner}/{name}/insights"),
2272×
        scope,
2282×
        &q,
2292×
        Default::default(),
2302×
        wider,
2312×
    )
2322×
    .await
2332×
}