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:
2026-03-16 18:46:38 +03:00
parent a4a0e39b9b
commit 304fa24389
47 changed files with 2594 additions and 250 deletions

View 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)