massive changes and it works

This commit is contained in:
2025-11-23 10:58:24 -06:00
parent 30aa969433
commit 2a0ff98834
3499 changed files with 7770 additions and 634687 deletions

View File

@@ -1,19 +1,29 @@
import { useState, useEffect } from 'react';
import { admin } from '../utils/api';
import UserJobs from './UserJobs';
import PasswordChange from './PasswordChange';
export default function AdminPanel() {
const [activeSection, setActiveSection] = useState('tokens');
const [tokens, setTokens] = useState([]);
const [runners, setRunners] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [newTokenExpires, setNewTokenExpires] = useState(24);
const [newToken, setNewToken] = useState(null);
const [selectedUser, setSelectedUser] = useState(null);
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [passwordChangeUser, setPasswordChangeUser] = useState(null);
useEffect(() => {
if (activeSection === 'tokens') {
loadTokens();
} else if (activeSection === 'runners') {
loadRunners();
} else if (activeSection === 'users') {
loadUsers();
} else if (activeSection === 'settings') {
loadSettings();
}
}, [activeSection]);
@@ -21,9 +31,10 @@ export default function AdminPanel() {
setLoading(true);
try {
const data = await admin.listTokens();
setTokens(data);
setTokens(Array.isArray(data) ? data : []);
} catch (error) {
console.error('Failed to load tokens:', error);
setTokens([]);
alert('Failed to load tokens');
} finally {
setLoading(false);
@@ -34,15 +45,58 @@ export default function AdminPanel() {
setLoading(true);
try {
const data = await admin.listRunners();
setRunners(data);
setRunners(Array.isArray(data) ? 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(Array.isArray(data) ? 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 generateToken = async () => {
setLoading(true);
try {
@@ -109,36 +163,68 @@ export default function AdminPanel() {
return (
<div className="space-y-6">
<div className="flex space-x-4 border-b border-gray-200">
<div className="flex space-x-4 border-b border-gray-700">
<button
onClick={() => setActiveSection('tokens')}
onClick={() => {
setActiveSection('tokens');
setSelectedUser(null);
}}
className={`py-2 px-4 border-b-2 font-medium ${
activeSection === 'tokens'
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
? 'border-orange-500 text-orange-500'
: 'border-transparent text-gray-400 hover:text-gray-300'
}`}
>
Registration Tokens
</button>
<button
onClick={() => setActiveSection('runners')}
onClick={() => {
setActiveSection('runners');
setSelectedUser(null);
}}
className={`py-2 px-4 border-b-2 font-medium ${
activeSection === 'runners'
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
? 'border-orange-500 text-orange-500'
: 'border-transparent text-gray-400 hover:text-gray-300'
}`}
>
Runner Management
</button>
<button
onClick={() => {
setActiveSection('users');
setSelectedUser(null);
}}
className={`py-2 px-4 border-b-2 font-medium ${
activeSection === 'users'
? 'border-orange-500 text-orange-500'
: 'border-transparent text-gray-400 hover:text-gray-300'
}`}
>
Users
</button>
<button
onClick={() => {
setActiveSection('settings');
setSelectedUser(null);
}}
className={`py-2 px-4 border-b-2 font-medium ${
activeSection === 'settings'
? 'border-orange-500 text-orange-500'
: 'border-transparent text-gray-400 hover:text-gray-300'
}`}
>
Settings
</button>
</div>
{activeSection === 'tokens' && (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-4">Generate Registration Token</h2>
<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">Generate Registration Token</h2>
<div className="flex gap-4 items-end">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-300 mb-2">
Expires in (hours)
</label>
<input
@@ -147,106 +233,106 @@ export default function AdminPanel() {
max="168"
value={newTokenExpires}
onChange={(e) => setNewTokenExpires(parseInt(e.target.value) || 24)}
className="w-32 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent"
className="w-32 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"
/>
</div>
<button
onClick={generateToken}
disabled={loading}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="px-6 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Generate Token
</button>
</div>
{newToken && (
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-sm font-medium text-green-800 mb-2">New Token Generated:</p>
<div className="mt-4 p-4 bg-green-400/20 border border-green-400/50 rounded-lg">
<p className="text-sm font-medium text-green-400 mb-2">New Token Generated:</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-white border border-green-300 rounded text-sm font-mono break-all">
<code className="flex-1 px-3 py-2 bg-gray-900 border border-green-400/50 rounded text-sm font-mono break-all text-gray-100">
{newToken}
</code>
<button
onClick={() => copyToClipboard(newToken)}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors text-sm"
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-500 transition-colors text-sm"
>
Copy
</button>
</div>
<p className="text-xs text-green-700 mt-2">
<p className="text-xs text-green-400/80 mt-2">
Save this token securely. It will not be shown again.
</p>
</div>
)}
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-4">Active Tokens</h2>
<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">Active Tokens</h2>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"></div>
</div>
) : tokens.length === 0 ? (
<p className="text-gray-500 text-center py-8">No tokens generated yet.</p>
) : !tokens || tokens.length === 0 ? (
<p className="text-gray-400 text-center py-8">No tokens generated yet.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-gray-700">
<thead className="bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Token
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Expires At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Created At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-gray-800 divide-y divide-gray-700">
{tokens.map((token) => {
const expired = isTokenExpired(token.expires_at);
const used = isTokenUsed(token.used);
return (
<tr key={token.id}>
<td className="px-6 py-4 whitespace-nowrap">
<code className="text-sm font-mono text-gray-900">
<code className="text-sm font-mono text-gray-100">
{token.token.substring(0, 16)}...
</code>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{expired ? (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-800">
<span className="px-2 py-1 text-xs font-medium rounded-full bg-red-400/20 text-red-400">
Expired
</span>
) : used ? (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800">
<span className="px-2 py-1 text-xs font-medium rounded-full bg-yellow-400/20 text-yellow-400">
Used
</span>
) : (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-400/20 text-green-400">
Active
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{new Date(token.expires_at).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{new Date(token.created_at).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{!used && !expired && (
<button
onClick={() => revokeToken(token.id)}
className="text-red-600 hover:text-red-800 font-medium"
className="text-red-400 hover:text-red-300 font-medium"
>
Revoke
</button>
@@ -264,56 +350,68 @@ export default function AdminPanel() {
)}
{activeSection === 'runners' && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-4">Runner Management</h2>
<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-purple-600"></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"></div>
</div>
) : runners.length === 0 ? (
<p className="text-gray-500 text-center py-8">No runners registered.</p>
) : !runners || runners.length === 0 ? (
<p className="text-gray-400 text-center py-8">No runners registered.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-gray-700">
<thead className="bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Hostname
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
IP Address
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Verified
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Priority
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Capabilities
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Last Heartbeat
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-gray-800 divide-y divide-gray-700">
{runners.map((runner) => {
const isOnline = new Date(runner.last_heartbeat) > new Date(Date.now() - 60000);
return (
<tr key={runner.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-100">
{runner.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{runner.hostname}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{runner.ip_address}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
isOnline
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
? 'bg-green-400/20 text-green-400'
: 'bg-gray-500/20 text-gray-400'
}`}
>
{isOnline ? 'Online' : 'Offline'}
@@ -323,28 +421,49 @@ export default function AdminPanel() {
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
runner.verified
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
? 'bg-green-400/20 text-green-400'
: 'bg-yellow-400/20 text-yellow-400'
}`}
>
{runner.verified ? 'Verified' : 'Unverified'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{runner.priority}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{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'
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{new Date(runner.last_heartbeat).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm space-x-2">
{!runner.verified && (
<button
onClick={() => verifyRunner(runner.id)}
className="text-blue-600 hover:text-blue-800 font-medium"
className="text-orange-400 hover:text-orange-300 font-medium"
>
Verify
</button>
)}
<button
onClick={() => deleteRunner(runner.id)}
className="text-red-600 hover:text-red-800 font-medium"
className="text-red-400 hover:text-red-300 font-medium"
>
Delete
</button>
@@ -358,6 +477,214 @@ export default function AdminPanel() {
)}
</div>
)}
{activeSection === 'change-password' && passwordChangeUser && (
<div className="bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
<button
onClick={() => {
setPasswordChangeUser(null);
setActiveSection('users');
}}
className="text-gray-400 hover:text-gray-300 mb-4 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Users
</button>
<PasswordChange
targetUserId={passwordChangeUser.id}
targetUserName={passwordChangeUser.name || passwordChangeUser.email}
onSuccess={() => {
setPasswordChangeUser(null);
setActiveSection('users');
}}
/>
</div>
)}
{activeSection === 'users' && (
<div className="space-y-6">
{selectedUser ? (
<UserJobs
userId={selectedUser.id}
userName={selectedUser.name || selectedUser.email}
onBack={() => setSelectedUser(null)}
/>
) : (
<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>
) : !users || users.length === 0 ? (
<p className="text-gray-400 text-center py-8">No users found.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-700">
<thead className="bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Provider
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Admin
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Jobs
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-gray-800 divide-y divide-gray-700">
{users.map((user) => (
<tr key={user.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-100">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
{user.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{user.oauth_provider}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{user.is_admin ? (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-orange-400/20 text-orange-400">
Admin
</span>
) : (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-500/20 text-gray-400">
User
</span>
)}
<button
onClick={async () => {
if (user.is_first_user && user.is_admin) {
alert('Cannot remove admin status from the first user');
return;
}
if (!confirm(`Are you sure you want to ${user.is_admin ? 'remove admin privileges from' : 'grant admin privileges to'} ${user.name || user.email}?`)) {
return;
}
try {
await admin.setUserAdminStatus(user.id, !user.is_admin);
await loadUsers();
alert(`Admin status ${user.is_admin ? 'removed' : 'granted'} successfully`);
} catch (error) {
console.error('Failed to update admin status:', error);
const errorMsg = error.message || 'Failed to update admin status';
if (errorMsg.includes('first user')) {
alert('Cannot remove admin status from the first user');
} else {
alert(errorMsg);
}
}
}}
disabled={user.is_first_user && user.is_admin}
className={`text-xs px-2 py-1 rounded ${
user.is_first_user && user.is_admin
? 'text-gray-500 bg-gray-500/10 cursor-not-allowed'
: user.is_admin
? 'text-red-400 hover:text-red-300 bg-red-400/10 hover:bg-red-400/20'
: 'text-green-400 hover:text-green-300 bg-green-400/10 hover:bg-green-400/20'
} transition-colors`}
title={user.is_first_user && user.is_admin ? 'First user must remain admin' : user.is_admin ? 'Remove admin privileges' : 'Grant admin privileges'}
>
{user.is_admin ? 'Remove Admin' : 'Make Admin'}
</button>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{user.job_count || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{new Date(user.created_at).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex gap-3">
<button
onClick={() => setSelectedUser(user)}
className="text-orange-400 hover:text-orange-300 font-medium"
>
View Jobs
</button>
{user.oauth_provider === 'local' && (
<button
onClick={() => {
const userForPassword = { id: user.id, name: user.name || user.email };
setPasswordChangeUser(userForPassword);
setSelectedUser(null);
setActiveSection('change-password');
}}
className="text-blue-400 hover:text-blue-300 font-medium"
>
Change Password
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)}
{activeSection === 'settings' && (
<div className="space-y-6">
<PasswordChange />
<div className="bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
<h2 className="text-xl font-semibold mb-6 text-gray-100">System Settings</h2>
<div className="space-y-6">
<div className="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700">
<div>
<h3 className="text-lg font-medium text-gray-100 mb-1">User Registration</h3>
<p className="text-sm text-gray-400">
{registrationEnabled
? 'New users can register via OAuth or local login'
: 'Registration is disabled. Only existing users can log in.'}
</p>
</div>
<div className="flex items-center gap-4">
<span className={`text-sm font-medium ${registrationEnabled ? 'text-green-400' : 'text-red-400'}`}>
{registrationEnabled ? 'Enabled' : 'Disabled'}
</span>
<button
onClick={handleToggleRegistration}
disabled={loading}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
registrationEnabled
? 'bg-red-600 hover:bg-red-500 text-white'
: 'bg-green-600 hover:bg-green-500 text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{registrationEnabled ? 'Disable' : 'Enable'}
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}