OXYGENHUB
# bf3d07541368 oxygenhub / crates / hub / src / web / flow.rs
18.7 KiB · Rust · last changed A queued run says where it stands 1 month ago source blame history raw
1
//! The Flow tab (hub spec §13.2): runs of a repository, and a run's job
2
//! graph with live status and logs.
3
4
use super::settings::{csrf, require};
5
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
6
use crate::auth::{Access, Role, Viewer};
7
use crate::db::{self, flow as fdb};
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, Path, Query, State};
11
use axum::response::{IntoResponse, Redirect, Response};
12
use axum::Json;
13
use serde::Deserialize;
14
15
pub struct RunRow {
16
    pub r: fdb::Run,
17
    pub duration: String,
18
    pub jobs: Vec<(String, String)>,
19
    /// "3rd in line" for a run still waiting for a runner.
20
    pub place: Option<String>,
21
}
22
23
#[derive(Template)]
24
#[template(path = "flow_runs.html")]
25
pub struct RunsPage {
26
    shell: Shell,
27
    repo: RepoShell,
28
    runs: Vec<RunRow>,
29
    enabled: bool,
30
    older: Option<i64>,
31
    /// Writers may start runs: the bookmarks to offer, default first.
32
    can_run: bool,
33
    bookmarks: Vec<String>,
34
    error: Option<String>,
35
}
36
37
#[derive(Deserialize)]
38
pub struct RunsQuery {
39
    before: Option<i64>,
40
    /// Why a "Run pipeline" or "Re-run" did not start.
41
    error: Option<String>,
42
}
43
44
const PER_PAGE: i64 = 30;
45
46
pub async fn runs(
47
    State(app): State<Shared>,
48
    viewer: Viewer,
49
    Path((owner, name)): Path<(String, String)>,
50
    Query(q): Query<RunsQuery>,
51
) -> Result<Page<RunsPage>, Error> {
52
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer);
53
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
54
    let list = fdb::runs(&app.db, row.id, PER_PAGE + 1, q.before).await.map_err(|e| Error::from_db(shell.clone(), e))?;
55
    let older = (list.len() as i64 > PER_PAGE).then(|| list[PER_PAGE as usize - 1].id);
56
    let queue = if list.iter().any(|r| r.live()) { fdb::queue(&app.db).await.unwrap_or_default() } else { vec![] };
57
    let mut runs = Vec::new();
58
    for r in list.into_iter().take(PER_PAGE as usize) {
59
        let place = if r.live() { crate::flow::queue_info(&queue, row.id, r.id).place() } else { None };
60
        let jobs = fdb::jobs(&app.db, r.id).await.unwrap_or_default();
61
        let duration = match r.finished_ms {
62
            Some(f) => crate::flow::human_ms(f - r.created_ms),
63
            None if r.live() => crate::flow::human_ms(db::now_ms() - r.created_ms),
64
            None => "–".into(),
65
        };
66
        runs.push(RunRow { jobs: jobs.into_iter().map(|j| (j.name, j.status)).collect(), duration, r, place });
67
    }
68
    let enabled = crate::flow::enabled(&app, &row);
69
    let can_run = enabled && viewer.account().is_some() && role >= Role::Write;
70
    let bookmarks = if can_run { bookmark_names(&app, &row).await } else { vec![] };
71
    Ok(Page(RunsPage {
72
        shell,
73
        repo: RepoShell::new(&app, &row, "flow").with_role(role),
74
        runs,
75
        enabled,
76
        older,
77
        can_run,
78
        bookmarks,
79
        error: q.error,
80
    }))
81
}
82
83
/// The repository's bookmarks, its default first.
84
async fn bookmark_names(app: &Shared, row: &db::repos::RepoRow) -> Vec<String> {
85
    let Some(repo) = crate::repos::RepoName::new(&row.owner, &row.name) else { return vec![] };
86
    let mut names: Vec<String> = app.repos.read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect())).await.unwrap_or_default();
87
    names.sort_by_key(|n| (n != &row.default_bookmark, n.clone()));
88
    names
89
}
90
91
#[derive(Deserialize)]
92
pub struct StartForm {
93
    #[serde(rename = "_csrf")]
94
    csrf_token: String,
95
    bookmark: String,
96
}
97
98
/// `POST /{owner}/{name}/flow/start`: "Run pipeline" on a bookmark.
99
pub async fn start(
100
    State(app): State<Shared>,
101
    viewer: Viewer,
102
    Path((owner, name)): Path<(String, String)>,
103
    Form(f): Form<StartForm>,
104
) -> Result<Response, Error> {
105
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer);
106
    let me = require(&viewer, &shell)?;
