diff --git a/pyproject.toml b/pyproject.toml index ec30870..89c3437 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual-webterm" -version = "0.2.4" +version = "0.2.5" description = "Serve terminal sessions over the web" authors = ["Will McGugan "] license = "MIT" diff --git a/src/textual_webterm/cli.py b/src/textual_webterm/cli.py index 615f863..0ccb95b 100644 --- a/src/textual_webterm/cli.py +++ b/src/textual_webterm/cli.py @@ -150,11 +150,14 @@ def app( landing_apps: list = [] is_compose_mode = False + compose_project: str | None = None if landing_manifest: landing_apps = load_landing_yaml(landing_manifest) elif compose_manifest: landing_apps = load_compose_manifest(compose_manifest) is_compose_mode = True + # Derive compose project name from directory (same as docker-compose default) + compose_project = compose_manifest.parent.name server = LocalServer( "./", @@ -163,6 +166,7 @@ def app( port=port, landing_apps=landing_apps, compose_mode=is_compose_mode, + compose_project=compose_project, ) for app_entry in landing_apps: server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug) diff --git a/src/textual_webterm/docker_stats.py b/src/textual_webterm/docker_stats.py index 96b6d25..b253b2d 100644 --- a/src/textual_webterm/docker_stats.py +++ b/src/textual_webterm/docker_stats.py @@ -9,13 +9,30 @@ import asyncio import contextlib import json import logging +import os import socket from collections import deque from pathlib import Path 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) POLL_INTERVAL = 10.0 # Seconds between polls @@ -23,8 +40,11 @@ POLL_INTERVAL = 10.0 # Seconds between polls class DockerStatsCollector: """Collects CPU stats from Docker containers via the Docker socket.""" - def __init__(self, socket_path: str = DOCKER_SOCKET) -> None: - self._socket_path = socket_path + def __init__( + 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) self._cpu_history: dict[str, deque[float]] = {} self._running = False @@ -34,8 +54,20 @@ class DockerStatsCollector: @property def available(self) -> bool: - """Check if Docker socket is available.""" - return Path(self._socket_path).exists() + """Check if Docker socket is available and accessible.""" + 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]: """Get CPU history for a container.""" @@ -129,6 +161,12 @@ class DockerStatsCollector: names = container.get("Names", []) 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 service = labels.get("com.docker.compose.service", "") if service in service_names: @@ -146,7 +184,7 @@ class DockerStatsCollector: break 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 @@ -236,8 +274,8 @@ class DockerStatsCollector: continue try: await self._poll_container(service_name, container_id) - except Exception: - log.debug("Error polling stats for %s", service_name) + except Exception as e: + log.debug("Error polling stats for %s: %s", service_name, e) await asyncio.sleep(POLL_INTERVAL) diff --git a/src/textual_webterm/local_server.py b/src/textual_webterm/local_server.py index 8120cbe..7c038cb 100644 --- a/src/textual_webterm/local_server.py +++ b/src/textual_webterm/local_server.py @@ -201,6 +201,7 @@ class LocalServer: exit_on_idle: int = 0, landing_apps: list | None = None, compose_mode: bool = False, + compose_project: str | None = None, ) -> None: self.host = host self.port = port @@ -221,6 +222,7 @@ class LocalServer: self._websocket_connections: dict[RouteKey, web.WebSocketResponse] = {} self._landing_apps = landing_apps or [] self._compose_mode = compose_mode + self._compose_project = compose_project self._screenshot_cache: dict[str, tuple[float, str]] = {} self._screenshot_cache_etag: dict[str, str] = {} @@ -332,13 +334,14 @@ class LocalServer: return routes async def _shutdown(self) -> None: - try: - for ws in list(self._websocket_connections.values()): - with contextlib.suppress(Exception): - await ws.close() + # 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()): + with contextlib.suppress(Exception): + await ws.close() + with contextlib.suppress(Exception): await self.session_manager.close_all() - finally: - self.exit_event.set() async def _run_local_server(self) -> None: app = web.Application() @@ -351,7 +354,9 @@ class LocalServer: # Start Docker stats collector in compose mode 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: # Pass service names (not slugs) for Docker matching service_names = [app.name for app in self._landing_apps]