game-stream: add Wolf control dashboard
This commit is contained in:
parent
dc0fccbbc6
commit
4392df8d0c
@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from html import escape
|
||||
import secrets
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from .auth.keycloak import AuthContext
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
@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")
|
||||
def get_game_stream_profile(ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
|
||||
"""Return the Wolf profile policy for the authenticated Keycloak user."""
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
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:
|
||||
ctx = AuthContext(username="brad", email="", groups=["game-stream-users"], claims={})
|
||||
client = _client(monkeypatch, ctx)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user