Files
muxplex/docs/plans/2026-03-26-web-tmux-dashboard-design.md
Brian Krabach 8234e2ec05 refactor: restructure project into muxplex/ subdir with brand integration
- Move coordinator/, frontend/, Caddyfile, pyproject.toml, requirements.txt,
  docs/ into muxplex/ subdir in prep for packaging/sharing
- Add brand assets to frontend/: favicon.ico, pwa-192/512.png,
  apple-touch-icon.png, wordmark-on-dark.svg
- Update app: title → muxplex, header → wordmark SVG, brand color tokens
  in style.css, manifest.json updated with muxplex name and brand icons
- Add design system: assets/branding/tokens.css (101 CSS custom properties),
  tokens.json (127 tokens), DESIGN-SYSTEM.md (856-line spec)
- Add assets/branding/: SVG sources, rendered PNGs (icons, favicons, PWA, OG)
- Add scripts/render-brand-assets.py for reproducible PNG generation
- Add muxplex/README.md
2026-03-27 15:06:00 -07:00

15 KiB

Web-Tmux Dashboard Design

Goal

Build a browser-based dashboard for monitoring and interacting with ~12 running tmux sessions on a home/dev device, accessible from multiple devices (laptop, mobile, tablet) over Tailscale.

Background

Managing a dozen tmux sessions across multiple devices requires constant SSH-ing and manual tmux attach commands. There's no unified view of what's running, no bell notifications when something needs attention, and no way to glance at session output from a phone. This project solves that by providing a live overview grid with capture-pane snapshots and on-demand interactive terminal access through the browser.

Tool Decision: ttyd

Six tools were evaluated for browser-based terminal access:

Tool Verdict Reason
ttyd Chosen Single C binary, wraps tmux attach natively with no SSH layer. ~5-10 MB memory. Actively maintained (11.3k stars, March 2024 release). Built-in basic auth, mTLS support, header-based auth for proxy delegation, -m N connection limits, -W writable mode.
WeTTY Rejected Requires SSH daemon + Node.js runtime. Higher complexity for the same outcome.
GoTTY Rejected Original abandoned 2017. Active fork has smaller community than ttyd.
Sshwifty Rejected Browser SSH client, not a terminal server. Solves a different problem.
Guacamole Rejected Java/Tomcat + 3 containers. Enterprise-scale overkill.
shellinabox Rejected No WebSocket support. Effectively a dead project.

Approach

Capture-pane overview + single live ttyd session.

The overview grid renders text snapshots via tmux capture-pane -p -t <session> (polled every 2s) into styled monospace <pre> blocks. A single ttyd instance handles whichever session is currently expanded. This avoids resize conflicts and simultaneous WebSocket pile-ups. ttyd stays running persistently (not tied to browser sessions).

Architecture

Three components running directly on the host (no Docker — need access to host tmux sessions), plus a reverse proxy:

