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:
+42
-32
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -285,6 +285,9 @@ export default function Settings() {
|
||||
const [recomputeMsg, setRecomputeMsg] = useState('');
|
||||
const [rebuilding, setRebuilding] = useState(false);
|
||||
const [rebuildResult, setRebuildResult] = useState<any>(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'}
|
||||
</button>
|
||||
</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 && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-mid)', fontFamily: 'var(--font-mono)' }}>
|
||||
{recomputeMsg}
|
||||
</div>
|
||||
)}
|
||||
{resetMsg && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: resetting ? 'var(--text-mid)' : 'var(--good)', fontFamily: 'var(--font-mono)' }}>
|
||||
{resetMsg}
|
||||
</div>
|
||||
)}
|
||||
{rebuildResult && (
|
||||
<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 ? (
|
||||
|
||||
Reference in New Issue
Block a user