fetch request fixes

This commit is contained in:
2026-07-11 17:11:27 +05:30
parent afddb3cfbc
commit 99cc5afb7c
3 changed files with 211 additions and 407 deletions

View File

@@ -12,7 +12,6 @@ import {
Stepper,
Step,
StepLabel,
StepIcon,
LinearProgress,
IconButton,
Snackbar,
@@ -35,7 +34,7 @@ import type {
ProgressMessage,
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useAppContext, useResource } from "../react-openapi";
import { useAppContext, useResource, useItemSse } from "../react-openapi";
import { useQuery, useMutation } from "@tanstack/react-query";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
@@ -58,6 +57,8 @@ const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
failed: <ErrorIcon sx={{ fontSize: 16 }} />,
};
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
function computeProgressPercent(
status: FetchRequestStatus,
liveCount: number,
@@ -86,8 +87,6 @@ function computeProgressPercent(
return Math.round(Math.min(100, pct));
}
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
function computeActiveStep(status: FetchRequestStatus, seenSteps: Set<string>): number {
if (status === "completed") return stepLabels.length;
@@ -125,27 +124,13 @@ function sseIcon(status: SSEEvent["status"]) {
case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />;
case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />;
case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />;
case "progress": return (
<FiberManualRecordIcon
sx={{ fontSize: 14, color: "info.main" }}
/>
);
case "progress": return <FiberManualRecordIcon sx={{ fontSize: 14, color: "info.main" }} />;
}
}
function isMathValid(candidate: { amount: number; balance: number }, prevBalance: number) {
return (
candidate.balance === prevBalance + candidate.amount ||
candidate.balance === prevBalance - candidate.amount ||
Math.abs(candidate.balance - (prevBalance + candidate.amount)) < 0.01 ||
Math.abs(candidate.balance - (prevBalance - candidate.amount)) < 0.01
);
}
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { config } = useAppContext();
const { get, update } = useResource("fetch-requests");
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
@@ -159,59 +144,14 @@ export default function FetchRequestDetail() {
const resolveMutation = useResolveAmbiguity();
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
const [sseEvents, setSseEvents] = React.useState<SSEEvent[]>([]);
const [sseConnected, setSseConnected] = React.useState(false);
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
const [stepStats, setStepStats] = React.useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
const [failNotif, setFailNotif] = React.useState<string | null>(null);
const sseRef = React.useRef<EventSource | null>(null);
const feedRef = React.useRef<HTMLDivElement>(null);
const txnBlockCount = React.useMemo(() => {
const blocks = (fetchRequest as any)?.source?.txn_blocks;
if (!blocks) return 0;
return Object.values(blocks).reduce(
(sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0),
0,
);
}, [fetchRequest]);
const stepMessages = React.useMemo(() => {
const msgs: Record<number, string> = {};
const source = (fetchRequest as any)?.source;
const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0);
if (rawLineCount) msgs[0] = `${rawLineCount}`;
const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
const dictLive = liveParsedCount ?? stepStats.txn_dicts ?? 0;
const dictCurrent = Math.max(dictLive, sourceDictCount);
if (dictCurrent && txnBlockCount) msgs[1] = `${dictCurrent}/${txnBlockCount}`;
else if (dictCurrent) msgs[1] = `${dictCurrent}`;
const txnDictDenom = stepStats.txn_dicts ?? sourceDictCount;
if (stepStats.enrich_count && txnDictDenom) msgs[2] = `${stepStats.enrich_count}/${txnDictDenom}`;
else if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count}`;
if (stepStats.save_count && txnDictDenom) msgs[3] = `${stepStats.save_count}/${txnDictDenom}`;
else if (stepStats.save_count) msgs[3] = `${stepStats.save_count}`;
return msgs;
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
React.useEffect(() => {
if (!id || !config?.baseApiUrl) return;
const url = `${config.baseApiUrl}/fetch-requests/${id}/events`;
const es = new EventSource(url);
sseRef.current = es;
es.onopen = () => setSseConnected(true);
es.onerror = () => setSseConnected(false);
es.onmessage = (event) => {
try {
const parsed: SSEEvent = JSON.parse(event.data);
setSseEvents((prev) => [...prev, parsed]);
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! }));
@@ -241,16 +181,8 @@ export default function FetchRequestDetail() {
if (parsed.status === "completed" || parsed.step === "resume_extract") {
refetchRequest();
}
} catch {
// ignore malformed events
}
};
return () => {
es.close();
sseRef.current = null;
};
}, [id, config?.baseApiUrl]);
},
});
React.useEffect(() => {
if (feedRef.current) {
@@ -258,27 +190,14 @@ export default function FetchRequestDetail() {
}
}, [sseEvents]);
const displayEvents = React.useMemo(() => {
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]);
const lastProgressIdx: Record<string, number> = {};
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),
const txnBlockCount = React.useMemo(() => {
const blocks = (fetchRequest as any)?.source?.txn_blocks;
if (!blocks) return 0;
return Object.values(blocks).reduce(
(sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0),
0,
);
}
return true;
});
}, [sseEvents]);
}, [fetchRequest]);
const seenSteps = React.useMemo(() => {
const steps = new Set<string>();
@@ -308,6 +227,29 @@ export default function FetchRequestDetail() {
return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
}, [fetchRequest, stepStats]);
const stepMessages = React.useMemo(() => {
const msgs: Record<number, string> = {};
const source = (fetchRequest as any)?.source;
const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0);
if (rawLineCount) msgs[0] = `${rawLineCount}`;
const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
const dictLive = liveParsedCount ?? stepStats.txn_dicts ?? 0;
const dictCurrent = Math.max(dictLive, sourceDictCount);
if (dictCurrent && txnBlockCount) msgs[1] = `${dictCurrent}/${txnBlockCount}`;
else if (dictCurrent) msgs[1] = `${dictCurrent}`;
const txnDictDenom = stepStats.txn_dicts ?? sourceDictCount;
if (stepStats.enrich_count && txnDictDenom) msgs[2] = `${stepStats.enrich_count}/${txnDictDenom}`;
else if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count}`;
if (stepStats.save_count && txnDictDenom) msgs[3] = `${stepStats.save_count}/${txnDictDenom}`;
else if (stepStats.save_count) msgs[3] = `${stepStats.save_count}`;
return msgs;
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
const progressPercent = React.useMemo(
() => computeProgressPercent(
(fetchRequest as any)?.status as FetchRequestStatus ?? "pending",
@@ -320,6 +262,28 @@ export default function FetchRequestDetail() {
[fetchRequest, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount],
);
const displayEvents = React.useMemo(() => {
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]);
const lastProgressIdx: Record<string, number> = {};
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 {
@@ -360,11 +324,8 @@ export default function FetchRequestDetail() {
const activeStep = computeActiveStep(req.status as FetchRequestStatus, seenSteps);
const retryCount = req.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const pendingAmbiguities = ambiguities?.filter((a: any) => a.status === "pending") ?? [];
const resolvedAmbiguities = ambiguities?.filter((a: any) => a.status === "resolved") ?? [];
const hasAmbiguities = ambiguities && ambiguities.length > 0;
const allResolved = hasAmbiguities && pendingAmbiguities.length === 0;
const ambiguitiesLoading = !ambiguities;
const allResolved = hasAmbiguities && ambiguities.every((a: any) => a.status === "resolved");
return (
<Container sx={{ mt: 4, mb: 4 }}>
@@ -372,6 +333,7 @@ export default function FetchRequestDetail() {
Back to Fetch Requests
</Button>
{/* Header Card */}
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Chip
@@ -392,7 +354,7 @@ export default function FetchRequestDetail() {
<Box>
<Typography variant="caption" color="text.secondary">Date Range</Typography>
<Typography variant="body2">
{(req as any).start_date ? new Date((req as any).start_date).toLocaleDateString() : "?"} {(req as any).end_date ? new Date((req as any).end_date).toLocaleDateString() : "?"}
{req.start_date ? new Date(req.start_date).toLocaleDateString() : "?"} {req.end_date ? new Date(req.end_date).toLocaleDateString() : "?"}
</Typography>
</Box>
<Box>
@@ -407,11 +369,10 @@ export default function FetchRequestDetail() {
)}
</Box>
{/* Progress Bar */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Overall Progress
</Typography>
<Typography variant="caption" color="text.secondary">Overall Progress</Typography>
{["processing", "paused"].includes(req.status) && displayParsedCount > 0 && (
<Typography variant="caption" fontWeight={600} color="info.main">
Validated: {displayParsedCount} transactions
@@ -429,6 +390,7 @@ export default function FetchRequestDetail() {
</Typography>
</Box>
{/* Retry Counter */}
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1, maxWidth: 300 }}>
<Typography variant="caption" color="text.secondary">
@@ -455,18 +417,19 @@ export default function FetchRequestDetail() {
</Box>
</Paper>
{/* Error Alerts */}
{req.status === "failed" && req.error_message && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{req.error_message}
</Alert>
)}
{isRetryExhausted && req.status === "failed" && (
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
Max retries reached no further retry attempts will be made.
</Alert>
)}
{/* Pipeline Stepper */}
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Pipeline Progress
@@ -491,17 +454,15 @@ export default function FetchRequestDetail() {
icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>;
}
const stepMsg = stepMessages[index];
return (
<Step key={label}>
<StepLabel
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
>
<Typography variant="body2" fontWeight={600}>{label}</Typography>
{stepMsg && (
{stepMessages[index] && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
{stepMsg}
{stepMessages[index]}
</Typography>
)}
</StepLabel>
@@ -511,6 +472,7 @@ export default function FetchRequestDetail() {
</Stepper>
</Paper>
{/* SSE Event Feed */}
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
@@ -568,15 +530,13 @@ export default function FetchRequestDetail() {
</Typography>
)}
</Box>
<Typography variant="caption" color="text.disabled">
{new Date().toLocaleTimeString()}
</Typography>
</Box>
))
)}
</Box>
</Paper>
{/* Ambiguity Resolution */}
{hasAmbiguities && (
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
@@ -666,6 +626,7 @@ export default function FetchRequestDetail() {
</Box>
</Paper>
)}
<Snackbar
open={!!failNotif}
autoHideDuration={6000}

View File

@@ -25,6 +25,12 @@ import {
FormControl,
OutlinedInput,
Autocomplete,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
@@ -36,9 +42,7 @@ import ErrorIcon from "@mui/icons-material/Error";
import ScheduleIcon from "@mui/icons-material/Schedule";
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import {
useUploadFile,
} from "./features/fetch-requests";
import { useUploadFile } from "./features/fetch-requests";
import type {
FetchRequest,
FetchRequestStatus,
@@ -47,7 +51,7 @@ import type {
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom";
import { useResource, FormFieldRenderer } from "../react-openapi";
import { useResource, FormFieldRenderer, applyDisplayFormat } from "../react-openapi";
import type { FieldConfig } from "../react-openapi";
import { useMutation, useQuery } from "@tanstack/react-query";
@@ -81,18 +85,6 @@ const STATUS_OPTIONS: FetchRequestStatus[] = [
"failed",
];
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString();
}
function formatDateRange(start?: string, end?: string) {
if (!start && !end) return "\u2014";
const s = start ? new Date(start).toLocaleDateString() : "?";
const e = end ? new Date(end).toLocaleDateString() : "?";
return `${s} \u2192 ${e}`;
}
function shortId(fp: string) {
return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp;
}
@@ -118,8 +110,7 @@ export default function FetchRequests() {
const [accountFilter, setAccountFilter] = React.useState("");
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
const fr = useResource("fetch-requests");
const { list, create, update, remove, resource: fetchRes } = fr;
const { list, create, update, remove, resource: fetchRes } = useResource("fetch-requests");
const { data: listData, isLoading, isFetching, refetch } = useQuery({
queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }],
@@ -141,20 +132,15 @@ export default function FetchRequests() {
const fields = fetchRes?.orderedFields ?? [];
const formatField: FieldConfig | undefined = fields.find(f => f.name === "format");
const formatOptions: string[] = formatField?.enumValues ?? [];
const startDateField: FieldConfig | undefined = fields.find(f => f.name === "start_date");
const endDateField: FieldConfig | undefined = fields.find(f => f.name === "end_date");
const payorUsernameField: FieldConfig | undefined = fields.find(f => f.name === "payor_username");
const createMutation = useMutation({
mutationFn: (data: any) => create(data),
});
const createMutation = useMutation({ mutationFn: (data: any) => create(data) });
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => remove(id),
});
const deleteMutation = useMutation({ mutationFn: (id: string) => remove(id) });
const uploadMutation = useUploadFile();
const requests = listData?.items ?? [];
@@ -199,7 +185,7 @@ export default function FetchRequests() {
navigate(`/fetch-requests/${result.id}`);
} catch (err: any) {
if (err?.response?.status === 409) {
setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" });
setSnackbar({ message: "Duplicate same fingerprint already exists", severity: "error" });
} else {
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
}
@@ -238,14 +224,13 @@ export default function FetchRequests() {
setDeleteTarget(null);
};
const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"];
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Fetch Request Pipeline
</Typography>
{/* Create Form */}
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
New Fetch Request
@@ -286,40 +271,14 @@ export default function FetchRequests() {
Uploaded as: {uploadedPath}
</Alert>
)}
{formatField ? (
<FormFieldRenderer
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
{formatField && (
<FormFieldRenderer field={formatField} value={format} onChange={setFormat} />
)}
</>
) : (
<>
{formatField ? (
<FormFieldRenderer
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
{formatField && (
<FormFieldRenderer field={formatField} value={format} onChange={setFormat} />
)}
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" />
<TextField label="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} size="small" />
@@ -334,58 +293,22 @@ export default function FetchRequests() {
renderInput={(params) => (
<TextField {...params} label="Account Name" size="small" required />
)}
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
/>
{payorUsernameField ? (
<FormFieldRenderer
field={payorUsernameField}
value={payorUsername}
onChange={setPayorUsername}
/>
) : (
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
{payorUsernameField && (
<FormFieldRenderer field={payorUsernameField} value={payorUsername} onChange={setPayorUsername} />
)}
<Box sx={{ display: "flex", gap: 2 }}>
{startDateField ? (
{startDateField && (
<Box sx={{ flex: 1 }}>
<FormFieldRenderer
field={startDateField}
value={startDate}
onChange={setStartDate}
/>
<FormFieldRenderer field={startDateField} value={startDate} onChange={setStartDate} />
</Box>
) : (
<TextField
label="Start Date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }}
/>
)}
{endDateField ? (
{endDateField && (
<Box sx={{ flex: 1 }}>
<FormFieldRenderer
field={endDateField}
value={endDate}
onChange={setEndDate}
/>
<FormFieldRenderer field={endDateField} value={endDate} onChange={setEndDate} />
</Box>
) : (
<TextField
label="End Date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }}
/>
)}
</Box>
@@ -399,6 +322,7 @@ export default function FetchRequests() {
</Box>
</Paper>
{/* Filters */}
<Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
@@ -410,7 +334,7 @@ export default function FetchRequests() {
input={<OutlinedInput label="Status" />}
renderValue={(selected) => (selected as string[]).join(", ")}
>
{STATUS_OPTIONS.map((s: string) => (
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
))}
</Select>
@@ -422,7 +346,7 @@ export default function FetchRequests() {
renderInput={(params) => (
<TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} />
)}
sx={{ minWidth: 160, "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
sx={{ minWidth: 160 }}
/>
<ToggleButtonGroup
value={sourceFilter}
@@ -430,11 +354,9 @@ export default function FetchRequests() {
onChange={(_, val) => val && setSourceFilter(val)}
size="small"
>
{sourceTypeOptions.map((opt) => (
<ToggleButton key={opt} value={opt}>
{opt === "all" ? "All" : opt === "file" ? "File" : "Email"}
</ToggleButton>
))}
<ToggleButton value="all">All</ToggleButton>
<ToggleButton value="file">File</ToggleButton>
<ToggleButton value="email">Email</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ flex: 1 }} />
<IconButton onClick={() => refetch()} disabled={isFetching}>
@@ -443,6 +365,7 @@ export default function FetchRequests() {
</Box>
</Paper>
{/* List Table */}
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
@@ -452,41 +375,35 @@ export default function FetchRequests() {
No fetch requests yet
</Box>
) : (
<Paper variant="outlined" sx={{ borderRadius: 4 }}>
<Box sx={{ overflowX: "auto" }}>
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
<Box component="thead">
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
{["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => (
<Box
key={h}
component="th"
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
>
{h}
</Box>
))}
</Box>
</Box>
<Box component="tbody">
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Account</TableCell>
<TableCell>Source</TableCell>
<TableCell>Date Range</TableCell>
<TableCell>Status</TableCell>
<TableCell>Retries</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{[...requests]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((req: FetchRequest) => (
<Box
<TableRow
key={req.id}
component="tr"
hover
sx={{ cursor: "pointer" }}
onClick={() => navigate(`/fetch-requests/${req.id}`)}
sx={{
cursor: "pointer",
borderBottom: 1,
borderColor: "divider",
"&:hover": { bgcolor: "action.hover" },
"&:last-child": { borderBottom: 0 },
}}
>
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{shortId(req.fingerprint)}
</Typography>
<IconButton
size="small"
onClick={(e) => {
@@ -499,25 +416,22 @@ export default function FetchRequests() {
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
{req.account_name}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
</TableCell>
<TableCell>{req.account_name}</TableCell>
<TableCell>
<Chip
label={"path" in req.source ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{formatDateRange((req as any).start_date, (req as any).end_date)}
{(req as any).start_date ? new Date((req as any).start_date).toLocaleDateString() : "?"} {(req as any).end_date ? new Date((req as any).end_date).toLocaleDateString() : "?"}
</Typography>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
</TableCell>
<TableCell>
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
<Chip
icon={statusIcons[req.status] as any}
@@ -526,69 +440,45 @@ export default function FetchRequests() {
size="small"
/>
</Tooltip>
</Box>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
{(req.retry_count ?? 0) > 0 ? (
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
{req.retry_count}/{RETRY_MAX}
{(req.retry_count ?? 0) > 0 ? `${req.retry_count}/${RETRY_MAX}` : "—"}
</Typography>
) : (
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
\u2014
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{new Date(req.created_at).toLocaleString()}
</Typography>
)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, whiteSpace: "nowrap", fontSize: "0.8rem" }}>
{formatDate(req.created_at)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
</TableCell>
<TableCell align="right">
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
{req.status === "paused" && (
<Tooltip title="Resolve ambiguities">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/fetch-requests/${req.id}`);
}}
>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); navigate(`/fetch-requests/${req.id}`); }}>
<WarningAmberIcon fontSize="small" color="warning" />
</IconButton>
</Tooltip>
)}
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
<Tooltip title="Retry">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleRetry(req);
}}
>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); handleRetry(req); }}>
<ReplayIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(req);
}}
>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setDeleteTarget(req); }}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Box>
</TableCell>
</TableRow>
))}
</Box>
</Box>
</Box>
</Paper>
</TableBody>
</Table>
</TableContainer>
)}
<Snackbar

View File

@@ -1,49 +1,7 @@
import { useResource, getApi } from "../../../react-openapi";
import { getApi } from "../../../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ResolveAmbiguityPayload } from "./fetch-requests.models";
export function useFetchRequestsList(params?: {
status?: string;
account_name?: string;
source_type?: string;
}) {
const { list } = useResource("fetch-requests");
return useQuery({
queryKey: ["fetch-requests", "list", params],
queryFn: () => list(params),
});
}
export function useFetchRequest(id: string) {
const { get } = useResource("fetch-requests");
return useQuery({
queryKey: ["fetch-requests", "detail", id],
queryFn: () => get(id),
enabled: !!id,
});
}
export function useCreateFetchRequest() {
const { create } = useResource("fetch-requests");
return useMutation({
mutationFn: (data: any) => create(data),
});
}
export function useUpdateFetchRequest() {
const { update } = useResource("fetch-requests");
return useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
});
}
export function useDeleteFetchRequest() {
const { remove } = useResource("fetch-requests");
return useMutation({
mutationFn: (id: string) => remove(id),
});
}
export function useUploadFile() {
return useMutation({
mutationFn: async (file: File) => {
@@ -66,9 +24,7 @@ export function useFetchRequestAmbiguities(fetchRequestId: string) {
queryKey: ["fetch-requests", fetchRequestId, "ambiguities"],
queryFn: async () => {
const api = getApi();
const res = await api.get(
`/fetch-requests/${fetchRequestId}/ambiguities`
);
const res = await api.get(`/fetch-requests/${fetchRequestId}/ambiguities`);
return res.data;
},
enabled: !!fetchRequestId,
@@ -87,10 +43,7 @@ export function useResolveAmbiguity() {
payload: ResolveAmbiguityPayload;
}) => {
const api = getApi();
const res = await api.post(
`/ambiguities/${ambiguityId}/resolve`,
payload
);
const res = await api.post(`/ambiguities/${ambiguityId}/resolve`, payload);
return res.data;
},
onSuccess: (data: any) => {