From e9655ab61f1049adb3baa9b2276809e26362e8fe Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 1 Aug 2026 22:31:46 -0300 Subject: [PATCH] feat(wolf): show Hermes GPU fallback state --- backend/atlas_portal/ariadne_client.py | 9 +++-- backend/atlas_portal/routes/account_wolf.py | 16 ++++++-- backend/atlas_portal/settings.py | 1 + backend/tests/test_account_wolf.py | 37 +++++++++++-------- frontend/src/account/useWolfDashboard.js | 15 +++++++- frontend/src/components/WolfAccountCard.vue | 14 +++++++ .../frontend/unit/account-dashboard.spec.js | 18 ++++++++- 7 files changed, 86 insertions(+), 24 deletions(-) diff --git a/backend/atlas_portal/ariadne_client.py b/backend/atlas_portal/ariadne_client.py index eb5f865..757c6ab 100644 --- a/backend/atlas_portal/ariadne_client.py +++ b/backend/atlas_portal/ariadne_client.py @@ -43,6 +43,7 @@ def request_raw( *, payload: Any | None = None, params: dict[str, Any] | None = None, + timeout_sec: float | None = None, ) -> httpx.Response: """Send one authenticated request to Ariadne and return the raw response. @@ -55,9 +56,10 @@ def request_raw( url = _url(path) attempts = max(1, settings.ARIADNE_RETRY_COUNT) + request_timeout = timeout_sec if timeout_sec is not None else settings.ARIADNE_TIMEOUT_SEC for attempt in range(1, attempts + 1): try: - with httpx.Client(timeout=settings.ARIADNE_TIMEOUT_SEC) as client: + with httpx.Client(timeout=request_timeout) as client: resp = client.request( method, url, @@ -78,7 +80,7 @@ def request_raw( "method": method, "path": path, "attempt": attempt, - "timeout_sec": settings.ARIADNE_TIMEOUT_SEC, + "timeout_sec": request_timeout, "error": str(exc), }, ) @@ -93,6 +95,7 @@ def proxy( *, payload: Any | None = None, params: dict[str, Any] | None = None, + timeout_sec: float | None = None, ) -> tuple[Any, int]: """Proxy an Ariadne response through Flask as JSON plus status code. @@ -101,7 +104,7 @@ def proxy( """ try: - resp = request_raw(method, path, payload=payload, params=params) + resp = request_raw(method, path, payload=payload, params=params, timeout_sec=timeout_sec) except AriadneError as exc: return jsonify({"error": str(exc)}), exc.status_code diff --git a/backend/atlas_portal/routes/account_wolf.py b/backend/atlas_portal/routes/account_wolf.py index 988eb44..f380b8a 100644 --- a/backend/atlas_portal/routes/account_wolf.py +++ b/backend/atlas_portal/routes/account_wolf.py @@ -5,7 +5,7 @@ from typing import Any from flask import jsonify, request -from .. import ariadne_client +from .. import ariadne_client, settings from ..keycloak import require_auth, require_account_access @@ -142,7 +142,12 @@ def register_account_wolf(app) -> None: ok, resp = _require_account() if not ok: return resp - return ariadne_client.proxy("POST", "/api/admin/game-mode/start", payload=_json_payload()) + return ariadne_client.proxy( + "POST", + "/api/admin/game-mode/start", + payload=_json_payload(), + timeout_sec=settings.ARIADNE_GAME_MODE_TIMEOUT_SEC, + ) @app.route("/api/account/wolf/game-mode/stop", methods=["POST"]) @require_auth @@ -150,7 +155,12 @@ def register_account_wolf(app) -> None: ok, resp = _require_account() if not ok: return resp - return ariadne_client.proxy("POST", "/api/admin/game-mode/stop", payload=_json_payload()) + return ariadne_client.proxy( + "POST", + "/api/admin/game-mode/stop", + payload=_json_payload(), + timeout_sec=settings.ARIADNE_GAME_MODE_TIMEOUT_SEC, + ) @app.route("/api/account/wolf/admin/firewall/unlock", methods=["POST"]) @require_auth diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index ce5a6e4..0c3da27 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -80,6 +80,7 @@ KEYCLOAK_ADMIN_REALM = os.getenv("KEYCLOAK_ADMIN_REALM", KEYCLOAK_REALM) ARIADNE_URL = os.getenv("ARIADNE_URL", "").strip() ARIADNE_TIMEOUT_SEC = float(os.getenv("ARIADNE_TIMEOUT_SEC", "10")) +ARIADNE_GAME_MODE_TIMEOUT_SEC = float(os.getenv("ARIADNE_GAME_MODE_TIMEOUT_SEC", "900")) ARIADNE_RETRY_COUNT = int(os.getenv("ARIADNE_RETRY_COUNT", "2")) ARIADNE_RETRY_BACKOFF_SEC = float(os.getenv("ARIADNE_RETRY_BACKOFF_SEC", "0.2")) diff --git a/backend/tests/test_account_wolf.py b/backend/tests/test_account_wolf.py index c024609..e17c804 100644 --- a/backend/tests/test_account_wolf.py +++ b/backend/tests/test_account_wolf.py @@ -8,13 +8,20 @@ from atlas_portal.routes import account_wolf class DummyAriadne: def __init__(self, enabled: bool = True) -> None: self._enabled = enabled - self.calls: list[tuple[str, str, object | None, dict | None]] = [] + self.calls: list[tuple[str, str, object | None, dict | None, float | None]] = [] def enabled(self) -> bool: return self._enabled - def proxy(self, method: str, path: str, payload: object | None = None, params: dict | None = None): - self.calls.append((method, path, payload, params)) + def proxy( + self, + method: str, + path: str, + payload: object | None = None, + params: dict | None = None, + timeout_sec: float | None = None, + ): + self.calls.append((method, path, payload, params, timeout_sec)) return jsonify({"path": path, "payload": payload, "params": params}) @@ -42,7 +49,7 @@ def test_wolf_status_proxies_source_ip(monkeypatch) -> None: ) assert resp.status_code == 200 - assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "1.2.3.4"})] + assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "1.2.3.4"}, None)] def test_wolf_status_prefers_public_query_ip(monkeypatch) -> None: @@ -54,7 +61,7 @@ def test_wolf_status_prefers_public_query_ip(monkeypatch) -> None: ) assert resp.status_code == 200 - assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "181.1.87.186"})] + assert ariadne.calls == [("GET", "/api/game-stream/status", None, {"source_ip": "181.1.87.186"}, None)] def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None: @@ -68,7 +75,7 @@ def test_wolf_unlock_uses_current_source_ip(monkeypatch) -> None: ) assert resp.status_code == 200 - assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ttl_seconds": 120, "ip": "5.6.7.8"}, None)] + assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ttl_seconds": 120, "ip": "5.6.7.8"}, None, None)] def test_wolf_unlock_prefers_public_payload_ip(monkeypatch) -> None: @@ -81,7 +88,7 @@ def test_wolf_unlock_prefers_public_payload_ip(monkeypatch) -> None: ) assert resp.status_code == 200 - assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None)] + assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None, None)] def test_wolf_unlock_ignores_private_payload_ip(monkeypatch) -> None: @@ -94,7 +101,7 @@ def test_wolf_unlock_ignores_private_payload_ip(monkeypatch) -> None: ) assert resp.status_code == 200 - assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "5.6.7.8"}, None)] + assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "5.6.7.8"}, None, None)] def test_wolf_source_ip_prefers_nearest_public_proxy_value(monkeypatch) -> None: @@ -108,7 +115,7 @@ def test_wolf_source_ip_prefers_nearest_public_proxy_value(monkeypatch) -> None: ) assert resp.status_code == 200 - assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None)] + assert ariadne.calls == [("POST", "/api/game-stream/firewall/unlock", {"ip": "181.1.87.186"}, None, None)] def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None: @@ -125,11 +132,11 @@ def test_wolf_pairing_and_admin_actions_proxy(monkeypatch) -> None: client.post("/api/account/wolf/admin/firewall/unlock", json={"ip": "8.8.8.8", "target_user": "olya"}) assert ariadne.calls == [ - ("GET", "/api/game-stream/pairing/status", None, {"source_ip": "181.1.87.186"}), - ("POST", "/api/game-stream/pairing/submit-pin", {"pair_secret": "secret", "pin": "1234", "source_ip": "181.1.87.186"}, None), - ("POST", "/api/admin/game-mode/start", {"game": "steam"}, None), - ("POST", "/api/admin/game-mode/stop", {"game": "steam"}, None), - ("POST", "/api/admin/game-stream/firewall/unlock", {"ip": "8.8.8.8", "target_user": "olya"}, None), + ("GET", "/api/game-stream/pairing/status", None, {"source_ip": "181.1.87.186"}, None), + ("POST", "/api/game-stream/pairing/submit-pin", {"pair_secret": "secret", "pin": "1234", "source_ip": "181.1.87.186"}, None, None), + ("POST", "/api/admin/game-mode/start", {"game": "steam"}, None, account_wolf.settings.ARIADNE_GAME_MODE_TIMEOUT_SEC), + ("POST", "/api/admin/game-mode/stop", {"game": "steam"}, None, account_wolf.settings.ARIADNE_GAME_MODE_TIMEOUT_SEC), + ("POST", "/api/admin/game-stream/firewall/unlock", {"ip": "8.8.8.8", "target_user": "olya"}, None, None), ] @@ -143,7 +150,7 @@ def test_wolf_user_revoke_accepts_public_payload_ip(monkeypatch) -> None: ) assert resp.status_code == 200 - assert ariadne.calls == [("POST", "/api/game-stream/firewall/revoke", {"ip": "9.9.9.9"}, None)] + assert ariadne.calls == [("POST", "/api/game-stream/firewall/revoke", {"ip": "9.9.9.9"}, None, None)] def test_wolf_routes_require_account_and_ariadne(monkeypatch) -> None: diff --git a/frontend/src/account/useWolfDashboard.js b/frontend/src/account/useWolfDashboard.js index d52e4b9..b3ed985 100644 --- a/frontend/src/account/useWolfDashboard.js +++ b/frontend/src/account/useWolfDashboard.js @@ -47,8 +47,13 @@ export function useWolfDashboard() { sourceIp: "", unlockTtlSeconds: 28800, gpuPriority: "unknown", + gpuOwner: "unknown", gameModeStatus: "unknown", gameModeActive: false, + localModel: "unknown", + localModelLoaded: false, + localInferenceReady: false, + inferencePath: "fallback", selectedGame: "steam", note: "", manualIp: "", @@ -82,8 +87,14 @@ export function useWolfDashboard() { wolf.sourceIp = data.moonlight?.source_ip || ""; wolf.unlockTtlSeconds = Number(data.moonlight?.unlock_ttl_seconds || 28800); wolf.gpuPriority = data.gpu?.priority || "unknown"; - wolf.gameModeStatus = data.gpu?.game_mode?.status || "unknown"; - wolf.gameModeActive = Boolean(data.gpu?.game_mode?.active); + const gameMode = data.gpu?.game_mode || {}; + wolf.gpuOwner = gameMode.gpu_owner || "unknown"; + wolf.gameModeStatus = gameMode.status || "unknown"; + wolf.gameModeActive = Boolean(gameMode.active); + wolf.localModel = gameMode.model || "unknown"; + wolf.localModelLoaded = Boolean(gameMode.model_loaded); + wolf.localInferenceReady = Boolean(gameMode.local_inference_ready); + wolf.inferencePath = gameMode.inference_path || "fallback"; wolf.clients = Array.isArray(data.wolf?.clients) ? data.wolf.clients : []; wolf.pendingPairRequests = Array.isArray(data.wolf?.pending_pair_requests) ? data.wolf.pending_pair_requests : []; wolf.sessions = Array.isArray(data.wolf?.sessions) ? data.wolf.sessions : []; diff --git a/frontend/src/components/WolfAccountCard.vue b/frontend/src/components/WolfAccountCard.vue index 09c5666..47f4b64 100644 --- a/frontend/src/components/WolfAccountCard.vue +++ b/frontend/src/components/WolfAccountCard.vue @@ -35,6 +35,12 @@ GPU {{ wolf.gpuPriority }} +
+ Hermes + + {{ wolf.inferencePath }} + +
Firewall @@ -60,6 +66,14 @@ Paired devices {{ pairedDeviceNames }}
+
+ GPU owner + {{ wolf.gpuOwner }} +
+
+ Local model + {{ wolf.localModel }} ยท {{ wolf.localModelLoaded ? "loaded" : "unloaded" }} +
diff --git a/testing/frontend/unit/account-dashboard.spec.js b/testing/frontend/unit/account-dashboard.spec.js index 3a184d6..de2c42d 100644 --- a/testing/frontend/unit/account-dashboard.spec.js +++ b/testing/frontend/unit/account-dashboard.spec.js @@ -215,7 +215,18 @@ describe("account dashboard", () => { return jsonResponse({ can_control_gpu: true, moonlight: { host: "moonlight.bstein.dev", source_ip: "181.1.87.186", unlock_ttl_seconds: 28800 }, - gpu: { priority: "ai", game_mode: { status: "idle", active: false } }, + gpu: { + priority: "ai", + game_mode: { + status: "idle", + active: false, + gpu_owner: "hermes", + model: "gpt-oss:20b", + model_loaded: true, + local_inference_ready: true, + inference_path: "local", + }, + }, wolf: { api_enabled: true, clients: [{ name: "Desktop" }], @@ -236,6 +247,11 @@ describe("account dashboard", () => { expect(dashboard.wolf.status).toBe("ready"); expect(dashboard.wolf.canControlGpu).toBe(true); + expect(dashboard.wolf.gpuOwner).toBe("hermes"); + expect(dashboard.wolf.localModel).toBe("gpt-oss:20b"); + expect(dashboard.wolf.localModelLoaded).toBe(true); + expect(dashboard.wolf.localInferenceReady).toBe(true); + expect(dashboard.wolf.inferencePath).toBe("local"); expect(dashboard.wolf.clients[0].name).toBe("Desktop"); expect(dashboard.wolf.pendingPairRequests[0].pair_secret).toBe("secret-1");