┌─────────────────────────────────────────────────────────────┐
│  Caddy (reverse proxy)                                      │
│  - HTTPS termination                                        │
│  - Basic auth (basicauth directive)                         │
│  - /  and /api/* → Coordinator (port 8080)                  │
│  - /terminal/*   → active ttyd (port 7682)                  │
└─────────────┬──────────────────────────────┬────────────────┘
              │                              │
              ▼                              ▼
┌──────────────────────────┐   ┌──────────────────────────────┐
│  Session Coordinator     │   │  ttyd (C binary)             │
│  Python server (~150 LOC)│   │  Spawned on demand           │
│  Port 8080               │   │  Port 7682                   │
│  - Dashboard HTML/JS/CSS │   │  - One instance at a time    │
│  - JSON API              │   │  - tmux attach -t {name}     │
│  - State management      │   │  - -m 3 (multi-device)       │
│  - Bell detection        │   │  - -W (writable)             │
│  - ttyd lifecycle        │   │                              │
└──────────────────────────┘   └──────────────────────────────┘
              │
              ▼
┌──────────────────────────┐
│  Dashboard Frontend      │
│  Vanilla HTML/CSS/JS     │
│  - CSS auto-fill grid    │
│  - xterm.js terminal     │
│  - Bell notifications    │
│  - PWA support           │
└──────────────────────────┘

Supervisor: The Coordinator runs as a systemd service for automatic restart after crashes.

Network access: Tailscale handles network-level access control. Caddy's basic auth serves as a local backstop.

Components

Session Coordinator (Python Server)

A lightweight Python server (~150 lines) serving the dashboard and exposing a JSON API:

Endpoint Method Purpose
/api/state GET Returns full persistent state (active session, session order, bell state)
/api/sessions GET Returns all running tmux sessions with capture-pane snapshots and bell flags
/api/sessions/{name}/connect POST Kills current ttyd, spawns fresh one on port 7682 for named session, updates state
/api/sessions/current DELETE Terminates active ttyd, clears active session from state
/api/state PATCH Updates session ordering

The coordinator owns all state. Browsers are views; the coordinator is the source of truth.

ttyd Instance

One instance at a time, always on port 7682:

ttyd -W -m 3 -p 7682 tmux attach -t {name}
  • -W — writable mode
  • -m 3 — allows laptop + tablet + phone simultaneously
  • Killed and respawned when switching sessions
  • Stays running when browser disconnects (persists until explicitly closed)

Dashboard Frontend

Vanilla HTML/CSS/JS (no framework).

Desktop layout:

  • CSS auto-fill grid with minmax(360px, 1fr) — column count emerges from viewport (4 at 2560px, 2 at 900px, 1 below 600px)
  • Fixed 300px tile height
  • capture-pane text rendered in <pre> blocks, showing bottom of output (most recent lines), faded top edge, no scrollbars

Mobile layout:

  • Three-tier priority list:
    1. Sessions with bells — expanded, 4-6 output lines
    2. Recently active (< 5 min) — 1 line preview
    3. Idle — name + timestamp only
  • Tap → full-screen terminal

Mode transition: Zoom-in-place — selected tile expands from grid position to fill viewport (~250ms), siblings fade. Reverse on return to grid.

Caddy (Reverse Proxy)

  • HTTPS termination
  • basicauth directive for authentication
  • Routes / and /api/* → Coordinator (port 8080)
  • Routes /terminal/* → active ttyd (port 7682)

Data Flow

Persistent State

State file location: ~/.local/share/tmux-web/state.json

{
  "active_session": "work",
  "session_order": ["main", "work", "logs", "music"],
  "sessions": {
    "logs-prod": {
      "bell": {
        "last_fired_at": 1711425900.0,
        "seen_at": null,
        "unseen_count": 3
      }
    }
  },
  "devices": {
    "d-a1b2c3": {
      "label": "Laptop Chrome",
      "viewing_session": "dev-server",
      "view_mode": "fullscreen",
      "last_interaction_at": 1711425950.0,
      "last_heartbeat_at": 1711425958.0
    }
  }
}

Atomic writes: All state.json writes use os.replace() (write to temp, rename) — atomic on POSIX.

Concurrent write safety: Coordinator uses asyncio with a single write lock around state.json updates. All async paths serialize through this lock before calling os.replace().

Browser Connection Flow

  1. New browser calls GET /api/state
  2. If active_session is set, frontend immediately connects to already-running ttyd (no "reopen" needed)
  3. Browser is a view; coordinator owns the truth

ttyd Lifecycle

  • Spawned when user explicitly opens a session via POST /api/sessions/{name}/connect
  • Stays running even when browser disconnects
  • New connections on any device attach to same running PTY
  • Killed only when user explicitly closes the session or switches to another

Multi-Device Connections

-m 3 allows phone + tablet + desktop simultaneously. When a new device connects with a different terminal size, the coordinator issues:

tmux resize-window -t {session} -x {cols} -y {rows}

Client Heartbeat

Every 5 seconds (via WebSocket or poll):

{
  "device_id": "d-a1b2c3",
  "viewing_session": "dev-server",
  "view_mode": "fullscreen",
  "last_interaction_at": 1711425950.0
}

Devices are pruned from state after 5 minutes of silence.

Bell Notification & Acknowledgement

Core Model

One user, one awareness. Bell state is global — not per-device.

Detection

Coordinator polls tmux display-message -t {name} -p "#{window_bell_flag}" alongside capture-pane every 2s. Transitions from false → true increment unseen_count and set last_fired_at.

Clear Rule

A bell clears globally (on every device) when:

  1. That session is open full-screen on a device (view_mode == "fullscreen"), AND
  2. That device has had a user interaction within the last 60 seconds (last_interaction_at > now - 60s)

If laptop is sitting idle on session #1, bells persist everywhere. When user taps session #1 on phone and interacts, it clears on all devices.

unseen_count

Tracks rapid successive bells — shows "3 bells while you were away."

Visual Indicators

  • Overview tiles: Amber (#E8A040) pulsing dot/border on tiles with unseen_count > 0
  • Mobile list: Amber badge next to session name; bell sessions sort to top automatically

Browser Notifications API

On first load, request permission. When poll cycle detects bell transition (false → true), fire new Notification("Activity in: " + name). Shows in OS notification center even when tab is backgrounded. Works on mobile when added to home screen (iOS Safari 16.4+, Android Chrome).

UI/UX Design

Session Tile Information Hierarchy

  1. Terminal content (~90% of tile area) — the <pre> IS the tile
  2. Bell indicator — amber (#E8A040) pulsing dot, top-right of header. Only chrome that interrupts scanning.
  3. Session name — top-left, 12-13px, medium weight
  4. Last activity time — top-right, 11-12px, dim. "2s ago" / "5m ago"

Responsive Breakpoints

Viewport Columns Notes
< 600px 1 (list) Mobile priority list
600-899px 1 Single-column tiles
900-1199px 2 NOT 768px — at 768px with 2 columns, monospace tiles are only ~26 chars wide (unreadable)
1200px+ 4 Full grid

Session Switcher

Desktop: Command palette triggered by Ctrl+K. Arrow keys + number keys 1-9 for navigation, type to fuzzy-filter by name, G to return to grid. No sidebar — sidebars steal terminal columns.

Mobile: Bottom sheet triggered by floating pill button (48x48px, bottom-right, semi-transparent when idle). 56px row height. Dismiss via swipe-down or tap outside.

Connection Status

Minimal inline indicators — no persistent status bar. Polling freshness shown via tile timestamp staleness. WebSocket status shown only when degraded (brief "reconnecting..." overlay on the active terminal).

xterm.js on Mobile

  • Use visualViewport API (not window.innerHeight) to resize terminal rows when keyboard opens
  • Never let terminal scroll behind keyboard — pin to fill visual viewport exactly
  • scrollback: 500 rows on mobile (vs 5000 on desktop)
  • Hint toward landscape in expanded mode (~80+ columns)

PWA Configuration

  • display: standalone — removes browser chrome
  • theme-color matched to dark theme — prevents white flash on launch
  • orientation: any — don't lock orientation
  • apple-mobile-web-app-capable + apple-mobile-web-app-status-bar-style: black-translucent for iOS
  • Suppress pinch-zoom on terminal view only

Accessibility

  • prefers-reduced-motion: reduce — respect for activity pulse animations
  • Arrow keys to navigate grid tiles, Enter to expand, Escape to return
  • Focus moves to xterm.js when session expands; returns to tile when closed

Error Handling

Scenario Behavior
Session disappears between polls Dropped from grid on next poll. If user was connected, ttyd exits, WebSocket closes, frontend returns to overview grid with "session ended" message.
ttyd fails to start POST /connect returns error JSON. Frontend shows toast: "Couldn't connect to session 'work'". No crashed state.
Orphaned ttyd on coordinator restart Startup routine reads PID file, checks if process is still running, kills any orphaned ttyd before registering new state.
WebSocket drops mid-session ttyd reconnects on brief drops (ping/pong). Longer outages show "reconnecting..." overlay. On reconnect, frontend re-calls /connect for fresh ttyd process.
No tmux sessions running Empty state: "No active tmux sessions — will update automatically."
Stale bell state on coordinator restart Bell state in memory only; restart treats all bells as fresh. Worst case: see a bell already addressed. No missed bells.
Concurrent state writes asyncio single write lock serializes all async paths before os.replace().

Testing Strategy

1. Coordinator Unit Tests (Python)

Session enumeration logic, ttyd process lifecycle (spawn, kill, orphan detection), state file atomic writes, bell detection and acknowledgement logic, active-device gate rule. Mock subprocess calls to tmux. No tmux or browser required. Fast, always runnable.

2. Integration Tests (Coordinator + tmux)

Spin up real tmux server in test environment (tmux -L test-server), create named sessions, verify full polling cycle — capture-pane output, bell flag detection and clearing, ttyd spawns on correct session, state file updated atomically. Requires tmux installed, no browser.

3. Browser/E2E Tests

Verify dashboard renders, tiles appear and update, bell indicators show/clear correctly, session expansion works, session switcher opens, mobile layout at 375px and 768px viewport widths.

Manual Smoke Test Checklist

Run after each deploy:

  • Open two browser tabs; verify state is consistent across both
  • Close browser, reopen on different device; verify state restored correctly
  • Trigger tmux bell with printf '\a'; verify appears on non-focused device within one poll cycle
  • Let laptop sit idle on session #1 for 90s; trigger bell in session #1; verify mobile shows bell indicator
  • Actively use session #1 on phone; verify bell clears on laptop within one poll cycle

Open Questions

  1. Escape key strategy — double-Escape, Ctrl+Shift+G, or hold-duration? Affects daily usability in terminal-heavy workflows (Vim, less, fzf all use Escape).
  2. Tile density toggle — single density or compact/comfortable toggle?
  3. Session reordering — drag-to-reorder tiles, or always match tmux session creation order?
  4. Bell flag read behavior — verify whether tmux display-message -p "#{window_bell_flag}" clears the flag or only reads it. Must be tested empirically before implementing bell logic.
  5. Polling interval — 2s chosen, but 4-5s may be imperceptible for overview scanning and halves subprocess load. Worth benchmarking.