107
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
108
    csrf(&viewer, &f.csrf_token, &shell)?;
109
    Ok(match crate::flow::run_bookmark(&app, &row, f.bookmark.trim(), &me.account.username).await {
110
        Ok(n) => {
111
            db::accounts::audit(&app.db, Some(&me.account), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {}", f.bookmark.trim()))
112
                .await
113
                .ok();
114
            Redirect::to(&format!("/{owner}/{name}/flow/{n}")).into_response()
115
        }
116
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
117
    })
118
}
119
120
#[derive(Deserialize)]
121
pub struct RerunForm {
122
    #[serde(rename = "_csrf")]
123
    csrf_token: String,
124
}
125
126
/// `POST /{owner}/{name}/flow/{number}/rerun`.
127
pub async fn rerun(
128
    State(app): State<Shared>,
129
    viewer: Viewer,
130
    Path((owner, name, number)): Path<(String, String, i64)>,
131
    Form(f): Form<RerunForm>,
132
) -> Result<Response, Error> {
133
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{number}"), &viewer);
134
    let me = require(&viewer, &shell)?;
135
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
136
    csrf(&viewer, &f.csrf_token, &shell)?;
137
    let run = fdb::run(&app.db, row.id, number)
138
        .await
139
        .map_err(|e| Error::from_db(shell.clone(), e))?
140
        .ok_or_else(|| Error::not_found(shell.clone()))?;
141
    Ok(match crate::flow::rerun(&app, &row, &run, &me.account.username).await {
142
        Ok(n) => {
143
            db::accounts::audit(&app.db, Some(&me.account), "flow.rerun", &format!("{owner}/{name}"), &format!("#{n} of #{number}"))
144
                .await
145
                .ok();
146
            Redirect::to(&format!("/{owner}/{name}/flow/{n}")).into_response()
147
        }
148
        Err(msg) => Redirect::to(&format!("/{owner}/{name}/flow?error={}", urlencoding(&msg))).into_response(),
149
    })
150
}
151
152
/// Percent-encode a message for a query string.
153
fn urlencoding(s: &str) -> String {
154
    s.bytes()
155
        .map(|b| match b {
156
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(),
157
            _ => format!("%{b:02X}"),
158
        })
159
        .collect()
160
}
161
162
/// A job placed in the graph.
163
pub struct Node {
164
    pub j: fdb::Job,
165
    pub x: i64,
166
    pub y: i64,
167
    pub duration: String,
168
}
169
170
pub struct Edge {
171
    pub d: String,
172
    pub status: String,
173
}
174
175
pub struct Graph {
176
    pub nodes: Vec<Node>,
177
    pub edges: Vec<Edge>,
178
    pub width: i64,
179
    pub height: i64,
180
}
181
182
pub const NODE_W: i64 = 200;
183
pub const NODE_H: i64 = 48;
184
const COL_GAP: i64 = 72;
185
const ROW_GAP: i64 = 18;
186
const PAD: i64 = 12;
187
188
/// Columns by dependency depth, jobs in each column in name order.
189
pub fn layout(jobs: Vec<fdb::Job>) -> Graph {
190
    let mut by_depth: std::collections::BTreeMap<i64, Vec<fdb::Job>> = Default::default();
191
    for j in jobs {
192
        by_depth.entry(j.depth).or_default().push(j);
193
    }
194
    let rows = by_depth.values().map(Vec::len).max().unwrap_or(0) as i64;
195
    let height = PAD * 2 + rows * NODE_H + (rows - 1).max(0) * ROW_GAP;
196
    let mut nodes = Vec::new();
197
    for (col, (_, mut js)) in by_depth.into_iter().enumerate() {
198
        js.sort_by(|a, b| a.name.cmp(&b.name));
199
        let n = js.len() as i64;
200
        let column_h = n * NODE_H + (n - 1).max(0) * ROW_GAP;
201
        let top = (height - column_h) / 2;
202
        for (i, j) in js.into_iter().enumerate() {
203
            let duration = crate::flow::duration(&j);
204
            nodes.push(Node { x: PAD + col as i64 * (NODE_W + COL_GAP), y: top + i as i64 * (NODE_H + ROW_GAP), duration, j });
205
        }
206
    }
207
    let mut edges = Vec::new();
208
    for n in &nodes {
209
        for d in n.j.depends_on() {
210
            if let Some(from) = nodes.iter().find(|m| m.j.name == d) {
211
                let (x1, y1) = (from.x + NODE_W, from.y + NODE_H / 2);
212
                let (x2, y2) = (n.x, n.y + NODE_H / 2);
213
                let mid = (x1 + x2) / 2;
214
                edges.push(Edge { d: format!("M{x1},{y1} C{mid},{y1} {mid},{y2} {x2},{y2}"), status: from.j.status.clone() });
215
            }
216
        }
217
    }
218
    let cols = nodes.iter().map(|n| n.x).max().map(|x| x + NODE_W + PAD).unwrap_or(PAD * 2);
219
    Graph { nodes, edges, width: cols, height: height.max(NODE_H + PAD * 2) }
220
}
221
222
pub fn glyph(status: &str) -> &'static str {
223
    match status {
224
        "passed" => "✓",
225
        "failed" => "✕",
226
        "running" => "●",
227
        "skipped" => "⤼",
228
        "error" => "!",
229
        _ => "○",
230
    }
