something
This commit is contained in:
45
web/embed.go
Normal file
45
web/embed.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
var distFS embed.FS
|
||||
|
||||
// GetFileSystem returns an http.FileSystem for the embedded web UI files
|
||||
func GetFileSystem() http.FileSystem {
|
||||
subFS, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return http.FS(subFS)
|
||||
}
|
||||
|
||||
// SPAHandler returns an http.Handler that serves the embedded SPA
|
||||
// It serves static files if they exist, otherwise falls back to index.html
|
||||
func SPAHandler() http.Handler {
|
||||
fsys := GetFileSystem()
|
||||
fileServer := http.FileServer(fsys)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
// Try to open the file
|
||||
f, err := fsys.Open(strings.TrimPrefix(path, "/"))
|
||||
if err != nil {
|
||||
// File doesn't exist, serve index.html for SPA routing
|
||||
r.URL.Path = "/"
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// File exists, serve it
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
41
web/src/components/ErrorBoundary.jsx
Normal file
41
web/src/components/ErrorBoundary.jsx
Normal 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;
|
||||
|
||||
26
web/src/components/ErrorMessage.jsx
Normal file
26
web/src/components/ErrorMessage.jsx
Normal 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
@@ -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
19
web/src/components/LoadingSpinner.jsx
Normal file
19
web/src/components/LoadingSpinner.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
177
web/src/utils/websocket.js
Normal 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();
|
||||
|
||||
Reference in New Issue
Block a user