Files
Astronome/frontend/src/pages/Settings.tsx
T
arnaudne 01ccae5951 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>
2026-04-17 11:19:55 +02:00

613 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}