Skip to content

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.
  • 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.

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.ts populates event.locals.authed based on a signed session cookie issued by /login.
  • +page.server.ts returns authed and authEnabled in page data so the dashboard renders a Sign in link (or Sign out button) next to the "Last update" timestamp.
  • ShipCard.svelte wraps the toggle/button block in {#if authed}, so logged-out visitors don't see those controls at all.
  • The POST /api/ships/:ship/auto-stream and …/force-stream endpoints return 401 Authentication required when locals.authed is 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.

  • Name: dashboard_session
  • Value: <expires-at-ms>.<hmac-sha256-hex> using SESSION_SECRET
  • Attributes: HttpOnly, SameSite=Lax, Path=/, Max-Age=7 days
  • Secure: set this to true (in src/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

docker compose up -d --build frontend
# dashboard available at http://<docker-host>:3000

Adding a new field

Adding a new metric to the cards follows a consistent pattern:

  1. If the metric comes from NATS, append its key to backend/collector_keys.toml.
  2. Add a key helper in frontend/src/lib/server/redis.ts and include it in the pipeline + return object inside readShipState.
  3. Extend the ShipState type in frontend/src/lib/types.ts.
  4. Update the empty-state fallbacks in +page.server.ts and api/state/+server.ts so they include the new field.
  5. Render it in ShipCard.svelte.
  6. Rebuild the affected containers.