210 lines
7.8 KiB
TypeScript
210 lines
7.8 KiB
TypeScript
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<string>,
|
|
stepStats: Record<string, number>,
|
|
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<string>): 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<string, number>,
|
|
liveParsedCount: number,
|
|
txnBlockCount: number,
|
|
): Record<number, string> {
|
|
const msgs: Record<number, string> = {};
|
|
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<string, number>;
|
|
liveParsedCount: number;
|
|
}
|
|
|
|
export function PipelineStepper({ fetchRequest, sseEvents, stepStats, liveParsedCount }: PipelineStepperProps) {
|
|
const status = (fetchRequest?.status ?? "pending") as FetchRequestStatus;
|
|
|
|
const seenSteps = useMemo(() => {
|
|
const steps = new Set<string>();
|
|
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 (
|
|
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
|
|
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
|
Pipeline Progress
|
|
</Typography>
|
|
<Stepper activeStep={activeStep} alternativeLabel>
|
|
{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 = <CheckCircleIcon sx={{ color: "success.main" }} />;
|
|
} else if (isFailed) {
|
|
icon = <ErrorIcon sx={{ color: "error.main" }} />;
|
|
} else if (isPaused) {
|
|
icon = <WarningAmberIcon sx={{ color: "warning.main" }} />;
|
|
} else if (isActive) {
|
|
icon = <CircularProgress size={20} />;
|
|
} else {
|
|
icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>;
|
|
}
|
|
|
|
return (
|
|
<Step key={label}>
|
|
<StepLabel
|
|
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
|
|
>
|
|
<Typography variant="body2" fontWeight={600}>{label}</Typography>
|
|
{stepMessages[index] && (
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
|
|
{stepMessages[index]}
|
|
</Typography>
|
|
)}
|
|
</StepLabel>
|
|
</Step>
|
|
);
|
|
})}
|
|
</Stepper>
|
|
|
|
<Box sx={{ mt: 3, mb: 1 }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}>
|
|
<Typography variant="caption" color="text.secondary">Overall Progress</Typography>
|
|
{["processing", "paused"].includes(status) && displayParsedCount > 0 && (
|
|
<Typography variant="caption" fontWeight={600} color="info.main">
|
|
Validated: {displayParsedCount} transactions
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={progressPercent}
|
|
color={status === "failed" ? "error" : status === "completed" ? "success" : "primary"}
|
|
sx={{ borderRadius: 1, height: 8, transition: "width 0.3s ease" }}
|
|
/>
|
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.25, display: "block" }}>
|
|
{progressPercent}%
|
|
</Typography>
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
}
|