something
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user