massive changes and it works
This commit is contained in:
@@ -8,11 +8,16 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState(null);
|
||||
const [taskLogs, setTaskLogs] = useState([]);
|
||||
const [taskSteps, setTaskSteps] = useState([]);
|
||||
// Store steps and logs per task: { taskId: { steps: [], logs: [] } }
|
||||
const [taskData, setTaskData] = useState({});
|
||||
// Track which tasks and steps are expanded
|
||||
const [expandedTasks, setExpandedTasks] = useState(new Set());
|
||||
const [expandedSteps, setExpandedSteps] = useState(new Set());
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState(null); // { url, fileName } or null
|
||||
const wsRef = useRef(null);
|
||||
const logContainerRefs = useRef({}); // Refs for each step's log container
|
||||
const shouldAutoScrollRefs = useRef({}); // Auto-scroll state per step
|
||||
|
||||
useEffect(() => {
|
||||
loadDetails();
|
||||
@@ -26,20 +31,53 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
}, [job.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTaskId && jobDetails.status === 'running') {
|
||||
startLogStream();
|
||||
// Load logs and steps for all running tasks
|
||||
if (jobDetails.status === 'running' && tasks.length > 0) {
|
||||
const runningTasks = tasks.filter(t => t.status === 'running' || t.status === 'pending');
|
||||
runningTasks.forEach(task => {
|
||||
if (!taskData[task.id]) {
|
||||
loadTaskData(task.id);
|
||||
}
|
||||
});
|
||||
// Start streaming for the first running task (WebSocket supports one at a time)
|
||||
if (runningTasks.length > 0 && !streaming) {
|
||||
startLogStream(runningTasks.map(t => t.id));
|
||||
}
|
||||
} else if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
setStreaming(false);
|
||||
}
|
||||
return () => {
|
||||
if (wsRef.current) {
|
||||
if (wsRef.current && jobDetails.status !== 'running') {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [selectedTaskId, jobDetails.status]);
|
||||
}, [tasks, jobDetails.status]);
|
||||
|
||||
// Auto-scroll logs to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
// Use requestAnimationFrame to ensure DOM has updated
|
||||
requestAnimationFrame(() => {
|
||||
Object.keys(logContainerRefs.current).forEach(key => {
|
||||
const ref = logContainerRefs.current[key];
|
||||
if (!ref) return;
|
||||
|
||||
// Initialize auto-scroll to true if not set
|
||||
if (shouldAutoScrollRefs.current[key] === undefined) {
|
||||
shouldAutoScrollRefs.current[key] = true;
|
||||
}
|
||||
|
||||
// Always auto-scroll unless user has manually scrolled up
|
||||
// shouldAutoScrollRefs.current[key] is false only if user scrolled up manually
|
||||
if (shouldAutoScrollRefs.current[key] !== false) {
|
||||
// Scroll to bottom
|
||||
ref.scrollTop = ref.scrollHeight;
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [taskData]);
|
||||
|
||||
const loadDetails = async () => {
|
||||
try {
|
||||
@@ -52,6 +90,23 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
setFiles(fileList);
|
||||
setTasks(taskList);
|
||||
|
||||
// Only load task data (logs/steps) for tasks that don't have data yet
|
||||
// This prevents overwriting logs that are being streamed via WebSocket
|
||||
// Once we have logs for a task, we rely on WebSocket for new logs
|
||||
if (details.status === 'running') {
|
||||
taskList.forEach(task => {
|
||||
const existingData = taskData[task.id];
|
||||
// Only fetch logs via HTTP if we don't have any logs yet
|
||||
// Once we have logs, WebSocket will handle new ones
|
||||
if (!existingData || !existingData.logs || existingData.logs.length === 0) {
|
||||
loadTaskData(task.id);
|
||||
} else if (!existingData.steps || existingData.steps.length === 0) {
|
||||
// If we have logs but no steps, fetch steps only
|
||||
loadTaskStepsOnly(task.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there's an MP4 output file
|
||||
const mp4File = fileList.find(
|
||||
(f) => f.file_type === 'output' && f.file_name.endsWith('.mp4')
|
||||
@@ -70,31 +125,81 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
window.open(jobs.downloadFile(job.id, fileId), '_blank');
|
||||
};
|
||||
|
||||
const loadTaskLogs = async (taskId) => {
|
||||
const loadTaskData = async (taskId) => {
|
||||
try {
|
||||
const [logs, steps] = await Promise.all([
|
||||
jobs.getTaskLogs(job.id, taskId),
|
||||
jobs.getTaskSteps(job.id, taskId),
|
||||
]);
|
||||
setTaskLogs(logs);
|
||||
setTaskSteps(steps);
|
||||
setTaskData(prev => {
|
||||
const current = prev[taskId] || { steps: [], logs: [] };
|
||||
// Merge logs instead of replacing - this preserves WebSocket-streamed logs
|
||||
// Deduplicate by log ID
|
||||
const existingLogIds = new Set((current.logs || []).map(l => l.id));
|
||||
const newLogs = (logs || []).filter(l => !existingLogIds.has(l.id));
|
||||
const mergedLogs = [...(current.logs || []), ...newLogs].sort((a, b) => a.id - b.id);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: {
|
||||
steps: steps || current.steps, // Steps can be replaced (they don't change often)
|
||||
logs: mergedLogs
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load task logs:', error);
|
||||
console.error('Failed to load task data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const startLogStream = () => {
|
||||
if (!selectedTaskId || streaming) return;
|
||||
const loadTaskStepsOnly = async (taskId) => {
|
||||
try {
|
||||
const steps = await jobs.getTaskSteps(job.id, taskId);
|
||||
setTaskData(prev => {
|
||||
const current = prev[taskId] || { steps: [], logs: [] };
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: {
|
||||
steps: steps || current.steps,
|
||||
logs: current.logs || [] // Preserve existing logs
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load task steps:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const startLogStream = (taskIds) => {
|
||||
if (taskIds.length === 0 || streaming) return;
|
||||
|
||||
setStreaming(true);
|
||||
const ws = jobs.streamTaskLogsWebSocket(job.id, selectedTaskId);
|
||||
// For now, stream the first task's logs (WebSocket supports one task at a time)
|
||||
// In the future, we could have multiple WebSocket connections
|
||||
const primaryTaskId = taskIds[0];
|
||||
const ws = jobs.streamTaskLogsWebSocket(job.id, primaryTaskId);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'log' && data.data) {
|
||||
setTaskLogs((prev) => [...prev, data.data]);
|
||||
const log = data.data;
|
||||
setTaskData(prev => {
|
||||
const taskId = log.task_id;
|
||||
const current = prev[taskId] || { steps: [], logs: [] };
|
||||
// Check if log already exists (avoid duplicates)
|
||||
if (!current.logs.find(l => l.id === log.id)) {
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: {
|
||||
...current,
|
||||
logs: [...current.logs, log]
|
||||
}
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} else if (data.type === 'connected') {
|
||||
// Connection established
|
||||
}
|
||||
@@ -111,31 +216,86 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
ws.onclose = () => {
|
||||
setStreaming(false);
|
||||
// Auto-reconnect if job is still running
|
||||
if (jobDetails.status === 'running' && selectedTaskId) {
|
||||
if (jobDetails.status === 'running' && taskIds.length > 0) {
|
||||
setTimeout(() => {
|
||||
if (jobDetails.status === 'running') {
|
||||
startLogStream();
|
||||
startLogStream(taskIds);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleTaskClick = async (taskId) => {
|
||||
setSelectedTaskId(taskId);
|
||||
await loadTaskLogs(taskId);
|
||||
const toggleTask = async (taskId) => {
|
||||
const newExpanded = new Set(expandedTasks);
|
||||
if (newExpanded.has(taskId)) {
|
||||
newExpanded.delete(taskId);
|
||||
} else {
|
||||
newExpanded.add(taskId);
|
||||
// Load data if not already loaded
|
||||
if (!taskData[taskId]) {
|
||||
await loadTaskData(taskId);
|
||||
}
|
||||
}
|
||||
setExpandedTasks(newExpanded);
|
||||
};
|
||||
|
||||
const toggleStep = (taskId, stepName) => {
|
||||
const key = `${taskId}-${stepName}`;
|
||||
const newExpanded = new Set(expandedSteps);
|
||||
if (newExpanded.has(key)) {
|
||||
newExpanded.delete(key);
|
||||
} else {
|
||||
newExpanded.add(key);
|
||||
// Initialize auto-scroll to true (default: on) when step is first expanded
|
||||
if (shouldAutoScrollRefs.current[key] === undefined) {
|
||||
shouldAutoScrollRefs.current[key] = true;
|
||||
}
|
||||
}
|
||||
setExpandedSteps(newExpanded);
|
||||
};
|
||||
|
||||
const toggleAutoScroll = (taskId, stepName) => {
|
||||
const key = `${taskId}-${stepName}`;
|
||||
// Toggle auto-scroll state (default to true if undefined)
|
||||
const currentState = shouldAutoScrollRefs.current[key] !== false;
|
||||
shouldAutoScrollRefs.current[key] = !currentState;
|
||||
// Force re-render to update button state
|
||||
setExpandedSteps(new Set(expandedSteps));
|
||||
};
|
||||
|
||||
const handleLogWheel = (taskId, stepName) => {
|
||||
const key = `${taskId}-${stepName}`;
|
||||
// Turn off auto-scroll when user scrolls with wheel
|
||||
if (shouldAutoScrollRefs.current[key] !== false) {
|
||||
shouldAutoScrollRefs.current[key] = false;
|
||||
// Force re-render to update button state
|
||||
setExpandedSteps(new Set(expandedSteps));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogClick = (taskId, stepName, e) => {
|
||||
// Pause on left or right click
|
||||
if (e.button === 0 || e.button === 2) {
|
||||
const key = `${taskId}-${stepName}`;
|
||||
if (shouldAutoScrollRefs.current[key] !== false) {
|
||||
shouldAutoScrollRefs.current[key] = false;
|
||||
// Force re-render to update button state
|
||||
setExpandedSteps(new Set(expandedSteps));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getLogLevelColor = (level) => {
|
||||
switch (level) {
|
||||
case 'ERROR':
|
||||
return 'text-red-600';
|
||||
return 'text-red-400';
|
||||
case 'WARN':
|
||||
return 'text-yellow-600';
|
||||
return 'text-yellow-400';
|
||||
case 'DEBUG':
|
||||
return 'text-gray-500';
|
||||
default:
|
||||
return 'text-gray-900';
|
||||
return 'text-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,34 +316,106 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
|
||||
const getTaskStatusColor = (status) => {
|
||||
const colors = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
running: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
pending: 'bg-yellow-400/20 text-yellow-400',
|
||||
running: 'bg-orange-400/20 text-orange-400',
|
||||
completed: 'bg-green-400/20 text-green-400',
|
||||
failed: 'bg-red-400/20 text-red-400',
|
||||
};
|
||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
||||
return colors[status] || 'bg-gray-500/20 text-gray-400';
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to permanently delete this job? This action cannot be undone.')) return;
|
||||
try {
|
||||
await jobs.delete(jobDetails.id);
|
||||
if (onUpdate) {
|
||||
onUpdate();
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
alert('Failed to delete job: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const outputFiles = files.filter((f) => f.file_type === 'output');
|
||||
const inputFiles = files.filter((f) => f.file_type === 'input');
|
||||
|
||||
// Helper to check if a file is an image
|
||||
const isImageFile = (fileName) => {
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'];
|
||||
const lowerName = fileName.toLowerCase();
|
||||
return imageExtensions.some(ext => lowerName.endsWith(ext));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900">{jobDetails.name}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl font-bold"
|
||||
<>
|
||||
{/* Image Preview Modal */}
|
||||
{previewImage && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-[60] p-4"
|
||||
onClick={() => setPreviewImage(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-900 rounded-lg shadow-xl max-w-7xl w-full max-h-[95vh] overflow-auto border border-gray-700 relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="sticky top-0 bg-gray-900 border-b border-gray-700 px-6 py-4 flex justify-between items-center">
|
||||
<h3 className="text-xl font-semibold text-gray-100">{previewImage.fileName}</h3>
|
||||
<button
|
||||
onClick={() => setPreviewImage(null)}
|
||||
className="text-gray-400 hover:text-gray-200 text-2xl font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 flex items-center justify-center bg-black">
|
||||
<img
|
||||
src={previewImage.url}
|
||||
alt={previewImage.fileName}
|
||||
className="max-w-full max-h-[85vh] object-contain"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
const errorDiv = e.target.nextSibling;
|
||||
if (errorDiv) {
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="hidden text-center p-8 text-gray-400 text-lg">
|
||||
Failed to load image preview
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto border border-gray-700">
|
||||
<div className="sticky top-0 bg-gray-800 border-b border-gray-700 px-6 py-4 flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold text-gray-100">{jobDetails.name}</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{(jobDetails.status === 'completed' || jobDetails.status === 'failed' || jobDetails.status === 'cancelled') && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors text-sm font-medium"
|
||||
title="Delete job"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-200 text-2xl font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{loading && (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -191,24 +423,24 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Status</p>
|
||||
<p className="font-semibold text-gray-900">{jobDetails.status}</p>
|
||||
<p className="text-sm text-gray-400">Status</p>
|
||||
<p className="font-semibold text-gray-100">{jobDetails.status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Progress</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
<p className="text-sm text-gray-400">Progress</p>
|
||||
<p className="font-semibold text-gray-100">
|
||||
{jobDetails.progress.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Frame Range</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
<p className="text-sm text-gray-400">Frame Range</p>
|
||||
<p className="font-semibold text-gray-100">
|
||||
{jobDetails.frame_start} - {jobDetails.frame_end}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Output Format</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
<p className="text-sm text-gray-400">Output Format</p>
|
||||
<p className="font-semibold text-gray-100">
|
||||
{jobDetails.output_format}
|
||||
</p>
|
||||
</div>
|
||||
@@ -216,7 +448,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
|
||||
{videoUrl && jobDetails.output_format === 'MP4' && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||||
Video Preview
|
||||
</h3>
|
||||
<VideoPlayer videoUrl={videoUrl} />
|
||||
@@ -225,46 +457,61 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
|
||||
{outputFiles.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||||
Output Files
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{outputFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{file.file_name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{(file.file_size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDownload(file.id, file.file_name)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
{outputFiles.map((file) => {
|
||||
const isImage = isImageFile(file.file_name);
|
||||
const imageUrl = isImage ? jobs.downloadFile(job.id, file.id) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-900 rounded-lg border border-gray-700"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-100">{file.file_name}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{(file.file_size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isImage && imageUrl && (
|
||||
<button
|
||||
onClick={() => setPreviewImage({ url: imageUrl, fileName: file.file_name })}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDownload(file.id, file.file_name)}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-500 transition-colors"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputFiles.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||||
Input Files
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{inputFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="p-3 bg-gray-50 rounded-lg"
|
||||
className="p-3 bg-gray-900 rounded-lg border border-gray-700"
|
||||
>
|
||||
<p className="font-medium text-gray-900">{file.file_name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-100">{file.file_name}</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{(file.file_size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
@@ -274,110 +521,235 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
)}
|
||||
|
||||
{jobDetails.error_message && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
<div className="p-4 bg-red-400/20 border border-red-400/50 rounded-lg text-red-400">
|
||||
<p className="font-semibold">Error:</p>
|
||||
<p>{jobDetails.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Tasks
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||||
Tasks {streaming && <span className="text-sm text-green-400">(streaming)</span>}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{tasks.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Task List</h4>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{tasks.map((task) => (
|
||||
<div className="space-y-2">
|
||||
{tasks.length > 0 ? (
|
||||
tasks.map((task) => {
|
||||
const isExpanded = expandedTasks.has(task.id);
|
||||
const taskInfo = taskData[task.id] || { steps: [], logs: [] };
|
||||
const { steps, logs } = taskInfo;
|
||||
|
||||
// Group logs by step_name
|
||||
const logsByStep = {};
|
||||
logs.forEach(log => {
|
||||
const stepName = log.step_name || 'general';
|
||||
if (!logsByStep[stepName]) {
|
||||
logsByStep[stepName] = [];
|
||||
}
|
||||
logsByStep[stepName].push(log);
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={task.id} className="bg-gray-900 rounded-lg border border-gray-700">
|
||||
{/* Task Header */}
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={() => handleTaskClick(task.id)}
|
||||
className={`flex items-center justify-between p-3 bg-white rounded cursor-pointer hover:bg-gray-100 transition-colors ${
|
||||
selectedTaskId === task.id ? 'ring-2 ring-purple-600' : ''
|
||||
}`}
|
||||
onClick={() => toggleTask(task.id)}
|
||||
className="flex items-center justify-between p-3 bg-gray-800 rounded-t-lg cursor-pointer hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-500">
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getTaskStatusColor(task.status)}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
Frame {task.frame_start}
|
||||
{task.frame_end !== task.frame_start ? `-${task.frame_end}` : ''}
|
||||
<span className="font-medium text-gray-100">
|
||||
{task.task_type === 'metadata' ? 'Metadata Extraction' : `Frame ${task.frame_start}${task.frame_end !== task.frame_start ? `-${task.frame_end}` : ''}`}
|
||||
</span>
|
||||
{task.task_type && task.task_type !== 'render' && (
|
||||
<span className="text-xs text-gray-500">({task.task_type})</span>
|
||||
<span className="text-xs text-gray-400">({task.task_type})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="text-sm text-gray-400">
|
||||
{task.runner_id && `Runner ${task.runner_id}`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskSteps.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Steps</h4>
|
||||
<div className="space-y-2">
|
||||
{taskSteps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center justify-between p-2 bg-white rounded"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">
|
||||
{getStepStatusIcon(step.status)}
|
||||
</span>
|
||||
<span className="font-medium">{step.step_name}</span>
|
||||
</div>
|
||||
{step.duration_ms && (
|
||||
<span className="text-sm text-gray-600">
|
||||
{(step.duration_ms / 1000).toFixed(2)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Task Content (Steps and Logs) */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-3">
|
||||
{/* General logs (logs without step_name) */}
|
||||
{logsByStep['general'] && logsByStep['general'].length > 0 && (() => {
|
||||
const generalKey = `${task.id}-general`;
|
||||
const isGeneralExpanded = expandedSteps.has(generalKey);
|
||||
const generalLogs = logsByStep['general'];
|
||||
|
||||
{selectedTaskId && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">
|
||||
Logs {streaming && <span className="text-sm text-green-600">(streaming)</span>}
|
||||
</h4>
|
||||
<div className="bg-black text-green-400 font-mono text-sm p-3 rounded max-h-96 overflow-y-auto">
|
||||
{taskLogs.length === 0 ? (
|
||||
<p className="text-gray-500">No logs yet...</p>
|
||||
) : (
|
||||
taskLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`${getLogLevelColor(log.log_level)} mb-1`}
|
||||
>
|
||||
<span className="text-gray-500">
|
||||
[{new Date(log.created_at).toLocaleTimeString()}]
|
||||
</span>
|
||||
{log.step_name && (
|
||||
<span className="text-blue-400 ml-2">
|
||||
[{log.step_name}]
|
||||
</span>
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-lg border border-gray-700">
|
||||
<div
|
||||
onClick={() => toggleStep(task.id, 'general')}
|
||||
className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{isGeneralExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span className="font-medium text-gray-100">General</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{generalLogs.length} log{generalLogs.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{isGeneralExpanded && (
|
||||
<div className="p-3 border-t border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-400">Logs</span>
|
||||
<button
|
||||
onClick={() => toggleAutoScroll(task.id, 'general')}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
shouldAutoScrollRefs.current[generalKey] !== false
|
||||
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
|
||||
: 'bg-gray-500/20 text-gray-400 hover:bg-gray-500/30'
|
||||
} transition-colors`}
|
||||
title={shouldAutoScrollRefs.current[generalKey] !== false ? 'Auto-scroll: ON' : 'Auto-scroll: OFF'}
|
||||
>
|
||||
{shouldAutoScrollRefs.current[generalKey] !== false ? '📜 Follow' : '⏸ Paused'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={el => {
|
||||
if (el) {
|
||||
logContainerRefs.current[generalKey] = el;
|
||||
// Initialize auto-scroll to true (follow logs) when ref is first set
|
||||
if (shouldAutoScrollRefs.current[generalKey] === undefined) {
|
||||
shouldAutoScrollRefs.current[generalKey] = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onWheel={() => handleLogWheel(task.id, 'general')}
|
||||
onMouseDown={(e) => handleLogClick(task.id, 'general', e)}
|
||||
onContextMenu={(e) => handleLogClick(task.id, 'general', e)}
|
||||
className="bg-black text-green-400 font-mono text-sm p-3 rounded max-h-64 overflow-y-auto"
|
||||
>
|
||||
{generalLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`${getLogLevelColor(log.log_level)} mb-1`}
|
||||
>
|
||||
<span className="text-gray-500">
|
||||
[{new Date(log.created_at).toLocaleTimeString()}]
|
||||
</span>
|
||||
<span className="ml-2">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Steps */}
|
||||
{steps.length > 0 ? (
|
||||
steps.map((step) => {
|
||||
const stepKey = `${task.id}-${step.step_name}`;
|
||||
const isStepExpanded = expandedSteps.has(stepKey);
|
||||
const stepLogs = logsByStep[step.step_name] || [];
|
||||
|
||||
return (
|
||||
<div key={step.id} className="bg-gray-800 rounded-lg border border-gray-700">
|
||||
{/* Step Header */}
|
||||
<div
|
||||
onClick={() => toggleStep(task.id, step.step_name)}
|
||||
className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{isStepExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{getStepStatusIcon(step.status)}
|
||||
</span>
|
||||
<span className="font-medium text-gray-100">{step.step_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{step.duration_ms && (
|
||||
<span className="text-sm text-gray-400">
|
||||
{(step.duration_ms / 1000).toFixed(2)}s
|
||||
</span>
|
||||
)}
|
||||
{stepLogs.length > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{stepLogs.length} log{stepLogs.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Logs */}
|
||||
{isStepExpanded && (
|
||||
<div className="p-3 border-t border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-400">Logs</span>
|
||||
<button
|
||||
onClick={() => toggleAutoScroll(task.id, step.step_name)}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
shouldAutoScrollRefs.current[stepKey] !== false
|
||||
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
|
||||
: 'bg-gray-500/20 text-gray-400 hover:bg-gray-500/30'
|
||||
} transition-colors`}
|
||||
title={shouldAutoScrollRefs.current[stepKey] !== false ? 'Auto-scroll: ON' : 'Auto-scroll: OFF'}
|
||||
>
|
||||
{shouldAutoScrollRefs.current[stepKey] !== false ? '📜 Follow' : '⏸ Paused'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={el => {
|
||||
if (el) {
|
||||
logContainerRefs.current[stepKey] = el;
|
||||
// Initialize auto-scroll to true (follow logs) when ref is first set
|
||||
if (shouldAutoScrollRefs.current[stepKey] === undefined) {
|
||||
shouldAutoScrollRefs.current[stepKey] = true;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onWheel={() => handleLogWheel(task.id, step.step_name)}
|
||||
onMouseDown={(e) => handleLogClick(task.id, step.step_name, e)}
|
||||
onContextMenu={(e) => handleLogClick(task.id, step.step_name, e)}
|
||||
className="bg-black text-green-400 font-mono text-sm p-3 rounded max-h-64 overflow-y-auto"
|
||||
>
|
||||
{stepLogs.length === 0 ? (
|
||||
<p className="text-gray-500">No logs yet...</p>
|
||||
) : (
|
||||
stepLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`${getLogLevelColor(log.log_level)} mb-1`}
|
||||
>
|
||||
<span className="text-gray-500">
|
||||
[{new Date(log.created_at).toLocaleTimeString()}]
|
||||
</span>
|
||||
<span className="ml-2">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
logsByStep['general'] && logsByStep['general'].length > 0 ? null : (
|
||||
<p className="text-gray-400 text-sm">No steps yet...</p>
|
||||
)
|
||||
)}
|
||||
<span className="ml-2">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedTaskId && (
|
||||
<p className="text-gray-600 text-sm">
|
||||
Select a task to view logs and steps
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">No tasks yet...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -386,6 +758,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user