Feature/analytics uptime dashboard #6

Merged
Sluijsens merged 9 commits from feature/analytics_uptime_dashboard into master 2026-07-26 00:03:17 +02:00
20 changed files with 691 additions and 62 deletions
+7
View File
@@ -8,3 +8,10 @@
# Sentry-project aan op https://sentry.io en plak hier de DSN van dat project. # 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). # Laat leeg/weg om Sentry lokaal uit te schakelen (alleen console-logging blijft actief).
VITE_SENTRY_DSN= VITE_SENTRY_DSN=
# Self-hosted Umami analytics (zie operations/monitoring/umami-setup.md).
# Geen van beide is een secret (client-side zichtbaar in de pagina-broncode).
# Laat leeg/weg om analytics lokaal uit te schakelen; in `pnpm dev` wordt het
# script sowieso nooit geladen, ongeacht deze waarden (zie UmamiAnalytics.tsx).
VITE_UMAMI_SCRIPT_URL=https://analytics.slpsoftware.nl/script.js
VITE_UMAMI_WEBSITE_ID=
+3 -1
View File
@@ -92,8 +92,9 @@ jobs:
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Build - name: Build
run: pnpm run build
env: env:
VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }}
VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID }}
# Sentry DSN is niet gevoelig (veilig om in de client-bundle te zitten), # 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: # daarom een Gitea Actions "vars"-waarde i.p.v. een secret. Optioneel:
# als deze niet is ingesteld, wordt Sentry-logging simpelweg overgeslagen # als deze niet is ingesteld, wordt Sentry-logging simpelweg overgeslagen
@@ -104,6 +105,7 @@ jobs:
# Zolang er nog geen aparte productie-build/deploy bestaat, is dit altijd # Zolang er nog geen aparte productie-build/deploy bestaat, is dit altijd
# gelijk aan DEPLOY_ENVIRONMENT ('test'). # gelijk aan DEPLOY_ENVIRONMENT ('test').
VITE_APP_ENV: ${{ env.DEPLOY_ENVIRONMENT }} VITE_APP_ENV: ${{ env.DEPLOY_ENVIRONMENT }}
run: pnpm run build
- name: Upload build artifact - name: Upload build artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -6,7 +6,7 @@
- **Project Type**: Greenfield - **Project Type**: Greenfield
- **Start Date**: 2026-07-18T00:37:00Z - **Start Date**: 2026-07-18T00:37:00Z
- **Current Stage**: Complete (post-completion follow-ups tracked in `audit.md` / `operations/production-readiness-checklist.md`) - **Current Stage**: Complete (post-completion follow-ups tracked in `audit.md` / `operations/production-readiness-checklist.md`)
- **Branch**: feature/sentry_error_logging - **Branch**: feature/analytics_uptime_dashboard
## Workspace State ## Workspace State
- **Existing Code**: No - **Existing Code**: No
@@ -564,3 +564,75 @@
**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. **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.
--- ---
## Post-Completion Follow-up: Analytics + Uptime Dashboard Tools Decided (Umami self-hosted + UptimeRobot)
**Timestamp**: 2026-07-25T16:10:00Z
**User Input**: "Analytics/uptime dashboard" open item chosen next, after closing the Sentry logging follow-up on its own branch. Clarified: Umami (self-hosted, needs Podman help), UptimeRobot, hosted on the webserver Pi (Pi Main), dashboard reachable via `analytics.slpsoftware.nl`, Podman already available on that Pi.
**AI Response**: "Created feature branch `feature/analytics_uptime_dashboard` from `master` (independent of the not-yet-merged `feature/sentry_error_logging` branch). Added `src/components/UmamiAnalytics.tsx`, which injects the Umami tracking script client-side, gated on `VITE_UMAMI_SCRIPT_URL`/`VITE_UMAMI_WEBSITE_ID` (typed in a new `src/vite-env.d.ts`) and skipped entirely during local development, mirroring the existing Sentry-DSN pattern; mounted it in `RootLayout.tsx`, with unit tests (`UmamiAnalytics.test.tsx`, 4 tests). Wired both variables into `continuous_integration.yaml`'s `Build` step as Gitea repository variables (not secrets). Added a self-hosted Umami example setup: `operations/deployment/umami/podman-compose.yml.example` + `.env.example` (Umami + Postgres, bound to the Pi's internal IP only) and `operations/deployment/nginx/analytics-nginx.conf.example` (reverse-proxy Pi forwarding `analytics.slpsoftware.nl` to Pi Main's Umami port, with certbot SSL, same pattern as the test-environment config). Wrote a new step-by-step `operations/monitoring/umami-setup.md` covering container setup, systemd auto-start (rootless Podman + `loginctl enable-linger`), reverse proxy/SSL, website registration in the Umami UI, and wiring the resulting website ID into the build. Updated `monitoring-plan.md`, `monitoring-setup.md`, and `production-readiness-checklist.md` to mark the analytics/uptime tool-selection open item as resolved, listing the remaining manual deployment steps."
**Context**: Post-workflow follow-up (feature already marked Complete); addresses the "Analytics/uptime dashboard" open item from `production-readiness-checklist.md`. Work done on `feature/analytics_uptime_dashboard`. `pnpm run build`, `pnpm run lint`, and `pnpm run test` to be verified before committing.
---
## Post-Completion Follow-up: Umami Moved to a Dedicated `umami` User (Podman Isolation Pattern)
**Timestamp**: 2026-07-25T21:20:00Z
**User Input**: "Ja, ik wil wel meer de kant op gaan van de verschillende self-hosted services als losse users. Maakt mij flexibeler als ik later meer wil ookal is een raspberry pi beperkt in rekenkracht" (confirming, after a prior explanation, the preference for a dedicated Linux user per self-hosted service instead of a shared folder under the existing user account).
**AI Response**: "Reworked `operations/monitoring/umami-setup.md` to run Umami under a new dedicated `umami` Linux user instead of the existing main user account: added a new "Waarom een dedicated user" section explaining the rootless-Podman isolation rationale, and a new step 0 covering `useradd --create-home`, verifying/adding a subuid/subgid range (`/etc/subuid`/`/etc/subgid`), and `loginctl enable-linger umami` so rootless Podman services keep running without an active login. Updated step 1 (container setup) and step 2 (systemd auto-start) to run as the `umami` user (`sudo -iu umami`), with paths now under `/home/umami/umami/`. Extended the outstanding manual steps list with the new user-creation step, and added a closing note documenting this as a reusable pattern for future self-hosted services on the same or other Pi's."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/analytics_uptime_dashboard` branch, refining the Umami deployment approach before it has been executed on the Pi. Documentation-only change (no code/build impact).
---
## Post-Completion Follow-up: Umami External Port Changed (3000 → 3001) to Avoid Conflict with Gitea
**Timestamp**: 2026-07-25T22:39:00Z
**User Input**: "de poort 3000 is la in gebruik. volgens mij door gitea: Error: unable to start container ...: rootlessport listen tcp 192.168.1.103:3000: bind: address already in use"
**AI Response**: "Confirmed the reported cause: Gitea already occupies port 3000 system-wide on the same Pi, so even though Umami's `podman-compose.yml.example` binds specifically to `192.168.1.103:3000` (not `0.0.0.0`), it still conflicts because Gitea's own binding already covers that IP/port combination. Changed the external port mapping to `192.168.1.103:3001:3000` (container still listens internally on 3000), and updated all references accordingly: `nginx/analytics-nginx.conf.example` (`proxy_pass` target + comments), `monitoring-setup.md` (decision summary), and `umami-setup.md` (heartbeat-check `curl` command, with an explanatory note)."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/analytics_uptime_dashboard` branch, fixing a port conflict discovered before the Umami deployment has been executed on the Pi. Documentation/example-config-only change (no code/build impact).
---
## Post-Completion Follow-up: Clarified `podman-compose down`/`up -d` Needed After Config Changes to an Already-Created Container
**Timestamp**: 2026-07-25T22:57:00Z
**User Input**: "maakt het uit dat ik al podman-compose heb uitgevoerd? moet ik nu eerst de container verwijderen?" (asking whether it matters that `podman-compose up -d` had already been run with the old port-3000 config, before the port-3001 fix).
**AI Response**: "Explained that a port mapping is set at container-creation time and is not live-reloaded by `podman-compose up -d` on an already-existing container, so after updating the local `podman-compose.yml` to the new port, a `podman-compose down` followed by `podman-compose up -d` is required to recreate the container with the corrected mapping (database data is preserved via the persistent volume). Added a callout to `umami-setup.md` documenting this `down`/`up` requirement for future config changes (e.g. another port change), so this is not only explained in chat but also captured for later reference."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/analytics_uptime_dashboard` branch, clarifying the deployment steps before the Umami container has successfully started on the Pi. Documentation-only change (no code/build impact).
---
## Post-Completion Follow-up: ufw Firewall Rule Missing for Port 3001 (Connection Reset by Peer)
**Timestamp**: 2026-07-25T23:05:00Z
**User Input**: "ik krijg nu: curl http://192.168.1.103:3001/api/heartbeat curl: (56) Recv failure: Connection reset by peer"
**AI Response**: "Diagnosed via `podman ps`/`podman logs`/`ss -tlnp` that the Umami container and rootlessport forwarder were both healthy and correctly listening on `192.168.1.103:3001`, ruling out an application/container issue. Root cause found in `sudo ufw status verbose`: an explicit `ALLOW` rule exists for `3000/tcp` from `192.168.1.102` (the reverse-proxy Pi), but no equivalent rule exists for `3001/tcp`, and ufw's default incoming policy is `deny` — so the request is blocked even when curled from Pi Main itself, since it targets the real host IP rather than loopback. Added a new firewall step to `umami-setup.md` (`sudo ufw allow from 192.168.1.102 to any port 3001 proto tcp` + `ufw reload`), inserted before the heartbeat-check step, plus a troubleshooting tip to test via `localhost:3001` (bypasses the firewall) to isolate firewall vs. container issues. Added the missing ufw rule to the outstanding manual steps list."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/analytics_uptime_dashboard` branch, resolving a connectivity blocker discovered while the user was testing the Umami container on Pi Main. Documentation-only change (no code/build impact); the actual `ufw allow` command still needs to be run by the user on Pi Main.
---
## Post-Completion Follow-up: "Failed to connect to bus: No medium found" When Setting Up the systemd User Service
**Timestamp**: 2026-07-25T23:18:00Z
**User Input**: "ik krijg het volgende al sik de service probeer aan te maken en deamon reload probeer te doen: systemctl --user daemon-reload Failed to connect to bus: No medium found"
**AI Response**: "Explained that this error means no D-Bus/systemd user session exists yet for the `umami` user, most likely because `sudo -iu umami` does not register a full PAM login session the way an interactive login does — so `/run/user/<uid>` (and the bus socket inside it) is missing, even with linger already enabled. Added a troubleshooting callout to `umami-setup.md` right before the systemd-service step: verify `Linger=yes` via `loginctl show-user umami`, re-run `loginctl enable-linger umami` if not, explicitly start `user@<uid>.service`, re-login and export `XDG_RUNTIME_DIR` manually, with `machinectl shell umami@ /bin/bash` offered as a more robust alternative to `sudo -iu`/`su -` if the issue persists."
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/analytics_uptime_dashboard` branch, resolving a blocker discovered while the user was setting up the systemd auto-start service on Pi Main. Documentation-only change (no code/build impact); the user still needs to apply the fix on Pi Main.
---
@@ -39,8 +39,8 @@ Deze secrets heten `PI_MAIN_*` (niet `PI_TEST_*`), omdat dezelfde Pi (Pi Main) e
## 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. 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`). 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. Ook dit bestand is de daadwerkelijk in gebruik zijnde (door certbot beheerde) configuratie, met `server_name test.slpsoftware.nl`. 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 is de versie van vóór certbot (alleen poort 80, geen SSL), 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 herschrijft dit bestand automatisch met de HTTPS-configuratie en de HTTP→HTTPS-redirect — zie `operations/deployment/nginx/reverse-proxy-nginx.conf.post-certbot.example` voor hoe het er dan uitziet (referentie, niet zelf kopiëren).
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`). 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`).
> **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`). > **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`).
@@ -0,0 +1,47 @@
# Voorbeeldconfiguratie voor de nginx reverse proxy op de reverse-proxy-Pi
# (dezelfde Pi als reverse-proxy-nginx.conf.example) om het zelf-gehoste
# Umami-analyticsdashboard bereikbaar te maken via analytics.slpsoftware.nl.
#
# Umami zelf draait als podman-container op de webserver-Pi (Pi Main,
# 192.168.1.103:3001 — zie ../umami/podman-compose.yml.example) en heeft dus
# GEEN eigen nginx-configuratie op die Pi nodig; Umami serveert zijn eigen
# dashboard + tracking-script rechtstreeks via die poort. Let op: de externe
# poort is bewust 3001 (niet het gebruikelijke 3000), omdat Gitea op diezelfde
# Pi al poort 3000 systeembreed bezet.
#
# Dit is de versie die je gebruikt VOORDAT certbot gedraaid heeft: alleen
# poort 80, geen SSL. Certbot heeft dit HTTP-server-block namelijk nodig om
# de ACME-challenge te kunnen afhandelen en zal, zodra je hem draait, dit
# bestand zelf herschrijven om er de HTTPS-configuratie en de HTTP→HTTPS-
# redirect aan toe te voegen. Zie analytics-nginx.conf.post-certbot.example
# voor hoe het bestand er na die stap uit gaat zien (puur ter referentie —
# dat bestand hoef je niet zelf te kopiëren, certbot genereert het).
#
# Kopieer dit bestand handmatig naar bijvoorbeeld
# /etc/nginx/sites-available/slpsoftware-analytics.conf op de reverse-proxy-Pi,
# maak een symlink in sites-enabled, herlaad nginx, en draai dan pas certbot:
# sudo certbot --nginx -d analytics.slpsoftware.nl
# Zorg dat het DNS-record voor analytics.slpsoftware.nl al naar het publieke
# IP van deze Pi wijst voordat je certbot draait.
server {
listen 80;
listen [::]:80;
server_name analytics.slpsoftware.nl;
error_log /var/log/nginx/slpsoftware-analytics_error.log;
access_log /var/log/nginx/slpsoftware-analytics_access.log;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
proxy_pass http://192.168.1.103:3001;
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;
}
}
@@ -0,0 +1,50 @@
# REFERENTIE ALLEEN — dit bestand hoef je niet handmatig te kopiëren.
#
# Dit toont hoe /etc/nginx/sites-available/slpsoftware-analytics.conf op de
# reverse-proxy-Pi er automatisch uit komt te zien NADAT je certbot hebt
# gedraaid (`sudo certbot --nginx -d analytics.slpsoftware.nl`) op basis van
# analytics-nginx.conf.example. Certbot voegt zelf de HTTPS-configuratie en
# het HTTP→HTTPS-redirect-blok toe (herkenbaar aan de "managed by Certbot"
# commentaren), en zet de error_log/access_log en het proxy_pass-blok gewoon
# over naar het nieuwe HTTPS-serverblok.
server {
server_name analytics.slpsoftware.nl;
error_log /var/log/nginx/slpsoftware-analytics_error.log;
access_log /var/log/nginx/slpsoftware-analytics_access.log;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
proxy_pass http://192.168.1.103:3001;
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;
}
listen 443 ssl; # managed by Certbot
listen [::]:443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/analytics.slpsoftware.nl/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/analytics.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 = analytics.slpsoftware.nl) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name analytics.slpsoftware.nl;
return 404; # managed by Certbot
}
@@ -3,21 +3,21 @@
# doorstuurt naar de webserver-Pi op 192.168.1.103:80) — voor de TESTOMGEVING, # doorstuurt naar de webserver-Pi op 192.168.1.103:80) — voor de TESTOMGEVING,
# bereikbaar via het domein test.slpsoftware.nl. # bereikbaar via het domein test.slpsoftware.nl.
# #
# Kopieer dit bestand handmatig naar bijvoorbeeld # Dit is de versie die je gebruikt VOORDAT certbot gedraaid heeft: alleen
# /etc/nginx/sites-available/slpsoftware-test.conf op de reverse-proxy-Pi, # poort 80, geen SSL. Certbot heeft dit HTTP-server-block namelijk nodig om
# maak een symlink in sites-enabled, en herlaad nginx. # de ACME-challenge te kunnen afhandelen en zal, zodra je hem draait, dit
# bestand zelf herschrijven om er de HTTPS-configuratie en de HTTP→HTTPS-
# redirect aan toe te voegen. Zie reverse-proxy-nginx.conf.post-certbot.example
# voor hoe het bestand er na die stap uit gaat zien (puur ter referentie —
# dat bestand hoef je niet zelf te kopiëren, certbot genereert het).
# #
# SSL-certificaat: net als voor de productiesite wordt dit aangevraagd met # Kopieer dit bestand handmatig naar
# certbot (Let's Encrypt) rechtstreeks op deze reverse-proxy-Pi, bijvoorbeeld: # /etc/nginx/sites-available/slpsoftware-test.conf op de reverse-proxy-Pi,
# maak een symlink in sites-enabled, herlaad nginx, en draai dan pas certbot:
# 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. # deze Pi wijst voordat je certbot draait.
# #
# Dit is de daadwerkelijk in gebruik zijnde configuratie op de reverse-proxy-Pi,
# zoals door certbot gegenereerd/beheerd. Het HTTP-server-block onderaan (dat
# doorverwijst naar HTTPS, behalve voor de ACME-challenge) en de #-commentaren
# "managed by Certbot" zijn automatisch door certbot toegevoegd/aangepast.
#
# LET OP - gevonden foutje: de header hieronder heet `X-Real-IP` (met # LET OP - gevonden foutje: de header hieronder heet `X-Real-IP` (met
# koppelteken), niet `X-Real_IP` (met underscore). Een header met een # koppelteken), niet `X-Real_IP` (met underscore). Een header met een
# underscore is geen geldige/herkenbare naam voor de standaard `$http_x_real_ip`- # underscore is geen geldige/herkenbare naam voor de standaard `$http_x_real_ip`-
@@ -25,6 +25,8 @@
# de ontvangende applicatie/module verwacht het koppelteken-formaat. Corrigeer # de ontvangende applicatie/module verwacht het koppelteken-formaat. Corrigeer
# dit op de Pi zelf naar `X-Real-IP` zodra je de config daar aanpast. # 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;
@@ -60,25 +62,4 @@ server {
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
}
@@ -0,0 +1,55 @@
# REFERENTIE ALLEEN — dit bestand hoef je niet handmatig te kopiëren.
#
# Dit toont hoe /etc/nginx/sites-available/slpsoftware-test.conf op de
# reverse-proxy-Pi er automatisch uit komt te zien NADAT je certbot hebt
# gedraaid (`sudo certbot --nginx -d test.slpsoftware.nl`) op basis van
# reverse-proxy-nginx.conf.example. Certbot voegt zelf de HTTPS-configuratie
# en het HTTP→HTTPS-redirect-blok toe (herkenbaar aan de "managed by Certbot"
# commentaren), en zet de error_log/access_log en overige location-blocks
# gewoon over naar het nieuwe HTTPS-serverblok.
server {
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/ {
root /var/www/certbot;
}
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;
}
location / {
proxy_pass http://192.168.1.103:80;
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;
}
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
}
@@ -16,6 +16,9 @@ server {
server_name test.slpsoftware.nl; server_name test.slpsoftware.nl;
error_log /var/log/nginx/slpsoftware-test-webserver_error.log;
access_log /var/log/nginx/slpsoftware-test-webserver_access.log;
root /mnt/storage1/www/html/test/slpsoftware; root /mnt/storage1/www/html/test/slpsoftware;
index index.html; index index.html;
@@ -0,0 +1,10 @@
# Kopieer dit bestand naar `.env` naast podman-compose.yml op de Pi (NIET committen —
# bevat straks echte wachtwoorden/secrets).
# Willekeurig, sterk wachtwoord voor de Umami PostgreSQL-database.
# Genereren met bv.: openssl rand -base64 32
POSTGRES_PASSWORD=
# Willekeurige, sterke random string die Umami gebruikt om sessies te ondertekenen.
# Genereren met bv.: openssl rand -base64 32
APP_SECRET=
@@ -0,0 +1,58 @@
# Voorbeeld podman-compose bestand om Umami (self-hosted analytics) te draaien
# op de webserver-Pi (Pi Main, 192.168.1.103), naast de al draaiende nginx die
# de website zelf serveert.
#
# Vereist: podman + podman-compose (`pip3 install --user podman-compose`) op de Pi.
#
# Gebruik:
# 1. Kopieer dit bestand naar bv. ~/umami/podman-compose.yml op de Pi.
# 2. Kopieer .env.example naast dit bestand naar .env en vul een echte
# POSTGRES_PASSWORD en APP_SECRET in (zie .env.example voor hoe je die genereert).
# 3. cd ~/umami && podman-compose up -d
# 4. Zie umami-setup.md voor het instellen van auto-start na reboot (systemd)
# en het aanmaken van een website in de Umami-UI om het website-ID te krijgen.
services:
umami:
image: docker.umami.is/umami-software/umami:postgresql-latest
# Alleen op het interne (LAN) IP van deze Pi binden, niet op 0.0.0.0 — zelfde
# aanpak als webserver-nginx.conf.example, zodat deze poort niet per ongeluk
# vanaf buiten het netwerk bereikbaar is. Externe poort is 3001 (i.p.v. het
# gebruikelijke 3000), omdat Gitea op dezelfde Pi al poort 3000 systeembreed
# bezet (ook op dit interne IP, ongeacht dat Umami een specifiek IP opgeeft).
# De container zelf luistert intern nog gewoon op 3000. De reverse-proxy-Pi
# stuurt analytics.slpsoftware.nl door naar 192.168.1.103:3001 (zie
# ../nginx/analytics-nginx.conf.example).
ports:
- "192.168.1.103:3001:3000"
environment:
DATABASE_URL: postgresql://umami:${POSTGRES_PASSWORD}@db:5432/umami
DATABASE_TYPE: postgresql
APP_SECRET: ${APP_SECRET}
depends_on:
db:
condition: service_healthy
restart: always
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/heartbeat"]
interval: 10s
timeout: 5s
retries: 5
db:
image: docker.io/postgres:15-alpine
environment:
POSTGRES_DB: umami
POSTGRES_USER: umami
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- umami-db-data:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umami -d umami"]
interval: 10s
timeout: 5s
retries: 5
volumes:
umami-db-data:
@@ -17,8 +17,8 @@ Client-side errors (JavaScript crashes, broken links) are logged. **Decided**: S
### Dashboards ### Dashboards
A combination of: A combination of:
- **Website analytics** (visitors, page views, basic engagement) — e.g. a simple/free tool such as Plausible, Umami, or Google Analytics/Search Console. - **Website analytics**: **decided** — self-hosted Umami (Podman on the webserver Pi, see `operations/monitoring/umami-setup.md`), chosen over GA4/hosted Plausible for privacy-friendliness and no recurring cost.
- **Uptime dashboard** (site reachability) — e.g. an external monitoring service such as UptimeRobot or Better Uptime. - **Uptime dashboard**: **decided** — UptimeRobot, chosen over Better Uptime for its established free tier.
(Original Question 6 = C, "Both (analytics + uptime dashboard)".) (Original Question 6 = C, "Both (analytics + uptime dashboard)".)
@@ -28,7 +28,7 @@ A combination of:
## Open Action Items ## Open Action Items
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`). 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~~**Resolved**: self-hosted Umami + UptimeRobot (see `monitoring-setup.md` and `umami-setup.md`). Remaining manual follow-ups: actually deploying the Umami containers on the Pi, registering DNS/SSL for `analytics.slpsoftware.nl`, creating the UptimeRobot monitor, and setting the `VITE_UMAMI_SCRIPT_URL`/`VITE_UMAMI_WEBSITE_ID` Gitea repository variables.
## Rationale ## Rationale
Given this is a simple static marketing site with no backend and no existing monitoring, the aim is lightweight, low/no-cost observability: enough to know if the site is down (uptime) and how it's being used (analytics), plus a documented (if not yet finalized) path for capturing client-side errors. Alerting was deliberately left out to avoid over-engineering a notification pipeline before there's a concrete trigger/audience for it. Given this is a simple static marketing site with no backend and no existing monitoring, the aim is lightweight, low/no-cost observability: enough to know if the site is down (uptime) and how it's being used (analytics), plus a documented (if not yet finalized) path for capturing client-side errors. Alerting was deliberately left out to avoid over-engineering a notification pipeline before there's a concrete trigger/audience for it.
@@ -39,38 +39,50 @@ Both the console and Sentry are now active (original Question 3 resolved as a co
## Dashboards ## Dashboards
### Website analytics ### Website analytics — decided: self-hosted Umami
Pick one (all have generous free tiers suitable for a small marketing site): **Decided**: self-hosted Umami, running via Podman on the webserver Pi (Pi Main,
`192.168.1.103:3001` — note: not the default 3000, since Gitea already occupies
that port on the same Pi), reachable for the dashboard itself via `analytics.slpsoftware.nl`
(reverse-proxied + SSL via certbot, same pattern as `test.slpsoftware.nl`). Chosen over
GA4 for privacy-friendliness (typically no cookie banner needed) and full self-hosted
control, and over a hosted Umami/Plausible plan to avoid recurring cost.
| Option | Notes | - Full step-by-step setup: `operations/monitoring/umami-setup.md` (Podman/compose files,
|---|---| systemd auto-start, reverse proxy + SSL, website registration).
| Plausible / Umami | Privacy-friendly, lightweight, no cookie banner typically required; self-hosted or low-cost hosted tier | - Example config: `operations/deployment/umami/podman-compose.yml.example` +
| Google Analytics (GA4) / Search Console | Free, widely known, but heavier script and involves third-party data sharing (cookie/consent implications) | `.env.example`; `operations/deployment/nginx/analytics-nginx.conf.example`.
- The tracking script is injected client-side by `src/components/UmamiAnalytics.tsx`,
gated on two build-time variables (`VITE_UMAMI_SCRIPT_URL`, `VITE_UMAMI_WEBSITE_ID`),
wired into `continuous_integration.yaml`'s `Build` step as Gitea repository variables,
not secrets (same pattern as `VITE_SENTRY_DSN`). It never loads during local
development (`pnpm dev`), even if those variables happen to be set, so local testing
never pollutes visitor analytics.
- Key metrics to surface: unique visitors, page views per route (Home, Packages, etc. —
see `frontend-components.md`), and referral sources.
**Setup (once a tool is picked)**: ### Uptime dashboard — decided: UptimeRobot
1. Create an account/site entry with the chosen provider and obtain the tracking snippet or `<script>` tag. **Decided**: UptimeRobot (free tier: up to 50 monitors, 5-minute check interval, optional
2. Add the snippet to `index.html` (or load it conditionally in `src/main.tsx`) — this is a documentation/config task, not something the current codebase needs restructuring for. e-mail notification on downtime — opportunistic, not a designed alerting feature per
3. Key metrics to surface: unique visitors, page views per route (Home, Packages, etc. — see `frontend-components.md`), and referral sources. `monitoring-plan.md`). Chosen over Better Uptime for its long-standing free tier and
simplicity for a single low-traffic site.
### Uptime dashboard **Setup**:
Pick one: 1. Create a free UptimeRobot account (https://uptimerobot.com).
2. Register `test.slpsoftware.nl` as an HTTP(S) monitor now (checking for a `200`
response); add the production URL once hosting is finalized (see
`operations/deployment/deployment-plan.md` "Open Item") — still an open follow-up.
3. Optional: publish a public status page if desired for transparency to visitors.
4. Key metric to surface: uptime percentage / current status.
| Option | Notes | No application code changes are needed for UptimeRobot — it works purely by polling the
|---|---| public URL from the outside, entirely independent of the site's own codebase.
| UptimeRobot | Free tier: up to 50 monitors, 5-minute check interval, optional e-mail notification on downtime (opportunistic, not a designed alerting feature per `monitoring-plan.md`) |
| Better Uptime | Free tier available; similar capability, nicer public status page option |
**Setup (once a tool is picked)**:
1. Register the production URL (once hosting is finalized — see `operations/deployment/deployment-plan.md` "Open Item") as an HTTP(S) monitor, checking for a `200` response.
2. Optional: publish a public status page if desired for transparency to visitors.
3. Key metric to surface: uptime percentage / current status.
## Summary Table ## Summary Table
| Concern | Approach | Status | | Concern | Approach | Status |
|---|---|---| |---|---|---|
| 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`) | | 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 (self-hosted Umami) | Tool decided; deployment (Podman on Pi Main + `analytics.slpsoftware.nl`) is a manual follow-up, see `umami-setup.md` |
| Site reachability | Uptime dashboard (UptimeRobot/Better Uptime) | Tool selection + production URL open item | | Site reachability | Uptime dashboard (UptimeRobot) | Tool decided; account creation + production URL are manual follow-ups |
| Alerting | Out of scope | Not configured | | Alerting | Out of scope | Not configured |
| Shared infrastructure reuse | Out of scope | None exists yet | | Shared infrastructure reuse | Out of scope | None exists yet |
@@ -0,0 +1,233 @@
# Umami Self-Hosted Setup (Analytics)
Stapsgewijze handleiding om Umami (self-hosted website-analytics) op te zetten op de
webserver-Pi (Pi Main, `192.168.1.103`) met Podman, en bereikbaar te maken via
`analytics.slpsoftware.nl`. Dit vult het "Analytics/uptime dashboard" open item uit
`operations/production-readiness-checklist.md` in voor het analytics-gedeelte.
Umami draait onder een **eigen dedicated Linux-user** (`umami`) in plaats van onder het
bestaande hoofdaccount, zodat elke self-hosted service (nu en in de toekomst) netjes
geïsoleerd blijft — zie "Waarom een dedicated user" hieronder. Dit patroon kan hergebruikt
worden voor volgende self-hosted diensten op dezelfde Pi('s).
## Vereisten
- Podman is al aanwezig op de Pi (bevestigd door de gebruiker) — systeembreed geïnstalleerd,
dus beschikbaar voor elke user, ook een nieuw aangemaakte.
- `podman-compose` geïnstalleerd voor de `umami`-user: `pip3 install --user podman-compose`
(of via de package manager van je distro, indien beschikbaar) — zie stap 0 hieronder.
- DNS: een A-record voor `analytics.slpsoftware.nl` dat naar het publieke IP van de
reverse-proxy-Pi wijst (dezelfde Pi die ook `test.slpsoftware.nl` afhandelt).
## Waarom een dedicated user
Podman zelf hoeft niet per user geïnstalleerd te worden (het is een systeembreed pakket),
maar rootless Podman-containers erven wél de rechten van de user die ze start. Door Umami
onder een eigen `umami`-user te draaien in plaats van je eigen hoofdaccount:
- kan een kwetsbaarheid in de Umami-container (zelfs bij een container-escape binnen de
rootless-namespace) geen bestanden van je persoonlijke account lezen/schrijven;
- houd je containerstorage, systemd user-services en logs van verschillende self-hosted
diensten netjes gescheiden per user, ook als je Pi qua rekenkracht beperkt is;
- kun je dit patroon straks 1-op-1 hergebruiken voor een volgende self-hosted dienst (bv.
een eigen `uptime`-user, `git`-user, etc.), zonder dat diensten elkaars bestanden kunnen
benaderen.
## 0. Dedicated `umami`-user aanmaken (op de webserver-Pi)
1. Maak de user aan (zonder wachtwoord-login is prima, we loggen in via `sudo -iu umami` of
`su - umami`):
```bash
sudo useradd --create-home --shell /bin/bash umami
sudo passwd -l umami # login met wachtwoord blokkeren, sudo -iu blijft werken
```
2. Controleer dat er een subuid/subgid-range is toegewezen (nodig voor rootless Podman).
Op de meeste moderne distributies (incl. Raspberry Pi OS) gebeurt dit automatisch bij
`useradd`:
```bash
grep umami /etc/subuid /etc/subgid
```
Zie je geen output, voeg dan handmatig een range toe (pas de startwaarde aan als die al
in gebruik is door een andere user):
```bash
sudo usermod --add-subuids 200000-265535 --add-subgids 200000-265535 umami
```
3. Zorg dat de `umami`-sessie blijft "linger-en", zodat rootless Podman-services ook
actief blijven zonder dat de user is ingelogd (nodig voor stap 2 hieronder):
```bash
sudo loginctl enable-linger umami
```
4. Log in als de nieuwe user om de rest van de setup uit te voeren:
```bash
sudo -iu umami
```
## 1. Umami + database opzetten (als de `umami`-user, op de webserver-Pi)
0. Installeer `podman-compose` voor deze user, indien nog niet systeembreed aanwezig:
```bash
pip3 install --user podman-compose
```
1. Maak een map aan, bv. `~/umami/` (dit is nu `/home/umami/umami/`), en kopieer daarin:
- `operations/deployment/umami/podman-compose.yml.example``~/umami/podman-compose.yml`
- `operations/deployment/umami/.env.example``~/umami/.env`
2. Vul in `~/umami/.env` een echte `POSTGRES_PASSWORD` en `APP_SECRET` in (bv. via
`openssl rand -base64 32` voor beide — gebruik twee verschillende waarden).
3. Start de containers:
```bash
cd ~/umami
podman-compose up -d
```
4. **Firewall (ufw) openzetten voor poort 3001.** Pi Main gebruikt `ufw` met als default
policy `deny incoming` — net als poort `3000` (Gitea) al een expliciete regel heeft
voor het interne IP van de reverse-proxy-Pi, heeft `3001` (Umami) dezelfde regel nodig,
anders krijg je bij het testen `curl: (56) Recv failure: Connection reset by peer`
(ook wanneer je *op Pi Main zelf* naar het publieke IP `192.168.1.103` curl't — dat
gaat namelijk nog steeds via de firewall-INPUT-chain, in tegenstelling tot
`localhost`/`127.0.0.1`):
```bash
sudo ufw allow from 192.168.1.102 to any port 3001 proto tcp
sudo ufw reload
```
Controleer met `sudo ufw status verbose` dat er nu een regel voor `3001/tcp` staat,
analoog aan de bestaande regel voor `3000/tcp`.
5. Controleer dat Umami draait en bereikbaar is op het interne netwerk. Let op: de
**externe** poort is `3001`, niet het gebruikelijke `3000` — die poort is op deze Pi al
in gebruik door Gitea (`bind: address already in use` bij het opstarten van de
container als je toch `3000` gebruikt):
```bash
curl http://192.168.1.103:3001/api/heartbeat
```
Dit zou een JSON-antwoord met `"ok"` moeten teruggeven. Krijg je toch een
`Connection reset by peer`, controleer dan eerst of `curl http://localhost:3001/api/heartbeat`
(dus via loopback, buiten de firewall om) wél werkt — zo ja, dan zit het probleem
zeker in de ufw-regel hierboven en niet in Umami/Podman zelf.
> **Let op — na een wijziging aan `podman-compose.yml`/`.env` (bv. een andere poort):**
> `podman-compose up -d` update alléén containers waarvan de configuratie is gewijzigd,
> maar een poortmapping (`ports:`) wordt door Podman **niet** live herladen op een
> bestaande, al aangemaakte container. Heb je `podman-compose up -d` al eerder gedraaid
> met een oude versie van `podman-compose.yml` (bv. met poort `3000` in plaats van
> `3001`), werk dan eerst je lokale `~/umami/podman-compose.yml` bij met de nieuwste
> versie uit dit repository, en draai daarna:
> ```bash
> cd ~/umami
> podman-compose down
> podman-compose up -d
> ```
> `podman-compose down` verwijdert de containers (niet de database-data, die staat in
> een persistent volume) en `up -d` maakt ze opnieuw aan met de bijgewerkte poort/config.
> Dit is enkel nodig als de container al bestond met de oude configuratie; bij een
> eerste, nieuwe `up -d` (of als de vorige poging überhaupt nooit is gestart doordat
> Podman de poort niet kon claimen) is dit niet nodig.
## 2. Automatisch starten na reboot (systemd user service)
Podman-compose start niet vanzelf op na een herstart van de Pi, tenzij je dit expliciet
instelt. Omdat rootless Podman hier al gebruikt wordt, is een systemd **user**-service —
gekoppeld aan de `umami`-user — de eenvoudigste aanpak. De `loginctl enable-linger umami`
uit stap 0.3 is hiervoor al gezet, dus deze service blijft ook draaien zonder dat de
`umami`-user zelf is ingelogd.
1. Log in (of blijf ingelogd) als de `umami`-user: `sudo -iu umami`.
> **Let op — "Failed to connect to bus: No medium found" bij `systemctl --user`:**
> Dit betekent dat er (nog) geen D-Bus/systemd user-sessie draait voor `umami`, meestal
> omdat `sudo -iu umami` geen volledige PAM-login-sessie registreert zoals een echte
> interactieve login dat wel doet — daardoor bestaat `/run/user/<uid>` (en de bus daarin)
> nog niet, zelfs met linger enabled. Los dit als volgt op:
> ```bash
> # Controleer eerst of linger daadwerkelijk actief staat:
> loginctl show-user umami | grep Linger
> # Verwacht: Linger=yes. Staat er Linger=no, herhaal dan:
> sudo loginctl enable-linger umami
> # Start de user-manager expliciet (uid van umami opzoeken met: id -u umami):
> sudo systemctl start user@$(id -u umami).service
> # Log opnieuw in als umami en zet XDG_RUNTIME_DIR expliciet, voor deze sessie:
> sudo -iu umami
> export XDG_RUNTIME_DIR=/run/user/$(id -u)
> ```
> Probeer daarna `systemctl --user daemon-reload` opnieuw. Blijft het misgaan, gebruik dan
> in plaats van `sudo -iu umami` het commando `sudo machinectl shell umami@ /bin/bash` om
> in te loggen — dat registreert wél altijd een volledige sessie inclusief D-Bus, in
> tegenstelling tot `sudo -iu`/`su -`.
2. Maak `~/.config/systemd/user/umami.service` aan:
```ini
[Unit]
Description=Umami analytics (podman-compose)
After=network-online.target
[Service]
WorkingDirectory=%h/umami
ExecStart=/usr/bin/podman-compose up
ExecStop=/usr/bin/podman-compose down
Restart=on-failure
[Install]
WantedBy=default.target
```
3. Activeer en start de service (nog steeds als de `umami`-user):
```bash
systemctl --user daemon-reload
systemctl --user enable --now umami.service
```
## 3. Reverse proxy + SSL (op de reverse-proxy-Pi)
1. Kopieer `operations/deployment/nginx/analytics-nginx.conf.example` (de versie van vóór
certbot: alleen poort 80, geen SSL) naar bv.
`/etc/nginx/sites-available/slpsoftware-analytics.conf` op de reverse-proxy-Pi, en maak
een symlink in `sites-enabled`.
2. Zorg dat het DNS-record voor `analytics.slpsoftware.nl` al actief is, en draai dan:
```bash
sudo certbot --nginx -d analytics.slpsoftware.nl
```
Certbot herschrijft het bestand automatisch met de HTTPS-configuratie en de
HTTP→HTTPS-redirect — zie `operations/deployment/nginx/analytics-nginx.conf.post-certbot.example`
voor hoe het er dan uitziet (referentie, niet zelf kopiëren).
3. Herlaad nginx: `sudo nginx -t && sudo systemctl reload nginx`.
4. Test: open `https://analytics.slpsoftware.nl` in de browser — je zou het
Umami-inlogscherm moeten zien (standaard inloggegevens: `admin` / `umami`, **direct
wijzigen na eerste login**).
## 4. Website registreren in Umami en het website-ID ophalen
1. Log in op `https://analytics.slpsoftware.nl` en wijzig direct het standaardwachtwoord.
2. Ga naar **Settings → Websites → Add website** en vul in:
- Name: `SLP Software` (of naar keuze)
- Domain: het domein van de daadwerkelijke website (bv. `slpsoftware.nl` of
`test.slpsoftware.nl`, afhankelijk van welke omgeving je eerst wilt meten)
3. Na het opslaan toont Umami een **Website ID** (een UUID) — dit heb je nodig voor de
volgende stap.
## 5. Tracking script koppelen aan de website (build-configuratie)
De React-app (`src/components/UmamiAnalytics.tsx`) injecteert het Umami tracking-script
automatisch, mits de volgende twee build-time variabelen zijn ingesteld — beide zijn
**geen secrets** (client-side zichtbaar), dus als Gitea Actions **repository variables**
(niet secrets), net als `VITE_SENTRY_DSN`:
| Variabele | Waarde |
|---|---|
| `VITE_UMAMI_SCRIPT_URL` | `https://analytics.slpsoftware.nl/script.js` |
| `VITE_UMAMI_WEBSITE_ID` | het Website ID uit stap 4 |
Stel deze in via **Gitea → Repository Settings → Actions → Variables**. Zodra beide
bestaan, pakt de eerstvolgende build ze automatisch op; zonder deze variabelen slaat de
app het inladen van het script gewoon over (geen crash, geen tracking).
**Lokaal (`pnpm dev`)**: het tracking-script wordt hier bewust nooit geladen (zie
`UmamiAnalytics.tsx`), zodat lokaal testen de bezoekersstatistieken niet vervuilt. Wil je
dit toch lokaal testen, zet dan tijdelijk beide waarden in `.env.local` (zie
`.env.example`) én verwijder tijdelijk de `import.meta.env.DEV`-check.
## Openstaande handmatige stappen
- [ ] Dedicated `umami`-user aanmaken op Pi Main (incl. subuid/subgid-check en `loginctl enable-linger`).
- [ ] `podman-compose up -d` daadwerkelijk draaien als de `umami`-user op Pi Main.
- [ ] ufw-regel toevoegen voor poort `3001/tcp` (analoog aan de bestaande `3000/tcp`-regel) op Pi Main.
- [ ] Systemd user-service instellen (onder de `umami`-user) voor auto-start na reboot.
- [ ] DNS-record + certbot voor `analytics.slpsoftware.nl` op de reverse-proxy-Pi.
- [ ] Standaard Umami-wachtwoord direct wijzigen na eerste login.
- [ ] Website aanmaken in Umami en het Website ID overnemen.
- [ ] `VITE_UMAMI_SCRIPT_URL` en `VITE_UMAMI_WEBSITE_ID` als Gitea repository variables instellen.
## Vervolgstappen voor toekomstige self-hosted diensten
Dit dedicated-user-patroon (stap 0 hierboven) is bewust generiek gehouden zodat het
hergebruikt kan worden: een volgende self-hosted dienst op dezelfde of een andere Pi kan
op dezelfde manier zijn eigen user krijgen (bv. `useradd --create-home`, subuid/subgid
controleren, `loginctl enable-linger <user>`, eigen `~/.config/systemd/user/<dienst>.service`),
zodat diensten onderling geïsoleerd blijven zonder dat dit ten koste gaat van de
al beperkte rekenkracht van een Raspberry Pi (rootless Podman zelf blijft immers
systeembreed gedeeld, alleen de user-context verandert per dienst).
@@ -20,5 +20,5 @@
- **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~~**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 - ~~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 and uptime dashboard tool~~**Resolved**: self-hosted Umami (Podman on Pi Main) + UptimeRobot decided; see `monitoring-setup.md` and the new `umami-setup.md`. Remaining manual follow-ups: actually deploy the Umami containers, set up `analytics.slpsoftware.nl` DNS/SSL, create the UptimeRobot monitor, and register 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`)
+2
View File
@@ -1,11 +1,13 @@
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 { UmamiAnalytics } from './UmamiAnalytics';
import { SentryTestButton } from './SentryTestButton'; import { SentryTestButton } from './SentryTestButton';
export function RootLayout({ children }: { children: ReactNode }) { export function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<div className="font-sans min-h-screen bg-bg text-text"> <div className="font-sans min-h-screen bg-bg text-text">
<UmamiAnalytics />
<Nav /> <Nav />
{children} {children}
<Footer /> <Footer />
+43
View File
@@ -0,0 +1,43 @@
import { useEffect } from 'react';
const SCRIPT_ELEMENT_ID = 'umami-analytics-script';
/**
* Injects the self-hosted Umami tracking script (see
* aidlc-docs/features/react-frontend/operations/monitoring/umami-setup.md) when both
* VITE_UMAMI_SCRIPT_URL and VITE_UMAMI_WEBSITE_ID are configured at build time.
*
* Silently does nothing if either variable is missing (e.g. local development, or a
* build for which the Umami instance/website has not been set up yet) safe default,
* no crash on missing config, mirroring the existing Sentry DSN pattern.
*
* Skipped during local development (`pnpm dev`) by default so local testing does not
* pollute production/test visitor analytics.
*/
export function UmamiAnalytics() {
useEffect(() => {
const scriptUrl = import.meta.env.VITE_UMAMI_SCRIPT_URL;
const websiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (import.meta.env.DEV || !scriptUrl || !websiteId) {
return;
}
if (document.getElementById(SCRIPT_ELEMENT_ID)) {
return;
}
const script = document.createElement('script');
script.id = SCRIPT_ELEMENT_ID;
script.src = scriptUrl;
script.defer = true;
script.setAttribute('data-website-id', websiteId);
document.head.appendChild(script);
return () => {
document.getElementById(SCRIPT_ELEMENT_ID)?.remove();
};
}, []);
return null;
}
@@ -0,0 +1,50 @@
import { render, cleanup } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { UmamiAnalytics } from '../UmamiAnalytics';
const SCRIPT_ID = 'umami-analytics-script';
afterEach(() => {
cleanup();
document.getElementById(SCRIPT_ID)?.remove();
vi.unstubAllEnvs();
});
describe('UmamiAnalytics', () => {
it('does not inject a script tag when the env vars are missing', () => {
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', '');
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '');
render(<UmamiAnalytics />);
expect(document.getElementById(SCRIPT_ID)).toBeNull();
});
it('does not inject a script tag during local development, even if configured', () => {
vi.stubEnv('DEV', true);
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://analytics.slpsoftware.nl/script.js');
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-website-id');
render(<UmamiAnalytics />);
expect(document.getElementById(SCRIPT_ID)).toBeNull();
});
it('injects the tracking script with the configured URL and website id when built for test/production', () => {
vi.stubEnv('DEV', false);
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://analytics.slpsoftware.nl/script.js');
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-website-id');
render(<UmamiAnalytics />);
const script = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
expect(script).not.toBeNull();
expect(script?.src).toBe('https://analytics.slpsoftware.nl/script.js');
expect(script?.getAttribute('data-website-id')).toBe('test-website-id');
});
it('renders nothing visible', () => {
const { container } = render(<UmamiAnalytics />);
expect(container).toBeEmptyDOMElement();
});
});
+4
View File
@@ -1,6 +1,10 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
/** Umami tracking script URL (self-hosted), e.g. https://analytics.slpsoftware.nl/script.js */
readonly VITE_UMAMI_SCRIPT_URL?: string;
/** Umami website ID, created manually in the Umami dashboard for this site. */
readonly VITE_UMAMI_WEBSITE_ID?: string;
/** Sentry DSN (Data Source Name) for client-side error reporting. Not a secret — safe to expose in the client bundle. */ /** 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; 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. */ /** Build-time deployment environment tag ('test' | 'production' | undefined). Used to hide dev/test-only UI (e.g. SentryTestButton) from production builds. */