Files
vf-cuda-grid/controller/cuda_grid_controller/browser_overlays.py
T
gx bd9dcd2441 controller: browser_overlays + extra_http_headers — auth для protected dashboards
Use case: Grafana service account token, basic auth, custom UA. Headers
устанавливаются через page.set_extra_http_headers перед первым goto.

Пример:
  dashboards:
    - id: grafana_gpu
      url: https://chat2.goldix.org/grafana/d/UID/dash
      extra_http_headers:
        Authorization: "Bearer glsa_xxxxxxxxxxxx"

Без этого Grafana с auth gate показывает empty login page (см. test
2026-05-22 ночью: page.screenshot вернул только footer "Documentation/
Support/Community" — content area empty потому что unauthorized).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:22:24 +01:00

247 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"}')
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)
# 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")
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())