The crawler subdirectory was the only active project. Moving it to the repo root simplifies paths and removes the unnecessary nesting. The vqa/ and immoweb/ directories were legacy/unused and have been removed. Updated .drone.yml, .gitignore, .claude/ docs, and skills to reflect the new flat structure.
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
import { TaskStatus, type TaskPhase, type TaskResult } from '@/types';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
} from './ui/sheet';
|
|
import { Button } from './ui/button';
|
|
import { CheckCircle2, Circle, Loader2, XCircle } from 'lucide-react';
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
interface TaskProgressDrawerProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
taskResult: TaskResult | null;
|
|
taskStatus: TaskStatus | null;
|
|
taskID: string | null;
|
|
onCancel: () => void;
|
|
isCancelling: boolean;
|
|
}
|
|
|
|
const PHASES: { key: TaskPhase; label: string }[] = [
|
|
{ key: 'splitting', label: 'Splitting queries' },
|
|
{ key: 'fetching', label: 'Fetching & processing' },
|
|
{ key: 'processing', label: 'Processing remaining' },
|
|
];
|
|
|
|
function getPhaseIndex(phase: TaskPhase | undefined): number {
|
|
if (!phase) return -1;
|
|
if (phase === 'splitting_complete') return 1; // splitting done, fetching is next
|
|
if (phase === 'completed') return PHASES.length;
|
|
return PHASES.findIndex((p) => p.key === phase);
|
|
}
|
|
|
|
function formatEta(seconds: number | undefined): string {
|
|
if (seconds === undefined || seconds <= 0) return '';
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.round(seconds % 60);
|
|
if (mins > 0) {
|
|
return `~${mins}m ${secs}s remaining`;
|
|
}
|
|
return `~${secs}s remaining`;
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: TaskStatus | null }) {
|
|
if (!status) return null;
|
|
|
|
const isInProgress =
|
|
status !== TaskStatus.SUCCESS &&
|
|
status !== TaskStatus.FAILURE &&
|
|
status !== TaskStatus.REVOKED;
|
|
|
|
if (isInProgress) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Running
|
|
</span>
|
|
);
|
|
}
|
|
if (status === TaskStatus.SUCCESS) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
Complete
|
|
</span>
|
|
);
|
|
}
|
|
if (status === TaskStatus.REVOKED) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700">
|
|
<XCircle className="h-3 w-3" />
|
|
Cancelled
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
|
|
<XCircle className="h-3 w-3" />
|
|
Failed
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function PhaseTimeline({
|
|
currentPhase,
|
|
taskStatus,
|
|
}: {
|
|
currentPhase: TaskPhase | undefined;
|
|
taskStatus: TaskStatus | null;
|
|
}) {
|
|
const isTerminal =
|
|
taskStatus === TaskStatus.SUCCESS ||
|
|
taskStatus === TaskStatus.FAILURE ||
|
|
taskStatus === TaskStatus.REVOKED;
|
|
const activeIdx = isTerminal ? PHASES.length : getPhaseIndex(currentPhase);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
{PHASES.map((phase, idx) => {
|
|
const isCompleted = idx < activeIdx;
|
|
const isActive = idx === activeIdx && !isTerminal;
|
|
const isFuture = idx > activeIdx;
|
|
|
|
return (
|
|
<div key={phase.key} className="flex items-center gap-2">
|
|
{isCompleted && (
|
|
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
|
)}
|
|
{isActive && (
|
|
<Loader2 className="h-4 w-4 animate-spin text-blue-500 shrink-0" />
|
|
)}
|
|
{isFuture && (
|
|
<Circle className="h-4 w-4 text-muted-foreground/40 shrink-0" />
|
|
)}
|
|
<span
|
|
className={
|
|
isActive
|
|
? 'text-sm font-medium text-foreground'
|
|
: isCompleted
|
|
? 'text-sm text-muted-foreground'
|
|
: 'text-sm text-muted-foreground/40'
|
|
}
|
|
>
|
|
{phase.label}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CounterRow({ label, value, total }: { label: string; value?: number; total?: number }) {
|
|
if (value === undefined) return null;
|
|
return (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">{label}</span>
|
|
<span className="font-mono tabular-nums">
|
|
{value}
|
|
{total !== undefined && ` / ${total}`}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PhaseDetails({ result }: { result: TaskResult }) {
|
|
const phase = result.phase;
|
|
|
|
if (phase === 'splitting' || phase === 'splitting_complete') {
|
|
return (
|
|
<div className="rounded-md border p-3 space-y-1">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
Splitting
|
|
</p>
|
|
<CounterRow
|
|
label="Subqueries probed"
|
|
value={result.subqueries_probed}
|
|
total={result.subqueries_initial}
|
|
/>
|
|
{result.subqueries_total !== undefined && (
|
|
<CounterRow label="Final subqueries" value={result.subqueries_total} />
|
|
)}
|
|
{result.estimated_results !== undefined && (
|
|
<CounterRow label="Estimated results" value={result.estimated_results} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (phase === 'fetching') {
|
|
return (
|
|
<div className="rounded-md border p-3 space-y-1">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
{result.fetching_done ? 'Fetching complete' : 'Fetching & processing'}
|
|
</p>
|
|
<CounterRow
|
|
label="Subqueries completed"
|
|
value={result.subqueries_completed}
|
|
total={result.subqueries_total}
|
|
/>
|
|
<CounterRow label="IDs collected" value={result.ids_collected} />
|
|
<CounterRow label="Pages fetched" value={result.pages_fetched} />
|
|
{(result.details_fetched !== undefined && result.details_fetched > 0) && (
|
|
<>
|
|
<div className="border-t my-2" />
|
|
<CounterRow
|
|
label="Details fetched"
|
|
value={result.details_fetched}
|
|
total={result.total}
|
|
/>
|
|
<CounterRow label="Images downloaded" value={result.images_downloaded} />
|
|
<CounterRow label="OCR completed" value={result.ocr_completed} />
|
|
{(result.failed ?? 0) > 0 && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-red-500">Failed</span>
|
|
<span className="font-mono tabular-nums text-red-500">{result.failed}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (phase === 'processing') {
|
|
return (
|
|
<div className="rounded-md border p-3 space-y-1">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
Processing
|
|
</p>
|
|
<CounterRow
|
|
label="Details fetched"
|
|
value={result.details_fetched}
|
|
total={result.total}
|
|
/>
|
|
<CounterRow label="Images downloaded" value={result.images_downloaded} />
|
|
<CounterRow label="OCR completed" value={result.ocr_completed} />
|
|
{(result.failed ?? 0) > 0 && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-red-500">Failed</span>
|
|
<span className="font-mono tabular-nums text-red-500">{result.failed}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function LogViewer({ logs }: { logs: string[] }) {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const isAutoScrolling = useRef(true);
|
|
|
|
const handleScroll = () => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
|
|
isAutoScrolling.current = atBottom;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isAutoScrolling.current && scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}
|
|
}, [logs]);
|
|
|
|
return (
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={handleScroll}
|
|
className="rounded-md bg-zinc-950 p-3 overflow-y-auto font-mono text-[11px] leading-4 text-zinc-300 min-h-[100px] h-full"
|
|
>
|
|
{logs.length === 0 ? (
|
|
<span className="text-zinc-500 italic">Waiting for logs...</span>
|
|
) : (
|
|
logs.map((line, i) => (
|
|
<div key={i} className="whitespace-pre-wrap break-all">
|
|
{line}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TaskProgressDrawer({
|
|
open,
|
|
onOpenChange,
|
|
taskResult,
|
|
taskStatus,
|
|
taskID,
|
|
onCancel,
|
|
isCancelling,
|
|
}: TaskProgressDrawerProps) {
|
|
const isInProgress =
|
|
taskStatus !== null &&
|
|
taskStatus !== TaskStatus.SUCCESS &&
|
|
taskStatus !== TaskStatus.FAILURE &&
|
|
taskStatus !== TaskStatus.REVOKED;
|
|
|
|
const progressPercent = taskResult
|
|
? Math.min((taskResult.progress ?? 0) * 100, 100)
|
|
: 0;
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent side="right" className="flex flex-col w-full sm:!max-w-lg">
|
|
<SheetHeader>
|
|
<div className="flex items-center justify-between pr-6">
|
|
<SheetTitle>Crawl Job Progress</SheetTitle>
|
|
<StatusBadge status={taskStatus} />
|
|
</div>
|
|
{taskID && (
|
|
<SheetDescription>
|
|
Task ID: {taskID.slice(0, 8)}...
|
|
</SheetDescription>
|
|
)}
|
|
</SheetHeader>
|
|
|
|
{/* Fixed top section: timeline + counters + progress */}
|
|
<div className="space-y-3 px-4 shrink-0">
|
|
<PhaseTimeline
|
|
currentPhase={taskResult?.phase}
|
|
taskStatus={taskStatus}
|
|
/>
|
|
|
|
{taskResult && <PhaseDetails result={taskResult} />}
|
|
|
|
{taskResult && (taskResult.phase === 'processing' || taskResult.phase === 'fetching') && (taskResult.total ?? 0) > 0 && (
|
|
<div className="space-y-1">
|
|
<div className="w-full h-2 bg-primary/20 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-primary transition-all duration-300 ease-out rounded-full"
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
<span>
|
|
{taskResult.processed ?? 0} / {taskResult.total ?? '?'}
|
|
</span>
|
|
<span>{formatEta(taskResult.eta_seconds)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{taskResult?.message && (
|
|
<p className="text-sm text-muted-foreground">{taskResult.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Log viewer fills remaining space */}
|
|
<div className="flex-1 min-h-0 flex flex-col gap-1 px-4 pb-2">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide shrink-0">
|
|
Worker Logs
|
|
</p>
|
|
<div className="flex-1 min-h-0">
|
|
<LogViewer logs={taskResult?.logs ?? []} />
|
|
</div>
|
|
</div>
|
|
|
|
{isInProgress && (
|
|
<SheetFooter>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={onCancel}
|
|
disabled={isCancelling}
|
|
className="w-full"
|
|
>
|
|
{isCancelling ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Cancelling...
|
|
</>
|
|
) : (
|
|
'Cancel Job'
|
|
)}
|
|
</Button>
|
|
</SheetFooter>
|
|
)}
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|