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
+372
View File
@@ -0,0 +1,372 @@
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, ScatterChart, Scatter, CartesianGrid, LineChart, Line,
} from 'recharts';
import { useStats } from '../hooks/useStats';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../api';
import type { Phd2Log } from '../api/types';
import { useRef, useState } from 'react';
const FILTER_COLORS: Record<string, string> = {
sv220: '#9b59b6',
c2: '#4d9de0',
sv260: '#e8832a',
uvir: '#3dba72',
};
function PHD2Section() {
const qc = useQueryClient();
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<string | null>(null);
const [deleting, setDeleting] = useState<number | null>(null);
const { data } = useQuery({
queryKey: ['phd2'],
queryFn: () => api.phd2.list(),
});
const items = data?.items ?? [];
const handleFile = async (file: File) => {
setUploading(true);
setUploadResult(null);
const fd = new FormData();
fd.append('file', file);
try {
const res = await api.phd2.upload(fd) as any;
if (res.duplicate) {
setUploadResult('⚠ Duplicate session - not imported');
} else {
const analysis = res.analysis;
const details: string[] = [];
details.push(`RMS ${analysis.rms_total_arcsec.toFixed(2)}`);
if (analysis.duration_min) details.push(`${analysis.duration_min}m`);
if (analysis.camera_name) details.push(analysis.camera_name);
setUploadResult(`✓ Uploaded: ${details.join(' · ')}`);
qc.invalidateQueries({ queryKey: ['phd2'] });
qc.invalidateQueries({ queryKey: ['stats'] });
}
} catch {
setUploadResult('✗ Upload failed');
}
setUploading(false);
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this PHD2 session?')) return;
setDeleting(id);
try {
await api.phd2.delete(id);
qc.invalidateQueries({ queryKey: ['phd2'] });
qc.invalidateQueries({ queryKey: ['stats'] });
} catch (e) {
alert(`Failed to delete: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
setDeleting(null);
};
return (
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden', marginTop: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, flex: 1 }}>PHD2 Guiding Sessions</div>
<div
onClick={() => !uploading && inputRef.current?.click()}
style={{
padding: '4px 12px',
border: '1px solid var(--border)',
borderRadius: 3,
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--text-mid)',
cursor: uploading ? 'default' : 'pointer',
background: 'var(--bg-deep)',
}}
>
{uploading ? 'Parsing…' : '↑ Upload .log'}
</div>
<input ref={inputRef} type="file" accept=".log,.csv" style={{ display: 'none' }}
onChange={e => e.target.files?.[0] && handleFile(e.target.files[0])} />
</div>
{uploadResult && (
<div style={{ padding: '6px 16px', fontFamily: 'var(--font-mono)', fontSize: 11,
color: uploadResult.startsWith('✓') ? 'var(--good)' : uploadResult.startsWith('⚠') ? 'var(--warn)' : 'var(--danger)',
borderBottom: '1px solid var(--border)', background: 'var(--bg-deep)' }}>
{uploadResult}
</div>
)}
{items.length === 0 ? (
<div style={{ padding: 16, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
No PHD2 logs imported yet. Upload a .log file to analyze guiding performance.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
{['Date', 'File', 'Duration', 'RMS Total', 'RMS RA', 'RMS Dec', 'Peak', 'Lost', 'SNR', ''].map(h => (
<th key={h} style={{ padding: '6px 10px', textAlign: 'left', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', fontWeight: 500, letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{items.map((log: Phd2Log) => {
const rms = log.rms_total ?? 0;
const rmsColor = rms < 0.7 ? 'var(--good)' : rms < 1.2 ? 'var(--warn)' : 'var(--danger)';
return (
<tr key={log.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)', whiteSpace: 'nowrap' }}>{log.session_date}</td>
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={log.filename}>{log.filename}</td>
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
{log.duration_min ? `${log.duration_min}m` : '—'}
</td>
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 700, color: rmsColor, whiteSpace: 'nowrap' }}>
{log.rms_total ? `${log.rms_total.toFixed(2)}` : '—'}
</td>
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
{log.rms_ra ? `${log.rms_ra.toFixed(2)}` : '—'}
</td>
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
{log.rms_dec ? `${log.rms_dec.toFixed(2)}` : '—'}
</td>
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: (log.peak_error ?? 0) > 2 ? 'var(--warn)' : 'var(--text-mid)', whiteSpace: 'nowrap' }}>
{log.peak_error ? `${log.peak_error.toFixed(2)}` : '—'}
</td>
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: (log.star_lost_count ?? 0) > 5 ? 'var(--danger)' : 'var(--text-mid)', whiteSpace: 'nowrap' }}>
{log.star_lost_count ?? 0}
</td>
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
{log.guide_star_snr ? log.guide_star_snr.toFixed(1) : '—'}
</td>
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
<button
onClick={() => handleDelete(log.id)}
disabled={deleting === log.id}
style={{
padding: '2px 6px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
borderRadius: 2,
fontFamily: 'var(--font-mono)',
fontSize: 10,
cursor: deleting === log.id ? 'default' : 'pointer',
opacity: deleting === log.id ? 0.5 : 1,
}}
>
{deleting === log.id ? '…' : '×'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
);
}
export default function Stats() {
const { data: stats, isLoading } = useStats();
if (isLoading || !stats) {
return (
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
Loading statistics
</div>
);
}
const totalH = (stats.total_integration_min / 60).toFixed(1);
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22 }}>Statistics</h1>
<button
onClick={() => api.log.exportCsv()}
style={{
marginLeft: 'auto',
padding: '5px 14px',
background: 'var(--bg-panel)',
border: '1px solid var(--border-hi)',
borderRadius: 4,
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--text-mid)',
cursor: 'pointer',
}}
>
Export Log CSV
</button>
</div>
{/* Header stat cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14, marginBottom: 28 }}>
{[
{ label: 'Total Sessions', value: stats.total_sessions },
{ label: 'Integration Time', value: `${totalH}h` },
{ label: 'Objects Imaged', value: stats.objects_with_keeper },
{ label: 'Filter Types Used', value: stats.filter_usage.length },
].map(({ label, value }) => (
<div key={label} style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 18px' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 6 }}>{label}</div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 24, fontWeight: 700 }}>{value}</div>
</div>
))}
</div>
{/* Charts */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 24 }}>
{/* Integration per month */}
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Integration per Month</div>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={stats.monthly}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} />
<YAxis tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${Math.round(v/60)}h`} />
<Tooltip
contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontFamily: 'IBM Plex Mono', fontSize: 11 }}
formatter={(v: number) => [`${(v / 60).toFixed(1)}h`, 'Integration']}
/>
<Bar dataKey="total_min" fill="var(--amber)" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Filter usage pie */}
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Filter Usage</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<ResponsiveContainer width={160} height={160}>
<PieChart>
<Pie data={stats.filter_usage} dataKey="total_min" nameKey="filter_id" cx="50%" cy="50%" outerRadius={70} innerRadius={40}>
{stats.filter_usage.map((entry) => (
<Cell key={entry.filter_id} fill={FILTER_COLORS[entry.filter_id] ?? '#888'} />
))}
</Pie>
<Tooltip formatter={(v: number) => `${(v / 60).toFixed(1)}h`} contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontSize: 11 }} />
</PieChart>
</ResponsiveContainer>
<div>
{stats.filter_usage.map(f => (
<div key={f.filter_id} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: FILTER_COLORS[f.filter_id] ?? '#888' }} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
{f.filter_id.toUpperCase()} {(f.total_min / 60).toFixed(1)}h
</span>
</div>
))}
</div>
</div>
</div>
{/* Object type breakdown */}
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>By Object Type</div>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={stats.by_type} layout="vertical">
<XAxis type="number" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${Math.round(v/60)}h`} />
<YAxis type="category" dataKey="obj_type" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} width={90} />
<Tooltip contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontSize: 11 }} formatter={(v: number) => `${(v / 60).toFixed(1)}h`} />
<Bar dataKey="total_min" fill="var(--teal)" radius={[0, 2, 2, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Guiding RMS over time */}
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Guiding RMS over Time</div>
{stats.guiding.length === 0 ? (
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>No PHD2 logs imported yet.</div>
) : (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={stats.guiding.map(g => ({ ...g, rms_total: g.rms_total ?? null, rms_ra: g.rms_ra ?? null, rms_dec: g.rms_dec ?? null }))}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} interval="preserveStartEnd" />
<YAxis tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${v}`} />
<Tooltip contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontFamily: 'IBM Plex Mono', fontSize: 11 }} formatter={(v: number) => `${v.toFixed(2)}`} />
<Line type="monotone" dataKey="rms_total" stroke="var(--blue)" strokeWidth={2} dot={{ r: 3, fill: 'var(--blue)' }} name="Total RMS" connectNulls={false} />
<Line type="monotone" dataKey="rms_ra" stroke="var(--amber)" strokeWidth={1} dot={false} strokeDasharray="3 2" name="RA RMS" connectNulls={false} />
<Line type="monotone" dataKey="rms_dec" stroke="var(--teal)" strokeWidth={1} dot={false} strokeDasharray="3 2" name="Dec RMS" connectNulls={false} />
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Top targets */}
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700 }}>
Most Integrated Targets
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
{['Name', 'Type', 'Sessions', 'Integration'].map(h => (
<th key={h} style={{ padding: '6px 12px', textAlign: 'left', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', fontWeight: 500, letterSpacing: '0.06em' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{stats.top_targets.map(t => (
<tr key={t.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)' }}>
{t.common_name ?? t.name}
{t.common_name && <span style={{ color: 'var(--text-lo)', marginLeft: 6 }}>{t.name}</span>}
</td>
<td style={{ padding: '7px 12px', fontSize: 11, color: 'var(--text-mid)' }}>{t.obj_type}</td>
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{t.sessions}</td>
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)' }}>
{(t.total_min / 60).toFixed(1)}h
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Quality breakdown */}
{stats.quality && stats.quality.length > 0 && (
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden', marginTop: 20 }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700 }}>
Session Quality Breakdown
</div>
<div style={{ display: 'flex', gap: 0 }}>
{stats.quality.map(q => {
const colorMap: Record<string, string> = {
keeper: 'var(--good)',
needs_more: 'var(--blue)',
rejected: 'var(--danger)',
pending: 'var(--muted)',
};
const color = colorMap[q.quality] ?? 'var(--text-mid)';
const total = stats.quality.reduce((s, x) => s + x.count, 0);
return (
<div key={q.quality} style={{
flex: q.count,
padding: '10px 14px',
borderRight: '1px solid var(--border)',
minWidth: 80,
}}>
<div className={`quality-chip ${q.quality}`} style={{ marginBottom: 4 }}>
{q.quality.replace('_', ' ')}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 18, fontWeight: 700, color }}>
{q.count}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
{total > 0 ? Math.round((q.count / total) * 100) : 0}%
</div>
</div>
);
})}
</div>
</div>
)}
<PHD2Section />
</div>
);
}