diff --git a/Dockerfile b/Dockerfile index 56a4049..320a3cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,24 @@ # syntax=docker/dockerfile:1 # ============================================================================= -# CRM System v1.0 - Production Dockerfile -# Multi-stage build: builder (with build tools) + runtime (slim, non-root) -# Base: python:3.12-slim +# LeoCRM v1.0 - Production Dockerfile +# Multi-stage build: frontend (Node) + builder (Python) + runtime (slim) # ============================================================================= -# === Stage 1: Builder === -# Installs build dependencies (needed for compiling asyncpg, cryptography, etc.) +# === Stage 0: Frontend Build === +FROM node:20-slim AS frontend + +WORKDIR /frontend + +# Copy package files first for layer caching +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --silent 2>/dev/null || npm install --silent + +# Copy frontend source and build +COPY frontend/ ./ +RUN npx vite build + +# === Stage 1: Python Builder === FROM python:3.12-slim AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -15,7 +26,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 -# Build tools (gcc, libpq-dev) — needed for asyncpg + python-jose[cryptography] RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ @@ -24,14 +34,10 @@ RUN apt-get update \ WORKDIR /app -# Copy ONLY requirements first for optimal layer caching COPY requirements.txt . - -# Install all production dependencies into a user-local prefix RUN pip install --user --no-cache-dir -r requirements.txt # === Stage 2: Runtime === -# Slim image, non-root user, no build tools FROM python:3.12-slim AS runtime ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -40,7 +46,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ PATH=/home/appuser/.local/bin:$PATH -# Runtime dependencies: libpq5 (for asyncpg), curl (for healthcheck) RUN apt-get update \ && apt-get install -y --no-install-recommends \ libpq5 \ @@ -54,21 +59,23 @@ WORKDIR /app # Copy installed Python packages from builder COPY --from=builder /root/.local /home/appuser/.local -# Copy application source (static files included via app/webui/) +# Copy application source COPY --chown=appuser:appuser . . +# Copy built frontend from frontend stage +COPY --from=frontend --chown=appuser:appuser /frontend/dist /app/frontend/dist + # Make prestart.sh executable RUN chmod +x /app/prestart.sh +# Create storage directory +RUN mkdir -p /data/storage && chown -R appuser:appuser /data + USER appuser -# Internal port (Coolify/Traefik terminate SSL on 443 externally) EXPOSE 8000 -# Healthcheck: hits the root-level /health endpoint defined in app/main.py -# Interval 30s, timeout 10s, 3 retries, 15s start-period (migrations need time) -HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ - CMD curl -fsS http://localhost:8000/health || exit 1 +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -fsS http://localhost:8000/api/v1/health || exit 1 -# Entrypoint runs DB migrations first, then starts uvicorn as PID 1 ENTRYPOINT ["/app/prestart.sh"] diff --git a/app/main.py b/app/main.py index b56ef51..7edf374 100644 --- a/app/main.py +++ b/app/main.py @@ -6,9 +6,12 @@ import time import traceback from contextlib import asynccontextmanager -from fastapi import FastAPI, Request +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware +import os from app.config import get_settings from app.core.db import close_engine, get_engine @@ -123,6 +126,27 @@ def create_app() -> FastAPI: app.include_router(ai_copilot.router) app.include_router(workflows.router) + # ─── Serve frontend static files (SPA) ─────────────────────────────── + # Mount built frontend assets (JS, CSS, images) + frontend_dist = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist") + if os.path.isdir(frontend_dist): + # Serve static assets at /assets + assets_path = os.path.join(frontend_dist, "assets") + if os.path.isdir(assets_path): + app.mount("/assets", StaticFiles(directory=assets_path), name="assets") + + # SPA catch-all: serve index.html for all non-API routes + @app.get("/{full_path:path}") + async def spa_spa(full_path: str): + """Serve index.html for all non-API routes (SPA fallback).""" + # Don't intercept API routes + if full_path.startswith(("api/", "docs", "openapi", "redoc")): + raise HTTPException(status_code=404, detail="Not Found") + index_path = os.path.join(frontend_dist, "index.html") + if os.path.isfile(index_path): + return FileResponse(index_path) + raise HTTPException(status_code=404, detail="Frontend not built") + return app