Initial Commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user