2026-02-01 17:28:37 +00:00
|
|
|
import { getUser } from '@/auth/authService';
|
2026-02-07 00:34:47 +00:00
|
|
|
import { getStoredPasskeyUser } from '@/auth/passkeyService';
|
|
|
|
|
import { fromOidcUser, type AuthUser } from '@/auth/types';
|
2026-02-01 17:28:37 +00:00
|
|
|
import { POLLING_INTERVALS } from '@/constants';
|
2026-02-01 20:40:07 +00:00
|
|
|
import { fetchTaskStatus, cancelTask, clearAllTasks } from '@/services';
|
2026-02-09 21:31:45 +00:00
|
|
|
import { TaskStatus, type TaskResult, type TaskState } from '@/types';
|
|
|
|
|
import { useEffect, useState, useRef, useMemo } from 'react';
|
2026-02-01 17:28:37 +00:00
|
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
|
|
|
|
import { Button } from './ui/button';
|
2026-02-01 20:40:07 +00:00
|
|
|
import { Loader2, CheckCircle2, XCircle, X, Trash2 } from 'lucide-react';
|
2026-02-06 22:37:53 +00:00
|
|
|
import { TaskProgressDrawer } from './TaskProgressDrawer';
|
2026-02-01 17:28:37 +00:00
|
|
|
|
|
|
|
|
interface TaskIndicatorProps {
|
|
|
|
|
taskID: string | null;
|
|
|
|
|
onTaskCancelled?: () => void;
|
2026-02-08 15:11:21 +00:00
|
|
|
onTaskCompleted?: () => void;
|
2026-02-09 21:31:45 +00:00
|
|
|
wsTasks?: Record<string, TaskState>;
|
|
|
|
|
wsConnected?: boolean;
|
2026-02-01 17:28:37 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-09 21:31:45 +00:00
|
|
|
/** Convert a TaskState (from WS) 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({
|
|
|
|
|
taskID,
|
|
|
|
|
onTaskCancelled,
|
|
|
|
|
onTaskCompleted,
|
|
|
|
|
wsTasks,
|
|
|
|
|
wsConnected,
|
|
|
|
|
}: TaskIndicatorProps) {
|
2026-02-07 00:34:47 +00:00
|
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
2026-02-01 17:28:37 +00:00
|
|
|
const [progressPercentage, setProgressPercentage] = useState<number>(0);
|
2026-02-01 19:19:59 +00:00
|
|
|
const [processed, setProcessed] = useState<number | null>(null);
|
|
|
|
|
const [total, setTotal] = useState<number | null>(null);
|
2026-02-01 17:28:37 +00:00
|
|
|
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(null);
|
2026-02-06 22:37:53 +00:00
|
|
|
const [taskResult, setTaskResult] = useState<TaskResult | null>(null);
|
2026-02-01 17:28:37 +00:00
|
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
2026-02-01 20:40:07 +00:00
|
|
|
const [isClearing, setIsClearing] = useState(false);
|
2026-02-06 22:37:53 +00:00
|
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
2026-02-09 21:31:45 +00:00
|
|
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
// Prevents WS effect from overwriting local cancel/clear state before
|
|
|
|
|
// the parent's setTaskID(null) propagates. Reset when taskID changes.
|
|
|
|
|
const cancelledRef = useRef(false);
|
2026-02-01 17:28:37 +00:00
|
|
|
|
2026-02-08 15:11:21 +00:00
|
|
|
const onTaskCompletedRef = useRef(onTaskCompleted);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
onTaskCompletedRef.current = onTaskCompleted;
|
|
|
|
|
}, [onTaskCompleted]);
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
useEffect(() => {
|
2026-02-07 00:34:47 +00:00
|
|
|
const passkeyUser = getStoredPasskeyUser();
|
|
|
|
|
if (passkeyUser) {
|
|
|
|
|
setUser(passkeyUser);
|
|
|
|
|
} else {
|
|
|
|
|
getUser().then((oidcUser) => {
|
|
|
|
|
if (oidcUser) setUser(fromOidcUser(oidcUser));
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-01 17:28:37 +00:00
|
|
|
}, []);
|
|
|
|
|
|
2026-02-09 21:31:45 +00:00
|
|
|
// Track the currently-viewed task in the drawer; default to the externally-provided taskID
|
2026-02-01 17:28:37 +00:00
|
|
|
useEffect(() => {
|
2026-02-09 21:31:45 +00:00
|
|
|
if (taskID) {
|
|
|
|
|
setSelectedTaskId(taskID);
|
|
|
|
|
cancelledRef.current = false; // new task, reset cancelled guard
|
|
|
|
|
}
|
|
|
|
|
}, [taskID]);
|
|
|
|
|
|
|
|
|
|
// Count active (non-terminal) tasks from WS
|
|
|
|
|
const activeWsTaskCount = useMemo(() => {
|
|
|
|
|
if (!wsTasks) return 0;
|
|
|
|
|
return Object.values(wsTasks).filter(
|
|
|
|
|
(t) => !isTerminalStatus(t.status),
|
|
|
|
|
).length;
|
|
|
|
|
}, [wsTasks]);
|
|
|
|
|
|
|
|
|
|
// ----- WebSocket-driven state updates -----
|
|
|
|
|
// When wsConnected, derive taskStatus/taskResult/progress from wsTasks
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!wsConnected || !wsTasks || !taskID) return;
|
|
|
|
|
// Don't let WS overwrite local cancel/clear state
|
|
|
|
|
if (cancelledRef.current) return;
|
|
|
|
|
const wsTask = wsTasks[taskID];
|
|
|
|
|
if (!wsTask) return;
|
|
|
|
|
|
|
|
|
|
const status = wsTask.status as TaskStatus;
|
|
|
|
|
setTaskStatus(status);
|
|
|
|
|
|
|
|
|
|
if (wsTask.phase) {
|
|
|
|
|
setTaskResult(taskStateToResult(wsTask));
|
|
|
|
|
}
|
|
|
|
|
if (wsTask.progress !== undefined) {
|
|
|
|
|
setProgressPercentage(wsTask.progress * 100);
|
|
|
|
|
}
|
|
|
|
|
if (wsTask.processed !== undefined) {
|
|
|
|
|
setProcessed(wsTask.processed);
|
|
|
|
|
}
|
|
|
|
|
if (wsTask.total !== undefined) {
|
|
|
|
|
setTotal(wsTask.total);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (status === TaskStatus.SUCCESS) {
|
|
|
|
|
setProgressPercentage(100);
|
|
|
|
|
onTaskCompletedRef.current?.();
|
|
|
|
|
}
|
|
|
|
|
}, [wsConnected, wsTasks, taskID]);
|
|
|
|
|
|
|
|
|
|
// ----- Polling fallback (only when WS is not connected) -----
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
// If WS is connected, skip polling
|
|
|
|
|
if (wsConnected) return;
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
if (!user || !taskID) {
|
|
|
|
|
setTaskStatus(null);
|
2026-02-06 22:37:53 +00:00
|
|
|
setTaskResult(null);
|
2026-02-01 17:28:37 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reset state for new task
|
|
|
|
|
setTaskStatus(TaskStatus.PENDING);
|
|
|
|
|
setProgressPercentage(0);
|
2026-02-01 19:19:59 +00:00
|
|
|
setProcessed(null);
|
|
|
|
|
setTotal(null);
|
2026-02-06 22:37:53 +00:00
|
|
|
setTaskResult(null);
|
2026-02-01 17:28:37 +00:00
|
|
|
|
|
|
|
|
const pollTaskStatus = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const data = await fetchTaskStatus(user, taskID);
|
|
|
|
|
const status = data.status as TaskStatus;
|
|
|
|
|
setTaskStatus(status);
|
|
|
|
|
|
|
|
|
|
if (status === TaskStatus.SUCCESS) {
|
|
|
|
|
setProgressPercentage(100);
|
2026-02-06 22:37:53 +00:00
|
|
|
if (data.result) {
|
|
|
|
|
try {
|
|
|
|
|
const parsedResult: TaskResult = JSON.parse(data.result);
|
|
|
|
|
if (parsedResult.phase) {
|
|
|
|
|
setTaskResult(parsedResult);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore parsing errors
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 15:11:21 +00:00
|
|
|
onTaskCompletedRef.current?.();
|
2026-02-09 21:31:45 +00:00
|
|
|
return true;
|
2026-02-01 17:28:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
|
2026-02-09 21:31:45 +00:00
|
|
|
return true;
|
2026-02-01 17:28:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data.result) {
|
|
|
|
|
try {
|
|
|
|
|
const parsedResult: TaskResult = JSON.parse(data.result);
|
2026-02-06 22:37:53 +00:00
|
|
|
if (parsedResult.phase) {
|
|
|
|
|
setTaskResult(parsedResult);
|
|
|
|
|
}
|
|
|
|
|
if (parsedResult.progress !== undefined) {
|
|
|
|
|
setProgressPercentage(parsedResult.progress * 100);
|
|
|
|
|
}
|
2026-02-01 19:19:59 +00:00
|
|
|
if (parsedResult.processed !== undefined) {
|
|
|
|
|
setProcessed(parsedResult.processed);
|
|
|
|
|
}
|
|
|
|
|
if (parsedResult.total !== undefined) {
|
|
|
|
|
setTotal(parsedResult.total);
|
|
|
|
|
}
|
2026-02-01 17:28:37 +00:00
|
|
|
} catch {
|
|
|
|
|
// Ignore parsing errors
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-09 21:31:45 +00:00
|
|
|
return false;
|
2026-02-01 17:28:37 +00:00
|
|
|
} catch {
|
|
|
|
|
setTaskStatus(TaskStatus.FAILURE);
|
2026-02-09 21:31:45 +00:00
|
|
|
return true;
|
2026-02-01 17:28:37 +00:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pollTaskStatus();
|
|
|
|
|
|
|
|
|
|
const interval = setInterval(async () => {
|
|
|
|
|
const shouldStop = await pollTaskStatus();
|
|
|
|
|
if (shouldStop) {
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
}
|
|
|
|
|
}, POLLING_INTERVALS.TASK_STATUS_MS);
|
|
|
|
|
|
|
|
|
|
return () => clearInterval(interval);
|
2026-02-09 21:31:45 +00:00
|
|
|
}, [taskID, user, wsConnected]);
|
2026-02-01 17:28:37 +00:00
|
|
|
|
|
|
|
|
const handleCancel = async () => {
|
|
|
|
|
if (!user || !taskID || isCancelling) return;
|
|
|
|
|
|
|
|
|
|
setIsCancelling(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await cancelTask(user, taskID);
|
|
|
|
|
if (result.success) {
|
2026-02-09 21:31:45 +00:00
|
|
|
cancelledRef.current = true;
|
2026-02-01 17:28:37 +00:00
|
|
|
setTaskStatus(TaskStatus.REVOKED);
|
|
|
|
|
onTaskCancelled?.();
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore cancel errors
|
|
|
|
|
} finally {
|
|
|
|
|
setIsCancelling(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-01 20:40:07 +00:00
|
|
|
const handleClearAll = async () => {
|
|
|
|
|
if (!user || isClearing) return;
|
|
|
|
|
|
|
|
|
|
setIsClearing(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await clearAllTasks(user);
|
|
|
|
|
if (result.success) {
|
2026-02-09 21:31:45 +00:00
|
|
|
cancelledRef.current = true;
|
2026-02-01 20:40:07 +00:00
|
|
|
setTaskStatus(null);
|
2026-02-06 22:37:53 +00:00
|
|
|
setTaskResult(null);
|
2026-02-01 20:40:07 +00:00
|
|
|
onTaskCancelled?.();
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore clear errors
|
|
|
|
|
} finally {
|
|
|
|
|
setIsClearing(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
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" />;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-01 19:19:59 +00:00
|
|
|
const getProgressText = () => {
|
|
|
|
|
if (processed !== null && total !== null && total > 0) {
|
|
|
|
|
return `${processed} / ${total}`;
|
|
|
|
|
}
|
2026-02-06 22:37:53 +00:00
|
|
|
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)}%`;
|
|
|
|
|
}
|
2026-02-01 19:19:59 +00:00
|
|
|
return `${Math.round(progressPercentage)}%`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
const getTooltipContent = () => {
|
|
|
|
|
if (isInProgress) {
|
2026-02-01 19:19:59 +00:00
|
|
|
if (processed !== null && total !== null && total > 0) {
|
2026-02-06 22:37:53 +00:00
|
|
|
return `Processing: ${processed} / ${total} listings (${Math.round(progressPercentage)}%) — click for details`;
|
2026-02-01 19:19:59 +00:00
|
|
|
}
|
2026-02-06 22:37:53 +00:00
|
|
|
return `Task running: ${getProgressText()} — click for details`;
|
2026-02-01 17:28:37 +00:00
|
|
|
}
|
|
|
|
|
if (taskStatus === TaskStatus.SUCCESS) {
|
2026-02-06 22:37:53 +00:00
|
|
|
return 'Task completed successfully — click for details';
|
2026-02-01 17:28:37 +00:00
|
|
|
}
|
|
|
|
|
if (taskStatus === TaskStatus.REVOKED) {
|
|
|
|
|
return 'Task was cancelled';
|
|
|
|
|
}
|
|
|
|
|
return 'Task failed';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<TooltipProvider>
|
2026-02-01 19:23:27 +00:00
|
|
|
<div className="flex items-center gap-2">
|
2026-02-01 17:28:37 +00:00
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
2026-02-06 22:37:53 +00:00
|
|
|
<div
|
|
|
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
|
|
|
onClick={() => setDrawerOpen(true)}
|
|
|
|
|
>
|
2026-02-01 17:28:37 +00:00
|
|
|
{getStatusIcon()}
|
2026-02-01 19:23:27 +00:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-02-09 21:31:45 +00:00
|
|
|
{activeWsTaskCount > 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">
|
|
|
|
|
{activeWsTaskCount}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-02-01 17:28:37 +00:00
|
|
|
</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>
|
|
|
|
|
)}
|
2026-02-01 20:40:07 +00:00
|
|
|
<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>
|
2026-02-01 17:28:37 +00:00
|
|
|
</div>
|
2026-02-06 22:37:53 +00:00
|
|
|
<TaskProgressDrawer
|
|
|
|
|
open={drawerOpen}
|
|
|
|
|
onOpenChange={setDrawerOpen}
|
|
|
|
|
taskResult={taskResult}
|
|
|
|
|
taskStatus={taskStatus}
|
2026-02-09 21:31:45 +00:00
|
|
|
taskID={selectedTaskId ?? taskID}
|
2026-02-06 22:37:53 +00:00
|
|
|
onCancel={handleCancel}
|
|
|
|
|
isCancelling={isCancelling}
|
2026-02-09 21:31:45 +00:00
|
|
|
wsTasks={wsTasks}
|
|
|
|
|
selectedTaskId={selectedTaskId}
|
|
|
|
|
onSelectTask={setSelectedTaskId}
|
2026-02-06 22:37:53 +00:00
|
|
|
/>
|
2026-02-01 17:28:37 +00:00
|
|
|
</TooltipProvider>
|
|
|
|
|
);
|
|
|
|
|
}
|