initial commit
This commit is contained in:
40
web/src/App.jsx
Normal file
40
web/src/App.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } 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 [activeTab, setActiveTab] = useState('jobs');
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout activeTab={activeTab} onTabChange={setActiveTab}>
|
||||
{activeTab === 'jobs' && <JobList />}
|
||||
{activeTab === 'submit' && (
|
||||
<JobSubmission onSuccess={() => setActiveTab('jobs')} />
|
||||
)}
|
||||
{activeTab === 'runners' && <RunnerList />}
|
||||
{activeTab === 'admin' && <AdminPanel />}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
364
web/src/components/AdminPanel.jsx
Normal file
364
web/src/components/AdminPanel.jsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { admin } from '../utils/api';
|
||||
|
||||
export default function AdminPanel() {
|
||||
const [activeSection, setActiveSection] = useState('tokens');
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [runners, setRunners] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newTokenExpires, setNewTokenExpires] = useState(24);
|
||||
const [newToken, setNewToken] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === 'tokens') {
|
||||
loadTokens();
|
||||
} else if (activeSection === 'runners') {
|
||||
loadRunners();
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
const loadTokens = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await admin.listTokens();
|
||||
setTokens(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tokens:', error);
|
||||
alert('Failed to load tokens');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRunners = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await admin.listRunners();
|
||||
setRunners(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load runners:', error);
|
||||
alert('Failed to load runners');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateToken = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await admin.generateToken(newTokenExpires);
|
||||
setNewToken(data.token);
|
||||
await loadTokens();
|
||||
} catch (error) {
|
||||
console.error('Failed to generate token:', error);
|
||||
alert('Failed to generate token');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeToken = async (tokenId) => {
|
||||
if (!confirm('Are you sure you want to revoke this token?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await admin.revokeToken(tokenId);
|
||||
await loadTokens();
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke token:', error);
|
||||
alert('Failed to revoke token');
|
||||
}
|
||||
};
|
||||
|
||||
const verifyRunner = async (runnerId) => {
|
||||
try {
|
||||
await admin.verifyRunner(runnerId);
|
||||
await loadRunners();
|
||||
alert('Runner verified');
|
||||
} catch (error) {
|
||||
console.error('Failed to verify runner:', error);
|
||||
alert('Failed to verify runner');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRunner = async (runnerId) => {
|
||||
if (!confirm('Are you sure you want to delete this runner?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await admin.deleteRunner(runnerId);
|
||||
await loadRunners();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete runner:', error);
|
||||
alert('Failed to delete runner');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
alert('Copied to clipboard!');
|
||||
};
|
||||
|
||||
const isTokenExpired = (expiresAt) => {
|
||||
return new Date(expiresAt) < new Date();
|
||||
};
|
||||
|
||||
const isTokenUsed = (used) => {
|
||||
return used;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex space-x-4 border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveSection('tokens')}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
Registration Tokens
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSection('runners')}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
Runner Management
|
||||
</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="flex gap-4 items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Expires in (hours)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="168"
|
||||
value={newTokenExpires}
|
||||
onChange={(e) => setNewTokenExpires(parseInt(e.target.value) || 24)}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 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"
|
||||
>
|
||||
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="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">
|
||||
{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"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 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>
|
||||
{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>
|
||||
) : tokens.length === 0 ? (
|
||||
<p className="text-gray-500 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">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Token
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Expires At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{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">
|
||||
{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">
|
||||
Expired
|
||||
</span>
|
||||
) : used ? (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800">
|
||||
Used
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(token.expires_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{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"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'runners' && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">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>
|
||||
) : runners.length === 0 ? (
|
||||
<p className="text-gray-500 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">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Hostname
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Verified
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Last Heartbeat
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{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">
|
||||
{runner.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{runner.hostname}
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
{isOnline ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
runner.verified
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{runner.verified ? 'Verified' : 'Unverified'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{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"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteRunner(runner.id)}
|
||||
className="text-red-600 hover:text-red-800 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
166
web/src/components/JobDetails.jsx
Normal file
166
web/src/components/JobDetails.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { jobs } from '../utils/api';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
|
||||
export default function JobDetails({ job, onClose, onUpdate }) {
|
||||
const [jobDetails, setJobDetails] = useState(job);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [videoUrl, setVideoUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadDetails();
|
||||
const interval = setInterval(loadDetails, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [job.id]);
|
||||
|
||||
const loadDetails = async () => {
|
||||
try {
|
||||
const [details, fileList] = await Promise.all([
|
||||
jobs.get(job.id),
|
||||
jobs.getFiles(job.id),
|
||||
]);
|
||||
setJobDetails(details);
|
||||
setFiles(fileList);
|
||||
|
||||
// Check if there's an MP4 output file
|
||||
const mp4File = fileList.find(
|
||||
(f) => f.file_type === 'output' && f.file_name.endsWith('.mp4')
|
||||
);
|
||||
if (mp4File) {
|
||||
setVideoUrl(jobs.getVideoUrl(job.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load job details:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (fileId, fileName) => {
|
||||
window.open(jobs.downloadFile(job.id, fileId), '_blank');
|
||||
};
|
||||
|
||||
const outputFiles = files.filter((f) => f.file_type === 'output');
|
||||
const inputFiles = files.filter((f) => f.file_type === 'input');
|
||||
|
||||
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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Progress</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{jobDetails.progress.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Frame Range</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{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">
|
||||
{jobDetails.output_format}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{videoUrl && jobDetails.output_format === 'MP4' && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Video Preview
|
||||
</h3>
|
||||
<VideoPlayer videoUrl={videoUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{outputFiles.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 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"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputFiles.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 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"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobDetails.error_message && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
<p className="font-semibold">Error:</p>
|
||||
<p>{jobDetails.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
128
web/src/components/JobList.jsx
Normal file
128
web/src/components/JobList.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { jobs } from '../utils/api';
|
||||
import JobDetails from './JobDetails';
|
||||
|
||||
export default function JobList() {
|
||||
const [jobList, setJobList] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedJob, setSelectedJob] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
const interval = setInterval(loadJobs, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadJobs = async () => {
|
||||
try {
|
||||
const data = await jobs.list();
|
||||
setJobList(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load jobs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (jobId) => {
|
||||
if (!confirm('Are you sure you want to cancel this job?')) return;
|
||||
try {
|
||||
await jobs.cancel(jobId);
|
||||
loadJobs();
|
||||
} catch (error) {
|
||||
alert('Failed to cancel 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',
|
||||
};
|
||||
return colors[status] || colors.pending;
|
||||
};
|
||||
|
||||
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 (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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{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"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-xl font-semibold text-gray-900">{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">
|
||||
<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">
|
||||
<span>Progress</span>
|
||||
<span>{job.progress.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-purple-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedJob && (
|
||||
<JobDetails
|
||||
job={selectedJob}
|
||||
onClose={() => setSelectedJob(null)}
|
||||
onUpdate={loadJobs}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
154
web/src/components/JobSubmission.jsx
Normal file
154
web/src/components/JobSubmission.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { jobs } from '../utils/api';
|
||||
|
||||
export default function JobSubmission({ onSuccess }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
frame_start: 1,
|
||||
frame_end: 10,
|
||||
output_format: 'PNG',
|
||||
});
|
||||
const [file, setFile] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
if (!file) {
|
||||
throw new Error('Please select a Blender file');
|
||||
}
|
||||
|
||||
if (formData.frame_start < 0 || formData.frame_end < formData.frame_start) {
|
||||
throw new Error('Invalid frame range');
|
||||
}
|
||||
|
||||
// Create job
|
||||
const job = await jobs.create({
|
||||
name: formData.name,
|
||||
frame_start: parseInt(formData.frame_start),
|
||||
frame_end: parseInt(formData.frame_end),
|
||||
output_format: formData.output_format,
|
||||
});
|
||||
|
||||
// Upload file
|
||||
await jobs.uploadFile(job.id, file);
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
frame_start: 1,
|
||||
frame_end: 10,
|
||||
output_format: 'PNG',
|
||||
});
|
||||
setFile(null);
|
||||
e.target.reset();
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to submit job');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Job Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
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">
|
||||
Frame Start
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.frame_start}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Frame End
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.frame_end}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 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"
|
||||
>
|
||||
<option value="PNG">PNG</option>
|
||||
<option value="JPEG">JPEG</option>
|
||||
<option value="EXR">EXR</option>
|
||||
<option value="MP4">MP4</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Blender File (.blend)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".blend"
|
||||
onChange={(e) => setFile(e.target.files[0])}
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
83
web/src/components/Layout.jsx
Normal file
83
web/src/components/Layout.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
export default function Layout({ children, activeTab, onTabChange }) {
|
||||
const { user, logout } = useAuth();
|
||||
const isAdmin = user?.is_admin || false;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<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>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-700">{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"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="bg-white border-b border-gray-200">
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
Jobs
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
40
web/src/components/Login.jsx
Normal file
40
web/src/components/Login.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
export default function Login() {
|
||||
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="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>
|
||||
<p className="text-gray-600 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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
92
web/src/components/RunnerList.jsx
Normal file
92
web/src/components/RunnerList.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
57
web/src/components/VideoPlayer.jsx
Normal file
57
web/src/components/VideoPlayer.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
export default function VideoPlayer({ videoUrl, onClose }) {
|
||||
const videoRef = useRef(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setError('Failed to load video');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
video.addEventListener('canplay', handleCanPlay);
|
||||
video.addEventListener('error', handleError);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('canplay', handleCanPlay);
|
||||
video.removeEventListener('error', handleError);
|
||||
};
|
||||
}, [videoUrl]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
controls
|
||||
className="w-full"
|
||||
onLoadedData={() => setLoading(false)}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
35
web/src/hooks/useAuth.js
Normal file
35
web/src/hooks/useAuth.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { auth } from '../utils/api';
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const userData = await auth.getMe();
|
||||
setUser(userData);
|
||||
} catch (error) {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await auth.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
return { user, loading, logout, refresh: checkAuth };
|
||||
}
|
||||
|
||||
10
web/src/main.jsx
Normal file
10
web/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
13
web/src/styles/index.css
Normal file
13
web/src/styles/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
123
web/src/utils/api.js
Normal file
123
web/src/utils/api.js
Normal file
@@ -0,0 +1,123 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
export const api = {
|
||||
async get(endpoint) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async post(endpoint, data) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async delete(endpoint) {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
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,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
|
||||
export const auth = {
|
||||
async getMe() {
|
||||
return api.get('/auth/me');
|
||||
},
|
||||
|
||||
async logout() {
|
||||
return api.post('/auth/logout');
|
||||
},
|
||||
};
|
||||
|
||||
export const jobs = {
|
||||
async list() {
|
||||
return api.get('/jobs');
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
return api.get(`/jobs/${id}`);
|
||||
},
|
||||
|
||||
async create(jobData) {
|
||||
return api.post('/jobs', jobData);
|
||||
},
|
||||
|
||||
async cancel(id) {
|
||||
return api.delete(`/jobs/${id}`);
|
||||
},
|
||||
|
||||
async uploadFile(jobId, file) {
|
||||
return api.uploadFile(`/jobs/${jobId}/upload`, file);
|
||||
},
|
||||
|
||||
async getFiles(jobId) {
|
||||
return api.get(`/jobs/${jobId}/files`);
|
||||
},
|
||||
|
||||
async downloadFile(jobId, fileId) {
|
||||
return `${API_BASE}/jobs/${jobId}/files/${fileId}/download`;
|
||||
},
|
||||
|
||||
async getVideoUrl(jobId) {
|
||||
return `${API_BASE}/jobs/${jobId}/video`;
|
||||
},
|
||||
};
|
||||
|
||||
export const runners = {
|
||||
async list() {
|
||||
return api.get('/runners');
|
||||
},
|
||||
};
|
||||
|
||||
export const admin = {
|
||||
async generateToken(expiresInHours) {
|
||||
return api.post('/admin/runners/tokens', { expires_in_hours: expiresInHours });
|
||||
},
|
||||
|
||||
async listTokens() {
|
||||
return api.get('/admin/runners/tokens');
|
||||
},
|
||||
|
||||
async revokeToken(tokenId) {
|
||||
return api.delete(`/admin/runners/tokens/${tokenId}`);
|
||||
},
|
||||
|
||||
async listRunners() {
|
||||
return api.get('/admin/runners');
|
||||
},
|
||||
|
||||
async verifyRunner(runnerId) {
|
||||
return api.post(`/admin/runners/${runnerId}/verify`);
|
||||
},
|
||||
|
||||
async deleteRunner(runnerId) {
|
||||
return api.delete(`/admin/runners/${runnerId}`);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user