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>
55 lines
1.8 KiB
Rust
55 lines
1.8 KiB
Rust
//! Persistence for runtime-editable application settings (`app_settings`),
|
|
//! a small key-value JSONB table (one row per subsystem group). Mirrors the
|
|
//! `crawler_state` access style: plain async fns over `&PgPool`, the `value`
|
|
//! a `serde_json::Value` the caller (de)serializes into a settings DTO.
|
|
|
|
use sqlx::{PgExecutor, PgPool};
|
|
|
|
/// Fetch the raw JSONB for a settings group, or `None` if unset.
|
|
pub async fn get(pool: &PgPool, key: &str) -> sqlx::Result<Option<serde_json::Value>> {
|
|
sqlx::query_scalar("SELECT value FROM app_settings WHERE key = $1")
|
|
.bind(key)
|
|
.fetch_optional(pool)
|
|
.await
|
|
}
|
|
|
|
/// Insert-or-replace the JSONB for a settings group, stamping `updated_at`.
|
|
/// Generic over the executor so it can run inside the same transaction as
|
|
/// the matching admin-audit insert.
|
|
pub async fn upsert<'e, E: PgExecutor<'e>>(
|
|
executor: E,
|
|
key: &str,
|
|
value: &serde_json::Value,
|
|
) -> sqlx::Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO app_settings (key, value, updated_at) \
|
|
VALUES ($1, $2, now()) \
|
|
ON CONFLICT (key) DO UPDATE \
|
|
SET value = EXCLUDED.value, updated_at = now()",
|
|
)
|
|
.bind(key)
|
|
.bind(value)
|
|
.execute(executor)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Write the value only if the row is absent (the env → DB boot seed). Returns
|
|
/// `true` when a row was inserted, `false` when one already existed. Uses
|
|
/// `ON CONFLICT DO NOTHING` so a concurrent boot can't race two seeds.
|
|
pub async fn seed_if_absent(
|
|
pool: &PgPool,
|
|
key: &str,
|
|
value: &serde_json::Value,
|
|
) -> sqlx::Result<bool> {
|
|
let result = sqlx::query(
|
|
"INSERT INTO app_settings (key, value) VALUES ($1, $2) \
|
|
ON CONFLICT (key) DO NOTHING",
|
|
)
|
|
.bind(key)
|
|
.bind(value)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(result.rows_affected() > 0)
|
|
}
|