diff --git a/README.md b/README.md index 0dd2c79..bef7f59 100644 --- a/README.md +++ b/README.md @@ -1179,13 +1179,17 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a Show overview statistics for hosts and stacks. - Without --live: Shows config/state info (hosts, stacks, pending migrations). + Without flags: Shows config/state info (hosts, stacks, pending migrations). With --live: Also queries Docker on each host for container counts. + With --containers: Shows per-container resource stats (requires Glances). ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --live -l Query Docker for live container stats │ -│ --config -c PATH Path to config file │ -│ --help -h Show this message and exit. │ +│ --live -l Query Docker for live container stats │ +│ --containers -C Show per-container resource stats (requires │ +│ Glances) │ +│ --host -H TEXT Filter to stacks on this host │ +│ --config -c PATH Path to config file │ +│ --help -h Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` diff --git a/pyproject.toml b/pyproject.toml index 9773d1d..3cc3cbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,6 +124,10 @@ python_version = "3.11" strict = true plugins = ["pydantic.mypy"] +[[tool.mypy.overrides]] +module = "compose_farm._version" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "asyncssh.*" ignore_missing_imports = true @@ -174,8 +178,12 @@ python-version = "3.11" exclude = [ "hatch_build.py", # Build-time only, hatchling not in dev deps "docs/demos/**", # Demo scripts with local conftest imports + "src/compose_farm/_version.py", # Generated at build time ] +[tool.ty.rules] +unresolved-import = "ignore" # _version.py is generated at build time + [dependency-groups] dev = [ "mypy>=1.19.0", diff --git a/src/compose_farm/cli/monitoring.py b/src/compose_farm/cli/monitoring.py index 4d930ee..0c9a119 100644 --- a/src/compose_farm/cli/monitoring.py +++ b/src/compose_farm/cli/monitoring.py @@ -21,17 +21,22 @@ from compose_farm.cli.common import ( report_results, run_async, run_parallel_with_progress, + validate_hosts, ) -from compose_farm.console import console, print_error +from compose_farm.console import console, print_error, print_warning from compose_farm.executor import run_command, run_on_stacks from compose_farm.state import get_stacks_needing_migration, group_stacks_by_host, load_state if TYPE_CHECKING: + from collections.abc import Callable + from compose_farm.config import Config + from compose_farm.glances import ContainerStats -def _get_container_counts(cfg: Config) -> dict[str, int]: - """Get container counts from all hosts with a progress bar.""" +def _get_container_counts(cfg: Config, hosts: list[str] | None = None) -> dict[str, int]: + """Get container counts from hosts with a progress bar.""" + host_list = hosts if hosts is not None else list(cfg.hosts.keys()) async def get_count(host_name: str) -> tuple[str, int]: host = cfg.hosts[host_name] @@ -44,7 +49,7 @@ def _get_container_counts(cfg: Config) -> dict[str, int]: results = run_parallel_with_progress( "Querying hosts", - list(cfg.hosts.keys()), + host_list, get_count, ) return dict(results) @@ -67,7 +72,7 @@ def _build_host_table( if show_containers: table.add_column("Containers", justify="right") - for host_name in sorted(cfg.hosts.keys()): + for host_name in sorted(stacks_by_host.keys()): host = cfg.hosts[host_name] configured = len(stacks_by_host[host_name]) running = len(running_by_host[host_name]) @@ -86,19 +91,46 @@ def _build_host_table( return table +def _state_includes_host(host_value: str | list[str], host_name: str) -> bool: + """Check whether a state entry includes the given host.""" + if isinstance(host_value, list): + return host_name in host_value + return host_value == host_name + + def _build_summary_table( - cfg: Config, state: dict[str, str | list[str]], pending: list[str] + cfg: Config, + state: dict[str, str | list[str]], + pending: list[str], + *, + host_filter: str | None = None, ) -> Table: """Build the summary table.""" on_disk = cfg.discover_compose_dirs() + if host_filter: + stacks_configured = [stack for stack in cfg.stacks if host_filter in cfg.get_hosts(stack)] + stacks_configured_set = set(stacks_configured) + state = { + stack: hosts + for stack, hosts in state.items() + if _state_includes_host(hosts, host_filter) + } + on_disk = {stack for stack in on_disk if stack in stacks_configured_set} + total_hosts = 1 + stacks_configured_count = len(stacks_configured) + stacks_tracked_count = len(state) + else: + total_hosts = len(cfg.hosts) + stacks_configured_count = len(cfg.stacks) + stacks_tracked_count = len(state) table = Table(title="Summary", show_header=False) table.add_column("Label", style="dim") table.add_column("Value", style="bold") - table.add_row("Total hosts", str(len(cfg.hosts))) - table.add_row("Stacks (configured)", str(len(cfg.stacks))) - table.add_row("Stacks (tracked)", str(len(state))) + table.add_row("Total hosts", str(total_hosts)) + table.add_row("Stacks (configured)", str(stacks_configured_count)) + table.add_row("Stacks (tracked)", str(stacks_tracked_count)) table.add_row("Compose files on disk", str(len(on_disk))) if pending: @@ -111,6 +143,81 @@ def _build_summary_table( return table +def _format_network(rx: int, tx: int, fmt: Callable[[int], str]) -> str: + """Format network I/O.""" + return f"[dim]↓[/]{fmt(rx)} [dim]↑[/]{fmt(tx)}" + + +def _cpu_style(percent: float) -> str: + """Rich style for CPU percentage.""" + if percent > 80: # noqa: PLR2004 + return "red" + if percent > 50: # noqa: PLR2004 + return "yellow" + return "green" + + +def _mem_style(percent: float) -> str: + """Rich style for memory percentage.""" + if percent > 90: # noqa: PLR2004 + return "red" + if percent > 70: # noqa: PLR2004 + return "yellow" + return "green" + + +def _status_style(status: str) -> str: + """Rich style for container status.""" + s = status.lower() + if s == "running": + return "green" + if s == "exited": + return "red" + if s == "paused": + return "yellow" + return "dim" + + +def _build_containers_table( + containers: list[ContainerStats], + host_filter: str | None = None, +) -> Table: + """Build Rich table for container stats.""" + from compose_farm.glances import format_bytes # noqa: PLC0415 + + table = Table(title="Containers", show_header=True, header_style="bold cyan") + table.add_column("Stack", style="cyan") + table.add_column("Service", style="dim") + table.add_column("Host", style="magenta") + table.add_column("Image") + table.add_column("Status") + table.add_column("Uptime", justify="right") + table.add_column("CPU%", justify="right") + table.add_column("Memory", justify="right") + table.add_column("Net I/O", justify="right") + + if host_filter: + containers = [c for c in containers if c.host == host_filter] + + # Sort by stack, then service + containers = sorted(containers, key=lambda c: (c.stack.lower(), c.service.lower())) + + for c in containers: + table.add_row( + c.stack or c.name, + c.service or c.name, + c.host, + c.image, + f"[{_status_style(c.status)}]{c.status}[/]", + c.uptime or "[dim]-[/]", + f"[{_cpu_style(c.cpu_percent)}]{c.cpu_percent:.1f}%[/]", + f"[{_mem_style(c.memory_percent)}]{format_bytes(c.memory_usage)}[/]", + _format_network(c.network_rx, c.network_tx, format_bytes), + ) + + return table + + # --- Command functions --- @@ -175,24 +282,66 @@ def stats( bool, typer.Option("--live", "-l", help="Query Docker for live container stats"), ] = False, + containers: Annotated[ + bool, + typer.Option( + "--containers", "-C", help="Show per-container resource stats (requires Glances)" + ), + ] = False, + host: HostOption = None, config: ConfigOption = None, ) -> None: """Show overview statistics for hosts and stacks. - Without --live: Shows config/state info (hosts, stacks, pending migrations). + Without flags: Shows config/state info (hosts, stacks, pending migrations). With --live: Also queries Docker on each host for container counts. + With --containers: Shows per-container resource stats (requires Glances). """ cfg = load_config_or_exit(config) + + host_filter = None + if host: + validate_hosts(cfg, host) + host_filter = host + + # Handle --containers mode + if containers: + if not cfg.glances_stack: + print_error("Glances not configured") + console.print("[dim]Add 'glances_stack: glances' to compose-farm.yaml[/]") + raise typer.Exit(1) + + from compose_farm.glances import fetch_all_container_stats # noqa: PLC0415 + + host_list = [host_filter] if host_filter else None + container_list = run_async(fetch_all_container_stats(cfg, hosts=host_list)) + + if not container_list: + print_warning("No containers found") + raise typer.Exit(0) + + console.print(_build_containers_table(container_list, host_filter=host_filter)) + return + + # Validate and filter by host if specified + if host_filter: + all_hosts = [host_filter] + selected_hosts = {host_filter: cfg.hosts[host_filter]} + else: + all_hosts = list(cfg.hosts.keys()) + selected_hosts = cfg.hosts + state = load_state(cfg) pending = get_stacks_needing_migration(cfg) - - all_hosts = list(cfg.hosts.keys()) - stacks_by_host = group_stacks_by_host(cfg.stacks, cfg.hosts, all_hosts) - running_by_host = group_stacks_by_host(state, cfg.hosts, all_hosts) + # Filter pending migrations to selected host(s) + if host_filter: + pending = [stack for stack in pending if host_filter in cfg.get_hosts(stack)] + stacks_by_host = group_stacks_by_host(cfg.stacks, selected_hosts, all_hosts) + running_by_host = group_stacks_by_host(state, selected_hosts, all_hosts) container_counts: dict[str, int] = {} if live: - container_counts = _get_container_counts(cfg) + container_counts = _get_container_counts(cfg, all_hosts) host_table = _build_host_table( cfg, stacks_by_host, running_by_host, container_counts, show_containers=live @@ -200,7 +349,7 @@ def stats( console.print(host_table) console.print() - console.print(_build_summary_table(cfg, state, pending)) + console.print(_build_summary_table(cfg, state, pending, host_filter=host_filter)) @app.command("list", rich_help_panel="Monitoring") diff --git a/src/compose_farm/glances.py b/src/compose_farm/glances.py index ca7b741..c2c2aa8 100644 --- a/src/compose_farm/glances.py +++ b/src/compose_farm/glances.py @@ -16,6 +16,13 @@ if TYPE_CHECKING: DEFAULT_GLANCES_PORT = 61208 +def format_bytes(bytes_val: int) -> str: + """Format bytes to human readable string (e.g., 1.5 GiB).""" + import humanize # noqa: PLC0415 + + return humanize.naturalsize(bytes_val, binary=True, format="%.1f") + + def _get_glances_address( host_name: str, host: Host, @@ -244,11 +251,13 @@ async def fetch_container_stats( async def fetch_all_container_stats( config: Config, port: int = DEFAULT_GLANCES_PORT, + hosts: list[str] | None = None, ) -> list[ContainerStats]: """Fetch container stats from all hosts in parallel, enriched with compose labels.""" from .executor import get_container_compose_labels # noqa: PLC0415 glances_container = config.glances_stack + host_names = hosts if hosts is not None else list(config.hosts.keys()) async def fetch_host_data( host_name: str, @@ -269,8 +278,9 @@ async def fetch_all_container_stats( return containers tasks = [ - fetch_host_data(name, _get_glances_address(name, host, glances_container)) - for name, host in config.hosts.items() + fetch_host_data(name, _get_glances_address(name, config.hosts[name], glances_container)) + for name in host_names + if name in config.hosts ] results = await asyncio.gather(*tasks) # Flatten list of lists diff --git a/src/compose_farm/web/routes/containers.py b/src/compose_farm/web/routes/containers.py index 41c8685..df59c27 100644 --- a/src/compose_farm/web/routes/containers.py +++ b/src/compose_farm/web/routes/containers.py @@ -7,12 +7,11 @@ import re from typing import TYPE_CHECKING from urllib.parse import quote -import humanize from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, JSONResponse from compose_farm.executor import TTLCache -from compose_farm.glances import ContainerStats, fetch_all_container_stats +from compose_farm.glances import ContainerStats, fetch_all_container_stats, format_bytes from compose_farm.registry import DOCKER_HUB_ALIASES, ImageRef from compose_farm.web.deps import get_config, get_templates @@ -32,11 +31,6 @@ MIN_NAME_PARTS = 2 _DASH_HTML = '-' -def _format_bytes(bytes_val: int) -> str: - """Format bytes to human readable string.""" - return humanize.naturalsize(bytes_val, binary=True, format="%.1f") - - def _parse_image(image: str) -> tuple[str, str]: """Parse image string into (name, tag).""" # Handle registry prefix (e.g., ghcr.io/user/repo:tag) @@ -177,8 +171,8 @@ def _render_row(c: ContainerStats, idx: int | str) -> str: f'{c.status}' f'{c.uptime or "-"}' f'
{cpu:.0f}%
' - f'
{_format_bytes(c.memory_usage)}
' - f'↓{_format_bytes(c.network_rx)} ↑{_format_bytes(c.network_tx)}' + f'
{format_bytes(c.memory_usage)}
' + f'↓{format_bytes(c.network_rx)} ↑{format_bytes(c.network_tx)}' "" ) diff --git a/tests/test_cli_monitoring.py b/tests/test_cli_monitoring.py new file mode 100644 index 0000000..1fe2c7b --- /dev/null +++ b/tests/test_cli_monitoring.py @@ -0,0 +1,168 @@ +"""Tests for CLI monitoring commands (stats).""" + +from pathlib import Path +from unittest.mock import patch + +import pytest +import typer + +from compose_farm.cli.monitoring import _build_summary_table, stats +from compose_farm.config import Config, Host +from compose_farm.glances import ContainerStats + + +def _make_config(tmp_path: Path, glances_stack: str | None = None) -> Config: + """Create a minimal config for testing.""" + config_path = tmp_path / "compose-farm.yaml" + config_path.write_text("") + + return Config( + compose_dir=tmp_path / "compose", + hosts={"host1": Host(address="localhost")}, + stacks={"svc1": "host1"}, + config_path=config_path, + glances_stack=glances_stack, + ) + + +class TestStatsCommand: + """Tests for the stats command.""" + + def test_stats_containers_requires_glances_config( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + """--containers fails if glances_stack is not configured.""" + cfg = _make_config(tmp_path, glances_stack=None) + + with ( + patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg), + pytest.raises(typer.Exit) as exc_info, + ): + stats(live=False, containers=True, host=None, config=None) + + assert exc_info.value.exit_code == 1 + captured = capsys.readouterr() + assert "Glances not configured" in captured.err + + def test_stats_containers_success( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + """--containers fetches and displays container stats.""" + cfg = _make_config(tmp_path, glances_stack="glances") + + mock_containers = [ + ContainerStats( + name="nginx", + host="host1", + status="running", + image="nginx:latest", + cpu_percent=10.5, + memory_usage=100 * 1024 * 1024, + memory_limit=1024 * 1024 * 1024, + memory_percent=10.0, + network_rx=1000, + network_tx=2000, + uptime="1h", + ports="80->80", + engine="docker", + stack="web", + service="nginx", + ) + ] + + async def mock_fetch_async( + cfg: Config, hosts: list[str] | None = None + ) -> list[ContainerStats]: + return mock_containers + + with ( + patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg), + patch( + "compose_farm.glances.fetch_all_container_stats", side_effect=mock_fetch_async + ) as mock_fetch, + ): + stats(live=False, containers=True, host=None, config=None) + + mock_fetch.assert_called_once_with(cfg, hosts=None) + + captured = capsys.readouterr() + # Verify table output + assert "nginx" in captured.out + assert "host1" in captured.out + assert "runni" in captured.out + assert "10.5%" in captured.out + + def test_stats_containers_empty( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + """--containers handles empty result gracefully.""" + cfg = _make_config(tmp_path, glances_stack="glances") + + async def mock_fetch_empty( + cfg: Config, hosts: list[str] | None = None + ) -> list[ContainerStats]: + return [] + + with ( + patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg), + patch("compose_farm.glances.fetch_all_container_stats", side_effect=mock_fetch_empty), + ): + with pytest.raises(typer.Exit) as exc_info: + stats(live=False, containers=True, host=None, config=None) + + assert exc_info.value.exit_code == 0 + + captured = capsys.readouterr() + assert "No containers found" in captured.err + + def test_stats_containers_host_filter(self, tmp_path: Path) -> None: + """--host limits container queries in --containers mode.""" + cfg = _make_config(tmp_path, glances_stack="glances") + + async def mock_fetch_async( + cfg: Config, hosts: list[str] | None = None + ) -> list[ContainerStats]: + return [] + + with ( + patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg), + patch( + "compose_farm.glances.fetch_all_container_stats", side_effect=mock_fetch_async + ) as mock_fetch, + pytest.raises(typer.Exit), + ): + stats(live=False, containers=True, host="host1", config=None) + + mock_fetch.assert_called_once_with(cfg, hosts=["host1"]) + + def test_stats_summary_respects_host_filter(self, tmp_path: Path) -> None: + """--host filters summary counts to the selected host.""" + compose_dir = tmp_path / "compose" + for name in ("svc1", "svc2", "svc3"): + stack_dir = compose_dir / name + stack_dir.mkdir(parents=True) + (stack_dir / "compose.yaml").write_text("services: {}\n") + + config_path = tmp_path / "compose-farm.yaml" + config_path.write_text("") + + cfg = Config( + compose_dir=compose_dir, + hosts={ + "host1": Host(address="localhost"), + "host2": Host(address="127.0.0.2"), + }, + stacks={"svc1": "host1", "svc2": "host2", "svc3": "host1"}, + config_path=config_path, + ) + + state: dict[str, str | list[str]] = {"svc1": "host1", "svc2": "host2"} + table = _build_summary_table(cfg, state, pending=[], host_filter="host1") + labels = table.columns[0]._cells + values = table.columns[1]._cells + summary = dict(zip(labels, values, strict=True)) + + assert summary["Total hosts"] == "1" + assert summary["Stacks (configured)"] == "2" + assert summary["Stacks (tracked)"] == "1" + assert summary["Compose files on disk"] == "2" diff --git a/tests/test_containers.py b/tests/test_containers.py index eedadab..4d99d22 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -7,10 +7,9 @@ import pytest from fastapi.testclient import TestClient from compose_farm.config import Config, Host -from compose_farm.glances import ContainerStats +from compose_farm.glances import ContainerStats, format_bytes from compose_farm.web.app import create_app from compose_farm.web.routes.containers import ( - _format_bytes, _infer_stack_service, _parse_image, _parse_uptime_seconds, @@ -23,25 +22,25 @@ GB = MB * 1024 class TestFormatBytes: - """Tests for _format_bytes function (uses humanize library).""" + """Tests for format_bytes function (uses humanize library).""" def test_bytes(self) -> None: - assert _format_bytes(500) == "500 Bytes" - assert _format_bytes(0) == "0 Bytes" + assert format_bytes(500) == "500 Bytes" + assert format_bytes(0) == "0 Bytes" def test_kilobytes(self) -> None: - assert _format_bytes(KB) == "1.0 KiB" - assert _format_bytes(KB * 5) == "5.0 KiB" - assert _format_bytes(KB + 512) == "1.5 KiB" + assert format_bytes(KB) == "1.0 KiB" + assert format_bytes(KB * 5) == "5.0 KiB" + assert format_bytes(KB + 512) == "1.5 KiB" def test_megabytes(self) -> None: - assert _format_bytes(MB) == "1.0 MiB" - assert _format_bytes(MB * 100) == "100.0 MiB" - assert _format_bytes(MB * 512) == "512.0 MiB" + assert format_bytes(MB) == "1.0 MiB" + assert format_bytes(MB * 100) == "100.0 MiB" + assert format_bytes(MB * 512) == "512.0 MiB" def test_gigabytes(self) -> None: - assert _format_bytes(GB) == "1.0 GiB" - assert _format_bytes(GB * 2) == "2.0 GiB" + assert format_bytes(GB) == "1.0 GiB" + assert format_bytes(GB * 2) == "2.0 GiB" class TestParseImage: