315 lines
9.2 KiB
TypeScript
315 lines
9.2 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import {
|
||
Box,
|
||
Flex,
|
||
Text,
|
||
Progress,
|
||
Button,
|
||
VStack,
|
||
HStack,
|
||
Badge,
|
||
useDisclosure,
|
||
Modal,
|
||
ModalOverlay,
|
||
ModalContent,
|
||
ModalHeader,
|
||
ModalBody,
|
||
ModalCloseButton,
|
||
Table,
|
||
Thead,
|
||
Tbody,
|
||
Tr,
|
||
Th,
|
||
Td,
|
||
Spinner,
|
||
} from '@chakra-ui/react';
|
||
import { api } from '../services/api';
|
||
|
||
interface JobProgress {
|
||
jobId: string;
|
||
type: 'storage-sync' | 'song-matching';
|
||
status: 'running' | 'completed' | 'failed';
|
||
progress: number;
|
||
current: number;
|
||
total: number;
|
||
message: string;
|
||
startTime: Date;
|
||
endTime?: Date;
|
||
result?: any;
|
||
error?: string;
|
||
}
|
||
|
||
interface BackgroundJobProgressProps {
|
||
jobId?: string;
|
||
onJobComplete?: (result: any) => void;
|
||
onJobError?: (error: string) => void;
|
||
}
|
||
|
||
export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
|
||
jobId,
|
||
onJobComplete,
|
||
onJobError,
|
||
}) => {
|
||
const [jobs, setJobs] = useState<JobProgress[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const { isOpen, onClose } = useDisclosure();
|
||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
// Load all jobs
|
||
const loadJobs = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const jobsData = await api.getAllJobs();
|
||
setJobs(jobsData);
|
||
setError(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to load jobs');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// Update specific job progress
|
||
const updateJobProgress = async (jobId: string) => {
|
||
try {
|
||
const progress = await api.getJobProgress(jobId);
|
||
|
||
setJobs(prev => prev.map(job =>
|
||
job.jobId === jobId ? progress : job
|
||
));
|
||
|
||
// Handle job completion
|
||
if (progress.status === 'completed' && onJobComplete) {
|
||
onJobComplete(progress.result);
|
||
} else if (progress.status === 'failed' && onJobError) {
|
||
onJobError(progress.error || 'Job failed');
|
||
}
|
||
} catch (err) {
|
||
console.error('Error updating job progress:', err);
|
||
}
|
||
};
|
||
|
||
// Start polling for jobs and update progress
|
||
const startPolling = () => {
|
||
if (intervalRef.current) {
|
||
clearInterval(intervalRef.current);
|
||
}
|
||
|
||
const tick = async () => {
|
||
try {
|
||
// Always reload job list to detect newly started jobs
|
||
const jobsData = await api.getAllJobs();
|
||
setJobs(jobsData);
|
||
|
||
// Update progress for active jobs
|
||
const activeJobIds = jobsData.filter((j: JobProgress) => j.status === 'running').map((j: JobProgress) => j.jobId);
|
||
for (const id of activeJobIds) {
|
||
await updateJobProgress(id);
|
||
}
|
||
|
||
if (jobId) {
|
||
await updateJobProgress(jobId);
|
||
}
|
||
} catch (err) {
|
||
// ignore transient polling errors
|
||
}
|
||
};
|
||
|
||
// Adaptive interval: 2s if active jobs, else 10s
|
||
const schedule = async () => {
|
||
await tick();
|
||
const hasActive = (jobs || []).some(j => j.status === 'running');
|
||
const delay = hasActive ? 2000 : 10000;
|
||
intervalRef.current = setTimeout(schedule, delay) as any;
|
||
};
|
||
|
||
schedule();
|
||
};
|
||
|
||
// Stop polling
|
||
const stopPolling = () => {
|
||
if (intervalRef.current) {
|
||
clearTimeout(intervalRef.current as any);
|
||
intervalRef.current = null;
|
||
}
|
||
};
|
||
|
||
// Start polling on mount and stop on unmount
|
||
useEffect(() => {
|
||
loadJobs();
|
||
startPolling();
|
||
return () => stopPolling();
|
||
}, []);
|
||
|
||
// Cleanup on unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
stopPolling();
|
||
};
|
||
}, []);
|
||
|
||
const getStatusColor = (status: string) => {
|
||
switch (status) {
|
||
case 'running': return 'blue';
|
||
case 'completed': return 'green';
|
||
case 'failed': return 'red';
|
||
default: return 'gray';
|
||
}
|
||
};
|
||
|
||
const getStatusIcon = (status: string) => {
|
||
switch (status) {
|
||
case 'running': return <Spinner size="sm" />;
|
||
case 'completed': return <Text fontSize="sm">✅</Text>;
|
||
case 'failed': return <Text fontSize="sm">❌</Text>;
|
||
default: return <Text fontSize="sm">⏸️</Text>;
|
||
}
|
||
};
|
||
|
||
const formatDuration = (startTime: Date, endTime?: Date) => {
|
||
const start = new Date(startTime).getTime();
|
||
const end = endTime ? new Date(endTime).getTime() : Date.now();
|
||
const duration = Math.floor((end - start) / 1000);
|
||
|
||
if (duration < 60) return `${duration}s`;
|
||
if (duration < 3600) return `${Math.floor(duration / 60)}m ${duration % 60}s`;
|
||
return `${Math.floor(duration / 3600)}h ${Math.floor((duration % 3600) / 60)}m`;
|
||
};
|
||
|
||
const activeJobs = jobs.filter(job => job.status === 'running');
|
||
// const completedJobs = jobs.filter(job => job.status === 'completed' || job.status === 'failed');
|
||
|
||
return (
|
||
<>
|
||
{/* Active Jobs Summary */}
|
||
{activeJobs.length > 0 && (
|
||
<Box
|
||
position="fixed"
|
||
bottom={4}
|
||
right={4}
|
||
bg="gray.800"
|
||
border="1px solid"
|
||
borderColor="gray.600"
|
||
borderRadius="lg"
|
||
p={4}
|
||
boxShadow="xl"
|
||
zIndex={1000}
|
||
maxW="400px"
|
||
>
|
||
<Flex justify="space-between" align="center" mb={3}>
|
||
<Text fontWeight="bold" fontSize="sm" color="gray.100">
|
||
Background Jobs ({activeJobs.length})
|
||
</Text>
|
||
</Flex>
|
||
|
||
<VStack spacing={3} align="stretch">
|
||
{activeJobs.map(job => (
|
||
<Box key={job.jobId} p={3} bg="gray.700" borderRadius="md">
|
||
<Flex justify="space-between" align="center" mb={2}>
|
||
<Text fontSize="sm" fontWeight="medium" color="gray.100">
|
||
{job.type === 'storage-sync' ? 'Storage Sync' : 'Song Matching'}
|
||
</Text>
|
||
<Badge colorScheme={getStatusColor(job.status)} size="sm">
|
||
{job.status}
|
||
</Badge>
|
||
</Flex>
|
||
|
||
<Text fontSize="xs" color="gray.400" mb={2}>
|
||
{job.message}
|
||
</Text>
|
||
|
||
<Progress
|
||
value={job.progress}
|
||
size="sm"
|
||
colorScheme={getStatusColor(job.status)}
|
||
mb={2}
|
||
/>
|
||
|
||
<Flex justify="space-between" fontSize="xs" color="gray.400">
|
||
<Text>{job.progress}%</Text>
|
||
<Text>{formatDuration(job.startTime)}</Text>
|
||
</Flex>
|
||
</Box>
|
||
))}
|
||
</VStack>
|
||
</Box>
|
||
)}
|
||
|
||
{/* All Jobs Modal */}
|
||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||
<ModalOverlay />
|
||
<ModalContent bg="gray.800" color="gray.100">
|
||
<ModalHeader color="gray.100">Background Jobs</ModalHeader>
|
||
<ModalCloseButton color="gray.300" />
|
||
<ModalBody pb={6}>
|
||
<VStack spacing={4} align="stretch">
|
||
<HStack justify="space-between">
|
||
<Text fontWeight="bold" color="gray.100">All Jobs</Text>
|
||
<Button size="sm" onClick={loadJobs} isLoading={loading}>
|
||
Refresh
|
||
</Button>
|
||
</HStack>
|
||
|
||
{error && (
|
||
<Text color="red.500" fontSize="sm">
|
||
{error}
|
||
</Text>
|
||
)}
|
||
|
||
<Table variant="simple" size="sm">
|
||
<Thead>
|
||
<Tr>
|
||
<Th color="gray.300">Type</Th>
|
||
<Th color="gray.300">Status</Th>
|
||
<Th color="gray.300">Progress</Th>
|
||
<Th color="gray.300">Duration</Th>
|
||
<Th color="gray.300">Message</Th>
|
||
</Tr>
|
||
</Thead>
|
||
<Tbody>
|
||
{jobs.map(job => (
|
||
<Tr key={job.jobId}>
|
||
<Td>
|
||
<Text fontSize="sm" color="gray.100">
|
||
{job.type === 'storage-sync' ? 'Storage Sync' : 'Song Matching'}
|
||
</Text>
|
||
</Td>
|
||
<Td>
|
||
<HStack spacing={2}>
|
||
{getStatusIcon(job.status)}
|
||
<Badge colorScheme={getStatusColor(job.status)} size="sm">
|
||
{job.status}
|
||
</Badge>
|
||
</HStack>
|
||
</Td>
|
||
<Td>
|
||
<Text fontSize="sm" color="gray.100">{job.progress}%</Text>
|
||
</Td>
|
||
<Td>
|
||
<Text fontSize="sm" color="gray.100">
|
||
{formatDuration(job.startTime, job.endTime)}
|
||
</Text>
|
||
</Td>
|
||
<Td>
|
||
<Text fontSize="sm" noOfLines={2} color="gray.100">
|
||
{job.message}
|
||
</Text>
|
||
</Td>
|
||
</Tr>
|
||
))}
|
||
</Tbody>
|
||
</Table>
|
||
|
||
{jobs.length === 0 && !loading && (
|
||
<Text textAlign="center" color="gray.500" py={4}>
|
||
No background jobs found
|
||
</Text>
|
||
)}
|
||
</VStack>
|
||
</ModalBody>
|
||
</ModalContent>
|
||
</Modal>
|
||
</>
|
||
);
|
||
};
|