import React, { useMemo } from "react"; import { Box, Paper, Typography, Stepper, Step, StepLabel, LinearProgress, } from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import ErrorIcon from "@mui/icons-material/Error"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import CircularProgress from "@mui/material/CircularProgress"; import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../../features/fetch-requests"; const STEP_LABELS = ["Extract", "Raw Expense", "Enrich", "Save"]; function computeProgressPercent( status: FetchRequestStatus, liveCount: number, seenSteps: Set, stepStats: Record, txnBlockCount: number, txnDictCount: number, ): number { if (status === "pending") return 0; if (status === "completed") return 100; let pct = 0; if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10; if (txnBlockCount > 0) { const current = Math.max(liveCount, stepStats.txn_dicts ?? 0); pct += Math.min(1, current / txnBlockCount) * 20; } if (txnDictCount > 0) { pct += Math.min(1, (stepStats.enrich_count ?? 0) / txnDictCount) * 50; pct += Math.min(1, (stepStats.save_count ?? 0) / txnDictCount) * 20; } return Math.round(Math.min(100, pct)); } function computeActiveStep(status: FetchRequestStatus, seenSteps: Set): number { if (status === "completed") return STEP_LABELS.length; if (seenSteps.has("save_expenses/completed") || seenSteps.has("complete/completed")) return STEP_LABELS.length; if (seenSteps.has("save_expenses") || seenSteps.has("complete")) return 3; if (seenSteps.has("enrich/completed")) return 3; if (seenSteps.has("enrich")) return 2; if (seenSteps.has("txn_dicts/completed") || status === "raw_expenses_done") return 2; if (seenSteps.has("txn_dicts")) return 1; if (seenSteps.has("txn_blocks/completed")) return 1; if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) return 0; if (status === "processing" || status === "paused") return 0; return -1; } function computeStepMessages( fetchRequest: any, stepStats: Record, liveParsedCount: number, txnBlockCount: number, ): Record { const msgs: Record = {}; const source = fetchRequest?.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; } interface PipelineStepperProps { fetchRequest: any; sseEvents: SSEEvent[]; stepStats: Record; liveParsedCount: number; } export function PipelineStepper({ fetchRequest, sseEvents, stepStats, liveParsedCount }: PipelineStepperProps) { const status = (fetchRequest?.status ?? "pending") as FetchRequestStatus; const seenSteps = useMemo(() => { const steps = new Set(); for (const evt of sseEvents) { steps.add(evt.step); if (evt.status === "completed") steps.add(`${evt.step}/completed`); if (evt.status === "failed") steps.add(`${evt.step}/failed`); if (evt.status === "started") steps.add(`${evt.step}/started`); if (evt.status === "progress") steps.add(`${evt.step}/progress`); } return steps; }, [sseEvents]); const txnBlockCount = useMemo(() => { const blocks = fetchRequest?.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 txnDictCount = useMemo(() => { const source = fetchRequest?.source; if (stepStats.txn_dicts && stepStats.txn_dicts > 0) return stepStats.txn_dicts; return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0; }, [fetchRequest, stepStats]); const displayParsedCount = useMemo(() => { if (liveParsedCount && liveParsedCount > 0) return liveParsedCount; const source = fetchRequest?.source; const persistedCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0; if (persistedCount > 0) return persistedCount; const dicts = source?.txn_dicts; if (Array.isArray(dicts) && dicts.length > 0) return dicts.length; return 0; }, [liveParsedCount, fetchRequest]); const activeStep = computeActiveStep(status, seenSteps); const progressPercent = computeProgressPercent(status, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount); const stepMessages = computeStepMessages(fetchRequest, stepStats, liveParsedCount, txnBlockCount); return ( Pipeline Progress {STEP_LABELS.map((label, index) => { const isCompleted = index < activeStep; const isActive = index === activeStep; const isPaused = status === "paused" && isActive; const isFailed = status === "failed" && isActive; let icon: React.ReactNode; if (isCompleted) { icon = ; } else if (isFailed) { icon = ; } else if (isPaused) { icon = ; } else if (isActive) { icon = ; } else { icon = {index + 1}; } return ( {icon}} > {label} {stepMessages[index] && ( {stepMessages[index]} )} ); })} Overall Progress {["processing", "paused"].includes(status) && displayParsedCount > 0 && ( Validated: {displayParsedCount} transactions )} {progressPercent}% ); }