republisher/repub/web.py
2026-03-30 13:11:37 +02:00

136 lines
4 KiB
Python

from __future__ import annotations
import asyncio
import hashlib
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import cast
import htpy as h
from datastar_py.quart import DatastarResponse
from datastar_py.sse import DatastarEvent
from htpy import Renderable
from quart import Quart, Response, request, url_for
from repub.datastar import RefreshBroker, render_stream
from repub.pages import (
create_source_page,
dashboard_page,
execution_logs_page,
runs_page,
shim_page,
sources_page,
)
REFRESH_BROKER_KEY = "repub.refresh_broker"
RenderFunction = Callable[[], Awaitable[Renderable]]
def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, str]:
head = (
h.title["Republisher Admin UI"],
h.link(rel="stylesheet", href=stylesheet_href),
)
body = str(shim_page(datastar_src=datastar_src, head=head))
etag = hashlib.sha256(body.encode("utf-8")).hexdigest()
return body, etag
def create_app() -> Quart:
app = Quart(__name__)
app.extensions[REFRESH_BROKER_KEY] = RefreshBroker()
@app.get("/")
@app.get("/sources")
@app.get("/sources/create")
@app.get("/runs")
@app.get("/job/<int:job_id>/execution/<int:execution_id>/logs")
async def page_shim(
job_id: int | None = None, execution_id: int | None = None
) -> Response:
del job_id, execution_id
body, etag = _render_shim_page(
stylesheet_href=url_for("static", filename="app.css"),
datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"),
)
if request.if_none_match.contains(etag):
response = Response(status=304)
response.set_etag(etag)
return response
response = Response(body, mimetype="text/html")
response.set_etag(etag)
return response
@app.post("/")
async def dashboard_patch() -> DatastarResponse:
return _page_patch_response(app, render_dashboard)
@app.post("/sources")
async def sources_patch() -> DatastarResponse:
return _page_patch_response(app, render_sources)
@app.post("/sources/create")
async def create_source_patch() -> DatastarResponse:
return _page_patch_response(app, render_create_source)
@app.post("/runs")
async def runs_patch() -> DatastarResponse:
return _page_patch_response(app, render_runs)
@app.post("/job/<int:job_id>/execution/<int:execution_id>/logs")
async def logs_patch(job_id: int, execution_id: int) -> DatastarResponse:
async def render() -> Renderable:
return await render_execution_logs(job_id=job_id, execution_id=execution_id)
return _page_patch_response(app, render)
return app
def get_refresh_broker(app: Quart) -> RefreshBroker:
return cast(RefreshBroker, app.extensions[REFRESH_BROKER_KEY])
def trigger_refresh(app: Quart, event: object = "refresh-event") -> None:
get_refresh_broker(app).publish(event)
async def render_dashboard() -> Renderable:
return dashboard_page()
async def render_sources() -> Renderable:
return sources_page()
async def render_create_source() -> Renderable:
return create_source_page()
async def render_runs() -> Renderable:
return runs_page()
async def render_execution_logs(*, job_id: int, execution_id: int) -> Renderable:
return execution_logs_page(job_id=job_id, execution_id=execution_id)
def _page_patch_response(app: Quart, render: RenderFunction) -> DatastarResponse:
queue = get_refresh_broker(app).subscribe()
stream = render_stream(
queue,
render=render,
last_event_id=request.headers.get("last-event-id"),
)
return DatastarResponse(_unsubscribe_on_close(queue, stream, app))
async def _unsubscribe_on_close(
queue: object, stream: AsyncGenerator[DatastarEvent, None], app: Quart
) -> AsyncGenerator[DatastarEvent, None]:
try:
async for event in stream:
yield event
finally:
get_refresh_broker(app).unsubscribe(cast(asyncio.Queue[object], queue))