Web App Overview
The Apollon web app is a Next.js 16 application that provides a real-time GPS tracking dashboard for logistics and cargo operations. It connects to the Rust API via REST proxy routes (GraphQL under the hood) and WebSocket for real-time data.
Tech Stack
| Technology | Purpose |
|---|---|
| Next.js 16 | React framework with App Router + Turbopack |
| React 19 | UI rendering |
| TypeScript | Type-safe application code |
| Tailwind CSS 4 | Utility-first styling |
| shadcn/ui | Accessible UI components (Button, Card, Input, Select, Badge, etc.) |
| MapLibre GL | Interactive maps with multiple tile providers (Carto, OpenFreeMap) |
| Zustand | Client-side state management |
| Motion | Spring-based animations (speed overlay) |
| @elcto/apollon-api | Shared GraphQL client, WebSocket client, types |
| @elcto/apollon-ui | Shared components (Map, MapMarker, MapRoute, Spinner, ThemeToggle, AIS Vessel Markers) |
| @elcto/apollon-logger | Structured logger with Sentry integration |
| @sentry/nextjs | Error tracking (client + server + edge) |
Data Flow
Browser → fetch('/api/*') → Next.js API Route → gql() → Rust API /v1/gql
Browser → WebSocket → Rust API /v1/ws (direct, no proxy)
- REST Proxy: Next.js API routes call
gql()from@elcto/apollon-api, transforming GraphQL responses to REST - WebSocket: Browser connects directly to Rust API for real-time GPS, speed, trip, AIS vessel, and watchlist alert data via channels
- No GraphQL client in browser — frontend uses only
fetch()against/api/*proxy routes
Project Structure
platform/web/
├── src/
│ ├── app/
│ │ ├── globals.css # Tailwind + shadcn theme
│ │ ├── layout.tsx # Root layout (Roboto font)
│ │ ├── (app)/ # Main app with navbar
│ │ │ ├── layout.tsx # Navbar (Trips, Clients, GPS, Features ▾) + ThemeToggle
│ │ │ ├── page.tsx # Dashboard
│ │ │ ├── gps/
│ │ │ │ ├── page.tsx # Server: reads config, renders client
│ │ │ │ └── gps-client.tsx # Client: GPS tracker with WS
│ │ │ ├── trips/
│ │ │ │ ├── page.tsx # Trip list (filterable)
│ │ │ │ ├── new/page.tsx # Create trip
│ │ │ │ ├── [id]/page.tsx # Trip detail
│ │ │ │ ├── [id]/edit/page.tsx # Edit trip
│ │ │ │ └── categories/page.tsx
│ │ │ ├── clients/
│ │ │ │ ├── page.tsx # Client list
│ │ │ │ ├── new/page.tsx # Create client
│ │ │ │ └── [id]/page.tsx # Client detail
│ │ │ └── feature/
│ │ │ ├── watchlist/
│ │ │ │ ├── page.tsx # Watchlist entries list
│ │ │ │ ├── new/page.tsx # Create entry
│ │ │ │ ├── [id]/page.tsx # Entry detail/edit
│ │ │ │ └── settings/page.tsx # Global settings + categories
│ │ │ └── vessels/
│ │ │ ├── page.tsx # Vessels registry (Registry + History Log tabs)
│ │ │ └── [mmsi]/page.tsx # Per-vessel detail
│ │ ├── (overlay)/ # Overlay layout (no nav)
│ │ │ └── overlay/
│ │ │ ├── speed/
│ │ │ │ ├── page.tsx # Server wrapper
│ │ │ │ └── speed-client.tsx # Client: speed display
│ │ │ ├── map/
│ │ │ │ ├── page.tsx # Server wrapper
│ │ │ │ └── map-client.tsx # Client: live map + AIS
│ │ │ ├── compass/
│ │ │ │ ├── page.tsx # Server wrapper
│ │ │ │ └── compass-client.tsx # Client: compass display
│ │ │ ├── data/
│ │ │ │ ├── page.tsx # Server wrapper
│ │ │ │ └── data-client.tsx # Client: compass + speed bar
│ │ │ ├── pegel/
│ │ │ │ ├── page.tsx # Server wrapper
│ │ │ │ └── pegel-client.tsx # Client: water level display
│ │ │ └── watchlist/
│ │ │ ├── page.tsx # Server wrapper
│ │ │ └── watchlist-client.tsx # Client: alert stars/icon
│ │ └── api/ # REST proxy routes
│ │ ├── gps/ # GPS endpoints
│ │ ├── trips/ # Trip CRUD
│ │ ├── clients/ # Client CRUD
│ │ ├── categories/ # Category CRUD
│ │ └── watchlist/ # Watchlist CRUD + sounds + settings
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ ├── pegel-widget.tsx # Shared water level widget
│ │ └── watchlist-stars.tsx # Watchlist alert stars/icon overlay
│ ├── hooks/
│ │ ├── use-spring.ts # Spring animation hook
│ │ ├── use-interpolated-position.ts # Dead reckoning GPS interpolation
│ │ ├── use-pegel.ts # Pegelonline water level polling
│ │ └── use-watchlist.ts # Watchlist WebSocket alert state
│ └── lib/
│ ├── api.ts # Client-side fetch wrapper
│ └── utils/ # Formatting helpers
├── components.json # shadcn configuration
├── next.config.ts
├── postcss.config.mjs
└── tsconfig.json
Route Groups
The app uses Next.js route groups to provide different layouts:
(app)— Main application with navigation bar and standard layout. All CRUD pages live here, including the AIS Vessels registry page (Registry + History Log tabs, with a per-vessel detail view backed by thevesselsregistry queries/endpoints).(overlay)— Minimal layout without navigation. Used for embeddable overlays: speed display, live map, compass, pegel, and watchlist alerts (OBS browser source).
Development
just dev-web # Start dev server (port 5001)
just watch-web # Same as dev-web
just build-web # Production build
just typecheck-web # TypeScript check
just lint-web # ESLint
Configuration
Runtime environment variables (server-side, Docker-friendly):
| Variable | Default | Description |
|---|---|---|
APOLLON_API_URL | http://localhost:3000 | Rust API base URL |
APOLLON_WS_URL | (derived from API URL) | WebSocket URL |
APOLLON_WS_TOKEN | (empty) | WebSocket auth token |
APOLLON_API_KEY | (empty) | Server-only API key for GraphQL |
SENTRY_DSN | (empty) | Sentry DSN (server-side) |
NEXT_PUBLIC_SENTRY_DSN | (empty) | Sentry DSN (client-side) |
NODE_ENV | development | Node.js environment |
PORT | 5001 | Dev server port |
Legacy NEXT_PUBLIC_* variables are supported as fallbacks but APOLLON_* variables take precedence and are read at runtime (not embedded at build time).
Runtime Config Pattern
Server-Component page.tsx reads env vars via @/lib/config and passes them as props to Client-Components (same pattern as Lumio):
// page.tsx (Server Component)
import { getWsUrl, getWsToken } from '@/lib/config';
import { MapOverlayClient } from './map-client';
export default function MapOverlayPage() {
return <MapOverlayClient wsUrl={getWsUrl()} wsToken={getWsToken()} />;
}
WebSocket Client
The createWebSocket() function from @elcto/apollon-api provides:
- Auto-reconnect with exponential backoff (3s base, max 30s)
- Token authentication via
?token=query parameter - JSON message parsing with type routing
- Clean disconnect with intentional close detection
import { createWebSocket } from '@elcto/apollon-api/clients';
const ws = createWebSocket('/v1/ws', {
wsUrl,
token: wsToken,
channels: ['speed', 'gps', 'watchlist'],
autoReconnect: true,
maxReconnectAttempts: 10,
});
ws.onMessage((data) => {
const msg = data as { type: string; data: unknown };
if (msg.type === 'speed') {
console.log('Speed:', msg.data);
}
if (msg.type === 'gps') {
console.log('GPS:', msg.data);
}
if (msg.type === 'vessels') {
console.log('AIS Vessels:', msg.data);
}
});