feat(wolf): show Hermes GPU fallback state
This commit is contained in:
parent
89ea032142
commit
e9655ab61f
@ -43,6 +43,7 @@ def request_raw(
|
|||||||
*,
|
*,
|
||||||
payload: Any | None = None,
|
payload: Any | None = None,
|
||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
|
timeout_sec: float | None = None,
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
"""Send one authenticated request to Ariadne and return the raw response.
|
"""Send one authenticated request to Ariadne and return the raw response.
|
||||||
|
|
||||||
@ -55,9 +56,10 @@ def request_raw(
|
|||||||
|
|
||||||
url = _url(path)
|
url = _url(path)
|
||||||
attempts = max(1, settings.ARIADNE_RETRY_COUNT)
|
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):
|
for attempt in range(1, attempts + 1):
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=settings.ARIADNE_TIMEOUT_SEC) as client:
|
with httpx.Client(timeout=request_timeout) as client:
|
||||||
resp = client.request(
|
resp = client.request(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
@ -78,7 +80,7 @@ def request_raw(
|
|||||||
"method": method,
|
"method": method,
|
||||||
"path": path,
|
"path": path,
|
||||||
"attempt": attempt,
|
"attempt": attempt,
|
||||||
"timeout_sec": settings.ARIADNE_TIMEOUT_SEC,
|
"timeout_sec": request_timeout,
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -93,6 +95,7 @@ def proxy(
|
|||||||
*,
|
*,
|
||||||
payload: Any | None = None,
|
payload: Any | None = None,
|
||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
|
timeout_sec: float | None = None,
|
||||||
) -> tuple[Any, int]:
|
) -> tuple[Any, int]:
|
||||||
"""Proxy an Ariadne response through Flask as JSON plus status code.
|
"""Proxy an Ariadne response through Flask as JSON plus status code.
|
||||||
|
|
||||||
@ -101,7 +104,7 @@ def proxy(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
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:
|
except AriadneError as exc:
|
||||||
return jsonify({"error": str(exc)}), exc.status_code
|
return jsonify({"error": str(exc)}), exc.status_code
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from typing import Any
|
|||||||
|
|
||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
|
|
||||||
from .. import ariadne_client
|
from .. import ariadne_client, settings
|
||||||
from ..keycloak import require_auth, require_account_access
|
from ..keycloak import require_auth, require_account_access
|
||||||
|
|
||||||
|
|
||||||
@ -142,7 +142,12 @@ def register_account_wolf(app) -> None:
|
|||||||
ok, resp = _require_account()
|
ok, resp = _require_account()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
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"])
|
@app.route("/api/account/wolf/game-mode/stop", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
@ -150,7 +155,12 @@ def register_account_wolf(app) -> None:
|
|||||||
ok, resp = _require_account()
|
ok, resp = _require_account()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
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"])
|
@app.route("/api/account/wolf/admin/firewall/unlock", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
|
|||||||
@ -80,6 +80,7 @@ KEYCLOAK_ADMIN_REALM = os.getenv("KEYCLOAK_ADMIN_REALM", KEYCLOAK_REALM)
|
|||||||
|
|
||||||
ARIADNE_URL = os.getenv("ARIADNE_URL", "").strip()
|
ARIADNE_URL = os.getenv("ARIADNE_URL", "").strip()
|
||||||
ARIADNE_TIMEOUT_SEC = float(os.getenv("ARIADNE_TIMEOUT_SEC", "10"))
|
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_COUNT = int(os.getenv("ARIADNE_RETRY_COUNT", "2"))
|
||||||
ARIADNE_RETRY_BACKOFF_SEC = float(os.getenv("ARIADNE_RETRY_BACKOFF_SEC", "0.2"))
|
ARIADNE_RETRY_BACKOFF_SEC = float(os.getenv("ARIADNE_RETRY_BACKOFF_SEC", "0.2"))
|
||||||
|
|
||||||
|
|||||||
@ -8,13 +8,20 @@ from atlas_portal.routes import account_wolf
|
|||||||
class DummyAriadne:
|
class DummyAriadne:
|
||||||
def __init__(self, enabled: bool = True) -> None:
|
def __init__(self, enabled: bool = True) -> None:
|
||||||
self._enabled = enabled
|
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:
|
def enabled(self) -> bool:
|
||||||
return self._enabled
|
return self._enabled
|
||||||
|
|
||||||
def proxy(self, method: str, path: str, payload: object | None = None, params: dict | None = None):
|
def proxy(
|
||||||
self.calls.append((method, path, payload, params))
|
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})
|
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 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:
|
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 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:
|
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 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:
|
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 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:
|
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 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:
|
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 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:
|
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"})
|
client.post("/api/account/wolf/admin/firewall/unlock", json={"ip": "8.8.8.8", "target_user": "olya"})
|
||||||
|
|
||||||
assert ariadne.calls == [
|
assert ariadne.calls == [
|
||||||
("GET", "/api/game-stream/pairing/status", None, {"source_ip": "181.1.87.186"}),
|
("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),
|
("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),
|
("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),
|
("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),
|
("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 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:
|
def test_wolf_routes_require_account_and_ariadne(monkeypatch) -> None:
|
||||||
|
|||||||
@ -47,8 +47,13 @@ export function useWolfDashboard() {
|
|||||||
sourceIp: "",
|
sourceIp: "",
|
||||||
unlockTtlSeconds: 28800,
|
unlockTtlSeconds: 28800,
|
||||||
gpuPriority: "unknown",
|
gpuPriority: "unknown",
|
||||||
|
gpuOwner: "unknown",
|
||||||
gameModeStatus: "unknown",
|
gameModeStatus: "unknown",
|
||||||
gameModeActive: false,
|
gameModeActive: false,
|
||||||
|
localModel: "unknown",
|
||||||
|
localModelLoaded: false,
|
||||||
|
localInferenceReady: false,
|
||||||
|
inferencePath: "fallback",
|
||||||
selectedGame: "steam",
|
selectedGame: "steam",
|
||||||
note: "",
|
note: "",
|
||||||
manualIp: "",
|
manualIp: "",
|
||||||
@ -82,8 +87,14 @@ export function useWolfDashboard() {
|
|||||||
wolf.sourceIp = data.moonlight?.source_ip || "";
|
wolf.sourceIp = data.moonlight?.source_ip || "";
|
||||||
wolf.unlockTtlSeconds = Number(data.moonlight?.unlock_ttl_seconds || 28800);
|
wolf.unlockTtlSeconds = Number(data.moonlight?.unlock_ttl_seconds || 28800);
|
||||||
wolf.gpuPriority = data.gpu?.priority || "unknown";
|
wolf.gpuPriority = data.gpu?.priority || "unknown";
|
||||||
wolf.gameModeStatus = data.gpu?.game_mode?.status || "unknown";
|
const gameMode = data.gpu?.game_mode || {};
|
||||||
wolf.gameModeActive = Boolean(data.gpu?.game_mode?.active);
|
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.clients = Array.isArray(data.wolf?.clients) ? data.wolf.clients : [];
|
||||||
wolf.pendingPairRequests = Array.isArray(data.wolf?.pending_pair_requests) ? data.wolf.pending_pair_requests : [];
|
wolf.pendingPairRequests = Array.isArray(data.wolf?.pending_pair_requests) ? data.wolf.pending_pair_requests : [];
|
||||||
wolf.sessions = Array.isArray(data.wolf?.sessions) ? data.wolf.sessions : [];
|
wolf.sessions = Array.isArray(data.wolf?.sessions) ? data.wolf.sessions : [];
|
||||||
|
|||||||
@ -35,6 +35,12 @@
|
|||||||
<span class="k mono">GPU</span>
|
<span class="k mono">GPU</span>
|
||||||
<strong class="mono">{{ wolf.gpuPriority }}</strong>
|
<strong class="mono">{{ wolf.gpuPriority }}</strong>
|
||||||
</div>
|
</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">
|
<div class="wolf-summary-item">
|
||||||
<span class="k mono">Firewall</span>
|
<span class="k mono">Firewall</span>
|
||||||
<strong class="mono" :class="currentIpUnlocked ? 'ok-text' : 'warn-text'">
|
<strong class="mono" :class="currentIpUnlocked ? 'ok-text' : 'warn-text'">
|
||||||
@ -60,6 +66,14 @@
|
|||||||
<span class="k mono">Paired devices</span>
|
<span class="k mono">Paired devices</span>
|
||||||
<span class="v mono">{{ pairedDeviceNames }}</span>
|
<span class="v mono">{{ pairedDeviceNames }}</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
|||||||
@ -215,7 +215,18 @@ describe("account dashboard", () => {
|
|||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
can_control_gpu: true,
|
can_control_gpu: true,
|
||||||
moonlight: { host: "moonlight.bstein.dev", source_ip: "181.1.87.186", unlock_ttl_seconds: 28800 },
|
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: {
|
wolf: {
|
||||||
api_enabled: true,
|
api_enabled: true,
|
||||||
clients: [{ name: "Desktop" }],
|
clients: [{ name: "Desktop" }],
|
||||||
@ -236,6 +247,11 @@ describe("account dashboard", () => {
|
|||||||
|
|
||||||
expect(dashboard.wolf.status).toBe("ready");
|
expect(dashboard.wolf.status).toBe("ready");
|
||||||
expect(dashboard.wolf.canControlGpu).toBe(true);
|
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.clients[0].name).toBe("Desktop");
|
||||||
expect(dashboard.wolf.pendingPairRequests[0].pair_secret).toBe("secret-1");
|
expect(dashboard.wolf.pendingPairRequests[0].pair_secret).toBe("secret-1");
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user