import React, { useMemo, useState, useEffect, useRef } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { Box, Container, Paper, Typography, Button, Chip, CircularProgress, Alert, Divider, } from "@mui/material"; import ReplayIcon from "@mui/icons-material/Replay"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import ErrorIcon from "@mui/icons-material/Error"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; import { useResource, useItemSse, useAppContext, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi"; import { useQuery } from "@tanstack/react-query"; import { RETRY_MAX, formatApiError } from "../features/fetch-requests"; import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../features/fetch-requests"; import { PipelineStepper } from "./components/PipelineStepper"; import { AmbiguityResolver } from "./components/AmbiguityResolver"; import { PageHeader } from "../ui/PageHeader"; import { useToast } from "../ui/Toast"; const statusColors: Record = { pending: "default", processing: "info", paused: "warning", raw_expenses_done: "primary", enriched_done: "warning", completed: "success", failed: "error", }; const statusIcons: Record = { pending: , processing: , paused: , raw_expenses_done: , enriched_done: , completed: , failed: , }; function formatProgressMessage(msg: ProgressMessage): string { if (msg.lines !== undefined) return `${msg.lines} lines`; if (msg.blocks !== undefined) return `${msg.blocks} blocks`; if (msg.count !== undefined && msg.unit) return `${msg.count} ${msg.unit}`; if (msg.count !== undefined) return `${msg.count} items`; if (msg.raw_ocr_line) return `"${msg.raw_ocr_line.slice(0, 60)}${msg.raw_ocr_line.length > 60 ? "…" : ""}"`; if (msg.error) return msg.error.slice(0, 80); return ""; } function sseIcon(status: SSEEvent["status"]) { switch (status) { case "started": return ; case "completed": return ; case "failed": return ; case "skipped": return ; case "paused": return ; case "progress": return ; } } function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { return ( {title} {action} {children} ); } export default function FetchRequestDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { get, patch, resource } = useResource("fetch-requests"); const { resources: allResources } = useAppContext(); const [stepStats, setStepStats] = useState>({}); const [liveParsedCount, setLiveParsedCount] = useState(undefined); const [retrying, setRetrying] = useState(false); const { showToast } = useToast(); const feedRef = useRef(null); const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({ queryKey: ["fetch-requests", "detail", id], queryFn: () => get(id!), enabled: !!id, }); const sseUrl = id ? `/fetch-requests/${id}/events` : null; const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, { onEvent: (parsed: SSEEvent) => { if (parsed.status === "progress" && parsed.message.count !== undefined) { if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count); if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! })); if (parsed.step === "save_expenses") setStepStats((prev) => ({ ...prev, save_count: parsed.message.count! })); } if (parsed.status === "completed" && parsed.message.count !== undefined) { const stats: Record = {}; if (parsed.step === "raw_lines" && parsed.message.lines !== undefined) stats.raw_lines = parsed.message.lines; if (parsed.step === "txn_blocks" && parsed.message.blocks !== undefined) stats.txn_blocks = parsed.message.blocks; if (parsed.step === "txn_dicts") stats.txn_dicts = parsed.message.count; if (parsed.step === "enrich") stats.enrich_count = parsed.message.count; if (parsed.step === "save_expenses") stats.save_count = parsed.message.count; if (Object.keys(stats).length) { setStepStats((prev) => ({ ...prev, ...stats })); } } if (parsed.status === "paused") { refetchRequest(); } if (parsed.status === "failed") { showToast(parsed.message.error || "Fetch request failed", "error"); refetchRequest(); } if (parsed.status === "completed" || parsed.step === "resume_extract") { refetchRequest(); } }, }); useEffect(() => { if (feedRef.current) { feedRef.current.scrollTop = feedRef.current.scrollHeight; } }, [sseEvents]); const displayEvents = useMemo(() => { const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]); const lastProgressIdx: Record = {}; for (let i = sseEvents.length - 1; i >= 0; i--) { const e = sseEvents[i]; if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) { lastProgressIdx[e.step] = i; } } const terminalStatuses = new Set(["completed", "skipped", "paused", "failed"]); return sseEvents.filter((e, i) => { if (progressSteps.has(e.step) && e.status === "progress") return i === lastProgressIdx[e.step]; if (e.status === "started") { return !sseEvents.slice(i + 1).some( (later) => later.step === e.step && terminalStatuses.has(later.status), ); } return true; }); }, [sseEvents]); const handleRetry = async () => { if (!id || !patch || retrying) return; setRetrying(true); try { await patch(id, { status: "pending" }); refetchRequest(); } catch (err: any) { showToast(formatApiError(err), "error"); } finally { setRetrying(false); } }; const req = fetchRequest as any; const retryCount = req?.retry_count ?? 0; const isRetryExhausted = retryCount >= RETRY_MAX; const status = (req?.status ?? "pending") as FetchRequestStatus; const detailFields = (resource?.orderedFields ?? []).filter( (f) => f.name !== "source", ); const displayTitle = useMemo(() => { if (!resource || !req) return ""; const resolved = Object.fromEntries( resource.orderedFields.map((field) => { const value = req[field.name]; if (field.fk && typeof value === "object" && value != null) { const target = allResources.find((r) => r.name === field.fk!.resource); if (target) return [field.name, applyDisplayFormat(value, target.displayFormat)]; } return [field.name, value]; }), ); return applyDisplayFormat(resolved, resource.displayFormat); }, [resource, req, allResources]); if (isLoading) { return ( ); } if (fetchError || !fetchRequest) { return ( Failed to load fetch request ); } return (
} > ID: {req.id} {detailFields.map((field) => { const value = req[field.name]; let fmt = resource?.displayFormat; if (field.fk && typeof value === "object" && value != null) { const target = allResources.find((r) => r.name === field.fk!.resource); if (target) fmt = target.displayFormat; } return ( ); })} Retries: {retryCount}/{RETRY_MAX} {status === "failed" && !isRetryExhausted && ( )}
{status === "failed" && req.error_message && ( {req.error_message} )} {isRetryExhausted && status === "failed" && ( Max retries reached — no further retry attempts will be made. )}
{sseConnected ? "Connected" : "Disconnected"} } > {displayEvents.length === 0 ? ( Waiting for events... ) : ( displayEvents.map((evt, i) => ( {sseIcon(evt.status)} {evt.step.replace(/_/g, " ")} {evt.message && formatProgressMessage(evt.message) && ( {formatProgressMessage(evt.message)} )} )) )}
); }