feat(wolf): show Hermes GPU fallback state

This commit is contained in:
codex 2026-08-01 22:31:46 -03:00
parent 89ea032142
commit e9655ab61f
7 changed files with 86 additions and 24 deletions

View File

@ -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

View File

@ -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

View File

@ -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"))

View File

@ -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:

View File

@ -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 : [];

View File

@ -35,6 +35,12 @@
<span class="k mono">GPU</span>
<strong class="mono">{{ wolf.gpuPriority }}</strong>
</div>
<div class="wolf-summary-item">
<span class="k mono">Hermes</span>
<strong class="mono" :class="wolf.localInferenceReady ? 'ok-text' : 'warn-text'">
{{ wolf.inferencePath }}
</strong>
</div>
<div class="wolf-summary-item">
<span class="k mono">Firewall</span>
<strong class="mono" :class="currentIpUnlocked ? 'ok-text' : 'warn-text'">
@ -60,6 +66,14 @@
<span class="k mono">Paired devices</span>
<span class="v mono">{{ pairedDeviceNames }}</span>
</div>
<div class="row">
<span class="k mono">GPU owner</span>
<span class="v mono">{{ wolf.gpuOwner }}</span>
</div>
<div class="row">
<span class="k mono">Local model</span>
<span class="v mono">{{ wolf.localModel }} · {{ wolf.localModelLoaded ? "loaded" : "unloaded" }}</span>
</div>
</div>
<div class="actions">

View File

@ -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");