rekordbox-viewer/packages/frontend/src/components/BackgroundJobProgress.tsx
2025-09-17 11:30:03 +02:00

315 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
);
};