wrongmove/frontend/src/components/TaskIndicator.tsx

266 lines
10 KiB
TypeScript
Raw Normal View History

import { TaskStatus, type TaskResult, type TaskState } from '@/types';
import { useEffect, useState, useRef, useMemo } 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 {
tasks: Record<string, TaskState>;
activeTaskId: string | null;
isConnected: boolean;
onCancelTask: (taskId: string) => Promise<boolean>;
onClearAllTasks: () => Promise<boolean>;
onTaskCompleted?: () => void;
}
/** Convert a TaskState into a TaskResult (for the drawer). */
function taskStateToResult(ts: TaskState): TaskResult {
return {
progress: ts.progress ?? 0,
processed: ts.processed,
total: ts.total,
phase: ts.phase,
message: ts.message,
subqueries_probed: ts.subqueries_probed,
subqueries_initial: ts.subqueries_initial,
estimated_results: ts.estimated_results,
subqueries_total: ts.subqueries_total,
subqueries_completed: ts.subqueries_completed,
ids_collected: ts.ids_collected,
pages_fetched: ts.pages_fetched,
fetching_done: ts.fetching_done,
details_fetched: ts.details_fetched,
images_downloaded: ts.images_downloaded,
ocr_completed: ts.ocr_completed,
failed: ts.failed,
elapsed_seconds: ts.elapsed_seconds,
rate_per_second: ts.rate_per_second,
eta_seconds: ts.eta_seconds,
logs: ts.logs,
};
}
function isTerminalStatus(status: string): boolean {
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
}
export function TaskIndicator({
tasks,
activeTaskId,
isConnected: _isConnected,
onCancelTask,
onClearAllTasks,
onTaskCompleted,
}: TaskIndicatorProps) {
const [isCancelling, setIsCancelling] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const onTaskCompletedRef = useRef(onTaskCompleted);
useEffect(() => {
onTaskCompletedRef.current = onTaskCompleted;
}, [onTaskCompleted]);
// Track the currently-viewed task in the drawer; default to the externally-provided activeTaskId
useEffect(() => {
if (activeTaskId) {
setSelectedTaskId(activeTaskId);
}
}, [activeTaskId]);
// Fire onTaskCompleted when the active task transitions to SUCCESS
const prevStatusRef = useRef<string | null>(null);
useEffect(() => {
if (!activeTaskId) {
prevStatusRef.current = null;
return;
}
const task = tasks[activeTaskId];
const currentStatus = task?.status ?? null;
if (
currentStatus === 'SUCCESS' &&
prevStatusRef.current !== null &&
prevStatusRef.current !== 'SUCCESS'
) {
onTaskCompletedRef.current?.();
}
prevStatusRef.current = currentStatus;
}, [activeTaskId, tasks]);
// Derive display data from the active task
const activeTask = activeTaskId ? tasks[activeTaskId] : undefined;
const taskStatus = activeTask ? (activeTask.status as TaskStatus) : null;
const taskResult = activeTask?.phase ? taskStateToResult(activeTask) : null;
const progressPercentage = (activeTask?.progress ?? 0) * 100;
const processed = activeTask?.processed ?? null;
const total = activeTask?.total ?? null;
// Count active (non-terminal) tasks
const activeTaskCount = useMemo(() => {
return Object.values(tasks).filter(
(t) => !isTerminalStatus(t.status),
).length;
}, [tasks]);
const handleCancel = async () => {
if (!activeTaskId || isCancelling) return;
setIsCancelling(true);
try {
await onCancelTask(activeTaskId);
} finally {
setIsCancelling(false);
}
};
const handleClearAll = async () => {
if (isClearing) return;
setIsClearing(true);
try {
await onClearAllTasks();
} finally {
setIsClearing(false);
}
};
if (!activeTaskId || !taskStatus) {
return null;
}
const isInProgress = !isTerminalStatus(taskStatus);
const getStatusIcon = () => {
if (isInProgress) {
return <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />;
}
if (taskStatus === TaskStatus.SUCCESS) {
return <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />;
}
return <XCircle className="h-3.5 w-3.5 text-red-500" />;
};
const getProgressText = () => {
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',
};
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)}%) — click for details`;
}
return `Task running: ${getProgressText()} — click for details`;
}
if (taskStatus === TaskStatus.SUCCESS) {
return 'Task completed successfully — click for details';
}
if (taskStatus === TaskStatus.REVOKED) {
return 'Task was cancelled';
}
return 'Task failed';
};
return (
<TooltipProvider>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setDrawerOpen(true)}
>
{getStatusIcon()}
{isInProgress && (
<div className="flex items-center gap-2">
<div className="w-24 h-1.5 bg-primary/20 rounded-full overflow-hidden hidden sm:block">
<div
className="h-full bg-primary transition-all duration-300 ease-out rounded-full"
style={{ width: `${Math.min(progressPercentage, 100)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground hidden sm:inline min-w-[60px]">
{getProgressText()}
</span>
</div>
)}
{!isInProgress && (
<span className="text-xs text-muted-foreground hidden sm:inline">
{taskStatus}
</span>
)}
{activeTaskCount > 1 && (
<span className="inline-flex items-center justify-center h-4 min-w-[16px] rounded-full bg-blue-500 text-[10px] font-medium text-white px-1">
{activeTaskCount}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{getTooltipContent()}</p>
<p className="text-xs text-muted-foreground mt-1">ID: {activeTaskId.slice(0, 8)}...</p>
</TooltipContent>
</Tooltip>
{isInProgress && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleCancel}
disabled={isCancelling}
className="h-6 w-6 text-muted-foreground hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Cancel task</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleClearAll}
disabled={isClearing}
className="h-6 w-6 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Clear all tasks</p>
</TooltipContent>
</Tooltip>
</div>
<TaskProgressDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
taskResult={taskResult}
taskStatus={taskStatus}
taskID={selectedTaskId ?? activeTaskId}
onCancel={handleCancel}
isCancelling={isCancelling}
tasks={tasks}
selectedTaskId={selectedTaskId}
onSelectTask={setSelectedTaskId}
/>
</TooltipProvider>
);
}