e773066867
Use case: Grafana с session cookie (login flow вместо Bearer token),
admin UIs без API token endpoint.
Config:
dashboards:
- id: my_widget
cookies:
- name: session_id
value: abc123
url: https://example.com/
Каждый element passed к page.context.add_cookies. Playwright spec format:
{name, value, url} OR {name, value, domain, path, ...}.
Multi-dashboard verified рабочий (caвая dashboard = own Page в shared browser).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
262 lines
12 KiB
Python
262 lines
12 KiB
Python
"""Browser-rendered overlays — Playwright headless Chromium → PNG → overlay.
|
||
|
||
Use case: Grafana dashboard, web chat, любой HTML с прозрачным background
|
||
в композите поверх video. Reuses existing icon-overlay infrastructure
|
||
(reload_icon + IconOverlay).
|
||
|
||
Pattern: один shared browser instance, per-dashboard page, periodic
|
||
reload + screenshot → PNG в `${icon_dir}/${dashboard_id}.png` → ZMQ
|
||
`reload_icon` → existing icon overlay re-reads file.
|
||
|
||
Refresh rate cap: 0.5 Hz (2s) default — heavy dashboards (Grafana) +
|
||
PNG re-blit на каждый frame нагружают pipeline. Видели stutter при 5Hz
|
||
reload_icon (см. memory: cuda-grid-filter-stutter). Hard min = 0.5s.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import io
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING
|
||
|
||
import structlog
|
||
from PIL import Image
|
||
from pydantic import BaseModel, Field
|
||
|
||
from .overlays import IconOverlay
|
||
|
||
if TYPE_CHECKING:
|
||
from .dispatch import CommandDispatcher
|
||
|
||
log = structlog.get_logger()
|
||
|
||
|
||
# ─── Config ───────────────────────────────────────────────────────────
|
||
|
||
class DashboardCfg(BaseModel):
|
||
"""Browser-rendered overlay — Grafana panel, chat widget, HTML view."""
|
||
id: str = Field(description="Уникальное имя → PNG file name + overlay_id")
|
||
target_instance: str
|
||
cell: int | None = Field(default=None, description="None = absolute coords на output")
|
||
url: str = Field(description="URL для loading в headless browser. Page CSS должна иметь "
|
||
"transparent background для proper alpha composition.")
|
||
x: float = Field(ge=0.0, le=1.0, description="X в [0..1] of output canvas")
|
||
y: float = Field(ge=0.0, le=1.0)
|
||
w_px: int = Field(default=480, ge=64, le=1920,
|
||
description="Output PNG width. Если selector задан — element auto-scaled "
|
||
"по своим dimensions, w_px используется только для viewport если "
|
||
"viewport_w не задан явно.")
|
||
h_px: int = Field(default=270, ge=32, le=1080)
|
||
selector: str | None = Field(default=None,
|
||
description="CSS selector (e.g. '#dashboard-container', '.panel-content'). "
|
||
"Если задан — snapshot'ится только этот element, не вся page. "
|
||
"Удобно для Grafana single-panel или isolated widget. "
|
||
"Element ждётся через page.wait_for_selector до screenshot.")
|
||
viewport_w: int | None = Field(default=None, ge=64, le=3840,
|
||
description="Override page viewport width. Default = w_px. "
|
||
"Полезно при selector — set viewport больше чем element, чтобы "
|
||
"layout не collapse'нул (e.g. viewport 1920×1080, selector '#panel-3').")
|
||
viewport_h: int | None = Field(default=None, ge=32, le=2160,
|
||
description="Override viewport height. Default = h_px.")
|
||
refresh_sec: float = Field(default=2.0, ge=0.5, le=300.0,
|
||
description="Snapshot interval. Min 0.5s — частые reload_icon снижают video framerate.")
|
||
wait_until: str = Field(default="networkidle",
|
||
description="Playwright wait_until для navigation: load, domcontentloaded, networkidle")
|
||
inject_css: str = Field(
|
||
default="html, body { background: transparent !important; margin: 0 !important; }",
|
||
description="CSS injected после load — ensure transparency, remove default margins",
|
||
)
|
||
page_timeout_ms: int = Field(default=10000, ge=1000, le=60000,
|
||
description="Max time to wait for page load")
|
||
extra_http_headers: dict[str, str] = Field(default_factory=dict,
|
||
description="HTTP headers для каждого request — auth tokens, custom UA. "
|
||
"Пример Grafana service account: "
|
||
'{"Authorization": "Bearer glsa_xxxxxxxx"}')
|
||
cookies: list[dict] = Field(default_factory=list,
|
||
description="Session cookies через Playwright context.add_cookies. "
|
||
"Каждый dict: {name, value, url}|{name, value, domain, path}. "
|
||
"Пример: [{\"name\":\"session_id\",\"value\":\"abc\",\"url\":\"https://...\"}]")
|
||
opacity: float = 1.0
|
||
z_order: int = 28
|
||
|
||
|
||
# ─── Renderer ─────────────────────────────────────────────────────────
|
||
|
||
class BrowserRenderer:
|
||
"""Управляет headless browser + per-dashboard pages."""
|
||
|
||
def __init__(
|
||
self,
|
||
icon_dir: Path,
|
||
dispatcher: "CommandDispatcher",
|
||
dashboards: list[DashboardCfg],
|
||
) -> None:
|
||
self.icon_dir = icon_dir
|
||
self.dispatcher = dispatcher
|
||
self.dashboards = dashboards
|
||
self._tasks: list[asyncio.Task] = []
|
||
self._playwright = None
|
||
self._browser = None
|
||
self._pages: dict[str, object] = {} # dashboard_id → playwright Page
|
||
|
||
async def start(self) -> None:
|
||
if not self.dashboards:
|
||
log.info("browser_overlays.disabled", reason="no dashboards configured")
|
||
return
|
||
|
||
# Import lazily — playwright optional dependency
|
||
try:
|
||
from playwright.async_api import async_playwright
|
||
except ImportError:
|
||
log.error("browser_overlays.no_playwright",
|
||
msg="playwright не установлен — dashboards disabled. "
|
||
"В Dockerfile: pip install playwright + playwright install chromium")
|
||
return
|
||
|
||
self.icon_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
self._playwright = await async_playwright().start()
|
||
# Full chromium (не headless-shell) — нужен для `--default-background-color`
|
||
# transparency support. headless-shell incompatible с remote debugging при
|
||
# этом флаге. channel="chromium" → use full browser binary (~170MB,
|
||
# установлен через `playwright install chromium`).
|
||
self._browser = await self._playwright.chromium.launch(
|
||
channel="chromium",
|
||
headless=True,
|
||
args=[
|
||
"--default-background-color=00000000", # hex alpha=00 = full transparent
|
||
"--no-sandbox", # требуется в docker root context
|
||
"--disable-dev-shm-usage", # /dev/shm в Docker обычно мал
|
||
"--disable-gpu", # headless без GPU OK для PIL
|
||
],
|
||
)
|
||
|
||
for cfg in self.dashboards:
|
||
try:
|
||
vw = cfg.viewport_w or cfg.w_px
|
||
vh = cfg.viewport_h or cfg.h_px
|
||
page = await self._browser.new_page(
|
||
viewport={"width": vw, "height": vh},
|
||
)
|
||
if cfg.extra_http_headers:
|
||
await page.set_extra_http_headers(cfg.extra_http_headers)
|
||
if cfg.cookies:
|
||
try:
|
||
await page.context.add_cookies(cfg.cookies)
|
||
except Exception as e:
|
||
log.warning("browser_overlays.cookies_invalid",
|
||
id=cfg.id, error=str(e))
|
||
# background_color в new_context не работает для transparent — used CLI arg
|
||
await page.goto(cfg.url, wait_until=cfg.wait_until,
|
||
timeout=cfg.page_timeout_ms)
|
||
if cfg.inject_css:
|
||
await page.add_style_tag(content=cfg.inject_css)
|
||
# Если selector — заранее проверим что он существует
|
||
if cfg.selector:
|
||
await page.wait_for_selector(cfg.selector,
|
||
timeout=cfg.page_timeout_ms)
|
||
self._pages[cfg.id] = page
|
||
log.info("browser_overlays.page_loaded", id=cfg.id, url=cfg.url,
|
||
viewport=f"{vw}×{vh}", selector=cfg.selector or "<full>")
|
||
except Exception as e:
|
||
log.error("browser_overlays.page_load_fail",
|
||
id=cfg.id, url=cfg.url, error=str(e))
|
||
continue
|
||
|
||
self._tasks.append(asyncio.create_task(self._render_loop(cfg)))
|
||
|
||
log.info("browser_overlays.started", dashboards=len(self._pages))
|
||
|
||
async def stop(self) -> None:
|
||
for t in self._tasks:
|
||
t.cancel()
|
||
for t in self._tasks:
|
||
try:
|
||
await t
|
||
except asyncio.CancelledError:
|
||
pass
|
||
self._tasks.clear()
|
||
if self._browser:
|
||
try:
|
||
await self._browser.close()
|
||
except Exception:
|
||
pass
|
||
if self._playwright:
|
||
try:
|
||
await self._playwright.stop()
|
||
except Exception:
|
||
pass
|
||
|
||
async def _render_loop(self, cfg: DashboardCfg) -> None:
|
||
page = self._pages.get(cfg.id)
|
||
if page is None:
|
||
return
|
||
|
||
registered = False
|
||
# Первый snapshot — сразу. Дальше — каждые refresh_sec.
|
||
while True:
|
||
try:
|
||
# screenshot omit_background=True → alpha-correct PNG
|
||
if cfg.selector:
|
||
locator = page.locator(cfg.selector)
|
||
screenshot_bytes = await locator.screenshot(
|
||
omit_background=True,
|
||
)
|
||
else:
|
||
screenshot_bytes = await page.screenshot(
|
||
omit_background=True,
|
||
full_page=False,
|
||
)
|
||
# Save через PIL для consistency с другими renderers
|
||
img = Image.open(io.BytesIO(screenshot_bytes))
|
||
if img.mode != "RGBA":
|
||
img = img.convert("RGBA")
|
||
# Resize к target w_px×h_px — overlay поместится exactly в configured slot.
|
||
# Без resize element скриншот возвращается в native dims (e.g. 790×258 для
|
||
# Grafana panel) → overflow / gap в slot. LANCZOS = high-quality downscale.
|
||
if img.size != (cfg.w_px, cfg.h_px):
|
||
img = img.resize((cfg.w_px, cfg.h_px), Image.LANCZOS)
|
||
path = self.icon_dir / f"{cfg.id}.png"
|
||
tmp = path.with_suffix(".png.tmp")
|
||
img.save(tmp, "PNG")
|
||
tmp.replace(path)
|
||
|
||
await self.dispatcher._reload_icon(cfg.target_instance, cfg.id)
|
||
|
||
if not registered:
|
||
await self._register_overlay(cfg)
|
||
registered = True
|
||
|
||
# Reload page для свежих данных (Grafana auto-refresh не triggers
|
||
# без user interaction в headless mode)
|
||
await asyncio.sleep(cfg.refresh_sec)
|
||
try:
|
||
await page.reload(wait_until=cfg.wait_until,
|
||
timeout=cfg.page_timeout_ms)
|
||
# Re-inject CSS после reload (новый DOM teryает добавленные styles)
|
||
if cfg.inject_css:
|
||
await page.add_style_tag(content=cfg.inject_css)
|
||
if cfg.selector:
|
||
await page.wait_for_selector(cfg.selector,
|
||
timeout=cfg.page_timeout_ms)
|
||
except Exception as e:
|
||
log.warning("browser_overlays.reload_fail",
|
||
id=cfg.id, error=str(e))
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
log.error("browser_overlays.loop_fail", id=cfg.id, error=str(e))
|
||
await asyncio.sleep(5)
|
||
|
||
async def _register_overlay(self, cfg: DashboardCfg) -> None:
|
||
ov = IconOverlay(
|
||
id=cfg.id,
|
||
cell=cfg.cell,
|
||
name=cfg.id,
|
||
x=cfg.x, y=cfg.y, size=0.1,
|
||
opacity=cfg.opacity, z_order=cfg.z_order,
|
||
)
|
||
await self.dispatcher.handle(cfg.target_instance, "overlay.add",
|
||
ov.model_dump_json())
|