From 10c548b01078853f915c04465149e57a0e5ed98c Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 2 May 2026 18:35:01 +0200 Subject: [PATCH] Add SPA service block, runtime config override, README updates - compose.yaml: new spa service. Image trm/spa:${SPA_TAG:-main}. expose: 80 (internal-only; reverse proxy fronts it). Read-only volume mount ${SPA_CONFIG_FILE:-./spa-config.json} -> /usr/share/nginx/html/config.json for the runtime-config override. Healthcheck via wget against localhost. - spa-config.example.json: dev-default-equivalent file with env=stage. Operators copy to spa-config.json and edit per environment. - .gitignore: spa-config.json never committed (per-environment). - .env.example: new spa section documenting SPA_TAG and SPA_CONFIG_FILE; reframes the directus internal-only note as a shared design (SPA also internal-only). - README.md: - Services in the stack: SPA moved from Planned to Currently. - Step 8 in first-deploy checklist: "Verify the SPA loads". - Network model: spell out the four routes the proxy must wire (/api, /ws-business, /ws-live, default -> SPA). Same-origin non-negotiable callout. - New "Runtime config override (SPA)" section: copy/edit/mount workflow + when not to use absolute URLs. --- .env.example | 25 ++++++++++++++++++++++ .gitignore | 4 ++++ README.md | 47 +++++++++++++++++++++++++++++++++++------ compose.yaml | 34 ++++++++++++++++++++++++++--- spa-config.example.json | 6 ++++++ 5 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 spa-config.example.json diff --git a/.env.example b/.env.example index c1b147c..9578f7c 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,31 @@ DIRECTUS_CORS_ORIGIN=false # for local debugging. LOG_STYLE=json +# --------------------------------------------------------------------- +# spa (React front-end) +# --------------------------------------------------------------------- + +# Image tag to pull. `main` auto-tracks the latest commit on the main branch. +# In production, pin to a specific commit SHA for reproducibility. +# Example: SPA_TAG=ab12cd3 +SPA_TAG=main + +# Path on the host to the runtime config file. Mounted read-only into the +# SPA container at /usr/share/nginx/html/config.json. Defaults to a sibling +# file in this repo; create it from spa-config.example.json before first +# deploy: +# cp spa-config.example.json spa-config.json +# # edit env to "stage" or "prod"; add googleMapsKey if operators have one +# In Portainer, mount via a host-path volume that points at the file. +SPA_CONFIG_FILE=./spa-config.json + +# Note: the SPA is intentionally NOT host-published. nginx serves the +# bundle on port 80 inside the trm_default Compose network only, +# reachable as `http://spa:80` from the same reverse proxy that fronts +# directus. The proxy routes `/api` + `/ws-business` + `/ws-live` to the +# backends and everything else to the SPA — same-origin is required for +# the cookie-based auth flow to work end-to-end. + # --------------------------------------------------------------------- # Shared # --------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore index 4d4b03a..9283952 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .env.local .env.*.local *.log + +# Per-environment SPA runtime config — never commit; copy spa-config.example.json +# to spa-config.json on each operator's host and keep it out of git. +spa-config.json diff --git a/README.md b/README.md index ceadf46..cf46cef 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,7 @@ Currently: - **postgres** — PostgreSQL 16 + TimescaleDB + PostGIS via `timescale/timescaledb-ha`. Schema authority is split: the `positions` hypertable is owned by [`trm/processor`](https://git.dev.microservices.al/trm/processor)'s migration runner; everything else is owned by `trm/directus` via its snapshot YAML. Internal-only, persisted via named volume. - **processor** — consumes telemetry from Redis, writes to Postgres. Image built by [`trm/processor`](https://git.dev.microservices.al/trm/processor)'s Gitea workflow. - **directus** — business-plane API + admin UI + schema authority. Image built by [`trm/directus`](https://git.dev.microservices.al/trm/directus)'s Gitea workflow. Boot pipeline runs db-init pre-schema → bootstrap → schema-apply → db-init post-schema → start; first boot on a fresh DB takes ~60–90 s. - -Planned (will be added as they land): - -- **react-spa** — front-end SPA (static bundle, served via reverse proxy). +- **spa** — React + TypeScript front-end (static bundle served by nginx). Image built by [`trm/spa`](https://git.dev.microservices.al/trm/spa)'s Gitea workflow. Internal-only; the reverse proxy routes the public domain root to it. Runtime config is overridden via a host-path volume mount onto `/usr/share/nginx/html/config.json` — one image, multiple environments. See `../docs/wiki/` for the full architecture. @@ -75,7 +72,9 @@ Optional: | Variable | When to set | |---|---| | `TCP_INGESTION_PORT` | Change if `5027` is already in use on the host. GPS devices need a real host port — this one is published. | -| `DIRECTUS_CORS_ENABLED` / `DIRECTUS_CORS_ORIGIN` | Set to `true` and the SPA's origin URL once the React SPA is deployed. | +| `SPA_TAG` | A specific commit SHA for production reproducibility; `main` for stage's auto-tracking. | +| `SPA_CONFIG_FILE` | Path on the host to the SPA's runtime config file (default `./spa-config.json`). See "Runtime config override" below. | +| `DIRECTUS_CORS_ENABLED` / `DIRECTUS_CORS_ORIGIN` | Leave disabled — same-origin proxy means CORS isn't in play. Enable only if you ever need cross-origin access from a partner integration. | > **Directus is internal-only by design.** It listens on `8055` inside the `trm_default` Compose network and is **not** published to the host. Wire a reverse proxy (Traefik / Caddy / nginx) on the host or attached to the network and forward your public domain to `http://directus:8055`. The proxy handles TLS, optional WAF / rate-limit, and any auth-header rewriting. Set `DIRECTUS_PUBLIC_URL` to the proxy-served URL (e.g. `https://directus.stage.example.com`) so password-reset emails and OAuth redirects work. The dev compose in `trm/directus` does host-publish `8055` for local iteration; this stage/prod stack deliberately does not. @@ -131,6 +130,17 @@ Admin UI → **Settings → Data Model**. You should see 12 user collections: `o > **Schema-as-code reminder:** do NOT add or remove collections via the admin UI on this stage instance. Schema changes flow through `trm/directus` (admin-UI edit → `pnpm run schema:snapshot` → commit → CI dry-run → image rebuild → redeploy). Edits made directly here will be DROPPED on the next image rebuild — schema-apply enforces the committed snapshot. See `docs/wiki/entities/directus.md` "destructive-apply hazard." +### 8. Verify the SPA loads + +Browse to the public root (e.g. `https://stage.trmtracking.org/`). Expected behaviour: + +- Unauthenticated → redirected to `/login`. +- Submit valid Directus credentials → redirected to `/`. Operator sees the placeholder home page (live-monitoring map lands in Phase 2). +- Hard refresh on `/` while authenticated stays on `/` (session cookie carries the session — no client-side state needed across reloads). +- Sign out → redirect back to `/login`. Refresh stays on `/login`. + +If `/config.json` returns a 404 or a malformed JSON, the SPA renders an explicit "Runtime config error" panel listing what's missing — that's a sign the volume mount didn't pick up the override file. Re-check the `SPA_CONFIG_FILE` path and that the file is reachable from inside the container. + --- ## Deploy via Portainer (Repository Stack) @@ -177,11 +187,34 @@ To pin a specific build for production, set the relevant `*_TAG` variable in `.e ## Network model - One internal Compose network (`trm_default`). -- **Redis**, **postgres**, **directus**, and **processor** are not bound to host ports — only reachable from other services in the stack via service-name DNS (`redis://redis:6379`, `postgres:5432`, `http://directus:8055`). +- **Redis**, **postgres**, **directus**, **processor**, and **spa** are not bound to host ports — only reachable from other services in the stack via service-name DNS (`redis://redis:6379`, `postgres:5432`, `http://directus:8055`, `http://spa:80`). - **tcp-ingestion**'s TCP port (`5027` by default) is bound to the host so GPS devices can reach it. This is the only host-published port in the stack. -- **Directus admin UI / API access** goes through a reverse proxy (Traefik / Caddy / nginx) on the host or attached to the `trm_default` network. The proxy handles TLS, public-DNS routing, and any WAF / rate-limit / auth-header policy. Wire your proxy to forward your domain to `http://directus:8055`. The proxy itself is not part of this stack — add it as a sibling stack in Portainer or run it on the host. +- **HTTP/WS access** goes through a reverse proxy (Traefik / Caddy / nginx) on the host or attached to the `trm_default` network. **Same-origin is non-negotiable** — the SPA's session cookie and the Processor's WebSocket upgrade both rely on browser-attached cookies that only flow within one origin. Route everything under one public domain: + - `/api/*` → `http://directus:8055/...` + - `/ws-business` → `ws://directus:8055/websocket` + - `/ws-live` → `ws://processor:` (Phase 1.5; default `8081`) + - everything else → `http://spa:80` (the SPA's nginx falls through to `index.html` for any unmatched path so client-side routes resolve) + + The proxy itself is not part of this stack — add it as a sibling stack in Portainer or run it on the host. - Other Redis / Postgres instances on the same host can keep using their default ports freely; this stack does not collide with them. The default postgres-on-host you may already have running on `5432` is untouched — this stack's postgres is internal-only. +## Runtime config override (SPA) + +The SPA reads `/config.json` at first paint to discover its backend URLs and feature toggles. The image bakes a default suitable for dev (relative paths matching the Vite proxy); stage and prod override via a host-path volume mount. + +1. Copy the example file: + ```bash + cp spa-config.example.json spa-config.json + ``` +2. Edit `spa-config.json`: + - Set `env` to `"stage"` or `"prod"`. + - Add `googleMapsKey` if operators have a Google Maps Tiles API key (optional; if absent the Google tile sources are hidden in the UI). + - URLs stay relative (`/api`, `/ws-live`, `/ws-business`) — the reverse proxy routes them to the right backends. Only switch to absolute URLs if the SPA ever needs to call cross-origin (which breaks cookie auth — avoid). +3. Keep `spa-config.json` outside source control. The repo's `.gitignore` already excludes it. +4. In Portainer, ensure `SPA_CONFIG_FILE` points at the on-host path of the file. Stack redeploy mounts it read-only into the SPA container. + +Changing the file does not require a SPA image rebuild — just `docker compose up -d spa` (or a stack redeploy in Portainer) to pick up the new file. The SPA refetches `/config.json` on every page load. + ## Environment variables See `.env.example` for the documented set with defaults and explanations. diff --git a/compose.yaml b/compose.yaml index b44fcec..acc5c7d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -213,10 +213,38 @@ services: retries: 3 # ------------------------------------------------------------------- - # Future services land here: - # - react-spa: front-end (static, served via nginx or Caddy) - # See ../docs/wiki/ for the platform architecture. + # spa — React + TypeScript single-page app for end-user operators. + # Built by git.dev.microservices.al/trm/spa's Gitea workflow. + # + # The image is nginx serving a static bundle. The bundle's runtime + # config (Directus URL, Processor WS URL, optional Google Maps key, + # env label) is read at first paint from /config.json — overridable + # via the volume mount below without rebuilding the image. + # + # Internal-only on the stack (expose: 80). The reverse proxy that + # fronts directus also routes the SPA at the public domain root. + # Same-origin is non-negotiable: the SPA's auth cookie + the + # processor's WebSocket upgrade both rely on browser-attached cookies + # that only flow within one origin. # ------------------------------------------------------------------- + spa: + image: git.dev.microservices.al/trm/spa:${SPA_TAG:-main} + expose: + - '80' + volumes: + # Override the baked-in dev config with the per-environment one. + # Operators copy spa-config.example.json to spa-config.json (or + # whatever ${SPA_CONFIG_FILE} points at) before first deploy and + # edit the URLs / env label / optional Google Maps key. Read-only + # mount — the container can't accidentally write to its own config. + - ${SPA_CONFIG_FILE:-./spa-config.json}:/usr/share/nginx/html/config.json:ro + restart: unless-stopped + healthcheck: + test: ['CMD-SHELL', 'wget -qO- http://localhost/ || exit 1'] + interval: 30s + timeout: 5s + start_period: 5s + retries: 3 volumes: redis-data: diff --git a/spa-config.example.json b/spa-config.example.json new file mode 100644 index 0000000..25fe414 --- /dev/null +++ b/spa-config.example.json @@ -0,0 +1,6 @@ +{ + "directusUrl": "/api", + "liveWsUrl": "/ws-live", + "businessWsUrl": "/ws-business", + "env": "stage" +}