feat(admin): runtime-editable crawler & analysis config in the dashboard
Some checks failed
deploy / test-backend (push) Waiting to run
deploy / build-and-push (push) Has been cancelled
deploy / deploy (push) Has been cancelled
deploy / test-frontend (push) Has been cancelled

Move crawler and analysis configuration out of boot-only env vars and into
a DB-backed, admin-editable surface applied live without a restart.

- New `app_settings` table (migration 0026) holds one JSONB row per group;
  env vars seed it on first boot, then the DB is the source of truth.
- `settings.rs` adds serializable Crawler/Analysis DTOs with env-base +
  DB-overlay conversion and field-level validation; env-only/secret fields
  (browser, proxy, TOR, vision API key, PHPSESSID) are never persisted.
- `GET/PUT /api/v1/admin/settings/{crawler,analysis}` (RequireAdmin,
  cookie-only, CSRF-guarded): GET returns the editable DTO + a read-only
  env-managed view + prompt defaults; PUT validates (422 + per-field
  details), persists + audits in one tx, then gracefully respawns the
  affected daemon via a Supervisor.
- AppState swaps the crawler control/resync handles and analysis enable
  gate behind shared runtime cells so reloads take effect with no restart;
  a boot-time spawn failure is logged rather than aborting startup.
- Make the three vision prompts and sampling temperature configurable
  (defaults preserved); cookie_domain is admin-editable too.
- Frontend: tabbed /admin/settings page with grouped fieldsets, prompt
  reset-to-default, env-managed read-only panel, save-&-apply confirm, and
  per-field validation; typed client + ApiError.details.
- Bump version 0.79.1 -> 0.80.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-14 13:32:20 +02:00
parent d3b827421f
commit 2b7a11b480
30 changed files with 2800 additions and 165 deletions

View File

