Safety & Correctness: - Add confirmation dialogs to Stop All, turnOffDevice - i18n confirm dialog (title, yes, no buttons) - Fix duplicate tutorial-overlay ID - Define missing CSS variables (--radius, --text-primary, --hover-bg, --input-bg) - Fix toast z-index conflict with confirm dialog (2500 → 3000) UX Consistency: - Add backdrop-close to test modals - Add device clone feature (only entity without it) - Add sync clocks to command palette - Replace 20+ hardcoded accent colors with CSS vars/color-mix() - Remove dead .badge duplicate from components.css - Make calibration elements keyboard-accessible (div → button) - Add aria-labels to color picker swatches - Fix pattern canvas mobile horizontal scroll - Fix graph editor mobile bottom clipping Polish: - Add empty-state messages to all CardSection instances - Convert 21 px font-sizes to rem - Add scroll-behavior: smooth with reduced-motion override - Add @media print styles - Add :focus-visible to 4 missing interactive elements - Fix settings modal close label (Cancel → Close) - Fix api-key submit button i18n New Features: - Command palette actions: start/stop targets, activate scenes, enable/disable - Bulk start/stop API endpoints (POST /output-targets/bulk/start|stop) - OS notification history viewer modal - Scene "used by" automation reference count on cards - Clock elapsed time display on Streams tab cards - Device "last seen" relative timestamp on cards - Audio device refresh button in edit modal - Composite layer drag-to-reorder - MQTT settings panel (broker config with JSON persistence) - WebSocket log viewer with level filtering and ring buffer - Runtime log-level adjustment (GET/PUT endpoints + settings UI) - Animated value source waveform canvas preview - Gradient custom preset save/delete (localStorage) - API key read-only display in settings - Backup metadata (file size, auto/manual badges) - Server restart button with confirm + overlay - Partial config export/import per entity type - Progressive disclosure in target editor (Advanced section) CSS Architecture: - Define radius scale tokens (--radius-sm/md/lg/pill) - Scope .cs-filter selectors to remove 7 !important overrides - Consolidate duplicate toggle switch (filter-list → settings-toggle) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
119 lines
4.1 KiB
Python
119 lines
4.1 KiB
Python
"""Log broadcaster: in-memory ring buffer + WebSocket fan-out for live log tailing."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections import deque
|
|
from typing import Set
|
|
|
|
# Maximum number of log records kept in memory
|
|
_BACKLOG_SIZE = 500
|
|
|
|
# A simple text formatter used by the broadcast handler
|
|
_formatter = logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
|
|
|
|
|
class LogBroadcaster:
|
|
"""Singleton that buffers recent log lines and fans them out to WS clients."""
|
|
|
|
def __init__(self, maxlen: int = _BACKLOG_SIZE) -> None:
|
|
self._backlog: deque[str] = deque(maxlen=maxlen)
|
|
self._clients: Set[asyncio.Queue] = set()
|
|
# The async event loop where send_to_clients() is scheduled.
|
|
# Set lazily on the first WS connection (inside the async context).
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Called from the logging.Handler (any thread)
|
|
# ------------------------------------------------------------------
|
|
|
|
def emit(self, line: str) -> None:
|
|
"""Append *line* to the backlog and notify all connected WS clients.
|
|
|
|
Safe to call from any thread — it schedules the async notification on
|
|
the server's event loop without blocking.
|
|
"""
|
|
self._backlog.append(line)
|
|
if self._clients and self._loop is not None:
|
|
try:
|
|
self._loop.call_soon_threadsafe(self._enqueue_line, line)
|
|
except RuntimeError:
|
|
# Loop closed / not running — silently drop
|
|
pass
|
|
|
|
def _enqueue_line(self, line: str) -> None:
|
|
"""Push *line* onto every connected client's queue (called from the event loop)."""
|
|
dead: Set[asyncio.Queue] = set()
|
|
for q in self._clients:
|
|
try:
|
|
q.put_nowait(line)
|
|
except asyncio.QueueFull:
|
|
dead.add(q)
|
|
self._clients -= dead
|
|
|
|
# ------------------------------------------------------------------
|
|
# Called from the async WS handler
|
|
# ------------------------------------------------------------------
|
|
|
|
def subscribe(self) -> "asyncio.Queue[str]":
|
|
"""Register a new WS client and return its private line queue."""
|
|
if self._loop is None:
|
|
try:
|
|
self._loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
pass
|
|
q: asyncio.Queue[str] = asyncio.Queue(maxsize=200)
|
|
self._clients.add(q)
|
|
return q
|
|
|
|
def unsubscribe(self, q: "asyncio.Queue[str]") -> None:
|
|
"""Remove a WS client queue."""
|
|
self._clients.discard(q)
|
|
|
|
def get_backlog(self) -> list[str]:
|
|
"""Return a snapshot of the current backlog (oldest → newest)."""
|
|
return list(self._backlog)
|
|
|
|
def ensure_loop(self) -> None:
|
|
"""Capture the running event loop if not already stored.
|
|
|
|
Call this once from an async context (e.g. the WS endpoint) so that
|
|
thread-safe scheduling works correctly even before the first client
|
|
connects.
|
|
"""
|
|
if self._loop is None:
|
|
try:
|
|
self._loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
pass
|
|
|
|
|
|
# Module-level singleton
|
|
broadcaster = LogBroadcaster()
|
|
|
|
|
|
class BroadcastLogHandler(logging.Handler):
|
|
"""A logging.Handler that pushes formatted records into the LogBroadcaster."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.setFormatter(_formatter)
|
|
|
|
def emit(self, record: logging.LogRecord) -> None: # type: ignore[override]
|
|
try:
|
|
line = self.format(record)
|
|
broadcaster.emit(line)
|
|
except Exception:
|
|
self.handleError(record)
|
|
|
|
|
|
def install_broadcast_handler() -> None:
|
|
"""Attach BroadcastLogHandler to the root logger (idempotent)."""
|
|
root = logging.getLogger()
|
|
# Avoid double-installation on hot reload / re-import
|
|
for h in root.handlers:
|
|
if isinstance(h, BroadcastLogHandler):
|
|
return
|
|
handler = BroadcastLogHandler()
|
|
handler.setLevel(logging.DEBUG)
|
|
root.addHandler(handler)
|