Add factory reset, safe catalog rebuild, and DB hardening

- Factory reset endpoint clears computed tables (catalog, nightly_cache,
  tonight, weather_cache), VACUUMs the DB, then rebuilds in background.
  Preserves all user data (imaging_log, gallery, phd2_logs, horizon).
- Catalog rebuild now fetches data BEFORE touching the DB — network
  failures no longer leave the catalog empty. DELETE + INSERT wrapped
  in a single transaction via replace_catalog() so a mid-write failure
  rolls back and old data is preserved.
- Added nightly_cache indexes and bumped pool to 10 connections with
  30s acquire timeout to prevent exhaustion during rebuilds.
- Settings page: factory reset button with inline confirmation dialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 11:19:55 +02:00
parent b5a1f40f27
commit 01ccae5951
4 changed files with 183 additions and 33 deletions
+42 -32
View File
@@ -103,6 +103,7 @@ pub fn build_router(pool: SqlitePool) -> Router {
.route("/api/catalog/refresh", post(catalog_refresh))
.route("/api/catalog/rebuild", get(catalog_rebuild))
.route("/api/nightly/recompute", post(nightly_recompute))
.route("/api/factory-reset", post(factory_reset))
// Stats
.route("/api/stats", get(stats::get_stats))
// Static gallery files served via tower-http
@@ -131,30 +132,13 @@ async fn catalog_rebuild(
axum::extract::State(state): axum::extract::State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
let pool = state.pool.clone();
match catalog_rebuild_task(&pool).await {
Ok(stats) => {
tracing::info!(
"Manual catalog rebuild complete: {} objects ({})",
stats.total,
stats.by_type.iter()
.map(|(t, c)| format!("{}: {}", t, c))
.collect::<Vec<_>>()
.join(", ")
);
Ok(Json(serde_json::json!({
"status": "success",
"total": stats.total,
"by_type": stats.by_type,
"messier_count": stats.messier_count,
"has_sizes": stats.has_sizes,
})))
tokio::spawn(async move {
match catalog_rebuild_task(&pool).await {
Ok(stats) => tracing::info!("Manual catalog rebuild complete: {} objects", stats.total),
Err(e) => tracing::error!("Manual catalog rebuild failed: {}", e),
}
Err(e) => {
tracing::error!("Manual catalog rebuild failed: {}", e);
Err(AppError::Internal(format!("Rebuild failed: {}", e)))
}
}
});
Ok(Json(serde_json::json!({ "status": "rebuild_started" })))
}
#[derive(serde::Serialize)]
@@ -166,15 +150,11 @@ struct RebuildStats {
}
async fn catalog_rebuild_task(pool: &SqlitePool) -> Result<RebuildStats, Box<dyn std::error::Error>> {
// Clear existing catalog
sqlx::query("DELETE FROM catalog").execute(pool).await?;
sqlx::query("DELETE FROM nightly_cache").execute(pool).await?;
// Build fresh catalog
// Fetch catalog data FIRST — if network fails, existing DB is untouched
let entries = crate::catalog::build_catalog().await?;
let total = entries.len();
// Compute stats
// Compute stats from in-memory entries before any DB writes
let mut by_type: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for entry in &entries {
*by_type.entry(entry.obj_type.clone()).or_insert(0) += 1;
@@ -182,8 +162,10 @@ async fn catalog_rebuild_task(pool: &SqlitePool) -> Result<RebuildStats, Box<dyn
let messier_count = entries.iter().filter(|e| e.messier_num.is_some()).count();
let has_sizes = entries.iter().filter(|e| e.size_arcmin_maj.is_some()).count();
// Upsert entries to database
crate::catalog::upsert_entries(pool, &entries).await?;
// Atomically replace catalog (DELETE + INSERT in one transaction).
// If the insert fails halfway, the transaction rolls back and old data is preserved.
sqlx::query("DELETE FROM nightly_cache").execute(pool).await?;
crate::catalog::replace_catalog(pool, &entries).await?;
// Update catalog version
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES ('catalog_version', ?)")
@@ -191,7 +173,7 @@ async fn catalog_rebuild_task(pool: &SqlitePool) -> Result<RebuildStats, Box<dyn
.execute(pool)
.await?;
// Automatically trigger nightly recompute
// Trigger nightly recompute
if let Err(e) = crate::jobs::precompute_tonight(pool).await {
tracing::warn!("Nightly precompute after rebuild failed: {}", e);
}
@@ -212,6 +194,34 @@ async fn nightly_recompute(
Ok(Json(serde_json::json!({ "status": "recompute_started" })))
}
async fn factory_reset(
axum::extract::State(state): axum::extract::State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
tracing::info!("Factory reset: clearing transient data...");
// Clear all computed/cached tables — preserve user data (imaging_log, gallery, phd2_logs, horizon, target_notes, custom_targets)
for table in &["nightly_cache", "tonight", "catalog", "weather_cache"] {
sqlx::query(&format!("DELETE FROM {}", table))
.execute(&state.pool)
.await?;
}
// Reclaim disk space
sqlx::query("VACUUM").execute(&state.pool).await?;
tracing::info!("Factory reset: VACUUM complete");
// Rebuild catalog + nightly precompute in background
let pool = state.pool.clone();
tokio::spawn(async move {
match catalog_rebuild_task(&pool).await {
Ok(stats) => tracing::info!("Factory reset rebuild complete: {} objects", stats.total),
Err(e) => tracing::error!("Factory reset rebuild failed: {}", e),
}
});
Ok(Json(serde_json::json!({ "status": "reset_started", "message": "Catalog cleared and rebuild started. Imaging logs and gallery preserved." })))
}
async fn health(
axum::extract::State(state): axum::extract::State<AppState>,
) -> Json<serde_json::Value> {
+45
View File
@@ -302,6 +302,51 @@ pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
}
/// Atomically replace the entire catalog: DELETE then INSERT in one transaction.
/// If the insert fails halfway, the transaction rolls back and the old catalog is preserved.
/// Call this only after build_catalog() has already succeeded.
pub async fn replace_catalog(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> {
let mut tx = pool.begin().await?;
sqlx::query("DELETE FROM catalog").execute(&mut *tx).await?;
for e in entries {
sqlx::query(
r#"INSERT OR REPLACE INTO catalog
(id, name, common_name, obj_type, ra_deg, dec_deg, ra_h, dec_dms,
constellation, size_arcmin_maj, size_arcmin_min, pos_angle_deg,
mag_v, surface_brightness, hubble_type, messier_num, is_highlight,
fov_fill_pct, mosaic_flag, mosaic_panels_w, mosaic_panels_h,
difficulty, guide_star_density, fetched_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"#,
)
.bind(&e.id).bind(&e.name).bind(&e.common_name).bind(&e.obj_type)
.bind(e.ra_deg).bind(e.dec_deg).bind(&e.ra_h).bind(&e.dec_dms)
.bind(&e.constellation).bind(e.size_arcmin_maj).bind(e.size_arcmin_min)
.bind(e.pos_angle_deg).bind(e.mag_v).bind(e.surface_brightness)
.bind(&e.hubble_type).bind(e.messier_num).bind(e.is_highlight)
.bind(e.fov_fill_pct).bind(e.mosaic_flag).bind(e.mosaic_panels_w)
.bind(e.mosaic_panels_h).bind(e.difficulty).bind(e.guide_star_density.as_deref())
.bind(e.fetched_at)
.execute(&mut *tx).await?;
}
tx.commit().await?;
// Apply cross-catalog number mappings (best-effort, outside transaction)
for (num, id) in caldwell::caldwell_map() {
let _ = sqlx::query("UPDATE catalog SET caldwell_num = ? WHERE id = ?").bind(num).bind(id).execute(pool).await;
}
for (num, id) in caldwell::arp_map() {
let _ = sqlx::query("UPDATE catalog SET arp_num = ? WHERE id = ?").bind(num).bind(id).execute(pool).await;
}
for (num, id) in melotte::melotte_map() {
let _ = sqlx::query("UPDATE catalog SET melotte_num = ? WHERE id = ?").bind(num).bind(id).execute(pool).await;
}
for (num, id) in collinder::collinder_map() {
let _ = sqlx::query("UPDATE catalog SET collinder_num = ? WHERE id = ?").bind(num).bind(id).execute(pool).await;
}
Ok(())
}
pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> {
let mut tx = pool.begin().await?;
for e in entries {
+2 -1
View File
@@ -11,7 +11,8 @@ pub async fn init_db(database_url: &str) -> anyhow::Result<SqlitePool> {
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.max_connections(10)
.acquire_timeout(std::time::Duration::from_secs(30))
.connect_with(options)
.await
.context("failed to connect to SQLite")?;