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'); const [apiKeys, setApiKeys] = useState([]); const [runners, setRunners] = useState([]); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); const [newAPIKeyName, setNewAPIKeyName] = useState(''); const [newAPIKeyDescription, setNewAPIKeyDescription] = useState(''); const [newAPIKeyScope, setNewAPIKeyScope] = useState('user'); // Default to user scope const [newAPIKey, setNewAPIKey] = useState(null); 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 - update both local refs and wsManager if (data.type === 'subscribed' && data.channel) { pendingSubscriptionsRef.current.delete(data.channel); subscribedChannelsRef.current.add(data.channel); wsManager.confirmSubscription(data.channel); console.log('Successfully subscribed to channel:', data.channel); } else if (data.type === 'subscription_error' && data.channel) { pendingSubscriptionsRef.current.delete(data.channel); subscribedChannelsRef.current.delete(data.channel); wsManager.failSubscription(data.channel); console.error('Subscription failed for channel:', data.channel, data.error); } // 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'; // Don't subscribe if already subscribed or pending if (subscribedChannelsRef.current.has(channel) || pendingSubscriptionsRef.current.has(channel)) { return; } wsManager.subscribeToChannel(channel); subscribedChannelsRef.current.add(channel); pendingSubscriptionsRef.current.add(channel); console.log('Subscribing to runners channel'); }; const unsubscribeFromRunners = () => { const channel = 'runners'; if (!subscribedChannelsRef.current.has(channel)) { return; // Not subscribed } wsManager.unsubscribeFromChannel(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]); const loadAPIKeys = async () => { setLoading(true); try { const data = await admin.listAPIKeys(); setApiKeys(normalizeArrayResponse(data)); } catch (error) { console.error('Failed to load API keys:', error); setApiKeys([]); alert('Failed to load API keys'); } finally { setLoading(false); } }; const loadRunners = async () => { setLoading(true); try { const data = await admin.listRunners(); setRunners(normalizeArrayResponse(data)); } catch (error) { console.error('Failed to load runners:', error); setRunners([]); alert('Failed to load runners'); } finally { setLoading(false); } }; const loadUsers = async () => { setLoading(true); try { const data = await admin.listUsers(); setUsers(normalizeArrayResponse(data)); } catch (error) { console.error('Failed to load users:', error); setUsers([]); alert('Failed to load users'); } finally { setLoading(false); } }; const loadSettings = async () => { setLoading(true); try { const data = await admin.getRegistrationEnabled(); setRegistrationEnabled(data.enabled); } catch (error) { console.error('Failed to load settings:', error); alert('Failed to load settings'); } finally { setLoading(false); } }; const handleToggleRegistration = async () => { const newValue = !registrationEnabled; setLoading(true); try { await admin.setRegistrationEnabled(newValue); setRegistrationEnabled(newValue); alert(`Registration ${newValue ? 'enabled' : 'disabled'}`); } catch (error) { console.error('Failed to update registration setting:', error); alert('Failed to update registration setting'); } finally { setLoading(false); } }; const generateAPIKey = async () => { if (!newAPIKeyName.trim()) { alert('API key name is required'); return; } setLoading(true); try { const data = await admin.generateAPIKey(newAPIKeyName.trim(), newAPIKeyDescription.trim() || undefined, newAPIKeyScope); setNewAPIKey(data); setNewAPIKeyName(''); setNewAPIKeyDescription(''); setNewAPIKeyScope('user'); await loadAPIKeys(); } catch (error) { console.error('Failed to generate API key:', error); alert('Failed to generate API key'); } finally { setLoading(false); } }; const [deletingKeyId, setDeletingKeyId] = useState(null); const [deletingRunnerId, setDeletingRunnerId] = useState(null); 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); } }; const deleteRunner = async (runnerId) => { 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); } }; const copyToClipboard = (text) => { navigator.clipboard.writeText(text); alert('Copied to clipboard!'); }; const isAPIKeyActive = (isActive) => { return isActive; }; return (
{activeSection === 'api-keys' && (

Generate API Key

setNewAPIKeyName(e.target.value)} placeholder="e.g., production-runner-01" className="w-full px-3 py-2 bg-gray-900 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent" required />
setNewAPIKeyDescription(e.target.value)} placeholder="Optional description" className="w-full px-3 py-2 bg-gray-900 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent" />
{newAPIKey && (

New API Key Generated:

{newAPIKey.key}

Name: {newAPIKey.name}

{newAPIKey.description &&

Description: {newAPIKey.description}

}

⚠️ Save this API key securely. It will not be shown again.

)}

API Keys

{loading ? ( ) : !apiKeys || apiKeys.length === 0 ? (

No API keys generated yet.

) : (
{apiKeys.map((key) => { return ( ); })}
Name Scope Key Prefix Status Created At Actions
{key.name}
{key.description && (
{key.description}
)}
{key.scope === 'manager' ? 'Manager' : 'User'} {key.key_prefix} {!key.is_active ? ( Revoked ) : ( Active )} {new Date(key.created_at).toLocaleString()}
)}
)} {activeSection === 'runners' && (

Runner Management

{loading ? ( ) : !runners || runners.length === 0 ? (

No runners registered.

) : (
{runners.map((runner) => { const isOnline = new Date(runner.last_heartbeat) > new Date(Date.now() - 60000); return ( ); })}
Name Hostname Status API Key Priority Capabilities Last Heartbeat Actions
{runner.name} {runner.hostname} {isOnline ? 'Online' : 'Offline'} jk_r{runner.id % 10}_... {runner.priority} {runner.capabilities ? ( (() => { try { const caps = JSON.parse(runner.capabilities); const enabled = Object.entries(caps) .filter(([_, v]) => v) .map(([k, _]) => k) .join(', '); return enabled || 'None'; } catch { return runner.capabilities; } })() ) : ( 'None' )} {new Date(runner.last_heartbeat).toLocaleString()}
)}
)} {activeSection === 'change-password' && passwordChangeUser && (
{ setPasswordChangeUser(null); setActiveSection('users'); }} />
)} {activeSection === 'users' && (
{selectedUser ? ( setSelectedUser(null)} /> ) : (

User Management

{loading ? ( ) : !users || users.length === 0 ? (

No users found.

) : (
{users.map((user) => ( ))}
Email Name Provider Admin Jobs Created Actions
{user.email} {user.name} {user.oauth_provider}
{user.is_admin ? ( Admin ) : ( User )}
{user.job_count || 0} {new Date(user.created_at).toLocaleString()}
{user.oauth_provider === 'local' && ( )}
)}
)}
)} {activeSection === 'settings' && (

System Settings

User Registration

{registrationEnabled ? 'New users can register via OAuth or local login' : 'Registration is disabled. Only existing users can log in.'}

{registrationEnabled ? 'Enabled' : 'Disabled'}
)}
); }