import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { api, Job, SyncPair } from '../api/client'; import { Button } from '@/components/ui/Button'; import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select'; import { Badge } from '@/components/ui/Badge'; import { PageHeader } from '@/components/ui/PageHeader'; import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell, } from '@/components/ui/Table'; import { EmptyState } from '@/components/ui/EmptyState'; import { Skeleton } from '@/components/ui/Skeleton'; import { History, XCircle, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { statusVariant, statusLabel } from '@/lib/status'; import { formatDuration } from '@/lib/utils'; export default function JobHistory() { const [jobs, setJobs] = useState([]); const [pairs, setPairs] = useState([]); const [filterStatus, setFilterStatus] = useState(''); const [filterPair, setFilterPair] = useState(''); const [filterRange, setFilterRange] = useState('7d'); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); const [loading, setLoading] = useState(true); const limit = 50; useEffect(() => { loadPairs(); }, []); useEffect(() => { load(); }, [filterStatus, filterPair, filterRange, page]); async function load() { setLoading(true); try { let url = `/api/jobs?limit=${limit}&offset=${page * limit}`; if (filterStatus) url += `&status=${filterStatus}`; if (filterPair) url += `&sync_pair_id=${filterPair}`; if (filterRange === '24h') { const from = new Date(Date.now() - 24 * 3600 * 1000).toISOString(); url += `&from=${encodeURIComponent(from)}`; } else if (filterRange === '7d') { const from = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString(); url += `&from=${encodeURIComponent(from)}`; } else if (filterRange === '30d') { const from = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString(); url += `&from=${encodeURIComponent(from)}`; } const res = await fetch(url, { credentials: 'include' }); const totalCount = res.headers.get('X-Total-Count'); if (totalCount) setTotal(Number(totalCount)); const data = await res.json(); setJobs(data); } catch { } finally { setLoading(false); } } async function loadPairs() { try { setPairs(await api('/api/sync-pairs')); } catch {} } async function cancel(id: number) { try { await api(`/api/jobs/${id}/cancel`, { method: 'POST' }); toast.success('Job cancelled'); load(); } catch (e: unknown) { toast.error((e as Error).message); } } function pairName(id: number) { const p = pairs.find(p => p.id === id); return p ? p.name : `Pair ${id}`; } const totalPages = Math.ceil(total / limit); const FilterChip = ({ label, value, onChange, }: { label: string; value: string; onChange: (v: string) => void; }) => ( ); return (
load()} disabled={loading} > Refresh } />
{ setFilterPair(v); setPage(0); }} /> { setFilterStatus(v); setPage(0); }} /> { setFilterRange(v); setPage(0); }} /> {(filterStatus || filterPair || filterRange !== '7d') && ( )}
{loading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : jobs.length === 0 ? ( } title="No jobs found" description={ filterStatus || filterPair || filterRange !== '7d' ? 'Try adjusting your filters' : 'Sync pairs will appear here once jobs are executed' } /> ) : ( ID Sync Pair Trigger Status Duration Started Finished Actions {jobs.map(j => ( #{j.id} {pairName(j.sync_pair_id)} {j.trigger_type} {j.started_at ? formatDuration( (j.finished_at ? new Date(j.finished_at).getTime() : Date.now()) - new Date(j.started_at).getTime() ) : '-'} {j.started_at ? new Date(j.started_at).toLocaleString() : '-'} {j.finished_at ? new Date(j.finished_at).toLocaleString() : '-'} {['queued', 'waking_up', 'running'].includes(j.status) && ( )} ))}
)} {totalPages > 1 && (
Page {page + 1} of {totalPages} ({total} total)
)}
); } function Card({ children, className }: { children: React.ReactNode; className?: string }) { return (
{children}
) }