diff --git a/.env b/.env index 86bc291..18072d8 100644 --- a/.env +++ b/.env @@ -2,9 +2,9 @@ NGINX_IMAGE=nginx:stable-alpine3.23 # Backend -APP_IMAGE=fullstack-symfony-react-dev:0.9.1 +APP_IMAGE=fullstack-symfony-react-dev:0.9.3 APP_NAME=fullstack-symfony-react -APP_VERSION=0.9.1 +APP_VERSION=0.9.3 APP_ENV=dev ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=secret diff --git a/.gitignore b/.gitignore index bc2662b..5f0d3e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,31 @@ +# Build artifacts project/.deptrac.cache project/diagram.* project/circle.svg -tempo-data/ \ No newline at end of file +tempo-data/ + +# Frontend build output +frontend/dist/ +frontend/node_modules/ +frontend/.env.local +frontend/.env.*.local + +# Backend build output +backend/public/dist/ +backend/vendor/ +backend/var/ +backend/.env.local +backend/.env.*.local + +# IDE and OS files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Environment +.env.local +.env.*.local \ No newline at end of file diff --git a/FEATURES.md b/FEATURES.md index ca4bc96..577a021 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -30,6 +30,7 @@ - ✅ [Loki](https://grafana.com/oss/loki/) for logging - ✅ [Tempo](https://grafana.com/oss/tempo/) for tracing - ✅ [Prometheus](https://prometheus.io/) for metrics + - ✅ use LGTM all in one solution from Grafana - ✅ [CI via Github actions](https://docs.github.com/en/actions/about-github-actions/about-continuous-integration-with-github-actions) - ✅ PhpUnit 13 - ✅ Vitest diff --git a/Makefile b/Makefile index f8410f1..3177956 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,13 @@ APP_NAME = fullstack-symfony-react -VERSION = 0.9.1 +VERSION = 0.9.3 .DEFAULT_GOAL := help -.PHONY: build dev prod backend-image frontend-image grafana up down reset \ +.PHONY: build dev prod backend-image grafana up down reset \ reset-worker reset-app init composer-install create-database create-schema \ load-fixtures init-test create-test-database create-test-schema composer shell \ qa sa cs test backend-test frontend-test arch clear cache-clear cache-pool-clear \ - maintenance maintain show-composer-updates \ + maintenance maintain show-composer-updates frontend-build \ update-composer-dependencies update-npm-dependencies coverage frontend-shell open help ## Start development environment (build images, start containers, init, open browser) @@ -20,18 +20,14 @@ dev: docker build . -f ./build/php/Dockerfile --target dev -t ${APP_NAME}-dev:${VERSION} ## Build production images (without Docker cache) -prod: backend-image frontend-image +prod: backend-image + ## Build backend image (without Docker cache) backend-image: @echo "Build backend image" docker build . -f ./build/php/Dockerfile --target prod --no-cache -t ${APP_NAME}:${VERSION} -## Build frontend image (without Docker cache) -frontend-image: - @echo "Build frontend image" - docker build . -f ./build/node/Dockerfile --target prod --no-cache -t ${APP_NAME}-web:${VERSION} - grafana: @echo "Build grafana image" docker build . -f ./build/grafana/Dockerfile -t ${APP_NAME}-grafana:${VERSION} @@ -44,8 +40,8 @@ up: down: docker compose down -## Reset all services -reset: reset-worker reset-app +## Reset worker, app, frontend +reset: reset-worker reset-app reset-frontend ## Reset worker reset-worker: @@ -57,14 +53,24 @@ reset-app: @echo "Reset app" docker compose restart app +## Reset frontend (Vite development server) +reset-frontend: + @echo "Reset frontend" + docker compose restart frontend + ## Initialize project (install dependencies, create database, schema, load fixtures) -init: composer-install create-database create-schema load-fixtures +init: composer-install frontend-build create-database create-schema load-fixtures ## Install composer dependencies composer-install: @echo "Install composer dependencies" docker compose exec -it app composer install +## Build frontend assets (Vite production build) +frontend-build: + @echo "Build frontend assets to backend/public/dist" + docker compose run --rm frontend-build + ## Create database create-database: @echo "Create database" diff --git a/README.md b/README.md index 0dde6f5..5a37b9b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ monitoring and observability. ### Tech Stack | Layer | Technology | |-------|-----------| -| Frontend | React 19 + MUI | +| Frontend (modern) | React 19 + MUI | +| Frontend (legacy) | Symfony 8 + TWIG + Bootstrap | Backend | PHP 8.4 + Symfony 8 | | Database | MySQL 8 | | DevOps | Docker, Kubernetes, Helm, Open Telemetry, Grafana | @@ -32,75 +33,45 @@ monitoring and observability. ## System Architecture ``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Client Layer │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ React UI │ │ Classic UI │ │ -│ │ (Modern) │ │ (Legacy) │ │ -│ │ Port 8090 │ │ Port 8080 │ │ -│ └────────┬─────────┘ └────────┬─────────┘ │ -│ │ │ │ -└───────────┼──────────────────────────────────┼──────────────────────────┘ - │ │ - └──────────────────┬───────────────┘ - │ -┌──────────────────────────────▼───────────────────────────────────────────┐ -│ API Gateway Layer │ -├──────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────┐ │ -│ │ Nginx (Reverse Proxy) │ │ -│ │ Port 8080 │ │ -│ └─────────────────────────────────────┘ │ -│ │ │ -└───────────────────────────────┼──────────────────────────────────────────┘ - │ -┌───────────────────────────────▼──────────────────────────────────────────┐ -│ Backend Application Layer │ +┌──────────────────────────────────────────────────────────────────────────┐ +│ Client Layer │ ├──────────────────────────────────────────────────────────────────────────┤ │ │ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ PHP 8.4 + Symfony 8 Application │ │ -│ │ - REST API Endpoints │ │ -│ │ - Business Logic │ │ -│ │ - Event Sourcing │ │ -│ └────────┬──────────────────┬─────────────┬──────────────────┘ │ -│ │ │ │ │ -└───────────┼──────────────────┼─────────────┼─────────────────────────────┘ +│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ React UI │ │ Legacy UI │ │ Login Form │ │ +│ │ (Modern SPA) │ │ (TWIG / Game) │ │ (TWIG Template) │ │ +│ │ Port 5173 │ │ Port 8080 │ │ Port 8080 │ │ +│ │ (dev via Vite) │ │ (via SpaCtrl) │ │ (via Backend) │ │ +│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │ +│ │ │ │ │ +└───────────┼─────────────────────┼─────────────────────┼──────────────────┘ + │ │ │ + └─────────────────────┴─────────┬───────────┘ + │ +┌───────────────────────────────────────────▼────────────────────────────────┐ +│ Backend (Port 8080) │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ PHP 8.4 + Symfony 8 Application │ │ +│ │ - SpaController (React app for authenticated users) │ │ +│ │ - Game Routes (Legacy UI endpoints) │ │ +│ │ - REST API Endpoints │ │ +│ │ - Business Logic & Event Sourcing │ │ +│ │ - Session-based Authentication │ │ +│ └────────┬──────────────────┬─────────────┬────────────────────┘ │ +│ │ │ │ │ +└───────────┼──────────────────┼─────────────┼───────────────────────────────┘ │ │ │ - ┌───────▼──────┐ ┌──────▼────────┐ ┌─▼──────────────┐ + ┌───────▼──────┐ ┌───────▼───────┐ ┌──▼─────────────┐ │ │ │ │ │ │ │ Database │ │ Redis Cache │ │ Message Queue │ │ MySQL 8 │ │ │ │ (Messenger) │ - │ Port 3306 │ │ Port 6379 │ │ - Async Tasks │ - │ │ │ │ │ - Background │ - │ Events & │ │ - Sessions │ │ Jobs │ - │ Data │ │ - Cache │ │ │ + │ Port 3306 │ │ Port 6379 │ │ │ + │ │ │ │ │ │ + │ Events & │ │ - Sessions │ │ - Async Tasks │ + │ Data │ │ - Cache │ │ - Background │ └──────────────┘ └───────────────┘ └────────────────┘ - │ - ┌────────▼──────────┐ - │ Background Worker │ - │ (Messenger) │ - │ - Process Events │ - │ - Async Tasks │ - └───────────────────┘ - -┌──────────────────────────────────────────────────────────────────────────┐ -│ Observability Layer (LGTM Stack) │ -├──────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌────────────┐ ┌─────────┐ ┌─────────────┐ ┌─────────┐ │ -│ │ Logs │ │ Traces │ │ Metrics │ │Grafana │ │ -│ │ (Loki) │ │ (Tempo) │ │ (Prometheus)│ │Dashboard│ │ -│ │ Port 3100 │ │ 3200 │ │ 9090 │ │3000 │ │ -│ └────────────┘ └─────────┘ └─────────────┘ └─────────┘ │ -│ ▲ ▲ ▲ │ -│ └─────────────┴─────────────┘ │ -│ OpenTelemetry Collector (Port 4317/4318) │ -│ │ -└──────────────────────────────────────────────────────────────────────────┘ ``` ## Prerequisites @@ -123,11 +94,6 @@ make up # Start all services make open # Open dashboard in browser ``` -**What's running?** -- Backend API at [http://localhost:8080](http://localhost:8080) -- React UI at [http://localhost:8090](http://localhost:8090) -- Monitoring at [http://localhost:3000](http://localhost:3000) - **Stop everything:** ```sh make down @@ -138,10 +104,12 @@ make down ## Services & Access Points **Frontend** -| Service | URL | Credentials | -|---------|-----|-------------| -| Classic UI | [http://localhost:8080](http://localhost:8080) | — | -| React UI | [http://localhost:8090](http://localhost:8090) | — | +| Service | URL | +|---------|-----| +| Login Form | [http://localhost:8080/login](http://localhost:8080/login) | +| Legacy App | [http://localhost:8080/game/index](http://localhost:8080/game/index) | +| React App (dev with HMR) | [http://localhost:5173](http://localhost:5173) | +| React App (production build) | [http://localhost:8080/spa](http://localhost:8080/spa) | **Backend & API** | Service | URL | Credentials | @@ -151,16 +119,17 @@ make down | Admin UI | [http://localhost:8080/admin](http://localhost:8080/admin) | admin@example.com:secret | **Monitoring & Observability** -| Service | URL | Credentials | -|---------|-----|-------------| -| Grafana Dashboard | [http://localhost:3000](http://localhost:3000) (Docker Compose) or [http://kubernetes.docker.internal:30300](http://kubernetes.docker.internal:30300) (Kubernetes) | — | +| Service | URL | +|---------|-----| +| Grafana Dashboard | [http://localhost:3000](http://localhost:3000) | **Data & Infrastructure** | Service | URL | Credentials | |---------|-----|-------------| -| Database (Adminer) | [http://localhost:8085](http://localhost:8085) | root:secret | -| Redis Cache | [http://localhost:5540](http://localhost:5540) | — | -| RabbitMQ | [http://localhost:15672](http://localhost:15672) | guest:guest | +| Database (Adminer UI) | [http://localhost:8085](http://localhost:8085) | root:secret | +| Redis Cache UI | [http://localhost:5540](http://localhost:5540) | — | +| RabbitMQ Management | [http://localhost:15672](http://localhost:15672) | guest:guest | +| OpenTelemetry Collector | localhost:4317 (gRPC), localhost:4318 (HTTP) | — | | Documentation | [http://localhost:8005](http://localhost:8005) | — | ## Quality Assurance & Development @@ -188,26 +157,37 @@ make shell # Shell access to PHP container ## Kubernetes Deployment -**Install with Helm:** +**Prerequisites:** +- Kubernetes cluster (e.g., Docker Desktop K8s, Minikube, EKS) +- `kubectl` CLI +- `helm` CLI -```sh -helm install app ./helm/app +**Deploy with Helm:** -# or with 2 replicas (Pods) each: -helm install app ./helm/app --set frontend.replicas=2 --set backend.replicas=2 +```sh +# Install using Ingress-based networking (Recommended) +helm install app ./helm -# for telemetry: -helm install telemetry ./helm/telemetry +# Verify deployment +kubectl get pods +kubectl get ingress ``` -**Access Services** -| Service | Internal | Local | -|---------|----------|-------| -| API | http://kubernetes.docker.internal:30100 | http://localhost:30100 | -| Webapp | http://kubernetes.docker.internal:30200 | http://localhost:30200 | -| Dashboard | http://kubernetes.docker.internal:30300 | http://localhost:30300 | - -See [helm](./helm/) for configuration details. +**Access Services via Ingress:** +| Service | URL | +|---------|-----| +| App & API | [http://localhost/](http://localhost/) | +| React SPA | [http://localhost/spa](http://localhost/spa) | +| OpenTelemetry | [http://localhost/otel](http://localhost/otel) | +| Grafana Dashboard | [http://localhost/grafana](http://localhost/grafana) | + +**Internal Services (inside cluster):** +- Database: `db:3306` +- Redis: `cache:6379` +- RabbitMQ: `rabbitmq:5672` + +**Configuration:** +See [helm/README.md](./helm/README.md) for Helm chart configuration, customization, and production deployment. ## Learning & References diff --git a/backend/.env b/backend/.env index f325b44..f0715ba 100644 --- a/backend/.env +++ b/backend/.env @@ -57,4 +57,7 @@ LOCK_DSN=flock ###< symfony/lock ### ADMIN_EMAIL='admin@example.com' -ADMIN_PASSWORD='secret' \ No newline at end of file +ADMIN_PASSWORD='secret' +# SPA Frontend Configuration +SPA_BACKEND_API_URL="http://localhost:8080/api" +SPA_OTEL_COLLECTOR_ADDRESS="http://localhost:4318" diff --git a/backend/config/packages/nelmio_api_doc.yaml b/backend/config/packages/nelmio_api_doc.yaml index 8f49d5e..e123af2 100644 --- a/backend/config/packages/nelmio_api_doc.yaml +++ b/backend/config/packages/nelmio_api_doc.yaml @@ -3,7 +3,7 @@ nelmio_api_doc: info: title: Fullstack Symfony React description: Fullstack demo application - version: 0.9.1 + version: 0.9.3 areas: default: path_patterns: [^/api/games] diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index e4bf3ce..457a08e 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -16,15 +16,18 @@ security: main: lazy: true provider: app_user_provider - json_login: - login_path: api.login - check_path: api.login - username_path: email - password_path: password + entry_point: App\Security\CustomAuthenticationEntryPoint form_login: login_path: app.login check_path: app.login enable_csrf: true + success_handler: App\Security\AuthenticationSuccessHandler + # Symfony's built-in target path parameter for post-login redirects + # When login form includes _target_path, user is redirected there after successful auth + target_path_parameter: _target_path + # Default fallback if no _target_path is provided + # Users are now redirected to the modern React SPA by default + default_target_path: /spa remember_me: secret: '%kernel.secret%' lifetime: 604800 # 1 week in seconds @@ -33,8 +36,7 @@ security: name: REMEMBERME logout: path: app.logout - # where to redirect after logout - # target: app_any_route + target: app.login # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall @@ -46,8 +48,7 @@ security: # Note: Only the *first* access control that matches will be used access_control: - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } - - { path: ^/login, roles: PUBLIC_ACCESS } + - { path: ^/spa, roles: ROLE_USER } - { path: ^/game, roles: ROLE_USER } - { path: ^/api/me, roles: PUBLIC_ACCESS } - { path: ^/api, roles: ROLE_USER } diff --git a/backend/config/routes.yaml b/backend/config/routes.yaml index fee3e05..60cdc89 100644 --- a/backend/config/routes.yaml +++ b/backend/config/routes.yaml @@ -4,9 +4,10 @@ controllers: namespace: App\Controller type: attribute +# Root redirect to default route (for unauthenticated users) +# Attribute-based routes (controllers) have priority and will be checked first root_shortcut: path: / controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController defaults: route: 'app.index_games' - ignoreAttributes: true diff --git a/backend/config/services.yaml b/backend/config/services.yaml index dad4740..41c72bf 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -8,6 +8,8 @@ parameters: app.telemetry: '%env(APP_TELEMETRY)%' admin.email: '%env(ADMIN_EMAIL)%' admin.password: '%env(ADMIN_PASSWORD)%' + spa.backend_api_url: '%env(string:SPA_BACKEND_API_URL)%' + spa.otel_collector_address: '%env(string:SPA_OTEL_COLLECTOR_ADDRESS)%' services: # default configuration for services in *this* file @@ -50,3 +52,8 @@ services: - '%kernel.project_dir%' tags: - { name: data_collector, template: 'profiler/architecture.html.twig', id: 'app.architecture_collector' } + + App\Controller\SpaController: + arguments: + $backendApiUrl: '%spa.backend_api_url%' + $otelCollectorAddress: '%spa.otel_collector_address%' diff --git a/backend/public/css/styles.css b/backend/public/css/styles.css index e65fd34..80b2e31 100644 --- a/backend/public/css/styles.css +++ b/backend/public/css/styles.css @@ -1,4 +1,4 @@ -/* assets/css/styles.css */ +/* public/css/styles.css */ .logout-button { margin-right: 8px; } diff --git a/backend/public/images/flower.svg b/backend/public/images/flower.svg deleted file mode 100644 index 12ef059..0000000 --- a/backend/public/images/flower.svg +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/backend/public/js/deptrac-visualization/index.js b/backend/public/js/deptrac-visualization/index.js deleted file mode 100644 index a4f1ba1..0000000 --- a/backend/public/js/deptrac-visualization/index.js +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Deptrac Architecture Visualization - * Renders an onion architecture diagram from deptrac.yaml using SVG - * - * Usage: - * const viz = new DeptracVisualization('container-id', { - * layers: { Core: {...}, Supporting: {...}, ... }, - * dependencies: { Core: [], Supporting: ['Core'], ... } - * }); - */ - -class DeptracVisualization { - constructor(containerId, data) { - this.container = document.getElementById(containerId); - if (!this.container) { - console.error(`Container with id "${containerId}" not found`); - return; - } - - this.data = data; - this.width = 900; - this.height = 700; - this.centerX = this.width / 2; - this.centerY = this.height / 2; - this.margin = 80; - this.render(); - } - - render() { - this.container.innerHTML = ''; - - const layers = this.data.layers || {}; - const dependencies = this.data.dependencies || {}; - - if (Object.keys(layers).length === 0) { - this.container.innerHTML = '

