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:
2026-01-02 13:55:19 -06:00
parent edc8ea160c
commit 94490237fe
44 changed files with 9463 additions and 7875 deletions

View File

@@ -33,14 +33,16 @@ export default function AdminPanel() {
}
},
message: (data) => {
// Handle subscription responses
// 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);
} 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);
}
@@ -83,27 +85,22 @@ export default function AdminPanel() {
const subscribeToRunners = () => {
const channel = 'runners';
if (wsManager.getReadyState() !== WebSocket.OPEN) {
return;
}
// Don't subscribe if already subscribed or pending
if (subscribedChannelsRef.current.has(channel) || pendingSubscriptionsRef.current.has(channel)) {
return;
}
wsManager.send({ type: 'subscribe', channel });
wsManager.subscribeToChannel(channel);
subscribedChannelsRef.current.add(channel);
pendingSubscriptionsRef.current.add(channel);
console.log('Subscribing to runners channel');
};
const unsubscribeFromRunners = () => {
const channel = 'runners';
if (wsManager.getReadyState() !== WebSocket.OPEN) {
return;
}
if (!subscribedChannelsRef.current.has(channel)) {
return; // Not subscribed
}
wsManager.send({ type: 'unsubscribe', channel });
wsManager.unsubscribeFromChannel(channel);
subscribedChannelsRef.current.delete(channel);
pendingSubscriptionsRef.current.delete(channel);
console.log('Unsubscribed from runners channel');

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
export default function FileExplorer({ files, onDownload, onPreview, isImageFile }) {
export default function FileExplorer({ files, onDownload, onPreview, onVideoPreview, isImageFile }) {
const [expandedPaths, setExpandedPaths] = useState(new Set()); // Root folder collapsed by default
// Build directory tree from file paths
@@ -69,19 +69,29 @@ export default function FileExplorer({ files, onDownload, onPreview, isImageFile
if (item.isFile) {
const file = item.file;
const isImage = isImageFile && isImageFile(file.file_name);
const isVideo = file.file_name.toLowerCase().endsWith('.mp4');
const sizeMB = (file.file_size / 1024 / 1024).toFixed(2);
const isArchive = file.file_name.endsWith('.tar') || file.file_name.endsWith('.zip');
return (
<div key={fullPath} className="flex items-center justify-between py-1.5 hover:bg-gray-800/50 rounded px-2" style={{ paddingLeft: `${indent + 8}px` }}>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-gray-500 text-sm">{isArchive ? '📦' : '📄'}</span>
<span className="text-gray-500 text-sm">{isArchive ? '📦' : isVideo ? '🎬' : '📄'}</span>
<span className="text-gray-200 text-sm truncate" title={item.name}>
{item.name}
</span>
<span className="text-gray-500 text-xs ml-2">{sizeMB} MB</span>
</div>
<div className="flex gap-2 ml-4 shrink-0">
{isVideo && onVideoPreview && (
<button
onClick={() => onVideoPreview(file)}
className="px-2 py-1 bg-purple-600 text-white rounded text-xs hover:bg-purple-500 transition-colors"
title="Play Video"
>
</button>
)}
{isImage && onPreview && (
<button
onClick={() => onPreview(file)}

View File

@@ -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>

View File

@@ -12,10 +12,12 @@ export default function JobSubmission({ onSuccess }) {
frame_start: 1,
frame_end: 10,
output_format: 'PNG',
allow_parallel_runners: true,
render_settings: null, // Will contain engine settings
unhide_objects: false, // Unhide objects/collections tweak
enable_execution: false, // Enable auto-execution in Blender
blender_version: '', // Blender version override (empty = auto-detect)
preserve_hdr: false, // Preserve HDR range for EXR encoding
preserve_alpha: false, // Preserve alpha channel for EXR encoding
});
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [file, setFile] = useState(null);
@@ -32,6 +34,8 @@ export default function JobSubmission({ onSuccess }) {
const [selectedMainBlend, setSelectedMainBlend] = useState('');
const [confirmedMissingFiles, setConfirmedMissingFiles] = useState(false); // Confirmation for missing files
const [uploadTimeRemaining, setUploadTimeRemaining] = useState(null); // Estimated time remaining in seconds
const [blenderVersions, setBlenderVersions] = useState([]); // Available Blender versions from server
const [loadingBlenderVersions, setLoadingBlenderVersions] = useState(false);
// Use refs to track cancellation state across re-renders
const isCancelledRef = useRef(false);
@@ -72,6 +76,25 @@ export default function JobSubmission({ onSuccess }) {
}
};
// Fetch available Blender versions on mount
useEffect(() => {
const fetchBlenderVersions = async () => {
setLoadingBlenderVersions(true);
try {
const response = await fetch('/api/blender/versions');
if (response.ok) {
const data = await response.json();
setBlenderVersions(data.versions || []);
}
} catch (err) {
console.error('Failed to fetch Blender versions:', err);
} finally {
setLoadingBlenderVersions(false);
}
};
fetchBlenderVersions();
}, []);
// Connect to shared WebSocket on mount
useEffect(() => {
listenerIdRef.current = wsManager.subscribe('jobsubmission', {
@@ -79,14 +102,16 @@ export default function JobSubmission({ onSuccess }) {
console.log('JobSubmission: Shared WebSocket connected');
},
message: (data) => {
// Handle subscription responses
// 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);
} 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 it's the upload channel we're trying to subscribe to, show error
if (data.channel.startsWith('upload:')) {
@@ -94,52 +119,7 @@ export default function JobSubmission({ onSuccess }) {
}
}
// Handle upload progress messages
if (data.channel && data.channel.startsWith('upload:') && subscribedChannelsRef.current.has(data.channel)) {
if (data.type === 'upload_progress' || data.type === 'processing_status') {
const progress = data.data?.progress || 0;
const status = data.data?.status || 'uploading';
const message = data.data?.message || '';
setUploadProgress(progress);
// Calculate time remaining for upload progress
if (status === 'uploading' && progress > 0 && progress < 100) {
if (!uploadStartTimeRef.current) {
uploadStartTimeRef.current = Date.now();
}
const elapsed = (Date.now() - uploadStartTimeRef.current) / 1000; // seconds
const remaining = (elapsed / progress) * (100 - progress);
setUploadTimeRemaining(remaining);
} else if (status === 'completed' || status === 'error') {
setUploadTimeRemaining(null);
uploadStartTimeRef.current = null;
}
if (status === 'uploading') {
setMetadataStatus('extracting');
} else if (status === 'processing' || status === 'extracting_zip' || status === 'extracting_metadata' || status === 'creating_context') {
setMetadataStatus('processing');
// Reset time remaining for processing phase
setUploadTimeRemaining(null);
} else if (status === 'completed') {
setMetadataStatus('completed');
setIsUploading(false);
setUploadTimeRemaining(null);
uploadStartTimeRef.current = null;
// Unsubscribe from upload channel
unsubscribeFromUploadChannel(data.channel);
} else if (status === 'error') {
setMetadataStatus('error');
setIsUploading(false);
setUploadTimeRemaining(null);
uploadStartTimeRef.current = null;
setError(message || 'Upload/processing failed');
// Unsubscribe from upload channel
unsubscribeFromUploadChannel(data.channel);
}
}
}
// Upload progress is now handled via HTTP response - no WebSocket messages needed
},
error: (error) => {
console.error('JobSubmission: Shared WebSocket error:', error);
@@ -166,13 +146,10 @@ export default function JobSubmission({ onSuccess }) {
// Helper function to unsubscribe from upload channel
const unsubscribeFromUploadChannel = (channel) => {
if (wsManager.getReadyState() !== WebSocket.OPEN) {
return;
}
if (!subscribedChannelsRef.current.has(channel)) {
return; // Not subscribed
}
wsManager.send({ type: 'unsubscribe', channel });
wsManager.unsubscribeFromChannel(channel);
subscribedChannelsRef.current.delete(channel);
pendingSubscriptionsRef.current.delete(channel);
console.log('Unsubscribed from upload channel:', channel);
@@ -180,11 +157,8 @@ export default function JobSubmission({ onSuccess }) {
// Helper function to unsubscribe from all channels
const unsubscribeFromAllChannels = () => {
if (wsManager.getReadyState() !== WebSocket.OPEN) {
return;
}
subscribedChannelsRef.current.forEach(channel => {
wsManager.send({ type: 'unsubscribe', channel });
wsManager.unsubscribeFromChannel(channel);
});
subscribedChannelsRef.current.clear();
pendingSubscriptionsRef.current.clear();
@@ -223,40 +197,40 @@ export default function JobSubmission({ onSuccess }) {
uploadStartTimeRef.current = Date.now();
setMetadataStatus('extracting');
// Upload file to new endpoint (no job required)
// Upload file and get metadata in HTTP response
const result = await jobs.uploadFileForJobCreation(selectedFile, (progress) => {
// XHR progress as fallback, but WebSocket is primary
// Show upload progress during upload
setUploadProgress(progress);
// Calculate time remaining for XHR progress
// Calculate time remaining for upload progress
if (progress > 0 && progress < 100 && uploadStartTimeRef.current) {
const elapsed = (Date.now() - uploadStartTimeRef.current) / 1000; // seconds
const remaining = (elapsed / progress) * (100 - progress);
setUploadTimeRemaining(remaining);
} else if (progress >= 100) {
// Upload complete - switch to processing status
setUploadProgress(100);
setMetadataStatus('processing');
setUploadTimeRemaining(null);
}
}, selectedMainBlend || undefined);
// Store session ID for later use when creating the job
if (result.session_id) {
setUploadSessionId(result.session_id);
// Subscribe to upload progress channel
if (wsManager.getReadyState() === WebSocket.OPEN) {
const channel = `upload:${result.session_id}`;
wsManager.send({ type: 'subscribe', channel });
// Don't set subscribedUploadChannelRef yet - wait for confirmation
console.log('Subscribing to upload channel:', channel);
}
}
// Check if ZIP extraction found multiple blend files
if (result.zip_extracted && result.blend_files && result.blend_files.length > 1) {
setBlendFiles(result.blend_files);
// Upload and processing complete - metadata is in the response
setIsUploading(false);
setUploadProgress(100);
setUploadTimeRemaining(null);
uploadStartTimeRef.current = null;
// Handle ZIP extraction results - multiple blend files found
if (result.status === 'select_blend' || (result.zip_extracted && result.blend_files && result.blend_files.length > 1)) {
setBlendFiles(result.blend_files || []);
setMetadataStatus('select_blend');
return;
}
// Upload and processing complete
setIsUploading(false);
// If metadata was extracted, use it
if (result.metadata_extracted && result.metadata) {
@@ -286,6 +260,7 @@ export default function JobSubmission({ onSuccess }) {
...result.metadata.render_settings,
engine_settings: result.metadata.render_settings.engine_settings || {},
} : null,
blender_version: result.metadata.blender_version || prev.blender_version,
}));
} else {
setMetadataStatus('error');
@@ -323,36 +298,30 @@ export default function JobSubmission({ onSuccess }) {
// Re-upload with selected main blend file
const result = await jobs.uploadFileForJobCreation(file, (progress) => {
// XHR progress as fallback, but WebSocket is primary
// Show upload progress during upload
setUploadProgress(progress);
// Calculate time remaining for XHR progress
// Calculate time remaining for upload progress
if (progress > 0 && progress < 100 && uploadStartTimeRef.current) {
const elapsed = (Date.now() - uploadStartTimeRef.current) / 1000; // seconds
const remaining = (elapsed / progress) * (100 - progress);
setUploadTimeRemaining(remaining);
} else if (progress >= 100) {
// Upload complete - switch to processing status
setUploadProgress(100);
setMetadataStatus('processing');
setUploadTimeRemaining(null);
}
}, selectedMainBlend);
setBlendFiles([]);
// Store session ID and subscribe to upload progress
// Store session ID
if (result.session_id) {
setUploadSessionId(result.session_id);
// Subscribe to upload progress channel
if (wsManager.getReadyState() === WebSocket.OPEN) {
const channel = `upload:${result.session_id}`;
// Don't subscribe if already subscribed or pending
if (!subscribedChannelsRef.current.has(channel) && !pendingSubscriptionsRef.current.has(channel)) {
wsManager.send({ type: 'subscribe', channel });
pendingSubscriptionsRef.current.add(channel);
console.log('Subscribing to upload channel:', channel);
}
}
}
// Upload and processing complete
setIsUploading(false);
// Upload and processing complete - metadata is in the response
setIsUploading(false);
// If metadata was extracted, use it
if (result.metadata_extracted && result.metadata) {
@@ -382,6 +351,7 @@ export default function JobSubmission({ onSuccess }) {
...result.metadata.render_settings,
engine_settings: result.metadata.render_settings.engine_settings || {},
} : null,
blender_version: result.metadata.blender_version || prev.blender_version,
}));
} else {
setMetadataStatus('error');
@@ -477,11 +447,13 @@ export default function JobSubmission({ onSuccess }) {
frame_start: parseInt(formData.frame_start),
frame_end: parseInt(formData.frame_end),
output_format: formData.output_format,
allow_parallel_runners: formData.allow_parallel_runners,
render_settings: renderSettings,
upload_session_id: uploadSessionId || undefined, // Pass session ID to move context archive
unhide_objects: formData.unhide_objects || undefined, // Pass unhide toggle
enable_execution: formData.enable_execution || undefined, // Pass enable execution toggle
preserve_hdr: formData.preserve_hdr || undefined, // Pass preserve HDR toggle
preserve_alpha: formData.preserve_alpha || undefined, // Pass preserve alpha toggle
blender_version: formData.blender_version || undefined, // Pass Blender version override
});
// Fetch the full job details
@@ -508,10 +480,12 @@ export default function JobSubmission({ onSuccess }) {
frame_start: 1,
frame_end: 10,
output_format: 'PNG',
allow_parallel_runners: true,
render_settings: null,
unhide_objects: false,
enable_execution: false,
blender_version: '',
preserve_hdr: false,
preserve_alpha: false,
});
setShowAdvancedSettings(false);
formatManuallyChangedRef.current = false;
@@ -534,6 +508,7 @@ export default function JobSubmission({ onSuccess }) {
render_settings: null,
unhide_objects: false,
enable_execution: false,
blender_version: '',
});
setShowAdvancedSettings(false);
setFile(null);
@@ -672,20 +647,9 @@ export default function JobSubmission({ onSuccess }) {
</div>
</div>
) : metadataStatus === 'processing' ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span>Processing file and extracting metadata...</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-orange-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
<div className="text-xs text-orange-400/80 mt-1">
This may take a moment for large files...
</div>
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-500"></div>
<span>Processing file and extracting metadata...</span>
</div>
) : (
<div className="flex items-center gap-2">
@@ -868,20 +832,35 @@ export default function JobSubmission({ onSuccess }) {
<option value="EXR">EXR</option>
<option value="EXR_264_MP4">EXR_264_MP4 (High Quality Video Without Alpha)</option>
<option value="EXR_AV1_MP4">EXR_AV1_MP4 (High Quality Video With Alpha)</option>
<option value="EXR_VP9_WEBM">EXR_VP9_WEBM (High Quality Video With Alpha & HDR)</option>
</select>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="allow_parallel_runners"
checked={formData.allow_parallel_runners}
onChange={(e) => setFormData({ ...formData, allow_parallel_runners: e.target.checked })}
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-900 rounded"
/>
<label htmlFor="allow_parallel_runners" className="ml-2 block text-sm text-gray-300">
Allow multiple runners to work on this job simultaneously
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Blender Version
{metadata?.blender_version && (
<span className="text-xs text-gray-400 ml-2">
(detected: {metadata.blender_version})
</span>
)}
</label>
<select
value={formData.blender_version}
onChange={(e) => setFormData({ ...formData, blender_version: e.target.value })}
className="w-full px-4 py-2 bg-gray-900 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent"
disabled={loadingBlenderVersions}
>
<option value="">Auto-detect from blend file</option>
{blenderVersions.map((v) => (
<option key={v.full} value={v.full}>
{v.full}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-400">
Override the Blender version used for rendering. Leave as auto-detect to use the version the file was saved with.
</p>
</div>
<div className="p-4 bg-blue-400/20 border border-blue-400/50 rounded-lg">
@@ -920,6 +899,53 @@ export default function JobSubmission({ onSuccess }) {
</div>
</div>
{(formData.output_format === 'EXR_264_MP4' || formData.output_format === 'EXR_AV1_MP4' || formData.output_format === 'EXR_VP9_WEBM') && (
<>
<div className="p-3 bg-blue-400/10 border border-blue-400/30 rounded-lg mb-2">
<p className="text-xs text-blue-400">
<strong>Note:</strong> The preserve options below allow you to explicitly control HDR and alpha preservation. If autodetection finds HDR content or alpha channels in your EXR files, they will be automatically preserved even if these options are unchecked. <strong>Important:</strong> Alpha detection only checks the first frame, so if your render uses transparency later in the sequence, you should explicitly enable the preserve alpha option. HDR detection is not perfect and may miss some HDR content, so if you're certain your render contains HDR content, you should explicitly enable the preserve HDR option.
</p>
</div>
<div className="p-4 bg-blue-400/20 border border-blue-400/50 rounded-lg">
<div className="flex items-center">
<input
type="checkbox"
id="preserve_hdr"
checked={formData.preserve_hdr}
onChange={(e) => setFormData({ ...formData, preserve_hdr: e.target.checked })}
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-900 rounded"
/>
<label htmlFor="preserve_hdr" className="ml-2 block text-sm text-gray-300">
<span className="font-medium">Preserve HDR range</span>
<span className="text-xs text-gray-400 block mt-1">
Explicitly enable HDR preservation with HLG transfer function. Works on both HDR and SDR displays. HDR content will be automatically detected and preserved if present, but detection may miss some content. If you're certain your render contains HDR, enable this option.
</span>
</label>
</div>
</div>
</>
)}
{(formData.output_format === 'EXR_AV1_MP4' || formData.output_format === 'EXR_VP9_WEBM') && (
<div className="p-4 bg-blue-400/20 border border-blue-400/50 rounded-lg">
<div className="flex items-center">
<input
type="checkbox"
id="preserve_alpha"
checked={formData.preserve_alpha}
onChange={(e) => setFormData({ ...formData, preserve_alpha: e.target.checked })}
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-900 rounded"
/>
<label htmlFor="preserve_alpha" className="ml-2 block text-sm text-gray-300">
<span className="font-medium">Preserve alpha channel</span>
<span className="text-xs text-gray-400 block mt-1">
Explicitly enable alpha channel encoding. Only available for AV1 and VP9 codecs. Alpha channels will be automatically detected and preserved if present in the first frame. Enable this if your render uses transparency later in the sequence.
</span>
</label>
</div>
</div>
)}
{metadata && metadataStatus === 'completed' && (
<>
<div className="p-4 bg-green-400/20 border border-green-400/50 rounded-lg text-sm mb-4">

View File

@@ -376,6 +376,10 @@ export const jobs = {
return `${API_BASE}/jobs/${jobId}/files/${fileId}/download`;
},
previewEXR(jobId, fileId) {
return `${API_BASE}/jobs/${jobId}/files/${fileId}/preview-exr`;
},
getVideoUrl(jobId) {
return `${API_BASE}/jobs/${jobId}/video`;
},

View File

@@ -10,6 +10,11 @@ class WebSocketManager {
this.isConnecting = false;
this.listenerIdCounter = 0;
this.verboseLogging = false; // Set to true to enable verbose WebSocket logging
// Track server-side channel subscriptions for re-subscription on reconnect
this.serverSubscriptions = new Set(); // Channels we want to be subscribed to
this.confirmedSubscriptions = new Set(); // Channels confirmed by server
this.pendingSubscriptions = new Set(); // Channels waiting for confirmation
}
connect() {
@@ -37,6 +42,10 @@ class WebSocketManager {
console.log('Shared WebSocket connected');
}
this.isConnecting = false;
// Re-subscribe to all channels that were previously subscribed
this.resubscribeToChannels();
this.notifyListeners('open', {});
};
@@ -68,17 +77,24 @@ class WebSocketManager {
}
this.ws = null;
this.isConnecting = false;
// Clear confirmed/pending but keep serverSubscriptions for re-subscription
this.confirmedSubscriptions.clear();
this.pendingSubscriptions.clear();
this.notifyListeners('close', event);
// Always retry connection
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
this.reconnectTimeout = setTimeout(() => {
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
this.connect();
// Always retry connection if we have listeners
if (this.listeners.size > 0) {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
}, this.reconnectDelay);
this.reconnectTimeout = setTimeout(() => {
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
this.connect();
}
}, this.reconnectDelay);
}
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
@@ -159,6 +175,81 @@ class WebSocketManager {
return this.ws ? this.ws.readyState : WebSocket.CLOSED;
}
// Subscribe to a server-side channel (will be re-subscribed on reconnect)
subscribeToChannel(channel) {
if (this.serverSubscriptions.has(channel)) {
// Already subscribed or pending
return;
}
this.serverSubscriptions.add(channel);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
if (!this.confirmedSubscriptions.has(channel) && !this.pendingSubscriptions.has(channel)) {
this.pendingSubscriptions.add(channel);
this.send({ type: 'subscribe', channel });
if (this.verboseLogging) {
console.log('WebSocketManager: Subscribing to channel:', channel);
}
}
}
}
// Unsubscribe from a server-side channel (won't be re-subscribed on reconnect)
unsubscribeFromChannel(channel) {
this.serverSubscriptions.delete(channel);
this.confirmedSubscriptions.delete(channel);
this.pendingSubscriptions.delete(channel);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.send({ type: 'unsubscribe', channel });
if (this.verboseLogging) {
console.log('WebSocketManager: Unsubscribing from channel:', channel);
}
}
}
// Mark a channel subscription as confirmed (call this when server confirms)
confirmSubscription(channel) {
this.pendingSubscriptions.delete(channel);
this.confirmedSubscriptions.add(channel);
if (this.verboseLogging) {
console.log('WebSocketManager: Subscription confirmed for channel:', channel);
}
}
// Mark a channel subscription as failed (call this when server rejects)
failSubscription(channel) {
this.pendingSubscriptions.delete(channel);
this.serverSubscriptions.delete(channel);
if (this.verboseLogging) {
console.log('WebSocketManager: Subscription failed for channel:', channel);
}
}
// Check if subscribed to a channel
isSubscribedToChannel(channel) {
return this.confirmedSubscriptions.has(channel);
}
// Re-subscribe to all channels after reconnect
resubscribeToChannels() {
if (this.serverSubscriptions.size === 0) {
return;
}
if (this.verboseLogging) {
console.log('WebSocketManager: Re-subscribing to channels:', Array.from(this.serverSubscriptions));
}
for (const channel of this.serverSubscriptions) {
if (!this.pendingSubscriptions.has(channel)) {
this.pendingSubscriptions.add(channel);
this.send({ type: 'subscribe', channel });
}
}
}
disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
@@ -169,6 +260,9 @@ class WebSocketManager {
this.ws = null;
}
this.listeners.clear();
this.serverSubscriptions.clear();
this.confirmedSubscriptions.clear();
this.pendingSubscriptions.clear();
}
}