1use std::collections::HashMap;
17
18use diesel::prelude::*;
19use diesel::sql_query;
20use rocket::form::Form;
21use rocket::http::Status;
22use rocket::response::Redirect;
23use rocket::serde::json::Json;
24use rocket::{Route, State};
25use rocket_dyn_templates::Template;
26use serde::{Deserialize, Serialize};
27
28use crate::backend::{DatabaseUrl, DbPool};
29use crate::concerns::CortexInsertable;
30use crate::frontend::actor::{Actor, AdminReject, AdminSession, ReturnTo, require_admin_to};
31use crate::frontend::corpora::start_activate;
32use crate::frontend::helpers::{decorate_uri_encodings, group_thousands};
33use crate::frontend::params::{MAX_REPORT_OFFSET, MAX_REPORT_PAGE_SIZE, TemplateContext};
34use crate::models::{Corpus, NewService, Service, WorkerMetadata};
35
36const IMPORT_SERVICE_ID: i32 = 2;
40
41#[derive(Debug, Serialize, schemars::JsonSchema)]
44pub struct ServiceDto {
45 pub public_id: String,
48 pub name: String,
50 pub version: f32,
52 pub inputformat: String,
54 pub outputformat: String,
56 pub inputconverter: Option<String>,
58 pub complex: bool,
60 pub description: String,
62 pub lease_timeout_seconds: Option<i32>,
65}
66
67impl From<Service> for ServiceDto {
68 fn from(service: Service) -> ServiceDto {
69 ServiceDto {
70 public_id: service.public_id.to_string(),
71 name: service.name,
72 version: service.version,
73 inputformat: service.inputformat,
74 outputformat: service.outputformat,
75 inputconverter: service.inputconverter,
76 complex: service.complex,
77 description: service.description,
78 lease_timeout_seconds: service.lease_timeout_seconds,
79 }
80 }
81}
82
83#[derive(Debug, Serialize, schemars::JsonSchema)]
85pub struct WorkerDto {
86 pub name: String,
88 pub total_dispatched: i32,
90 pub total_returned: i32,
92 pub in_flight: i32,
95 pub last_dispatched_task_id: i64,
97 pub last_returned_task_id: Option<i64>,
99 pub seconds_since_last_active: i64,
105 pub fresh: bool,
108}
109
110impl From<WorkerMetadata> for WorkerDto {
111 fn from(worker: WorkerMetadata) -> WorkerDto {
112 let last_active = match worker.time_last_return {
115 Some(returned) => returned.max(worker.time_last_dispatch),
116 None => worker.time_last_dispatch,
117 };
118 let seconds_since_last_active = std::time::SystemTime::now()
119 .duration_since(last_active)
120 .map(|elapsed| elapsed.as_secs() as i64)
121 .unwrap_or(0);
122 WorkerDto {
123 in_flight: worker.total_dispatched - worker.total_returned,
124 name: worker.name,
125 total_dispatched: worker.total_dispatched,
126 total_returned: worker.total_returned,
127 last_dispatched_task_id: worker.last_dispatched_task_id,
128 last_returned_task_id: worker.last_returned_task_id,
129 seconds_since_last_active,
130 fresh: seconds_since_last_active < 60,
131 }
132 }
133}
134
135fn resolve(service: &str, connection: &mut diesel::PgConnection) -> Result<Service, Status> {
137 Service::find_by_name(service, connection).map_err(|_| Status::NotFound)
138}
139
140#[rocket_okapi::openapi(tag = "Services")]
143#[get("/api/services")]
144pub fn api_services(_caller: Actor, pool: &State<DbPool>) -> Result<Json<Vec<ServiceDto>>, Status> {
145 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
146 let services = Service::all(&mut connection).unwrap_or_default();
147 Ok(Json(services.into_iter().map(ServiceDto::from).collect()))
148}
149
150fn insert_service(pool: &DbPool, mut service: NewService) -> Result<(), Status> {
153 if service.name.trim().is_empty() {
156 return Err(Status::BadRequest);
157 }
158 service.inputconverter = service.inputconverter.filter(|s| !s.is_empty());
159 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
160 if Service::find_by_name(&service.name, &mut connection).is_ok() {
161 return Err(Status::Conflict);
162 }
163 service
164 .create(&mut connection)
165 .map_err(|_| Status::InternalServerError)?;
166 Ok(())
167}
168
169#[derive(Debug, Deserialize, schemars::JsonSchema)]
171pub struct ServiceRegisterRequest {
172 pub name: String,
174 pub version: f32,
176 pub inputformat: String,
178 pub outputformat: String,
180 pub inputconverter: Option<String>,
182 pub complex: bool,
184 pub description: Option<String>,
186}
187
188#[rocket_okapi::openapi(tag = "Services")]
194#[post("/api/services", format = "json", data = "<request>")]
195pub fn register_service(
196 request: Json<ServiceRegisterRequest>,
197 _actor: Actor,
198 pool: &State<DbPool>,
199) -> Result<(Status, Json<ServiceDto>), Status> {
200 let request = request.into_inner();
201 let name = request.name.clone();
202 insert_service(
203 pool,
204 NewService {
205 name: request.name,
206 version: request.version,
207 inputformat: request.inputformat,
208 outputformat: request.outputformat,
209 inputconverter: request.inputconverter,
210 complex: request.complex,
211 description: request.description.unwrap_or_default(),
212 },
213 )?;
214 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
215 let service =
216 Service::find_by_name(&name, &mut connection).map_err(|_| Status::InternalServerError)?;
217 Ok((Status::Created, Json(ServiceDto::from(service))))
218}
219
220#[derive(Debug, Deserialize, schemars::JsonSchema)]
222pub struct LeaseUpdateRequest {
223 pub seconds: Option<i32>,
226}
227
228fn valid_lease(seconds: Option<i32>) -> bool { !matches!(seconds, Some(value) if value <= 0) }
231
232#[rocket_okapi::openapi(tag = "Services")]
239#[put("/api/services/<service>/lease", format = "json", data = "<request>")]
240pub fn set_service_lease(
241 service: &str,
242 request: Json<LeaseUpdateRequest>,
243 _actor: Actor,
244 pool: &State<DbPool>,
245) -> Result<Json<ServiceDto>, Status> {
246 let seconds = request.into_inner().seconds;
247 if !valid_lease(seconds) {
248 return Err(Status::BadRequest);
249 }
250 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
251 let service_record = resolve(service, &mut connection)?;
252 service_record
253 .set_lease_timeout(seconds, &mut connection)
254 .map_err(|_| Status::InternalServerError)?;
255 let updated = resolve(service, &mut connection)?;
256 Ok(Json(ServiceDto::from(updated)))
257}
258
259#[derive(FromForm)]
261pub struct SetLeaseForm {
262 pub seconds: Option<i32>,
264}
265
266#[post("/services/<service>/lease", data = "<form>")]
270pub fn set_service_lease_human(
271 service: &str,
272 form: Form<SetLeaseForm>,
273 session: Option<AdminSession>,
274 pool: &State<DbPool>,
275) -> Result<Redirect, Status> {
276 if session.is_none() {
277 return Ok(Redirect::to("/admin/login"));
278 }
279 let seconds = form.into_inner().seconds;
280 if !valid_lease(seconds) {
281 return Err(Status::BadRequest);
282 }
283 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
284 let service_record =
285 Service::find_by_name(service, &mut connection).map_err(|_| Status::NotFound)?;
286 service_record
287 .set_lease_timeout(seconds, &mut connection)
288 .map_err(|_| Status::InternalServerError)?;
289 Ok(Redirect::to("/services"))
290}
291
292fn corpora_activated_on(service_id: i32, connection: &mut diesel::PgConnection) -> Vec<i32> {
297 use crate::schema::tasks;
298 tasks::table
299 .filter(tasks::service_id.eq(service_id))
300 .select(tasks::corpus_id)
301 .distinct()
302 .load(connection)
303 .unwrap_or_default()
304}
305
306#[derive(FromForm)]
309pub struct AddServiceForm {
310 pub name: String,
312 pub version: f32,
314 pub inputformat: String,
316 pub outputformat: String,
318 pub inputconverter: Option<String>,
320 pub complex: bool,
322 pub description: Option<String>,
324 pub corpora: Vec<String>,
326}
327
328#[allow(clippy::too_many_arguments)]
339fn render_add_service(
340 pool: &DbPool,
341 error: Option<&str>,
342 name: &str,
343 version: &str,
344 inputformat: &str,
345 outputformat: &str,
346 inputconverter: &str,
347 description: &str,
348 complex: bool,
349 selected: &[String],
350) -> Template {
351 let mut corpora: Vec<HashMap<String, String>> = match pool.get() {
352 Ok(mut connection) => Corpus::all(&mut connection)
353 .unwrap_or_default()
354 .iter()
355 .map(Corpus::to_hash)
356 .collect(),
357 Err(_) => Vec::new(),
358 };
359 for corpus in &mut corpora {
362 let is_selected = corpus
363 .get("name")
364 .is_some_and(|cname| selected.iter().any(|s| s == cname));
365 corpus.insert("checked".to_string(), is_selected.to_string());
366 }
367 let mut global = HashMap::new();
368 global.insert("title".to_string(), "Add a service".to_string());
369 global.insert(
370 "description".to_string(),
371 "Define a new processing service and optionally activate it on existing corpora".to_string(),
372 );
373 if let Some(message) = error {
374 global.insert("error".to_string(), message.to_string());
375 }
376 global.insert("svc_name".to_string(), name.to_string());
377 global.insert("svc_version".to_string(), version.to_string());
378 global.insert("svc_inputformat".to_string(), inputformat.to_string());
379 global.insert("svc_outputformat".to_string(), outputformat.to_string());
380 global.insert("svc_inputconverter".to_string(), inputconverter.to_string());
381 global.insert("svc_description".to_string(), description.to_string());
382 global.insert("svc_complex".to_string(), complex.to_string());
383 let mut context = TemplateContext {
384 global,
385 corpora: Some(corpora),
386 is_admin: true,
387 ..TemplateContext::default()
388 };
389 decorate_uri_encodings(&mut context);
390 Template::render("add-service", context)
391}
392
393#[allow(clippy::result_large_err)] #[get("/services/new")]
399pub fn add_service_page(
400 session: Option<AdminSession>,
401 return_to: ReturnTo,
402 pool: &State<DbPool>,
403) -> Result<Template, AdminReject> {
404 require_admin_to(session, &return_to)?;
405 Ok(render_add_service(
407 pool,
408 None,
409 "",
410 "0.1",
411 "tex",
412 "html",
413 "",
414 "",
415 true,
416 &[],
417 ))
418}
419
420#[allow(clippy::result_large_err)]
429#[post("/services/create", data = "<form>")]
430pub fn create_service_human(
431 form: Form<AddServiceForm>,
432 session: Option<AdminSession>,
433 pool: &State<DbPool>,
434 database_url: &State<DatabaseUrl>,
435) -> Result<Redirect, Template> {
436 let Some(session) = session else {
437 return Ok(Redirect::to("/admin/login"));
438 };
439 let form = form.into_inner();
440 if let Err(status) = insert_service(
441 pool,
442 NewService {
443 name: form.name.clone(),
444 version: form.version,
445 inputformat: form.inputformat.clone(),
446 outputformat: form.outputformat.clone(),
447 inputconverter: form.inputconverter.clone(),
448 complex: form.complex,
449 description: form.description.clone().unwrap_or_default(),
450 },
451 ) {
452 let message = match status.code {
455 409 => format!(
456 "A service named “{}” already exists — choose a different name.",
457 form.name
458 ),
459 503 => "The database is temporarily unavailable — please try again.".to_string(),
460 _ => "Could not define the service — check the values and try again.".to_string(),
461 };
462 return Err(render_add_service(
463 pool,
464 Some(&message),
465 &form.name,
466 &form.version.to_string(),
467 &form.inputformat,
468 &form.outputformat,
469 form.inputconverter.as_deref().unwrap_or(""),
470 form.description.as_deref().unwrap_or(""),
471 form.complex,
472 &form.corpora,
473 ));
474 }
475 let mut activated_any = false;
480 for corpus in form.corpora.iter().filter(|name| !name.is_empty()) {
481 if start_activate(pool, &database_url.0, &session.owner, corpus, &form.name).is_ok() {
482 activated_any = true;
483 }
484 }
485 Ok(Redirect::to(if activated_any {
486 "/jobs"
487 } else {
488 "/services"
489 }))
490}
491
492#[derive(FromForm)]
495pub struct ActivateOnCorpusForm {
496 pub corpus: String,
498}
499
500#[allow(clippy::result_large_err)] #[get("/services/<service>/activate")]
505pub fn activate_on_corpus_page(
506 service: &str,
507 session: Option<AdminSession>,
508 return_to: ReturnTo,
509 pool: &State<DbPool>,
510) -> Result<Template, AdminReject> {
511 require_admin_to(session, &return_to)?;
512 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
513 let service_record = resolve(service, &mut connection)?;
514 let activated_ids = corpora_activated_on(service_record.id, &mut connection);
515 let all_corpora = Corpus::all(&mut connection).unwrap_or_default();
516 let available: Vec<HashMap<String, String>> = all_corpora
517 .iter()
518 .filter(|corpus| !activated_ids.contains(&corpus.id))
519 .map(Corpus::to_hash)
520 .collect();
521 let already: Vec<String> = all_corpora
522 .iter()
523 .filter(|corpus| activated_ids.contains(&corpus.id))
524 .map(|corpus| corpus.name.clone())
525 .collect();
526 let mut global = HashMap::new();
527 global.insert(
528 "title".to_string(),
529 format!("Register service {service} on a corpus"),
530 );
531 global.insert(
532 "description".to_string(),
533 format!("Register the service {service} on an additional corpus"),
534 );
535 global.insert("service_name".to_string(), service.to_string());
536 global.insert("already_activated".to_string(), already.join(", "));
537 let mut context = TemplateContext {
538 global,
539 corpora: Some(available),
540 ..TemplateContext::default()
541 };
542 decorate_uri_encodings(&mut context);
543 Ok(Template::render("service-activate", context))
544}
545
546#[post("/services/<service>/activate", data = "<form>")]
551pub fn activate_on_corpus_human(
552 service: &str,
553 form: Form<ActivateOnCorpusForm>,
554 session: Option<AdminSession>,
555 pool: &State<DbPool>,
556 database_url: &State<DatabaseUrl>,
557) -> Result<Redirect, Status> {
558 let Some(session) = session else {
559 return Ok(Redirect::to("/admin/login"));
560 };
561 let uuid = start_activate(pool, &database_url.0, &session.owner, &form.corpus, service)?;
562 Ok(Redirect::to(format!("/jobs/{uuid}")))
563}
564
565#[allow(clippy::result_large_err)] #[get("/services")]
571pub fn services_page(
572 session: Option<AdminSession>,
573 return_to: ReturnTo,
574 pool: &State<DbPool>,
575) -> Result<Template, AdminReject> {
576 require_admin_to(session, &return_to)?;
577 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
578 let services: Vec<HashMap<String, String>> = Service::all(&mut connection)
579 .unwrap_or_default()
580 .iter()
581 .map(Service::to_hash)
582 .collect();
583 let mut global = HashMap::new();
584 global.insert("title".to_string(), "Registered services".to_string());
585 global.insert(
586 "description".to_string(),
587 "All processing services registered with the CorTeX framework".to_string(),
588 );
589 let mut context = TemplateContext {
590 global,
591 services: Some(services),
592 ..TemplateContext::default()
593 };
594 decorate_uri_encodings(&mut context);
595 Ok(Template::render("service-registry", context))
596}
597
598#[rocket_okapi::openapi(tag = "Services")]
601#[get("/api/services/<service>/workers")]
602pub fn api_service_workers(
603 _caller: Actor,
604 service: &str,
605 pool: &State<DbPool>,
606) -> Result<Json<Vec<WorkerDto>>, Status> {
607 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
608 let service = resolve(service, &mut connection)?;
609 let workers = service.select_workers(&mut connection).unwrap_or_default();
610 Ok(Json(workers.into_iter().map(WorkerDto::from).collect()))
611}
612
613#[allow(clippy::result_large_err)] #[get("/workers/<service>")]
618pub fn worker_report_page(
619 service: &str,
620 session: Option<AdminSession>,
621 return_to: ReturnTo,
622 pool: &State<DbPool>,
623) -> Result<Template, AdminReject> {
624 require_admin_to(session, &return_to)?;
625 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
626 let service_record = resolve(service, &mut connection)?;
627 let worker_records = service_record
628 .select_workers(&mut connection)
629 .unwrap_or_default();
630 let fleet_total = worker_records.len();
632 let fleet_fresh = worker_records.iter().filter(|w| w.is_fresh()).count();
633 let fleet_dispatched: i64 = worker_records
634 .iter()
635 .map(|w| i64::from(w.total_dispatched))
636 .sum();
637 let fleet_returned: i64 = worker_records
638 .iter()
639 .map(|w| i64::from(w.total_returned))
640 .sum();
641 let workers: Vec<HashMap<String, String>> = worker_records.into_iter().map(Into::into).collect();
642 let mut global = HashMap::new();
643 global.insert("fleet_total".to_string(), fleet_total.to_string());
644 global.insert("fleet_fresh".to_string(), fleet_fresh.to_string());
645 global.insert(
646 "fleet_stale".to_string(),
647 fleet_total.saturating_sub(fleet_fresh).to_string(),
648 );
649 global.insert(
654 "fleet_dispatched".to_string(),
655 group_thousands(fleet_dispatched),
656 );
657 global.insert(
658 "fleet_returned".to_string(),
659 group_thousands(fleet_returned),
660 );
661 global.insert(
662 "title".to_string(),
663 format!("Worker report for service {service} "),
664 );
665 global.insert(
666 "description".to_string(),
667 format!("Worker report for service {service} as registered by the CorTeX dispatcher"),
668 );
669 global.insert("service_name".to_string(), service.to_string());
670 global.insert(
671 "service_description".to_string(),
672 service_record.description.clone(),
673 );
674 let mut context = TemplateContext {
675 global,
676 workers: Some(workers),
677 ..TemplateContext::default()
678 };
679 decorate_uri_encodings(&mut context);
680 Ok(Template::render("workers", context))
681}
682
683#[rocket_okapi::openapi(tag = "Services")]
691#[delete("/api/services/<service>?<confirm>")]
692pub fn delete_service(
693 service: &str,
694 confirm: Option<&str>,
695 _actor: Actor,
696 pool: &State<DbPool>,
697) -> Status {
698 if confirm != Some(service) {
699 return Status::BadRequest;
700 }
701 let mut connection = match pool.get() {
702 Ok(connection) => connection,
703 Err(_) => return Status::ServiceUnavailable,
704 };
705 let service_record = match Service::find_by_name(service, &mut connection) {
706 Ok(service) => service,
707 Err(_) => return Status::NotFound,
708 };
709 if service_record.id <= IMPORT_SERVICE_ID {
711 return Status::Forbidden;
712 }
713 match service_record.destroy(&mut connection) {
714 Ok(_) => Status::NoContent,
715 Err(_) => Status::InternalServerError,
716 }
717}
718
719#[derive(FromForm)]
721pub struct DeleteServiceForm {
722 pub confirm: String,
724}
725
726#[post("/services/<service>/delete", data = "<form>")]
731pub fn delete_service_human(
732 service: &str,
733 form: Form<DeleteServiceForm>,
734 session: Option<AdminSession>,
735 pool: &State<DbPool>,
736) -> Result<Redirect, Status> {
737 if session.is_none() {
738 return Ok(Redirect::to("/admin/login"));
739 }
740 if form.confirm != service {
741 return Err(Status::BadRequest);
742 }
743 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
744 let service_record =
745 Service::find_by_name(service, &mut connection).map_err(|_| Status::NotFound)?;
746 if service_record.id <= IMPORT_SERVICE_ID {
748 return Err(Status::Forbidden);
749 }
750 service_record
751 .destroy(&mut connection)
752 .map_err(|_| Status::InternalServerError)?;
753 Ok(Redirect::to("/services"))
754}
755
756#[derive(Debug, Serialize, schemars::JsonSchema)]
759pub struct RuntimeRowDto {
760 pub corpus: String,
762 pub paper: String,
764 pub task_id: i64,
766 pub runtime_ms: i32,
768}
769
770#[derive(Debug, Serialize, schemars::JsonSchema)]
773pub struct RuntimeBucketDto {
774 pub label: String,
776 pub count: i64,
778}
779
780#[derive(Debug, Serialize, schemars::JsonSchema)]
785pub struct ServiceRuntimeDto {
786 pub service: String,
788 pub total: i64,
790 pub avg_ms: i32,
792 pub p50_ms: i32,
794 pub p90_ms: i32,
796 pub p99_ms: i32,
798 pub max_ms: i32,
800 pub histogram: Vec<RuntimeBucketDto>,
802 pub offset: i64,
804 pub page_size: i64,
806 pub slowest: Vec<RuntimeRowDto>,
808}
809
810const RUNTIME_BUCKET_LABELS: [&str; 11] = [
813 "<100ms",
814 "100–250ms",
815 "250–500ms",
816 "0.5–1s",
817 "1–2s",
818 "2–5s",
819 "5–10s",
820 "10–30s",
821 "30–60s",
822 "60–120s",
823 "≥120s",
824];
825
826fn service_runtime_report(
831 connection: &mut diesel::PgConnection,
832 service: &Service,
833 offset: i64,
834 page_size: i64,
835) -> Result<ServiceRuntimeDto, Status> {
836 use diesel::sql_types::{BigInt, Integer, Text};
837 #[derive(QueryableByName)]
842 struct SummaryRow {
843 #[diesel(sql_type = BigInt)]
844 total: i64,
845 #[diesel(sql_type = Integer)]
846 avg_ms: i32,
847 #[diesel(sql_type = Integer)]
848 p50: i32,
849 #[diesel(sql_type = Integer)]
850 p90: i32,
851 #[diesel(sql_type = Integer)]
852 p99: i32,
853 #[diesel(sql_type = Integer)]
854 max_ms: i32,
855 }
856 let summary: SummaryRow = sql_query(
857 "SELECT count(*) AS total, \
858 coalesce(round(avg(runtime_ms))::int, 0) AS avg_ms, \
859 coalesce(percentile_disc(0.5) WITHIN GROUP (ORDER BY runtime_ms), 0) AS p50, \
860 coalesce(percentile_disc(0.9) WITHIN GROUP (ORDER BY runtime_ms), 0) AS p90, \
861 coalesce(percentile_disc(0.99) WITHIN GROUP (ORDER BY runtime_ms), 0) AS p99, \
862 coalesce(max(runtime_ms), 0) AS max_ms FROM task_runtimes WHERE service_id = $1",
863 )
864 .bind::<Integer, _>(service.id)
865 .get_result(connection)
866 .map_err(|_| Status::InternalServerError)?;
867
868 #[derive(QueryableByName)]
869 struct BucketRow {
870 #[diesel(sql_type = Integer)]
871 bucket: i32,
872 #[diesel(sql_type = BigInt)]
873 n: i64,
874 }
875 let bucket_rows: Vec<BucketRow> = sql_query(
876 "SELECT CASE WHEN runtime_ms<100 THEN 0 WHEN runtime_ms<250 THEN 1 WHEN runtime_ms<500 THEN 2 \
877 WHEN runtime_ms<1000 THEN 3 WHEN runtime_ms<2000 THEN 4 WHEN runtime_ms<5000 THEN 5 \
878 WHEN runtime_ms<10000 THEN 6 WHEN runtime_ms<30000 THEN 7 WHEN runtime_ms<60000 THEN 8 \
879 WHEN runtime_ms<120000 THEN 9 ELSE 10 END AS bucket, count(*) AS n \
880 FROM task_runtimes WHERE service_id = $1 GROUP BY 1 ORDER BY 1",
881 )
882 .bind::<Integer, _>(service.id)
883 .load(connection)
884 .map_err(|_| Status::InternalServerError)?;
885 let mut counts = [0i64; RUNTIME_BUCKET_LABELS.len()];
886 for row in &bucket_rows {
887 if let Some(slot) = counts.get_mut(row.bucket as usize) {
888 *slot = row.n;
889 }
890 }
891 let histogram = RUNTIME_BUCKET_LABELS
892 .iter()
893 .zip(counts)
894 .map(|(label, count)| RuntimeBucketDto {
895 label: (*label).to_string(),
896 count,
897 })
898 .collect();
899
900 #[derive(QueryableByName)]
901 struct SlowRow {
902 #[diesel(sql_type = Text)]
903 corpus: String,
904 #[diesel(sql_type = Text)]
905 entry: String,
906 #[diesel(sql_type = BigInt)]
907 task_id: i64,
908 #[diesel(sql_type = Integer)]
909 runtime_ms: i32,
910 }
911 let slow_rows: Vec<SlowRow> = sql_query(
912 "SELECT c.name AS corpus, t.entry AS entry, tr.task_id AS task_id, tr.runtime_ms AS runtime_ms \
913 FROM task_runtimes tr JOIN tasks t ON t.id = tr.task_id JOIN corpora c ON c.id = t.corpus_id \
914 WHERE tr.service_id = $1 ORDER BY tr.runtime_ms DESC LIMIT $2 OFFSET $3",
915 )
916 .bind::<Integer, _>(service.id)
917 .bind::<BigInt, _>(page_size)
918 .bind::<BigInt, _>(offset)
919 .load(connection)
920 .map_err(|_| Status::InternalServerError)?;
921 let slowest = slow_rows
922 .into_iter()
923 .map(|row| RuntimeRowDto {
924 corpus: row.corpus,
925 paper: std::path::Path::new(&row.entry)
927 .parent()
928 .and_then(|p| p.file_name())
929 .map(|s| s.to_string_lossy().into_owned())
930 .unwrap_or(row.entry),
931 task_id: row.task_id,
932 runtime_ms: row.runtime_ms,
933 })
934 .collect();
935
936 Ok(ServiceRuntimeDto {
937 service: service.name.clone(),
938 total: summary.total,
939 avg_ms: summary.avg_ms,
940 p50_ms: summary.p50,
941 p90_ms: summary.p90,
942 p99_ms: summary.p99,
943 max_ms: summary.max_ms,
944 histogram,
945 offset,
946 page_size,
947 slowest,
948 })
949}
950
951fn format_runtime(ms: i32) -> String {
953 if ms < 1000 {
954 format!("{ms} ms")
955 } else {
956 format!("{:.1} s", f64::from(ms) / 1000.0)
957 }
958}
959
960#[rocket_okapi::openapi(tag = "Services")]
966#[get("/api/services/<service>/runtimes?<offset>&<page_size>")]
967pub fn api_service_runtimes(
968 _caller: Actor,
969 service: &str,
970 offset: Option<i64>,
971 page_size: Option<i64>,
972 pool: &State<DbPool>,
973) -> Result<Json<ServiceRuntimeDto>, Status> {
974 let offset = offset.unwrap_or(0).clamp(0, MAX_REPORT_OFFSET);
975 let page_size = page_size.unwrap_or(100).clamp(1, MAX_REPORT_PAGE_SIZE);
976 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
977 let service_record = resolve(service, &mut connection)?;
978 Ok(Json(service_runtime_report(
979 &mut connection,
980 &service_record,
981 offset,
982 page_size,
983 )?))
984}
985
986#[allow(clippy::result_large_err)] #[get("/runtimes/<service>?<offset>&<page_size>")]
992pub fn service_runtimes_page(
993 service: &str,
994 offset: Option<i64>,
995 page_size: Option<i64>,
996 session: Option<AdminSession>,
997 return_to: ReturnTo,
998 pool: &State<DbPool>,
999) -> Result<Template, AdminReject> {
1000 require_admin_to(session, &return_to)?;
1001 let offset = offset.unwrap_or(0).clamp(0, MAX_REPORT_OFFSET);
1002 let page_size = page_size.unwrap_or(100).clamp(1, MAX_REPORT_PAGE_SIZE);
1003 let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1004 let service_record = resolve(service, &mut connection)?;
1005 let report = service_runtime_report(&mut connection, &service_record, offset, page_size)?;
1006
1007 let max_bucket = report
1010 .histogram
1011 .iter()
1012 .map(|b| b.count)
1013 .max()
1014 .unwrap_or(0)
1015 .max(1);
1016 let histogram: Vec<HashMap<String, String>> = report
1017 .histogram
1018 .iter()
1019 .enumerate()
1020 .map(|(i, bucket)| {
1021 let bar_class = if i <= 4 {
1024 "ok"
1025 } else if i <= 7 {
1026 "warn"
1027 } else {
1028 "fatal"
1029 };
1030 HashMap::from([
1031 ("label".to_string(), bucket.label.clone()),
1032 ("count".to_string(), group_thousands(bucket.count)),
1033 (
1034 "pct".to_string(),
1035 format!("{:.1}", 100.0 * bucket.count as f64 / max_bucket as f64),
1036 ),
1037 ("bar_class".to_string(), bar_class.to_string()),
1038 ])
1039 })
1040 .collect();
1041 let entries: Vec<HashMap<String, String>> = report
1043 .slowest
1044 .iter()
1045 .map(|row| {
1046 HashMap::from([
1047 ("corpus".to_string(), row.corpus.clone()),
1048 ("paper".to_string(), row.paper.clone()),
1049 ("task_id".to_string(), row.task_id.to_string()),
1050 ("runtime".to_string(), format_runtime(row.runtime_ms)),
1051 (
1052 "runtime_ms".to_string(),
1053 group_thousands(i64::from(row.runtime_ms)),
1054 ),
1055 ])
1056 })
1057 .collect();
1058
1059 let mut global = HashMap::new();
1060 global.insert(
1061 "title".to_string(),
1062 format!("Conversion runtimes — {service}"),
1063 );
1064 global.insert(
1065 "description".to_string(),
1066 format!("Per-paper conversion wall-times for service {service}: distribution + slowest"),
1067 );
1068 global.insert("service_name".to_string(), service.to_string());
1069 global.insert("total".to_string(), group_thousands(report.total));
1070 global.insert("avg".to_string(), format_runtime(report.avg_ms));
1071 global.insert("p50".to_string(), format_runtime(report.p50_ms));
1072 global.insert("p90".to_string(), format_runtime(report.p90_ms));
1073 global.insert("p99".to_string(), format_runtime(report.p99_ms));
1074 global.insert("max".to_string(), format_runtime(report.max_ms));
1075 global.insert("offset".to_string(), offset.to_string());
1077 global.insert("page_size".to_string(), page_size.to_string());
1078 global.insert("has_prev".to_string(), (offset > 0).to_string());
1079 global.insert(
1080 "prev_offset".to_string(),
1081 (offset - page_size).max(0).to_string(),
1082 );
1083 global.insert(
1084 "has_next".to_string(),
1085 (offset + page_size < report.total).to_string(),
1086 );
1087 global.insert("next_offset".to_string(), (offset + page_size).to_string());
1088
1089 let mut context = TemplateContext {
1090 global,
1091 entries: Some(entries),
1092 whats: Some(histogram),
1093 is_admin: true,
1094 ..TemplateContext::default()
1095 };
1096 decorate_uri_encodings(&mut context);
1097 Ok(Template::render("service-runtimes", context))
1098}
1099
1100pub fn routes() -> Vec<Route> {
1102 routes![
1105 add_service_page,
1106 create_service_human,
1107 activate_on_corpus_page,
1108 activate_on_corpus_human,
1109 services_page,
1110 worker_report_page,
1111 service_runtimes_page,
1112 delete_service_human,
1113 set_service_lease_human
1114 ]
1115}