No architecture data available

'; - return; - } - - const svg = this._createSvg(); - const availableRadius = Math.min(this.width, this.height) / 2 - this.margin; - - const layerOrder = ['Core', 'Supporting', 'Tests', 'Generic']; - const sortedLayers = layerOrder - .filter(name => layers[name]) - .map(name => ({ name, ...layers[name] })); - - const layerCount = sortedLayers.length; - const layerThickness = availableRadius / layerCount; - const colors = this._getColorPalette(); - const layerPositions = {}; - - sortedLayers.forEach((layer, index) => { - const depth = layerCount - 1 - index; - const innerRadius = availableRadius - ((depth + 1) * layerThickness); - const outerRadius = availableRadius - (depth * layerThickness); - const midRadius = (innerRadius + outerRadius) / 2; - - layerPositions[layer.name] = { index, innerRadius, outerRadius, midRadius }; - - this._drawLayer(svg, layer, colors[layer.name], innerRadius, outerRadius, midRadius); - }); - - this._drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions); - this._drawLegend(svg, colors); - - this.container.appendChild(svg); - } - - _createSvg() { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('width', this.width); - svg.setAttribute('height', this.height); - svg.setAttribute('style', 'display: block;'); - svg.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`); - - const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - bg.setAttribute('width', this.width); - bg.setAttribute('height', this.height); - bg.setAttribute('fill', 'white'); - svg.appendChild(bg); - - this._addArrowMarker(svg); - return svg; - } - - _getColorPalette() { - return { - 'Core': { fill: '#ff00eecc', stroke: '#9E7777', text: '#ffffff' }, - 'Supporting': { fill: '#ff00ee99', stroke: '#5F9A8C', text: '#ffffff' }, - 'Generic': { fill: '#ff00ee33', stroke: '#4E7FA3', text: '#ffffff' }, - 'Tests': { fill: '#ff00ee77', stroke: '#D08842', text: '#ffffff' } - }; - } - - _addArrowMarker(svg) { - const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); - const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); - - marker.setAttribute('id', 'arrowhead'); - marker.setAttribute('markerWidth', '10'); - marker.setAttribute('markerHeight', '10'); - marker.setAttribute('refX', '8'); - marker.setAttribute('refY', '3'); - marker.setAttribute('orient', 'auto'); - - const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); - polygon.setAttribute('points', '0 0, 10 3, 0 6'); - polygon.setAttribute('fill', '#666'); - marker.appendChild(polygon); - - defs.appendChild(marker); - svg.appendChild(defs); - } - - _drawLayer(svg, layer, color, innerRadius, outerRadius, midRadius) { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - const outerCircle = `M ${this.centerX + outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX - outerRadius} ${this.centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${this.centerX + outerRadius} ${this.centerY}`; - const innerCircle = `M ${this.centerX + innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX - innerRadius} ${this.centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${this.centerX + innerRadius} ${this.centerY}`; - - path.setAttribute('d', outerCircle + ' ' + innerCircle); - path.setAttribute('fill', color.fill); - path.setAttribute('fill-rule', 'evenodd'); - path.setAttribute('stroke', color.stroke); - path.setAttribute('stroke-width', '1'); - svg.appendChild(path); - - const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - label.setAttribute('x', this.centerX); - label.setAttribute('y', this.centerY - midRadius); - label.setAttribute('text-anchor', 'middle'); - label.setAttribute('dominant-baseline', 'middle'); - label.setAttribute('font-size', '18'); - label.setAttribute('font-weight', 'bold'); - label.setAttribute('fill', color.text); - label.textContent = layer.name; - svg.appendChild(label); - } - - _drawDependencyArrows(svg, sortedLayers, dependencies, layerPositions) { - sortedLayers.forEach((sourceLayer) => { - const deps = dependencies[sourceLayer.name] || []; - const sourcePos = layerPositions[sourceLayer.name]; - - if (!deps || deps.length === 0) return; - - deps.forEach((targetLayerName) => { - const targetPos = layerPositions[targetLayerName]; - if (!targetPos) return; - - const depCount = deps.length; - const depIndex = deps.indexOf(targetLayerName); - const angle = (depIndex * (360 / depCount)) - 90 + 22.5; - const angleRad = (angle * Math.PI) / 180; - - const startX = this.centerX + Math.cos(angleRad) * sourcePos.midRadius; - const startY = this.centerY + Math.sin(angleRad) * sourcePos.midRadius; - const endX = this.centerX + Math.cos(angleRad) * targetPos.midRadius; - const endY = this.centerY + Math.sin(angleRad) * targetPos.midRadius; - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', `M ${startX} ${startY} L ${endX} ${endY}`); - path.setAttribute('fill', 'none'); - path.setAttribute('stroke', '#333'); - path.setAttribute('stroke-width', '2'); - path.setAttribute('marker-end', 'url(#arrowhead)'); - svg.appendChild(path); - }); - }); - } - - _drawLegend(svg, colors) { - const legendX = this.width - 200; - const legendY = 20; - - const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - bg.setAttribute('x', legendX - 10); - bg.setAttribute('y', legendY - 10); - bg.setAttribute('width', 190); - bg.setAttribute('height', 150); - bg.setAttribute('fill', '#ffffff'); - bg.setAttribute('stroke', '#ddd'); - bg.setAttribute('stroke-width', '1'); - bg.setAttribute('opacity', '0.95'); - svg.appendChild(bg); - - const title = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - title.setAttribute('x', legendX); - title.setAttribute('y', legendY + 15); - title.setAttribute('font-size', '14'); - title.setAttribute('font-weight', 'bold'); - title.setAttribute('fill', '#333'); - title.textContent = 'Layers'; - svg.appendChild(title); - - ['Core', 'Supporting', 'Generic', 'Tests'].forEach((layerName, i) => { - const yPos = legendY + 35 + i * 25; - - const square = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - square.setAttribute('x', legendX); - square.setAttribute('y', yPos - 8); - square.setAttribute('width', '12'); - square.setAttribute('height', '12'); - square.setAttribute('fill', colors[layerName].fill); - svg.appendChild(square); - - const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - label.setAttribute('x', legendX + 20); - label.setAttribute('y', yPos); - label.setAttribute('font-size', '12'); - label.setAttribute('fill', '#333'); - label.textContent = layerName; - svg.appendChild(label); - }); - - const arrowY = legendY + 130; - const arrowLine = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - arrowLine.setAttribute('d', `M ${legendX} ${arrowY} L ${legendX + 15} ${arrowY}`); - arrowLine.setAttribute('stroke', '#333'); - arrowLine.setAttribute('stroke-width', '2'); - arrowLine.setAttribute('marker-end', 'url(#arrowhead)'); - svg.appendChild(arrowLine); - - const arrowLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - arrowLabel.setAttribute('x', legendX + 20); - arrowLabel.setAttribute('y', arrowY + 3); - arrowLabel.setAttribute('font-size', '11'); - arrowLabel.setAttribute('fill', '#666'); - arrowLabel.textContent = 'Dependency'; - svg.appendChild(arrowLabel); - } -} - -// For backward compatibility -const ArchitectureVisualization = DeptracVisualization; - -// Initialize on document ready -document.addEventListener('DOMContentLoaded', function() { - const container = document.getElementById('architecture-visualization'); - if (container && window.architectureData) { - new DeptracVisualization('architecture-visualization', window.architectureData); - } -}); diff --git a/backend/src/Controller/API/LoginController.php b/backend/src/Controller/API/LoginController.php deleted file mode 100644 index 3b435d8..0000000 --- a/backend/src/Controller/API/LoginController.php +++ /dev/null @@ -1,30 +0,0 @@ -json( - ['message' => 'missing credentials'], - Response::HTTP_UNAUTHORIZED - ); - } - - return $this->json([ - 'user' => $user->getUserIdentifier(), - // TODO: list roles, permissions, created tokens etc. - ]); - } -} diff --git a/backend/src/Controller/API/LogoutController.php b/backend/src/Controller/API/LogoutController.php deleted file mode 100644 index d41b749..0000000 --- a/backend/src/Controller/API/LogoutController.php +++ /dev/null @@ -1,28 +0,0 @@ -invalidate(); - $tokenStorage->setToken(null); - - $response = new Response(content: null, status: Response::HTTP_NO_CONTENT); - - $response->headers->clearCookie('PHPSESSID', '/', null, false, false, 'lax'); - $response->headers->clearCookie('REMEMBERME', '/', null, false, false, 'lax'); - - return $response; - } -} diff --git a/backend/src/Controller/API/MeController.php b/backend/src/Controller/API/MeController.php index 1785683..82a8c14 100644 --- a/backend/src/Controller/API/MeController.php +++ b/backend/src/Controller/API/MeController.php @@ -7,6 +7,12 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +/** + * API endpoint to check current user session. + * Used by frontend's checkRememberMeAsync() during app initialization. + * Required for session verification when user returns with remember-me cookie. + */ +// TODO (redesign): I don't think this is useful anymore. Check if we can remove it! #[Route('/api', name: 'api.')] class MeController extends AbstractController { @@ -23,7 +29,6 @@ public function me(): JsonResponse return $this->json([ 'user' => $user->getUserIdentifier(), - // TODO: list roles, permissions, created tokens etc. ]); } } diff --git a/backend/src/Controller/Admin/DashboardController.php b/backend/src/Controller/Admin/DashboardController.php index 8ec35ea..6bbcdb9 100644 --- a/backend/src/Controller/Admin/DashboardController.php +++ b/backend/src/Controller/Admin/DashboardController.php @@ -2,7 +2,6 @@ namespace App\Controller\Admin; -use App\Entity\User; use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard; use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard; use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem; diff --git a/backend/src/Controller/AdminLoginRedirectController.php b/backend/src/Controller/AdminLoginRedirectController.php new file mode 100644 index 0000000..4d28c9d --- /dev/null +++ b/backend/src/Controller/AdminLoginRedirectController.php @@ -0,0 +1,23 @@ +redirectToRoute('app.login', [ + '_username' => 'admin@example.com', + ], status: RedirectResponse::HTTP_FOUND); + } +} diff --git a/backend/src/Controller/GameController.php b/backend/src/Controller/GameController.php index 3c3f473..9aff371 100644 --- a/backend/src/Controller/GameController.php +++ b/backend/src/Controller/GameController.php @@ -10,7 +10,6 @@ use App\Game\Command\RemoveGameCommand; use App\Game\Game; use App\Game\Games; -use App\Game\Statistic; use App\Game\Statistics; use Faker\Factory as Faker; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; diff --git a/backend/src/Controller/SpaController.php b/backend/src/Controller/SpaController.php new file mode 100644 index 0000000..8aa0704 --- /dev/null +++ b/backend/src/Controller/SpaController.php @@ -0,0 +1,62 @@ + '^(?!api|admin|login|logout|_)[a-z0-9/\-]*$'], methods: ['GET'], defaults: ['reactRouting' => ''])] + public function index(string $reactRouting = ''): Response + { + $projectDir = $this->getParameter('kernel.project_dir'); + if (!is_string($projectDir)) { + throw new \RuntimeException('kernel.project_dir parameter must be a string'); + } + + $viteIndexPath = $projectDir.'/public/dist/index.html'; + + if (!file_exists($viteIndexPath)) { + throw $this->createNotFoundException('React app build not found. Run "make frontend-build"'); + } + + $content = file_get_contents($viteIndexPath); + if (!is_string($content)) { + throw new \RuntimeException('Failed to read React app build'); + } + + // Inject environment variables into the HTML + $content = preg_replace( + '/', json_encode($this->backendApiUrl)), + $content + ); + + assert(is_string($content), new \RuntimeException('Failed to configure "BACKEND_API_URL"!')); + + $content = preg_replace( + '/', json_encode($this->otelCollectorAddress)), + $content + ); + + assert(is_string($content), new \RuntimeException('Failed to configure "OTEL_COLLECTOR_ADDRESS"!')); + + return new Response($content, 200, ['Content-Type' => 'text/html; charset=utf-8']); + } +} diff --git a/backend/src/Game/EventStoreInterface.php b/backend/src/Game/EventStoreInterface.php index c2266df..ba52ac7 100644 --- a/backend/src/Game/EventStoreInterface.php +++ b/backend/src/Game/EventStoreInterface.php @@ -9,7 +9,6 @@ interface EventStoreInterface /** @return Event[] */ public function getEvents(QueryOptions $options = new QueryOptions()): array; - // TODO return the event itself. public function persist(Event $event, bool $dontFlush = false): void; public function reset(): void; diff --git a/backend/src/Security/AuthenticationSuccessHandler.php b/backend/src/Security/AuthenticationSuccessHandler.php new file mode 100644 index 0000000..d14df45 --- /dev/null +++ b/backend/src/Security/AuthenticationSuccessHandler.php @@ -0,0 +1,81 @@ +getTargetPathFromRequest($request); + + $this->logger->info('AuthenticationSuccessHandler called', [ + 'targetPath' => $targetPath, + 'requestData' => [ + 'post' => $request->request->all(), + 'query' => $request->query->all(), + ], + ]); + + // If a target path was provided in the request, use it + if ($targetPath && ($this->isValidPath($targetPath) || $this->isValidUrl($targetPath))) { + $this->logger->info('Redirecting to target path', ['target' => $targetPath]); + + return new RedirectResponse($targetPath); + } + + // Otherwise use role-based defaults + $user = $token->getUser(); + if ($user && in_array('ROLE_ADMIN', $user->getRoles(), strict: true)) { + $adminPath = $this->router->generate('admin'); + $this->logger->info('Redirecting admin to admin panel', ['path' => $adminPath]); + + return new RedirectResponse($adminPath); + } + + $gamePath = $this->router->generate('app.index_games'); + $this->logger->info('Redirecting user to games', ['path' => $gamePath]); + + return new RedirectResponse($gamePath); + } + + private function getTargetPathFromRequest(Request $request): ?string + { + // Check POST first (from form submission) + $targetPath = $request->request->get('_target_path'); + if ($targetPath && is_string($targetPath)) { + return $targetPath; + } + + // Check query string as fallback + $targetPath = $request->query->get('_target_path'); + + return is_string($targetPath) ? $targetPath : null; + } + + private function isValidPath(string $path): bool + { + // Ensure it starts with / (relative path) + return str_starts_with($path, '/'); + } + + private function isValidUrl(string $url): bool + { + // Accept absolute URLs that start with http + return str_starts_with($url, 'http://') || str_starts_with($url, 'https://'); + } +} diff --git a/backend/src/Security/CustomAuthenticationEntryPoint.php b/backend/src/Security/CustomAuthenticationEntryPoint.php new file mode 100644 index 0000000..895534f --- /dev/null +++ b/backend/src/Security/CustomAuthenticationEntryPoint.php @@ -0,0 +1,61 @@ +getPathInfo(); + + // If we're already at /login, try to extract target from referrer or use /spa as fallback + if ('/login' === $targetPath) { + $referer = $request->headers->get('Referer'); + if ($referer) { + // Parse the referrer URL to extract just the path + $refererPath = parse_url($referer, PHP_URL_PATH); + // Only use /spa or /game paths, not login or other pages + if ($refererPath && (str_starts_with($refererPath, '/spa') || str_starts_with($refererPath, '/game'))) { + $targetPath = $refererPath; + } + } + // Fallback to /spa + if ('/login' === $targetPath) { + $targetPath = '/spa'; + } + } + + // Build login URL with _target_path query parameter + $loginUrl = $this->urlGenerator->generate('app.login', [ + '_target_path' => $targetPath, + ]); + + // Create a redirect response + return new Response( + "\n\n\n". + "\n". + "\n". + "Redirecting to login\n". + "\n\n". + "Redirecting to login.\n". + "\n", + 302, + ['Location' => $loginUrl] + ); + } +} diff --git a/backend/src/Security/LogoutEventListener.php b/backend/src/Security/LogoutEventListener.php new file mode 100644 index 0000000..bc54bf6 --- /dev/null +++ b/backend/src/Security/LogoutEventListener.php @@ -0,0 +1,45 @@ +getRequest(); + + // Try to get the referrer to determine where user came from + $referer = $request->headers->get('Referer'); + $targetPath = '/spa'; // Default to modern app + + if ($referer) { + // Parse the referrer URL to extract just the path + $refererPath = parse_url($referer, PHP_URL_PATH); + // Preserve /spa or /game paths as target + if ($refererPath && (str_starts_with($refererPath, '/spa') || str_starts_with($refererPath, '/game'))) { + $targetPath = $refererPath; + } + } + + // Build login URL with _target_path query parameter + $loginUrl = $this->urlGenerator->generate('app.login', [ + '_target_path' => $targetPath, + ]); + + $event->setResponse(new RedirectResponse($loginUrl)); + } +} diff --git a/backend/templates/login/login.html.twig b/backend/templates/login/login.html.twig index b79e8f5..451d773 100644 --- a/backend/templates/login/login.html.twig +++ b/backend/templates/login/login.html.twig @@ -1,54 +1,103 @@ {% extends 'base.html.twig' %} -{% block title %}Log in!{% endblock %} +{% block title %}Sign In{% endblock %} {% block body %} - -
+ +
-
-
-
- {% if error %} -
{{ error.messageKey|trans(error.messageData, 'security') }}
- {% endif %} +
+
+ +
+

Welcome Back

+

Sign in to your account

+
- {% if app.user %} -
- You are logged in as {{ app.user.userIdentifier }}, Logout -
- {% endif %} + + {% if error %} + + {% 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 %} -
- - + + + +
+ + + We'll never share your email.
-
- - + + +
+ +
- {# - Uncomment this section and add a remember_me option below your firewall to activate remember me functionality. - See https://symfony.com/doc/current/security/remember_me.html - #} -
- - + +
+ +
+ -
- -
+ + {% if app.request.query.get('_target_path') %} + + {% endif %} + + + + +
+ + Need help? Contact your administrator. + +
@@ -56,47 +105,4 @@
- - {% 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