1use diesel::PgConnection;
3use rocket::fs::NamedFile;
4use rocket::http::Status;
5use rocket::response::status::{Accepted, NotFound};
6use rocket::serde::json::Json;
7use rocket::tokio::sync::{OwnedSemaphorePermit, Semaphore};
8use rocket::{Route, State, get, post, routes};
9use rocket_dyn_templates::Template;
10use std::collections::HashMap;
11use std::str;
12use std::sync::Arc;
13
14use crate::backend::{
15 DbPool, PooledConn, RerunOptions, live_run_diff, mark_all_blocked, mark_blocked, mark_rerun,
16 progress_report, resume_all_blocked, resume_blocked, save_historical_tasks,
17};
18use crate::frontend::actor::AdminSession;
19use crate::frontend::helpers::*;
20use crate::frontend::params::{ReportParams, RerunRequestParams, TemplateContext};
21use crate::frontend::render::task_report;
22use crate::models::{Corpus, HistoricalRun, Service, Task};
23
24pub const UNKNOWN: &str = "_unknown_";
26
27const SLOW_REPORT_WARN_MS: i64 = 2000;
32
33const INLINE_POPULATE_BUDGET_MS: u32 = 4000;
38
39pub const MAX_CONCURRENT_LIVE_REPORTS: usize = 4;
44
45pub struct LiveReportLimiter(Arc<Semaphore>);
53
54impl LiveReportLimiter {
55 pub fn new(max_concurrent: usize) -> Self {
57 LiveReportLimiter(Arc::new(Semaphore::new(max_concurrent.max(1))))
58 }
59
60 pub async fn acquire(&self) -> Result<OwnedSemaphorePermit, Status> {
63 self
64 .0
65 .clone()
66 .acquire_owned()
67 .await
68 .map_err(|_| Status::ServiceUnavailable)
69 }
70}
71
72impl Default for LiveReportLimiter {
73 fn default() -> Self { Self::new(MAX_CONCURRENT_LIVE_REPORTS) }
74}
75
76fn defer_cold_slice(
87 connection: &mut PgConnection,
88 pool: &crate::backend::DbPool,
89 used_rollup: bool,
90 severity: Option<&str>,
91 corpus: &Corpus,
92 service: &Service,
93) -> bool {
94 if !used_rollup {
95 return false;
96 }
97 let Some(sev) = severity else {
98 return false;
99 };
100 if crate::backend::scope_cached(connection, corpus.id, service.id, sev) {
101 return false; }
103 if crate::jobs::report_populate_active(connection, corpus.id, service.id, sev) {
104 return true; }
106 if crate::backend::populate_scope_bounded(
108 connection,
109 corpus.id,
110 service.id,
111 sev,
112 INLINE_POPULATE_BUDGET_MS,
113 )
114 .is_ok()
115 {
116 return false;
117 }
118 let _ =
120 crate::jobs::spawn_report_populate(pool.clone(), corpus.id, service.id, sev, "report-view");
121 true
122}
123
124#[allow(clippy::too_many_arguments)]
128pub fn serve_report(
129 connection: &mut PgConnection,
130 pool: &crate::backend::DbPool,
131 corpus_name: String,
132 service_name: String,
133 severity: Option<String>,
134 category: Option<String>,
135 what: Option<String>,
136 params: Option<ReportParams>,
137 is_admin: bool,
138) -> Result<Template, Status> {
139 let report_start = chrono::Utc::now();
140 let mut context = TemplateContext {
143 is_admin,
144 ..TemplateContext::default()
145 };
146 let mut global = HashMap::new();
147
148 let corpus_name = corpus_name.to_lowercase();
149 let service_name = service_name.to_lowercase();
150 let corpus_result = Corpus::find_by_name(&corpus_name, connection);
151 if let Ok(corpus) = corpus_result {
152 let service_result = Service::find_by_name(&service_name, connection);
153 if let Ok(service) = service_result {
154 global.insert(
156 "title".to_string(),
157 "Corpus Report for ".to_string() + &corpus.name,
158 );
159 global.insert(
160 "description".to_string(),
161 "An analysis framework for corpora of TeX/LaTeX documents - statistical reports for "
162 .to_string()
163 + &corpus.name,
164 );
165 global.insert("corpus_name".to_string(), corpus.name.clone());
168 global.insert("corpus_description".to_string(), corpus.description.clone());
169 global.insert("service_name".to_string(), service.name.clone());
170 global.insert(
171 "service_description".to_string(),
172 service.description.clone(),
173 );
174 global.insert("type".to_string(), "Conversion".to_string());
175 global.insert("inputformat".to_string(), service.inputformat.clone());
176 global.insert("outputformat".to_string(), service.outputformat.clone());
177
178 if let Ok(Some(historical_run)) = HistoricalRun::find_current(&corpus, &service, connection) {
179 global.insert(
180 "run_start_time".to_string(),
181 crate::frontend::helpers::iso_utc(historical_run.start_time),
182 );
183 global.insert("run_owner".to_string(), historical_run.owner);
184 global.insert("run_description".to_string(), historical_run.description);
185 }
186 let all_messages = match params {
187 None => false,
188 Some(ref params) => *params.all.as_ref().unwrap_or(&false),
189 };
190 global.insert("all_messages".to_string(), all_messages.to_string());
191 if all_messages {
192 global.insert("all_messages_true".to_string(), all_messages.to_string());
195 }
196 let used_rollup = crate::backend::report_uses_rollup(
201 severity.as_deref(),
202 category.as_deref(),
203 what.as_deref(),
204 all_messages,
205 );
206 let report_severity = severity.clone();
209 let report_computing = defer_cold_slice(
213 connection,
214 pool,
215 used_rollup,
216 report_severity.as_deref(),
217 &corpus,
218 &service,
219 );
220 match service.inputconverter {
221 Some(ref ic_service_name) => {
222 global.insert("inputconverter".to_string(), ic_service_name.clone())
223 },
224 None => global.insert("inputconverter".to_string(), "import".to_string()),
230 };
231
232 let report;
233 let template;
234 if severity.is_none() {
235 report = progress_report(connection, corpus.id, service.id);
237 let count = |key: &str| *report.get(key).unwrap_or(&0.0);
244 let (np, warn, err, fat) = (
245 count("no_problem"),
246 count("warning"),
247 count("error"),
248 count("fatal"),
249 );
250 let completed = np + warn + err + fat;
251 let pct = |n: f64| {
252 if completed > 0.0 {
253 format!("{:.2}", 100.0 * n / completed)
254 } else {
255 "0.00".to_string()
256 }
257 };
258 global.insert("rerun_completed".to_string(), completed.to_string());
259 global.insert("rerun_no_problem_percent".to_string(), pct(np));
260 global.insert("rerun_warning_percent".to_string(), pct(warn));
261 global.insert("rerun_error_percent".to_string(), pct(err));
262 global.insert("rerun_fatal_percent".to_string(), pct(fat));
263 let total = count("total");
266 global.insert(
267 "processed_percent".to_string(),
268 if total > 0.0 {
269 format!("{:.2}", 100.0 * completed / total)
270 } else {
271 "0.00".to_string()
272 },
273 );
274 let ld = live_run_diff(connection, corpus.id, service.id);
279 if ld.compared() > 0 {
280 global.insert("livediff_improved".to_string(), ld.improved.to_string());
281 global.insert("livediff_regressed".to_string(), ld.regressed.to_string());
282 global.insert("livediff_unchanged".to_string(), ld.unchanged.to_string());
283 global.insert(
284 "livediff_reclassified".to_string(),
285 ld.reclassified.to_string(),
286 );
287 }
288 for (key, val) in report {
290 global.insert(key.clone(), val.to_string());
291 }
292 global.insert(
293 "report_time".to_string(),
294 crate::frontend::helpers::report_timestamp(),
295 );
296 template = "report";
297 } else if category.is_none() {
298 global.insert("severity".to_string(), severity.clone().unwrap_or_default());
300 global.insert(
301 "highlight".to_string(),
302 severity_highlight(&severity.clone().unwrap_or_default()).to_string(),
303 );
304 let no_problem_kind = match severity {
305 Some(ref s) => s == "no_problem",
306 None => false,
307 };
308 if report_computing {
309 global.insert("report_computing".to_string(), "true".to_string());
314 context.categories = Some(Vec::new());
315 template = "severity-report";
316 } else {
317 let fetched_report = task_report(
318 connection,
319 &mut global,
320 &corpus,
321 &service,
322 severity,
323 None,
324 None,
325 ¶ms,
326 );
327 template = if no_problem_kind {
328 context.entries = Some(fetched_report);
330 "task-list-report"
332 } else {
333 context.categories = Some(fetched_report);
335 "severity-report"
337 };
338 }
339 } else if what.is_none() {
340 global.insert("severity".to_string(), severity.clone().unwrap_or_default());
342 global.insert(
343 "highlight".to_string(),
344 severity_highlight(&severity.clone().unwrap_or_default()).to_string(),
345 );
346 global.insert("category".to_string(), category.clone().unwrap_or_default());
347 let no_messages_kind = category.as_deref() == Some("no_messages");
348 if report_computing {
349 global.insert("report_computing".to_string(), "true".to_string());
353 context.whats = Some(Vec::new());
354 template = "category-report";
355 } else {
356 let fetched_report = task_report(
357 connection,
358 &mut global,
359 &corpus,
360 &service,
361 severity,
362 category,
363 None,
364 ¶ms,
365 );
366 template = if no_messages_kind {
367 context.entries = Some(fetched_report);
369 "task-list-report"
371 } else {
372 context.whats = Some(fetched_report);
374 "category-report"
376 };
377 }
378 } else {
379 global.insert("severity".to_string(), severity.clone().unwrap_or_default());
381 global.insert(
382 "highlight".to_string(),
383 severity_highlight(&severity.clone().unwrap_or_default()).to_string(),
384 );
385 global.insert("category".to_string(), category.clone().unwrap_or_default());
386 global.insert("what".to_string(), what.clone().unwrap_or_default());
387 let entries = task_report(
388 connection,
389 &mut global,
390 &corpus,
391 &service,
392 severity,
393 category,
394 what,
395 ¶ms,
396 );
397 context.entries = Some(entries);
399 template = "task-list-report";
401 }
402 context.global = global;
404 decorate_uri_encodings(&mut context);
407
408 let report_end = chrono::Utc::now();
410 let report_duration = (report_end - report_start).num_milliseconds();
411 context
412 .global
413 .insert("report_duration".to_string(), report_duration.to_string());
414 if !used_rollup {
423 let corpus_l = context
424 .global
425 .get("corpus_name")
426 .map(String::as_str)
427 .unwrap_or("?");
428 let service_l = context
429 .global
430 .get("service_name")
431 .map(String::as_str)
432 .unwrap_or("?");
433 let severity_l = context
434 .global
435 .get("severity")
436 .map(String::as_str)
437 .unwrap_or("-");
438 if report_duration >= SLOW_REPORT_WARN_MS {
439 tracing::warn!(
440 corpus = corpus_l,
441 service = service_l,
442 severity = severity_l,
443 all_messages,
444 duration_ms = report_duration,
445 "slow live report held a pooled connection for the whole query (P-2 pool-saturation risk)"
446 );
447 } else {
448 tracing::debug!(
449 corpus = corpus_l,
450 service = service_l,
451 severity = severity_l,
452 all_messages,
453 duration_ms = report_duration,
454 "live-computed (non-rollup) report"
455 );
456 }
457 }
458 if used_rollup {
465 if let Some((epoch_ms, human)) = report_severity.as_deref().and_then(|sev| {
467 crate::backend::report_cache_computed_at(connection, corpus.id, service.id, sev)
468 }) {
469 context.global.insert("report_time".to_string(), human);
470 context
471 .global
472 .insert("report_time_epoch".to_string(), epoch_ms.to_string());
473 }
474 } else {
475 context.global.insert(
477 "report_time".to_string(),
478 crate::frontend::helpers::report_timestamp(),
479 );
480 context.global.insert(
481 "report_time_epoch".to_string(),
482 chrono::Utc::now().timestamp_millis().to_string(),
483 );
484 }
485 Ok(Template::render(template, context))
486 } else {
487 Err(Status::NotFound)
488 }
489 } else {
490 Err(Status::NotFound)
491 }
492}
493
494#[allow(clippy::too_many_arguments)]
499pub fn serve_rerun(
500 connection: &mut PgConnection,
501 _pool: &DbPool,
502 corpus_name: String,
503 service_name: String,
504 severity: Option<String>,
505 category: Option<String>,
506 what: Option<String>,
507 owner: &str,
508 description: &str,
509) -> Result<Accepted<String>, Status> {
510 let corpus_name = corpus_name.to_lowercase();
511 let service_name = service_name.to_lowercase();
512 if let Some(ref severity) = severity
516 && !crate::frontend::reports::is_valid_rerun_severity(severity, category.is_some())
517 {
518 return Err(Status::BadRequest);
519 }
520 tracing::info!(
523 actor = owner,
524 corpus = corpus_name,
525 service = service_name,
526 severity = ?severity,
527 category = ?category,
528 what = ?what,
529 "rerun requested"
530 );
531 let corpus = Corpus::find_by_name(&corpus_name, connection).map_err(|_| Status::NotFound)?;
532 let service = Service::find_by_name(&service_name, connection).map_err(|_| Status::NotFound)?;
533 let report_start = chrono::Utc::now();
534 let rerun_result = mark_rerun(
535 connection,
536 RerunOptions {
537 corpus: &corpus,
538 service: &service,
539 severity_opt: severity,
540 category_opt: category,
541 what_opt: what,
542 description_opt: Some(description.to_string()),
543 owner_opt: Some(owner.to_string()),
544 },
545 );
546 let report_duration = (chrono::Utc::now() - report_start).num_milliseconds();
547 match rerun_result {
548 Err(error) => {
549 tracing::warn!(actor = owner, duration_ms = report_duration, %error, "rerun failed");
550 Err(Status::InternalServerError)
551 },
552 Ok(_) => {
553 tracing::info!(
554 actor = owner,
555 duration_ms = report_duration,
556 "rerun committed"
557 );
558 Ok(Accepted(String::default()))
562 },
563 }
564}
565
566pub fn serve_pause_resume(
574 connection: &mut PgConnection,
575 corpus_name: &str,
576 service_name: &str,
577 owner: &str,
578 pause: bool,
579) -> Result<usize, Status> {
580 let corpus_name = corpus_name.to_lowercase();
581 let service_name = service_name.to_lowercase();
582 let action = if pause { "pause" } else { "resume" };
583 tracing::info!(
585 actor = owner,
586 corpus = corpus_name,
587 service = service_name,
588 action,
589 "run control requested"
590 );
591 let corpus = Corpus::find_by_name(&corpus_name, connection).map_err(|_| Status::NotFound)?;
592 let service = Service::find_by_name(&service_name, connection).map_err(|_| Status::NotFound)?;
593 let result = if pause {
594 mark_blocked(connection, corpus.id, service.id)
595 } else {
596 resume_blocked(connection, corpus.id, service.id)
597 };
598 match result {
599 Err(error) => {
600 tracing::warn!(actor = owner, action, %error, "run control failed");
601 Err(Status::InternalServerError)
602 },
603 Ok(count) => {
604 tracing::info!(
607 actor = owner,
608 action,
609 affected = count,
610 "run control committed"
611 );
612 Ok(count)
613 },
614 }
615}
616
617#[post("/pause/<corpus_name>/<service_name>")]
621pub fn pause_run(
622 corpus_name: String,
623 service_name: String,
624 session: Option<AdminSession>,
625 pool: &State<DbPool>,
626) -> Result<rocket::response::Redirect, Status> {
627 let session = session.ok_or(Status::Unauthorized)?;
628 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
629 serve_pause_resume(
630 &mut connection,
631 &corpus_name,
632 &service_name,
633 &session.owner,
634 true,
635 )?;
636 Ok(rocket::response::Redirect::to(format!(
637 "/corpus/{corpus_name}/{service_name}"
638 )))
639}
640
641#[post("/resume/<corpus_name>/<service_name>")]
645pub fn resume_run(
646 corpus_name: String,
647 service_name: String,
648 session: Option<AdminSession>,
649 pool: &State<DbPool>,
650) -> Result<rocket::response::Redirect, Status> {
651 let session = session.ok_or(Status::Unauthorized)?;
652 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
653 serve_pause_resume(
654 &mut connection,
655 &corpus_name,
656 &service_name,
657 &session.owner,
658 false,
659 )?;
660 Ok(rocket::response::Redirect::to(format!(
661 "/corpus/{corpus_name}/{service_name}"
662 )))
663}
664
665pub fn serve_pause_resume_all(
670 connection: &mut PgConnection,
671 owner: &str,
672 pause: bool,
673) -> Result<usize, Status> {
674 let action = if pause { "pause-all" } else { "resume-all" };
675 tracing::info!(actor = owner, action, "global run control requested");
676 let result = if pause {
677 mark_all_blocked(connection)
678 } else {
679 resume_all_blocked(connection)
680 };
681 match result {
682 Err(error) => {
683 tracing::warn!(actor = owner, action, %error, "global run control failed");
684 Err(Status::InternalServerError)
685 },
686 Ok(count) => {
687 tracing::info!(
688 actor = owner,
689 action,
690 affected = count,
691 "global run control committed"
692 );
693 Ok(count)
694 },
695 }
696}
697
698#[post("/pause-all")]
702pub fn pause_all(
703 session: Option<AdminSession>,
704 pool: &State<DbPool>,
705) -> Result<rocket::response::Redirect, Status> {
706 let session = session.ok_or(Status::Unauthorized)?;
707 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
708 serve_pause_resume_all(&mut connection, &session.owner, true)?;
709 Ok(rocket::response::Redirect::to("/admin"))
710}
711
712#[post("/resume-all")]
715pub fn resume_all(
716 session: Option<AdminSession>,
717 pool: &State<DbPool>,
718) -> Result<rocket::response::Redirect, Status> {
719 let session = session.ok_or(Status::Unauthorized)?;
720 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
721 serve_pause_resume_all(&mut connection, &session.owner, false)?;
722 Ok(rocket::response::Redirect::to("/admin"))
723}
724
725pub fn serve_savetasks(
729 connection: &mut PgConnection,
730 corpus_name: String,
731 service_name: String,
732) -> Result<Accepted<String>, Status> {
733 let corpus_name = corpus_name.to_lowercase();
734 let service_name = service_name.to_lowercase();
735 let corpus = Corpus::find_by_name(&corpus_name, connection).map_err(|_| Status::NotFound)?;
736 let service = Service::find_by_name(&service_name, connection).map_err(|_| Status::NotFound)?;
737 let progress = progress_report(connection, corpus.id, service.id);
742 let in_progress =
743 progress.get("todo").copied().unwrap_or(0.0) + progress.get("queued").copied().unwrap_or(0.0);
744 if in_progress > 0.0 {
745 return Err(Status::Conflict);
746 }
747 match save_historical_tasks(connection, &corpus, &service) {
748 Err(_) => Err(Status::InternalServerError),
749 Ok(count) => Ok(Accepted(format!("Saved {count} tasks"))),
750 }
751}
752
753pub struct EntryDownload {
757 file: NamedFile,
758 filename: String,
760}
761
762impl<'r> rocket::response::Responder<'r, 'static> for EntryDownload {
763 fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'static> {
764 let mut response = self.file.respond_to(request)?;
765 response.set_raw_header(
766 "Content-Disposition",
767 format!("attachment; filename=\"{}\"", self.filename),
768 );
769 Ok(response)
770 }
771}
772
773fn sanitize_download_filename(name: &str) -> String {
777 let safe: String = name
778 .chars()
779 .map(|c| {
780 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
781 c
782 } else {
783 '_'
784 }
785 })
786 .collect();
787 if safe.is_empty() {
788 "download".to_string()
789 } else {
790 safe
791 }
792}
793
794fn entry_download_filename(document_name: &str, service_name: &str, ext: &str) -> String {
801 let stem = if service_name == "import" {
802 document_name.to_string()
803 } else {
804 format!("{document_name}_{service_name}")
805 };
806 sanitize_download_filename(&format!("{stem}.{ext}"))
807}
808
809pub async fn serve_entry(
814 connection: &mut PgConnection,
815 service_name: String,
816 entry_id: usize,
817) -> Result<EntryDownload, NotFound<String>> {
818 if service_name.contains('/')
825 || service_name.contains('\\')
826 || service_name.contains("..")
827 || service_name.contains('\0')
828 {
829 return Err(NotFound("invalid service".to_string()));
830 }
831 match Task::find(entry_id as i64, connection) {
832 Ok(task) => {
833 let document_name = crate::helpers::entry_document_name(&task.entry);
836 let zip_path = if service_name == "import" {
837 Some(std::path::PathBuf::from(&task.entry))
838 } else {
839 let sandbox_id = Corpus::find_by_id(task.corpus_id, connection)
842 .ok()
843 .and_then(|corpus| corpus.sandbox_id());
844 crate::helpers::result_archive_path(&task.entry, &service_name, sandbox_id)
845 };
846 match zip_path {
847 Some(path) => {
848 let file = NamedFile::open(&path)
849 .await
850 .map_err(|_| NotFound("Invalid Zip at path".to_string()))?;
851 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("zip");
857 let filename = entry_download_filename(&document_name, &service_name, ext);
858 Ok(EntryDownload { file, filename })
859 },
860 None => Err(NotFound(format!(
861 "Service {service_name:?} does not have a result for entry {entry_id:?}"
862 ))),
863 }
864 },
865 Err(_) => Err(NotFound("Task not found".to_string())),
868 }
869}
870
871pub fn serve_entry_preview(
874 connection: &mut PgConnection,
875 corpus_name: String,
876 service_name: String,
877 entry_name: String,
878) -> Result<Template, NotFound<String>> {
879 let report_start = chrono::Utc::now();
880 let corpus_name = corpus_name.to_lowercase();
881 let mut context = TemplateContext::default();
882 let mut global = HashMap::new();
883
884 let corpus = Corpus::find_by_name(&corpus_name, connection)
889 .map_err(|_| NotFound(format!("Unknown corpus: {corpus_name}")))?;
890 let service = Service::find_by_name(&service_name, connection)
891 .map_err(|_| NotFound(format!("Unknown service: {service_name}")))?;
892 let task = Task::find_by_name(&entry_name, &corpus, &service, connection).map_err(|_| {
894 NotFound(format!(
895 "No '{entry_name}' document found in {corpus_name} / {service_name}"
896 ))
897 })?;
898 let download_url = format!("/entry/{}/{}", service_name, task.id);
899 global.insert("download_url".to_string(), download_url);
900
901 global.insert(
903 "title".to_string(),
904 "Corpus Report for ".to_string() + &corpus_name,
905 );
906 global.insert(
907 "description".to_string(),
908 "An analysis framework for corpora of TeX/LaTeX documents - statistical reports for "
909 .to_string()
910 + &corpus_name,
911 );
912 global.insert("corpus_description".to_string(), corpus.description);
913 global.insert("service_name".to_string(), service_name);
914 global.insert(
915 "service_description".to_string(),
916 service.description.clone(),
917 );
918 global.insert("type".to_string(), "Conversion".to_string());
919 global.insert("inputformat".to_string(), service.inputformat.clone());
920 global.insert("outputformat".to_string(), service.outputformat.clone());
921 match service.inputconverter {
922 Some(ref ic_service_name) => {
923 global.insert("inputconverter".to_string(), ic_service_name.clone())
924 },
925 None => global.insert("inputconverter".to_string(), "import".to_string()),
928 };
929 global.insert(
930 "report_time".to_string(),
931 crate::frontend::helpers::report_timestamp(),
932 );
933 global.insert("corpus_name".to_string(), corpus_name);
934 global.insert("severity".to_string(), entry_name.clone());
935 global.insert("entry_name".to_string(), entry_name);
936
937 context.global = global;
939 decorate_uri_encodings(&mut context);
942
943 let report_end = chrono::Utc::now();
945 let report_duration = (report_end - report_start).num_milliseconds();
946 context
947 .global
948 .insert("report_duration".to_string(), report_duration.to_string());
949 Ok(Template::render("task-preview", context))
950}
951
952fn pooled(pool: &State<DbPool>) -> Result<PooledConn, NotFound<String>> {
955 pool
956 .get()
957 .map_err(|_| NotFound("database unavailable".to_string()))
958}
959
960#[get("/preview/<corpus_name>/<service_name>/<entry_name>")]
963pub fn preview_entry(
964 corpus_name: String,
965 service_name: String,
966 entry_name: String,
967 pool: &State<DbPool>,
968) -> Result<Template, NotFound<String>> {
969 let mut connection = pooled(pool)?;
970 serve_entry_preview(&mut connection, corpus_name, service_name, entry_name)
971}
972
973#[post("/entry/<service_name>/<entry_id>")]
977pub async fn entry_fetch(
978 service_name: String,
979 entry_id: usize,
980 pool: &State<DbPool>,
981) -> Result<EntryDownload, NotFound<String>> {
982 let mut connection = pooled(pool)?;
983 serve_entry(&mut connection, service_name, entry_id).await
984}
985
986#[get("/entry/<service_name>/<entry_id>")]
991pub async fn entry_download(
992 service_name: String,
993 entry_id: usize,
994 pool: &State<DbPool>,
995) -> Result<EntryDownload, NotFound<String>> {
996 let mut connection = pooled(pool)?;
997 serve_entry(&mut connection, service_name, entry_id).await
998}
999
1000#[allow(clippy::too_many_arguments)]
1008fn human_rerun(
1009 session: Option<AdminSession>,
1010 pool: &State<DbPool>,
1011 corpus_name: String,
1012 service_name: String,
1013 severity: Option<String>,
1014 category: Option<String>,
1015 what: Option<String>,
1016 description: &str,
1017) -> Result<Accepted<String>, Status> {
1018 let session = session.ok_or(Status::Unauthorized)?;
1019 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1020 serve_rerun(
1021 &mut connection,
1022 pool.inner(),
1023 corpus_name,
1024 service_name,
1025 severity,
1026 category,
1027 what,
1028 &session.owner,
1029 description,
1030 )
1031}
1032
1033#[post(
1036 "/rerun/<corpus_name>/<service_name>",
1037 format = "application/json",
1038 data = "<rr>"
1039)]
1040pub fn rerun_corpus(
1041 corpus_name: String,
1042 service_name: String,
1043 rr: Json<RerunRequestParams>,
1044 session: Option<AdminSession>,
1045 pool: &State<DbPool>,
1046) -> Result<Accepted<String>, Status> {
1047 human_rerun(
1048 session,
1049 pool,
1050 corpus_name,
1051 service_name,
1052 None,
1053 None,
1054 None,
1055 &rr.description,
1056 )
1057}
1058
1059#[post(
1061 "/rerun/<corpus_name>/<service_name>/<severity>",
1062 format = "application/json",
1063 data = "<rr>"
1064)]
1065pub fn rerun_severity(
1066 corpus_name: String,
1067 service_name: String,
1068 severity: String,
1069 rr: Json<RerunRequestParams>,
1070 session: Option<AdminSession>,
1071 pool: &State<DbPool>,
1072) -> Result<Accepted<String>, Status> {
1073 human_rerun(
1074 session,
1075 pool,
1076 corpus_name,
1077 service_name,
1078 Some(severity),
1079 None,
1080 None,
1081 &rr.description,
1082 )
1083}
1084
1085#[post(
1087 "/rerun/<corpus_name>/<service_name>/<severity>/<category>",
1088 format = "application/json",
1089 data = "<rr>"
1090)]
1091#[allow(clippy::too_many_arguments)]
1092pub fn rerun_category(
1093 corpus_name: String,
1094 service_name: String,
1095 severity: String,
1096 category: String,
1097 rr: Json<RerunRequestParams>,
1098 session: Option<AdminSession>,
1099 pool: &State<DbPool>,
1100) -> Result<Accepted<String>, Status> {
1101 human_rerun(
1102 session,
1103 pool,
1104 corpus_name,
1105 service_name,
1106 Some(severity),
1107 Some(category),
1108 None,
1109 &rr.description,
1110 )
1111}
1112
1113#[post(
1115 "/rerun/<corpus_name>/<service_name>/<severity>/<category>/<what>",
1116 format = "application/json",
1117 data = "<rr>"
1118)]
1119#[allow(clippy::too_many_arguments)]
1120pub fn rerun_what(
1121 corpus_name: String,
1122 service_name: String,
1123 severity: String,
1124 category: String,
1125 what: String,
1126 rr: Json<RerunRequestParams>,
1127 session: Option<AdminSession>,
1128 pool: &State<DbPool>,
1129) -> Result<Accepted<String>, Status> {
1130 human_rerun(
1131 session,
1132 pool,
1133 corpus_name,
1134 service_name,
1135 Some(severity),
1136 Some(category),
1137 Some(what),
1138 &rr.description,
1139 )
1140}
1141
1142#[post("/savetasks/<corpus_name>/<service_name>")]
1145pub fn savetasks(
1146 corpus_name: String,
1147 service_name: String,
1148 session: Option<AdminSession>,
1149 pool: &State<DbPool>,
1150) -> Result<Accepted<String>, Status> {
1151 if session.is_none() {
1152 return Err(Status::Unauthorized);
1153 }
1154 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1155 serve_savetasks(&mut connection, corpus_name.to_lowercase(), service_name)
1156}
1157
1158#[get("/rerun/<corpus_name>/<service_name>/<scope..>")]
1166pub fn rerun_get_redirect(
1167 corpus_name: &str,
1168 service_name: &str,
1169 scope: std::path::PathBuf,
1170) -> rocket::response::Redirect {
1171 let scope = scope.display().to_string();
1172 let target = if scope.is_empty() {
1173 format!("/corpus/{corpus_name}/{service_name}")
1174 } else {
1175 format!("/corpus/{corpus_name}/{service_name}/{scope}")
1176 };
1177 rocket::response::Redirect::to(target)
1178}
1179
1180pub fn routes() -> Vec<Route> {
1183 routes![
1184 preview_entry,
1185 entry_fetch,
1186 entry_download,
1187 rerun_corpus,
1188 rerun_severity,
1189 rerun_category,
1190 rerun_what,
1191 rerun_get_redirect,
1192 pause_run,
1193 resume_run,
1194 pause_all,
1195 resume_all,
1196 savetasks
1197 ]
1198}
1199
1200#[cfg(test)]
1201mod live_report_limiter_tests {
1202 use super::LiveReportLimiter;
1203
1204 #[test]
1205 fn caps_then_releases_permits() {
1206 let limiter = LiveReportLimiter::new(1);
1207 assert_eq!(limiter.0.available_permits(), 1);
1208 {
1209 let _permit = limiter
1210 .0
1211 .clone()
1212 .try_acquire_owned()
1213 .expect("first permit available");
1214 assert!(
1215 limiter.0.clone().try_acquire_owned().is_err(),
1216 "exhausted at the cap of 1"
1217 );
1218 }
1219 assert_eq!(limiter.0.available_permits(), 1, "permit released on drop");
1221 assert!(
1222 limiter.0.clone().try_acquire_owned().is_ok(),
1223 "reacquire succeeds after release"
1224 );
1225 }
1226
1227 #[test]
1228 fn zero_is_clamped_to_one() {
1229 let limiter = LiveReportLimiter::new(0);
1230 assert_eq!(limiter.0.available_permits(), 1, "a 0 cap can't deadlock");
1231 }
1232}
1233
1234#[cfg(test)]
1235mod download_filename_tests {
1236 use super::entry_download_filename;
1237
1238 #[test]
1239 fn import_keeps_the_bare_document_name() {
1240 assert_eq!(
1242 entry_download_filename("0811.0417", "import", "zip"),
1243 "0811.0417.zip"
1244 );
1245 }
1246
1247 #[test]
1248 fn a_result_archive_appends_its_service() {
1249 assert_eq!(
1251 entry_download_filename("2105.13573", "tex_to_html", "zip"),
1252 "2105.13573_tex_to_html.zip"
1253 );
1254 assert_eq!(
1256 entry_download_filename("0801.1234", "ngram", "gz"),
1257 "0801.1234_ngram.gz"
1258 );
1259 }
1260
1261 #[test]
1262 fn unsafe_characters_are_sanitised() {
1263 assert_eq!(
1265 entry_download_filename("0801.1234", "te x/t", "zip"),
1266 "0801.1234_te_x_t.zip"
1267 );
1268 }
1269}