wulf-pulse/components/admin/EntitySyncProgress.tsx

247 lines
7.4 KiB
TypeScript
Raw Normal View History

'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Loader2, CheckCircle2, XCircle, Database, ArrowDownToLine, FileEdit, Trash2 } from 'lucide-react';
interface SyncProgressState {
syncId: string;
entityType: string;
status: 'idle' | 'running' | 'completed' | 'failed';
currentPage: number;
totalRecords: number;
estimatedTotal?: number;
startTime: number;
endTime?: number;
error?: string;
phase: 'fetching' | 'mapping' | 'upserting' | 'deleting' | 'completed';
}
interface EntitySyncProgressProps {
entityType: string;
syncId?: string;
onComplete?: () => void;
onError?: (error: string) => void;
}
const PHASE_LABELS = {
fetching: 'Fetching from Autotask',
mapping: 'Mapping records',
upserting: 'Saving to database',
deleting: 'Cleaning up',
completed: 'Completed',
};
const PHASE_ICONS = {
fetching: ArrowDownToLine,
mapping: FileEdit,
upserting: Database,
deleting: Trash2,
completed: CheckCircle2,
};
export default function EntitySyncProgress({
entityType,
syncId,
onComplete,
onError,
}: EntitySyncProgressProps) {
const [progress, setProgress] = useState<SyncProgressState | null>(null);
const [animatedProgress, setAnimatedProgress] = useState(0);
// Poll for progress updates
useEffect(() => {
let pollInterval: NodeJS.Timeout;
let mounted = true;
const fetchProgress = async () => {
try {
const params = new URLSearchParams();
if (syncId) {
params.append('syncId', syncId);
} else {
params.append('entityType', entityType);
}
const response = await fetch(`/api/sync/progress?${params}`);
if (!response.ok) {
// No progress found yet
return;
}
const data = await response.json();
const progressData = data.progress;
if (mounted && progressData) {
setProgress(progressData);
// Handle completion
if (progressData.status === 'completed' && onComplete) {
onComplete();
}
// Handle errors
if (progressData.status === 'failed' && onError) {
onError(progressData.error || 'Sync failed');
}
}
} catch (error) {
console.error('Error fetching sync progress:', error);
}
};
// Initial fetch
fetchProgress();
// Poll every 2 seconds while sync is running
pollInterval = setInterval(() => {
if (progress?.status === 'running') {
fetchProgress();
} else if (progress?.status === 'completed' || progress?.status === 'failed') {
clearInterval(pollInterval);
}
}, 2000);
return () => {
mounted = false;
clearInterval(pollInterval);
};
}, [entityType, syncId, progress?.status, onComplete, onError]);
// Animate progress bar
useEffect(() => {
if (!progress) return;
let targetProgress = 0;
// Calculate progress based on phase
switch (progress.phase) {
case 'fetching':
targetProgress = 25;
break;
case 'mapping':
targetProgress = 50;
break;
case 'upserting':
targetProgress = 75;
break;
case 'deleting':
targetProgress = 90;
break;
case 'completed':
targetProgress = 100;
break;
}
// Smooth animation
const step = (targetProgress - animatedProgress) / 10;
const interval = setInterval(() => {
setAnimatedProgress((prev) => {
const next = prev + step;
if (Math.abs(next - targetProgress) < 1) {
clearInterval(interval);
return targetProgress;
}
return next;
});
}, 50);
return () => clearInterval(interval);
}, [progress?.phase]);
if (!progress || progress.status === 'idle') {
return null;
}
const PhaseIcon = PHASE_ICONS[progress.phase];
const duration = progress.endTime
? Math.round((progress.endTime - progress.startTime) / 1000)
: Math.round((Date.now() - progress.startTime) / 1000);
return (
<Card className="border-2 dark:border-gray-700">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{progress.status === 'running' && (
<Loader2 className="h-5 w-5 animate-spin text-blue-500" />
)}
{progress.status === 'completed' && (
<CheckCircle2 className="h-5 w-5 text-green-500" />
)}
{progress.status === 'failed' && (
<XCircle className="h-5 w-5 text-red-500" />
)}
<CardTitle className="text-lg">
{entityType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} Sync
</CardTitle>
</div>
<Badge
variant={
progress.status === 'running' ? 'default' :
progress.status === 'completed' ? 'secondary' :
'destructive'
}
className={
progress.status === 'running' ? 'bg-blue-500 hover:bg-blue-600' : ''
}
>
{progress.status}
</Badge>
</div>
<CardDescription className="flex items-center gap-2 mt-2">
<PhaseIcon className="h-4 w-4" />
<span>{PHASE_LABELS[progress.phase]}</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Progress Bar */}
<div className="space-y-2">
<Progress
value={animatedProgress}
className="h-2"
aria-label={`Sync progress: ${Math.round(animatedProgress)}%`}
/>
<div className="flex justify-between text-sm text-muted-foreground">
<span>{Math.round(animatedProgress)}% complete</span>
<span>{duration}s elapsed</span>
</div>
</div>
{/* Stats */}
{progress.totalRecords > 0 && (
<div className="grid grid-cols-2 gap-4 pt-2 border-t dark:border-gray-700">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Records Processed</p>
<p className="text-2xl font-bold">{progress.totalRecords.toLocaleString()}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Current Phase</p>
<p className="text-lg font-semibold capitalize">{progress.phase}</p>
</div>
</div>
)}
{/* Error Message */}
{progress.status === 'failed' && progress.error && (
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-md p-3">
<p className="text-sm text-red-800 dark:text-red-200">{progress.error}</p>
</div>
)}
{/* Completion Message */}
{progress.status === 'completed' && (
<div className="bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md p-3">
<p className="text-sm text-green-800 dark:text-green-200">
Successfully synced {progress.totalRecords.toLocaleString()} records in {duration}s
</p>
</div>
)}
</CardContent>
</Card>
);
}