Catches up the wiki with several pieces of work accumulated during this session. INGEST: TRACCAR_MAPS_ARCHITECTURE.md - raw/TRACCAR_MAPS_ARCHITECTURE.md (source doc, read-only). - wiki/sources/traccar-maps-architecture.md — TL;DR + key claims + notable quotes + TRM divergences (PostGIS-native GeoJSON, rAF coalescer, Zustand, longer trail, racing sprite set). - wiki/concepts/maps-architecture.md — distilled patterns for the SPA's map subsystem: singleton MapLibre + side-effect-only Map* components + two GeoJSON sources + style-swap mapReady gate + sprite preload + WS- to-map data flow (with rAF coalescer) + geofence editing + camera control trio. - wiki/entities/react-spa.md — corrected the "talks exclusively to Directus" contradiction with [[live-channel-architecture]] (SPA connects to two endpoints — Directus + Processor); locked stack (raw MapLibre over react-map-gl, Zustand over Redux); added Auth section. - wiki/concepts/live-channel-architecture.md — single sentence cross- referencing [[maps-architecture]] for consumer-side throughput discipline. - index.md — Sources + Concepts entries. SYNTHESIS: processor-ws-contract - wiki/synthesis/processor-ws-contract.md — wire-level spec for the live-position WebSocket: endpoint, transport, auth handshake, subscribe/snapshot/streaming/unsubscribe protocol, reconnect, multi- instance behaviour, connection limits, versioning, open questions. Implementation-agnostic; the producer is cookie-name-agnostic so the spec doesn't pin to a specific Directus auth mode. - index.md — Synthesis entry. AUTH-MODE REALIGNMENT (cookie -> session) - SPA implementation surfaced that Directus SDK 'cookie' mode doesn't survive a hard reload cleanly. Switched the SPA to 'session' mode (separate commit in trm/spa). Wiki updates here: - wiki/entities/react-spa.md §Auth pattern — describes session mode (single httpOnly session cookie, no separate access token, no /auth/refresh dance). Added "Mode choice context" note. - wiki/synthesis/processor-ws-contract.md §Auth handshake — emphasises the producer is cookie-name-agnostic; reframed "Cookie refresh while connected" as "Session expiry while connected". Plus all the chronological log.md entries documenting the above plus Phase 1.5 planning, SPA Phase 1 planning, and stage verify+seed work from earlier in the session. Skipped from this commit: .claude/agent-memory/* (user-local agent state, not project content); .gitignore (already-modified by user outside this session's scope). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6.3 KiB
title, type, created, updated, sources, tags
| title | type | created | updated | sources | tags | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| React SPA | entity | 2026-04-30 | 2026-05-02 |
|
|
React SPA
The end-user experience for operators and external participants. Single React application, role-based views, served as a static bundle.
Why a separate SPA (not just the Directus admin UI)
The Directus admin UI is generic CRUD over collections — right for back-office editing and operators who think in records, wrong for end users who think in domain concepts. A dedicated SPA delivers:
- Domain-shaped UX — screens organized around the user's mental model.
- Independent deployment — front-end ships on its own cadence.
- Targeted access control — public/partner-facing routes without exposing the admin surface.
- Mobile/offline tuning — bundles tuned for the actual user environment.
Single app, role-based views
One application serves multiple user types via role-based routing and conditional UI. All users authenticate through directus; the SPA receives a JWT, reads role, and renders appropriate navigation/screens. Splitting into multiple apps is only justified when user populations are genuinely disjoint (public site vs. authenticated console) or when bundle size for one audience harms another.
Data access pattern
The SPA talks to two endpoints, one per plane (see live-channel-architecture):
- directus — REST/GraphQL via
@directus/sdk, plus Directus's WebSocket for business-plane events. Auth, schema, all CRUD on entries / vehicles / devices / geofences / etc. - processor — its own WebSocket endpoint, exclusively for the live position firehose. Authenticated by the same Directus-issued credential the SPA already holds; authorization delegated to Directus once at subscribe time.
Never talks to tcp-ingestion, redis-streams, or postgres-timescaledb directly. The two-endpoint split exists because Directus's WebSocket subscriptions only fire for writes through its own ItemsService — Processor's direct-to-DB position writes are invisible to it. See live-channel-architecture for why this is the architecturally honest answer rather than a workaround.
Stack
- Vite + React + TypeScript — SPA build, no SSR.
- TanStack Router — file-based, type-safe routes.
- TanStack Query — Directus REST: caching, invalidation, refetch on focus.
@directus/sdk— typed access for REST + Directus's WebSocket.- MapLibre GL JS — WebGL map renderer. Used raw, not via
react-map-gl— the declarative wrapper fights the imperativesetDatapattern that's the whole point of the architecture (see maps-architecture). maplibre-google-maps(optional, runtime-config-gated) — protocol adapter that lets MapLibre consume Google's official Map Tiles API when an operator-provided API key is present.@mapbox/mapbox-gl-draw— geofence editor (polygon / line / trash modes) wrapped to look native to MapLibre.- Zustand — high-frequency live state (positions, trails, selection). Granular subscribers; chosen over Redux specifically because Redux's dispatch + selector cascade is the most likely cause of the lag observed in production traccar-maps-architecture deployments.
- shadcn/ui + Tailwind — UI primitives.
- react-hook-form + Zod — forms and validation.
Covers form-heavy admin screens and real-time map dashboards without architectural changes between them.
Auth pattern
Same-domain session cookie via reverse proxy. One origin serves the SPA, Directus, and Processor's WebSocket endpoint — Vite's server.proxy in dev, Traefik (or whatever fronts the deploy stack) in stage/prod.
- Login uses the Directus SDK in session mode (
authentication('session', { credentials: 'include' })). Directus issues anhttpOnly/Secure/SameSite=Laxsession cookie; the cookie itself carries the session — there is no separate in-memory access token to manage and no/auth/refreshdance. - Reload survives cleanly: the browser still has the cookie,
/users/mereturns the user without any client-side state. - No JavaScript ever reads or writes the cookie (it's
httpOnly), so XSS cannot exfiltrate it. - WebSocket handshake: same-origin means the browser sends the session cookie automatically with the upgrade request. Processor reads it on the upgrade, validates against Directus's
/users/me, and uses the resulting user identity for subscription authorization. See live-channel-architecture and processor-ws-contract.
This requires the proxy to serve everything under one origin (path-based or single subdomain) — separate subdomains break cookie flow.
Mode choice context. Directus's SDK also supports 'cookie' mode (refresh cookie + in-memory access token). It works while the SDK is alive in memory but doesn't survive a hard reload cleanly because there's no access token to retry /users/me against, and the refresh-then-read sequence is order-sensitive. 'session' mode collapses that to one credential — the session cookie — and is the right default for an SPA that wants reload-survives behaviour.
Real-time rendering
The full pattern lives at maps-architecture; the headlines:
- MapLibre is a singleton held in a module-level variable, attached to a detached
<div>that React refs mount/unmount per page. WebGL context survives navigation. - Two GeoJSON sources for live positions: clustered non-selected, unclustered always-on-top selected. Updates flow through
setData, not DOM marker manipulation. - rAF coalescer at the WS boundary. Incoming position messages buffer per-device; one
requestAnimationFrametick flushes the latest snapshot to the Zustand store. Without this, per-message dispatches cascade through selectors andsetDataat every position arrival — the failure mode traccar-maps-architecture exhibits. - Per-device bounded ring buffers for trail history. Default 200 points per device, configurable. The throttle controls visual cadence; trails are never lossy.
- High-frequency tabular updates (live leaderboards, event feeds) — same Zustand store, separate component subtrees so the map's re-renders don't ripple into the leaderboard and vice versa.
Failure mode
UI unavailable → back-end unaffected. See failure-domains.