| 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 |
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 |
|
| 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 |
|
| 175 |
|
| 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 |
|
| 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 |
|
| 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 |
} |