massive changes and it works
This commit is contained in:
@@ -1,36 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import Login from './components/Login';
|
||||
import Layout from './components/Layout';
|
||||
import JobList from './components/JobList';
|
||||
import JobSubmission from './components/JobSubmission';
|
||||
import RunnerList from './components/RunnerList';
|
||||
import AdminPanel from './components/AdminPanel';
|
||||
import './styles/index.css';
|
||||
|
||||
function App() {
|
||||
const { user, loading } = useAuth();
|
||||
const { user, loading, refresh } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('jobs');
|
||||
|
||||
// Memoize login component to ensure it's ready immediately
|
||||
const loginComponent = useMemo(() => <Login key="login" />, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Login />;
|
||||
return loginComponent;
|
||||
}
|
||||
|
||||
// Wrapper to check auth before changing tabs
|
||||
const handleTabChange = async (newTab) => {
|
||||
// Check auth before allowing navigation
|
||||
try {
|
||||
await refresh();
|
||||
// If refresh succeeds, user is still authenticated
|
||||
setActiveTab(newTab);
|
||||
} catch (error) {
|
||||
// Auth check failed, user will be set to null and login will show
|
||||
console.error('Auth check failed on navigation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout activeTab={activeTab} onTabChange={setActiveTab}>
|
||||
<Layout activeTab={activeTab} onTabChange={handleTabChange}>
|
||||
{activeTab === 'jobs' && <JobList />}
|
||||
{activeTab === 'submit' && (
|
||||
<JobSubmission onSuccess={() => setActiveTab('jobs')} />
|
||||
<JobSubmission onSuccess={() => handleTabChange('jobs')} />
|
||||
)}
|
||||
{activeTab === 'runners' && <RunnerList />}
|
||||
{activeTab === 'admin' && <AdminPanel />}
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
140
web/src/components/PasswordChange.jsx
Normal file
140
web/src/components/PasswordChange.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
134
web/src/components/UserJobs.jsx
Normal file
134
web/src/components/UserJobs.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { auth } from '../utils/api';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { auth, setAuthErrorHandler } from '../utils/api';
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const userRef = useRef(user);
|
||||
|
||||
// Keep userRef in sync with user state
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
userRef.current = user;
|
||||
}, [user]);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const userData = await auth.getMe();
|
||||
setUser(userData);
|
||||
} catch (error) {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
return userData; // Return user data for verification
|
||||
} catch (error) {
|
||||
// If 401/403, user is not authenticated
|
||||
// Check if it's an auth error
|
||||
if (error.message && (error.message.includes('Unauthorized') || error.message.includes('401') || error.message.includes('403'))) {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
throw error; // Re-throw to allow caller to handle
|
||||
} else {
|
||||
// Other errors (network, etc.) - don't log out, just re-throw
|
||||
// This prevents network issues from logging users out
|
||||
setLoading(false);
|
||||
throw error; // Re-throw to allow caller to handle
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,10 +39,50 @@ export function useAuth() {
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
// Refresh the page to show login
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Set up global auth error handler
|
||||
setAuthErrorHandler(() => {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// Listen for auth errors from API calls
|
||||
const handleAuthErrorEvent = () => {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
};
|
||||
window.addEventListener('auth-error', handleAuthErrorEvent);
|
||||
|
||||
// Initial auth check
|
||||
checkAuth();
|
||||
|
||||
// Periodic auth check every 10 seconds
|
||||
const authInterval = setInterval(() => {
|
||||
// Use ref to check current user state without dependency
|
||||
if (userRef.current) {
|
||||
// Only check if we have a user (don't spam when logged out)
|
||||
checkAuth().catch((error) => {
|
||||
// Only log out if it's actually an auth error, not a network error
|
||||
// Network errors shouldn't log the user out
|
||||
if (error.message && (error.message.includes('Unauthorized') || error.message.includes('401') || error.message.includes('403'))) {
|
||||
// This is a real auth error - user will be set to null by checkAuth
|
||||
}
|
||||
// For other errors (network, etc.), don't log out - just silently fail
|
||||
});
|
||||
}
|
||||
}, 10000); // 10 seconds
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('auth-error', handleAuthErrorEvent);
|
||||
clearInterval(authInterval);
|
||||
};
|
||||
}, []); // Empty deps - only run on mount/unmount
|
||||
|
||||
return { user, loading, logout, refresh: checkAuth };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,5 +9,6 @@ body {
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@apply bg-gray-900 text-gray-100;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
// Global auth error handler - will be set by useAuth hook
|
||||
let onAuthError = null;
|
||||
|
||||
export const setAuthErrorHandler = (handler) => {
|
||||
onAuthError = handler;
|
||||
};
|
||||
|
||||
const handleAuthError = (response) => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Trigger auth error handler if set (this will clear user state)
|
||||
if (onAuthError) {
|
||||
onAuthError();
|
||||
}
|
||||
// Force a re-check of auth status to ensure login is shown
|
||||
// This ensures the App component re-renders with user=null
|
||||
if (typeof window !== 'undefined') {
|
||||
// Dispatch a custom event that useAuth can listen to
|
||||
window.dispatchEvent(new CustomEvent('auth-error'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const api = {
|
||||
async get(endpoint) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`);
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
credentials: 'include', // Include cookies for session
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Handle auth errors before parsing response
|
||||
// Don't redirect on /auth/me - that's the auth check itself
|
||||
if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) {
|
||||
handleAuthError(response);
|
||||
// Don't redirect - let React handle UI change through state
|
||||
}
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const errorMessage = errorData?.error || response.statusText;
|
||||
throw new Error(errorMessage);
|
||||
@@ -16,8 +46,15 @@ export const api = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include', // Include cookies for session
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Handle auth errors before parsing response
|
||||
// Don't redirect on /auth/* endpoints - those are login/logout
|
||||
if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) {
|
||||
handleAuthError(response);
|
||||
// Don't redirect - let React handle UI change through state
|
||||
}
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const errorMessage = errorData?.error || response.statusText;
|
||||
throw new Error(errorMessage);
|
||||
@@ -28,8 +65,15 @@ export const api = {
|
||||
async delete(endpoint) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include', // Include cookies for session
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Handle auth errors before parsing response
|
||||
// Don't redirect on /auth/* endpoints
|
||||
if ((response.status === 401 || response.status === 403) && !endpoint.startsWith('/auth/')) {
|
||||
handleAuthError(response);
|
||||
// Don't redirect - let React handle UI change through state
|
||||
}
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const errorMessage = errorData?.error || response.statusText;
|
||||
throw new Error(errorMessage);
|
||||
@@ -37,19 +81,61 @@ export const api = {
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async uploadFile(endpoint, file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
async uploadFile(endpoint, file, onProgress, mainBlendFile) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (mainBlendFile) {
|
||||
formData.append('main_blend_file', mainBlendFile);
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
// Track upload progress
|
||||
if (onProgress) {
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = (e.loaded / e.total) * 100;
|
||||
onProgress(percentComplete);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
resolve(response);
|
||||
} catch (err) {
|
||||
resolve(xhr.responseText);
|
||||
}
|
||||
} else {
|
||||
// Handle auth errors
|
||||
if (xhr.status === 401 || xhr.status === 403) {
|
||||
handleAuthError({ status: xhr.status });
|
||||
// Don't redirect - let React handle UI change through state
|
||||
}
|
||||
try {
|
||||
const errorData = JSON.parse(xhr.responseText);
|
||||
reject(new Error(errorData.error || xhr.statusText));
|
||||
} catch {
|
||||
reject(new Error(xhr.statusText));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('Upload failed'));
|
||||
});
|
||||
|
||||
xhr.addEventListener('abort', () => {
|
||||
reject(new Error('Upload aborted'));
|
||||
});
|
||||
|
||||
xhr.open('POST', `${API_BASE}${endpoint}`);
|
||||
xhr.withCredentials = true; // Include cookies for session
|
||||
xhr.send(formData);
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const errorMessage = errorData?.error || response.statusText;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -61,6 +147,30 @@ export const auth = {
|
||||
async logout() {
|
||||
return api.post('/auth/logout');
|
||||
},
|
||||
|
||||
async getProviders() {
|
||||
return api.get('/auth/providers');
|
||||
},
|
||||
|
||||
async isLocalLoginAvailable() {
|
||||
return api.get('/auth/local/available');
|
||||
},
|
||||
|
||||
async localRegister(email, name, password) {
|
||||
return api.post('/auth/local/register', { email, name, password });
|
||||
},
|
||||
|
||||
async localLogin(username, password) {
|
||||
return api.post('/auth/local/login', { username, password });
|
||||
},
|
||||
|
||||
async changePassword(oldPassword, newPassword, targetUserId = null) {
|
||||
const body = { old_password: oldPassword, new_password: newPassword };
|
||||
if (targetUserId !== null) {
|
||||
body.target_user_id = targetUserId;
|
||||
}
|
||||
return api.post('/auth/change-password', body);
|
||||
},
|
||||
};
|
||||
|
||||
export const jobs = {
|
||||
@@ -80,19 +190,23 @@ export const jobs = {
|
||||
return api.delete(`/jobs/${id}`);
|
||||
},
|
||||
|
||||
async uploadFile(jobId, file) {
|
||||
return api.uploadFile(`/jobs/${jobId}/upload`, file);
|
||||
async delete(id) {
|
||||
return api.post(`/jobs/${id}/delete`);
|
||||
},
|
||||
|
||||
async uploadFile(jobId, file, onProgress, mainBlendFile) {
|
||||
return api.uploadFile(`/jobs/${jobId}/upload`, file, onProgress, mainBlendFile);
|
||||
},
|
||||
|
||||
async getFiles(jobId) {
|
||||
return api.get(`/jobs/${jobId}/files`);
|
||||
},
|
||||
|
||||
async downloadFile(jobId, fileId) {
|
||||
downloadFile(jobId, fileId) {
|
||||
return `${API_BASE}/jobs/${jobId}/files/${fileId}/download`;
|
||||
},
|
||||
|
||||
async getVideoUrl(jobId) {
|
||||
getVideoUrl(jobId) {
|
||||
return `${API_BASE}/jobs/${jobId}/video`;
|
||||
},
|
||||
|
||||
@@ -131,9 +245,7 @@ export const jobs = {
|
||||
};
|
||||
|
||||
export const runners = {
|
||||
async list() {
|
||||
return api.get('/runners');
|
||||
},
|
||||
// Non-admin runner list removed - use admin.listRunners() instead
|
||||
};
|
||||
|
||||
export const admin = {
|
||||
@@ -160,5 +272,25 @@ export const admin = {
|
||||
async deleteRunner(runnerId) {
|
||||
return api.delete(`/admin/runners/${runnerId}`);
|
||||
},
|
||||
|
||||
async listUsers() {
|
||||
return api.get('/admin/users');
|
||||
},
|
||||
|
||||
async getUserJobs(userId) {
|
||||
return api.get(`/admin/users/${userId}/jobs`);
|
||||
},
|
||||
|
||||
async setUserAdminStatus(userId, isAdmin) {
|
||||
return api.post(`/admin/users/${userId}/admin`, { is_admin: isAdmin });
|
||||
},
|
||||
|
||||
async getRegistrationEnabled() {
|
||||
return api.get('/admin/settings/registration');
|
||||
},
|
||||
|
||||
async setRegistrationEnabled(enabled) {
|
||||
return api.post('/admin/settings/registration', { enabled });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user