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