1168 lines
48 KiB
JavaScript
1168 lines
48 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
||
import { jobs, REQUEST_SUPERSEDED } from '../utils/api';
|
||
import { wsManager } from '../utils/websocket';
|
||
import VideoPlayer from './VideoPlayer';
|
||
import FileExplorer from './FileExplorer';
|
||
import ErrorMessage from './ErrorMessage';
|
||
import LoadingSpinner from './LoadingSpinner';
|
||
|
||
export default function JobDetails({ job, onClose, onUpdate }) {
|
||
const [jobDetails, setJobDetails] = useState(job);
|
||
const [files, setFiles] = useState([]);
|
||
const [contextFiles, setContextFiles] = useState([]);
|
||
const [tasks, setTasks] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
// Store steps and logs per task: { taskId: { steps: [], logs: [] } }
|
||
const [taskData, setTaskData] = useState({});
|
||
// Track which tasks are expanded
|
||
const [expandedTasks, setExpandedTasks] = 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 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(() => {
|
||
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();
|
||
|
||
loadDetails();
|
||
// Use shared WebSocket manager for real-time updates
|
||
listenerIdRef.current = wsManager.subscribe(`jobdetails_${job.id}`, {
|
||
open: () => {
|
||
console.log('JobDetails: Shared WebSocket connected for job', job.id);
|
||
// Subscribe to job channel
|
||
subscribe(`job:${job.id}`);
|
||
},
|
||
message: (data) => {
|
||
handleWebSocketMessage(data);
|
||
},
|
||
error: (error) => {
|
||
console.error('JobDetails: Shared WebSocket error:', error);
|
||
},
|
||
close: (event) => {
|
||
console.log('JobDetails: Shared WebSocket closed:', event);
|
||
subscribedChannelsRef.current.clear();
|
||
pendingSubscriptionsRef.current.clear();
|
||
}
|
||
});
|
||
|
||
// Ensure connection is established
|
||
wsManager.connect();
|
||
|
||
return () => {
|
||
// Cancel any pending HTTP requests
|
||
if (abortControllerRef.current) {
|
||
abortControllerRef.current.abort();
|
||
abortControllerRef.current = null;
|
||
}
|
||
// Unsubscribe from all channels
|
||
unsubscribeAll();
|
||
if (listenerIdRef.current) {
|
||
wsManager.unsubscribe(listenerIdRef.current);
|
||
listenerIdRef.current = null;
|
||
}
|
||
};
|
||
}, [job?.id]);
|
||
|
||
useEffect(() => {
|
||
// 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
|
||
|
||
// 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 () => {
|
||
// 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
|
||
const signal = abortControllerRef.current?.signal;
|
||
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:asc', signal }), // Get all tasks
|
||
]);
|
||
|
||
// Check if request was aborted
|
||
if (signal?.aborted) {
|
||
return;
|
||
}
|
||
setJobDetails(details);
|
||
|
||
// Handle paginated file response - check for superseded sentinel
|
||
if (fileList === REQUEST_SUPERSEDED) {
|
||
return; // Request was superseded, skip this update
|
||
}
|
||
const fileData = fileList?.data || fileList;
|
||
setFiles(Array.isArray(fileData) ? fileData : []);
|
||
|
||
// Handle paginated task summary response - check for superseded sentinel
|
||
if (taskListResult === REQUEST_SUPERSEDED) {
|
||
return; // Request was superseded, skip this update
|
||
}
|
||
const taskData = taskListResult?.data || taskListResult;
|
||
const taskSummaries = Array.isArray(taskData) ? taskData : [];
|
||
|
||
// Convert summaries to task-like objects for display
|
||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||
id: summary.id,
|
||
job_id: job.id,
|
||
frame: summary.frame,
|
||
status: summary.status,
|
||
task_type: summary.task_type,
|
||
runner_id: summary.runner_id,
|
||
// These will be loaded on expand
|
||
current_step: null,
|
||
retry_count: 0,
|
||
max_retries: 3,
|
||
created_at: new Date().toISOString(),
|
||
}));
|
||
setTasks(Array.isArray(tasksForDisplay) ? tasksForDisplay : []);
|
||
|
||
// Fetch context archive contents separately (may not exist for old jobs)
|
||
try {
|
||
const contextList = await jobs.getContextArchive(job.id, { signal });
|
||
if (signal?.aborted) return;
|
||
setContextFiles(contextList || []);
|
||
} catch (error) {
|
||
if (signal?.aborted) return;
|
||
// Context archive may not exist for old jobs
|
||
setContextFiles([]);
|
||
}
|
||
|
||
// Only load task data (logs/steps) for expanded tasks
|
||
// Don't auto-load for all tasks - wait for user to expand
|
||
if (details.status === 'running') {
|
||
// Only load data for tasks that are expanded
|
||
tasksForDisplay.forEach(task => {
|
||
if (expandedTasks.has(task.id)) {
|
||
const existingData = taskData[task.id];
|
||
// Only fetch logs via HTTP if we don't have any logs yet
|
||
if (!existingData || !existingData.logs || existingData.logs.length === 0) {
|
||
loadTaskData(task.id);
|
||
} else if (!existingData.steps || existingData.steps.length === 0) {
|
||
loadTaskStepsOnly(task.id);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Failed to load job details:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleDownload = (fileId, fileName) => {
|
||
window.open(jobs.downloadFile(job.id, fileId), '_blank');
|
||
};
|
||
|
||
const loadTaskData = async (taskId) => {
|
||
try {
|
||
console.log(`Loading task data for task ${taskId}...`);
|
||
const signal = abortControllerRef.current?.signal;
|
||
const [logsResult, steps] = await Promise.all([
|
||
jobs.getTaskLogs(job.id, taskId, { limit: 1000, signal }), // Increased limit for completed tasks
|
||
jobs.getTaskSteps(job.id, taskId, { signal }),
|
||
]);
|
||
|
||
// Check if request was aborted
|
||
if (signal?.aborted) {
|
||
return;
|
||
}
|
||
|
||
// Check for superseded sentinel
|
||
if (logsResult === REQUEST_SUPERSEDED || steps === REQUEST_SUPERSEDED) {
|
||
return; // Request was superseded, skip this update
|
||
}
|
||
|
||
console.log(`Task ${taskId} logs result:`, logsResult);
|
||
|
||
// Handle new format with logs, last_id, limit
|
||
const logs = logsResult.logs || logsResult;
|
||
const lastId = logsResult.last_id;
|
||
|
||
console.log(`Task ${taskId} - loaded ${Array.isArray(logs) ? logs.length : 0} logs, ${Array.isArray(steps) ? steps.length : 0} steps`);
|
||
|
||
setTaskData(prev => {
|
||
const current = prev[taskId] || { steps: [], logs: [], lastId: 0 };
|
||
// 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 = (Array.isArray(logs) ? 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,
|
||
logs: mergedLogs,
|
||
lastId: lastId || current.lastId
|
||
}
|
||
};
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to load task data:', error);
|
||
}
|
||
};
|
||
|
||
const loadTaskStepsOnly = async (taskId) => {
|
||
try {
|
||
const signal = abortControllerRef.current?.signal;
|
||
const steps = await jobs.getTaskSteps(job.id, taskId, { signal });
|
||
|
||
// Check if request was aborted
|
||
if (signal?.aborted) {
|
||
return;
|
||
}
|
||
|
||
// Check for superseded sentinel
|
||
if (steps === REQUEST_SUPERSEDED) {
|
||
return; // Request was superseded, skip this update
|
||
}
|
||
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 subscribe = (channel) => {
|
||
// Use wsManager's channel subscription (handles reconnect automatically)
|
||
wsManager.subscribeToChannel(channel);
|
||
subscribedChannelsRef.current.add(channel);
|
||
pendingSubscriptionsRef.current.add(channel);
|
||
};
|
||
|
||
const unsubscribe = (channel) => {
|
||
// Use wsManager's channel unsubscription
|
||
wsManager.unsubscribeFromChannel(channel);
|
||
subscribedChannelsRef.current.delete(channel);
|
||
pendingSubscriptionsRef.current.delete(channel);
|
||
};
|
||
|
||
const unsubscribeAll = () => {
|
||
subscribedChannelsRef.current.forEach(channel => {
|
||
unsubscribe(channel);
|
||
});
|
||
};
|
||
|
||
const updateLogSubscriptions = () => {
|
||
// Guard against undefined job or job.id
|
||
if (!job || !job.id) {
|
||
return;
|
||
}
|
||
|
||
// Determine which log channels should be subscribed
|
||
const shouldSubscribe = new Set();
|
||
const isRunning = jobDetails.status === 'running' || jobDetails.status === 'pending';
|
||
|
||
// Subscribe to logs when task is expanded (not when step is expanded)
|
||
if (isRunning) {
|
||
expandedTasks.forEach(taskId => {
|
||
const channel = `logs:${job.id}:${taskId}`;
|
||
shouldSubscribe.add(channel);
|
||
});
|
||
}
|
||
|
||
// Subscribe to new channels
|
||
shouldSubscribe.forEach(channel => {
|
||
if (!subscribedChannelsRef.current.has(channel)) {
|
||
subscribe(channel);
|
||
}
|
||
});
|
||
|
||
// Unsubscribe from channels that shouldn't be subscribed
|
||
subscribedChannelsRef.current.forEach(channel => {
|
||
if (channel.startsWith('logs:') && !shouldSubscribe.has(channel)) {
|
||
unsubscribe(channel);
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleWebSocketMessage = (data) => {
|
||
try {
|
||
console.log('JobDetails: Client WebSocket message received:', data.type, data.channel, data);
|
||
|
||
// Handle subscription responses - update both local refs and wsManager
|
||
if (data.type === 'subscribed' && data.channel) {
|
||
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);
|
||
subscribedChannelsRef.current.delete(data.channel);
|
||
wsManager.failSubscription(data.channel);
|
||
console.error('Subscription failed for channel:', data.channel, data.error);
|
||
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) {
|
||
console.log('Job channel message received:', data.type, data);
|
||
if (data.type === 'job_update' && data.data) {
|
||
// Update job details
|
||
console.log('Updating job details:', data.data);
|
||
setJobDetails(prev => {
|
||
const updated = { ...prev, ...data.data };
|
||
console.log('Job details updated:', {
|
||
old_progress: prev.progress,
|
||
new_progress: updated.progress,
|
||
old_status: prev.status,
|
||
new_status: updated.status
|
||
});
|
||
// Notify parent component of update
|
||
if (onUpdate) {
|
||
onUpdate(data.job_id || job.id, updated);
|
||
}
|
||
return updated;
|
||
});
|
||
} else if (data.type === 'task_update') {
|
||
// Handle task_update - data.data contains the update fields
|
||
const taskId = data.task_id || (data.data && (data.data.id || data.data.task_id));
|
||
console.log('Task update received:', { task_id: taskId, data: data.data, full_message: data });
|
||
|
||
if (!taskId) {
|
||
console.warn('task_update message missing task_id:', data);
|
||
return;
|
||
}
|
||
|
||
if (!data.data) {
|
||
console.warn('task_update message missing data:', data);
|
||
return;
|
||
}
|
||
|
||
// Update task in list
|
||
setTasks(prev => {
|
||
// Ensure prev is always an array
|
||
const prevArray = Array.isArray(prev) ? prev : [];
|
||
const index = prevArray.findIndex(t => t.id === taskId);
|
||
|
||
if (index >= 0) {
|
||
// Task exists - update it
|
||
const updated = [...prevArray];
|
||
const oldTask = updated[index];
|
||
// Create a completely new task object to ensure React detects the change
|
||
const newTask = {
|
||
...oldTask,
|
||
// Explicitly update each field from data.data to ensure changes are detected
|
||
status: data.data.status !== undefined ? data.data.status : oldTask.status,
|
||
runner_id: data.data.runner_id !== undefined ? data.data.runner_id : oldTask.runner_id,
|
||
started_at: data.data.started_at !== undefined ? data.data.started_at : oldTask.started_at,
|
||
completed_at: data.data.completed_at !== undefined ? data.data.completed_at : oldTask.completed_at,
|
||
error_message: data.data.error_message !== undefined ? data.data.error_message : oldTask.error_message,
|
||
output_path: data.data.output_path !== undefined ? data.data.output_path : oldTask.output_path,
|
||
current_step: data.data.current_step !== undefined ? data.data.current_step : oldTask.current_step,
|
||
// Merge any other fields
|
||
...Object.keys(data.data).reduce((acc, key) => {
|
||
if (!['status', 'runner_id', 'started_at', 'completed_at', 'error_message', 'output_path', 'current_step'].includes(key)) {
|
||
acc[key] = data.data[key];
|
||
}
|
||
return acc;
|
||
}, {})
|
||
};
|
||
updated[index] = newTask;
|
||
console.log('Updated task at index', index, {
|
||
task_id: taskId,
|
||
old_status: oldTask.status,
|
||
new_status: newTask.status,
|
||
old_runner_id: oldTask.runner_id,
|
||
new_runner_id: newTask.runner_id,
|
||
update_data: data.data,
|
||
full_new_task: newTask
|
||
});
|
||
return updated;
|
||
}
|
||
// Task not found - check if data contains full task info (from initial state)
|
||
// Check both 'id' and 'task_id' fields
|
||
const taskIdFromData = data.data.id || data.data.task_id;
|
||
if (data.data && typeof data.data === 'object' && taskIdFromData && taskIdFromData === taskId) {
|
||
// This is a full task object from initial state - add it
|
||
console.log('Adding new task from initial state:', data.data);
|
||
return [...prevArray, { ...data.data, id: taskIdFromData }];
|
||
}
|
||
// If task not found and it's a partial update, reload tasks to get the full list
|
||
console.log('Task not found in list, reloading tasks...');
|
||
setTimeout(() => {
|
||
const reloadTasks = async () => {
|
||
try {
|
||
const signal = abortControllerRef.current?.signal;
|
||
const taskListResult = await jobs.getTasksSummary(job.id, { sort: 'frame:asc', signal });
|
||
|
||
// Check if request was aborted
|
||
if (signal?.aborted) {
|
||
return;
|
||
}
|
||
|
||
// Check for superseded sentinel
|
||
if (taskListResult === REQUEST_SUPERSEDED) {
|
||
return; // Request was superseded, skip this update
|
||
}
|
||
const taskData = taskListResult.data || taskListResult;
|
||
const taskSummaries = Array.isArray(taskData) ? taskData : [];
|
||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||
id: summary.id,
|
||
job_id: job.id,
|
||
frame: summary.frame,
|
||
status: summary.status,
|
||
task_type: summary.task_type,
|
||
runner_id: summary.runner_id,
|
||
current_step: summary.current_step || null,
|
||
retry_count: summary.retry_count || 0,
|
||
max_retries: summary.max_retries || 3,
|
||
created_at: summary.created_at || new Date().toISOString(),
|
||
started_at: summary.started_at,
|
||
completed_at: summary.completed_at,
|
||
error_message: summary.error_message,
|
||
output_path: summary.output_path,
|
||
}));
|
||
setTasks(Array.isArray(tasksForDisplay) ? tasksForDisplay : []);
|
||
} catch (error) {
|
||
console.error('Failed to reload tasks:', error);
|
||
}
|
||
};
|
||
reloadTasks();
|
||
}, 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:asc', signal });
|
||
|
||
// Check if request was aborted
|
||
if (signal?.aborted) {
|
||
return;
|
||
}
|
||
|
||
// Check for superseded sentinel
|
||
if (taskListResult === REQUEST_SUPERSEDED) {
|
||
return; // Request was superseded, skip this update
|
||
}
|
||
const taskData = taskListResult.data || taskListResult;
|
||
const taskSummaries = Array.isArray(taskData) ? taskData : [];
|
||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||
id: summary.id,
|
||
job_id: job.id,
|
||
frame: summary.frame,
|
||
status: summary.status,
|
||
task_type: summary.task_type,
|
||
runner_id: summary.runner_id,
|
||
current_step: null,
|
||
retry_count: 0,
|
||
max_retries: 3,
|
||
created_at: new Date().toISOString(),
|
||
}));
|
||
setTasks(Array.isArray(tasksForDisplay) ? tasksForDisplay : []);
|
||
} catch (error) {
|
||
console.error('Failed to reload tasks:', error);
|
||
// Fallback to full reload
|
||
loadDetails();
|
||
}
|
||
};
|
||
reloadTasks();
|
||
} else if (data.type === 'tasks_added' && data.data) {
|
||
// Multiple new tasks were added - reload task summaries
|
||
console.log('tasks_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:asc', signal });
|
||
|
||
// Check if request was aborted
|
||
if (signal?.aborted) {
|
||
return;
|
||
}
|
||
|
||
// Check for superseded sentinel
|
||
if (taskListResult === REQUEST_SUPERSEDED) {
|
||
return; // Request was superseded, skip this update
|
||
}
|
||
const taskData = taskListResult.data || taskListResult;
|
||
const taskSummaries = Array.isArray(taskData) ? taskData : [];
|
||
const tasksForDisplay = taskSummaries.map(summary => ({
|
||
id: summary.id,
|
||
job_id: job.id,
|
||
frame: summary.frame,
|
||
status: summary.status,
|
||
task_type: summary.task_type,
|
||
runner_id: summary.runner_id,
|
||
current_step: null,
|
||
retry_count: 0,
|
||
max_retries: 3,
|
||
created_at: new Date().toISOString(),
|
||
}));
|
||
setTasks(Array.isArray(tasksForDisplay) ? tasksForDisplay : []);
|
||
} catch (error) {
|
||
console.error('Failed to reload tasks:', error);
|
||
// Fallback to full reload
|
||
loadDetails();
|
||
}
|
||
};
|
||
reloadTasks();
|
||
} else if (data.type === 'file_added' && data.data) {
|
||
// New file was added - reload file list
|
||
const reloadFiles = async () => {
|
||
try {
|
||
const fileList = await jobs.getFiles(job.id, { limit: 50 });
|
||
// Check for superseded sentinel
|
||
if (fileList === REQUEST_SUPERSEDED) {
|
||
return; // Request was superseded, skip this update
|
||
}
|
||
const fileData = fileList.data || fileList;
|
||
setFiles(Array.isArray(fileData) ? fileData : []);
|
||
} catch (error) {
|
||
console.error('Failed to reload files:', error);
|
||
}
|
||
};
|
||
reloadFiles();
|
||
} else if (data.type === 'step_update' && data.data && data.task_id) {
|
||
// Step was created or updated - update task data
|
||
console.log('step_update message received:', data);
|
||
setTaskData(prev => {
|
||
const taskId = data.task_id;
|
||
const current = prev[taskId] || { steps: [], logs: [] };
|
||
const stepData = data.data;
|
||
|
||
// Find if step already exists
|
||
const existingSteps = current.steps || [];
|
||
const stepIndex = existingSteps.findIndex(s => s.step_name === stepData.step_name);
|
||
|
||
let updatedSteps;
|
||
if (stepIndex >= 0) {
|
||
// Update existing step
|
||
updatedSteps = [...existingSteps];
|
||
updatedSteps[stepIndex] = {
|
||
...updatedSteps[stepIndex],
|
||
...stepData,
|
||
id: stepData.step_id || updatedSteps[stepIndex].id,
|
||
};
|
||
} else {
|
||
// Add new step
|
||
updatedSteps = [...existingSteps, {
|
||
id: stepData.step_id,
|
||
step_name: stepData.step_name,
|
||
status: stepData.status,
|
||
duration_ms: stepData.duration_ms,
|
||
error_message: stepData.error_message,
|
||
}];
|
||
}
|
||
|
||
return {
|
||
...prev,
|
||
[taskId]: {
|
||
...current,
|
||
steps: updatedSteps,
|
||
}
|
||
};
|
||
});
|
||
}
|
||
} else if (data.channel && data.channel.startsWith('logs:')) {
|
||
// Handle log channel messages
|
||
if (data.type === 'log' && data.data) {
|
||
const log = data.data;
|
||
// Get task_id from log data or top-level message
|
||
const taskId = log.task_id || data.task_id;
|
||
if (!taskId) {
|
||
console.warn('Log message missing task_id:', data);
|
||
return;
|
||
}
|
||
console.log('Received log for task:', taskId, log);
|
||
setTaskData(prev => {
|
||
const current = prev[taskId] || { steps: [], logs: [] };
|
||
|
||
// If log has a step_name, ensure the step exists in the steps array
|
||
let updatedSteps = current.steps || [];
|
||
if (log.step_name) {
|
||
const stepExists = updatedSteps.some(s => s.step_name === log.step_name);
|
||
if (!stepExists) {
|
||
// Create placeholder step for logs that arrive before step_update
|
||
console.log('Creating placeholder step for:', log.step_name, 'in task:', taskId);
|
||
updatedSteps = [...updatedSteps, {
|
||
id: null, // Will be updated when step_update arrives
|
||
step_name: log.step_name,
|
||
status: 'running', // Default to running since we're receiving logs
|
||
duration_ms: null,
|
||
error_message: null,
|
||
}];
|
||
}
|
||
}
|
||
|
||
// Check if log already exists (avoid duplicates)
|
||
if (!current.logs.find(l => l.id === log.id)) {
|
||
return {
|
||
...prev,
|
||
[taskId]: {
|
||
...current,
|
||
steps: updatedSteps,
|
||
logs: [...current.logs, log]
|
||
}
|
||
};
|
||
}
|
||
// Even if log is duplicate, update steps if needed
|
||
return {
|
||
...prev,
|
||
[taskId]: {
|
||
...current,
|
||
steps: updatedSteps,
|
||
}
|
||
};
|
||
});
|
||
}
|
||
} else if (data.type === 'connected') {
|
||
// Connection established
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to parse WebSocket message:', error);
|
||
}
|
||
};
|
||
|
||
// startLogStream is no longer needed - subscriptions are managed by updateLogSubscriptions
|
||
|
||
const toggleTask = async (taskId) => {
|
||
const newExpanded = new Set(expandedTasks);
|
||
if (newExpanded.has(taskId)) {
|
||
newExpanded.delete(taskId);
|
||
} else {
|
||
newExpanded.add(taskId);
|
||
// Load full task details if we only have summary
|
||
const tasksArray = Array.isArray(tasks) ? tasks : [];
|
||
const currentTask = tasksArray.find(t => t.id === taskId);
|
||
if (currentTask && !currentTask.created_at) {
|
||
// This is a summary - fetch full task details
|
||
try {
|
||
const signal = abortControllerRef.current?.signal;
|
||
const fullTasks = await jobs.getTasks(job.id, {
|
||
limit: 1,
|
||
signal,
|
||
// We can't filter by task ID, so we'll get all and find the one we need
|
||
});
|
||
|
||
// Check if request was aborted
|
||
if (signal?.aborted) {
|
||
return;
|
||
}
|
||
const taskData = fullTasks.data || fullTasks;
|
||
const fullTask = Array.isArray(taskData) ? taskData.find(t => t.id === taskId) : null;
|
||
if (fullTask) {
|
||
setTasks(prev => {
|
||
const prevArray = Array.isArray(prev) ? prev : [];
|
||
return prevArray.map(t => t.id === taskId ? fullTask : t);
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load full task details:', err);
|
||
}
|
||
}
|
||
// Always load logs/steps when expanding a task to ensure we have the latest data
|
||
// This is especially important for completed tasks that weren't loaded before
|
||
const existingData = taskData[taskId];
|
||
const hasLogs = existingData && existingData.logs && existingData.logs.length > 0;
|
||
const hasSteps = existingData && existingData.steps && existingData.steps.length > 0;
|
||
|
||
if (!hasLogs || !hasSteps) {
|
||
console.log(`Loading task data for task ${taskId} (logs: ${hasLogs}, steps: ${hasSteps})`);
|
||
await loadTaskData(taskId);
|
||
} else {
|
||
console.log(`Task ${taskId} already has ${existingData.logs.length} logs and ${existingData.steps.length} steps, skipping load`);
|
||
}
|
||
}
|
||
setExpandedTasks(newExpanded);
|
||
};
|
||
|
||
|
||
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
|
||
// We don't have expandedSteps anymore, so just trigger a re-render by updating a dummy state
|
||
setExpandedTasks(new Set(expandedTasks));
|
||
};
|
||
|
||
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
|
||
setExpandedTasks(new Set(expandedTasks));
|
||
}
|
||
};
|
||
|
||
const handleLogClick = (taskId, containerName, e) => {
|
||
// Pause on left or right click
|
||
if (e.button === 0 || e.button === 2) {
|
||
const key = `${taskId}-${containerName}`;
|
||
if (shouldAutoScrollRefs.current[key] !== false) {
|
||
shouldAutoScrollRefs.current[key] = false;
|
||
// Force re-render to update button state
|
||
setExpandedTasks(new Set(expandedTasks));
|
||
}
|
||
}
|
||
};
|
||
|
||
const getLogLevelColor = (level) => {
|
||
switch (level) {
|
||
case 'ERROR':
|
||
return 'text-red-400';
|
||
case 'WARN':
|
||
return 'text-yellow-400';
|
||
case 'DEBUG':
|
||
return 'text-gray-500';
|
||
default:
|
||
return 'text-gray-200';
|
||
}
|
||
};
|
||
|
||
const getStepStatusIcon = (status) => {
|
||
switch (status) {
|
||
case 'completed':
|
||
return '✓';
|
||
case 'failed':
|
||
return '✗';
|
||
case 'running':
|
||
return '⏳';
|
||
case 'skipped':
|
||
return '⏸';
|
||
default:
|
||
return '○';
|
||
}
|
||
};
|
||
|
||
const getTaskStatusColor = (status) => {
|
||
const colors = {
|
||
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-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 a browser-supported image (or EXR which we convert server-side)
|
||
const isImageFile = (fileName) => {
|
||
// 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 */}
|
||
{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()}
|
||
>
|
||
<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>
|
||
)}
|
||
|
||
{/* 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">
|
||
<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 && <LoadingSpinner size="sm" className="py-8" />}
|
||
|
||
{!loading && (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<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-400">Progress</p>
|
||
<p className="font-semibold text-gray-100">
|
||
{(jobDetails.progress || 0).toFixed(1)}%
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<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-400">Output Format</p>
|
||
<p className="font-semibold text-gray-100">
|
||
{jobDetails.output_format}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{contextFiles.length > 0 && (
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||
Context Archive
|
||
</h3>
|
||
<FileExplorer
|
||
files={contextFiles.map(f => ({
|
||
id: 0, // Context files don't have IDs
|
||
file_name: f.path || f.name || '',
|
||
file_size: f.size || 0,
|
||
file_type: 'input'
|
||
}))}
|
||
onDownload={null} // Context files can't be downloaded individually
|
||
isImageFile={isImageFile}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{outputFiles.length > 0 && (
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-100 mb-3">
|
||
Output Files
|
||
</h3>
|
||
<FileExplorer
|
||
files={outputFiles}
|
||
onDownload={handleDownload}
|
||
onPreview={(file) => {
|
||
// 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>
|
||
)}
|
||
|
||
<ErrorMessage error={jobDetails.error_message} />
|
||
|
||
<div>
|
||
<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-2">
|
||
{tasks.length > 0 ? (
|
||
tasks.map((task) => {
|
||
const isExpanded = expandedTasks.has(task.id);
|
||
const taskInfo = taskData[task.id] || { steps: [], logs: [] };
|
||
const { steps, logs } = taskInfo;
|
||
|
||
// 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">
|
||
{/* Task Header */}
|
||
<div
|
||
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-100">
|
||
{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 !== 'encode' && (
|
||
<span className="text-xs text-gray-400">({task.task_type})</span>
|
||
)}
|
||
</div>
|
||
<div className="text-sm text-gray-400">
|
||
{task.runner_id && `Runner ${task.runner_id}`}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Task Content (Continuous Log Stream) */}
|
||
{isExpanded && (
|
||
<div className="p-4 space-y-3">
|
||
{/* Header with auto-scroll */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-1 text-sm text-gray-400">
|
||
</div>
|
||
<button
|
||
onClick={() => toggleAutoScroll(task.id, 'logs')}
|
||
className={`px-2 py-1 text-xs rounded ${
|
||
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[`${task.id}-logs`] !== false ? 'Auto-scroll: ON' : 'Auto-scroll: OFF'}
|
||
>
|
||
{shouldAutoScrollRefs.current[`${task.id}-logs`] !== false ? '📜 Follow' : '⏸ Paused'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Logs */}
|
||
<div
|
||
ref={el => {
|
||
if (el) {
|
||
logContainerRefs.current[`${task.id}-logs`] = el;
|
||
// Initialize auto-scroll to true (follow logs) when ref is first set
|
||
if (shouldAutoScrollRefs.current[`${task.id}-logs`] === undefined) {
|
||
shouldAutoScrollRefs.current[`${task.id}-logs`] = true;
|
||
}
|
||
}
|
||
}}
|
||
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"
|
||
>
|
||
{sortedLogs.length === 0 ? (
|
||
<p className="text-gray-500">No logs yet...</p>
|
||
) : (
|
||
sortedLogs.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>
|
||
);
|
||
})
|
||
) : (
|
||
<p className="text-gray-400 text-sm">No tasks yet...</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|