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:
+1
-1
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user