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>
);
}

View File

@@ -8,11 +8,16 @@ export default function JobDetails({ job, onClose, onUpdate }) {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [videoUrl, setVideoUrl] = useState(null);
const [selectedTaskId, setSelectedTaskId] = useState(null);
const [taskLogs, setTaskLogs] = useState([]);
const [taskSteps, setTaskSteps] = useState([]);
// Store steps and logs per task: { taskId: { steps: [], logs: [] } }
const [taskData, setTaskData] = useState({});
// Track which tasks and steps are expanded
const [expandedTasks, setExpandedTasks] = useState(new Set());
const [expandedSteps, setExpandedSteps] = useState(new Set());
const [streaming, setStreaming] = useState(false);
const [previewImage, setPreviewImage] = useState(null); // { url, fileName } or null
const wsRef = useRef(null);
const logContainerRefs = useRef({}); // Refs for each step's log container
const shouldAutoScrollRefs = useRef({}); // Auto-scroll state per step
useEffect(() => {
loadDetails();
@@ -26,20 +31,53 @@ export default function JobDetails({ job, onClose, onUpdate }) {
}, [job.id]);
useEffect(() => {
if (selectedTaskId && jobDetails.status === 'running') {
startLogStream();
// Load logs and steps for all running tasks
if (jobDetails.status === 'running' && tasks.length > 0) {
const runningTasks = tasks.filter(t => t.status === 'running' || t.status === 'pending');
runningTasks.forEach(task => {
if (!taskData[task.id]) {
loadTaskData(task.id);
}
});
// Start streaming for the first running task (WebSocket supports one at a time)
if (runningTasks.length > 0 && !streaming) {
startLogStream(runningTasks.map(t => t.id));
}
} else if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
setStreaming(false);
}
return () => {
if (wsRef.current) {
if (wsRef.current && jobDetails.status !== 'running') {
wsRef.current.close();
wsRef.current = null;
}
};
}, [selectedTaskId, jobDetails.status]);
}, [tasks, jobDetails.status]);
// Auto-scroll logs to bottom when new logs arrive
useEffect(() => {
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
Object.keys(logContainerRefs.current).forEach(key => {
const ref = logContainerRefs.current[key];
if (!ref) return;
// Initialize auto-scroll to true if not set
if (shouldAutoScrollRefs.current[key] === undefined) {
shouldAutoScrollRefs.current[key] = true;
}
// Always auto-scroll unless user has manually scrolled up
// shouldAutoScrollRefs.current[key] is false only if user scrolled up manually
if (shouldAutoScrollRefs.current[key] !== false) {
// Scroll to bottom
ref.scrollTop = ref.scrollHeight;
}
});
});
}, [taskData]);
const loadDetails = async () => {
try {
@@ -52,6 +90,23 @@ export default function JobDetails({ job, onClose, onUpdate }) {
setFiles(fileList);
setTasks(taskList);
// Only load task data (logs/steps) for tasks that don't have data yet
// This prevents overwriting logs that are being streamed via WebSocket
// Once we have logs for a task, we rely on WebSocket for new logs
if (details.status === 'running') {
taskList.forEach(task => {
const existingData = taskData[task.id];
// Only fetch logs via HTTP if we don't have any logs yet
// Once we have logs, WebSocket will handle new ones
if (!existingData || !existingData.logs || existingData.logs.length === 0) {
loadTaskData(task.id);
} else if (!existingData.steps || existingData.steps.length === 0) {
// If we have logs but no steps, fetch steps only
loadTaskStepsOnly(task.id);
}
});
}
// Check if there's an MP4 output file
const mp4File = fileList.find(
(f) => f.file_type === 'output' && f.file_name.endsWith('.mp4')
@@ -70,31 +125,81 @@ export default function JobDetails({ job, onClose, onUpdate }) {
window.open(jobs.downloadFile(job.id, fileId), '_blank');
};
const loadTaskLogs = async (taskId) => {
const loadTaskData = async (taskId) => {
try {
const [logs, steps] = await Promise.all([
jobs.getTaskLogs(job.id, taskId),
jobs.getTaskSteps(job.id, taskId),
]);
setTaskLogs(logs);
setTaskSteps(steps);
setTaskData(prev => {
const current = prev[taskId] || { steps: [], logs: [] };
// Merge logs instead of replacing - this preserves WebSocket-streamed logs
// Deduplicate by log ID
const existingLogIds = new Set((current.logs || []).map(l => l.id));
const newLogs = (logs || []).filter(l => !existingLogIds.has(l.id));
const mergedLogs = [...(current.logs || []), ...newLogs].sort((a, b) => a.id - b.id);
return {
...prev,
[taskId]: {
steps: steps || current.steps, // Steps can be replaced (they don't change often)
logs: mergedLogs
}
};
});
} catch (error) {
console.error('Failed to load task logs:', error);
console.error('Failed to load task data:', error);
}
};
const startLogStream = () => {
if (!selectedTaskId || streaming) return;
const loadTaskStepsOnly = async (taskId) => {
try {
const steps = await jobs.getTaskSteps(job.id, taskId);
setTaskData(prev => {
const current = prev[taskId] || { steps: [], logs: [] };
return {
...prev,
[taskId]: {
steps: steps || current.steps,
logs: current.logs || [] // Preserve existing logs
}
};
});
} catch (error) {
console.error('Failed to load task steps:', error);
}
};
const startLogStream = (taskIds) => {
if (taskIds.length === 0 || streaming) return;
setStreaming(true);
const ws = jobs.streamTaskLogsWebSocket(job.id, selectedTaskId);
// For now, stream the first task's logs (WebSocket supports one task at a time)
// In the future, we could have multiple WebSocket connections
const primaryTaskId = taskIds[0];
const ws = jobs.streamTaskLogsWebSocket(job.id, primaryTaskId);
wsRef.current = ws;
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'log' && data.data) {
setTaskLogs((prev) => [...prev, data.data]);
const log = data.data;
setTaskData(prev => {
const taskId = log.task_id;
const current = prev[taskId] || { steps: [], logs: [] };
// Check if log already exists (avoid duplicates)
if (!current.logs.find(l => l.id === log.id)) {
return {
...prev,
[taskId]: {
...current,
logs: [...current.logs, log]
}
};
}
return prev;
});
} else if (data.type === 'connected') {
// Connection established
}
@@ -111,31 +216,86 @@ export default function JobDetails({ job, onClose, onUpdate }) {
ws.onclose = () => {
setStreaming(false);
// Auto-reconnect if job is still running
if (jobDetails.status === 'running' && selectedTaskId) {
if (jobDetails.status === 'running' && taskIds.length > 0) {
setTimeout(() => {
if (jobDetails.status === 'running') {
startLogStream();
startLogStream(taskIds);
}
}, 2000);
}
};
};
const handleTaskClick = async (taskId) => {
setSelectedTaskId(taskId);
await loadTaskLogs(taskId);
const toggleTask = async (taskId) => {
const newExpanded = new Set(expandedTasks);
if (newExpanded.has(taskId)) {
newExpanded.delete(taskId);
} else {
newExpanded.add(taskId);
// Load data if not already loaded
if (!taskData[taskId]) {
await loadTaskData(taskId);
}
}
setExpandedTasks(newExpanded);
};
const toggleStep = (taskId, stepName) => {
const key = `${taskId}-${stepName}`;
const newExpanded = new Set(expandedSteps);
if (newExpanded.has(key)) {
newExpanded.delete(key);
} else {
newExpanded.add(key);
// Initialize auto-scroll to true (default: on) when step is first expanded
if (shouldAutoScrollRefs.current[key] === undefined) {
shouldAutoScrollRefs.current[key] = true;
}
}
setExpandedSteps(newExpanded);
};
const toggleAutoScroll = (taskId, stepName) => {
const key = `${taskId}-${stepName}`;
// Toggle auto-scroll state (default to true if undefined)
const currentState = shouldAutoScrollRefs.current[key] !== false;
shouldAutoScrollRefs.current[key] = !currentState;
// Force re-render to update button state
setExpandedSteps(new Set(expandedSteps));
};
const handleLogWheel = (taskId, stepName) => {
const key = `${taskId}-${stepName}`;
// Turn off auto-scroll when user scrolls with wheel
if (shouldAutoScrollRefs.current[key] !== false) {
shouldAutoScrollRefs.current[key] = false;
// Force re-render to update button state
setExpandedSteps(new Set(expandedSteps));
}
};
const handleLogClick = (taskId, stepName, e) => {
// Pause on left or right click
if (e.button === 0 || e.button === 2) {
const key = `${taskId}-${stepName}`;
if (shouldAutoScrollRefs.current[key] !== false) {
shouldAutoScrollRefs.current[key] = false;
// Force re-render to update button state
setExpandedSteps(new Set(expandedSteps));
}
}
};
const getLogLevelColor = (level) => {
switch (level) {
case 'ERROR':
return 'text-red-600';
return 'text-red-400';
case 'WARN':
return 'text-yellow-600';
return 'text-yellow-400';
case 'DEBUG':
return 'text-gray-500';
default:
return 'text-gray-900';
return 'text-gray-200';
}
};
@@ -156,34 +316,106 @@ export default function JobDetails({ job, onClose, onUpdate }) {
const getTaskStatusColor = (status) => {
const colors = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
pending: 'bg-yellow-400/20 text-yellow-400',
running: 'bg-orange-400/20 text-orange-400',
completed: 'bg-green-400/20 text-green-400',
failed: 'bg-red-400/20 text-red-400',
};
return colors[status] || 'bg-gray-100 text-gray-800';
return colors[status] || 'bg-gray-500/20 text-gray-400';
};
const handleDelete = async () => {
if (!confirm('Are you sure you want to permanently delete this job? This action cannot be undone.')) return;
try {
await jobs.delete(jobDetails.id);
if (onUpdate) {
onUpdate();
}
onClose();
} catch (error) {
alert('Failed to delete job: ' + error.message);
}
};
const outputFiles = files.filter((f) => f.file_type === 'output');
const inputFiles = files.filter((f) => f.file_type === 'input');
// Helper to check if a file is an image
const isImageFile = (fileName) => {
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'];
const lowerName = fileName.toLowerCase();
return imageExtensions.some(ext => lowerName.endsWith(ext));
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-900">{jobDetails.name}</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl font-bold"
<>
{/* Image Preview Modal */}
{previewImage && (
<div
className="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-[60] p-4"
onClick={() => setPreviewImage(null)}
>
<div
className="bg-gray-900 rounded-lg shadow-xl max-w-7xl w-full max-h-[95vh] overflow-auto border border-gray-700 relative"
onClick={(e) => e.stopPropagation()}
>
×
</button>
<div className="sticky top-0 bg-gray-900 border-b border-gray-700 px-6 py-4 flex justify-between items-center">
<h3 className="text-xl font-semibold text-gray-100">{previewImage.fileName}</h3>
<button
onClick={() => setPreviewImage(null)}
className="text-gray-400 hover:text-gray-200 text-2xl font-bold"
>
×
</button>
</div>
<div className="p-6 flex items-center justify-center bg-black">
<img
src={previewImage.url}
alt={previewImage.fileName}
className="max-w-full max-h-[85vh] object-contain"
onError={(e) => {
e.target.style.display = 'none';
const errorDiv = e.target.nextSibling;
if (errorDiv) {
errorDiv.style.display = 'block';
}
}}
/>
<div className="hidden text-center p-8 text-gray-400 text-lg">
Failed to load image preview
</div>
</div>
</div>
</div>
)}
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto border border-gray-700">
<div className="sticky top-0 bg-gray-800 border-b border-gray-700 px-6 py-4 flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-100">{jobDetails.name}</h2>
<div className="flex items-center gap-3">
{(jobDetails.status === 'completed' || jobDetails.status === 'failed' || jobDetails.status === 'cancelled') && (
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors text-sm font-medium"
title="Delete job"
>
Delete
</button>
)}
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-200 text-2xl font-bold"
>
×
</button>
</div>
</div>
<div className="p-6 space-y-6">
{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>
)}
@@ -191,24 +423,24 @@ export default function JobDetails({ job, onClose, onUpdate }) {
<>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">Status</p>
<p className="font-semibold text-gray-900">{jobDetails.status}</p>
<p className="text-sm text-gray-400">Status</p>
<p className="font-semibold text-gray-100">{jobDetails.status}</p>
</div>
<div>
<p className="text-sm text-gray-600">Progress</p>
<p className="font-semibold text-gray-900">
<p className="text-sm text-gray-400">Progress</p>
<p className="font-semibold text-gray-100">
{jobDetails.progress.toFixed(1)}%
</p>
</div>
<div>
<p className="text-sm text-gray-600">Frame Range</p>
<p className="font-semibold text-gray-900">
<p className="text-sm text-gray-400">Frame Range</p>
<p className="font-semibold text-gray-100">
{jobDetails.frame_start} - {jobDetails.frame_end}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Output Format</p>
<p className="font-semibold text-gray-900">
<p className="text-sm text-gray-400">Output Format</p>
<p className="font-semibold text-gray-100">
{jobDetails.output_format}
</p>
</div>
@@ -216,7 +448,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
{videoUrl && jobDetails.output_format === 'MP4' && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">
<h3 className="text-lg font-semibold text-gray-100 mb-3">
Video Preview
</h3>
<VideoPlayer videoUrl={videoUrl} />
@@ -225,46 +457,61 @@ export default function JobDetails({ job, onClose, onUpdate }) {
{outputFiles.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">
<h3 className="text-lg font-semibold text-gray-100 mb-3">
Output Files
</h3>
<div className="space-y-2">
{outputFiles.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="font-medium text-gray-900">{file.file_name}</p>
<p className="text-sm text-gray-600">
{(file.file_size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<button
onClick={() => handleDownload(file.id, file.file_name)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
{outputFiles.map((file) => {
const isImage = isImageFile(file.file_name);
const imageUrl = isImage ? jobs.downloadFile(job.id, file.id) : null;
return (
<div
key={file.id}
className="flex items-center justify-between p-3 bg-gray-900 rounded-lg border border-gray-700"
>
Download
</button>
</div>
))}
<div className="flex-1">
<p className="font-medium text-gray-100">{file.file_name}</p>
<p className="text-sm text-gray-400">
{(file.file_size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<div className="flex gap-2">
{isImage && imageUrl && (
<button
onClick={() => setPreviewImage({ url: imageUrl, fileName: file.file_name })}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
>
Preview
</button>
)}
<button
onClick={() => handleDownload(file.id, file.file_name)}
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-500 transition-colors"
>
Download
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{inputFiles.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">
<h3 className="text-lg font-semibold text-gray-100 mb-3">
Input Files
</h3>
<div className="space-y-2">
{inputFiles.map((file) => (
<div
key={file.id}
className="p-3 bg-gray-50 rounded-lg"
className="p-3 bg-gray-900 rounded-lg border border-gray-700"
>
<p className="font-medium text-gray-900">{file.file_name}</p>
<p className="text-sm text-gray-600">
<p className="font-medium text-gray-100">{file.file_name}</p>
<p className="text-sm text-gray-400">
{(file.file_size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
@@ -274,110 +521,235 @@ export default function JobDetails({ job, onClose, onUpdate }) {
)}
{jobDetails.error_message && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
<div className="p-4 bg-red-400/20 border border-red-400/50 rounded-lg text-red-400">
<p className="font-semibold">Error:</p>
<p>{jobDetails.error_message}</p>
</div>
)}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Tasks
<h3 className="text-lg font-semibold text-gray-100 mb-3">
Tasks {streaming && <span className="text-sm text-green-400">(streaming)</span>}
</h3>
<div className="space-y-4">
{tasks.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<h4 className="font-medium text-gray-900 mb-2">Task List</h4>
<div className="space-y-2 max-h-64 overflow-y-auto">
{tasks.map((task) => (
<div className="space-y-2">
{tasks.length > 0 ? (
tasks.map((task) => {
const isExpanded = expandedTasks.has(task.id);
const taskInfo = taskData[task.id] || { steps: [], logs: [] };
const { steps, logs } = taskInfo;
// Group logs by step_name
const logsByStep = {};
logs.forEach(log => {
const stepName = log.step_name || 'general';
if (!logsByStep[stepName]) {
logsByStep[stepName] = [];
}
logsByStep[stepName].push(log);
});
return (
<div key={task.id} className="bg-gray-900 rounded-lg border border-gray-700">
{/* Task Header */}
<div
key={task.id}
onClick={() => handleTaskClick(task.id)}
className={`flex items-center justify-between p-3 bg-white rounded cursor-pointer hover:bg-gray-100 transition-colors ${
selectedTaskId === task.id ? 'ring-2 ring-purple-600' : ''
}`}
onClick={() => toggleTask(task.id)}
className="flex items-center justify-between p-3 bg-gray-800 rounded-t-lg cursor-pointer hover:bg-gray-750 transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-gray-500">
{isExpanded ? '▼' : '▶'}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${getTaskStatusColor(task.status)}`}>
{task.status}
</span>
<span className="font-medium text-gray-900">
Frame {task.frame_start}
{task.frame_end !== task.frame_start ? `-${task.frame_end}` : ''}
<span className="font-medium text-gray-100">
{task.task_type === 'metadata' ? 'Metadata Extraction' : `Frame ${task.frame_start}${task.frame_end !== task.frame_start ? `-${task.frame_end}` : ''}`}
</span>
{task.task_type && task.task_type !== 'render' && (
<span className="text-xs text-gray-500">({task.task_type})</span>
<span className="text-xs text-gray-400">({task.task_type})</span>
)}
</div>
<div className="text-sm text-gray-600">
<div className="text-sm text-gray-400">
{task.runner_id && `Runner ${task.runner_id}`}
</div>
</div>
))}
</div>
</div>
)}
{taskSteps.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-2">Steps</h4>
<div className="space-y-2">
{taskSteps.map((step) => (
<div
key={step.id}
className="flex items-center justify-between p-2 bg-white rounded"
>
<div className="flex items-center gap-2">
<span className="text-lg">
{getStepStatusIcon(step.status)}
</span>
<span className="font-medium">{step.step_name}</span>
</div>
{step.duration_ms && (
<span className="text-sm text-gray-600">
{(step.duration_ms / 1000).toFixed(2)}s
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Task Content (Steps and Logs) */}
{isExpanded && (
<div className="p-4 space-y-3">
{/* General logs (logs without step_name) */}
{logsByStep['general'] && logsByStep['general'].length > 0 && (() => {
const generalKey = `${task.id}-general`;
const isGeneralExpanded = expandedSteps.has(generalKey);
const generalLogs = logsByStep['general'];
{selectedTaskId && (
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-2">
Logs {streaming && <span className="text-sm text-green-600">(streaming)</span>}
</h4>
<div className="bg-black text-green-400 font-mono text-sm p-3 rounded max-h-96 overflow-y-auto">
{taskLogs.length === 0 ? (
<p className="text-gray-500">No logs yet...</p>
) : (
taskLogs.map((log) => (
<div
key={log.id}
className={`${getLogLevelColor(log.log_level)} mb-1`}
>
<span className="text-gray-500">
[{new Date(log.created_at).toLocaleTimeString()}]
</span>
{log.step_name && (
<span className="text-blue-400 ml-2">
[{log.step_name}]
</span>
return (
<div className="bg-gray-800 rounded-lg border border-gray-700">
<div
onClick={() => toggleStep(task.id, 'general')}
className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-750 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-gray-500 text-sm">
{isGeneralExpanded ? '▼' : '▶'}
</span>
<span className="font-medium text-gray-100">General</span>
</div>
<span className="text-xs text-gray-400">
{generalLogs.length} log{generalLogs.length !== 1 ? 's' : ''}
</span>
</div>
{isGeneralExpanded && (
<div className="p-3 border-t border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-400">Logs</span>
<button
onClick={() => toggleAutoScroll(task.id, 'general')}
className={`px-2 py-1 text-xs rounded ${
shouldAutoScrollRefs.current[generalKey] !== false
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
: 'bg-gray-500/20 text-gray-400 hover:bg-gray-500/30'
} transition-colors`}
title={shouldAutoScrollRefs.current[generalKey] !== false ? 'Auto-scroll: ON' : 'Auto-scroll: OFF'}
>
{shouldAutoScrollRefs.current[generalKey] !== false ? '📜 Follow' : '⏸ Paused'}
</button>
</div>
<div
ref={el => {
if (el) {
logContainerRefs.current[generalKey] = el;
// Initialize auto-scroll to true (follow logs) when ref is first set
if (shouldAutoScrollRefs.current[generalKey] === undefined) {
shouldAutoScrollRefs.current[generalKey] = true;
}
}
}}
onWheel={() => handleLogWheel(task.id, 'general')}
onMouseDown={(e) => handleLogClick(task.id, 'general', e)}
onContextMenu={(e) => handleLogClick(task.id, 'general', e)}
className="bg-black text-green-400 font-mono text-sm p-3 rounded max-h-64 overflow-y-auto"
>
{generalLogs.map((log) => (
<div
key={log.id}
className={`${getLogLevelColor(log.log_level)} mb-1`}
>
<span className="text-gray-500">
[{new Date(log.created_at).toLocaleTimeString()}]
</span>
<span className="ml-2">{log.message}</span>
</div>
))}
</div>
</div>
)}
</div>
);
})()}
{/* Steps */}
{steps.length > 0 ? (
steps.map((step) => {
const stepKey = `${task.id}-${step.step_name}`;
const isStepExpanded = expandedSteps.has(stepKey);
const stepLogs = logsByStep[step.step_name] || [];
return (
<div key={step.id} className="bg-gray-800 rounded-lg border border-gray-700">
{/* Step Header */}
<div
onClick={() => toggleStep(task.id, step.step_name)}
className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-750 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-gray-500 text-sm">
{isStepExpanded ? '▼' : '▶'}
</span>
<span className="text-lg">
{getStepStatusIcon(step.status)}
</span>
<span className="font-medium text-gray-100">{step.step_name}</span>
</div>
<div className="flex items-center gap-3">
{step.duration_ms && (
<span className="text-sm text-gray-400">
{(step.duration_ms / 1000).toFixed(2)}s
</span>
)}
{stepLogs.length > 0 && (
<span className="text-xs text-gray-400">
{stepLogs.length} log{stepLogs.length !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
{/* Step Logs */}
{isStepExpanded && (
<div className="p-3 border-t border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-400">Logs</span>
<button
onClick={() => toggleAutoScroll(task.id, step.step_name)}
className={`px-2 py-1 text-xs rounded ${
shouldAutoScrollRefs.current[stepKey] !== false
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
: 'bg-gray-500/20 text-gray-400 hover:bg-gray-500/30'
} transition-colors`}
title={shouldAutoScrollRefs.current[stepKey] !== false ? 'Auto-scroll: ON' : 'Auto-scroll: OFF'}
>
{shouldAutoScrollRefs.current[stepKey] !== false ? '📜 Follow' : '⏸ Paused'}
</button>
</div>
<div
ref={el => {
if (el) {
logContainerRefs.current[stepKey] = el;
// Initialize auto-scroll to true (follow logs) when ref is first set
if (shouldAutoScrollRefs.current[stepKey] === undefined) {
shouldAutoScrollRefs.current[stepKey] = true;
}
}
}}
onWheel={() => handleLogWheel(task.id, step.step_name)}
onMouseDown={(e) => handleLogClick(task.id, step.step_name, e)}
onContextMenu={(e) => handleLogClick(task.id, step.step_name, e)}
className="bg-black text-green-400 font-mono text-sm p-3 rounded max-h-64 overflow-y-auto"
>
{stepLogs.length === 0 ? (
<p className="text-gray-500">No logs yet...</p>
) : (
stepLogs.map((log) => (
<div
key={log.id}
className={`${getLogLevelColor(log.log_level)} mb-1`}
>
<span className="text-gray-500">
[{new Date(log.created_at).toLocaleTimeString()}]
</span>
<span className="ml-2">{log.message}</span>
</div>
))
)}
</div>
</div>
)}
</div>
);
})
) : (
logsByStep['general'] && logsByStep['general'].length > 0 ? null : (
<p className="text-gray-400 text-sm">No steps yet...</p>
)
)}
<span className="ml-2">{log.message}</span>
</div>
))
)}
</div>
</div>
)}
{!selectedTaskId && (
<p className="text-gray-600 text-sm">
Select a task to view logs and steps
</p>
)}
</div>
);
})
) : (
<p className="text-gray-400 text-sm">No tasks yet...</p>
)}
</div>
</div>
@@ -386,6 +758,7 @@ export default function JobDetails({ job, onClose, onUpdate }) {
</div>
</div>
</div>
</>
);
}

View File

@@ -34,13 +34,26 @@ 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();
if (selectedJob && selectedJob.id === jobId) {
setSelectedJob(null);
}
} catch (error) {
alert('Failed to delete job: ' + error.message);
}
};
const getStatusColor = (status) => {
const colors = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
cancelled: 'bg-gray-100 text-gray-800',
pending: 'bg-yellow-400/20 text-yellow-400',
running: 'bg-orange-400/20 text-orange-400',
completed: 'bg-green-400/20 text-green-400',
failed: 'bg-red-400/20 text-red-400',
cancelled: 'bg-gray-500/20 text-gray-400',
};
return colors[status] || colors.pending;
};
@@ -48,7 +61,7 @@ export default function JobList() {
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-purple-600"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500"></div>
</div>
);
}
@@ -56,7 +69,7 @@ export default function JobList() {
if (jobList.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No jobs yet. Submit a job to get started!</p>
<p className="text-gray-400 text-lg">No jobs yet. Submit a job to get started!</p>
</div>
);
}
@@ -67,29 +80,29 @@ export default function JobList() {
{jobList.map((job) => (
<div
key={job.id}
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border-l-4 border-purple-600"
className="bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border-l-4 border-orange-500 border border-gray-700"
>
<div className="flex justify-between items-start mb-4">
<h3 className="text-xl font-semibold text-gray-900">{job.name}</h3>
<h3 className="text-xl font-semibold text-gray-100">{job.name}</h3>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(job.status)}`}>
{job.status}
</span>
</div>
<div className="space-y-2 text-sm text-gray-600 mb-4">
<div className="space-y-2 text-sm text-gray-400 mb-4">
<p>Frames: {job.frame_start} - {job.frame_end}</p>
<p>Format: {job.output_format}</p>
<p>Created: {new Date(job.created_at).toLocaleString()}</p>
</div>
<div className="mb-4">
<div className="flex justify-between text-xs text-gray-600 mb-1">
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>Progress</span>
<span>{job.progress.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all duration-300"
className="bg-orange-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${job.progress}%` }}
></div>
</div>
@@ -98,18 +111,27 @@ export default function JobList() {
<div className="flex gap-2">
<button
onClick={() => setSelectedJob(job)}
className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-500 transition-colors font-medium"
>
View Details
</button>
{(job.status === 'pending' || job.status === 'running') && (
<button
onClick={() => handleCancel(job.id)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors font-medium"
className="px-4 py-2 bg-gray-700 text-gray-200 rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
Cancel
</button>
)}
{(job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') && (
<button
onClick={() => handleDelete(job.id)}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors font-medium"
title="Delete job"
>
Delete
</button>
)}
</div>
</div>
))}

View File

@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { jobs } from '../utils/api';
import JobDetails from './JobDetails';
export default function JobSubmission({ onSuccess }) {
const [step, setStep] = useState(1); // 1 = upload & extract metadata, 2 = configure & submit
const [formData, setFormData] = useState({
name: '',
frame_start: 1,
@@ -15,24 +17,57 @@ export default function JobSubmission({ onSuccess }) {
const [metadataStatus, setMetadataStatus] = useState(null); // 'extracting', 'completed', 'error'
const [metadata, setMetadata] = useState(null);
const [currentJobId, setCurrentJobId] = useState(null);
const [createdJob, setCreatedJob] = useState(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [blendFiles, setBlendFiles] = useState([]); // For ZIP files with multiple blend files
const [selectedMainBlend, setSelectedMainBlend] = useState('');
// Use refs to track cancellation state across re-renders
const isCancelledRef = useRef(false);
const isCompletedRef = useRef(false);
const currentJobIdRef = useRef(null);
const cleanupRef = useRef(null);
// Poll for metadata after file upload
useEffect(() => {
if (!currentJobId || metadataStatus !== 'extracting') return;
if (!currentJobId || metadataStatus !== 'extracting') {
// Reset refs when not extracting
isCancelledRef.current = false;
isCompletedRef.current = false;
currentJobIdRef.current = null;
// Clear any pending cleanup
if (cleanupRef.current) {
cleanupRef.current = null;
}
return;
}
// Reset refs for new job
if (currentJobIdRef.current !== currentJobId) {
isCancelledRef.current = false;
isCompletedRef.current = false;
currentJobIdRef.current = currentJobId;
}
let pollCount = 0;
const maxPolls = 30; // 60 seconds max (30 * 2 seconds)
let timeoutId = null;
let interval = null;
let mounted = true; // Track if effect is still mounted
const pollMetadata = async () => {
if (!mounted || isCancelledRef.current || isCompletedRef.current) return;
pollCount++;
// Stop polling after timeout
if (pollCount > maxPolls) {
if (!mounted) return;
setMetadataStatus('error');
// Cancel temp job on timeout
try {
await jobs.cancel(currentJobId);
isCancelledRef.current = true;
} catch (err) {
// Ignore errors when canceling
}
@@ -41,9 +76,10 @@ export default function JobSubmission({ onSuccess }) {
try {
const metadata = await jobs.getMetadata(currentJobId);
if (metadata) {
if (metadata && mounted) {
setMetadata(metadata);
setMetadataStatus('completed');
isCompletedRef.current = true; // Mark as completed
// Auto-populate form fields
setFormData(prev => ({
...prev,
@@ -51,38 +87,62 @@ export default function JobSubmission({ onSuccess }) {
frame_end: metadata.frame_end || prev.frame_end,
output_format: metadata.render_settings?.output_format || prev.output_format,
}));
// Stop polling on success
if (interval) clearInterval(interval);
if (timeoutId) clearTimeout(timeoutId);
}
} catch (err) {
if (!mounted) return;
// Metadata not ready yet, continue polling (only if 404/not found)
if (err.message.includes('404') || err.message.includes('not found')) {
// Continue polling via interval
} else {
setMetadataStatus('error');
// Stop polling on error
if (interval) clearInterval(interval);
if (timeoutId) clearTimeout(timeoutId);
}
}
};
const interval = setInterval(pollMetadata, 2000);
interval = setInterval(pollMetadata, 2000);
// Set timeout to stop polling after 60 seconds
timeoutId = setTimeout(() => {
clearInterval(interval);
if (metadataStatus === 'extracting') {
if (!mounted) return;
if (interval) clearInterval(interval);
if (!isCancelledRef.current && !isCompletedRef.current) {
setMetadataStatus('error');
// Cancel temp job on timeout
jobs.cancel(currentJobId).catch(() => {});
isCancelledRef.current = true;
}
}, 60000);
return () => {
clearInterval(interval);
// Store cleanup function in ref so we can check if it should run
cleanupRef.current = () => {
mounted = false;
if (interval) clearInterval(interval);
if (timeoutId) clearTimeout(timeoutId);
// Cleanup: cancel temp job if component unmounts during extraction
if (currentJobId && metadataStatus === 'extracting') {
jobs.cancel(currentJobId).catch(() => {});
// DO NOT cancel the job in cleanup - let it run to completion
// The job will be cleaned up when the user submits the actual job or navigates away
};
return cleanupRef.current;
}, [currentJobId, metadataStatus]); // Include metadataStatus to properly track state changes
// Separate effect to handle component unmount - only cancel if truly unmounting
useEffect(() => {
return () => {
// Only cancel on actual component unmount, not on effect re-run
// Check if we're still extracting and haven't completed
if (currentJobIdRef.current && !isCompletedRef.current && !isCancelledRef.current && metadataStatus === 'extracting') {
// Only cancel if we're actually unmounting (not just re-rendering)
// This is a last resort - ideally we should let metadata extraction complete
jobs.cancel(currentJobIdRef.current).catch(() => {});
}
};
}, [currentJobId, metadataStatus]);
}, []); // Empty deps - only runs on mount/unmount
const handleFileChange = async (e) => {
const selectedFile = e.target.files[0];
@@ -95,31 +155,92 @@ export default function JobSubmission({ onSuccess }) {
setMetadataStatus(null);
setMetadata(null);
setCurrentJobId(null);
setUploadProgress(0);
setBlendFiles([]);
setSelectedMainBlend('');
// If it's a blend file, create a temporary job to extract metadata
if (selectedFile.name.toLowerCase().endsWith('.blend')) {
const isBlend = selectedFile.name.toLowerCase().endsWith('.blend');
const isZip = selectedFile.name.toLowerCase().endsWith('.zip');
// If it's a blend file or ZIP, create a temporary job to extract metadata
if (isBlend || isZip) {
try {
setIsUploading(true);
setUploadProgress(0);
// Create a temporary job for metadata extraction
const tempJob = await jobs.create({
job_type: 'metadata',
name: 'Metadata Extraction',
frame_start: 1,
frame_end: 10,
output_format: 'PNG',
allow_parallel_runners: true,
});
setCurrentJobId(tempJob.id);
setMetadataStatus('extracting');
// Upload file to trigger metadata extraction
await jobs.uploadFile(tempJob.id, selectedFile);
// Upload file to trigger metadata extraction with progress tracking
const result = await jobs.uploadFile(tempJob.id, selectedFile, (progress) => {
setUploadProgress(progress);
}, selectedMainBlend || undefined);
setUploadProgress(100);
setIsUploading(false);
// Check if ZIP extraction found multiple blend files
if (result.zip_extracted && result.blend_files && result.blend_files.length > 1) {
setBlendFiles(result.blend_files);
setMetadataStatus('select_blend');
return;
}
// If main blend file was auto-detected or specified, continue
if (result.main_blend_file) {
setSelectedMainBlend(result.main_blend_file);
}
} catch (err) {
console.error('Failed to start metadata extraction:', err);
setMetadataStatus('error');
setIsUploading(false);
setUploadProgress(0);
}
}
};
const handleBlendFileSelect = async () => {
if (!selectedMainBlend || !currentJobId) {
setError('Please select a main blend file');
return;
}
try {
setIsUploading(true);
setUploadProgress(0);
// Re-upload with selected main blend file
const result = await jobs.uploadFile(currentJobId, file, (progress) => {
setUploadProgress(progress);
}, selectedMainBlend);
setUploadProgress(100);
setIsUploading(false);
setBlendFiles([]);
setMetadataStatus('extracting');
} catch (err) {
console.error('Failed to upload with selected blend file:', err);
setError(err.message || 'Failed to upload with selected blend file');
setIsUploading(false);
}
};
const handleContinueToStep2 = () => {
if (metadataStatus === 'completed' || metadataStatus === 'error') {
setStep(2);
}
};
const handleBackToStep1 = () => {
setStep(1);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
@@ -143,55 +264,206 @@ export default function JobSubmission({ onSuccess }) {
}
}
// Create actual job
// Create actual render job, linking it to the metadata job if we have one
const job = await jobs.create({
job_type: 'render',
name: formData.name,
frame_start: parseInt(formData.frame_start),
frame_end: parseInt(formData.frame_end),
output_format: formData.output_format,
allow_parallel_runners: formData.allow_parallel_runners,
metadata_job_id: currentJobId || undefined, // Link to metadata job to copy input files
});
// Upload file
await jobs.uploadFile(job.id, file);
// Note: File is already uploaded to metadata job, so we don't need to upload again
// The backend will copy the file reference from the metadata job
// Reset form
setFormData({
name: '',
frame_start: 1,
frame_end: 10,
output_format: 'PNG',
allow_parallel_runners: true,
});
setFile(null);
setMetadata(null);
setMetadataStatus(null);
setCurrentJobId(null);
e.target.reset();
// Fetch the full job details
const jobDetails = await jobs.get(job.id);
if (onSuccess) {
onSuccess();
}
// Set created job to show details
setCreatedJob(jobDetails);
} catch (err) {
setError(err.message || 'Failed to submit job');
} finally {
setSubmitting(false);
}
};
const handleCloseJobDetails = () => {
setCreatedJob(null);
// Reset form
setFormData({
name: '',
frame_start: 1,
frame_end: 10,
output_format: 'PNG',
allow_parallel_runners: true,
});
setFile(null);
setMetadata(null);
setMetadataStatus(null);
setCurrentJobId(null);
setStep(1);
if (onSuccess) {
onSuccess();
}
};
// If job was created, show job details
if (createdJob) {
return (
<JobDetails
job={createdJob}
onClose={handleCloseJobDetails}
onUpdate={() => jobs.get(createdJob.id).then(setCreatedJob)}
/>
);
}
return (
<div className="bg-white rounded-lg shadow-md p-8 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Submit New Job</h2>
<div className="bg-gray-800 rounded-lg shadow-md p-8 max-w-2xl mx-auto border border-gray-700">
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-100 mb-2">Submit New Job</h2>
<div className="flex items-center gap-2 text-sm text-gray-400">
<div className={`flex items-center gap-2 ${step >= 1 ? 'text-orange-500 font-medium' : 'text-gray-500'}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${step >= 1 ? 'bg-orange-600 text-white' : 'bg-gray-700'}`}>
{step > 1 ? '✓' : '1'}
</div>
<span>Upload & Extract Metadata</span>
</div>
<div className="w-8 h-0.5 bg-gray-700"></div>
<div className={`flex items-center gap-2 ${step >= 2 ? 'text-orange-500 font-medium' : 'text-gray-500'}`}>
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${step >= 2 ? 'bg-orange-600 text-white' : 'bg-gray-700'}`}>
2
</div>
<span>Configure & Submit</span>
</div>
</div>
</div>
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
<div className="mb-4 p-4 bg-red-400/20 border border-red-400/50 rounded-lg text-red-400">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{step === 1 ? (
// Step 1: Upload file and extract metadata
<div className="space-y-6">
<div className="p-4 bg-blue-400/20 border border-blue-400/50 rounded-lg">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-blue-400">
<strong>Notice:</strong> All files uploaded and generated will be deleted along with your job after 30 days unless you or an admin delete it earlier.
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Blender File or ZIP Archive (.blend, .zip)
</label>
<input
type="file"
accept=".blend,.zip"
onChange={handleFileChange}
required
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 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-orange-600/20 file:text-orange-400 hover:file:bg-orange-600/30"
/>
{blendFiles.length > 1 && (
<div className="mt-4 p-4 bg-yellow-400/20 border border-yellow-400/50 rounded-lg">
<p className="text-sm font-medium text-yellow-400 mb-3">
Multiple blend files found. Please select the main blend file:
</p>
<select
value={selectedMainBlend}
onChange={(e) => setSelectedMainBlend(e.target.value)}
className="w-full px-3 py-2 bg-gray-900 border border-yellow-400/50 rounded-lg text-gray-100 focus:ring-2 focus:ring-yellow-500 focus:border-transparent"
>
<option value="">-- Select main blend file --</option>
{blendFiles.map((blendFile) => (
<option key={blendFile} value={blendFile}>
{blendFile}
</option>
))}
</select>
<button
onClick={handleBlendFileSelect}
disabled={!selectedMainBlend || isUploading}
className="mt-3 w-full px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-500 disabled:bg-gray-700 disabled:cursor-not-allowed"
>
Continue with Selected File
</button>
</div>
)}
{(isUploading || metadataStatus === 'extracting') && (
<div className="mt-2 p-3 bg-orange-400/20 border border-orange-400/50 rounded-lg text-orange-400 text-sm">
{isUploading ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span>Uploading file...</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-orange-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
</div>
) : (
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-500"></div>
<span>Extracting metadata from blend file...</span>
</div>
)}
</div>
)}
{metadataStatus === 'completed' && metadata && (
<div className="mt-2 p-3 bg-green-400/20 border border-green-400/50 rounded-lg text-sm">
<div className="text-green-400 font-semibold mb-2">Metadata extracted successfully!</div>
<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>Engine: {metadata.render_settings?.engine}</div>
<div>Samples: {metadata.render_settings?.samples}</div>
</div>
<button
type="button"
onClick={handleContinueToStep2}
className="mt-3 w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-500 transition-colors font-medium"
>
Continue to Configuration
</button>
</div>
)}
{metadataStatus === 'error' && (
<div className="mt-2 p-3 bg-yellow-400/20 border border-yellow-400/50 rounded-lg text-yellow-400 text-sm">
<div className="mb-2">Could not extract metadata. You can still continue and fill in the form manually.</div>
<button
type="button"
onClick={handleContinueToStep2}
className="w-full px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-500 transition-colors font-medium"
>
Continue to Configuration
</button>
</div>
)}
</div>
</div>
) : (
// Step 2: Configure and submit
<form onSubmit={handleSubmit} className="space-y-6">
<button
type="button"
onClick={handleBackToStep1}
className="text-orange-500 hover:text-orange-400 font-medium text-sm flex items-center gap-1"
>
Back to Upload
</button>
<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">
Job Name
</label>
<input
@@ -199,14 +471,14 @@ export default function JobSubmission({ onSuccess }) {
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent"
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 placeholder-gray-500"
placeholder="My Render Job"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<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">
Frame Start
</label>
<input
@@ -215,11 +487,11 @@ export default function JobSubmission({ onSuccess }) {
onChange={(e) => setFormData({ ...formData, frame_start: e.target.value })}
required
min="0"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent"
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>
<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">
Frame End
</label>
<input
@@ -228,19 +500,19 @@ export default function JobSubmission({ onSuccess }) {
onChange={(e) => setFormData({ ...formData, frame_end: e.target.value })}
required
min={formData.frame_start}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent"
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>
</div>
<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">
Output Format
</label>
<select
value={formData.output_format}
onChange={(e) => setFormData({ ...formData, output_format: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent"
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"
>
<option value="PNG">PNG</option>
<option value="JPEG">JPEG</option>
@@ -255,61 +527,55 @@ export default function JobSubmission({ onSuccess }) {
id="allow_parallel_runners"
checked={formData.allow_parallel_runners}
onChange={(e) => setFormData({ ...formData, allow_parallel_runners: e.target.checked })}
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
className="h-4 w-4 text-orange-600 focus:ring-orange-500 border-gray-600 bg-gray-900 rounded"
/>
<label htmlFor="allow_parallel_runners" className="ml-2 block text-sm text-gray-700">
<label htmlFor="allow_parallel_runners" className="ml-2 block text-sm text-gray-300">
Allow multiple runners to work on this job simultaneously
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Blender File (.blend)
</label>
<input
type="file"
accept=".blend"
onChange={handleFileChange}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-purple-50 file:text-purple-700 hover:file:bg-purple-100"
/>
{metadataStatus === 'extracting' && (
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg text-blue-700 text-sm">
Extracting metadata from blend file...
</div>
)}
{metadataStatus === 'completed' && metadata && (
<div className="mt-2 p-3 bg-green-50 border border-green-200 rounded-lg text-sm">
<div className="text-green-700 font-semibold mb-1">Metadata extracted successfully!</div>
<div className="text-green-600 text-xs space-y-1">
{metadata && metadataStatus === 'completed' && (
<div className="p-4 bg-green-400/20 border border-green-400/50 rounded-lg text-sm mb-4">
<div className="text-green-400 font-semibold mb-2">Metadata from blend file:</div>
<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>Engine: {metadata.render_settings?.engine}</div>
<div>Samples: {metadata.render_settings?.samples}</div>
<div className="text-gray-600 mt-2">Form fields have been auto-populated. You can adjust them if needed.</div>
{(formData.frame_start < metadata.frame_start || formData.frame_end > metadata.frame_end) && (
<div className="mt-2 p-2 bg-yellow-50 border border-yellow-200 rounded text-yellow-800 text-xs">
<strong>Warning:</strong> Your frame range ({formData.frame_start}-{formData.frame_end}) exceeds the blend file's range ({metadata.frame_start}-{metadata.frame_end}). This may cause errors.
</div>
)}
</div>
</div>
)}
{metadataStatus === 'error' && (
<div className="mt-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-700 text-sm">
Could not extract metadata. Please fill in the form manually.
</div>
)}
</div>
<button
type="submit"
disabled={submitting}
className="w-full px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Submitting...' : 'Submit Job'}
</button>
</form>
<div className="pt-4 border-t border-gray-700">
<div className="text-xs text-gray-400 mb-2">
Selected file: {file?.name}
</div>
{(isUploading || submitting) && (
<div className="mb-4 p-3 bg-orange-400/20 border border-orange-400/50 rounded-lg">
<div className="flex items-center justify-between text-xs text-orange-400 mb-2">
<span>{isUploading ? 'Uploading file...' : 'Creating job...'}</span>
{isUploading && <span>{Math.round(uploadProgress)}%</span>}
</div>
{isUploading && (
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-orange-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
)}
</div>
)}
<button
type="submit"
disabled={submitting || !file || isUploading}
className="w-full px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-500 transition-colors font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Creating Job...' : 'Create Job'}
</button>
</div>
</form>
)}
</div>
);
}

View File

@@ -4,19 +4,22 @@ export default function Layout({ children, activeTab, onTabChange }) {
const { user, logout } = useAuth();
const isAdmin = user?.is_admin || false;
// Note: If user becomes null, App.jsx will handle showing Login component
// We don't need to redirect here as App.jsx already checks for !user
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="min-h-screen bg-gray-900">
<header className="bg-gray-800 shadow-sm border-b border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-600 to-blue-600">
Fuego
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-orange-500 to-amber-500">
JiggaBlend
</h1>
<div className="flex items-center gap-4">
<span className="text-gray-700">{user?.name || user?.email}</span>
<span className="text-gray-300">{user?.name || user?.email}</span>
<button
onClick={logout}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
className="px-4 py-2 text-sm font-medium text-gray-200 bg-gray-700 border border-gray-600 rounded-lg hover:bg-gray-600 transition-colors"
>
Logout
</button>
@@ -25,15 +28,15 @@ export default function Layout({ children, activeTab, onTabChange }) {
</div>
</header>
<nav className="bg-white border-b border-gray-200">
<nav className="bg-gray-800 border-b border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex space-x-8">
<button
onClick={() => onTabChange('jobs')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'jobs'
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-orange-500 text-orange-500'
: 'border-transparent text-gray-400 hover:text-gray-300 hover:border-gray-600'
}`}
>
Jobs
@@ -42,29 +45,19 @@ export default function Layout({ children, activeTab, onTabChange }) {
onClick={() => onTabChange('submit')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'submit'
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-orange-500 text-orange-500'
: 'border-transparent text-gray-400 hover:text-gray-300 hover:border-gray-600'
}`}
>
Submit Job
</button>
<button
onClick={() => onTabChange('runners')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'runners'
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Runners
</button>
{isAdmin && (
<button
onClick={() => onTabChange('admin')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'admin'
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-orange-500 text-orange-500'
: 'border-transparent text-gray-400 hover:text-gray-300 hover:border-gray-600'
}`}
>
Admin

View File

@@ -1,37 +1,277 @@
import { useState, useEffect } from 'react';
import { auth } from '../utils/api';
export default function Login() {
const [providers, setProviders] = useState({
google: false,
discord: false,
local: false,
});
const [showRegister, setShowRegister] = useState(false);
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
checkAuthProviders();
// Check for registration disabled error in URL
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('error') === 'registration_disabled') {
setError('Registration is currently disabled. Please contact an administrator.');
}
}, []);
const checkAuthProviders = async () => {
try {
const result = await auth.getProviders();
setProviders({
google: result.google || false,
discord: result.discord || false,
local: result.local || false,
});
} catch (error) {
// If endpoint fails, assume no providers are available
console.error('Failed to check auth providers:', error);
setProviders({ google: false, discord: false, local: false });
}
};
const handleLocalLogin = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await auth.localLogin(username, password);
// Reload page to trigger auth check in App component
window.location.reload();
} catch (err) {
setError(err.message || 'Login failed');
setLoading(false);
}
};
const handleLocalRegister = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters long');
return;
}
setLoading(true);
try {
await auth.localRegister(email, name, password);
// Reload page to trigger auth check in App component
window.location.reload();
} catch (err) {
setError(err.message || 'Registration failed');
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-600 via-blue-600 to-indigo-700">
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-md">
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="bg-gray-800 rounded-2xl shadow-2xl p-8 w-full max-w-md border border-gray-700">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-600 to-blue-600 mb-2">
Fuego
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-orange-500 to-amber-500 mb-2">
JiggaBlend
</h1>
<p className="text-gray-600 text-lg">Blender Render Farm</p>
<p className="text-gray-400 text-lg">Blender Render Farm</p>
</div>
<div className="space-y-4">
<a
href="/api/auth/google/login"
className="w-full flex items-center justify-center gap-3 bg-white border-2 border-gray-300 text-gray-700 font-semibold py-3 px-6 rounded-lg hover:bg-gray-50 hover:border-gray-400 transition-all duration-200 shadow-sm"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Google
</a>
{error && (
<div className="p-4 bg-red-400/20 border border-red-400/50 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{providers.local && (
<div className="pb-4 border-b border-gray-700">
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => {
setShowRegister(false);
setError('');
}}
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-colors ${
!showRegister
? 'bg-orange-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
Login
</button>
<button
type="button"
onClick={() => {
setShowRegister(true);
setError('');
}}
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-colors ${
showRegister
? 'bg-orange-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
Register
</button>
</div>
{!showRegister ? (
<form onSubmit={handleLocalLogin} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-1">
Email
</label>
<input
id="username"
type="email"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
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 placeholder-gray-500"
placeholder="Enter your email"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
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 placeholder-gray-500"
placeholder="Enter password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-orange-600 text-white font-semibold py-3 px-6 rounded-lg hover:bg-orange-500 transition-all duration-200 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
) : (
<form onSubmit={handleLocalRegister} className="space-y-4">
<div>
<label htmlFor="reg-email" className="block text-sm font-medium text-gray-300 mb-1">
Email
</label>
<input
id="reg-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
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 placeholder-gray-500"
placeholder="Enter your email"
/>
</div>
<div>
<label htmlFor="reg-name" className="block text-sm font-medium text-gray-300 mb-1">
Name
</label>
<input
id="reg-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
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 placeholder-gray-500"
placeholder="Enter your name"
/>
</div>
<div>
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-300 mb-1">
Password
</label>
<input
id="reg-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
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 placeholder-gray-500"
placeholder="At least 8 characters"
/>
</div>
<div>
<label htmlFor="reg-confirm-password" className="block text-sm font-medium text-gray-300 mb-1">
Confirm Password
</label>
<input
id="reg-confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
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 placeholder-gray-500"
placeholder="Confirm password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-orange-600 text-white font-semibold py-3 px-6 rounded-lg hover:bg-orange-500 transition-all duration-200 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Registering...' : 'Register'}
</button>
</form>
)}
</div>
)}
<a
href="/api/auth/discord/login"
className="w-full flex items-center justify-center gap-3 bg-[#5865F2] text-white font-semibold py-3 px-6 rounded-lg hover:bg-[#4752C4] transition-all duration-200 shadow-lg"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
Continue with Discord
</a>
{providers.google && (
<a
href="/api/auth/google/login"
className="w-full flex items-center justify-center gap-3 bg-gray-700 border-2 border-gray-600 text-gray-200 font-semibold py-3 px-6 rounded-lg hover:bg-gray-600 hover:border-gray-500 transition-all duration-200 shadow-sm"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Google
</a>
)}
{providers.discord && (
<a
href="/api/auth/discord/login"
className="w-full flex items-center justify-center gap-3 bg-[#5865F2] text-white font-semibold py-3 px-6 rounded-lg hover:bg-[#4752C4] transition-all duration-200 shadow-lg"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
Continue with Discord
</a>
)}
{!providers.google && !providers.discord && !providers.local && (
<div className="p-4 bg-yellow-400/20 border border-yellow-400/50 rounded-lg text-yellow-400 text-sm text-center">
No authentication methods are configured. Please contact an administrator.
</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,140 @@
import { useState } from 'react';
import { auth } from '../utils/api';
import { useAuth } from '../hooks/useAuth';
export default function PasswordChange({ targetUserId = null, targetUserName = null, onSuccess }) {
const { user } = useAuth();
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const isAdmin = user?.is_admin || false;
const isChangingOtherUser = targetUserId !== null && isAdmin;
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
if (newPassword !== confirmPassword) {
setError('New passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters long');
return;
}
if (!isChangingOtherUser && !oldPassword) {
setError('Old password is required');
return;
}
setLoading(true);
try {
await auth.changePassword(
isChangingOtherUser ? null : oldPassword,
newPassword,
isChangingOtherUser ? targetUserId : null
);
setSuccess('Password changed successfully');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
if (onSuccess) {
setTimeout(() => {
onSuccess();
}, 1500);
}
} catch (err) {
setError(err.message || 'Failed to change password');
} finally {
setLoading(false);
}
};
return (
<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">
{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>
)}
{success && (
<div className="mb-4 p-3 bg-green-400/20 border border-green-400/50 rounded-lg text-green-400 text-sm">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{!isChangingOtherUser && (
<div>
<label htmlFor="old-password" className="block text-sm font-medium text-gray-300 mb-1">
Current Password
</label>
<input
id="old-password"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
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"
placeholder="Enter current password"
/>
</div>
)}
<div>
<label htmlFor="new-password" className="block text-sm font-medium text-gray-300 mb-1">
New Password
</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
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"
placeholder="At least 8 characters"
/>
</div>
<div>
<label htmlFor="confirm-password" className="block text-sm font-medium text-gray-300 mb-1">
Confirm New Password
</label>
<input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
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"
placeholder="Confirm new password"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-orange-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-orange-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Changing Password...' : 'Change Password'}
</button>
</form>
</div>
);
}

View File

@@ -1,92 +0,0 @@
import { useState, useEffect } from 'react';
import { runners } from '../utils/api';
export default function RunnerList() {
const [runnerList, setRunnerList] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadRunners();
const interval = setInterval(loadRunners, 5000);
return () => clearInterval(interval);
}, []);
const loadRunners = async () => {
try {
const data = await runners.list();
setRunnerList(data);
} catch (error) {
console.error('Failed to load runners:', error);
} finally {
setLoading(false);
}
};
const isOnline = (lastHeartbeat) => {
const now = new Date();
const heartbeat = new Date(lastHeartbeat);
return (now - heartbeat) < 60000; // 1 minute
};
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-purple-600"></div>
</div>
);
}
if (runnerList.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No runners connected.</p>
</div>
);
}
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{runnerList.map((runner) => {
const online = isOnline(runner.last_heartbeat);
return (
<div
key={runner.id}
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border-l-4 border-green-500"
>
<div className="flex justify-between items-start mb-4">
<h3 className="text-xl font-semibold text-gray-900">{runner.name}</h3>
<span
className={`px-3 py-1 rounded-full text-xs font-medium ${
online
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{online ? 'Online' : 'Offline'}
</span>
</div>
<div className="space-y-2 text-sm text-gray-600">
<p>
<span className="font-medium">Hostname:</span> {runner.hostname}
</p>
<p>
<span className="font-medium">IP:</span> {runner.ip_address}
</p>
<p>
<span className="font-medium">Last heartbeat:</span>{' '}
{new Date(runner.last_heartbeat).toLocaleString()}
</p>
{runner.capabilities && (
<p>
<span className="font-medium">Capabilities:</span> {runner.capabilities}
</p>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useState, useEffect } from 'react';
import { admin } from '../utils/api';
import JobDetails from './JobDetails';
export default function UserJobs({ userId, userName, onBack }) {
const [jobList, setJobList] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedJob, setSelectedJob] = useState(null);
useEffect(() => {
loadJobs();
const interval = setInterval(loadJobs, 5000);
return () => clearInterval(interval);
}, [userId]);
const loadJobs = async () => {
try {
const data = await admin.getUserJobs(userId);
setJobList(Array.isArray(data) ? data : []);
} catch (error) {
console.error('Failed to load jobs:', error);
setJobList([]);
} finally {
setLoading(false);
}
};
const getStatusColor = (status) => {
const colors = {
pending: 'bg-yellow-400/20 text-yellow-400',
running: 'bg-orange-400/20 text-orange-400',
completed: 'bg-green-400/20 text-green-400',
failed: 'bg-red-400/20 text-red-400',
cancelled: 'bg-gray-500/20 text-gray-400',
};
return colors[status] || colors.pending;
};
if (selectedJob) {
return (
<JobDetails
job={selectedJob}
onClose={() => setSelectedJob(null)}
onUpdate={loadJobs}
/>
);
}
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<button
onClick={onBack}
className="text-gray-400 hover:text-gray-300 mb-2 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>
<h2 className="text-2xl font-bold text-gray-100">Jobs for {userName}</h2>
</div>
</div>
{jobList.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-400 text-lg">No jobs found for this user.</p>
</div>
) : (
<div className="grid gap-6">
{jobList.map((job) => (
<div
key={job.id}
className="bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border-l-4 border-orange-500 border border-gray-700"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-semibold text-gray-100 mb-1">{job.name}</h3>
<p className="text-sm text-gray-400">
{job.job_type === 'render' && job.frame_start !== null && job.frame_end !== null
? `Frames ${job.frame_start}-${job.frame_end}`
: 'Metadata extraction'}
</p>
</div>
<span
className={`px-3 py-1 text-sm font-medium rounded-full ${getStatusColor(job.status)}`}
>
{job.status}
</span>
</div>
{job.status === 'running' && (
<div className="mb-4">
<div className="flex justify-between text-sm text-gray-400 mb-1">
<span>Progress</span>
<span>{Math.round(job.progress)}%</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-orange-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${job.progress}%` }}
></div>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-400">
Created: {new Date(job.created_at).toLocaleString()}
</div>
<button
onClick={() => setSelectedJob(job)}
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-500 transition-colors font-medium"
>
View Details
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -7,23 +7,49 @@ export default function VideoPlayer({ videoUrl, onClose }) {
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (!video || !videoUrl) return;
const handleCanPlay = () => {
setLoading(false);
};
const handleError = () => {
setError('Failed to load video');
const handleError = (e) => {
console.error('Video playback error:', e, video.error);
// Get more detailed error information
let errorMsg = 'Failed to load video';
if (video.error) {
switch (video.error.code) {
case video.error.MEDIA_ERR_ABORTED:
errorMsg = 'Video loading aborted';
break;
case video.error.MEDIA_ERR_NETWORK:
errorMsg = 'Network error while loading video';
break;
case video.error.MEDIA_ERR_DECODE:
errorMsg = 'Video decoding error';
break;
case video.error.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMsg = 'Video format not supported';
break;
}
}
setError(errorMsg);
setLoading(false);
};
const handleLoadStart = () => {
setLoading(true);
setError(null);
};
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('error', handleError);
video.addEventListener('loadstart', handleLoadStart);
return () => {
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('error', handleError);
video.removeEventListener('loadstart', handleLoadStart);
};
}, [videoUrl]);
@@ -31,6 +57,9 @@ export default function VideoPlayer({ videoUrl, onClose }) {
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>
</div>
);
}
@@ -38,7 +67,7 @@ export default function VideoPlayer({ videoUrl, onClose }) {
return (
<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">
<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>
</div>
)}
@@ -48,8 +77,10 @@ export default function VideoPlayer({ videoUrl, onClose }) {
controls
className="w-full"
onLoadedData={() => setLoading(false)}
preload="metadata"
>
Your browser does not support the video tag.
<a href={videoUrl} download>Download the video</a>
</video>
</div>
);