its a bit broken

This commit is contained in:
2025-11-25 03:48:28 -06:00
parent a53ea4dce7
commit 690e6b13f8
16 changed files with 1542 additions and 861 deletions

View File

@@ -4,20 +4,22 @@ import UserJobs from './UserJobs';
import PasswordChange from './PasswordChange';
export default function AdminPanel() {
const [activeSection, setActiveSection] = useState('tokens');
const [tokens, setTokens] = useState([]);
const [activeSection, setActiveSection] = useState('api-keys');
const [apiKeys, setApiKeys] = 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 [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);
useEffect(() => {
if (activeSection === 'tokens') {
loadTokens();
if (activeSection === 'api-keys') {
loadAPIKeys();
} else if (activeSection === 'runners') {
loadRunners();
} else if (activeSection === 'users') {
@@ -27,15 +29,15 @@ export default function AdminPanel() {
}
}, [activeSection]);
const loadTokens = async () => {
const loadAPIKeys = async () => {
setLoading(true);
try {
const data = await admin.listTokens();
setTokens(Array.isArray(data) ? data : []);
const data = await admin.listAPIKeys();
setApiKeys(Array.isArray(data) ? data : []);
} catch (error) {
console.error('Failed to load tokens:', error);
setTokens([]);
alert('Failed to load tokens');
console.error('Failed to load API keys:', error);
setApiKeys([]);
alert('Failed to load API keys');
} finally {
setLoading(false);
}
@@ -97,44 +99,55 @@ export default function AdminPanel() {
}
};
const generateToken = async () => {
const generateAPIKey = async () => {
if (!newAPIKeyName.trim()) {
alert('API key name is required');
return;
}
setLoading(true);
try {
const data = await admin.generateToken(newTokenExpires);
setNewToken(data.token);
await loadTokens();
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 token:', error);
alert('Failed to generate token');
console.error('Failed to generate API key:', error);
alert('Failed to generate API key');
} finally {
setLoading(false);
}
};
const revokeToken = async (tokenId) => {
if (!confirm('Are you sure you want to revoke this token?')) {
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.revokeToken(tokenId);
await loadTokens();
await admin.revokeAPIKey(keyId);
await loadAPIKeys();
} catch (error) {
console.error('Failed to revoke token:', error);
alert('Failed to revoke token');
console.error('Failed to revoke API key:', error);
alert('Failed to revoke API key');
}
};
const verifyRunner = async (runnerId) => {
const deleteAPIKey = async (keyId) => {
if (!confirm('Are you sure you want to permanently delete this API key? This action cannot be undone.')) {
return;
}
try {
await admin.verifyRunner(runnerId);
await loadRunners();
alert('Runner verified');
await admin.deleteAPIKey(keyId);
await loadAPIKeys();
} catch (error) {
console.error('Failed to verify runner:', error);
alert('Failed to verify runner');
console.error('Failed to delete API key:', error);
alert('Failed to delete API key');
}
};
const deleteRunner = async (runnerId) => {
if (!confirm('Are you sure you want to delete this runner?')) {
return;
@@ -153,12 +166,8 @@ export default function AdminPanel() {
alert('Copied to clipboard!');
};
const isTokenExpired = (expiresAt) => {
return new Date(expiresAt) < new Date();
};
const isTokenUsed = (used) => {
return used;
const isAPIKeyActive = (isActive) => {
return isActive;
};
return (
@@ -166,16 +175,16 @@ export default function AdminPanel() {
<div className="flex space-x-4 border-b border-gray-700">
<button
onClick={() => {
setActiveSection('tokens');
setActiveSection('api-keys');
setSelectedUser(null);
}}
className={`py-2 px-4 border-b-2 font-medium ${
activeSection === 'tokens'
activeSection === 'api-keys'
? 'border-orange-500 text-orange-500'
: 'border-transparent text-gray-400 hover:text-gray-300'
}`}
>
Registration Tokens
API Keys
</button>
<button
onClick={() => {
@@ -218,76 +227,114 @@ export default function AdminPanel() {
</button>
</div>
{activeSection === 'tokens' && (
{activeSection === 'api-keys' && (
<div className="space-y-6">
<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-300 mb-2">
Expires in (hours)
</label>
<input
type="number"
min="1"
max="168"
value={newTokenExpires}
onChange={(e) => setNewTokenExpires(parseInt(e.target.value) || 24)}
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"
/>
<h2 className="text-xl font-semibold mb-4 text-gray-100">Generate API Key</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Name *
</label>
<input
type="text"
value={newAPIKeyName}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<input
type="text"
value={newAPIKeyDescription}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Scope
</label>
<select
value={newAPIKeyScope}
onChange={(e) => setNewAPIKeyScope(e.target.value)}
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"
>
<option value="user">User - Only jobs from API key owner</option>
<option value="manager">Manager - All jobs from any user</option>
</select>
</div>
</div>
<div className="flex justify-end">
<button
onClick={generateAPIKey}
disabled={loading || !newAPIKeyName.trim()}
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 API Key
</button>
</div>
<button
onClick={generateToken}
disabled={loading}
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 && (
{newAPIKey && (
<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-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-500 transition-colors text-sm"
>
Copy
</button>
<p className="text-sm font-medium text-green-400 mb-2">New API Key Generated:</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<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">
{newAPIKey.key}
</code>
<button
onClick={() => copyToClipboard(newAPIKey.key)}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-500 transition-colors text-sm whitespace-nowrap"
>
Copy Key
</button>
</div>
<div className="text-xs text-green-400/80">
<p><strong>Name:</strong> {newAPIKey.name}</p>
{newAPIKey.description && <p><strong>Description:</strong> {newAPIKey.description}</p>}
</div>
<p className="text-xs text-green-400/80 mt-2">
Save this API key securely. It will not be shown again.
</p>
</div>
<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-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>
<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>
) : !tokens || tokens.length === 0 ? (
<p className="text-gray-400 text-center py-8">No tokens generated yet.</p>
) : !apiKeys || apiKeys.length === 0 ? (
<p className="text-gray-400 text-center py-8">No API keys generated yet.</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">
Token
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Scope
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Key Prefix
</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-400 uppercase tracking-wider">
Expires At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Created At
</th>
@@ -297,46 +344,62 @@ export default function AdminPanel() {
</tr>
</thead>
<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-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-400/20 text-red-400">
Expired
</span>
) : used ? (
<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-400/20 text-green-400">
Active
</span>
{apiKeys.map((key) => {
return (
<tr key={key.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-100">{key.name}</div>
{key.description && (
<div className="text-sm text-gray-400">{key.description}</div>
)}
</td>
<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-400">
{new Date(token.created_at).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{!used && !expired && (
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
key.scope === 'manager'
? 'bg-purple-400/20 text-purple-400'
: 'bg-blue-400/20 text-blue-400'
}`}>
{key.scope === 'manager' ? 'Manager' : 'User'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<code className="text-sm font-mono text-gray-300">
{key.key_prefix}
</code>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{!key.is_active ? (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-500/20 text-gray-400">
Revoked
</span>
) : (
<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-400">
{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={() => revokeToken(token.id)}
className="text-red-400 hover:text-red-300 font-medium"
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"
>
Delete
</button>
</td>
</tr>
);
@@ -373,7 +436,7 @@ export default function AdminPanel() {
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Verified
API Key
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Priority
@@ -411,16 +474,10 @@ export default function AdminPanel() {
{isOnline ? 'Online' : 'Offline'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
runner.verified
? 'bg-green-400/20 text-green-400'
: 'bg-yellow-400/20 text-yellow-400'
}`}
>
{runner.verified ? 'Verified' : 'Unverified'}
</span>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
<code className="text-xs font-mono bg-gray-900 px-2 py-1 rounded">
jk_r{runner.id % 10}_...
</code>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{runner.priority}
@@ -446,15 +503,7 @@ export default function AdminPanel() {
<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-orange-400 hover:text-orange-300 font-medium"
>
Verify
</button>
)}
<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"

View File

@@ -269,7 +269,7 @@ export default function JobSubmission({ onSuccess }) {
throw new Error('File upload session not found. Please upload the file again.');
}
if (formData.frame_start < 0 || formData.frame_end < formData.frame_start) {
if (parseInt(formData.frame_end) < parseInt(formData.frame_start)) {
throw new Error('Invalid frame range');
}
@@ -278,6 +278,7 @@ export default function JobSubmission({ onSuccess }) {
engine: formData.render_settings.engine || 'cycles',
resolution_x: formData.render_settings.resolution_x || 1920,
resolution_y: formData.render_settings.resolution_y || 1080,
frame_rate: formData.render_settings.frame_rate || (metadata?.render_settings?.frame_rate || 24.0),
engine_settings: formData.render_settings.engine_settings,
} : null;
@@ -475,6 +476,7 @@ export default function JobSubmission({ onSuccess }) {
<div className="text-green-400/80 text-xs space-y-1">
<div>Frames: {metadata.frame_start} - {metadata.frame_end}</div>
<div>Resolution: {metadata.render_settings?.resolution_x} x {metadata.render_settings?.resolution_y}</div>
<div>Frame Rate: {metadata.render_settings?.frame_rate || 24} fps</div>
<div>Engine: {metadata.render_settings?.engine}</div>
{metadata.render_settings?.engine_settings?.samples && (
<div>Cycles Samples: {metadata.render_settings.engine_settings.samples}</div>
@@ -573,7 +575,6 @@ export default function JobSubmission({ onSuccess }) {
value={formData.frame_start}
onChange={(e) => setFormData({ ...formData, frame_start: e.target.value })}
required
min="0"
className="w-full px-4 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>
@@ -668,6 +669,7 @@ export default function JobSubmission({ onSuccess }) {
<div className="text-green-400/80 text-xs space-y-1">
<div>Frames: {metadata.frame_start} - {metadata.frame_end}</div>
<div>Resolution: {metadata.render_settings?.resolution_x} x {metadata.render_settings?.resolution_y}</div>
<div>Frame Rate: {metadata.render_settings?.frame_rate || 24} fps</div>
<div>Engine: {metadata.render_settings?.engine}</div>
{metadata.render_settings?.engine_settings?.samples && (
<div>Samples: {metadata.render_settings.engine_settings.samples}</div>
@@ -786,6 +788,33 @@ export default function JobSubmission({ onSuccess }) {
</div>
</div>
{/* Frame Rate */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Frame Rate (FPS)
<span className="text-xs text-gray-400 ml-2"> Changing this will desync ffmpeg from Blender</span>
</label>
<input
type="number"
step="0.01"
value={formData.render_settings.frame_rate || (metadata?.render_settings?.frame_rate || 24.0)}
onChange={(e) => setFormData({
...formData,
render_settings: {
...formData.render_settings,
frame_rate: parseFloat(e.target.value) || 24.0,
}
})}
min="0.01"
max="120"
className="w-full px-4 py-2 bg-gray-900 border border-gray-600 rounded-lg text-gray-100 focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
<p className="text-xs text-yellow-400 mt-1">
<strong>Warning:</strong> Adjusting FPS will cause slow-motion/fast-motion effects and may make the video look laggy.
This only changes the playback speed - the number of rendered frames stays the same.
</p>
</div>
{/* Cycles Settings */}
{formData.render_settings.engine === 'cycles' && formData.render_settings.engine_settings && (
<div className="space-y-3 p-3 bg-gray-900/50 rounded-lg">

View File

@@ -449,16 +449,22 @@ export const runners = {
};
export const admin = {
async generateToken(expiresInHours) {
return api.post('/admin/runners/tokens', { expires_in_hours: expiresInHours });
async generateAPIKey(name, description, scope) {
const data = { name, scope };
if (description) data.description = description;
return api.post('/admin/runners/api-keys', data);
},
async listTokens() {
return api.get('/admin/runners/tokens');
async listAPIKeys() {
return api.get('/admin/runners/api-keys');
},
async revokeToken(tokenId) {
return api.delete(`/admin/runners/tokens/${tokenId}`);
async revokeAPIKey(keyId) {
return api.patch(`/admin/runners/api-keys/${keyId}/revoke`);
},
async deleteAPIKey(keyId) {
return api.delete(`/admin/runners/api-keys/${keyId}`);
},
async listRunners() {