//! Insights pages (oxygenhub#257): one page at three scopes, so the numbers //! always agree: the site (`/admin/insights`, administrators), an owner //! (`/insights/{owner}`, the owner, its members and administrators) and a //! repository (`/{owner}/{name}/insights`, anyone who reads it). `?range=` //! is `1h`, `24h` (default) or `7d`; the site page also takes `repo` and //! `runner`; `?wall=1` is the second-monitor view. The page refreshes its //! numbers every 30 seconds (hub.js), and works as a snapshot without it. use super::admin::require_admin; use super::charts; use super::{filters, lookup, Error, Page, RepoShell, Shell}; use crate::auth::Viewer; use crate::insights::{self, Insights, Range, Scope}; use crate::{db, Shared}; use askama::Template; use axum::extract::{Path, Query, State}; use serde::Deserialize; #[derive(Deserialize, Default)] pub struct InsightsQuery { #[serde(default)] range: String, #[serde(default)] repo: String, #[serde(default)] runner: String, #[serde(default)] wall: String, } /// The charts, drawn. pub struct Charts { pub pickup: String, pub results: String, pub durations: String, pub load: String, pub utilisation: String, } #[derive(Template)] #[template(path = "insights.html")] pub struct InsightsPage { shell: Shell, /// The repository's bar, on a repository's page. repo: Option, /// What the page covers: `the site`, an owner, `owner/name`. scope: String, /// This page's path, for the range links. base: String, /// The filters to keep in the range links (`&repo=…&runner=…`). keep: String, repo_filter: String, runner_filter: String, /// The site page's filter choices. repo_choices: Vec, runner_choices: Vec, i: Insights, charts: Charts, wall: bool, grafana: String, /// The wider scope's page, when there is one the viewer may see. wider: Option<(String, String)>, } impl InsightsPage { pub fn secs(&self, s: &Option) -> String { s.map(insights::seconds).unwrap_or_else(|| "–".into()) } pub fn pass_rate(&self) -> String { if self.i.passed + self.i.failed == 0 { "–".into() } else { format!("{:.0}%", self.i.passed as f64 * 100.0 / (self.i.passed + self.i.failed) as f64) } } pub fn range_on(&self, key: &str) -> bool { self.i.range.key() == key } } fn draw(i: &Insights) -> Charts { let secs = |v: f64| insights::seconds(v); let count = |v: f64| format!("{v:.0}"); let pct = |v: f64| format!("{v:.0}%"); Charts { pickup: charts::lines("Time to pickup", i.range, &i.pickup, &secs), results: charts::stacked("Jobs finished", i.range, &i.results, &count), durations: charts::lines("Job duration", i.range, &i.durations, &secs), load: charts::lines("Queued and running", i.range, &i.load, &count), utilisation: charts::lines("Runner utilisation", i.range, &i.utilisation, &pct), } } fn keep(q: &InsightsQuery) -> String { let mut k = String::new(); for (name, v) in [("repo", &q.repo), ("runner", &q.runner)] { if !v.is_empty() { k.push_str(&format!("&{name}={}", super::settings::urlencode(v))); } } if q.wall == "1" { k.push_str("&wall=1"); } k } #[allow(clippy::too_many_arguments)] async fn page( app: &Shared, shell: Shell, repo: Option, scope_label: String, base: String, scope: Scope, q: &InsightsQuery, choices: (Vec, Vec), wider: Option<(String, String)>, ) -> Result, Error> { let range = Range::parse(&q.range); let i = insights::gather(&app.db, &scope, range).await.map_err(|e| Error::from_db(shell.clone(), e))?; let charts = draw(&i); Ok(Page(InsightsPage { shell, repo, scope: scope_label, base, keep: keep(q), repo_filter: q.repo.clone(), runner_filter: q.runner.clone(), repo_choices: choices.0, runner_choices: choices.1, i, charts, wall: q.wall == "1", grafana: app.config.server.grafana_url.clone(), wider, })) } /// `GET /admin/insights`: the whole site, for administrators. pub async fn site(State(app): State, viewer: Viewer, Query(q): Query) -> Result, Error> { let shell = Shell::new(&app, "/admin/insights", &viewer); require_admin(&viewer, &shell)?; let mut scope = Scope::default(); if let Some((owner, name)) = q.repo.split_once('/') { let row = db::repos::get(&app.db, owner, name).await.map_err(|e| Error::from_db(shell.clone(), e))?; scope.repo_id = Some(row.map_or(-1, |r| r.id)); } else if !q.repo.is_empty() { scope.owner = Some(q.repo.clone()); } if !q.runner.is_empty() { scope.runner = Some(q.runner.clone()); } let runners: Vec = db::runners::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().map(|r| r.name).collect(); let repos: Vec = sqlx::query_scalar( "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", ) .bind(db::now_ms() - Range::Week.ms()) .fetch_all(&app.db) .await .map_err(|e| Error::from_db(shell.clone(), e))?; let label = match (q.repo.is_empty(), q.runner.is_empty()) { (true, true) => "the site".to_string(), (false, true) => q.repo.clone(), (true, false) => format!("runner {}", q.runner), (false, false) => format!("{} on {}", q.repo, q.runner), }; page(&app, shell, None, label, "/admin/insights".into(), scope, &q, (repos, runners), None).await } /// Whether the viewer may see an owner's numbers: the owner, a member of /// the organisation, or an administrator. async fn sees_owner(app: &Shared, viewer: &Viewer, owner: &str) -> sqlx::Result { let Some(me) = viewer.account() else { return Ok(false) }; if me.is_admin || me.username.eq_ignore_ascii_case(owner) { return Ok(true); } match db::accounts::org_by_name(&app.db, owner).await? { Some(org) => Ok(db::accounts::org_role(&app.db, org.id, me.id).await?.is_some()), None => Ok(false), } } /// `GET /insights/{owner}`: every repository of an owner. pub async fn owner( State(app): State, viewer: Viewer, Path(owner): Path, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/insights/{owner}"), &viewer); if !sees_owner(&app, &viewer, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))? { return Err(Error::not_found(shell)); } let q = InsightsQuery { repo: String::new(), runner: String::new(), ..q }; let scope = Scope { owner: Some(owner.clone()), ..Default::default() }; let wider = viewer.is_admin().then(|| ("the site".to_string(), "/admin/insights".to_string())); page(&app, shell, None, owner.clone(), format!("/insights/{owner}"), scope, &q, Default::default(), wider).await } /// `GET /{owner}/{name}/insights`: one repository, for anyone who reads it. pub async fn repo( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/insights"), &viewer); let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let q = InsightsQuery { repo: String::new(), runner: String::new(), ..q }; let scope = Scope { repo_id: Some(row.id), ..Default::default() }; let wider = if sees_owner(&app, &viewer, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))? { Some((format!("all of {owner}'s repositories"), format!("/insights/{owner}"))) } else { None }; let repo_shell = RepoShell::new(&app, &row, "insights").with_role(role); page( &app, shell, Some(repo_shell), format!("{owner}/{name}"), format!("/{owner}/{name}/insights"), scope, &q, Default::default(), wider, ) .await }