tag2upload_service_manager/
ui_routes.rs

1
2use crate::prelude::*;
3use crate::ui_render::*;
4
5fn status_is_queued() -> impl Bsql {
6    bsql!("    status IN " [
7                              JobStatus::Noticed,
8                              JobStatus::Queued,
9                              JobStatus::Building,
10                            ] "")
11}
12
13fn show_recent_threshold(gl: &Arc<Globals>) -> TimeT {
14    let now = gl.now_systemtime();
15
16    now
17        .checked_sub(*gl.config.intervals.show_recent)
18        .unwrap_or(now /* whatever */)
19        .into()
20}
21
22struct FromInterestingJobs<Q: Bsql, A: Bsql> {
23    queued_unrecent: Q,
24    anystatus_recent: A,
25}
26
27impl<'t> FromInterestingJobs<bsql::EmptyFragment, bsql::EmptyFragment> {
28    fn new<'s>(recent: &'s TimeT) -> FromInterestingJobs<
29        impl Bsql + 's,
30        impl Bsql + 's,
31    > {
32        FromInterestingJobs {
33            queued_unrecent: bsql!("
34                      FROM jobs
35                     WHERE " {status_is_queued()} "
36                       AND last_update < " recent "
37                "),
38            anystatus_recent: bsql!("
39                      FROM jobs
40                     WHERE last_update >= " recent "
41                "),
42        }
43    }
44}
45
46//---------- / ----------
47
48#[rocket::get("/")]
49pub async fn r_toplevel(req_info: UiReqInfoUnchecked) -> RenderedTemplate {
50    let req_info = req_info.check()?;
51
52    let gl = globals();
53
54    #[derive(Deftly, Ord, PartialOrd, Eq, PartialEq)]
55    #[derive_deftly(FromSqlRow)]
56    struct JobStatusCount {
57        status: JobStatus,
58        duplicate_of: Option<JobId>,
59        count: i64,
60    }
61
62    #[derive(Deftly, Ord, PartialOrd, Eq, PartialEq)]
63    #[derive_deftly(UiMap)]
64    struct ShownJobStatusCounts {
65        status: ShownJobStatus,
66        #[deftly(ui(flatten))]
67        values: ShownJobStatusCountsValues,
68    }
69    #[derive(Deftly, Ord, PartialOrd, Eq, PartialEq, Default)]
70    #[derive_deftly(UiMap)]
71    struct ShownJobStatusCountsValues {
72        recent: i64,
73        unarchived: i64,
74        total: i64,
75    }
76
77    // Ideally we would list counts of *all* jobs, but sqlite can't maintain
78    // an "index" giving count(*), and we don't want to do a full table scan.
79    let recent = show_recent_threshold(&gl);
80    let from_jobs = FromInterestingJobs::new(&recent);
81
82    let (status_count, pause, last_archive) =
83        db_transaction(TN::Readonly, |dbt| {
84
85            let mut counts = ShownJobStatus::iter()
86                .map(|s| (s, ShownJobStatusCountsValues::default()))
87                .collect::<HashMap<_, _>>();
88
89            let mut add = |JobStatusCount { status, duplicate_of, count }| {
90                let shown = ShownJobStatus::new(status, duplicate_of);
91                counts.entry(shown).or_default().recent += count;
92                Ok::<_, IE>(())
93            };
94
95            // Even this way it's a bit tiresome.
96            // sqlite won't optimise this the way we want,
97            // unless we do it as two queries, - one of which scans
98            // the whole queue.  Fine.
99
100            dbt.bsql_query_n_call(
101                &bsql!("
102                    SELECT status, duplicate_of, count(1) AS count
103                         " {&from_jobs.queued_unrecent} "
104                  GROUP BY status, duplicate_of
105                "),
106                &mut add,
107            )?;
108
109            dbt.bsql_query_n_call(
110                &bsql!("
111                    SELECT status, duplicate_of, 1 as count
112                         " {&from_jobs.anystatus_recent} "
113                "),
114                &mut add,
115            )?;
116
117            let pause = query_pause_insn_either(dbt)?;
118
119            let mut update_from_stats = |table: &dyn Bsql, upd: fn(&mut _, _)| {
120                dbt.bsql_query_n_call(
121                    &bsql!("SELECT * FROM " {table} ""),
122                    &mut |StatsByShownStatusRow { shown_status, n_jobs }| {
123                        upd(
124                            counts.entry(shown_status).or_default(),
125                            n_jobs
126                        );
127                        Ok::<_, IE>(())
128                    }
129                )
130            };
131
132            update_from_stats(
133                &bsql!("stats_by_shown_status"),
134                |ent: &mut _, n_jobs| {
135                    ent.unarchived += n_jobs;
136                    ent.total += n_jobs;
137                },
138            )?;
139            update_from_stats(
140                &bsql!("stats_by_shown_status_expired"),
141                |ent: &mut _, n_jobs| {
142                    ent.total += n_jobs;
143                },
144            )?;
145
146            let mut counts = counts.into_iter()
147                .map(|(status, values)| {
148                    ShownJobStatusCounts { status, values }
149                })
150                .collect_vec();
151
152            counts.sort();
153
154            let last_archive = LastExpiryRow::for_ui(dbt)?;
155
156            Ok((counts, pause, last_archive))
157        })?;
158
159    let status_count = UiSerializeRows(status_count);
160
161    #[derive(Debug, Copy, Clone, Serialize)]
162    #[serde(rename_all = "snake_case")]
163    enum ManagerStatus {
164        Running,
165        Paused,
166        Throttled,
167        #[serde(rename = "shutting down")]
168        ShuttingDown,
169        Crashing,
170    }
171    use ManagerStatus as MS;
172
173    let manager_status = match (&gl.state.borrow().shutdown_reason, &pause) {
174        (None, None) => MS::Running,
175        (None, Some((_insn, None))) => MS::Paused,
176        (None, Some((_insn, Some(IsThrottled)))) => MS::Throttled,
177        (Some(Ok(ShuttingDown {})),_) => MS::ShuttingDown,
178        (Some(Err(_)),_) => MS::Crashing,
179    };
180
181    let workers = {
182        let w = gl.worker_tracker.list_workers();
183        UiSerializeRows(w)
184    };
185
186    let mut n_workers_idle = 0;
187    let mut n_workers_busy = 0;
188    for w in &workers.0 {
189        use WorkerPhase as WP;
190        match w.phase {
191            WP::Building | WP::Selected => n_workers_busy += 1,
192            WP::Idle => n_workers_idle += 1,
193            WP::Init | // doesn't count as connected
194            WP::Disconnected => {}
195        }
196    }
197
198    #[derive(Debug, Copy, Clone, Serialize)]
199    #[serde(rename_all = "snake_case")]
200    enum OverallStatus {
201        Down,
202        Maintenance,
203        #[serde(rename = "(re)starting")]
204        Starting,
205        Busy,
206        Up,
207    }
208    use OverallStatus as OS;
209
210    let no_workers = || n_workers_idle + n_workers_busy == 0;
211    let just_started = || {
212        let now = gl.now_systemtime();
213        now.duration_since(
214            *gl.last_worker_restart.read().expect("poisoned")
215        ).unwrap_or_default()
216            < *gl.config.timeouts.disconnected_worker_expire
217    };
218    let manager_overall_status = || match manager_status {
219        MS::Running => None,
220        MS::Crashing => Some(OS::Down),
221        MS::Paused | MS::Throttled | MS::ShuttingDown => Some(OS::Maintenance),
222    };
223
224    let overall_status =
225        if no_workers() && !just_started() { OS::Down }
226        else if let Some(from_ms) = manager_overall_status() { from_ms }
227        else if no_workers() { OS::Starting }
228        else if n_workers_idle == 0 { OS::Busy }
229        else { OS::Up };
230
231    let recent = HtTimeT(recent);
232
233    template_page! {
234        "toplevel.html", req_info;
235        {
236            overall_status,
237            manager_status,
238            pause_info: pause.map(|(insn, _thro)| insn.pause_info),
239            status_count,
240            recent,
241            last_archive,
242            workers,
243            n_workers_busy,
244            n_workers_idle,
245            n_workers_up: n_workers_busy + n_workers_idle,
246        }
247    }
248}
249
250//---------- /queue ----------
251
252#[rocket::get("/queue")]
253pub async fn r_queue(req_info: UiReqInfoUnchecked) -> RenderedTemplate {
254    let req_info = req_info.check()?;
255
256    let (data, ()) = db_jobs_for_ui(
257        req_info.vhost,
258        &status_is_queued(),
259        &bsql!(" jid ASC "),
260        |_dbt| Ok(()),
261    )?;
262
263    template_page! {
264        "queue.html", req_info;
265        {
266            jobs: data,
267        }
268    }
269}
270
271//---------- /recent ----------
272
273#[rocket::get("/recent")]
274pub async fn r_recent(req_info: UiReqInfoUnchecked) -> RenderedTemplate {
275    let req_info = req_info.check()?;
276    let gl = globals();
277    let recent = show_recent_threshold(&gl);
278    let from_jobs = FromInterestingJobs::new(&recent);
279
280    let (jobs, last_archive) = db_rows_for_ui::<JobForUi, _>(
281            req_info.vhost,
282            &bsql!("
283                          SELECT " {job_for_ui_cols(&bsql!("jobs"))} "
284                         " {from_jobs.queued_unrecent} "
285                          UNION
286                          SELECT " {job_for_ui_cols(&bsql!("jobs"))} "
287                         " {from_jobs.anystatus_recent} "
288                        ORDER BY last_update DESC
289            "),
290            |dbt| LastExpiryRow::for_ui(dbt),
291        )?;
292
293    let recent = HtTimeT(recent);
294    let jobs = UiSerializeRows(jobs);
295
296    template_page! {
297        "recent.html", req_info;
298        {
299            jobs,
300            recent,
301            last_archive,
302        }
303    }
304}
305
306//---------- /all-jobs ----------
307
308#[rocket::get("/all-jobs")]
309pub async fn r_all_jobs(req_info: UiReqInfoUnchecked) -> RenderedTemplate {
310    let req_info = req_info.check()?;
311
312    // TODO this query loads the whole db into memory
313    // This is OK if there aren't too many.  If this becomes a problem
314    // we should;
315    //   - use watersheds to coalesce multiple queries for this route
316    //   - do the db query and stream rendering rows to a temporary file
317    //   - stream the temporary file out with pread
318    let (data, last_archive) = db_jobs_for_ui(
319        req_info.vhost,
320        &bsql!(" TRUE "),
321        &bsql!(" last_update DESC "),
322        |dbt| LastExpiryRow::for_ui(dbt),
323    )?;
324
325    template_page! {
326        "all-jobs.html", req_info;
327        {
328            jobs: data,
329            last_archive,
330        }
331    }
332}
333
334//---------- /job/JID ----------
335
336#[rocket::get("/job/<jid_u64>")]
337pub async fn r_job(jid_u64: u64, req_info: UiReqInfoUnchecked)
338                   -> RenderedTemplate
339{
340    let req_info = req_info.check()?;
341
342    let data = db_query_for_ui(req_info.vhost, |dbt| {
343        let j = dbt.bsql_query_01::<JobForUi>(&bsql!("
344                SELECT " {job_for_ui_cols(&bsql!("jobs"))}
345                " FROM jobs WHERE jid = " jid_u64 "
346            "))?;
347        let Some(j) = j else {
348            return Ok(Err(WebError::PageNotFoundHere(
349                anyhow!("job {jid_u64} does not exist or has been archived")
350            )));
351        };
352        // The `job_history` table has au superset of the columns in JobState
353        //
354        // We don't currently linkify duplicate jobs in the history display,
355        // only in the main tables of jobs.  So we don't need job_for_ui_cols.
356        let mut history: Vec<JobState> = dbt.bsql_query_n_vec(&bsql!("
357                SELECT *
358                         FROM job_history
359                        WHERE jid = " jid_u64 "
360                     ORDER BY histent ASC
361           "))?;
362        let tag_object = match j.s.status {
363            JobStatus::Noticed |
364            JobStatus::NotForUs => String::new(),
365            JobStatus::Queued |
366            JobStatus::Building |
367            JobStatus::Failed |
368            JobStatus::Irrecoverable |
369            JobStatus::Uploaded => j.tag_data
370                .as_deref().cloned().unwrap_or_default(),
371        };
372        history.push(j.s.clone());
373
374        // Replace identical info lines with ditto marks.
375        //   https://en.wikipedia.org/wiki/Ditto_mark
376        // This is helpful because they can be very long, so when
377        // they wrap you just see a bunch of text that's hard to compare.
378        let mut last_info = None;
379        for histent in &mut history {
380            let this_info = &mut histent.info;
381            if Some(&this_info) == last_info.as_ref() {
382                *this_info = r#" "  " "#.into();
383            } else {
384                last_info = Some(this_info);
385            }
386        }
387
388        Ok(Ok((UiSerializeMap(j), UiSerializeRows(history), tag_object)))
389    })?;
390
391    let (j, history, tag_object) = data?;
392    template_page! {
393        "job.html", req_info;
394        {
395            j,
396            history,
397            tag_object,
398        }
399    }
400}
401
402//---------- error catcher ----------
403
404fn handle_error(
405    status: rocket::http::Status,
406    client_ip: Option<IpAddr>,
407    request_path: Option<PathBuf>,
408) -> (ContentType, String) {
409    let request_path = request_path.map(
410        |maybe_relative| PathBuf::from("/").join(maybe_relative)
411    );
412
413    debug!(
414        "from {}: error {}: path={}",
415        client_ip
416            .map(|y| y.to_string())
417            .unwrap_or_else(|| format!("<unknown>")),
418        status.code,
419        request_path
420            .map(|y| format!("{y:?}"))
421            .unwrap_or_else(|| format!("<invalid>")),
422    );
423    (|| {
424        let s = template_html_unchecked! {
425            "error.html",
426            tera_context! {
427                t2usm_version: &globals().version_info,
428                status_headline: status.to_string(),
429            }
430        };
431        Ok::<_, WebError>((ContentType::HTML, s?))
432    })().unwrap_or_else(|e| {
433        (ContentType::Text, format!("error rendering error: {e}"))
434    })
435}
436
437// If we don't install an error catcher, Rocket logs like this
438//    2025-06-30T17:05:42.542766Z  WARN rocket::server::_: No ESC[1;34m404ESC[0m catcher registered. Using Rocket default.    
439//
440// We don't much like the default error document, either.
441//
442// And the default handler logs with colour escapes.
443#[rocket::catch(default)]
444fn default_error_catcher(
445    status: rocket::http::Status,
446    req: &rocket::Request,
447) -> (ContentType, String) {
448    handle_error(
449        status,
450        req.client_ip(),
451        req.segments::<PathBuf>(0..).ok(), // discard errors
452    )
453}
454
455// This exists because if Rocket doesn't find a route, it logs twice,
456// at level ERROR and WARN.  But 404s are normal, as attacks wash up.
457//
458//     https://github.com/rwf2/Rocket/issues/2951
459#[rocket::get("/<any..>")]
460fn wildcard_404(
461    client_ip: Option<IpAddr>,
462    any: Option<PathBuf>,
463) -> (rocket::http::Status, (ContentType, String))
464{
465    let status = rocket::http::Status::NotFound;
466    (status, handle_error(status, client_ip, any))
467}
468
469//---------- inventories ----------
470
471load_template_parts! {
472    "duplicate-of.part.html",
473    "jobtable.part.html",
474    "footer.part.html",
475    "navbar.part.html",
476    "recent-note.part.html",
477    "retry-earliest.part.html",
478    "archived-note.part.html",
479}
480
481pub(crate) const NAVBAR: &[NavbarEntry] = &[
482    ("service",     "toplevel.html",     "/"),
483    ("queue",       "queue.html",        "/queue"),
484    ("recent",      "recent.html",       "/recent"),
485    ("all jobs",    "all-jobs.html",     "/all-jobs"),
486];
487
488pub fn mount_ui_catchers(rocket: RocketBuild) -> RocketBuild {
489    let wildcards = {
490        // Set up a separate 404 handler for each HTTP method.
491        //
492        // Rocket doesn't have method wildcard routes,
493        // https://github.com/rwf2/Rocket/issues/2731
494
495        use rocket::http::Method::*;
496
497        let mut w = rocket::routes![wildcard_404];
498        for method in [/* Get, already included */
499                       Put, Post, Delete, Options,
500                       Head, Trace, Connect, Patch] {
501            w.push({
502                let mut route = w[0].clone();
503                route.method = method;
504                route
505            })
506        }
507        w
508    };
509
510    rocket.mount("/", rocket::routes![
511        r_toplevel,
512        r_queue,
513        r_recent,
514        r_all_jobs,
515        r_job,
516    ])
517        .mount("/", wildcards)
518        .register("/", rocket::catchers![default_error_catcher])
519}