wrongmove/crawler/frontend/src/components/TaskIndicator.tsx

235 lines
8.7 KiB
TypeScript

import { getUser } from '@/auth/authService';
import { POLLING_INTERVALS } from '@/constants';
import { fetchTaskStatus, cancelTask, clearAllTasks } from '@/services';
import { TaskStatus, type TaskResult } from '@/types';
import type { User } from 'oidc-client-ts';
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';
interface TaskIndicatorProps {
taskID: string | null;
onTaskCancelled?: () => void;
}
export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
const [user, setUser] = useState<User | null>(null);
const [progressPercentage, setProgressPercentage] = useState<number>(0);
const [processed, setProcessed] = useState<number | null>(null);
const [total, setTotal] = useState<number | null>(null);
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(null);
const [isCancelling, setIsCancelling] = useState(false);
const [isClearing, setIsClearing] = useState(false);
useEffect(() => {
getUser().then(setUser);
}, []);
useEffect(() => {
if (!user || !taskID) {
setTaskStatus(null);
return;
}
// Reset state for new task
setTaskStatus(TaskStatus.PENDING);
setProgressPercentage(0);
setProcessed(null);
setTotal(null);
const pollTaskStatus = async () => {
try {
const data = await fetchTaskStatus(user, taskID);
const status = data.status as TaskStatus;
setTaskStatus(status);
if (status === TaskStatus.SUCCESS) {
setProgressPercentage(100);
return true; // Stop polling
}
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
return true; // Stop polling
}
// Parse progress for in-progress tasks
if (data.result) {
try {
const parsedResult: TaskResult = JSON.parse(data.result);
setProgressPercentage(parsedResult.progress * 100);
if (parsedResult.processed !== undefined) {
setProcessed(parsedResult.processed);
}
if (parsedResult.total !== undefined) {
setTotal(parsedResult.total);
}
} catch {
// Ignore parsing errors
}
}
return false; // Continue polling
} catch {
setTaskStatus(TaskStatus.FAILURE);
return true; // Stop polling on error
}
};
// Initial poll
pollTaskStatus();
const interval = setInterval(async () => {
const shouldStop = await pollTaskStatus();
if (shouldStop) {
clearInterval(interval);
}
}, POLLING_INTERVALS.TASK_STATUS_MS);
return () => clearInterval(interval);
}, [taskID, user]);
const handleCancel = async () => {
if (!user || !taskID || isCancelling) return;
setIsCancelling(true);
try {
const result = await cancelTask(user, taskID);
if (result.success) {
setTaskStatus(TaskStatus.REVOKED);
onTaskCancelled?.();
}
} catch {
// Ignore cancel errors
} finally {
setIsCancelling(false);
}
};
const handleClearAll = async () => {
if (!user || isClearing) return;
setIsClearing(true);
try {
const result = await clearAllTasks(user);
if (result.success) {
setTaskStatus(null);
onTaskCancelled?.();
}
} catch {
// Ignore clear errors
} finally {
setIsClearing(false);
}
};
if (!taskID || !taskStatus) {
return null;
}
const isInProgress = taskStatus !== TaskStatus.SUCCESS &&
taskStatus !== TaskStatus.FAILURE &&
taskStatus !== TaskStatus.REVOKED;
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}`;
}
return `${Math.round(progressPercentage)}%`;
};
const getTooltipContent = () => {
if (isInProgress) {
if (processed !== null && total !== null && total > 0) {
return `Processing: ${processed} / ${total} listings (${Math.round(progressPercentage)}%)`;
}
return `Task running: ${Math.round(progressPercentage)}%`;
}
if (taskStatus === TaskStatus.SUCCESS) {
return 'Task completed successfully';
}
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-default">
{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>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{getTooltipContent()}</p>
<p className="text-xs text-muted-foreground mt-1">ID: {taskID.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>
</TooltipProvider>
);
}