Skip to main content

cortex/frontend/
reports.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//! Reports capability: the typed, paginated agent API for the category and `what` reports — the
9//! agent twin of the most-used human screens (severity-report / category-report).
10//!
11//! Reads straight from the `report_summary` rollup (`crate::backend::{category_rollup, what_rollup,
12//! severity_total, category_total}`): indexed, refreshed on the run-completion path, and already
13//! paginated. This is the structured counterpart to the HTML reports the legacy routes render;
14//! both reflect the same rollup, so humans and agents see the same numbers.
15
16use rocket::http::Status;
17use rocket::response::Redirect;
18use rocket::serde::json::Json;
19use rocket::{Route, State};
20use rocket_dyn_templates::Template;
21use serde::Serialize;
22
23use crate::backend::{
24  DatabaseUrl, DbPool, MessageCounts, ReportSummaryRow, RerunOptions, TaskReportOptions,
25  category_rollup, category_total, from_address, progress_report, severity_total, task_messages,
26  task_report, what_rollup,
27};
28use crate::frontend::actor::{Actor, AdminReject, AdminSession, require_admin};
29use crate::frontend::concerns::{LiveReportLimiter, serve_report};
30use crate::frontend::params::{MAX_REPORT_OFFSET, MAX_REPORT_PAGE_SIZE, ReportParams};
31use crate::helpers::TaskStatus;
32use crate::jobs;
33use crate::models::{Corpus, Service, Task};
34
35/// One status bucket in the service overview: a conversion-status key with its task count and its
36/// share of the valid-task total.
37#[derive(Debug, Serialize, schemars::JsonSchema)]
38pub struct StatusCountDto {
39  /// Status key: `no_problem` | `warning` | `error` | `fatal` | `invalid` | `todo` | `blocked` |
40  /// `queued`.
41  pub status: String,
42  /// Tasks currently in this status.
43  pub tasks: i64,
44  /// Percentage of the valid-task total (invalids excluded from the denominator), 2-dp.
45  pub percent: f64,
46}
47
48/// The service-overview hub (the macro top rung of the report ladder): the `(corpus, service)`
49/// conversion-status breakdown an agent reads first, before drilling into a severity. The `status`
50/// keys double as the `<severity>` path segment for the category report
51/// (`GET /api/reports/<corpus>/<service>/<severity>`).
52#[derive(Debug, Serialize, schemars::JsonSchema)]
53pub struct ServiceOverviewDto {
54  /// Corpus name.
55  pub corpus: String,
56  /// Service name.
57  pub service: String,
58  /// Valid-task total (invalids excluded), the percentage denominator.
59  pub total: i64,
60  /// One bucket per conversion status, in canonical severity order.
61  pub statuses: Vec<StatusCountDto>,
62}
63
64/// One report row: a category (in the category report) or a `what` class (in the drill-down), with
65/// its distinct-task and message counts.
66#[derive(Debug, Serialize, schemars::JsonSchema)]
67pub struct ReportRowDto {
68  /// Category or `what` name (the empty string for uncategorized messages).
69  pub name: String,
70  /// Distinct tasks contributing to this row.
71  pub tasks: i64,
72  /// Total messages for this row.
73  pub messages: i64,
74}
75
76/// The category report for a `(corpus, service, severity)`: one row per category (a page of them),
77/// plus the severity grand totals to compute shares against.
78#[derive(Debug, Serialize, schemars::JsonSchema)]
79pub struct CategoryReportDto {
80  /// The severity reported on.
81  pub severity: String,
82  /// Distinct tasks carrying at least one message of this severity.
83  pub total_tasks: i64,
84  /// Total messages of this severity.
85  pub total_messages: i64,
86  /// The category rows for the requested page.
87  pub categories: Vec<ReportRowDto>,
88}
89
90/// The `what` drill-down for a `(corpus, service, severity, category)`: one row per `what` (a
91/// page), plus the category totals.
92#[derive(Debug, Serialize, schemars::JsonSchema)]
93pub struct WhatReportDto {
94  /// The severity reported on.
95  pub severity: String,
96  /// The category drilled into.
97  pub category: String,
98  /// Distinct tasks in this category.
99  pub total_tasks: i64,
100  /// Total messages in this category.
101  pub total_messages: i64,
102  /// The `what` rows for the requested page.
103  pub whats: Vec<ReportRowDto>,
104}
105
106/// One worker-log message behind a document's status: its severity and the `category`/`what`/
107/// `details` triple parsed from the worker's `cortex.log`.
108#[derive(Debug, Serialize, schemars::JsonSchema)]
109pub struct MessageDto {
110  /// `info` | `warning` | `error` | `fatal` | `invalid`.
111  pub severity: String,
112  /// Mid-level description (open set).
113  pub category: String,
114  /// Low-level description (open set).
115  pub what: String,
116  /// Technical details (e.g. localization info).
117  pub details: String,
118}
119
120/// True per-severity message totals for a document (the real counts behind a possibly-sampled
121/// `messages` list — see [`DocumentReportDto::messages`]).
122#[derive(Debug, Serialize, schemars::JsonSchema)]
123pub struct MessageCountsDto {
124  /// info-level messages
125  pub info: i64,
126  /// warning-level messages
127  pub warning: i64,
128  /// error-level messages
129  pub error: i64,
130  /// fatal-level messages
131  pub fatal: i64,
132  /// invalid-level messages
133  pub invalid: i64,
134  /// total across all severities
135  pub total: i64,
136}
137impl From<MessageCounts> for MessageCountsDto {
138  fn from(counts: MessageCounts) -> Self {
139    MessageCountsDto {
140      info: counts.info,
141      warning: counts.warning,
142      error: counts.error,
143      fatal: counts.fatal,
144      invalid: counts.invalid,
145      total: counts.total(),
146    }
147  }
148}
149
150/// The per-article forensic report (the micro magnification): one document's conversion outcome for
151/// a service, plus the messages behind it — the answer to "what are the errors of this article?".
152#[derive(Debug, Serialize, schemars::JsonSchema)]
153pub struct DocumentReportDto {
154  /// Corpus name.
155  pub corpus: String,
156  /// Service name.
157  pub service: String,
158  /// The document's short name as queried (e.g. `0801.1234`).
159  pub name: String,
160  /// The document's source archive path (`tasks.entry`).
161  pub entry: String,
162  /// The task id for this `(corpus, service, document)`.
163  pub task_id: i64,
164  /// Conversion status key: `no_problem` | `warning` | `error` | `fatal` | `invalid` | `todo` | …
165  pub status: String,
166  /// The raw signed status code (see `helpers::TaskStatus`).
167  pub status_code: i32,
168  /// The document's messages, info → invalid — **sampled**: at most
169  /// `backend::DOCUMENT_MESSAGE_CAP` per severity, so a pathological document (millions of
170  /// messages) can't blow up the response. Use `message_counts` for the real magnitude and
171  /// `messages_truncated` to know it was capped.
172  pub messages: Vec<MessageDto>,
173  /// The true per-severity message totals (before the sampling cap on `messages`).
174  pub message_counts: MessageCountsDto,
175  /// `true` when `messages` was capped (the document has more messages than are listed).
176  pub messages_truncated: bool,
177  /// Path to download the converted result archive.
178  pub result_url: String,
179  /// Path to the human preview page.
180  pub preview_url: String,
181}
182
183/// Severities the rollup aggregates over (the four message severities plus the all-messages `info`
184/// dimension). Anything else is a `400` rather than a silently-empty report.
185fn is_rollup_severity(severity: &str) -> bool {
186  matches!(severity, "warning" | "error" | "fatal" | "invalid" | "info")
187}
188
189/// Severities valid for a **rerun** filter — which differs from the report rollup, because rerun
190/// filters *tasks* (or their messages), it doesn't aggregate a report (R-9). With a `category` the
191/// scope is over log messages (`warning`/`error`/`fatal`/`invalid` + the all-messages `info`);
192/// without one it is over task *status* (`no_problem`/`warning`/`error`/`fatal`/`invalid` —
193/// `no_problem` IS a legitimate rerun scope, e.g. regenerating output after a worker upgrade, and
194/// `info` is not a task status). One shared validator for the agent (`rerun_report`) and human
195/// (`serve_rerun`) surfaces, so both reject the same typos instead of silently mis-scoping to
196/// `no_problem`.
197pub fn is_valid_rerun_severity(severity: &str, has_category: bool) -> bool {
198  if has_category {
199    matches!(severity, "warning" | "error" | "fatal" | "invalid" | "info")
200  } else {
201    matches!(
202      severity,
203      "no_problem" | "warning" | "error" | "fatal" | "invalid"
204    )
205  }
206}
207
208/// Resolves a `(corpus, service)` name pair, mapping each miss to `404`.
209fn resolve(
210  corpus: &str,
211  service: &str,
212  connection: &mut diesel::PgConnection,
213) -> Result<(Corpus, Service), Status> {
214  let corpus = Corpus::find_by_name(corpus, connection).map_err(|_| Status::NotFound)?;
215  let service = Service::find_by_name(service, connection).map_err(|_| Status::NotFound)?;
216  Ok((corpus, service))
217}
218
219/// Grand totals (distinct tasks, messages) from an optional rollup total row.
220fn totals(row: Option<ReportSummaryRow>) -> (i64, i64) {
221  row.map_or((0, 0), |total| (total.task_count, total.message_count))
222}
223
224/// The service-overview hub (agent twin of the top report screen): the `(corpus, service)`
225/// conversion-status breakdown — total + per-status counts/percentages — the macro entry point an
226/// agent reads before drilling into a severity. Shares `backend::progress_report` with the HTML top
227/// screen, so the numbers match. `404` on an unknown corpus/service.
228#[rocket_okapi::openapi(tag = "Reports")]
229#[get("/api/reports/<corpus>/<service>")]
230pub fn api_service_overview(
231  corpus: &str,
232  service: &str,
233  pool: &State<DbPool>,
234) -> Result<Json<ServiceOverviewDto>, Status> {
235  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
236  let (corpus, service) = resolve(corpus, service, &mut connection)?;
237  let stats = progress_report(&mut connection, corpus.id, service.id);
238  let statuses = TaskStatus::keys()
239    .into_iter()
240    .map(|status| {
241      let percent = stats
242        .get(&format!("{status}_percent"))
243        .copied()
244        .unwrap_or(0.0);
245      let tasks = stats.get(&status).copied().unwrap_or(0.0) as i64;
246      StatusCountDto {
247        status,
248        tasks,
249        percent,
250      }
251    })
252    .collect();
253  Ok(Json(ServiceOverviewDto {
254    corpus: corpus.name,
255    service: service.name,
256    total: stats.get("total").copied().unwrap_or(0.0) as i64,
257    statuses,
258  }))
259}
260
261/// The category report (agent twin of the severity screen): one row per category, descending by
262/// task count, paginated. `400` on an unknown severity, `404` on an unknown corpus/service.
263#[rocket_okapi::openapi(tag = "Reports")]
264#[get("/api/reports/<corpus>/<service>/<severity>?<offset>&<page_size>")]
265pub fn api_category_report(
266  corpus: &str,
267  service: &str,
268  severity: &str,
269  offset: Option<i64>,
270  page_size: Option<i64>,
271  pool: &State<DbPool>,
272) -> Result<Json<CategoryReportDto>, Status> {
273  if !is_rollup_severity(severity) {
274    return Err(Status::BadRequest);
275  }
276  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
277  let (corpus, service) = resolve(corpus, service, &mut connection)?;
278  let limit = page_size.unwrap_or(100).clamp(1, MAX_REPORT_PAGE_SIZE);
279  let offset = offset.unwrap_or(0).clamp(0, MAX_REPORT_OFFSET);
280  let categories = category_rollup(
281    &mut connection,
282    corpus.id,
283    service.id,
284    severity,
285    limit,
286    offset,
287  )
288  .unwrap_or_default()
289  .into_iter()
290  .map(|row| ReportRowDto {
291    name: row.category,
292    tasks: row.task_count,
293    messages: row.message_count,
294  })
295  .collect();
296  let (total_tasks, total_messages) =
297    totals(severity_total(&mut connection, corpus.id, service.id, severity).unwrap_or_default());
298  Ok(Json(CategoryReportDto {
299    severity: severity.to_string(),
300    total_tasks,
301    total_messages,
302    categories,
303  }))
304}
305
306/// The `what` drill-down (agent twin of the category screen): one row per `what` within a category,
307/// descending by task count, paginated. `400` on an unknown severity, `404` on an unknown
308/// corpus/service.
309#[rocket_okapi::openapi(tag = "Reports")]
310#[get("/api/reports/<corpus>/<service>/<severity>/<category>?<offset>&<page_size>")]
311pub fn api_what_report(
312  corpus: &str,
313  service: &str,
314  severity: &str,
315  category: &str,
316  offset: Option<i64>,
317  page_size: Option<i64>,
318  pool: &State<DbPool>,
319) -> Result<Json<WhatReportDto>, Status> {
320  if !is_rollup_severity(severity) {
321    return Err(Status::BadRequest);
322  }
323  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
324  let (corpus, service) = resolve(corpus, service, &mut connection)?;
325  let limit = page_size.unwrap_or(100).clamp(1, MAX_REPORT_PAGE_SIZE);
326  let offset = offset.unwrap_or(0).clamp(0, MAX_REPORT_OFFSET);
327  let whats = what_rollup(
328    &mut connection,
329    corpus.id,
330    service.id,
331    severity,
332    category,
333    limit,
334    offset,
335  )
336  .unwrap_or_default()
337  .into_iter()
338  .map(|row| ReportRowDto {
339    name: row.what.unwrap_or_default(),
340    tasks: row.task_count,
341    messages: row.message_count,
342  })
343  .collect();
344  let (total_tasks, total_messages) = totals(
345    category_total(&mut connection, corpus.id, service.id, severity, category).unwrap_or_default(),
346  );
347  Ok(Json(WhatReportDto {
348    severity: severity.to_string(),
349    category: category.to_string(),
350    total_tasks,
351    total_messages,
352    whats,
353  }))
354}
355
356/// One affected document in the entry list: its short name (paper id), task id, and the message
357/// detail that placed it under the queried `(severity, category, what)`.
358#[derive(Debug, Serialize, schemars::JsonSchema)]
359pub struct EntryRowDto {
360  /// The document's short name (e.g. `0801.1234`) — feed it to
361  /// `GET /api/corpus/<c>/<svc>/document/<name>` for per-article forensics.
362  pub name: String,
363  /// The task id for this document under this service.
364  pub task_id: i64,
365  /// Technical detail of this entry's message for the queried `what` (e.g. localization context).
366  pub details: String,
367}
368
369/// The deepest report rung: the **paginated list of documents** (paper ids) affected by a specific
370/// `(severity, category, what)`. The agent twin of the entry-list screen and the bridge from the
371/// macro `what`-breakdown counts to per-article forensics — "*which* papers have this issue?", so
372/// an agent can enumerate and then drill into each via the document endpoint. Page with
373/// `offset`/`page_size` (default 100, max 1000).
374#[derive(Debug, Serialize, schemars::JsonSchema)]
375pub struct EntryListDto {
376  /// Corpus name.
377  pub corpus: String,
378  /// Service name.
379  pub service: String,
380  /// The severity reported on.
381  pub severity: String,
382  /// The category drilled into.
383  pub category: String,
384  /// The `what` drilled into.
385  pub what: String,
386  /// Pagination offset echoed back.
387  pub offset: i64,
388  /// Page size echoed back (the cap actually applied).
389  pub page_size: i64,
390  /// The affected documents for this page.
391  pub entries: Vec<EntryRowDto>,
392}
393
394/// Lists the documents affected by a `(corpus, service, severity, category, what)` — the agent twin
395/// of the deepest report screen (the entry list). Paginated (`offset`/`page_size`, default 100, max
396/// `MAX_REPORT_PAGE_SIZE`); `offset` is capped at `MAX_REPORT_OFFSET` (a `400` beyond it — see
397/// P-4). `400` on an unknown severity, `404` on an unknown corpus/service.
398#[rocket_okapi::openapi(tag = "Reports")]
399#[get("/api/reports/<corpus>/<service>/<severity>/<category>/<what>?<offset>&<page_size>")]
400#[allow(clippy::too_many_arguments)]
401pub fn api_entry_list(
402  corpus: &str,
403  service: &str,
404  severity: &str,
405  category: &str,
406  what: &str,
407  offset: Option<i64>,
408  page_size: Option<i64>,
409  pool: &State<DbPool>,
410) -> Result<Json<EntryListDto>, Status> {
411  if !is_rollup_severity(severity) {
412    return Err(Status::BadRequest);
413  }
414  let offset = offset.unwrap_or(0).max(0);
415  // Bound the paginate depth: a deep `OFFSET` is a multi-second scan-and-discard that pins a pooled
416  // connection — so reject it on this scriptable surface rather than risk pool saturation (P-4).
417  if offset > MAX_REPORT_OFFSET {
418    return Err(Status::BadRequest);
419  }
420  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
421  let (corpus_record, service_record) = resolve(corpus, service, &mut connection)?;
422  // Clamp the page so a `?page_size=0` / huge value can't request an unbounded or empty scan.
423  let page_size = page_size.unwrap_or(100).clamp(1, MAX_REPORT_PAGE_SIZE);
424  // The entry list is per-task (not a rollup grain), so it comes from the live report path — the
425  // same one the human entry-list screen renders, paginated identically.
426  let rows = task_report(
427    &mut connection,
428    TaskReportOptions {
429      corpus: &corpus_record,
430      service: &service_record,
431      severity_opt: Some(severity.to_string()),
432      category_opt: Some(category.to_string()),
433      what_opt: Some(what.to_string()),
434      all_messages: false,
435      offset,
436      page_size,
437    },
438  );
439  let entries = rows
440    .iter()
441    .map(|row| EntryRowDto {
442      name: row.get("entry_name").cloned().unwrap_or_default(),
443      task_id: row
444        .get("entry_taskid")
445        .and_then(|id| id.parse().ok())
446        .unwrap_or(0),
447      details: row.get("details").cloned().unwrap_or_default(),
448    })
449    .collect();
450  Ok(Json(EntryListDto {
451    corpus: corpus_record.name,
452    service: service_record.name,
453    severity: severity.to_string(),
454    category: category.to_string(),
455    what: what.to_string(),
456    offset,
457    page_size,
458    entries,
459  }))
460}
461
462/// Builds the [`DocumentReportDto`] for one `(corpus, service, document-name)` — the shared backend
463/// of the agent endpoint and the human forensic screen, so both show identical status + messages.
464/// `404` on an unknown corpus / service / document.
465fn document_report(
466  corpus: &str,
467  service: &str,
468  name: &str,
469  connection: &mut diesel::PgConnection,
470) -> Result<DocumentReportDto, Status> {
471  let (corpus, service) = resolve(corpus, service, connection)?;
472  let task =
473    Task::find_by_name(name, &corpus, &service, connection).map_err(|_| Status::NotFound)?;
474  let status = TaskStatus::from_raw(task.status);
475  let (records, counts) = task_messages(connection, &task);
476  let messages: Vec<MessageDto> = records
477    .iter()
478    .map(|message| MessageDto {
479      severity: message.severity().to_string(),
480      category: message.category().to_string(),
481      what: message.what().to_string(),
482      details: message.details().to_string(),
483    })
484    .collect();
485  let messages_truncated = (messages.len() as i64) < counts.total();
486  Ok(DocumentReportDto {
487    corpus: corpus.name.clone(),
488    service: service.name.clone(),
489    name: name.to_string(),
490    entry: task.entry.trim_end().to_string(),
491    task_id: task.id,
492    status: status.to_key(),
493    status_code: status.raw(),
494    messages,
495    message_counts: counts.into(),
496    messages_truncated,
497    result_url: format!("/entry/{}/{}", service.name, task.id),
498    preview_url: format!("/preview/{}/{}/{}", corpus.name, service.name, name),
499  })
500}
501
502/// The per-article forensic report (agent micro-drill-down): a single document's status for a
503/// service plus every worker-log message behind it — "what are the errors of this article?".
504/// `<name>` is the document's short name as it appears in reports (e.g. `0801.1234`). `404` on an
505/// unknown corpus / service / document.
506#[rocket_okapi::openapi(tag = "Reports")]
507#[get("/api/corpus/<corpus>/<service>/document/<name>")]
508pub fn api_document(
509  corpus: &str,
510  service: &str,
511  name: &str,
512  pool: &State<DbPool>,
513) -> Result<Json<DocumentReportDto>, Status> {
514  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
515  Ok(Json(document_report(
516    corpus,
517    service,
518    name,
519    &mut connection,
520  )?))
521}
522
523/// The per-article forensic **screen** (HTML twin of [`api_document`]): a document's status and the
524/// table of every worker-log message behind it. The fast structured view of "what are the errors of
525/// this article?" — straight from the parsed `log_*` rows (no result-archive fetch), complementing
526/// the rendered `/preview`. `404` on an unknown corpus / service / document. Lives at a top-level
527/// `/document/...` path (a sibling of `/preview/...`) so it never collides with the same-shape
528/// severity/category report route.
529#[get("/document/<corpus>/<service>/<name>")]
530pub fn document_report_page(
531  corpus: &str,
532  service: &str,
533  name: &str,
534  pool: &State<DbPool>,
535) -> Result<(Status, Template), Status> {
536  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
537  match document_report(corpus, service, name, &mut connection) {
538    Ok(report) => {
539      let global = serde_json::json!({
540        "title": format!("{} — {}/{}", report.name, report.corpus, report.service),
541        "description": "Per-article conversion forensics",
542      });
543      Ok((
544        Status::Ok,
545        Template::render(
546          "document-report",
547          rocket_dyn_templates::context! { global, report },
548        ),
549      ))
550    },
551    // A human looking up an article by id should get a clear "not found" with a way back to the
552    // report (and its search box), not the bare 404 catcher. (The agent twin keeps its plain 404.)
553    Err(status) if status == Status::NotFound => {
554      let message =
555        format!("No article “{name}” in {corpus} / {service}. Check the paper id and try again.");
556      let global = serde_json::json!({
557        "title": format!("404 · {message}"),
558        "description": message.clone(),
559      });
560      Ok((
561        Status::NotFound,
562        Template::render(
563          "error",
564          rocket_dyn_templates::context! {
565            global,
566            status: 404,
567            message,
568            back_url: format!("/corpus/{corpus}/{service}"),
569            back_label: format!("Back to the {service} report"),
570          },
571        ),
572      ))
573    },
574    Err(status) => Err(status),
575  }
576}
577
578/// Query-style entry to the per-article forensic screen: `GET
579/// /document/<corpus>/<service>?name=<id>` → **303** to the canonical path URL
580/// `/document/<corpus>/<service>/<id>`. This is the **no-JS fallback** target for the
581/// service-overview "look up an article" form (the JS enhancement navigates to the path URL
582/// directly), so a human can jump to one article's forensics by id without drilling the report
583/// ladder — even with scripting off. `400` on a blank `name`; the document itself is validated at
584/// the path route (`404` there if unknown).
585#[get("/document/<corpus>/<service>?<name>")]
586pub fn document_lookup_redirect(
587  corpus: &str,
588  service: &str,
589  name: Option<&str>,
590) -> Result<Redirect, Status> {
591  let trimmed = name.unwrap_or("").trim();
592  if trimmed.is_empty() {
593    return Err(Status::BadRequest);
594  }
595  Ok(Redirect::to(uri!(document_report_page(
596    corpus = corpus,
597    service = service,
598    name = trimmed
599  ))))
600}
601
602/// Acknowledgement of a rerun: the scope that was marked and who marked it.
603#[derive(Debug, Serialize, schemars::JsonSchema)]
604pub struct RerunAckDto {
605  /// Corpus the rerun targeted.
606  pub corpus: String,
607  /// Service the rerun targeted.
608  pub service: String,
609  /// The authenticated initiator (the run's `owner`).
610  pub actor: String,
611  /// The recorded run description.
612  pub description: String,
613}
614
615/// Marks the selected `(corpus, service[, severity, category, what])` scope for reprocessing — the
616/// agent twin of the report screen's rerun action, and a new historical run. **Token-gated** via
617/// the [`Actor`] guard (`X-Cortex-Token` header or `?token=`); `401` without a valid token, so
618/// results can't be wiped by an unauthenticated caller. `400` on an unknown severity, `404` on an
619/// unknown corpus/service. Returns `202 Accepted`.
620#[rocket_okapi::openapi(tag = "Reports")]
621#[post("/api/reports/<corpus>/<service>/rerun?<severity>&<category>&<what>&<description>")]
622#[allow(clippy::too_many_arguments)]
623pub fn rerun_report(
624  corpus: &str,
625  service: &str,
626  severity: Option<&str>,
627  category: Option<&str>,
628  what: Option<&str>,
629  description: Option<&str>,
630  actor: Actor,
631  database_url: &State<DatabaseUrl>,
632  _pool: &State<DbPool>,
633) -> Result<(Status, Json<RerunAckDto>), Status> {
634  if let Some(severity) = severity
635    && !is_valid_rerun_severity(severity, category.is_some())
636  {
637    return Err(Status::BadRequest);
638  }
639  let description = description.unwrap_or("rerun via API").to_string();
640  // A fresh connection for this low-frequency, consequential admin action (mirrors the legacy
641  // path).
642  let mut backend = from_address(&database_url.0);
643  let corpus_record =
644    Corpus::find_by_name(corpus, &mut backend.connection).map_err(|_| Status::NotFound)?;
645  let service_record =
646    Service::find_by_name(service, &mut backend.connection).map_err(|_| Status::NotFound)?;
647  backend
648    .mark_rerun(RerunOptions {
649      corpus: &corpus_record,
650      service: &service_record,
651      severity_opt: severity.map(str::to_string),
652      category_opt: category.map(str::to_string),
653      what_opt: what.map(str::to_string),
654      description_opt: Some(description.clone()),
655      owner_opt: Some(actor.owner.clone()),
656    })
657    .map_err(|_| Status::InternalServerError)?;
658  tracing::info!(actor = %actor.owner, corpus, service, severity = ?severity, category = ?category, what = ?what, "rerun via API");
659  // The reran (corpus, service) scope's report cache was already invalidated inside the rerun
660  // transaction (`mark_rerun`), so its reports repopulate fresh on the next view — no separate,
661  // globally-scoped refresh job needed.
662  Ok((
663    Status::Accepted,
664    Json(RerunAckDto {
665      corpus: corpus.to_string(),
666      service: service.to_string(),
667      actor: actor.owner,
668      description,
669    }),
670  ))
671}
672
673/// Result of a pause/resume run-control action (the agent twin of the report screen's Pause/Resume
674/// buttons).
675#[derive(Serialize, schemars::JsonSchema)]
676pub struct RunControlDto {
677  /// The corpus acted on.
678  pub corpus: String,
679  /// The service acted on.
680  pub service: String,
681  /// `pause` or `resume`.
682  pub action: String,
683  /// Tasks transitioned — blocked (on pause) or returned to TODO (on resume).
684  pub affected: usize,
685  /// The token-resolved actor recorded as the initiator.
686  pub actor: String,
687}
688
689/// Shared core of the agent pause/resume twins: gate via the token-resolved `owner`, run the shared
690/// [`crate::frontend::concerns::serve_pause_resume`], wrap the count in a [`RunControlDto`].
691fn api_run_control(
692  corpus: &str,
693  service: &str,
694  owner: &str,
695  pause: bool,
696  pool: &State<DbPool>,
697) -> Result<Json<RunControlDto>, Status> {
698  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
699  let affected =
700    crate::frontend::concerns::serve_pause_resume(&mut connection, corpus, service, owner, pause)?;
701  Ok(Json(RunControlDto {
702    corpus: corpus.to_string(),
703    service: service.to_string(),
704    action: if pause { "pause" } else { "resume" }.to_string(),
705    affected,
706    actor: owner.to_string(),
707  }))
708}
709
710/// **Pause a run** — block every in-progress task (`status >= 0`) of a `(corpus, service)` so the
711/// dispatcher stops leasing them. The agent twin of the report screen's "Pause run" button.
712/// **Token-gated** via the [`Actor`] guard; `404` on an unknown corpus/service. Returns the count
713/// blocked. Reversible with the resume twin.
714#[rocket_okapi::openapi(tag = "Reports")]
715#[post("/api/reports/<corpus>/<service>/pause")]
716pub fn pause_run_api(
717  corpus: &str,
718  service: &str,
719  actor: Actor,
720  pool: &State<DbPool>,
721) -> Result<Json<RunControlDto>, Status> {
722  api_run_control(corpus, service, &actor.owner, true, pool)
723}
724
725/// **Resume a run** — return every Blocked task (`status < -5`) of a `(corpus, service)` to TODO so
726/// the dispatcher picks them up again. The agent twin of the report screen's "Resume run" button.
727/// **Token-gated** via the [`Actor`] guard; `404` on an unknown corpus/service. Returns the count
728/// resumed.
729#[rocket_okapi::openapi(tag = "Reports")]
730#[post("/api/reports/<corpus>/<service>/resume")]
731pub fn resume_run_api(
732  corpus: &str,
733  service: &str,
734  actor: Actor,
735  pool: &State<DbPool>,
736) -> Result<Json<RunControlDto>, Status> {
737  api_run_control(corpus, service, &actor.owner, false, pool)
738}
739
740/// Acknowledgement for a global pause/resume-all conversion control.
741#[derive(Serialize, schemars::JsonSchema)]
742pub struct GlobalRunControlDto {
743  /// `pause` or `resume`.
744  pub action: String,
745  /// Number of tasks moved (blocked, or returned to TODO).
746  pub affected: usize,
747  /// The token-resolved actor recorded as the initiator.
748  pub actor: String,
749}
750
751fn api_run_control_all(
752  owner: &str,
753  pause: bool,
754  pool: &State<DbPool>,
755) -> Result<Json<GlobalRunControlDto>, Status> {
756  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
757  let affected = crate::frontend::concerns::serve_pause_resume_all(&mut connection, owner, pause)?;
758  Ok(Json(GlobalRunControlDto {
759    action: if pause { "pause" } else { "resume" }.to_string(),
760    affected,
761    actor: owner.to_string(),
762  }))
763}
764
765/// **Pause all conversions** — block every in-progress task fleet-wide so the dispatcher stops
766/// leasing new work everywhere. The agent twin of the dashboard's "Pause all conversions".
767/// **Token-gated**; returns the count blocked. Reversible with the resume twin.
768#[rocket_okapi::openapi(tag = "Reports")]
769#[post("/api/conversions/pause")]
770pub fn pause_all_api(
771  actor: Actor,
772  pool: &State<DbPool>,
773) -> Result<Json<GlobalRunControlDto>, Status> {
774  api_run_control_all(&actor.owner, true, pool)
775}
776
777/// **Resume all conversions** — return every Blocked task fleet-wide to TODO. The agent twin of the
778/// dashboard's "Resume all conversions". **Token-gated**; returns the count resumed.
779#[rocket_okapi::openapi(tag = "Reports")]
780#[post("/api/conversions/resume")]
781pub fn resume_all_api(
782  actor: Actor,
783  pool: &State<DbPool>,
784) -> Result<Json<GlobalRunControlDto>, Status> {
785  api_run_control_all(&actor.owner, false, pool)
786}
787
788/// Acknowledgement for a forced report-rollup refresh: the background [`crate::jobs`] handle to
789/// poll.
790#[derive(Serialize, schemars::JsonSchema)]
791pub struct RefreshAckDto {
792  /// The spawned (or already-running, if debounced) refresh job's external uuid.
793  pub job: String,
794  /// Where to poll the job's status / health / duration.
795  pub poll: String,
796  /// The token-resolved actor recorded as the job's initiator.
797  pub actor: String,
798}
799
800/// Forces a rebuild of the `report_summary` rollup that backs **every** report page, as a
801/// background job — the rebuild is multi-minute at production scale, so it must not block the
802/// request (see `docs/archive/REPORT_FRESHNESS.md`). Returns the job handle immediately (`202
803/// Accepted`); poll `GET /api/jobs/<job>` for status/health. **Debounced:** a refresh already in
804/// flight is reused rather than piled on. **Token-gated** via the [`Actor`] guard (`X-Cortex-Token`
805/// / `?token=`); `401` without a valid token.
806#[rocket_okapi::openapi(tag = "Reports")]
807#[post("/api/reports/refresh")]
808pub fn refresh_reports(
809  actor: Actor,
810  pool: &State<DbPool>,
811) -> Result<(Status, Json<RefreshAckDto>), Status> {
812  let job_uuid = jobs::spawn_report_refresh(pool.inner().clone(), &actor.owner)
813    .map_err(|_| Status::InternalServerError)?;
814  Ok((
815    Status::Accepted,
816    Json(RefreshAckDto {
817      job: job_uuid.to_string(),
818      poll: format!("/api/jobs/{job_uuid}"),
819      actor: actor.owner,
820    }),
821  ))
822}
823
824/// The human twin of [`refresh_reports`]: the jobs-dashboard "Refresh reports now" button. **Gated
825/// by the signed-in [`AdminSession`] cookie** (the jobs dashboard is signed-in-only; anonymous →
826/// sign-in), spawns the same debounced refresh job, and redirects to `/jobs` where the admin
827/// watches it run — the async UI pattern (no blocking, no JS).
828#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
829#[post("/reports/refresh")]
830pub fn refresh_reports_human(
831  session: Option<AdminSession>,
832  pool: &State<DbPool>,
833) -> Result<rocket::response::Redirect, AdminReject> {
834  let session = require_admin(session)?;
835  let uuid = jobs::spawn_report_refresh(pool.inner().clone(), &session.owner)
836    .map_err(|_| Status::InternalServerError)?;
837  Ok(rocket::response::Redirect::to(format!("/jobs/{uuid}")))
838}
839
840/// Report-footer **"Refresh this report"**: drop the cached report grains for *this*
841/// `(corpus, service)` scope so the next view recomputes them from current data. Scoped (not the
842/// global all-corpora bust [`refresh_reports`] does) and **synchronous** — busting
843/// `report_grain_cache` is an instant keyed `DELETE`, so there is no background job to poll; the
844/// caller just reloads. **Gated by the signed-in [`AdminSession`] cookie** (the footer shows the
845/// button only to admins; a missing session is a clean `401` for the XHR rather than an HTML
846/// redirect). `404` on an unknown corpus/service, `204` on success. Agent twin:
847/// [`refresh_report_scope_api`].
848#[post("/corpus/<corpus_name>/<service_name>/refresh")]
849pub fn refresh_report_scope(
850  corpus_name: String,
851  service_name: String,
852  session: Option<AdminSession>,
853  pool: &State<DbPool>,
854) -> Result<Status, Status> {
855  session.ok_or(Status::Unauthorized)?;
856  let mut connection = pooled(pool)?;
857  let corpus = Corpus::find_by_name(&corpus_name.to_lowercase(), &mut connection)
858    .map_err(|_| Status::NotFound)?;
859  let service = Service::find_by_name(&service_name.to_lowercase(), &mut connection)
860    .map_err(|_| Status::NotFound)?;
861  crate::backend::invalidate_scope(&mut connection, corpus.id, service.id)
862    .map_err(|_| Status::InternalServerError)?;
863  Ok(Status::NoContent)
864}
865
866/// Acknowledgement of a scoped report-cache refresh: the scope that was busted and who busted it.
867#[derive(Debug, Serialize, schemars::JsonSchema)]
868pub struct ScopeRefreshAckDto {
869  /// Corpus whose cached report grains were dropped.
870  pub corpus: String,
871  /// Service whose cached report grains were dropped.
872  pub service: String,
873  /// The token-resolved actor recorded as the initiator.
874  pub actor: String,
875}
876
877/// Busts *this* `(corpus, service)`'s cached report grains — the **agent twin** of the report
878/// footer's "Refresh this report" action ([`refresh_report_scope`]) — so its reports recompute from
879/// current data on the next view (a heavy slice recomputes off the request path; see
880/// [`crate::frontend::concerns::serve_report`]). Scoped, unlike the global bust [`refresh_reports`]
881/// does. The bust is an instant keyed `DELETE`, so this returns `200 OK` immediately — there is no
882/// background job to poll. **Token-gated** via the [`Actor`] guard (`X-Cortex-Token` header or
883/// `?token=`); `401` without a valid token, `404` on an unknown corpus/service.
884#[rocket_okapi::openapi(tag = "Reports")]
885#[post("/api/reports/<corpus>/<service>/refresh")]
886pub fn refresh_report_scope_api(
887  corpus: &str,
888  service: &str,
889  actor: Actor,
890  pool: &State<DbPool>,
891) -> Result<Json<ScopeRefreshAckDto>, Status> {
892  let mut connection = pooled(pool)?;
893  let corpus_record =
894    Corpus::find_by_name(&corpus.to_lowercase(), &mut connection).map_err(|_| Status::NotFound)?;
895  let service_record = Service::find_by_name(&service.to_lowercase(), &mut connection)
896    .map_err(|_| Status::NotFound)?;
897  crate::backend::invalidate_scope(&mut connection, corpus_record.id, service_record.id)
898    .map_err(|_| Status::InternalServerError)?;
899  tracing::info!(actor = %actor.owner, corpus, service, "scoped report refresh via API");
900  Ok(Json(ScopeRefreshAckDto {
901    corpus: corpus_record.name,
902    service: service_record.name,
903    actor: actor.owner,
904  }))
905}
906
907// --- The human report screens (HTML twins of the typed report API above) -----------------------
908//
909// These render the corpus/service report hierarchy (top → severity → category → `what` → task
910// list) via the shared [`serve_report`] controller, now reading over a **pooled** connection
911// instead of the prototype per-request `Backend::default()`. Relocated from `bin/frontend.rs` onto
912// the library surface so they are testable and share the pool with the agent API. `404` on an
913// unknown corpus/service; `503` if the pool is exhausted.
914
915/// Checks out a pooled connection, mapping exhaustion to `503`.
916fn pooled(pool: &State<DbPool>) -> Result<crate::backend::PooledConn, Status> {
917  pool.get().map_err(|_| Status::ServiceUnavailable)
918}
919
920/// Top-level corpus/service report (overall progress).
921#[get("/corpus/<corpus_name>/<service_name>")]
922pub fn top_service_report(
923  corpus_name: String,
924  service_name: String,
925  session: Option<AdminSession>,
926  pool: &State<DbPool>,
927) -> Result<Template, Status> {
928  let mut connection = pooled(pool)?;
929  serve_report(
930    &mut connection,
931    pool.inner(),
932    corpus_name,
933    service_name,
934    None,
935    None,
936    None,
937    None,
938    session.is_some(),
939  )
940}
941
942/// Severity-level report: the categories carrying messages of `severity`.
943#[get("/corpus/<corpus_name>/<service_name>/<severity>")]
944pub fn severity_service_report(
945  corpus_name: String,
946  service_name: String,
947  severity: String,
948  session: Option<AdminSession>,
949  pool: &State<DbPool>,
950) -> Result<Template, Status> {
951  let mut connection = pooled(pool)?;
952  serve_report(
953    &mut connection,
954    pool.inner(),
955    corpus_name,
956    service_name,
957    Some(severity),
958    None,
959    None,
960    None,
961    session.is_some(),
962  )
963}
964
965/// Severity-level report with paging/all-messages query params.
966#[allow(clippy::too_many_arguments)]
967#[get("/corpus/<corpus_name>/<service_name>/<severity>?<params..>")]
968pub async fn severity_service_report_all(
969  corpus_name: String,
970  service_name: String,
971  severity: String,
972  params: Option<ReportParams>,
973  session: Option<AdminSession>,
974  limiter: &State<LiveReportLimiter>,
975  pool: &State<DbPool>,
976) -> Result<Template, Status> {
977  // Gate the expensive live `?all=true` aggregation: acquire a permit BEFORE checking out a pooled
978  // connection so a burst can't pin the pool and 503 others (P-2). Paged/non-all requests through
979  // this route are rollup-fast and skip the permit.
980  let _permit = match params.as_ref().and_then(|p| p.all) {
981    Some(true) => Some(limiter.acquire().await?),
982    _ => None,
983  };
984  let mut connection = pooled(pool)?;
985  serve_report(
986    &mut connection,
987    pool.inner(),
988    corpus_name,
989    service_name,
990    Some(severity),
991    None,
992    None,
993    params,
994    session.is_some(),
995  )
996}
997
998/// Category-level report: the `what` classes within a `(severity, category)`.
999#[get("/corpus/<corpus_name>/<service_name>/<severity>/<category>")]
1000pub fn category_service_report(
1001  corpus_name: String,
1002  service_name: String,
1003  severity: String,
1004  category: String,
1005  session: Option<AdminSession>,
1006  pool: &State<DbPool>,
1007) -> Result<Template, Status> {
1008  let mut connection = pooled(pool)?;
1009  serve_report(
1010    &mut connection,
1011    pool.inner(),
1012    corpus_name,
1013    service_name,
1014    Some(severity),
1015    Some(category),
1016    None,
1017    None,
1018    session.is_some(),
1019  )
1020}
1021
1022/// Category-level report with paging/all-messages query params.
1023#[allow(clippy::too_many_arguments)]
1024#[get("/corpus/<corpus_name>/<service_name>/<severity>/<category>?<params..>")]
1025pub async fn category_service_report_all(
1026  corpus_name: String,
1027  service_name: String,
1028  severity: String,
1029  category: String,
1030  params: Option<ReportParams>,
1031  session: Option<AdminSession>,
1032  limiter: &State<LiveReportLimiter>,
1033  pool: &State<DbPool>,
1034) -> Result<Template, Status> {
1035  // Gate the expensive live `?all=true` aggregation behind the limiter (P-2); see the severity
1036  // variant above. Cheap paged requests skip the permit.
1037  let _permit = match params.as_ref().and_then(|p| p.all) {
1038    Some(true) => Some(limiter.acquire().await?),
1039    _ => None,
1040  };
1041  let mut connection = pooled(pool)?;
1042  serve_report(
1043    &mut connection,
1044    pool.inner(),
1045    corpus_name,
1046    service_name,
1047    Some(severity),
1048    Some(category),
1049    None,
1050    params,
1051    session.is_some(),
1052  )
1053}
1054
1055/// `what`-level report: the task list for a `(severity, category, what)`.
1056#[get("/corpus/<corpus_name>/<service_name>/<severity>/<category>/<what>")]
1057pub fn what_service_report(
1058  corpus_name: String,
1059  service_name: String,
1060  severity: String,
1061  category: String,
1062  what: String,
1063  session: Option<AdminSession>,
1064  pool: &State<DbPool>,
1065) -> Result<Template, Status> {
1066  let mut connection = pooled(pool)?;
1067  serve_report(
1068    &mut connection,
1069    pool.inner(),
1070    corpus_name,
1071    service_name,
1072    Some(severity),
1073    Some(category),
1074    Some(what),
1075    None,
1076    session.is_some(),
1077  )
1078}
1079
1080/// `what`-level report with paging/all-messages query params.
1081#[allow(clippy::too_many_arguments)]
1082#[get("/corpus/<corpus_name>/<service_name>/<severity>/<category>/<what>?<params..>")]
1083pub fn what_service_report_all(
1084  corpus_name: String,
1085  service_name: String,
1086  severity: String,
1087  category: String,
1088  what: String,
1089  params: Option<ReportParams>,
1090  session: Option<AdminSession>,
1091  pool: &State<DbPool>,
1092) -> Result<Template, Status> {
1093  let mut connection = pooled(pool)?;
1094  serve_report(
1095    &mut connection,
1096    pool.inner(),
1097    corpus_name,
1098    service_name,
1099    Some(severity),
1100    Some(category),
1101    Some(what),
1102    params,
1103    session.is_some(),
1104  )
1105}
1106
1107/// The route set for the reports capability (typed API + the human report screens).
1108pub fn routes() -> Vec<Route> {
1109  // NB: the agent report routes (`api_category_report`, `api_what_report`, `rerun_report`,
1110  // `refresh_reports`, `refresh_report_scope_api`) are mounted via `frontend::apidoc`
1111  // (rocket_okapi).
1112  routes![
1113    refresh_reports_human,
1114    refresh_report_scope,
1115    top_service_report,
1116    severity_service_report,
1117    severity_service_report_all,
1118    category_service_report,
1119    category_service_report_all,
1120    what_service_report,
1121    what_service_report_all,
1122    document_report_page,
1123    document_lookup_redirect
1124  ]
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129  use super::is_valid_rerun_severity;
1130
1131  /// The rerun-severity validator is context-aware (R-9) and shared by all three surfaces
1132  /// (`rerun_report` agent, `serve_rerun` human, `cortex rerun` CLI): without a category the scope
1133  /// is a task **status** (incl. `no_problem`, not `info`); with a category it's a log-message
1134  /// **severity** (incl. `info`, not `no_problem`).
1135  #[test]
1136  fn rerun_severity_is_context_aware() {
1137    // No category → task statuses.
1138    for sev in ["no_problem", "warning", "error", "fatal", "invalid"] {
1139      assert!(
1140        is_valid_rerun_severity(sev, false),
1141        "{sev} is a valid no-category rerun severity"
1142      );
1143    }
1144    assert!(
1145      !is_valid_rerun_severity("info", false),
1146      "info is not a task status"
1147    );
1148    assert!(
1149      !is_valid_rerun_severity("todo", false),
1150      "todo isn't a rerun scope"
1151    );
1152    assert!(!is_valid_rerun_severity("bogus", false));
1153
1154    // With a category → message severities.
1155    for sev in ["warning", "error", "fatal", "invalid", "info"] {
1156      assert!(
1157        is_valid_rerun_severity(sev, true),
1158        "{sev} is a valid with-category rerun severity"
1159      );
1160    }
1161    assert!(
1162      !is_valid_rerun_severity("no_problem", true),
1163      "no_problem tasks carry no messages, so it's invalid with a category"
1164    );
1165    assert!(!is_valid_rerun_severity("bogus", true));
1166  }
1167}