diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..6f52bd63 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# Multi-stage build optimized for Google Cloud Run +FROM node:18-alpine AS frontend-build + +# Build frontend +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Python backend stage +FROM python:3.11-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy Python requirements and install +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Install gunicorn for production WSGI server +RUN pip install gunicorn + +# Copy backend source code +COPY src/ ./src/ +COPY *.py ./ + +# Copy built frontend from previous stage +COPY --from=frontend-build /app/dist ./dist + +# Create necessary directories +RUN mkdir -p saved_graphs plots + +# Create non-root user for security +RUN useradd --create-home --shell /bin/bash app \ + && chown -R app:app /app +USER app + +# Set environment variables +ENV FLASK_APP=src.backend +ENV FLASK_ENV=production +ENV PYTHONPATH=/app +ENV PORT=8080 + +# Expose port (Cloud Run uses PORT env variable) +EXPOSE $PORT + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:$PORT/health || exit 1 + +# Use gunicorn for production +CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 src.backend:app diff --git a/src/App.jsx b/src/App.jsx index aee0c912..69824fb8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,7 @@ import { import '@xyflow/react/dist/style.css'; import './App.css'; import Plot from 'react-plotly.js'; +import { getApiEndpoint } from './config.js'; import ContextMenu from './ContextMenu.jsx'; @@ -294,7 +295,7 @@ export default function App() { globalVariables }; - const response = await fetch('http://localhost:8000/convert-to-python', { + const response = await fetch(getApiEndpoint('/convert-to-python'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -364,7 +365,7 @@ export default function App() { globalVariables }; - const response = await fetch('http://localhost:8000/run-pathsim', { + const response = await fetch(getApiEndpoint('/run-pathsim'), { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/backend.py b/src/backend.py index f5e5db83..41d8dd97 100644 --- a/src/backend.py +++ b/src/backend.py @@ -12,12 +12,31 @@ from .pathsim_utils import make_pathsim_model from pathsim.blocks import Scope -app = Flask(__name__) -CORS( - app, - resources={r"/*": {"origins": "http://localhost:5173"}}, - supports_credentials=True, -) +# Configure Flask app for Cloud Run +app = Flask(__name__, static_folder="../dist", static_url_path="") + +# Configure CORS based on environment +if os.getenv("FLASK_ENV") == "production": + # Production: Allow Cloud Run domains and common domains + CORS( + app, + resources={ + r"/*": { + "origins": ["*"], # Allow all origins for Cloud Run + "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + "allow_headers": ["Content-Type", "Authorization"], + } + }, + ) +else: + # Development: Only allow localhost + CORS( + app, + resources={ + r"/*": {"origins": ["http://localhost:5173", "http://localhost:3000"]} + }, + supports_credentials=True, + ) # Creates directory for saved graphs @@ -25,8 +44,17 @@ os.makedirs(SAVE_DIR, exist_ok=True) -# Health check endpoint for CI/CD -@app.route("/", methods=["GET"]) +# Serve React frontend for production +@app.route("/") +def serve_frontend(): + """Serve the React frontend in production.""" + if os.getenv("FLASK_ENV") == "production": + return app.send_static_file("index.html") + else: + return jsonify({"message": "Fuel Cycle Simulator API", "status": "running"}) + + +# Health check endpoint for Cloud Run @app.route("/health", methods=["GET"]) def health_check(): return jsonify( @@ -165,5 +193,16 @@ def run_pathsim(): return jsonify({"success": False, "error": f"Server error: {str(e)}"}), 500 +# Catch-all route for React Router (SPA routing) +@app.route("/") +def catch_all(path): + """Serve React app for all routes in production (for client-side routing).""" + if os.getenv("FLASK_ENV") == "production": + return app.send_static_file("index.html") + else: + return jsonify({"error": "Route not found"}), 404 + + if __name__ == "__main__": - app.run(port=8000, debug=True) + port = int(os.getenv("PORT", 8000)) + app.run(host="0.0.0.0", port=port, debug=os.getenv("FLASK_ENV") != "production") diff --git a/src/config.js b/src/config.js new file mode 100644 index 00000000..e6f1cc21 --- /dev/null +++ b/src/config.js @@ -0,0 +1,28 @@ +// API configuration for development and production +const API_CONFIG = { + development: { + baseUrl: 'http://localhost:8000' + }, + production: { + baseUrl: '' // Use relative URLs in production (same domain) + } +}; + +// Get the current environment +const getCurrentEnvironment = () => { + return process.env.NODE_ENV === 'production' ? 'production' : 'development'; +}; + +// Get the API base URL for the current environment +export const getApiUrl = () => { + const env = getCurrentEnvironment(); + return API_CONFIG[env].baseUrl; +}; + +// Helper function to construct full API endpoint URLs +export const getApiEndpoint = (endpoint) => { + const baseUrl = getApiUrl(); + return `${baseUrl}${endpoint}`; +}; + +export default API_CONFIG;