Refactor task progress to unified useTaskProgress hook
Replace WebSocket-only useTaskWebSocket with useTaskProgress that provides a unified task state interface. TaskIndicator no longer manages its own polling or auth — it receives task state from the parent via props. Rename wsTasks prop to tasks throughout.
This commit is contained in:
parent
3616e678ac
commit
2d86213db5
6 changed files with 130 additions and 363 deletions
|
|
@ -17,13 +17,16 @@ import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet';
|
||||||
import { Button } from './components/ui/button';
|
import { Button } from './components/ui/button';
|
||||||
import { Filter } from 'lucide-react';
|
import { Filter } from 'lucide-react';
|
||||||
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types';
|
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types';
|
||||||
import { refreshListings, fetchTasksForUser, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services';
|
import { refreshListings, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services';
|
||||||
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
|
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
|
||||||
import { useTaskWebSocket } from '@/hooks/useTaskWebSocket';
|
import { useTaskProgress } from '@/hooks/useTaskProgress';
|
||||||
|
|
||||||
|
function isTerminalStatus(status: string): boolean {
|
||||||
|
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
|
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
|
||||||
const [taskID, setTaskID] = useState<string | null>(null);
|
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
|
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
@ -44,8 +47,26 @@ function App() {
|
||||||
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
||||||
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
||||||
|
|
||||||
// WebSocket-based real-time task progress
|
// Explicit task ID set by fetch-data action (to track as "active")
|
||||||
const { tasks: wsTasks, isConnected: wsConnected, subscribe: wsSubscribe } = useTaskWebSocket(user);
|
const [explicitTaskId, setExplicitTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Unified task progress: WS primary, polling fallback
|
||||||
|
const { tasks, isConnected, subscribe, cancelTask, clearAllTasks } = useTaskProgress(user);
|
||||||
|
|
||||||
|
// Derive activeTaskId: explicit ID if set, else most recent non-terminal task
|
||||||
|
const activeTaskId = useMemo(() => {
|
||||||
|
if (explicitTaskId && tasks[explicitTaskId]) return explicitTaskId;
|
||||||
|
// Fall back to any non-terminal task
|
||||||
|
const nonTerminal = Object.entries(tasks).filter(
|
||||||
|
([, t]) => !isTerminalStatus(t.status),
|
||||||
|
);
|
||||||
|
if (nonTerminal.length > 0) return nonTerminal[0][0];
|
||||||
|
// Fall back to explicit even if terminal (to show final status)
|
||||||
|
if (explicitTaskId && tasks[explicitTaskId]) return explicitTaskId;
|
||||||
|
// Show most recent task if any
|
||||||
|
const allIds = Object.keys(tasks);
|
||||||
|
return allIds.length > 0 ? allIds[allIds.length - 1] : null;
|
||||||
|
}, [explicitTaskId, tasks]);
|
||||||
|
|
||||||
// Ref to track accumulated features during streaming
|
// Ref to track accumulated features during streaming
|
||||||
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
|
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
|
||||||
|
|
@ -77,17 +98,6 @@ function App() {
|
||||||
setUser(passkeyUser);
|
setUser(passkeyUser);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetchTasksForUser(user).then((tasks) => {
|
|
||||||
if (tasks && tasks.length > 0) {
|
|
||||||
setTaskID(tasks[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [user, taskID]);
|
|
||||||
|
|
||||||
// Load user's POIs
|
// Load user's POIs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
@ -235,6 +245,10 @@ function App() {
|
||||||
}
|
}
|
||||||
}, [queryParameters, loadListings]);
|
}, [queryParameters, loadListings]);
|
||||||
|
|
||||||
|
const handleTaskCancelled = useCallback(() => {
|
||||||
|
setExplicitTaskId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
|
return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
|
||||||
}
|
}
|
||||||
|
|
@ -248,8 +262,8 @@ function App() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await refreshListings(user!, parameters);
|
const data = await refreshListings(user!, parameters);
|
||||||
setTaskID(data.task_id);
|
setExplicitTaskId(data.task_id);
|
||||||
if (data.task_id) wsSubscribe(data.task_id);
|
if (data.task_id) subscribe(data.task_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
setSubmitError(error.message);
|
setSubmitError(error.message);
|
||||||
|
|
@ -347,13 +361,9 @@ function App() {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTaskCancelled = () => {
|
|
||||||
setTaskID(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePOITaskCreated = (taskId: string) => {
|
const handlePOITaskCreated = (taskId: string) => {
|
||||||
setTaskID(taskId);
|
setExplicitTaskId(taskId);
|
||||||
if (taskId) wsSubscribe(taskId);
|
if (taskId) subscribe(taskId);
|
||||||
// Refresh POI list in case new ones were created
|
// Refresh POI list in case new ones were created
|
||||||
if (user) {
|
if (user) {
|
||||||
fetchUserPOIs(user).then(setUserPOIs).catch(() => {});
|
fetchUserPOIs(user).then(setUserPOIs).catch(() => {});
|
||||||
|
|
@ -379,12 +389,18 @@ function App() {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Header
|
<Header
|
||||||
user={user}
|
user={user}
|
||||||
taskID={taskID}
|
tasks={tasks}
|
||||||
onTaskCancelled={handleTaskCancelled}
|
activeTaskId={activeTaskId}
|
||||||
|
isConnected={isConnected}
|
||||||
|
onCancelTask={cancelTask}
|
||||||
|
onClearAllTasks={async () => {
|
||||||
|
const result = await clearAllTasks();
|
||||||
|
if (result) {
|
||||||
|
handleTaskCancelled();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}}
|
||||||
onTaskCompleted={handleTaskCompleted}
|
onTaskCompleted={handleTaskCompleted}
|
||||||
wsTasks={wsTasks}
|
|
||||||
wsConnected={wsConnected}
|
|
||||||
wsSubscribe={wsSubscribe}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,29 @@ import { TaskIndicator } from './TaskIndicator';
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
activeFilterCount?: number;
|
activeFilterCount?: number;
|
||||||
taskID?: string | null;
|
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onToggleFilters?: () => void;
|
onToggleFilters?: () => void;
|
||||||
showFilterToggle?: boolean;
|
showFilterToggle?: boolean;
|
||||||
onTaskCancelled?: () => void;
|
// Task progress (unified)
|
||||||
|
tasks: Record<string, TaskState>;
|
||||||
|
activeTaskId: string | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
onCancelTask: (taskId: string) => Promise<boolean>;
|
||||||
|
onClearAllTasks: () => Promise<boolean>;
|
||||||
onTaskCompleted?: () => void;
|
onTaskCompleted?: () => void;
|
||||||
wsTasks?: Record<string, TaskState>;
|
|
||||||
wsConnected?: boolean;
|
|
||||||
wsSubscribe?: (taskId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
user,
|
user,
|
||||||
activeFilterCount = 0,
|
activeFilterCount = 0,
|
||||||
taskID,
|
|
||||||
onToggleFilters,
|
onToggleFilters,
|
||||||
showFilterToggle = false,
|
showFilterToggle = false,
|
||||||
onTaskCancelled,
|
tasks,
|
||||||
|
activeTaskId,
|
||||||
|
isConnected,
|
||||||
|
onCancelTask,
|
||||||
|
onClearAllTasks,
|
||||||
onTaskCompleted,
|
onTaskCompleted,
|
||||||
wsTasks,
|
|
||||||
wsConnected,
|
|
||||||
wsSubscribe,
|
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
if (user.provider === 'passkey') {
|
if (user.provider === 'passkey') {
|
||||||
|
|
@ -58,11 +59,12 @@ export function Header({
|
||||||
|
|
||||||
{/* Task Indicator */}
|
{/* Task Indicator */}
|
||||||
<TaskIndicator
|
<TaskIndicator
|
||||||
taskID={taskID ?? null}
|
tasks={tasks}
|
||||||
onTaskCancelled={onTaskCancelled}
|
activeTaskId={activeTaskId}
|
||||||
|
isConnected={isConnected}
|
||||||
|
onCancelTask={onCancelTask}
|
||||||
|
onClearAllTasks={onClearAllTasks}
|
||||||
onTaskCompleted={onTaskCompleted}
|
onTaskCompleted={onTaskCompleted}
|
||||||
wsTasks={wsTasks}
|
|
||||||
wsConnected={wsConnected}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Filter Toggle (mobile) */}
|
{/* Filter Toggle (mobile) */}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,3 @@
|
||||||
import { getUser } from '@/auth/authService';
|
|
||||||
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, type TaskState } from '@/types';
|
import { TaskStatus, type TaskResult, type TaskState } from '@/types';
|
||||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||||
|
|
@ -11,14 +6,15 @@ import { Loader2, CheckCircle2, XCircle, X, Trash2 } from 'lucide-react';
|
||||||
import { TaskProgressDrawer } from './TaskProgressDrawer';
|
import { TaskProgressDrawer } from './TaskProgressDrawer';
|
||||||
|
|
||||||
interface TaskIndicatorProps {
|
interface TaskIndicatorProps {
|
||||||
taskID: string | null;
|
tasks: Record<string, TaskState>;
|
||||||
onTaskCancelled?: () => void;
|
activeTaskId: string | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
onCancelTask: (taskId: string) => Promise<boolean>;
|
||||||
|
onClearAllTasks: () => Promise<boolean>;
|
||||||
onTaskCompleted?: () => void;
|
onTaskCompleted?: () => void;
|
||||||
wsTasks?: Record<string, TaskState>;
|
|
||||||
wsConnected?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert a TaskState (from WS) into a TaskResult (for the drawer). */
|
/** Convert a TaskState into a TaskResult (for the drawer). */
|
||||||
function taskStateToResult(ts: TaskState): TaskResult {
|
function taskStateToResult(ts: TaskState): TaskResult {
|
||||||
return {
|
return {
|
||||||
progress: ts.progress ?? 0,
|
progress: ts.progress ?? 0,
|
||||||
|
|
@ -50,215 +46,91 @@ function isTerminalStatus(status: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskIndicator({
|
export function TaskIndicator({
|
||||||
taskID,
|
tasks,
|
||||||
onTaskCancelled,
|
activeTaskId,
|
||||||
|
isConnected: _isConnected,
|
||||||
|
onCancelTask,
|
||||||
|
onClearAllTasks,
|
||||||
onTaskCompleted,
|
onTaskCompleted,
|
||||||
wsTasks,
|
|
||||||
wsConnected,
|
|
||||||
}: TaskIndicatorProps) {
|
}: TaskIndicatorProps) {
|
||||||
const [user, setUser] = useState<AuthUser | 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 [taskResult, setTaskResult] = useState<TaskResult | null>(null);
|
|
||||||
const [isCancelling, setIsCancelling] = useState(false);
|
const [isCancelling, setIsCancelling] = useState(false);
|
||||||
const [isClearing, setIsClearing] = useState(false);
|
const [isClearing, setIsClearing] = useState(false);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
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);
|
const onTaskCompletedRef = useRef(onTaskCompleted);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onTaskCompletedRef.current = onTaskCompleted;
|
onTaskCompletedRef.current = onTaskCompleted;
|
||||||
}, [onTaskCompleted]);
|
}, [onTaskCompleted]);
|
||||||
|
|
||||||
|
// Track the currently-viewed task in the drawer; default to the externally-provided activeTaskId
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const passkeyUser = getStoredPasskeyUser();
|
if (activeTaskId) {
|
||||||
if (passkeyUser) {
|
setSelectedTaskId(activeTaskId);
|
||||||
setUser(passkeyUser);
|
|
||||||
} else {
|
|
||||||
getUser().then((oidcUser) => {
|
|
||||||
if (oidcUser) setUser(fromOidcUser(oidcUser));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, [activeTaskId]);
|
||||||
|
|
||||||
// Track the currently-viewed task in the drawer; default to the externally-provided taskID
|
// Fire onTaskCompleted when the active task transitions to SUCCESS
|
||||||
|
const prevStatusRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (taskID) {
|
if (!activeTaskId) {
|
||||||
setSelectedTaskId(taskID);
|
prevStatusRef.current = null;
|
||||||
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 (always active as baseline; WS provides faster updates on top) -----
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user || !taskID) {
|
|
||||||
setTaskStatus(null);
|
|
||||||
setTaskResult(null);
|
|
||||||
return;
|
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]);
|
||||||
|
|
||||||
// Reset state for new task
|
// Derive display data from the active task
|
||||||
setTaskStatus(TaskStatus.PENDING);
|
const activeTask = activeTaskId ? tasks[activeTaskId] : undefined;
|
||||||
setProgressPercentage(0);
|
const taskStatus = activeTask ? (activeTask.status as TaskStatus) : null;
|
||||||
setProcessed(null);
|
const taskResult = activeTask?.phase ? taskStateToResult(activeTask) : null;
|
||||||
setTotal(null);
|
const progressPercentage = (activeTask?.progress ?? 0) * 100;
|
||||||
setTaskResult(null);
|
const processed = activeTask?.processed ?? null;
|
||||||
|
const total = activeTask?.total ?? null;
|
||||||
|
|
||||||
const pollTaskStatus = async () => {
|
// Count active (non-terminal) tasks
|
||||||
// Skip this poll cycle if cancelled locally
|
const activeTaskCount = useMemo(() => {
|
||||||
if (cancelledRef.current) return true;
|
return Object.values(tasks).filter(
|
||||||
try {
|
(t) => !isTerminalStatus(t.status),
|
||||||
const data = await fetchTaskStatus(user, taskID);
|
).length;
|
||||||
const status = data.status as TaskStatus;
|
}, [tasks]);
|
||||||
setTaskStatus(status);
|
|
||||||
|
|
||||||
if (status === TaskStatus.SUCCESS) {
|
|
||||||
setProgressPercentage(100);
|
|
||||||
if (data.result) {
|
|
||||||
try {
|
|
||||||
const parsedResult: TaskResult = JSON.parse(data.result);
|
|
||||||
if (parsedResult.phase) {
|
|
||||||
setTaskResult(parsedResult);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parsing errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onTaskCompletedRef.current?.();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.result) {
|
|
||||||
try {
|
|
||||||
const parsedResult: TaskResult = JSON.parse(data.result);
|
|
||||||
if (parsedResult.phase) {
|
|
||||||
setTaskResult(parsedResult);
|
|
||||||
}
|
|
||||||
if (parsedResult.progress !== undefined) {
|
|
||||||
setProgressPercentage(parsedResult.progress * 100);
|
|
||||||
}
|
|
||||||
if (parsedResult.processed !== undefined) {
|
|
||||||
setProcessed(parsedResult.processed);
|
|
||||||
}
|
|
||||||
if (parsedResult.total !== undefined) {
|
|
||||||
setTotal(parsedResult.total);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parsing errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch {
|
|
||||||
setTaskStatus(TaskStatus.FAILURE);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 () => {
|
const handleCancel = async () => {
|
||||||
if (!user || !taskID || isCancelling) return;
|
if (!activeTaskId || isCancelling) return;
|
||||||
|
|
||||||
setIsCancelling(true);
|
setIsCancelling(true);
|
||||||
try {
|
try {
|
||||||
const result = await cancelTask(user, taskID);
|
await onCancelTask(activeTaskId);
|
||||||
if (result.success) {
|
|
||||||
cancelledRef.current = true;
|
|
||||||
setTaskStatus(TaskStatus.REVOKED);
|
|
||||||
onTaskCancelled?.();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore cancel errors
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsCancelling(false);
|
setIsCancelling(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
const handleClearAll = async () => {
|
||||||
if (!user || isClearing) return;
|
if (isClearing) return;
|
||||||
|
|
||||||
setIsClearing(true);
|
setIsClearing(true);
|
||||||
try {
|
try {
|
||||||
const result = await clearAllTasks(user);
|
await onClearAllTasks();
|
||||||
if (result.success) {
|
|
||||||
cancelledRef.current = true;
|
|
||||||
setTaskStatus(null);
|
|
||||||
setTaskResult(null);
|
|
||||||
onTaskCancelled?.();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore clear errors
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsClearing(false);
|
setIsClearing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!taskID || !taskStatus) {
|
if (!activeTaskId || !taskStatus) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInProgress = taskStatus !== TaskStatus.SUCCESS &&
|
const isInProgress = !isTerminalStatus(taskStatus);
|
||||||
taskStatus !== TaskStatus.FAILURE &&
|
|
||||||
taskStatus !== TaskStatus.REVOKED;
|
|
||||||
|
|
||||||
const getStatusIcon = () => {
|
const getStatusIcon = () => {
|
||||||
if (isInProgress) {
|
if (isInProgress) {
|
||||||
|
|
@ -329,16 +201,16 @@ export function TaskIndicator({
|
||||||
{taskStatus}
|
{taskStatus}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{activeWsTaskCount > 1 && (
|
{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">
|
<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}
|
{activeTaskCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<p>{getTooltipContent()}</p>
|
<p>{getTooltipContent()}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">ID: {taskID.slice(0, 8)}...</p>
|
<p className="text-xs text-muted-foreground mt-1">ID: {activeTaskId.slice(0, 8)}...</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{isInProgress && (
|
{isInProgress && (
|
||||||
|
|
@ -381,10 +253,10 @@ export function TaskIndicator({
|
||||||
onOpenChange={setDrawerOpen}
|
onOpenChange={setDrawerOpen}
|
||||||
taskResult={taskResult}
|
taskResult={taskResult}
|
||||||
taskStatus={taskStatus}
|
taskStatus={taskStatus}
|
||||||
taskID={selectedTaskId ?? taskID}
|
taskID={selectedTaskId ?? activeTaskId}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
isCancelling={isCancelling}
|
isCancelling={isCancelling}
|
||||||
wsTasks={wsTasks}
|
tasks={tasks}
|
||||||
selectedTaskId={selectedTaskId}
|
selectedTaskId={selectedTaskId}
|
||||||
onSelectTask={setSelectedTaskId}
|
onSelectTask={setSelectedTaskId}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ interface TaskProgressDrawerProps {
|
||||||
taskID: string | null;
|
taskID: string | null;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isCancelling: boolean;
|
isCancelling: boolean;
|
||||||
wsTasks?: Record<string, TaskState>;
|
tasks?: Record<string, TaskState>;
|
||||||
selectedTaskId?: string | null;
|
selectedTaskId?: string | null;
|
||||||
onSelectTask?: (taskId: string) => void;
|
onSelectTask?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -415,16 +415,16 @@ export function TaskProgressDrawer({
|
||||||
taskID,
|
taskID,
|
||||||
onCancel,
|
onCancel,
|
||||||
isCancelling,
|
isCancelling,
|
||||||
wsTasks,
|
tasks,
|
||||||
selectedTaskId,
|
selectedTaskId,
|
||||||
onSelectTask,
|
onSelectTask,
|
||||||
}: TaskProgressDrawerProps) {
|
}: TaskProgressDrawerProps) {
|
||||||
// Determine which task's data to show
|
// Determine which task's data to show
|
||||||
const hasMultipleTasks = wsTasks && Object.keys(wsTasks).length > 1;
|
const hasMultipleTasks = tasks && Object.keys(tasks).length > 1;
|
||||||
const effectiveTaskId = selectedTaskId ?? taskID;
|
const effectiveTaskId = selectedTaskId ?? taskID;
|
||||||
|
|
||||||
// Derive the active task data from wsTasks if available, else fall back to props
|
// Derive the active task data from tasks if available, else fall back to props
|
||||||
const activeWsTask = effectiveTaskId && wsTasks ? wsTasks[effectiveTaskId] : undefined;
|
const activeWsTask = effectiveTaskId && tasks ? tasks[effectiveTaskId] : undefined;
|
||||||
const effectiveResult = activeWsTask ? taskStateToResult(activeWsTask) : taskResult;
|
const effectiveResult = activeWsTask ? taskStateToResult(activeWsTask) : taskResult;
|
||||||
const effectiveStatus = activeWsTask ? (activeWsTask.status as TaskStatus) : taskStatus;
|
const effectiveStatus = activeWsTask ? (activeWsTask.status as TaskStatus) : taskStatus;
|
||||||
const effectiveTaskType = activeWsTask
|
const effectiveTaskType = activeWsTask
|
||||||
|
|
@ -462,7 +462,7 @@ export function TaskProgressDrawer({
|
||||||
{/* Multi-job tab bar */}
|
{/* Multi-job tab bar */}
|
||||||
{hasMultipleTasks && onSelectTask && (
|
{hasMultipleTasks && onSelectTask && (
|
||||||
<TaskTabBar
|
<TaskTabBar
|
||||||
tasks={wsTasks!}
|
tasks={tasks!}
|
||||||
selectedTaskId={effectiveTaskId}
|
selectedTaskId={effectiveTaskId}
|
||||||
onSelectTask={onSelectTask}
|
onSelectTask={onSelectTask}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -48,9 +48,15 @@ export enum TaskStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskStatusResponse {
|
export interface TaskStatusResponse {
|
||||||
status: TaskStatus;
|
task_id: string;
|
||||||
result: string; // JSON string containing TaskResult
|
status: string;
|
||||||
message?: string;
|
result: string | null; // JSON string containing TaskResult, or null
|
||||||
|
progress: number | null;
|
||||||
|
processed: number | null;
|
||||||
|
total: number | null;
|
||||||
|
message: string | null;
|
||||||
|
error: string | null;
|
||||||
|
traceback: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskPhase = 'splitting' | 'splitting_complete' | 'fetching' | 'processing' | 'completed';
|
export type TaskPhase = 'splitting' | 'splitting_complete' | 'fetching' | 'processing' | 'completed';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue