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 (
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.
No API keys generated yet.
) : (| 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()} |
No runners registered.
) : (| 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()} |
No users found.
) : (| 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' && (
)}
|
{registrationEnabled ? 'New users can register via OAuth or local login' : 'Registration is disabled. Only existing users can log in.'}