From ac0b84c1d9b69f34a4b97f0fbe980b76b49adfef Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 31 Mar 2026 03:40:57 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20openSession=20=E2=80=94=20full=20remote?= =?UTF-8?q?=20routing=20via=20sourceUrl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-03-30-multi-device-federation-phase1.md | 1067 ++++++++ ...26-03-30-multi-device-federation-phase2.md | 2279 +++++++++++++++++ ...26-03-30-multi-device-federation-phase3.md | 1321 ++++++++++ muxplex/__pycache__/__init__.cpython-313.pyc | Bin 152 -> 159 bytes muxplex/frontend/app.js | 13 +- muxplex/frontend/tests/test_app.mjs | 72 +- .../__pycache__/__init__.cpython-313.pyc | Bin 158 -> 165 bytes ..._frontend_css.cpython-313-pytest-9.0.2.pyc | Bin 268049 -> 327415 bytes uv.lock | 752 ++++++ 9 files changed, 5499 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-03-30-multi-device-federation-phase1.md create mode 100644 docs/plans/2026-03-30-multi-device-federation-phase2.md create mode 100644 docs/plans/2026-03-30-multi-device-federation-phase3.md create mode 100644 uv.lock diff --git a/docs/plans/2026-03-30-multi-device-federation-phase1.md b/docs/plans/2026-03-30-multi-device-federation-phase1.md new file mode 100644 index 0000000..191da08 --- /dev/null +++ b/docs/plans/2026-03-30-multi-device-federation-phase1.md @@ -0,0 +1,1067 @@ +# Multi-Device Federation — Phase 1: Cleanup + Backend Foundation + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Prepare the backend for multi-device federation by removing dead files, extending settings with federation fields, adding a public `/api/instance-info` route, enabling CORS, and expanding WebSocket proxy test coverage. + +**Architecture:** The browser-side federation model requires each muxplex instance to (a) know its own display name and version, (b) allow cross-origin requests, and (c) expose a lightweight public metadata endpoint. This phase adds those backend primitives without touching the frontend. The existing `settings.py` defaults-merge pattern absorbs the new fields with zero migration. + +**Tech Stack:** Python 3.11+, FastAPI, pytest, Starlette `CORSMiddleware` + +**Design doc:** `docs/plans/2026-03-30-multi-device-federation-design.md` + +--- + +## Orientation — What You Need to Know + +### Project layout + +``` +muxplex/ ← project root (run all commands from here) +├── pyproject.toml ← deps, pytest config (asyncio_mode = "auto") +├── Caddyfile ← DEAD FILE — deleting in Task 1 +├── requirements.txt ← DEAD FILE — deleting in Task 2 +└── muxplex/ ← Python package + ├── main.py ← FastAPI app, routes, WebSocket proxy + ├── settings.py ← DEFAULT_SETTINGS dict, load/save/patch + ├── auth.py ← AuthMiddleware, _AUTH_EXEMPT_PATHS set + ├── ttyd.py ← TTYD_PORT = 7682 + └── tests/ + ├── test_api.py ← 1068 lines, uses `client` fixture w/ auth cookie + ├── test_settings.py ← 144 lines, redirect_settings_path fixture + ├── test_auth.py ← 449 lines, _make_test_app pattern + └── test_ws_proxy.py ← 28 lines — expanding in Task 7 +``` + +### Test conventions + +- **Run tests:** `cd muxplex && pytest muxplex/tests/ -v` (or a specific file/test) +- **Async mode:** `asyncio_mode = "auto"` — any `async def test_*` runs automatically +- **Fixtures:** `autouse=True` fixtures redirect state/settings paths to `tmp_path` +- **Auth in tests:** The `client` fixture in `test_api.py` creates a `TestClient(app)` context manager, generates a real signed cookie via `create_session_cookie(_auth_secret, _auth_ttl)`, and sets it on `c.cookies` +- **Monkeypatching:** Patches at the **import site** — e.g. `monkeypatch.setattr("muxplex.main.get_session_list", ...)`, not `"muxplex.sessions.get_session_list"` + +### Key constants you'll encounter + +| Constant | Location | Value | +|----------|----------|-------| +| `_AUTH_EXEMPT_PATHS` | `muxplex/auth.py:137` | `{"/login", "/auth/mode", "/auth/logout"}` | +| `SETTINGS_PATH` | `muxplex/settings.py:11` | `Path.home() / ".config" / "muxplex" / "settings.json"` | +| `DEFAULT_SETTINGS` | `muxplex/settings.py:13-20` | 6-key dict (sort_order, hidden_sessions, etc.) | +| `TTYD_PORT` | `muxplex/ttyd.py:32` | `7682` | +| App version | `muxplex/main.py:191` | `"0.1.0"` | + +--- + +## Task 1: Delete Caddyfile + +**Files:** +- Delete: `Caddyfile` (project root) + +**Why:** Dead artifact. The WebSocket proxy is fully built into FastAPI (`main.py` lines 506-559). The Caddyfile was from an earlier architecture and does nothing. + +**Step 1: Verify the file exists and is what we expect** + +Run: +```bash +cat Caddyfile +``` + +Expected: An 11-line Caddy config with `:8088`, `/terminal/*` reverse proxy rules. Confirms this is the dead artifact, not something secretly important. + +**Step 2: Delete the file** + +Run: +```bash +rm Caddyfile +``` + +**Step 3: Run tests to confirm nothing depended on it** + +Run: +```bash +cd muxplex && pytest muxplex/tests/ -x -q +``` + +Expected: All tests pass. No test references Caddyfile. + +**Step 4: Commit** + +Run: +```bash +git add Caddyfile && git commit -m "chore: remove dead Caddyfile + +The WebSocket proxy is built into FastAPI (main.py). Caddy is no longer +used. Removing the stale config to avoid confusion." +``` + +--- + +## Task 2: Delete requirements.txt + +**Files:** +- Delete: `requirements.txt` (project root) + +**Why:** Contains only a 6-line comment pointing to `pyproject.toml`. It's a lie on disk — anyone running `pip install -r requirements.txt` gets nothing. + +**Step 1: Verify contents** + +Run: +```bash +cat requirements.txt +``` + +Expected: A comment saying "Dependencies are now managed in pyproject.toml" and nothing else. + +**Step 2: Delete the file** + +Run: +```bash +rm requirements.txt +``` + +**Step 3: Run tests to confirm nothing depended on it** + +Run: +```bash +cd muxplex && pytest muxplex/tests/ -x -q +``` + +Expected: All tests pass. + +**Step 4: Commit** + +Run: +```bash +git add requirements.txt && git commit -m "chore: remove stale requirements.txt + +Dependencies live in pyproject.toml. The file contained only a comment +pointing there — removing to avoid stale breadcrumbs." +``` + +--- + +## Task 3: Extend DEFAULT_SETTINGS with federation fields + +**Files:** +- Modify: `muxplex/settings.py` (lines 1-20) +- Test: `muxplex/tests/test_settings.py` + +**What:** Add two new keys to `DEFAULT_SETTINGS`: +- `"remote_instances": []` — list of `{url, name}` dicts for peer instances +- `"device_name": ""` — this instance's display name, defaults to hostname at read time + +The `device_name` default is `""` in the dict (so JSON serialization is clean), but `load_settings()` replaces `""` with `socket.gethostname()` after loading — this way the hostname is always current even if the machine is renamed. + +**Step 1: Write the failing tests** + +Add these tests at the bottom of `muxplex/tests/test_settings.py`: + +```python +# --------------------------------------------------------------------------- +# Federation field tests +# --------------------------------------------------------------------------- + + +def test_defaults_include_remote_instances(): + """DEFAULT_SETTINGS must include remote_instances as an empty list.""" + assert "remote_instances" in DEFAULT_SETTINGS + assert DEFAULT_SETTINGS["remote_instances"] == [] + + +def test_defaults_include_device_name(): + """DEFAULT_SETTINGS must include device_name as an empty string.""" + assert "device_name" in DEFAULT_SETTINGS + assert DEFAULT_SETTINGS["device_name"] == "" + + +def test_load_returns_hostname_when_device_name_empty(monkeypatch): + """load_settings() fills empty device_name with the system hostname.""" + monkeypatch.setattr("muxplex.settings.socket.gethostname", lambda: "my-laptop") + + result = load_settings() + + assert result["device_name"] == "my-laptop" + + +def test_load_preserves_explicit_device_name(redirect_settings_path, monkeypatch): + """load_settings() keeps a user-set device_name (does not overwrite with hostname).""" + import json + + monkeypatch.setattr("muxplex.settings.socket.gethostname", lambda: "my-laptop") + redirect_settings_path.write_text(json.dumps({"device_name": "Work PC"})) + + result = load_settings() + + assert result["device_name"] == "Work PC" + + +def test_remote_instances_round_trip(redirect_settings_path): + """remote_instances survive a save/load cycle unchanged.""" + instances = [ + {"url": "http://workstation:8088", "name": "Workstation"}, + {"url": "https://devserver:8088", "name": "Dev Server"}, + ] + save_settings({"remote_instances": instances}) + + result = load_settings() + + assert result["remote_instances"] == instances + + +def test_device_name_round_trip(redirect_settings_path, monkeypatch): + """An explicit device_name survives a save/load cycle.""" + monkeypatch.setattr("muxplex.settings.socket.gethostname", lambda: "fallback-host") + save_settings({"device_name": "My Server"}) + + result = load_settings() + + assert result["device_name"] == "My Server" + + +def test_load_does_not_mutate_default_remote_instances(): + """Mutating loaded remote_instances must not corrupt DEFAULT_SETTINGS.""" + result = load_settings() + result["remote_instances"].append({"url": "http://evil:8088", "name": "Evil"}) + + assert DEFAULT_SETTINGS["remote_instances"] == [] + + result2 = load_settings() + assert result2["remote_instances"] == [] +``` + +**Step 2: Run tests to verify they fail** + +Run: +```bash +cd muxplex && pytest muxplex/tests/test_settings.py -v -k "federation or remote_instances or device_name or hostname" +``` + +Expected: All 7 new tests FAIL with `KeyError: 'remote_instances'` or `KeyError: 'device_name'` or `AssertionError`. + +**Step 3: Implement the settings changes** + +In `muxplex/settings.py`, make these changes: + +1. Add `import socket` to the imports (after `import json`): + +```python +import copy +import json +import socket +from pathlib import Path +``` + +2. Extend `DEFAULT_SETTINGS` — add the two new keys at the end of the dict: + +```python +DEFAULT_SETTINGS: dict = { + "default_session": None, + "sort_order": "manual", + "hidden_sessions": [], + "window_size_largest": False, + "auto_open_created": True, + "new_session_template": "tmux new-session -d -s {name}", + "remote_instances": [], + "device_name": "", +} +``` + +3. In `load_settings()`, add hostname fallback **after** the merge loop. Insert these 2 lines right before `return result`: + +```python +def load_settings() -> dict: + """Load settings from disk, merging saved values over defaults. + + Returns DEFAULT_SETTINGS if the file does not exist or contains corrupt JSON. + Unknown keys in the file are ignored. + """ + result = copy.deepcopy(DEFAULT_SETTINGS) + try: + text = SETTINGS_PATH.read_text() + data = json.loads(text) + for key in DEFAULT_SETTINGS: + if key in data: + result[key] = data[key] + except (FileNotFoundError, json.JSONDecodeError): + pass + # Hostname fallback: empty device_name → current hostname + if not result["device_name"]: + result["device_name"] = socket.gethostname() + return result +``` + +**Step 4: Run tests to verify they pass** + +Run: +```bash +cd muxplex && pytest muxplex/tests/test_settings.py -v +``` + +Expected: ALL tests pass (existing 8 + new 7 = 15 total). + +**Step 5: Commit** + +Run: +```bash +cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: add remote_instances and device_name to settings + +Extends DEFAULT_SETTINGS with: +- remote_instances: [] — list of {url, name} for peer muxplex instances +- device_name: '' — this instance's display name, falls back to hostname + +The hostname fallback happens at load time so it stays current even if +the machine is renamed. Explicit values are preserved." +``` + +--- + +## Task 4: Add /api/instance-info to auth exempt paths + +**Files:** +- Modify: `muxplex/auth.py` (line 137) +- Test: `muxplex/tests/test_auth.py` + +**What:** Add `"/api/instance-info"` to the `_AUTH_EXEMPT_PATHS` set so the endpoint can be called without authentication. This must happen before the route itself is created (Task 5), because the route handler assumes no auth. + +**Step 1: Write the failing test** + +Add this test at the end of `muxplex/tests/test_auth.py`: + +```python +# --------------------------------------------------------------------------- +# Auth exempt paths — federation +# --------------------------------------------------------------------------- + + +def test_middleware_instance_info_path_excluded(): + """/api/instance-info is excluded from auth (public metadata endpoint).""" + app = _make_test_app() + + @app.get("/api/instance-info") + async def instance_info(): + return PlainTextResponse("info") + + client = TestClient(app, base_url="http://192.168.1.1") + response = client.get("/api/instance-info") + assert response.status_code == 200 +``` + +**Step 2: Run the test to verify it fails** + +Run: +```bash +cd muxplex && pytest muxplex/tests/test_auth.py::test_middleware_instance_info_path_excluded -v +``` + +Expected: FAIL — response is 307 redirect to `/login` because `/api/instance-info` is not in the exempt set yet. + +**Step 3: Add the path to the exempt set** + +In `muxplex/auth.py`, change line 137 from: + +```python +_AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout"} +``` + +to: + +```python +_AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout", "/api/instance-info"} +``` + +**Step 4: Run the test to verify it passes** + +Run: +```bash +cd muxplex && pytest muxplex/tests/test_auth.py::test_middleware_instance_info_path_excluded -v +``` + +Expected: PASS. + +**Step 5: Run full auth test suite to check for regressions** + +Run: +```bash +cd muxplex && pytest muxplex/tests/test_auth.py -v +``` + +Expected: All tests pass. + +**Step 6: Commit** + +Run: +```bash +cd muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat: exempt /api/instance-info from auth + +The instance-info endpoint is public metadata (like a health check). +Remote instances need to call it without a session cookie to discover +peer names and verify reachability." +``` + +--- + +## Task 5: Add GET /api/instance-info route + +**Files:** +- Modify: `muxplex/main.py` (add route after the `/api/settings` routes, before the WebSocket proxy section) +- Test: `muxplex/tests/test_api.py` + +**What:** `GET /api/instance-info` returns `{"name": "", "version": "0.1.0"}`. No auth required (handled by Task 4). The route reads `device_name` from `load_settings()` and the version from `app.version`. + +**Step 1: Write the failing tests** + +Add these tests in `muxplex/tests/test_api.py`, after the `PATCH /api/settings` test section (after line ~968): + +```python +# --------------------------------------------------------------------------- +# GET /api/instance-info +# --------------------------------------------------------------------------- + + +def test_instance_info_returns_200(client): + """GET /api/instance-info returns HTTP 200.""" + response = client.get("/api/instance-info") + assert response.status_code == 200 + + +def test_instance_info_returns_name_and_version(client, tmp_path, monkeypatch): + """GET /api/instance-info returns JSON with name and version keys.""" + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") + monkeypatch.setattr("muxplex.settings.socket.gethostname", lambda: "test-host") + + response = client.get("/api/instance-info") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "test-host" + assert data["version"] == "0.1.0" + + +def test_instance_info_uses_explicit_device_name(client, tmp_path, monkeypatch): + """GET /api/instance-info uses explicit device_name from settings.""" + import json + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text(json.dumps({"device_name": "My Workstation"})) + + response = client.get("/api/instance-info") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "My Workstation" + + +def test_instance_info_no_auth_required(monkeypatch): + """GET /api/instance-info succeeds without a session cookie (non-localhost).""" + monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password") + + with TestClient(app) as c: + # Do NOT set any auth cookie + response = c.get( + "/api/instance-info", + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200 + data = response.json() + assert "name" in data + assert "version" in data +``` + +**Step 2: Run tests to verify they fail** + +Run: +```bash +cd muxplex && pytest muxplex/tests/test_api.py -v -k "instance_info" +``` + +Expected: All 4 tests FAIL with 404 (route doesn't exist yet) or `KeyError`. + +**Step 3: Add the route to main.py** + +In `muxplex/main.py`, add this route **after** the `PATCH /api/settings` route (after line 498) and **before** the WebSocket proxy section (line 501 comment): + +```python +@app.get("/api/instance-info") +async def instance_info() -> dict: + """Return this instance's display name and version. + + Public endpoint (no auth required) — used by remote instances to + discover peer names and verify reachability. + """ + settings = load_settings() + return {"name": settings["device_name"], "version": app.version} +``` + +Place it between the existing `update_settings` function and the `# WebSocket proxy` comment block. No new imports needed — `load_settings` is already imported at line 61. + +**Step 4: Run tests to verify they pass** + +Run: +```bash +cd muxplex && pytest muxplex/tests/test_api.py -v -k "instance_info" +``` + +Expected: All 4 tests PASS. + +**Step 5: Commit** + +Run: +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add GET /api/instance-info endpoint + +Returns {name, version} for this muxplex instance. No auth required — +remote instances call this to discover peer display names and verify +reachability. The name comes from settings.device_name (falls back to +hostname)." +``` + +--- + +## Task 6: Add CORS middleware + +**Files:** +- Modify: `muxplex/main.py` (add middleware after auth middleware) +- Test: `muxplex/tests/test_api.py` + +**What:** Add Starlette's `CORSMiddleware` to the FastAPI app so browsers on one muxplex instance can make cross-origin requests to another. Allow all origins — these are private network tools, not public APIs. + +**Step 1: Write the failing tests** + +Add these tests in `muxplex/tests/test_api.py`, right after the instance-info tests you added in Task 5: + +```python +# --------------------------------------------------------------------------- +# CORS middleware +# --------------------------------------------------------------------------- + + +def test_cors_preflight_returns_200(client): + """An OPTIONS preflight request returns 200 with CORS headers.""" + response = client.options( + "/api/sessions", + headers={ + "Origin": "http://workstation:8088", + "Access-Control-Request-Method": "GET", + }, + ) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "*" + + +def test_cors_allows_any_origin(client): + """A normal GET with an Origin header gets Access-Control-Allow-Origin: *.""" + response = client.get( + "/api/sessions", + headers={"Origin": "http://some-other-host:8088"}, + ) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "*" + + +def test_cors_allows_credentials(client): + """CORS response includes Access-Control-Allow-Credentials: true.""" + response = client.get( + "/api/sessions", + headers={"Origin": "http://workstation:8088"}, + ) + assert response.status_code == 200 + assert response.headers.get("access-control-allow-credentials") == "true" +``` + +**Step 2: Run tests to verify they fail** + +Run: +```bash +cd muxplex && pytest muxplex/tests/test_api.py -v -k "cors" +``` + +Expected: All 3 tests FAIL — no `access-control-allow-origin` header in responses. The `test_cors_preflight_returns_200` may also get a 405 or redirect. + +**Step 3: Add CORS middleware to main.py** + +In `muxplex/main.py`: + +1. Add the import. Find the existing import block near line 26-30 and add `CORSMiddleware`: + +```python +from starlette.middleware.cors import CORSMiddleware +``` + +Add this line right after the existing `from starlette.responses import RedirectResponse` import (line 30). + +2. Add the middleware **after** the auth middleware block (after line 252). Insert it between the auth middleware and the request/response models section: + +```python +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +``` + +> **Important:** FastAPI middleware executes in reverse registration order (last-added runs first). By adding CORS **after** auth, CORS preflight (OPTIONS) will be handled before the auth check, which is correct — preflight requests don't carry cookies. + +**Step 4: Run tests to verify they pass** + +Run: +```bash +cd muxplex && pytest muxplex/tests/test_api.py -v -k "cors" +``` + +Expected: All 3 CORS tests PASS. + +**Step 5: Run full test suite to check for regressions** + +Run: +```bash +cd muxplex && pytest muxplex/tests/ -v +``` + +Expected: All tests pass. CORS middleware is additive — it only adds headers, never blocks existing functionality. + +**Step 6: Commit** + +Run: +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add CORS middleware for cross-origin federation + +Adds Starlette CORSMiddleware with allow_origins=['*'] so browsers on +one muxplex instance can fetch sessions from another. Permissive CORS +is appropriate — these are private network tools, not public APIs." +``` + +--- + +## Task 7: Expand WebSocket proxy tests — bidirectional relay + +**Files:** +- Modify: `muxplex/tests/test_ws_proxy.py` + +**What:** Add tests for bidirectional message relay (browser → ttyd and ttyd → browser). This requires mocking the `websockets.connect` call to intercept what gets sent to ttyd. + +**Step 1: Write the test file preamble and relay tests** + +Replace the **entire contents** of `muxplex/tests/test_ws_proxy.py` with the following. The existing regression test is preserved as-is at the top: + +```python +""" +Tests for the WebSocket proxy in muxplex/main.py. + +The proxy at /terminal/ws bridges the browser WebSocket to a backend ttyd +WebSocket at ws://localhost:7682/ws. These tests mock the ttyd side using +a real asyncio server so we can verify bidirectional relay, close propagation, +auth rejection, and concurrent sessions. +""" + +import asyncio +import inspect +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from muxplex.auth import create_session_cookie +from muxplex.main import app, _auth_secret, _auth_ttl, terminal_ws_proxy + + +# --------------------------------------------------------------------------- +# autouse fixture — redirect state/PID files, mock startup side-effects +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def patch_startup_and_state(tmp_path, monkeypatch): + """Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op.""" + tmp_state_dir = tmp_path / "state" + tmp_state_path = tmp_state_dir / "state.json" + monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir) + monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path) + + tmp_pid_dir = tmp_path / "ttyd" + tmp_pid_path = tmp_pid_dir / "ttyd.pid" + monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir) + monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path) + + async def _mock_kill_orphan(): + return False + + monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan) + + async def noop_poll_loop() -> None: + pass + + monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_authed_client() -> TestClient: + """Create a TestClient with a valid session cookie.""" + c = TestClient(app) + cookie = create_session_cookie(_auth_secret, _auth_ttl) + c.cookies.set("muxplex_session", cookie) + return c + + +class FakeTtydWs: + """A fake ttyd WebSocket connection for mocking websockets.connect(). + + Stores messages sent to ttyd and yields pre-loaded responses back. + """ + + def __init__(self, responses=None): + self.sent = [] # messages sent TO ttyd + self._responses = list(responses or []) + self._closed = False + + async def send(self, message): + self.sent.append(message) + + async def close(self): + self._closed = True + + def __aiter__(self): + return self + + async def __anext__(self): + if self._responses: + return self._responses.pop(0) + raise StopAsyncIteration + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + self._closed = True + + +# --------------------------------------------------------------------------- +# Regression test (preserved from original) +# --------------------------------------------------------------------------- + + +def test_terminal_ws_proxy_does_not_use_receive_bytes(): + """Regression: receive_bytes() silently drops TEXT frames (like the ttyd auth token). + + terminal.js sends {"AuthToken": ""} as a TEXT WebSocket frame. The original + proxy used receive_bytes() which fails on text frames, swallowed the exception, + and exited — meaning ttyd never received the auth token, never started + streaming, resulting in a permanent black screen and reconnect loop. + + The proxy MUST use receive() and dispatch on message type to handle both + binary and text frames correctly. + """ + source = inspect.getsource(terminal_ws_proxy) + assert "receive_bytes" not in source, ( + "client_to_ttyd must not use receive_bytes() — silently drops text frames " + 'like the ttyd auth token {"AuthToken": ""}' + ) + assert ".receive()" in source, ( + "client_to_ttyd must use receive() to handle both text and binary frames" + ) + + +# --------------------------------------------------------------------------- +# Auth rejection tests +# --------------------------------------------------------------------------- + + +def test_ws_auth_rejection_no_cookie(): + """WebSocket from non-localhost without a cookie is closed with code 4001.""" + with TestClient(app) as c: + with pytest.raises(WebSocketDisconnect) as exc_info: + with c.websocket_connect("/terminal/ws"): + pass + assert exc_info.value.code == 4001 + + +def test_ws_auth_rejection_invalid_cookie(): + """WebSocket from non-localhost with a tampered cookie is closed with code 4001.""" + with TestClient(app) as c: + c.cookies.set("muxplex_session", "tampered.invalid.cookie") + with pytest.raises(WebSocketDisconnect) as exc_info: + with c.websocket_connect("/terminal/ws"): + pass + assert exc_info.value.code == 4001 + + +# --------------------------------------------------------------------------- +# Bidirectional relay tests +# --------------------------------------------------------------------------- + + +def test_browser_text_relayed_to_ttyd(): + """A text message from the browser is forwarded to ttyd.""" + fake_ttyd = FakeTtydWs() + + with ( + patch("muxplex.main.websockets.connect", return_value=fake_ttyd), + TestClient(app) as c, + ): + cookie = create_session_cookie(_auth_secret, _auth_ttl) + c.cookies.set("muxplex_session", cookie) + with c.websocket_connect("/terminal/ws") as ws: + ws.send_text('{"AuthToken": ""}') + # Give the relay task a moment to forward + import time + time.sleep(0.1) + + assert '{"AuthToken": ""}' in fake_ttyd.sent + + +def test_browser_bytes_relayed_to_ttyd(): + """A binary message from the browser is forwarded to ttyd.""" + fake_ttyd = FakeTtydWs() + + with ( + patch("muxplex.main.websockets.connect", return_value=fake_ttyd), + TestClient(app) as c, + ): + cookie = create_session_cookie(_auth_secret, _auth_ttl) + c.cookies.set("muxplex_session", cookie) + with c.websocket_connect("/terminal/ws") as ws: + ws.send_bytes(b"\x01hello") + import time + time.sleep(0.1) + + assert b"\x01hello" in fake_ttyd.sent + + +def test_ttyd_text_relayed_to_browser(): + """A text message from ttyd is forwarded to the browser.""" + fake_ttyd = FakeTtydWs(responses=["terminal output"]) + + with ( + patch("muxplex.main.websockets.connect", return_value=fake_ttyd), + TestClient(app) as c, + ): + cookie = create_session_cookie(_auth_secret, _auth_ttl) + c.cookies.set("muxplex_session", cookie) + with c.websocket_connect("/terminal/ws") as ws: + msg = ws.receive_text() + assert msg == "terminal output" + + +def test_ttyd_bytes_relayed_to_browser(): + """A binary message from ttyd is forwarded to the browser.""" + fake_ttyd = FakeTtydWs(responses=[b"\x01binary-output"]) + + with ( + patch("muxplex.main.websockets.connect", return_value=fake_ttyd), + TestClient(app) as c, + ): + cookie = create_session_cookie(_auth_secret, _auth_ttl) + c.cookies.set("muxplex_session", cookie) + with c.websocket_connect("/terminal/ws") as ws: + msg = ws.receive_bytes() + assert msg == b"\x01binary-output" + + +# --------------------------------------------------------------------------- +# Close propagation tests +# --------------------------------------------------------------------------- + + +def test_ttyd_close_propagates_to_browser(): + """When ttyd has no more messages, the browser WebSocket closes cleanly.""" + # FakeTtydWs with no responses will exhaust immediately → proxy should close + fake_ttyd = FakeTtydWs(responses=[]) + + with ( + patch("muxplex.main.websockets.connect", return_value=fake_ttyd), + TestClient(app) as c, + ): + cookie = create_session_cookie(_auth_secret, _auth_ttl) + c.cookies.set("muxplex_session", cookie) + # The websocket_connect should terminate without hanging + try: + with c.websocket_connect("/terminal/ws") as ws: + # Try to receive — should get disconnect since ttyd has no data + ws.receive_text() + except (WebSocketDisconnect, Exception): + pass # Expected — connection closed after ttyd exhausted + + +# --------------------------------------------------------------------------- +# ttyd unreachable test +# --------------------------------------------------------------------------- + + +def test_ttyd_unreachable_closes_browser_ws(): + """When ttyd is unreachable, the browser WebSocket is closed (not hung).""" + with ( + patch( + "muxplex.main.websockets.connect", + side_effect=OSError("Connection refused"), + ), + TestClient(app) as c, + ): + cookie = create_session_cookie(_auth_secret, _auth_ttl) + c.cookies.set("muxplex_session", cookie) + try: + with c.websocket_connect("/terminal/ws") as ws: + ws.receive_text() + except (WebSocketDisconnect, Exception): + pass # Expected — proxy closes browser WS when ttyd is unreachable + + +# --------------------------------------------------------------------------- +# Concurrent sessions test +# --------------------------------------------------------------------------- + + +def test_concurrent_ws_sessions(): + """Two simultaneous WebSocket proxy sessions don't interfere.""" + fake_ttyd_1 = FakeTtydWs(responses=["session-1-output"]) + fake_ttyd_2 = FakeTtydWs(responses=["session-2-output"]) + + call_count = 0 + + def mock_connect(*args, **kwargs): + nonlocal call_count + call_count += 1 + return fake_ttyd_1 if call_count == 1 else fake_ttyd_2 + + with ( + patch("muxplex.main.websockets.connect", side_effect=mock_connect), + TestClient(app) as c, + ): + cookie = create_session_cookie(_auth_secret, _auth_ttl) + c.cookies.set("muxplex_session", cookie) + + # Open first connection and read its message + with c.websocket_connect("/terminal/ws") as ws1: + msg1 = ws1.receive_text() + assert msg1 == "session-1-output" + + # Open second connection and read its message + with c.websocket_connect("/terminal/ws") as ws2: + msg2 = ws2.receive_text() + assert msg2 == "session-2-output" +``` + +**Step 2: Run the new tests** + +Run: +```bash +cd muxplex && pytest muxplex/tests/test_ws_proxy.py -v +``` + +Expected: All tests PASS. These tests use mocking to simulate ttyd, so they don't require a real ttyd process. + +> **Note:** If any relay tests fail due to timing, the `time.sleep(0.1)` calls may need a slight increase (e.g., to `0.2`). The TestClient's synchronous WebSocket interface introduces small timing gaps between send and the async relay task. + +**Step 3: Run the full test suite** + +Run: +```bash +cd muxplex && pytest muxplex/tests/ -v +``` + +Expected: All tests pass across all test files. + +**Step 4: Commit** + +Run: +```bash +cd muxplex && git add muxplex/tests/test_ws_proxy.py && git commit -m "test: expand WebSocket proxy test suite + +Replaces the 28-line regression test with a comprehensive suite covering: +- Bidirectional message relay (text + binary, both directions) +- Close propagation (ttyd exhaustion → browser close) +- Auth rejection (no cookie → 4001, invalid cookie → 4001) +- ttyd unreachable (OSError → browser WS closed) +- Concurrent sessions (two proxies don't interfere) + +Uses FakeTtydWs mock to simulate the ttyd WebSocket without needing a +real ttyd process." +``` + +--- + +## Task 8: Final verification + +**No files changed.** This task is a sanity check that everything works together. + +**Step 1: Run the entire test suite** + +Run: +```bash +cd muxplex && pytest muxplex/tests/ -v +``` + +Expected: All tests pass. Zero failures, zero errors. + +**Step 2: Verify the deleted files are gone** + +Run: +```bash +ls -la Caddyfile requirements.txt 2>&1 +``` + +Expected: `No such file or directory` for both. + +**Step 3: Verify the new fields are in settings** + +Run: +```bash +cd muxplex && python -c "from muxplex.settings import DEFAULT_SETTINGS; print(sorted(DEFAULT_SETTINGS.keys()))" +``` + +Expected output includes `device_name` and `remote_instances` among the 8 keys. + +**Step 4: Verify instance-info route exists** + +Run: +```bash +cd muxplex && python -c "from muxplex.main import app; print([r.path for r in app.routes if hasattr(r, 'path') and 'instance' in r.path])" +``` + +Expected: `['/api/instance-info']` + +**Step 5: Check git log** + +Run: +```bash +git log --oneline -7 +``` + +Expected: 7 commits in order: +1. `test: expand WebSocket proxy test suite` +2. `feat: add CORS middleware for cross-origin federation` +3. `feat: add GET /api/instance-info endpoint` +4. `feat: exempt /api/instance-info from auth` +5. `feat: add remote_instances and device_name to settings` +6. `chore: remove stale requirements.txt` +7. `chore: remove dead Caddyfile` + +--- + +## Summary + +| Task | What | Tests Added | +|------|------|-------------| +| 1 | Delete `Caddyfile` | — (deletion) | +| 2 | Delete `requirements.txt` | — (deletion) | +| 3 | Extend `DEFAULT_SETTINGS` with `remote_instances` + `device_name` | 7 tests in `test_settings.py` | +| 4 | Add `/api/instance-info` to auth exempt paths | 1 test in `test_auth.py` | +| 5 | Add `GET /api/instance-info` route | 4 tests in `test_api.py` | +| 6 | Add CORS middleware | 3 tests in `test_api.py` | +| 7 | Expand `test_ws_proxy.py` to full suite | 11 tests (replaces 1) | +| 8 | Final verification | — (sanity check) | + +**Total new tests:** 26 +**Files modified:** 5 (`settings.py`, `main.py`, `auth.py`, `test_settings.py`, `test_api.py`, `test_ws_proxy.py`) +**Files deleted:** 2 (`Caddyfile`, `requirements.txt`) + +After this phase the backend is fully federation-ready: settings accept remote instances, each instance knows its name, CORS is enabled, and a public metadata endpoint exists. The frontend is untouched — that's Phase 2. \ No newline at end of file diff --git a/docs/plans/2026-03-30-multi-device-federation-phase2.md b/docs/plans/2026-03-30-multi-device-federation-phase2.md new file mode 100644 index 0000000..13b0ec3 --- /dev/null +++ b/docs/plans/2026-03-30-multi-device-federation-phase2.md @@ -0,0 +1,2279 @@ +# Multi-Device Federation — Phase 2: Frontend Multi-Origin + View Modes + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Make the muxplex dashboard display sessions from multiple muxplex instances in a single grid, with device badges, three view modes (flat/grouped/filtered), and settings UI for managing remote instances. + +**Architecture:** The browser fetches `/api/sessions` from every configured muxplex instance in parallel, tags each session with its source device name and URL, merges them into a single list, and renders them using the existing tile/sidebar system augmented with device badges and view-mode logic. All cross-origin requests use `credentials: "include"` so the browser sends cookies for each remote origin. Unreachable sources are tracked with exponential backoff but no placeholder tiles yet (Phase 3). No new backend routes — this phase is pure frontend (app.js, style.css, index.html). + +**Tech Stack:** Vanilla JS (app.js), CSS (style.css), HTML (index.html), Node.js `--test` runner for JS tests + +**Design doc:** `docs/plans/2026-03-30-multi-device-federation-design.md` + +--- + +## Orientation — What You Need to Know + +### Phase 1 delivers (already done before you start) + +- `settings.py` has `remote_instances` (list of `{url, name}`) and `device_name` (string) in `DEFAULT_SETTINGS` +- `GET /api/instance-info` route exists — no auth required, returns `{"name": "DeviceName", "version": "0.1.0"}` +- CORS middleware is active on all instances (allows any origin) +- `/api/instance-info` is in `_AUTH_EXEMPT_PATHS` (no cookie needed) + +### Project layout + +``` +muxplex/ ← project root (run all commands from here) +├── muxplex/ +│ ├── main.py ← FastAPI app, routes (NOT modified in Phase 2) +│ ├── settings.py ← DEFAULT_SETTINGS dict w/ remote_instances, device_name +│ └── frontend/ +│ ├── app.js ← 1724 lines, vanilla JS, CommonJS export shim at bottom +│ ├── style.css ← 1388 lines, CSS custom properties on :root +│ ├── index.html ← 183 lines, settings dialog, grid, sidebar +│ └── tests/ +│ └── test_app.mjs ← 1955 lines, Node.js --test runner, globalThis stubs +``` + +### Key patterns in app.js you MUST follow + +1. **No framework, no bundler** — plain functions, no classes, no imports +2. **DOM helpers:** `$(id)` → `document.getElementById(id)`, `on(el, ev, fn)` +3. **API wrapper:** `async function api(method, path, body)` — line 160, currently uses relative URLs +4. **State globals:** `_deviceId`, `_currentSessions`, `_viewingSession`, `_viewMode` (`'grid'|'fullscreen'`), `_serverSettings`, `_pollFailCount` +5. **Poll pattern:** `pollSessions()` → `GET /api/sessions` → `renderGrid` → `renderSidebar` → `handleBellTransitions` → `updateSessionPill` +6. **Settings:** `loadServerSettings()` → `GET /api/settings` → caches in `_serverSettings`. `patchServerSetting(key, value)` → `PATCH /api/settings` +7. **Display settings:** `loadDisplaySettings()` / `saveDisplaySettings()` use `localStorage` key `muxplex.display` +8. **CommonJS export shim** at bottom (line 1660): `if (typeof module !== 'undefined' && module.exports) { module.exports = { ... }; }` — every new function you want to test MUST be added here +9. **Test-only helpers:** `_setCurrentSessions(sessions)`, `_setViewMode(mode)`, `_setViewingSession(name)` — for setting internal state from tests + +### Key patterns in test_app.mjs you MUST follow + +1. **Import:** `const app = require(join(__dirname, '..', 'app.js'));` +2. **Stub globals BEFORE import** at top of file — `globalThis.localStorage`, `globalThis.document`, `globalThis.window`, `globalThis.fetch` +3. **Per-test mocking pattern:** Save original → replace → run test → restore original +4. **Flat `test()` calls** (not nested `describe/it`) for most tests +5. **`assert.*`** from `node:assert/strict` +6. **Run command:** `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs` + +### Key patterns in style.css + +1. **CSS custom properties on `:root`:** `--bg`, `--accent`, `--bell`, `--tile-height`, `--tile-min-width`, etc. +2. **Sections delimited by** `/* ============================================================ */` comments +3. **Grid:** `.session-grid` uses `repeat(auto-fill, minmax(var(--tile-min-width), 1fr))` +4. **File ends at line 1388** with the `@media (max-width: 959px)` block — add new sections BEFORE this final responsive block + +### Critical constraint: backward compatibility + +- `api()` must keep working with existing relative-URL calls (e.g. `api('GET', '/api/sessions')`) +- `openSession(name)` calls `window._openTerminal(name)` — do NOT break this for local sessions +- `getVisibleSessions()` filters by `hidden_sessions` server setting — hidden filtering must still work after session tagging +- `pollSessions()` currently returns early in fullscreen mode with `_viewMode === 'fullscreen'` check — BUT actually it doesn't, it always polls. Keep it always polling. + +### Session identity model (critical) + +Two different machines may have sessions named "main". After this phase, every session object gets two extra fields: +- `deviceName` — e.g. `"Laptop"`, `"Workstation"` +- `sourceUrl` — e.g. `""` (local), `"http://work:8088"` (remote) + +The unique key for a session is the tuple `(sourceUrl, name)`. Use `sourceUrl + '::' + name` as a string key whenever you need a unique identifier (e.g. in `data-session-key` attributes, `hidden_sessions` dedup). + +--- + +## Task 1: Refactor `api()` to accept optional base URL + +**Files:** +- Modify: `muxplex/frontend/app.js` (lines 159–171) +- Test: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Write the failing tests** + +Add these tests at the end of `muxplex/frontend/tests/test_app.mjs` (before the final empty line): + +```javascript +// --- api() with baseUrl --- + +test('api with no baseUrl uses relative path (backward compat)', async () => { + const calls = []; + const origFetch = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, json: async () => ({}) }; + }; + + await app.api('GET', '/api/sessions'); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, '/api/sessions'); + assert.strictEqual(calls[0].opts.credentials, undefined); + globalThis.fetch = origFetch; +}); + +test('api with baseUrl prepends it to path and sets credentials include', async () => { + const calls = []; + const origFetch = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, json: async () => ({}) }; + }; + + await app.api('GET', '/api/sessions', undefined, 'http://work:8088'); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'http://work:8088/api/sessions'); + assert.strictEqual(calls[0].opts.credentials, 'include'); + globalThis.fetch = origFetch; +}); + +test('api with baseUrl and trailing slash does not double-slash', async () => { + const calls = []; + const origFetch = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, json: async () => ({}) }; + }; + + await app.api('GET', '/api/sessions', undefined, 'http://work:8088/'); + + assert.strictEqual(calls[0].url, 'http://work:8088/api/sessions'); + globalThis.fetch = origFetch; +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -20 +``` + +Expected: FAIL — `app.api` currently only accepts 3 args, the baseUrl parameter is ignored. + +**Step 3: Implement the `api()` refactor** + +In `muxplex/frontend/app.js`, replace the `api` function (lines 160–171) with: + +```javascript +async function api(method, path, body, baseUrl) { + const opts = { method, headers: {} }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + let url = path; + if (baseUrl) { + // Strip trailing slash from baseUrl to avoid double-slash + url = baseUrl.replace(/\/+$/, '') + path; + opts.credentials = 'include'; + } + const res = await fetch(url, opts); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + return res; +} +``` + +Also add `api` to the `module.exports` block (it's not currently exported). Find the line `pollSessions,` inside the `module.exports` block and add `api,` right before it: + +```javascript + // Fetch wrapper + api, + pollSessions, +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: refactor api() to accept optional baseUrl for cross-origin requests" +``` + +--- + +## Task 2: Build sources model from server settings + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Write the failing tests** + +Add to end of `test_app.mjs`: + +```javascript +// --- buildSources --- + +test('buildSources returns only local source when no remote_instances', () => { + const sources = app.buildSources({ device_name: 'Laptop' }); + assert.strictEqual(sources.length, 1); + assert.deepStrictEqual(sources[0], { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }); +}); + +test('buildSources returns local + remote sources from remote_instances', () => { + const sources = app.buildSources({ + device_name: 'Laptop', + remote_instances: [ + { url: 'http://work:8088', name: 'Workstation' }, + { url: 'https://dev:8088', name: 'Dev Server' }, + ], + }); + assert.strictEqual(sources.length, 3); + assert.strictEqual(sources[0].type, 'local'); + assert.strictEqual(sources[0].name, 'Laptop'); + assert.strictEqual(sources[1].type, 'remote'); + assert.strictEqual(sources[1].url, 'http://work:8088'); + assert.strictEqual(sources[1].name, 'Workstation'); + assert.strictEqual(sources[1].status, 'authenticated'); + assert.strictEqual(sources[2].url, 'https://dev:8088'); +}); + +test('buildSources uses hostname fallback when device_name is empty', () => { + const sources = app.buildSources({}); + assert.strictEqual(sources.length, 1); + assert.strictEqual(sources[0].name, 'This device'); +}); + +test('buildSources strips trailing slash from remote URLs', () => { + const sources = app.buildSources({ + device_name: 'Laptop', + remote_instances: [{ url: 'http://work:8088/', name: 'Work' }], + }); + assert.strictEqual(sources[1].url, 'http://work:8088'); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: FAIL — `app.buildSources is not a function`. + +**Step 3: Implement `buildSources`** + +In `muxplex/frontend/app.js`, add this function right after the `api()` function (after line ~175 post-Task 1): + +```javascript +/** + * Build the list of session sources from server settings. + * Local source is always first with url: "". + * @param {object} settings - server settings object + * @returns {object[]} array of source objects + */ +function buildSources(settings) { + var localName = (settings && settings.device_name) || 'This device'; + var sources = [ + { url: '', name: localName, type: 'local', status: 'authenticated', backoffMs: 2000 }, + ]; + var remotes = (settings && settings.remote_instances) || []; + for (var i = 0; i < remotes.length; i++) { + var r = remotes[i]; + if (r && r.url) { + sources.push({ + url: r.url.replace(/\/+$/, ''), + name: r.name || r.url, + type: 'remote', + status: 'authenticated', + backoffMs: 2000, + }); + } + } + return sources; +} +``` + +Add a new global right after the existing `let _pollFailCount = 0;` line (around line 127): + +```javascript +let _sources = []; +``` + +Add `buildSources` and `_sources` to the `module.exports` block. Find the `api,` line you added in Task 1 and add after it: + +```javascript + buildSources, +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add buildSources to construct session source list from settings" +``` + +--- + +## Task 3: Add test-only helpers for new state + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/frontend/tests/test_app.mjs` + +We need test helpers to set `_sources` and `_serverSettings` from tests, plus a `_getGridViewMode()` getter for later tasks. + +**Step 1: Write the failing tests** + +Add to end of `test_app.mjs`: + +```javascript +// --- test-only helpers for federation state --- + +test('_setSources sets internal _sources array', () => { + const sources = [{ url: '', name: 'Test', type: 'local', status: 'authenticated', backoffMs: 2000 }]; + app._setSources(sources); + // Verify via buildSources that _sources is set (we'll use it in later tests) + assert.ok(true, '_setSources should not throw'); +}); + +test('_setServerSettings sets internal _serverSettings', () => { + app._setServerSettings({ device_name: 'TestDevice', remote_instances: [] }); + assert.ok(true, '_setServerSettings should not throw'); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: FAIL — `app._setSources is not a function`. + +**Step 3: Implement helpers** + +In `muxplex/frontend/app.js`, add these after the existing `_setViewMode` function (around line 1636): + +```javascript +/** Test-only: set _sources directly. */ +function _setSources(sources) { + _sources = sources; +} + +/** Test-only: set _serverSettings directly. */ +function _setServerSettings(settings) { + _serverSettings = settings; +} + +/** Test-only: get current _gridViewMode. */ +function _getGridViewMode() { + return _gridViewMode; +} + +/** Test-only: get current _sources. */ +function _getSources() { + return _sources; +} +``` + +Also add a new global after `let _sources = [];` (from Task 2): + +```javascript +let _gridViewMode = 'flat'; +let _activeFilterDevice = 'all'; +``` + +Add to `module.exports`: + +```javascript + _setSources, + _setServerSettings, + _getGridViewMode, + _getSources, +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add test-only helpers for federation state (_setSources, _setServerSettings)" +``` + +--- + +## Task 4: Multi-source parallel polling with session tagging + +**Files:** +- Modify: `muxplex/frontend/app.js` (the `pollSessions` function) +- Test: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Write the failing tests** + +Add to end of `test_app.mjs`: + +```javascript +// --- tagSessions --- + +test('tagSessions adds deviceName and sourceUrl to each session', () => { + const sessions = [{ name: 'main' }, { name: 'dev' }]; + const tagged = app.tagSessions(sessions, 'Laptop', ''); + assert.strictEqual(tagged[0].deviceName, 'Laptop'); + assert.strictEqual(tagged[0].sourceUrl, ''); + assert.strictEqual(tagged[1].deviceName, 'Laptop'); +}); + +test('tagSessions adds sessionKey as sourceUrl::name', () => { + const sessions = [{ name: 'main' }]; + const tagged = app.tagSessions(sessions, 'Work', 'http://work:8088'); + assert.strictEqual(tagged[0].sessionKey, 'http://work:8088::main'); +}); + +test('tagSessions returns empty array for empty input', () => { + const tagged = app.tagSessions([], 'Laptop', ''); + assert.deepStrictEqual(tagged, []); +}); + +// --- mergeSources --- + +test('mergeSources combines sessions from multiple sources', () => { + const results = [ + { source: { url: '', name: 'Laptop' }, sessions: [{ name: 'main' }] }, + { source: { url: 'http://work:8088', name: 'Work' }, sessions: [{ name: 'main' }, { name: 'dev' }] }, + ]; + const merged = app.mergeSources(results); + assert.strictEqual(merged.length, 3); + assert.strictEqual(merged[0].sessionKey, '::main'); + assert.strictEqual(merged[1].sessionKey, 'http://work:8088::main'); + assert.strictEqual(merged[2].sessionKey, 'http://work:8088::dev'); +}); + +test('mergeSources returns empty array when all sources failed', () => { + const merged = app.mergeSources([]); + assert.deepStrictEqual(merged, []); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: FAIL — `app.tagSessions is not a function`. + +**Step 3: Implement `tagSessions` and `mergeSources`** + +In `muxplex/frontend/app.js`, add these functions after `buildSources`: + +```javascript +/** + * Tag each session with its source device name, URL, and a unique key. + * Does NOT mutate originals — returns new objects. + * @param {object[]} sessions + * @param {string} deviceName + * @param {string} sourceUrl + * @returns {object[]} + */ +function tagSessions(sessions, deviceName, sourceUrl) { + return (sessions || []).map(function(s) { + return Object.assign({}, s, { + deviceName: deviceName, + sourceUrl: sourceUrl, + sessionKey: sourceUrl + '::' + (s.name || ''), + }); + }); +} + +/** + * Merge tagged session arrays from multiple successful source fetches. + * @param {Array<{source: object, sessions: object[]}>} results + * @returns {object[]} + */ +function mergeSources(results) { + var all = []; + for (var i = 0; i < results.length; i++) { + var r = results[i]; + var tagged = tagSessions(r.sessions, r.source.name, r.source.url); + for (var j = 0; j < tagged.length; j++) { + all.push(tagged[j]); + } + } + return all; +} +``` + +Add both to `module.exports`: + +```javascript + tagSessions, + mergeSources, +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add tagSessions and mergeSources for multi-origin session merging" +``` + +--- + +## Task 5: Rewrite `pollSessions` for multi-source polling + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Write the failing tests** + +Add to end of `test_app.mjs`: + +```javascript +// --- pollSessions multi-source --- + +test('pollSessions fetches from all sources and merges results', async () => { + const origGetById = globalThis.document.getElementById; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.getElementById = (id) => { + if (id === 'connection-status') return { textContent: '', className: '' }; + if (id === 'session-grid') return { innerHTML: '' }; + if (id === 'empty-state') return { classList: { add: () => {}, remove: () => {} } }; + return null; + }; + globalThis.document.querySelectorAll = () => []; + + // Set up 2 sources + app._setSources([ + { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }, + { url: 'http://work:8088', name: 'Work', type: 'remote', status: 'authenticated', backoffMs: 2000 }, + ]); + + const fetchCalls = []; + globalThis.fetch = async (url, opts) => { + fetchCalls.push(url); + if (url === '/api/sessions') { + return { ok: true, json: async () => [{ name: 'local-sess' }] }; + } + if (url === 'http://work:8088/api/sessions') { + return { ok: true, json: async () => [{ name: 'remote-sess' }] }; + } + return { ok: true, json: async () => [] }; + }; + + await app.pollSessions(); + + assert.ok(fetchCalls.includes('/api/sessions'), 'should fetch local sessions'); + assert.ok(fetchCalls.includes('http://work:8088/api/sessions'), 'should fetch remote sessions'); + + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; + globalThis.fetch = undefined; + // Reset sources to default + app._setSources([]); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: FAIL or unexpected behavior — the current `pollSessions` doesn't use `_sources`. + +**Step 3: Rewrite `pollSessions`** + +In `muxplex/frontend/app.js`, replace the entire `pollSessions` function (lines 239–255) with: + +```javascript +async function pollSessions() { + // If no sources configured yet, fall back to local-only + var sources = _sources.length > 0 ? _sources : [{ url: '', name: 'This device', type: 'local', status: 'authenticated', backoffMs: 2000 }]; + + // Fetch each source in parallel + var promises = sources.map(function(source) { + // Skip sources that are in backoff (unreachable with nextRetryAt in the future) + if (source.nextRetryAt && Date.now() < source.nextRetryAt) { + return Promise.resolve(null); + } + return api('GET', '/api/sessions', undefined, source.url || undefined) + .then(function(res) { return res.json(); }) + .then(function(sessions) { + source.status = 'authenticated'; + source.backoffMs = 2000; + delete source.nextRetryAt; + return { source: source, sessions: sessions }; + }) + .catch(function(err) { + // Check if it's a 401/403 + if (err.message && (err.message.includes('401') || err.message.includes('403'))) { + source.status = 'auth_required'; + } else { + source.status = 'unreachable'; + // Exponential backoff: 2s → 4s → 8s → cap at 30s + source.nextRetryAt = Date.now() + source.backoffMs; + source.backoffMs = Math.min(source.backoffMs * 2, 30000); + } + return null; + }); + }); + + var results = await Promise.all(promises); + var successful = results.filter(function(r) { return r !== null; }); + var merged = mergeSources(successful); + + var prev = _currentSessions; + _currentSessions = merged; + + // Connection status based on local source health + var localSource = sources.find(function(s) { return s.type === 'local'; }); + if (localSource && localSource.status === 'authenticated') { + _pollFailCount = 0; + setConnectionStatus('ok'); + } else { + _pollFailCount++; + setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err'); + } + + renderGrid(merged); + renderSidebar(merged, _viewingSession); + handleBellTransitions(prev, merged); + updateSessionPill(merged); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass (including existing `pollSessions` tests which still work because `_sources` defaults to empty so local-only fallback triggers). + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: rewrite pollSessions for multi-source parallel polling" +``` + +--- + +## Task 6: Wire sources initialization into startup + +**Files:** +- Modify: `muxplex/frontend/app.js` + +No new test for this task — it wires existing tested functions into the DOMContentLoaded handler. + +**Step 1: Modify `loadServerSettings` to rebuild sources** + +In `muxplex/frontend/app.js`, find the `loadServerSettings` function (around line 954). Replace it with: + +```javascript +async function loadServerSettings() { + try { + const res = await api('GET', '/api/settings'); + _serverSettings = await res.json(); + } catch (err) { + console.warn('[loadServerSettings] failed:', err); + if (!_serverSettings) _serverSettings = {}; + } + // Rebuild sources from settings + _sources = buildSources(_serverSettings); + return _serverSettings; +} +``` + +**Step 2: Run all tests** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 3: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js && git commit -m "feat: rebuild sources list when server settings load" +``` + +--- + +## Task 7: Update `getVisibleSessions` for session keys + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/frontend/tests/test_app.mjs` + +The `getVisibleSessions` function currently filters by `s.name` — but with federation, two sessions from different devices can share a name. We need it to only hide LOCAL sessions matching the hidden list (remote sessions with the same name should still show). + +**Step 1: Write the failing test** + +Add to end of `test_app.mjs`: + +```javascript +// --- getVisibleSessions with federation --- + +test('getVisibleSessions exported and filters hidden sessions', () => { + assert.strictEqual(typeof app.getVisibleSessions, 'function'); +}); + +test('getVisibleSessions hides local sessions by name but not remote sessions with same name', () => { + app._setServerSettings({ hidden_sessions: ['main'] }); + + const sessions = [ + { name: 'main', sourceUrl: '', sessionKey: '::main' }, + { name: 'main', sourceUrl: 'http://work:8088', sessionKey: 'http://work:8088::main' }, + { name: 'dev', sourceUrl: '', sessionKey: '::dev' }, + ]; + + const visible = app.getVisibleSessions(sessions); + assert.strictEqual(visible.length, 2, 'should hide local main but keep remote main'); + assert.ok(visible.some(function(s) { return s.sessionKey === 'http://work:8088::main'; })); + assert.ok(visible.some(function(s) { return s.sessionKey === '::dev'; })); + + app._setServerSettings(null); +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: FAIL — current `getVisibleSessions` would hide BOTH "main" sessions. + +**Step 3: Update `getVisibleSessions`** + +In `muxplex/frontend/app.js`, replace the `getVisibleSessions` function (around line 459) with: + +```javascript +function getVisibleSessions(sessions) { + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + return (sessions || []).filter(function(s) { + // hidden_sessions only applies to local sessions (sourceUrl is empty or absent) + if (hidden.length > 0 && (!s.sourceUrl) && hidden.includes(s.name)) { + return false; + } + return true; + }); +} +``` + +Add `getVisibleSessions` to `module.exports` (it's not currently exported): + +```javascript + getVisibleSessions, +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: getVisibleSessions only hides local sessions, not remote with same name" +``` + +--- + +## Task 8: CSS for device badge, group header, filter bar + +**Files:** +- Modify: `muxplex/frontend/style.css` + +No TDD step for CSS — add the styles needed for Tasks 9–13. + +**Step 1: Add new CSS sections** + +In `muxplex/frontend/style.css`, add this new section **BEFORE** the final responsive overlay sidebar block (before line 1350 which starts `/* ============================================================ Responsive overlay sidebar`): + +```css +/* ============================================================ + Device badge (on tiles and sidebar items) + ============================================================ */ + +.device-badge { + display: inline-block; + font-size: 9px; + font-weight: 600; + color: var(--accent); + background: var(--accent-dim); + border-radius: 3px; + padding: 1px 5px; + margin-left: 6px; + white-space: nowrap; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + line-height: 14px; +} + +/* ============================================================ + Device group header (grouped view mode) + ============================================================ */ + +.device-group-header { + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + padding: 12px 0 4px 0; + margin: 0; + grid-column: 1 / -1; + border-bottom: 1px solid var(--border-subtle); +} + +.device-group-header:first-child { + padding-top: 0; +} + +/* ============================================================ + Filter bar (filtered view mode) + ============================================================ */ + +.filter-bar { + display: flex; + gap: 6px; + padding: 0 var(--grid-padding) 8px; + flex-shrink: 0; + flex-wrap: wrap; +} + +.filter-pill { + font-size: 12px; + font-family: var(--font-ui); + padding: 4px 12px; + border-radius: 14px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast); + white-space: nowrap; +} + +.filter-pill:hover { + border-color: var(--accent); + color: var(--text); +} + +.filter-pill--active { + border-color: var(--accent); + background: var(--accent-dim); + color: var(--accent); + font-weight: 600; +} + +/* ============================================================ + Sidebar device group header + ============================================================ */ + +.sidebar-device-header { + font-size: 10px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 10px 12px 4px; + margin: 0; +} + +.sidebar-device-header:first-child { + padding-top: 6px; +} +``` + +**Step 2: Verify CSS parses correctly** + +```bash +cd muxplex && python3 -c " +css = open('muxplex/frontend/style.css').read() +for cls in ['.device-badge', '.device-group-header', '.filter-bar', '.filter-pill', '.filter-pill--active', '.sidebar-device-header']: + assert cls in css, f'Missing {cls}' +print('All new CSS classes present') +" +``` + +Expected: `All new CSS classes present` + +**Step 3: Commit** + +```bash +cd muxplex && git add muxplex/frontend/style.css && git commit -m "feat: add CSS for device badge, group headers, and filter bar" +``` + +--- + +## Task 9: Device badge on tiles + +**Files:** +- Modify: `muxplex/frontend/app.js` (`buildTileHTML`) +- Test: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Write the failing tests** + +Add to end of `test_app.mjs`: + +```javascript +// --- buildTileHTML device badge --- + +test('buildTileHTML shows device-badge when session has deviceName and multiple sources', () => { + app._setSources([ + { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }, + { url: 'http://work:8088', name: 'Work', type: 'remote', status: 'authenticated', backoffMs: 2000 }, + ]); + const session = { name: 'main', deviceName: 'Laptop', sourceUrl: '', snapshot: '' }; + const html = app.buildTileHTML(session, 0, false); + assert.ok(html.includes('device-badge'), 'should include device-badge span'); + assert.ok(html.includes('Laptop'), 'badge should show device name'); + app._setSources([]); +}); + +test('buildTileHTML omits device-badge when only one source configured', () => { + app._setSources([ + { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }, + ]); + const session = { name: 'main', deviceName: 'Laptop', sourceUrl: '', snapshot: '' }; + const html = app.buildTileHTML(session, 0, false); + assert.ok(!html.includes('device-badge'), 'should NOT include device-badge with single source'); + app._setSources([]); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: FAIL — current `buildTileHTML` doesn't produce `device-badge`. + +**Step 3: Update `buildTileHTML`** + +In `muxplex/frontend/app.js`, find the `buildTileHTML` function (around line 378). Replace the tile header line inside the return statement. Find: + +```javascript + `
` + + `${escapeHtml(name)}` + + `${bellHtml}${escapeHtml(timeStr)}` + + `
` + +``` + +Replace with: + +```javascript + `
` + + `${escapeHtml(name)}${_sources.length > 1 && session.deviceName ? '' + escapeHtml(session.deviceName) + '' : ''}` + + `${bellHtml}${escapeHtml(timeStr)}` + + `
` + +``` + +Also update the `data-session` attribute in the tile to use `sessionKey` when available (for unique identification). Find: + +```javascript + `
` + +``` + +Replace with: + +```javascript + `
` + +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add device badge to session tiles when multiple sources configured" +``` + +--- + +## Task 10: Device badge on sidebar items + +**Files:** +- Modify: `muxplex/frontend/app.js` (`buildSidebarHTML`) +- Test: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Write the failing test** + +Add to end of `test_app.mjs`: + +```javascript +// --- buildSidebarHTML device badge --- + +test('buildSidebarHTML shows device-badge when multiple sources configured', () => { + app._setSources([ + { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }, + { url: 'http://work:8088', name: 'Work', type: 'remote', status: 'authenticated', backoffMs: 2000 }, + ]); + const session = { name: 'main', deviceName: 'Work', sourceUrl: 'http://work:8088', snapshot: '' }; + const html = app.buildSidebarHTML(session, null); + assert.ok(html.includes('device-badge'), 'should include device-badge'); + assert.ok(html.includes('Work'), 'badge should show Work'); + app._setSources([]); +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: FAIL — `buildSidebarHTML` doesn't produce `device-badge`. + +**Step 3: Update `buildSidebarHTML`** + +In `muxplex/frontend/app.js`, find the `buildSidebarHTML` function (around line 420). Find the sidebar name span: + +```javascript + `${escapedName}` + +``` + +Replace with: + +```javascript + `${escapedName}${_sources.length > 1 && session.deviceName ? '' + escapeHtml(session.deviceName) + '' : ''}` + +``` + +Also update the data attribute for session key. Find: + +```javascript + `
` + +``` + +Replace with: + +```javascript + `
` + +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add device badge to sidebar items when multiple sources configured" +``` + +--- + +## Task 11: Grouped view mode rendering + +**Files:** +- Modify: `muxplex/frontend/app.js` (`renderGrid`) +- Test: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Write the failing test** + +Add to end of `test_app.mjs`: + +```javascript +// --- renderGrid grouped view mode --- + +test('renderGrid in grouped mode produces device-group-header elements', () => { + const mockGrid = { innerHTML: '' }; + const mockEmpty = { classList: { add: () => {}, remove: () => {} } }; + const origGetById = globalThis.document.getElementById; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.getElementById = (id) => { + if (id === 'session-grid') return mockGrid; + if (id === 'empty-state') return mockEmpty; + if (id === 'filter-bar') return null; + return null; + }; + globalThis.document.querySelectorAll = () => []; + + app._setSources([ + { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }, + { url: 'http://work:8088', name: 'Work', type: 'remote', status: 'authenticated', backoffMs: 2000 }, + ]); + app._setServerSettings({}); + + // Temporarily set grid view mode + app._setGridViewMode('grouped'); + + const sessions = [ + { name: 'sess-a', deviceName: 'Laptop', sourceUrl: '', snapshot: '', sessionKey: '::sess-a' }, + { name: 'sess-b', deviceName: 'Work', sourceUrl: 'http://work:8088', snapshot: '', sessionKey: 'http://work:8088::sess-b' }, + ]; + app.renderGrid(sessions); + + assert.ok(mockGrid.innerHTML.includes('device-group-header'), 'grouped mode should include device-group-header'); + assert.ok(mockGrid.innerHTML.includes('Laptop'), 'should show Laptop header'); + assert.ok(mockGrid.innerHTML.includes('Work'), 'should show Work header'); + + app._setGridViewMode('flat'); + app._setSources([]); + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: FAIL — `app._setGridViewMode is not a function` (or no device-group-header in output). + +**Step 3: Implement** + +First, add a test-only setter for `_gridViewMode`. In app.js, after `_getGridViewMode`: + +```javascript +/** Test-only: set _gridViewMode directly. */ +function _setGridViewMode(mode) { + _gridViewMode = mode; +} +``` + +Add `_setGridViewMode` to `module.exports`. + +Now update `renderGrid`. Replace the entire `renderGrid` function with: + +```javascript +function renderGrid(sessions) { + var grid = $('session-grid'); + var emptyState = $('empty-state'); + var filterBar = $('filter-bar'); + + var visible = getVisibleSessions(sessions); + + // In filtered mode, apply device filter + if (_gridViewMode === 'filtered' && _activeFilterDevice !== 'all') { + visible = visible.filter(function(s) { return s.deviceName === _activeFilterDevice; }); + } + + if (visible.length === 0) { + if (grid) grid.innerHTML = ''; + if (emptyState) emptyState.classList.remove('hidden'); + // Show filter bar even when filtered to empty (so user can switch back) + if (filterBar) { + if (_gridViewMode === 'filtered') { + renderFilterBar(filterBar, sessions); + } else { + filterBar.innerHTML = ''; + } + } + return; + } + + if (emptyState) emptyState.classList.add('hidden'); + + // Apply sort order from server settings + var sortOrder = _serverSettings && _serverSettings.sort_order; + var mobile = isMobile(); + var ordered; + if (sortOrder === 'alphabetical') { + ordered = visible.slice().sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); }); + } else { + ordered = mobile ? sortByPriority(visible) : visible; + } + + var html; + if (_gridViewMode === 'grouped') { + html = renderGroupedGrid(ordered, mobile); + } else { + html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join(''); + } + + if (grid) grid.innerHTML = html; + + // Render filter bar + if (filterBar) { + if (_gridViewMode === 'filtered') { + renderFilterBar(filterBar, sessions); + } else { + filterBar.innerHTML = ''; + } + } + + // Bind interaction handlers on each tile + document.querySelectorAll('.session-tile').forEach(function(tile) { + on(tile, 'click', function() { openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl }); }); + on(tile, 'keydown', function(e) { + if (e.key === 'Enter' || e.key === ' ') { + openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl }); + } + }); + }); + + if (_viewMode === 'fullscreen') { + updatePillBell(); + } +} +``` + +Add the `renderGroupedGrid` helper right before `renderGrid`: + +```javascript +/** + * Render sessions grouped by device name. Returns HTML string. + * @param {object[]} sessions - sorted, visible sessions + * @param {boolean} mobile + * @returns {string} + */ +function renderGroupedGrid(sessions, mobile) { + // Group by deviceName + var groups = {}; + var groupOrder = []; + for (var i = 0; i < sessions.length; i++) { + var dn = sessions[i].deviceName || 'Unknown'; + if (!groups[dn]) { + groups[dn] = []; + groupOrder.push(dn); + } + groups[dn].push(sessions[i]); + } + + var html = ''; + for (var g = 0; g < groupOrder.length; g++) { + var name = groupOrder[g]; + html += '

