167 lines
5.8 KiB
TypeScript
167 lines
5.8 KiB
TypeScript
|
|
import { getUser } from '@/auth/authService';
|
||
|
|
import { POLLING_INTERVALS } from '@/constants';
|
||
|
|
import { fetchTaskStatus, cancelTask } 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 } 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 [taskStatus, setTaskStatus] = useState<TaskStatus | null>(null);
|
||
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
getUser().then(setUser);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!user || !taskID) {
|
||
|
|
setTaskStatus(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reset state for new task
|
||
|
|
setTaskStatus(TaskStatus.PENDING);
|
||
|
|
setProgressPercentage(0);
|
||
|
|
|
||
|
|
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);
|
||
|
|
} 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);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
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 getTooltipContent = () => {
|
||
|
|
if (isInProgress) {
|
||
|
|
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-1">
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<div className="flex items-center gap-1.5 cursor-default">
|
||
|
|
{getStatusIcon()}
|
||
|
|
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||
|
|
{isInProgress ? `${Math.round(progressPercentage)}%` : 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>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</TooltipProvider>
|
||
|
|
);
|
||
|
|
}
|