import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { Container, Navbar, Card, ProgressBar, Alert, Button, Form, Row, Col, ListGroup, Badge } from 'react-bootstrap';
const API_URL = process.env.NODE_ENV === 'development' ? 'http://localhost:8000' : '';
const WS_URL = process.env.NODE_ENV === 'development'
? 'ws://localhost:8000'
: window.location.protocol.replace('http', 'ws') + '//' + window.location.host;
// --- Individual Job Item Component ---
const JobItem = ({ job, API_URL, onJobDelete }) => {
const getVariant = (status) => {
switch (status) {
case 'PENDING': return 'info';
case 'PROCESSING': return 'primary';
case 'COMPLETE': return 'success';
case 'FAILED': return 'danger';
default: return 'secondary';
}
};
const handleDelete = async () => {
if (window.confirm(`Are you sure you want to delete job ${job.filename} (${job.id})?`)) {
try {
await axios.delete(`${API_URL}/api/jobs/${job.id}`);
onJobDelete(job.id);
} catch (error) {
console.error("Error deleting job:", error);
alert("Failed to delete job.");
}
}
};
return (
<ListGroup.Item className="d-flex justify-content-between align-items-center">
<div>
<strong>{job.filename}</strong> (ID: {job.id.substring(0, 8) + '...'})
<br />
<small className="text-muted">Status: <Badge bg={getVariant(job.status)}>{job.status}</Badge></small>
{job.status === 'PROCESSING' && <ProgressBar now={job.progress} label={`${job.progress}%`} animated className="mt-1" />}
{job.message && <small className="d-block mt-1">{job.message}</small>}
</div>
<div>
{job.status === 'COMPLETE' && job.download_url && (
<Button variant="success" href={`${API_URL}${job.download_url}`} size="sm" className="me-2">
Download
</Button>
)}
<Button variant="outline-danger" size="sm" onClick={handleDelete}>
Delete
</Button>
</div>
</ListGroup.Item>
);
};
// --- Upload Component (modified) ---
const UploadComponent = ({ setJobs }) => {
const [file, setFile] = useState(null);
const [numLayers, setNumLayers] = useState(20);
const [numPoints, setNumPoints] = useState(30);
const [uploadProgress, setUploadProgress] = useState(0); // For file upload progress
const [uploadStatusMessage, setUploadStatusMessage] = useState('Select a file to begin.');
const [error, setError] = useState(null);
const handleFileChange = (event) => {
setFile(event.target.files[0]);
setUploadStatusMessage(event.target.files[0] ? event.target.files[0].name : 'Select a file to begin.');
setUploadProgress(0);
setError(null);
};
const handleUpload = async () => {
if (!file) return;
setUploadProgress(0);
setError(null);
setUploadStatusMessage('Uploading file...');
const formData = new FormData();
formData.append('file', file);
formData.append('num_layers', numLayers);
formData.append('num_points_per_layer', numPoints);
try {
const uploadResponse = await axios.post(`${API_URL}/upload/`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setUploadProgress(percentCompleted);
setUploadStatusMessage(`Uploading... ${percentCompleted}%`);
},
});
const { job_id, filename, status } = uploadResponse.data;
// Add the new job to the global state immediately
setJobs(prevJobs => [{
id: job_id,
filename: filename,
status: status,
progress: 0,
message: `File '${filename}' uploaded, awaiting processing.`,
num_layers: numLayers,
num_points_per_layer: numPoints,
timestamp: new Date().toISOString() // Use ISO string for consistency
}, ...prevJobs]);
setUploadStatusMessage(`Job ${job_id.substring(0, 8)}... created. Waiting for WebSocket updates.`);
setUploadProgress(0); // Reset upload progress for next file
setFile(null); // Clear the file input
// No longer open WebSocket here. WebSocket will be opened by JobList for ongoing jobs.
} catch (err) {
console.error(err);
setError(err.response?.data?.detail || 'File upload failed. Please try again.');
}
};
return (
<Card className="mb-4">
<Card.Body>
<Card.Title>Upload Mesh File</Card.Title>
<Card.Text>
Select a <code>.obj</code>, <code>.stl</code>, or <code>.3mf</code> file to process. The tool will generate a DXF file containing the layered profiles of your model.
</Card.Text>
<Form>
<Form.Group controlId="formFile" className="mb-3">
<Form.Label>Select Mesh File</Form.Label>
<Form.Control type="file" onChange={handleFileChange} accept=".obj,.stl,.3mf" />
</Form.Group>
<Row className="mb-3">
<Form.Group as={Col} controlId="formNumLayers">
<Form.Label>Number of Layers</Form.Label>
<Form.Control
type="number"
value={numLayers}
onChange={(e) => setNumLayers(parseInt(e.target.value))}
min="2"
max="100"
/>
</Form.Group>
<Form.Group as={Col} controlId="formNumPoints">
<Form.Label>Points per Layer</Form.Label>
<Form.Control
type="number"
value={numPoints}
onChange={(e) => setNumPoints(parseInt(e.target.value))}
min="3"
max="200"
/>
</Form.Group>
</Row>
{file && (
<Button variant="primary" onClick={handleUpload} className="mb-3">
Start Processing
</Button>
)}
</Form>
<hr />
<h6>Upload Status</h6>
<p className="text-muted">{uploadStatusMessage}</p>
{uploadProgress > 0 && uploadProgress < 100 && (
<ProgressBar now={uploadProgress} label={`${uploadProgress}%`} animated />
)}
{error && (
<Alert variant="danger" className="mt-3">
{error}
</Alert>
)}
</Card.Body>
</Card>
);
};
// --- Main App Component (with WebSocket logic) ---
function App() {
const [jobs, setJobs] = useState([]);
const [websocket, setWebsocket] = useState(null);
const [trackingJobId, setTrackingJobId] = useState(null);
// Fetch all existing jobs on component mount
useEffect(() => {
const fetchJobs = async () => {
try {
const response = await axios.get(`${API_URL}/api/jobs`);
setJobs(response.data.map(job => ({ ...job, timestamp: new Date(job.timestamp) })));
} catch (error) {
console.error("Error fetching jobs:", error);
}
};
fetchJobs();
}, []);
// Effect to connect WebSocket for a single QUEUED/PROCESSING job
useEffect(() => {
const jobToTrack = jobs.find(j => j.status === 'QUEUED' || j.status === 'PROCESSING');
if (jobToTrack && (!websocket || trackingJobId !== jobToTrack.id)) {
if (websocket) {
websocket.close(); // Close previous connection if tracking a new job
}
const ws = new WebSocket(`${WS_URL}/ws/${jobToTrack.id}`);
ws.onopen = () => {
console.log(`WebSocket connected for job ${jobToTrack.id}`);
setTrackingJobId(jobToTrack.id);
};
ws.onmessage = (event) => {
const updatedJob = JSON.parse(event.data);
setJobs(prevJobs =>
prevJobs.map(job =>
job.id === updatedJob.id ? { ...job, ...updatedJob, timestamp: new Date(updatedJob.timestamp) } : job
)
);
};
ws.onclose = () => {
console.log(`WebSocket disconnected for job ${jobToTrack.id}`);
setTrackingJobId(null);
// Fetch latest job status to ensure consistency after disconnect
axios.get(`${API_URL}/api/jobs/${jobToTrack.id}`).then(response => {
setJobs(prevJobs =>
prevJobs.map(job =>
job.id === response.data.id ? { ...job, ...response.data, timestamp: new Date(response.data.timestamp) } : job
)
);
});
};
ws.onerror = (error) => {
console.error(`WebSocket error for job ${jobToTrack.id}:`, error);
};
setWebsocket(ws);
}
// Cleanup on component unmount
return () => {
if (websocket) {
websocket.close();
}
};
}, [jobs]); // Rerun when jobs list changes
const handleJobDelete = (jobId) => {
setJobs(prevJobs => prevJobs.filter(job => job.id !== jobId));
};
// Separate lists for display
const processingJob = jobs.find(j => j.status === 'PROCESSING' || j.status === 'QUEUED');
const otherJobs = jobs.filter(j => j.status !== 'PROCESSING' && j.status !== 'QUEUED');
return (
<div>
<Navbar bg="dark" variant="dark">
<Container>
<Navbar.Brand href="#home">DXF Curve Generator</Navbar.Brand>
</Container>
</Navbar>
<Container className="mt-5">
<UploadComponent setJobs={setJobs} />
{processingJob && (
<Card className="mt-4">
<Card.Body>
<Card.Title>Currently Processing</Card.Title>
<ListGroup variant="flush">
<JobItem key={processingJob.id} job={processingJob} API_URL={API_URL} onJobDelete={handleJobDelete} />
</ListGroup>
</Card.Body>
</Card>
)}
<Card className="mt-4">
<Card.Body>
<Card.Title>Job History</Card.Title>
{otherJobs.length === 0 ? (
<Card.Text>No past jobs to show.</Card.Text>
) : (
<ListGroup variant="flush">
{otherJobs.map(job => (
<JobItem key={job.id} job={job} API_URL={API_URL} onJobDelete={handleJobDelete} />
))}
</ListGroup>
)}
</Card.Body>
</Card>
</Container>
</div>
);
}
export default App;