OXYGENHUB
# cb7b3cd45dc0 oxygenhub / crates / hub / src / web / insights.rs
8.2 KiB · Rust · last changed Insights: OxygenFlow's standard charts, for the site, an owner and a repository 18 days ago source blame history raw
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 {
66
    pub fn secs(&self, s: &Option<f64>) -> String {
67
        s.map(insights::seconds).unwrap_or_else(|| "–".into())
68
    }
69
70
    pub fn pass_rate(&self) -> String {
71
        if self.i.passed + self.i.failed == 0 {
72
            "–".into()
73
        } else {
74
            format!("{:.0}%", self.i.passed as f64 * 100.0 / (self.i.passed + self.i.failed) as f64)
75
        }
76
    }
77
78
    pub fn range_on(&self, key: &str) -> bool {
79
        self.i.range.key() == key
80
    }
81
}
82
83
fn draw(i: &Insights) -> Charts {
84
    let secs = |v: f64| insights::seconds(v);
85
    let count = |v: f64| format!("{v:.0}");
86
    let pct = |v: f64| format!("{v:.0}%");
87
    Charts {
88
        pickup: charts::lines("Time to pickup", i.range, &i.pickup, &secs),
89
        results: charts::stacked("Jobs finished", i.range, &i.results, &count),
90
        durations: charts::lines("Job duration", i.range, &i.durations, &secs),
91
        load: charts::lines("Queued and running", i.range, &i.load, &count),
92
        utilisation: charts::lines("Runner utilisation", i.range, &i.utilisation, &pct),
93
    }
94
}
95
96
fn keep(q: &InsightsQuery) -> String {
97
    let mut k = String::new();
98
    for (name, v) in [("repo", &q.repo), ("runner", &q.runner)] {
99
        if !v.is_empty() {
100
            k.push_str(&format!("&{name}={}", super::settings::urlencode(v)));
101
        }
102
    }
103
    if q.wall == "1" {
104
        k.push_str("&wall=1");
105
    }
106
    k
107
}
108
109
#[allow(clippy::too_many_arguments)]
110
async fn page(
111
    app: &Shared,
112
    shell: Shell,
113
    repo: Option<RepoShell>,
114
    scope_label: String,
115
    base: String,
116
    scope: Scope,
117
    q: &InsightsQuery,
118
    choices: (Vec<String>, Vec<String>),
119
    wider: Option<(String, String)>,
120
) -> Result<Page<InsightsPage>, Error> {
121
    let range = Range::parse(&q.range);
122
    let i = insights::gather(&app.db, &scope, range).await.map_err(|e| Error::from_db(shell.clone(), e))?;
123
    let charts = draw(&i);
124
    Ok(Page(InsightsPage {
125
        shell,
126
        repo,
127
        scope: scope_label,
128
        base,
129
        keep: keep(q),
130
        repo_filter: q.repo.clone(),
131
        runner_filter: q.runner.clone(),
132
        repo_choices: choices.0,
133
        runner_choices: choices.1,
134
        i,
135
        charts,
136
        wall: q.wall == "1",
137
        grafana: app.config.server.grafana_url.clone(),
138
        wider,
139
    }))
140
}
141
142
/// `GET /admin/insights`: the whole site, for administrators.
143
pub async fn site(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InsightsQuery>) -> Result<Page<InsightsPage>, Error> {
144
    let shell = Shell::new(&app, "/admin/insights", &viewer);
145
    require_admin(&viewer, &shell)?;
146
    let mut scope = Scope::default();
147
    if let Some((owner, name)) = q.repo.split_once('/') {
148
        let row = db::repos::get(&app.db, owner, name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
149
        scope.repo_id = Some(row.map_or(-1, |r| r.id));
150
    } else if !q.repo.is_empty() {
151
        scope.owner = Some(q.repo.clone());
152
    }
153
    if !q.runner.is_empty() {
154
        scope.runner = Some(q.runner.clone());
155
    }
156
    let runners: Vec<String> =
157
        db::runners::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().map(|r| r.name).collect();
158
    let repos: Vec<String> = sqlx::query_scalar(
159
        "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",
160
    )
161
    .bind(db::now_ms() - Range::Week.ms())
162
    .fetch_all(&app.db)
163
    .await
164
    .map_err(|e| Error::from_db(shell.clone(), e))?;
165
    let label = match (q.repo.is_empty(), q.runner.is_empty()) {
166
        (true, true) => "the site".to_string(),
167
        (false, true) => q.repo.clone(),
168
        (true, false) => format!("runner {}", q.runner),
169
        (false, false) => format!("{} on {}", q.repo, q.runner),
170
    };
171
    page(&app, shell, None, label, "/admin/insights".into(), scope, &q, (repos, runners), None).await
172
}
173
174
/// Whether the viewer may see an owner's numbers: the owner, a member of
175
/// the organisation, or an administrator.
176
async fn sees_owner(app: &Shared, viewer: &Viewer, owner: &str) -> sqlx::Result<bool> {
177
    let Some(me) = viewer.account() else { return Ok(false) };
178
    if me.is_admin || me.username.eq_ignore_ascii_case(owner) {
179
        return Ok(true);
180
    }
181
    match db::accounts::org_by_name(&app.db, owner).await? {
182
        Some(org) => Ok(db::accounts::org_role(&app.db, org.id, me.id).await?.is_some()),
183
        None => Ok(false),
184
    }
185
}
186
187
/// `GET /insights/{owner}`: every repository of an owner.
188
pub async fn owner(
189
    State(app): State<Shared>,
190
    viewer: Viewer,
191
    Path(owner): Path<String>,
192
    Query(q): Query<InsightsQuery>,
193
) -> Result<Page<InsightsPage>, Error> {
194
    let shell = Shell::new(&app, &format!("/insights/{owner}"), &viewer);
195
    if !sees_owner(&app, &viewer, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
196
        return Err(Error::not_found(shell));
197
    }
198
    let q = InsightsQuery { repo: String::new(), runner: String::new(), ..q };
199
    let scope = Scope { owner: Some(owner.clone()), ..Default::default() };
200
    let wider = viewer.is_admin().then(|| ("the site".to_string(), "/admin/insights".to_string()));
201
    page(&app, shell, None, owner.clone(), format!("/insights/{owner}"), scope, &q, Default::default(), wider).await
202
}
203
204
/// `GET /{owner}/{name}/insights`: one repository, for anyone who reads it.
205
pub async fn repo(
206
    State(app): State<Shared>,
207
    viewer: Viewer,
208
    Path((owner, name)): Path<(String, String)>,
209
    Query(q): Query<InsightsQuery>,
210
) -> Result<Page<InsightsPage>, Error> {
211
    let shell = Shell::new(&app, &format!("/{owner}/{name}/insights"), &viewer);
212
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
213
    let q = InsightsQuery { repo: String::new(), runner: String::new(), ..q };
214
    let scope = Scope { repo_id: Some(row.id), ..Default::default() };
215
    let wider = if sees_owner(&app, &viewer, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
216
        Some((format!("all of {owner}'s repositories"), format!("/insights/{owner}")))
217
    } else {
218
        None
219
    };
220
    let repo_shell = RepoShell::new(&app, &row, "insights").with_role(role);
221
    page(
222
        &app,
223
        shell,
224
        Some(repo_shell),
225
        format!("{owner}/{name}"),
226
        format!("/{owner}/{name}/insights"),
227
        scope,
228
        &q,
229
        Default::default(),
230
        wider,
231
    )
232
    .await
233
}