game-stream: add Wolf control dashboard

This commit is contained in:
codex 2026-05-21 05:40:15 -03:00
parent dc0fccbbc6
commit 4392df8d0c
2 changed files with 236 additions and 1 deletions

View File

@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from html import escape
import secrets import secrets
from typing import Any, Callable from typing import Any, Callable
from fastapi import Depends, FastAPI, HTTPException, Request from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from .auth.keycloak import AuthContext from .auth.keycloak import AuthContext
from .db.storage import TaskRunRecord from .db.storage import TaskRunRecord
@ -100,7 +101,231 @@ def _ensure_wolf_oauth2(module: Any, ctx: AuthContext) -> JSONResponse:
_record_simple_task(module, "wolf_oidc_ensure", started, status, detail or None) _record_simple_task(module, "wolf_oidc_ensure", started, status, detail or None)
def _dashboard_html(ctx: AuthContext) -> str:
display_name = escape(ctx.username or ctx.email or "player")
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wolf Game Stream</title>
<style>
:root {{
color-scheme: dark;
--bg: #101214;
--panel: #191d21;
--border: #303840;
--text: #eef1f3;
--muted: #a9b2bb;
--accent: #6fd3b4;
--warn: #f0b35a;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}}
main {{
width: min(960px, calc(100vw - 32px));
margin: 0 auto;
padding: 32px 0;
}}
header {{
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border);
padding-bottom: 18px;
margin-bottom: 20px;
}}
h1 {{ margin: 0; font-size: 28px; font-weight: 700; }}
h2 {{ margin: 0 0 12px; font-size: 18px; }}
.muted {{ color: var(--muted); }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }}
section {{
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 18px;
}}
dl {{ display: grid; grid-template-columns: max-content 1fr; gap: 8px 12px; margin: 0; }}
dt {{ color: var(--muted); }}
dd {{ margin: 0; overflow-wrap: anywhere; }}
.row {{ display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }}
select, input, button {{
border: 1px solid var(--border);
border-radius: 6px;
background: #11161a;
color: var(--text);
padding: 9px 10px;
font: inherit;
}}
button {{ cursor: pointer; min-width: 96px; }}
button.primary {{ border-color: #2a8069; background: #13634f; }}
button.stop {{ border-color: #8d5520; background: #663916; }}
button:disabled {{ opacity: .45; cursor: progress; }}
pre {{
min-height: 120px;
max-height: 300px;
overflow: auto;
margin: 0;
padding: 12px;
border-radius: 6px;
background: #0b0e11;
border: 1px solid var(--border);
white-space: pre-wrap;
overflow-wrap: anywhere;
}}
.pill {{
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 9px;
color: var(--muted);
}}
.ok {{ color: var(--accent); }}
.warn {{ color: var(--warn); }}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Wolf Game Stream</h1>
<div class="muted">Signed in as {display_name}</div>
</div>
<span class="pill" id="session-state">checking</span>
</header>
<div class="grid">
<section>
<h2>Profile</h2>
<dl id="profile"></dl>
</section>
<section>
<h2>Game Mode</h2>
<dl id="status"></dl>
</section>
<section>
<h2>Controls</h2>
<div class="row">
<select id="game">
<option value="arc-raiders">Arc Raiders</option>
<option value="satisfactory">Satisfactory</option>
<option value="steam">Steam</option>
<option value="wolf">Wolf</option>
</select>
<input id="note" placeholder="note" aria-label="note">
</div>
<div class="row" style="margin-top: 12px">
<button class="primary" id="start">Start</button>
<button class="stop" id="stop">Stop</button>
<button id="refresh">Refresh</button>
</div>
</section>
<section>
<h2>Result</h2>
<pre id="result">Ready.</pre>
</section>
</div>
</main>
<script>
const result = document.getElementById("result");
const state = document.getElementById("session-state");
const profile = document.getElementById("profile");
const status = document.getElementById("status");
const buttons = [...document.querySelectorAll("button")];
function setBusy(busy) {{
buttons.forEach((button) => button.disabled = busy);
}}
function renderList(target, data) {{
target.innerHTML = "";
Object.entries(data).forEach(([key, value]) => {{
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = key.replaceAll("_", " ");
dd.textContent = typeof value === "object" ? JSON.stringify(value) : String(value);
target.append(dt, dd);
}});
}}
async function request(path, options = {{}}) {{
const response = await fetch(path, {{
credentials: "same-origin",
headers: {{"content-type": "application/json"}},
...options,
}});
const text = await response.text();
let body;
try {{ body = JSON.parse(text); }} catch {{ body = text; }}
if (!response.ok) throw new Error(JSON.stringify(body));
return body;
}}
async function refresh() {{
setBusy(true);
try {{
const [profileData, statusData] = await Promise.all([
request("/api/game-stream/me"),
request("/api/admin/game-mode/status"),
]);
renderList(profile, profileData);
renderList(status, statusData);
state.textContent = statusData.active ? "game mode active" : "idle";
state.className = statusData.active ? "pill ok" : "pill";
result.textContent = JSON.stringify({{profile: profileData, game_mode: statusData}}, null, 2);
}} catch (error) {{
state.textContent = "attention";
state.className = "pill warn";
result.textContent = String(error.message || error);
}} finally {{
setBusy(false);
}}
}}
async function action(kind) {{
setBusy(true);
try {{
const body = {{
game: document.getElementById("game").value,
note: document.getElementById("note").value,
}};
const data = await request(`/api/admin/game-mode/${{kind}}`, {{
method: "POST",
body: JSON.stringify(body),
}});
result.textContent = JSON.stringify(data, null, 2);
await refresh();
}} catch (error) {{
result.textContent = String(error.message || error);
}} finally {{
setBusy(false);
}}
}}
document.getElementById("start").addEventListener("click", () => action("start"));
document.getElementById("stop").addEventListener("click", () => action("stop"));
document.getElementById("refresh").addEventListener("click", refresh);
refresh();
</script>
</body>
</html>"""
def _register_game_routes(app: FastAPI, require_auth: Callable, deps: Callable[[], Any]) -> None: def _register_game_routes(app: FastAPI, require_auth: Callable, deps: Callable[[], Any]) -> None:
@app.get("/", response_class=HTMLResponse)
@app.get("/game-stream", response_class=HTMLResponse)
def get_game_stream_dashboard(ctx: AuthContext = Depends(require_auth)) -> HTMLResponse:
"""Return the authenticated Wolf game-stream control surface."""
return HTMLResponse(_dashboard_html(ctx))
@app.get("/api/game-stream/me") @app.get("/api/game-stream/me")
def get_game_stream_profile(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: def get_game_stream_profile(ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
"""Return the Wolf profile policy for the authenticated Keycloak user.""" """Return the Wolf profile policy for the authenticated Keycloak user."""

View File

@ -1,6 +1,16 @@
from tests.unit.app.app_route_helpers import * from tests.unit.app.app_route_helpers import *
def test_game_stream_dashboard(monkeypatch) -> None:
ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={})
client = _client(monkeypatch, ctx)
resp = client.get("/", headers={"Authorization": "Bearer token"})
assert resp.status_code == 200
assert "Wolf Game Stream" in resp.text
assert "/api/admin/game-mode/${kind}" in resp.text
def test_game_stream_profile_me(monkeypatch) -> None: def test_game_stream_profile_me(monkeypatch) -> None:
ctx = AuthContext(username="brad", email="", groups=["game-stream-users"], claims={}) ctx = AuthContext(username="brad", email="", groups=["game-stream-users"], claims={})
client = _client(monkeypatch, ctx) client = _client(monkeypatch, ctx)