Feature/sentry error logging #5

Merged
Sluijsens merged 11 commits from feature/sentry_error_logging into master 2026-07-25 20:04:18 +02:00
27 changed files with 633 additions and 130 deletions
+10
View File
@@ -0,0 +1,10 @@
# Voorbeeldbestand voor lokale environment-variabelen (Vite).
# Kopieer dit naar `.env.local` (die is al gitignored via de "*.local" regel)
# en vul je eigen waarden in. `.env.local` overschrijft niets in Gitea CI/CD —
# die gebruikt zijn eigen repository variables/secrets (zie continuous_integration.yaml).
# Sentry DSN (Data Source Name) voor client-side error/performance-monitoring.
# Geen secret — een DSN is veilig om client-side te tonen. Maak een (gratis)
# Sentry-project aan op https://sentry.io en plak hier de DSN van dat project.
# Laat leeg/weg om Sentry lokaal uit te schakelen (alleen console-logging blijft actief).
VITE_SENTRY_DSN=
@@ -93,6 +93,17 @@ jobs:
- name: Build - name: Build
run: pnpm run build run: pnpm run build
env:
# Sentry DSN is niet gevoelig (veilig om in de client-bundle te zitten),
# daarom een Gitea Actions "vars"-waarde i.p.v. een secret. Optioneel:
# als deze niet is ingesteld, wordt Sentry-logging simpelweg overgeslagen
# (zie src/main.tsx) en blijft alleen console-logging actief.
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
# Build-time tag die bepaalt of dev/test-only UI (zoals de tijdelijke
# SentryTestButton) zichtbaar is; zie src/components/SentryTestButton.tsx.
# Zolang er nog geen aparte productie-build/deploy bestaat, is dit altijd
# gelijk aan DEPLOY_ENVIRONMENT ('test').
VITE_APP_ENV: ${{ env.DEPLOY_ENVIRONMENT }}
- name: Upload build artifact - name: Upload build artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
+8
View File
@@ -5,6 +5,14 @@ node_modules
dist dist
dist-ssr dist-ssr
# TypeScript incremental build cache (local-only, regenerated on every build)
tsconfig.tsbuildinfo
tsconfig.node.tsbuildinfo
# vite.config.js is emitted next to vite.config.ts by `tsc -b` (tsconfig.node.json has
# no outDir/noEmit), so it is a regenerated build artifact, not source.
vite.config.js
# Logs # Logs
*.local *.local
npm-debug.log* npm-debug.log*
@@ -5,8 +5,8 @@
- **Feature Slug**: react-frontend - **Feature Slug**: react-frontend
- **Project Type**: Greenfield - **Project Type**: Greenfield
- **Start Date**: 2026-07-18T00:37:00Z - **Start Date**: 2026-07-18T00:37:00Z
- **Current Stage**: OPERATIONS - Monitoring Setup (in progress) - **Current Stage**: Complete (post-completion follow-ups tracked in `audit.md` / `operations/production-readiness-checklist.md`)
- **Branch**: master - **Branch**: feature/sentry_error_logging
## Workspace State ## Workspace State
- **Existing Code**: No - **Existing Code**: No
+132
View File
@@ -432,3 +432,135 @@
**Context**: Post-workflow follow-up (feature already marked Complete); reverts the previous change and documents remaining open questions about the PR triggering issue that require checking the Gitea instance directly. Committed on `feature/updates_gitea_workflow`. **Context**: Post-workflow follow-up (feature already marked Complete); reverts the previous change and documents remaining open questions about the PR triggering issue that require checking the Gitea instance directly. Committed on `feature/updates_gitea_workflow`.
--- ---
## Post-Completion Follow-up: SCP Method Fixed (Docker/Podman Issue) & nginx Configs Finalized
**Timestamp**: 2026-07-25T12:22:00Z
**User Input**: "ik heb de deploy-workflow iets aangepast. De originele manier werkte niet met Docker/Podman. De huidige manier werkt wel. Kan je dat doorvoeren door de documentatie? Verder heb ik in `reverse-proxy-nginx.conf.example` en `webserver-nginx.conf.example` onder de comment '# Gebruikte versie' de daadwerkelijke versie staan. Dit werkt nu. Kan je de configuraties controleren en als example opnemen ipv jouw variant? Controleer even of alle headers goed genoemd zijn of dat je andere foutjes ziet. [...] De productieversie mag je van mij ook weg doen voor nu."
**AI Response**: "Confirmed the user's own fix to `deploy.yaml` (already merged to `master` externally): the `appleboy/scp-action` Docker-container-action failed on the self-hosted Podman-based runner (`failed to attach to container: unable to upgrade to tcp, received 409`); replaced with a plain `run:` step using `sshpass`+`scp`. Updated `deployment-instructions.md`, `deployment-plan.md` and `rollback-plan.md` to describe this. Replaced both nginx example files with only the user's 'Gebruikte versie' configs (removed the earlier draft variants), found and fixed a header typo (`X-Real_IP` should be `X-Real-IP`) in `reverse-proxy-nginx.conf.example`, and flagged a path discrepancy: the pipeline uploads to `/html/test/slpsoftware` while nginx's `root` is `/mnt/storage1/www/html/test/slpsoftware` (documented as needing a symlink/mount check on the Pi). Deleted `nginx/reverse-proxy-nginx-production.conf.example` per explicit request."
**Context**: Post-workflow follow-up (feature already marked Complete); reconciles documentation with manual workflow/nginx changes made directly by the user outside this session. Changes not yet committed/pushed.
---
## Post-Completion Follow-up: Path Difference Between `deploy_path` and nginx `root` Clarified
**Timestamp**: 2026-07-25T12:33:00Z
**User Input**: "Ik zie dat je het pad-verschil hebt opgemerkt van wat er in de nginx config staat en wat er wordt meegegeven in de deploy-workflow. Het pad op de server zelf en wat gebruikt wordt voor de nginx configuratie is het volledige pad `/mnt/storage1/www/html/test/slpsoftware` terwijl de ftp gebruiker als root heeft `/mnt/storage1/www` dus het pad voor deployment is `/html/test/slpsoftware`. Misschien dat dat verschil wat mooier kan worden verwoord in de documentatie"
**AI Response**: "Reworded the earlier 'path discrepancy — verify on the Pi' warning in `deployment-instructions.md` and `rollback-plan.md`: this is not a potential bug requiring verification, but expected behavior, since the SCP/SSH user's root is `/mnt/storage1/www` (comparable to an FTP chroot). `deploy_path` (`/html/test/slpsoftware`) is therefore the correct path relative to that user's root, which on the Pi's actual filesystem resolves to the full path `/mnt/storage1/www/html/test/slpsoftware` used as nginx's `root`."
**Context**: Post-workflow follow-up (feature already marked Complete); clarifies wording of the previous follow-up's path-discrepancy note per user feedback. Changes not yet committed/pushed.
---
## Post-Completion Follow-up: Client-Side Error Logging Destination Decided (Sentry)
**Timestamp**: 2026-07-25T12:55:00Z
**User Input**: "Als het goed is stonden er nog een paar punten open? Zullen we het volgende punt oppakken? Kan je ook een feature-branch aanmaken met een passende naam? [...]" — followed by selecting "Logging-bestemming kiezen" and then "Sentry (free tier)" as the concrete destination.
**AI Response**: "Picked up the next open follow-up from `production-readiness-checklist.md`: the client-side error logging destination. Created feature branch `feature/sentry_error_logging` (carrying over the previously uncommitted path-documentation changes, committed there first). Added `@sentry/react` as a dependency, initialized it in `src/main.tsx` via `Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN })` (no-op if the DSN is unset), added `src/vite-env.d.ts` for `import.meta.env` typing, and hooked `ErrorBoundary.componentDidCatch` to also call `Sentry.captureException`. Wired the DSN as a Gitea Actions repository variable (`vars.VITE_SENTRY_DSN`, not a secret) into the `Build` step of `continuous_integration.yaml`. Updated `monitoring-plan.md`, `monitoring-setup.md`, and `production-readiness-checklist.md` to mark this open item resolved, with the remaining manual step (creating the actual `VITE_SENTRY_DSN` Gitea variable with a real Sentry DSN) called out explicitly."
**Context**: Post-workflow follow-up (feature already marked Complete); resolves open follow-up #2 from `production-readiness-checklist.md`. Work done on new branch `feature/sentry_error_logging`.
---
## Post-Completion Follow-up: Sentry Setup Checked Against Official Instrumentation Guide
**Timestamp**: 2026-07-25T13:20:00Z
**User Input**: "Ik zie dat sentry al helemaal is ingericht, maar ik kreeg vanuit Sentry zelf de volgende prompt voor het opzetten. Kan je kijken of je misschien dingen hebt gemist of dat er alsnog wat nodig is: Use curl to download, read and follow https://skills.sentry.dev/instrument to set up the Sentry React SDK."
**AI Response**: "Fetched and read the official Sentry instrumentation guide and its React/Vite-specific reference. Compared it against the existing setup and found the following gaps: (1) no tracing/performance monitoring configured (the guide's recommended default is errors + tracing, not errors alone), (2) no `environment`/`release` tags, (3) no TanStack Router tracing integration, (4) no automatic source map upload (stack traces would show minified code in production). Asked the user which gaps to address; they chose tracing and environment/release tags, explicitly declining source maps for now. Implemented: added `tanstackRouterBrowserTracingIntegration(router)` + `tracesSampleRate: 1.0` and `environment: import.meta.env.MODE` + `release: __APP_VERSION__` to `Sentry.init()` in `src/main.tsx`; `__APP_VERSION__` is injected at build time from `package.json`'s version via a new `define` block in `vite.config.ts` (typed in `src/vite-env.d.ts`). Updated `monitoring-setup.md` to describe the new tracing/environment/release behavior and explicitly note that source map upload was deliberately left out of scope (would require a new Sentry auth-token/org/project secret)."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/sentry_error_logging` branch with a gap-check against Sentry's own official setup guide. Build, lint and tests re-verified successfully after the change.
---
## Post-Completion Follow-up: Temporary Sentry Test Button Added (Dev/Test Only)
**Timestamp**: 2026-07-25T13:41:00Z
**User Input**: "Ik heb hier een code snippet met een intentionele fout om sentry te kunnen testen. Kan je die ergens toevoegen zodat we tijdelijk een test hebben of misschien iets slims bedenken zodat het alleen in development- en testomgevingen zichtbaar is" (a `Sentry.logger.info` + `Sentry.metrics.count` + throw snippet for a "Break the world" button).
**AI Response**: "Added `src/components/SentryTestButton.tsx`, mounted globally via `RootLayout.tsx`. Extracted the click logic into an exported `handleSentryTestErrorClick` function for reliable unit testing (React error boundaries don't catch event-handler errors, so the throw is intentionally left uncaught for Sentry's global handler to pick up). Added `enableLogs: true` to `Sentry.init()` in `main.tsx`, required for `Sentry.logger.*` calls. Introduced a new build-time `VITE_APP_ENV` variable (typed in `vite-env.d.ts`, set to `DEPLOY_ENVIRONMENT` i.e. `test` in the `Build` step of `continuous_integration.yaml`) so the button is only visible during local development (`import.meta.env.DEV`) or when explicitly built for the test environment — hidden by default, including in any future production build. Added `SentryTestButton.test.tsx` (2 tests) and updated `monitoring-setup.md` with a new subsection documenting this temporary tool and noting it should be removed once Sentry has been confirmed to receive test errors/logs/metrics end-to-end."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/sentry_error_logging` branch. `pnpm run build`, `pnpm run lint` (0 errors, pre-existing-style warnings only) and `pnpm run test` (16/16 tests) all succeeded. Changes not yet committed/pushed.
---
## Post-Completion Follow-up: Local Development Sentry DSN Added
**Timestamp**: 2026-07-25T14:48:00Z
**User Input**: "Kunnen we voor lokaal development ook een DSN toevoegen? Kan dat eventueel in een lokale environment file of iets?" (with a Sentry DSN value provided).
**AI Response**: "Added a `.env.local` file containing the user-provided `VITE_SENTRY_DSN` for local development (`pnpm dev`) — already covered by the existing `.gitignore` `*.local` rule, so it is never committed. Added a committed `.env.example` template (empty `VITE_SENTRY_DSN` value) documenting how to set this up for other/future contributors. Updated `monitoring-setup.md` with a new bullet explaining the local-DSN workflow and recommending a separate Sentry project/DSN for local development so its noise doesn't mix with the test-environment data."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/sentry_error_logging` branch. No code logic changes — only local-only config and documentation.
---
## Post-Completion Follow-up: Sentry `environment` Tag Fixed to Distinguish Test vs. Production
**Timestamp**: 2026-07-25T14:52:00Z
**User Input**: "Volgens mij hadden we iets van een onderscheiding tussen de test en productie-omgevingen. Is dat ook met de development-omgeving?"
**AI Response**: "Identified a real gap: `environment: import.meta.env.MODE` correctly reports `'development'` for `pnpm dev`, but both the test build and any future production build run via `vite build` without an explicit mode, so `MODE` is always `'production'` for both — meaning Sentry would currently tag test-environment events as `'production'`, indistinguishable from real production. Fixed by changing `environment` to `import.meta.env.VITE_APP_ENV ?? import.meta.env.MODE` in `src/main.tsx`: this uses the already-existing `VITE_APP_ENV` build-time variable (`'test'`/`'production'`) when set, falling back to `MODE` only for local development where `VITE_APP_ENV` is unset. Updated `monitoring-setup.md` to describe this corrected behavior."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/sentry_error_logging` branch. Build/lint/test re-verified after the change.
---
## Post-Completion Follow-up: Sentry "Tunnel" Added to Bypass Ad-Blocker on Test Environment
**Timestamp**: 2026-07-25T15:30:00Z
**User Input**: "Op de testomgeving heb ik hetzelfde. Laten we de tunnel-fix toevoegen" (confirming the same `ERR_BLOCKED_BY_CLIENT` ad-blocker issue also occurs on the test environment, after previously choosing "just disable the ad-blocker" for local development).
**AI Response**: "Added `tunnel: import.meta.env.DEV ? undefined : '/sentry-tunnel'` to `Sentry.init()` in `src/main.tsx`, so built (test/production) environments route Sentry traffic through the app's own domain instead of posting straight to `*.ingest.de.sentry.io` — this makes the request first-party from the browser's perspective, so ad-blockers no longer recognize/block it as third-party tracking. Local development is left untouched (tunnel stays `undefined`), since the user already resolved that case by disabling the ad-blocker. Added a `location /sentry-tunnel` block to `nginx/reverse-proxy-nginx.conf.example` that forwards to Sentry's envelope endpoint for the specific org/project baked into the DSN, with a comment flagging that this must be kept in sync if the Sentry project/DSN ever changes. Updated `monitoring-setup.md` with a new bullet explaining the tunnel approach, why it works here (full control over the reverse-proxy Pi's nginx config) and its limitation (would not work on a third-party host without reverse-proxy access)."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/sentry_error_logging` branch. `pnpm run build`, `pnpm run lint` and `pnpm run test` re-verified successfully after the change. Changes not yet committed/pushed.
---
## Post-Completion Follow-up: Sentry "Tunnel" Extended to Local Development
**Timestamp**: 2026-07-25T15:45:00Z
**User Input**: "Is het ook gefixt voor lokaal?" — followed by: "Ja voeg voor lokaal ook een tunnel toe".
**AI Response**: "Added a `server.proxy` entry for `/sentry-tunnel` in `vite.config.ts`, forwarding to the same Sentry envelope endpoint as the reverse-proxy Pi's nginx tunnel (matching the org/project baked into the local `.env.local` DSN). Simplified `src/main.tsx`'s `tunnel` option from the previous `import.meta.env.DEV ? undefined : '/sentry-tunnel'` to an unconditional `'/sentry-tunnel'`, since `pnpm dev` now also has a matching proxy route — local testing no longer requires disabling the ad-blocker. Updated `monitoring-setup.md` to describe the tunnel as active in all environments (including local), and to flag that the org/project id must stay in sync across the nginx config, the Vite proxy target, and the configured DSN."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/sentry_error_logging` branch. `pnpm run build`, `pnpm run lint` and `pnpm run test` re-verified successfully after the change. Changes not yet committed/pushed.
---
## Post-Completion Follow-up: Regenerated Build Artifacts Removed from Git Tracking
**Timestamp**: 2026-07-25T15:47:00Z
**User Input**: "ik wil niet dat er wijzigingen open blijven staan lokaal. zorg dat je ze mee-commit of ignored. Als het nodig is maak je een lokale versie die ignored moet worden."
**AI Response**: "Investigated the two remaining locally-modified files (`tsconfig.node.tsbuildinfo`, `vite.config.js`) and confirmed both are build artifacts regenerated by `tsc -b` (per `tsconfig.node.json`, which has no `outDir`/`noEmit`, `vite.config.ts` gets compiled to `vite.config.js` right next to it, and its own incremental-build cache is `tsconfig.node.tsbuildinfo`) — neither is source, so committing their contents would just cause perpetual diff noise. Removed both from git tracking (`git rm --cached`) and added them to `.gitignore` alongside the already-ignored `tsconfig.tsbuildinfo`, so they remain on disk (needed for `tsc -b` to work) but are never tracked again."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/sentry_error_logging` branch, ensuring no local changes are left uncommitted/untracked.
---
## Post-Completion Follow-up: Sentry Logging Follow-up Confirmed Working & Closed
**Timestamp**: 2026-07-25T16:04:00Z
**User Input**: "het werkt zoals ik zou verwachten. De feature mag afgerond worden. De documentatie updaten en committen. Dan door naar het volgende openstaande punt en ook daarvoor een feature branch aanmaken."
**AI Response**: "User confirmed Sentry (errors, logs, and metrics via the tunnel) works as expected, both locally and on the test environment. Updated `monitoring-setup.md`, `monitoring-plan.md`, and `production-readiness-checklist.md` to mark the 'client-side error logging destination' open follow-up as resolved and verified end-to-end, leaving only the manual `VITE_SENTRY_DSN` Gitea repository variable creation as an outstanding step for the test/production build. Also corrected `aidlc-state.md`'s stale `Current Stage`/`Branch` fields to reflect that the workflow is complete and work is happening on `feature/sentry_error_logging`."
**Context**: Closes out the Sentry logging-destination follow-up. Three open follow-ups remain per `production-readiness-checklist.md`: production hosting/deploy, analytics/uptime dashboard tool selection, and a formal Lighthouse performance check.
---
@@ -13,7 +13,10 @@ Sinds deze stap wordt er automatisch gedeployed naar een **testomgeving**: een R
- `prepare``build` (uploadt de artifact, naam/pad uit `env.ARTIFACT_NAME`/`env.ARTIFACT_PATH`) → `test` (lint + unit tests) - `prepare``build` (uploadt de artifact, naam/pad uit `env.ARTIFACT_NAME`/`env.ARTIFACT_PATH`) → `test` (lint + unit tests)
- Een losse `config`-job zet deze `env`-waarden om in job-outputs (zie hieronder waarom dat nodig is). - Een losse `config`-job zet deze `env`-waarden om in job-outputs (zie hieronder waarom dat nodig is).
- `deploy-test` (alleen bij `workflow_dispatch` of een push naar `master`) roept `deploy.yaml` aan met `artifact_name`/`environment`/`deploy_path` afkomstig van `needs.config.outputs.*` (dus indirect uit het `env:`-blok). - `deploy-test` (alleen bij `workflow_dispatch` of een push naar `master`) roept `deploy.yaml` aan met `artifact_name`/`environment`/`deploy_path` afkomstig van `needs.config.outputs.*` (dus indirect uit het `env:`-blok).
- `deploy.yaml` — download de artifact (naam/lokaal pad = `inputs.artifact_name`) en upload de inhoud via `appleboy/scp-action` naar de opgegeven `deploy_path` op de host uit de meegegeven secrets. - `deploy.yaml` — download de artifact (naam/lokaal pad = `inputs.artifact_name`) en upload de inhoud via een `scp`-commando (met `sshpass` voor het wachtwoord) in een gewone shell-stap naar de opgegeven `deploy_path` op de host uit de meegegeven secrets.
### Waarom `sshpass`/`scp` in een shell-stap in plaats van de `appleboy/scp-action` Docker-action?
De oorspronkelijke aanpak gebruikte de `appleboy/scp-action` (een Docker-container-action). Dit werkte niet op deze zelf-gehoste Gitea-runner: de stap faalde met `failed to attach to container: unable to upgrade to tcp, received 409`, een bekende beperking van Podman's Docker-compatibele API, die het attach/log-streaming-mechanisme voor container-based actions niet volledig ondersteunt. De huidige aanpak (een normale `run:`-stap die `sshpass` installeert en zelf `scp` aanroept) heeft geen geneste container nodig en werkt daardoor wel.
### Waarom een aparte `config`-job in plaats van rechtstreeks het `env:`-blok? ### Waarom een aparte `config`-job in plaats van rechtstreeks het `env:`-blok?
Gitea/GitHub Actions ondersteunt geen `env`-context in de `with:`-sectie waarmee een reusable workflow wordt aangeroepen (`jobs.<job_id>.with`) — dat werkt alléén binnen `jobs.<job_id>.steps`. De oplossing is een klein voorloop-job (`config`) dat de gewenste `env`-waarden via `$GITHUB_OUTPUT` naar job-outputs schrijft; die outputs (`needs.config.outputs.*`) zijn wél bruikbaar in `jobs.<job_id>.with`. Zo hoef je, om de artifact-naam/pad of de testdeploy-bestemming te wijzigen, alléén het `env:`-blok bovenaan `continuous_integration.yaml` aan te passen — niet de `deploy-test`-job zelf. Gitea/GitHub Actions ondersteunt geen `env`-context in de `with:`-sectie waarmee een reusable workflow wordt aangeroepen (`jobs.<job_id>.with`) — dat werkt alléén binnen `jobs.<job_id>.steps`. De oplossing is een klein voorloop-job (`config`) dat de gewenste `env`-waarden via `$GITHUB_OUTPUT` naar job-outputs schrijft; die outputs (`needs.config.outputs.*`) zijn wél bruikbaar in `jobs.<job_id>.with`. Zo hoef je, om de artifact-naam/pad of de testdeploy-bestemming te wijzigen, alléén het `env:`-blok bovenaan `continuous_integration.yaml` aan te passen — niet de `deploy-test`-job zelf.
@@ -35,11 +38,12 @@ Deze secrets heten `PI_MAIN_*` (niet `PI_TEST_*`), omdat dezelfde Pi (Pi Main) e
- **Productie** (nog niet automatisch gedeployed, maar domein al bekend): `slpsoftware.nl` (en `www.slpsoftware.nl`) → zelfde reverse-proxy-Pi, zodra productie wordt opgezet. - **Productie** (nog niet automatisch gedeployed, maar domein al bekend): `slpsoftware.nl` (en `www.slpsoftware.nl`) → zelfde reverse-proxy-Pi, zodra productie wordt opgezet.
## Eenmalige Setup — nginx & SSL op de Raspberry Pi's ## Eenmalige Setup — nginx & SSL op de Raspberry Pi's
1. Kopieer `operations/deployment/nginx/webserver-nginx.conf.example` naar `/etc/nginx/sites-available/` op de webserver-Pi, maak een symlink in `sites-enabled/`, en herlaad nginx. 1. Kopieer `operations/deployment/nginx/webserver-nginx.conf.example` naar `/etc/nginx/sites-available/` op de webserver-Pi, maak een symlink in `sites-enabled/`, en herlaad nginx. Dit bestand is de daadwerkelijk in gebruik zijnde configuratie (`server_name test.slpsoftware.nl`, luistert op poort 80, serveert vanaf `/mnt/storage1/www/html/test/slpsoftware`).
2. Kopieer `operations/deployment/nginx/reverse-proxy-nginx.conf.example` naar `/etc/nginx/sites-available/slpsoftware-test.conf` op de reverse-proxy-Pi, maak een symlink in `sites-enabled/`, en herlaad nginx. Dit bestand gebruikt al `test.slpsoftware.nl` als `server_name`. 2. Kopieer `operations/deployment/nginx/reverse-proxy-nginx.conf.example` naar `/etc/nginx/sites-available/slpsoftware-test.conf` op de reverse-proxy-Pi, maak een symlink in `sites-enabled/`, en herlaad nginx. Ook dit bestand is de daadwerkelijk in gebruik zijnde (door certbot beheerde) configuratie, met `server_name test.slpsoftware.nl`.
3. Vraag op de reverse-proxy-Pi een SSL-certificaat aan met certbot (Let's Encrypt), nadat het DNS-record klopt: `sudo certbot --nginx -d test.slpsoftware.nl`. Certbot regelt automatisch de HTTPS-configuratie en de HTTP→HTTPS-redirect (net zoals je gewend bent van certbot). 3. Vraag op de reverse-proxy-Pi een SSL-certificaat aan met certbot (Let's Encrypt), nadat het DNS-record klopt: `sudo certbot --nginx -d test.slpsoftware.nl`. Certbot regelt automatisch de HTTPS-configuratie en de HTTP→HTTPS-redirect (net zoals je gewend bent van certbot).
4. Zorg dat de map `/html/test/slpsoftware` bestaat op de webserver-Pi en schrijfbaar is voor de gebruiker `webadmin` (bijv. `sudo mkdir -p /html/test/slpsoftware && sudo chown webadmin:webadmin /html/test/slpsoftware`). 4. Zorg dat de map `/mnt/storage1/www/html/test/slpsoftware` bestaat op de webserver-Pi en schrijfbaar is voor de gebruiker `webadmin` (bijv. `sudo mkdir -p /mnt/storage1/www/html/test/slpsoftware && sudo chown webadmin:webadmin /mnt/storage1/www/html/test/slpsoftware`).
5. Voor later, wanneer productie wordt opgezet: zie `operations/deployment/nginx/reverse-proxy-nginx-production.conf.example` (domein `slpsoftware.nl`, certbot-commando alvast gedocumenteerd).
> **Waarom `deploy_path` en de nginx `root` niet hetzelfde pad zijn**: de pipeline uploadt via SCP naar `deploy_path` = `/html/test/slpsoftware` (zie `env.DEPLOY_PATH` in `continuous_integration.yaml`), terwijl de nginx `root` in `webserver-nginx.conf.example` het volledige pad `/mnt/storage1/www/html/test/slpsoftware` is. Dit is geen fout of inconsistentie: de SSH/SCP-gebruiker (`webadmin`) heeft `/mnt/storage1/www` als root (vergelijkbaar met een FTP-chroot), dus vanuit het perspectief van deze gebruiker is `/html/test/slpsoftware` het juiste (relatieve) pad, terwijl dat op het bestandssysteem van de Pi zelf overeenkomt met het volledige pad `/mnt/storage1/www/html/test/slpsoftware` dat nginx als `root` gebruikt. Kortom: `deploy_path` (`/html/test/slpsoftware`) + de root van de `webadmin`-gebruiker (`/mnt/storage1/www`) = de nginx `root` (`/mnt/storage1/www/html/test/slpsoftware`).
## How to Deploy to Test ## How to Deploy to Test
### Automatisch ### Automatisch
@@ -55,5 +59,5 @@ Merge een pull request naar `master` — de `deploy-test` job draait dan automat
2. Open de testomgeving in de browser (via het adres/IP dat je bij de reverse-proxy hebt ingesteld) en controleer dat de site correct laadt (check de browserconsole op fouten, zoals in de handmatige smoke test in `construction/build-and-test/integration-test-instructions.md`). 2. Open de testomgeving in de browser (via het adres/IP dat je bij de reverse-proxy hebt ingesteld) en controleer dat de site correct laadt (check de browserconsole op fouten, zoals in de handmatige smoke test in `construction/build-and-test/integration-test-instructions.md`).
## Future Work ## Future Work
- **Van wachtwoord naar SSH-key**: vervang `password: ${{ secrets.PI_MAIN_PASSWORD }}` in `deploy.yaml` door `key: ${{ secrets.PI_MAIN_SSH_KEY }}` (een nieuwe secret met de private key-inhoud), en zet de bijbehorende public key in `~/.ssh/authorized_keys` van de `webadmin`-gebruiker op de webserver-Pi. Verwijder daarna het wachtwoord-secret. - **Van wachtwoord naar SSH-key**: vervang `sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" scp ...` in `deploy.yaml` door een `scp`-commando met `-i <key-bestand>` (een nieuwe secret `PI_MAIN_SSH_KEY` die je eerst als bestand wegschrijft in de run-stap), en zet de bijbehorende public key in `~/.ssh/authorized_keys` van de `webadmin`-gebruiker op de webserver-Pi. Verwijder daarna het wachtwoord-secret.
- **Productie-omgeving**: voeg een `deploy-production`-job toe zodra de definitieve productiehosting bekend is (zie `deployment-plan.md`'s "Open Item"), en pas `nginx/reverse-proxy-nginx-production.conf.example` (domein `slpsoftware.nl`) toe zodra de webserver-locatie voor productie vastligt. - **Productie-omgeving**: voeg een `deploy-production`-job toe zodra de definitieve productiehosting bekend is (zie `deployment-plan.md`'s "Open Item"). De productie-nginx-voorbeeldconfiguratie is op verzoek van de gebruiker verwijderd totdat er een goed-werkende versie is; die kan later opnieuw opgebouwd worden naar analogie van `nginx/webserver-nginx.conf.example` en `nginx/reverse-proxy-nginx.conf.example`, met domein `slpsoftware.nl`.
@@ -11,13 +11,13 @@ Sinds deze stap is er een echte, geautomatiseerde upload naar een **testomgeving
1. Bij elke pull request draait automatisch de build/test/lint-gate (`prepare``build``test`), zodat merge requests direct gevalideerd worden. 1. Bij elke pull request draait automatisch de build/test/lint-gate (`prepare``build``test`), zodat merge requests direct gevalideerd worden.
2. Zodra een pull request naar `master` gemerged wordt (of de workflow handmatig via `workflow_dispatch` gestart wordt), draait aanvullend de `deploy-test` job. 2. Zodra een pull request naar `master` gemerged wordt (of de workflow handmatig via `workflow_dispatch` gestart wordt), draait aanvullend de `deploy-test` job.
3. `deploy-test` roept de herbruikbare `deploy.yaml` workflow aan met `artifact_name`/`environment`/`deploy_path`, en geeft via `secrets: inherit` de Pi-inloggegevens door. Deze drie waarden (samen met de artifact-naam/pad die de `build`-job gebruikt) staan als variabelen in het `env:`-blok bovenaan `continuous_integration.yaml` (`ARTIFACT_NAME`, `ARTIFACT_PATH`, `DEPLOY_ENVIRONMENT`, `DEPLOY_PATH`), en worden via een kleine `config`-job als job-outputs doorgegeven aan `deploy-test` (nodig omdat de `env`-context zelf niet werkt in de `with:`-sectie van een reusable-workflow-aanroep). 3. `deploy-test` roept de herbruikbare `deploy.yaml` workflow aan met `artifact_name`/`environment`/`deploy_path`, en geeft via `secrets: inherit` de Pi-inloggegevens door. Deze drie waarden (samen met de artifact-naam/pad die de `build`-job gebruikt) staan als variabelen in het `env:`-blok bovenaan `continuous_integration.yaml` (`ARTIFACT_NAME`, `ARTIFACT_PATH`, `DEPLOY_ENVIRONMENT`, `DEPLOY_PATH`), en worden via een kleine `config`-job als job-outputs doorgegeven aan `deploy-test` (nodig omdat de `env`-context zelf niet werkt in de `with:`-sectie van een reusable-workflow-aanroep).
4. `deploy.yaml` downloadt de artifact en uploadt de inhoud via SCP (wachtwoord-login) naar de webserver-Pi op het interne netwerk (`192.168.1.103`, poort `2224`). 4. `deploy.yaml` downloadt de artifact en uploadt de inhoud via een `scp`-commando (met `sshpass` voor het wachtwoord) in een gewone shell-stap naar de webserver-Pi op het interne netwerk (`192.168.1.103`, poort `2224`). Dit vervangt de eerdere `appleboy/scp-action` (Docker-container-action), die faalde op de zelf-gehoste Podman-runner (`failed to attach to container: unable to upgrade to tcp, received 409`).
5. nginx op de webserver-Pi serveert de bestanden vanaf `/html/test/slpsoftware`; de reverse-proxy-Pi stuurt binnenkomend verkeer door naar deze webserver-Pi. Voorbeeldconfiguraties staan in `operations/deployment/nginx/`. 5. nginx op de webserver-Pi serveert de bestanden vanaf `/mnt/storage1/www/html/test/slpsoftware`; de reverse-proxy-Pi stuurt binnenkomend verkeer door naar deze webserver-Pi. Voorbeeldconfiguraties staan in `operations/deployment/nginx/` en zijn de daadwerkelijk in gebruik zijnde configuraties (niet langer illustratieve concepten).
6. De reverse-proxy-Pi is ook verantwoordelijk voor SSL: certificaten worden net als voorheen aangevraagd via certbot (Let's Encrypt) en HTTP-verkeer wordt doorverwezen naar HTTPS. 6. De reverse-proxy-Pi is ook verantwoordelijk voor SSL: certificaten worden net als voorheen aangevraagd via certbot (Let's Encrypt) en HTTP-verkeer wordt doorverwezen naar HTTPS.
## Environments ## Environments
- **Test** (nieuw, geautomatiseerd): zoals hierboven beschreven — de enige omgeving die op dit moment daadwerkelijk automatisch gedeployed wordt. Domeinnaam: `test.slpsoftware.nl` (SSL via certbot op de reverse-proxy-Pi). - **Test** (nieuw, geautomatiseerd): zoals hierboven beschreven — de enige omgeving die op dit moment daadwerkelijk automatisch gedeployed wordt. Domeinnaam: `test.slpsoftware.nl` (SSL via certbot op de reverse-proxy-Pi).
- **Productie**: nog niet geautomatiseerd. Zodra de definitieve productiehosting bekend is, kan een vergelijkbare `deploy-production`-job worden toegevoegd die `deploy.yaml` aanroept met `environment: production` en de productie-secrets/pad. Domeinnaam ligt al vast: `slpsoftware.nl` (SSL eveneens via certbot; zie `nginx/reverse-proxy-nginx-production.conf.example`). - **Productie**: nog niet geautomatiseerd. Zodra de definitieve productiehosting bekend is, kan een vergelijkbare `deploy-production`-job worden toegevoegd die `deploy.yaml` aanroept met `environment: production` en de productie-secrets/pad. Domeinnaam ligt al vast: `slpsoftware.nl` (SSL eveneens via certbot). Er is (nog) geen productie-nginx-voorbeeldconfiguratie; deze is op verzoek verwijderd totdat er een goed-werkende, foutloze versie is, en kan later opnieuw opgebouwd worden naar analogie van de testomgeving-configuraties.
## Automation Level ## Automation Level
Volledig geautomatiseerd voor de testomgeving: build, test, lint én upload naar de test-Pi gebeuren zonder handmatige tussenstap, zodra er gemerged wordt naar `master` (of handmatig getriggerd wordt). Alleen productie is nog niet geautomatiseerd. Volledig geautomatiseerd voor de testomgeving: build, test, lint én upload naar de test-Pi gebeuren zonder handmatige tussenstap, zodra er gemerged wordt naar `master` (of handmatig getriggerd wordt). Alleen productie is nog niet geautomatiseerd.
@@ -37,7 +37,7 @@ Deze secrets heten bewust `PI_MAIN_*` in plaats van `PI_TEST_*`: alle webhosts g
Dit is bewust wachtwoord-authenticatie (voor nu, zoals gekozen), zodat de testomgeving snel werkend is. Zie "Future Work" in `deployment-instructions.md` voor de overstap naar SSH-key-authenticatie. Dit is bewust wachtwoord-authenticatie (voor nu, zoals gekozen), zodat de testomgeving snel werkend is. Zie "Future Work" in `deployment-instructions.md` voor de overstap naar SSH-key-authenticatie.
## Open Item — Productie-deploy Nog Niet Geautomatiseerd ## Open Item — Productie-deploy Nog Niet Geautomatiseerd
Zodra de definitieve productiehosting bekend is (en of dit dezelfde soort Raspberry Pi-opstelling is, of een externe hostingpartij), voeg een `deploy-production`-job toe aan `continuous_integration.yaml` die `deploy.yaml` aanroept met `environment: production`. Zolang het dezelfde webhost (Pi Main) blijft, kunnen de bestaande `PI_MAIN_*` secrets hergebruikt worden; pas dit pas aan naar omgeving-specifieke secrets als productie daadwerkelijk op een andere host komt. Domeinnaam (`slpsoftware.nl`) en SSL-aanpak (certbot/Let's Encrypt op de reverse-proxy-Pi) liggen al vast, zie `nginx/reverse-proxy-nginx-production.conf.example`. Zodra de definitieve productiehosting bekend is (en of dit dezelfde soort Raspberry Pi-opstelling is, of een externe hostingpartij), voeg een `deploy-production`-job toe aan `continuous_integration.yaml` die `deploy.yaml` aanroept met `environment: production`. Zolang het dezelfde webhost (Pi Main) blijft, kunnen de bestaande `PI_MAIN_*` secrets hergebruikt worden; pas dit pas aan naar omgeving-specifieke secrets als productie daadwerkelijk op een andere host komt. Domeinnaam (`slpsoftware.nl`) en SSL-aanpak (certbot/Let's Encrypt op de reverse-proxy-Pi) liggen al vast; er is bewust (nog) geen productie-nginx-voorbeeldconfiguratie, deze wordt later opnieuw opgebouwd zodra er een goed-werkende, foutloze versie is.
## Verified Build Prerequisite ## Verified Build Prerequisite
Dit plan bouwt voort op de Build and Test-stage (`construction/build-and-test/build-and-test-summary.md`): `pnpm run build` produceert een statische `dist/`-bundel zonder server-side vereisten, geschikt om direct door nginx geserveerd te worden. Dit plan bouwt voort op de Build and Test-stage (`construction/build-and-test/build-and-test-summary.md`): `pnpm run build` produceert een statische `dist/`-bundel zonder server-side vereisten, geschikt om direct door nginx geserveerd te worden.
@@ -1,55 +0,0 @@
# Voorbeeldconfiguratie voor de nginx reverse proxy op de Raspberry Pi voor de
# PRODUCTIEOMGEVING, bereikbaar via het domein slpsoftware.nl.
#
# LET OP: dit bestand is nog niet in gebruik — de productie-deploy is nog niet
# geautomatiseerd (zie deployment-plan.md's "Open Item — Productie-deploy Nog
# Niet Geautomatiseerd"). Dit voorbeeld ligt hier alvast klaar zodat het
# domein en SSL-certificaat (net als bij de testomgeving via certbot) bekend
# zijn wanneer de productie-deploy wordt opgezet. Vervang <PROD_WEBSERVER_IP>
# en <PROD_WEBSERVER_POORT> door de daadwerkelijke waarden zodra die bekend
# zijn (mogelijk dezelfde webserver-Pi op een ander poortnummer/pad, of een
# aparte Pi/host).
#
# Kopieer dit bestand handmatig naar bijvoorbeeld
# /etc/nginx/sites-available/slpsoftware-production.conf op de reverse-proxy-Pi,
# maak een symlink in sites-enabled, en herlaad nginx.
#
# SSL-certificaat aanvragen met certbot (Let's Encrypt), nadat het DNS-record
# voor slpsoftware.nl naar het publieke IP van deze Pi wijst:
# sudo certbot --nginx -d slpsoftware.nl -d www.slpsoftware.nl
server {
listen 80;
listen [::]:80;
server_name slpsoftware.nl www.slpsoftware.nl;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name slpsoftware.nl www.slpsoftware.nl;
ssl_certificate /etc/letsencrypt/live/slpsoftware.nl/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/slpsoftware.nl/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://<PROD_WEBSERVER_IP>:<PROD_WEBSERVER_POORT>;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -11,46 +11,74 @@
# certbot (Let's Encrypt) rechtstreeks op deze reverse-proxy-Pi, bijvoorbeeld: # certbot (Let's Encrypt) rechtstreeks op deze reverse-proxy-Pi, bijvoorbeeld:
# sudo certbot --nginx -d test.slpsoftware.nl # sudo certbot --nginx -d test.slpsoftware.nl
# Zorg dat het DNS-record voor test.slpsoftware.nl al naar het publieke IP van # Zorg dat het DNS-record voor test.slpsoftware.nl al naar het publieke IP van
# deze Pi wijst voordat je certbot draait. Certbot vult automatisch het # deze Pi wijst voordat je certbot draait.
# onderstaande `listen 443 ssl` server-block aan (of maakt het aan) en #
# schrijft de HTTP-server hieronder om naar een 301-redirect. Het onderstaande # Dit is de daadwerkelijk in gebruik zijnde configuratie op de reverse-proxy-Pi,
# is dus vooral illustratief voor de eindsituatie. # zoals door certbot gegenereerd/beheerd. Het HTTP-server-block onderaan (dat
# doorverwijst naar HTTPS, behalve voor de ACME-challenge) en de #-commentaren
# HTTP: alleen gebruikt voor de Let's Encrypt ACME-challenge en om door te # "managed by Certbot" zijn automatisch door certbot toegevoegd/aangepast.
# verwijzen naar HTTPS. Na het draaien van certbot ziet dit block er zo uit. #
# LET OP - gevonden foutje: de header hieronder heet `X-Real-IP` (met
# koppelteken), niet `X-Real_IP` (met underscore). Een header met een
# underscore is geen geldige/herkenbare naam voor de standaard `$http_x_real_ip`-
# achtige verwerking; nginx stuurt de header wel door zoals opgegeven, maar
# de ontvangende applicatie/module verwacht het koppelteken-formaat. Corrigeer
# dit op de Pi zelf naar `X-Real-IP` zodra je de config daar aanpast.
server { server {
listen 80;
listen [::]:80;
server_name test.slpsoftware.nl; server_name test.slpsoftware.nl;
error_log /var/log/nginx/slpsoftware-test_error.log;
access_log /var/log/nginx/slpsoftware-test_access.log;
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
root /var/www/certbot; root /var/www/certbot;
} }
location / { # Sentry "tunnel" endpoint (see src/main.tsx's Sentry.init tunnel option).
return 301 https://$host$request_uri; # Browser ad-blockers/privacy extensions commonly block requests that go
# straight to *.ingest.<region>.sentry.io (ERR_BLOCKED_BY_CLIENT), since
# that looks like third-party tracking. By forwarding this path server-side
# to Sentry instead, the browser only ever talks to our own domain
# (test.slpsoftware.nl), so it is no longer recognized/blocked as
# third-party tracking.
#
# LET OP: de org-/project-id in de proxy_pass hieronder MOET overeenkomen
# met de VITE_SENTRY_DSN die voor DEZE omgeving is ingesteld (de
# `VITE_SENTRY_DSN` Gitea repository variable voor de testomgeving). Pas
# dit aan als je ooit van Sentry-project/DSN wisselt.
location /sentry-tunnel {
proxy_pass https://o4511795618185216.ingest.de.sentry.io/api/4511795622838352/envelope/;
proxy_set_header Host o4511795618185216.ingest.de.sentry.io;
proxy_ssl_server_name on;
} }
}
# HTTPS: het daadwerkelijke reverse-proxy-verkeer naar de webserver-Pi.
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name test.slpsoftware.nl;
ssl_certificate /etc/letsencrypt/live/test.slpsoftware.nl/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/test.slpsoftware.nl/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / { location / {
proxy_pass http://192.168.1.103:80; proxy_pass http://192.168.1.103:80;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
listen 443 ssl; # managed by Certbot
listen [::]:443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/test.slpsoftware.nl/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/test.slpsoftware.nl/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = test.slpsoftware.nl) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name test.slpsoftware.nl;
return 404; # managed by Certbot
} }
@@ -3,21 +3,20 @@
# #
# Deze Pi is NIET rechtstreeks vanaf het internet bereikbaar; de andere # Deze Pi is NIET rechtstreeks vanaf het internet bereikbaar; de andere
# Raspberry Pi (met de nginx reverse proxy, zie reverse-proxy-nginx.conf.example) # Raspberry Pi (met de nginx reverse proxy, zie reverse-proxy-nginx.conf.example)
# stuurt inkomend verkeer door naar deze server op het interne netwerk. # stuurt inkomend verkeer door naar deze server op het interne netwerk (poort 80).
# #
# Dit is de daadwerkelijk in gebruik zijnde configuratie op de webserver-Pi.
# Kopieer dit bestand handmatig naar bijvoorbeeld # Kopieer dit bestand handmatig naar bijvoorbeeld
# /etc/nginx/sites-available/slpsoftware-test.conf op de webserver-Pi, # /etc/nginx/sites-available/slpsoftware-test.conf op de webserver-Pi,
# maak een symlink in sites-enabled, en herlaad nginx (`sudo nginx -t && sudo systemctl reload nginx`). # maak een symlink in sites-enabled, en herlaad nginx (`sudo nginx -t && sudo systemctl reload nginx`).
server { server {
# Alleen luisteren op het interne (LAN) IP-adres van deze Pi, niet op 0.0.0.0, listen 80;
# zodat deze poort niet per ongeluk vanaf buiten het netwerk bereikbaar is.
listen 192.168.1.103:80;
listen [::]:80; listen [::]:80;
server_name _; server_name test.slpsoftware.nl;
root /html/test/slpsoftware; root /mnt/storage1/www/html/test/slpsoftware;
index index.html; index index.html;
# De React-app gebruikt client-side routing (React Router), dus onbekende # De React-app gebruikt client-side routing (React Router), dus onbekende
@@ -34,6 +33,12 @@ server {
} }
# Doorgeven van het echte client-IP en protocol, afkomstig van de reverse proxy. # Doorgeven van het echte client-IP en protocol, afkomstig van de reverse proxy.
set_real_ip_from 192.168.1.0/24; # Uitgecommentarieerd: de reverse-proxy Pi geeft het echte client-IP al door
real_ip_header X-Forwarded-For; # via de X-Forwarded-For header hierboven; deze twee directieven zouden nginx
# dat IP ook laten gebruiken als "remote address" in de eigen access-log van
# deze Pi. Zonder deze regels tonen de logs van deze webserver-Pi het interne
# IP van de reverse-proxy Pi als bezoeker-IP (functioneel geen probleem, wel
# minder nauwkeurig voor logging/analyse op deze Pi zelf).
#set_real_ip_from 192.168.1.0/24;
#real_ip_header X-Forwarded-For;
} }
@@ -6,7 +6,7 @@ Sinds deze stap wordt de testomgeving automatisch gedeployed door de `deploy-tes
## Rolling Back the Live Site (Test-omgeving) ## Rolling Back the Live Site (Test-omgeving)
1. Identificeer de laatste bekend-goede commit op `master` (of een eerdere `release/*`-branch/tag) die succesvol gedeployed was. 1. Identificeer de laatste bekend-goede commit op `master` (of een eerdere `release/*`-branch/tag) die succesvol gedeployed was.
2. Trigger de **Continuous Integration** workflow handmatig (`workflow_dispatch`) tegen die commit/branch/tag in Gitea Actions. 2. Trigger de **Continuous Integration** workflow handmatig (`workflow_dispatch`) tegen die commit/branch/tag in Gitea Actions.
3. De `deploy-test` job uploadt automatisch de resulterende `dist/`-build naar `/html/test/slpsoftware` op de webserver-Pi, en overschrijft daarmee de huidige (foutieve) bestanden. 3. De `deploy-test` job uploadt automatisch de resulterende `dist/`-build via SCP naar `/html/test/slpsoftware` op de webserver-Pi — dit relatieve pad staat t.o.v. de root van de SCP-gebruiker (`/mnt/storage1/www`), waardoor nginx dezelfde bestanden serveert vanaf het volledige pad `/mnt/storage1/www/html/test/slpsoftware` (zie de uitleg hierover in `deployment-instructions.md`) — en overschrijft daarmee de huidige (foutieve) bestanden.
4. Verify de live testomgeving reflecteert de teruggedraaide versie (via het adres achter de reverse proxy). 4. Verify de live testomgeving reflecteert de teruggedraaide versie (via het adres achter de reverse proxy).
## Keeping Rollback Possible ## Keeping Rollback Possible
@@ -13,7 +13,7 @@ Initial answers to the monitoring plan were contradictory: Question 2 selected "
## Chosen Approach(es) ## Chosen Approach(es)
### Logging ### Logging
Client-side errors (JavaScript crashes, broken links) should be logged, but the concrete destination is not yet decided (original Question 3 = C, "not yet determined"). This is tracked as an **open action item** below rather than blocking this stage. Client-side errors (JavaScript crashes, broken links) are logged. **Decided**: Sentry free tier (in addition to the browser console) — see the follow-up in `audit.md` and the implementation in `monitoring-setup.md`.
### Dashboards ### Dashboards
A combination of: A combination of:
@@ -27,7 +27,7 @@ A combination of:
- **Reuse of existing infrastructure**: this feature does not plug into any pre-existing shared monitoring (original Question 7 = A) — there is none yet. Should a shared back-end/CMS monitoring stack be introduced later, this can be revisited. - **Reuse of existing infrastructure**: this feature does not plug into any pre-existing shared monitoring (original Question 7 = A) — there is none yet. Should a shared back-end/CMS monitoring stack be introduced later, this can be revisited.
## Open Action Items ## Open Action Items
1. **Decide logging destination**: choose between "browser console only" (no central storage, manual debugging) or a free/low-cost external error-tracking service (e.g. Sentry free tier) once this becomes a priority. Until decided, `monitoring-setup.md` documents both options so either can be adopted without re-doing this stage. 1. ~~Decide logging destination~~**Resolved and verified**: Sentry free tier, wired into `ErrorBoundary`/`main.tsx` (see `monitoring-setup.md`), including tracing, environment/release tags, and a tunnel to bypass ad-blocker blocking. The user confirmed errors, logs, and metrics are received correctly, both locally and on the test environment. The Gitea Actions variable `VITE_SENTRY_DSN` still needs to be created by the user with a real Sentry project DSN for the test/production build; until then that build simply skips Sentry initialization and only console-logging is active (local development already has its own DSN via `.env.local`).
2. **Pick concrete analytics + uptime tools**: `monitoring-setup.md` lists candidate free-tier tools; final tool selection/account creation is a manual follow-up outside this workflow (no code changes required to swap providers, since neither is wired into the codebase yet beyond an optional embed snippet). 2. **Pick concrete analytics + uptime tools**: `monitoring-setup.md` lists candidate free-tier tools; final tool selection/account creation is a manual follow-up outside this workflow (no code changes required to swap providers, since neither is wired into the codebase yet beyond an optional embed snippet).
## Rationale ## Rationale
@@ -9,20 +9,34 @@ Concrete setup steps for the approaches chosen in `monitoring-plan.md`: **Loggin
- Broken/failed navigation (e.g. an unexpected router error). - Broken/failed navigation (e.g. an unexpected router error).
- No user PII, form input, or sensitive data should ever be logged — this is a public marketing site, but keep this discipline regardless. - No user PII, form input, or sensitive data should ever be logged — this is a public marketing site, but keep this discipline regardless.
### Destination — open decision ### Destination — decided: Sentry free tier + console
The destination was not finalized (original Question 3 = C). Two supported options, either of which can be adopted later without further design work: Both the console and Sentry are now active (original Question 3 resolved as a combination):
**Option 1: Browser console only (default today)** **Console (always on)**
- No code changes needed — errors already surface via `console.error` inside the existing `ErrorBoundary`. - Errors already surface via `console.error` inside `ErrorBoundary.componentDidCatch` — unchanged, zero cost, useful for local/manual debugging.
- Zero cost, but not centrally visible; only useful for manual debugging (e.g. via a user's screenshot or a support request).
**Option 2: External error-tracking service (e.g. Sentry free tier)** **Sentry free tier (implemented)**
- When decided, add `@sentry/react` as a dependency, initialize it once in the app entry point (e.g. `src/main.tsx`) with the project DSN, and report caught errors from the `ErrorBoundary`'s `componentDidCatch`/`onError` hook to Sentry in addition to the console. - `@sentry/react` is a dependency; `src/main.tsx` calls `Sentry.init({ ... })` at startup, but only when a DSN is present — if not configured, Sentry is silently skipped and only console-logging remains active (safe default, no crash on missing config).
- Store the DSN as a Gitea Actions variable (or a build-time `.env` value, since it's not a secret — Sentry DSNs are safe to expose client-side) and inject it via Vite's `import.meta.env`. - `ErrorBoundary.componentDidCatch` calls `Sentry.captureException(error, { extra: { componentStack: info.componentStack } })` in addition to `console.error`.
- **Tracing/performance** is also enabled (Sentry's recommended default alongside error monitoring, per the official React SDK setup guide): `tanstackRouterBrowserTracingIntegration(router)` is wired up so route navigations are captured as transactions, with `tracesSampleRate: 1.0` (capture all — appropriate for a low-traffic marketing site; lower this if traffic grows significantly).
- **Environment/release tagging**: `environment` is set to `import.meta.env.VITE_APP_ENV` (`'test'` / `'production'`, set at build time — see below), falling back to `import.meta.env.MODE` for local development (`pnpm dev``'development'`). This fallback is needed because `vite build` runs in production mode by default regardless of target environment, so `MODE` alone cannot distinguish a test build from a production build. `release` is set to the app version from `package.json` (injected at build time via `vite.config.ts`'s `define: { __APP_VERSION__ }`), so events in Sentry can be filtered/grouped per environment and per shipped version.
- The DSN is injected at build time via Vite's `import.meta.env.VITE_SENTRY_DSN` (typed in `src/vite-env.d.ts`). It is **not** a secret (Sentry DSNs are safe to expose client-side), so it is passed as a **Gitea Actions repository variable** (`vars.VITE_SENTRY_DSN`, not a secret) to the `Build` step in `continuous_integration.yaml`.
- **Local development DSN**: for `pnpm dev`, Vite automatically loads a `.env.local` file (already covered by `.gitignore`'s `*.local` rule, so it is never committed). Copy `.env.example` to `.env.local` and set `VITE_SENTRY_DSN` there to enable Sentry locally — a separate Sentry project/DSN is recommended so local test noise doesn't mix with the test-environment data. Omitting `.env.local` (or leaving the value empty) simply disables Sentry locally, falling back to console-only logging.
- **Manual follow-up required**: create a free Sentry project (https://sentry.io) for this app, copy its DSN, and set it as the `VITE_SENTRY_DSN` repository variable in Gitea (Repository Settings → Actions → Variables). Until that variable is set, the build still succeeds and the site still works — Sentry reporting simply stays inactive.
- Free tier limits (error volume, retention) are typically sufficient for a low-traffic marketing site. - Free tier limits (error volume, retention) are typically sufficient for a low-traffic marketing site.
- **Not (yet) implemented, by explicit choice**: automatic source map upload (via `@sentry/vite-plugin`), which the official Sentry setup guide also recommends so stack traces show real source code instead of minified code. This requires a Sentry auth token/org/project as a new Gitea secret; deliberately left out of scope for now — revisit if readable production stack traces become a priority.
- **Ad-blocker mitigation via a Sentry "tunnel" (all environments, including local)**: requests straight to `*.ingest.<region>.sentry.io` are commonly blocked client-side by ad-blockers/privacy extensions (`ERR_BLOCKED_BY_CLIENT`), because they resemble third-party tracking. To avoid this, `Sentry.init()` always sets `tunnel: '/sentry-tunnel'`. In deployed (test/production) environments, the reverse-proxy Pi (`nginx/reverse-proxy-nginx.conf.example`) forwards that path server-side to Sentry's envelope endpoint, so the browser only ever talks to the first-party domain (e.g. `test.slpsoftware.nl`). Locally (`pnpm dev`), the equivalent route is provided by Vite's own dev-server proxy (`server.proxy` in `vite.config.ts`), so no ad-blocker whitelisting/disabling is needed anymore for local testing either. This relies on full control over the reverse-proxy's nginx config, which is the case here (self-hosted on the user's own Raspberry Pi's) — it would not work on a third-party/shared host without reverse-proxy access. **Important**: the org-/project-id hardcoded in both the nginx `location /sentry-tunnel` block and the Vite `server.proxy` target must match whatever `VITE_SENTRY_DSN`/`.env.local` DSN is actually configured; update all together if the Sentry project/DSN ever changes.
**Log level strategy**: only errors are logged (no verbose/info-level client logging) — this is a static site with no meaningful "business events" beyond page views, which are covered by analytics (see Dashboards below), not logging. **Log level strategy**: only errors are logged (no verbose/info-level client logging) — this is a static site with no meaningful "business events" beyond page views, which are covered by analytics (see Dashboards below), not logging.
### Temporary manual test tool: `SentryTestButton`
- `src/components/SentryTestButton.tsx` renders a "Break the world" button, mounted globally via `RootLayout.tsx`, used to manually verify that errors, logs (`Sentry.logger.info`), and metrics (`Sentry.metrics.count`) actually arrive in Sentry end-to-end.
- **Visibility**: only shown during local development (`pnpm dev`, via Vite's `import.meta.env.DEV`) and in the test environment (via the new build-time `VITE_APP_ENV` variable, set to `test` by `continuous_integration.yaml`'s `Build` step). It is hidden by default (including in any future production build) unless one of those conditions is explicitly true.
- **Visual feedback**: clicking the button immediately shows a green confirmation toast ("Test error verzonden naar Sentry ✅", `role="status"`) that auto-hides after 4 seconds, so the user gets clear confirmation that the test action fired — without this, the resulting uncaught error/blank state gave no indication anything happened. The actual log/metric/throw (`handleSentryTestErrorClick`) is fired on the next tick (`setTimeout(..., 0)`) so the toast has a chance to render/paint first.
- `Sentry.logger.*` requires `enableLogs: true` in `Sentry.init()` (`src/main.tsx`) — added specifically to support this test button (and any future structured logging).
- The thrown error is **intentionally uncaught**: React error boundaries do not catch errors thrown from event handlers (only render/lifecycle errors), so this relies on Sentry's own global `window.onerror` handler, exactly like the official Sentry test snippet.
- This is a temporary verification tool, not a permanent feature — remove `SentryTestButton` (and its usage in `RootLayout.tsx`) once Sentry has been confirmed to receive test errors/logs/metrics end-to-end.
## Dashboards ## Dashboards
### Website analytics ### Website analytics
@@ -55,7 +69,7 @@ Pick one:
| Concern | Approach | Status | | Concern | Approach | Status |
|---|---|---| |---|---|---|
| Client-side errors | Logging (console today; Sentry free tier optional later) | Destination open item | | Client-side errors | Logging (console + Sentry free tier, incl. tracing, environment/release tags, and an ad-blocker-proof tunnel) | Implemented and confirmed working end-to-end (errors, logs, and metrics received) locally and on the test environment; the `VITE_SENTRY_DSN` Gitea repository variable still needs to be created by the user for the test/production build to report to Sentry (local already works via `.env.local`) |
| Visitor/usage insight | Analytics dashboard (Plausible/Umami/GA4) | Tool selection open item | | Visitor/usage insight | Analytics dashboard (Plausible/Umami/GA4) | Tool selection open item |
| Site reachability | Uptime dashboard (UptimeRobot/Better Uptime) | Tool selection + production URL open item | | Site reachability | Uptime dashboard (UptimeRobot/Better Uptime) | Tool selection + production URL open item |
| Alerting | Out of scope | Not configured | | Alerting | Out of scope | Not configured |
@@ -19,6 +19,6 @@
- **Ready for Production**: Yes with caveats - **Ready for Production**: Yes with caveats
- **Open Follow-ups**: - **Open Follow-ups**:
- Finalize the hosting/domain setup and extend `.gitea/workflows/deploy.yml`'s `deploy` job to actually upload `dist/` to the host, instead of only packaging it as a downloadable artifact (see `deployment-plan.md` "Open Item") - Finalize the hosting/domain setup and extend `.gitea/workflows/deploy.yml`'s `deploy` job to actually upload `dist/` to the host, instead of only packaging it as a downloadable artifact (see `deployment-plan.md` "Open Item")
- Decide the client-side error logging destination (browser console only vs. an external service such as Sentry free tier) — see `monitoring-setup.md` - ~~Decide the client-side error logging destination~~**Resolved and verified**: console + Sentry free tier, implemented and confirmed working end-to-end (errors, logs, metrics) locally and on the test environment, including tracing, environment/release tags, and an ad-blocker-proof tunnel (see `monitoring-setup.md`); remaining manual step is creating the `VITE_SENTRY_DSN` Gitea Actions variable for the test/production build
- Pick and configure the concrete analytics tool (e.g. Plausible/Umami/GA4) and uptime dashboard tool (e.g. UptimeRobot/Better Uptime), including registering the final production URL once hosting is finalized - Pick and configure the concrete analytics tool (e.g. Plausible/Umami/GA4) and uptime dashboard tool (e.g. UptimeRobot/Better Uptime), including registering the final production URL once hosting is finalized
- Run a formal Lighthouse performance check before the first real production deployment (flagged as not yet run in `build-and-test-summary.md`) - Run a formal Lighthouse performance check before the first real production deployment (flagged as not yet run in `build-and-test-summary.md`)
+1
View File
@@ -16,6 +16,7 @@
"@fontsource/instrument-sans": "^5.1.0", "@fontsource/instrument-sans": "^5.1.0",
"@fontsource/jetbrains-mono": "^5.1.1", "@fontsource/jetbrains-mono": "^5.1.1",
"@fontsource/sora": "^5.1.0", "@fontsource/sora": "^5.1.0",
"@sentry/react": "^10.68.0",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",
"@tanstack/react-router": "^1.79.0", "@tanstack/react-router": "^1.79.0",
"react": "^18.3.1", "react": "^18.3.1",
+78
View File
@@ -17,6 +17,9 @@ importers:
'@fontsource/sora': '@fontsource/sora':
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.2.8 version: 5.2.8
'@sentry/react':
specifier: ^10.68.0
version: 10.68.0(react@18.3.1)
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.59.0 specifier: ^5.59.0
version: 5.101.2(react@18.3.1) version: 5.101.2(react@18.3.1)
@@ -592,6 +595,40 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@sentry/browser-utils@10.68.0':
resolution: {integrity: sha512-be8VtdjCngKc77cstJeV+gO15iH+blyXpBDk8yOehmtX4BkFO33mfTMNCWVR2LA0oOxjIHWRAhf77fIUEhzxPg==}
engines: {node: '>=18'}
'@sentry/browser@10.68.0':
resolution: {integrity: sha512-8xVgk7oG2lajXnbXF6a7H1xMZ/U6icqSldHGzQu1+bajfrK8Gan9ULG/Xsj1VM1LlNeK6/7znDJ3u1jgvIwznw==}
engines: {node: '>=18'}
'@sentry/conventions@0.16.0':
resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==}
engines: {node: '>=14'}
'@sentry/core@10.68.0':
resolution: {integrity: sha512-5Amhx8ltVz7vb1bRGyf3c4J69/iHW8R/H+SJxTRILHlsSOBrnVVc/IQEYDC6PTRdRdZ3x2u7RVjxZi2Mhe525g==}
engines: {node: '>=18'}
'@sentry/feedback@10.68.0':
resolution: {integrity: sha512-XbdcXiBnpC3vgw46eHOPeD/ZQ+XzluP75ubdUcaPDW02hCh2nsdXiwjZ2DBImbpvIpTbJgjHf/sIlHWvcZJ2Mg==}
engines: {node: '>=18'}
'@sentry/react@10.68.0':
resolution: {integrity: sha512-rIq4QR4ScMHHx9JJZv7Jgw31bMdUVJMx+ykHIJb7htjY6mj78sjKs+KpCsMDnvJxDhSmvftGM1KfKD4BggL7OQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
'@sentry/replay-canvas@10.68.0':
resolution: {integrity: sha512-HusYcr+He+ohnUDHunYrc5St6vdDnBXpUAndnT5ReyUMVSCiWKfY3paXowU/0787HwYfxdcpZgwC5u79+XbEIg==}
engines: {node: '>=18'}
'@sentry/replay@10.68.0':
resolution: {integrity: sha512-ZoG2n16vbkx4GWSCnLIqUUN9xlUmccQFbQ2US2rhruQeHTUnHl/ukr8NHOQXZaEbwKyMkX9bEMwfmZHJm+wSTQ==}
engines: {node: '>=18'}
'@tanstack/history@1.162.0': '@tanstack/history@1.162.0':
resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==}
engines: {node: '>=20.19'} engines: {node: '>=20.19'}
@@ -2280,6 +2317,47 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.2': '@rollup/rollup-win32-x64-msvc@4.62.2':
optional: true optional: true
'@sentry/browser-utils@10.68.0':
dependencies:
'@sentry/conventions': 0.16.0
'@sentry/core': 10.68.0
'@sentry/browser@10.68.0':
dependencies:
'@sentry/browser-utils': 10.68.0
'@sentry/conventions': 0.16.0
'@sentry/core': 10.68.0
'@sentry/feedback': 10.68.0
'@sentry/replay': 10.68.0
'@sentry/replay-canvas': 10.68.0
'@sentry/conventions@0.16.0': {}
'@sentry/core@10.68.0':
dependencies:
'@sentry/conventions': 0.16.0
'@sentry/feedback@10.68.0':
dependencies:
'@sentry/core': 10.68.0
'@sentry/react@10.68.0(react@18.3.1)':
dependencies:
'@sentry/browser': 10.68.0
'@sentry/conventions': 0.16.0
'@sentry/core': 10.68.0
react: 18.3.1
'@sentry/replay-canvas@10.68.0':
dependencies:
'@sentry/core': 10.68.0
'@sentry/replay': 10.68.0
'@sentry/replay@10.68.0':
dependencies:
'@sentry/browser-utils': 10.68.0
'@sentry/core': 10.68.0
'@tanstack/history@1.162.0': {} '@tanstack/history@1.162.0': {}
'@tanstack/query-core@5.101.2': {} '@tanstack/query-core@5.101.2': {}
+2
View File
@@ -1,5 +1,6 @@
import { Component } from 'react'; import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react'; import type { ErrorInfo, ReactNode } from 'react';
import * as Sentry from '@sentry/react';
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: ReactNode; children: ReactNode;
@@ -22,6 +23,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
componentDidCatch(error: Error, info: ErrorInfo): void { componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('Unexpected application error', error, info); console.error('Unexpected application error', error, info);
Sentry.captureException(error, { extra: { componentStack: info.componentStack } });
} }
render(): ReactNode { render(): ReactNode {
+2
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Nav } from './Nav'; import { Nav } from './Nav';
import { Footer } from './Footer'; import { Footer } from './Footer';
import { SentryTestButton } from './SentryTestButton';
export function RootLayout({ children }: { children: ReactNode }) { export function RootLayout({ children }: { children: ReactNode }) {
return ( return (
@@ -8,6 +9,7 @@ export function RootLayout({ children }: { children: ReactNode }) {
<Nav /> <Nav />
{children} {children}
<Footer /> <Footer />
<SentryTestButton />
</div> </div>
); );
} }
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import * as Sentry from '@sentry/react';
/**
* Alleen zichtbaar tijdens lokale development (`pnpm dev`) en in de testomgeving
* (waar de build met `VITE_APP_ENV=test` wordt gebouwd, zie continuous_integration.yaml).
* In een productiebuild (VITE_APP_ENV ontbreekt of is 'production') is deze knop
* altijd verborgen, ook als iemand vergeet de variabele expliciet te zetten.
*/
const isVisible = import.meta.env.DEV || import.meta.env.VITE_APP_ENV === 'test';
/** Hoe lang de bevestigingsmelding zichtbaar blijft voordat hij vanzelf verdwijnt. */
const TOAST_DURATION_MS = 4000;
/**
* Losstaand van het component zodat dit direct (zonder DOM/React event-dispatch)
* getest kan worden. React vangt fouten uit event handlers namelijk NIET op via
* een ErrorBoundary (die vangt alleen render-/lifecycle-fouten). De fout hieronder
* blijft dus bewust een onafgevangen (uncaught) fout, die door Sentry's eigen
* globale `window.onerror`-handler wordt opgepikt precies zoals bedoeld voor
* deze test-knop.
*/
export function handleSentryTestErrorClick(): void {
// Send a log before throwing the error
Sentry.logger.info('User triggered test error', {
action: 'test_error_button_click',
});
// Send a test metric before throwing the error
Sentry.metrics.count('test_counter', 1);
throw new Error('This is your first error!');
}
/**
* Tijdelijke testknop om te verifiëren dat Sentry errors, logs en metrics
* daadwerkelijk binnenkomen.
*/
export function SentryTestButton() {
const [showToast, setShowToast] = useState(false);
// Verberg de melding automatisch na een paar seconden.
useEffect(() => {
if (!showToast) return;
const hideTimer = setTimeout(() => setShowToast(false), TOAST_DURATION_MS);
return () => clearTimeout(hideTimer);
}, [showToast]);
if (!isVisible) {
return null;
}
function onClick() {
setShowToast(true);
// De daadwerkelijke fout (met log + metric) pas na deze tick versturen,
// zodat de melding hierboven eerst kan renderen/schilderen voordat de
// onafgevangen fout verderop wordt gegooid.
setTimeout(handleSentryTestErrorClick, 0);
}
return (
<>
{showToast && (
<div
role="status"
className="fixed bottom-20 right-4 z-50 rounded bg-green-600 px-4 py-2 text-sm font-semibold text-white shadow-lg"
>
Test error verzonden naar Sentry
</div>
)}
<button
type="button"
onClick={onClick}
className="fixed bottom-4 right-4 z-50 rounded bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-red-700"
>
Break the world
</button>
</>
);
}
@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as Sentry from '@sentry/react';
import { ErrorBoundary } from '../ErrorBoundary';
vi.mock('@sentry/react', () => ({
captureException: vi.fn(),
}));
function ThrowingChild(): never {
throw new Error('boom');
}
describe('ErrorBoundary', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders children when there is no error', () => {
render(
<ErrorBoundary>
<p>Alles werkt</p>
</ErrorBoundary>,
);
expect(screen.getByText('Alles werkt')).toBeInTheDocument();
});
it('renders the generic fallback message when a child throws', () => {
// Suppress the expected React error boundary console.error noise for this test.
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>,
);
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
expect(screen.getByText('Er ging iets mis. Probeer de pagina te vernieuwen.')).toBeInTheDocument();
consoleErrorSpy.mockRestore();
});
it('reports the caught error to Sentry', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>,
);
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ componentStack: expect.any(String) }) }),
);
consoleErrorSpy.mockRestore();
});
});
@@ -0,0 +1,81 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as Sentry from '@sentry/react';
import { SentryTestButton, handleSentryTestErrorClick } from '../SentryTestButton';
vi.mock('@sentry/react', () => ({
captureException: vi.fn(),
logger: { info: vi.fn() },
metrics: { count: vi.fn() },
}));
/**
* De echte fout wordt bewust ongevangen via een `setTimeout` gegooid (zie
* SentryTestButton.tsx). Om dat in deze fake-timer-tests te simuleren zonder
* dat de test zelf crasht op die intentionele throw, wordt het "doortikken"
* van de timers hierin afgevangen.
*/
function advanceTimersIgnoringIntentionalThrow(ms: number) {
try {
act(() => {
vi.advanceTimersByTime(ms);
});
} catch {
// verwacht: de intentionele test-fout uit handleSentryTestErrorClick
}
}
describe('SentryTestButton', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('is visible in the current (development/test) build', () => {
render(<SentryTestButton />);
expect(screen.getByRole('button', { name: 'Break the world' })).toBeInTheDocument();
});
it('sends a log and a metric to Sentry, then throws an intentional test error', () => {
expect(() => handleSentryTestErrorClick()).toThrow('This is your first error!');
expect(Sentry.logger.info).toHaveBeenCalledWith('User triggered test error', {
action: 'test_error_button_click',
});
expect(Sentry.metrics.count).toHaveBeenCalledWith('test_counter', 1);
});
it('shows a confirmation toast immediately when clicked, before the test error fires', () => {
render(<SentryTestButton />);
fireEvent.click(screen.getByRole('button', { name: 'Break the world' }));
expect(screen.getByRole('status')).toHaveTextContent('Test error verzonden naar Sentry');
// De log/metric/throw zijn pas gepland (setTimeout), nog niet uitgevoerd.
expect(Sentry.logger.info).not.toHaveBeenCalled();
advanceTimersIgnoringIntentionalThrow(0);
expect(Sentry.logger.info).toHaveBeenCalledWith('User triggered test error', {
action: 'test_error_button_click',
});
expect(Sentry.metrics.count).toHaveBeenCalledWith('test_counter', 1);
});
it('hides the toast again after it has been shown for a while', () => {
render(<SentryTestButton />);
fireEvent.click(screen.getByRole('button', { name: 'Break the world' }));
expect(screen.getByRole('status')).toBeInTheDocument();
// De intentionele fout (0ms-timer) stopt het "doortikken" van timers
// binnen dezelfde advanceTimersByTime-call, dus eerst die apart afhandelen
// voordat de resterende hide-timer (4000ms) doorgetikt kan worden.
advanceTimersIgnoringIntentionalThrow(0);
advanceTimersIgnoringIntentionalThrow(4000);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
});
+24
View File
@@ -1,10 +1,34 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { RouterProvider } from '@tanstack/react-router'; import { RouterProvider } from '@tanstack/react-router';
import * as Sentry from '@sentry/react';
import './fonts'; import './fonts';
import './index.css'; import './index.css';
import { router } from './router'; import { router } from './router';
const sentryDsn = import.meta.env.VITE_SENTRY_DSN;
if (sentryDsn) {
Sentry.init({
dsn: sentryDsn,
// VITE_APP_ENV ('test' | 'production') distinguishes test vs. production builds — both use
// `vite build` without an explicit mode, so MODE alone would tag both as 'production'.
// Falls back to MODE for local development (`pnpm dev` -> 'development').
environment: import.meta.env.VITE_APP_ENV ?? import.meta.env.MODE,
release: __APP_VERSION__,
integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)],
// Low-traffic marketing site: capture all traces (lower this if traffic grows significantly).
tracesSampleRate: 1.0,
// Required for Sentry.logger.* calls (e.g. the temporary SentryTestButton) to actually be sent.
enableLogs: true,
// Ad-blockers/privacy extensions commonly block requests straight to *.ingest.sentry.io
// (ERR_BLOCKED_BY_CLIENT), because it looks like third-party tracking. Routing through our
// own domain via a "tunnel" avoids that: in built (test/production) environments this is
// handled by the reverse-proxy Pi (see nginx/reverse-proxy-nginx.conf.example); locally
// (`pnpm dev`) the equivalent proxy route is set up in vite.config.ts's `server.proxy`.
tunnel: '/sentry-tunnel',
});
}
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
throw new Error('Root element "#root" not found in index.html'); throw new Error('Root element "#root" not found in index.html');
+15
View File
@@ -0,0 +1,15 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Sentry DSN (Data Source Name) for client-side error reporting. Not a secret — safe to expose in the client bundle. */
readonly VITE_SENTRY_DSN?: string;
/** Build-time deployment environment tag ('test' | 'production' | undefined). Used to hide dev/test-only UI (e.g. SentryTestButton) from production builds. */
readonly VITE_APP_ENV?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
/** App version at build time (from package.json), injected via vite.config.ts `define`. Used as the Sentry `release` tag. */
declare const __APP_VERSION__: string;
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"root":["./src/fonts.ts","./src/main.tsx","./src/queryclient.ts","./src/router.tsx","./src/components/aboutsection.tsx","./src/components/contactsection.tsx","./src/components/errorboundary.tsx","./src/components/footer.tsx","./src/components/hero.tsx","./src/components/nav.tsx","./src/components/packagecard.tsx","./src/components/packagessection.tsx","./src/components/processsection.tsx","./src/components/processstep.tsx","./src/components/rootlayout.tsx","./src/components/themetoggle.tsx","./src/components/__tests__/nav.test.tsx","./src/components/__tests__/packagessection.test.tsx","./src/data/content.ts","./src/hooks/usepackagesquery.ts","./src/routes/__root.tsx","./src/routes/index.tsx","./src/test/setup.ts","./src/theme/themeprovider.tsx","./src/theme/tokens.ts","./src/theme/__tests__/themeprovider.test.tsx","./src/utils/scrolltohash.ts"],"version":"5.9.3"}
-13
View File
@@ -1,13 +0,0 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
css: true,
},
});
+19
View File
@@ -1,10 +1,29 @@
/// <reference types="vitest/config" /> /// <reference types="vitest/config" />
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { version } from './package.json';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: {
// Injected at build time, used as the Sentry `release` tag (see src/main.tsx).
__APP_VERSION__: JSON.stringify(version),
},
server: {
proxy: {
// Mirrors the reverse-proxy Pi's "/sentry-tunnel" location (see
// nginx/reverse-proxy-nginx.conf.example) so the same tunnel path also
// works locally during `pnpm dev`, avoiding ad-blocker ERR_BLOCKED_BY_CLIENT
// errors when testing locally too (see src/main.tsx's Sentry.init tunnel option).
// LET OP: moet in sync blijven met de VITE_SENTRY_DSN uit .env.local.
'/sentry-tunnel': {
target: 'https://o4511795618185216.ingest.de.sentry.io',
changeOrigin: true,
rewrite: () => '/api/4511795622838352/envelope/',
},
},
},
test: { test: {
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,