fix: font stack, sparklines, and DA1 response issues

- Restore terminal.options.fontFamily assignment for proper font stack handling
- Add dynamic service registration to DockerStatsCollector for docker watch mode
- Remove force_redraw on reconnect that caused DA1 responses to display as text
This commit is contained in:
GitHub Copilot
2026-01-29 18:38:49 +00:00
parent 6ad9bc9ad0
commit 3a6f797e84
6 changed files with 75 additions and 28 deletions
+31 -4
View File
@@ -49,6 +49,9 @@ class DockerStatsCollector:
self._task: asyncio.Task | None = None
# Track previous CPU values for delta calculation
self._prev_cpu: dict[str, tuple[int, int]] = {}
# Service names to poll (can be modified dynamically)
self._service_names: list[str] = []
self._service_names_lock = asyncio.Lock()
@property
def available(self) -> bool:
@@ -248,7 +251,7 @@ class DockerStatsCollector:
self._cpu_history[service_name] = deque(maxlen=STATS_HISTORY_SIZE)
self._cpu_history[service_name].append(cpu_percent)
async def _poll_loop(self, service_names: list[str]) -> None:
async def _poll_loop(self) -> None:
"""Background polling loop."""
# Discover container IDs on first run and periodically refresh
service_to_container: dict[str, str] = {}
@@ -256,10 +259,14 @@ class DockerStatsCollector:
warned_no_containers = False
while self._running:
# Get current service names (may change dynamically)
service_names = list(self._service_names)
# Refresh container mapping every 30 iterations (~5 minutes at 10s interval)
if refresh_counter % 30 == 0:
# or immediately if service list changed
if refresh_counter % 30 == 0 or set(service_to_container.keys()) != set(service_names):
service_to_container = await self._discover_containers(service_names)
if not service_to_container and not warned_no_containers:
if not service_to_container and service_names and not warned_no_containers:
log.warning(
"No Docker containers found for CPU stats. "
"Ensure Docker socket is mounted (-v /var/run/docker.sock:/var/run/docker.sock)"
@@ -290,10 +297,30 @@ class DockerStatsCollector:
if self._running:
return
self._service_names = list(service_names)
self._running = True
self._task = asyncio.create_task(self._poll_loop(service_names))
self._task = asyncio.create_task(self._poll_loop())
log.info("Started Docker stats collection for %d services", len(service_names))
def add_service(self, service_name: str) -> None:
"""Add a service to the polling list dynamically.
This is safe to call while the collector is running - the poll loop
will pick up the new service on its next container discovery cycle.
"""
if service_name not in self._service_names:
self._service_names.append(service_name)
log.debug("Added service to stats collector: %s", service_name)
def remove_service(self, service_name: str) -> None:
"""Remove a service from the polling list."""
if service_name in self._service_names:
self._service_names.remove(service_name)
# Clean up history for removed service
self._cpu_history.pop(service_name, None)
self._prev_cpu.pop(service_name, None)
log.debug("Removed service from stats collector: %s", service_name)
async def stop(self) -> None:
"""Stop collecting stats."""
self._running = False
+7 -11
View File
@@ -35,7 +35,6 @@ DEFAULT_TERMINAL_SIZE = (132, 45)
SCREENSHOT_CACHE_SECONDS = 0.3
SCREENSHOT_MAX_CACHE_SECONDS = 20.0
CLEAR_AND_REDRAW_SEQ = "\x1b[2J\x1b[H\x1b[3J" # Clear screen and scrollback, move to home
WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
@@ -361,8 +360,10 @@ class LocalServer:
"""Callback when a Docker container is added."""
log.info("Container added to dashboard: %s -> %s", name, slug)
# Update slug-to-service mapping for sparklines
if self._docker_stats:
self._slug_to_service[slug] = name
# Register new service with stats collector so it starts polling
if self._docker_stats:
self._docker_stats.add_service(name)
log.debug("Added sparkline mapping: %s -> %s", slug, name)
# Notify SSE subscribers about dashboard change
self._notify_activity("__dashboard__")
@@ -370,9 +371,10 @@ class LocalServer:
def _on_docker_container_removed(self, slug: str) -> None:
"""Callback when a Docker container is removed."""
log.info("Container removed from dashboard: %s", slug)
# Remove slug-to-service mapping
if self._docker_stats and slug in self._slug_to_service:
del self._slug_to_service[slug]
# Remove from stats collector and slug mapping
service_name = self._slug_to_service.pop(slug, None)
if self._docker_stats and service_name:
self._docker_stats.remove_service(service_name)
# Invalidate any cached screenshots
self._screenshot_cache.pop(slug, None)
self._screenshot_cache_etag.pop(slug, None)
@@ -450,12 +452,6 @@ class LocalServer:
self.session_manager.on_session_end(session_id)
session_id = None
session = None
else:
# Force terminal redraw on reconnect to avoid blank screen
if hasattr(session, "force_redraw"):
await session.force_redraw()
if hasattr(session, "send_bytes"):
await session.send_bytes(CLEAR_AND_REDRAW_SEQ.encode("utf-8"))
session_created = session_id is not None
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -515,7 +515,12 @@ class WebTerminal {
// Wait for fonts to load before fitting to ensure correct measurements
this.waitForFonts().then(() => {
console.log("[webterm:init] Fonts loaded, reapplying font family and fitting...");
// Use renderer's setFontFamily method to properly update fonts
// IMPORTANT: Font updates require BOTH steps to work correctly:
// 1. Set terminal.options.fontFamily - stores the font stack for future reference
// 2. Call renderer.setFontFamily() + remeasureFont() - applies the font and recalculates metrics
// Without step 1, the font stack is lost and defaults are used on re-render.
// Without step 2, the renderer doesn't know about the new fonts.
this.terminal.options.fontFamily = this.fontFamily;
const renderer = (this.terminal as unknown as { renderer?: { setFontFamily: (family: string) => void; remeasureFont: () => void } }).renderer;
if (renderer) {
renderer.setFontFamily(this.fontFamily);
+29
View File
@@ -168,6 +168,35 @@ class TestDockerStatsCollector:
assert len(collector._cpu_history["test"]) == STATS_HISTORY_SIZE
def test_add_service_dynamic(self):
"""Services can be added dynamically after start."""
collector = DockerStatsCollector("/nonexistent")
collector._service_names = ["svc1"]
collector.add_service("svc2")
assert "svc2" in collector._service_names
# Adding same service again is a no-op
collector.add_service("svc2")
assert collector._service_names.count("svc2") == 1
def test_remove_service_dynamic(self):
"""Services can be removed dynamically."""
from collections import deque
collector = DockerStatsCollector("/nonexistent")
collector._service_names = ["svc1", "svc2"]
collector._cpu_history["svc1"] = deque([10.0, 20.0])
collector._prev_cpu["svc1"] = (100, 200)
collector.remove_service("svc1")
assert "svc1" not in collector._service_names
assert "svc1" not in collector._cpu_history
assert "svc1" not in collector._prev_cpu
# Removing non-existent service is safe
collector.remove_service("nonexistent") # Should not raise
class TestLocalServerSparklineEndpoint:
"""Tests for the CPU sparkline endpoint in LocalServer."""
@@ -81,19 +81,12 @@ async def test_websocket_creates_session_on_resize(tmp_path):
await client.close()
assert created["args"] == ("test", 90, 25)
# Reconnect should trigger redraw without creating a new session
called = {"redraw": 0, "stdin": 0}
# Reconnect to an existing session should reuse it and send replay buffer
class DummySession:
def is_running(self):
return True
async def force_redraw(self):
called["redraw"] += 1
async def send_bytes(self, data: bytes):
called["stdin"] += 1
server.session_manager.routes["test"] = "sid"
server.session_manager.sessions["sid"] = DummySession()
@@ -111,9 +104,6 @@ async def test_websocket_creates_session_on_resize(tmp_path):
finally:
await client.close()
assert called["redraw"] == 1
assert called["stdin"] == 1
@pytest.mark.asyncio
async def test_websocket_ping_pong(tmp_path):