Skip to main content

cortex/frontend/
management.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//! Management & health capability: the configuration view/edit surface, the Settings page, and a
9//! health check.
10//!
11//! Follows the plan's "symmetry contract": one shared, serializable DTO renders as JSON for agents
12//! (`GET /api/config`) and as HTML for humans (`GET /settings`), and the write path
13//! (`PUT /api/config`, `POST /settings`) shares one merge-and-persist function. Handlers live here;
14//! the app is assembled in [`crate::frontend::server`].
15
16use std::path::Path;
17use std::path::PathBuf;
18
19use diesel::prelude::*;
20use figment::Figment;
21use figment::providers::Serialized;
22use rocket::form::Form;
23use rocket::http::Status;
24use rocket::response::Redirect;
25use rocket::serde::json::Json;
26use rocket::{Build, Rocket, Route, State};
27use rocket_dyn_templates::{Template, context};
28use serde::Serialize;
29
30use crate::backend::DbPool;
31use crate::config::{AssetsConfig, CortexConfig, DispatcherConfig, JobsConfig, config};
32use crate::frontend::actor::{
33  Actor, AdminReject, AdminSession, ReturnTo, require_admin, require_admin_to,
34};
35
36/// Managed state: the path where the write path persists the configuration file.
37pub struct ConfigFile(pub PathBuf);
38
39/// Masked view of the database settings — the password is never exposed.
40#[derive(Debug, Serialize, schemars::JsonSchema)]
41pub struct DatabaseDto {
42  /// Connection URL with any password component replaced by `***`.
43  pub url: String,
44}
45
46/// Masked view of the auth settings — secrets are summarized, never exposed.
47#[derive(Debug, Serialize, schemars::JsonSchema)]
48pub struct AuthDto {
49  /// How many rerun/admin tokens are configured.
50  pub rerun_token_count: usize,
51}
52
53/// A masked, serializable view of [`CortexConfig`] safe to expose over the API and UI.
54#[derive(Debug, Serialize, schemars::JsonSchema)]
55pub struct ConfigDto {
56  /// Database settings (password masked).
57  pub database: DatabaseDto,
58  /// ZeroMQ dispatcher settings.
59  pub dispatcher: DispatcherConfig,
60  /// On-disk asset locations.
61  pub assets: AssetsConfig,
62  /// Background-job lifecycle settings (the stall-reap threshold).
63  pub jobs: JobsConfig,
64  /// Auth settings (secrets masked).
65  pub auth: AuthDto,
66  /// Passkey (WebAuthn) sign-in settings (non-secret: enabled flag + relying-party id/origin).
67  pub webauthn: crate::config::WebauthnConfig,
68}
69
70impl ConfigDto {
71  /// Builds the masked DTO from a configuration.
72  pub fn from_config(cfg: &CortexConfig) -> ConfigDto {
73    ConfigDto {
74      database: DatabaseDto {
75        url: mask_db_password(&cfg.database.url),
76      },
77      dispatcher: cfg.dispatcher.clone(),
78      assets: cfg.assets.clone(),
79      jobs: cfg.jobs.clone(),
80      auth: AuthDto {
81        rerun_token_count: cfg.auth.rerun_tokens.len(),
82      },
83      webauthn: cfg.webauthn.clone(),
84    }
85  }
86}
87
88/// Health of the database dependency.
89#[derive(Debug, Serialize, schemars::JsonSchema)]
90pub struct DbHealth {
91  /// Whether the configured database accepts a connection and a trivial query.
92  pub reachable: bool,
93}
94
95/// Health of the schema migrations.
96#[derive(Debug, Serialize, schemars::JsonSchema)]
97pub struct MigrationsHealth {
98  /// Whether the database schema is at the latest embedded migration.
99  pub current: bool,
100}
101
102/// Utilization of the web frontend's database connection pool — a key load / saturation signal
103/// (when `in_use` approaches `max`, requests start waiting on `pool.get()` and may `503`).
104#[derive(Debug, Serialize, schemars::JsonSchema)]
105pub struct PoolHealth {
106  /// Configured maximum pool size (`database.pool_size`).
107  pub max: u32,
108  /// Connections currently established (idle + in-use).
109  pub connections: u32,
110  /// Idle, immediately-available connections.
111  pub idle: u32,
112  /// Connections currently checked out (in use).
113  pub in_use: u32,
114}
115
116/// Reachability of the ZeroMQ dispatcher, probed by a short TCP connect to its bound ports. The
117/// frontend doesn't otherwise speak to the dispatcher (workers do), so this is a pure liveness
118/// probe of the **co-located** dispatcher (localhost) — informational, it does not flip the overall
119/// `status` (a read-only/report-only frontend deployment legitimately runs without a dispatcher).
120#[derive(Debug, Serialize, schemars::JsonSchema)]
121pub struct DispatcherHealth {
122  /// Whether both the ventilator and sink ports accept a TCP connection on localhost.
123  pub reachable: bool,
124  /// Ventilator (worker task-request) port.
125  pub source_port: usize,
126  /// Sink (worker result) port.
127  pub result_port: usize,
128}
129
130/// A corpus whose configured source directory could not be read on disk (missing / unmounted /
131/// wrong permissions). Its conversions and re-imports will fail until the path is restored.
132#[derive(Debug, Serialize, schemars::JsonSchema)]
133pub struct UnreadableCorpus {
134  /// Corpus name (its external handle).
135  pub name: String,
136  /// The configured source path that is missing or unreadable.
137  pub path: String,
138}
139
140/// Health of the shared document storage: every corpus's `path` is stat-checked on disk. Document
141/// bytes live on a shared filesystem (`tasks.entry` are absolute paths under each `corpus.path`),
142/// so a moved/unmounted data mount makes the whole conversion pipeline fail — surfaced here instead
143/// of only as mysterious cascading task failures. **Informational** (the frontend still serves
144/// reports from the DB), so it does not flip the overall `status`. Corpora with an empty path are
145/// skipped.
146#[derive(Debug, Serialize, schemars::JsonSchema)]
147pub struct StorageHealth {
148  /// Number of corpora whose source path was checked (non-empty paths).
149  pub corpora_checked: usize,
150  /// Corpora whose source directory is missing or unreadable (empty = all good).
151  pub unreadable: Vec<UnreadableCorpus>,
152}
153
154/// Structured health report, identical for agents and human supervisors.
155#[derive(Debug, Serialize, schemars::JsonSchema)]
156pub struct HealthDto {
157  /// Overall status: `"ok"` when every *frontend* dependency (DB + migrations) is healthy, else
158  /// `"degraded"`. Pool/dispatcher/storage fields are informational and do not flip this.
159  pub status: &'static str,
160  /// Database dependency health.
161  pub database: DbHealth,
162  /// Schema-migration health.
163  pub migrations: MigrationsHealth,
164  /// Connection-pool utilization.
165  pub pool: PoolHealth,
166  /// Co-located dispatcher reachability (informational).
167  pub dispatcher: DispatcherHealth,
168  /// Shared document-storage reachability per corpus (informational).
169  pub storage: StorageHealth,
170  /// Actionable operator guidance for every degraded or warning signal above, in fix-this-first
171  /// order (empty when all-clear). The runtime twin of `cortex doctor`'s remediation hints — so an
172  /// operator (or agent) polling health is told *how* to fix a red/amber signal, not just that it
173  /// is one. Computed from the fields above by [`HealthDto::remediations`].
174  pub remediations: Vec<String>,
175}
176
177/// Minimal, **public** liveness projection — the open `GET /healthz`. Just whether the service is
178/// up and its database reachable; deliberately omits the internal topology (corpus paths, pool
179/// sizing, dispatcher ports, remediations) that [`HealthDto`] exposes. The detailed report is
180/// admin-only: the `/health` screen and its token-gated agent twin `GET /api/health` (KNOWN_ISSUES
181/// X-1).
182#[derive(Debug, Serialize, schemars::JsonSchema)]
183pub struct LivenessDto {
184  /// `"ok"` when the database is reachable, else `"degraded"`.
185  pub status: &'static str,
186  /// Database dependency health.
187  pub database: DbHealth,
188}
189
190impl HealthDto {
191  /// Builds the actionable remediation hints from the report's signals (reads every field *except*
192  /// `remediations` itself, so it can populate that field). Fix-this-first ordered: a down database
193  /// degrades the frontend and makes the migration check unknowable, so it is surfaced alone;
194  /// otherwise pending migrations, then the informational pool / dispatcher / storage warnings.
195  #[must_use]
196  pub fn remediations(&self) -> Vec<String> {
197    let mut hints = Vec::new();
198    if !self.database.reachable {
199      hints.push(
200        "database unreachable — the frontend is degraded; check the database URL (`cortex.toml` \
201         [database].url or DATABASE_URL) and that PostgreSQL is running"
202          .to_string(),
203      );
204    } else if !self.migrations.current {
205      hints
206        .push("schema out of date — run `cortex init` to apply the pending migrations".to_string());
207    }
208    // Pool exhaustion: with every connection checked out, new requests block on `pool.get()` and
209    // may time out to 503. Surface it before it cascades.
210    if self.pool.in_use >= self.pool.max {
211      hints.push(format!(
212        "connection pool exhausted ({}/{} in use) — requests are waiting and may 503; raise \
213         `database.pool_size` or investigate slow / long-held queries",
214        self.pool.in_use, self.pool.max
215      ));
216    }
217    if !self.dispatcher.reachable {
218      hints.push(format!(
219        "dispatcher not listening on localhost:{}/{} — if this node runs conversions, start the \
220         dispatcher; a report-only frontend can ignore this",
221        self.dispatcher.source_port, self.dispatcher.result_port
222      ));
223    }
224    if !self.storage.unreadable.is_empty() {
225      let names: Vec<&str> = self
226        .storage
227        .unreadable
228        .iter()
229        .map(|corpus| corpus.name.as_str())
230        .collect();
231      hints.push(format!(
232        "{} corpus source path(s) unreadable ({}) — check the mount / permissions; conversions and \
233         re-imports for them fail until restored",
234        self.storage.unreadable.len(),
235        names.join(", ")
236      ));
237    }
238    hints
239  }
240}
241
242/// The editable, non-secret fields of the Settings form (database/auth are edited out-of-band).
243#[derive(FromForm)]
244pub struct SettingsForm {
245  /// Ventilator port.
246  pub dispatcher_source_port: usize,
247  /// Sink port.
248  pub dispatcher_result_port: usize,
249  /// Task batch size.
250  pub dispatcher_queue_size: usize,
251  /// ZeroMQ chunk size, in bytes.
252  pub dispatcher_message_size: usize,
253  /// Backpressure threshold: max in-flight tasks before the ventilator stops leasing.
254  pub dispatcher_max_in_flight: usize,
255  /// Hard cap (bytes) on a single worker result archive; an oversized result is rejected + the
256  /// task marked `Invalid` so a runaway worker can't fill `/data` (W-1③).
257  pub dispatcher_max_result_bytes: usize,
258  /// How often (seconds) the finalize thread refreshes the report rollup (the freshness
259  /// guarantee).
260  pub dispatcher_report_refresh_interval_seconds: u64,
261  /// Job stall-reap threshold (seconds): a non-terminal job silent this long is reaped as hung.
262  pub jobs_stale_timeout_seconds: i64,
263  /// Template directory.
264  pub assets_template_dir: String,
265  /// Public assets directory.
266  pub assets_public_dir: String,
267}
268
269/// Replaces the password component of a `scheme://user:pass@host/db` URL with `***`.
270fn mask_db_password(url: &str) -> String {
271  if let (Some(scheme_end), Some(at)) = (url.find("://"), url.find('@')) {
272    let creds = &url[scheme_end + 3..at];
273    if let Some(colon) = creds.find(':') {
274      let user = &creds[..colon];
275      return format!("{}://{}:***{}", &url[..scheme_end], user, &url[at..]);
276    }
277  }
278  url.to_string()
279}
280
281/// Deep-merges a partial JSON patch onto the running config, validates it, and persists the
282/// non-secret sections to `path` as TOML. Returns the merged configuration.
283fn merge_and_persist(patch: &serde_json::Value, path: &Path) -> Result<CortexConfig, Status> {
284  let mut merged: CortexConfig = Figment::from(Serialized::defaults(config().clone()))
285    .merge(Serialized::defaults(patch))
286    .extract()
287    .map_err(|_| Status::UnprocessableEntity)?;
288  // `auth` is `#[serde(skip)]` (tokens live solely in the JSON token file, never cortex.toml), so
289  // the figment round-trip above drops it. Restore the live, token-populated auth so the returned
290  // DTO's masked token count is accurate — Settings never edits or persists tokens regardless.
291  merged.auth = config().auth.clone();
292  // The figment extract above only checks *types*. Reject a structurally-valid but operationally
293  // **bricking** value (a zero pool/queue, an out-of-range port) BEFORE persisting — otherwise it
294  // is written to disk and silently breaks the next restart.
295  if let Err(reason) = validate_config_bounds(&merged) {
296    tracing::warn!(%reason, "rejected a config update that would brick a component");
297    return Err(Status::UnprocessableEntity);
298  }
299  let toml_text =
300    crate::config::to_persisted_toml(&merged).map_err(|_| Status::InternalServerError)?;
301  // Atomic publish (B-1): write a sibling temp + rename, so a crash mid-write can't leave a
302  // half-written `cortex.toml` that bricks the next restart — the same guard `cortex init` /
303  // `set-admin-token` use.
304  crate::bootstrap::write_config_atomically(path, &toml_text)
305    .map_err(|_| Status::InternalServerError)?;
306  Ok(merged)
307}
308
309/// Value-bound check for a merged config: rejects knobs whose value is structurally valid (the
310/// right type) but would make a component unusable — the kind of footgun an admin could otherwise
311/// persist from the Settings screen / `PUT /api/config` and only discover on the next (broken)
312/// restart. The returned reason is logged; the write path maps it to `422`.
313fn validate_config_bounds(c: &CortexConfig) -> Result<(), String> {
314  if c.database.pool_size < 1 {
315    return Err("database.pool_size must be >= 1 (a zero pool blocks every request)".to_string());
316  }
317  let port_ok = |port: usize| (1..=65535).contains(&port);
318  if !port_ok(c.dispatcher.source_port) {
319    return Err(format!(
320      "dispatcher.source_port {} is out of range (1..=65535)",
321      c.dispatcher.source_port
322    ));
323  }
324  if !port_ok(c.dispatcher.result_port) {
325    return Err(format!(
326      "dispatcher.result_port {} is out of range (1..=65535)",
327      c.dispatcher.result_port
328    ));
329  }
330  if c.dispatcher.source_port == c.dispatcher.result_port {
331    return Err(
332      "dispatcher.source_port and result_port must differ (the ventilator and sink can't \
333                share a port)"
334        .to_string(),
335    );
336  }
337  if c.dispatcher.queue_size < 1 {
338    return Err("dispatcher.queue_size must be >= 1 (a zero queue leases no work)".to_string());
339  }
340  if c.dispatcher.max_in_flight < 1 {
341    return Err("dispatcher.max_in_flight must be >= 1".to_string());
342  }
343  if c.dispatcher.max_result_bytes < 1 {
344    return Err(
345      "dispatcher.max_result_bytes must be >= 1 (a zero cap rejects every result)".to_string(),
346    );
347  }
348  if c.jobs.stale_timeout_seconds < 1 {
349    return Err(
350      "jobs.stale_timeout_seconds must be >= 1 (a non-positive timeout reaps every job \
351                instantly)"
352        .to_string(),
353    );
354  }
355  Ok(())
356}
357
358/// One row of the mounted route surface — an endpoint's method, URI pattern, and handler name.
359#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
360pub struct RouteInfo {
361  /// HTTP method (`GET`, `POST`, …).
362  pub method: String,
363  /// URI pattern, with `<param>` placeholders and any `?<query>` parameters.
364  pub uri: String,
365  /// The handler's name (the Rust fn) — a hint at the operation.
366  pub name: Option<String>,
367}
368
369/// A snapshot of the mounted route table, captured at mount time so the discovery index can never
370/// drift from the routes actually served.
371pub struct RouteTable(pub Vec<RouteInfo>);
372impl RouteTable {
373  /// Introspects a built Rocket's mounted routes into a serializable snapshot.
374  pub fn snapshot(rocket: &Rocket<Build>) -> RouteTable {
375    RouteTable(
376      rocket
377        .routes()
378        .map(|route| RouteInfo {
379          method: route.method.to_string(),
380          uri: route.uri.to_string(),
381          name: route.name.as_deref().map(str::to_string),
382        })
383        .collect(),
384    )
385  }
386}
387
388/// Discovery index of the **agent API**: every mounted `/api/*` endpoint (method, path, handler
389/// name) in a single call, so an agent can enumerate CorTeX's machine surface without out-of-band
390/// docs. Self-describing — built by introspecting the live route table, so it never drifts.
391#[derive(Debug, Serialize, schemars::JsonSchema)]
392pub struct ApiIndexDto {
393  /// One-line orientation for an agent landing on the API root.
394  pub description: &'static str,
395  /// Path to the full machine-readable OpenAPI 3 specification (typed request/response schemas for
396  /// every endpoint) — the authoritative contract behind this lightweight index.
397  pub openapi: &'static str,
398  /// Path to the human-browsable API reference (RapiDoc, rendered from the same OpenAPI spec).
399  pub docs: &'static str,
400  /// Number of agent endpoints.
401  pub count: usize,
402  /// The agent endpoints, sorted by path then method.
403  pub endpoints: Vec<RouteInfo>,
404}
405
406/// `GET /api` — the agent-API discovery index (see [`ApiIndexDto`]).
407#[rocket_okapi::openapi(tag = "Meta")]
408#[get("/api")]
409pub fn api_index(routes: &State<RouteTable>) -> Json<ApiIndexDto> {
410  let mut endpoints: Vec<RouteInfo> = routes
411    .0
412    .iter()
413    .filter(|r| r.uri == "/api" || r.uri.starts_with("/api/"))
414    .cloned()
415    .collect();
416  endpoints.sort_by(|a, b| a.uri.cmp(&b.uri).then_with(|| a.method.cmp(&b.method)));
417  Json(ApiIndexDto {
418    description: "CorTeX agent API. Enumerate endpoints below; see `openapi` for the full typed \
419                 contract. Most reads are open; mutations require an X-Cortex-Token header.",
420    openapi: "/api/openapi.json",
421    docs: "/api/docs",
422    count: endpoints.len(),
423    endpoints,
424  })
425}
426
427/// The effective configuration, masked for safe exposure (the agent twin of the Settings screen).
428/// **Token-gated** via the [`Actor`] guard (clean `401` without a token), matching the human
429/// `/settings` (`require_admin`) and the write twin `PUT /api/config` — config is admin-only on
430/// every surface. Secrets are masked regardless (DB password → `***`, only the token *count* is
431/// shown), but the operational config (DB host/user/name, ports, pool/queue tuning) is not for
432/// anonymous eyes.
433#[rocket_okapi::openapi(tag = "Management")]
434#[get("/api/config")]
435pub fn api_config(_caller: Actor) -> Json<ConfigDto> { Json(ConfigDto::from_config(config())) }
436
437/// Whether a TCP connection to `127.0.0.1:port` succeeds within a short timeout — a liveness probe
438/// of a ZeroMQ socket bound by the dispatcher (ZMQ `tcp://` sockets are TCP listeners). A closed
439/// port returns "connection refused" immediately; the timeout only bounds the rare filtered-port
440/// hang, so this stays fast on the common (co-located) path.
441fn port_listening(port: usize) -> bool {
442  use std::net::{TcpStream, ToSocketAddrs};
443  ("127.0.0.1", port as u16)
444    .to_socket_addrs()
445    .ok()
446    .and_then(|mut addrs| addrs.next())
447    .is_some_and(|addr| {
448      TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)).is_ok()
449    })
450}
451
452/// Builds the health report: probes the database through the pool (so reachability reflects the
453/// same path requests use), samples pool utilization, and probes the co-located dispatcher's ports.
454/// Shared by the agent (`/api/health`) and human (`/health`) routes so both report identical state.
455fn health_report(pool: &DbPool) -> HealthDto {
456  let (reachable, migrations_current, corpora_paths) = match pool.get() {
457    Ok(mut connection) => {
458      use crate::schema::corpora;
459      let reachable = diesel::sql_query("SELECT 1")
460        .execute(&mut *connection)
461        .is_ok();
462      let migrations_current = !crate::migrations::has_pending_migrations(&mut connection);
463      // Gather the (name, path) pairs *inside* the checkout; the disk stat happens after the
464      // connection is returned, so we don't hold a pooled connection during filesystem I/O.
465      let corpora_paths: Vec<(String, String)> = corpora::table
466        .select((corpora::name, corpora::path))
467        .load(&mut connection)
468        .unwrap_or_default();
469      (reachable, migrations_current, corpora_paths)
470    },
471    Err(_) => (false, false, Vec::new()),
472  };
473  // Stat each corpus source path (local shared storage; a plain existence check, no read_dir). A
474  // corpus with an empty path has no configured location, so it is not a storage fault.
475  let corpora_checked = corpora_paths
476    .iter()
477    .filter(|(_, path)| !path.is_empty())
478    .count();
479  let unreadable: Vec<UnreadableCorpus> = corpora_paths
480    .into_iter()
481    .filter(|(_, path)| !path.is_empty() && !Path::new(path).is_dir())
482    .map(|(name, path)| UnreadableCorpus { name, path })
483    .collect();
484  // Sample utilization after the probe connection is returned, so it reflects concurrent load.
485  let state = pool.state();
486  let status = if reachable && migrations_current {
487    "ok"
488  } else {
489    "degraded"
490  };
491  let mut report = HealthDto {
492    status,
493    database: DbHealth { reachable },
494    migrations: MigrationsHealth {
495      current: migrations_current,
496    },
497    pool: PoolHealth {
498      max: pool.max_size(),
499      connections: state.connections,
500      idle: state.idle_connections,
501      in_use: state.connections.saturating_sub(state.idle_connections),
502    },
503    dispatcher: {
504      let dispatcher = &config().dispatcher;
505      DispatcherHealth {
506        reachable: port_listening(dispatcher.source_port) && port_listening(dispatcher.result_port),
507        source_port: dispatcher.source_port,
508        result_port: dispatcher.result_port,
509      }
510    },
511    storage: StorageHealth {
512      corpora_checked,
513      unreadable,
514    },
515    remediations: Vec::new(),
516  };
517  // Derive the operator guidance from the assembled signals.
518  report.remediations = report.remediations();
519  report
520}
521
522/// A cheap liveness probe: a single pooled `SELECT 1`, nothing else (no corpus stat / port probes),
523/// so the **public** endpoint stays O(1) and leaks no internal structure.
524fn liveness_report(pool: &DbPool) -> LivenessDto {
525  let reachable = pool
526    .get()
527    .map(|mut connection| {
528      diesel::sql_query("SELECT 1")
529        .execute(&mut *connection)
530        .is_ok()
531    })
532    .unwrap_or(false);
533  LivenessDto {
534    status: if reachable { "ok" } else { "degraded" },
535    database: DbHealth { reachable },
536  }
537}
538
539/// Public liveness probe — minimal by design (KNOWN_ISSUES X-1): `{status, database.reachable}`
540/// only, safe to expose unauthenticated at the edge for load balancers and agents. The *detailed*
541/// report (pool, dispatcher ports, corpus storage, remediations) is admin-only: the `/health`
542/// screen and its token-gated agent twin [`api_health`] (`GET /api/health`).
543#[rocket_okapi::openapi(tag = "Management")]
544#[get("/healthz")]
545pub fn healthz(pool: &State<DbPool>) -> Json<LivenessDto> { Json(liveness_report(pool)) }
546
547/// Detailed health report for agents — the **token-gated** JSON twin of the admin [`health_page`]
548/// screen (sharing [`HealthDto`]). Gated by the [`Actor`] guard (clean `401` without a token) so
549/// the internal topology it exposes (corpus paths, pool sizing, dispatcher ports) isn't
550/// world-readable like the open `/healthz` once was (KNOWN_ISSUES X-1).
551#[rocket_okapi::openapi(tag = "Management")]
552#[get("/api/health")]
553pub fn api_health(_caller: Actor, pool: &State<DbPool>) -> Json<HealthDto> {
554  Json(health_report(pool))
555}
556
557/// The human health screen: the HTML twin of `GET /api/health`, sharing [`HealthDto`] — database
558/// reachability, migration currency, and live connection-pool utilization at a glance. **Signed-in
559/// admins only** (unauthenticated → sign-in page); the public `/healthz` JSON probe stays open for
560/// liveness, but the detailed view is admin/token-gated.
561#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
562#[get("/health")]
563pub fn health_page(
564  session: Option<AdminSession>,
565  return_to: ReturnTo,
566  pool: &State<DbPool>,
567) -> Result<Template, AdminReject> {
568  require_admin_to(session, &return_to)?;
569  let health = health_report(pool);
570  let global = serde_json::json!({
571    "title": format!("System health — {}", health.status),
572    "description": "CorTeX system health: database, schema migrations, connection pool.",
573  });
574  Ok(Template::render("health", context! { global, health }))
575}
576
577/// The Settings page: the human (HTML) twin of `GET /api/config`. **Signed-in admins only**
578/// (unauthenticated → sign-in page); the agent twin keeps the token guard.
579#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
580#[get("/settings?<saved>")]
581pub fn settings(
582  saved: Option<bool>,
583  session: Option<AdminSession>,
584  return_to: ReturnTo,
585) -> Result<Template, AdminReject> {
586  require_admin_to(session, &return_to)?;
587  let global = serde_json::json!({
588    "title": "Configuration",
589    "description": "CorTeX framework configuration",
590  });
591  // `?saved=true` (set by the post-redirect-get after a successful save) flashes a confirmation, so
592  // the admin gets feedback on the write instead of a silent reload — the same pattern as the
593  // retention screen's `?pruned`.
594  Ok(Template::render(
595    "settings",
596    context! { global, config: ConfigDto::from_config(config()), saved: saved.unwrap_or(false) },
597  ))
598}
599
600/// Agent write path: deep-merge a partial config patch, persist it, and return the masked result.
601/// **Token-gated** via the [`Actor`] guard — rewriting the running configuration (dispatcher ports,
602/// queue/result sizes, asset dirs, the job stall threshold) is a consequential mutation, so it
603/// requires a valid `X-Cortex-Token` exactly like every other agent write (the human twin
604/// `post_settings` is `AdminSession`-gated). `401` without a token.
605#[rocket_okapi::openapi(tag = "Management")]
606#[put("/api/config", format = "json", data = "<patch>")]
607pub fn put_config(
608  patch: Json<serde_json::Value>,
609  actor: Actor,
610  config_file: &State<ConfigFile>,
611) -> Result<Json<ConfigDto>, Status> {
612  let patch = patch.into_inner();
613  // The changed top-level sections (keys only — never the values, which can carry secrets) for the
614  // operational journal; the audit fairing records the full action + actor to the DB.
615  let sections: Vec<&str> = patch
616    .as_object()
617    .map(|o| o.keys().map(String::as_str).collect())
618    .unwrap_or_default();
619  let merged = merge_and_persist(&patch, &config_file.0)?;
620  tracing::info!(actor = %actor.owner, sections = ?sections, "config updated via API");
621  Ok(Json(ConfigDto::from_config(&merged)))
622}
623
624/// Human write path: a native form POST from the Settings page; persists, then redirects back.
625/// **Gated by the signed-in [`AdminSession`] cookie** (the Settings screen is signed-in-only;
626/// anonymous → sign-in).
627#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
628#[post("/settings", data = "<form>")]
629pub fn post_settings(
630  form: Form<SettingsForm>,
631  session: Option<AdminSession>,
632  config_file: &State<ConfigFile>,
633) -> Result<Redirect, AdminReject> {
634  let _session = require_admin(session)?;
635  let f = form.into_inner();
636  let patch = serde_json::json!({
637    "dispatcher": {
638      "source_port": f.dispatcher_source_port,
639      "result_port": f.dispatcher_result_port,
640      "queue_size": f.dispatcher_queue_size,
641      "message_size": f.dispatcher_message_size,
642      "max_in_flight": f.dispatcher_max_in_flight,
643      "max_result_bytes": f.dispatcher_max_result_bytes,
644      "report_refresh_interval_seconds": f.dispatcher_report_refresh_interval_seconds,
645    },
646    "jobs": { "stale_timeout_seconds": f.jobs_stale_timeout_seconds },
647    "assets": { "template_dir": f.assets_template_dir, "public_dir": f.assets_public_dir },
648  });
649  merge_and_persist(&patch, &config_file.0)?;
650  Ok(Redirect::to("/settings?saved=true"))
651}
652
653/// Acknowledgement for a maintenance job: the background [`crate::jobs`] handle to poll.
654#[derive(Debug, Serialize, schemars::JsonSchema)]
655pub struct MaintenanceAckDto {
656  /// The spawned (or already-running, if debounced) maintenance job's external uuid.
657  pub job: String,
658  /// Where to poll the job's status / health / per-table progress.
659  pub poll: String,
660  /// The token-resolved actor recorded as the job's initiator.
661  pub actor: String,
662}
663
664/// Triggers an **online** index rebuild (`REINDEX (CONCURRENTLY)` over the high-churn tables) as a
665/// background job — index bloat slows scans over time, and this rebuilds without an exclusive lock
666/// (DB ongoing-maintenance; `docs/DB_TUNING.md`). **Token-gated**; returns `202` + the job handle,
667/// poll `GET /api/jobs/<job>` for per-table progress. Debounced.
668#[rocket_okapi::openapi(tag = "Management")]
669#[post("/api/maintenance/reindex")]
670pub fn reindex(
671  actor: Actor,
672  pool: &State<DbPool>,
673) -> Result<(Status, Json<MaintenanceAckDto>), Status> {
674  let job_uuid = crate::jobs::spawn_reindex(pool.inner().clone(), &actor.owner)
675    .map_err(|_| Status::InternalServerError)?;
676  Ok((
677    Status::Accepted,
678    Json(MaintenanceAckDto {
679      job: job_uuid.to_string(),
680      poll: format!("/api/jobs/{job_uuid}"),
681      actor: actor.owner,
682    }),
683  ))
684}
685
686/// The human twin of [`reindex`]: the health screen's "Reindex database now" button. **Gated by the
687/// signed-in [`AdminSession`] cookie** (the health screen is itself signed-in-only; anonymous →
688/// sign-in). Spawns the same debounced reindex job and redirects to `/jobs`.
689#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
690#[post("/maintenance/reindex")]
691pub fn reindex_human(
692  session: Option<AdminSession>,
693  pool: &State<DbPool>,
694) -> Result<rocket::response::Redirect, AdminReject> {
695  let session = require_admin(session)?;
696  let uuid = crate::jobs::spawn_reindex(pool.inner().clone(), &session.owner)
697    .map_err(|_| Status::InternalServerError)?;
698  Ok(rocket::response::Redirect::to(format!("/jobs/{uuid}")))
699}
700
701/// Triggers a planner-statistics refresh (`ANALYZE` over the high-churn tables) as a background job
702/// — keeps the planner's row estimates current after bulk imports/reruns so it keeps choosing the
703/// right indexes (e.g. the TODO leasing index) instead of waiting for autovacuum (DB
704/// ongoing-maintenance; `docs/DB_TUNING.md`). **Token-gated**; returns `202` + the job handle, poll
705/// `GET /api/jobs/<job>` for per-table progress. Debounced.
706#[rocket_okapi::openapi(tag = "Management")]
707#[post("/api/maintenance/analyze")]
708pub fn analyze(
709  actor: Actor,
710  pool: &State<DbPool>,
711) -> Result<(Status, Json<MaintenanceAckDto>), Status> {
712  let job_uuid = crate::jobs::spawn_analyze(pool.inner().clone(), &actor.owner)
713    .map_err(|_| Status::InternalServerError)?;
714  Ok((
715    Status::Accepted,
716    Json(MaintenanceAckDto {
717      job: job_uuid.to_string(),
718      poll: format!("/api/jobs/{job_uuid}"),
719      actor: actor.owner,
720    }),
721  ))
722}
723
724/// The human twin of [`analyze`]: the health screen's "Refresh planner statistics" button. **Gated
725/// by the signed-in [`AdminSession`] cookie** (anonymous → sign-in). Spawns the same debounced
726/// analyze job and redirects to `/jobs`.
727#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
728#[post("/maintenance/analyze")]
729pub fn analyze_human(
730  session: Option<AdminSession>,
731  pool: &State<DbPool>,
732) -> Result<rocket::response::Redirect, AdminReject> {
733  let session = require_admin(session)?;
734  let uuid = crate::jobs::spawn_analyze(pool.inner().clone(), &session.owner)
735    .map_err(|_| Status::InternalServerError)?;
736  Ok(rocket::response::Redirect::to(format!("/jobs/{uuid}")))
737}
738
739/// The route set for the management/health/settings capability.
740pub fn routes() -> Vec<Route> {
741  // NB: the agent management routes (`api_index`, `api_config`, `healthz`, `put_config`, `reindex`,
742  // `analyze`) are mounted via `frontend::apidoc` (rocket_okapi).
743  routes![
744    health_page,
745    reindex_human,
746    analyze_human,
747    settings,
748    post_settings
749  ]
750}
751
752#[cfg(test)]
753mod tests {
754  use super::*;
755
756  /// An all-clear report; tests mutate one signal at a time off this baseline.
757  fn healthy() -> HealthDto {
758    HealthDto {
759      status: "ok",
760      database: DbHealth { reachable: true },
761      migrations: MigrationsHealth { current: true },
762      pool: PoolHealth {
763        max: 32,
764        connections: 4,
765        idle: 4,
766        in_use: 0,
767      },
768      dispatcher: DispatcherHealth {
769        reachable: true,
770        source_port: 51695,
771        result_port: 51696,
772      },
773      storage: StorageHealth {
774        corpora_checked: 2,
775        unreadable: Vec::new(),
776      },
777      remediations: Vec::new(),
778    }
779  }
780
781  #[test]
782  fn healthy_report_has_no_remediations() {
783    assert!(
784      healthy().remediations().is_empty(),
785      "an all-clear report needs no actions"
786    );
787  }
788
789  #[test]
790  fn db_down_surfaces_only_the_db_fix() {
791    let mut health = healthy();
792    health.database.reachable = false;
793    health.migrations.current = false; // a consequence of the down DB — must not add its own hint
794    let hints = health.remediations();
795    assert_eq!(hints.len(), 1, "a down DB surfaces only the DB fix");
796    assert!(
797      hints[0].contains("database") && hints[0].contains("DATABASE_URL"),
798      "the DB hint names the URL to check"
799    );
800  }
801
802  #[test]
803  fn pending_migrations_point_at_init() {
804    let mut health = healthy();
805    health.migrations.current = false;
806    assert!(
807      health
808        .remediations()
809        .iter()
810        .any(|h| h.contains("cortex init")),
811      "pending migrations point at cortex init"
812    );
813  }
814
815  #[test]
816  fn pool_exhaustion_is_flagged_with_counts() {
817    let mut health = healthy();
818    health.pool.in_use = health.pool.max; // every connection checked out
819    assert!(
820      health
821        .remediations()
822        .iter()
823        .any(|h| h.contains("pool exhausted") && h.contains("32/32")),
824      "an exhausted pool is flagged with its in-use/max counts"
825    );
826  }
827
828  #[test]
829  fn dispatcher_and_storage_warnings_are_actionable() {
830    let mut health = healthy();
831    health.dispatcher.reachable = false;
832    health.storage.unreadable = vec![UnreadableCorpus {
833      name: "arxiv".to_string(),
834      path: "/data/arxiv".to_string(),
835    }];
836    let hints = health.remediations();
837    assert!(
838      hints.iter().any(|h| h.contains("dispatcher not listening")),
839      "an unreachable dispatcher is actionable"
840    );
841    assert!(
842      hints
843        .iter()
844        .any(|h| h.contains("unreadable") && h.contains("arxiv")),
845      "an unreadable corpus path is named in the hint"
846    );
847  }
848}