diff --git a/compose.cloud.yml b/compose.cloud.yml new file mode 100644 index 0000000..c7aae9a --- /dev/null +++ b/compose.cloud.yml @@ -0,0 +1,42 @@ +# Snipe — cloud managed instance +# Project: snipe-cloud (docker compose -f compose.cloud.yml -p snipe-cloud ...) +# Web: http://127.0.0.1:8514 → menagerie.circuitforge.tech/snipe (via Caddy basicauth) +# API: internal only on snipe-cloud-net (no host port — only reachable via nginx) +# +# Usage: ./manage.sh cloud-start | cloud-stop | cloud-restart | cloud-status | cloud-logs | cloud-build + +services: + api: + build: + context: .. + dockerfile: snipe/Dockerfile + restart: unless-stopped + # No network_mode: host — isolated on snipe-cloud-net; nginx reaches it via 'api:8510' + volumes: + - /devl/snipe-cloud-data:/app/snipe/data + networks: + - snipe-cloud-net + + web: + build: + context: . + dockerfile: docker/web/Dockerfile + args: + # Vite bakes these at image build time — changing them requires cloud-build. + # VITE_BASE_URL: app served under /snipe → asset URLs become /snipe/assets/... + # VITE_API_BASE: prepended to all /api/* fetch calls → /snipe/api/search + VITE_BASE_URL: /snipe + VITE_API_BASE: /snipe + restart: unless-stopped + ports: + - "8514:80" # Caddy (caddy-proxy container) reaches via host.docker.internal:8514 + volumes: + - ./docker/web/nginx.cloud.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - snipe-cloud-net + depends_on: + - api + +networks: + snipe-cloud-net: + driver: bridge diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index de50164..a43eb42 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -4,6 +4,15 @@ WORKDIR /app COPY web/package*.json ./ RUN npm ci --prefer-offline COPY web/ ./ + +# Build-time env vars — Vite bakes these as static strings into the bundle. +# VITE_BASE_URL: URL prefix the app is served under (/ for dev, /snipe for cloud) +# VITE_API_BASE: prefix for all /api/* fetch calls (empty for dev, /snipe for cloud) +ARG VITE_BASE_URL=/ +ARG VITE_API_BASE= +ENV VITE_BASE_URL=$VITE_BASE_URL +ENV VITE_API_BASE=$VITE_API_BASE + RUN npm run build # Stage 2: serve diff --git a/docker/web/nginx.cloud.conf b/docker/web/nginx.cloud.conf new file mode 100644 index 0000000..3c7669a --- /dev/null +++ b/docker/web/nginx.cloud.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Proxy API requests to the FastAPI container via Docker bridge network. + # In cloud, 'api' resolves to the api service container — no host networking needed. + location /api/ { + proxy_pass http://api:8510; + 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 $http_x_forwarded_proto; + } + + # index.html — never cache; ensures clients always get the latest entry point + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files $uri /index.html; + } + + # SPA fallback for all other routes + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets aggressively — content hash in filename guarantees freshness + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/manage.sh b/manage.sh index 7102b65..58a517f 100755 --- a/manage.sh +++ b/manage.sh @@ -2,22 +2,35 @@ set -euo pipefail SERVICE=snipe -PORT=8509 # Vue web UI (nginx) -API_PORT=8510 # FastAPI +PORT=8509 # Vue web UI (nginx) — dev +API_PORT=8510 # FastAPI — dev +CLOUD_PORT=8514 # Vue web UI (nginx) — cloud (menagerie.circuitforge.tech/snipe) COMPOSE_FILE="compose.yml" +CLOUD_COMPOSE_FILE="compose.cloud.yml" +CLOUD_PROJECT="snipe-cloud" usage() { - echo "Usage: $0 {start|stop|restart|status|logs|open|build|update|test}" + echo "Usage: $0 {start|stop|restart|status|logs|open|build|update|test" + echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build}" echo "" - echo " start Build (if needed) and start all services" - echo " stop Stop and remove containers" - echo " restart Stop then start" - echo " status Show running containers" - echo " logs Follow logs (logs api | logs web | logs — defaults to all)" - echo " open Open web UI in browser" - echo " build Rebuild Docker images without cache" - echo " update Pull latest images and rebuild" - echo " test Run pytest test suite in the api container" + echo "Dev:" + echo " start Build (if needed) and start all services" + echo " stop Stop and remove containers" + echo " restart Stop then start" + echo " status Show running containers" + echo " logs [svc] Follow logs (api | web — defaults to all)" + echo " open Open web UI in browser" + echo " build Rebuild Docker images without cache" + echo " update Pull latest images and rebuild" + echo " test Run pytest test suite in the api container" + echo "" + echo "Cloud (menagerie.circuitforge.tech/snipe):" + echo " cloud-start Build cloud images and start snipe-cloud project" + echo " cloud-stop Stop cloud instance" + echo " cloud-restart Stop then start cloud instance" + echo " cloud-status Show cloud containers" + echo " cloud-logs Follow cloud logs [api|web — defaults to all]" + echo " cloud-build Rebuild cloud images without cache (required after code changes)" exit 1 } @@ -67,6 +80,36 @@ case "$cmd" in docker compose -f "$COMPOSE_FILE" exec api \ conda run -n job-seeker python -m pytest /app/snipe/tests/ -v "${@}" ;; + + # ── Cloud commands ──────────────────────────────────────────────────────── + cloud-start) + docker compose -f "$CLOUD_COMPOSE_FILE" -p "$CLOUD_PROJECT" up -d --build + echo "$SERVICE cloud started — https://menagerie.circuitforge.tech/snipe" + ;; + cloud-stop) + docker compose -p "$CLOUD_PROJECT" down --remove-orphans + ;; + cloud-restart) + docker compose -p "$CLOUD_PROJECT" down --remove-orphans + docker compose -f "$CLOUD_COMPOSE_FILE" -p "$CLOUD_PROJECT" up -d --build + echo "$SERVICE cloud restarted — https://menagerie.circuitforge.tech/snipe" + ;; + cloud-status) + docker compose -p "$CLOUD_PROJECT" ps + ;; + cloud-logs) + target="${1:-}" + if [[ -n "$target" ]]; then + docker compose -p "$CLOUD_PROJECT" logs -f "$target" + else + docker compose -p "$CLOUD_PROJECT" logs -f + fi + ;; + cloud-build) + docker compose -f "$CLOUD_COMPOSE_FILE" -p "$CLOUD_PROJECT" build --no-cache + echo "Cloud build complete. Run './manage.sh cloud-restart' to deploy." + ;; + *) usage ;; diff --git a/web/src/stores/search.ts b/web/src/stores/search.ts index 40d903d..855794e 100644 --- a/web/src/stores/search.ts +++ b/web/src/stores/search.ts @@ -84,13 +84,15 @@ export const useSearchStore = defineStore('search', () => { try { // TODO: POST /api/search with { query: q, filters } // API does not exist yet — stub returns empty results + // VITE_API_BASE is '' in dev; '/snipe' under menagerie (baked at build time by Vite) + const apiBase = (import.meta.env.VITE_API_BASE as string) ?? '' const params = new URLSearchParams({ q }) if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice)) if (filters.minPrice != null) params.set('min_price', String(filters.minPrice)) if (filters.pages != null && filters.pages > 1) params.set('pages', String(filters.pages)) if (filters.mustInclude?.trim()) params.set('must_include', filters.mustInclude.trim()) if (filters.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim()) - const res = await fetch(`/api/search?${params}`) + const res = await fetch(`${apiBase}/api/search?${params}`) if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`) const data = await res.json() as {