import React, { useMemo, useState, useEffect, useRef } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { Box, Container, Paper, Typography, Button, Chip, CircularProgress, Alert, IconButton, Snackbar, } from "@mui/material"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; 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, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi"; import { useQuery, useMutation } 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"; 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 ; } } export default function FetchRequestDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { get, update, resource } = useResource("fetch-requests"); const [stepStats, setStepStats] = useState>({}); const [liveParsedCount, setLiveParsedCount] = useState(undefined); const [failNotif, setFailNotif] = useState(null); const feedRef = useRef(null); const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({ queryKey: ["fetch-requests", "detail", id], queryFn: () => get(id!), enabled: !!id, }); const updateMutation = useMutation({ mutationFn: ({ id: rid, data }: { id: string; data: any }) => update(rid, data), }); 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") { setFailNotif(parsed.message.error || "Fetch request failed"); 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) return; try { await updateMutation.mutateAsync({ id, data: { status: "pending" } }); refetchRequest(); } catch (err: any) { setFailNotif(formatApiError(err)); } }; if (isLoading) { return ( ); } if (fetchError || !fetchRequest) { return ( Failed to load fetch request ); } const req = fetchRequest as any; const retryCount = req.retry_count ?? 0; const isRetryExhausted = retryCount >= RETRY_MAX; const status = req.status as FetchRequestStatus; const detailFields = (resource?.orderedFields ?? []).filter( (f) => f.name !== "source", ); return ( {req.account_name} {detailFields.map((field) => ( ))} 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. )} Progress Events {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)} )} )) )} setFailNotif(null)} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} > setFailNotif(null)} sx={{ borderRadius: 2 }}> {failNotif} ); }