01ccae5951
- 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>
613 lines
26 KiB
TypeScript
613 lines
26 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { api } from '../api';
|
||
import type { HorizonPoint } from '../api/types';
|
||
|
||
const DEFAULT_PROFILE: EquipProfile = {
|
||
id: 'default',
|
||
name: 'AT71 + ATR2600C',
|
||
focal_mm: 490,
|
||
aperture_mm: 71,
|
||
pixel_um: 3.76,
|
||
res_x: 6248,
|
||
res_y: 4176,
|
||
};
|
||
|
||
interface EquipProfile {
|
||
id: string;
|
||
name: string;
|
||
focal_mm: number;
|
||
aperture_mm: number;
|
||
pixel_um: number;
|
||
res_x: number;
|
||
res_y: number;
|
||
}
|
||
|
||
function loadProfiles(): EquipProfile[] {
|
||
try {
|
||
const stored = JSON.parse(localStorage.getItem('astronome_equip_profiles') ?? '[]') as EquipProfile[];
|
||
// Ensure the default profile is always present
|
||
if (!stored.find(p => p.id === 'default')) {
|
||
return [DEFAULT_PROFILE, ...stored];
|
||
}
|
||
return stored;
|
||
} catch { return [DEFAULT_PROFILE]; }
|
||
}
|
||
|
||
function calcProfile(p: { focal_mm: number; aperture_mm: number; pixel_um: number; res_x: number; res_y: number }) {
|
||
const ps = (206.265 * p.pixel_um / 1000) / p.focal_mm * 1000;
|
||
const fov_w_deg = (ps * p.res_x) / 3600;
|
||
const fov_h_deg = (ps * p.res_y) / 3600;
|
||
const focal_ratio = p.focal_mm / p.aperture_mm;
|
||
return {
|
||
plate_scale_arcsec: ps.toFixed(3),
|
||
fov_w: `${(fov_w_deg * 60).toFixed(1)}′ × ${(fov_h_deg * 60).toFixed(1)}′`,
|
||
focal_ratio: `f/${focal_ratio.toFixed(1)}`,
|
||
};
|
||
}
|
||
|
||
function EquipmentProfiles() {
|
||
const [profiles, setProfiles] = useState<EquipProfile[]>(loadProfiles);
|
||
const [activeId, setActiveId] = useState<string>(() =>
|
||
localStorage.getItem('astronome_active_profile') ?? 'default'
|
||
);
|
||
const [editing, setEditing] = useState<EquipProfile | null>(null);
|
||
const [adding, setAdding] = useState(false);
|
||
const [form, setForm] = useState({ name: '', focal_mm: 490, aperture_mm: 71, pixel_um: 3.76, res_x: 6248, res_y: 4176 });
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem('astronome_equip_profiles', JSON.stringify(profiles));
|
||
}, [profiles]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem('astronome_active_profile', activeId);
|
||
}, [activeId]);
|
||
|
||
const saveProfile = () => {
|
||
if (!form.name.trim()) return;
|
||
if (editing) {
|
||
setProfiles(ps => ps.map(p => p.id === editing.id ? { ...form, id: editing.id } : p));
|
||
} else {
|
||
setProfiles(ps => [...ps, { ...form, id: Date.now().toString() }]);
|
||
}
|
||
setEditing(null);
|
||
setAdding(false);
|
||
setForm({ name: '', focal_mm: 490, aperture_mm: 71, pixel_um: 3.76, res_x: 6248, res_y: 4176 });
|
||
};
|
||
|
||
const deleteProfile = (id: string) => {
|
||
if (profiles.length <= 1) return;
|
||
setProfiles(ps => ps.filter(p => p.id !== id));
|
||
if (activeId === id) setActiveId(profiles.find(p => p.id !== id)?.id ?? 'default');
|
||
};
|
||
|
||
const startEdit = (p: EquipProfile) => {
|
||
setEditing(p);
|
||
setForm({ name: p.name, focal_mm: p.focal_mm, aperture_mm: p.aperture_mm, pixel_um: p.pixel_um, res_x: p.res_x, res_y: p.res_y });
|
||
setAdding(false);
|
||
};
|
||
|
||
const fieldStyle: React.CSSProperties = {
|
||
background: 'var(--bg-void)', border: '1px solid var(--border)', borderRadius: 3,
|
||
color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 12,
|
||
padding: '4px 8px', width: '100%', boxSizing: 'border-box',
|
||
};
|
||
|
||
return (
|
||
<section style={{ marginBottom: 32 }}>
|
||
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
||
Equipment Profiles
|
||
</h2>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 640 }}>
|
||
{profiles.map(p => {
|
||
const calc = calcProfile(p);
|
||
const isActive = p.id === activeId;
|
||
return (
|
||
<div key={p.id} style={{
|
||
background: 'var(--bg-panel)', border: `1px solid ${isActive ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||
borderRadius: 6, padding: '12px 16px',
|
||
}}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: isActive ? 'var(--amber)' : 'var(--text-hi)', fontWeight: 600 }}>
|
||
{p.name}
|
||
{isActive && <span style={{ marginLeft: 8, fontSize: 10, color: 'var(--amber)', background: 'var(--amber-glow)', padding: '1px 6px', borderRadius: 3 }}>ACTIVE</span>}
|
||
</div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginTop: 4 }}>
|
||
{p.focal_mm}mm {calc.focal_ratio} · {p.aperture_mm}mm aperture · {p.pixel_um}μm px · {p.res_x}×{p.res_y}
|
||
</div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', marginTop: 3 }}>
|
||
Plate scale: <strong style={{ color: 'var(--teal)' }}>{calc.plate_scale_arcsec}″/px</strong>
|
||
{' · '}FOV: <strong style={{ color: 'var(--teal)' }}>{calc.fov_w}</strong>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||
{!isActive && (
|
||
<button onClick={() => setActiveId(p.id)} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)', background: 'var(--amber-glow)', border: '1px solid var(--amber-dim)', borderRadius: 3, padding: '3px 8px', cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||
Use
|
||
</button>
|
||
)}
|
||
<button onClick={() => startEdit(p)} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--blue)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '3px 8px', cursor: 'pointer' }}>
|
||
Edit
|
||
</button>
|
||
{profiles.length > 1 && (
|
||
<button onClick={() => deleteProfile(p.id)} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--danger)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '3px 8px', cursor: 'pointer' }}>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{(adding || editing) && (
|
||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-hi)', borderRadius: 6, padding: '14px 16px' }}>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||
{editing ? 'Edit Profile' : 'New Profile'}
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, marginBottom: 10 }}>
|
||
{[
|
||
{ key: 'name', label: 'Profile name', type: 'text', fullWidth: true },
|
||
{ key: 'focal_mm', label: 'Focal length (mm)', type: 'number' },
|
||
{ key: 'aperture_mm', label: 'Aperture (mm)', type: 'number' },
|
||
{ key: 'pixel_um', label: 'Pixel size (μm)', type: 'number' },
|
||
{ key: 'res_x', label: 'Sensor width (px)', type: 'number' },
|
||
{ key: 'res_y', label: 'Sensor height (px)', type: 'number' },
|
||
].map(field => (
|
||
<div key={field.key} style={{ gridColumn: field.fullWidth ? '1 / -1' : undefined }}>
|
||
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 3 }}>
|
||
{field.label}
|
||
</label>
|
||
<input
|
||
type={field.type}
|
||
step={field.key === 'pixel_um' ? '0.01' : '1'}
|
||
value={(form as Record<string, string | number>)[field.key]}
|
||
onChange={e => setForm(f => ({ ...f, [field.key]: field.type === 'number' ? parseFloat(e.target.value) || 0 : e.target.value }))}
|
||
style={fieldStyle}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{form.focal_mm > 0 && form.pixel_um > 0 && (
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--teal)', marginBottom: 10 }}>
|
||
Preview: {calcProfile(form).plate_scale_arcsec}″/px · {calcProfile(form).fov_w} FOV
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button onClick={saveProfile} style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: '#fff', background: 'var(--amber)', border: 'none', borderRadius: 3, padding: '5px 14px', cursor: 'pointer' }}>
|
||
Save
|
||
</button>
|
||
<button onClick={() => { setAdding(false); setEditing(null); }} style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-mid)', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 3, padding: '5px 14px', cursor: 'pointer' }}>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!adding && !editing && (
|
||
<button
|
||
onClick={() => { setAdding(true); setEditing(null); }}
|
||
style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', background: 'var(--bg-deep)', border: '1px dashed var(--amber-dim)', borderRadius: 4, padding: '8px 16px', cursor: 'pointer', textAlign: 'left' }}
|
||
>
|
||
+ Add equipment profile
|
||
</button>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function HorizonPolarChart({ points }: { points: HorizonPoint[] }) {
|
||
const size = 280;
|
||
const cx = size / 2;
|
||
const cy = size / 2;
|
||
const r = 120;
|
||
|
||
// Draw horizon profile as a polar chart
|
||
const pathParts = points.map((p, i) => {
|
||
const azRad = (p.az_deg - 90) * (Math.PI / 180);
|
||
const altFrac = 1 - p.alt_deg / 90;
|
||
const pr = altFrac * r;
|
||
const x = cx + pr * Math.cos(azRad);
|
||
const y = cy + pr * Math.sin(azRad);
|
||
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||
});
|
||
if (pathParts.length) pathParts.push('Z');
|
||
|
||
return (
|
||
<svg width={size} height={size} style={{ display: 'block' }}>
|
||
{/* Grid circles */}
|
||
{[15, 30, 45, 60, 75, 90].map(alt => {
|
||
const pr = (1 - alt / 90) * r;
|
||
return (
|
||
<g key={alt}>
|
||
<circle cx={cx} cy={cy} r={pr}
|
||
fill="none" stroke="var(--border)" strokeWidth={1} strokeDasharray={alt === 15 ? '4 4' : '2 4'}
|
||
/>
|
||
<text x={cx + 3} y={cy - pr - 2} fill="var(--text-lo)" fontSize={8} fontFamily="IBM Plex Mono">{alt}°</text>
|
||
</g>
|
||
);
|
||
})}
|
||
{/* Cardinal lines */}
|
||
{[0, 90, 180, 270].map(az => {
|
||
const azRad = (az - 90) * (Math.PI / 180);
|
||
return (
|
||
<line key={az}
|
||
x1={cx} y1={cy}
|
||
x2={cx + r * Math.cos(azRad)} y2={cy + r * Math.sin(azRad)}
|
||
stroke="var(--border)" strokeWidth={1}
|
||
/>
|
||
);
|
||
})}
|
||
{/* Labels */}
|
||
{[['N', 0], ['E', 90], ['S', 180], ['W', 270]].map(([label, az]) => {
|
||
const azRad = ((az as number) - 90) * (Math.PI / 180);
|
||
return (
|
||
<text key={label as string}
|
||
x={cx + (r + 14) * Math.cos(azRad)}
|
||
y={cy + (r + 14) * Math.sin(azRad) + 4}
|
||
textAnchor="middle"
|
||
fill="var(--text-lo)"
|
||
fontSize={10}
|
||
fontFamily="IBM Plex Mono"
|
||
>
|
||
{label as string}
|
||
</text>
|
||
);
|
||
})}
|
||
{/* Horizon profile */}
|
||
{pathParts.length > 0 && (
|
||
<path d={pathParts.join(' ')} fill="rgba(232,131,42,0.15)" stroke="var(--amber)" strokeWidth={1.5} />
|
||
)}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export default function Settings() {
|
||
const qc = useQueryClient();
|
||
const { data: horizonData } = useQuery({
|
||
queryKey: ['horizon'],
|
||
queryFn: () => api.horizon.get(),
|
||
});
|
||
const { data: health } = useQuery({
|
||
queryKey: ['health'],
|
||
queryFn: () => api.health.get(),
|
||
});
|
||
|
||
const setHorizon = useMutation({
|
||
mutationFn: (points: HorizonPoint[]) => api.horizon.set(points),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ['horizon'] }),
|
||
});
|
||
|
||
const [recomputing, setRecomputing] = useState(false);
|
||
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);
|
||
setRecomputeMsg('');
|
||
try {
|
||
const res = await fetch('/api/nightly/recompute', { method: 'POST' });
|
||
if (res.ok) {
|
||
setRecomputeMsg('Nightly recompute started — takes ~20s. Reload the Targets page when done.');
|
||
} else {
|
||
setRecomputeMsg('Backend returned an error. Check logs.');
|
||
}
|
||
} catch {
|
||
setRecomputeMsg('Error reaching backend.');
|
||
}
|
||
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);
|
||
try {
|
||
const res = await fetch('/api/catalog/rebuild');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.status === 'success') {
|
||
setRebuildResult({
|
||
status: 'success',
|
||
message: `Rebuild complete: ${data.total} objects. Starting automatic nightly recompute...`,
|
||
...data
|
||
});
|
||
// Invalidate queries to refresh the catalog
|
||
qc.invalidateQueries({ queryKey: ['targets'] });
|
||
qc.invalidateQueries({ queryKey: ['health'] });
|
||
// Wait for nightly recompute to complete (~30s) then reload
|
||
setTimeout(() => window.location.reload(), 4000);
|
||
} else {
|
||
setRebuildResult({ error: 'Unexpected response from server.' });
|
||
}
|
||
} else {
|
||
const errorData = await res.json().catch(() => ({}));
|
||
setRebuildResult({ error: errorData.error || 'Backend returned an error. Check logs.' });
|
||
}
|
||
} catch (err) {
|
||
setRebuildResult({ error: `Error reaching backend: ${String(err)}` });
|
||
}
|
||
setRebuilding(false);
|
||
};
|
||
|
||
const handleHorizonCSV = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const text = await file.text();
|
||
const lines = text.trim().split('\n').slice(1); // skip header
|
||
const points: HorizonPoint[] = [];
|
||
for (const line of lines) {
|
||
const [az, alt] = line.split(',').map(Number);
|
||
if (!isNaN(az) && !isNaN(alt)) {
|
||
points.push({ az_deg: Math.round(az) % 360, alt_deg: Math.max(0, Math.min(90, alt)) });
|
||
}
|
||
}
|
||
if (points.length === 360) {
|
||
setHorizon.mutate(points);
|
||
} else {
|
||
alert(`CSV must have exactly 360 rows (got ${points.length}). Format: az_deg,alt_deg`);
|
||
}
|
||
};
|
||
|
||
const resetHorizon = () => {
|
||
const flat: HorizonPoint[] = Array.from({ length: 360 }, (_, i) => ({ az_deg: i, alt_deg: 15.0 }));
|
||
setHorizon.mutate(flat);
|
||
};
|
||
|
||
return (
|
||
<div className="page-body">
|
||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 24 }}>Settings</h1>
|
||
|
||
{/* Equipment Profiles */}
|
||
<EquipmentProfiles />
|
||
|
||
{/* Custom Horizon */}
|
||
<section style={{ marginBottom: 32 }}>
|
||
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
||
Custom Horizon Profile
|
||
</h2>
|
||
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
||
{horizonData?.points && <HorizonPolarChart points={horizonData.points} />}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
<div style={{ fontSize: 12, color: 'var(--text-mid)', maxWidth: 300 }}>
|
||
Upload a CSV file with columns <code>az_deg,alt_deg</code>, one row per degree (360 rows total).
|
||
</div>
|
||
<label style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
padding: '6px 14px',
|
||
background: 'var(--bg-panel)',
|
||
border: '1px solid var(--border-hi)',
|
||
borderRadius: 4,
|
||
cursor: 'pointer',
|
||
fontFamily: 'var(--font-mono)',
|
||
fontSize: 12,
|
||
color: 'var(--text-hi)',
|
||
}}>
|
||
↑ Upload CSV
|
||
<input type="file" accept=".csv" style={{ display: 'none' }} onChange={handleHorizonCSV} />
|
||
</label>
|
||
<button
|
||
onClick={resetHorizon}
|
||
style={{
|
||
padding: '6px 14px',
|
||
background: 'var(--bg-deep)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 4,
|
||
fontFamily: 'var(--font-mono)',
|
||
fontSize: 12,
|
||
color: 'var(--text-mid)',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
Reset to Flat 15°
|
||
</button>
|
||
{setHorizon.isSuccess && (
|
||
<div style={{ color: 'var(--good)', fontSize: 12, fontFamily: 'var(--font-mono)' }}>
|
||
✓ Horizon updated
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* App Info */}
|
||
<section>
|
||
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
||
App Info
|
||
</h2>
|
||
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 18px', maxWidth: 440 }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 12 }}>
|
||
<tbody>
|
||
{[
|
||
['Status', health?.status ?? '—'],
|
||
['Catalog size', health?.catalog_size != null ? `${health.catalog_size.toLocaleString()} objects` : '—'],
|
||
['Last refreshed', health?.catalog_last_refreshed
|
||
? new Date(health.catalog_last_refreshed * 1000).toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' })
|
||
: '—'],
|
||
['DB size', health?.db_size_bytes != null
|
||
? health.db_size_bytes < 1024 * 1024
|
||
? `${Math.round(health.db_size_bytes / 1024)} KB`
|
||
: `${(health.db_size_bytes / 1024 / 1024).toFixed(1)} MB`
|
||
: '—'],
|
||
['Backend version', health?.version ?? '—'],
|
||
].map(([label, value]) => (
|
||
<tr key={label}>
|
||
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, paddingBottom: 8, width: '45%' }}>{label}</td>
|
||
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>{value}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||
<button
|
||
onClick={triggerRebuild}
|
||
disabled={rebuilding}
|
||
style={{
|
||
padding: '6px 16px',
|
||
background: 'var(--blue)',
|
||
color: '#fff',
|
||
borderRadius: 4,
|
||
fontFamily: 'var(--font-mono)',
|
||
fontSize: 12,
|
||
opacity: rebuilding ? 0.6 : 1,
|
||
cursor: rebuilding ? 'default' : 'pointer',
|
||
}}
|
||
>
|
||
{rebuilding ? 'Rebuilding…' : 'Rebuild Catalog'}
|
||
</button>
|
||
<button
|
||
onClick={triggerRecompute}
|
||
disabled={recomputing}
|
||
style={{
|
||
padding: '6px 16px',
|
||
background: 'var(--bg-deep)',
|
||
border: '1px solid var(--border-hi)',
|
||
color: 'var(--text-hi)',
|
||
borderRadius: 4,
|
||
fontFamily: 'var(--font-mono)',
|
||
fontSize: 12,
|
||
opacity: recomputing ? 0.6 : 1,
|
||
cursor: recomputing ? 'default' : 'pointer',
|
||
}}
|
||
>
|
||
{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 ? (
|
||
<div style={{ color: 'var(--danger)' }}>{rebuildResult.error}</div>
|
||
) : rebuildResult.message ? (
|
||
<div style={{ color: 'var(--good)' }}>{rebuildResult.message}</div>
|
||
) : (
|
||
<>
|
||
<div><strong>Total:</strong> {rebuildResult.total?.toLocaleString() || '?'} entries</div>
|
||
<div style={{ marginTop: 6 }}>
|
||
<strong>By Type:</strong>
|
||
{rebuildResult.by_type && Object.entries(rebuildResult.by_type).length > 0 ? (
|
||
<div style={{ marginLeft: 12, marginTop: 4 }}>
|
||
{Object.entries(rebuildResult.by_type).map(([type, count]: [string, any]) => (
|
||
<div key={type} style={{ fontSize: 10 }}>{type}: {count}</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div style={{ marginLeft: 12, marginTop: 4, fontSize: 10 }}>None</div>
|
||
)}
|
||
</div>
|
||
<div style={{ marginTop: 6 }}>
|
||
<strong>Messier:</strong> {rebuildResult.messier_count || 0}
|
||
<span style={{ color: 'var(--text-lo)', marginLeft: 8 }}>({rebuildResult.has_sizes || 0} with size data)</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|