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/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);
|
Ok(Json(serde_json::json!({ "status": "rebuild_started" })))
|
||||||
Err(AppError::Internal(format!("Rebuild failed: {}", e)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")?;
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user