Skip to main content

cortex/frontend/
admin.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//! Admin web UI: a single **signed-in** `/admin` dashboard that consolidates the admin actions
9//! (service registry, background jobs, system health, settings, API docs, and "add a corpus") which
10//! previously sprinkled the public homepage. Access uses the lightweight token scheme — an
11//! [`AdminSession`] cookie (`frontend::actor`), set on the sign-in page below.
12
13use diesel::sql_types::{BigInt, Integer, Nullable, Text};
14use diesel::{PgConnection, QueryableByName, RunQueryDsl, sql_query};
15use rocket::form::Form;
16use rocket::http::{Cookie, CookieJar, SameSite, Status};
17use rocket::response::Redirect;
18use rocket::serde::json::Json;
19use rocket::{Route, State};
20use rocket_dyn_templates::{Template, context};
21use schemars::JsonSchema;
22use serde::Serialize;
23
24use crate::backend::DbPool;
25use crate::frontend::actor::{
26  ADMIN_COOKIE, Actor, AdminSession, ReturnTo, owner_for_token, safe_next, sign_in_url,
27};
28use crate::models::{Corpus, HistoricalRun, Session, Task, WorkerMetadata};
29
30/// At-a-glance operational snapshot for the admin **live ops console** — the small-table signals
31/// (plus the one pending-task backlog count) the dashboard polls every few seconds and renders
32/// server-side on first paint. Every field is best-effort: a database hiccup degrades it to
33/// `0`/`None` rather than failing the screen. Deliberately excludes the dispatcher-port /
34/// corpus-storage probes (those are the System Health screen's job — and too slow to poll). Agents
35/// get this same snapshot from the token-gated `GET /api/status` ([`api_status`]) or the Prometheus
36/// gauges at `/metrics`.
37#[derive(Serialize, JsonSchema)]
38pub struct AdminStatusDto {
39  /// Registered corpora.
40  pub corpus_count: usize,
41  /// Background jobs currently queued or running.
42  pub active_jobs: usize,
43  /// Active (unexpired) admin sessions.
44  pub active_sessions: usize,
45  /// Workers **active in the last ~10 minutes** (dispatched or returned a task) — the
46  /// actively-converting fleet, not all registered rows. `0` when no dispatcher is running.
47  pub workers_total: i64,
48  /// Tasks in-flight (dispatched, not yet returned) at those **active** workers — real current
49  /// in-flight work, `0` on an idle deployment (no longer a cumulative-lifetime tally;
50  /// KNOWN_ISSUES P-3).
51  pub workers_in_flight: i64,
52  /// Tasks awaiting conversion (status TODO, not yet dispatched) — the pending-work backlog, the
53  /// human twin of the `cortex_tasks_todo` `/metrics` gauge.
54  pub tasks_todo: i64,
55  /// Background jobs that ended `failed` within the last 24h (rolling window).
56  pub jobs_failed_recent: usize,
57  /// Pooled connections currently checked out (saturation signal).
58  pub pool_in_use: u32,
59  /// Maximum size of the frontend connection pool.
60  pub pool_max: u32,
61  /// The most recent conversion run (live tallies overlaid while it is still open), if any.
62  pub last_run: Option<LastRunDto>,
63}
64
65/// The latest run's headline, with live task tallies overlaid while it is still open (so the card
66/// shows real progress, not a frozen-at-completion zero).
67#[derive(Serialize, JsonSchema)]
68pub struct LastRunDto {
69  /// Run start time, ISO-8601 UTC.
70  pub when: String,
71  /// The actor who launched the run.
72  pub owner: String,
73  /// The run's description.
74  pub description: String,
75  /// Total tasks in the run.
76  pub total: i32,
77  /// Tasks still in progress (live).
78  pub in_progress: i32,
79  /// Whether the run is still open (tallies not yet frozen).
80  pub open: bool,
81}
82
83/// Gathers the [`AdminStatusDto`] over one pooled connection (plus the in-memory pool counters).
84/// Shared by the HTML dashboard's first paint and the `/admin/status.json` poll feed so both show
85/// identical state. Mostly cheap small-table reads (plus the one `tasks_todo` backlog count) — no
86/// dispatcher/storage probe.
87pub fn admin_status(pool: &DbPool) -> AdminStatusDto {
88  // Pool counters are in-memory — available even if the database is unreachable.
89  let state = pool.state();
90  let mut status = AdminStatusDto {
91    corpus_count: 0,
92    active_jobs: 0,
93    active_sessions: 0,
94    workers_total: 0,
95    workers_in_flight: 0,
96    tasks_todo: 0,
97    jobs_failed_recent: 0,
98    pool_in_use: state.connections.saturating_sub(state.idle_connections),
99    pool_max: pool.max_size(),
100    last_run: None,
101  };
102  if let Ok(mut connection) = pool.get() {
103    status.corpus_count = Corpus::all(&mut connection).map_or(0, |corpora| corpora.len());
104    status.active_jobs = crate::jobs::list_recent(&mut connection, true, 200).len();
105    status.active_sessions = Session::active(&mut connection).map_or(0, |sessions| sessions.len());
106    status.jobs_failed_recent =
107      crate::jobs::count_recent_with_status(&mut connection, "failed", 24);
108    if let Ok((workers, in_flight)) = WorkerMetadata::fleet_summary(&mut connection) {
109      status.workers_total = workers;
110      status.workers_in_flight = in_flight;
111    }
112    // Pending-conversion backlog (the unleased work waiting for the fleet) — the one full-table
113    // count here, degrading to 0 on error like its siblings.
114    status.tasks_todo = Task::count_todo(&mut connection);
115    status.last_run = HistoricalRun::recent_all(&mut connection, 1)
116      .ok()
117      .and_then(|runs| runs.into_iter().next())
118      .map(|run| {
119        // The latest run is often still open (tallies frozen only at completion) — overlay live
120        // progress so the card shows real task counts, not a misleading zero.
121        let run = run.with_live_tallies(&mut connection);
122        LastRunDto {
123          when: crate::frontend::helpers::iso_utc(run.start_time),
124          owner: run.owner,
125          description: run.description,
126          total: run.total,
127          in_progress: run.in_progress,
128          open: run.end_time.is_none(),
129        }
130      });
131  }
132  status
133}
134
135/// The admin dashboard (`GET /admin`): the consolidated home for admin actions. **Signed-in admins
136/// only** — an unauthenticated browser is redirected to the sign-in page (`Err(Redirect)`).
137// `Redirect` (Rocket's URI responder) is a chunky type, so the `Err` variant trips
138// `result_large_err` — irrelevant for a one-shot request handler; the page-or-redirect `Result` is
139// the idiomatic shape.
140#[allow(clippy::result_large_err)]
141#[get("/admin")]
142pub fn admin_page(
143  session: Option<AdminSession>,
144  return_to: ReturnTo,
145  pool: &State<DbPool>,
146) -> Result<Template, Redirect> {
147  let session = session.ok_or_else(|| Redirect::to(sign_in_url(false, Some(&return_to.0))))?;
148  // The command center's first paint — the same snapshot the page then polls live from
149  // `/admin/status.json` (one shared DTO, so server-render and live update never diverge).
150  let status = admin_status(pool);
151  let global = serde_json::json!({
152    "title": "Admin",
153    "description": "CorTeX administration dashboard",
154  });
155  Ok(Template::render(
156    "admin",
157    context! { global, owner: session.owner, status },
158  ))
159}
160
161/// `GET /admin/status.json` — the live ops console's poll feed: the [`AdminStatusDto`] as JSON, for
162/// the dashboard's few-second auto-refresh. **Cookie-gated** (a signed-in [`AdminSession`]); an
163/// expired session returns `401` so the page simply keeps its last-good values rather than
164/// redirecting an XHR. Same-origin only — the agent twin (token-gated, same DTO) is
165/// [`api_status`] (`GET /api/status`); the Prometheus gauges are at `/metrics`.
166#[get("/admin/status.json")]
167pub fn admin_status_feed(
168  session: Option<AdminSession>,
169  pool: &State<DbPool>,
170) -> Result<Json<AdminStatusDto>, Status> {
171  let _session = session.ok_or(Status::Unauthorized)?;
172  Ok(Json(admin_status(pool)))
173}
174
175/// `GET /api/status` — the **agent twin** of the dashboard's `/admin/status.json` feed: the
176/// [`AdminStatusDto`] system snapshot (corpus count, the worker fleet, background-job activity, the
177/// pending-conversion backlog, and the latest run) as one structured JSON call a monitoring agent
178/// can poll. Complements the Prometheus `/metrics` gauges — it carries the structured `last_run`
179/// detail (owner / description / timing) the gauges can't, and matches `cortex status --json`.
180/// **Token-gated** via the [`Actor`] guard (`401` without a valid token).
181#[rocket_okapi::openapi(tag = "Management")]
182#[get("/api/status")]
183pub fn api_status(_actor: Actor, pool: &State<DbPool>) -> Json<AdminStatusDto> {
184  Json(admin_status(pool))
185}
186
187/// One worker's live activity row for the [`LiveActivityDto`] fleet feed — the "what the fleet is
188/// doing now" signal, read straight from the `worker_metadata` rows the dispatcher already keeps.
189#[derive(Serialize, JsonSchema)]
190pub struct FleetWorkerDto {
191  /// Worker identity (usually `hostname:pid`).
192  pub name: String,
193  /// The service this worker serves.
194  pub service_id: i32,
195  /// Lifetime results this worker has returned.
196  pub total_returned: i32,
197  /// The most recent task id this worker returned a result for (`None` if it never has).
198  pub last_returned_task_id: Option<i64>,
199  /// When this worker was last dispatched a task (RFC 3339 UTC).
200  pub time_last_dispatch: String,
201  /// When this worker last returned a result (RFC 3339 UTC), if ever.
202  pub time_last_return: Option<String>,
203}
204
205/// One recent conversion message (fatal/error/warning) for the [`LiveActivityDto`] stream — read
206/// from the `log_*` rows the dispatcher's finalize thread already persists, joined to the task's
207/// entry/corpus/service. The live signal of a run's health.
208#[derive(Serialize, JsonSchema)]
209pub struct ActivityMessageDto {
210  /// `"fatal"`, `"error"`, or `"warning"`.
211  pub severity: String,
212  /// The corpus the converting task belongs to.
213  pub corpus: String,
214  /// The service that produced the message.
215  pub service: String,
216  /// The document entry (source path) that errored.
217  pub entry: String,
218  /// The message category (e.g. `undefined`), if any.
219  pub category: Option<String>,
220  /// The message subject (`what`), if any.
221  pub what: Option<String>,
222  /// The message detail, truncated for the feed.
223  pub details: Option<String>,
224  /// When the message was recorded (RFC 3339 UTC); `None` for rows written before the
225  /// `recorded_at` column existed (they sort last in the stream).
226  pub when: Option<String>,
227}
228
229/// The admin **live activity** feed: the actively-converting fleet plus the most recent conversion
230/// problems. Every field is read-only over data the dispatcher already writes to Postgres as its
231/// normal work — the frontend polls it and the **dispatcher is never in the request loop**, so a
232/// slow or absent UI can never back-pressure or endanger the conversion hot path.
233#[derive(Serialize, JsonSchema)]
234pub struct LiveActivityDto {
235  /// Recently-active workers, newest dispatch first.
236  pub fleet: Vec<FleetWorkerDto>,
237  /// The most recent conversion messages — fatals, errors, and warnings **intermeshed and sorted
238  /// newest-first by `recorded_at`** (a live streaming-log tail), capped at ~100.
239  pub recent: Vec<ActivityMessageDto>,
240}
241
242/// A raw recent-message row from the unified [`recent_activity`] UNION — already carries its
243/// `severity` (a SQL literal per branch) and the `recorded_at` timestamp formatted to ISO in SQL
244/// (`when_iso`), so ordering is a lexical sort (ISO 8601 sorts chronologically) with no chrono type
245/// to thread through the projection.
246#[derive(QueryableByName)]
247struct MessageRow {
248  #[diesel(sql_type = Text)]
249  severity: String,
250  #[diesel(sql_type = Text)]
251  entry: String,
252  #[diesel(sql_type = Text)]
253  corpus: String,
254  #[diesel(sql_type = Text)]
255  service: String,
256  #[diesel(sql_type = Nullable<Text>)]
257  category: Option<String>,
258  #[diesel(sql_type = Nullable<Text>)]
259  what: Option<String>,
260  #[diesel(sql_type = Nullable<Text>)]
261  details: Option<String>,
262  #[diesel(sql_type = Nullable<Text>)]
263  when_iso: Option<String>,
264}
265
266impl MessageRow {
267  fn into_dto(self) -> ActivityMessageDto {
268    // Cap detail length so the live-poll payload stays small (details can be up to 2000 chars).
269    let details = self.details.map(|d| {
270      const MAX: usize = 300;
271      if d.chars().count() <= MAX {
272        d
273      } else {
274        d.chars().take(MAX).collect::<String>() + "…"
275      }
276    });
277    ActivityMessageDto {
278      severity: self.severity,
279      corpus: self.corpus,
280      service: self.service,
281      entry: self.entry,
282      category: self.category,
283      what: self.what,
284      details,
285      when: self.when_iso,
286    }
287  }
288}
289
290/// The most recent fatal/error/warning messages for one **service**, **intermeshed and sorted
291/// newest-first by `recorded_at`** — a unified streaming-log tail. Each severity-partitioned table
292/// has its own BIGSERIAL `id` sequence (not comparable across tables), so the shared `recorded_at`
293/// timestamp is the only valid cross-severity ordering key; it is formatted to ISO in SQL so the
294/// outer `ORDER BY` is a lexical (= chronological) sort with `NULLS LAST` for pre-migration rows.
295/// Each inner branch is bounded by `per_table` via its own `id`-PK scan (index-cheap even on the
296/// ~100M-row production tables); the outer `LIMIT total` takes the global newest. The table names +
297/// severity literals are fixed (never user input). Best-effort: a query error degrades to empty.
298fn recent_activity(
299  connection: &mut PgConnection,
300  service_id: i32,
301  per_table: i64,
302  total: i64,
303) -> Vec<MessageRow> {
304  // One UNION branch per severity table; `$1` = service_id, `$2` = per-table cap, `$3` = total cap.
305  let branch = |table: &str, severity: &str| {
306    format!(
307      "(SELECT '{severity}' AS severity, t.entry AS entry, c.name AS corpus, s.name AS service, \
308               l.category AS category, l.what AS what, l.details AS details, \
309               to_char(l.recorded_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS when_iso \
310        FROM {table} l \
311        JOIN tasks t ON t.id = l.task_id \
312        JOIN corpora c ON c.id = t.corpus_id \
313        JOIN services s ON s.id = t.service_id \
314        WHERE t.service_id = $1 \
315        ORDER BY l.id DESC LIMIT $2)"
316    )
317  };
318  let query = format!(
319    "SELECT severity, entry, corpus, service, category, what, details, when_iso FROM ( {} UNION ALL \
320     {} UNION ALL {} ) u ORDER BY when_iso DESC NULLS LAST LIMIT $3",
321    branch("log_fatals", "fatal"),
322    branch("log_errors", "error"),
323    branch("log_warnings", "warning"),
324  );
325  sql_query(query)
326    .bind::<Integer, _>(service_id)
327    .bind::<BigInt, _>(per_table)
328    .bind::<BigInt, _>(total)
329    .get_results::<MessageRow>(connection)
330    .unwrap_or_default()
331}
332
333/// Gathers the [`LiveActivityDto`] over one pooled connection. Like [`admin_status`], every read is
334/// best-effort (degrades to an empty list) and **read-only** — the dispatcher is never involved, so
335/// the live feed cannot perturb the conversion hot path.
336///
337/// The feed is **conditioned on the active run's service** — the one actually generating messages
338/// now (the latest [`HistoricalRun`]). A co-resident legacy service (e.g. a Perl `tex-to-html`
339/// sharing the DB with the Rust `oxidized_tex_to_html` run) therefore never pollutes the live view;
340/// the feed follows whatever is converting. With no runs yet, the feed is simply empty.
341pub fn live_activity(pool: &DbPool, limit: i64) -> LiveActivityDto {
342  let mut activity = LiveActivityDto {
343    fleet: Vec::new(),
344    recent: Vec::new(),
345  };
346  if let Ok(mut connection) = pool.get() {
347    let active_service = HistoricalRun::recent_all(&mut connection, 1)
348      .ok()
349      .and_then(|runs| runs.into_iter().next())
350      .map(|run| run.service_id);
351    let Some(service_id) = active_service else {
352      return activity;
353    };
354    if let Ok(workers) = WorkerMetadata::recent(&mut connection, 200) {
355      activity.fleet = workers
356        .into_iter()
357        .filter(|w| w.service_id == service_id)
358        .take(80)
359        .map(|w| FleetWorkerDto {
360          name: w.name,
361          service_id: w.service_id,
362          total_returned: w.total_returned,
363          last_returned_task_id: w.last_returned_task_id,
364          time_last_dispatch: crate::models::iso_utc_system(w.time_last_dispatch),
365          time_last_return: w.time_last_return.map(crate::models::iso_utc_system),
366        })
367        .collect();
368    }
369    // Fetch up to `limit` per severity, intermeshed + sorted newest-first by recorded_at, capped at
370    // `limit` total — a unified streaming-log tail.
371    activity.recent = recent_activity(&mut connection, service_id, limit, limit)
372      .into_iter()
373      .map(MessageRow::into_dto)
374      .collect();
375  }
376  activity
377}
378
379/// `GET /admin/logs.json` — the live-activity panel's poll feed: the [`LiveActivityDto`] as JSON.
380/// **Cookie-gated** (a signed-in [`AdminSession`]); an expired session returns `401` so the page
381/// keeps its last-good values. The agent twin is [`api_logs`] (`GET /api/logs`).
382#[get("/admin/logs.json")]
383pub fn admin_logs_feed(
384  session: Option<AdminSession>,
385  pool: &State<DbPool>,
386) -> Result<Json<LiveActivityDto>, Status> {
387  let _session = session.ok_or(Status::Unauthorized)?;
388  Ok(Json(live_activity(pool, 100)))
389}
390
391/// `GET /api/logs` — the **agent twin** of the dashboard's `/admin/logs.json` feed: the live fleet
392/// activity plus the most recent fatal/error conversion messages as one structured JSON call a
393/// monitoring agent can poll. **Token-gated** via the [`Actor`] guard.
394#[rocket_okapi::openapi(tag = "Management")]
395#[get("/api/logs")]
396pub fn api_logs(_actor: Actor, pool: &State<DbPool>) -> Json<LiveActivityDto> {
397  Json(live_activity(pool, 100))
398}
399
400/// The sign-in page (`GET /admin/login?<bad>&<next>`): a form to enter an admin token, plus a "sign
401/// in with a passkey" affordance when passkeys are enabled. `?bad=true` flags a failed previous
402/// attempt; `?next=` is the destination to return to after signing in (carried through the form).
403#[get("/admin/login?<bad>&<next>")]
404pub fn admin_login_page(
405  bad: Option<bool>,
406  next: Option<String>,
407  webauthn: &State<Option<crate::frontend::webauthn::WebauthnState>>,
408) -> Template {
409  let global = serde_json::json!({
410    "title": "Admin sign-in",
411    "description": "Sign in to the CorTeX admin dashboard",
412  });
413  // Only carry a safe local `next` into the page (open-redirect guard; also avoids reflecting
414  // junk).
415  let next = next.filter(|path| path.starts_with('/') && !path.starts_with("//"));
416  Template::render(
417    "admin-login",
418    context! { global, bad: bad.unwrap_or(false), next, passkeys_enabled: webauthn.inner().is_some() },
419  )
420}
421
422/// The sign-in form fields.
423#[derive(FromForm)]
424pub struct LoginForm {
425  /// A rerun token (resolved to an owner via `auth.rerun_tokens`).
426  pub token: String,
427  /// Where to return after a successful sign-in (validated to a safe local path).
428  pub next: Option<String>,
429}
430
431/// Processes sign-in (`POST /admin/login`): validates the token against `auth.rerun_tokens`; on
432/// success **opens a server-side session** and sets the [`ADMIN_COOKIE`] cookie to its random
433/// opaque id (HttpOnly, SameSite=Lax) — the cookie no longer carries the token — then redirects to
434/// the validated `next` destination (default `/admin`). A bad token (or a DB hiccup opening the
435/// session) returns to the sign-in page flagged, preserving `next`.
436#[post("/admin/login", data = "<form>")]
437pub fn admin_login(
438  form: Form<LoginForm>,
439  cookies: &CookieJar<'_>,
440  pool: &State<DbPool>,
441) -> Redirect {
442  let session_id = owner_for_token(&form.token).and_then(|owner| {
443    let mut connection = pool.get().ok()?;
444    Session::open(&mut connection, &owner, "token").ok()
445  });
446  match session_id {
447    Some(session_id) => {
448      cookies.add(
449        Cookie::build((ADMIN_COOKIE, session_id))
450          .http_only(true)
451          .same_site(SameSite::Lax)
452          .path("/")
453          .build(),
454      );
455      Redirect::to(safe_next(form.next.as_deref()))
456    },
457    // Preserve the return destination across a failed attempt.
458    None => Redirect::to(sign_in_url(true, form.next.as_deref())),
459  }
460}
461
462/// Signs out (`POST /admin/logout`): **revokes** the server-side session (so the id is dead even if
463/// the cookie lingers), clears the cookie, and returns to the sign-in page.
464#[post("/admin/logout")]
465pub fn admin_logout(cookies: &CookieJar<'_>, pool: &State<DbPool>) -> Redirect {
466  if let Some(session_id) = cookies
467    .get(ADMIN_COOKIE)
468    .map(|cookie| cookie.value().to_string())
469    && let Ok(mut connection) = pool.get()
470  {
471    let _ = Session::revoke(&mut connection, &session_id);
472  }
473  cookies.remove(Cookie::build(ADMIN_COOKIE).path("/").build());
474  Redirect::to("/admin/login")
475}
476
477/// The route set for the admin web UI.
478pub fn routes() -> Vec<Route> {
479  routes![
480    admin_page,
481    admin_status_feed,
482    admin_logs_feed,
483    admin_login_page,
484    admin_login,
485    admin_logout
486  ]
487}