Insights: OxygenFlow's standard charts, for the site, an owner and a repository e97500b70f9a
One page at three scopes so the numbers always agree: /admin/insights
(administrators, filtered by repository, owner or runner), /insights/
{owner} and /{owner}/{name}/insights. Headline numbers (pickup median and
p95, jobs finished and pass rate, duration, queued and running now,
runners online and slots busy), five charts (pickup, jobs by result,
queued and running, duration, utilisation), a runners table and the
busiest repositories; 1 hour, 24 hours or 7 days.
Pickup is from when a job could run to its claim: jobs now record when
they were queued (made or run again) and when they became ready (the
latest of that, their dependencies passing and their approval), set at
the claim (migration 0080, which estimates earlier jobs).
Charts are inline SVG drawn by the hub, coloured by theme classes: no
script, no library, nothing the CSP forbids. The page refreshes itself
every 30 seconds; ?wall=1 is a second-monitor view without menus. The
runners page's queue filters get a Charts link that keeps them, the
repository's More menu and Flow tab link its page, and [server]
grafana_url adds an Open in Grafana link. 'log', 'metrics' and
'insights' are reserved names.
Closes #257
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 10c5b2371b38
- When
- · 2026-10-18 11:55 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
e97500b70f9ae63aa6df3fadb221f58aa092362b74dda26f09feb1677aab9d8a- Depends on
- 05f1a85a2842 0b203bbf7cc3 119fd17d213f 16ee5d19d220 2cbf6f348595 38833ed78707 3b1bc27fe805 417184c73f4e 42fa95540468 47e374020b59 4866be1c9040 53a0fb2e4ac6 554ccacfc123 5d90e0e045e2 617c2840446c 67523e06ed86 67be8ac00c24 6ca0b971ff07 757195ad7a5d 8a48dd830178 92a797b20912 93be9ce4653e a10446c68043 b644e1757711 b6b7e4cf6b24 c9aacc0e7dc1 d23464c0ac60 e8fa53694f68 e9b9619cd857
- Depended on by
- c26267b3aba6 8501fd5815b9 ab4df6aae179 5588bb3631d2 166f6e9bf4f1 e57765dee61a 2a8b621676b4 7c06f5b28ade
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 35 atoms, 6 tree operations, 18 files
Browse the tree at this change
added crates/hub/migrations-pg/0080_flow_job_times.sql +15 −0
| 1 | + | -- When a job entered the queue and when it became ready to run |
|
| 2 | + | -- (oxygenhub#257): its pickup time is started_ms - ready_ms. queued_ms is |
|
| 3 | + | -- set when the job is made and when it is run again; ready_ms when a |
|
| 4 | + | -- runner claims it: the latest of queued_ms, its dependencies finishing |
|
| 5 | + | -- and its approval. |
|
| 6 | + | ALTER TABLE flow_jobs ADD COLUMN queued_ms BIGINT; |
|
| 7 | + | ALTER TABLE flow_jobs ADD COLUMN ready_ms BIGINT; |
|
| 8 | + | -- Earlier jobs: queued with their run, ready once the stages before |
|
| 9 | + | -- theirs had finished (a job runs again keeps its run's time: an estimate). |
|
| 10 | + | UPDATE flow_jobs SET queued_ms = (SELECT r.created_ms FROM flow_runs r WHERE r.id = flow_jobs.run_id); |
|
| 11 | + | UPDATE flow_jobs SET ready_ms = LEAST(started_ms, GREATEST(queued_ms, COALESCE(approved_ms, 0), |
|
| 12 | + | COALESCE((SELECT MAX(x.finished_ms) FROM flow_jobs x WHERE x.run_id = flow_jobs.run_id AND x.depth < flow_jobs.depth), 0))) |
|
| 13 | + | WHERE started_ms IS NOT NULL; |
|
| 14 | + | CREATE INDEX flow_jobs_started ON flow_jobs (started_ms); |
|
| 15 | + | CREATE INDEX flow_jobs_finished ON flow_jobs (finished_ms); |
added crates/hub/migrations/0080_flow_job_times.sql +15 −0
| 1 | + | -- When a job entered the queue and when it became ready to run |
|
| 2 | + | -- (oxygenhub#257): its pickup time is started_ms - ready_ms. queued_ms is |
|
| 3 | + | -- set when the job is made and when it is run again; ready_ms when a |
|
| 4 | + | -- runner claims it: the latest of queued_ms, its dependencies finishing |
|
| 5 | + | -- and its approval. |
|
| 6 | + | ALTER TABLE flow_jobs ADD COLUMN queued_ms INTEGER; |
|
| 7 | + | ALTER TABLE flow_jobs ADD COLUMN ready_ms INTEGER; |
|
| 8 | + | -- Earlier jobs: queued with their run, ready once the stages before |
|
| 9 | + | -- theirs had finished (a job runs again keeps its run's time: an estimate). |
|
| 10 | + | UPDATE flow_jobs SET queued_ms = (SELECT r.created_ms FROM flow_runs r WHERE r.id = flow_jobs.run_id); |
|
| 11 | + | UPDATE flow_jobs SET ready_ms = MIN(started_ms, MAX(queued_ms, COALESCE(approved_ms, 0), |
|
| 12 | + | COALESCE((SELECT MAX(x.finished_ms) FROM flow_jobs x WHERE x.run_id = flow_jobs.run_id AND x.depth < flow_jobs.depth), 0))) |
|
| 13 | + | WHERE started_ms IS NOT NULL; |
|
| 14 | + | CREATE INDEX flow_jobs_started ON flow_jobs (started_ms); |
|
| 15 | + | CREATE INDEX flow_jobs_finished ON flow_jobs (finished_ms); |
added crates/hub/src/insights.rs +381 −0
| 1 | + | //! Insights (oxygenhub#257): OxygenFlow's standard numbers over time, for |
|
| 2 | + | //! the site, an owner or a repository: how long jobs wait for a runner, how |
|
| 3 | + | //! many run and how they end, how long they take, how deep the queue is and |
|
| 4 | + | //! how busy the runners are. All from `flow_jobs` (migration 0080's queue |
|
| 5 | + | //! and ready times) and the runners' rows, so nothing new is collected; |
|
| 6 | + | //! power users point Grafana at `/metrics` for anything more. |
|
| 7 | + | ||
| 8 | + | use crate::db::{self, Pool}; |
|
| 9 | + | use sqlx::FromRow; |
|
| 10 | + | ||
| 11 | + | /// The span a page shows. |
|
| 12 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 13 | + | pub enum Range { |
|
| 14 | + | Hour, |
|
| 15 | + | Day, |
|
| 16 | + | Week, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | impl Range { |
|
| 20 | + | pub fn parse(s: &str) -> Range { |
|
| 21 | + | match s { |
|
| 22 | + | "1h" => Range::Hour, |
|
| 23 | + | "7d" => Range::Week, |
|
| 24 | + | _ => Range::Day, |
|
| 25 | + | } |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | pub fn key(self) -> &'static str { |
|
| 29 | + | match self { |
|
| 30 | + | Range::Hour => "1h", |
|
| 31 | + | Range::Day => "24h", |
|
| 32 | + | Range::Week => "7d", |
|
| 33 | + | } |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | pub fn ms(self) -> i64 { |
|
| 37 | + | match self { |
|
| 38 | + | Range::Hour => 3_600_000, |
|
| 39 | + | Range::Day => 86_400_000, |
|
| 40 | + | Range::Week => 7 * 86_400_000, |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | /// How many buckets the charts have. |
|
| 45 | + | pub fn buckets(self) -> usize { |
|
| 46 | + | match self { |
|
| 47 | + | Range::Hour => 60, |
|
| 48 | + | Range::Day => 48, |
|
| 49 | + | Range::Week => 56, |
|
| 50 | + | } |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | pub fn bucket_ms(self) -> i64 { |
|
| 54 | + | self.ms() / self.buckets() as i64 |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | /// Which jobs count. |
|
| 59 | + | #[derive(Clone, Debug, Default)] |
|
| 60 | + | pub struct Scope { |
|
| 61 | + | /// An owner's repositories. |
|
| 62 | + | pub owner: Option<String>, |
|
| 63 | + | /// One repository. |
|
| 64 | + | pub repo_id: Option<i64>, |
|
| 65 | + | /// One runner's jobs. |
|
| 66 | + | pub runner: Option<String>, |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | #[derive(Clone, Debug, FromRow)] |
|
| 70 | + | struct JobRow { |
|
| 71 | + | status: String, |
|
| 72 | + | runner: String, |
|
| 73 | + | owner: String, |
|
| 74 | + | repo: String, |
|
| 75 | + | queued_ms: Option<i64>, |
|
| 76 | + | ready_ms: Option<i64>, |
|
| 77 | + | started_ms: Option<i64>, |
|
| 78 | + | finished_ms: Option<i64>, |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | impl JobRow { |
|
| 82 | + | /// Seconds from ready to started, when it started. |
|
| 83 | + | fn pickup(&self) -> Option<f64> { |
|
| 84 | + | Some((self.started_ms? - self.ready_ms.or(self.queued_ms)?).max(0) as f64 / 1000.0) |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | fn duration(&self) -> Option<f64> { |
|
| 88 | + | Some((self.finished_ms? - self.started_ms?).max(0) as f64 / 1000.0) |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | /// When it left the queue: started, or ended without starting. |
|
| 92 | + | fn left_queue(&self) -> Option<i64> { |
|
| 93 | + | self.started_ms.or(self.finished_ms) |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | fn ended(&self) -> bool { |
|
| 97 | + | !matches!(self.status.as_str(), "queued" | "running") |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | /// One chart's line or stack: a value per bucket. |
|
| 102 | + | #[derive(Clone, Debug)] |
|
| 103 | + | pub struct Series { |
|
| 104 | + | pub name: &'static str, |
|
| 105 | + | /// The CSS class that colours it (`s-ok`, `s-bad`, …). |
|
| 106 | + | pub class: &'static str, |
|
| 107 | + | pub values: Vec<Option<f64>>, |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /// One runner's row. |
|
| 111 | + | #[derive(Clone, Debug)] |
|
| 112 | + | pub struct RunnerRow { |
|
| 113 | + | pub name: String, |
|
| 114 | + | pub status: &'static str, |
|
| 115 | + | pub running: i64, |
|
| 116 | + | pub max_jobs: i64, |
|
| 117 | + | pub jobs: i64, |
|
| 118 | + | pub failed: i64, |
|
| 119 | + | pub pickup_median: Option<f64>, |
|
| 120 | + | pub duration_median: Option<f64>, |
|
| 121 | + | pub last_seen_ms: Option<i64>, |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | /// One repository's row. |
|
| 125 | + | #[derive(Clone, Debug)] |
|
| 126 | + | pub struct RepoRow { |
|
| 127 | + | pub slug: String, |
|
| 128 | + | pub jobs: i64, |
|
| 129 | + | pub failed: i64, |
|
| 130 | + | pub pickup_median: Option<f64>, |
|
| 131 | + | pub pickup_p95: Option<f64>, |
|
| 132 | + | pub queued: i64, |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | /// Everything a page draws. |
|
| 136 | + | #[derive(Clone, Debug)] |
|
| 137 | + | pub struct Insights { |
|
| 138 | + | pub range: Range, |
|
| 139 | + | /// The first bucket's start; buckets follow every `range.bucket_ms()`. |
|
| 140 | + | pub start_ms: i64, |
|
| 141 | + | pub now_ms: i64, |
|
| 142 | + | /// Seconds: median and 95th percentile per bucket, by the job's start. |
|
| 143 | + | pub pickup: Vec<Series>, |
|
| 144 | + | /// Jobs that ended per bucket, by result. |
|
| 145 | + | pub results: Vec<Series>, |
|
| 146 | + | /// Seconds: median and 95th percentile per bucket, by the job's end. |
|
| 147 | + | pub durations: Vec<Series>, |
|
| 148 | + | /// Jobs in the queue and running, at each bucket's end. |
|
| 149 | + | pub load: Vec<Series>, |
|
| 150 | + | /// Running jobs over the online runners' slots, percent, per bucket. |
|
| 151 | + | pub utilisation: Vec<Series>, |
|
| 152 | + | pub ended: i64, |
|
| 153 | + | pub passed: i64, |
|
| 154 | + | pub failed: i64, |
|
| 155 | + | pub pickup_median: Option<f64>, |
|
| 156 | + | pub pickup_p95: Option<f64>, |
|
| 157 | + | pub duration_median: Option<f64>, |
|
| 158 | + | pub queued_now: i64, |
|
| 159 | + | pub running_now: i64, |
|
| 160 | + | pub runners_online: i64, |
|
| 161 | + | pub runners_total: i64, |
|
| 162 | + | pub slots_busy: i64, |
|
| 163 | + | pub slots_total: i64, |
|
| 164 | + | pub runners: Vec<RunnerRow>, |
|
| 165 | + | pub repos: Vec<RepoRow>, |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | /// The `p`-th percentile (0..=1) of `v`, nearest rank. |
|
| 169 | + | pub fn percentile(v: &mut [f64], p: f64) -> Option<f64> { |
|
| 170 | + | if v.is_empty() { |
|
| 171 | + | return None; |
|
| 172 | + | } |
|
| 173 | + | v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); |
|
| 174 | + | let rank = ((p * v.len() as f64).ceil() as usize).clamp(1, v.len()); |
|
| 175 | + | Some(v[rank - 1]) |
|
| 176 | + | } |
|
| 177 | + | ||
| 178 | + | async fn rows(pool: &Pool, scope: &Scope, since: i64) -> sqlx::Result<Vec<JobRow>> { |
|
| 179 | + | sqlx::query_as( |
|
| 180 | + | "SELECT j.status, j.runner, p.owner, p.name AS repo, COALESCE(j.queued_ms, r.created_ms) AS queued_ms, j.ready_ms, |
|
| 181 | + | j.started_ms, j.finished_ms |
|
| 182 | + | FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id |
|
| 183 | + | WHERE (j.finished_ms >= $1 OR j.status IN ('queued', 'running')) |
|
| 184 | + | AND ($2 = '' OR p.owner = $3) AND ($4 IS NULL OR p.id = $5) AND ($6 = '' OR j.runner = $7)", |
|
| 185 | + | ) |
|
| 186 | + | .bind(since) |
|
| 187 | + | .bind(scope.owner.as_deref().unwrap_or("")) |
|
| 188 | + | .bind(scope.owner.as_deref().unwrap_or("")) |
|
| 189 | + | .bind(scope.repo_id) |
|
| 190 | + | .bind(scope.repo_id) |
|
| 191 | + | .bind(scope.runner.as_deref().unwrap_or("")) |
|
| 192 | + | .bind(scope.runner.as_deref().unwrap_or("")) |
|
| 193 | + | .fetch_all(pool) |
|
| 194 | + | .await |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | /// Gather a scope's numbers over `range`, ending now. |
|
| 198 | + | pub async fn gather(pool: &Pool, scope: &Scope, range: Range) -> sqlx::Result<Insights> { |
|
| 199 | + | let now = db::now_ms(); |
|
| 200 | + | let start = now - range.ms(); |
|
| 201 | + | let jobs = rows(pool, scope, start).await?; |
|
| 202 | + | let runners = db::runners::list(pool).await?; |
|
| 203 | + | let n = range.buckets(); |
|
| 204 | + | let width = range.bucket_ms(); |
|
| 205 | + | let bucket = |t: i64| -> Option<usize> { (t >= start && t <= now).then(|| (((t - start) / width) as usize).min(n - 1)) }; |
|
| 206 | + | ||
| 207 | + | let mut pickups: Vec<Vec<f64>> = vec![Vec::new(); n]; |
|
| 208 | + | let mut durations: Vec<Vec<f64>> = vec![Vec::new(); n]; |
|
| 209 | + | let (mut passed, mut failed, mut other) = (vec![0f64; n], vec![0f64; n], vec![0f64; n]); |
|
| 210 | + | let (mut all_pickups, mut all_durations) = (Vec::new(), Vec::new()); |
|
| 211 | + | for j in &jobs { |
|
| 212 | + | if let (Some(p), Some(b)) = (j.pickup(), j.started_ms.and_then(bucket)) { |
|
| 213 | + | pickups[b].push(p); |
|
| 214 | + | all_pickups.push(p); |
|
| 215 | + | } |
|
| 216 | + | if let (Some(f), true) = (j.finished_ms, j.ended()) { |
|
| 217 | + | if let Some(b) = bucket(f) { |
|
| 218 | + | match j.status.as_str() { |
|
| 219 | + | "passed" => passed[b] += 1.0, |
|
| 220 | + | "failed" => failed[b] += 1.0, |
|
| 221 | + | _ => other[b] += 1.0, |
|
| 222 | + | } |
|
| 223 | + | if let Some(d) = j.duration() { |
|
| 224 | + | durations[b].push(d); |
|
| 225 | + | all_durations.push(d); |
|
| 226 | + | } |
|
| 227 | + | } |
|
| 228 | + | } |
|
| 229 | + | } |
|
| 230 | + | // The queue and the running jobs at each bucket's end. |
|
| 231 | + | let (mut queued_at, mut running_at) = (vec![0f64; n], vec![0f64; n]); |
|
| 232 | + | for (i, (q, r)) in queued_at.iter_mut().zip(running_at.iter_mut()).enumerate() { |
|
| 233 | + | let t = (start + (i as i64 + 1) * width).min(now); |
|
| 234 | + | for j in &jobs { |
|
| 235 | + | let entered = j.queued_ms.unwrap_or(i64::MAX); |
|
| 236 | + | if entered <= t && j.left_queue().is_none_or(|l| l > t) && !(j.ended() && j.finished_ms.is_none()) { |
|
| 237 | + | *q += 1.0; |
|
| 238 | + | } |
|
| 239 | + | if j.started_ms.is_some_and(|s| s <= t) && j.finished_ms.is_none_or(|f| f > t) && !(j.ended() && j.finished_ms.is_none()) { |
|
| 240 | + | *r += 1.0; |
|
| 241 | + | } |
|
| 242 | + | } |
|
| 243 | + | } |
|
| 244 | + | // Slots: the runners serving the scope that are online now. |
|
| 245 | + | let serving: Vec<&db::runners::Runner> = |
|
| 246 | + | runners.iter().filter(|r| scope.runner.as_deref().is_none_or(|n| n == r.name)).filter(|r| r.state != "removed").collect(); |
|
| 247 | + | let slots_total: i64 = serving.iter().filter(|r| r.online()).map(|r| r.max_jobs.max(1)).sum(); |
|
| 248 | + | let utilisation: Vec<Option<f64>> = |
|
| 249 | + | running_at.iter().map(|r| (slots_total > 0).then(|| (r / slots_total as f64 * 100.0).min(100.0))).collect(); |
|
| 250 | + | ||
| 251 | + | let quantiles = |sets: &mut Vec<Vec<f64>>, p: f64| -> Vec<Option<f64>> { sets.iter_mut().map(|v| percentile(v, p)).collect() }; |
|
| 252 | + | let pickup = vec![ |
|
| 253 | + | Series { name: "median", class: "s-main", values: quantiles(&mut pickups, 0.5) }, |
|
| 254 | + | Series { name: "95th percentile", class: "s-warn", values: quantiles(&mut pickups, 0.95) }, |
|
| 255 | + | ]; |
|
| 256 | + | let durations_series = vec![ |
|
| 257 | + | Series { name: "median", class: "s-main", values: quantiles(&mut durations, 0.5) }, |
|
| 258 | + | Series { name: "95th percentile", class: "s-warn", values: quantiles(&mut durations, 0.95) }, |
|
| 259 | + | ]; |
|
| 260 | + | let some = |v: Vec<f64>| v.into_iter().map(Some).collect::<Vec<_>>(); |
|
| 261 | + | let ended_total = |v: &[f64]| v.iter().sum::<f64>() as i64; |
|
| 262 | + | let (p_total, f_total, o_total) = (ended_total(&passed), ended_total(&failed), ended_total(&other)); |
|
| 263 | + | ||
| 264 | + | // Runners: what each did in the range. |
|
| 265 | + | let mut runner_rows: Vec<RunnerRow> = serving |
|
| 266 | + | .iter() |
|
| 267 | + | .filter(|r| scope.owner.is_none() && scope.repo_id.is_none() || jobs.iter().any(|j| j.runner == r.name)) |
|
| 268 | + | .map(|r| { |
|
| 269 | + | let mine: Vec<&JobRow> = jobs.iter().filter(|j| j.runner == r.name).collect(); |
|
| 270 | + | let mut p: Vec<f64> = mine.iter().filter(|j| j.started_ms.is_some_and(|s| s >= start)).filter_map(|j| j.pickup()).collect(); |
|
| 271 | + | let mut d: Vec<f64> = mine.iter().filter(|j| j.ended()).filter_map(|j| j.duration()).collect(); |
|
| 272 | + | RunnerRow { |
|
| 273 | + | name: r.name.clone(), |
|
| 274 | + | status: r.status(), |
|
| 275 | + | running: mine.iter().filter(|j| j.status == "running").count() as i64, |
|
| 276 | + | max_jobs: r.max_jobs.max(1), |
|
| 277 | + | jobs: mine.iter().filter(|j| j.ended()).count() as i64, |
|
| 278 | + | failed: mine.iter().filter(|j| j.status == "failed").count() as i64, |
|
| 279 | + | pickup_median: percentile(&mut p, 0.5), |
|
| 280 | + | duration_median: percentile(&mut d, 0.5), |
|
| 281 | + | last_seen_ms: r.last_seen_ms, |
|
| 282 | + | } |
|
| 283 | + | }) |
|
| 284 | + | .collect(); |
|
| 285 | + | runner_rows.sort_by(|a, b| b.jobs.cmp(&a.jobs).then_with(|| a.name.cmp(&b.name))); |
|
| 286 | + | ||
| 287 | + | // Repositories, busiest first (a repository's own page has just itself). |
|
| 288 | + | let mut by_repo: std::collections::BTreeMap<String, (i64, i64, Vec<f64>, i64)> = Default::default(); |
|
| 289 | + | for j in &jobs { |
|
| 290 | + | let e = by_repo.entry(format!("{}/{}", j.owner, j.repo)).or_default(); |
|
| 291 | + | if j.ended() && j.finished_ms.is_some_and(|f| f >= start) { |
|
| 292 | + | e.0 += 1; |
|
| 293 | + | if j.status == "failed" { |
|
| 294 | + | e.1 += 1; |
|
| 295 | + | } |
|
| 296 | + | } |
|
| 297 | + | if let Some(p) = j.pickup().filter(|_| j.started_ms.is_some_and(|s| s >= start)) { |
|
| 298 | + | e.2.push(p); |
|
| 299 | + | } |
|
| 300 | + | if j.status == "queued" { |
|
| 301 | + | e.3 += 1; |
|
| 302 | + | } |
|
| 303 | + | } |
|
| 304 | + | let mut repos: Vec<RepoRow> = by_repo |
|
| 305 | + | .into_iter() |
|
| 306 | + | .map(|(slug, (jobs, failed, mut p, queued))| RepoRow { |
|
| 307 | + | slug, |
|
| 308 | + | jobs, |
|
| 309 | + | failed, |
|
| 310 | + | pickup_median: percentile(&mut p, 0.5), |
|
| 311 | + | pickup_p95: percentile(&mut p, 0.95), |
|
| 312 | + | queued, |
|
| 313 | + | }) |
|
| 314 | + | .collect(); |
|
| 315 | + | repos.sort_by(|a, b| (b.jobs + b.queued).cmp(&(a.jobs + a.queued)).then_with(|| a.slug.cmp(&b.slug))); |
|
| 316 | + | repos.truncate(15); |
|
| 317 | + | ||
| 318 | + | Ok(Insights { |
|
| 319 | + | range, |
|
| 320 | + | start_ms: start, |
|
| 321 | + | now_ms: now, |
|
| 322 | + | pickup, |
|
| 323 | + | results: vec![ |
|
| 324 | + | Series { name: "passed", class: "s-ok", values: some(passed) }, |
|
| 325 | + | Series { name: "failed", class: "s-bad", values: some(failed) }, |
|
| 326 | + | Series { name: "other (error, cancelled, skipped)", class: "s-mute", values: some(other) }, |
|
| 327 | + | ], |
|
| 328 | + | durations: durations_series, |
|
| 329 | + | load: vec![ |
|
| 330 | + | Series { name: "queued", class: "s-warn", values: some(queued_at) }, |
|
| 331 | + | Series { name: "running", class: "s-main", values: some(running_at) }, |
|
| 332 | + | ], |
|
| 333 | + | utilisation: vec![Series { name: "busy slots", class: "s-main", values: utilisation }], |
|
| 334 | + | ended: p_total + f_total + o_total, |
|
| 335 | + | passed: p_total, |
|
| 336 | + | failed: f_total, |
|
| 337 | + | pickup_median: percentile(&mut all_pickups, 0.5), |
|
| 338 | + | pickup_p95: percentile(&mut all_pickups, 0.95), |
|
| 339 | + | duration_median: percentile(&mut all_durations, 0.5), |
|
| 340 | + | queued_now: jobs.iter().filter(|j| j.status == "queued").count() as i64, |
|
| 341 | + | running_now: jobs.iter().filter(|j| j.status == "running").count() as i64, |
|
| 342 | + | runners_online: serving.iter().filter(|r| r.online()).count() as i64, |
|
| 343 | + | runners_total: serving.len() as i64, |
|
| 344 | + | slots_busy: jobs.iter().filter(|j| j.status == "running").count() as i64, |
|
| 345 | + | slots_total, |
|
| 346 | + | runners: runner_rows, |
|
| 347 | + | repos, |
|
| 348 | + | }) |
|
| 349 | + | } |
|
| 350 | + | ||
| 351 | + | /// A span of seconds, short: `42 s`, `3.5 min`, `1.2 h`. |
|
| 352 | + | pub fn seconds(s: f64) -> String { |
|
| 353 | + | if s < 60.0 { |
|
| 354 | + | format!("{s:.0} s") |
|
| 355 | + | } else if s < 3600.0 { |
|
| 356 | + | let m = s / 60.0; |
|
| 357 | + | if m < 10.0 { |
|
| 358 | + | format!("{m:.1} min") |
|
| 359 | + | } else { |
|
| 360 | + | format!("{m:.0} min") |
|
| 361 | + | } |
|
| 362 | + | } else { |
|
| 363 | + | format!("{:.1} h", s / 3600.0) |
|
| 364 | + | } |
|
| 365 | + | } |
|
| 366 | + | ||
| 367 | + | #[cfg(test)] |
|
| 368 | + | mod tests { |
|
| 369 | + | use super::*; |
|
| 370 | + | ||
| 371 | + | #[test] |
|
| 372 | + | fn percentiles_are_nearest_rank() { |
|
| 373 | + | let mut v = vec![5.0, 1.0, 3.0, 2.0, 4.0]; |
|
| 374 | + | assert_eq!(percentile(&mut v, 0.5), Some(3.0)); |
|
| 375 | + | assert_eq!(percentile(&mut v, 0.95), Some(5.0)); |
|
| 376 | + | assert_eq!(percentile(&mut [], 0.5), None); |
|
| 377 | + | assert_eq!(seconds(42.0), "42 s"); |
|
| 378 | + | assert_eq!(seconds(210.0), "3.5 min"); |
|
| 379 | + | assert_eq!(seconds(4320.0), "1.2 h"); |
|
| 380 | + | } |
|
| 381 | + | } |
added crates/hub/src/web/charts.rs +150 −0
| 1 | + | //! Charts drawn by the hub (oxygenhub#257): inline SVG, coloured by CSS |
|
| 2 | + | //! classes from the theme's tokens, so they need no script, no library and |
|
| 3 | + | //! nothing the Content-Security-Policy forbids. Lines for times and levels, |
|
| 4 | + | //! stacked bars for counts; the x axis is relative (`−24 h` … `now`). |
|
| 5 | + | ||
| 6 | + | use crate::insights::{Range, Series}; |
|
| 7 | + | use std::fmt::Write; |
|
| 8 | + | ||
| 9 | + | const W: f64 = 640.0; |
|
| 10 | + | const H: f64 = 190.0; |
|
| 11 | + | const LEFT: f64 = 52.0; |
|
| 12 | + | const RIGHT: f64 = 8.0; |
|
| 13 | + | const TOP: f64 = 10.0; |
|
| 14 | + | const BOTTOM: f64 = 22.0; |
|
| 15 | + | ||
| 16 | + | fn esc(s: &str) -> String { |
|
| 17 | + | s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | /// A round top for the y axis: 1, 2 or 5 times a power of ten. |
|
| 21 | + | fn nice(max: f64) -> f64 { |
|
| 22 | + | if max <= 0.0 { |
|
| 23 | + | return 1.0; |
|
| 24 | + | } |
|
| 25 | + | let p = 10f64.powf(max.log10().floor()); |
|
| 26 | + | for m in [1.0, 2.0, 5.0, 10.0] { |
|
| 27 | + | if max <= m * p { |
|
| 28 | + | return m * p; |
|
| 29 | + | } |
|
| 30 | + | } |
|
| 31 | + | 10.0 * p |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | /// The frame: grid lines with the y labels, and the relative x labels. |
|
| 35 | + | fn frame(out: &mut String, top: f64, range: Range, label: &dyn Fn(f64) -> String) { |
|
| 36 | + | let plot_h = H - TOP - BOTTOM; |
|
| 37 | + | for i in 0..=2 { |
|
| 38 | + | let v = top * i as f64 / 2.0; |
|
| 39 | + | let y = TOP + plot_h - plot_h * i as f64 / 2.0; |
|
| 40 | + | let _ = write!(out, r#"<line class="grid" x1="{LEFT}" x2="{}" y1="{y:.1}" y2="{y:.1}"/>"#, W - RIGHT); |
|
| 41 | + | let _ = write!(out, r#"<text class="axis" x="{}" y="{:.1}" text-anchor="end">{}</text>"#, LEFT - 6.0, y + 4.0, esc(&label(v))); |
|
| 42 | + | } |
|
| 43 | + | let (first, mid) = match range { |
|
| 44 | + | Range::Hour => ("−60 min", "−30 min"), |
|
| 45 | + | Range::Day => ("−24 h", "−12 h"), |
|
| 46 | + | Range::Week => ("−7 d", "−3.5 d"), |
|
| 47 | + | }; |
|
| 48 | + | let base = H - 6.0; |
|
| 49 | + | let mid_x = LEFT + (W - LEFT - RIGHT) / 2.0; |
|
| 50 | + | let _ = write!(out, r#"<text class="axis" x="{LEFT}" y="{base}">{first}</text>"#); |
|
| 51 | + | let _ = write!(out, r#"<text class="axis" x="{mid_x}" y="{base}" text-anchor="middle">{mid}</text>"#); |
|
| 52 | + | let _ = write!(out, r#"<text class="axis" x="{}" y="{base}" text-anchor="end">now</text>"#, W - RIGHT); |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | fn open(out: &mut String, title: &str) { |
|
| 56 | + | let _ = write!(out, r#"<svg class="chart" viewBox="0 0 {W} {H}" role="img" aria-label="{}">"#, esc(title)); |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | /// Lines, one per series; a missing value breaks its line. |
|
| 60 | + | pub fn lines(title: &str, range: Range, series: &[Series], label: &dyn Fn(f64) -> String) -> String { |
|
| 61 | + | let max = series.iter().flat_map(|s| s.values.iter().flatten()).fold(0f64, |a, b| a.max(*b)); |
|
| 62 | + | let top = nice(max); |
|
| 63 | + | let mut out = String::new(); |
|
| 64 | + | open(&mut out, title); |
|
| 65 | + | frame(&mut out, top, range, label); |
|
| 66 | + | let n = series.first().map_or(0, |s| s.values.len()).max(1); |
|
| 67 | + | let step = (W - LEFT - RIGHT) / n as f64; |
|
| 68 | + | let plot_h = H - TOP - BOTTOM; |
|
| 69 | + | for s in series { |
|
| 70 | + | let mut d = String::new(); |
|
| 71 | + | let mut pen = false; |
|
| 72 | + | for (i, v) in s.values.iter().enumerate() { |
|
| 73 | + | match v { |
|
| 74 | + | Some(v) => { |
|
| 75 | + | let x = LEFT + step * (i as f64 + 0.5); |
|
| 76 | + | let y = TOP + plot_h - plot_h * (v / top).min(1.0); |
|
| 77 | + | let _ = write!(d, "{}{x:.1},{y:.1} ", if pen { "L" } else { "M" }); |
|
| 78 | + | pen = true; |
|
| 79 | + | } |
|
| 80 | + | None => pen = false, |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | if !d.is_empty() { |
|
| 84 | + | let _ = write!(out, r#"<path class="line {}" d="{}"><title>{}</title></path>"#, s.class, d.trim_end(), esc(s.name)); |
|
| 85 | + | } |
|
| 86 | + | // A lone point between gaps would not show as a line: a dot. |
|
| 87 | + | for (i, v) in s.values.iter().enumerate() { |
|
| 88 | + | let alone = v.is_some() && (i == 0 || s.values[i - 1].is_none()) && s.values.get(i + 1).is_none_or(|n| n.is_none()); |
|
| 89 | + | if let (true, Some(v)) = (alone, v) { |
|
| 90 | + | let x = LEFT + step * (i as f64 + 0.5); |
|
| 91 | + | let y = TOP + plot_h - plot_h * (v / top).min(1.0); |
|
| 92 | + | let _ = write!(out, r#"<circle class="dot {}" cx="{x:.1}" cy="{y:.1}" r="2.5"/>"#, s.class); |
|
| 93 | + | } |
|
| 94 | + | } |
|
| 95 | + | } |
|
| 96 | + | out.push_str("</svg>"); |
|
| 97 | + | out |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | /// Stacked bars: each bucket's series on top of one another. |
|
| 101 | + | pub fn stacked(title: &str, range: Range, series: &[Series], label: &dyn Fn(f64) -> String) -> String { |
|
| 102 | + | let n = series.first().map_or(0, |s| s.values.len()); |
|
| 103 | + | let totals: Vec<f64> = (0..n).map(|i| series.iter().map(|s| s.values[i].unwrap_or(0.0)).sum()).collect(); |
|
| 104 | + | let top = nice(totals.iter().fold(0f64, |a, b| a.max(*b))); |
|
| 105 | + | let mut out = String::new(); |
|
| 106 | + | open(&mut out, title); |
|
| 107 | + | frame(&mut out, top, range, label); |
|
| 108 | + | let step = (W - LEFT - RIGHT) / n.max(1) as f64; |
|
| 109 | + | let plot_h = H - TOP - BOTTOM; |
|
| 110 | + | for i in 0..n { |
|
| 111 | + | let mut y = TOP + plot_h; |
|
| 112 | + | let x = LEFT + step * i as f64 + step * 0.1; |
|
| 113 | + | for s in series { |
|
| 114 | + | let v = s.values[i].unwrap_or(0.0); |
|
| 115 | + | if v <= 0.0 { |
|
| 116 | + | continue; |
|
| 117 | + | } |
|
| 118 | + | let h = plot_h * v / top; |
|
| 119 | + | y -= h; |
|
| 120 | + | let _ = write!( |
|
| 121 | + | out, |
|
| 122 | + | r#"<rect class="bar {}" x="{x:.1}" y="{y:.1}" width="{:.1}" height="{h:.1}"><title>{} {}</title></rect>"#, |
|
| 123 | + | s.class, |
|
| 124 | + | step * 0.8, |
|
| 125 | + | label(v), |
|
| 126 | + | esc(s.name) |
|
| 127 | + | ); |
|
| 128 | + | } |
|
| 129 | + | } |
|
| 130 | + | out.push_str("</svg>"); |
|
| 131 | + | out |
|
| 132 | + | } |
|
| 133 | + | ||
| 134 | + | #[cfg(test)] |
|
| 135 | + | mod tests { |
|
| 136 | + | use super::*; |
|
| 137 | + | ||
| 138 | + | #[test] |
|
| 139 | + | fn charts_are_svg_with_classes_and_no_styles() { |
|
| 140 | + | let s = vec![Series { name: "median", class: "s-main", values: vec![Some(1.0), None, Some(3.0), Some(4.0)] }]; |
|
| 141 | + | let svg = lines("Pickup", Range::Hour, &s, &|v| format!("{v:.0}")); |
|
| 142 | + | assert!(svg.starts_with("<svg class=\"chart\"") && svg.ends_with("</svg>")); |
|
| 143 | + | assert!(svg.contains("class=\"line s-main\"") && svg.contains("class=\"dot s-main\""), "{svg}"); |
|
| 144 | + | assert!(!svg.contains("style="), "no inline styles: the CSP"); |
|
| 145 | + | let bars = stacked("Jobs", Range::Day, &s, &|v| format!("{v:.0}")); |
|
| 146 | + | assert_eq!(bars.matches("<rect").count(), 3); |
|
| 147 | + | assert_eq!(nice(7.0), 10.0); |
|
| 148 | + | assert_eq!(nice(130.0), 200.0); |
|
| 149 | + | } |
|
| 150 | + | } |
added crates/hub/src/web/insights.rs +233 −0
| 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 | + | } |
added templates/insights.html +65 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Insights · {{ scope }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %}{% if wall %} class="wall"{% endif %}{% endblock %} |
|
| 5 | + | {% block tabs %}{% if !wall %}{% if let Some(r) = repo %}{% call m::tabs(r, "insights") %}{% endif %}{% endif %}{% endblock %} |
|
| 6 | + | {% block content %} |
|
| 7 | + | <div class="insights" data-refresh="30" data-fragment="insights"> |
|
| 8 | + | <div class="head"><h1>Insights <small>{{ scope }} · OxygenFlow, the last {% if range_on("1h") %}hour{% else if range_on("7d") %}7 days{% else %}24 hours{% endif %}</small></h1></div> |
|
| 9 | + | <div class="bar insights-bar"> |
|
| 10 | + | <span class="modes"> |
|
| 11 | + | <a href="{{ base }}?range=1h{{ keep }}"{% if range_on("1h") %} class="on" aria-current="page"{% endif %}>1 hour</a> |
|
| 12 | + | <a href="{{ base }}?range=24h{{ keep }}"{% if range_on("24h") %} class="on" aria-current="page"{% endif %}>24 hours</a> |
|
| 13 | + | <a href="{{ base }}?range=7d{{ keep }}"{% if range_on("7d") %} class="on" aria-current="page"{% endif %}>7 days</a> |
|
| 14 | + | </span> |
|
| 15 | + | {% if !repo_choices.is_empty() || !runner_choices.is_empty() %} |
|
| 16 | + | <form class="filters" method="get" action="{{ base }}"> |
|
| 17 | + | <input type="hidden" name="range" value="{{ i.range.key() }}">{% if wall %}<input type="hidden" name="wall" value="1">{% endif %} |
|
| 18 | + | <select name="repo" aria-label="Repository or owner"><option value="">every repository</option>{% for r in repo_choices %}<option{% if *r == repo_filter %} selected{% endif %}>{{ r }}</option>{% endfor %}</select> |
|
| 19 | + | <select name="runner" aria-label="Runner"><option value="">every runner</option>{% for r in runner_choices %}<option{% if *r == runner_filter %} selected{% endif %}>{{ r }}</option>{% endfor %}</select> |
|
| 20 | + | <button>filter</button> |
|
| 21 | + | </form> |
|
| 22 | + | {% endif %} |
|
| 23 | + | <span class="insights-links small"> |
|
| 24 | + | {% if let Some((label, url)) = wider %}<a href="{{ url }}?range={{ i.range.key() }}">{{ label }}</a>{% endif %} |
|
| 25 | + | {% if wall %}<a href="{{ base }}?range={{ i.range.key() }}{% if !repo_filter.is_empty() %}&repo={{ repo_filter|urlencode }}{% endif %}{% if !runner_filter.is_empty() %}&runner={{ runner_filter|urlencode }}{% endif %}">leave the wall view</a>{% else %}<a href="{{ base }}?range={{ i.range.key() }}{{ keep }}&wall=1" target="_blank" rel="noopener" title="For a second monitor: large, no menus, refreshing itself">wall view ↗</a>{% endif %} |
|
| 26 | + | {% if !grafana.is_empty() %}<a href="{{ grafana }}" target="_blank" rel="noopener">open in Grafana ↗</a>{% endif %} |
|
| 27 | + | </span> |
|
| 28 | + | </div> |
|
| 29 | + | ||
| 30 | + | <div class="kpis"> |
|
| 31 | + | <div class="kpi"><span class="kpi-v">{{ secs(i.pickup_median) }}</span><span class="kpi-l">time to pickup, median</span><span class="kpi-s">95th percentile {{ secs(i.pickup_p95) }}</span></div> |
|
| 32 | + | <div class="kpi"><span class="kpi-v">{{ i.ended }}</span><span class="kpi-l">jobs finished</span><span class="kpi-s">{{ pass_rate() }} passed of those that passed or failed</span></div> |
|
| 33 | + | <div class="kpi"><span class="kpi-v">{{ secs(i.duration_median) }}</span><span class="kpi-l">job duration, median</span><span class="kpi-s">started to finished</span></div> |
|
| 34 | + | <div class="kpi{% if i.queued_now > 0 && i.slots_total > 0 && i.slots_busy >= i.slots_total %} kpi-warn{% endif %}"><span class="kpi-v">{{ i.queued_now }}</span><span class="kpi-l">queued now</span><span class="kpi-s">{{ i.running_now }} running</span></div> |
|
| 35 | + | <div class="kpi{% if i.runners_total > 0 && i.runners_online == 0 %} kpi-bad{% endif %}"><span class="kpi-v">{{ i.runners_online }}/{{ i.runners_total }}</span><span class="kpi-l">runners online</span><span class="kpi-s">{{ i.slots_busy }} of {{ i.slots_total }} slots busy</span></div> |
|
| 36 | + | </div> |
|
| 37 | + | ||
| 38 | + | <div class="chart-grid"> |
|
| 39 | + | <figure class="chart-card"><figcaption>Time to pickup <span class="mute small">ready to started</span></figcaption>{{ charts.pickup|safe }}<div class="legend">{% for s in i.pickup %}<span class="key {{ s.class }}">{{ s.name }}</span>{% endfor %}</div></figure> |
|
| 40 | + | <figure class="chart-card"><figcaption>Jobs finished <span class="mute small">per {% if range_on("1h") %}minute{% else if range_on("7d") %}3 hours{% else %}30 minutes{% endif %}</span></figcaption>{{ charts.results|safe }}<div class="legend">{% for s in i.results %}<span class="key {{ s.class }}">{{ s.name }}</span>{% endfor %}</div></figure> |
|
| 41 | + | <figure class="chart-card"><figcaption>Queued and running</figcaption>{{ charts.load|safe }}<div class="legend">{% for s in i.load %}<span class="key {{ s.class }}">{{ s.name }}</span>{% endfor %}</div></figure> |
|
| 42 | + | <figure class="chart-card"><figcaption>Job duration <span class="mute small">started to finished</span></figcaption>{{ charts.durations|safe }}<div class="legend">{% for s in i.durations %}<span class="key {{ s.class }}">{{ s.name }}</span>{% endfor %}</div></figure> |
|
| 43 | + | <figure class="chart-card"><figcaption>Runner utilisation <span class="mute small">running jobs over the slots of the runners online now</span></figcaption>{{ charts.utilisation|safe }}<div class="legend">{% for s in i.utilisation %}<span class="key {{ s.class }}">{{ s.name }}</span>{% endfor %}</div></figure> |
|
| 44 | + | </div> |
|
| 45 | + | ||
| 46 | + | {% if !i.runners.is_empty() %} |
|
| 47 | + | <section class="panel"><h2>Runners</h2> |
|
| 48 | + | <div class="table"><table> |
|
| 49 | + | <tr><th>Runner</th><th>Status</th><th>Busy</th><th>Jobs</th><th>Failed</th><th>Pickup, median</th><th>Duration, median</th><th>Last heard from</th></tr> |
|
| 50 | + | {% for r in i.runners %}<tr><td><strong>{{ r.name }}</strong></td><td><span class="runner-status st-{{ r.status }}">{{ r.status }}</span></td><td>{{ r.running }}/{{ r.max_jobs }}</td><td>{{ r.jobs }}</td><td>{% if r.failed > 0 %}{{ r.failed }}{% else %}<span class="mute">0</span>{% endif %}</td><td>{{ secs(r.pickup_median) }}</td><td>{{ secs(r.duration_median) }}</td><td class="small">{% if let Some(t) = r.last_seen_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td></tr>{% endfor %} |
|
| 51 | + | </table></div> |
|
| 52 | + | </section> |
|
| 53 | + | {% endif %} |
|
| 54 | + | ||
| 55 | + | {% if i.repos.len() > 1 %} |
|
| 56 | + | <section class="panel"><h2>Repositories <small class="mute">busiest first</small></h2> |
|
| 57 | + | <div class="table"><table> |
|
| 58 | + | <tr><th>Repository</th><th>Jobs</th><th>Failed</th><th>Queued now</th><th>Pickup, median</th><th>Pickup, 95th percentile</th></tr> |
|
| 59 | + | {% for r in i.repos %}<tr><td><a href="/{{ r.slug }}/insights?range={{ i.range.key() }}">{{ r.slug }}</a></td><td>{{ r.jobs }}</td><td>{% if r.failed > 0 %}{{ r.failed }}{% else %}<span class="mute">0</span>{% endif %}</td><td>{{ r.queued }}</td><td>{{ secs(r.pickup_median) }}</td><td>{{ secs(r.pickup_p95) }}</td></tr>{% endfor %} |
|
| 60 | + | </table></div> |
|
| 61 | + | </section> |
|
| 62 | + | {% endif %} |
|
| 63 | + | <p class="small mute">Updated <time title="{{ i.now_ms|when_i }}">{{ i.now_ms|ago_i }}</time>; this page refreshes itself every 30 seconds. Pickup is from when a job could run (queued, its dependencies passed, approved) to when a runner took it.</p> |
|
| 64 | + | </div> |
|
| 65 | + | {% endblock %} |
modified assets/hub.js +35 −0
| … | 784 unchanged lines |
||
| 785 | 785 | }); |
|
| 786 | 786 | })(); |
|
| 787 | 787 | ||
| 788 | + | /* Insights (oxygenhub#257): a part marked data-refresh="SECONDS" |
|
| 789 | + | data-fragment="NAME" is fetched again on that interval while the page is |
|
| 790 | + | visible, and swapped in place; never while someone is using a control in |
|
| 791 | + | it. Without this file the page is a snapshot; a reload updates it. */ |
|
| 792 | + | (function () { |
|
| 793 | + | "use strict"; |
|
| 794 | + | var parts = document.querySelectorAll("[data-refresh][data-fragment]"); |
|
| 795 | + | if (!parts.length || !window.fetch || !window.DOMParser) return; |
|
| 796 | + | var every = Math.max(10, parseInt(parts[0].getAttribute("data-refresh"), 10) || 30) * 1000; |
|
| 797 | + | var busy = false; |
|
| 798 | + | var inUse = function (el) { |
|
| 799 | + | var a = document.activeElement; |
|
| 800 | + | return a && el.contains(a) && /^(SELECT|INPUT|TEXTAREA)$/.test(a.tagName); |
|
| 801 | + | }; |
|
| 802 | + | var tick = function () { |
|
| 803 | + | if (busy || document.hidden) return; |
|
| 804 | + | busy = true; |
|
| 805 | + | fetch(location.href, { credentials: "same-origin", headers: { "X-Fragment": "page" } }) |
|
| 806 | + | .then(function (r) { return r.ok ? r.text() : null; }) |
|
| 807 | + | .then(function (html) { |
|
| 808 | + | busy = false; |
|
| 809 | + | if (html === null) return; |
|
| 810 | + | var doc = new DOMParser().parseFromString(html, "text/html"); |
|
| 811 | + | Array.prototype.forEach.call(parts, function (el) { |
|
| 812 | + | if (inUse(el)) return; |
|
| 813 | + | var fresh = doc.querySelector('[data-fragment="' + el.getAttribute("data-fragment") + '"]'); |
|
| 814 | + | if (fresh) el.innerHTML = fresh.innerHTML; |
|
| 815 | + | }); |
|
| 816 | + | }) |
|
| 817 | + | .catch(function () { busy = false; }); |
|
| 818 | + | }; |
|
| 819 | + | setInterval(tick, every); |
|
| 820 | + | document.addEventListener("visibilitychange", function () { if (!document.hidden) tick(); }); |
|
| 821 | + | })(); |
|
| 822 | + | ||
| 788 | 823 | /* Drafting with an assistant (oxygenhub#248): the draft is streamed into |
|
| 789 | 824 | the description as it is written, so a long one is neither cut off by a |
|
| 790 | 825 | proxy nor waited for in silence. Without this file the button posts the |
|
| … | 55 unchanged lines |
modified crates/hub/src/config.rs +4 −0
| … | 234 unchanged lines |
||
| 235 | 235 | /// The bearer token `/metrics` asks for (oxygenhub#212); empty, the |
|
| 236 | 236 | /// metrics are off. |
|
| 237 | 237 | pub metrics_token: String, |
|
| 238 | + | /// A Grafana for this hub's metrics, linked from Insights (oxygenhub#257) |
|
| 239 | + | /// for charts of one's own; empty, no link. |
|
| 240 | + | pub grafana_url: String, |
|
| 238 | 241 | } |
|
| 239 | 242 | ||
| 240 | 243 | #[derive(Clone, Debug, Deserialize)] |
|
| … | 90 unchanged lines |
||
| 331 | 334 | behind_proxy: false, |
|
| 332 | 335 | hsts_max_age: 0, |
|
| 333 | 336 | metrics_token: String::new(), |
|
| 337 | + | grafana_url: String::new(), |
|
| 334 | 338 | } |
|
| 335 | 339 | } |
|
| 336 | 340 | } |
|
| … | 176 unchanged lines |
modified crates/hub/src/db/flow.rs +27 −11
| … | 161 unchanged lines |
||
| 162 | 162 | pub environment: String, |
|
| 163 | 163 | /// The approval attestation that released it, when sealed (#235). |
|
| 164 | 164 | pub approval_seal: String, |
|
| 165 | + | /// When it entered the queue (made, or run again), and when it became |
|
| 166 | + | /// ready to run: set at its claim (migration 0080, oxygenhub#257). |
|
| 167 | + | #[sqlx(default)] |
|
| 168 | + | pub queued_ms: Option<i64>, |
|
| 169 | + | #[sqlx(default)] |
|
| 170 | + | pub ready_ms: Option<i64>, |
|
| 165 | 171 | } |
|
| 166 | 172 | ||
| 167 | 173 | impl Job { |
|
| … | 99 unchanged lines |
||
| 267 | 273 | .await?; |
|
| 268 | 274 | for j in jobs { |
|
| 269 | 275 | sqlx::query( |
|
| 270 | - | "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status, need_disk_mib, approval, environment, priority) \ |
|
| 271 | - | VALUES ($1, $2, $3, $4, $5, $6, $7, 'queued', $8, $9, $10, $11)", |
|
| 276 | + | "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status, need_disk_mib, approval, environment, priority, queued_ms) \ |
|
| 277 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, 'queued', $8, $9, $10, $11, $12)", |
|
| 272 | 278 | ) |
|
| 273 | 279 | .bind(id) |
|
| 274 | 280 | .bind(&j.name) |
|
| … | 6 unchanged lines |
||
| 281 | 287 | .bind(if needs_approval(&j.spec) { "waiting" } else { "" }) |
|
| 282 | 288 | .bind(environment_of(&j.spec)) |
|
| 283 | 289 | .bind(priority_of(&j.spec)) |
|
| 290 | + | .bind(now) |
|
| 284 | 291 | .execute(&mut *tx) |
|
| 285 | 292 | .await?; |
|
| 286 | 293 | } |
|
| … | 345 unchanged lines |
||
| 632 | 639 | continue; |
|
| 633 | 640 | } |
|
| 634 | 641 | let deps = j.depends_on(); |
|
| 642 | + | // When it became ready: queued, its dependencies passed, approved. |
|
| 643 | + | let mut ready = j.queued_ms.unwrap_or(0).max(j.approved_ms.unwrap_or(0)); |
|
| 635 | 644 | if !deps.is_empty() { |
|
| 636 | 645 | let mut passed = 0i64; |
|
| 637 | 646 | for d in &deps { |
|
| 638 | - | let st: Option<String> = sqlx::query_scalar("SELECT status FROM flow_jobs WHERE run_id = $1 AND name = $2") |
|
| 639 | - | .bind(j.run_id) |
|
| 640 | - | .bind(d) |
|
| 641 | - | .fetch_optional(&mut *tx) |
|
| 642 | - | .await?; |
|
| 643 | - | if st.as_deref() == Some("passed") { |
|
| 644 | - | passed += 1; |
|
| 647 | + | let st: Option<(String, Option<i64>)> = |
|
| 648 | + | sqlx::query_as("SELECT status, finished_ms FROM flow_jobs WHERE run_id = $1 AND name = $2") |
|
| 649 | + | .bind(j.run_id) |
|
| 650 | + | .bind(d) |
|
| 651 | + | .fetch_optional(&mut *tx) |
|
| 652 | + | .await?; |
|
| 653 | + | if let Some((status, finished)) = st { |
|
| 654 | + | if status == "passed" { |
|
| 655 | + | passed += 1; |
|
| 656 | + | ready = ready.max(finished.unwrap_or(0)); |
|
| 657 | + | } |
|
| 645 | 658 | } |
|
| 646 | 659 | } |
|
| 647 | 660 | if passed != deps.len() as i64 { |
|
| … | 5 unchanged lines |
||
| 653 | 666 | } |
|
| 654 | 667 | let now = now_ms(); |
|
| 655 | 668 | let done = sqlx::query( |
|
| 656 | - | "UPDATE flow_jobs SET status = 'running', runner = $1, token_hash = $2, started_ms = $3, waiting = '' WHERE id = $4 AND status = 'queued'", |
|
| 669 | + | "UPDATE flow_jobs SET status = 'running', runner = $1, token_hash = $2, started_ms = $3, waiting = '', ready_ms = $5 \ |
|
| 670 | + | WHERE id = $4 AND status = 'queued'", |
|
| 657 | 671 | ) |
|
| 658 | 672 | .bind(runner) |
|
| 659 | 673 | .bind(token_hash) |
|
| 660 | 674 | .bind(now) |
|
| 661 | 675 | .bind(j.id) |
|
| 676 | + | .bind(if ready > 0 { ready.min(now) } else { now }) |
|
| 662 | 677 | .execute(&mut *tx) |
|
| 663 | 678 | .await?; |
|
| 664 | 679 | if done.rows_affected() == 1 { |
|
| … | 383 unchanged lines |
||
| 1048 | 1063 | exit_code = NULL, error = '', receipt = '', receipt_hash = '', log_count = 0, also_read = '', receipt_attest = '', |
|
| 1049 | 1064 | receipt_signer = '', receipt_note = '', runner_id = NULL, waiting = '', placed = '', peak_disk_mib = NULL, |
|
| 1050 | 1065 | peak_memory_mib = NULL, approval = CASE WHEN approval = '' THEN '' ELSE 'waiting' END, approved_by = '', |
|
| 1051 | - | approved_ms = NULL, approval_seal = '', approved_via = '' WHERE id = $1", |
|
| 1066 | + | approved_ms = NULL, approval_seal = '', approved_via = '', queued_ms = $2, ready_ms = NULL WHERE id = $1", |
|
| 1052 | 1067 | ) |
|
| 1053 | 1068 | .bind(id) |
|
| 1069 | + | .bind(now_ms()) |
|
| 1054 | 1070 | .execute(&mut *tx) |
|
| 1055 | 1071 | .await?; |
|
| 1056 | 1072 | } |
|
| … | 208 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 16 unchanged lines |
||
| 17 | 17 | pub mod headers; |
|
| 18 | 18 | pub mod hooks; |
|
| 19 | 19 | pub mod images; |
|
| 20 | + | pub mod insights; |
|
| 20 | 21 | pub mod junit; |
|
| 21 | 22 | pub mod keyless; |
|
| 22 | 23 | pub mod keys; |
|
| … | 332 unchanged lines |
modified crates/hub/src/repos/names.rs +4 −0
| … | 37 unchanged lines |
||
| 38 | 38 | "csp-report", |
|
| 39 | 39 | "security.txt", |
|
| 40 | 40 | "security-contact", |
|
| 41 | + | // The transparency log, the metrics and Insights (oxygenhub#238, #212, #257). |
|
| 42 | + | "log", |
|
| 43 | + | "metrics", |
|
| 44 | + | "insights", |
|
| 41 | 45 | ]; |
|
| 42 | 46 | ||
| 43 | 47 | /// `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`, not `.`/`..`, not ending in `.git`. |
|
| … | 60 unchanged lines |
modified crates/hub/src/web/mod.rs +5 −0
| … | 8 unchanged lines |
||
| 9 | 9 | mod avatar; |
|
| 10 | 10 | pub(crate) mod backups; |
|
| 11 | 11 | pub mod chain; |
|
| 12 | + | pub mod charts; |
|
| 12 | 13 | pub mod coverage; |
|
| 13 | 14 | mod device; |
|
| 14 | 15 | mod diffusion; |
|
| … | 5 unchanged lines |
||
| 20 | 21 | pub mod history; |
|
| 21 | 22 | pub mod home; |
|
| 22 | 23 | pub mod icons; |
|
| 24 | + | mod insights; |
|
| 23 | 25 | mod live_stream; |
|
| 24 | 26 | mod security_txt; |
|
| 25 | 27 | mod transparency; |
|
| … | 129 unchanged lines |
||
| 155 | 157 | .route("/admin/runners/settings", get(runners::settings).post(runners::save_settings)) |
|
| 156 | 158 | .route("/admin/runners/priority", post(runners::save_priority)) |
|
| 157 | 159 | .route("/admin/runners/images", get(runners::images)) |
|
| 160 | + | .route("/admin/insights", get(insights::site)) |
|
| 161 | + | .route("/insights/{owner}", get(insights::owner)) |
|
| 158 | 162 | .route("/admin/diffusion", get(diffusion::admin)) |
|
| 159 | 163 | .route("/admin/backups", get(backups::admin)) |
|
| 160 | 164 | .route("/{owner}/{name}/diffusion", get(diffusion::page)) |
|
| … | 49 unchanged lines |
||
| 210 | 214 | .route("/{owner}/{name}/artifacts", get(artifacts::artifacts)) |
|
| 211 | 215 | .route("/{owner}/{name}/images", get(artifacts::images)) |
|
| 212 | 216 | .route("/{owner}/{name}/terraform", get(artifacts::terraform)) |
|
| 217 | + | .route("/{owner}/{name}/insights", get(insights::repo)) |
|
| 213 | 218 | .route("/{owner}/{name}/terraform/{state}/download", get(artifacts::download)) |
|
| 214 | 219 | .route("/{owner}/{name}/terraform/{state}/restore", post(artifacts::restore)) |
|
| 215 | 220 | .route("/{owner}/{name}/conflicts", get(refs::conflicts)) |
|
| … | 636 unchanged lines |
modified crates/hub/tests/flow.rs +69 −0
| … | 5430 unchanged lines |
||
| 5431 | 5431 | assert!(!text.contains("oxyhub_flow_jobs{state=\"queued\"} 0"), "the run's jobs are queued:\n{text}"); |
|
| 5432 | 5432 | } |
|
| 5433 | 5433 | ||
| 5434 | + | /// Insights (oxygenhub#257): a job is ready when it could run (queued, its |
|
| 5435 | + | /// dependencies passed), so its pickup time is from then to its claim; the |
|
| 5436 | + | /// repository's page charts its jobs, an owner's numbers are its own and |
|
| 5437 | + | /// the site's are the administrators'. |
|
| 5438 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 5439 | + | async fn insights_chart_pickup_from_when_a_job_could_run() { |
|
| 5440 | + | let h = hub().await; |
|
| 5441 | + | create(&h.app, "andy", "demo").await; |
|
| 5442 | + | let work = tempfile::tempdir().unwrap(); |
|
| 5443 | + | let c = client(&format!("{}/andy/demo", h.base), work.path()); |
|
| 5444 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 5445 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 5446 | + | c.snapshot("test").unwrap(); |
|
| 5447 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 5448 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 5449 | + | assert_eq!(runs_of(&h.app, "andy", "demo").await.len(), 1); |
|
| 5450 | + | let mut finished = std::collections::HashMap::new(); |
|
| 5451 | + | for want in ["build", "lint", "test"] { |
|
| 5452 | + | let j = claim(&h.app).await.expect("a job"); |
|
| 5453 | + | assert_eq!(j["job"]["name"].as_str(), Some(want)); |
|
| 5454 | + | let id = j["job_id"].as_i64().unwrap(); |
|
| 5455 | + | let row = db::flow::job(&h.app.db, id).await.unwrap().unwrap(); |
|
| 5456 | + | let (queued, ready, started) = (row.queued_ms.unwrap(), row.ready_ms.unwrap(), row.started_ms.unwrap()); |
|
| 5457 | + | assert!(queued <= ready && ready <= started, "{want}: {queued} {ready} {started}"); |
|
| 5458 | + | if want == "test" { |
|
| 5459 | + | assert_eq!(Some(ready), finished["build"], "ready when build passed, not when it was queued"); |
|
| 5460 | + | } |
|
| 5461 | + | let ph = j["pipeline_hash"].as_str().unwrap(); |
|
| 5462 | + | let st = finish(&h.app, id, serde_json::json!({ "exit_code": 0, "duration_ms": 700, "receipt": receipt(want, ph, 0) })).await; |
|
| 5463 | + | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 5464 | + | finished.insert(want, db::flow::job(&h.app.db, id).await.unwrap().unwrap().finished_ms); |
|
| 5465 | + | } |
|
| 5466 | + | let (st, page) = call(&h.app, "GET", "/andy/demo/insights", None, None).await; |
|
| 5467 | + | assert_eq!(st, StatusCode::OK, "{page}"); |
|
| 5468 | + | assert!(page.contains("<span class=\"kpi-v\">3</span><span class=\"kpi-l\">jobs finished"), "{page}"); |
|
| 5469 | + | assert!(page.contains("100% passed"), "{page}"); |
|
| 5470 | + | assert_eq!(page.matches("<svg class=\"chart\"").count(), 5, "five charts"); |
|
| 5471 | + | assert!(page.contains("class=\"bar s-ok\""), "the passed jobs drawn"); |
|
| 5472 | + | assert!(page.contains("data-refresh=\"30\" data-fragment=\"insights\""), "it refreshes itself"); |
|
| 5473 | + | assert!(page.contains("<summary class=\"on\">More</summary>"), "the bar's More menu holds it"); |
|
| 5474 | + | let (_, wall) = call(&h.app, "GET", "/andy/demo/insights?range=7d&wall=1", None, None).await; |
|
| 5475 | + | assert!(wall.contains("<body class=\"wall\"") && wall.contains("the last 7 days") && wall.contains("leave the wall view"), "{wall}"); |
|
| 5476 | + | // An owner's numbers are not anyone's; the site's are administrators'. |
|
| 5477 | + | assert_eq!(call(&h.app, "GET", "/insights/andy", None, None).await.0, StatusCode::NOT_FOUND); |
|
| 5478 | + | assert_eq!(call(&h.app, "GET", "/admin/insights", None, None).await.0, StatusCode::NOT_FOUND); |
|
| 5479 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 5480 | + | .oneshot( |
|
| 5481 | + | Request::post("/register") |
|
| 5482 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 5483 | + | .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 5484 | + | .unwrap(), |
|
| 5485 | + | ) |
|
| 5486 | + | .await |
|
| 5487 | + | .unwrap(); |
|
| 5488 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 5489 | + | let andy = db::accounts::account_by_name(&h.app.db, "andy").await.unwrap().unwrap(); |
|
| 5490 | + | db::accounts::set_admin(&h.app.db, andy.id, true).await.unwrap(); |
|
| 5491 | + | for path in ["/insights/andy", "/admin/insights?runner=test-1&range=1h"] { |
|
| 5492 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 5493 | + | .oneshot(Request::get(path).header(header::COOKIE, cookie.clone()).body(Body::empty()).unwrap()) |
|
| 5494 | + | .await |
|
| 5495 | + | .unwrap(); |
|
| 5496 | + | let st = res.status(); |
|
| 5497 | + | let body = String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned(); |
|
| 5498 | + | assert_eq!(st, StatusCode::OK, "{path}: {body}"); |
|
| 5499 | + | assert!(body.contains("<svg class=\"chart\""), "{path}"); |
|
| 5500 | + | } |
|
| 5501 | + | } |
|
| 5502 | + | ||
| 5434 | 5503 | /// Live L5 (docs/live.md, oxygenhub#207): the issue page's timeline |
|
| 5435 | 5504 | /// listens on the issue's topic and the repository's, the overview's latest |
|
| 5436 | 5505 | /// change and bookmarks on the repository's, and its Diffusion card and the |
|
| … | 209 unchanged lines |
modified docs/spec.md +10 −0
| … | 271 unchanged lines |
||
| 272 | 272 | | `/{owner}/{name}/releases`, `/tags` | §9; the two are views of the Releases tab | |
|
| 273 | 273 | | `/{owner}/{name}/artifacts` | Build artifacts: the newest 200 artifacts of every run, with the run, job, size, BLAKE3 and when, each linking its download (oxygenhub#254) | |
|
| 274 | 274 | | `/{owner}/{name}/images` | The runner images the repository's jobs ran on in the last 90 days: architecture and size when the hub keeps the image, the jobs that used it and when last | |
|
| 275 | + | | `/{owner}/{name}/insights`, `/insights/{owner}`, `/admin/insights` | Insights (§13.5.6): OxygenFlow's charts for a repository (anyone who reads it), an owner (the owner, its members, administrators) or the site (administrators) | |
|
| 275 | 276 | | `/{owner}/{name}/terraform` | Terraform state (§13.5.4): the backend block, how a pipeline and a person authenticate, and the states with their serials, last writer, lock and versions kept | |
|
| 276 | 277 | | `/{owner}/{name}/trust` | Policy, roots, rules, hub delegate status, verification summary (§6.11) | |
|
| 277 | 278 | | `/{owner}/{name}/pushes` | Reference update log: who pushed what, when, with which key, result (§6.12) | |
|
| … | 375 unchanged lines |
||
| 653 | 654 | ||
| 654 | 655 | Per-route request counts and latency histograms, pushes and fetches, backups, Diffusion, mail and the database pool are the next slices; the runner's own metrics too. |
|
| 655 | 656 | ||
| 657 | + | ### 13.5.6 Insights (oxygenhub#257) |
|
| 658 | + | ||
| 659 | + | The standard OxygenFlow charts, for people who will not build their own in Grafana: one page at three scopes, so the numbers always agree, the site (`/admin/insights`, administrators, filtered by repository, owner or runner), an owner (`/insights/{owner}`: the owner, the organisation's members, administrators) and a repository (`/{owner}/{name}/insights`, in its bar's More menu and linked from its Flow tab). `?range=` is `1h`, `24h` (the default) or `7d`. It shows: |
|
| 660 | + | - headline numbers: time to pickup (median, 95th percentile), jobs finished and the share that passed, the median job duration, the jobs queued and running now, runners online and their slots in use; |
|
| 661 | + | - charts: time to pickup (median and 95th percentile by the job's start), jobs finished per bucket by result, queued and running jobs over time, job duration, and runner utilisation (running jobs over the slots of the runners online now); |
|
| 662 | + | - tables: each runner (status, busy slots, jobs, failures, median pickup and duration, last heard from) and, for the site and an owner, the busiest repositories. |
|
| 663 | + | ||
| 664 | + | **Pickup** is from when a job could run to when a runner took it. A job is *queued* when it is made or run again (`queued_ms`) and *ready* at the latest of that, its dependencies passing and its approval (`ready_ms`, set at its claim; migration 0080, which estimates earlier jobs from their run's start and the earlier stages' ends). A job a runner hands back keeps its times: the person still waits. The charts are inline SVG drawn by the hub (no script or library; coloured by theme classes). The page refreshes its numbers every 30 seconds while visible (`data-refresh`), and is a snapshot without JavaScript. **Wall view** (`?wall=1`, opened in a new window from the page): no menus, larger numbers, for a second monitor. The runners page's queue links its filters to the site's Insights (**Charts ↗**). `[server] grafana_url`, when set, adds an *open in Grafana* link for one's own charts from `/metrics`. |
|
| 665 | + | ||
| 656 | 666 | ### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06) |
|
| 657 | 667 | ||
| 658 | 668 | Coverage is built in, as Coveralls or Codecov would be, fed by OxygenFlow. |
|
| … | 167 unchanged lines |
modified templates/admin_runners.html +1 −0
| … | 45 unchanged lines |
||
| 46 | 46 | <select name="status" aria-label="Status"><option value="">running and queued</option><option value="running"{% if filter.status == "running" %} selected{% endif %}>running</option><option value="queued"{% if filter.status == "queued" %} selected{% endif %}>queued</option></select> |
|
| 47 | 47 | <select name="runner" aria-label="Runner"><option value="">every runner</option>{% for r in queue_runners %}<option{% if *r == filter.runner %} selected{% endif %}>{{ r }}</option>{% endfor %}{% if !filter.runner.is_empty() && !queue_runners.contains(filter.runner) %}<option selected>{{ filter.runner }}</option>{% endif %}</select> |
|
| 48 | 48 | <button>filter</button> |
|
| 49 | + | <a class="go end" href="/admin/insights?range=24h{% if !filter.repo.is_empty() %}&repo={{ filter.repo|urlencode }}{% endif %}{% if !filter.runner.is_empty() %}&runner={{ filter.runner|urlencode }}{% endif %}" target="_blank" rel="noopener" title="Pickup time, jobs, durations and runners, charted (opens a new window)">Charts ↗</a> |
|
| 49 | 50 | </form> |
|
| 50 | 51 | {% if queue.is_empty() %}<p class="mute small">{% if filter.any() %}Nothing matches.{% else %}Nothing running or waiting.{% endif %}</p>{% else %} |
|
| 51 | 52 | <div class="table"><table> |
|
| … | 17 unchanged lines |
modified templates/flow_runs.html +1 −1
| … | 3 unchanged lines |
||
| 4 | 4 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %} |
|
| 6 | 6 | {% block content %} |
|
| 7 | - | <div class="head"><h1>Flow <small>pipeline runs · <a href="{{ repo.url }}/deployments">deployments</a></small></h1> |
|
| 7 | + | <div class="head"><h1>Flow <small>pipeline runs · <a href="{{ repo.url }}/deployments">deployments</a> · <a href="{{ repo.url }}/insights" target="_blank" rel="noopener" title="Pickup time, jobs, durations and runners, charted">charts ↗</a></small></h1> |
|
| 8 | 8 | {% if can_run %}<form class="inline" method="post" action="{{ repo.url }}/flow/start"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label for="run-bookmark" class="small mute">Run the pipeline of</label> <select id="run-bookmark" name="bookmark">{% for b in bookmarks %}<option value="{{ b }}">{{ b }}</option>{% endfor %}</select> <button type="submit" class="primary-btn">Run pipeline</button></form>{% endif %} |
|
| 9 | 9 | </div> |
|
| 10 | 10 | {% if let Some(w) = why_not %}<p class="small mute">{{ w }}</p>{% endif %} |
|
| … | 42 unchanged lines |
modified templates/repo_tabs.html +2 −1
| … | 20 unchanged lines |
||
| 21 | 21 | </div> |
|
| 22 | 22 | </details> |
|
| 23 | 23 | <details class="tab-menu" data-tab-menu> |
|
| 24 | - | <summary{% if tab == "bookmarks" || tab == "diffusion" || tab == "trust" || tab == "pushes" || tab == "conflicts" %} class="on"{% endif %}>More</summary> |
|
| 24 | + | <summary{% if tab == "bookmarks" || tab == "diffusion" || tab == "trust" || tab == "pushes" || tab == "conflicts" || tab == "insights" %} class="on"{% endif %}>More</summary> |
|
| 25 | 25 | <div class="tab-menu-list"> |
|
| 26 | + | <a href="{{ repo.url }}/insights"{% if tab == "insights" %} aria-current="page"{% endif %}>Insights</a> |
|
| 26 | 27 | <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} aria-current="page"{% endif %}>Bookmarks</a> |
|
| 27 | 28 | <a href="{{ repo.url }}/tags"{% if tab == "tags" %} aria-current="page"{% endif %}>Tags</a> |
|
| 28 | 29 | {% if repo.diffusion %}<a href="{{ repo.url }}/diffusion"{% if tab == "diffusion" %} aria-current="page"{% endif %}>Diffusion</a>{% endif %} |
|
| … | 64 unchanged lines |
modified templates/style.css +38 −0
| … | 858 unchanged lines |
||
| 859 | 859 | form.filters > select, form.filters > input { width: auto; max-width: 100%; min-width: 0; margin: 0; } |
|
| 860 | 860 | form.filters > button { margin: 0; align-self: stretch; } |
|
| 861 | 861 | form.filters > .end { margin-left: auto; } |
|
| 862 | + | /* Insights (oxygenhub#257): headline numbers, then charts drawn by the hub |
|
| 863 | + | as inline SVG, coloured through currentColor from these classes. */ |
|
| 864 | + | .insights-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; } |
|
| 865 | + | .insights-bar form.filters { margin: 0; } |
|
| 866 | + | .insights-links { margin-left: auto; display: flex; flex-wrap: wrap; gap: 14px; } |
|
| 867 | + | .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin: 14px 0; } |
|
| 868 | + | .kpi { display: flex; flex-direction: column; gap: 2px; padding: 12px 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; min-width: 0; } |
|
| 869 | + | .kpi-v { font-size: 28px; font-weight: 600; line-height: 1.15; font-variant-numeric: tabular-nums; } |
|
| 870 | + | .kpi-l { font-size: 13px; } |
|
| 871 | + | .kpi-s { font-size: 12px; color: var(--mute); } |
|
| 872 | + | .kpi-warn { border-color: var(--warn); } |
|
| 873 | + | .kpi-bad { border-color: var(--bad); } |
|
| 874 | + | .chart-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: 12px; margin-bottom: 14px; } |
|
| 875 | + | .chart-card { margin: 0; padding: 10px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; min-width: 0; } |
|
| 876 | + | .chart-card figcaption { font-weight: 600; margin-bottom: 4px; } |
|
| 877 | + | svg.chart { display: block; width: 100%; height: auto; } |
|
| 878 | + | svg.chart .grid { stroke: var(--line); stroke-width: 1; } |
|
| 879 | + | svg.chart .axis { fill: var(--mute); font-size: 11px; } |
|
| 880 | + | svg.chart .line { fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; } |
|
| 881 | + | svg.chart .dot, svg.chart .bar { fill: currentColor; } |
|
| 882 | + | .s-main { color: var(--acc); } |
|
| 883 | + | .s-warn { color: var(--warn); } |
|
| 884 | + | .s-ok { color: var(--ok); } |
|
| 885 | + | .s-bad { color: var(--bad); } |
|
| 886 | + | .s-mute { color: var(--mute); } |
|
| 887 | + | .legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; margin-top: 4px; } |
|
| 888 | + | .legend .key { color: var(--ink); } |
|
| 889 | + | .legend .key::before { content: ""; display: inline-block; width: 10px; height: 10px; margin-right: 6px; border-radius: 2px; background: var(--acc); vertical-align: -1px; } |
|
| 890 | + | .legend .key.s-warn::before { background: var(--warn); } |
|
| 891 | + | .legend .key.s-ok::before { background: var(--ok); } |
|
| 892 | + | .legend .key.s-bad::before { background: var(--bad); } |
|
| 893 | + | .legend .key.s-mute::before { background: var(--mute); } |
|
| 894 | + | /* The wall view (?wall=1): for a second monitor, no menus, larger. */ |
|
| 895 | + | body.wall > header, body.wall > .band, body.wall > footer, body.wall > .skip { display: none; } |
|
| 896 | + | body.wall main { max-width: none; padding: 18px 28px; } |
|
| 897 | + | body.wall .kpi-v { font-size: 40px; } |
|
| 898 | + | body.wall .kpi-l { font-size: 15px; } |
|
| 899 | + | body.wall .chart-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 560px), 1fr)); } |
|
| 862 | 900 | /* A Terraform state's kept versions (#255). */ |
|
| 863 | 901 | details.tf-versions { margin-top: 14px; } |
|
| 864 | 902 | details.tf-versions > summary { cursor: pointer; } |
|
| … | 459 unchanged lines |