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:
@@ -49,6 +49,9 @@ class DockerStatsCollector:
|
|||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
# Track previous CPU values for delta calculation
|
# Track previous CPU values for delta calculation
|
||||||
self._prev_cpu: dict[str, tuple[int, int]] = {}
|
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
|
@property
|
||||||
def available(self) -> bool:
|
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] = deque(maxlen=STATS_HISTORY_SIZE)
|
||||||
self._cpu_history[service_name].append(cpu_percent)
|
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."""
|
"""Background polling loop."""
|
||||||
# Discover container IDs on first run and periodically refresh
|
# Discover container IDs on first run and periodically refresh
|
||||||
service_to_container: dict[str, str] = {}
|
service_to_container: dict[str, str] = {}
|
||||||
@@ -256,10 +259,14 @@ class DockerStatsCollector:
|
|||||||
warned_no_containers = False
|
warned_no_containers = False
|
||||||
|
|
||||||
while self._running:
|
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)
|
# 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)
|
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(
|
log.warning(
|
||||||
"No Docker containers found for CPU stats. "
|
"No Docker containers found for CPU stats. "
|
||||||
"Ensure Docker socket is mounted (-v /var/run/docker.sock:/var/run/docker.sock)"
|
"Ensure Docker socket is mounted (-v /var/run/docker.sock:/var/run/docker.sock)"
|
||||||
@@ -290,10 +297,30 @@ class DockerStatsCollector:
|
|||||||
if self._running:
|
if self._running:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self._service_names = list(service_names)
|
||||||
self._running = True
|
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))
|
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:
|
async def stop(self) -> None:
|
||||||
"""Stop collecting stats."""
|
"""Stop collecting stats."""
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ DEFAULT_TERMINAL_SIZE = (132, 45)
|
|||||||
|
|
||||||
SCREENSHOT_CACHE_SECONDS = 0.3
|
SCREENSHOT_CACHE_SECONDS = 0.3
|
||||||
SCREENSHOT_MAX_CACHE_SECONDS = 20.0
|
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"
|
WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
|
||||||
@@ -361,8 +360,10 @@ class LocalServer:
|
|||||||
"""Callback when a Docker container is added."""
|
"""Callback when a Docker container is added."""
|
||||||
log.info("Container added to dashboard: %s -> %s", name, slug)
|
log.info("Container added to dashboard: %s -> %s", name, slug)
|
||||||
# Update slug-to-service mapping for sparklines
|
# Update slug-to-service mapping for sparklines
|
||||||
|
self._slug_to_service[slug] = name
|
||||||
|
# Register new service with stats collector so it starts polling
|
||||||
if self._docker_stats:
|
if self._docker_stats:
|
||||||
self._slug_to_service[slug] = name
|
self._docker_stats.add_service(name)
|
||||||
log.debug("Added sparkline mapping: %s -> %s", slug, name)
|
log.debug("Added sparkline mapping: %s -> %s", slug, name)
|
||||||
# Notify SSE subscribers about dashboard change
|
# Notify SSE subscribers about dashboard change
|
||||||
self._notify_activity("__dashboard__")
|
self._notify_activity("__dashboard__")
|
||||||
@@ -370,9 +371,10 @@ class LocalServer:
|
|||||||
def _on_docker_container_removed(self, slug: str) -> None:
|
def _on_docker_container_removed(self, slug: str) -> None:
|
||||||
"""Callback when a Docker container is removed."""
|
"""Callback when a Docker container is removed."""
|
||||||
log.info("Container removed from dashboard: %s", slug)
|
log.info("Container removed from dashboard: %s", slug)
|
||||||
# Remove slug-to-service mapping
|
# Remove from stats collector and slug mapping
|
||||||
if self._docker_stats and slug in self._slug_to_service:
|
service_name = self._slug_to_service.pop(slug, None)
|
||||||
del self._slug_to_service[slug]
|
if self._docker_stats and service_name:
|
||||||
|
self._docker_stats.remove_service(service_name)
|
||||||
# Invalidate any cached screenshots
|
# Invalidate any cached screenshots
|
||||||
self._screenshot_cache.pop(slug, None)
|
self._screenshot_cache.pop(slug, None)
|
||||||
self._screenshot_cache_etag.pop(slug, None)
|
self._screenshot_cache_etag.pop(slug, None)
|
||||||
@@ -450,12 +452,6 @@ class LocalServer:
|
|||||||
self.session_manager.on_session_end(session_id)
|
self.session_manager.on_session_end(session_id)
|
||||||
session_id = None
|
session_id = None
|
||||||
session = 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
|
session_created = session_id is not None
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -515,7 +515,12 @@ class WebTerminal {
|
|||||||
// Wait for fonts to load before fitting to ensure correct measurements
|
// Wait for fonts to load before fitting to ensure correct measurements
|
||||||
this.waitForFonts().then(() => {
|
this.waitForFonts().then(() => {
|
||||||
console.log("[webterm:init] Fonts loaded, reapplying font family and fitting...");
|
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;
|
const renderer = (this.terminal as unknown as { renderer?: { setFontFamily: (family: string) => void; remeasureFont: () => void } }).renderer;
|
||||||
if (renderer) {
|
if (renderer) {
|
||||||
renderer.setFontFamily(this.fontFamily);
|
renderer.setFontFamily(this.fontFamily);
|
||||||
|
|||||||
@@ -168,6 +168,35 @@ class TestDockerStatsCollector:
|
|||||||
|
|
||||||
assert len(collector._cpu_history["test"]) == STATS_HISTORY_SIZE
|
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:
|
class TestLocalServerSparklineEndpoint:
|
||||||
"""Tests for the CPU sparkline endpoint in LocalServer."""
|
"""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()
|
await client.close()
|
||||||
|
|
||||||
assert created["args"] == ("test", 90, 25)
|
assert created["args"] == ("test", 90, 25)
|
||||||
# Reconnect should trigger redraw without creating a new session
|
# Reconnect to an existing session should reuse it and send replay buffer
|
||||||
called = {"redraw": 0, "stdin": 0}
|
|
||||||
|
|
||||||
class DummySession:
|
class DummySession:
|
||||||
def is_running(self):
|
def is_running(self):
|
||||||
return True
|
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.routes["test"] = "sid"
|
||||||
server.session_manager.sessions["sid"] = DummySession()
|
server.session_manager.sessions["sid"] = DummySession()
|
||||||
|
|
||||||
@@ -111,9 +104,6 @@ async def test_websocket_creates_session_on_resize(tmp_path):
|
|||||||
finally:
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
assert called["redraw"] == 1
|
|
||||||
assert called["stdin"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_websocket_ping_pong(tmp_path):
|
async def test_websocket_ping_pong(tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user