feat: tag sessions with device_id-based sessionKey in federation_sessions
This commit is contained in:
+23
-15
@@ -1217,10 +1217,10 @@ async def auth_mode_endpoint():
|
|||||||
return {"mode": _auth_mode, "user": username}
|
return {"mode": _auth_mode, "user": username}
|
||||||
|
|
||||||
|
|
||||||
# Module-level cache: remote_id → {"sessions": [...], "fail_count": int}
|
# Module-level cache: remote_device_id → {"sessions": [...], "fail_count": int}
|
||||||
# Populated by fetch_remote() on every successful poll; returned on transient failures
|
# 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.
|
# so a single slow/dropped request doesn't immediately evict a device from the UI.
|
||||||
_federation_cache: dict[int, dict] = {}
|
_federation_cache: dict[str, dict] = {}
|
||||||
_FEDERATION_GRACE_FAILURES = 3 # consecutive failures before marking unreachable
|
_FEDERATION_GRACE_FAILURES = 3 # consecutive failures before marking unreachable
|
||||||
|
|
||||||
|
|
||||||
@@ -1235,9 +1235,10 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|||||||
"""
|
"""
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
local_device_name: str = settings.get("device_name", "")
|
local_device_name: str = settings.get("device_name", "")
|
||||||
|
local_device_id: str = load_device_id()
|
||||||
remote_instances: list[dict] = settings.get("remote_instances", [])
|
remote_instances: list[dict] = settings.get("remote_instances", [])
|
||||||
|
|
||||||
# Build local sessions with deviceName/remoteId tags
|
# Build local sessions with deviceId/deviceName/remoteId/sessionKey tags
|
||||||
names = get_session_list()
|
names = get_session_list()
|
||||||
snapshots = get_snapshots()
|
snapshots = get_snapshots()
|
||||||
state = await read_state()
|
state = await read_state()
|
||||||
@@ -1250,8 +1251,10 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|||||||
"name": name,
|
"name": name,
|
||||||
"snapshot": snapshots.get(name, ""),
|
"snapshot": snapshots.get(name, ""),
|
||||||
"bell": bell,
|
"bell": bell,
|
||||||
|
"deviceId": local_device_id,
|
||||||
"deviceName": local_device_name,
|
"deviceName": local_device_name,
|
||||||
"remoteId": None,
|
"remoteId": None,
|
||||||
|
"sessionKey": f"{local_device_id}:{name}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1271,7 +1274,7 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|||||||
url: str = remote.get("url", "")
|
url: str = remote.get("url", "")
|
||||||
key: str = remote.get("key", "")
|
key: str = remote.get("key", "")
|
||||||
remote_name: str = remote.get("name", url)
|
remote_name: str = remote.get("name", url)
|
||||||
remote_id: int = i
|
remote_device_id: str = remote.get("device_id", str(i))
|
||||||
try:
|
try:
|
||||||
resp = await http_client.get(
|
resp = await http_client.get(
|
||||||
f"{url.rstrip('/')}/api/sessions",
|
f"{url.rstrip('/')}/api/sessions",
|
||||||
@@ -1279,61 +1282,66 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|||||||
)
|
)
|
||||||
if resp.status_code in (401, 403):
|
if resp.status_code in (401, 403):
|
||||||
# Auth failure — clear cache so stale data is not served
|
# Auth failure — clear cache so stale data is not served
|
||||||
_federation_cache.pop(remote_id, None)
|
_federation_cache.pop(remote_device_id, None)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"status": "auth_failed",
|
"status": "auth_failed",
|
||||||
"remoteId": remote_id,
|
"deviceId": remote_device_id,
|
||||||
|
"remoteId": remote_device_id,
|
||||||
"deviceName": remote_name,
|
"deviceName": remote_name,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
sessions = resp.json()
|
sessions = resp.json()
|
||||||
# Tag each session with deviceName, remoteId, and unique sessionKey
|
# Tag each session with deviceId, deviceName, remoteId, and unique sessionKey
|
||||||
tagged = [
|
tagged = [
|
||||||
{
|
{
|
||||||
**s,
|
**s,
|
||||||
|
"deviceId": remote_device_id,
|
||||||
"deviceName": remote_name,
|
"deviceName": remote_name,
|
||||||
"remoteId": remote_id,
|
"remoteId": remote_device_id,
|
||||||
"sessionKey": f"{remote_id}:{s.get('name', '')}",
|
"sessionKey": f"{remote_device_id}:{s.get('name', '')}",
|
||||||
}
|
}
|
||||||
for s in sessions
|
for s in sessions
|
||||||
]
|
]
|
||||||
# Update cache on every successful poll (even empty)
|
# Update cache on every successful poll (even empty)
|
||||||
_federation_cache[remote_id] = {"sessions": tagged, "fail_count": 0}
|
_federation_cache[remote_device_id] = {"sessions": tagged, "fail_count": 0}
|
||||||
if not tagged:
|
if not tagged:
|
||||||
# Device is online but has zero tmux sessions — show a status tile
|
# Device is online but has zero tmux sessions — show a status tile
|
||||||
# rather than making the device completely invisible.
|
# rather than making the device completely invisible.
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"status": "empty",
|
"status": "empty",
|
||||||
"remoteId": remote_id,
|
"deviceId": remote_device_id,
|
||||||
|
"remoteId": remote_device_id,
|
||||||
"deviceName": remote_name,
|
"deviceName": remote_name,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
return tagged
|
return tagged
|
||||||
except httpx.HTTPStatusError:
|
except httpx.HTTPStatusError:
|
||||||
cached = _federation_cache.get(remote_id)
|
cached = _federation_cache.get(remote_device_id)
|
||||||
if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES:
|
if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES:
|
||||||
cached["fail_count"] += 1
|
cached["fail_count"] += 1
|
||||||
return cached["sessions"]
|
return cached["sessions"]
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"status": "unreachable",
|
"status": "unreachable",
|
||||||
"remoteId": remote_id,
|
"deviceId": remote_device_id,
|
||||||
|
"remoteId": remote_device_id,
|
||||||
"deviceName": remote_name,
|
"deviceName": remote_name,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.warning("Unexpected error fetching remote %s: %s", url, exc)
|
_log.warning("Unexpected error fetching remote %s: %s", url, exc)
|
||||||
cached = _federation_cache.get(remote_id)
|
cached = _federation_cache.get(remote_device_id)
|
||||||
if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES:
|
if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES:
|
||||||
cached["fail_count"] += 1
|
cached["fail_count"] += 1
|
||||||
return cached["sessions"]
|
return cached["sessions"]
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"status": "unreachable",
|
"status": "unreachable",
|
||||||
"remoteId": remote_id,
|
"deviceId": remote_device_id,
|
||||||
|
"remoteId": remote_device_id,
|
||||||
"deviceName": remote_name,
|
"deviceName": remote_name,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+80
-21
@@ -1754,11 +1754,11 @@ def test_federation_sessions_returns_local_sessions(client, monkeypatch, tmp_pat
|
|||||||
|
|
||||||
|
|
||||||
def test_federation_sessions_remote_id_is_integer_index(client, monkeypatch, tmp_path):
|
def test_federation_sessions_remote_id_is_integer_index(client, monkeypatch, tmp_path):
|
||||||
"""GET /api/federation/sessions returns integer remoteId (index) for remote sessions.
|
"""GET /api/federation/sessions returns device_id-based remoteId for remote sessions.
|
||||||
|
|
||||||
remoteId must be the enumerate index (0, 1, 2...) of the remote in
|
When a remote doesn't have a device_id field, remoteId falls back to str(index)
|
||||||
remote_instances -- NOT the URL string and NOT any 'id' field from the
|
(e.g. '0', '1', '2'...) -- NOT the URL string and NOT any 'id' field from the
|
||||||
remote config dict.
|
remote config dict. When device_id is set, it is used directly.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -1823,37 +1823,43 @@ def test_federation_sessions_remote_id_is_integer_index(client, monkeypatch, tmp
|
|||||||
(s for s in remote_entries if s.get("deviceName") == "spark-2"), None
|
(s for s in remote_entries if s.get("deviceName") == "spark-2"), None
|
||||||
)
|
)
|
||||||
assert spark2_session is not None, "Expected a session entry from spark-2"
|
assert spark2_session is not None, "Expected a session entry from spark-2"
|
||||||
assert spark2_session["remoteId"] == 0, (
|
assert spark2_session["remoteId"] == "0", (
|
||||||
f"remoteId for first remote (index 0) must be integer 0, "
|
f"remoteId for first remote (no device_id, index 0) must be string '0', "
|
||||||
f"got: {spark2_session['remoteId']!r}"
|
f"got: {spark2_session['remoteId']!r}"
|
||||||
)
|
)
|
||||||
assert isinstance(spark2_session["remoteId"], int), (
|
assert isinstance(spark2_session["remoteId"], str), (
|
||||||
f"remoteId must be an int, got {type(spark2_session['remoteId'])}"
|
f"remoteId must be a str, got {type(spark2_session['remoteId'])}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# The unreachable remote (spark-3, index 1) must have remoteId == 1
|
# The unreachable remote (spark-3, index 1) must have remoteId == '1'
|
||||||
spark3_entry = next(
|
spark3_entry = next(
|
||||||
(s for s in remote_entries if s.get("deviceName") == "spark-3"), None
|
(s for s in remote_entries if s.get("deviceName") == "spark-3"), None
|
||||||
)
|
)
|
||||||
assert spark3_entry is not None, "Expected a status entry from spark-3"
|
assert spark3_entry is not None, "Expected a status entry from spark-3"
|
||||||
assert spark3_entry["remoteId"] == 1, (
|
assert spark3_entry["remoteId"] == "1", (
|
||||||
f"remoteId for second remote (index 1) must be integer 1, "
|
f"remoteId for second remote (no device_id, index 1) must be string '1', "
|
||||||
f"got: {spark3_entry['remoteId']!r}"
|
f"got: {spark3_entry['remoteId']!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_federation_sessions_local_sessions_have_no_session_key(
|
def test_federation_sessions_local_sessions_have_session_key(
|
||||||
client, monkeypatch, tmp_path
|
client, monkeypatch, tmp_path
|
||||||
):
|
):
|
||||||
"""GET /api/federation/sessions: local sessions must NOT have a sessionKey field.
|
"""GET /api/federation/sessions: local sessions must have sessionKey = 'deviceId:name'.
|
||||||
|
|
||||||
Local sessions use name as the unique key — no prefix needed since they
|
Local sessions are tagged with sessionKey = f'{local_device_id}:{name}' so they
|
||||||
never collide with themselves.
|
can be uniquely identified in the merged multi-device session list.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import muxplex.identity as identity_mod
|
||||||
import muxplex.settings as settings_mod
|
import muxplex.settings as settings_mod
|
||||||
|
|
||||||
|
# Redirect identity file to a known UUID
|
||||||
|
identity_path = tmp_path / "identity.json"
|
||||||
|
identity_path.write_text(json.dumps({"device_id": "test-local-id"}))
|
||||||
|
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", identity_path)
|
||||||
|
|
||||||
settings_path = tmp_path / "settings.json"
|
settings_path = tmp_path / "settings.json"
|
||||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||||
|
|
||||||
@@ -1875,8 +1881,12 @@ def test_federation_sessions_local_sessions_have_no_session_key(
|
|||||||
assert len(local_sessions) == 2, f"Expected 2 local sessions, got: {local_sessions}"
|
assert len(local_sessions) == 2, f"Expected 2 local sessions, got: {local_sessions}"
|
||||||
|
|
||||||
for local in local_sessions:
|
for local in local_sessions:
|
||||||
assert "sessionKey" not in local, (
|
assert "sessionKey" in local, (
|
||||||
f"Local session must NOT have sessionKey field, but got: {local}"
|
f"Local session must have sessionKey field, but got: {local}"
|
||||||
|
)
|
||||||
|
expected_key = f"test-local-id:{local['name']}"
|
||||||
|
assert local["sessionKey"] == expected_key, (
|
||||||
|
f"Local sessionKey must be '{expected_key}', got: {local['sessionKey']!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2030,7 +2040,9 @@ def test_federation_sessions_includes_remote_failure_status(
|
|||||||
)
|
)
|
||||||
entry = failure_entries[0]
|
entry = failure_entries[0]
|
||||||
assert entry["status"] == "unreachable"
|
assert entry["status"] == "unreachable"
|
||||||
assert entry.get("remoteId") == 0 # integer index, not the "id" field string
|
assert (
|
||||||
|
entry.get("remoteId") == "0"
|
||||||
|
) # device_id string (fallback to str(index) when no device_id)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -3426,6 +3438,55 @@ def test_federation_connect_by_device_id(client, monkeypatch, tmp_path):
|
|||||||
assert data["active_session"] == "my-session"
|
assert data["active_session"] == "my-session"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task-10: Tag Sessions with device_id
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_federation_sessions_tags_local_with_device_id(client, monkeypatch, tmp_path):
|
||||||
|
"""GET /api/federation/sessions: local sessions have deviceId and sessionKey from identity.
|
||||||
|
|
||||||
|
The local device_id is loaded from the identity file and used to:
|
||||||
|
- tag each local session with deviceId: local_device_id
|
||||||
|
- build sessionKey: f'{local_device_id}:{name}'
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import muxplex.identity as identity_mod
|
||||||
|
import muxplex.settings as settings_mod
|
||||||
|
|
||||||
|
# Redirect identity file to a known UUID
|
||||||
|
identity_path = tmp_path / "identity.json"
|
||||||
|
identity_path.write_text(json.dumps({"device_id": "local-uuid"}))
|
||||||
|
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", identity_path)
|
||||||
|
|
||||||
|
# Set up settings with no remote instances
|
||||||
|
settings_path = tmp_path / "settings.json"
|
||||||
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||||
|
settings_path.write_text(
|
||||||
|
json.dumps({"device_name": "my-machine", "remote_instances": []})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock local session list
|
||||||
|
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["dev"])
|
||||||
|
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {"dev": ""})
|
||||||
|
|
||||||
|
response = client.get("/api/federation/sessions")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
local_sessions = [s for s in data if s.get("remoteId") is None]
|
||||||
|
assert len(local_sessions) == 1, f"Expected 1 local session, got: {local_sessions}"
|
||||||
|
|
||||||
|
local = local_sessions[0]
|
||||||
|
assert local.get("deviceId") == "local-uuid", (
|
||||||
|
f"Local session must have deviceId='local-uuid', got: {local.get('deviceId')!r}"
|
||||||
|
)
|
||||||
|
assert local.get("sessionKey") == "local-uuid:dev", (
|
||||||
|
f"Local session must have sessionKey='local-uuid:dev', got: {local.get('sessionKey')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_federation_connect_device_id_not_found(client, monkeypatch, tmp_path):
|
def test_federation_connect_device_id_not_found(client, monkeypatch, tmp_path):
|
||||||
"""POST /api/federation/{device_id}/connect/{session_name} returns 404 when device_id has no match.
|
"""POST /api/federation/{device_id}/connect/{session_name} returns 404 when device_id has no match.
|
||||||
|
|
||||||
@@ -3442,9 +3503,7 @@ def test_federation_connect_device_id_not_found(client, monkeypatch, tmp_path):
|
|||||||
# No remotes configured — any device_id lookup returns None
|
# No remotes configured — any device_id lookup returns None
|
||||||
settings_path.write_text(json.dumps({"remote_instances": []}))
|
settings_path.write_text(json.dumps({"remote_instances": []}))
|
||||||
|
|
||||||
response = client.post(
|
response = client.post("/api/federation/nonexistent-device/connect/my-session")
|
||||||
"/api/federation/nonexistent-device/connect/my-session"
|
|
||||||
)
|
|
||||||
assert response.status_code == 404, (
|
assert response.status_code == 404, (
|
||||||
f"Expected 404 for unknown device_id, got {response.status_code}: {response.text}"
|
f"Expected 404 for unknown device_id, got {response.status_code}: {response.text}"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user