feat: add React frontend with nginx reverse proxy
Browse files- Create React GUI with Vite 6, Tailwind CSS 3, Recharts
- Implement hash-based routing (dashboard, simulation, results)
- Create core components (Button, Card, Input, Spinner)
- Build pages: Dashboard, SimulationForm, SimulationResults
- Add API client with fetch wrapper
- Create multi-stage Dockerfile (Node→Python→Nginx)
- Configure nginx for port 7860 with /api proxy to Django
- Add supervisor for running Django + nginx together
- Add gunicorn for production WSGI server
- Update README with full-stack tech stack
- Frontend builds to 165KB bundle (gzipped: 52KB)
- .gitignore +2 -0
- Dockerfile +59 -6
- README.md +16 -2
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +23 -0
- frontend/postcss.config.js +6 -0
- frontend/src/App.jsx +87 -0
- frontend/src/components/Button.jsx +29 -0
- frontend/src/components/Card.jsx +7 -0
- frontend/src/components/Input.jsx +35 -0
- frontend/src/components/Spinner.jsx +7 -0
- frontend/src/index.css +27 -0
- frontend/src/main.jsx +10 -0
- frontend/src/pages/Dashboard.jsx +49 -0
- frontend/src/pages/SimulationForm.jsx +107 -0
- frontend/src/pages/SimulationResults.jsx +82 -0
- frontend/src/utils/api.js +60 -0
- frontend/tailwind.config.js +11 -0
- frontend/vite.config.js +21 -0
- nginx.conf +30 -0
- pyproject.toml +1 -0
- uv.lock +14 -0
.gitignore
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
# Dependencies and environments
|
| 2 |
.venv/
|
| 3 |
*.egg-info/
|
|
|
|
|
|
|
| 4 |
|
| 5 |
# Test artifacts
|
| 6 |
.coverage
|
|
|
|
| 1 |
# Dependencies and environments
|
| 2 |
.venv/
|
| 3 |
*.egg-info/
|
| 4 |
+
frontend/node_modules/
|
| 5 |
+
frontend/dist/
|
| 6 |
|
| 7 |
# Test artifacts
|
| 8 |
.coverage
|
Dockerfile
CHANGED
|
@@ -1,4 +1,22 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
|
@@ -13,14 +31,49 @@ ENV UV_PROJECT_ENVIRONMENT=/app/.venv
|
|
| 13 |
COPY pyproject.toml ./
|
| 14 |
COPY uv.lock ./
|
| 15 |
|
| 16 |
-
# Install dependencies using uv
|
| 17 |
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 18 |
uv sync --frozen
|
| 19 |
|
| 20 |
# Copy application code
|
| 21 |
COPY . .
|
| 22 |
|
| 23 |
-
# Run migrations
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: Build React frontend
|
| 2 |
+
FROM node:22-alpine AS frontend-builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /frontend
|
| 5 |
+
|
| 6 |
+
# Copy frontend package files
|
| 7 |
+
COPY frontend/package*.json ./
|
| 8 |
+
|
| 9 |
+
# Install dependencies
|
| 10 |
+
RUN npm ci
|
| 11 |
+
|
| 12 |
+
# Copy frontend source
|
| 13 |
+
COPY frontend/ ./
|
| 14 |
+
|
| 15 |
+
# Build React app
|
| 16 |
+
RUN npm run build
|
| 17 |
+
|
| 18 |
+
# Stage 2: Setup Python/Django backend
|
| 19 |
+
FROM python:3.12-slim AS backend
|
| 20 |
|
| 21 |
WORKDIR /app
|
| 22 |
|
|
|
|
| 31 |
COPY pyproject.toml ./
|
| 32 |
COPY uv.lock ./
|
| 33 |
|
| 34 |
+
# Install dependencies using uv
|
| 35 |
RUN --mount=type=cache,target=/root/.cache/uv \
|
| 36 |
uv sync --frozen
|
| 37 |
|
| 38 |
# Copy application code
|
| 39 |
COPY . .
|
| 40 |
|
| 41 |
+
# Run migrations
|
| 42 |
+
RUN /app/.venv/bin/python manage.py migrate
|
| 43 |
+
|
| 44 |
+
# Stage 3: Final image with nginx
|
| 45 |
+
FROM nginx:alpine
|
| 46 |
+
|
| 47 |
+
# Install Python and supervisor
|
| 48 |
+
RUN apk add --no-cache python3 py3-pip supervisor
|
| 49 |
+
|
| 50 |
+
# Copy Python app and venv from backend stage
|
| 51 |
+
COPY --from=backend /app /app
|
| 52 |
+
COPY --from=backend /usr/local/bin/uv /usr/local/bin/uv
|
| 53 |
+
|
| 54 |
+
# Copy React build from frontend stage
|
| 55 |
+
COPY --from=frontend-builder /frontend/dist /usr/share/nginx/html
|
| 56 |
+
|
| 57 |
+
# Copy nginx configuration
|
| 58 |
+
COPY nginx.conf /etc/nginx/nginx.conf
|
| 59 |
+
|
| 60 |
+
# Create supervisor config
|
| 61 |
+
RUN echo '[supervisord]' > /etc/supervisor/conf.d/supervisord.conf && \
|
| 62 |
+
echo 'nodaemon=true' >> /etc/supervisor/conf.d/supervisord.conf && \
|
| 63 |
+
echo '' >> /etc/supervisor/conf.d/supervisord.conf && \
|
| 64 |
+
echo '[program:django]' >> /etc/supervisor/conf.d/supervisord.conf && \
|
| 65 |
+
echo 'command=/app/.venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 2' >> /etc/supervisor/conf.d/supervisord.conf && \
|
| 66 |
+
echo 'directory=/app' >> /etc/supervisor/conf.d/supervisord.conf && \
|
| 67 |
+
echo 'autostart=true' >> /etc/supervisor/conf.d/supervisord.conf && \
|
| 68 |
+
echo 'autorestart=true' >> /etc/supervisor/conf.d/supervisord.conf && \
|
| 69 |
+
echo '' >> /etc/supervisor/conf.d/supervisord.conf && \
|
| 70 |
+
echo '[program:nginx]' >> /etc/supervisor/conf.d/supervisord.conf && \
|
| 71 |
+
echo 'command=nginx -g "daemon off;"' >> /etc/supervisor/conf.d/supervisord.conf && \
|
| 72 |
+
echo 'autostart=true' >> /etc/supervisor/conf.d/supervisord.conf && \
|
| 73 |
+
echo 'autorestart=true' >> /etc/supervisor/conf.d/supervisord.conf
|
| 74 |
+
|
| 75 |
+
WORKDIR /app
|
| 76 |
+
|
| 77 |
+
EXPOSE 7860
|
| 78 |
+
|
| 79 |
+
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
README.md
CHANGED
|
@@ -7,9 +7,9 @@ sdk: docker
|
|
| 7 |
app_port: 7860
|
| 8 |
---
|
| 9 |
|
| 10 |
-
# Wall Construction
|
| 11 |
|
| 12 |
-
A
|
| 13 |
|
| 14 |
## Features
|
| 15 |
|
|
@@ -167,14 +167,28 @@ curl http://localhost:7860/api/profiles/
|
|
| 167 |
|
| 168 |
## Technology Stack
|
| 169 |
|
|
|
|
| 170 |
- **Django 5.2.7 LTS** - Web framework
|
| 171 |
- **Django REST Framework 3.16** - API framework
|
| 172 |
- **Python 3.12** - Programming language
|
|
|
|
| 173 |
- **uv** - Fast Python package manager
|
| 174 |
- **SQLite** - File-based database
|
| 175 |
- **ThreadPoolExecutor** - Parallel cost calculations
|
| 176 |
- **pytest** - Testing framework
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
## Development Standards
|
| 179 |
|
| 180 |
- Dependencies managed via `pyproject.toml` (NOT requirements.txt)
|
|
|
|
| 7 |
app_port: 7860
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Wall Construction Tracker
|
| 11 |
|
| 12 |
+
A full-stack application for tracking multi-profile wall construction operations, ice material consumption, and associated costs. Features a React GUI frontend and Django REST API backend.
|
| 13 |
|
| 14 |
## Features
|
| 15 |
|
|
|
|
| 167 |
|
| 168 |
## Technology Stack
|
| 169 |
|
| 170 |
+
### Backend
|
| 171 |
- **Django 5.2.7 LTS** - Web framework
|
| 172 |
- **Django REST Framework 3.16** - API framework
|
| 173 |
- **Python 3.12** - Programming language
|
| 174 |
+
- **Gunicorn** - WSGI HTTP server
|
| 175 |
- **uv** - Fast Python package manager
|
| 176 |
- **SQLite** - File-based database
|
| 177 |
- **ThreadPoolExecutor** - Parallel cost calculations
|
| 178 |
- **pytest** - Testing framework
|
| 179 |
|
| 180 |
+
### Frontend
|
| 181 |
+
- **React 18** - UI framework
|
| 182 |
+
- **Vite 6** - Build tool
|
| 183 |
+
- **Tailwind CSS 3** - Utility-first CSS
|
| 184 |
+
- **Recharts 2** - Chart library
|
| 185 |
+
- **Hash-based routing** - Client-side navigation
|
| 186 |
+
|
| 187 |
+
### Infrastructure
|
| 188 |
+
- **Nginx** - Reverse proxy and static file server
|
| 189 |
+
- **Supervisor** - Process control system
|
| 190 |
+
- **Docker multi-stage builds** - Optimized deployment
|
| 191 |
+
|
| 192 |
## Development Standards
|
| 193 |
|
| 194 |
- Dependencies managed via `pyproject.toml` (NOT requirements.txt)
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Wall Construction Tracker</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "wall-construction-gui",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^18.3.1",
|
| 13 |
+
"react-dom": "^18.3.1",
|
| 14 |
+
"recharts": "^2.15.0"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"@vitejs/plugin-react": "^4.3.1",
|
| 18 |
+
"vite": "^6.0.1",
|
| 19 |
+
"tailwindcss": "^3.4.1",
|
| 20 |
+
"autoprefixer": "^10.4.18",
|
| 21 |
+
"postcss": "^8.4.35"
|
| 22 |
+
}
|
| 23 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react'
|
| 2 |
+
import Dashboard from './pages/Dashboard'
|
| 3 |
+
import SimulationForm from './pages/SimulationForm'
|
| 4 |
+
import SimulationResults from './pages/SimulationResults'
|
| 5 |
+
|
| 6 |
+
function App() {
|
| 7 |
+
const [route, setRoute] = useState(window.location.hash.slice(1) || 'dashboard')
|
| 8 |
+
const [params, setParams] = useState({})
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
const handleHashChange = () => {
|
| 12 |
+
const hash = window.location.hash.slice(1)
|
| 13 |
+
const [path, query] = hash.split('?')
|
| 14 |
+
setRoute(path || 'dashboard')
|
| 15 |
+
|
| 16 |
+
// Parse query params
|
| 17 |
+
const searchParams = new URLSearchParams(query)
|
| 18 |
+
const paramsObj = {}
|
| 19 |
+
searchParams.forEach((value, key) => {
|
| 20 |
+
paramsObj[key] = value
|
| 21 |
+
})
|
| 22 |
+
setParams(paramsObj)
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
window.addEventListener('hashchange', handleHashChange)
|
| 26 |
+
return () => window.removeEventListener('hashchange', handleHashChange)
|
| 27 |
+
}, [])
|
| 28 |
+
|
| 29 |
+
const navigate = (path, queryParams = {}) => {
|
| 30 |
+
const query = new URLSearchParams(queryParams).toString()
|
| 31 |
+
window.location.hash = query ? `${path}?${query}` : path
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<div className="min-h-screen bg-gray-50">
|
| 36 |
+
<nav className="bg-white shadow-sm mb-8">
|
| 37 |
+
<div className="max-w-7xl mx-auto px-4 py-4">
|
| 38 |
+
<div className="flex items-center justify-between">
|
| 39 |
+
<h1 className="text-xl font-bold text-gray-900">
|
| 40 |
+
Wall Construction Tracker
|
| 41 |
+
</h1>
|
| 42 |
+
<div className="flex gap-4">
|
| 43 |
+
<button
|
| 44 |
+
onClick={() => navigate('dashboard')}
|
| 45 |
+
className={`px-3 py-2 rounded-md ${
|
| 46 |
+
route === 'dashboard'
|
| 47 |
+
? 'bg-blue-100 text-blue-700 font-semibold'
|
| 48 |
+
: 'text-gray-600 hover:text-gray-900'
|
| 49 |
+
}`}
|
| 50 |
+
>
|
| 51 |
+
Dashboard
|
| 52 |
+
</button>
|
| 53 |
+
<button
|
| 54 |
+
onClick={() => navigate('simulation')}
|
| 55 |
+
className={`px-3 py-2 rounded-md ${
|
| 56 |
+
route === 'simulation'
|
| 57 |
+
? 'bg-blue-100 text-blue-700 font-semibold'
|
| 58 |
+
: 'text-gray-600 hover:text-gray-900'
|
| 59 |
+
}`}
|
| 60 |
+
>
|
| 61 |
+
Run Simulation
|
| 62 |
+
</button>
|
| 63 |
+
<button
|
| 64 |
+
onClick={() => navigate('results')}
|
| 65 |
+
className={`px-3 py-2 rounded-md ${
|
| 66 |
+
route === 'results'
|
| 67 |
+
? 'bg-blue-100 text-blue-700 font-semibold'
|
| 68 |
+
: 'text-gray-600 hover:text-gray-900'
|
| 69 |
+
}`}
|
| 70 |
+
>
|
| 71 |
+
View Results
|
| 72 |
+
</button>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</nav>
|
| 77 |
+
|
| 78 |
+
<main className="max-w-7xl mx-auto px-4 pb-12">
|
| 79 |
+
{route === 'dashboard' && <Dashboard navigate={navigate} params={params} />}
|
| 80 |
+
{route === 'simulation' && <SimulationForm navigate={navigate} params={params} />}
|
| 81 |
+
{route === 'results' && <SimulationResults navigate={navigate} params={params} />}
|
| 82 |
+
</main>
|
| 83 |
+
</div>
|
| 84 |
+
)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
export default App
|
frontend/src/components/Button.jsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function Button({
|
| 2 |
+
children,
|
| 3 |
+
variant = 'primary',
|
| 4 |
+
onClick,
|
| 5 |
+
disabled = false,
|
| 6 |
+
type = 'button'
|
| 7 |
+
}) {
|
| 8 |
+
const variants = {
|
| 9 |
+
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
|
| 10 |
+
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900',
|
| 11 |
+
danger: 'bg-red-600 hover:bg-red-700 text-white'
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<button
|
| 16 |
+
type={type}
|
| 17 |
+
onClick={onClick}
|
| 18 |
+
disabled={disabled}
|
| 19 |
+
className={`
|
| 20 |
+
px-4 py-2 rounded-lg font-medium
|
| 21 |
+
transition-colors duration-200
|
| 22 |
+
disabled:opacity-50 disabled:cursor-not-allowed
|
| 23 |
+
${variants[variant]}
|
| 24 |
+
`}
|
| 25 |
+
>
|
| 26 |
+
{children}
|
| 27 |
+
</button>
|
| 28 |
+
)
|
| 29 |
+
}
|
frontend/src/components/Card.jsx
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function Card({ children, className = '' }) {
|
| 2 |
+
return (
|
| 3 |
+
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
| 4 |
+
{children}
|
| 5 |
+
</div>
|
| 6 |
+
)
|
| 7 |
+
}
|
frontend/src/components/Input.jsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function Input({
|
| 2 |
+
label,
|
| 3 |
+
type = 'text',
|
| 4 |
+
value,
|
| 5 |
+
onChange,
|
| 6 |
+
placeholder,
|
| 7 |
+
required = false,
|
| 8 |
+
error
|
| 9 |
+
}) {
|
| 10 |
+
return (
|
| 11 |
+
<div className="mb-4">
|
| 12 |
+
{label && (
|
| 13 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 14 |
+
{label}
|
| 15 |
+
{required && <span className="text-red-500 ml-1">*</span>}
|
| 16 |
+
</label>
|
| 17 |
+
)}
|
| 18 |
+
<input
|
| 19 |
+
type={type}
|
| 20 |
+
value={value}
|
| 21 |
+
onChange={(e) => onChange(e.target.value)}
|
| 22 |
+
placeholder={placeholder}
|
| 23 |
+
required={required}
|
| 24 |
+
className={`
|
| 25 |
+
w-full px-4 py-2 border rounded-lg
|
| 26 |
+
focus:outline-none focus:ring-2 focus:ring-blue-500
|
| 27 |
+
${error ? 'border-red-500' : 'border-gray-300'}
|
| 28 |
+
`}
|
| 29 |
+
/>
|
| 30 |
+
{error && (
|
| 31 |
+
<p className="text-red-500 text-sm mt-1">{error}</p>
|
| 32 |
+
)}
|
| 33 |
+
</div>
|
| 34 |
+
)
|
| 35 |
+
}
|
frontend/src/components/Spinner.jsx
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function Spinner() {
|
| 2 |
+
return (
|
| 3 |
+
<div className="flex items-center justify-center p-8">
|
| 4 |
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
| 5 |
+
</div>
|
| 6 |
+
)
|
| 7 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--color-primary: #3b82f6;
|
| 7 |
+
--color-secondary: #64748b;
|
| 8 |
+
--color-success: #10b981;
|
| 9 |
+
--color-danger: #ef4444;
|
| 10 |
+
--color-warning: #f59e0b;
|
| 11 |
+
--color-ice: #93c5fd;
|
| 12 |
+
--color-gold: #fbbf24;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
body {
|
| 16 |
+
@apply bg-gray-50 text-gray-900;
|
| 17 |
+
margin: 0;
|
| 18 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
| 19 |
+
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
| 20 |
+
sans-serif;
|
| 21 |
+
-webkit-font-smoothing: antialiased;
|
| 22 |
+
-moz-osx-font-smoothing: grayscale;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
code {
|
| 26 |
+
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
| 27 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react'
|
| 2 |
+
import ReactDOM from 'react-dom/client'
|
| 3 |
+
import App from './App.jsx'
|
| 4 |
+
import './index.css'
|
| 5 |
+
|
| 6 |
+
ReactDOM.createRoot(document.getElementById('root')).render(
|
| 7 |
+
<React.StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</React.StrictMode>,
|
| 10 |
+
)
|
frontend/src/pages/Dashboard.jsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Card from '../components/Card'
|
| 2 |
+
import Button from '../components/Button'
|
| 3 |
+
|
| 4 |
+
export default function Dashboard({ navigate }) {
|
| 5 |
+
return (
|
| 6 |
+
<div className="space-y-6">
|
| 7 |
+
<div className="text-center py-12">
|
| 8 |
+
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
| 9 |
+
Wall Construction Tracker
|
| 10 |
+
</h1>
|
| 11 |
+
<p className="text-xl text-gray-600 mb-8">
|
| 12 |
+
Track ice usage and construction costs for the Great Wall of the North
|
| 13 |
+
</p>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 17 |
+
<Card>
|
| 18 |
+
<h3 className="text-lg font-semibold mb-2">Run Simulation</h3>
|
| 19 |
+
<p className="text-gray-600 mb-4">
|
| 20 |
+
Configure wall sections and simulate construction progress
|
| 21 |
+
</p>
|
| 22 |
+
<Button onClick={() => navigate('simulation')}>
|
| 23 |
+
Start Simulation
|
| 24 |
+
</Button>
|
| 25 |
+
</Card>
|
| 26 |
+
|
| 27 |
+
<Card>
|
| 28 |
+
<h3 className="text-lg font-semibold mb-2">View Results</h3>
|
| 29 |
+
<p className="text-gray-600 mb-4">
|
| 30 |
+
Analyze ice usage and construction costs by day
|
| 31 |
+
</p>
|
| 32 |
+
<Button variant="secondary" onClick={() => navigate('results')}>
|
| 33 |
+
View Results
|
| 34 |
+
</Button>
|
| 35 |
+
</Card>
|
| 36 |
+
|
| 37 |
+
<Card>
|
| 38 |
+
<h3 className="text-lg font-semibold mb-2">API Documentation</h3>
|
| 39 |
+
<p className="text-gray-600 mb-4">
|
| 40 |
+
Explore the REST API endpoints
|
| 41 |
+
</p>
|
| 42 |
+
<Button variant="secondary" onClick={() => window.location.href = '/api/'}>
|
| 43 |
+
Browse API
|
| 44 |
+
</Button>
|
| 45 |
+
</Card>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
)
|
| 49 |
+
}
|
frontend/src/pages/SimulationForm.jsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react'
|
| 2 |
+
import Card from '../components/Card'
|
| 3 |
+
import Button from '../components/Button'
|
| 4 |
+
import Input from '../components/Input'
|
| 5 |
+
import Spinner from '../components/Spinner'
|
| 6 |
+
import { api } from '../utils/api'
|
| 7 |
+
|
| 8 |
+
export default function SimulationForm({ navigate }) {
|
| 9 |
+
const [config, setConfig] = useState('21 25 28\n17\n17 22 17 19 17')
|
| 10 |
+
const [numTeams, setNumTeams] = useState('10')
|
| 11 |
+
const [startDate, setStartDate] = useState('2025-10-20')
|
| 12 |
+
const [loading, setLoading] = useState(false)
|
| 13 |
+
const [error, setError] = useState(null)
|
| 14 |
+
const [result, setResult] = useState(null)
|
| 15 |
+
|
| 16 |
+
const handleSubmit = async (e) => {
|
| 17 |
+
e.preventDefault()
|
| 18 |
+
setLoading(true)
|
| 19 |
+
setError(null)
|
| 20 |
+
setResult(null)
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
const data = await api.runSimulation(config, parseInt(numTeams), startDate)
|
| 24 |
+
setResult(data)
|
| 25 |
+
} catch (err) {
|
| 26 |
+
setError(err.message || 'Simulation failed')
|
| 27 |
+
} finally {
|
| 28 |
+
setLoading(false)
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="max-w-4xl mx-auto space-y-6">
|
| 34 |
+
<h1 className="text-3xl font-bold text-gray-900">Run Simulation</h1>
|
| 35 |
+
|
| 36 |
+
<Card>
|
| 37 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 38 |
+
<div>
|
| 39 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 40 |
+
Wall Configuration
|
| 41 |
+
<span className="text-red-500 ml-1">*</span>
|
| 42 |
+
</label>
|
| 43 |
+
<textarea
|
| 44 |
+
value={config}
|
| 45 |
+
onChange={(e) => setConfig(e.target.value)}
|
| 46 |
+
required
|
| 47 |
+
rows="6"
|
| 48 |
+
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 49 |
+
placeholder="Enter wall section heights (space or newline separated)"
|
| 50 |
+
/>
|
| 51 |
+
<p className="text-sm text-gray-500 mt-1">
|
| 52 |
+
Example: "21 25 28" creates 3 sections with heights 21, 25, and 28 feet
|
| 53 |
+
</p>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<Input
|
| 57 |
+
label="Number of Teams"
|
| 58 |
+
type="number"
|
| 59 |
+
value={numTeams}
|
| 60 |
+
onChange={setNumTeams}
|
| 61 |
+
required
|
| 62 |
+
placeholder="10"
|
| 63 |
+
/>
|
| 64 |
+
|
| 65 |
+
<Input
|
| 66 |
+
label="Start Date"
|
| 67 |
+
type="date"
|
| 68 |
+
value={startDate}
|
| 69 |
+
onChange={setStartDate}
|
| 70 |
+
required
|
| 71 |
+
/>
|
| 72 |
+
|
| 73 |
+
<Button type="submit" disabled={loading}>
|
| 74 |
+
{loading ? 'Running Simulation...' : 'Run Simulation'}
|
| 75 |
+
</Button>
|
| 76 |
+
</form>
|
| 77 |
+
|
| 78 |
+
{loading && <Spinner />}
|
| 79 |
+
|
| 80 |
+
{error && (
|
| 81 |
+
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
| 82 |
+
<p className="text-red-800">{error}</p>
|
| 83 |
+
</div>
|
| 84 |
+
)}
|
| 85 |
+
|
| 86 |
+
{result && (
|
| 87 |
+
<div className="mt-6 space-y-4">
|
| 88 |
+
<h2 className="text-xl font-semibold text-green-600">Simulation Complete!</h2>
|
| 89 |
+
<div className="grid grid-cols-2 gap-4">
|
| 90 |
+
<div className="bg-blue-50 p-4 rounded-lg">
|
| 91 |
+
<p className="text-sm text-gray-600">Total Sections</p>
|
| 92 |
+
<p className="text-2xl font-bold text-blue-600">{result.total_sections}</p>
|
| 93 |
+
</div>
|
| 94 |
+
<div className="bg-green-50 p-4 rounded-lg">
|
| 95 |
+
<p className="text-sm text-gray-600">Total Days</p>
|
| 96 |
+
<p className="text-2xl font-bold text-green-600">{result.total_days}</p>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
<Button onClick={() => navigate('results')}>
|
| 100 |
+
View Detailed Results
|
| 101 |
+
</Button>
|
| 102 |
+
</div>
|
| 103 |
+
)}
|
| 104 |
+
</Card>
|
| 105 |
+
</div>
|
| 106 |
+
)
|
| 107 |
+
}
|
frontend/src/pages/SimulationResults.jsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react'
|
| 2 |
+
import Card from '../components/Card'
|
| 3 |
+
import Spinner from '../components/Spinner'
|
| 4 |
+
import { api } from '../utils/api'
|
| 5 |
+
|
| 6 |
+
export default function SimulationResults({ navigate }) {
|
| 7 |
+
const [loading, setLoading] = useState(true)
|
| 8 |
+
const [error, setError] = useState(null)
|
| 9 |
+
const [data, setData] = useState(null)
|
| 10 |
+
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
async function fetchData() {
|
| 13 |
+
try {
|
| 14 |
+
setLoading(true)
|
| 15 |
+
const result = await api.getOverviewTotal()
|
| 16 |
+
setData(result)
|
| 17 |
+
} catch (err) {
|
| 18 |
+
setError(err.message || 'Failed to load results')
|
| 19 |
+
} finally {
|
| 20 |
+
setLoading(false)
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
fetchData()
|
| 24 |
+
}, [])
|
| 25 |
+
|
| 26 |
+
if (loading) return <Spinner />
|
| 27 |
+
|
| 28 |
+
if (error) {
|
| 29 |
+
return (
|
| 30 |
+
<div className="max-w-4xl mx-auto">
|
| 31 |
+
<Card>
|
| 32 |
+
<div className="text-center py-12">
|
| 33 |
+
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
| 34 |
+
No Simulation Data
|
| 35 |
+
</h2>
|
| 36 |
+
<p className="text-gray-600 mb-6">
|
| 37 |
+
{error}
|
| 38 |
+
</p>
|
| 39 |
+
<button
|
| 40 |
+
onClick={() => navigate('simulation')}
|
| 41 |
+
className="text-blue-600 hover:underline"
|
| 42 |
+
>
|
| 43 |
+
Run a simulation first
|
| 44 |
+
</button>
|
| 45 |
+
</div>
|
| 46 |
+
</Card>
|
| 47 |
+
</div>
|
| 48 |
+
)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="max-w-6xl mx-auto space-y-6">
|
| 53 |
+
<h1 className="text-3xl font-bold text-gray-900">Simulation Results</h1>
|
| 54 |
+
|
| 55 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 56 |
+
<Card>
|
| 57 |
+
<h3 className="text-lg font-semibold mb-2">Total Cost</h3>
|
| 58 |
+
<p className="text-4xl font-bold text-[var(--color-gold)]">
|
| 59 |
+
{data.cost} GD
|
| 60 |
+
</p>
|
| 61 |
+
<p className="text-sm text-gray-500 mt-2">Gold Dragons</p>
|
| 62 |
+
</Card>
|
| 63 |
+
|
| 64 |
+
<Card>
|
| 65 |
+
<h3 className="text-lg font-semibold mb-2">Completion Time</h3>
|
| 66 |
+
<p className="text-4xl font-bold text-blue-600">
|
| 67 |
+
{data.day || 'N/A'} {data.day === '1' ? 'day' : 'days'}
|
| 68 |
+
</p>
|
| 69 |
+
<p className="text-sm text-gray-500 mt-2">Total construction time</p>
|
| 70 |
+
</Card>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<Card>
|
| 74 |
+
<h3 className="text-lg font-semibold mb-4">Construction Overview</h3>
|
| 75 |
+
<p className="text-gray-600">
|
| 76 |
+
The simulation has been completed successfully. Total cost for the entire
|
| 77 |
+
construction project is <strong>{data.cost} Gold Dragons</strong>.
|
| 78 |
+
</p>
|
| 79 |
+
</Card>
|
| 80 |
+
</div>
|
| 81 |
+
)
|
| 82 |
+
}
|
frontend/src/utils/api.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const API_BASE = '/api'
|
| 2 |
+
|
| 3 |
+
class ApiError extends Error {
|
| 4 |
+
constructor(message, status, data) {
|
| 5 |
+
super(message)
|
| 6 |
+
this.status = status
|
| 7 |
+
this.data = data
|
| 8 |
+
}
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
async function request(endpoint, options = {}) {
|
| 12 |
+
const url = `${API_BASE}${endpoint}`
|
| 13 |
+
|
| 14 |
+
const config = {
|
| 15 |
+
headers: {
|
| 16 |
+
'Content-Type': 'application/json',
|
| 17 |
+
...options.headers
|
| 18 |
+
},
|
| 19 |
+
...options
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
const response = await fetch(url, config)
|
| 24 |
+
|
| 25 |
+
const data = await response.json()
|
| 26 |
+
|
| 27 |
+
if (!response.ok) {
|
| 28 |
+
throw new ApiError(
|
| 29 |
+
data.message || 'Request failed',
|
| 30 |
+
response.status,
|
| 31 |
+
data
|
| 32 |
+
)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return data
|
| 36 |
+
} catch (error) {
|
| 37 |
+
if (error instanceof ApiError) {
|
| 38 |
+
throw error
|
| 39 |
+
}
|
| 40 |
+
throw new ApiError('Network error', 0, { originalError: error })
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export const api = {
|
| 45 |
+
// Profiles
|
| 46 |
+
getProfiles: () => request('/profiles/'),
|
| 47 |
+
getProfile: (id) => request(`/profiles/${id}/`),
|
| 48 |
+
|
| 49 |
+
// Simulation
|
| 50 |
+
runSimulation: (config, numTeams = 10, startDate) => request('/profiles/simulate/', {
|
| 51 |
+
method: 'POST',
|
| 52 |
+
body: JSON.stringify({ config, num_teams: numTeams, start_date: startDate })
|
| 53 |
+
}),
|
| 54 |
+
|
| 55 |
+
// Analytics
|
| 56 |
+
getProfileDays: (id, day) => request(`/profiles/${id}/days/${day}/`),
|
| 57 |
+
getProfileOverview: (id, day) => request(`/profiles/${id}/overview/${day}/`),
|
| 58 |
+
getOverviewByDay: (day) => request(`/profiles/overview/${day}/`),
|
| 59 |
+
getOverviewTotal: () => request('/profiles/overview/')
|
| 60 |
+
}
|
frontend/tailwind.config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
content: [
|
| 4 |
+
"./index.html",
|
| 5 |
+
"./src/**/*.{js,jsx}",
|
| 6 |
+
],
|
| 7 |
+
theme: {
|
| 8 |
+
extend: {},
|
| 9 |
+
},
|
| 10 |
+
plugins: [],
|
| 11 |
+
}
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
server: {
|
| 7 |
+
port: 5173,
|
| 8 |
+
proxy: {
|
| 9 |
+
'/api': {
|
| 10 |
+
target: 'http://localhost:8000',
|
| 11 |
+
changeOrigin: true
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
build: {
|
| 16 |
+
outDir: 'dist',
|
| 17 |
+
sourcemap: false,
|
| 18 |
+
minify: 'esbuild',
|
| 19 |
+
target: 'es2020'
|
| 20 |
+
}
|
| 21 |
+
})
|
nginx.conf
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
events {
|
| 2 |
+
worker_connections 1024;
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
http {
|
| 6 |
+
include /etc/nginx/mime.types;
|
| 7 |
+
default_type application/octet-stream;
|
| 8 |
+
|
| 9 |
+
server {
|
| 10 |
+
listen 7860;
|
| 11 |
+
server_name _;
|
| 12 |
+
|
| 13 |
+
root /usr/share/nginx/html;
|
| 14 |
+
index index.html;
|
| 15 |
+
|
| 16 |
+
# API proxy to Django backend
|
| 17 |
+
location /api {
|
| 18 |
+
proxy_pass http://127.0.0.1:8000;
|
| 19 |
+
proxy_set_header Host $host;
|
| 20 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 21 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 22 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
# SPA fallback - serve index.html for all routes except /api
|
| 26 |
+
location / {
|
| 27 |
+
try_files $uri $uri/ /index.html;
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
}
|
pyproject.toml
CHANGED
|
@@ -13,6 +13,7 @@ dependencies = [
|
|
| 13 |
"djangorestframework==3.16.0",
|
| 14 |
"django-filter>=24.3",
|
| 15 |
"pydantic>=2.10.6",
|
|
|
|
| 16 |
]
|
| 17 |
|
| 18 |
[project.optional-dependencies]
|
|
|
|
| 13 |
"djangorestframework==3.16.0",
|
| 14 |
"django-filter>=24.3",
|
| 15 |
"pydantic>=2.10.6",
|
| 16 |
+
"gunicorn>=23.0.0",
|
| 17 |
]
|
| 18 |
|
| 19 |
[project.optional-dependencies]
|
uv.lock
CHANGED
|
@@ -121,6 +121,7 @@ dependencies = [
|
|
| 121 |
{ name = "django" },
|
| 122 |
{ name = "django-filter" },
|
| 123 |
{ name = "djangorestframework" },
|
|
|
|
| 124 |
{ name = "loguru" },
|
| 125 |
{ name = "pydantic" },
|
| 126 |
]
|
|
@@ -153,6 +154,7 @@ requires-dist = [
|
|
| 153 |
{ name = "djangorestframework-stubs", marker = "extra == 'dev'", specifier = "==3.16.4" },
|
| 154 |
{ name = "factory-boy", marker = "extra == 'test'", specifier = "==3.3.3" },
|
| 155 |
{ name = "faker", marker = "extra == 'test'", specifier = "==33.3.0" },
|
|
|
|
| 156 |
{ name = "loguru", specifier = "==0.7.3" },
|
| 157 |
{ name = "mypy", marker = "extra == 'dev'", specifier = "==1.18.2" },
|
| 158 |
{ name = "pydantic", specifier = ">=2.10.6" },
|
|
@@ -281,6 +283,18 @@ wheels = [
|
|
| 281 |
{ url = "https://files.pythonhosted.org/packages/d2/7e/553601891ef96f030a1a6cc14d7957fb1c81e394ca89cafccb97e0eb5882/Faker-33.3.0-py3-none-any.whl", hash = "sha256:ae074d9c7ef65817a93b448141a5531a16b2ea2e563dc5774578197c7c84060c", size = 1894526, upload-time = "2025-01-03T21:53:27.711Z" },
|
| 282 |
]
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
[[package]]
|
| 285 |
name = "idna"
|
| 286 |
version = "3.11"
|
|
|
|
| 121 |
{ name = "django" },
|
| 122 |
{ name = "django-filter" },
|
| 123 |
{ name = "djangorestframework" },
|
| 124 |
+
{ name = "gunicorn" },
|
| 125 |
{ name = "loguru" },
|
| 126 |
{ name = "pydantic" },
|
| 127 |
]
|
|
|
|
| 154 |
{ name = "djangorestframework-stubs", marker = "extra == 'dev'", specifier = "==3.16.4" },
|
| 155 |
{ name = "factory-boy", marker = "extra == 'test'", specifier = "==3.3.3" },
|
| 156 |
{ name = "faker", marker = "extra == 'test'", specifier = "==33.3.0" },
|
| 157 |
+
{ name = "gunicorn", specifier = ">=23.0.0" },
|
| 158 |
{ name = "loguru", specifier = "==0.7.3" },
|
| 159 |
{ name = "mypy", marker = "extra == 'dev'", specifier = "==1.18.2" },
|
| 160 |
{ name = "pydantic", specifier = ">=2.10.6" },
|
|
|
|
| 283 |
{ url = "https://files.pythonhosted.org/packages/d2/7e/553601891ef96f030a1a6cc14d7957fb1c81e394ca89cafccb97e0eb5882/Faker-33.3.0-py3-none-any.whl", hash = "sha256:ae074d9c7ef65817a93b448141a5531a16b2ea2e563dc5774578197c7c84060c", size = 1894526, upload-time = "2025-01-03T21:53:27.711Z" },
|
| 284 |
]
|
| 285 |
|
| 286 |
+
[[package]]
|
| 287 |
+
name = "gunicorn"
|
| 288 |
+
version = "23.0.0"
|
| 289 |
+
source = { registry = "https://pypi.org/simple" }
|
| 290 |
+
dependencies = [
|
| 291 |
+
{ name = "packaging" },
|
| 292 |
+
]
|
| 293 |
+
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
|
| 294 |
+
wheels = [
|
| 295 |
+
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
|
| 296 |
+
]
|
| 297 |
+
|
| 298 |
[[package]]
|
| 299 |
name = "idna"
|
| 300 |
version = "3.11"
|