From 1ba4ce2a3487227f28ae8444c9e74aa20f161376 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 24 Jan 2026 11:41:01 +0000 Subject: [PATCH] Adjust sparkline and screenshot timing Sparklines: - Poll interval: 2s -> 10s - History size: 30 -> 180 readings - Now shows 30 minutes of CPU history Screenshots: - Dashboard refresh interval: 15s -> 5s - Combined with dirty tracking, updates on activity with 5s cap --- src/textual_webterm/docker_stats.py | 100 ++++++++++++++++++++++------ src/textual_webterm/local_server.py | 2 +- tests/test_docker_stats.py | 13 ++-- 3 files changed, 87 insertions(+), 28 deletions(-) diff --git a/src/textual_webterm/docker_stats.py b/src/textual_webterm/docker_stats.py index 2ca23bc..572f9a0 100644 --- a/src/textual_webterm/docker_stats.py +++ b/src/textual_webterm/docker_stats.py @@ -16,8 +16,8 @@ from pathlib import Path log = logging.getLogger("textual-webterm") DOCKER_SOCKET = "/var/run/docker.sock" -STATS_HISTORY_SIZE = 30 # Number of CPU readings to keep -POLL_INTERVAL = 2.0 # Seconds between polls +STATS_HISTORY_SIZE = 180 # Number of CPU readings to keep (30 min at 10s interval) +POLL_INTERVAL = 10.0 # Seconds between polls class DockerStatsCollector: @@ -43,7 +43,7 @@ class DockerStatsCollector: return [] return list(self._cpu_history[container_name]) - async def _make_request(self, path: str) -> dict | None: + async def _make_request(self, path: str) -> dict | list | None: """Make HTTP request to Docker socket.""" loop = asyncio.get_event_loop() @@ -81,14 +81,14 @@ class DockerStatsCollector: else: body = response_str - # Handle chunked encoding - find the JSON object - if body.startswith("{"): + # Handle chunked encoding - find the JSON object/array + if body.startswith("{") or body.startswith("["): json_str = body else: # Skip chunk size line in chunked encoding lines = body.split("\r\n") for line in lines: - if line.startswith("{"): + if line.startswith("{") or line.startswith("["): json_str = line break else: @@ -98,6 +98,44 @@ class DockerStatsCollector: except (json.JSONDecodeError, ValueError): return None + async def _discover_containers(self, service_names: list[str]) -> dict[str, str]: + """Map service names to container IDs by querying Docker. + + Returns: + Dict mapping service_name -> container_id + """ + # List all containers + containers = await self._make_request("/containers/json") + if not isinstance(containers, list): + return {} + + mapping: dict[str, str] = {} + for container in containers: + if not isinstance(container, dict): + continue + + container_id = container.get("Id", "")[:12] # Short ID + names = container.get("Names", []) + labels = container.get("Labels", {}) + + # Check compose service label + service = labels.get("com.docker.compose.service", "") + if service in service_names: + mapping[service] = container_id + continue + + # Fall back to container name matching + for name in names: + # Docker names start with / + clean_name = name.lstrip("/") + # Check if service name is part of container name + for svc in service_names: + if svc in clean_name or clean_name == svc: + mapping[svc] = container_id + break + + return mapping + def _calculate_cpu_percent( self, container: str, cpu_stats: dict, precpu_stats: dict ) -> float | None: @@ -139,38 +177,56 @@ class DockerStatsCollector: except (KeyError, TypeError, ZeroDivisionError): return None - async def _poll_container(self, container_name: str) -> None: + async def _poll_container(self, service_name: str, container_id: str) -> None: """Poll stats for a single container.""" - # Docker API uses container name without leading slash - path = f"/containers/{container_name}/stats?stream=false" + path = f"/containers/{container_id}/stats?stream=false" stats = await self._make_request(path) - if stats is None: + if not isinstance(stats, dict): return cpu_stats = stats.get("cpu_stats", {}) precpu_stats = stats.get("precpu_stats", {}) - cpu_percent = self._calculate_cpu_percent(container_name, cpu_stats, precpu_stats) + cpu_percent = self._calculate_cpu_percent(service_name, cpu_stats, precpu_stats) if cpu_percent is not None: - if container_name not in self._cpu_history: - self._cpu_history[container_name] = deque(maxlen=STATS_HISTORY_SIZE) - self._cpu_history[container_name].append(cpu_percent) + if service_name not in self._cpu_history: + self._cpu_history[service_name] = deque(maxlen=STATS_HISTORY_SIZE) + self._cpu_history[service_name].append(cpu_percent) - async def _poll_loop(self, containers: list[str]) -> None: + async def _poll_loop(self, service_names: list[str]) -> None: """Background polling loop.""" + # Discover container IDs on first run and periodically refresh + service_to_container: dict[str, str] = {} + refresh_counter = 0 + while self._running: - for container in containers: + # Refresh container mapping every 30 iterations (~60 seconds) + if refresh_counter % 30 == 0: + service_to_container = await self._discover_containers(service_names) + if service_to_container: + log.debug( + "Discovered containers: %s", + ", ".join(f"{k}={v}" for k, v in service_to_container.items()), + ) + + refresh_counter += 1 + + for service_name in service_names: if not self._running: break + container_id = service_to_container.get(service_name) + if not container_id: + continue try: - await self._poll_container(container) + await self._poll_container(service_name, container_id) except Exception: - log.debug("Error polling stats for %s", container) + log.debug("Error polling stats for %s", service_name) + await asyncio.sleep(POLL_INTERVAL) - def start(self, containers: list[str]) -> None: - """Start collecting stats for given containers.""" + def start(self, service_names: list[str]) -> None: + """Start collecting stats for given service names.""" if not self.available: log.debug("Docker socket not available at %s", self._socket_path) return @@ -179,8 +235,8 @@ class DockerStatsCollector: return self._running = True - self._task = asyncio.create_task(self._poll_loop(containers)) - log.info("Started Docker stats collection for %d containers", len(containers)) + self._task = asyncio.create_task(self._poll_loop(service_names)) + log.info("Started Docker stats collection for %d services", len(service_names)) async def stop(self) -> None: """Stop collecting stats.""" diff --git a/src/textual_webterm/local_server.py b/src/textual_webterm/local_server.py index 5cf76bb..f400f1e 100644 --- a/src/textual_webterm/local_server.py +++ b/src/textual_webterm/local_server.py @@ -788,7 +788,7 @@ class LocalServer: function startRefresh() {{ if (refreshTimer !== null) return; refresh(); - refreshTimer = setInterval(refresh, 15000); + refreshTimer = setInterval(refresh, 5000); }} function stopRefresh() {{ if (refreshTimer === null) return; diff --git a/tests/test_docker_stats.py b/tests/test_docker_stats.py index 6873e85..0a07f82 100644 --- a/tests/test_docker_stats.py +++ b/tests/test_docker_stats.py @@ -1,11 +1,13 @@ """Tests for docker_stats module.""" +from unittest.mock import MagicMock + import pytest -from unittest.mock import MagicMock, patch, AsyncMock + from textual_webterm.docker_stats import ( + STATS_HISTORY_SIZE, DockerStatsCollector, render_sparkline_svg, - STATS_HISTORY_SIZE, ) @@ -165,8 +167,9 @@ class TestLocalServerSparklineEndpoint: async def test_sparkline_endpoint_missing_container(self): """Missing container param returns 400.""" from aiohttp.web import HTTPBadRequest - from textual_webterm.local_server import LocalServer + from textual_webterm.config import Config + from textual_webterm.local_server import LocalServer server = LocalServer("./", Config(), compose_mode=True) @@ -179,8 +182,8 @@ class TestLocalServerSparklineEndpoint: @pytest.mark.asyncio async def test_sparkline_endpoint_returns_svg(self): """Sparkline endpoint returns SVG.""" - from textual_webterm.local_server import LocalServer from textual_webterm.config import Config + from textual_webterm.local_server import LocalServer server = LocalServer("./", Config(), compose_mode=True) @@ -194,8 +197,8 @@ class TestLocalServerSparklineEndpoint: @pytest.mark.asyncio async def test_sparkline_with_stats_collector(self): """Sparkline uses stats collector data when available.""" - from textual_webterm.local_server import LocalServer from textual_webterm.config import Config + from textual_webterm.local_server import LocalServer server = LocalServer("./", Config(), compose_mode=True) server._docker_stats = MagicMock()