From b3d902ab749ad7e9740a2cea3aedab3db7d1b3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=93=D0=B5=D0=BE=D1=80=D0=B3=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 22:19:45 +0300 Subject: [PATCH] feat: deploy admin panel to stage --- .dockerignore | 8 +++++ .env.example | 9 +++++ .gitea/workflows/build-stage.yaml | 57 +++++++++++++++++++++++++++++++ Dockerfile | 27 ++++++++++----- STAGE_DEPLOYMENT.md | 22 ++++++++++++ nginx.conf | 11 ++++++ src/client/src/api/apiConfig.js | 31 ++++++++++++----- 7 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/build-stage.yaml create mode 100644 STAGE_DEPLOYMENT.md create mode 100644 nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6dec475 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +node_modules +dist +dist-ssr +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9504e32 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Backend content/auth API and media endpoint. +VITE_API_URL="https://server.white-nights-stage.makkah.verstack.ru" +VITE_KRBL_MEDIA="https://server.white-nights-stage.makkah.verstack.ru/media/" + +# Device APIs used by the embedded route demo at /demo/:id. +VITE_GEO_API_URL="https://device.white-nights-stage.makkah.verstack.ru" +VITE_WEATHER_API_URL="https://device.white-nights-stage.makkah.verstack.ru" + +VITE_NEED_AUTH="true" diff --git a/.gitea/workflows/build-stage.yaml b/.gitea/workflows/build-stage.yaml new file mode 100644 index 0000000..b89dc1f --- /dev/null +++ b/.gitea/workflows/build-stage.yaml @@ -0,0 +1,57 @@ +name: Build White Nights admin stage + +on: + workflow_dispatch: + push: + branches: + - feature/stage-service-endpoints + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set image metadata + id: meta + run: | + IMAGE_PREFIX="${{ secrets.REGISTRY_URL }}/krbl/whitenightsadminpanel" + echo "IMAGE_PREFIX=${IMAGE_PREFIX}" >> "$GITEA_OUTPUT" + echo "IMAGE_TAG=$(echo \"${{ gitea.sha }}\" | cut -c1-7)" >> "$GITEA_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the container registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY_URL }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push admin panel + uses: docker/build-push-action@v5 + with: + context: . + push: true + build-args: | + VITE_API_URL=https://server.white-nights-stage.makkah.verstack.ru + VITE_KRBL_MEDIA=https://server.white-nights-stage.makkah.verstack.ru/media/ + VITE_GEO_API_URL=https://device.white-nights-stage.makkah.verstack.ru + VITE_WEATHER_API_URL=https://device.white-nights-stage.makkah.verstack.ru + tags: | + ${{ steps.meta.outputs.IMAGE_PREFIX }}/service-admin-panel:${{ steps.meta.outputs.IMAGE_TAG }} + ${{ steps.meta.outputs.IMAGE_PREFIX }}/service-admin-panel:stage + + - name: Restart backend pod to pick up the stage frontend + env: + KUBE_CONFIG_DATA: ${{ secrets.WNS_KUBE_CONFIG }} + run: | + set -eu + KUBECONFIG_PATH="$RUNNER_TEMP/wn-kubeconfig" + printf '%s' "$KUBE_CONFIG_DATA" > "$KUBECONFIG_PATH" + chmod 600 "$KUBECONFIG_PATH" + export KUBECONFIG="$KUBECONFIG_PATH" + kubectl -n white-nights-stage rollout restart deployment/wn-backend + kubectl -n white-nights-stage rollout status deployment/wn-backend --timeout=5m diff --git a/Dockerfile b/Dockerfile index 25ae166..3f22749 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build the application -FROM node:20-alpine AS build +FROM node:24-alpine AS build # Set working directory WORKDIR /app @@ -7,6 +7,13 @@ WORKDIR /app # Copy package.json and yarn.lock COPY package.json yarn.lock ./ +# Vite embeds these values into the static bundle at build time. When an +# argument is omitted, the checked-in .env remains the fallback. +ARG VITE_API_URL +ARG VITE_GEO_API_URL +ARG VITE_WEATHER_API_URL +ARG VITE_KRBL_MEDIA + # Install dependencies RUN yarn install --frozen-lockfile @@ -14,19 +21,23 @@ RUN yarn install --frozen-lockfile COPY . . # Build the application -RUN yarn build +RUN if [ -n "${VITE_API_URL:-}" ]; then export VITE_API_URL; fi && \ + if [ -n "${VITE_GEO_API_URL:-}" ]; then export VITE_GEO_API_URL; fi && \ + if [ -n "${VITE_WEATHER_API_URL:-}" ]; then export VITE_WEATHER_API_URL; fi && \ + if [ -n "${VITE_KRBL_MEDIA:-}" ]; then export VITE_KRBL_MEDIA; fi && \ + yarn build # Stage 2: Serve the application with Nginx -FROM nginx:alpine +FROM nginxinc/nginx-unprivileged:alpine # Copy the built application from the build stage COPY --from=build /app/dist /usr/share/nginx/html -# Copy nginx configuration (optional, can be added later if needed) -# COPY nginx.conf /etc/nginx/conf.d/default.conf +# Keep client-side routes working on direct navigation and page refresh. +COPY nginx.conf /etc/nginx/conf.d/default.conf -# Expose port 80 -EXPOSE 80 +# The unprivileged image cannot bind to port 80. +EXPOSE 8080 # Start Nginx server -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] diff --git a/STAGE_DEPLOYMENT.md b/STAGE_DEPLOYMENT.md new file mode 100644 index 0000000..66d9490 --- /dev/null +++ b/STAGE_DEPLOYMENT.md @@ -0,0 +1,22 @@ +# Stage deployment + +The admin panel is a static Vite bundle. The backend and device remain +separate services. + +Build a stage image with the service URLs embedded into the bundle: + +```bash +docker build \ + --build-arg VITE_API_URL=https://server.white-nights-stage.makkah.verstack.ru \ + --build-arg VITE_KRBL_MEDIA=https://server.white-nights-stage.makkah.verstack.ru/media/ \ + --build-arg VITE_GEO_API_URL=https://device.white-nights-stage.makkah.verstack.ru \ + --build-arg VITE_WEATHER_API_URL=https://device.white-nights-stage.makkah.verstack.ru \ + -t white-nights-admin-panel:stage . +``` + +The backend URL serves content, authentication, media and admin API requests. +The device URL serves geolocation and weather requests used by `/demo/:id`. + +The device ingress must publish the geolocation and weather paths to the +corresponding device ports. The browser must also be authorized by Authelia +and the backend must allow the frontend origin through CORS. diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..cac4dcd --- /dev/null +++ b/nginx.conf @@ -0,0 +1,11 @@ +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/src/client/src/api/apiConfig.js b/src/client/src/api/apiConfig.js index 85f0b45..8b00b6e 100644 --- a/src/client/src/api/apiConfig.js +++ b/src/client/src/api/apiConfig.js @@ -1,11 +1,24 @@ import axios from "axios"; -const envUrl = import.meta.env.VITE_API_URL || "localhost"; -const baseURL = envUrl.includes("://") ? envUrl : `http://${envUrl}`; -const isFullUrl = - baseURL.includes("://") && - !envUrl.includes("localhost") && - !envUrl.includes("127.0.0.1"); +const trimTrailingSlash = (value) => value.replace(/\/+$/, ""); + +const serviceURL = (rawValue, defaultPort) => { + const value = String(rawValue || "").trim() || "localhost"; + const hasScheme = value.includes("://"); + const url = new URL(hasScheme ? value : `http://${value}`); + + // Bare local hosts use service-specific ports. Full URLs keep their + // configured scheme and port, which is required behind ingress. + if (!hasScheme && !url.port) { + url.port = String(defaultPort); + } + + return trimTrailingSlash(url.toString()); +}; + +const apiURL = import.meta.env.VITE_API_URL || "localhost"; +const geoURL = import.meta.env.VITE_GEO_API_URL || apiURL; +const weatherURL = import.meta.env.VITE_WEATHER_API_URL || apiURL; const addAuthInterceptor = (instance) => { instance.interceptors.request.use((config) => { @@ -17,9 +30,9 @@ const addAuthInterceptor = (instance) => { }); }; -export const apiBaseURL = isFullUrl ? baseURL : `${baseURL}:8080`; -export const geoBaseURL = isFullUrl ? baseURL : `${baseURL}:6001`; -export const weatherBaseURL = isFullUrl ? baseURL : `${baseURL}:6002`; +export const apiBaseURL = serviceURL(apiURL, 8080); +export const geoBaseURL = serviceURL(geoURL, 6001); +export const weatherBaseURL = serviceURL(weatherURL, 6002); export const getMediaUrl = (id) => { const token = localStorage.getItem("token");