Files
Astronome/frontend/src/pages/Stats.tsx
T

373 lines
19 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 {
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=".txt,.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>
);
}