e0e94bd518
Frontend: - JobDetail loadLogs: fallback to [] when API returns null - JobDetail loadJob: pairs ?? [] guard on /api/sync-pairs 500 - JobHistory: Array.isArray guard on job list response - api client: return undefined for null body instead of throwing Backend: - handlers_jobs GetLog: return [] instead of null when no log rows - router: custom recoverer middleware that logs panics to slog with full stack trace, method, and path
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
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<Job[]>([]);
|
|
const [pairs, setPairs] = useState<SyncPair[]>([]);
|
|
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(Array.isArray(data) ? data : []);
|
|
} catch {
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function loadPairs() {
|
|
try {
|
|
setPairs(await api<SyncPair[]>('/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;
|
|
}) => (
|
|
<Select value={value} onValueChange={onChange}>
|
|
<SelectTrigger className="w-auto min-w-[140px]">
|
|
<SelectValue placeholder={label} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">{label}</SelectItem>
|
|
{label === 'All Pairs' &&
|
|
pairs.map(p => (
|
|
<SelectItem key={p.id} value={p.id.toString()}>
|
|
{p.name}
|
|
</SelectItem>
|
|
))}
|
|
{label === 'All Statuses' &&
|
|
[
|
|
{ value: 'queued', label: 'Queued' },
|
|
{ value: 'waking_up', label: 'Waking Up' },
|
|
{ value: 'running', label: 'Running' },
|
|
{ value: 'success', label: 'Success' },
|
|
{ value: 'failed', label: 'Failed' },
|
|
{ value: 'cancelled', label: 'Cancelled' },
|
|
].map(s => (
|
|
<SelectItem key={s.value} value={s.value}>
|
|
{s.label}
|
|
</SelectItem>
|
|
))}
|
|
{label === 'Time Range' &&
|
|
[
|
|
{ value: '24h', label: 'Last 24h' },
|
|
{ value: '7d', label: 'Last 7 days' },
|
|
{ value: '30d', label: 'Last 30 days' },
|
|
{ value: 'all', label: 'All time' },
|
|
].map(r => (
|
|
<SelectItem key={r.value} value={r.value}>
|
|
{r.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title="Job History"
|
|
description={`${total} job${total !== 1 ? 's' : ''} found`}
|
|
actions={
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => load()}
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
|
Refresh
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<FilterChip label="All Pairs" value={filterPair} onChange={v => { setFilterPair(v); setPage(0); }} />
|
|
<FilterChip label="All Statuses" value={filterStatus} onChange={v => { setFilterStatus(v); setPage(0); }} />
|
|
<FilterChip label="Time Range" value={filterRange} onChange={v => { setFilterRange(v); setPage(0); }} />
|
|
{(filterStatus || filterPair || filterRange !== '7d') && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => { setFilterStatus(''); setFilterPair(''); setFilterRange('7d'); setPage(0); }}
|
|
className="text-fg-subtle"
|
|
>
|
|
<XCircle className="h-3.5 w-3.5" />
|
|
Clear filters
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<Card className="p-0">
|
|
{loading ? (
|
|
<div className="p-5 space-y-3">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-12 w-full" />
|
|
))}
|
|
</div>
|
|
) : jobs.length === 0 ? (
|
|
<EmptyState
|
|
icon={<History className="h-5 w-5" />}
|
|
title="No jobs found"
|
|
description={
|
|
filterStatus || filterPair || filterRange !== '7d'
|
|
? 'Try adjusting your filters'
|
|
: 'Sync pairs will appear here once jobs are executed'
|
|
}
|
|
/>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>ID</TableHead>
|
|
<TableHead>Sync Pair</TableHead>
|
|
<TableHead>Trigger</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Duration</TableHead>
|
|
<TableHead>Started</TableHead>
|
|
<TableHead>Finished</TableHead>
|
|
<TableHead className="w-20">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{jobs.map(j => (
|
|
<TableRow key={j.id}>
|
|
<TableCell>
|
|
<Link
|
|
to={`/jobs/${j.id}`}
|
|
className="text-accent hover:text-accent-hover font-mono text-xs"
|
|
>
|
|
#{j.id}
|
|
</Link>
|
|
</TableCell>
|
|
<TableCell className="text-fg-muted">
|
|
{pairName(j.sync_pair_id)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-xs text-fg-muted capitalize">
|
|
{j.trigger_type}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={statusVariant(j.status)}
|
|
label={statusLabel(j.status)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-fg-muted font-mono text-xs">
|
|
{j.started_at
|
|
? formatDuration(
|
|
(j.finished_at
|
|
? new Date(j.finished_at).getTime()
|
|
: Date.now()) -
|
|
new Date(j.started_at).getTime()
|
|
)
|
|
: '-'}
|
|
</TableCell>
|
|
<TableCell className="text-fg-muted text-xs">
|
|
{j.started_at
|
|
? new Date(j.started_at).toLocaleString()
|
|
: '-'}
|
|
</TableCell>
|
|
<TableCell className="text-fg-muted text-xs">
|
|
{j.finished_at
|
|
? new Date(j.finished_at).toLocaleString()
|
|
: '-'}
|
|
</TableCell>
|
|
<TableCell>
|
|
{['queued', 'waking_up', 'running'].includes(j.status) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => cancel(j.id)}
|
|
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
|
|
title="Cancel job"
|
|
>
|
|
<XCircle className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between px-5 py-3 border-t border-border">
|
|
<div className="text-xs text-fg-muted">
|
|
Page {page + 1} of {totalPages} ({total} total)
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="icon-sm"
|
|
onClick={() => setPage(p => Math.max(0, p - 1))}
|
|
disabled={page === 0}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="icon-sm"
|
|
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
|
disabled={page >= totalPages - 1}
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Card({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
return (
|
|
<div className={cn('rounded-card border border-border bg-surface shadow-card', className)}>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|