Update .gitignore to include log files and database journal files. Modify go.mod to update dependencies for go-sqlite3 and cloud.google.com/go/compute/metadata. Enhance Makefile to include logging options for manager and runner commands. Introduce new job token handling in auth package and implement database migration scripts. Refactor manager and runner components to improve job processing and metadata extraction. Add support for video preview in frontend components and enhance WebSocket management for channel subscriptions.
This commit is contained in:
@@ -12,27 +12,34 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const [contextFiles, setContextFiles] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
// Store steps and logs per task: { taskId: { steps: [], logs: [] } }
|
||||
const [taskData, setTaskData] = useState({});
|
||||
// Track which tasks and steps are expanded
|
||||
// Track which tasks 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 [previewVideo, setPreviewVideo] = useState(null); // { url, fileName } or null
|
||||
const listenerIdRef = useRef(null); // Listener ID for shared WebSocket
|
||||
const subscribedChannelsRef = useRef(new Set()); // Track confirmed subscribed channels
|
||||
const pendingSubscriptionsRef = useRef(new Set()); // Track pending subscriptions (waiting for confirmation)
|
||||
const logContainerRefs = useRef({}); // Refs for each step's log container
|
||||
const shouldAutoScrollRefs = useRef({}); // Auto-scroll state per step
|
||||
const logContainerRefs = useRef({}); // Refs for each task's log container
|
||||
const shouldAutoScrollRefs = useRef({}); // Auto-scroll state per task
|
||||
const abortControllerRef = useRef(null); // AbortController for HTTP requests
|
||||
|
||||
// Sync job prop to state when it changes
|
||||
useEffect(() => {
|
||||
setJobDetails(job);
|
||||
}, [job.id, job.status, job.progress]);
|
||||
if (job) {
|
||||
setJobDetails(job);
|
||||
}
|
||||
}, [job?.id, job?.status, job?.progress]);
|
||||
|
||||
useEffect(() => {
|
||||
// Guard against undefined job or job.id
|
||||
if (!job || !job.id) {
|
||||
console.warn('JobDetails: job or job.id is undefined, skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new AbortController for this effect
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
@@ -73,10 +80,10 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
listenerIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [job.id]);
|
||||
}, [job?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Update log subscriptions based on expanded tasks (not steps)
|
||||
// Update log subscriptions based on expanded tasks
|
||||
updateLogSubscriptions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [expandedTasks, tasks.length, jobDetails.status]); // Use tasks.length instead of tasks to avoid unnecessary re-runs
|
||||
@@ -105,6 +112,12 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
}, [taskData]);
|
||||
|
||||
const loadDetails = async () => {
|
||||
// Guard against undefined job or job.id
|
||||
if (!job || !job.id) {
|
||||
console.warn('JobDetails: Cannot load details - job or job.id is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// Use summary endpoint for tasks initially - much faster
|
||||
@@ -112,7 +125,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const [details, fileList, taskListResult] = await Promise.all([
|
||||
jobs.get(job.id, { signal }),
|
||||
jobs.getFiles(job.id, { limit: 50, signal }), // Only load first page of files
|
||||
jobs.getTasksSummary(job.id, { sort: 'frame_start:asc', signal }), // Get all tasks
|
||||
jobs.getTasksSummary(job.id, { sort: 'frame:asc', signal }), // Get all tasks
|
||||
]);
|
||||
|
||||
// Check if request was aborted
|
||||
@@ -139,8 +152,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||||
id: summary.id,
|
||||
job_id: job.id,
|
||||
frame_start: summary.frame_start,
|
||||
frame_end: summary.frame_end,
|
||||
frame: summary.frame,
|
||||
status: summary.status,
|
||||
task_type: summary.task_type,
|
||||
runner_id: summary.runner_id,
|
||||
@@ -180,14 +192,6 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there's an MP4 output file
|
||||
const fileArray = Array.isArray(fileData) ? fileData : [];
|
||||
const mp4File = fileArray.find(
|
||||
(f) => f.file_type === 'output' && f.file_name && f.file_name.endsWith('.mp4')
|
||||
);
|
||||
if (mp4File) {
|
||||
setVideoUrl(jobs.getVideoUrl(job.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load job details:', error);
|
||||
} finally {
|
||||
@@ -278,27 +282,17 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
};
|
||||
|
||||
const subscribe = (channel) => {
|
||||
if (wsManager.getReadyState() !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
// Don't subscribe if already subscribed or pending
|
||||
if (subscribedChannelsRef.current.has(channel) || pendingSubscriptionsRef.current.has(channel)) {
|
||||
return; // Already subscribed or subscription pending
|
||||
}
|
||||
wsManager.send({ type: 'subscribe', channel });
|
||||
pendingSubscriptionsRef.current.add(channel); // Mark as pending
|
||||
// Use wsManager's channel subscription (handles reconnect automatically)
|
||||
wsManager.subscribeToChannel(channel);
|
||||
subscribedChannelsRef.current.add(channel);
|
||||
pendingSubscriptionsRef.current.add(channel);
|
||||
};
|
||||
|
||||
const unsubscribe = (channel) => {
|
||||
if (wsManager.getReadyState() !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (!subscribedChannelsRef.current.has(channel)) {
|
||||
return; // Not subscribed
|
||||
}
|
||||
wsManager.send({ type: 'unsubscribe', channel });
|
||||
// Use wsManager's channel unsubscription
|
||||
wsManager.unsubscribeFromChannel(channel);
|
||||
subscribedChannelsRef.current.delete(channel);
|
||||
console.log('Unsubscribed from channel:', channel);
|
||||
pendingSubscriptionsRef.current.delete(channel);
|
||||
};
|
||||
|
||||
const unsubscribeAll = () => {
|
||||
@@ -308,7 +302,8 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
};
|
||||
|
||||
const updateLogSubscriptions = () => {
|
||||
if (wsManager.getReadyState() !== WebSocket.OPEN) {
|
||||
// Guard against undefined job or job.id
|
||||
if (!job || !job.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -326,7 +321,9 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
|
||||
// Subscribe to new channels
|
||||
shouldSubscribe.forEach(channel => {
|
||||
subscribe(channel);
|
||||
if (!subscribedChannelsRef.current.has(channel)) {
|
||||
subscribe(channel);
|
||||
}
|
||||
});
|
||||
|
||||
// Unsubscribe from channels that shouldn't be subscribed
|
||||
@@ -341,23 +338,28 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
try {
|
||||
console.log('JobDetails: Client WebSocket message received:', data.type, data.channel, data);
|
||||
|
||||
// Handle subscription responses
|
||||
// Handle subscription responses - update both local refs and wsManager
|
||||
if (data.type === 'subscribed' && data.channel) {
|
||||
pendingSubscriptionsRef.current.delete(data.channel); // Remove from pending
|
||||
subscribedChannelsRef.current.add(data.channel); // Add to confirmed
|
||||
pendingSubscriptionsRef.current.delete(data.channel);
|
||||
subscribedChannelsRef.current.add(data.channel);
|
||||
wsManager.confirmSubscription(data.channel);
|
||||
console.log('Successfully subscribed to channel:', data.channel, 'Total subscriptions:', subscribedChannelsRef.current.size);
|
||||
} else if (data.type === 'subscription_error' && data.channel) {
|
||||
pendingSubscriptionsRef.current.delete(data.channel); // Remove from pending
|
||||
subscribedChannelsRef.current.delete(data.channel); // Remove from confirmed (if it was there)
|
||||
pendingSubscriptionsRef.current.delete(data.channel);
|
||||
subscribedChannelsRef.current.delete(data.channel);
|
||||
wsManager.failSubscription(data.channel);
|
||||
console.error('Subscription failed for channel:', data.channel, data.error);
|
||||
// If it's the job channel, this is a critical error
|
||||
if (data.channel === `job:${job.id}`) {
|
||||
if (job && job.id && data.channel === `job:${job.id}`) {
|
||||
console.error('Failed to subscribe to job channel - job may not exist or access denied');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle job channel messages
|
||||
// Check both explicit channel and job_id match (for backwards compatibility)
|
||||
// Guard against undefined job.id
|
||||
if (!job || !job.id) {
|
||||
return;
|
||||
}
|
||||
const isJobChannel = data.channel === `job:${job.id}` ||
|
||||
(data.job_id === job.id && !data.channel);
|
||||
if (isJobChannel) {
|
||||
@@ -449,7 +451,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const reloadTasks = async () => {
|
||||
try {
|
||||
const signal = abortControllerRef.current?.signal;
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { sort: 'frame_start:asc', signal });
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { sort: 'frame:asc', signal });
|
||||
|
||||
// Check if request was aborted
|
||||
if (signal?.aborted) {
|
||||
@@ -465,8 +467,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||||
id: summary.id,
|
||||
job_id: job.id,
|
||||
frame_start: summary.frame_start,
|
||||
frame_end: summary.frame_end,
|
||||
frame: summary.frame,
|
||||
status: summary.status,
|
||||
task_type: summary.task_type,
|
||||
runner_id: summary.runner_id,
|
||||
@@ -488,13 +489,62 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
}, 100);
|
||||
return prevArray;
|
||||
});
|
||||
} else if (data.type === 'task_reset') {
|
||||
// Handle task_reset - task was reset to pending, steps and logs were cleared
|
||||
const taskId = data.task_id || (data.data && (data.data.id || data.data.task_id));
|
||||
console.log('Task reset received:', { task_id: taskId, data: data.data });
|
||||
|
||||
if (!taskId) {
|
||||
console.warn('task_reset message missing task_id:', data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update task in list
|
||||
setTasks(prev => {
|
||||
const prevArray = Array.isArray(prev) ? prev : [];
|
||||
const index = prevArray.findIndex(t => t.id === taskId);
|
||||
|
||||
if (index >= 0) {
|
||||
const updated = [...prevArray];
|
||||
const oldTask = updated[index];
|
||||
const newTask = {
|
||||
...oldTask,
|
||||
status: data.data?.status || 'pending',
|
||||
runner_id: null,
|
||||
current_step: null,
|
||||
started_at: null,
|
||||
error_message: data.data?.error_message || null,
|
||||
retry_count: data.data?.retry_count !== undefined ? data.data.retry_count : oldTask.retry_count,
|
||||
};
|
||||
updated[index] = newTask;
|
||||
console.log('Reset task at index', index, { task_id: taskId, new_task: newTask });
|
||||
return updated;
|
||||
}
|
||||
return prevArray;
|
||||
});
|
||||
|
||||
// Clear steps and logs for this task if flags indicate they were cleared
|
||||
if (data.data?.steps_cleared || data.data?.logs_cleared) {
|
||||
setTaskData(prev => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: {
|
||||
steps: data.data?.steps_cleared ? [] : current.steps,
|
||||
logs: data.data?.logs_cleared ? [] : current.logs,
|
||||
lastId: 0,
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'task_added' && data.data) {
|
||||
// New task was added - reload task summaries to get the new task
|
||||
console.log('task_added message received, reloading tasks...', data);
|
||||
const reloadTasks = async () => {
|
||||
try {
|
||||
const signal = abortControllerRef.current?.signal;
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { limit: 100, sort: 'frame_start:asc', signal });
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { limit: 100, sort: 'frame:asc', signal });
|
||||
|
||||
// Check if request was aborted
|
||||
if (signal?.aborted) {
|
||||
@@ -510,8 +560,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||||
id: summary.id,
|
||||
job_id: job.id,
|
||||
frame_start: summary.frame_start,
|
||||
frame_end: summary.frame_end,
|
||||
frame: summary.frame,
|
||||
status: summary.status,
|
||||
task_type: summary.task_type,
|
||||
runner_id: summary.runner_id,
|
||||
@@ -534,7 +583,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const reloadTasks = async () => {
|
||||
try {
|
||||
const signal = abortControllerRef.current?.signal;
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { limit: 100, sort: 'frame_start:asc', signal });
|
||||
const taskListResult = await jobs.getTasksSummary(job.id, { limit: 100, sort: 'frame:asc', signal });
|
||||
|
||||
// Check if request was aborted
|
||||
if (signal?.aborted) {
|
||||
@@ -550,8 +599,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||||
id: summary.id,
|
||||
job_id: job.id,
|
||||
frame_start: summary.frame_start,
|
||||
frame_end: summary.frame_end,
|
||||
frame: summary.frame,
|
||||
status: summary.status,
|
||||
task_type: summary.task_type,
|
||||
runner_id: summary.runner_id,
|
||||
@@ -738,48 +786,35 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
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}`;
|
||||
const toggleAutoScroll = (taskId, containerName) => {
|
||||
const key = `${taskId}-${containerName}`;
|
||||
// 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));
|
||||
// We don't have expandedSteps anymore, so just trigger a re-render by updating a dummy state
|
||||
setExpandedTasks(new Set(expandedTasks));
|
||||
};
|
||||
|
||||
const handleLogWheel = (taskId, stepName) => {
|
||||
const key = `${taskId}-${stepName}`;
|
||||
const handleLogWheel = (taskId, containerName) => {
|
||||
const key = `${taskId}-${containerName}`;
|
||||
// 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));
|
||||
setExpandedTasks(new Set(expandedTasks));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogClick = (taskId, stepName, e) => {
|
||||
const handleLogClick = (taskId, containerName, e) => {
|
||||
// Pause on left or right click
|
||||
if (e.button === 0 || e.button === 2) {
|
||||
const key = `${taskId}-${stepName}`;
|
||||
const key = `${taskId}-${containerName}`;
|
||||
if (shouldAutoScrollRefs.current[key] !== false) {
|
||||
shouldAutoScrollRefs.current[key] = false;
|
||||
// Force re-render to update button state
|
||||
setExpandedSteps(new Set(expandedSteps));
|
||||
setExpandedTasks(new Set(expandedTasks));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -838,13 +873,23 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
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
|
||||
// Helper to check if a file is a browser-supported image (or EXR which we convert server-side)
|
||||
const isImageFile = (fileName) => {
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'];
|
||||
// Browser-supported image formats + EXR (converted server-side)
|
||||
const imageExtensions = [
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg',
|
||||
'.ico', '.avif', '.apng', '.jfif', '.pjpeg', '.pjp',
|
||||
'.exr' // EXR files are converted to PNG server-side
|
||||
];
|
||||
const lowerName = fileName.toLowerCase();
|
||||
return imageExtensions.some(ext => lowerName.endsWith(ext));
|
||||
};
|
||||
|
||||
// Helper to check if a file is an EXR file
|
||||
const isEXRFile = (fileName) => {
|
||||
return fileName.toLowerCase().endsWith('.exr');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Image Preview Modal */}
|
||||
@@ -887,6 +932,32 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Preview Modal */}
|
||||
{previewVideo && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-[60] p-4"
|
||||
onClick={() => setPreviewVideo(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-900 rounded-lg shadow-xl max-w-5xl w-full max-h-[95vh] overflow-auto border border-gray-700 relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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">{previewVideo.fileName}</h3>
|
||||
<button
|
||||
onClick={() => setPreviewVideo(null)}
|
||||
className="text-gray-400 hover:text-gray-200 text-2xl font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 bg-black">
|
||||
<VideoPlayer videoUrl={previewVideo.url} />
|
||||
</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">
|
||||
@@ -940,15 +1011,6 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{videoUrl && (jobDetails.output_format === 'EXR_264_MP4' || jobDetails.output_format === 'EXR_AV1_MP4') && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||||
Video Preview
|
||||
</h3>
|
||||
<VideoPlayer videoUrl={videoUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextFiles.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||||
@@ -976,9 +1038,15 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
files={outputFiles}
|
||||
onDownload={handleDownload}
|
||||
onPreview={(file) => {
|
||||
const imageUrl = jobs.downloadFile(job.id, file.id);
|
||||
// Use EXR preview endpoint for EXR files, regular download for others
|
||||
const imageUrl = isEXRFile(file.file_name)
|
||||
? jobs.previewEXR(job.id, file.id)
|
||||
: jobs.downloadFile(job.id, file.id);
|
||||
setPreviewImage({ url: imageUrl, fileName: file.file_name });
|
||||
}}
|
||||
onVideoPreview={(file) => {
|
||||
setPreviewVideo({ url: jobs.getVideoUrl(job.id), fileName: file.file_name });
|
||||
}}
|
||||
isImageFile={isImageFile}
|
||||
/>
|
||||
</div>
|
||||
@@ -997,15 +1065,8 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
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);
|
||||
});
|
||||
// Sort all logs chronologically (no grouping by step_name)
|
||||
const sortedLogs = [...logs].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
|
||||
return (
|
||||
<div key={task.id} className="bg-gray-900 rounded-lg border border-gray-700">
|
||||
@@ -1022,9 +1083,9 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
{task.status}
|
||||
</span>
|
||||
<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}` : ''}`}
|
||||
{task.task_type === 'encode' ? `Encode (${jobDetails.frame_start} - ${jobDetails.frame_end})` : `Frame ${task.frame}`}
|
||||
</span>
|
||||
{task.task_type && task.task_type !== 'render' && (
|
||||
{task.task_type && task.task_type !== 'render' && task.task_type !== 'encode' && (
|
||||
<span className="text-xs text-gray-400">({task.task_type})</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1033,153 +1094,46 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Content (Steps and Logs) */}
|
||||
{/* Task Content (Continuous Log Stream) */}
|
||||
{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'];
|
||||
|
||||
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>
|
||||
{/* Header with auto-scroll */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-400">
|
||||
</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)}
|
||||
onClick={() => toggleAutoScroll(task.id, 'logs')}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
shouldAutoScrollRefs.current[stepKey] !== false
|
||||
shouldAutoScrollRefs.current[`${task.id}-logs`] !== 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'}
|
||||
title={shouldAutoScrollRefs.current[`${task.id}-logs`] !== false ? 'Auto-scroll: ON' : 'Auto-scroll: OFF'}
|
||||
>
|
||||
{shouldAutoScrollRefs.current[stepKey] !== false ? '📜 Follow' : '⏸ Paused'}
|
||||
{shouldAutoScrollRefs.current[`${task.id}-logs`] !== false ? '📜 Follow' : '⏸ Paused'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logs */}
|
||||
<div
|
||||
ref={el => {
|
||||
if (el) {
|
||||
logContainerRefs.current[stepKey] = el;
|
||||
logContainerRefs.current[`${task.id}-logs`] = el;
|
||||
// Initialize auto-scroll to true (follow logs) when ref is first set
|
||||
if (shouldAutoScrollRefs.current[stepKey] === undefined) {
|
||||
shouldAutoScrollRefs.current[stepKey] = true;
|
||||
if (shouldAutoScrollRefs.current[`${task.id}-logs`] === undefined) {
|
||||
shouldAutoScrollRefs.current[`${task.id}-logs`] = 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"
|
||||
onWheel={() => handleLogWheel(task.id, 'logs')}
|
||||
onMouseDown={(e) => handleLogClick(task.id, 'logs', e)}
|
||||
onContextMenu={(e) => handleLogClick(task.id, 'logs', e)}
|
||||
className="bg-black text-green-400 font-mono text-sm p-3 rounded max-h-96 overflow-y-auto"
|
||||
>
|
||||
{stepLogs.length === 0 ? (
|
||||
{sortedLogs.length === 0 ? (
|
||||
<p className="text-gray-500">No logs yet...</p>
|
||||
) : (
|
||||
stepLogs.map((log) => (
|
||||
sortedLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`${getLogLevelColor(log.log_level)} mb-1`}
|
||||
@@ -1192,16 +1146,6 @@ export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
logsByStep['general'] && logsByStep['general'].length > 0 ? null : (
|
||||
<p className="text-gray-400 text-sm">No steps yet...</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user