Add real-time WebSocket task progress with multi-job drawer
Replace 5s HTTP polling with WebSocket-based real-time updates for task progress. Celery workers publish progress to Redis pub/sub channels; a FastAPI WebSocket endpoint subscribes and forwards to the browser. Polling is kept as a 30s fallback when WebSocket is unavailable. The task progress drawer now supports multiple concurrent jobs with a tab bar for switching between scrape and POI distance tasks. Backend: - Add services/task_progress_publisher.py (Redis pub/sub bridge) - Add api/ws_routes.py (WebSocket endpoint with JWT auth) - Publish progress from listing_tasks and poi_tasks - Publish REVOKED via pub/sub on cancel/clear to fix stuck UI Frontend: - Add useTaskWebSocket hook with reconnection and keepalive - Add TaskState and WS message types - TaskIndicator: WS-driven updates with polling fallback - TaskProgressDrawer: multi-job tabs, POI phase timeline - Guard against WS overwriting local cancel state
This commit is contained in:
parent
73d19e29d5
commit
8559c4b461
11 changed files with 774 additions and 72 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import type { AuthUser } from '@/auth/types';
|
||||
import type { TaskState } from '@/types';
|
||||
import { Button } from './ui/button';
|
||||
import { Separator } from './ui/separator';
|
||||
import { LogOut, Home, Filter } from 'lucide-react';
|
||||
|
|
@ -16,6 +17,9 @@ interface HeaderProps {
|
|||
showFilterToggle?: boolean;
|
||||
onTaskCancelled?: () => void;
|
||||
onTaskCompleted?: () => void;
|
||||
wsTasks?: Record<string, TaskState>;
|
||||
wsConnected?: boolean;
|
||||
wsSubscribe?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
|
|
@ -26,6 +30,9 @@ export function Header({
|
|||
showFilterToggle = false,
|
||||
onTaskCancelled,
|
||||
onTaskCompleted,
|
||||
wsTasks,
|
||||
wsConnected,
|
||||
wsSubscribe,
|
||||
}: HeaderProps) {
|
||||
const handleLogout = async () => {
|
||||
if (user.provider === 'passkey') {
|
||||
|
|
@ -50,7 +57,13 @@ export function Header({
|
|||
<HealthIndicator />
|
||||
|
||||
{/* Task Indicator */}
|
||||
<TaskIndicator taskID={taskID ?? null} onTaskCancelled={onTaskCancelled} onTaskCompleted={onTaskCompleted} />
|
||||
<TaskIndicator
|
||||
taskID={taskID ?? null}
|
||||
onTaskCancelled={onTaskCancelled}
|
||||
onTaskCompleted={onTaskCompleted}
|
||||
wsTasks={wsTasks}
|
||||
wsConnected={wsConnected}
|
||||
/>
|
||||
|
||||
{/* Filter Toggle (mobile) */}
|
||||
{showFilterToggle && (
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { getStoredPasskeyUser } from '@/auth/passkeyService';
|
|||
import { fromOidcUser, type AuthUser } from '@/auth/types';
|
||||
import { POLLING_INTERVALS } from '@/constants';
|
||||
import { fetchTaskStatus, cancelTask, clearAllTasks } from '@/services';
|
||||
import { TaskStatus, type TaskResult } from '@/types';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
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';
|
||||
|
|
@ -14,9 +14,48 @@ interface TaskIndicatorProps {
|
|||
taskID: string | null;
|
||||
onTaskCancelled?: () => void;
|
||||
onTaskCompleted?: () => void;
|
||||
wsTasks?: Record<string, TaskState>;
|
||||
wsConnected?: boolean;
|
||||
}
|
||||
|
||||
export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: TaskIndicatorProps) {
|
||||
/** 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) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [progressPercentage, setProgressPercentage] = useState<number>(0);
|
||||
const [processed, setProcessed] = useState<number | null>(null);
|
||||
|
|
@ -26,6 +65,11 @@ export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: Task
|
|||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
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);
|
||||
|
||||
const onTaskCompletedRef = useRef(onTaskCompleted);
|
||||
useEffect(() => {
|
||||
|
|
@ -43,7 +87,58 @@ export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: Task
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Track the currently-viewed task in the drawer; default to the externally-provided taskID
|
||||
useEffect(() => {
|
||||
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;
|
||||
|
||||
if (!user || !taskID) {
|
||||
setTaskStatus(null);
|
||||
setTaskResult(null);
|
||||
|
|
@ -65,10 +160,6 @@ export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: Task
|
|||
|
||||
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);
|
||||
|
|
@ -80,26 +171,19 @@ export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: Task
|
|||
}
|
||||
}
|
||||
onTaskCompletedRef.current?.();
|
||||
return true; // Stop polling
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
|
||||
return true; // Stop polling
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse progress for in-progress tasks
|
||||
if (data.result) {
|
||||
try {
|
||||
const parsedResult: TaskResult = JSON.parse(data.result);
|
||||
// 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);
|
||||
}
|
||||
|
|
@ -113,14 +197,13 @@ export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: Task
|
|||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
return false; // Continue polling
|
||||
return false;
|
||||
} catch {
|
||||
setTaskStatus(TaskStatus.FAILURE);
|
||||
return true; // Stop polling on error
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial poll
|
||||
pollTaskStatus();
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
|
|
@ -131,7 +214,7 @@ export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: Task
|
|||
}, POLLING_INTERVALS.TASK_STATUS_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [taskID, user]);
|
||||
}, [taskID, user, wsConnected]);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!user || !taskID || isCancelling) return;
|
||||
|
|
@ -140,6 +223,7 @@ export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: Task
|
|||
try {
|
||||
const result = await cancelTask(user, taskID);
|
||||
if (result.success) {
|
||||
cancelledRef.current = true;
|
||||
setTaskStatus(TaskStatus.REVOKED);
|
||||
onTaskCancelled?.();
|
||||
}
|
||||
|
|
@ -157,6 +241,7 @@ export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: Task
|
|||
try {
|
||||
const result = await clearAllTasks(user);
|
||||
if (result.success) {
|
||||
cancelledRef.current = true;
|
||||
setTaskStatus(null);
|
||||
setTaskResult(null);
|
||||
onTaskCancelled?.();
|
||||
|
|
@ -245,6 +330,11 @@ export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: Task
|
|||
{taskStatus}
|
||||
</span>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
|
|
@ -292,9 +382,12 @@ export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: Task
|
|||
onOpenChange={setDrawerOpen}
|
||||
taskResult={taskResult}
|
||||
taskStatus={taskStatus}
|
||||
taskID={taskID}
|
||||
taskID={selectedTaskId ?? taskID}
|
||||
onCancel={handleCancel}
|
||||
isCancelling={isCancelling}
|
||||
wsTasks={wsTasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={setSelectedTaskId}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { TaskStatus, type TaskPhase, type TaskResult } from '@/types';
|
||||
import { TaskStatus, type TaskPhase, type TaskResult, type TaskState } from '@/types';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
|
|
@ -8,8 +8,8 @@ import {
|
|||
SheetFooter,
|
||||
} from './ui/sheet';
|
||||
import { Button } from './ui/button';
|
||||
import { CheckCircle2, Circle, Loader2, XCircle } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { CheckCircle2, Circle, Loader2, XCircle, MapPin, Search } from 'lucide-react';
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
|
||||
interface TaskProgressDrawerProps {
|
||||
open: boolean;
|
||||
|
|
@ -19,19 +19,62 @@ interface TaskProgressDrawerProps {
|
|||
taskID: string | null;
|
||||
onCancel: () => void;
|
||||
isCancelling: boolean;
|
||||
wsTasks?: Record<string, TaskState>;
|
||||
selectedTaskId?: string | null;
|
||||
onSelectTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const PHASES: { key: TaskPhase; label: string }[] = [
|
||||
const SCRAPE_PHASES: { key: TaskPhase; label: string }[] = [
|
||||
{ key: 'splitting', label: 'Splitting queries' },
|
||||
{ key: 'fetching', label: 'Fetching & processing' },
|
||||
{ key: 'processing', label: 'Processing remaining' },
|
||||
];
|
||||
|
||||
const POI_PHASES: { key: string; label: string }[] = [
|
||||
{ key: 'starting', label: 'Starting' },
|
||||
{ key: 'computing', label: 'Computing distances' },
|
||||
{ key: 'completed', label: 'Completed' },
|
||||
];
|
||||
|
||||
function inferTaskType(task: TaskState | TaskResult): 'scrape' | 'poi' | 'task' {
|
||||
if ('distances_computed' in task && task.distances_computed !== undefined) return 'poi';
|
||||
if ('subqueries_completed' in task || 'ids_collected' in task || 'pages_fetched' in task) return 'scrape';
|
||||
const phase = task.phase;
|
||||
if (phase === 'starting' || phase === 'computing') return 'poi';
|
||||
if (phase === 'splitting' || phase === 'splitting_complete' || phase === 'fetching' || phase === 'processing') return 'scrape';
|
||||
return 'task';
|
||||
}
|
||||
|
||||
function taskTypeLabel(type: 'scrape' | 'poi' | 'task'): string {
|
||||
switch (type) {
|
||||
case 'scrape': return 'Scrape';
|
||||
case 'poi': return 'POI Distances';
|
||||
default: return 'Task';
|
||||
}
|
||||
}
|
||||
|
||||
function taskTypeIcon(type: 'scrape' | 'poi' | 'task') {
|
||||
switch (type) {
|
||||
case 'scrape': return <Search className="h-3 w-3" />;
|
||||
case 'poi': return <MapPin className="h-3 w-3" />;
|
||||
default: return <Circle className="h-3 w-3" />;
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: string): boolean {
|
||||
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
||||
}
|
||||
|
||||
function getPhaseIndex(phase: TaskPhase | undefined): number {
|
||||
if (!phase) return -1;
|
||||
if (phase === 'splitting_complete') return 1; // splitting done, fetching is next
|
||||
if (phase === 'completed') return PHASES.length;
|
||||
return PHASES.findIndex((p) => p.key === phase);
|
||||
if (phase === 'splitting_complete') return 1;
|
||||
if (phase === 'completed') return SCRAPE_PHASES.length;
|
||||
return SCRAPE_PHASES.findIndex((p) => p.key === phase);
|
||||
}
|
||||
|
||||
function getPoiPhaseIndex(phase: string | undefined): number {
|
||||
if (!phase) return -1;
|
||||
return POI_PHASES.findIndex((p) => p.key === phase);
|
||||
}
|
||||
|
||||
function formatEta(seconds: number | undefined): string {
|
||||
|
|
@ -44,13 +87,10 @@ function formatEta(seconds: number | undefined): string {
|
|||
return `~${secs}s remaining`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: TaskStatus | null }) {
|
||||
function StatusBadge({ status }: { status: TaskStatus | string | null }) {
|
||||
if (!status) return null;
|
||||
|
||||
const isInProgress =
|
||||
status !== TaskStatus.SUCCESS &&
|
||||
status !== TaskStatus.FAILURE &&
|
||||
status !== TaskStatus.REVOKED;
|
||||
const isInProgress = !isTerminalStatus(status);
|
||||
|
||||
if (isInProgress) {
|
||||
return (
|
||||
|
|
@ -60,7 +100,7 @@ function StatusBadge({ status }: { status: TaskStatus | null }) {
|
|||
</span>
|
||||
);
|
||||
}
|
||||
if (status === TaskStatus.SUCCESS) {
|
||||
if (status === 'SUCCESS') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
|
|
@ -68,7 +108,7 @@ function StatusBadge({ status }: { status: TaskStatus | null }) {
|
|||
</span>
|
||||
);
|
||||
}
|
||||
if (status === TaskStatus.REVOKED) {
|
||||
if (status === 'REVOKED') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700">
|
||||
<XCircle className="h-3 w-3" />
|
||||
|
|
@ -87,21 +127,23 @@ function StatusBadge({ status }: { status: TaskStatus | null }) {
|
|||
function PhaseTimeline({
|
||||
currentPhase,
|
||||
taskStatus,
|
||||
taskType,
|
||||
}: {
|
||||
currentPhase: TaskPhase | undefined;
|
||||
taskStatus: TaskStatus | null;
|
||||
currentPhase: TaskPhase | string | undefined;
|
||||
taskStatus: TaskStatus | string | null;
|
||||
taskType: 'scrape' | 'poi' | 'task';
|
||||
}) {
|
||||
const isTerminal =
|
||||
taskStatus === TaskStatus.SUCCESS ||
|
||||
taskStatus === TaskStatus.FAILURE ||
|
||||
taskStatus === TaskStatus.REVOKED;
|
||||
const activeIdx = isTerminal ? PHASES.length : getPhaseIndex(currentPhase);
|
||||
const phases = taskType === 'poi' ? POI_PHASES : SCRAPE_PHASES;
|
||||
const terminal = taskStatus !== null && isTerminalStatus(taskStatus);
|
||||
const activeIdx = taskType === 'poi'
|
||||
? (terminal ? phases.length : getPoiPhaseIndex(currentPhase))
|
||||
: (terminal ? phases.length : getPhaseIndex(currentPhase as TaskPhase | undefined));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{PHASES.map((phase, idx) => {
|
||||
{phases.map((phase, idx) => {
|
||||
const isCompleted = idx < activeIdx;
|
||||
const isActive = idx === activeIdx && !isTerminal;
|
||||
const isActive = idx === activeIdx && !terminal;
|
||||
const isFuture = idx > activeIdx;
|
||||
|
||||
return (
|
||||
|
|
@ -231,6 +273,20 @@ function PhaseDetails({ result }: { result: TaskResult }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function POIPhaseDetails({ task }: { task: TaskState }) {
|
||||
return (
|
||||
<div className="rounded-md border p-3 space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
POI Distances
|
||||
</p>
|
||||
<CounterRow label="Processed" value={task.processed} total={task.total} />
|
||||
{task.distances_computed !== undefined && (
|
||||
<CounterRow label="Distances computed" value={task.distances_computed} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogViewer({ logs }: { logs: string[] }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isAutoScrolling = useRef(true);
|
||||
|
|
@ -267,6 +323,90 @@ function LogViewer({ logs }: { logs: string[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Convert TaskState → TaskResult for existing phase detail components. */
|
||||
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 TaskTabBar({
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
}: {
|
||||
tasks: Record<string, TaskState>;
|
||||
selectedTaskId: string | null;
|
||||
onSelectTask: (taskId: string) => void;
|
||||
}) {
|
||||
// Sort: active first, then completed, then failed
|
||||
const sortedEntries = useMemo(() => {
|
||||
return Object.entries(tasks).sort(([, a], [, b]) => {
|
||||
const aTerminal = isTerminalStatus(a.status);
|
||||
const bTerminal = isTerminalStatus(b.status);
|
||||
if (aTerminal !== bTerminal) return aTerminal ? 1 : -1;
|
||||
if (aTerminal && bTerminal) {
|
||||
if (a.status === 'SUCCESS' && b.status !== 'SUCCESS') return -1;
|
||||
if (b.status === 'SUCCESS' && a.status !== 'SUCCESS') return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [tasks]);
|
||||
|
||||
if (sortedEntries.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 overflow-x-auto px-4 pb-2 scrollbar-thin">
|
||||
{sortedEntries.map(([tid, task]) => {
|
||||
const type = inferTaskType(task);
|
||||
const isSelected = tid === selectedTaskId;
|
||||
const terminal = isTerminalStatus(task.status);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tid}
|
||||
onClick={() => onSelectTask(tid)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium whitespace-nowrap transition-colors shrink-0 ${
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
{!terminal && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{task.status === 'SUCCESS' && <CheckCircle2 className="h-3 w-3 text-green-500" />}
|
||||
{(task.status === 'FAILURE' || task.status === 'REVOKED') && <XCircle className="h-3 w-3 text-red-500" />}
|
||||
{taskTypeIcon(type)}
|
||||
<span>{taskTypeLabel(type)}</span>
|
||||
{!terminal && task.progress !== undefined && (
|
||||
<span className="opacity-70">{Math.round(task.progress * 100)}%</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskProgressDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -275,42 +415,75 @@ export function TaskProgressDrawer({
|
|||
taskID,
|
||||
onCancel,
|
||||
isCancelling,
|
||||
wsTasks,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
}: TaskProgressDrawerProps) {
|
||||
const isInProgress =
|
||||
taskStatus !== null &&
|
||||
taskStatus !== TaskStatus.SUCCESS &&
|
||||
taskStatus !== TaskStatus.FAILURE &&
|
||||
taskStatus !== TaskStatus.REVOKED;
|
||||
// Determine which task's data to show
|
||||
const hasMultipleTasks = wsTasks && Object.keys(wsTasks).length > 1;
|
||||
const effectiveTaskId = selectedTaskId ?? taskID;
|
||||
|
||||
const progressPercent = taskResult
|
||||
? Math.min((taskResult.progress ?? 0) * 100, 100)
|
||||
// Derive the active task data from wsTasks if available, else fall back to props
|
||||
const activeWsTask = effectiveTaskId && wsTasks ? wsTasks[effectiveTaskId] : undefined;
|
||||
const effectiveResult = activeWsTask ? taskStateToResult(activeWsTask) : taskResult;
|
||||
const effectiveStatus = activeWsTask ? (activeWsTask.status as TaskStatus) : taskStatus;
|
||||
const effectiveTaskType = activeWsTask
|
||||
? inferTaskType(activeWsTask)
|
||||
: (taskResult ? inferTaskType(taskResult) : 'scrape');
|
||||
|
||||
const isInProgress =
|
||||
effectiveStatus !== null &&
|
||||
effectiveStatus !== undefined &&
|
||||
!isTerminalStatus(effectiveStatus);
|
||||
|
||||
const progressPercent = effectiveResult
|
||||
? Math.min((effectiveResult.progress ?? 0) * 100, 100)
|
||||
: 0;
|
||||
|
||||
const drawerTitle = hasMultipleTasks
|
||||
? 'Job Progress'
|
||||
: `${taskTypeLabel(effectiveTaskType)} Job Progress`;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex flex-col w-full sm:!max-w-lg">
|
||||
<SheetHeader>
|
||||
<div className="flex items-center justify-between pr-6">
|
||||
<SheetTitle>Crawl Job Progress</SheetTitle>
|
||||
<StatusBadge status={taskStatus} />
|
||||
<SheetTitle>{drawerTitle}</SheetTitle>
|
||||
<StatusBadge status={effectiveStatus} />
|
||||
</div>
|
||||
{taskID && (
|
||||
{effectiveTaskId && (
|
||||
<SheetDescription>
|
||||
Task ID: {taskID.slice(0, 8)}...
|
||||
Task ID: {effectiveTaskId.slice(0, 8)}...
|
||||
</SheetDescription>
|
||||
)}
|
||||
</SheetHeader>
|
||||
|
||||
{/* Multi-job tab bar */}
|
||||
{hasMultipleTasks && onSelectTask && (
|
||||
<TaskTabBar
|
||||
tasks={wsTasks!}
|
||||
selectedTaskId={effectiveTaskId}
|
||||
onSelectTask={onSelectTask}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fixed top section: timeline + counters + progress */}
|
||||
<div className="space-y-3 px-4 shrink-0">
|
||||
<PhaseTimeline
|
||||
currentPhase={taskResult?.phase}
|
||||
taskStatus={taskStatus}
|
||||
currentPhase={effectiveResult?.phase ?? activeWsTask?.phase}
|
||||
taskStatus={effectiveStatus}
|
||||
taskType={effectiveTaskType}
|
||||
/>
|
||||
|
||||
{taskResult && <PhaseDetails result={taskResult} />}
|
||||
{effectiveTaskType === 'poi' && activeWsTask && (
|
||||
<POIPhaseDetails task={activeWsTask} />
|
||||
)}
|
||||
{effectiveTaskType !== 'poi' && effectiveResult && (
|
||||
<PhaseDetails result={effectiveResult} />
|
||||
)}
|
||||
|
||||
{taskResult && (taskResult.phase === 'processing' || taskResult.phase === 'fetching') && (taskResult.total ?? 0) > 0 && (
|
||||
{effectiveResult && (effectiveResult.phase === 'processing' || effectiveResult.phase === 'fetching' || effectiveResult.phase === 'computing') && (effectiveResult.total ?? 0) > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="w-full h-2 bg-primary/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
|
|
@ -320,15 +493,15 @@ export function TaskProgressDrawer({
|
|||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{taskResult.processed ?? 0} / {taskResult.total ?? '?'}
|
||||
{effectiveResult.processed ?? 0} / {effectiveResult.total ?? '?'}
|
||||
</span>
|
||||
<span>{formatEta(taskResult.eta_seconds)}</span>
|
||||
<span>{formatEta(effectiveResult.eta_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskResult?.message && (
|
||||
<p className="text-sm text-muted-foreground">{taskResult.message}</p>
|
||||
{effectiveResult?.message && (
|
||||
<p className="text-sm text-muted-foreground">{effectiveResult.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -338,7 +511,7 @@ export function TaskProgressDrawer({
|
|||
Worker Logs
|
||||
</p>
|
||||
<div className="flex-1 min-h-0">
|
||||
<LogViewer logs={taskResult?.logs ?? []} />
|
||||
<LogViewer logs={effectiveResult?.logs ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -59,5 +59,8 @@ export const DEFAULT_FORM_VALUES = {
|
|||
|
||||
// Polling intervals
|
||||
export const POLLING_INTERVALS = {
|
||||
TASK_STATUS_MS: 5000, // 5 seconds
|
||||
TASK_STATUS_MS: 30000, // 30 seconds (fallback when WebSocket is unavailable)
|
||||
} as const;
|
||||
|
||||
// WebSocket paths
|
||||
export const WS_TASKS_PATH = '/api/ws/tasks';
|
||||
|
|
|
|||
129
frontend/src/hooks/useTaskWebSocket.ts
Normal file
129
frontend/src/hooks/useTaskWebSocket.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { AuthUser } from '@/auth/types';
|
||||
import type { TaskState, WSMessage } from '@/types';
|
||||
import { WS_TASKS_PATH } from '@/constants';
|
||||
|
||||
const KEEPALIVE_MS = 30_000;
|
||||
const MAX_RECONNECT_DELAY_MS = 30_000;
|
||||
|
||||
function wsUrl(token: string): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
return `${proto}://${window.location.host}${WS_TASKS_PATH}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export interface UseTaskWebSocketReturn {
|
||||
tasks: Record<string, TaskState>;
|
||||
isConnected: boolean;
|
||||
subscribe: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function useTaskWebSocket(user: AuthUser | null): UseTaskWebSocketReturn {
|
||||
const [tasks, setTasks] = useState<Record<string, TaskState>>({});
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectAttempt = useRef(0);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const keepaliveTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
reconnectTimer.current = null;
|
||||
}
|
||||
if (keepaliveTimer.current) {
|
||||
clearInterval(keepaliveTimer.current);
|
||||
keepaliveTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!user) return;
|
||||
|
||||
const ws = new WebSocket(wsUrl(user.accessToken));
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setIsConnected(true);
|
||||
reconnectAttempt.current = 0;
|
||||
|
||||
// Start keepalive pings
|
||||
keepaliveTimer.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, KEEPALIVE_MS);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (!mountedRef.current) return;
|
||||
try {
|
||||
const msg: WSMessage = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'init') {
|
||||
const initial: Record<string, TaskState> = {};
|
||||
for (const t of msg.tasks) {
|
||||
initial[t.task_id] = t;
|
||||
}
|
||||
setTasks(initial);
|
||||
} else if (msg.type === 'task_update') {
|
||||
const { type: _, ...taskData } = msg;
|
||||
setTasks((prev) => ({
|
||||
...prev,
|
||||
[msg.task_id]: { ...prev[msg.task_id], ...taskData } as TaskState,
|
||||
}));
|
||||
}
|
||||
// pong messages are ignored
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setIsConnected(false);
|
||||
if (keepaliveTimer.current) {
|
||||
clearInterval(keepaliveTimer.current);
|
||||
keepaliveTimer.current = null;
|
||||
}
|
||||
|
||||
// Exponential backoff reconnect
|
||||
const delay = Math.min(
|
||||
1000 * 2 ** reconnectAttempt.current,
|
||||
MAX_RECONNECT_DELAY_MS,
|
||||
);
|
||||
reconnectAttempt.current += 1;
|
||||
reconnectTimer.current = setTimeout(() => {
|
||||
if (mountedRef.current) connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after this, triggering reconnect
|
||||
};
|
||||
}, [user, clearTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearTimers();
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect, clearTimers]);
|
||||
|
||||
const subscribe = useCallback((taskId: string) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', task_id: taskId }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { tasks, isConnected, subscribe };
|
||||
}
|
||||
|
|
@ -120,3 +120,55 @@ export interface POITravelFilter {
|
|||
travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT';
|
||||
maxMinutes: number | undefined;
|
||||
}
|
||||
|
||||
// WebSocket task state (combines status + result fields)
|
||||
export interface TaskState {
|
||||
task_id: string;
|
||||
status: string;
|
||||
progress?: number;
|
||||
processed?: number;
|
||||
total?: number;
|
||||
phase?: TaskPhase;
|
||||
message?: string;
|
||||
// Splitting phase
|
||||
subqueries_probed?: number;
|
||||
subqueries_initial?: number;
|
||||
estimated_results?: number;
|
||||
subqueries_total?: number;
|
||||
// Fetching phase
|
||||
subqueries_completed?: number;
|
||||
ids_collected?: number;
|
||||
pages_fetched?: number;
|
||||
fetching_done?: boolean;
|
||||
// Processing phase
|
||||
details_fetched?: number;
|
||||
images_downloaded?: number;
|
||||
ocr_completed?: number;
|
||||
failed?: number;
|
||||
elapsed_seconds?: number;
|
||||
rate_per_second?: number;
|
||||
eta_seconds?: number;
|
||||
// POI-specific
|
||||
distances_computed?: number;
|
||||
// Live logs
|
||||
logs?: string[];
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
export interface WSInitMessage {
|
||||
type: 'init';
|
||||
tasks: TaskState[];
|
||||
}
|
||||
|
||||
export interface WSTaskUpdateMessage {
|
||||
type: 'task_update';
|
||||
task_id: string;
|
||||
status: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WSPongMessage {
|
||||
type: 'pong';
|
||||
}
|
||||
|
||||
export type WSMessage = WSInitMessage | WSTaskUpdateMessage | WSPongMessage;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue