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}