Fix Docker sparklines and Ctrl-C exit

- Fix Ctrl-C to exit immediately by setting exit_event before cleanup
- Filter Docker containers by compose project name to match correct stack
- Derive compose project from manifest directory (matches docker-compose default)
- Improve Docker socket availability check to test actual connectivity
- Add DOCKER_HOST env var support for alternate socket paths
- Better error logging for socket permission issues

Bump version to 0.2.5
This commit is contained in:
GitHub Copilot
2026-01-24 12:53:09 +00:00
parent 6ab7503748
commit cc2ab79859
4 changed files with 63 additions and 16 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "textual-webterm" name = "textual-webterm"
version = "0.2.4" version = "0.2.5"
description = "Serve terminal sessions over the web" description = "Serve terminal sessions over the web"
authors = ["Will McGugan <will@textualize.io>"] authors = ["Will McGugan <will@textualize.io>"]
license = "MIT" license = "MIT"
+4
View File
@@ -150,11 +150,14 @@ def app(
landing_apps: list = [] landing_apps: list = []
is_compose_mode = False is_compose_mode = False
compose_project: str | None = None
if landing_manifest: if landing_manifest:
landing_apps = load_landing_yaml(landing_manifest) landing_apps = load_landing_yaml(landing_manifest)
elif compose_manifest: elif compose_manifest:
landing_apps = load_compose_manifest(compose_manifest) landing_apps = load_compose_manifest(compose_manifest)
is_compose_mode = True is_compose_mode = True
# Derive compose project name from directory (same as docker-compose default)
compose_project = compose_manifest.parent.name
server = LocalServer( server = LocalServer(
"./", "./",
@@ -163,6 +166,7 @@ def app(
port=port, port=port,
landing_apps=landing_apps, landing_apps=landing_apps,
compose_mode=is_compose_mode, compose_mode=is_compose_mode,
compose_project=compose_project,
) )
for app_entry in landing_apps: for app_entry in landing_apps:
server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug) server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug)
+46 -8
View File
@@ -9,13 +9,30 @@ import asyncio
import contextlib import contextlib
import json import json
import logging import logging
import os
import socket import socket
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
log = logging.getLogger("textual-webterm") log = logging.getLogger("textual-webterm")
DOCKER_SOCKET = "/var/run/docker.sock" DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock"
def get_docker_socket_path() -> str:
"""Get Docker socket path from DOCKER_HOST env var or default.
Supports unix:// scheme or plain path in DOCKER_HOST.
"""
docker_host = os.environ.get("DOCKER_HOST", "")
if docker_host:
if docker_host.startswith("unix://"):
return docker_host[7:] # Strip unix:// prefix
if docker_host.startswith("/"):
return docker_host
return DEFAULT_DOCKER_SOCKET
STATS_HISTORY_SIZE = 180 # Number of CPU readings to keep (30 min at 10s interval) STATS_HISTORY_SIZE = 180 # Number of CPU readings to keep (30 min at 10s interval)
POLL_INTERVAL = 10.0 # Seconds between polls POLL_INTERVAL = 10.0 # Seconds between polls
@@ -23,8 +40,11 @@ POLL_INTERVAL = 10.0 # Seconds between polls
class DockerStatsCollector: class DockerStatsCollector:
"""Collects CPU stats from Docker containers via the Docker socket.""" """Collects CPU stats from Docker containers via the Docker socket."""
def __init__(self, socket_path: str = DOCKER_SOCKET) -> None: def __init__(
self._socket_path = socket_path self, socket_path: str | None = None, compose_project: str | None = None
) -> None:
self._socket_path = socket_path or get_docker_socket_path()
self._compose_project = compose_project
# container_name -> deque of CPU % values (0-100) # container_name -> deque of CPU % values (0-100)
self._cpu_history: dict[str, deque[float]] = {} self._cpu_history: dict[str, deque[float]] = {}
self._running = False self._running = False
@@ -34,8 +54,20 @@ class DockerStatsCollector:
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Check if Docker socket is available.""" """Check if Docker socket is available and accessible."""
return Path(self._socket_path).exists() path = Path(self._socket_path)
if not path.exists():
return False
# Also check we can actually connect
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(2.0)
sock.connect(self._socket_path)
sock.close()
return True
except (OSError, TimeoutError) as e:
log.warning("Docker socket exists but not accessible: %s", e)
return False
def get_cpu_history(self, container_name: str) -> list[float]: def get_cpu_history(self, container_name: str) -> list[float]:
"""Get CPU history for a container.""" """Get CPU history for a container."""
@@ -129,6 +161,12 @@ class DockerStatsCollector:
names = container.get("Names", []) names = container.get("Names", [])
labels = container.get("Labels", {}) labels = container.get("Labels", {})
# Filter by compose project if specified
if self._compose_project:
project = labels.get("com.docker.compose.project", "")
if project != self._compose_project:
continue
# Check compose service label # Check compose service label
service = labels.get("com.docker.compose.service", "") service = labels.get("com.docker.compose.service", "")
if service in service_names: if service in service_names:
@@ -146,7 +184,7 @@ class DockerStatsCollector:
break break
if mapping: if mapping:
log.debug("Discovered %d containers for stats", len(mapping)) log.debug("Discovered %d containers for stats (project=%s)", len(mapping), self._compose_project)
return mapping return mapping
@@ -236,8 +274,8 @@ class DockerStatsCollector:
continue continue
try: try:
await self._poll_container(service_name, container_id) await self._poll_container(service_name, container_id)
except Exception: except Exception as e:
log.debug("Error polling stats for %s", service_name) log.debug("Error polling stats for %s: %s", service_name, e)
await asyncio.sleep(POLL_INTERVAL) await asyncio.sleep(POLL_INTERVAL)
+9 -4
View File
@@ -201,6 +201,7 @@ class LocalServer:
exit_on_idle: int = 0, exit_on_idle: int = 0,
landing_apps: list | None = None, landing_apps: list | None = None,
compose_mode: bool = False, compose_mode: bool = False,
compose_project: str | None = None,
) -> None: ) -> None:
self.host = host self.host = host
self.port = port self.port = port
@@ -221,6 +222,7 @@ class LocalServer:
self._websocket_connections: dict[RouteKey, web.WebSocketResponse] = {} self._websocket_connections: dict[RouteKey, web.WebSocketResponse] = {}
self._landing_apps = landing_apps or [] self._landing_apps = landing_apps or []
self._compose_mode = compose_mode self._compose_mode = compose_mode
self._compose_project = compose_project
self._screenshot_cache: dict[str, tuple[float, str]] = {} self._screenshot_cache: dict[str, tuple[float, str]] = {}
self._screenshot_cache_etag: dict[str, str] = {} self._screenshot_cache_etag: dict[str, str] = {}
@@ -332,13 +334,14 @@ class LocalServer:
return routes return routes
async def _shutdown(self) -> None: async def _shutdown(self) -> None:
try: # Set exit event first so main loop exits immediately
self.exit_event.set()
# Then clean up resources (best effort, don't block exit)
for ws in list(self._websocket_connections.values()): for ws in list(self._websocket_connections.values()):
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await ws.close() await ws.close()
with contextlib.suppress(Exception):
await self.session_manager.close_all() await self.session_manager.close_all()
finally:
self.exit_event.set()
async def _run_local_server(self) -> None: async def _run_local_server(self) -> None:
app = web.Application() app = web.Application()
@@ -351,7 +354,9 @@ class LocalServer:
# Start Docker stats collector in compose mode # Start Docker stats collector in compose mode
if self._compose_mode and self._landing_apps: if self._compose_mode and self._landing_apps:
self._docker_stats = DockerStatsCollector() self._docker_stats = DockerStatsCollector(
compose_project=self._compose_project
)
if self._docker_stats.available: if self._docker_stats.available:
# Pass service names (not slugs) for Docker matching # Pass service names (not slugs) for Docker matching
service_names = [app.name for app in self._landing_apps] service_names = [app.name for app in self._landing_apps]