Web console
GopherTrunk ships a full browser-based operator console alongside the TUI. Two delivery paths:
- Embedded. When the daemon binary was linked against the built
SPA, the daemon hosts the console at the HTTP API’s
/(no separate static server). Rungophertrunk -web(or pick[2] Webfrom the launcher menu) and your default browser opens against the daemon URL automatically. - Standalone static bundle. A pure HTML / CSS / JS folder that
works without the embedded build. Open
index.htmlin any modern browser, point it at a running daemon’s URL on the connect screen, and operate. Useful when you don’t want to (re)build the daemon binary alongside the SPA.
Every Bubbletea TUI panel has a browser counterpart:
| Panel | What it does |
|---|---|
| Dashboard | Live WebSocket event feed, audio cockpit with PCM-over-WAV playback |
| Active | Active-call list with live elapsed ticker + end-call mutation |
| History | Filterable call-log explorer + retention-sweep mutation |
| Systems | Trunked-system browser with detail modal |
| Talkgroups | Sortable / filterable list with scan / lockout / priority edits |
| Devices | SDR pool inspector (live attach / detach) |
| Events | Live ring-buffer viewer (filter / pause / JSON expansion) |
| Tones | tone.alert feed with per-device reset |
| Metrics | Curated gophertrunk_* Prometheus tiles + Chart.js trend |
| Scanner | CC hunter, conventional channels, manual VFO tune, scan_mode |
| Settings | Theme, write-mode toggle, live config editing (PATCH /api/v1/settings) |
| Import | Upload PDFs / CSV bundles, preview, commit into config.yaml |
The headline scenario: run the daemon on a Raspberry Pi (or any host with the RTL-SDR attached), then operate from a laptop, tablet, or phone anywhere on the LAN.
Quick path: gophertrunk -web
When the daemon binary was built with the SPA embedded (the default
in release builds), the launcher’s -web flag wires everything up
for you:
gophertrunk -config config.yaml -web
The daemon binds the HTTP API, registers the SPA at /, and opens
your default browser at the daemon URL. Same flow when you run
gophertrunk with no flags and pick [2] Web from the interactive
launcher menu (see launcher.md).
On a headless host (SSH session, no $DISPLAY) the launcher prints
the URL + a hint instead of trying to launch a browser, so a remote
operator can open the URL from their laptop.
If you’re building the daemon yourself, run make web-build (or
cd web && npm run build) before go build so web/dist/ is
populated for the embed. Without the embed the launcher falls back
to the standalone-bundle workflow below.
1. Get the bundle
The web console ships as a sibling directory inside every release
archive — same archive that contains the gophertrunk binary:
gophertrunk-<version>-<os>-<arch>/
├── gophertrunk # the daemon / CLI binary
├── gophertrunk-web/ # standalone web console
│ ├── index.html
│ ├── assets/… # bundled React / Tailwind / Chart.js
│ ├── favicon.svg
│ ├── manifest.webmanifest
│ └── sw.js # PWA service worker
├── config.example.yaml
└── samples/…
Download the matching release archive from the Downloads page and unpack it. That’s the only file you need on the device that will run the browser; nothing else is installed.
2. Configure the daemon for browser access
Two daemon-side knobs control browser access — both already on the
existing api: block in config.yaml. Edit your config.yaml:
api:
# Bind 0.0.0.0 so a laptop on the same network can reach the daemon.
# Use a specific LAN address like 192.168.1.42 if you prefer.
http_addr: "0.0.0.0:8080"
# Browsers send a different Origin than the daemon's bind address,
# so the daemon needs an explicit allow-list. Choose ONE of:
cors:
allowed_origins:
- "null" # SPA opened via file:// (most common)
# - "http://192.168.1.7:9000" # SPA hosted by a static web server on the laptop
# - "*" # any origin (loopback-only daemons; not for public binds)
# Require a bearer token when the daemon binds to a non-loopback
# interface. The SPA prompts for the token on the connect screen.
auth:
mode: "required"
token_file: "/etc/gophertrunk/api-token"
Restart the daemon for the changes to take effect:
./gophertrunk run -config config.yaml
Why CORS? The SPA is loaded from a different origin than the daemon (either
file://or whatever static host you use), so the browser performs a CORS preflight on every request."null"covers thefile://case; explicit URLs cover the rest.
3. Open the SPA
On the same machine as the daemon
Double-click gophertrunk-web/index.html to open it in your default
browser. On the connect screen enter:
- Server URL:
http://127.0.0.1:8080 - Bearer token: the contents of your
token_file(or empty ifauth.mode: disabled) - Remember on this device: check it if you want the token to
survive a browser restart (it lands in
localStorageinstead ofsessionStorage).
From a laptop, tablet, or phone on the same LAN
This is the canonical “headless Pi, operate from the couch” scenario.
- On the host running the daemon, set
api.http_addr: "0.0.0.0:8080"(or a specific LAN IP) and restart. - Copy
gophertrunk-web/to the device that will run the browser — USB stick,scp, or just unpack the same release archive on that device. - Double-click
gophertrunk-web/index.html. On the connect screen enter the daemon’s URL (e.g.http://192.168.1.42:8080) and the bearer token.
The browser remembers the URL across visits.
Hosted by a static web server (optional)
If you’d rather serve the SPA over HTTP than open file://, point
any static-file HTTP server at gophertrunk-web/:
# Quick and dirty — any laptop with Python
cd gophertrunk-web/
python3 -m http.server 9000
# Caddy one-liner
caddy file-server --root gophertrunk-web --listen :9000
Then add the server’s origin to the daemon’s CORS list:
api:
cors:
allowed_origins:
- "http://192.168.1.7:9000"
Open http://192.168.1.7:9000/ in any browser on the network and
follow the connect-screen flow as above.
4. Install as a phone app (optional)
The SPA is a Progressive Web App. After connecting once:
- Android (Chrome / Edge): An “Install GopherTrunk” banner appears once the service worker registers — accept it. Or use the browser menu → “Install app”.
- iOS (Safari): Open the SPA in Safari, tap the Share button, then “Add to Home Screen”. iOS launches the installed app full- screen with a dedicated icon.
- Desktop Chrome / Edge: Click the install icon in the address bar (right side, looks like a small monitor) to install as a desktop app.
The installed PWA still talks to whichever daemon URL you set on the connect screen — installation doesn’t change the API target. Audio playback works on both platforms after a one-time “Tap to enable audio” gesture (required by iOS / Android autoplay rules).
Quick reference
Connect screen field map
| Field | What to enter |
|---|---|
| Server URL | http://<host>:<port>, e.g. http://192.168.1.42:8080 |
| Bearer token | Contents of api.auth.token_file — leave empty if auth is disabled |
| Remember on this device | Move the token from sessionStorage → localStorage so it survives browser restart |
Shareable one-click link
The SPA accepts pre-filled credentials via a URL hash:
index.html#server=http://192.168.1.42:8080&token=<your-token>
Bookmark or share that link (with appropriate care for the token). The SPA stores the values in browser storage on first load so the hash isn’t needed again.
Write mode
Mutation controls (end-call, talkgroup edits, scanner hold / resume / retune / manual-tune, retention sweep, tone-detector reset) are hidden by default. To unlock them:
- Open Settings.
- Tick Allow mutations from this browser.
- If the daemon’s
/api/v1/mutationsendpoint reportsallow_mutations: false, the toggle is informational only — fixapi.auth.modeor the trusted-networks list (see Hardening) to actually permit writes.
The browser remembers the write-mode flag locally. Destructive mutations (end-call, channel lockout, retune, retention sweep) always prompt for confirmation before firing.
Audio playback
The audio cockpit lives at the top of the Dashboard:
- Volume slider →
PATCH /api/v1/audio { volume } - Mute toggle →
PATCH /api/v1/audio { muted } - Record toggle →
PATCH /api/v1/audio { recording_enabled } - Tap to enable → required once per session on iOS / Android per the autoplay rules; the SPA shows a one-shot prompt.
The browser streams live PCM from
GET /api/v1/audio/stream — a continuous open-ended WAV body, no
JavaScript decoding required. Reads are unauthenticated by default;
authenticate them by binding the daemon to a trusted network or
requiring TLS + token via your reverse proxy.
Tabs
Bottom-nav on phones, top-tab strip on desktop:
Dashboard · Active · Scanner · Settings (always visible)
Systems · Talkgroups · History · Events ·
Tones · Metrics · Devices (desktop overflow row)
On phones the overflow row is reachable via the hamburger menu in the bottom nav. Keyboard shortcuts are not yet wired (a follow-up PR will add the TUI’s command palette + jump-to-panel hotkeys).
Troubleshooting
| Symptom | Likely cause + fix |
|---|---|
| “Failed to fetch” on the connect screen | Daemon not reachable. Confirm gophertrunk run is up and the URL is right (curl http://<url>/api/v1/health). |
| Browser console shows “CORS preflight rejected” | The daemon hasn’t allow-listed your SPA’s origin. Add it under api.cors.allowed_origins and restart. |
| 401 on every request | Wrong bearer token. Re-check api.auth.token_file contents and re-enter on the connect screen. |
| Connect succeeds but no events / no audio | The WebSocket couldn’t upgrade. If you’re behind a reverse proxy, confirm it forwards Upgrade / Connection headers. |
| Audio plays for 2 s then stops | iOS / Android autoplay block. Tap the “Tap to enable audio” prompt on the Dashboard. |
| Mutation buttons are invisible | Write mode is off or the daemon rejects mutations. Open Settings → tick “Allow mutations”. |
| SPA loads but tabs are stuck on a spinner | Daemon is reachable but the API rejected the token mid-flight. Open the connect screen and re-enter creds. |
| PWA install prompt never appears (Android) | First-load only — clear the site’s data in the browser and revisit. |
Building from source
The web console builds with Vite. The daemon’s Makefile wraps
the npm scripts:
make web-build # produces web/dist/ — the shipped artifact
make web-dev # Vite dev server on :5173 with proxy to :8080
make web-clean # removes node_modules/, dist/, dev-dist/
Tested with Node.js 20 LTS and npm 10. Older Node versions may work but aren’t in CI. End users never run Node — the dev server is a developer convenience only.
The Vite dev server proxies /api/* and /metrics to
http://127.0.0.1:8080, so the SPA running on :5173 looks
same-origin to the browser and CORS isn’t required during
development.
See also
web/README.md— full developer reference + architecture- Hardening —
api.auth.mode,token_file, trusted networks - TUI — full-screen terminal cockpit alternative