Frontend Dashboard
Folder: frontend/
A SvelteKit + Tailwind dashboard that gives operators a live view of each ship and lets them override the dive detector's automation. Served from a Node container on port 3000.
At a glance
For each ship the dashboard shows:
- A streaming state pill (Streaming / Off air / Unknown).
- An ROV in-water pill with the current depth.
- An OBS health pill (OK / error with tooltip / unknown).
- A "Force ON" indicator pill when the manual override is active.
- An embedded LiveLink player (iframe) when the ship has a configured stream ID — see Stream embed below.
- The ship's position, both as text (
lat · lon) and on an interactive map widget with selectable bathymetry layers. - A ROV telemetry table — ROV latitude, ROV longitude, depth.
- Two toggles:
- Automatic streaming — when on, the detector drives OBS to
match dive state. When off, OBS is held stopped on
FeedOffline. - Force stream ON — manual override; wins over the auto toggle.
- Automatic streaming — when on, the detector drives OBS to
match dive state. When off, OBS is held stopped on
- An optional External link button that opens a configured URL in a new tab — see External link below.
The dashboard polls its own /api/state endpoint every 3 seconds.
Architecture
Browser ──HTTPS──▶ SvelteKit server ──ioredis──▶ Redis
/api/state (reads sensors,
/api/ships/:ship/auto-stream state, config)
/api/ships/:ship/force-stream
The browser never speaks Redis directly. SvelteKit's server-side routes
(+server.ts) use ioredis to bridge HTTP/JSON ↔ Redis. The same
process serves the static SvelteKit build and the API endpoints.
Redis keys
The dashboard interacts with Redis in two directions.
Reads
| Key | Display |
|---|---|
{ship}:rov:ctd:inwater |
In-water pill |
{ship}:rov:mbari:depth |
In-water pill + telemetry table |
{ship}:ship:gps:lat |
Position line + map marker |
{ship}:ship:gps:lon |
Position line + map marker |
{ship}:rov:position:lat |
Telemetry table |
{ship}:rov:position:lon |
Telemetry table |
{ship}:state:streaming |
Streaming pill |
{ship}:state:obs_ok |
OBS pill tone |
{ship}:state:obs_last_error |
OBS pill tooltip |
{ship}:config:auto_stream |
Auto toggle state |
{ship}:config:force_stream |
Force toggle state |
Each ship's state is read in a single Redis pipeline per /api/state
call.
Writes
| Endpoint | Key written |
|---|---|
POST /api/ships/:ship/auto-stream |
{ship}:config:auto_stream |
POST /api/ships/:ship/force-stream |
{ship}:config:force_stream |
Each accepts { "enabled": true|false }. The detector reads these keys
on its next poll and adjusts behaviour accordingly — see
Dive Detector for the control table.
File layout
frontend/
├── src/
│ ├── app.html
│ ├── app.css # Tailwind v4 entry
│ ├── lib/
│ │ ├── types.ts # ShipState, DashboardState, SHIPS
│ │ ├── components/
│ │ │ ├── ShipCard.svelte # Per-ship card layout
│ │ │ ├── MapWidget.svelte # Leaflet map + layer switcher
│ │ │ ├── StatusPill.svelte # Coloured status pill
│ │ │ └── LabeledToggle.svelte # Reusable on/off switch
│ │ └── server/
│ │ └── redis.ts # ioredis singleton + accessors
│ └── routes/
│ ├── +layout.svelte
│ ├── +page.server.ts # Initial SSR load
│ ├── +page.svelte # Dashboard page
│ └── api/
│ ├── state/+server.ts # GET dashboard state
│ ├── ships/[ship]/auto-stream/+server.ts # POST toggle
│ └── ships/[ship]/force-stream/+server.ts # POST toggle
├── static/
│ └── favicon.svg
└── package.json
Stream embed
Each ship card includes a 16:9 responsive iframe embedding the LiveLink
player for that ship. The iframe is only rendered when the ship has a
streamId configured in src/lib/types.ts:
export const LIVELINK_BASE = 'http://134.89.5.10:5080/LiveLink/play.html';
export const SHIPS: readonly Ship[] = [
{
id: 'rcsn',
name: 'R/V Rachel Carson',
streamId: 'm5HAGhtVdwGRgLy617117381693925'
},
{
id: 'dpkd',
name: 'R/V David Packard'
// streamId not yet configured — card hides the embed.
}
];
The resulting iframe URL is ${LIVELINK_BASE}?id=${streamId}. To update
a ship's stream, edit streamId and rebuild the frontend container.
Mixed-content avoidance via Caddy proxy
LIVELINK_BASE is a relative URL (/LiveLink/play.html) so the
iframe always loads from the dashboard's own origin. The Caddy reverse
proxy has a handle /LiveLink/* block that forwards those requests to
the real LiveLink server at
http://teledistributor.shore.mbari.org:5080/LiveLink/... — see the
Caddyfile at the repo root.
The reason for this indirection is that modern browsers block HTTP iframes loaded inside an HTTPS page (mixed-content). Routing through Caddy lets the browser see only same-origin HTTPS while Caddy handles the HTTP hop server-side. The same prefix covers anything the player itself fetches (player JS, HLS chunks, etc.) so everything keeps working without further configuration.
If LiveLink ever moves to HTTPS, you can either keep the proxy block
(works fine either way) or switch LIVELINK_BASE to the absolute
https://... URL and drop the proxy block from the Caddyfile.
External link
Each ship card can surface a single external link as a styled button via
the reusable LabeledButton.svelte component. The button is rendered
inside the toggle group at the bottom of the card and opens its target
in a new tab (target="_blank", with rel="noopener noreferrer").
The URL is configured per ship in src/lib/types.ts:
export const SHIPS: readonly Ship[] = [
{
id: 'rcsn',
name: 'R/V Rachel Carson',
streamId: '…',
externalUrl: 'https://example.mbari.org/rachel-carson'
},
{
id: 'dpkd',
name: 'R/V David Packard',
streamId: '…'
// externalUrl unset — the button is hidden for this ship.
}
];
When externalUrl is unset for a ship, the button is hidden entirely
(no broken state). The LabeledButton component itself accepts label,
description, href, buttonText, variant (emerald, amber, or
slate), and ariaLabel, so it can be reused for other links if
needed.
Map widget
MapWidget.svelte uses Leaflet (dynamically
imported on mount so it stays out of SSR) to render an interactive map
with a marker at the ship's current GPS fix.
The widget exposes a base-layer switcher with three options:
| Layer | Source | Notes |
|---|---|---|
| Ocean Basemap | Esri (GEBCO + NOAA) | Default. Bathymetric raster with shaded relief and labeled depths. |
| GEBCO bathymetry | wms.gebco.net |
Pure depth-shaded WMS tiles from the GEBCO global grid. |
| Dark (street) | CartoDB Dark Matter | Dark themed street map; useful when bathymetry isn't relevant. |
The current selection is persisted in localStorage under
dashboard.mapLayer so it survives reloads. The map auto-centers and
zooms on the first valid GPS fix, then leaves the user in control of
pan/zoom — the marker still updates in place as the ship moves.
Configuration
| Env var | Default | Description |
|---|---|---|
REDIS_HOST |
localhost |
Redis hostname |
REDIS_PORT |
6379 |
Redis port |
PORT |
3000 |
Listen port |
HOST |
0.0.0.0 |
Bind address |
NODE_ENV |
production |
Node environment |
DASHBOARD_PASSWORD |
(unset) | When set, requires login before controls are rendered or accepted. Leave unset to disable auth. |
SESSION_SECRET |
(unset) | HMAC key used to sign session cookies. Set to a long random string in production; ephemeral random secret used if unset. |
In Docker Compose the service joins redisnet and reaches Redis as
redis:6379.
Authentication
The dashboard supports an optional shared-password gate. The view (status pills, telemetry, map, live video iframe) is always public; only the controls (the two toggles and the scene-control button) are hidden behind login.
When DASHBOARD_PASSWORD is set:
src/hooks.server.tspopulatesevent.locals.authedbased on a signed session cookie issued by/login.+page.server.tsreturnsauthedandauthEnabledin page data so the dashboard renders a Sign in link (or Sign out button) next to the "Last update" timestamp.ShipCard.sveltewraps the toggle/button block in{#if authed}, so logged-out visitors don't see those controls at all.- The
POST /api/ships/:ship/auto-streamand…/force-streamendpoints return401 Authentication requiredwhenlocals.authedis false, so the gate isn't bypassable by crafting a direct fetch.
When DASHBOARD_PASSWORD is unset or empty the hook short-circuits to
authed = true for every request, the controls render unconditionally,
and /login redirects back to / — exactly the pre-auth behaviour.
Session cookie
- Name:
dashboard_session - Value:
<expires-at-ms>.<hmac-sha256-hex>usingSESSION_SECRET - Attributes:
HttpOnly,SameSite=Lax,Path=/,Max-Age=7 days Secure: set this totrue(insrc/routes/login/+page.server.ts) if you put TLS in front. The default leaves it off because the dashboard is currently served over HTTP.
Login flow
GET / → see dashboard view, "Sign in" link in header
GET /login → password form
POST /login → checks password (constant-time SHA-256), sets cookie, 303 → /
GET / → controls now visible
POST /logout → clears cookie, 303 → /
Note on SvelteKit's CSRF check
SvelteKit normally rejects POST form submissions (HTTP 403) when the
Origin header doesn't exactly match the URL it thinks it's serving
from — a common false positive when the dashboard is accessed via a
Docker host IP or a hostname different from what the container resolves
to internally.
The session cookie is HttpOnly + SameSite=Lax, which on its own
blocks cross-site CSRF on the toggle and login POSTs, so this build
explicitly turns SvelteKit's checkOrigin off in
svelte.config.js. With ORIGIN set (see TLS below) you can
safely flip csrf.checkOrigin back to true for the extra layer.
TLS
The dashboard is served behind a Caddy
reverse proxy in production. Caddy terminates TLS on :443 and proxies
plain HTTP to the SvelteKit container on frontend:3000 — that port
is not exposed outside redisnet.
Operator-supplied cert files live in certs/ and are mounted into the
Caddy container read-only. The Caddyfile at the repo root names the
hostname and the cert file paths. See
Deployment → Enabling TLS for the
full procedure.
ORIGIN env var
When TLS is terminated upstream, the SvelteKit container only sees
plain HTTP from Caddy. By default it would think it's running on
http://... and:
- Not mark the session cookie as
Secure. - Generate
http://...URLs in any server-rendered links.
Setting ORIGIN=https://<your-hostname> on the frontend service tells
SvelteKit the canonical origin, and both behaviours flip to HTTPS. The
session cookie picks up the Secure flag automatically because
login/+page.server.ts no longer overrides it — SvelteKit's default
sets it based on the request scheme, which now resolves to HTTPS.
Running locally
cd frontend
npm install
cp .env.example .env # point at a reachable Redis
npm run dev # http://localhost:3000
Running in Docker
Adding a new field
Adding a new metric to the cards follows a consistent pattern:
- If the metric comes from NATS, append its key to
backend/collector_keys.toml. - Add a key helper in
frontend/src/lib/server/redis.tsand include it in the pipeline + return object insidereadShipState. - Extend the
ShipStatetype infrontend/src/lib/types.ts. - Update the empty-state fallbacks in
+page.server.tsandapi/state/+server.tsso they include the new field. - Render it in
ShipCard.svelte. - Rebuild the affected containers.