something

This commit is contained in:
2025-11-27 00:46:48 -06:00
parent 11e7552b5b
commit edc8ea160c
43 changed files with 9990 additions and 3059 deletions

View File

@@ -5,6 +5,8 @@ import Layout from './components/Layout';
import JobList from './components/JobList';
import JobSubmission from './components/JobSubmission';
import AdminPanel from './components/AdminPanel';
import ErrorBoundary from './components/ErrorBoundary';
import LoadingSpinner from './components/LoadingSpinner';
import './styles/index.css';
function App() {
@@ -17,7 +19,7 @@ function App() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500"></div>
<LoadingSpinner size="md" />
</div>
);
}
@@ -26,26 +28,20 @@ function App() {
return loginComponent;
}
// Wrapper to check auth before changing tabs
const handleTabChange = async (newTab) => {
// Check auth before allowing navigation
try {
await refresh();
// If refresh succeeds, user is still authenticated
setActiveTab(newTab);
} catch (error) {
// Auth check failed, user will be set to null and login will show
console.error('Auth check failed on navigation:', error);
}
// Wrapper to change tabs - only check auth on mount, not on every navigation
const handleTabChange = (newTab) => {
setActiveTab(newTab);
};
return (
<Layout activeTab={activeTab} onTabChange={handleTabChange}>
{activeTab === 'jobs' && <JobList />}
{activeTab === 'submit' && (
<JobSubmission onSuccess={() => handleTabChange('jobs')} />
)}
{activeTab === 'admin' && <AdminPanel />}
<ErrorBoundary>
{activeTab === 'jobs' && <JobList />}
{activeTab === 'submit' && (
<JobSubmission onSuccess={() => handleTabChange('jobs')} />
)}
{activeTab === 'admin' && <AdminPanel />}
</ErrorBoundary>
</Layout>
);
}

View File

