initial commit

This commit is contained in:
2025-11-21 17:31:18 -06:00
commit 87cb54a17d
2451 changed files with 508075 additions and 0 deletions

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

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

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

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

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

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

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

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