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
+41 -31
View File
@@ -103,6 +103,7 @@ pub fn build_router(pool: SqlitePool) -> Router {
.route("/api/catalog/refresh", post(catalog_refresh)) .route("/api/catalog/refresh", post(catalog_refresh))
.route("/api/catalog/rebuild", get(catalog_rebuild)) .route("/api/catalog/rebuild", get(catalog_rebuild))
.route("/api/nightly/recompute", post(nightly_recompute)) .route("/api/nightly/recompute", post(nightly_recompute))
.route("/api/factory-reset", post(factory_reset))
// Stats // Stats
.route("/api/stats", get(stats::get_stats)) .route("/api/stats", get(stats::get_stats))
// Static gallery files served via tower-http // Static gallery files served via tower-http
@@ -131,30 +132,13 @@ async fn catalog_rebuild(
axum::extract::State(state): axum::extract::State<AppState>, axum::extract::State(state): axum::extract::State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> { ) -> Result<Json<serde_json::Value>, AppError> {
let pool = state.pool.clone(); let pool = state.pool.clone();
tokio::spawn(async move {
match catalog_rebuild_task(&pool).await { match catalog_rebuild_task(&pool).await {
Ok(stats) => { Ok(stats) => tracing::info!("Manual catalog rebuild complete: {} objects", stats.total),
tracing::info!( Err(e) => tracing::error!("Manual catalog rebuild failed: {}", e),
"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,
})))
}
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)] #[derive(serde::Serialize)]
@@ -166,15 +150,11 @@ struct RebuildStats {
} }
async fn catalog_rebuild_task(pool: &SqlitePool) -> Result<RebuildStats, Box<dyn std::error::Error>> { async fn catalog_rebuild_task(pool: &SqlitePool) -> Result<RebuildStats, Box<dyn std::error::Error>> {
// Clear existing catalog // Fetch catalog data FIRST — if network fails, existing DB is untouched
sqlx::query("DELETE FROM catalog").execute(pool).await?;
sqlx::query("DELETE FROM nightly_cache").execute(pool).await?;
// Build fresh catalog
let entries = crate::catalog::build_catalog().await?; let entries = crate::catalog::build_catalog().await?;
let total = entries.len(); 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(); let mut by_type: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for entry in &entries { for entry in &entries {
*by_type.entry(entry.obj_type.clone()).or_insert(0) += 1; *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 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(); let has_sizes = entries.iter().filter(|e| e.size_arcmin_maj.is_some()).count();
// Upsert entries to database // Atomically replace catalog (DELETE + INSERT in one transaction).
crate::catalog::upsert_entries(pool, &entries).await?; // 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 // Update catalog version
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES ('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) .execute(pool)
.await?; .await?;
// Automatically trigger nightly recompute // Trigger nightly recompute
if let Err(e) = crate::jobs::precompute_tonight(pool).await { if let Err(e) = crate::jobs::precompute_tonight(pool).await {
tracing::warn!("Nightly precompute after rebuild failed: {}", e); tracing::warn!("Nightly precompute after rebuild failed: {}", e);
} }
@@ -212,6 +194,34 @@ async fn nightly_recompute(
Ok(Json(serde_json::json!({ "status": "recompute_started" }))) 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( async fn health(
axum::extract::State(state): axum::extract::State<AppState>, axum::extract::State(state): axum::extract::State<AppState>,
) -> Json<serde_json::Value> { ) -> 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<()> { pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> {
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
for e in entries { 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); .foreign_keys(true);
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(10)
.acquire_timeout(std::time::Duration::from_secs(30))
.connect_with(options) .connect_with(options)
.await .await
.context("failed to connect to SQLite")?; .context("failed to connect to SQLite")?;
+94
View File
@@ -285,6 +285,9 @@ export default function Settings() {
const [recomputeMsg, setRecomputeMsg] = useState(''); const [recomputeMsg, setRecomputeMsg] = useState('');
const [rebuilding, setRebuilding] = useState(false); const [rebuilding, setRebuilding] = useState(false);
const [rebuildResult, setRebuildResult] = useState<any>(null); const [rebuildResult, setRebuildResult] = useState<any>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [resetting, setResetting] = useState(false);
const [resetMsg, setResetMsg] = useState('');
const triggerRecompute = async () => { const triggerRecompute = async () => {
setRecomputing(true); setRecomputing(true);
@@ -302,6 +305,26 @@ export default function Settings() {
setRecomputing(false); 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 () => { const triggerRebuild = async () => {
setRebuilding(true); setRebuilding(true);
setRebuildResult(null); setRebuildResult(null);
@@ -477,11 +500,82 @@ export default function Settings() {
{recomputing ? 'Starting…' : 'Recompute Tonight'} {recomputing ? 'Starting…' : 'Recompute Tonight'}
</button> </button>
</div> </div>
{/* Factory Reset */}
<div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
{!showResetConfirm ? (
<button
onClick={() => setShowResetConfirm(true)}
disabled={resetting}
style={{
padding: '6px 16px',
background: 'rgba(224,82,82,0.1)',
border: '1px solid rgba(224,82,82,0.4)',
color: 'var(--danger)',
borderRadius: 4,
fontFamily: 'var(--font-mono)',
fontSize: 12,
cursor: resetting ? 'default' : 'pointer',
opacity: resetting ? 0.6 : 1,
}}
>
{resetting ? 'Resetting…' : 'Factory Reset'}
</button>
) : (
<div style={{ background: 'rgba(224,82,82,0.08)', border: '1px solid rgba(224,82,82,0.3)', borderRadius: 4, padding: '12px 14px' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--danger)', marginBottom: 8, fontWeight: 600 }}>
Factory Reset
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', marginBottom: 10, lineHeight: 1.5 }}>
Clears catalog, nightly cache, and weather data. Triggers a full rebuild (~60s).
<br />
<strong style={{ color: 'var(--text-hi)' }}>Imaging logs, gallery, PHD2 logs, and horizon are preserved.</strong>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={triggerFactoryReset}
style={{
padding: '5px 14px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
borderRadius: 3,
fontFamily: 'var(--font-mono)',
fontSize: 12,
cursor: 'pointer',
}}
>
Confirm Reset
</button>
<button
onClick={() => setShowResetConfirm(false)}
style={{
padding: '5px 14px',
background: 'var(--bg-deep)',
border: '1px solid var(--border)',
color: 'var(--text-mid)',
borderRadius: 3,
fontFamily: 'var(--font-mono)',
fontSize: 12,
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
{recomputeMsg && ( {recomputeMsg && (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-mid)', fontFamily: 'var(--font-mono)' }}> <div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-mid)', fontFamily: 'var(--font-mono)' }}>
{recomputeMsg} {recomputeMsg}
</div> </div>
)} )}
{resetMsg && (
<div style={{ marginTop: 8, fontSize: 12, color: resetting ? 'var(--text-mid)' : 'var(--good)', fontFamily: 'var(--font-mono)' }}>
{resetMsg}
</div>
)}
{rebuildResult && ( {rebuildResult && (
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--text-mid)', fontFamily: 'var(--font-mono)', background: 'var(--bg-row)', padding: '10px 12px', borderRadius: 4 }}> <div style={{ marginTop: 12, fontSize: 11, color: 'var(--text-mid)', fontFamily: 'var(--font-mono)', background: 'var(--bg-row)', padding: '10px 12px', borderRadius: 4 }}>
{rebuildResult.error ? ( {rebuildResult.error ? (