diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 15161a6..7341654 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -762,6 +762,7 @@ function renderGrid(sessions) { (sessions || []).forEach(function(session) { if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth'); else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline'); + else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty'); }); if (grid) grid.innerHTML = statusTilesHtml; // Only show empty-state when there are truly no tiles at all @@ -800,11 +801,12 @@ function renderGrid(sessions) { html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join(''); } - // Append status tiles for auth_failed and unreachable sessions + // Append status tiles for auth_failed, unreachable, and empty sessions var statusTilesHtml = ''; (sessions || []).forEach(function(session) { if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth'); else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline'); + else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty'); }); if (grid) grid.innerHTML = html + statusTilesHtml; diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index 1de6d7c..4f0cae1 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -1002,6 +1002,11 @@ body { border-style: dashed; } +.source-tile--empty { + opacity: 0.6; + border-style: dashed; +} + .source-tile__name { font-size: 15px; font-weight: 600; diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 40b99d5..c44c788 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -4758,3 +4758,35 @@ test('renderGrid status tiles use session.deviceName not session.name for offlin globalThis.document.getElementById = origGetById; }); + +// --- renderGrid: status=empty shows "No sessions" tile --- + +test('renderGrid shows "No sessions" status tile for status=empty devices', () => { + // A device that is online but has zero tmux sessions returns + // {status: 'empty', deviceName: '...'} from the federation endpoint. + // renderGrid must render a status tile with the text "No sessions" (not blank). + // + // Before implementation: fails because neither status loop handles status === 'empty', + // so the tile is never rendered and grid.innerHTML stays empty. + const grid = { innerHTML: '' }; + const emptyState = { style: {}, classList: { add() {}, remove() {} } }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'session-grid') return grid; + if (id === 'empty-state') return emptyState; + return null; + }; + + app.renderGrid([{ status: 'empty', deviceName: 'quiet-box', remoteId: 3 }]); + + assert.ok( + grid.innerHTML.includes('No sessions'), + `renderGrid must include "No sessions" text for status=empty device, got: ${grid.innerHTML}` + ); + assert.ok( + grid.innerHTML.includes('quiet-box'), + `renderGrid must include the deviceName "quiet-box" in the status tile, got: ${grid.innerHTML}` + ); + + globalThis.document.getElementById = origGetById; +}); diff --git a/muxplex/main.py b/muxplex/main.py index 1e7617b..9f8254a 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -1182,6 +1182,13 @@ async def auth_mode_endpoint(): return {"mode": _auth_mode, "user": username} +# Module-level cache: remote_id → {"sessions": [...], "fail_count": int} +# Populated by fetch_remote() on every successful poll; returned on transient failures +# so a single slow/dropped request doesn't immediately evict a device from the UI. +_federation_cache: dict[int, dict] = {} +_FEDERATION_GRACE_FAILURES = 3 # consecutive failures before marking unreachable + + @app.get("/api/federation/sessions") async def federation_sessions(request: Request) -> list[dict]: """Fetch sessions from all instances (local + remotes) and merge. @@ -1220,7 +1227,12 @@ async def federation_sessions(request: Request) -> list[dict]: http_client: httpx.AsyncClient = request.app.state.federation_client async def fetch_remote(i: int, remote: dict) -> list[dict]: - """Fetch /api/sessions from a remote instance, returning session dicts or a status entry.""" + """Fetch /api/sessions from a remote instance, returning session dicts or a status entry. + + On success: cache the result and return tagged sessions (or {status: 'empty'} if none). + On transient failure: return cached sessions for up to _FEDERATION_GRACE_FAILURES + consecutive failures before promoting to {status: 'unreachable'}. + """ url: str = remote.get("url", "") key: str = remote.get("key", "") remote_name: str = remote.get("name", url) @@ -1231,6 +1243,8 @@ async def federation_sessions(request: Request) -> list[dict]: headers={"Authorization": f"Bearer {key}"} if key else {}, ) if resp.status_code in (401, 403): + # Auth failure — clear cache so stale data is not served + _federation_cache.pop(remote_id, None) return [ { "status": "auth_failed", @@ -1241,7 +1255,7 @@ async def federation_sessions(request: Request) -> list[dict]: resp.raise_for_status() sessions = resp.json() # Tag each session with deviceName, remoteId, and unique sessionKey - return [ + tagged = [ { **s, "deviceName": remote_name, @@ -1250,7 +1264,24 @@ async def federation_sessions(request: Request) -> list[dict]: } for s in sessions ] + # Update cache on every successful poll (even empty) + _federation_cache[remote_id] = {"sessions": tagged, "fail_count": 0} + if not tagged: + # Device is online but has zero tmux sessions — show a status tile + # rather than making the device completely invisible. + return [ + { + "status": "empty", + "remoteId": remote_id, + "deviceName": remote_name, + } + ] + return tagged except httpx.HTTPStatusError: + cached = _federation_cache.get(remote_id) + if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES: + cached["fail_count"] += 1 + return cached["sessions"] return [ { "status": "unreachable", @@ -1260,6 +1291,10 @@ async def federation_sessions(request: Request) -> list[dict]: ] except Exception as exc: _log.warning("Unexpected error fetching remote %s: %s", url, exc) + cached = _federation_cache.get(remote_id) + if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES: + cached["fail_count"] += 1 + return cached["sessions"] return [ { "status": "unreachable", diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index eee77c6..e7082d3 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -41,6 +41,22 @@ def patch_startup_and_state(tmp_path, monkeypatch): monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop) +@pytest.fixture(autouse=True) +def reset_federation_cache(): + """Clear _federation_cache before and after each test. + + The module-level _federation_cache persists across tests in the same process, + causing cross-test contamination: a test that populates the cache for remoteId=0 + causes a later test (also using remoteId=0) to get stale cached data instead of + the expected unreachable status. + """ + import muxplex.main as main_mod + + main_mod._federation_cache.clear() + yield + main_mod._federation_cache.clear() + + # --------------------------------------------------------------------------- # Client fixture — TestClient with lifespan enabled # --------------------------------------------------------------------------- @@ -3036,3 +3052,215 @@ def test_put_settings_sync_ignores_nonsyncable_keys(client, tmp_path, monkeypatc assert local["host"] == "127.0.0.1", ( f"Non-syncable key 'host' must remain '127.0.0.1', got: {local['host']}" ) + + +# --------------------------------------------------------------------------- +# fetch_remote: zero-session visibility and flapping grace period +# --------------------------------------------------------------------------- + + +def test_fetch_remote_returns_empty_status_for_zero_sessions( + client, monkeypatch, tmp_path +): + """When remote /api/sessions returns [], federation returns {status: 'empty'} entry. + + A device that is online but has zero tmux sessions must not vanish silently. + Instead, the endpoint must include a {status: 'empty', deviceName: ...} entry + so the frontend can render a 'No sessions' tile. + + Before implementation: fails because the list comprehension returns [] for empty + session lists, and the empty list is flattened into nothing. + """ + import json + from unittest.mock import MagicMock + + import httpx + + 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( + { + "remote_instances": [ + { + "url": "http://empty-host:8088", + "key": "secret", + "name": "empty-host", + } + ] + } + ) + ) + monkeypatch.setattr("muxplex.main.get_session_list", lambda: []) + monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {}) + + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.json.return_value = [] + mock_resp.raise_for_status.return_value = None + + async def mock_get(*args, **kwargs): + return mock_resp + + mock_fed_client = MagicMock() + mock_fed_client.get = mock_get + monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client) + + response = client.get("/api/federation/sessions") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1, ( + f"Expected exactly one status entry for empty remote, got {len(data)} entries: {data}" + ) + assert data[0].get("status") == "empty", ( + f"Expected status='empty' for zero-session remote, got: {data[0].get('status')!r}" + ) + assert data[0].get("deviceName") == "empty-host", ( + f"Expected deviceName='empty-host', got: {data[0].get('deviceName')!r}" + ) + + +def test_fetch_remote_uses_cache_on_transient_failure(client, monkeypatch, tmp_path): + """When remote fails after a prior success, cached sessions are returned (grace period). + + A single failed HTTP request must not immediately evict the device from the UI. + The server keeps the last-known-good result and returns it for up to + _FEDERATION_GRACE_FAILURES consecutive failures. + + Before implementation: fails because fetch_remote has no cache — transient failure + immediately returns {status: 'unreachable'}. + """ + import json + from unittest.mock import MagicMock + + import httpx + + import muxplex.main as main_mod + import muxplex.settings as settings_mod + + # Reset module-level cache so this test starts clean + monkeypatch.setattr(main_mod, "_federation_cache", {}) + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text( + json.dumps( + { + "remote_instances": [ + {"url": "http://remote:8088", "key": "k", "name": "cache-host"} + ] + } + ) + ) + monkeypatch.setattr("muxplex.main.get_session_list", lambda: []) + monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {}) + + call_count = [0] + + async def mock_get_stateful(*args, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.json.return_value = [{"name": "sess1"}, {"name": "sess2"}] + mock_resp.raise_for_status.return_value = None + return mock_resp + raise httpx.TimeoutException("timeout", request=MagicMock()) + + mock_fed_client = MagicMock() + mock_fed_client.get = mock_get_stateful + monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client) + + # First call: succeeds and populates cache + r1 = client.get("/api/federation/sessions") + assert r1.status_code == 200 + d1 = [s for s in r1.json() if s.get("deviceName") == "cache-host"] + assert len(d1) == 2, f"First call must return 2 sessions, got {d1}" + + # Second call: remote times out — cache should return the 2 cached sessions + r2 = client.get("/api/federation/sessions") + assert r2.status_code == 200 + d2 = [s for s in r2.json() if s.get("deviceName") == "cache-host"] + assert len(d2) == 2, f"Within grace period, must return 2 cached sessions, got {d2}" + assert not any(s.get("status") == "unreachable" for s in d2), ( + "Within grace period, cached sessions must be returned, not 'unreachable'" + ) + + +def test_fetch_remote_marks_unreachable_after_grace_period( + client, monkeypatch, tmp_path +): + """After _FEDERATION_GRACE_FAILURES consecutive failures, device is marked unreachable. + + The grace period prevents flapping, but must not hide a genuinely offline device + indefinitely. After 3 consecutive failures the next poll must return + {status: 'unreachable'}. + + Before implementation: fails because there is no cache at all — unreachable is + returned immediately on first failure. + """ + import json + from unittest.mock import MagicMock + + import httpx + + import muxplex.main as main_mod + import muxplex.settings as settings_mod + + # Reset module-level cache so this test starts clean + monkeypatch.setattr(main_mod, "_federation_cache", {}) + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text( + json.dumps( + { + "remote_instances": [ + {"url": "http://remote:8088", "key": "k", "name": "grace-host"} + ] + } + ) + ) + monkeypatch.setattr("muxplex.main.get_session_list", lambda: []) + monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {}) + + call_count = [0] + + async def mock_get_stateful(*args, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.json.return_value = [{"name": "sess1"}] + mock_resp.raise_for_status.return_value = None + return mock_resp + raise httpx.TimeoutException("timeout", request=MagicMock()) + + mock_fed_client = MagicMock() + mock_fed_client.get = mock_get_stateful + monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client) + + # Call 1: success — populates cache + r = client.get("/api/federation/sessions") + d = r.json() + assert any(s.get("name") == "sess1" for s in d), "Call 1 must return sess1" + + # Calls 2-4 (failures 1-3): within grace period — must return cached sessions + for i in range(3): + r = client.get("/api/federation/sessions") + d = r.json() + host_entries = [s for s in d if s.get("deviceName") == "grace-host"] + assert not any(s.get("status") == "unreachable" for s in host_entries), ( + f"Call {i + 2}: fail_count={i + 1} is within grace period — " + f"must return cached sessions, not 'unreachable'. Got: {host_entries}" + ) + + # Call 5 (failure 4): exceeds grace period — must return unreachable + r = client.get("/api/federation/sessions") + d = r.json() + host_entries = [s for s in d if s.get("deviceName") == "grace-host"] + assert any(s.get("status") == "unreachable" for s in host_entries), ( + f"After exceeding grace period, device must be marked 'unreachable'. Got: {host_entries}" + )