1use chrono::NaiveDateTime;
18use rocket::http::Status;
19use rocket::response::Redirect;
20use rocket::serde::json::Json;
21use rocket::{Route, State};
22use rocket_dyn_templates::{Template, context};
23use serde::Serialize;
24
25use std::collections::HashMap;
26
27use crate::backend::{DbPool, list_task_diffs, summary_task_diffs};
28use crate::frontend::actor::{AdminReject, AdminSession, ReturnTo, require_admin_to};
29use crate::frontend::helpers::uri_escape;
30use crate::frontend::params::{MAX_REPORT_OFFSET, MAX_REPORT_PAGE_SIZE};
31use crate::helpers::TaskStatus;
32use crate::models::{
33 Corpus, DiffStatusFilter, HistoricalRun, RunMetadata, RunMetadataStack, Service, TaskRunMetadata,
34};
35
36#[derive(Debug, Serialize, schemars::JsonSchema)]
42pub struct RunDto {
43 pub public_id: String,
45 pub id: i32,
47 pub owner: String,
49 pub description: String,
51 pub start_time: String,
53 pub end_time: Option<String>,
55 pub completed: bool,
57 pub total: i32,
59 pub no_problem: i32,
61 pub warning: i32,
63 pub error: i32,
65 pub fatal: i32,
67 pub invalid: i32,
69 pub in_progress: i32,
71}
72
73impl From<HistoricalRun> for RunDto {
74 fn from(run: HistoricalRun) -> RunDto {
75 RunDto {
76 public_id: run.public_id.to_string(),
77 id: run.id,
78 owner: run.owner,
79 description: run.description,
80 start_time: crate::frontend::helpers::iso_utc(run.start_time),
81 end_time: run.end_time.map(crate::frontend::helpers::iso_utc),
82 completed: run.end_time.is_some(),
83 total: run.total,
84 no_problem: run.no_problem,
85 warning: run.warning,
86 error: run.error,
87 fatal: run.fatal,
88 invalid: run.invalid,
89 in_progress: run.in_progress,
90 }
91 }
92}
93
94#[derive(Debug, Serialize)]
98struct DeltaCell {
99 v: i32,
101 cls: &'static str,
104}
105
106#[derive(Debug, Serialize)]
109struct RunDelta {
110 no_problem: DeltaCell,
112 warning: DeltaCell,
114 error: DeltaCell,
116 fatal: DeltaCell,
118}
119
120#[derive(Debug, Serialize)]
123struct RunRow {
124 #[serde(flatten)]
126 run: RunDto,
127 delta: Option<RunDelta>,
129}
130
131fn delta_class(delta: i32, higher_is_better: bool) -> &'static str {
135 if delta == 0 {
136 "delta-zero"
137 } else if (delta > 0) == higher_is_better {
138 "delta-good"
139 } else {
140 "delta-bad"
141 }
142}
143
144impl RunDelta {
145 fn between(run: &RunDto, older: &RunDto) -> RunDelta {
147 let cell = |delta: i32, higher_is_better: bool| DeltaCell {
148 v: delta,
149 cls: delta_class(delta, higher_is_better),
150 };
151 RunDelta {
152 no_problem: cell(run.no_problem - older.no_problem, true),
153 warning: cell(run.warning - older.warning, false),
154 error: cell(run.error - older.error, false),
155 fatal: cell(run.fatal - older.fatal, false),
156 }
157 }
158}
159
160#[derive(Debug, Serialize, schemars::JsonSchema)]
165pub struct RunOverviewDto {
166 pub public_id: String,
168 pub corpus: String,
170 pub service: String,
172 pub owner: String,
174 pub description: String,
176 pub start_time: String,
178 pub end_time: Option<String>,
180 pub completed: bool,
182 pub total: i32,
184 pub no_problem: i32,
186 pub warning: i32,
188 pub error: i32,
190 pub fatal: i32,
192 pub invalid: i32,
194 pub in_progress: i32,
196}
197
198impl RunOverviewDto {
199 fn build(
202 run: HistoricalRun,
203 corpora: &HashMap<i32, String>,
204 services: &HashMap<i32, String>,
205 ) -> RunOverviewDto {
206 RunOverviewDto {
207 public_id: run.public_id.to_string(),
208 corpus: corpora
209 .get(&run.corpus_id)
210 .cloned()
211 .unwrap_or_else(|| format!("#{}", run.corpus_id)),
212 service: services
213 .get(&run.service_id)
214 .cloned()
215 .unwrap_or_else(|| format!("#{}", run.service_id)),
216 owner: run.owner,
217 description: run.description,
218 start_time: crate::frontend::helpers::iso_utc(run.start_time),
219 end_time: run.end_time.map(crate::frontend::helpers::iso_utc),
220 completed: run.end_time.is_some(),
221 total: run.total,
222 no_problem: run.no_problem,
223 warning: run.warning,
224 error: run.error,
225 fatal: run.fatal,
226 invalid: run.invalid,
227 in_progress: run.in_progress,
228 }
229 }
230}
231
232fn load_recent_runs(
238 pool: &DbPool,
239 corpus: Option<&str>,
240 service: Option<&str>,
241 owner: Option<&str>,
242 limit: i64,
243) -> Result<Vec<RunOverviewDto>, Status> {
244 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
245 let corpus_id = match corpus.filter(|name| !name.is_empty()) {
247 Some(name) => match Corpus::find_by_name(name, &mut connection) {
248 Ok(corpus) => Some(corpus.id),
249 Err(_) => return Ok(Vec::new()),
250 },
251 None => None,
252 };
253 let service_id = match service.filter(|name| !name.is_empty()) {
254 Some(name) => match Service::find_by_name(name, &mut connection) {
255 Ok(service) => Some(service.id),
256 Err(_) => return Ok(Vec::new()),
257 },
258 None => None,
259 };
260 let owner = owner.filter(|owner| !owner.is_empty());
261 let runs = HistoricalRun::recent_filtered(&mut connection, corpus_id, service_id, owner, limit)
262 .map_err(|_| Status::InternalServerError)?;
263 let runs = HistoricalRun::overlay_live_tallies(runs, &mut connection);
268 let corpora: HashMap<i32, String> = Corpus::all(&mut connection)
270 .unwrap_or_default()
271 .into_iter()
272 .map(|corpus| (corpus.id, corpus.name))
273 .collect();
274 let services: HashMap<i32, String> = Service::all(&mut connection)
275 .unwrap_or_default()
276 .into_iter()
277 .map(|service| (service.id, service.name))
278 .collect();
279 Ok(
280 runs
281 .into_iter()
282 .map(|run| RunOverviewDto::build(run, &corpora, &services))
283 .collect(),
284 )
285}
286
287#[rocket_okapi::openapi(tag = "Runs")]
291#[get("/api/runs?<corpus>&<service>&<owner>&<limit>")]
292pub fn api_all_runs(
293 corpus: Option<String>,
294 service: Option<String>,
295 owner: Option<String>,
296 limit: Option<i64>,
297 pool: &State<DbPool>,
298) -> Result<Json<Vec<RunOverviewDto>>, Status> {
299 let limit = limit.unwrap_or(100).clamp(1, 500);
300 Ok(Json(load_recent_runs(
301 pool,
302 corpus.as_deref(),
303 service.as_deref(),
304 owner.as_deref(),
305 limit,
306 )?))
307}
308
309#[allow(clippy::result_large_err)] #[get("/admin/runs?<corpus>&<service>&<owner>&<limit>")]
314pub fn all_runs_page(
315 corpus: Option<String>,
316 service: Option<String>,
317 owner: Option<String>,
318 limit: Option<i64>,
319 session: Option<AdminSession>,
320 return_to: ReturnTo,
321 pool: &State<DbPool>,
322) -> Result<Template, AdminReject> {
323 let admin = require_admin_to(session, &return_to)?;
324 let limit = limit.unwrap_or(100).clamp(1, 500);
325 let runs = load_recent_runs(
327 pool,
328 corpus.as_deref(),
329 service.as_deref(),
330 owner.as_deref(),
331 limit,
332 )
333 .unwrap_or_default();
334 let mut corpus_names: Vec<String> = Vec::new();
336 let mut service_names: Vec<String> = Vec::new();
337 if let Ok(mut connection) = pool.get() {
338 corpus_names = Corpus::all(&mut connection)
339 .unwrap_or_default()
340 .into_iter()
341 .map(|corpus| corpus.name)
342 .collect();
343 service_names = Service::all(&mut connection)
344 .unwrap_or_default()
345 .into_iter()
346 .map(|service| service.name)
347 .collect();
348 }
349 let global = serde_json::json!({
350 "title": "Historical runs",
351 "description": "Recent conversion runs across every corpus and service",
352 });
353 Ok(Template::render(
354 "admin-runs",
355 context! {
356 global, owner: admin.owner, runs, corpus_names, service_names,
357 filter_corpus: corpus, filter_service: service, filter_owner: owner,
358 },
359 ))
360}
361
362fn resolve(
364 corpus: &str,
365 service: &str,
366 connection: &mut diesel::PgConnection,
367) -> Result<(Corpus, Service), Status> {
368 let corpus = Corpus::find_by_name(corpus, connection).map_err(|_| Status::NotFound)?;
369 let service = Service::find_by_name(service, connection).map_err(|_| Status::NotFound)?;
370 Ok((corpus, service))
371}
372
373#[rocket_okapi::openapi(tag = "Runs")]
376#[get("/api/runs/<corpus>/<service>")]
377pub fn api_runs(
378 corpus: &str,
379 service: &str,
380 pool: &State<DbPool>,
381) -> Result<Json<Vec<RunDto>>, Status> {
382 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
383 let (corpus, service) = resolve(corpus, service, &mut connection)?;
384 let runs = HistoricalRun::find_by(&corpus, &service, &mut connection).unwrap_or_default();
385 Ok(Json(
387 runs
388 .into_iter()
389 .map(|run| RunDto::from(run.with_live_tallies(&mut connection)))
390 .collect(),
391 ))
392}
393
394#[rocket_okapi::openapi(tag = "Runs")]
397#[get("/api/runs/<corpus>/<service>/current")]
398pub fn api_run_current(
399 corpus: &str,
400 service: &str,
401 pool: &State<DbPool>,
402) -> Result<Json<Option<RunDto>>, Status> {
403 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
404 let (corpus, service) = resolve(corpus, service, &mut connection)?;
405 let current = HistoricalRun::find_current(&corpus, &service, &mut connection)
406 .map_err(|_| Status::InternalServerError)?;
407 Ok(Json(current.map(|run| {
409 RunDto::from(run.with_live_tallies(&mut connection))
410 })))
411}
412
413#[derive(Debug, Serialize, schemars::JsonSchema)]
416pub struct RunDiffTransitionDto {
417 pub previous_status: String,
419 pub current_status: String,
421 pub task_count: usize,
423}
424
425#[derive(Debug, Serialize, schemars::JsonSchema)]
428pub struct RunDiffDto {
429 pub available_dates: Vec<String>,
431 pub transitions: Vec<RunDiffTransitionDto>,
433}
434
435fn parse_snapshot_date(raw: Option<&str>) -> Result<Option<NaiveDateTime>, Status> {
439 match raw.map(str::trim).filter(|value| !value.is_empty()) {
440 None => Ok(None),
441 Some(value) => NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S%.f")
442 .map(Some)
443 .map_err(|_| Status::BadRequest),
444 }
445}
446
447fn parse_status(raw: Option<&str>) -> Result<Option<TaskStatus>, Status> {
450 match raw.map(str::trim).filter(|value| !value.is_empty()) {
451 None => Ok(None),
452 Some(value) => TaskStatus::from_key(value)
453 .map(Some)
454 .ok_or(Status::BadRequest),
455 }
456}
457
458#[rocket_okapi::openapi(tag = "Runs")]
462#[get("/api/runs/<corpus>/<service>/diff?<previous>&<current>")]
463pub fn api_run_diff(
464 corpus: &str,
465 service: &str,
466 previous: Option<&str>,
467 current: Option<&str>,
468 pool: &State<DbPool>,
469) -> Result<Json<RunDiffDto>, Status> {
470 let previous_date = parse_snapshot_date(previous)?;
471 let current_date = parse_snapshot_date(current)?;
472 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
473 let (corpus, service) = resolve(corpus, service, &mut connection)?;
474 let (available_dates, rows) = summary_task_diffs(
475 &mut connection,
476 &corpus,
477 &service,
478 previous_date,
479 current_date,
480 );
481 let transitions = rows
482 .into_iter()
483 .map(|row| RunDiffTransitionDto {
484 previous_status: row.previous_status,
485 current_status: row.current_status,
486 task_count: row.task_count,
487 })
488 .collect();
489 Ok(Json(RunDiffDto {
490 available_dates,
491 transitions,
492 }))
493}
494
495#[derive(Debug, Serialize, schemars::JsonSchema)]
498pub struct TaskDiffDto {
499 pub task_id: String,
501 pub entry: String,
503 pub previous_status: String,
505 pub current_status: String,
507 pub previous_saved_at: String,
509 pub current_saved_at: String,
511}
512
513impl From<TaskRunMetadata> for TaskDiffDto {
514 fn from(task: TaskRunMetadata) -> TaskDiffDto {
515 TaskDiffDto {
516 task_id: task.task_id,
517 entry: task.entry,
518 previous_status: task.previous_status,
519 current_status: task.current_status,
520 previous_saved_at: task.previous_saved_at,
521 current_saved_at: task.current_saved_at,
522 }
523 }
524}
525
526#[allow(clippy::too_many_arguments)]
531#[rocket_okapi::openapi(tag = "Runs")]
532#[get(
533 "/api/runs/<corpus>/<service>/tasks?<previous>&<current>&<previous_status>&<current_status>&<offset>&<page_size>"
534)]
535pub fn api_run_task_diffs(
536 corpus: &str,
537 service: &str,
538 previous: Option<&str>,
539 current: Option<&str>,
540 previous_status: Option<&str>,
541 current_status: Option<&str>,
542 offset: Option<usize>,
543 page_size: Option<usize>,
544 pool: &State<DbPool>,
545) -> Result<Json<Vec<TaskDiffDto>>, Status> {
546 let filters = DiffStatusFilter {
547 previous_status: parse_status(previous_status)?,
548 current_status: parse_status(current_status)?,
549 previous_date: parse_snapshot_date(previous)?,
550 current_date: parse_snapshot_date(current)?,
551 offset: offset.unwrap_or(0).min(MAX_REPORT_OFFSET as usize),
555 page_size: page_size
556 .unwrap_or(100)
557 .clamp(1, MAX_REPORT_PAGE_SIZE as usize),
558 };
559 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
560 let (corpus, service) = resolve(corpus, service, &mut connection)?;
561 let tasks = list_task_diffs(&mut connection, &corpus, &service, filters);
562 Ok(Json(tasks.into_iter().map(TaskDiffDto::from).collect()))
563}
564
565const DIFF_SEVERITY_KEYS: [&str; 6] =
568 ["no_problem", "warning", "error", "fatal", "invalid", "todo"];
569
570#[allow(clippy::too_many_arguments)]
579#[get(
580 "/runs/<corpus>/<service>/tasks?<previous>&<current>&<previous_status>&<current_status>&<offset>&<page_size>"
581)]
582pub fn runs_tasks_page(
583 corpus: &str,
584 service: &str,
585 previous: Option<&str>,
586 current: Option<&str>,
587 previous_status: Option<&str>,
588 current_status: Option<&str>,
589 offset: Option<usize>,
590 page_size: Option<usize>,
591 pool: &State<DbPool>,
592) -> Result<Template, Status> {
593 let previous_status_filter = parse_status(previous_status)?;
595 let current_status_filter = parse_status(current_status)?;
596 let previous_date = parse_snapshot_date(previous)?;
597 let current_date = parse_snapshot_date(current)?;
598 let offset = offset.unwrap_or(0).min(MAX_REPORT_OFFSET as usize);
600 let page_size = page_size
601 .unwrap_or(100)
602 .clamp(1, MAX_REPORT_PAGE_SIZE as usize);
603
604 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
605 let (corpus_record, service_record) = resolve(corpus, service, &mut connection)?;
606 let tasks: Vec<TaskDiffDto> = list_task_diffs(
607 &mut connection,
608 &corpus_record,
609 &service_record,
610 DiffStatusFilter {
611 previous_status: previous_status_filter,
612 current_status: current_status_filter,
613 previous_date,
614 current_date,
615 offset,
616 page_size,
617 },
618 )
619 .into_iter()
620 .map(TaskDiffDto::from)
621 .collect();
622
623 let page_len = tasks.len();
625 let has_next = page_len == page_size;
626 let has_prev = offset > 0;
627 let selected_previous = previous_status_filter
630 .map(|s| s.to_key())
631 .unwrap_or_default();
632 let selected_current = current_status_filter
633 .map(|s| s.to_key())
634 .unwrap_or_default();
635 let global = serde_json::json!({
636 "title": format!("Task changes · {service} / {corpus}"),
637 "description": format!("Per-task severity changes of service {service} over corpus {corpus}"),
638 });
639 Ok(Template::render(
640 "runs-tasks",
641 context! {
642 global,
643 corpus,
644 service,
645 tasks,
646 severities: DIFF_SEVERITY_KEYS,
647 selected_previous,
648 selected_current,
649 previous_date: previous.unwrap_or_default(),
650 current_date: current.unwrap_or_default(),
651 offset: offset as i64,
652 page_size: page_size as i64,
653 from_offset: offset as i64 + 1,
654 to_offset: offset as i64 + page_len as i64,
655 prev_offset: offset.saturating_sub(page_size) as i64,
656 next_offset: (offset + page_size) as i64,
657 has_prev,
658 has_next,
659 },
660 ))
661}
662
663#[get("/runs/<corpus>/<service>/diff?<previous>&<current>")]
671pub fn runs_diff_page(
672 corpus: &str,
673 service: &str,
674 previous: Option<&str>,
675 current: Option<&str>,
676 pool: &State<DbPool>,
677) -> Result<Template, Status> {
678 let previous_date = parse_snapshot_date(previous)?;
679 let current_date = parse_snapshot_date(current)?;
680 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
681 let (corpus_record, service_record) = resolve(corpus, service, &mut connection)?;
682 let (available_dates, rows) = summary_task_diffs(
683 &mut connection,
684 &corpus_record,
685 &service_record,
686 previous_date,
687 current_date,
688 );
689 let transitions: Vec<RunDiffTransitionDto> = rows
690 .into_iter()
691 .map(|row| RunDiffTransitionDto {
692 previous_status: row.previous_status,
693 current_status: row.current_status,
694 task_count: row.task_count,
695 })
696 .collect();
697 let global = serde_json::json!({
698 "title": format!("Run diff · {service} / {corpus}"),
699 "description": format!("Status-transition matrix of service {service} over corpus {corpus}"),
700 });
701 Ok(Template::render(
702 "runs-diff",
703 context! {
704 global,
705 corpus,
706 service,
707 available_dates,
708 transitions,
709 previous_date: previous.unwrap_or_default(),
710 current_date: current.unwrap_or_default(),
711 },
712 ))
713}
714
715#[get("/runs/<corpus>/<service>")]
718pub fn runs_page(corpus: &str, service: &str, pool: &State<DbPool>) -> Result<Template, Status> {
719 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
720 let (corpus_record, service_record) = resolve(corpus, service, &mut connection)?;
721 let historical: Vec<HistoricalRun> =
726 HistoricalRun::find_by(&corpus_record, &service_record, &mut connection)
727 .unwrap_or_default()
728 .into_iter()
729 .map(|run| run.with_live_tallies(&mut connection))
730 .collect();
731 let runs_meta: Vec<RunMetadata> = historical.iter().cloned().map(RunMetadata::from).collect();
732 let runs: Vec<RunDto> = historical.into_iter().map(RunDto::from).collect();
733 let deltas: Vec<Option<RunDelta>> = (0..runs.len())
737 .map(|i| {
738 runs
739 .get(i + 1)
740 .map(|older| RunDelta::between(&runs[i], older))
741 })
742 .collect();
743 let runs: Vec<RunRow> = runs
744 .into_iter()
745 .zip(deltas)
746 .map(|(run, delta)| RunRow { run, delta })
747 .collect();
748 let history_serialized = serde_json::to_string(&RunMetadataStack::transform(&runs_meta))
754 .unwrap_or_default()
755 .replace('<', "\\u003c")
756 .replace('>', "\\u003e")
757 .replace('&', "\\u0026");
758 let history_length = runs_meta
759 .iter()
760 .filter(|run| !run.end_time.is_empty())
761 .count()
762 .to_string();
763 let global = serde_json::json!({
766 "title": format!("Run history · {service} / {corpus}"),
767 "description": format!("Historical runs of service {service} over corpus {corpus}"),
768 "service_name": service,
769 "corpus_name": corpus,
770 "corpus_name_uri": uri_escape(Some(corpus.to_string())).unwrap_or_default(),
774 "service_name_uri": uri_escape(Some(service.to_string())).unwrap_or_default(),
775 "history_length": history_length,
776 });
777 Ok(Template::render(
778 "runs",
779 context! { global, corpus, service, runs, history_serialized },
780 ))
781}
782
783#[get("/history/<corpus>/<service>")]
787pub fn history_page(corpus: &str, service: &str) -> Redirect {
788 Redirect::to(format!("/runs/{corpus}/{service}"))
791}
792
793pub fn routes() -> Vec<Route> {
795 routes![
798 all_runs_page,
799 runs_tasks_page,
800 runs_diff_page,
801 runs_page,
802 history_page
803 ]
804}
805
806#[cfg(test)]
807mod tests {
808 use super::delta_class;
809
810 #[test]
811 fn delta_class_polarity() {
812 assert_eq!(delta_class(26, true), "delta-good");
814 assert_eq!(delta_class(-5, true), "delta-bad");
815 assert_eq!(delta_class(3, false), "delta-bad");
817 assert_eq!(delta_class(-3, false), "delta-good");
818 assert_eq!(delta_class(0, true), "delta-zero");
820 assert_eq!(delta_class(0, false), "delta-zero");
821 }
822}