231
}
232
233
pub struct JobLog {
234
    pub j: fdb::Job,
235
    pub lines: Vec<fdb::LogLine>,
236
    pub duration: String,
237
    /// The last line shown; the live view asks for what follows.
238
    pub last_seq: i64,
239
}
240
241
#[derive(Template)]
242
#[template(path = "flow_run.html")]
243
pub struct RunPage {
244
    shell: Shell,
245
    repo: RepoShell,
246
    run: fdb::Run,
247
    graph: Graph,
248
    logs: Vec<JobLog>,
249
    duration: String,
250
    /// Whether the viewer may re-run it.
251
    can_rerun: bool,
252
    /// Where a live run stands among the runners' work.
253
    queue: Option<crate::flow::QueueInfo>,
254
}
255
256
/// Lines of a job's log a page shows before "the rest is loaded as it comes".
257
const PAGE_LINES: i64 = 5000;
258
259
pub async fn run(
260
    State(app): State<Shared>,
261
    viewer: Viewer,
262
    Path((owner, name, id)): Path<(String, String, i64)>,
263
) -> Result<Page<RunPage>, Error> {
264
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/{id}"), &viewer);
265
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
266
    let run = fdb::run(&app.db, row.id, id)
267
        .await
268
        .map_err(|e| Error::from_db(shell.clone(), e))?
269
        .ok_or_else(|| Error::not_found(shell.clone()))?;
270
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
271
    let mut logs = Vec::new();
272
    for j in &jobs {
273
        let lines = fdb::log(&app.db, j.id, 0, PAGE_LINES).await.unwrap_or_default();
274
        let last_seq = lines.last().map(|l| l.seq).unwrap_or(0);
275
        logs.push(JobLog { duration: crate::flow::duration(j), j: j.clone(), lines, last_seq });
276
    }
277
    let duration = match run.finished_ms {
278
        Some(f) => crate::flow::human_ms(f - run.created_ms),
279
        None => crate::flow::human_ms(db::now_ms() - run.created_ms),
280
    };
281
    let graph = layout(jobs);
282
    let can_rerun = !run.live() && viewer.account().is_some() && role >= Role::Write && crate::flow::enabled(&app, &row);
283
    let queue = if run.live() {
284
        let q = fdb::queue(&app.db).await.unwrap_or_default();
285
        Some(crate::flow::queue_info(&q, row.id, run.id)).filter(|i| i.position.is_some())
286
    } else {
287
        None
288
    };
289
    Ok(Page(RunPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), run, graph, logs, duration, can_rerun, queue }))
290
}
291
292
#[derive(Deserialize)]
293
pub struct LiveQuery {
294
    /// `job:seq,job:seq`: the last log line each open job already shows.
295
    after: Option<String>,
296
}
297
298
/// `GET /{owner}/{name}/flow/{id}/live`: statuses and new log lines, for the
299
/// run page while it is live.
300
pub async fn live(
301
    State(app): State<Shared>,
302
    viewer: Viewer,
303
    Path((owner, name, id)): Path<(String, String, i64)>,
304
    Query(q): Query<LiveQuery>,
305
) -> Result<Response, Error> {
306
    let shell = Shell::new(&app, "", &viewer);
307
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
308
    let run = fdb::run(&app.db, row.id, id)
309
        .await
310
        .map_err(|e| Error::from_db(shell.clone(), e))?
311
        .ok_or_else(|| Error::not_found(shell.clone()))?;
312
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
313
    let after: std::collections::HashMap<i64, i64> = q
314
        .after
315
        .unwrap_or_default()
316
        .split(',')
317
        .filter_map(|p| p.split_once(':'))
318
        .filter_map(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))
