OXYGENHUB

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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
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