+
+
+
+
Welcome Back
+
Sign in to your account
+
- {% if app.user %}
-
- You are logged in as {{ app.user.userIdentifier }},
Logout
-
- {% endif %}
+
+ {% if error %}
+
+ Login Failed: {{ error.messageKey|trans(error.messageData, 'security') }}
+
+
+ {% endif %}
-
Sign in
+
+ {% if app.user %}
+
+
Already signed in: You are logged in as
{{ app.user.userIdentifier }}
+
Logout here if you want to sign in with a different account.
+
+ {% endif %}
-
-
-
{% endblock %}
diff --git a/build/grafana/Dockerfile b/build/grafana/Dockerfile
deleted file mode 100644
index c965146..0000000
--- a/build/grafana/Dockerfile
+++ /dev/null
@@ -1,5 +0,0 @@
-FROM grafana/grafana:12.4
-
-COPY grafana/ /etc/grafana/provisioning
-
-CMD ["/run.sh"]
\ No newline at end of file
diff --git a/build/nginx/default.conf b/build/nginx/default.conf
index a888bf5..7763606 100644
--- a/build/nginx/default.conf
+++ b/build/nginx/default.conf
@@ -7,8 +7,18 @@ server {
error_log /var/log/nginx/project_error.log;
access_log /var/log/nginx/project_access.log;
+ # Serve static React app assets directly without PHP fallback
+ location /dist/ {
+ try_files $uri =404;
+ }
+
+ # Serve asset references from CSS/JS files (fonts, images, etc.)
+ location /assets/ {
+ alias /var/www/project/public/dist/assets/;
+ try_files $uri =404;
+ }
+
location / {
- # try to serve file directly, fallback to index.php
try_files $uri /index.php$is_args$args;
}
@@ -22,7 +32,6 @@ server {
location ~ ^/index\.php(/|$) {
# Sets the address of a FastCGI server. The address can be specified as a domain name or IP address, and a port
- # fastcgi_pass php:9000;
fastcgi_pass app:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
@@ -59,4 +68,4 @@ server {
location ~ \.php$ {
return 404;
}
-}
\ No newline at end of file
+}
diff --git a/build/node/.dockerignore b/build/node/.dockerignore
deleted file mode 100644
index 41a5346..0000000
--- a/build/node/.dockerignore
+++ /dev/null
@@ -1,2 +0,0 @@
-.gitignore
-node_modules
\ No newline at end of file
diff --git a/build/node/Dockerfile b/build/node/Dockerfile
deleted file mode 100644
index 9e7de24..0000000
--- a/build/node/Dockerfile
+++ /dev/null
@@ -1,45 +0,0 @@
-# Build-Stage
-FROM node:25.2-alpine AS build
-
-WORKDIR /app
-
-# Abhängigkeiten installieren
-COPY frontend/package.json .
-COPY frontend/package-lock.json* .
-RUN npm ci
-
-# Quellcode kopieren und bauen
-COPY frontend/src ./src
-COPY frontend/tests ./tests
-COPY frontend/.eslintrc.cjs .
-COPY frontend/.prettier.rc .
-COPY frontend/eslint.config.js .
-COPY frontend/index.html .
-COPY frontend/tsconfig.app.json .
-COPY frontend/tsconfig.json .
-COPY frontend/tsconfig.node.json .
-COPY frontend/vite.config.ts .
-COPY frontend/vitest.config.ts .
-
-RUN npm run build
-
-# Produktions-Stage
-FROM node:25.2-alpine AS prod
-
-WORKDIR /app
-
-# Setze Node auf Produktionsmodus
-ENV NODE_ENV=production
-
-# Installiere serve zum Hosten der statischen Dateien
-RUN npm install -g serve
-
-# Kopiere die Build-Dateien von der Build-Stage
-COPY --from=build /app/dist ./dist
-COPY frontend/inject.env.cjs .
-
-# Port 3500 für die App
-EXPOSE 3500
-
-# Starte die Anwendung und injiziere die Umgebungsvariable
-CMD ["sh", "-c", "node inject.env.cjs --indexPath=./dist/index.html && serve -s dist -l 3500"]
\ No newline at end of file
diff --git a/build/php/.dockerignore b/build/php/.dockerignore
deleted file mode 100644
index 497b398..0000000
--- a/build/php/.dockerignore
+++ /dev/null
@@ -1,10 +0,0 @@
-.git
-.gitignore
-backend/.phpunit.result.cache
-backend/.phpunit.cache
-backend/.php-cs-fixer.cache
-backend/.deptrac.cache
-backend/diagram.*
-backend/coverage/
-backend/vendor/
-backend/var/
\ No newline at end of file
diff --git a/build/php/Dockerfile b/build/php/Dockerfile
index 6a041f5..8158ea6 100644
--- a/build/php/Dockerfile
+++ b/build/php/Dockerfile
@@ -1,3 +1,6 @@
+# -----------------------------------
+# Base-Stage (Development - Debian for XDebug)
+# -----------------------------------
FROM php:8.4-fpm AS base
WORKDIR /var/www/project
@@ -14,6 +17,41 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY build/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
+# -----------------------------------
+# Base-Alpine-Stage (Production - Alpine for smaller image)
+# -----------------------------------
+FROM php:8.4-fpm-alpine AS base-alpine
+
+WORKDIR /var/www/project
+
+RUN apk add --no-cache \
+ icu-libs libzip rabbitmq-c \
+ && apk add --no-cache --virtual .build-deps \
+ build-base autoconf icu-dev libzip-dev rabbitmq-c-dev \
+ && docker-php-ext-configure intl \
+ && docker-php-ext-install intl pdo opcache pdo_mysql zip \
+ && pecl install opentelemetry redis amqp \
+ && docker-php-ext-enable opentelemetry redis amqp \
+ && apk del .build-deps \
+ && rm -rf /tmp/* /var/cache/apk/*
+
+COPY build/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
+
+# -----------------------------------
+# Frontend-Stage (Node + Vite build)
+# -----------------------------------
+FROM node:20-alpine AS frontend
+
+WORKDIR /var/www/project/frontend
+
+COPY frontend/package*.json ./
+RUN npm ci
+
+COPY frontend/ .
+
+# Build React app with Vite
+RUN OTEL_COLLECTOR_ADDRESS="http://localhost:30571/v1/logs" npm run build
+
# -----------------------------------
# Development-Stage (with Composer + XDebug)
# -----------------------------------
@@ -60,10 +98,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# -----------------------------------
-# Production-Stage (no Composer, no XDebug)
-# Note: from vendor!
+# Production-Stage (Alpine - no Composer, no XDebug)
# -----------------------------------
-FROM base AS prod
+FROM base-alpine AS prod
COPY --from=vendor /var/www/project/vendor ./vendor
@@ -76,6 +113,5 @@ COPY backend/src ./src
COPY backend/symfony.lock .
COPY backend/templates ./templates
-#RUN mkdir -p var/cache var/log && chmod -R 777 var
-# The following might not be required once we have the shared cache - check it!
-#RUN mkdir var && chmod -R 777 var
+# Copy built React frontend from frontend stage
+COPY --from=frontend /var/www/project/frontend/dist ./public/dist
diff --git a/build/php/Dockerfile.alpine b/build/php/Dockerfile.alpine
deleted file mode 100644
index b262999..0000000
--- a/build/php/Dockerfile.alpine
+++ /dev/null
@@ -1,74 +0,0 @@
-FROM php:8.4.3-fpm-alpine3.20 AS base
-
-WORKDIR /var/www/project
-
-RUN apk add --no-cache --virtual .build-deps \
- build-base autoconf icu-dev libzip-dev \
- && apk add --no-cache libintl git zip icu-libs \
- && docker-php-ext-configure intl \
- && docker-php-ext-install intl pdo opcache pdo_mysql zip \
- && pecl install opentelemetry redis \
- && docker-php-ext-enable opentelemetry redis \
- && apk del .build-deps icu-dev libzip-dev
-
-COPY build/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
-
-# -----------------------------------
-# Development-Stage (with Composer + XDebug)
-# -----------------------------------
-FROM base AS dev
-
-COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer
-
-RUN apk add --no-cache --virtual .build-deps \
- build-base linux-headers autoconf && \
- pecl install xdebug && docker-php-ext-enable xdebug && \
- echo "xdebug.mode=debug,coverage" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
- echo "xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
- apk del .build-deps
-
-EXPOSE 9000
-
-CMD ["php-fpm"]
-
-# -----------------------------------
-# Vendor-Stage (for Production)
-# -----------------------------------
-FROM base AS vendor
-
-COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer
-
-COPY backend/composer.json .
-COPY backend/composer.lock .
-COPY backend/.env .env
-COPY backend/bin ./bin
-COPY backend/src ./src
-COPY backend/public ./public
-
-ENV COMPOSER_ALLOW_SUPERUSER=1
-
-RUN apk add --no-cache --virtual .composer-deps \
- icu-libs git zip unzip \
- && composer install \
- --no-dev \
- --optimize-autoloader \
- --classmap-authoritative \
- && apk del .composer-deps
-
-# -----------------------------------
-# Production-Stage (no Composer, no XDebug)
-# -----------------------------------
-FROM base AS prod
-
-COPY --from=vendor /var/www/backend/vendor ./vendor
-
-COPY backend/bin ./bin
-COPY backend/config ./config
-COPY backend/migrations ./migrations
-COPY backend/public ./public
-COPY backend/src ./src
-COPY backend/templates ./templates
-
-EXPOSE 9000
-
-CMD ["php-fpm"]
\ No newline at end of file
diff --git a/dashboard/assets/config.yml b/dashboard/assets/config.yml
index df34a7a..d74b0e7 100644
--- a/dashboard/assets/config.yml
+++ b/dashboard/assets/config.yml
@@ -17,13 +17,18 @@ services:
target: "_blank"
- name: "Application (legacy)"
subtitle: "Symfony + TWIG + MySQL"
- icon: "fas fa-desktop"
+ icon: "fas fa-mobile-retro"
url: "http://localhost:8080"
target: "_blank"
- name: "Application (modern)"
+ subtitle: "Symfony + React + MySQL"
+ icon: "fas fa-desktop"
+ url: "http://localhost:8080/spa"
+ target: "_blank"
+ - name: "Application (Vite HMR)"
subtitle: "Symfony + React + MySQL"
icon: "fas fa-tablet-screen-button"
- url: "http://localhost:8090"
+ url: "http://localhost:5173"
target: "_blank"
- name: "Infrastructure"
diff --git a/docker-compose.yaml b/docker-compose.yaml
index f50fdc3..c1b921a 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -18,7 +18,10 @@ services:
# php-fpm
app:
- image: ${APP_IMAGE}
+ build:
+ context: .
+ dockerfile: build/php/Dockerfile
+ target: dev
ports:
- "9000:9000"
# not required on Windows:
@@ -148,21 +151,33 @@ services:
networks:
- fullstack-symfony-react
- # React + Material UI frontend
+ # Frontend build service: builds Vite assets for production/staging
+ # Runs once on startup, copies dist/ to backend/public/dist for serving
+ frontend-build:
+ image: ${NODE_IMAGE}
+ working_dir: /app
+ volumes:
+ - ./frontend:/app
+ - ./backend/public/dist:/dist-out
+ networks:
+ - fullstack-symfony-react
+ command: ["sh", "-c", "npm ci && npm run build && cp -r dist/* /dist-out/ && echo '✓ Frontend build complete'"]
+
+ # React + Material UI frontend with Vite dev server for HMR
frontend:
image: ${NODE_IMAGE}
working_dir: /app
volumes:
- ./frontend:/app
ports:
- - "8090:5173"
+ - "5173:5173"
environment:
- NODE_ENV=development
- BACKEND_API_URL=http://localhost:8080/api
- OTEL_COLLECTOR_ADDRESS=http://localhost:4318
networks:
- fullstack-symfony-react
- command: ["sh", "-c", "npm install && node inject.env.cjs --indexPath=./index.html && npm run dev"]
+ command: ["sh", "-c", "npm ci && npm run dev"]
# message queue
rabbitmq:
diff --git a/frontend/index.html b/frontend/index.html
index ae8e3fa..13fc6b8 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,8 +1,20 @@
+
-
+
React + K8s demo app
diff --git a/frontend/inject.env.cjs b/frontend/inject.env.cjs
deleted file mode 100644
index bc70b32..0000000
--- a/frontend/inject.env.cjs
+++ /dev/null
@@ -1,41 +0,0 @@
-const fs = require("fs");
-const { parseArgs } = require("node:util");
-
-const { BACKEND_API_URL, OTEL_COLLECTOR_ADDRESS } = process.env;
-
-const args = process.argv;
-const options = {
- indexPath: {
- type: "string",
- },
-};
-const { values } = parseArgs({
- args,
- options,
- allowPositionals: true,
-});
-
-const { indexPath } = values;
-
-fs.readFile(indexPath, "utf8", (err, data) => {
- if (err) {
- return console.error("Error reading index.html:", err);
- }
-
- const withBackendApiUrl = data.replace(
- '',
- ``,
- );
-
- const withCollectorAddress = withBackendApiUrl.replace(
- '',
- ``,
- );
-
- fs.writeFile(indexPath, withCollectorAddress, "utf8", (err) => {
- if (err) {
- return console.error("Error writing index.html:", err);
- }
- console.log("Environment variable injected successfully.");
- });
-});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 421663a..a9263b6 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -3336,17 +3336,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz",
- "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==",
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
+ "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.58.0",
- "@typescript-eslint/type-utils": "8.58.0",
- "@typescript-eslint/utils": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0",
+ "@typescript-eslint/scope-manager": "8.58.1",
+ "@typescript-eslint/type-utils": "8.58.1",
+ "@typescript-eslint/utils": "8.58.1",
+ "@typescript-eslint/visitor-keys": "8.58.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -3359,23 +3359,23 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.58.0",
+ "@typescript-eslint/parser": "^8.58.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz",
- "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==",
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz",
+ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
- "@typescript-eslint/scope-manager": "8.58.0",
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0",
+ "@typescript-eslint/scope-manager": "8.58.1",
+ "@typescript-eslint/types": "8.58.1",
+ "@typescript-eslint/typescript-estree": "8.58.1",
+ "@typescript-eslint/visitor-keys": "8.58.1",
"debug": "^4.4.3"
},
"engines": {
@@ -3391,14 +3391,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz",
- "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==",
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz",
+ "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.58.0",
- "@typescript-eslint/types": "^8.58.0",
+ "@typescript-eslint/tsconfig-utils": "^8.58.1",
+ "@typescript-eslint/types": "^8.58.1",
"debug": "^4.4.3"
},
"engines": {
@@ -3413,14 +3413,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz",
- "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==",
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz",
+ "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0"
+ "@typescript-eslint/types": "8.58.1",
+ "@typescript-eslint/visitor-keys": "8.58.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3431,9 +3431,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz",
- "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==",
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz",
+ "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3448,15 +3448,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz",
- "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==",
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz",
+ "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0",
- "@typescript-eslint/utils": "8.58.0",
+ "@typescript-eslint/types": "8.58.1",
+ "@typescript-eslint/typescript-estree": "8.58.1",
+ "@typescript-eslint/utils": "8.58.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -3473,9 +3473,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz",
- "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==",
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz",
+ "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3487,16 +3487,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz",
- "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==",
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz",
+ "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.58.0",
- "@typescript-eslint/tsconfig-utils": "8.58.0",
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/visitor-keys": "8.58.0",
+ "@typescript-eslint/project-service": "8.58.1",
+ "@typescript-eslint/tsconfig-utils": "8.58.1",
+ "@typescript-eslint/types": "8.58.1",
+ "@typescript-eslint/visitor-keys": "8.58.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -3515,16 +3515,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz",
- "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==",
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz",
+ "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.58.0",
- "@typescript-eslint/types": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0"
+ "@typescript-eslint/scope-manager": "8.58.1",
+ "@typescript-eslint/types": "8.58.1",
+ "@typescript-eslint/typescript-estree": "8.58.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3539,13 +3539,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz",
- "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==",
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz",
+ "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.58.0",
+ "@typescript-eslint/types": "8.58.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -4211,9 +4211,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001786",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
- "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==",
+ "version": "1.0.30001787",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
+ "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==",
"dev": true,
"funding": [
{
@@ -5018,9 +5018,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.332",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.332.tgz",
- "integrity": "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ==",
+ "version": "1.5.334",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz",
+ "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
"dev": true,
"license": "ISC"
},
@@ -5054,9 +5054,9 @@
}
},
"node_modules/es-abstract": {
- "version": "1.24.1",
- "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
- "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==",
+ "version": "1.24.2",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
+ "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7574,9 +7574,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.8",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
- "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "version": "8.5.9",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
+ "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"dev": true,
"funding": [
{
@@ -7723,9 +7723,9 @@
}
},
"node_modules/react": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
- "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
+ "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"peer": true,
"engines": {
@@ -7733,22 +7733,22 @@
}
},
"node_modules/react-dom": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
- "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
- "react": "^19.2.4"
+ "react": "^19.2.5"
}
},
"node_modules/react-is": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
- "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
+ "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
"license": "MIT"
},
"node_modules/react-refresh": {
@@ -8171,14 +8171,14 @@
}
},
"node_modules/side-channel-list": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
- "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
- "object-inspect": "^1.13.3"
+ "object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -8579,14 +8579,14 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
- "version": "0.2.15",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
- "picomatch": "^4.0.3"
+ "picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -8817,16 +8817,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.58.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz",
- "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==",
+ "version": "8.58.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz",
+ "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.58.0",
- "@typescript-eslint/parser": "8.58.0",
- "@typescript-eslint/typescript-estree": "8.58.0",
- "@typescript-eslint/utils": "8.58.0"
+ "@typescript-eslint/eslint-plugin": "8.58.1",
+ "@typescript-eslint/parser": "8.58.1",
+ "@typescript-eslint/typescript-estree": "8.58.1",
+ "@typescript-eslint/utils": "8.58.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg
new file mode 100644
index 0000000..11f2f7f
--- /dev/null
+++ b/frontend/public/vite.svg
@@ -0,0 +1 @@
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 07f2390..4bc0e57 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3,20 +3,52 @@ import { MyErrorBoundary, CustomThemeProvider, Loading } from "./components";
import Router from "./Router";
import { Notifier, useRememberMe } from "./features";
import { UserContextProvider } from "./features/auth";
+import { BrowserRouter } from "react-router-dom";
+import { useEffect } from "react";
+import { BACKEND_API_URL } from "./config/env";
-export default function App() {
+function AppContent() {
const { pending, me } = useRememberMe();
+
+ // Redirect to backend login if user is not authenticated
+ useEffect(() => {
+ if (!pending && !me) {
+ // Extract backend base URL from API URL (remove /api suffix)
+ const backendBaseUrl = BACKEND_API_URL.replace(/\/api$/, "");
+
+ // Use relative SPA route as _target_path
+ // In development: redirects back to backend's SPA route (same as production)
+ // After login, user is redirected to /spa which serves the React app
+ const targetPath = encodeURIComponent("/spa");
+
+ window.location.href = `${backendBaseUrl}/login?_target_path=${targetPath}`;
+ }
+ }, [pending, me]);
+
+ if (pending) {
+ return
;
+ }
+
+ if (!me) {
+ // This should rarely happen due to the effect above, but guard against it
+ return
;
+ }
+
+ return (
+
+
+
+ );
+}
+
+export default function App() {
return (
- {pending ? (
-
- ) : (
-
-
-
- )}
+
+
+
diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx
index 7c645ea..9454da2 100644
--- a/frontend/src/Router.tsx
+++ b/frontend/src/Router.tsx
@@ -1,36 +1,27 @@
-import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
+import { Routes, Route, Navigate } from "react-router-dom";
import { NavBar, NotFound } from "./components";
import { styled } from "@mui/material";
-import { Games, UserContext, LoginView } from "./features";
+import { Games, UserContext } from "./features";
import { useContext } from "react";
const Offset = styled("div")(({ theme }) => theme.mixins.toolbar);
export default function Router() {
- const { user, loginAsync, pending, logout } = useContext(UserContext);
+ const { user, logout } = useContext(UserContext);
+
+ if (!user) {
+ return null;
+ }
return (
-
- {!user ? (
-
- }
- />
- } />
-
- ) : (
- <>
-
-
-
- } />
- } />
- } />
- } />
-
- >
- )}
-
+ <>
+
+
+
+ } />
+ } />
+ } />
+
+ >
);
}
diff --git a/frontend/src/features/auth/LoginView.tsx b/frontend/src/features/auth/LoginView.tsx
deleted file mode 100644
index 8686410..0000000
--- a/frontend/src/features/auth/LoginView.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import { useState } from "react";
-import {
- TextField,
- Button,
- Box,
- styled,
- Divider,
- Checkbox,
- FormControlLabel,
-} from "@mui/material";
-import MuiCard from "@mui/material/Card";
-import Stack from "@mui/material/Stack";
-import { useNavigate, useLocation } from "react-router-dom";
-import { Google } from "../../assets/icons/Google";
-import { Facebook } from "../../assets/icons/Facebook";
-import Flower from "../../assets/icons/Flower";
-
-const Card = styled(MuiCard)(({ theme }) => ({
- display: "flex",
- flexDirection: "column",
- alignSelf: "center",
- width: "100%",
- padding: theme.spacing(4),
- gap: theme.spacing(2),
- margin: "auto",
- [theme.breakpoints.up("sm")]: {
- maxWidth: "340px",
- },
- boxShadow:
- "hsla(220, 30%, 5%, 0.05) 0px 5px 15px 0px, hsla(220, 25%, 10%, 0.05) 0px 15px 35px -5px",
- ...theme.applyStyles("dark", {
- boxShadow:
- "hsla(220, 30%, 5%, 0.5) 0px 5px 15px 0px, hsla(220, 25%, 10%, 0.08) 0px 15px 35px -5px",
- }),
-}));
-
-const LoginViewContainer = styled(Stack)(({ theme }) => ({
- height: "100dvh",
- width: "100vw", // Volle Breite
- overflow: "hidden", // Kein Scrollen
- position: "relative",
- padding: theme.spacing(2),
- boxSizing: "border-box",
- alignItems: "center", // Horizontal zentriert
- justifyContent: "center", // Vertikal zentriert
- [theme.breakpoints.up("sm")]: {
- padding: theme.spacing(4),
- },
- "&::before": {
- content: '""',
- display: "block",
- position: "absolute",
- zIndex: -1,
- inset: 0,
- backgroundImage:
- "radial-gradient(ellipse at 50% 50%, hsl(210, 100%, 97%), hsl(0, 0%, 100%))",
- backgroundRepeat: "no-repeat",
- ...theme.applyStyles("dark", {
- backgroundImage:
- "radial-gradient(at 50% 50%, hsla(210, 100%, 16%, 0.5), hsl(220, 30%, 5%))",
- }),
- },
-}));
-
-type Props = {
- loginAsync: (
- email: string,
- password: string,
- rememberMe: boolean,
- ) => Promise
;
- pending: boolean;
-};
-
-export default function LoginView({ loginAsync, pending }: Props) {
- const [email, setEmail] = useState(undefined);
- const [password, setPassword] = useState(undefined);
- const [rememberMe, setRememberMe] = useState(true);
- const navigate = useNavigate();
- const location = useLocation();
- const from = (location.state as { from?: Location })?.from?.pathname || "/";
-
- const toggleRememberMe = () => setRememberMe(!rememberMe);
-
- const doLogin = () => {
- loginAsync(email!, password!, rememberMe).then(() =>
- navigate(from, { replace: true }),
- );
- };
-
- const canSubmit =
- (email as unknown as boolean) && (password as unknown as boolean);
-
- const submit = (event: React.FormEvent) => {
- event.preventDefault();
- doLogin();
- };
-
- return (
-
-
-
-
-
-
- setEmail(e.target.value)}
- />
- setPassword(e.target.value)}
- />
-
- }
- label="Remember me"
- />
-
-
- or
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/features/auth/UserContext.tsx b/frontend/src/features/auth/UserContext.tsx
index 8e089ba..31bf32a 100644
--- a/frontend/src/features/auth/UserContext.tsx
+++ b/frontend/src/features/auth/UserContext.tsx
@@ -6,22 +6,12 @@ export interface User {
type UserContextType = {
user?: User;
- loginAsync: (
- email: string,
- password: string,
- rememberMe: boolean,
- ) => Promise;
logout: () => void;
- pending: boolean;
};
const INITIAL_STATE: UserContextType = {
user: undefined,
- /* eslint-disable-next-line @typescript-eslint/no-unused-vars */
- loginAsync: (_email: string, _password: string, _rememberMe: boolean) =>
- Promise.resolve(),
logout: () => {},
- pending: false,
};
export const UserContext = createContext(INITIAL_STATE);
diff --git a/frontend/src/features/auth/UserContextProvider.tsx b/frontend/src/features/auth/UserContextProvider.tsx
index 7a147ba..7d023f5 100644
--- a/frontend/src/features/auth/UserContextProvider.tsx
+++ b/frontend/src/features/auth/UserContextProvider.tsx
@@ -1,7 +1,7 @@
-import React, { useContext, useState } from "react";
+import React from "react";
import { User, UserContext } from "./UserContext";
import { NotifierContext } from "../notifier/NotifierContext";
-import { loginAsync, logoutAsync } from "./api";
+import { logoutAsync } from "./api";
type Props = {
children: React.ReactNode;
@@ -9,41 +9,22 @@ type Props = {
};
export default function UserContextProvider({ children, me }: Props) {
- const [user, setUser] = useState(me);
- const [pending, setPending] = useState(false);
- const { show } = useContext(NotifierContext);
+ const [user] = React.useState(me);
+ const { show } = React.useContext(NotifierContext);
- const login = async (
- email: string,
- password: string,
- rememberMe: boolean,
- ) => {
- setPending(true);
+ const logout = () => {
try {
- const user = await loginAsync(email, password, rememberMe);
- setUser(user);
+ logoutAsync();
} catch (ex: unknown) {
const error = ex as Error;
show(error.message);
- } finally {
- setPending(false);
}
};
- const logout = () => {
- setPending(true);
- logoutAsync()
- .then(() => setUser(undefined))
- .catch((ex: Error) => show(ex.message))
- .finally(() => setPending(false));
- };
-
return (
diff --git a/frontend/src/features/auth/api.ts b/frontend/src/features/auth/api.ts
index e604517..f993691 100644
--- a/frontend/src/features/auth/api.ts
+++ b/frontend/src/features/auth/api.ts
@@ -5,45 +5,15 @@ interface ErrorResponseType {
error: string;
}
-export async function loginAsync(
- email: string,
- password: string,
- rememberMe: boolean,
-): Promise {
- const url = `${BACKEND_API_URL}/login`;
- const response = await fetch(url, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- credentials: "include",
- body: JSON.stringify({
- email: email,
- password: password,
- _remember_me: rememberMe,
- }),
- });
-
- const obj = await response.json();
-
- if (!response.ok) {
- const errorObj = obj as ErrorResponseType;
- throw Error(errorObj.error);
- }
-
- return obj;
-}
-
-export async function logoutAsync(): Promise {
- const url = `${BACKEND_API_URL}/logout`;
- const response = await fetch(url, {
- method: "POST",
- credentials: "include",
- });
-
- if (!response.ok) {
- throw Error(`Logout failed with ${response.status}!`);
- }
+/**
+ * Logout by redirecting to server logout endpoint.
+ * Server clears session and redirects to login form.
+ * Non-async but kept in auth API module for consistency.
+ */
+export function logoutAsync(): void {
+ // Extract backend base URL from API URL (remove /api suffix)
+ const backendBaseUrl = BACKEND_API_URL.replace(/\/api$/, "");
+ window.location.href = `${backendBaseUrl}/logout`;
}
export async function checkRememberMeAsync(): Promise {
diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts
index 477eb64..ebdbc31 100644
--- a/frontend/src/features/auth/index.ts
+++ b/frontend/src/features/auth/index.ts
@@ -1,4 +1,4 @@
export { UserContext, type User } from "./UserContext";
export { default as UserContextProvider } from "./UserContextProvider";
export { default as useRememberMe } from "./useRememberMe";
-export { default as LoginView } from "./LoginView";
+
diff --git a/frontend/src/features/games/fetchGames.ts b/frontend/src/features/games/fetchGames.ts
index 126988c..e876e1d 100644
--- a/frontend/src/features/games/fetchGames.ts
+++ b/frontend/src/features/games/fetchGames.ts
@@ -2,7 +2,8 @@ import { BACKEND_API_URL } from "../../config/env";
import { ErrorResponseType, GameType } from "./types";
export async function fetchGamesAsync(): Promise {
- const url = new URL(`${BACKEND_API_URL}/games`);
+ const urlPath = `${BACKEND_API_URL}/games`;
+ const url = new URL(urlPath, window.location.origin);
url.searchParams.append("from_cache", "yes");
const response = await fetch(url, { method: "GET", credentials: "include" });
diff --git a/frontend/src/features/games/fetchStatistics.ts b/frontend/src/features/games/fetchStatistics.ts
index 92de37a..8e798e2 100644
--- a/frontend/src/features/games/fetchStatistics.ts
+++ b/frontend/src/features/games/fetchStatistics.ts
@@ -7,7 +7,8 @@ export async function fetchStatisticsAsync(
if (gameId === "") {
throw Error("Invalid game ID!");
}
- const url = new URL(`${BACKEND_API_URL}/games/${gameId}`);
+ const urlPath = `${BACKEND_API_URL}/games/${gameId}`;
+ const url = new URL(urlPath, window.location.origin);
url.searchParams.append("from_cache", "yes");
const response = await fetch(url, {
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 0e43ae8..114e8da 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,7 +1,28 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
+// Plugin to inject environment variables into index.html
+function injectEnvPlugin() {
+ return {
+ name: "inject-env",
+ transformIndexHtml(html: string) {
+ const backendApiUrl = process.env.BACKEND_API_URL || "http://localhost:8080/api";
+ const otelCollectorAddress = process.env.OTEL_COLLECTOR_ADDRESS || "http://localhost:4318";
+
+ return html
+ .replace(
+ '',
+ ``
+ )
+ .replace(
+ '',
+ ``
+ );
+ },
+ };
+}
+
// https://vite.dev/config/
export default defineConfig({
- plugins: [react()],
+ plugins: [injectEnvPlugin(), react()],
});
diff --git a/grafana/dashboards/logs-traces-metrics-k8s.json b/grafana/dashboards/logs-traces-metrics-k8s.json
index c9e9be1..a79216f 100644
--- a/grafana/dashboards/logs-traces-metrics-k8s.json
+++ b/grafana/dashboards/logs-traces-metrics-k8s.json
@@ -18,7 +18,7 @@
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
- "id": 4,
+ "id": 1,
"links": [],
"panels": [
{
@@ -41,7 +41,7 @@
},
{
"color": "red",
- "value": 400
+ "value": 80
}
]
}
@@ -49,12 +49,12 @@
"overrides": []
},
"gridPos": {
- "h": 6,
- "w": 3,
+ "h": 5,
+ "w": 5,
"x": 0,
"y": 0
},
- "id": 8,
+ "id": 2,
"options": {
"minVizHeight": 75,
"minVizWidth": 75,
@@ -78,173 +78,14 @@
"uid": "prometheus"
},
"disableTextWrap": false,
- "editorMode": "code",
- "exemplar": false,
- "expr": "command_executed_milliseconds",
- "format": "time_series",
- "fullMetaSearch": false,
- "includeNullMetadata": true,
- "instant": true,
- "interval": "",
- "legendFormat": "Command executed (ms)",
- "range": false,
- "refId": "Command executed (ms)",
- "useBackend": false
- },
- {
- "datasource": {
- "type": "prometheus",
- "uid": "prometheus"
- },
- "disableTextWrap": false,
- "editorMode": "code",
- "exemplar": false,
- "expr": "message_handled_milliseconds",
- "format": "time_series",
- "fullMetaSearch": false,
- "includeNullMetadata": true,
- "instant": true,
- "legendFormat": "Command executed (ms)",
- "range": false,
- "refId": "Message handled (ms)",
- "useBackend": false
- }
- ],
- "title": "",
- "type": "gauge"
- },
- {
- "datasource": {
- "type": "prometheus",
- "uid": "prometheus"
- },
- "fieldConfig": {
- "defaults": {
- "color": {
- "mode": "palette-classic"
- },
- "custom": {
- "axisBorderShow": false,
- "axisCenteredZero": false,
- "axisColorMode": "text",
- "axisLabel": "",
- "axisPlacement": "auto",
- "barAlignment": 0,
- "barWidthFactor": 0.6,
- "drawStyle": "line",
- "fillOpacity": 0,
- "gradientMode": "none",
- "hideFrom": {
- "legend": false,
- "tooltip": false,
- "viz": false
- },
- "insertNulls": false,
- "lineInterpolation": "linear",
- "lineWidth": 1,
- "pointSize": 5,
- "scaleDistribution": {
- "type": "linear"
- },
- "showPoints": "auto",
- "spanNulls": false,
- "stacking": {
- "group": "A",
- "mode": "none"
- },
- "thresholdsStyle": {
- "mode": "off"
- }
- },
- "fieldMinMax": false,
- "mappings": [],
- "thresholds": {
- "mode": "percentage",
- "steps": [
- {
- "color": "green",
- "value": null
- },
- {
- "color": "red",
- "value": 2000
- }
- ]
- },
- "unit": "none"
- },
- "overrides": [
- {
- "matcher": {
- "id": "byName",
- "options": "Value #Message handled (ms)"
- },
- "properties": [
- {
- "id": "color",
- "value": {
- "fixedColor": "dark-orange",
- "mode": "fixed"
- }
- }
- ]
- },
- {
- "matcher": {
- "id": "byName",
- "options": "Value #Command executed (ms)"
- },
- "properties": [
- {
- "id": "color",
- "value": {
- "fixedColor": "dark-green",
- "mode": "fixed"
- }
- }
- ]
- }
- ]
- },
- "gridPos": {
- "h": 6,
- "w": 9,
- "x": 3,
- "y": 0
- },
- "id": 9,
- "options": {
- "legend": {
- "calcs": [],
- "displayMode": "list",
- "placement": "bottom",
- "showLegend": true
- },
- "tooltip": {
- "hideZeros": false,
- "mode": "single",
- "sort": "none"
- }
- },
- "pluginVersion": "11.4.0",
- "targets": [
- {
- "datasource": {
- "type": "prometheus",
- "uid": "prometheus"
- },
- "disableTextWrap": false,
- "editorMode": "code",
- "exemplar": false,
+ "editorMode": "builder",
"expr": "command_executed_milliseconds",
- "format": "time_series",
"fullMetaSearch": false,
"includeNullMetadata": true,
"instant": false,
- "interval": "",
- "legendFormat": "Command executed (ms)",
+ "legendFormat": "Executed (ms)",
"range": true,
- "refId": "Command executed (ms)",
+ "refId": "A",
"useBackend": false
},
{
@@ -253,21 +94,19 @@
"uid": "prometheus"
},
"disableTextWrap": false,
- "editorMode": "code",
- "exemplar": false,
+ "editorMode": "builder",
"expr": "message_handled_milliseconds",
- "format": "time_series",
"fullMetaSearch": false,
"includeNullMetadata": true,
"instant": false,
- "legendFormat": "Message handled (ms)",
+ "legendFormat": "Handled (ms)",
"range": true,
- "refId": "Message handled (ms)",
+ "refId": "B",
"useBackend": false
}
],
- "title": "",
- "type": "timeseries"
+ "title": "Metrics",
+ "type": "gauge"
},
{
"datasource": {
@@ -283,11 +122,10 @@
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
- "axisGridShow": false,
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
- "barWidthFactor": 1,
+ "barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 100,
"gradientMode": "opacity",
@@ -307,7 +145,7 @@
"type": "linear"
},
"showPoints": "never",
- "spanNulls": false,
+ "spanNulls": true,
"stacking": {
"group": "A",
"mode": "percent"
@@ -331,121 +169,15 @@
]
}
},
- "overrides": [
- {
- "matcher": {
- "id": "byName",
- "options": "success"
- },
- "properties": [
- {
- "id": "color",
- "value": {
- "fixedColor": "dark-green",
- "mode": "fixed"
- }
- }
- ]
- },
- {
- "matcher": {
- "id": "byName",
- "options": "server-error"
- },
- "properties": [
- {
- "id": "color",
- "value": {
- "fixedColor": "dark-red",
- "mode": "fixed"
- }
- }
- ]
- },
- {
- "matcher": {
- "id": "byName",
- "options": "client-error"
- },
- "properties": [
- {
- "id": "color",
- "value": {
- "fixedColor": "dark-orange",
- "mode": "fixed"
- }
- }
- ]
- },
- {
- "matcher": {
- "id": "byName",
- "options": "Value #success"
- },
- "properties": [
- {
- "id": "color",
- "value": {
- "fixedColor": "dark-green",
- "mode": "fixed"
- }
- }
- ]
- },
- {
- "matcher": {
- "id": "byName",
- "options": "Value #client_error"
- },
- "properties": [
- {
- "id": "color",
- "value": {
- "fixedColor": "dark-yellow",
- "mode": "fixed"
- }
- }
- ]
- },
- {
- "matcher": {
- "id": "byName",
- "options": "Value #server_error"
- },
- "properties": [
- {
- "id": "color",
- "value": {
- "fixedColor": "dark-red",
- "mode": "fixed"
- }
- }
- ]
- },
- {
- "matcher": {
- "id": "byName",
- "options": "5xx"
- },
- "properties": [
- {
- "id": "color",
- "value": {
- "fixedColor": "dark-red",
- "mode": "fixed"
- }
- }
- ]
- }
- ]
+ "overrides": []
},
"gridPos": {
- "h": 6,
- "w": 12,
- "x": 12,
+ "h": 5,
+ "w": 19,
+ "x": 5,
"y": 0
},
- "id": 7,
+ "id": 1,
"options": {
"legend": {
"calcs": [],
@@ -454,8 +186,7 @@
"showLegend": true
},
"tooltip": {
- "hideZeros": false,
- "mode": "none",
+ "mode": "single",
"sort": "none"
}
},
@@ -467,16 +198,15 @@
"uid": "prometheus"
},
"disableTextWrap": false,
- "editorMode": "builder",
- "exemplar": false,
+ "editorMode": "code",
"expr": "my_http_response_total{message=\"success\"}",
- "format": "time_series",
"fullMetaSearch": false,
+ "hide": false,
"includeNullMetadata": true,
"instant": false,
- "legendFormat": "success",
+ "legendFormat": "2xx",
"range": true,
- "refId": "success",
+ "refId": "success (2xx)",
"useBackend": false
},
{
@@ -485,16 +215,14 @@
"uid": "prometheus"
},
"disableTextWrap": false,
- "editorMode": "builder",
- "exemplar": false,
+ "editorMode": "code",
"expr": "my_http_response_total{message=\"client_error\"}",
- "format": "time_series",
"fullMetaSearch": false,
"includeNullMetadata": true,
"instant": false,
"legendFormat": "4xx",
"range": true,
- "refId": "client_error",
+ "refId": "client_error (4xx)",
"useBackend": false
},
{
@@ -503,16 +231,15 @@
"uid": "prometheus"
},
"disableTextWrap": false,
- "editorMode": "builder",
- "exemplar": false,
+ "editorMode": "code",
"expr": "my_http_response_total{message=\"server_error\"}",
- "format": "time_series",
"fullMetaSearch": false,
+ "hide": false,
"includeNullMetadata": true,
"instant": false,
"legendFormat": "5xx",
"range": true,
- "refId": "server_error",
+ "refId": "server_error (5xx)",
"useBackend": false
}
],
@@ -522,30 +249,27 @@
{
"datasource": {
"type": "loki",
- "uid": "loki"
+ "uid": "PEF36D7C306617170"
},
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
- "h": 6,
- "w": 24,
+ "h": 8,
+ "w": 12,
"x": 0,
- "y": 6
+ "y": 5
},
- "id": 4,
+ "id": 3,
"options": {
- "dedupStrategy": "exact",
- "enableInfiniteScrolling": false,
+ "dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
- "showControls": false,
"showLabels": false,
- "showTime": true,
+ "showTime": false,
"sortOrder": "Descending",
- "unwrappedColumns": false,
"wrapLogMessage": false
},
"pluginVersion": "11.4.0",
@@ -553,44 +277,41 @@
{
"datasource": {
"type": "loki",
- "uid": "loki"
+ "uid": "PEF36D7C306617170"
},
"editorMode": "code",
- "expr": "{service_name=\"backend\"} | channel = \"app\" | detected_level > 100",
+ "expr": "{service_name=\"app\"} | channel=\"app\" | detected_level > 100",
"queryType": "range",
"refId": "A"
}
],
- "title": "Backend",
+ "title": "App",
"type": "logs"
},
{
"datasource": {
"type": "loki",
- "uid": "loki"
+ "uid": "PEF36D7C306617170"
},
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
- "h": 7,
- "w": 24,
- "x": 0,
- "y": 12
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 5
},
- "id": 3,
+ "id": 4,
"options": {
"dedupStrategy": "none",
- "enableInfiniteScrolling": false,
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
- "showControls": false,
"showLabels": false,
- "showTime": true,
+ "showTime": false,
"sortOrder": "Descending",
- "unwrappedColumns": false,
"wrapLogMessage": false
},
"pluginVersion": "11.4.0",
@@ -598,10 +319,10 @@
{
"datasource": {
"type": "loki",
- "uid": "loki"
+ "uid": "PEF36D7C306617170"
},
"editorMode": "code",
- "expr": "{service_name=\"worker\"} | channel = \"app\" | detected_level > 100",
+ "expr": "{service_name=\"worker\"} | channel=\"app\" | detected_level > 100",
"queryType": "range",
"refId": "A"
}
@@ -641,12 +362,12 @@
"overrides": []
},
"gridPos": {
- "h": 9,
- "w": 24,
+ "h": 14,
+ "w": 12,
"x": 0,
- "y": 19
+ "y": 13
},
- "id": 11,
+ "id": 5,
"options": {
"cellHeight": "sm",
"footer": {
@@ -662,29 +383,35 @@
"pluginVersion": "11.4.0",
"targets": [
{
+ "datasource": {
+ "type": "tempo",
+ "uid": "tempo"
+ },
"filters": [
{
- "id": "span-name",
+ "id": "a8856304",
"operator": "=",
- "scope": "span",
- "tag": "name",
- "value": "POST api.add_game",
- "valueType": "string"
+ "scope": "span"
},
{
- "id": "337df8fa",
+ "id": "service-name",
"operator": "=",
- "scope": "span"
+ "scope": "resource",
+ "tag": "service.name",
+ "value": [
+ "frontend"
+ ],
+ "valueType": "string"
}
],
+ "key": "Q-26de73da-736f-4476-bfdf-ddf43332a0d7-1",
"limit": 20,
- "query": "{name=~\"^(GET|POST|PUT|DELETE|PATCH) api.*\"}",
- "queryType": "traceql",
+ "queryType": "traceqlSearch",
"refId": "A",
"tableType": "traces"
}
],
- "title": "Traces (API)",
+ "title": "Traces",
"type": "table"
}
],
@@ -696,13 +423,13 @@
"list": []
},
"time": {
- "from": "now-15m",
+ "from": "now-30m",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
- "title": "Logs, traces, metrics 📜🔎",
- "uid": "beavav8rrsbggb",
- "version": 2,
+ "title": "Logs, traces, metrics ⏱️📊",
+ "uid": "cfj5426vm5lvkc",
+ "version": 1,
"weekStart": ""
}
\ No newline at end of file
diff --git a/helm/Chart.yaml b/helm/Chart.yaml
new file mode 100644
index 0000000..e68fc56
--- /dev/null
+++ b/helm/Chart.yaml
@@ -0,0 +1,10 @@
+apiVersion: v2
+name: fullstack # TODO Check name!
+description: Full-stack Symfony + React application with observability (LGTM)
+type: application
+version: 0.9.3
+appVersion: "0.9.3"
+
+maintainers:
+ - name: Development Team
+ email: dev@example.com
diff --git a/helm/README.md b/helm/README.md
new file mode 100644
index 0000000..f355f66
--- /dev/null
+++ b/helm/README.md
@@ -0,0 +1,193 @@
+# Fullstack Symfony + React Helm Chart
+
+Consolidated Kubernetes deployment chart for the complete full-stack application including observability stack.
+
+## What's Included
+
+### Core Services
+- **Nginx** - Reverse proxy and web server
+- **PHP FPM** - Symfony backend application
+- **MySQL** - Database
+- **Redis** - Cache and session storage
+- **RabbitMQ** - Message broker for async jobs
+- **Worker** - Background job processor
+
+### Observability
+- **LGTM Stack** (all-in-one Grafana + Loki + Tempo + Prometheus + OpenTelemetry Collector)
+
+## Quick Start
+
+```bash
+# Install the chart with release name 'app'
+helm install app ./helm
+
+# Verify deployment
+kubectl get pods
+kubectl get ingress
+
+# Access the application via Ingress
+# App: http://localhost
+# Grafana: http://localhost/grafana (login: admin/admin)
+# OTLP HTTP: http://localhost/otel
+```
+
+## Configuration
+
+All configuration is in `values.yaml`. Key settings:
+
+### Environment
+- `global.environment` - Set to `prod` for production mode (no HMR)
+- `app.replicas` - Number of app replicas (default: 1)
+
+### Credentials (Change these in production!)
+- `app.secret` - Symfony secret key
+- `app.admin.email` / `app.admin.password` - Admin credentials
+- `database.env.MYSQL_ROOT_PASSWORD` - MySQL root password
+- `database.env.MYSQL_PASSWORD` - MySQL user password
+- `rabbitmq.env.RABBITMQ_DEFAULT_PASS` - RabbitMQ password
+
+### Observability
+- `observability.grafanaRootUrl` - Grafana root URL (default: "http://localhost/grafana")
+- `spa.backendApiUrl` - SPA backend API URL (default: "/api")
+- `spa.otelCollectorAddress` - OpenTelemetry collector address (default: "http://localhost/otel")
+
+## ⚠️ Production Considerations
+
+### Database, Cache, and RabbitMQ
+
+**Current Setup (Dev/Test):**
+- Uses simple Kubernetes `Pod` objects
+- **Data is lost** on pod restart
+- Ephemeral storage only
+
+**Production Setup:**
+- Should use `StatefulSet` instead of `Pod`
+- Must use `PersistentVolume` and `PersistentVolumeClaim`
+- Consider managed services:
+ - AWS RDS for MySQL
+ - AWS ElastiCache for Redis
+ - AWS MQ for RabbitMQ
+
+**To migrate to StatefulSet + PV:**
+
+1. **For Database:**
+ ```yaml
+ kind: StatefulSet
+ metadata:
+ name: db
+ spec:
+ serviceName: db
+ selector:
+ matchLabels:
+ app: db
+ template:
+ # ... pod spec ...
+ volumeMounts:
+ - name: mysql-data
+ mountPath: /var/lib/mysql
+ volumeClaimTemplates:
+ - metadata:
+ name: mysql-data
+ spec:
+ accessModes: [ "ReadWriteOnce" ]
+ resources:
+ requests:
+ storage: 10Gi
+ ```
+
+2. **For Cache (Redis):**
+ Similar structure with `mountPath: /data` and appropriate storage size
+
+3. **For RabbitMQ:**
+ Similar structure with `mountPath: /var/lib/rabbitmq` and persistent storage
+
+### Image Registry
+
+If using private image registry:
+```bash
+helm install app ./helm \
+ --set global.imageRegistry=your-registry.com
+```
+
+### Secrets Management
+
+For production, don't commit passwords to git. Use Kubernetes secrets:
+
+```bash
+# Create secret
+kubectl create secret generic app-secrets \
+ --from-literal=db-password=YOUR_DB_PASSWORD \
+ --from-literal=mysql-root-password=YOUR_ROOT_PASSWORD \
+ --from-literal=app-secret=YOUR_APP_SECRET
+
+# Reference in values or deployment
+```
+
+### Resource Limits
+
+Add resource requests/limits in production:
+
+```yaml
+# In values.yaml for each container
+resources:
+ requests:
+ memory: "256Mi"
+ cpu: "250m"
+ limits:
+ memory: "512Mi"
+ cpu: "500m"
+```
+
+### High Availability
+
+For HA setup:
+- Set `app.replicas: 3` or higher
+- Set `nginx.replicas: 3` or higher
+- Convert stateful services to StatefulSets with multiple replicas
+- Use RollingUpdate strategy
+- Add Pod Disruption Budgets (PDB)
+
+## Troubleshooting
+
+### Check deployment status
+```bash
+kubectl get all
+kubectl describe pod POD_NAME
+kubectl logs POD_NAME
+```
+
+### Access services via Ingress
+```bash
+kubectl get ingress
+
+# Access the app
+open http://localhost
+
+# Access Grafana
+open http://localhost/grafana
+
+# OpenTelemetry endpoints
+# gRPC: Inside cluster at lgtm:4317
+# HTTP: http://localhost/otel
+```
+
+### Database initialization issues
+```bash
+# Check init job
+kubectl describe job init
+kubectl logs job/init
+```
+
+## Uninstall
+
+```bash
+helm uninstall app
+```
+
+## Support
+
+For issues, check:
+1. Logs: `kubectl logs POD_NAME`
+2. Events: `kubectl describe pod POD_NAME`
+3. Services connectivity: `kubectl exec POD_NAME -- nc -zv SERVICE_NAME:PORT`
+4. Ingress routing: `kubectl get ingress` and `curl -v http://localhost/`
diff --git a/helm/app/Chart.yaml b/helm/app/Chart.yaml
deleted file mode 100644
index 9c5e120..0000000
--- a/helm/app/Chart.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-apiVersion: v2
-name: app
-description: Helm chart for Symfony + React fullstack demo app
-type: application
-version: 0.9.1
-appVersion: "0.9.1"
diff --git a/helm/app/templates/NOTES.txt b/helm/app/templates/NOTES.txt
deleted file mode 100644
index 912f13e..0000000
--- a/helm/app/templates/NOTES.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Access the webapp at: http://kubernetes.docker.internal:{{ .Values.frontend.nodePort }}
-Or at: http://localhost:{{ .Values.frontend.nodePort }}
\ No newline at end of file
diff --git a/helm/app/templates/cm-app.yaml b/helm/app/templates/cm-app.yaml
deleted file mode 100644
index 27bf1a9..0000000
--- a/helm/app/templates/cm-app.yaml
+++ /dev/null
@@ -1,26 +0,0 @@
-apiVersion: v1
-kind: ConfigMap
-metadata:
- name: {{ .Release.Name }}-config
- labels:
- app: {{ .Release.Name }}
- tier: backend
-data:
- APP_ENV: {{ .Values.environment }}
- APP_VERSION: {{ .Chart.Version }}
- APP_SECRET: {{ .Values.backend.secret }}
- ADMIN_EMAIL: {{ .Values.backend.admin.email }}
- ADMIN_PASSWORD: {{ .Values.backend.admin.password }}
- DATABASE_URL: "mysql://root:{{ .Values.database.rootPassword }}@{{ .Release.Name }}-db:3306/app?serverVersion=8.0.31&charset=utf8mb4"
- REDIS_URL: "redis://{{ .Release.Name }}-cache:6379"
- # TODO Use RabbitMQ instead!
- MESSENGER_TRANSPORT_DSN: "doctrine://default"
- # Valid options for APP_TELEMETRY are:
- # "Stdout", "Otel", or "Noop"
- APP_TELEMETRY: "Otel"
- OTEL_PHP_AUTOLOAD_ENABLED: "true"
- OTEL_TRACES_EXPORTER: "otlp"
- OTEL_LOGS_EXPORTER: "otlp"
- OTEL_METRICS_EXPORTER: "otlp"
- OTEL_EXPORTER_OTLP_ENDPOINT: {{ .Values.observability.otelCollectorAddress }}
-
\ No newline at end of file
diff --git a/helm/app/templates/cm-nginx.yaml b/helm/app/templates/cm-nginx.yaml
deleted file mode 100644
index 4dc6f5e..0000000
--- a/helm/app/templates/cm-nginx.yaml
+++ /dev/null
@@ -1,70 +0,0 @@
-apiVersion: v1
-kind: ConfigMap
-metadata:
- name: {{ .Release.Name }}-nginx-config
- labels:
- app: {{ .Release.Name }}
- tier: backend
-data:
- default.conf: |
- server {
-
- listen 80;
- index index.php;
- server_name localhost;
- root /var/www/project/public;
- error_log /var/log/nginx/project_error.log;
- access_log /var/log/nginx/project_access.log;
-
- location / {
- # try to serve file directly, fallback to index.php
- try_files $uri /index.php$is_args$args;
- }
-
- # optionally disable falling back to PHP script for the asset directories;
- # nginx will return a 404 error when files are not found instead of passing the
- # request to Symfony (improves performance but Symfony's 404 page is not displayed)
- # location /bundles {
- # try_files $uri =404;
- # }
-
- location ~ ^/index\.php(/|$) {
-
- # Sets the address of a FastCGI server. The address can be specified as a domain name or IP address, and a port
- fastcgi_pass localhost:9000;
- fastcgi_split_path_info ^(.+\.php)(/.*)$;
- include fastcgi_params;
-
- # optionally set the value of the environment variables used in the application
- # fastcgi_param APP_ENV prod;
- # fastcgi_param APP_SECRET ;
- # fastcgi_param DATABASE_URL "mysql://db_user:db_pass@host:3306/db_name";
-
- # When you are using symlinks to link the document root to the
- # current version of your application, you should pass the real
- # application path instead of the path to the symlink to PHP
- # FPM.
- # Otherwise, PHP's OPcache may not properly detect changes to
- # your PHP files (see https://github.com/zendtech/ZendOptimizerPlus/issues/126
- # for more information).
- fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
- fastcgi_param DOCUMENT_ROOT $realpath_root;
-
- # Avoid upstream sent too big header while reading error
- # https://stackoverflow.com/questions/17708152/nginx-overwrites-general-symfony-errors-with-502-bad-gateway
- fastcgi_buffer_size 128k;
- fastcgi_buffers 4 256k;
- fastcgi_busy_buffers_size 256k;
-
- # Prevents URIs that include the front controller. This will 404:
- # http://domain.tld/index.php/some-path
- # Remove the internal directive to allow URIs like this
- internal;
- }
-
- # return 404 for all other php files not matching the front controller
- # this prevents access to other php files you don't want to be accessible.
- location ~ \.php$ {
- return 404;
- }
- }
\ No newline at end of file
diff --git a/helm/app/templates/deploy-app.yaml b/helm/app/templates/deploy-app.yaml
deleted file mode 100644
index 3280711..0000000
--- a/helm/app/templates/deploy-app.yaml
+++ /dev/null
@@ -1,69 +0,0 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: {{ .Release.Name }}
- labels:
- app: {{ .Release.Name }}
- tier: backend
-spec:
- replicas: {{ .Values.backend.replicas }}
- selector:
- matchLabels:
- app: {{ .Release.Name }}
- template:
- metadata:
- labels:
- app: {{ .Release.Name }}
- tier: backend
- purpose: backend
- spec:
- volumes:
- - name: nginx-config-volume
- configMap:
- name: {{ .Release.Name }}-nginx-config
- - name: files-volume
- emptyDir: {}
- initContainers:
- - name: copy-files
- image: {{ .Values.backend.image }}
- command: ["/bin/sh", "-c", "cp -r . /project/"]
- volumeMounts:
- - name: files-volume
- mountPath: /project
- - name: set-write-permissions
- image: {{ .Values.backend.image }}
- workingDir: /var/www/project
- command: ["/bin/sh", "-c", "mkdir var && chmod -R 777 var/"]
- volumeMounts:
- - name: files-volume
- mountPath: /var/www/project
- containers:
- - name: nginx
- image: nginx
- workingDir: /var/www/project
- resources:
- limits:
- cpu: "250m"
- memory: "500Mi"
- volumeMounts:
- - name: files-volume
- mountPath: /var/www/project
- - name: nginx-config-volume
- mountPath: /etc/nginx/conf.d
- - name: php
- image: {{ .Values.backend.image }}
- workingDir: /var/www/project
- resources:
- limits:
- cpu: "250m"
- memory: "500Mi"
- volumeMounts:
- - name: files-volume
- mountPath: /var/www/project
- envFrom:
- - configMapRef:
- name: {{ .Release.Name }}-config
- env:
- - name: OTEL_SERVICE_NAME
- value: "backend"
- restartPolicy: Always
diff --git a/helm/app/templates/deploy-frontend.yaml b/helm/app/templates/deploy-frontend.yaml
deleted file mode 100644
index 32685c6..0000000
--- a/helm/app/templates/deploy-frontend.yaml
+++ /dev/null
@@ -1,26 +0,0 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- labels:
- app: {{ .Release.Name }}-frontend
- name: {{ .Release.Name }}-frontend
-spec:
- replicas: {{ .Values.frontend.replicas }}
- selector:
- matchLabels:
- app: {{ .Release.Name }}-frontend
- template:
- metadata:
- labels:
- app: {{ .Release.Name }}-frontend
- tier: frontend
- purpose: frontend
- spec:
- containers:
- - image: {{ .Values.frontend.image }}
- name: node
- env:
- - name: BACKEND_API_URL
- value: {{ .Values.frontend.apiAddress }}
- - name: OTEL_COLLECTOR_ADDRESS
- value: {{ .Values.observability.otelCollectorAddress }}
diff --git a/helm/app/templates/deploy-worker.yaml b/helm/app/templates/deploy-worker.yaml
deleted file mode 100644
index b55dadf..0000000
--- a/helm/app/templates/deploy-worker.yaml
+++ /dev/null
@@ -1,59 +0,0 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: {{ .Release.Name }}-worker
- labels:
- app: {{ .Release.Name }}
- tier: backend
-spec:
- replicas: {{ .Values.backend.replicas }}
- selector:
- matchLabels:
- app: {{ .Release.Name }}-worker
- template:
- metadata:
- labels:
- app: {{ .Release.Name }}-worker
- tier: backend
- purpose: worker
- spec:
- terminationGracePeriodSeconds: 3600
- containers:
- #- name: sidecar
- # volumeMounts:
- # - mountPath: /share
- # name: share # must be a shared volume between worker and sidecar!
- # lifecycle:
- # preStop:
- # exec:
- # command: ["sh", "-c", "while ! [ -f /share/kill_sidecar ]; do sleep 1; done; kill -2 1"]
- # image: python-flask
- # resources:
- # limits:
- # cpu: "500m"
- # memory: "500Mi"
- - name: worker
- image: {{ .Values.backend.image }}
- resources:
- limits:
- cpu: "250m"
- memory: "500Mi"
- lifecycle:
- preStop:
- exec:
- command: ["sh", "-c", "touch /tmp/kill_me"]
- command:
- - /bin/sh
- - -c
- - >
- while ! [ -f /tmp/kill_me ];
- do
- bin/console messenger:consume async --limit=1 --time-limit=60 -vv
- done;
- # touch /share/kill_sidecar
- envFrom:
- - configMapRef:
- name: {{ .Release.Name }}-config
- env:
- - name: OTEL_SERVICE_NAME
- value: "worker"
\ No newline at end of file
diff --git a/helm/app/templates/job-db-init.yaml b/helm/app/templates/job-db-init.yaml
deleted file mode 100644
index 05ffcc7..0000000
--- a/helm/app/templates/job-db-init.yaml
+++ /dev/null
@@ -1,35 +0,0 @@
-apiVersion: batch/v1
-kind: Job
-metadata:
- name: {{ .Release.Name }}-initialize
- labels:
- app: {{ .Release.Name }}-initialize
- tier: backend
-spec:
- template:
- spec:
- initContainers:
- - name: create-db
- image: {{ .Values.backend.image }}
- command: ["/bin/sh","-c"]
- args: ["bin/console doctrine:database:create --if-not-exists"]
- envFrom:
- - configMapRef:
- name: {{ .Release.Name }}-config
- - name: create-schema
- image: {{ .Values.backend.image }}
- command: ["/bin/sh","-c"]
- args: ["bin/console doctrine:schema:update --force"]
- envFrom:
- - configMapRef:
- name: {{ .Release.Name }}-config
- containers:
- - name: fixtures
- image: {{ .Values.backend.image }}
- command: ["/bin/sh","-c"]
- args: ["bin/console doctrine:fixtures:load -q"]
- envFrom:
- - configMapRef:
- name: {{ .Release.Name }}-config
- restartPolicy: Never
- backoffLimit: 5
\ No newline at end of file
diff --git a/helm/app/templates/pod-cache.yaml b/helm/app/templates/pod-cache.yaml
deleted file mode 100644
index 985c63c..0000000
--- a/helm/app/templates/pod-cache.yaml
+++ /dev/null
@@ -1,18 +0,0 @@
-apiVersion: v1
-kind: Pod
-metadata:
- name: {{ .Release.Name }}-cache
- labels:
- app: {{ .Release.Name }}-cache
- tier: backend
-spec:
- containers:
- - image: redis:7.2-alpine
- name: redis
- ports:
- - containerPort: 6379
- name: redis
- resources:
- limits:
- cpu: "100m"
- memory: "200Mi"
diff --git a/helm/app/templates/pod-db.yaml b/helm/app/templates/pod-db.yaml
deleted file mode 100644
index b645d5a..0000000
--- a/helm/app/templates/pod-db.yaml
+++ /dev/null
@@ -1,28 +0,0 @@
-apiVersion: v1
-kind: Pod
-metadata:
- name: {{ .Release.Name }}-db
- labels:
- app: {{ .Release.Name }}-db
- tier: backend
-spec:
- containers:
- - name: mysql
- image: mysql
- env:
- # TODO: Use secret instead!
- - name: MYSQL_ROOT_PASSWORD
- value: secret
- ports:
- - containerPort: 3306
- name: mysql
- volumeMounts:
- - name: persistent-storage
- mountPath: /var/lib/mysql
- resources:
- limits:
- cpu: "500m"
- memory: "2Gi"
- volumes:
- - name: persistent-storage
- emptyDir: {}
\ No newline at end of file
diff --git a/helm/app/templates/svc-app.yaml b/helm/app/templates/svc-app.yaml
deleted file mode 100644
index 052c283..0000000
--- a/helm/app/templates/svc-app.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
- labels:
- app: {{ .Release.Name }}
- tier: backend
- name: {{ .Release.Name }}
-spec:
- ports:
- - name: 80-80
- port: 80
- protocol: TCP
- targetPort: 80
- nodePort: {{ .Values.backend.nodePort }}
- selector:
- app: {{ .Release.Name }}
- type: NodePort
diff --git a/helm/app/templates/svc-cache.yaml b/helm/app/templates/svc-cache.yaml
deleted file mode 100644
index e113b20..0000000
--- a/helm/app/templates/svc-cache.yaml
+++ /dev/null
@@ -1,19 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
- name: {{ .Release.Name }}-cache
- labels:
- app: {{ .Release.Name }}-cache
- tier: backend
-spec:
- selector:
- app: {{ .Release.Name }}-cache
- ports:
- - name: 6379-6379
- port: 6379
- protocol: TCP
- targetPort: 6379
- {{ if eq .Values.environment "dev" }}
- nodePort: {{ .Values.cache.nodePort }}
- type: NodePort
- {{ end }}
\ No newline at end of file
diff --git a/helm/app/templates/svc-db.yaml b/helm/app/templates/svc-db.yaml
deleted file mode 100644
index b4d1882..0000000
--- a/helm/app/templates/svc-db.yaml
+++ /dev/null
@@ -1,19 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
- name: {{ .Release.Name }}-db
- labels:
- app: {{ .Release.Name }}-db
- tier: backend
-spec:
- selector:
- app: {{ .Release.Name }}-db
- ports:
- - name: 3306-3306
- port: 3306
- protocol: TCP
- targetPort: 3306
- {{ if eq .Values.environment "dev" }}
- nodePort: {{ .Values.database.nodePort }}
- type: NodePort
- {{ end }}
\ No newline at end of file
diff --git a/helm/app/templates/svc-frontend.yaml b/helm/app/templates/svc-frontend.yaml
deleted file mode 100644
index 05518e1..0000000
--- a/helm/app/templates/svc-frontend.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
- labels:
- app: {{ .Release.Name }}-frontend
- tier: frontend
- name: {{ .Release.Name }}-frontend
-spec:
- ports:
- - name: 3500-3500
- port: 3500
- protocol: TCP
- targetPort: 3500
- nodePort: {{ .Values.frontend.nodePort }}
- selector:
- app: {{ .Release.Name }}-frontend
- type: NodePort
diff --git a/helm/app/values.yaml b/helm/app/values.yaml
deleted file mode 100644
index cd94b69..0000000
--- a/helm/app/values.yaml
+++ /dev/null
@@ -1,33 +0,0 @@
-# Application Environment
-environment: prod
-
-# Backend Application
-backend:
- image: fullstack-symfony-react:0.9.1
- replicas: 1
- nodePort: 30100
- admin:
- email: "admin@example.com"
- password: "secret"
- secret: "5fb106d07c5b59845136ac75b26a925f"
-
-# Frontend Application
-frontend:
- image: fullstack-symfony-react-web:0.9.1
- replicas: 1
- nodePort: 30200
- apiAddress: "http://localhost:30100/api"
-
-# Database
-database:
- rootPassword: "secret"
- nodePort: 30201
-
-# Cache
-cache:
- nodePort: 30202
-
-# Observability & Telemetry
-# ATTENTION: must match the service name of the telemtry deployment!
-observability:
- otelCollectorAddress: "http://telemetry:4318"
diff --git a/helm/telemetry/Chart.yaml b/helm/telemetry/Chart.yaml
deleted file mode 100644
index c77db0a..0000000
--- a/helm/telemetry/Chart.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-apiVersion: v2
-name: lgtm
-description: Helm chart for LGTM Stack telemetry (Grafana, Loki, Tempo, Prometheus, OpenTelemetry Collector)
-type: application
-version: 1.0.0
-appVersion: "0.8.1"
diff --git a/helm/telemetry/templates/NOTES.txt b/helm/telemetry/templates/NOTES.txt
deleted file mode 100644
index af9f1dd..0000000
--- a/helm/telemetry/templates/NOTES.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-1. LGTM Stack is running with all-in-one deployment
-2. Grafana Dashboard at: http://localhost:{{ .Values.lgtm.nodePort }}/dashboards
-3. Or from host: http://kubernetes.docker.internal:{{ .Values.lgtm.nodePort }}/dashboards
-4.
-5. Includes:
- - Grafana (visualization)
- - Loki (logs)
- - Tempo (traces)
- - Prometheus (metrics)
- - OpenTelemetry Collector (instrumentation receiver)
diff --git a/helm/telemetry/templates/cm-lgtm.yaml b/helm/telemetry/templates/cm-lgtm.yaml
deleted file mode 100644
index 0fbf389..0000000
--- a/helm/telemetry/templates/cm-lgtm.yaml
+++ /dev/null
@@ -1,19 +0,0 @@
-apiVersion: v1
-kind: ConfigMap
-metadata:
- name: {{ .Release.Name }}-config
- labels:
- app: {{ .Release.Name }}
- tier: telemetry
-data:
- # LGTM Stack Configuration (all-in-one deployment)
- # This ConfigMap provides configuration for the unified Grafana OTEL LGTM stack
- # which includes: Grafana, Loki, Tempo, Prometheus, and OpenTelemetry Collector
-
- # Grafana datasources are automatically configured in the LGTM image to point to:
- # - Loki (logs): http://localhost:3100
- # - Tempo (traces): http://localhost:3200
- # - Prometheus (metrics): http://localhost:9090
- # - OTel Collector (receiver): grpc://localhost:4317, http://localhost:4318
-
- description: "LGTM Stack - Logs, Traces, Metrics, Profiles"
diff --git a/helm/telemetry/templates/pod-lgtm.yaml b/helm/telemetry/templates/pod-lgtm.yaml
deleted file mode 100644
index bf02c24..0000000
--- a/helm/telemetry/templates/pod-lgtm.yaml
+++ /dev/null
@@ -1,45 +0,0 @@
-apiVersion: v1
-kind: Pod
-metadata:
- name: {{ .Release.Name }}
- labels:
- app: {{ .Release.Name }}
- tier: telemetry
-spec:
- containers:
- - name: lgtm
- image: {{ .Values.lgtm.image }}
- ports:
- - name: grafana
- containerPort: 3000
- - name: otel-grpc
- containerPort: 4317
- - name: otel-http
- containerPort: 4318
- - name: prometheus
- containerPort: 9090
- env:
- - name: GF_AUTH_ANONYMOUS_ORG_ROLE
- value: "Admin"
- - name: GF_AUTH_ANONYMOUS_ENABLED
- value: "true"
- - name: GF_AUTH_BASIC_ENABLED
- value: "false"
- - name: GF_AUTH_DISABLE_LOGIN_FORM
- value: "true"
- - name: GF_FEATURE_TOGGLES_ENABLE
- value: "accessControlOnCall traceqlEditor"
- volumeMounts:
- - name: my-config
- mountPath: /etc/otel-lgtm
- resources:
- requests:
- memory: "512Mi"
- cpu: "250m"
- limits:
- memory: "1Gi"
- cpu: "1000m"
- volumes:
- - name: my-config
- configMap:
- name: {{ .Release.Name }}-config
diff --git a/helm/telemetry/templates/svc-lgtm.yaml b/helm/telemetry/templates/svc-lgtm.yaml
deleted file mode 100644
index 3abe3a7..0000000
--- a/helm/telemetry/templates/svc-lgtm.yaml
+++ /dev/null
@@ -1,25 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
- name: {{ .Release.Name }}
- labels:
- app: {{ .Release.Name }}
- tier: telemetry
-spec:
- type: NodePort
- ports:
- - name: grafana
- port: 3000
- targetPort: 3000
- nodePort: {{ .Values.lgtm.nodePort }}
- - name: otel-grpc
- port: 4317
- targetPort: 4317
- - name: otel-http
- port: 4318
- targetPort: 4318
- - name: prometheus
- port: 9090
- targetPort: 9090
- selector:
- app: {{ .Release.Name }}
diff --git a/helm/telemetry/values.yaml b/helm/telemetry/values.yaml
deleted file mode 100644
index 8ed4ac4..0000000
--- a/helm/telemetry/values.yaml
+++ /dev/null
@@ -1,18 +0,0 @@
-# LGTM Stack Configuration
-lgtm:
- # All-in-one image includes: Grafana, Loki, Tempo, Prometheus, OpenTelemetry Collector
- image: grafana/otel-lgtm:0.8.1
-
- # Services
- services:
- name: lgtm
-
- # Port Mappings
- ports:
- grafana: 3000
- otelGrpc: 4317
- otelHttp: 4318
- prometheus: 9090
-
- # Node Port for external access
- nodePort: 30300
\ No newline at end of file
diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl
new file mode 100644
index 0000000..fb343c1
--- /dev/null
+++ b/helm/templates/_helpers.tpl
@@ -0,0 +1,45 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "chart.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+Uses minimal naming by just returning the chart name.
+*/}}
+{{- define "chart.fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+
+{{/*
+Create chart name and version as used by the chart label.
+*/}}
+{{- define "chart.version" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels
+*/}}
+{{- define "chart.labels" -}}
+helm.sh/chart: {{ include "chart.version" . }}
+{{ include "chart.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Selector labels
+*/}}
+{{- define "chart.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "chart.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
diff --git a/helm/templates/cm-app.yaml b/helm/templates/cm-app.yaml
new file mode 100644
index 0000000..2b2a112
--- /dev/null
+++ b/helm/templates/cm-app.yaml
@@ -0,0 +1,37 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+data:
+ # Environment
+ APP_ENV: {{ .Values.global.environment }}
+ APP_VERSION: {{ .Chart.AppVersion | quote }}
+
+ # Database
+ DATABASE_URL: "mysql://{{ .Values.database.env.MYSQL_USER }}:{{ .Values.database.env.MYSQL_PASSWORD }}@db:3306/{{ .Values.database.env.MYSQL_DATABASE }}?serverVersion=8.0&charset=utf8mb4"
+
+ # Cache & Session Storage
+ REDIS_URL: "redis://cache:{{ .Values.cache.port }}"
+
+ # Message Queue
+ MESSENGER_TRANSPORT_DSN: "amqp://{{ .Values.rabbitmq.env.RABBITMQ_DEFAULT_USER }}:{{ .Values.rabbitmq.env.RABBITMQ_DEFAULT_PASS }}@rabbitmq:{{ .Values.rabbitmq.port }}/%2f/messages"
+
+ # Application Secrets & Credentials
+ APP_SECRET: {{ .Values.app.secret | quote }}
+ ADMIN_EMAIL: {{ .Values.app.admin.email | quote }}
+ ADMIN_PASSWORD: {{ .Values.app.admin.password | quote }}
+
+ # SPA Frontend Configuration
+ SPA_BACKEND_API_URL: {{ .Values.spa.backendApiUrl | quote }}
+ SPA_OTEL_COLLECTOR_ADDRESS: {{ .Values.spa.otelCollectorAddress | quote }}
+
+ # Telemetry & Observability
+ APP_TELEMETRY: "Otel"
+ OTEL_PHP_AUTOLOAD_ENABLED: "true"
+ OTEL_TRACES_EXPORTER: "otlp"
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://lgtm:{{ .Values.observability.ports.otelHttp }}"
+
+
diff --git a/helm/templates/cm-grafana-datasources.yaml b/helm/templates/cm-grafana-datasources.yaml
new file mode 100644
index 0000000..96af97b
--- /dev/null
+++ b/helm/templates/cm-grafana-datasources.yaml
@@ -0,0 +1,42 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: grafana-datasources
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+data:
+ default.yml: |
+ apiVersion: 1
+ datasources:
+ - name: Loki
+ type: loki
+ uid: PEF36D7C306617170
+ access: proxy
+ url: http://localhost:3100
+
+ - name: Prometheus
+ type: prometheus
+ uid: prometheus
+ access: proxy
+ url: http://localhost:9090
+ isDefault: false
+ jsonData:
+ httpMethod: GET
+ exemplarTraceIdDestinations:
+ - name: traceID
+ datasourceUid: tempo
+
+ - name: Tempo
+ type: tempo
+ uid: tempo
+ access: proxy
+ url: http://localhost:3200
+ isDefault: true
+ jsonData:
+ httpMethod: GET
+ serviceMap:
+ datasourceUid: prometheus
+ tracesToLogs:
+ datasourceUid: PEF36D7C306617170
+ tags: ['service.name']
diff --git a/helm/templates/cm-grafana-provisioning-dashboards.yaml b/helm/templates/cm-grafana-provisioning-dashboards.yaml
new file mode 100644
index 0000000..0c07ef5
--- /dev/null
+++ b/helm/templates/cm-grafana-provisioning-dashboards.yaml
@@ -0,0 +1,456 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: grafana-provisioning-dashboards
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+data:
+ dashboards.yaml: |
+ apiVersion: 1
+ providers:
+ - name: 'Dashboards'
+ orgId: 1
+ folder: ''
+ type: 'file'
+ allowUiUpdates: false
+ options:
+ path: /otel-lgtm/grafana/conf/provisioning/dashboards
+ logs-traces-metrics-k8s.json: |
+ {
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "id": 1,
+ "links": [],
+ "panels": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 5,
+ "w": 5,
+ "x": 0,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "minVizHeight": 75,
+ "minVizWidth": 75,
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true,
+ "sizing": "auto"
+ },
+ "pluginVersion": "11.4.0",
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "disableTextWrap": false,
+ "editorMode": "builder",
+ "expr": "command_executed_milliseconds",
+ "fullMetaSearch": false,
+ "includeNullMetadata": true,
+ "instant": false,
+ "legendFormat": "Executed (ms)",
+ "range": true,
+ "refId": "A",
+ "useBackend": false
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "disableTextWrap": false,
+ "editorMode": "builder",
+ "expr": "message_handled_milliseconds",
+ "fullMetaSearch": false,
+ "includeNullMetadata": true,
+ "instant": false,
+ "legendFormat": "Handled (ms)",
+ "range": true,
+ "refId": "B",
+ "useBackend": false
+ }
+ ],
+ "title": "Metrics",
+ "type": "gauge"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisBorderShow": false,
+ "axisCenteredZero": false,
+ "axisColorMode": "text",
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "barWidthFactor": 0.6,
+ "drawStyle": "line",
+ "fillOpacity": 100,
+ "gradientMode": "opacity",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "insertNulls": false,
+ "lineInterpolation": "linear",
+ "lineStyle": {
+ "fill": "solid"
+ },
+ "lineWidth": 0,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "never",
+ "spanNulls": true,
+ "stacking": {
+ "group": "A",
+ "mode": "percent"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 5,
+ "w": 19,
+ "x": 5,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "legend": {
+ "calcs": [],
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "single",
+ "sort": "none"
+ }
+ },
+ "pluginVersion": "11.4.0",
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "disableTextWrap": false,
+ "editorMode": "code",
+ "expr": "my_http_response_total{message=\"success\"}",
+ "fullMetaSearch": false,
+ "hide": false,
+ "includeNullMetadata": true,
+ "instant": false,
+ "legendFormat": "2xx",
+ "range": true,
+ "refId": "success (2xx)",
+ "useBackend": false
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "disableTextWrap": false,
+ "editorMode": "code",
+ "expr": "my_http_response_total{message=\"client_error\"}",
+ "fullMetaSearch": false,
+ "includeNullMetadata": true,
+ "instant": false,
+ "legendFormat": "4xx",
+ "range": true,
+ "refId": "client_error (4xx)",
+ "useBackend": false
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "prometheus"
+ },
+ "disableTextWrap": false,
+ "editorMode": "code",
+ "expr": "my_http_response_total{message=\"server_error\"}",
+ "fullMetaSearch": false,
+ "hide": false,
+ "includeNullMetadata": true,
+ "instant": false,
+ "legendFormat": "5xx",
+ "range": true,
+ "refId": "server_error (5xx)",
+ "useBackend": false
+ }
+ ],
+ "title": "HTTP Responses",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "PEF36D7C306617170"
+ },
+ "fieldConfig": {
+ "defaults": {},
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 5
+ },
+ "id": 3,
+ "options": {
+ "dedupStrategy": "none",
+ "enableLogDetails": true,
+ "prettifyLogMessage": false,
+ "showCommonLabels": false,
+ "showLabels": false,
+ "showTime": false,
+ "sortOrder": "Descending",
+ "wrapLogMessage": false
+ },
+ "pluginVersion": "11.4.0",
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "PEF36D7C306617170"
+ },
+ "editorMode": "code",
+ "expr": "{service_name=\"app\"} | channel=\"app\" | detected_level > 100",
+ "queryType": "range",
+ "refId": "A"
+ }
+ ],
+ "title": "App",
+ "type": "logs"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "PEF36D7C306617170"
+ },
+ "fieldConfig": {
+ "defaults": {},
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 5
+ },
+ "id": 4,
+ "options": {
+ "dedupStrategy": "none",
+ "enableLogDetails": true,
+ "prettifyLogMessage": false,
+ "showCommonLabels": false,
+ "showLabels": false,
+ "showTime": false,
+ "sortOrder": "Descending",
+ "wrapLogMessage": false
+ },
+ "pluginVersion": "11.4.0",
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "PEF36D7C306617170"
+ },
+ "editorMode": "code",
+ "expr": "{service_name=\"worker\"} | channel=\"app\" | detected_level > 100",
+ "queryType": "range",
+ "refId": "A"
+ }
+ ],
+ "title": "Worker",
+ "type": "logs"
+ },
+ {
+ "datasource": {
+ "type": "tempo",
+ "uid": "tempo"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "align": "auto",
+ "cellOptions": {
+ "type": "auto"
+ },
+ "inspect": false
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 80
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 14,
+ "w": 12,
+ "x": 0,
+ "y": 13
+ },
+ "id": 5,
+ "options": {
+ "cellHeight": "sm",
+ "footer": {
+ "countRows": false,
+ "fields": "",
+ "reducer": [
+ "sum"
+ ],
+ "show": false
+ },
+ "showHeader": true
+ },
+ "pluginVersion": "11.4.0",
+ "targets": [
+ {
+ "datasource": {
+ "type": "tempo",
+ "uid": "tempo"
+ },
+ "filters": [
+ {
+ "id": "a8856304",
+ "operator": "=",
+ "scope": "span"
+ },
+ {
+ "id": "service-name",
+ "operator": "=",
+ "scope": "resource",
+ "tag": "service.name",
+ "value": [
+ "frontend"
+ ],
+ "valueType": "string"
+ }
+ ],
+ "key": "Q-26de73da-736f-4476-bfdf-ddf43332a0d7-1",
+ "limit": 20,
+ "queryType": "traceqlSearch",
+ "refId": "A",
+ "tableType": "traces"
+ }
+ ],
+ "title": "Traces",
+ "type": "table"
+ }
+ ],
+ "preload": false,
+ "refresh": "auto",
+ "schemaVersion": 40,
+ "tags": [],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-30m",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "browser",
+ "title": "Logs, traces, metrics ⏱️📊",
+ "uid": "cfj5426vm5lvkc",
+ "version": 1,
+ "weekStart": ""
+ }
+
+
\ No newline at end of file
diff --git a/helm/templates/cm-nginx.yaml b/helm/templates/cm-nginx.yaml
new file mode 100644
index 0000000..ea19e3b
--- /dev/null
+++ b/helm/templates/cm-nginx.yaml
@@ -0,0 +1,48 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: nginx-config
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+data:
+ default.conf: |
+ server {
+ listen 80;
+ index index.php;
+ server_name localhost;
+ root /var/www/project/public;
+ error_log /var/log/nginx/project_error.log;
+ access_log /var/log/nginx/project_access.log;
+
+ # Serve static React app assets directly without PHP fallback
+ location /dist/ {
+ try_files $uri =404;
+ }
+
+ # Serve asset references from CSS/JS files (fonts, images, etc.)
+ location /assets/ {
+ alias /var/www/project/public/dist/assets/;
+ try_files $uri =404;
+ }
+
+ location / {
+ try_files $uri /index.php$is_args$args;
+ }
+
+ location ~ ^/index\.php(/|$) {
+ fastcgi_pass 127.0.0.1:9000;
+ fastcgi_split_path_info ^(.+\.php)(/.*)$;
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
+ fastcgi_param DOCUMENT_ROOT $realpath_root;
+ fastcgi_buffer_size 128k;
+ fastcgi_buffers 4 256k;
+ fastcgi_busy_buffers_size 256k;
+ internal;
+ }
+
+ location ~ \.php$ {
+ return 404;
+ }
+ }
diff --git a/helm/templates/deploy-app.yaml b/helm/templates/deploy-app.yaml
new file mode 100644
index 0000000..08e7c10
--- /dev/null
+++ b/helm/templates/deploy-app.yaml
@@ -0,0 +1,87 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: app
+spec:
+ replicas: {{ .Values.app.replicas }}
+ selector:
+ matchLabels:
+ {{- include "chart.selectorLabels" . | nindent 6 }}
+ app: app
+ template:
+ metadata:
+ labels:
+ {{- include "chart.selectorLabels" . | nindent 8 }}
+ app: app
+ spec:
+ containers:
+ - name: nginx
+ image: {{ .Values.nginx.image }}
+ imagePullPolicy: Always
+ ports:
+ - containerPort: {{ .Values.nginx.port }}
+ name: http
+ volumeMounts:
+ - name: nginx-config
+ mountPath: /etc/nginx/conf.d
+ - name: app-files
+ mountPath: /var/www/project
+ livenessProbe:
+ tcpSocket:
+ port: http
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ readinessProbe:
+ tcpSocket:
+ port: http
+ initialDelaySeconds: 5
+ periodSeconds: 5
+ - name: app
+ image: {{ .Values.app.image }}
+ imagePullPolicy: Always
+ ports:
+ - containerPort: {{ .Values.app.port }}
+ name: fpm
+ volumeMounts:
+ - name: app-files
+ mountPath: /var/www/project
+ envFrom:
+ - configMapRef:
+ name: app-config
+ env:
+ - name: OTEL_SERVICE_NAME
+ value: "app"
+ livenessProbe:
+ tcpSocket:
+ port: fpm
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ readinessProbe:
+ tcpSocket:
+ port: fpm
+ initialDelaySeconds: 5
+ periodSeconds: 5
+ volumes:
+ - name: nginx-config
+ configMap:
+ name: nginx-config
+ - name: app-files
+ emptyDir: {}
+ initContainers:
+ - name: copy-app-files
+ image: {{ .Values.app.image }}
+ imagePullPolicy: Always
+ command: ['sh', '-c', 'cp -r /var/www/project/. /app-files/']
+ volumeMounts:
+ - name: app-files
+ mountPath: /app-files
+ - name: wait-for-db
+ image: busybox:latest
+ command: ['sh', '-c', 'until nc -z db 3306; do echo waiting for db; sleep 2; done']
+ - name: wait-for-cache
+ image: busybox:latest
+ command: ['sh', '-c', 'until nc -z cache 6379; do echo waiting for cache; sleep 2; done']
diff --git a/helm/templates/deploy-worker.yaml b/helm/templates/deploy-worker.yaml
new file mode 100644
index 0000000..2726ace
--- /dev/null
+++ b/helm/templates/deploy-worker.yaml
@@ -0,0 +1,39 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: worker
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: worker
+spec:
+ replicas: {{ .Values.app.replicas }}
+ selector:
+ matchLabels:
+ {{- include "chart.selectorLabels" . | nindent 6 }}
+ app: worker
+ template:
+ metadata:
+ labels:
+ {{- include "chart.selectorLabels" . | nindent 8 }}
+ app: worker
+ spec:
+ containers:
+ - name: worker
+ image: {{ .Values.app.image }}
+ imagePullPolicy: Always
+ command: ["php", "bin/console", "messenger:consume", "async", "-vv"]
+ envFrom:
+ - configMapRef:
+ name: app-config
+ env:
+ - name: OTEL_SERVICE_NAME
+ value: "worker"
+ initContainers:
+ - name: wait-for-db
+ image: busybox:latest
+ command: ['sh', '-c', 'until nc -z db 3306; do echo waiting for db; sleep 2; done']
+ - name: wait-for-rabbitmq
+ image: busybox:latest
+ command: ['sh', '-c', 'until nc -z rabbitmq 5672; do echo waiting for rabbitmq; sleep 2; done']
+
diff --git a/helm/templates/ingress-app.yaml b/helm/templates/ingress-app.yaml
new file mode 100644
index 0000000..ce68ce6
--- /dev/null
+++ b/helm/templates/ingress-app.yaml
@@ -0,0 +1,20 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: {{ include "chart.fullname" . }}-app
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+spec:
+ ingressClassName: nginx
+ rules:
+ - host: localhost
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: app
+ port:
+ number: 80
diff --git a/helm/templates/ingress-lgtm.yaml b/helm/templates/ingress-lgtm.yaml
new file mode 100644
index 0000000..af9004e
--- /dev/null
+++ b/helm/templates/ingress-lgtm.yaml
@@ -0,0 +1,29 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: lgtm-ingress
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ annotations:
+ nginx.ingress.kubernetes.io/rewrite-target: /$2
+ nginx.ingress.kubernetes.io/use-regex: "true"
+spec:
+ ingressClassName: nginx
+ rules:
+ - host: localhost
+ http:
+ paths:
+ - path: /otel(/|$)(.*)
+ pathType: ImplementationSpecific
+ backend:
+ service:
+ name: lgtm
+ port:
+ number: 4318
+ - path: /grafana(/|$)(.*)
+ pathType: ImplementationSpecific
+ backend:
+ service:
+ name: lgtm
+ port:
+ number: 3000
diff --git a/helm/templates/job-init.yaml b/helm/templates/job-init.yaml
new file mode 100644
index 0000000..316c133
--- /dev/null
+++ b/helm/templates/job-init.yaml
@@ -0,0 +1,39 @@
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: init
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: init
+spec:
+ backoffLimit: 3
+ template:
+ spec:
+ containers:
+ - name: init
+ image: {{ .Values.app.image }}
+ imagePullPolicy: Always
+ command:
+ - sh
+ - -c
+ - |
+ set -e
+ echo "Creating database..."
+ php bin/console doctrine:database:create --if-not-exists
+ echo "Creating schema..."
+ php bin/console doctrine:schema:update --force
+ echo "Running database migrations..."
+ php bin/console doctrine:migrations:migrate --no-interaction || true
+ echo "Loading fixtures..."
+ bin/console doctrine:fixtures:load -q
+ echo "✓ Database initialization complete"
+ envFrom:
+ - configMapRef:
+ name: app-config
+ restartPolicy: Never
+ initContainers:
+ - name: wait-for-db
+ image: busybox:latest
+ command: ['sh', '-c', 'until nc -z db 3306; do echo waiting for db; sleep 2; done']
+ ttlSecondsAfterFinished: 300
diff --git a/helm/templates/pod-cache.yaml b/helm/templates/pod-cache.yaml
new file mode 100644
index 0000000..9bdb994
--- /dev/null
+++ b/helm/templates/pod-cache.yaml
@@ -0,0 +1,45 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: cache
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: cache
+spec:
+ containers:
+ - name: redis
+ image: {{ .Values.cache.image }}
+ ports:
+ - containerPort: {{ .Values.cache.port }}
+ name: redis
+ livenessProbe:
+ exec:
+ command:
+ - redis-cli
+ - ping
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ readinessProbe:
+ exec:
+ command:
+ - redis-cli
+ - ping
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ restartPolicy: Always
+
+ # DOCUMENTATION:
+ # For PRODUCTION use StatefulSet instead of Pod!
+ #
+ # This Pod is ephemeral - Redis data is lost on pod restart.
+ #
+ # Production setup should use:
+ # 1. StatefulSet with persistent storage
+ # 2. PersistentVolume (PV) for Redis persistence
+ # 3. PersistentVolumeClaim (PVC)
+ #
+ # Also consider:
+ # - Redis Cluster for high availability
+ # - Redis Sentinel for failover
+ # - Using managed Redis services (AWS ElastiCache, GCP Memorystore, etc.)
diff --git a/helm/templates/pod-db.yaml b/helm/templates/pod-db.yaml
new file mode 100644
index 0000000..bafc1b9
--- /dev/null
+++ b/helm/templates/pod-db.yaml
@@ -0,0 +1,81 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: db
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: db
+spec:
+ containers:
+ - name: mysql
+ image: {{ .Values.database.image }}
+ ports:
+ - containerPort: {{ .Values.database.port }}
+ name: mysql
+ env:
+ - name: MYSQL_ROOT_PASSWORD
+ value: {{ .Values.database.env.MYSQL_ROOT_PASSWORD | quote }}
+ - name: MYSQL_DATABASE
+ value: {{ .Values.database.env.MYSQL_DATABASE | quote }}
+ - name: MYSQL_USER
+ value: {{ .Values.database.env.MYSQL_USER | quote }}
+ - name: MYSQL_PASSWORD
+ value: {{ .Values.database.env.MYSQL_PASSWORD | quote }}
+ livenessProbe:
+ exec:
+ command:
+ - mysqladmin
+ - ping
+ - -h
+ - localhost
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ readinessProbe:
+ exec:
+ command:
+ - mysqladmin
+ - ping
+ - -h
+ - localhost
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ restartPolicy: Always
+
+ # DOCUMENTATION:
+ # For PRODUCTION use StatefulSet instead of Pod!
+ #
+ # Reason: This Pod configuration is ephemeral - data is lost on pod restart.
+ #
+ # Production setup should use:
+ # 1. StatefulSet with persistent storage
+ # 2. PersistentVolume (PV) for actual storage
+ # 3. PersistentVolumeClaim (PVC) to attach PV to pod
+ #
+ # Example production StatefulSet structure:
+ # apiVersion: apps/v1
+ # kind: StatefulSet
+ # metadata:
+ # name: {{ include "chart.fullname" . }}-db
+ # spec:
+ # serviceName: db
+ # replicas: 1
+ # selector:
+ # matchLabels:
+ # app: db
+ # template:
+ # # ... pod spec above ...
+ # spec:
+ # containers:
+ # - name: mysql
+ # volumeMounts:
+ # - name: mysql-data
+ # mountPath: /var/lib/mysql
+ # volumeClaimTemplates:
+ # - metadata:
+ # name: mysql-data
+ # spec:
+ # accessModes: [ "ReadWriteOnce" ]
+ # resources:
+ # requests:
+ # storage: 10Gi
diff --git a/helm/templates/pod-lgtm.yaml b/helm/templates/pod-lgtm.yaml
new file mode 100644
index 0000000..3763b85
--- /dev/null
+++ b/helm/templates/pod-lgtm.yaml
@@ -0,0 +1,51 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: lgtm
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: lgtm
+spec:
+ containers:
+ - name: lgtm
+ image: {{ .Values.observability.image }}
+ ports:
+ - containerPort: {{ .Values.observability.ports.grafana }}
+ name: grafana
+ - containerPort: {{ .Values.observability.ports.otelGrpc }}
+ name: otel-grpc
+ - containerPort: {{ .Values.observability.ports.otelHttp }}
+ name: otel-http
+ - containerPort: {{ .Values.observability.ports.prometheus }}
+ name: prometheus
+ env:
+ - name: OTEL_TRACES_EXPORTER
+ value: "otlp"
+ - name: GF_SERVER_ROOT_URL
+ value: {{ .Values.observability.grafanaRootUrl | quote }}
+ volumeMounts:
+ - name: grafana-datasources
+ mountPath: /otel-lgtm/grafana/conf/provisioning/datasources
+ - name: grafana-dashboards
+ mountPath: /otel-lgtm/grafana/conf/provisioning/dashboards
+ livenessProbe:
+ httpGet:
+ path: /api/health
+ port: grafana
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ readinessProbe:
+ httpGet:
+ path: /api/health
+ port: grafana
+ initialDelaySeconds: 5
+ periodSeconds: 5
+ volumes:
+ - name: grafana-datasources
+ configMap:
+ name: grafana-datasources
+ - name: grafana-dashboards
+ configMap:
+ name: grafana-provisioning-dashboards
+ restartPolicy: Always
diff --git a/helm/templates/pod-rabbitmq.yaml b/helm/templates/pod-rabbitmq.yaml
new file mode 100644
index 0000000..50bcdd1
--- /dev/null
+++ b/helm/templates/pod-rabbitmq.yaml
@@ -0,0 +1,52 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: rabbitmq
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: rabbitmq
+spec:
+ containers:
+ - name: rabbitmq
+ image: {{ .Values.rabbitmq.image }}
+ ports:
+ - containerPort: {{ .Values.rabbitmq.port }}
+ name: amqp
+ - containerPort: {{ .Values.rabbitmq.managementPort }}
+ name: management
+ env:
+ - name: RABBITMQ_DEFAULT_USER
+ value: {{ .Values.rabbitmq.env.RABBITMQ_DEFAULT_USER | quote }}
+ - name: RABBITMQ_DEFAULT_PASS
+ value: {{ .Values.rabbitmq.env.RABBITMQ_DEFAULT_PASS | quote }}
+ livenessProbe:
+ exec:
+ command:
+ - rabbitmq-diagnostics
+ - ping
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ readinessProbe:
+ exec:
+ command:
+ - rabbitmq-diagnostics
+ - ping
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ restartPolicy: Always
+
+ # DOCUMENTATION:
+ # For PRODUCTION use StatefulSet instead of Pod!
+ #
+ # This Pod is ephemeral - RabbitMQ messages and configuration are lost on pod restart.
+ #
+ # Production setup should use:
+ # 1. StatefulSet with persistent storage
+ # 2. PersistentVolume (PV) for RabbitMQ data
+ # 3. PersistentVolumeClaim (PVC)
+ # 4. RabbitMQ Cluster for high availability (multiple replicas)
+ #
+ # Also consider:
+ # - Using managed message queue services (AWS SQS/MQ, GCP Pub/Sub, etc.)
+ # - RabbitMQ Operator for advanced clustering and management
diff --git a/helm/templates/svc-app.yaml b/helm/templates/svc-app.yaml
new file mode 100644
index 0000000..1a48b53
--- /dev/null
+++ b/helm/templates/svc-app.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: app
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: app
+spec:
+ type: ClusterIP
+ selector:
+ {{- include "chart.selectorLabels" . | nindent 4 }}
+ app: app
+ ports:
+ - port: 80
+ targetPort: http
+ name: http
diff --git a/helm/templates/svc-cache.yaml b/helm/templates/svc-cache.yaml
new file mode 100644
index 0000000..738712b
--- /dev/null
+++ b/helm/templates/svc-cache.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: cache
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: cache
+spec:
+ type: ClusterIP
+ selector:
+ {{- include "chart.selectorLabels" . | nindent 4 }}
+ app: cache
+ ports:
+ - port: 6379
+ targetPort: redis
+ name: redis
diff --git a/helm/templates/svc-db.yaml b/helm/templates/svc-db.yaml
new file mode 100644
index 0000000..d76854e
--- /dev/null
+++ b/helm/templates/svc-db.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: db
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: db
+spec:
+ type: ClusterIP
+ selector:
+ {{- include "chart.selectorLabels" . | nindent 4 }}
+ app: db
+ ports:
+ - port: 3306
+ targetPort: mysql
+ name: mysql
diff --git a/helm/templates/svc-lgtm.yaml b/helm/templates/svc-lgtm.yaml
new file mode 100644
index 0000000..4441930
--- /dev/null
+++ b/helm/templates/svc-lgtm.yaml
@@ -0,0 +1,29 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: lgtm
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: lgtm
+spec:
+ type: ClusterIP
+ selector:
+ {{- include "chart.selectorLabels" . | nindent 4 }}
+ app: lgtm
+ ports:
+ - port: 3000
+ targetPort: grafana
+ name: grafana
+ - port: 4317
+ targetPort: otel-grpc
+ name: otel-grpc
+ - port: 4318
+ targetPort: otel-http
+ name: otel-http
+ - port: 9090
+ targetPort: prometheus
+ name: prometheus
+ - port: 15672
+ targetPort: management
+ name: rabbitmq-management
diff --git a/helm/templates/svc-rabbitmq.yaml b/helm/templates/svc-rabbitmq.yaml
new file mode 100644
index 0000000..0ea1a0c
--- /dev/null
+++ b/helm/templates/svc-rabbitmq.yaml
@@ -0,0 +1,20 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: rabbitmq
+ namespace: {{ .Release.Namespace }}
+ labels:
+ {{- include "chart.labels" . | nindent 4 }}
+ app: rabbitmq
+spec:
+ type: ClusterIP
+ selector:
+ {{- include "chart.selectorLabels" . | nindent 4 }}
+ app: rabbitmq
+ ports:
+ - port: 5672
+ targetPort: amqp
+ name: amqp
+ - port: 15672
+ targetPort: management
+ name: management
diff --git a/helm/values.yaml b/helm/values.yaml
new file mode 100644
index 0000000..f320617
--- /dev/null
+++ b/helm/values.yaml
@@ -0,0 +1,102 @@
+# Global Configuration
+global:
+ environment: prod
+ imageRegistry: "" # Leave empty to use docker.io or set to your registry
+
+# Nginx Reverse Proxy
+nginx:
+ replicas: 1
+ image: nginx:latest
+ port: 80
+
+# PHP Application Backend
+app:
+ replicas: 1
+ image: fullstack-symfony-react:0.9.3
+ port: 9000
+
+ # Admin credentials (change in production!)
+ admin:
+ email: "admin@example.com"
+ password: "secret" # IMPORTANT: Override this in production
+
+ # Symfony secret (change in production!)
+ secret: "5fb106d07c5b59845136ac75b26a925f" # IMPORTANT: Generate a new one
+
+# MySQL Database
+database:
+ image: mysql:8.0
+ port: 3306
+
+ # WARNING: Using Pod for dev/testing only!
+ # For production, use StatefulSet with PersistentVolume (see documentation below)
+ env:
+ MYSQL_ROOT_PASSWORD: "secret" # IMPORTANT: Override in production
+ MYSQL_DATABASE: "fullstack"
+ MYSQL_USER: "symfony"
+ MYSQL_PASSWORD: "secret" # IMPORTANT: Override in production
+
+ # Storage configuration (Pod-based only, ephemeral)
+ # For production StatefulSet with PersistentVolume, add:
+ # persistence:
+ # enabled: true
+ # size: 10Gi
+ # storageClass: "standard"
+
+# Redis Cache & Session Storage
+cache:
+ image: redis:7-alpine
+ port: 6379
+
+ # WARNING: Using Pod for dev/testing only!
+ # For production, use StatefulSet with PersistentVolume
+
+# RabbitMQ Message Broker
+rabbitmq:
+ image: rabbitmq:4-management-alpine
+ port: 5672
+ managementPort: 15672
+
+ env:
+ RABBITMQ_DEFAULT_USER: "rabbitmq"
+ RABBITMQ_DEFAULT_PASS: "rabbitmq" # IMPORTANT: Override in production
+
+ # WARNING: Using Pod for dev/testing only!
+ # For production, use StatefulSet with PersistentVolume
+
+# LGTM Stack (Grafana, Loki, Tempo, Prometheus, OpenTelemetry Collector)
+observability:
+ image: grafana/otel-lgtm:0.8.1
+
+ # Services exposed by LGTM
+ ports:
+ grafana: 3000
+ otelGrpc: 4317
+ otelHttp: 4318
+ prometheus: 9090
+
+ # Grafana root URL - Set this when accessing Grafana through a path-based ingress
+ # Examples:
+ # - Ingress path: "http://localhost/grafana"
+ # - Root access: "http://localhost"
+ grafanaRootUrl: "http://localhost/grafana"
+
+# SPA Frontend Configuration
+spa:
+ # Backend API URL - Use absolute URL for external access
+ # Examples:
+ # - Local development with Ingress: "http://localhost/api"
+ # - Production: "https://example.com/api"
+ # - Internal Kubernetes: Use "/api" for same-host requests
+ backendApiUrl: "/api"
+
+ # OpenTelemetry Collector Address - Where telemetry is sent
+ # Examples:
+ # - Internal Kubernetes: "http://lgtm:4318"
+ # - External via Ingress: "http://localhost/otel"
+ otelCollectorAddress: "http://localhost/otel"
+
+# Database Initialization Job
+init:
+ enabled: true
+ # Job runs composer install + database migrations before app starts
diff --git a/mkdocs/docs/architecture/8_crosscutting_concepts/index.md b/mkdocs/docs/architecture/8_crosscutting_concepts/index.md
index faf3c33..75b6177 100644
--- a/mkdocs/docs/architecture/8_crosscutting_concepts/index.md
+++ b/mkdocs/docs/architecture/8_crosscutting_concepts/index.md
@@ -1,5 +1,85 @@
# Cross-cutting Concepts
+## Single Page Application (SPA) Serving
+
+### Overview
+
+The React application is served differently depending on the environment:
+
+**Development:** Vite dev server (port 5173) with Hot Module Reload (HMR) for fast feedback
+**Production:** Symfony backend (port 8080) serves compiled React app to authenticated users
+
+### Architecture
+
+Two entry points ensure optimal developer experience and production efficiency:
+
+| Environment | Entry Point | Used By | Script | Features |
+|-------------|------------|---------|--------|----------|
+| **Development** | `frontend/index.html` | Vite dev server (5173) | `npm run dev` | HMR enabled, TypeScript source |
+| **Production** | `backend/templates/spa/index.html.twig` | SpaController (8080) | `npm run build` | Compiled bundle, dynamic config |
+
+### Development Flow (Port 5173)
+
+```
+Browser (5173)
+ ↓
+Vite dev server loads: frontend/index.html
+ ↓
+Renders React from: /src/main.tsx (TypeScript source)
+ ↓
+Hardcoded config for localhost development
+ ↓
+Hot Module Reload active - instant feedback on code changes
+```
+
+**Benefits:**
+- Instant feedback loop (HMR refreshes browser on file changes)
+- Source maps available for debugging
+- TypeScript compilation in-memory
+- No rebuild step needed per change
+
+### Production Flow (Port 8080)
+
+```
+Browser (8080/spa)
+ ↓
+Symfony backend (SpaController)
+ ↓
+Renders: backend/templates/spa/index.html.twig
+ ↓
+Twig injects dynamic environment config
+ ↓
+Loads compiled React from: /dist/main.js (production bundle)
+ ↓
+Static asset serving - no HMR
+```
+
+**Benefits:**
+- Optimized bundle (Vite build output)
+- Dynamic configuration at runtime
+- Same origin - no CORS issues
+- Session-based authentication for all users
+
+### Key Differences
+
+| Aspect | Development | Production |
+|--------|-------------|-----------|
+| **Served by** | Vite dev server | Symfony backend |
+| **Port** | 5173 | 8080 |
+| **Config** | Hardcoded in HTML | Injected by Twig |
+| **Source** | TypeScript (`/src/`) | Compiled (`/dist/`) |
+| **HMR** | ✅ Enabled | ❌ Disabled |
+| **Purpose** | Developer productivity | Optimized delivery |
+
+### Related Files
+
+- **Development:** `frontend/index.html`, `frontend/src/main.tsx`, `frontend/vite.config.ts`
+- **Production:** `backend/templates/spa/index.html.twig`, `backend/src/Controller/SpaController.php`
+- **Build:** `build/node/Dockerfile` (Node build stage for Vite compilation)
+- **Development Config:** `docker-compose.yaml` (frontend service with Vite)
+
+---
+
## Observability & Distributed Tracing (OpenTelemetry)
### Overview