diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..a75577a --- /dev/null +++ b/.env.production.example @@ -0,0 +1,6 @@ +# Production environment (copy to server as /opt/nomadweb/.env) +DOMAIN=nomadweb.nomadro.com +NEXT_PUBLIC_API_URL=https://nomadweb.nomadro.com/api/v1 +CORS_ORIGINS=https://nomadweb.nomadro.com,http://nomadweb.nomadro.com +POCKETBASE_ADMIN_EMAIL=admin@nomadflow.io +POCKETBASE_ADMIN_PASSWORD=change-me-in-production diff --git a/deploy/nginx/nomadweb.conf b/deploy/nginx/nomadweb.conf new file mode 100644 index 0000000..7a2b026 --- /dev/null +++ b/deploy/nginx/nomadweb.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name nomadweb.nomadro.com; + + client_max_body_size 20M; + + location /api/ { + proxy_pass http://127.0.0.1:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /docs { + proxy_pass http://127.0.0.1:8000/docs; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /openapi.json { + proxy_pass http://127.0.0.1:8000/openapi.json; + proxy_set_header Host $host; + } + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..85b800f --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,39 @@ +services: + pocketbase: + image: ghcr.io/muchobien/pocketbase:latest + container_name: nomadflow-pb + volumes: + - ./pocketbase/pb_data:/pb_data + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: ./backend + container_name: nomadflow-api + ports: + - "127.0.0.1:8000:8000" + environment: + - POCKETBASE_URL=http://pocketbase:8090 + - POCKETBASE_ADMIN_EMAIL=${POCKETBASE_ADMIN_EMAIL:-admin@nomadflow.io} + - POCKETBASE_ADMIN_PASSWORD=${POCKETBASE_ADMIN_PASSWORD:-admin123456} + - CORS_ORIGINS=${CORS_ORIGINS:-https://nomadweb.nomadro.com,http://nomadweb.nomadro.com} + depends_on: + pocketbase: + condition: service_healthy + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://nomadweb.nomadro.com/api/v1} + container_name: nomadflow-web + ports: + - "127.0.0.1:3000:3000" + depends_on: + - backend + restart: unless-stopped diff --git a/frontend/Dockerfile b/frontend/Dockerfile index be2dc28..9e7e0bd 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,7 +3,7 @@ FROM node:20-alpine AS base FROM base AS deps WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci +RUN npm ci --legacy-peer-deps || npm install --legacy-peer-deps FROM base AS builder WORKDIR /app diff --git a/frontend/src/components/Charts.tsx b/frontend/src/components/Charts.tsx index 364d5fd..5041f14 100644 --- a/frontend/src/components/Charts.tsx +++ b/frontend/src/components/Charts.tsx @@ -12,23 +12,25 @@ interface Props { const COLORS = ["#FF6B6B", "#FFE66D", "#4ECDC4", "#A78BFA", "#F472B6", "#60A5FA"]; -function loadChartJS(): Promise { +function loadChartJS(): Promise<{ Chart: ChartConstructor }> { return new Promise((resolve, reject) => { - if (typeof window !== "undefined" && (window as unknown as { Chart?: unknown }).Chart) { - resolve({ Chart: (window as unknown as { Chart: typeof import("chart.js").Chart }).Chart } as typeof import("chart.js")); + if (typeof window !== "undefined" && (window as unknown as { Chart?: ChartConstructor }).Chart) { + resolve({ Chart: (window as unknown as { Chart: ChartConstructor }).Chart }); return; } const script = document.createElement("script"); script.src = "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"; script.onload = () => { - const Chart = (window as unknown as { Chart: typeof import("chart.js").Chart }).Chart; - resolve({ Chart } as typeof import("chart.js")); + resolve({ Chart: (window as unknown as { Chart: ChartConstructor }).Chart }); }; script.onerror = reject; document.head.appendChild(script); }); } +type ChartInstance = { destroy: () => void }; +type ChartConstructor = new (ctx: HTMLCanvasElement, config: unknown) => ChartInstance; + export default function Charts({ cost, speed, growth, radar }: Props) { const costRef = useRef(null); const speedRef = useRef(null); @@ -36,7 +38,7 @@ export default function Charts({ cost, speed, growth, radar }: Props) { const radarRef = useRef(null); useEffect(() => { - let charts: InstanceType[] = []; + let charts: ChartInstance[] = []; loadChartJS().then(({ Chart }) => { const isLight = document.documentElement.getAttribute("data-theme") === "light"; diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 0000000..35cf9cb --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -euo pipefail + +APP_DIR="${APP_DIR:-/opt/nomadweb}" +DOMAIN="${DOMAIN:-nomadweb.nomadro.com}" +GIT_REPO="${GIT_REPO:-https://gitea.dsx2020.com/eric/nomadweb.git}" +BRANCH="${BRANCH:-main}" + +echo "==> NomadFlow production deploy" +echo " Dir: $APP_DIR" +echo " Domain: $DOMAIN" + +export DEBIAN_FRONTEND=noninteractive + +if ! command -v docker &>/dev/null; then + echo "==> Installing Docker..." + curl -fsSL https://get.docker.com | sh + systemctl enable docker + systemctl start docker +fi + +if ! command -v nginx &>/dev/null; then + echo "==> Installing Nginx..." + apt-get update -qq + apt-get install -y -qq nginx git curl + systemctl enable nginx +fi + +if [ ! -d "$APP_DIR/.git" ]; then + echo "==> Cloning repository..." + mkdir -p "$(dirname "$APP_DIR")" + git clone "$GIT_REPO" "$APP_DIR" +else + echo "==> Pulling latest..." + cd "$APP_DIR" + git fetch origin + git reset --hard "origin/$BRANCH" +fi + +cd "$APP_DIR" + +export NEXT_PUBLIC_API_URL="https://${DOMAIN}/api/v1" +export CORS_ORIGINS="https://${DOMAIN},http://${DOMAIN}" + +echo "==> Building and starting containers..." +docker compose -f docker-compose.prod.yml down 2>/dev/null || true +docker compose -f docker-compose.prod.yml build --no-cache +docker compose -f docker-compose.prod.yml up -d + +echo "==> Configuring Nginx..." +cp deploy/nginx/nomadweb.conf "/etc/nginx/sites-available/${DOMAIN}" +ln -sf "/etc/nginx/sites-available/${DOMAIN}" "/etc/nginx/sites-enabled/${DOMAIN}" +rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true +nginx -t +systemctl reload nginx + +if command -v certbot &>/dev/null; then + echo "==> Renewing SSL certificate..." + certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos --register-unsafely-without-email --redirect 2>/dev/null || \ + certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos -m "admin@${DOMAIN}" --redirect || true +fi + +echo "==> Health check..." +sleep 5 +curl -sf "http://127.0.0.1:8000/api/v1/health" && echo " API OK" +curl -sf -o /dev/null "http://127.0.0.1:3000" && echo " Frontend OK" + +echo "" +echo "Deploy complete!" +echo " Site: https://${DOMAIN}" +echo " API: https://${DOMAIN}/api/v1/health"