diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 218afbe..5f8a315 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -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, ) -> Result, 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::>() - .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> { - // 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 = 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 Result, +) -> Result, 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, ) -> Json { diff --git a/backend/src/catalog/mod.rs b/backend/src/catalog/mod.rs index cdbdedf..8b4cf94 100644 --- a/backend/src/catalog/mod.rs +++ b/backend/src/catalog/mod.rs @@ -302,6 +302,51 @@ pub async fn build_catalog() -> anyhow::Result> { } +/// 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 { diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 96f99f8..092efae 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -11,7 +11,8 @@ pub async fn init_db(database_url: &str) -> anyhow::Result { .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")?; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 0e120d3..f9fc3a7 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -285,6 +285,9 @@ export default function Settings() { const [recomputeMsg, setRecomputeMsg] = useState(''); const [rebuilding, setRebuilding] = useState(false); const [rebuildResult, setRebuildResult] = useState(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [resetting, setResetting] = useState(false); + const [resetMsg, setResetMsg] = useState(''); const triggerRecompute = async () => { setRecomputing(true); @@ -302,6 +305,26 @@ export default function Settings() { setRecomputing(false); }; + const triggerFactoryReset = async () => { + setResetting(true); + setResetMsg(''); + setShowResetConfirm(false); + try { + const res = await fetch('/api/factory-reset', { method: 'POST' }); + if (res.ok) { + setResetMsg('Reset started. Catalog is rebuilding (~60s). Reload the page when done.'); + qc.invalidateQueries({ queryKey: ['health'] }); + qc.invalidateQueries({ queryKey: ['targets'] }); + } else { + const d = await res.json().catch(() => ({})); + setResetMsg((d as { error?: string }).error ?? 'Backend returned an error.'); + } + } catch { + setResetMsg('Error reaching backend.'); + } + setResetting(false); + }; + const triggerRebuild = async () => { setRebuilding(true); setRebuildResult(null); @@ -477,11 +500,82 @@ export default function Settings() { {recomputing ? 'Starting…' : 'Recompute Tonight'} + + {/* Factory Reset */} +
+ {!showResetConfirm ? ( + + ) : ( +
+
+ Factory Reset +
+
+ Clears catalog, nightly cache, and weather data. Triggers a full rebuild (~60s). +
+ Imaging logs, gallery, PHD2 logs, and horizon are preserved. +
+
+ + +
+
+ )} +
{recomputeMsg && (
{recomputeMsg}
)} + {resetMsg && ( +
+ {resetMsg} +
+ )} {rebuildResult && (
{rebuildResult.error ? (