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 )
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#[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 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 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 | 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#[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#[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#[rocket::get("/all-jobs")]
309pub async fn r_all_jobs(req_info: UiReqInfoUnchecked) -> RenderedTemplate {
310 let req_info = req_info.check()?;
311
312 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#[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 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 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
402fn 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#[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(), )
453}
454
455#[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
469load_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 use rocket::http::Method::*;
496
497 let mut w = rocket::routes![wildcard_404];
498 for method in [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}