319
        .collect();
320
    let mut out = Vec::new();
321
    for j in &jobs {
322
        let lines = match after.get(&j.id) {
323
            Some(seq) => fdb::log(&app.db, j.id, *seq, 1000).await.unwrap_or_default(),
324
            None => vec![],
325
        };
326
        out.push(serde_json::json!({
327
            "id": j.id,
328
            "name": j.name,
329
            "status": j.status,
330
            "glyph": glyph(&j.status),
331
            "duration": crate::flow::duration(j),
332
            "exit_code": j.exit_code,
333
            "lines": lines.iter().map(|l| serde_json::json!({ "seq": l.seq, "t": l.t, "stream": l.stream, "text": l.text })).collect::<Vec<_>>(),
334
        }));
335
    }
336
    Ok(Json(serde_json::json!({ "status": run.status, "live": run.live(), "jobs": out })).into_response())
337
}
338
339
// ------------------------------------------------------------ state stamps
340
341
/// In rendered Markdown, the image path that is the viewed state's stamp.
342
pub const STAMP_IMAGE: &str = ".flow/stamp";
343
/// In rendered Markdown, the link path to the viewed state's run.
344
pub const RUN_LINK: &str = ".flow/run";
345
346
/// Resolve a ref to its state address, and whether the ref can move.
347
async fn state_of(app: &Shared, owner: &str, name: &str, r: &str) -> Result<(String, bool), crate::repos::RepoError> {
348
    let repo = crate::repos::RepoName::new(owner, name).ok_or(crate::repos::RepoError::NotFound)?;
349
    let spec = r.to_string();
350
    app.repos
351
        .read(&repo, move |repo, cache| {
352
            let t = crate::repos::view::resolve(repo, cache, &spec)?;
353
            let moves = matches!(t.kind, crate::repos::view::RefKind::Bookmark | crate::repos::view::RefKind::Tag);
354
            Ok((t.permalink().unwrap_or_default(), moves))
355
        })
356
        .await
357
}
358
359
/// `GET /{owner}/{name}/flow/stamp/{ref}` (hub spec §13.2).
360
pub async fn stamp(
361
    State(app): State<Shared>,
362
    viewer: Viewer,
363
    Path((owner, name, r)): Path<(String, String, String)>,
364
) -> Result<Response, Error> {
365
    let shell = Shell::new(&app, "", &viewer);
366
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
367
    let (heads, moves) = state_of(&app, &owner, &name, &r).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
368
    if moves && !heads.is_empty() {
369
        let mut res = axum::response::Redirect::to(&format!("/{owner}/{name}/flow/stamp/{heads}")).into_response();
370
        res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache"));
371
        return Ok(res);
372
    }
373
    let run = fdb::latest_for_heads(&app.db, row.id, &heads).await.map_err(|e| Error::from_db(shell.clone(), e))?;
374
    let jobs = match &run {
375
        Some(r) => fdb::jobs(&app.db, r.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
376
        None => vec![],
377
    };
378
    let svg = stamp_svg(&heads, run.as_ref(), &jobs);
379
    let cache = if run.as_ref().is_some_and(|r| !r.live()) { "public, max-age=3600" } else { "public, max-age=60" };
380
    Ok((
381
        [
382
            (axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"),
383
            (axum::http::header::CACHE_CONTROL, cache),
384
            (axum::http::header::CONTENT_SECURITY_POLICY, "default-src 'none'; style-src 'unsafe-inline'"),
385
        ],
386
        svg,
387
    )
388
        .into_response())
389
}
390
391
/// `GET /{owner}/{name}/flow/at/{ref}`: the run of the ref's state, or the Flow tab.
392
pub async fn at(
393
    State(app): State<Shared>,
394
    viewer: Viewer,
395
    Path((owner, name, r)): Path<(String, String, String)>,
396
) -> Result<Response, Error> {
397
    let shell = Shell::new(&app, "", &viewer);
398
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
399
    let (heads, _) = state_of(&app, &owner, &name, &r).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
400
    let run = fdb::latest_for_heads(&app.db, row.id, &heads).await.map_err(|e| Error::from_db(shell.clone(), e))?;
401
    let to = match run {
402
        Some(r) => format!("/{owner}/{name}/flow/{}", r.number),
403
        None => format!("/{owner}/{name}/flow"),
404
    };
405
    Ok(axum::response::Redirect::to(&to).into_response())
406
}
407
408
fn xml(s: &str) -> String {
409
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
410
}
411
412
/// The stamp: the mark, one glyph per job, the status, the state and the
413
/// date; the same facts machine-readable in `<metadata>`. Fixed colours,
414
/// because an image does not follow the page's theme.
415
pub fn stamp_svg(heads: &str, run: Option<&fdb::Run>, jobs: &[fdb::Job]) -> String {
416
    const CH: f64 = 6.6; // IBM Plex Mono at 11px
417
    let colour = |s: &str| match s {
418
        "passed" => "#5fe3a1",
419
        "failed" | "error" => "#ff5e7a",
420
        "running" => "#00e0f0",
421
        _ => "#8592ad",
422
    };
423
    let short: String = heads.chars().take(12).collect();
424
    let (status, date) = match run {
425
        Some(r) => (r.status.as_str(), crate::render::time::absolute(r.created_ms as u64).chars().take(10).collect::<String>()),
426
        None => ("no run", String::new()),
427
    };
428
    let mut ordered: Vec<&fdb::Job> = jobs.iter().collect();
429
    ordered.sort_by(|a, b| (a.depth, &a.name).cmp(&(b.depth, &b.name)));
430
    let label = "OXYGENFLOW";
431
    let label_w = 14.0 + label.len() as f64 * 6.2 + 8.0;
432
    let glyphs_w = if ordered.is_empty() { 0.0 } else { ordered.len() as f64 * 10.0 + 6.0 };
433
    let tail = if date.is_empty() { format!("{status} · {short}") } else { format!("{status} · {short} · {date}") };
434
    let right_w = 8.0 + glyphs_w + tail.chars().count() as f64 * CH + 8.0;
435
    let w = (label_w + right_w).ceil();
436
    let mut glyphs = String::new();
437
    for (i, j) in ordered.iter().enumerate() {
438
        let x = label_w + 8.0 + i as f64 * 10.0 + 4.0;
439
        glyphs.push_str(&format!(
440
            r#"<text x="{x:.1}" y="15" fill="{}" text-anchor="middle">{}</text>"#,
441
            colour(&j.status),
442
            glyph(&j.status)
443
        ));
444
    }
445
    let meta = serde_json::json!({
446
        "heads": heads.split('+').filter(|h| !h.is_empty()).collect::<Vec<_>>(),
447
        "run": run.map(|r| r.number),
448
        "status": status,
449
        "jobs": ordered.iter().map(|j| serde_json::json!({ "name": j.name, "status": j.status, "receipt": j.receipt_hash })).collect::<Vec<_>>(),
450
    });
451
    let title = match run {
452
        Some(r) => format!("OxygenFlow run #{} {status} on state {short}", r.number),
453
        None => format!("OxygenFlow: no run for state {short}"),
454
    };
455
    format!(
456
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="22" viewBox="0 0 {w} 22" role="img" aria-label="{t}"><title>{t}</title><metadata>{m}</metadata><rect width="{w}" height="22" rx="4" fill="#0e1526"/><rect width="{lw:.1}" height="22" rx="4" fill="#0b3f9e"/><rect x="{lx:.1}" width="4" height="22" fill="#0b3f9e"/><circle cx="9" cy="11" r="3.2" fill="none" stroke="#00f0ff" stroke-width="1.6"/><text x="16" y="15" fill="#ffffff" font-family="Barlow, Verdana, sans-serif" font-size="10.5" font-weight="700" letter-spacing=".6">{label}</text><g font-family="IBM Plex Mono, ui-monospace, monospace" font-size="11">{glyphs}<text x="{tx:.1}" y="15" fill="{sc}">{tail}</text></g><rect x=".5" y=".5" width="{wi}" height="21" rx="3.5" fill="none" stroke="#1f2b47"/></svg>"##,
457
        t = xml(&title),
458
        m = xml(&meta.to_string()),
459
        lw = label_w,
460
        lx = label_w - 4.0,
461
        tx = label_w + 8.0 + glyphs_w,
462
        sc = colour(status),
463
        tail = xml(&tail),
464
        wi = w - 1.0,
465
    )
466
}