Skip to main content

cortex/frontend/
services.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Services capability: the worker-fleet view for a service, as a human screen + agent API.
9//!
10//! Follows the symmetry contract — the worker-fleet screen (`GET /workers/<service>`) and its agent
11//! twin (`GET /api/services/<service>/workers`) live in one module, both pooled (no per-request
12//! `Backend::default()`). The agent twin surfaces the per-worker dispatch/return tallies and the
13//! in-flight backlog, the operational signal for spotting a stuck or struggling worker (directly
14//! useful for watching the hardened dispatcher's fleet).
15
16use 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
36/// Magic service-id ceiling: ids `1=init` and `2=import` are infrastructure and must never be
37/// destroyed (deleting `import` would wipe a corpus's document registry). Mirrors the same guard in
38/// [`crate::frontend::corpora`].
39const IMPORT_SERVICE_ID: i32 = 2;
40
41/// A registered service as exposed over the API/UI — the service-registry view. `name` is the
42/// stable external handle used by every service route.
43#[derive(Debug, Serialize, schemars::JsonSchema)]
44pub struct ServiceDto {
45  /// Stable external handle (UUIDv7) — survives a rename, unlike `name`. Use for durable
46  /// references.
47  pub public_id: String,
48  /// Service name (its external handle); `init`/`import` are the magic internal services.
49  pub name: String,
50  /// Service version.
51  pub version: f32,
52  /// Expected input format (e.g. `tex`).
53  pub inputformat: String,
54  /// Produced output format (e.g. `html`).
55  pub outputformat: String,
56  /// Prerequisite input-conversion service, if any.
57  pub inputconverter: Option<String>,
58  /// Whether the service needs more than a document's main textual content.
59  pub complex: bool,
60  /// Human-readable description.
61  pub description: String,
62  /// Per-service lease / visibility-timeout override in seconds (D-17), or `null` to use the
63  /// global `dispatcher.lease_timeout_seconds`. Set via `PUT /api/services/<service>/lease`.
64  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/// A worker's dispatch/return tallies for a service — the machine-readable fleet-health view.
84#[derive(Debug, Serialize, schemars::JsonSchema)]
85pub struct WorkerDto {
86  /// Worker identity (usually `hostname:pid`).
87  pub name: String,
88  /// Tasks ever dispatched to this worker.
89  pub total_dispatched: i32,
90  /// Tasks ever returned by this worker.
91  pub total_returned: i32,
92  /// Dispatched-but-not-yet-returned tasks (`dispatched - returned`); a large or growing value
93  /// flags a stuck or struggling worker.
94  pub in_flight: i32,
95  /// The id of the most recent task dispatched to this worker.
96  pub last_dispatched_task_id: i64,
97  /// The id of the most recent task this worker returned (`None` if it never has).
98  pub last_returned_task_id: Option<i64>,
99  /// Seconds since this worker was last active (the more recent of its last dispatch / last
100  /// return) — its **liveness age**. Across a large fleet, a value that keeps climbing flags a
101  /// worker gone silent (crashed / disconnected); this is the agent-twin parity of the human
102  /// screen's "N ago" + fresh/stale display. `0` if the timestamp is in the future (clock skew),
103  /// never negative.
104  pub seconds_since_last_active: i64,
105  /// Whether the worker has been active within the last minute — the at-a-glance liveness flag
106  /// (matches the human screen's `fresh`/`stale` threshold).
107  pub fresh: bool,
108}
109
110impl From<WorkerMetadata> for WorkerDto {
111  fn from(worker: WorkerMetadata) -> WorkerDto {
112    // Liveness = time since the most recent activity (a dispatch *or* a return). Skew-safe: a
113    // future timestamp yields 0 rather than panicking (cf. `since_string`).
114    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
135/// Resolves a service name to its record, mapping a miss to `404`.
136fn resolve(service: &str, connection: &mut diesel::PgConnection) -> Result<Service, Status> {
137  Service::find_by_name(service, connection).map_err(|_| Status::NotFound)
138}
139
140/// The service registry (agent twin of the registry screen): every registered service. `503` if the
141/// pool is exhausted.
142#[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
150/// Inserts a new service definition (`409` if the name is taken). Shared by the agent endpoint and
151/// the human form. Normalizes an empty `inputconverter` to `None` (no prerequisite).
152fn insert_service(pool: &DbPool, mut service: NewService) -> Result<(), Status> {
153  // Reject a blank name on the agent path too (the HTML form enforces `required`) — a service with
154  // an empty handle is unreachable by every name-keyed route.
155  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/// Request body for registering (defining) a new service.
170#[derive(Debug, Deserialize, schemars::JsonSchema)]
171pub struct ServiceRegisterRequest {
172  /// Service name (external handle).
173  pub name: String,
174  /// Service version (e.g. `0.1`).
175  pub version: f32,
176  /// Expected input format (e.g. `tex`).
177  pub inputformat: String,
178  /// Produced output format (e.g. `html`).
179  pub outputformat: String,
180  /// Prerequisite input-conversion service, if any (empty = none).
181  pub inputconverter: Option<String>,
182  /// Whether the service needs more than a document's main textual content.
183  pub complex: bool,
184  /// Optional human-readable description.
185  pub description: Option<String>,
186}
187
188/// Registers (defines) a new service in the registry — the agent twin of the "Add a service" screen
189/// (`/services/new`, `create_service_human`). **Token-gated** via the [`Actor`] guard; `401`
190/// without a valid token, `409` if the service name already exists, `201` with the service on
191/// success. (This *defines* a service; activating it on a corpus — creating tasks — is `POST
192/// /api/corpora/<c>/services/<s>`.)
193#[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/// Request body for setting a service's per-service lease timeout (D-17).
221#[derive(Debug, Deserialize, schemars::JsonSchema)]
222pub struct LeaseUpdateRequest {
223  /// New lease / visibility-timeout in seconds (must be positive), or `null` to clear the override
224  /// and fall back to the global `dispatcher.lease_timeout_seconds`.
225  pub seconds: Option<i32>,
226}
227
228/// Rejects a non-positive lease (`Some(<= 0)`); `None` (clear) and any positive value pass. Shared
229/// by the agent and human lease setters.
230fn valid_lease(seconds: Option<i32>) -> bool { !matches!(seconds, Some(value) if value <= 0) }
231
232/// Sets (or clears) a service's per-service lease / visibility timeout — the agent twin of the
233/// registry screen's inline "lease" form (D-17). **Token-gated** via the [`Actor`] guard (`401`
234/// without a valid token); `400` if `seconds` is non-positive, `404` if the service is unknown,
235/// `200` with the updated [`ServiceDto`] on success. `null` clears the override (the service falls
236/// back to the global dispatcher lease). Takes effect on the next dispatch — an already-leased task
237/// keeps the timeout captured when it was leased.
238#[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/// Fields of the registry screen's inline "set lease" form. A blank value parses to `None` (clear).
260#[derive(FromForm)]
261pub struct SetLeaseForm {
262  /// New lease in seconds; blank clears the per-service override (the global default applies).
263  pub seconds: Option<i32>,
264}
265
266/// The human twin of [`set_service_lease`]: the registry screen's inline per-service lease form.
267/// **Gated by the signed-in [`AdminSession`] cookie** (anonymous → sign-in). A blank value clears
268/// the override; a non-positive value is `400`. Redirects back to `/services`; `404` if unknown.
269#[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
292/// The corpus ids a service is already activated on (i.e. has at least one task for) — so the
293/// "register on a corpus" screen can **exclude** them from the picker. Re-activating an existing
294/// `(service, corpus)` pair is *destructive* (`register_service` wipes & re-creates that pair's
295/// tasks + logs), so the UI only ever offers genuinely-new corpora.
296fn 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/// Fields of the "Add a service" form: the new-service definition plus the names of the corpora
307/// (zero or more checkboxes) to activate it on.
308#[derive(FromForm)]
309pub struct AddServiceForm {
310  /// Service name (external handle).
311  pub name: String,
312  /// Service version.
313  pub version: f32,
314  /// Expected input format.
315  pub inputformat: String,
316  /// Produced output format.
317  pub outputformat: String,
318  /// Prerequisite input-conversion service, if any.
319  pub inputconverter: Option<String>,
320  /// Whether the service is complex.
321  pub complex: bool,
322  /// Optional description.
323  pub description: Option<String>,
324  /// The corpora to activate the new service on (the checked checkboxes; empty = define only).
325  pub corpora: Vec<String>,
326}
327
328/// The "Add a service" screen: the full new-service form plus a checkbox list of every registered
329/// corpus to activate the freshly-defined service on (zero or more). **Signed-in admins only** (an
330/// unauthenticated browser is redirected to the sign-in page). The agent equivalent composes the
331/// two documented primitives — `POST /api/services` (define) then `POST
332/// /api/corpora/<c>/services/<s>` (activate) per corpus.
333/// Renders the "Add a service" form. Shared by the GET page and the POST error path, so a failed
334/// submit (e.g. a name collision) re-renders with a friendly `error` and every typed value
335/// preserved — including which corpora were checked — instead of a bare error page. `svc_*` keys
336/// carry the form values (the page meta `title`/`description` are separate). Admin-only page
337/// (`is_admin`).
338#[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  // Mark every corpus checked/unchecked (the key must always exist so the template's
360  // `corpus.checked` lookup never errors), re-checking the ones the admin had selected.
361  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/// The "Add a service" screen (`GET /services/new`): the full new-service form + a checkbox list of
394/// corpora to activate it on. **Signed-in admins only** (anonymous → sign-in). The agent equivalent
395/// composes `POST /api/services` (define) + `POST /api/corpora/<c>/services/<s>` (activate) per
396/// corpus.
397#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
398#[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  // Sensible defaults for a fresh form.
406  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/// Defines a new service and activates it on each checked corpus — one **background** activation
421/// job per corpus (each creates a TODO task per imported document, so a large corpus can run for a
422/// while; the operator tracks them on `/jobs`). **Gated by the signed-in [`AdminSession`] cookie**
423/// (anonymous → sign-in). Redirects to `/jobs` when any corpus was selected (so the in-flight
424/// registrations are immediately visible), else back to `/services`. `409` if the name already
425/// exists.
426// The Err variant is a re-rendered form `Template` (the friendly-error path), which is chunky —
427// fine for a one-shot request handler.
428#[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    // A failed definition re-renders the form with a friendly message + every value preserved
453    // (including the checked corpora), rather than a bare error page that loses the admin's input.
454    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  // The service is defined; activate it on each selected corpus (a blank value defensively
476  // skipped). Each spawns its own background `service_activate` job, attributed to the signed-in
477  // admin. Best-effort: a failed activation on one corpus doesn't undo the (already-created)
478  // service.
479  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/// Fields of the per-service "Register on a corpus" form: the single corpus (a `<select>` choice
493/// over existing corpora) to activate this already-defined service on.
494#[derive(FromForm)]
495pub struct ActivateOnCorpusForm {
496  /// The corpus to activate this service on.
497  pub corpus: String,
498}
499
500/// The "register an existing service on a corpus" screen: a `<select>` over the corpora this
501/// service is **not yet** activated on (already-activated corpora are excluded — re-activating is
502/// destructive). **Signed-in admins only** (anonymous → sign-in). `404` if the service is unknown.
503#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
504#[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/// Activates an existing `service` on the chosen `corpus` — a **background** `service_activate` job
547/// (the operator tracks it on `/jobs`). **Gated by the signed-in [`AdminSession`] cookie**
548/// (anonymous → sign-in). Redirects to `/jobs`. `404` on an unknown service/corpus. (The agent twin
549/// is `POST /api/corpora/<c>/services/<s>`.)
550#[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/// The service-registry screen (HTML twin of [`api_services`]): the table of registered services,
566/// each linking to its worker-fleet view. **Signed-in admins only** (an unauthenticated browser is
567/// redirected to the sign-in page; the agent twin keeps the token guard). `503` if the pool is
568/// exhausted.
569#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
570#[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/// The worker-fleet status for a service (agent twin of the workers screen): per-worker dispatch/
599/// return tallies and in-flight backlog. `404` if the service is unknown.
600#[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/// The worker-fleet screen (HTML twin): the dispatcher's registered workers for a service and their
614/// activity. **Signed-in admins only** (unauthenticated → sign-in page). `404` if the service is
615/// unknown. Relocated from `bin/frontend.rs` onto the pooled library surface.
616#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
617#[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  // Fleet-health summary so a ~200-worker fleet reads at a glance instead of by scanning every row.
631  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  // Throughput is cumulative (lifetime) and can reach millions on a long-lived corpus, so group the
650  // digits for readability. (Deliberately no "in flight" total here — `dispatched - returned` is a
651  // lifetime gap inflated by reaps/redispatches, not currently-in-flight work; the per-worker table
652  // surfaces the actionable per-worker gap.)
653  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/// Permanently deletes a service **and all of its tasks + log messages across every corpus** — the
684/// destructive twin of [`register_service`], closing the R-6 orphan hazard at the data layer
685/// ([`Service::destroy`]). **Token-gated** via the [`Actor`] guard (an unauthenticated wipe must
686/// not be possible — `401` without a valid token) and double-guarded: the caller must echo the
687/// service name via `?confirm=<service>`. The magic `init`/`import` services are infrastructure and
688/// can never be deleted (`403`). Returns `204` on success, `400` if the confirmation doesn't match,
689/// `403` for a protected service, `404` if unknown.
690#[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  // The magic init (1) / import (2) services are infrastructure — never destroyable.
710  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/// Fields of the human "Delete service" form: the service name echoed as confirmation.
720#[derive(FromForm)]
721pub struct DeleteServiceForm {
722  /// Must equal the service name to confirm the destructive action.
723  pub confirm: String,
724}
725
726/// The human twin of [`delete_service`]: the registry screen's per-service "Delete" form. **Gated
727/// by the signed-in [`AdminSession`] cookie** (anonymous → sign-in) *and* confirmation-gated
728/// (echoes the service name), then redirects back to `/services`. `400` if the confirmation doesn't
729/// match, `403` for a protected service, `404` if unknown.
730#[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  // Guard the magic init/import services (see [`delete_service`]).
747  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/// One conversion's recorded wall-time (from the worker's `Info:runtime_ms:<N>` log line), for
757/// the slowest-conversions table.
758#[derive(Debug, Serialize, schemars::JsonSchema)]
759pub struct RuntimeRowDto {
760  /// Corpus the document belongs to (a service may run on several).
761  pub corpus: String,
762  /// Document short name (paper id) — feed to the document view for forensics.
763  pub paper: String,
764  /// Task id.
765  pub task_id: i64,
766  /// Recorded conversion wall-time in milliseconds.
767  pub runtime_ms: i32,
768}
769
770/// One bar of the runtime-distribution histogram: a millisecond range and how many conversions fell
771/// in it.
772#[derive(Debug, Serialize, schemars::JsonSchema)]
773pub struct RuntimeBucketDto {
774  /// Human label for the range (e.g. `1–2s`).
775  pub label: String,
776  /// Number of conversions whose runtime fell in this range.
777  pub count: i64,
778}
779
780/// The per-service conversion-runtime report: a distribution summary, an aggregate histogram (the
781/// bar chart), and the paginated slowest conversions. Sourced from the worker's
782/// `Info:runtime_ms:<N>` log lines, so it only populates after a run with a runtime-emitting
783/// worker. Heavier than the other service views (it scans `log_infos`), hence its own page.
784#[derive(Debug, Serialize, schemars::JsonSchema)]
785pub struct ServiceRuntimeDto {
786  /// Service name.
787  pub service: String,
788  /// Conversions that recorded a runtime.
789  pub total: i64,
790  /// Mean runtime (ms).
791  pub avg_ms: i32,
792  /// Median (p50) runtime (ms).
793  pub p50_ms: i32,
794  /// p90 runtime (ms).
795  pub p90_ms: i32,
796  /// p99 runtime (ms).
797  pub p99_ms: i32,
798  /// Slowest single runtime (ms).
799  pub max_ms: i32,
800  /// Distribution histogram across fixed ms buckets — the aggregate bar chart.
801  pub histogram: Vec<RuntimeBucketDto>,
802  /// Pagination offset echoed back.
803  pub offset: i64,
804  /// Page size echoed back (the cap actually applied).
805  pub page_size: i64,
806  /// The slowest conversions on this page (descending runtime).
807  pub slowest: Vec<RuntimeRowDto>,
808}
809
810/// Fixed histogram bucket labels for the runtime distribution (log-ish ms ranges). The index
811/// matches the `CASE` bucket computed in [`service_runtime_report`].
812const 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
826/// Builds the [`ServiceRuntimeDto`] for a service from the denormalized `task_runtimes` table (one
827/// validated runtime per task, mirrored from each `Info:runtime_ms:<N>` log line on the finalize
828/// path) — distribution summary + histogram + the paginated slowest conversions. Three index-only
829/// scans over `(service_id, runtime_ms)`.
830fn 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  // All three scans read the denormalized `task_runtimes` table (one validated `runtime_ms` int per
838  // task, with `service_id` inlined), so the per-service filter and aggregates run index-only over
839  // `(service_id, runtime_ms)` — no `log_infos`-to-`tasks` join, no `::int` cast, no `~` guard.
840
841  #[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      // The paper id is the entry's parent-dir name (`…/<paper>/<paper>.zip`).
926      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
951/// Human-readable runtime: `<N> ms` under a second, else `<N.N> s`.
952fn 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/// Conversion-runtime report for a service (agent twin of the runtime screen): distribution summary
961/// + histogram + paginated slowest conversions, from the worker's `runtime_ms` log lines.
962/// **Token-gated** via the [`Actor`] guard (`401` without a token). Paginated
963/// (`offset`/`page_size`, default 100, max `MAX_REPORT_PAGE_SIZE`; `offset` capped at
964/// `MAX_REPORT_OFFSET`); `404` if the service is unknown.
965#[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/// The conversion-runtime screen for a service (HTML twin of [`api_service_runtimes`]): the
987/// aggregate runtime histogram (bar chart) + the paginated slowest conversions, reached from the
988/// worker screen. **Signed-in admins only** (anonymous → sign-in). Separate from the worker view
989/// because it reads the `task_runtimes` rollup. `404` if the service is unknown.
990#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
991#[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  // Histogram rows (reusing the `whats` list slot): each carries a CSS bar width as a percentage of
1008  // the busiest bucket, so the template is a pure-CSS bar chart (no JS).
1009  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      // Colour the bars by speed using the report design tokens: fast buckets (≤2 s) ok, mid
1022      // (2–30 s) warn, slow (≥30 s) fatal — so the distribution's health reads at a glance.
1023      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  // Slowest-conversion rows (reusing the `entries` list slot).
1042  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  // Pagination: prev/next offsets + whether each exists (next exists only if more rows remain).
1076  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
1100/// The route set for the services capability (registry + worker-fleet, screens + agent API).
1101pub fn routes() -> Vec<Route> {
1102  // NB: `api_services` + `api_service_workers` + `register_service` + `delete_service` +
1103  // `set_service_lease` + `api_service_runtimes` are mounted via `frontend::apidoc` (rocket_okapi).
1104  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}