massive changes and it works

This commit is contained in:
2025-11-23 10:58:24 -06:00
parent 30aa969433
commit 2a0ff98834
3499 changed files with 7770 additions and 634687 deletions

View File

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