@@ -1,5 +1,6 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::RwLock as StdRwLock;
use anyhow::Context;
use async_trait::async_trait;
@@ -17,7 +18,7 @@ use tower_http::trace::TraceLayer;
use crate::auth::extractor::CurrentUser;
use crate::auth::rate_limit::AuthRateLimiter;
use crate::error::AppError;
use crate::config::{AuthConfig, Config, CrawlerConfig, UploadConfig};
use crate::config::{AnalysisConfig, AuthConfig, Config, CrawlerConfig, UploadConfig};
use crate::crawler::browser_manager::{self, BrowserManager};
use crate::crawler::content::{self, SyncOutcome};
use crate::crawler::daemon::{self, ChapterDispatcher, DaemonConfig, MetadataPass};
@@ -40,33 +41,125 @@ pub struct AppState {
/// One instance per AppState so tests stay isolated across the
/// same process.
pub auth_limiter: Arc<AuthRateLimiter>,
/// Admin-triggered force resync. `None` when the crawler daemon
/// is disabled (`CRAWLER_DAEMON=false`); admin handlers gate on
/// `.is_some()` and return 503 otherwise. Set by [`build`] from the
/// same wiring that builds the daemon's chapter dispatcher, so a
/// force resync uses the daemon's BrowserManager + rate limiters.
pub resync: Option<Arc<dyn ResyncService>>,
/// Crawler observability + control handle (live status, coordinated
/// browser restart, runtime session, manual run). `None` when the
/// daemon is disabled; admin handlers gate on `.is_some()` → 503.
pub crawler: Option<Arc<CrawlerControl>>,
/// Runtime-swappable controls (crawler resync/control handles + the
/// analysis enable gate). Shared with the [`Supervisors`] so a config
/// reload that respawns a daemon updates what handlers see, live —
/// without a restart. In tests this starts empty (no daemons).
pub runtime: Arc<RuntimeControls>,
/// Applies a persisted settings change to the running daemons
/// (graceful stop + respawn). `Some` in production ([`build`]); `None`
/// in the test harness, where settings still persist but no daemon is
/// spawned. See [`DaemonReloader`].
pub reloader: Option<Arc<dyn DaemonReloader>>,
/// Env-derived crawler config — the **base** the stored settings DTO is
/// overlaid onto (carries env-only fields: browser, proxy, TOR, session).
pub crawler_base: CrawlerConfig,
/// Env-derived analysis config base (carries the env-only vision API key).
pub analysis_base: AnalysisConfig,
/// Browser origins permitted to issue mutating requests to
/// `/api/v1/admin/*`. See [`crate::config::Config::admin_allowed_origins`]
/// for the policy. Cloned per-request into the CSRF middleware; the
/// `Arc` keeps the clone cheap. Empty list → check is skipped
/// (operator opt-out documented in `.env.example`).
pub admin_allowed_origins: Arc<Vec<String>>,
/// When `true`, page-create paths (upload + crawler) enqueue
/// `analyze_page` jobs and the admin re-enqueue endpoints are active.
/// Mirrors `config.analysis.enabled`; defaults to `false` so the
/// feature is opt-in and the test harness inherits a no-op gate.
pub analysis_enabled: bool,
/// Live analysis-event broadcaster. Always present (the worker and the
/// admin enqueue path publish here; the SSE endpoint subscribes). When
/// the worker is disabled the channel simply stays quiet.
pub analysis_events: Arc<crate::analysis::events::AnalysisEvents>,
}
impl AppState {
/// Current crawler control handle, or `None` when the daemon is stopped.
pub fn crawler(&self) -> Option<Arc<CrawlerControl>> {
self.runtime.crawler_control()
}
/// Current force-resync service, or `None` when the daemon is stopped.
pub fn resync(&self) -> Option<Arc<dyn ResyncService>> {
self.runtime.resync()
}
/// Whether AI page analysis is currently enabled (read live).
pub fn analysis_enabled(&self) -> bool {
self.runtime.analysis_enabled()
}
}
/// The crawler control handles, swapped atomically when the daemon is
/// (re)spawned or stopped. Cloned cheaply on every read.
#[derive(Clone, Default)]
struct CrawlerControls {
resync: Option<Arc<dyn ResyncService>>,
control: Option<Arc<CrawlerControl>>,
}
/// Shared, runtime-mutable surface read by handlers and mutated by the
/// [`Supervisors`] on a config reload. The analysis gate is an `Arc<AtomicBool>`
/// so the crawler's chapter dispatcher (which enqueues `analyze_page` jobs)
/// sees toggles live without being respawned.
pub struct RuntimeControls {
crawler: StdRwLock<CrawlerControls>,
analysis_enabled: Arc<AtomicBool>,
}
impl RuntimeControls {
/// Empty controls (no crawler daemon) with the given initial analysis gate.
pub fn new(analysis_enabled: bool) -> Self {
Self {
crawler: StdRwLock::new(CrawlerControls::default()),
analysis_enabled: Arc::new(AtomicBool::new(analysis_enabled)),
}
}
pub fn analysis_enabled(&self) -> bool {
self.analysis_enabled.load(Ordering::Relaxed)
}
/// Flip the analysis gate. Used by the [`Supervisors`] on reload and by
/// the test harness's stub reloader.
pub fn set_analysis_enabled(&self, v: bool) {
self.analysis_enabled.store(v, Ordering::Relaxed);
}
/// The shared gate handle, passed to the crawler chapter dispatcher.
fn analysis_gate(&self) -> Arc<AtomicBool> {
Arc::clone(&self.analysis_enabled)
}
pub fn crawler_control(&self) -> Option<Arc<CrawlerControl>> {
self.crawler.read().unwrap().control.clone()
}
pub fn resync(&self) -> Option<Arc<dyn ResyncService>> {
self.crawler.read().unwrap().resync.clone()
}
/// Test helper: install a resync service without a running daemon.
pub fn set_resync(&self, resync: Option<Arc<dyn ResyncService>>) {
self.crawler.write().unwrap().resync = resync;
}
fn set_crawler(
&self,
resync: Option<Arc<dyn ResyncService>>,
control: Option<Arc<CrawlerControl>>,
) {
let mut g = self.crawler.write().unwrap();
g.resync = resync;
g.control = control;
}
}
/// Applies a validated settings change to the running daemons by gracefully
/// stopping and respawning them with the new effective config (and updating
/// the shared [`RuntimeControls`]). Implemented by [`Supervisors`] in
/// production; the settings handlers call it after persisting.
#[async_trait]
pub trait DaemonReloader: Send + Sync {
async fn reload_crawler(&self, cfg: CrawlerConfig) -> anyhow::Result<()>;
async fn reload_analysis(&self, cfg: AnalysisConfig) -> anyhow::Result<()>;
}
/// Shared handle the admin crawler endpoints use to observe and control
/// the running daemon. Bundled so the handlers take one optional field on
/// `AppState` rather than many.
@@ -90,27 +183,96 @@ pub struct CrawlerControl {
}
/// Bundle returned by [`build`]. The router is what `axum::serve` consumes;
/// the daemon (when enabled) outlives the HTTP server and is awaited via
/// [`AppHandle::shutdown`] after the listener has finished gracefully.
/// the [`Supervisors`] own the background daemons (when enabled), outlive the
/// HTTP server, and are awaited via [`AppHandle::shutdown`] after the listener
/// has finished gracefully.
pub struct AppHandle {
pub router: Router,
pub daemon: Option<daemon::DaemonHandle>,
/// AI content-analysis worker daemon; `None` when `ANALYSIS_ENABLED`
/// is off. Awaited alongside the crawler daemon on shutdown.
pub analysis_daemon: Option<crate::analysis::daemon::AnalysisDaemonHandle>,
pub supervisors: Arc<Supervisors>,
}
impl AppHandle {
pub async fn shutdown(self) {
if let Some(d) = self.daemon {
d.shutdown().await;
self.supervisors.shutdown().await;
}
}
/// Owns the crawler + analysis daemon handles and respawns them on a config
/// reload. Holds the build-time inputs (db, storage, env-base configs, the
/// shared event bus and [`RuntimeControls`]) needed to spawn from scratch.
pub struct Supervisors {
db: PgPool,
storage: Arc<dyn Storage>,
runtime: Arc<RuntimeControls>,
analysis_events: Arc<crate::analysis::events::AnalysisEvents>,
/// Serializes reloads + owns the live crawler daemon handle.
crawler_handle: tokio::sync::Mutex<Option<daemon::DaemonHandle>>,
/// Serializes reloads + owns the live analysis daemon handle.
analysis_handle: tokio::sync::Mutex<Option<crate::analysis::daemon::AnalysisDaemonHandle>>,
}
impl Supervisors {
pub async fn shutdown(&self) {
if let Some(h) = self.crawler_handle.lock().await.take() {
h.shutdown().await;
}
if let Some(a) = self.analysis_daemon {
a.shutdown().await;
if let Some(h) = self.analysis_handle.lock().await.take() {
h.shutdown().await;
}
}
}
#[async_trait]
impl DaemonReloader for Supervisors {
async fn reload_crawler(&self, cfg: CrawlerConfig) -> anyhow::Result<()> {
// Serialize reloads; do the (possibly slow) graceful shutdown under
// the handle lock but outside the read-path RwLock so status reads
// never block on a draining browser.
let mut guard = self.crawler_handle.lock().await;
if let Some(h) = guard.take() {
h.shutdown().await;
}
self.runtime.set_crawler(None, None);
if cfg.daemon_enabled {
let spawned = spawn_crawler_daemon(
self.db.clone(),
Arc::clone(&self.storage),
&cfg,
self.runtime.analysis_gate(),
)
.await?;
self.runtime
.set_crawler(Some(spawned.resync), Some(spawned.crawler));
*guard = Some(spawned.handle);
tracing::info!("crawler daemon (re)started from settings");
} else {
tracing::info!("crawler daemon stopped (disabled in settings)");
}
Ok(())
}
async fn reload_analysis(&self, cfg: AnalysisConfig) -> anyhow::Result<()> {
let mut guard = self.analysis_handle.lock().await;
if let Some(h) = guard.take() {
h.shutdown().await;
}
self.runtime.set_analysis_enabled(cfg.enabled);
if cfg.enabled {
let handle = spawn_analysis_daemon(
self.db.clone(),
Arc::clone(&self.storage),
&cfg,
Arc::clone(&self.analysis_events),
)?;
*guard = Some(handle);
tracing::info!(model = %cfg.model, "analysis daemon (re)started from settings");
} else {
tracing::info!("analysis daemon stopped (disabled in settings)");
}
Ok(())
}
}
pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
let db = PgPoolOptions::new()
.max_connections(10)
@@ -127,60 +289,48 @@ pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
let storage: Arc<dyn Storage> = Arc::new(LocalStorage::new(config.storage_dir.clone()));
let (daemon, resync, crawler) = if config.crawler.daemon_enabled {
let spawned = spawn_crawler_daemon(
db.clone(),
Arc::clone(&storage),
&config.crawler,
config.analysis.enabled,
)
.await?;
(Some(spawned.handle), Some(spawned.resync), Some(spawned.crawler))
} else {
tracing::info!("crawler daemon disabled (CRAWLER_DAEMON=false)");
(None, None, None)
};
// Seed the settings rows from env on first boot, then load the effective
// config (DB overlaid on the env base). The DB is the source of truth
// after the first boot; env only fills a missing row.
let crawler_cfg = load_effective_crawler(&db, &config.crawler).await?;
let analysis_cfg = load_effective_analysis(&db, &config.analysis).await?;
// Live-event bus shared by the worker (progress) and the admin enqueue
// path; the SSE endpoint subscribes. Created unconditionally so the
// stream exists even with the worker disabled.
let analysis_events = Arc::new(crate::analysis::events::AnalysisEvents::new());
// AI content-analysis worker. Independent of the crawler daemon (works
// for uploads with the crawler off), gated on ANALYSIS_ENABLED. Uses a
// plain reqwest client — no cookie jar / proxy, unlike the crawler's.
let analysis_daemon = if config.analysis.enabled {
let http = reqwest::Client::builder()
.timeout(config.analysis.request_timeout)
.build()
.context("build analysis http client")?;
let vision = crate::analysis::vision::VisionClient::new(http, &config.analysis);
let dispatcher = Arc::new(crate::analysis::daemon::RealAnalyzeDispatcher {
db: db.clone(),
storage: Arc::clone(&storage),
vision,
model: config.analysis.model.clone(),
max_image_bytes: config.analysis.max_image_bytes,
});
let handle = crate::analysis::daemon::spawn(
db.clone(),
tokio_util::sync::CancellationToken::new(),
crate::analysis::daemon::AnalysisDaemonConfig {
dispatcher,
workers: config.analysis.workers,
job_timeout: config.analysis.job_timeout,
events: Arc::clone(&analysis_events),
},
);
tracing::info!(
workers = config.analysis.workers,
model = %config.analysis.model,
"analysis worker daemon started"
);
Some(handle)
let runtime = Arc::new(RuntimeControls::new(analysis_cfg.enabled));
let supervisors = Arc::new(Supervisors {
db: db.clone(),
storage: Arc::clone(&storage),
runtime: Arc::clone(&runtime),
analysis_events: Arc::clone(&analysis_events),
crawler_handle: tokio::sync::Mutex::new(None),
analysis_handle: tokio::sync::Mutex::new(None),
});
// Initial spawn goes through the same reload path so there's one code
// path for "bring the daemon up with this config". A spawn failure is
// logged but does NOT abort startup: the persisted settings are the
// source of truth and a bad value (e.g. a config that won't launch the
// browser) must not wedge the whole server into a boot loop — the
// server comes up with that daemon stopped so an admin can fix it via
// the settings UI.
if crawler_cfg.daemon_enabled {
if let Err(e) = supervisors.reload_crawler(crawler_cfg).await {
tracing::error!(?e, "crawler daemon failed to start; continuing with it stopped");
}
} else {
None
};
tracing::info!("crawler daemon disabled");
}
if analysis_cfg.enabled {
if let Err(e) = supervisors.reload_analysis(analysis_cfg).await {
tracing::error!(?e, "analysis worker failed to start; continuing with it stopped");
}
} else {
tracing::info!("analysis worker disabled");
}
let auth_limiter = Arc::new(AuthRateLimiter::new(config.auth.rate_limit));
let state = AppState {
@@ -189,14 +339,99 @@ pub async fn build(config: Config) -> anyhow::Result<AppHandle> {
auth: config.auth.clone(),
upload: config.upload.clone(),
auth_limiter,
resync,
crawler,
runtime,
reloader: Some(Arc::clone(&supervisors) as Arc<dyn DaemonReloader>),
crawler_base: config.crawler.clone(),
analysis_base: config.analysis.clone(),
admin_allowed_origins: Arc::new(config.admin_allowed_origins.clone()),
analysis_enabled: config.analysis.enabled,
analysis_events,
};
let router = router(state).layer(cors_layer(&config.cors_allowed_origins));
Ok(AppHandle { router, daemon, analysis_daemon })
Ok(AppHandle { router, supervisors })
}
/// Seed the crawler settings row from env when absent, then return the
/// effective [`CrawlerConfig`] (DB DTO overlaid on the env base). A stored
/// DTO that fails to convert (corruption / drift) falls back to the env base
/// rather than blocking startup.
async fn load_effective_crawler(
db: &PgPool,
base: &CrawlerConfig,
) -> anyhow::Result<CrawlerConfig> {
use crate::settings::{CrawlerSettings, KEY_CRAWLER};
let dto = match repo::app_settings::get(db, KEY_CRAWLER).await? {
Some(v) => serde_json::from_value::<CrawlerSettings>(v)
.unwrap_or_else(|_| CrawlerSettings::from_config(base)),
None => {
let dto = CrawlerSettings::from_config(base);
repo::app_settings::seed_if_absent(db, KEY_CRAWLER, &serde_json::to_value(&dto)?)
.await?;
tracing::info!("seeded crawler settings from env");
dto
}
};
Ok(dto.to_config(base).unwrap_or_else(|e| {
tracing::warn!(?e, "stored crawler settings invalid; using env base");
base.clone()
}))
}
/// Analysis counterpart of [`load_effective_crawler`].
async fn load_effective_analysis(
db: &PgPool,
base: &AnalysisConfig,
) -> anyhow::Result<AnalysisConfig> {
use crate::settings::{AnalysisSettings, KEY_ANALYSIS};
let dto = match repo::app_settings::get(db, KEY_ANALYSIS).await? {
Some(v) => serde_json::from_value::<AnalysisSettings>(v)
.unwrap_or_else(|_| AnalysisSettings::from_config(base)),
None => {
let dto = AnalysisSettings::from_config(base);
repo::app_settings::seed_if_absent(db, KEY_ANALYSIS, &serde_json::to_value(&dto)?)
.await?;
tracing::info!("seeded analysis settings from env");
dto
}
};
Ok(dto.to_config(base).unwrap_or_else(|e| {
tracing::warn!(?e, "stored analysis settings invalid; using env base");
base.clone()
}))
}
/// Spawn the AI content-analysis worker daemon with the given config. Returns
/// its handle. Independent of the crawler daemon (works for uploads with the
/// crawler off). Uses a plain reqwest client — no cookie jar / proxy.
fn spawn_analysis_daemon(
db: PgPool,
storage: Arc<dyn Storage>,
cfg: &AnalysisConfig,
events: Arc<crate::analysis::events::AnalysisEvents>,
) -> anyhow::Result<crate::analysis::daemon::AnalysisDaemonHandle> {
let http = reqwest::Client::builder()
.timeout(cfg.request_timeout)
.build()
.context("build analysis http client")?;
let vision = crate::analysis::vision::VisionClient::new(http, cfg);
let dispatcher = Arc::new(crate::analysis::daemon::RealAnalyzeDispatcher {
db: db.clone(),
storage,
vision,
model: cfg.model.clone(),
max_image_bytes: cfg.max_image_bytes,
});
let handle = crate::analysis::daemon::spawn(
db,
CancellationToken::new(),
crate::analysis::daemon::AnalysisDaemonConfig {
dispatcher,
workers: cfg.workers,
job_timeout: cfg.job_timeout,
events,
},
);
tracing::info!(workers = cfg.workers, model = %cfg.model, "analysis worker daemon started");
Ok(handle)
}
/// Bundle returned by [`spawn_crawler_daemon`]. The handle owns the
@@ -213,7 +448,7 @@ async fn spawn_crawler_daemon(
db: PgPool,
storage: Arc<dyn Storage>,
cfg: &CrawlerConfig,
analysis_enabled: bool,
analysis_enabled: Arc<AtomicBool>,
) -> anyhow::Result<SpawnedDaemon> {
// Reqwest client with a shared cookie jar so CDN image fetches include
// PHPSESSID. The same `Arc<Jar>` is held by the SessionController, so a
@@ -512,9 +747,10 @@ struct RealChapterDispatcher {
rate: Arc<HostRateLimiters>,
download_allowlist: DownloadAllowlist,
max_image_bytes: usize,
/// Enqueue `analyze_page` jobs for freshly-crawled pages. Mirrors
/// `config.analysis.enabled`.
analysis_enabled: bool,
/// Enqueue `analyze_page` jobs for freshly-crawled pages. Shared gate
/// (read live) so toggling analysis at runtime takes effect without a
/// crawler respawn. Mirrors the analysis enable setting.
analysis_enabled: Arc<AtomicBool>,
/// Consecutive transient chapter failures; resets on any success.
/// Drives the automatic coordinated browser restart.
transient_failures: Arc<std::sync::atomic::AtomicU32>,
@@ -570,7 +806,7 @@ impl ChapterDispatcher for RealChapterDispatcher {
self.max_image_bytes,
self.tor.as_deref(),
Some(&self.status),
self.analysis_enabled,
self.analysis_enabled.load(Ordering::Relaxed),
)
.await;
drop(lease);