@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react';
import { admin } from '../utils/api';
import { useState, useEffect, useRef } from 'react';
import { admin, jobs, normalizeArrayResponse } from '../utils/api';
import { wsManager } from '../utils/websocket';
import UserJobs from './UserJobs';
import PasswordChange from './PasswordChange';
import LoadingSpinner from './LoadingSpinner';
export default function AdminPanel() {
const [activeSection, setActiveSection] = useState('api-keys');
@@ -16,16 +18,110 @@ export default function AdminPanel() {
const [selectedUser, setSelectedUser] = useState(null);
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [passwordChangeUser, setPasswordChangeUser] = useState(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)
// Connect to shared WebSocket on mount
useEffect(() => {
listenerIdRef.current = wsManager.subscribe('adminpanel', {
open: () => {
console.log('AdminPanel: Shared WebSocket connected');
// Subscribe to runners if already viewing runners section
if (activeSection === 'runners') {
subscribeToRunners();
}
},
message: (data) => {
// Handle subscription responses
if (data.type === 'subscribed' && data.channel) {
pendingSubscriptionsRef.current.delete(data.channel);
subscribedChannelsRef.current.add(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);
console.error('Subscription failed for channel:', data.channel, data.error);
}
// Handle runners channel messages
if (data.channel === 'runners' && data.type === 'runner_status') {
// Update runner in list
setRunners(prev => {
const index = prev.findIndex(r => r.id === data.runner_id);
if (index >= 0 && data.data) {
const updated = [...prev];
updated[index] = { ...updated[index], ...data.data };
return updated;
}
return prev;
});
}
},
error: (error) => {
console.error('AdminPanel: Shared WebSocket error:', error);
},
close: (event) => {
console.log('AdminPanel: Shared WebSocket closed:', event);
subscribedChannelsRef.current.clear();
pendingSubscriptionsRef.current.clear();
}
});
// Ensure connection is established
wsManager.connect();
return () => {
// Unsubscribe from all channels before unmounting
unsubscribeFromRunners();
if (listenerIdRef.current) {
wsManager.unsubscribe(listenerIdRef.current);
listenerIdRef.current = null;
}
};
}, []);
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 });
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 });
subscribedChannelsRef.current.delete(channel);
pendingSubscriptionsRef.current.delete(channel);
console.log('Unsubscribed from runners channel');
};
useEffect(() => {
if (activeSection === 'api-keys') {
loadAPIKeys();
unsubscribeFromRunners();
} else if (activeSection === 'runners') {
loadRunners();
subscribeToRunners();
} else if (activeSection === 'users') {
loadUsers();
unsubscribeFromRunners();
} else if (activeSection === 'settings') {
loadSettings();
unsubscribeFromRunners();
}
}, [activeSection]);
@@ -33,7 +129,7 @@ export default function AdminPanel() {
setLoading(true);
try {
const data = await admin.listAPIKeys();
setApiKeys(Array.isArray(data) ? data : []);
setApiKeys(normalizeArrayResponse(data));
} catch (error) {
console.error('Failed to load API keys:', error);
setApiKeys([]);
@@ -47,7 +143,7 @@ export default function AdminPanel() {
setLoading(true);
try {
const data = await admin.listRunners();
setRunners(Array.isArray(data) ? data : []);
setRunners(normalizeArrayResponse(data));
} catch (error) {
console.error('Failed to load runners:', error);
setRunners([]);
@@ -61,7 +157,7 @@ export default function AdminPanel() {
setLoading(true);
try {
const data = await admin.listUsers();
setUsers(Array.isArray(data) ? data : []);
setUsers(normalizeArrayResponse(data));
} catch (error) {
console.error('Failed to load users:', error);
setUsers([]);
@@ -121,29 +217,22 @@ export default function AdminPanel() {
}
};
const revokeAPIKey = async (keyId) => {
if (!confirm('Are you sure you want to revoke this API key? Revoked keys cannot be used for new runner registrations.')) {
return;
}
try {
await admin.revokeAPIKey(keyId);
await loadAPIKeys();
} catch (error) {
console.error('Failed to revoke API key:', error);
alert('Failed to revoke API key');
}
};
const [deletingKeyId, setDeletingKeyId] = useState(null);
const [deletingRunnerId, setDeletingRunnerId] = useState(null);
const deleteAPIKey = async (keyId) => {
if (!confirm('Are you sure you want to permanently delete this API key? This action cannot be undone.')) {
const revokeAPIKey = async (keyId) => {
if (!confirm('Are you sure you want to delete this API key? This action cannot be undone.')) {
return;
}
setDeletingKeyId(keyId);
try {
await admin.deleteAPIKey(keyId);
await loadAPIKeys();
} catch (error) {
console.error('Failed to delete API key:', error);
alert('Failed to delete API key');
} finally {
setDeletingKeyId(null);
}
};
@@ -152,12 +241,15 @@ export default function AdminPanel() {
if (!confirm('Are you sure you want to delete this runner?')) {
return;
}
setDeletingRunnerId(runnerId);
try {
await admin.deleteRunner(runnerId);
await loadRunners();
} catch (error) {
console.error('Failed to delete runner:', error);
alert('Failed to delete runner');
} finally {
setDeletingRunnerId(null);
}
};
@@ -313,9 +405,7 @@ export default function AdminPanel() {
<div className="bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
<h2 className="text-xl font-semibold mb-4 text-gray-100">API Keys</h2>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"></div>
</div>
<LoadingSpinner size="sm" className="py-8" />
) : !apiKeys || apiKeys.length === 0 ? (
<p className="text-gray-400 text-center py-8">No API keys generated yet.</p>
) : (
@@ -384,21 +474,13 @@ export default function AdminPanel() {
{new Date(key.created_at).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm space-x-2">
{key.is_active && !expired && (
<button
onClick={() => revokeAPIKey(key.id)}
className="text-yellow-400 hover:text-yellow-300 font-medium"
title="Revoke API key"
>
Revoke
</button>
)}
<button
onClick={() => deleteAPIKey(key.id)}
className="text-red-400 hover:text-red-300 font-medium"
title="Permanently delete API key"
onClick={() => revokeAPIKey(key.id)}
disabled={deletingKeyId === key.id}
className="text-red-400 hover:text-red-300 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
title="Delete API key"
>
Delete
{deletingKeyId === key.id ? 'Deleting...' : 'Delete'}
</button>
</td>
</tr>
@@ -416,9 +498,7 @@ export default function AdminPanel() {
<div className="bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
<h2 className="text-xl font-semibold mb-4 text-gray-100">Runner Management</h2>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"></div>
</div>
<LoadingSpinner size="sm" className="py-8" />
) : !runners || runners.length === 0 ? (
<p className="text-gray-400 text-center py-8">No runners registered.</p>
) : (
@@ -506,9 +586,10 @@ export default function AdminPanel() {
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button
onClick={() => deleteRunner(runner.id)}
className="text-red-400 hover:text-red-300 font-medium"
disabled={deletingRunnerId === runner.id}
className="text-red-400 hover:text-red-300 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Delete
{deletingRunnerId === runner.id ? 'Deleting...' : 'Delete'}
</button>
</td>
</tr>
@@ -558,9 +639,7 @@ export default function AdminPanel() {
<div className="bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
<h2 className="text-xl font-semibold mb-4 text-gray-100">User Management</h2>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"></div>
</div>
<LoadingSpinner size="sm" className="py-8" />
) : !users || users.length === 0 ? (
<p className="text-gray-400 text-center py-8">No users found.</p>
) : (

View File

@@ -0,0 +1,41 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="p-6 bg-red-400/20 border border-red-400/50 rounded-lg text-red-400">
<h2 className="text-xl font-semibold mb-2">Something went wrong</h2>
<p className="mb-4">{this.state.error?.message || 'An unexpected error occurred'}</p>
<button
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.reload();
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors"
>
Reload Page
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,26 @@
import React from 'react';
/**
* Shared ErrorMessage component for consistent error display
* Sanitizes error messages to prevent XSS
*/
export default function ErrorMessage({ error, className = '' }) {
if (!error) return null;
// Sanitize error message - escape HTML entities
const sanitize = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
const sanitizedError = typeof error === 'string' ? sanitize(error) : sanitize(error.message || 'An error occurred');
return (
<div className={`p-4 bg-red-400/20 border border-red-400/50 rounded-lg text-red-400 ${className}`}>
<p className="font-semibold">Error:</p>
<p dangerouslySetInnerHTML={{ __html: sanitizedError }} />
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { jobs } from '../utils/api';
import { jobs, normalizeArrayResponse } from '../utils/api';
import { wsManager } from '../utils/websocket';
import JobDetails from './JobDetails';
import LoadingSpinner from './LoadingSpinner';
export default function JobList() {
const [jobList, setJobList] = useState([]);
@@ -8,140 +10,75 @@ export default function JobList() {
const [selectedJob, setSelectedJob] = useState(null);
const [pagination, setPagination] = useState({ total: 0, limit: 50, offset: 0 });
const [hasMore, setHasMore] = useState(true);
const pollingIntervalRef = useRef(null);
const wsRef = useRef(null);
const listenerIdRef = useRef(null);
useEffect(() => {
loadJobs();
// Use WebSocket for real-time updates instead of polling
connectWebSocket();
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
if (wsRef.current) {
try {
wsRef.current.close();
} catch (e) {
// Ignore errors when closing
}
wsRef.current = null;
}
};
}, []);
const connectWebSocket = () => {
try {
// Close existing connection if any
if (wsRef.current) {
try {
wsRef.current.close();
} catch (e) {
// Ignore errors when closing
}
wsRef.current = null;
}
const ws = jobs.streamJobsWebSocket();
wsRef.current = ws;
ws.onopen = () => {
console.log('Job list WebSocket connected');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Use shared WebSocket manager for real-time updates
listenerIdRef.current = wsManager.subscribe('joblist', {
open: () => {
console.log('JobList: Shared WebSocket connected');
// Load initial job list via HTTP to get current state
loadJobs();
},
message: (data) => {
console.log('JobList: Client WebSocket message received:', data.type, data.channel, data);
// Handle jobs channel messages (always broadcasted)
if (data.channel === 'jobs') {
if (data.type === 'job_update' && data.data) {
console.log('JobList: Updating job:', data.job_id, data.data);
// Update job in list
setJobList(prev => {
const index = prev.findIndex(j => j.id === data.job_id);
const prevArray = Array.isArray(prev) ? prev : [];
const index = prevArray.findIndex(j => j.id === data.job_id);
if (index >= 0) {
const updated = [...prev];
const updated = [...prevArray];
updated[index] = { ...updated[index], ...data.data };
console.log('JobList: Updated job at index', index, updated[index]);
return updated;
}
// If job not in current page, reload to get updated list
if (data.data.status === 'completed' || data.data.status === 'failed') {
loadJobs();
}
return prev;
return prevArray;
});
} else if (data.type === 'job_created' && data.data) {
console.log('JobList: New job created:', data.job_id, data.data);
// New job created - add to list
setJobList(prev => {
const prevArray = Array.isArray(prev) ? prev : [];
// Check if job already exists (avoid duplicates)
if (prevArray.findIndex(j => j.id === data.job_id) >= 0) {
return prevArray;
}
// Add new job at the beginning
return [data.data, ...prevArray];
});
} else if (data.type === 'connected') {
// Connection established
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
} else if (data.type === 'connected') {
// Connection established
console.log('JobList: WebSocket connected');
}
};
ws.onerror = (error) => {
console.error('Job list WebSocket error:', {
error,
readyState: ws.readyState,
url: ws.url
});
// WebSocket errors don't provide much detail, but we can check readyState
if (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
console.warn('Job list WebSocket is closed or closing, will fallback to polling');
// Fallback to polling on error
startAdaptivePolling();
}
};
ws.onclose = (event) => {
console.log('Job list WebSocket closed:', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean
});
wsRef.current = null;
// Code 1006 = Abnormal Closure (connection lost without close frame)
// Code 1000 = Normal Closure
// Code 1001 = Going Away (server restart, etc.)
// We should reconnect for abnormal closures (1006) or unexpected closes
const shouldReconnect = !event.wasClean || event.code === 1006 || event.code === 1001;
if (shouldReconnect) {
console.log(`Attempting to reconnect job list WebSocket in 2 seconds... (code: ${event.code})`);
setTimeout(() => {
if (wsRef.current === null || (wsRef.current && wsRef.current.readyState === WebSocket.CLOSED)) {
connectWebSocket();
}
}, 2000);
} else {
// Clean close (code 1000) - fallback to polling
console.log('WebSocket closed cleanly, falling back to polling');
startAdaptivePolling();
}
};
} catch (error) {
console.error('Failed to connect WebSocket:', error);
// Fallback to polling
startAdaptivePolling();
}
};
const startAdaptivePolling = () => {
const checkAndPoll = () => {
const hasRunningJobs = jobList.some(job => job.status === 'running' || job.status === 'pending');
const interval = hasRunningJobs ? 5000 : 10000; // 5s for running, 10s for completed
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
},
error: (error) => {
console.error('JobList: Shared WebSocket error:', error);
},
close: (event) => {
console.log('JobList: Shared WebSocket closed:', event);
}
});
// Ensure connection is established
wsManager.connect();
return () => {
if (listenerIdRef.current) {
wsManager.unsubscribe(listenerIdRef.current);
listenerIdRef.current = null;
}
pollingIntervalRef.current = setInterval(() => {
loadJobs();
}, interval);
};
checkAndPoll();
// Re-check interval when job list changes
const checkInterval = setInterval(checkAndPoll, 5000);
return () => clearInterval(checkInterval);
};
}, []);
const loadJobs = async (append = false) => {
try {
@@ -153,20 +90,27 @@ export default function JobList() {
});
// Handle both old format (array) and new format (object with data, total, etc.)
const jobsData = result.data || result;
const total = result.total !== undefined ? result.total : jobsData.length;
const jobsArray = normalizeArrayResponse(result);
const total = result.total !== undefined ? result.total : jobsArray.length;
if (append) {
setJobList(prev => [...prev, ...jobsData]);
setJobList(prev => {
const prevArray = Array.isArray(prev) ? prev : [];
return [...prevArray, ...jobsArray];
});
setPagination(prev => ({ ...prev, offset, total }));
} else {
setJobList(jobsData);
setJobList(jobsArray);
setPagination({ total, limit: result.limit || pagination.limit, offset: result.offset || 0 });
}
setHasMore(offset + jobsData.length < total);
setHasMore(offset + jobsArray.length < total);
} catch (error) {
console.error('Failed to load jobs:', error);
// Ensure jobList is always an array even on error
if (!append) {
setJobList([]);
}
} finally {
setLoading(false);
}
@@ -206,12 +150,21 @@ export default function JobList() {
const handleDelete = async (jobId) => {
if (!confirm('Are you sure you want to permanently delete this job? This action cannot be undone.')) return;
try {
await jobs.delete(jobId);
loadJobs();
// Optimistically update the list
setJobList(prev => {
const prevArray = Array.isArray(prev) ? prev : [];
return prevArray.filter(j => j.id !== jobId);
});
if (selectedJob && selectedJob.id === jobId) {
setSelectedJob(null);
}
// Then actually delete
await jobs.delete(jobId);
// Reload to ensure consistency
loadJobs();
} catch (error) {
// On error, reload to restore correct state
loadJobs();
alert('Failed to delete job: ' + error.message);
}
};
@@ -228,11 +181,7 @@ export default function JobList() {
};
if (loading && jobList.length === 0) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500"></div>
</div>
);
return <LoadingSpinner size="md" className="h-64" />;
}
if (jobList.length === 0) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
import React from 'react';
/**
* Shared LoadingSpinner component with size variants
*/
export default function LoadingSpinner({ size = 'md', className = '', borderColor = 'border-orange-500' }) {
const sizeClasses = {
sm: 'h-8 w-8',
md: 'h-12 w-12',
lg: 'h-16 w-16',
};
return (
<div className={`flex justify-center items-center ${className}`}>
<div className={`animate-spin rounded-full border-b-2 ${borderColor} ${sizeClasses[size]}`}></div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { auth } from '../utils/api';
import ErrorMessage from './ErrorMessage';
export default function Login() {
const [providers, setProviders] = useState({
@@ -92,11 +93,7 @@ export default function Login() {
</div>
<div className="space-y-4">
{error && (
<div className="p-4 bg-red-400/20 border border-red-400/50 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<ErrorMessage error={error} className="text-sm" />
{providers.local && (
<div className="pb-4 border-b border-gray-700">
<div className="flex gap-2 mb-4">

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { auth } from '../utils/api';
import ErrorMessage from './ErrorMessage';
import { useAuth } from '../hooks/useAuth';
export default function PasswordChange({ targetUserId = null, targetUserName = null, onSuccess }) {
@@ -64,11 +65,7 @@ export default function PasswordChange({ targetUserId = null, targetUserName = n
{isChangingOtherUser ? `Change Password for ${targetUserName || 'User'}` : 'Change Password'}
</h2>
{error && (
<div className="mb-4 p-3 bg-red-400/20 border border-red-400/50 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<ErrorMessage error={error} className="mb-4 text-sm" />
{success && (
<div className="mb-4 p-3 bg-green-400/20 border border-green-400/50 rounded-lg text-green-400 text-sm">

View File

@@ -1,22 +1,71 @@
import { useState, useEffect } from 'react';
import { admin } from '../utils/api';
import { useState, useEffect, useRef } from 'react';
import { admin, normalizeArrayResponse } from '../utils/api';
import { wsManager } from '../utils/websocket';
import JobDetails from './JobDetails';
import LoadingSpinner from './LoadingSpinner';
export default function UserJobs({ userId, userName, onBack }) {
const [jobList, setJobList] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedJob, setSelectedJob] = useState(null);
const listenerIdRef = useRef(null);
useEffect(() => {
loadJobs();
const interval = setInterval(loadJobs, 5000);
return () => clearInterval(interval);
// Use shared WebSocket manager for real-time updates instead of polling
listenerIdRef.current = wsManager.subscribe(`userjobs_${userId}`, {
open: () => {
console.log('UserJobs: Shared WebSocket connected');
loadJobs();
},
message: (data) => {
// Handle jobs channel messages (always broadcasted)
if (data.channel === 'jobs') {
if (data.type === 'job_update' && data.data) {
// Update job in list if it belongs to this user
setJobList(prev => {
const prevArray = Array.isArray(prev) ? prev : [];
const index = prevArray.findIndex(j => j.id === data.job_id);
if (index >= 0) {
const updated = [...prevArray];
updated[index] = { ...updated[index], ...data.data };
return updated;
}
// If job not in current list, reload to get updated list
if (data.data.status === 'completed' || data.data.status === 'failed') {
loadJobs();
}
return prevArray;
});
} else if (data.type === 'job_created' && data.data) {
// New job created - reload to check if it belongs to this user
loadJobs();
}
}
},
error: (error) => {
console.error('UserJobs: Shared WebSocket error:', error);
},
close: (event) => {
console.log('UserJobs: Shared WebSocket closed:', event);
}
});
// Ensure connection is established
wsManager.connect();
return () => {
if (listenerIdRef.current) {
wsManager.unsubscribe(listenerIdRef.current);
listenerIdRef.current = null;
}
};
}, [userId]);
const loadJobs = async () => {
try {
const data = await admin.getUserJobs(userId);
setJobList(Array.isArray(data) ? data : []);
setJobList(normalizeArrayResponse(data));
} catch (error) {
console.error('Failed to load jobs:', error);
setJobList([]);
@@ -47,11 +96,7 @@ export default function UserJobs({ userId, userName, onBack }) {
}
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500"></div>
</div>
);
return <LoadingSpinner size="md" className="h-64" />;
}
return (

View File

@@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import ErrorMessage from './ErrorMessage';
import LoadingSpinner from './LoadingSpinner';
export default function VideoPlayer({ videoUrl, onClose }) {
const videoRef = useRef(null);
@@ -55,10 +57,10 @@ export default function VideoPlayer({ videoUrl, onClose }) {
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
<div className="mt-2 text-sm text-red-600">
<a href={videoUrl} download className="underline">Download video instead</a>
<div>
<ErrorMessage error={error} />
<div className="mt-2 text-sm text-gray-400">
<a href={videoUrl} download className="text-orange-400 hover:text-orange-300 underline">Download video instead</a>
</div>
</div>
);
@@ -68,7 +70,7 @@ export default function VideoPlayer({ videoUrl, onClose }) {
<div className="relative bg-black rounded-lg overflow-hidden">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 z-10">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
<LoadingSpinner size="lg" className="border-white" />
</div>
)}
<video

View File

@@ -21,6 +21,12 @@ function getCacheKey(endpoint, options = {}) {
return `${endpoint}${query ? '?' + query : ''}`;
}
// Utility function to normalize array responses (handles both old and new formats)
export function normalizeArrayResponse(response) {
const data = response?.data || response;
return Array.isArray(data) ? data : [];
}
// Sentinel value to indicate a request was superseded (instead of rejecting)
// Export it so components can check for it
export const REQUEST_SUPERSEDED = Symbol('REQUEST_SUPERSEDED');
@@ -36,6 +42,9 @@ function debounceRequest(key, requestFn, delay = DEBOUNCE_DELAY) {
if (pending.timestamp && (now - pending.timestamp) < DEDUPE_WINDOW) {
pending.promise.then(resolve).catch(reject);
return;
} else {
// Request is older than dedupe window - remove it and create new one
pendingRequests.delete(key);
}
}
@@ -74,8 +83,16 @@ export const setAuthErrorHandler = (handler) => {
onAuthError = handler;
};
const handleAuthError = (response) => {
// Whitelist of endpoints that should NOT trigger auth error handling
// These are endpoints that can legitimately return 401/403 without meaning the user is logged out
const AUTH_CHECK_ENDPOINTS = ['/auth/me', '/auth/logout'];
const handleAuthError = (response, endpoint) => {
if (response.status === 401 || response.status === 403) {
// Don't trigger auth error handler for endpoints that check auth status
if (AUTH_CHECK_ENDPOINTS.includes(endpoint)) {
return;
}
// Trigger auth error handler if set (this will clear user state)
if (onAuthError) {
onAuthError();
@@ -89,60 +106,79 @@ const handleAuthError = (response) => {
}
};
// Extract error message from response - centralized to avoid duplication
async function extractErrorMessage(response) {
try {
const errorData = await response.json();
return errorData?.error || response.statusText;
} catch {
return response.statusText;
}
}
export const api = {
async get(endpoint) {
async get(endpoint, options = {}) {
const abortController = options.signal || new AbortController();
const response = await fetch(`${API_BASE}${endpoint}`, {
credentials: 'include', // Include cookies for session
signal: abortController.signal,
});
if (!response.ok) {
// Handle auth errors before parsing response
// Don't redirect on /auth/me - that's the auth check itself
if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) {
handleAuthError(response);
// Don't redirect - let React handle UI change through state
}
const errorData = await response.json().catch(() => null);
const errorMessage = errorData?.error || response.statusText;
handleAuthError(response, endpoint);
const errorMessage = await extractErrorMessage(response);
throw new Error(errorMessage);
}
return response.json();
},
async post(endpoint, data) {
async post(endpoint, data, options = {}) {
const abortController = options.signal || new AbortController();
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include', // Include cookies for session
signal: abortController.signal,
});
if (!response.ok) {
// Handle auth errors before parsing response
// Don't redirect on /auth/* endpoints - those are login/logout
if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) {
handleAuthError(response);
// Don't redirect - let React handle UI change through state
}
const errorData = await response.json().catch(() => null);
const errorMessage = errorData?.error || response.statusText;
handleAuthError(response, endpoint);
const errorMessage = await extractErrorMessage(response);
throw new Error(errorMessage);
}
return response.json();
},
async delete(endpoint) {
async patch(endpoint, data, options = {}) {
const abortController = options.signal || new AbortController();
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'DELETE',
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: data ? JSON.stringify(data) : undefined,
credentials: 'include', // Include cookies for session
signal: abortController.signal,
});
if (!response.ok) {
// Handle auth errors before parsing response
// Don't redirect on /auth/* endpoints
if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) {
handleAuthError(response);
// Don't redirect - let React handle UI change through state
}
const errorData = await response.json().catch(() => null);
const errorMessage = errorData?.error || response.statusText;
handleAuthError(response, endpoint);
const errorMessage = await extractErrorMessage(response);
throw new Error(errorMessage);
}
return response.json();
},
async delete(endpoint, options = {}) {
const abortController = options.signal || new AbortController();
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'DELETE',
credentials: 'include', // Include cookies for session
signal: abortController.signal,
});
if (!response.ok) {
// Handle auth errors before parsing response
handleAuthError(response, endpoint);
const errorMessage = await extractErrorMessage(response);
throw new Error(errorMessage);
}
return response.json();
@@ -179,8 +215,7 @@ export const api = {
} else {
// Handle auth errors
if (xhr.status === 401 || xhr.status === 403) {
handleAuthError({ status: xhr.status });
// Don't redirect - let React handle UI change through state
handleAuthError({ status: xhr.status }, endpoint);
}
try {
const errorData = JSON.parse(xhr.responseText);
@@ -263,7 +298,7 @@ export const jobs = {
if (options.status) params.append('status', options.status);
if (options.sort) params.append('sort', options.sort);
const query = params.toString();
return api.get(`/jobs/summary${query ? '?' + query : ''}`);
return api.get(`/jobs/summary${query ? '?' + query : ''}`, options);
});
},
@@ -286,7 +321,7 @@ export const jobs = {
}
return response.json();
}
return api.get(`/jobs/${id}`);
return api.get(`/jobs/${id}`, options);
});
},
@@ -319,7 +354,7 @@ export const jobs = {
if (options.file_type) params.append('file_type', options.file_type);
if (options.extension) params.append('extension', options.extension);
const query = params.toString();
return api.get(`/jobs/${jobId}/files${query ? '?' + query : ''}`);
return api.get(`/jobs/${jobId}/files${query ? '?' + query : ''}`, options);
});
},
@@ -333,8 +368,8 @@ export const jobs = {
});
},
async getContextArchive(jobId) {
return api.get(`/jobs/${jobId}/context`);
async getContextArchive(jobId, options = {}) {
return api.get(`/jobs/${jobId}/context`, options);
},
downloadFile(jobId, fileId) {
@@ -354,7 +389,7 @@ export const jobs = {
if (options.limit) params.append('limit', options.limit.toString());
if (options.sinceId) params.append('since_id', options.sinceId.toString());
const query = params.toString();
const result = await api.get(`/jobs/${jobId}/tasks/${taskId}/logs${query ? '?' + query : ''}`);
const result = await api.get(`/jobs/${jobId}/tasks/${taskId}/logs${query ? '?' + query : ''}`, options);
// Handle both old format (array) and new format (object with logs, last_id, limit)
if (Array.isArray(result)) {
return { logs: result, last_id: result.length > 0 ? result[result.length - 1].id : 0, limit: options.limit || 100 };
@@ -363,10 +398,21 @@ export const jobs = {
});
},
async getTaskSteps(jobId, taskId) {
return api.get(`/jobs/${jobId}/tasks/${taskId}/steps`);
async getTaskSteps(jobId, taskId, options = {}) {
return api.get(`/jobs/${jobId}/tasks/${taskId}/steps`, options);
},
// New unified client WebSocket - DEPRECATED: Use wsManager from websocket.js instead
// This is kept for backwards compatibility but should not be used
streamClientWebSocket() {
console.warn('streamClientWebSocket() is deprecated - use wsManager from websocket.js instead');
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.host;
const url = `${wsProtocol}//${wsHost}${API_BASE}/ws`;
return new WebSocket(url);
},
// Old WebSocket methods (to be removed after migration)
streamTaskLogsWebSocket(jobId, taskId, lastId = 0) {
// Convert HTTP to WebSocket URL
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -378,7 +424,7 @@ export const jobs = {
streamJobsWebSocket() {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.host;
const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/ws`;
const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/ws-old`;
return new WebSocket(url);
},
@@ -408,7 +454,7 @@ export const jobs = {
if (options.frameEnd) params.append('frame_end', options.frameEnd.toString());
if (options.sort) params.append('sort', options.sort);
const query = params.toString();
return api.get(`/jobs/${jobId}/tasks${query ? '?' + query : ''}`);
return api.get(`/jobs/${jobId}/tasks${query ? '?' + query : ''}`, options);
});
},
@@ -421,7 +467,7 @@ export const jobs = {
if (options.status) params.append('status', options.status);
if (options.sort) params.append('sort', options.sort);
const query = params.toString();
return api.get(`/jobs/${jobId}/tasks/summary${query ? '?' + query : ''}`);
return api.get(`/jobs/${jobId}/tasks/summary${query ? '?' + query : ''}`, options);
});
},

177
web/src/utils/websocket.js Normal file
View File

@@ -0,0 +1,177 @@
// Shared WebSocket connection manager
// All components should use this instead of creating their own connections
class WebSocketManager {
constructor() {
this.ws = null;
this.listeners = new Map(); // Map of listener IDs to callback functions
this.reconnectTimeout = null;
this.reconnectDelay = 2000;
this.isConnecting = false;
this.listenerIdCounter = 0;
this.verboseLogging = false; // Set to true to enable verbose WebSocket logging
}
connect() {
// If already connected or connecting, don't create a new connection
if (this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN)) {
return;
}
if (this.isConnecting) {
return;
}
this.isConnecting = true;
try {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.host;
const API_BASE = '/api';
const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/ws`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
if (this.verboseLogging) {
console.log('Shared WebSocket connected');
}
this.isConnecting = false;
this.notifyListeners('open', {});
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (this.verboseLogging) {
console.log('WebSocketManager: Message received:', data.type, data.channel || 'no channel', data);
}
this.notifyListeners('message', data);
} catch (error) {
console.error('WebSocketManager: Failed to parse message:', error, 'Raw data:', event.data);
}
};
this.ws.onerror = (error) => {
console.error('Shared WebSocket error:', error);
this.isConnecting = false;
this.notifyListeners('error', error);
};
this.ws.onclose = (event) => {
if (this.verboseLogging) {
console.log('Shared WebSocket closed:', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean
});
}
this.ws = null;
this.isConnecting = false;
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();
}
}, this.reconnectDelay);
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
this.isConnecting = false;
// Retry after delay
this.reconnectTimeout = setTimeout(() => {
this.connect();
}, this.reconnectDelay);
}
}
subscribe(listenerId, callbacks) {
// Generate ID if not provided
if (!listenerId) {
listenerId = `listener_${this.listenerIdCounter++}`;
}
if (this.verboseLogging) {
console.log('WebSocketManager: Subscribing listener:', listenerId, 'WebSocket state:', this.ws ? this.ws.readyState : 'no connection');
}
this.listeners.set(listenerId, callbacks);
// Connect if not already connected
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
if (this.verboseLogging) {
console.log('WebSocketManager: WebSocket not connected, connecting...');
}
this.connect();
}
// If already open, notify immediately
if (this.ws && this.ws.readyState === WebSocket.OPEN && callbacks.open) {
if (this.verboseLogging) {
console.log('WebSocketManager: WebSocket already open, calling open callback for listener:', listenerId);
}
// Use setTimeout to ensure this happens after the listener is registered
setTimeout(() => {
if (callbacks.open) {
callbacks.open();
}
}, 0);
}
return listenerId;
}
unsubscribe(listenerId) {
this.listeners.delete(listenerId);
// If no more listeners, we could close the connection, but let's keep it open
// in case other components need it
}
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
if (this.verboseLogging) {
console.log('WebSocketManager: Sending message:', data);
}
this.ws.send(JSON.stringify(data));
} else {
console.warn('WebSocketManager: Cannot send message - connection not open. State:', this.ws ? this.ws.readyState : 'no connection', 'Message:', data);
}
}
notifyListeners(eventType, data) {
this.listeners.forEach((callbacks) => {
if (callbacks[eventType]) {
try {
callbacks[eventType](data);
} catch (error) {
console.error('Error in WebSocket listener:', error);
}
}
});
}
getReadyState() {
return this.ws ? this.ws.readyState : WebSocket.CLOSED;
}
disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.listeners.clear();
}
}
// Export singleton instance
export const wsManager = new WebSocketManager();