Initial Commit

This commit is contained in:
2026-04-09 23:23:31 +02:00
commit 9223e4d35f
94 changed files with 15173 additions and 0 deletions
+319
View File
@@ -0,0 +1,319 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api';
import type { HorizonPoint } from '../api/types';
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 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 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>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 24 }}>Settings</h1>
{/* 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>
{recomputeMsg && (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-mid)', fontFamily: 'var(--font-mono)' }}>
{recomputeMsg}
</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>
);
}