feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars

This commit is contained in:
hh
2026-08-29 00:49:06 +02:00
parent 540327efa1
commit 16b6bbddda
32 changed files with 691 additions and 441 deletions
+16 -52
View File
@@ -1,11 +1,11 @@
"""Admin console: serves the ``ui/`` SPA and signs the operator in.
Everything the console shows comes from ``/api/*`` (``ApiFrontend``)
with a bearer. The admin port only owns three JSON routes under
``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env,
with a bearer. This app, mounted at ``/admin``, owns three JSON routes
under ``/admin/auth`` - login (``ADMIN_USER`` / ``ADMIN_PASS`` from env,
session cookie signed with ``SESSION_SECRET``, 8 h), logout, and
``session``, which hands a signed-in browser the process-lifetime admin
bearer plus the API origin - and the static build under ``/admin/``.
bearer - and the static build under ``/admin/``.
The bearer is minted at startup, registered in the token store with
scope ``*`` and never persisted; a gateway restart rotates it, and the
@@ -25,7 +25,7 @@ from typing import TYPE_CHECKING, Any
import itsdangerous
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
from fastapi.responses import FileResponse, JSONResponse, Response
from beaver_gateway.core import audit
from beaver_gateway.frontends.base import Frontend
@@ -49,17 +49,10 @@ __all__ = ["AdminFrontend"]
class AdminFrontend(Frontend):
def __init__(
self,
*,
host: str = "0.0.0.0", # noqa: S104
port: int = 8002,
public_base_url: str | None = None,
ui_dir: Path | None = None,
) -> None:
self.host = host
self.port = port
self.public_base_url = public_base_url.rstrip("/") if public_base_url else None
path = BASE
landing = True
def __init__(self, *, ui_dir: Path | None = None) -> None:
self.ui_dir = (ui_dir or DEFAULT_UI_DIR).resolve()
self._app: FastAPI | None = None
@@ -78,16 +71,8 @@ class AdminFrontend(Frontend):
"admin UI build missing at %s (run `make ui-build`)", self.ui_dir
)
async def serve(self) -> None:
import uvicorn
if self._app is None:
msg = "configure() must be called before serve()"
raise RuntimeError(msg)
config = uvicorn.Config(
self._app, host=self.host, port=self.port, log_level="info"
)
await uvicorn.Server(config).serve()
def app(self) -> FastAPI | None:
return self._app
def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
@@ -100,13 +85,7 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
)
index = ui_dir / "index.html"
@app.get("/")
async def root() -> Response:
return RedirectResponse(
f"{BASE}/", status_code=status.HTTP_307_TEMPORARY_REDIRECT
)
@app.post(f"{BASE}/auth/login")
@app.post("/auth/login")
async def login(request: Request) -> Response:
try:
data = await request.json()
@@ -157,7 +136,7 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
await audit.log(runtime, actor=f"admin:{username}", kind="login_ok", ip=ip)
return response
@app.post(f"{BASE}/auth/logout", status_code=status.HTTP_204_NO_CONTENT)
@app.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(request: Request) -> Response:
user = _current_user(request, signer)
response = Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -166,15 +145,15 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
await audit.log(runtime, actor=f"admin:{user}", kind="logout")
return response
@app.get(f"{BASE}/auth/session")
@app.get("/auth/session")
async def session(request: Request) -> dict[str, Any]:
user = _current_user(request, signer)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not signed in")
return {"user": user, "token": token, "api_base": _api_base(request, runtime)}
return {"user": user, "token": token}
@app.get(BASE)
@app.get(f"{BASE}/{{path:path}}")
@app.get("/")
@app.get("/{path:path}")
async def spa(path: str = "") -> Response:
if path:
target = (ui_dir / path).resolve()
@@ -204,21 +183,6 @@ def build_app(runtime: GatewayRuntime, *, token: str, ui_dir: Path) -> FastAPI:
return app
def _api_base(request: Request, runtime: GatewayRuntime) -> str | None:
for fe in runtime.frontends:
if fe.name != "api":
continue
public = getattr(fe, "public_base_url", None)
if public:
return public.removesuffix("/api")
port = getattr(fe, "port", None)
if port is None:
return None
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
return f"{scheme}://{request.url.hostname}:{port}"
return None
def _current_user(
request: Request, signer: itsdangerous.URLSafeTimedSerializer
) -> str | None: