Add crawl job progress drawer with phase tracking and live logs

- Add phase-aware progress reporting across all crawl phases (splitting,
  fetching, filtering, processing) with per-step counters
- Add TaskProgressDrawer component with phase timeline stepper, detail
  counters, progress bar with ETA, and live worker log viewer
- Add on_step_complete callback to ListingProcessor for granular tracking
  of details/images/OCR steps
- Extend QuerySplitter on_progress callback with structured counter data
- Capture celery worker logs via ring buffer handler and inject into task
  state updates for frontend display
- Guard taskResult updates with phase presence check to prevent drawer
  from blanking during state transitions
This commit is contained in:
Viktor Barzin 2026-02-06 22:37:53 +00:00
parent 4018503723
commit b4837e1603
No known key found for this signature in database
GPG key ID: 0EB088298288D958
6 changed files with 617 additions and 24 deletions

View file

@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Button } from './ui/button';
import { Loader2, CheckCircle2, XCircle, X, Trash2 } from 'lucide-react';
import { TaskProgressDrawer } from './TaskProgressDrawer';
interface TaskIndicatorProps {
taskID: string | null;
@ -19,8 +20,10 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
const [processed, setProcessed] = useState<number | null>(null);
const [total, setTotal] = useState<number | null>(null);
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(null);
const [taskResult, setTaskResult] = useState<TaskResult | null>(null);
const [isCancelling, setIsCancelling] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
getUser().then(setUser);
@ -29,6 +32,7 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
useEffect(() => {
if (!user || !taskID) {
setTaskStatus(null);
setTaskResult(null);
return;
}
@ -37,6 +41,7 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
setProgressPercentage(0);
setProcessed(null);
setTotal(null);
setTaskResult(null);
const pollTaskStatus = async () => {
try {
@ -46,6 +51,20 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
if (status === TaskStatus.SUCCESS) {
setProgressPercentage(100);
// Parse final result for the drawer to show completed state.
// Only update taskResult if the new result has phase info;
// otherwise keep the last in-progress result which has richer data
// than the bare SUCCESS return value.
if (data.result) {
try {
const parsedResult: TaskResult = JSON.parse(data.result);
if (parsedResult.phase) {
setTaskResult(parsedResult);
}
} catch {
// Ignore parsing errors
}
}
return true; // Stop polling
}
@ -57,7 +76,18 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
if (data.result) {
try {
const parsedResult: TaskResult = JSON.parse(data.result);
setProgressPercentage(parsedResult.progress * 100);
// Only update taskResult if the parsed data has a phase field.
// This prevents blanking the drawer when the backend sends a
// state update without phase info (e.g. during brief transitions).
if (parsedResult.phase) {
setTaskResult(parsedResult);
}
// Only update progress/processed/total when the fields are
// actually present — otherwise keep the previous values so
// the UI doesn't flash back to 0 during phase transitions.
if (parsedResult.progress !== undefined) {
setProgressPercentage(parsedResult.progress * 100);
}
if (parsedResult.processed !== undefined) {
setProcessed(parsedResult.processed);
}
@ -113,6 +143,7 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
const result = await clearAllTasks(user);
if (result.success) {
setTaskStatus(null);
setTaskResult(null);
onTaskCancelled?.();
}
} catch {
@ -144,18 +175,27 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
if (processed !== null && total !== null && total > 0) {
return `${processed} / ${total}`;
}
if (taskResult?.phase && taskResult.phase !== 'processing') {
const phaseLabels: Record<string, string> = {
splitting: 'Splitting',
splitting_complete: 'Split done',
fetching: 'Fetching',
filtering: 'Filtering',
};
return phaseLabels[taskResult.phase] ?? `${Math.round(progressPercentage)}%`;
}
return `${Math.round(progressPercentage)}%`;
};
const getTooltipContent = () => {
if (isInProgress) {
if (processed !== null && total !== null && total > 0) {
return `Processing: ${processed} / ${total} listings (${Math.round(progressPercentage)}%)`;
return `Processing: ${processed} / ${total} listings (${Math.round(progressPercentage)}%) — click for details`;
}
return `Task running: ${Math.round(progressPercentage)}%`;
return `Task running: ${getProgressText()} — click for details`;
}
if (taskStatus === TaskStatus.SUCCESS) {
return 'Task completed successfully';
return 'Task completed successfully — click for details';
}
if (taskStatus === TaskStatus.REVOKED) {
return 'Task was cancelled';
@ -168,7 +208,10 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 cursor-default">
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setDrawerOpen(true)}
>
{getStatusIcon()}
{isInProgress && (
<div className="flex items-center gap-2">
@ -230,6 +273,15 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
</TooltipContent>
</Tooltip>
</div>
<TaskProgressDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
taskResult={taskResult}
taskStatus={taskStatus}
taskID={taskID}
onCancel={handleCancel}
isCancelling={isCancelling}
/>
</TooltipProvider>
);
}