' + escapeHtml(name) + '

'; + var groupSessions = groups[name]; + for (var j = 0; j < groupSessions.length; j++) { + html += buildTileHTML(groupSessions[j], j, mobile); + } + } + return html; +} +``` + +Add `renderGroupedGrid` to `module.exports`. + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add grouped view mode to renderGrid with device-group-header sections" +``` + +--- + +## Task 12: Filtered view mode with pill bar + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/frontend/index.html` +- Test: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Add filter bar container to index.html** + +In `muxplex/frontend/index.html`, find line 29: + +```html +
+``` + +Add the filter bar container BEFORE it: + +```html +
+
+``` + +**Step 2: Write the failing test** + +Add to end of `test_app.mjs`: + +```javascript +// --- renderFilterBar --- + +test('renderFilterBar produces pill buttons for each device plus All', () => { + const mockBar = { innerHTML: '' }; + const sessions = [ + { name: 'a', deviceName: 'Laptop', sourceUrl: '' }, + { name: 'b', deviceName: 'Work', sourceUrl: 'http://work:8088' }, + ]; + app.renderFilterBar(mockBar, sessions); + + assert.ok(mockBar.innerHTML.includes('filter-pill'), 'should contain filter-pill elements'); + assert.ok(mockBar.innerHTML.includes('All'), 'should contain All pill'); + assert.ok(mockBar.innerHTML.includes('Laptop'), 'should contain Laptop pill'); + assert.ok(mockBar.innerHTML.includes('Work'), 'should contain Work pill'); +}); + +test('renderFilterBar marks active device pill with filter-pill--active', () => { + const mockBar = { innerHTML: '' }; + app._setActiveFilterDevice('Work'); + const sessions = [ + { name: 'a', deviceName: 'Laptop', sourceUrl: '' }, + { name: 'b', deviceName: 'Work', sourceUrl: 'http://work:8088' }, + ]; + app.renderFilterBar(mockBar, sessions); + + // The "Work" pill should have filter-pill--active + assert.ok(mockBar.innerHTML.includes('filter-pill--active'), 'active pill should have active class'); + app._setActiveFilterDevice('all'); +}); +``` + +**Step 3: Run tests to verify they fail** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: FAIL — `app.renderFilterBar is not a function`. + +**Step 4: Implement `renderFilterBar` and helpers** + +In `muxplex/frontend/app.js`, add after `renderGroupedGrid`: + +```javascript +/** + * Render the device filter pill bar into the given container element. + * @param {HTMLElement} container + * @param {object[]} allSessions - unfiltered sessions (for device name extraction) + */ +function renderFilterBar(container, allSessions) { + // Collect unique device names in order + var devices = []; + var seen = {}; + for (var i = 0; i < (allSessions || []).length; i++) { + var dn = allSessions[i].deviceName || 'Unknown'; + if (!seen[dn]) { + seen[dn] = true; + devices.push(dn); + } + } + + var html = ''; + for (var d = 0; d < devices.length; d++) { + var active = _activeFilterDevice === devices[d] ? ' filter-pill--active' : ''; + html += ''; + } + container.innerHTML = html; +} +``` + +Add a test-only setter for `_activeFilterDevice`: + +```javascript +/** Test-only: set _activeFilterDevice directly. */ +function _setActiveFilterDevice(device) { + _activeFilterDevice = device; +} +``` + +Add to `module.exports`: + +```javascript + renderFilterBar, + _setActiveFilterDevice, + renderGroupedGrid, +``` + +**Step 5: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 6: Bind filter pill click handlers** + +In `muxplex/frontend/app.js`, find the `bindStaticEventListeners` function. Add this block at the end of the function body (just before the closing `}`): + +```javascript + // Filter bar pill clicks (delegated \u2014 pills are re-rendered each poll) + var filterBarEl = $('filter-bar'); + if (filterBarEl) { + filterBarEl.addEventListener('click', function(e) { + var pill = e.target.closest('.filter-pill'); + if (!pill) return; + _activeFilterDevice = pill.dataset.filterDevice || 'all'; + // Re-render with current sessions + renderGrid(_currentSessions); + }); + } +``` + +**Step 7: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/index.html muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add filtered view mode with device pill bar" +``` + +--- + +## Task 13: View preference storage + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Write the failing tests** + +Add to end of `test_app.mjs`: + +```javascript +// --- view preference storage --- + +test('loadGridViewMode returns flat by default', () => { + _localStorageStore = {}; + const mode = app.loadGridViewMode(); + assert.strictEqual(mode, 'flat'); +}); + +test('loadGridViewMode reads from localStorage when scope is local', () => { + _localStorageStore = {}; + _localStorageStore['muxplex.display'] = JSON.stringify({ viewPreferenceScope: 'local', gridViewMode: 'grouped' }); + const mode = app.loadGridViewMode(); + assert.strictEqual(mode, 'grouped'); + _localStorageStore = {}; +}); + +test('loadGridViewMode reads from serverSettings when scope is server', () => { + _localStorageStore = {}; + _localStorageStore['muxplex.display'] = JSON.stringify({ viewPreferenceScope: 'server' }); + app._setServerSettings({ grid_view_mode: 'filtered' }); + const mode = app.loadGridViewMode(); + assert.strictEqual(mode, 'filtered'); + app._setServerSettings(null); + _localStorageStore = {}; +}); + +test('saveGridViewMode stores to localStorage when scope is local', () => { + _localStorageStore = {}; + _localStorageStore['muxplex.display'] = JSON.stringify({ viewPreferenceScope: 'local' }); + app.saveGridViewMode('grouped'); + const ds = JSON.parse(_localStorageStore['muxplex.display']); + assert.strictEqual(ds.gridViewMode, 'grouped'); + _localStorageStore = {}; +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: FAIL — `app.loadGridViewMode is not a function`. + +**Step 3: Implement** + +In `muxplex/frontend/app.js`, add after the `saveDisplaySettings` function: + +```javascript +/** + * Load the grid view mode preference based on the view_preference_scope setting. + * @returns {'flat'|'grouped'|'filtered'} + */ +function loadGridViewMode() { + var ds = loadDisplaySettings(); + var scope = ds.viewPreferenceScope || 'local'; + if (scope === 'server') { + return (_serverSettings && _serverSettings.grid_view_mode) || 'flat'; + } + return ds.gridViewMode || 'flat'; +} + +/** + * Save the grid view mode preference based on current scope. + * @param {'flat'|'grouped'|'filtered'} mode + */ +function saveGridViewMode(mode) { + var ds = loadDisplaySettings(); + var scope = ds.viewPreferenceScope || 'local'; + if (scope === 'server') { + patchServerSetting('grid_view_mode', mode); + } else { + ds.gridViewMode = mode; + saveDisplaySettings(ds); + } + _gridViewMode = mode; +} +``` + +Wire the view mode into startup. In the `DOMContentLoaded` handler, after `applyDisplaySettings(loadDisplaySettings());` (line ~1640), add: + +```javascript + _gridViewMode = loadGridViewMode(); +``` + +Add to `module.exports`: + +```javascript + loadGridViewMode, + saveGridViewMode, +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add view preference storage with local/server scope" +``` + +--- + +## Task 14: Settings dialog — view mode selector and scope toggle + +**Files:** +- Modify: `muxplex/frontend/index.html` +- Modify: `muxplex/frontend/app.js` (`openSettings`, `bindStaticEventListeners`) + +**Step 1: Add view mode controls to Display tab in index.html** + +In `muxplex/frontend/index.html`, find the end of the Display settings panel (the `` that closes `data-tab="display"`, around line 123). Add BEFORE that closing ``: + +```html +
+ + +
+
+ + +
+``` + +**Step 2: Populate view mode controls in `openSettings`** + +In `muxplex/frontend/app.js`, find the `openSettings` function. After the line that sets `gridColumnsEl` value (around line 1096), add: + +```javascript + // View mode selector + var viewModeEl = $('setting-view-mode'); + if (viewModeEl) viewModeEl.value = _gridViewMode || 'flat'; + var viewScopeEl = $('setting-view-scope'); + if (viewScopeEl) viewScopeEl.value = settings.viewPreferenceScope || 'local'; +``` + +**Step 3: Bind change handlers in `bindStaticEventListeners`** + +In the `bindStaticEventListeners` function, after the existing display settings bindings (after the `on($('setting-grid-columns'), 'change', onDisplaySettingChange);` line), add: + +```javascript + // View mode selector — save preference and re-render grid + on($('setting-view-mode'), 'change', function() { + var el = $('setting-view-mode'); + if (el) { + saveGridViewMode(el.value); + renderGrid(_currentSessions); + renderSidebar(_currentSessions, _viewingSession); + } + }); + + // View preference scope toggle + on($('setting-view-scope'), 'change', function() { + var el = $('setting-view-scope'); + if (el) { + var ds = loadDisplaySettings(); + ds.viewPreferenceScope = el.value; + saveDisplaySettings(ds); + // Migrate current mode to new scope + saveGridViewMode(_gridViewMode); + } + }); +``` + +**Step 4: Run all tests** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/index.html muxplex/frontend/app.js && git commit -m "feat: add view mode selector and scope toggle to settings dialog" +``` + +--- + +## Task 15: Settings dialog — Remote Instances management + +**Files:** +- Modify: `muxplex/frontend/index.html` +- Modify: `muxplex/frontend/app.js` + +**Step 1: Add Remote Instances section to Sessions tab in index.html** + +In `muxplex/frontend/index.html`, find the Sessions settings panel (`data-tab="sessions"`). Add BEFORE the closing `` of that panel: + +```html +
+ + + How this instance appears on other dashboards +
+
+ +
+ +
+``` + +**Step 2: Populate remote instances in `openSettings`** + +In `muxplex/frontend/app.js`, inside the `openSettings` function, inside the `.then(function(ss) {` callback, after the auto-open section (around line 1162), add: + +```javascript + // Device name + var deviceNameEl = $('setting-device-name'); + if (deviceNameEl) { + deviceNameEl.value = (ss && ss.device_name) || ''; + } + + // Remote instances list + var remoteEl = $('setting-remote-instances'); + if (remoteEl) { + remoteEl.innerHTML = ''; + var instances = (ss && ss.remote_instances) || []; + for (var i = 0; i < instances.length; i++) { + var inst = instances[i]; + var row = document.createElement('div'); + row.className = 'settings-remote-row'; + row.innerHTML = + '' + + '' + + ''; + remoteEl.appendChild(row); + } + } +``` + +**Step 3: Bind change handlers in `bindStaticEventListeners`** + +In `bindStaticEventListeners`, add these blocks: + +```javascript + // Device name — debounced save + var _deviceNameTimer; + on($('setting-device-name'), 'input', function() { + clearTimeout(_deviceNameTimer); + var val = this.value; + _deviceNameTimer = setTimeout(function() { + patchServerSetting('device_name', val); + }, 500); + }); + + // Add remote instance button + on($('add-remote-instance-btn'), 'click', function() { + var container = $('setting-remote-instances'); + if (!container) return; + var row = document.createElement('div'); + row.className = 'settings-remote-row'; + row.innerHTML = + '' + + '' + + ''; + container.appendChild(row); + }); + + // Remote instances — delegated handlers for remove + change + var remoteContainer = $('setting-remote-instances'); + if (remoteContainer) { + // Remove button + remoteContainer.addEventListener('click', function(e) { + var removeBtn = e.target.closest('.settings-remote-remove'); + if (!removeBtn) return; + var row = removeBtn.closest('.settings-remote-row'); + if (row) row.remove(); + _saveRemoteInstances(); + }); + + // Save on input change (debounced) + var _remoteTimer; + remoteContainer.addEventListener('input', function() { + clearTimeout(_remoteTimer); + _remoteTimer = setTimeout(_saveRemoteInstances, 500); + }); + } +``` + +Add a `_saveRemoteInstances` helper function before `bindStaticEventListeners`: + +```javascript +/** + * Read current remote instance rows from the settings dialog and save to server. + */ +function _saveRemoteInstances() { + var container = $('setting-remote-instances'); + if (!container) return; + var rows = container.querySelectorAll('.settings-remote-row'); + var instances = []; + rows.forEach(function(row) { + var urlInput = row.querySelector('.settings-remote-url'); + var nameInput = row.querySelector('.settings-remote-name'); + var url = urlInput ? urlInput.value.trim() : ''; + var name = nameInput ? nameInput.value.trim() : ''; + if (url) { + instances.push({ url: url, name: name || url }); + } + }); + patchServerSetting('remote_instances', instances); + // Rebuild sources immediately + if (_serverSettings) { + _serverSettings.remote_instances = instances; + _sources = buildSources(_serverSettings); + } +} +``` + +**Step 4: Add minimal CSS for remote instance rows** + +In `muxplex/frontend/style.css`, add before the responsive overlay sidebar block: + +```css +/* ============================================================ + Settings: Remote instances + ============================================================ */ + +.settings-remote-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.settings-remote-row { + display: flex; + gap: 6px; + align-items: center; +} + +.settings-remote-url { + flex: 2; +} + +.settings-remote-name { + flex: 1; +} + +.settings-remote-remove { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--err); + cursor: pointer; + width: 28px; + height: 28px; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.settings-remote-remove:hover { + border-color: var(--err); + background: rgba(248, 81, 73, 0.1); +} + +.settings-input { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + padding: 6px 8px; + font-size: 13px; + font-family: var(--font-ui); +} + +.settings-input:focus { + border-color: var(--accent); + outline: none; +} +``` + +**Step 5: Run all tests** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 6: Commit** + +```bash +cd muxplex && git add muxplex/frontend/index.html muxplex/frontend/app.js muxplex/frontend/style.css && git commit -m "feat: add remote instances management to settings dialog" +``` + +--- + +## Task 16: Sidebar device grouping + +**Files:** +- Modify: `muxplex/frontend/app.js` (`renderSidebar`) +- Test: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Write the failing test** + +Add to end of `test_app.mjs`: + +```javascript +// --- renderSidebar device grouping --- + +test('renderSidebar groups sessions by device when multiple sources configured', () => { + let capturedHTML = ''; + const mockList = { + get innerHTML() { return capturedHTML; }, + set innerHTML(v) { capturedHTML = v; }, + querySelectorAll: () => [], + }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'sidebar-list') return mockList; + return null; + }; + + app._setViewMode('fullscreen'); + app._setSources([ + { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }, + { url: 'http://work:8088', name: 'Work', type: 'remote', status: 'authenticated', backoffMs: 2000 }, + ]); + app._setServerSettings({}); + + const sessions = [ + { name: 'sess-a', deviceName: 'Laptop', sourceUrl: '', snapshot: '', sessionKey: '::sess-a' }, + { name: 'sess-b', deviceName: 'Work', sourceUrl: 'http://work:8088', snapshot: '', sessionKey: 'http://work:8088::sess-b' }, + ]; + + app.renderSidebar(sessions, null); + + assert.ok(capturedHTML.includes('sidebar-device-header'), 'should include device group headers'); + assert.ok(capturedHTML.includes('Laptop'), 'should include Laptop header'); + assert.ok(capturedHTML.includes('Work'), 'should include Work header'); + + app._setViewMode('grid'); + app._setSources([]); + globalThis.document.getElementById = origGetById; +}); + +test('renderSidebar does NOT group when only one source configured', () => { + let capturedHTML = ''; + const mockList = { + get innerHTML() { return capturedHTML; }, + set innerHTML(v) { capturedHTML = v; }, + querySelectorAll: () => [], + }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'sidebar-list') return mockList; + return null; + }; + + app._setViewMode('fullscreen'); + app._setSources([ + { url: '', name: 'Laptop', type: 'local', status: 'authenticated', backoffMs: 2000 }, + ]); + app._setServerSettings({}); + + const sessions = [ + { name: 'sess-a', deviceName: 'Laptop', sourceUrl: '', snapshot: '', sessionKey: '::sess-a' }, + ]; + + app.renderSidebar(sessions, null); + + assert.ok(!capturedHTML.includes('sidebar-device-header'), 'should NOT include device group headers with single source'); + + app._setViewMode('grid'); + app._setSources([]); + globalThis.document.getElementById = origGetById; +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: FAIL — current `renderSidebar` doesn't produce `sidebar-device-header`. + +**Step 3: Update `renderSidebar`** + +Replace the entire `renderSidebar` function in `muxplex/frontend/app.js`: + +```javascript +function renderSidebar(sessions, currentSession) { + if (_viewMode !== 'fullscreen') return; + + var list = $('sidebar-list'); + if (!list) return; + + var visible = getVisibleSessions(sessions); + + if (visible.length === 0) { + list.innerHTML = ''; + return; + } + + var html; + if (_sources.length > 1) { + // Group by deviceName + var groups = {}; + var groupOrder = []; + for (var i = 0; i < visible.length; i++) { + var dn = visible[i].deviceName || 'Unknown'; + if (!groups[dn]) { + groups[dn] = []; + groupOrder.push(dn); + } + groups[dn].push(visible[i]); + } + html = ''; + for (var g = 0; g < groupOrder.length; g++) { + html += ''; + var groupSessions = groups[groupOrder[g]]; + for (var j = 0; j < groupSessions.length; j++) { + html += buildSidebarHTML(groupSessions[j], currentSession); + } + } + } else { + html = visible.map(function(session) { return buildSidebarHTML(session, currentSession); }).join(''); + } + + list.innerHTML = html; + + // Bind click handlers on each sidebar item + if (typeof list.querySelectorAll === 'function') { + list.querySelectorAll('.sidebar-item').forEach(function(item) { + var name = item.dataset.session; + var sourceUrl = item.dataset.sourceUrl || ''; + on(item, 'click', function() { + if (name !== currentSession) openSession(name, { sourceUrl: sourceUrl }); + }); + }); + } +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: add device grouping to sidebar when multiple sources configured" +``` + +--- + +## Task 17: Update `openSession` to pass sourceUrl through + +**Files:** +- Modify: `muxplex/frontend/app.js` + +Phase 3 will handle remote terminal connections. For now, `openSession` needs to accept `sourceUrl` in its options and store it, but only actually connect/open terminal for local sessions. + +**Step 1: Update `openSession` signature** + +In `muxplex/frontend/app.js`, find the `openSession` function (around line 827). The function signature already has `opts = {}`. That's fine. Update the connect call to use sourceUrl when it's provided. + +Find this block: + +```javascript + // Connect to session (kill old ttyd, spawn new one for this session) + try { + if (!opts.skipConnect) { + await api('POST', `/api/sessions/${name}/connect`); + } + } catch (err) { +``` + +Replace with: + +```javascript + // Connect to session (kill old ttyd, spawn new one for this session) + var sourceUrl = opts.sourceUrl || ''; + try { + if (!opts.skipConnect) { + // Phase 2: only connect to local sessions. Remote connect is Phase 3. + if (!sourceUrl) { + await api('POST', `/api/sessions/${name}/connect`); + } + } + } catch (err) { +``` + +And update the terminal mount to also skip for remote: + +Find: + +```javascript + // Mount terminal NOW — /connect has completed, new ttyd is serving the correct session + if (window._openTerminal) window._openTerminal(name); +``` + +Replace with: + +```javascript + // Mount terminal NOW — /connect has completed, new ttyd is serving the correct session + // Phase 2: only open terminal for local sessions. Remote terminal is Phase 3. + if (!sourceUrl && window._openTerminal) window._openTerminal(name); +``` + +**Step 2: Run all tests** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass (existing openSession tests don't set sourceUrl, so they exercise the local path). + +**Step 3: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js && git commit -m "feat: openSession accepts sourceUrl opt, skips connect/terminal for remote (Phase 3)" +``` + +--- + +## Task 18: Update `detectBellTransitions` for session keys + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/frontend/tests/test_app.mjs` + +`detectBellTransitions` currently deduplicates by `s.name` — with federation, two devices can have sessions named "main". Use `sessionKey` instead. + +**Step 1: Write the failing test** + +Add to end of `test_app.mjs`: + +```javascript +// --- detectBellTransitions with sessionKey --- + +test('detectBellTransitions uses sessionKey to distinguish same-name sessions across devices', () => { + const prev = [ + { name: 'main', sessionKey: '::main', bell: { unseen_count: 0 } }, + { name: 'main', sessionKey: 'http://work:8088::main', bell: { unseen_count: 0 } }, + ]; + const next = [ + { name: 'main', sessionKey: '::main', bell: { unseen_count: 0 } }, + { name: 'main', sessionKey: 'http://work:8088::main', bell: { unseen_count: 3 } }, + ]; + const transitions = app.detectBellTransitions(prev, next); + // Should fire for remote main but not local main + assert.strictEqual(transitions.length, 1); + assert.strictEqual(transitions[0], 'main'); +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: Might pass or fail depending on how `detectBellTransitions` uses name vs sessionKey. Let's check — the current implementation uses `s.name` as map key, which would collapse both "main" sessions into one. If prev has count 0 for "main" and next has count 3 for "main", it would fire once. But we need to make sure it uses `sessionKey` for accurate tracking. + +**Step 3: Update `detectBellTransitions`** + +In `muxplex/frontend/app.js`, replace the `detectBellTransitions` function: + +```javascript +function detectBellTransitions(prev, next) { + var prevMap = new Map( + (prev || []).map(function(s) { + return [s.sessionKey || s.name, (s.bell && s.bell.unseen_count) || 0]; + }), + ); + return (next || []) + .filter(function(s) { + var unseen = s.bell && s.bell.unseen_count; + if (!unseen || unseen <= 0) return false; + var key = s.sessionKey || s.name; + var prevCount = prevMap.has(key) ? prevMap.get(key) : 0; + return unseen > prevCount; + }) + .map(function(s) { return s.name; }); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -5 +``` + +Expected: All tests pass (existing tests don't set sessionKey, so fallback to `s.name` still works). + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: detectBellTransitions uses sessionKey to distinguish cross-device sessions" +``` + +--- + +## Task 19: Full integration test — all exports present + +**Files:** +- Test: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Write the integration test** + +Add to end of `test_app.mjs`: + +```javascript +// --- Phase 2 integration: all new exports present --- + +test('app.js exports all Phase 2 federation functions', () => { + const phase2Functions = [ + 'api', + 'buildSources', + 'tagSessions', + 'mergeSources', + 'getVisibleSessions', + 'renderGroupedGrid', + 'renderFilterBar', + 'loadGridViewMode', + 'saveGridViewMode', + '_setSources', + '_getSources', + '_setServerSettings', + '_getGridViewMode', + '_setGridViewMode', + '_setActiveFilterDevice', + ]; + + for (const fn of phase2Functions) { + assert.ok(fn in app, `app.js should export "${fn}"`); + assert.strictEqual(typeof app[fn], 'function', `"${fn}" should be a function`); + } +}); + +test('Phase 2 end-to-end: buildSources → tagSessions → mergeSources produces valid merged list', () => { + const settings = { + device_name: 'Laptop', + remote_instances: [{ url: 'http://work:8088', name: 'Work' }], + }; + const sources = app.buildSources(settings); + assert.strictEqual(sources.length, 2); + + const localSessions = [{ name: 'dev' }]; + const remoteSessions = [{ name: 'dev' }, { name: 'prod' }]; + + const results = [ + { source: sources[0], sessions: localSessions }, + { source: sources[1], sessions: remoteSessions }, + ]; + const merged = app.mergeSources(results); + + assert.strictEqual(merged.length, 3); + // Both "dev" sessions should have different sessionKeys + const devSessions = merged.filter(function(s) { return s.name === 'dev'; }); + assert.strictEqual(devSessions.length, 2); + assert.notStrictEqual(devSessions[0].sessionKey, devSessions[1].sessionKey); +}); +``` + +**Step 2: Run all tests** + +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -10 +``` + +Expected: All tests pass. + +**Step 3: Commit** + +```bash +cd muxplex && git add muxplex/frontend/tests/test_app.mjs && git commit -m "test: add Phase 2 integration tests for federation exports and end-to-end flow" +``` + +--- + +## Summary Checklist + +| Task | What it does | Key files | +|------|-------------|-----------| +| 1 | `api()` accepts `baseUrl` for cross-origin | `app.js` | +| 2 | `buildSources()` from settings | `app.js` | +| 3 | Test-only helpers for new state | `app.js` | +| 4 | `tagSessions()` + `mergeSources()` | `app.js` | +| 5 | Rewrite `pollSessions()` for multi-source | `app.js` | +| 6 | Wire sources into startup | `app.js` | +| 7 | `getVisibleSessions()` federation-aware | `app.js` | +| 8 | CSS for badges, headers, pills | `style.css` | +| 9 | Device badge on tiles | `app.js` | +| 10 | Device badge on sidebar items | `app.js` | +| 11 | Grouped view mode | `app.js` | +| 12 | Filtered view mode + pill bar | `app.js`, `index.html` | +| 13 | View preference storage | `app.js` | +| 14 | Settings: view mode + scope | `index.html`, `app.js` | +| 15 | Settings: remote instances | `index.html`, `app.js`, `style.css` | +| 16 | Sidebar device grouping | `app.js` | +| 17 | `openSession` sourceUrl passthrough | `app.js` | +| 18 | `detectBellTransitions` sessionKey | `app.js` | +| 19 | Integration test — all exports | `test_app.mjs` | + +**Phase 2 end state:** Dashboard shows sessions from multiple instances with device tags and three view modes. Clicking a remote tile opens the fullscreen view but does NOT connect a terminal yet (Phase 3). Remote sources that return 401 are tracked as `auth_required` but no login UI is shown. \ No newline at end of file diff --git a/docs/plans/2026-03-30-multi-device-federation-phase3.md b/docs/plans/2026-03-30-multi-device-federation-phase3.md new file mode 100644 index 0000000..c608f8f --- /dev/null +++ b/docs/plans/2026-03-30-multi-device-federation-phase3.md @@ -0,0 +1,1321 @@ +# Multi-Device Federation Phase 3: Remote Auth + Terminal + Unreachable States + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Make remote sessions fully interactive — detect auth requirements, present login flow, show unreachable placeholders, and connect terminals to remote instances. + +**Architecture:** Phase 2 left sources in three status states (`authenticated | auth_required | unreachable`) but only rendered sessions for `authenticated` sources. Phase 3 adds rendering for the other two states (auth-required tiles with login buttons, offline placeholder tiles with last-seen timestamps), implements the popup login flow, makes `terminal.js` connect WebSockets to remote origins, and updates `openSession`/`closeSession` to route API calls to the correct instance. + +**Tech Stack:** Vanilla JS (no framework), CSS custom properties, Node.js `--test` runner with `assert`, `window.open()` for login popups, cross-origin `fetch` with `credentials: "include"`. + +--- + +## Conventions (read this before you start) + +**File paths are relative to the repo root** (`muxplex/`). + +**How to run tests:** +```bash +cd muxplex && node --test muxplex/frontend/tests/test_app.mjs +cd muxplex && node --test muxplex/frontend/tests/test_terminal.mjs +``` + +**Test pattern:** Every test file uses `.mjs` extension, imports via `createRequire`, and mocks `globalThis.document`, `globalThis.fetch`, `globalThis.window`, etc. before requiring the module. See the top of `muxplex/frontend/tests/test_app.mjs` for the full mock setup. + +**CommonJS export shim:** `app.js` ends with a `module.exports = { ... }` block wrapped in `if (typeof module !== 'undefined' && module.exports)`. Any new function you want to test **must** be added to that exports object at the bottom of `app.js` (around line 1660). + +**CSS design tokens:** The file `muxplex/frontend/style.css` defines tokens on `:root`. Key ones: `--bg: #0D1117`, `--accent: #00D9F5`, `--text-dim: #4A5060`, `--text-muted: #8E95A3`, `--border: #2A3040`, `--err: #f85149`, `--warn: #d29922`, `--ok: #3fb950`. + +**Phase 2 assumptions:** The plan assumes Phase 2 left these in place: +- `_sources` array with `{ url, name, type, status, lastSeenAt, backoff }` per source +- `pollSessions()` does `Promise.all` across sources, sets `source.status` to `authenticated | auth_required | unreachable` +- Each session object has `deviceName`, `sourceUrl`, `sessionKey` fields +- Device badge HTML in `buildTileHTML` (something like `Laptop`) +- `_sources` is accessible from tests via a `_setSources` or `_getSources` test helper + +If any of these are named slightly differently in the actual Phase 2 code, adjust accordingly — the logic is the same. + +--- + +### Task 1: CSS for auth-required and offline tile states + +**Files:** +- Modify: `muxplex/frontend/style.css` + +Phase 3 tiles need two new visual states: a greyed-out "offline" tile and an "auth required" tile with a login button. Add these styles to the end of `style.css`, before the `@media (prefers-reduced-motion)` block. + +**Step 1: Add the CSS** + +Append these rules to `muxplex/frontend/style.css`, just before the `/* Reduced Motion */` media query (currently around line 798): + +```css +/* ============================================================ + Federation: source status tiles (auth-required + offline) + ============================================================ */ + +.source-tile { + height: var(--tile-height); + background: var(--bg-tile); + border: 1px solid var(--border); + border-radius: 4px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + text-align: center; + padding: 24px; +} + +/* Offline / unreachable device tile */ +.source-tile--offline { + opacity: 0.45; + border-style: dashed; +} + +.source-tile--offline .source-tile__name { + color: var(--text-dim); +} + +.source-tile--offline .source-tile__badge { + background: var(--err); + color: #fff; + font-size: 10px; + font-weight: 700; + padding: 2px 8px; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.source-tile--offline .source-tile__last-seen { + font-size: 11px; + color: var(--text-dim); +} + +/* Auth-required device tile */ +.source-tile--auth { + border-color: var(--warn); + border-style: dashed; +} + +.source-tile__name { + font-size: 15px; + font-weight: 600; + color: var(--text); +} + +.source-tile__login-btn { + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 4px; + padding: 8px 20px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity var(--t-fast); +} + +.source-tile__login-btn:hover { + opacity: 0.85; +} + +.source-tile__login-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.source-tile__hint { + font-size: 11px; + color: var(--text-muted); +} +``` + +**Step 2: Verify the CSS file is valid** + +Open the file and visually confirm the new classes are syntactically correct (no unclosed braces): + +```bash +cd muxplex && grep -c 'source-tile' muxplex/frontend/style.css +``` + +Expected: a count of ~20+ (the lines we just added). + +**Step 3: Commit** + +```bash +cd muxplex && git add muxplex/frontend/style.css && git commit -m "feat(federation): add CSS for auth-required and offline source tiles" +``` + +--- + +### Task 2: `buildAuthTileHTML` — render an auth-required tile + +**Files:** +- Modify: `muxplex/frontend/app.js` (add function + export) +- Modify: `muxplex/frontend/tests/test_app.mjs` (add tests) + +This function builds the HTML for a single auth-required source tile. It shows the device name and a "Log in" button. + +**Step 1: Write the failing tests** + +Add these tests to the end of `muxplex/frontend/tests/test_app.mjs` (before the final blank lines): + +```javascript +// --- buildAuthTileHTML --- + +test('buildAuthTileHTML is exported as a function', () => { + assert.strictEqual(typeof app.buildAuthTileHTML, 'function'); +}); + +test('buildAuthTileHTML returns article with source-tile--auth class', () => { + const html = app.buildAuthTileHTML({ name: 'Workstation', url: 'http://work:8088' }); + assert.ok(html.includes('source-tile--auth'), 'must have source-tile--auth class'); + assert.ok(html.startsWith(' element'); +}); + +test('buildAuthTileHTML includes device name', () => { + const html = app.buildAuthTileHTML({ name: 'Dev Server', url: 'http://dev:8088' }); + assert.ok(html.includes('Dev Server'), 'must include the device name'); +}); + +test('buildAuthTileHTML includes login button with data-url attribute', () => { + const html = app.buildAuthTileHTML({ name: 'Dev', url: 'http://dev:8088' }); + assert.ok(html.includes('source-tile__login-btn'), 'must have login button'); + assert.ok(html.includes('data-url="http://dev:8088"'), 'button must have data-url'); +}); + +test('buildAuthTileHTML escapes HTML in device name', () => { + const html = app.buildAuthTileHTML({ name: '', url: 'http://x' }); + assert.ok(!html.includes('