Comprehensive WebUI review: 41 UX/feature/CSS improvements
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>
This commit is contained in:
@@ -4,5 +4,16 @@ from .file_ops import atomic_write_json
|
||||
from .logger import setup_logging, get_logger
|
||||
from .monitor_names import get_monitor_names, get_monitor_name, get_monitor_refresh_rates
|
||||
from .timer import high_resolution_timer
|
||||
from .log_broadcaster import broadcaster as log_broadcaster, install_broadcast_handler
|
||||
|
||||
__all__ = ["atomic_write_json", "setup_logging", "get_logger", "get_monitor_names", "get_monitor_name", "get_monitor_refresh_rates", "high_resolution_timer"]
|
||||
__all__ = [
|
||||
"atomic_write_json",
|
||||
"setup_logging",
|
||||
"get_logger",
|
||||
"get_monitor_names",
|
||||
"get_monitor_name",
|
||||
"get_monitor_refresh_rates",
|
||||
"high_resolution_timer",
|
||||
"log_broadcaster",
|
||||
"install_broadcast_handler",
|
||||
]
|
||||
|
||||
118
server/src/wled_controller/utils/log_broadcaster.py
Normal file
118
server/src/wled_controller/utils/log_broadcaster.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user