cli: Add --containers flag to stats command (#159)

* fix: Ignore _version.py in type checkers

The _version.py file is generated at build time by hatchling,
so mypy and ty can't resolve it during development.

* Update README.md

* cli: Respect --host flag in stats summary and add tests

- Fix --host filter to work in non-containers mode (was ignored)
- Filter hosts table, pending migrations, and --live queries by host
- Add tests for stats --containers functionality

* refactor: Remove redundant _format_bytes wrappers

Use format_bytes directly from glances module instead of wrapper
functions that add no value.

* Fix stats --host filtering

* refactor: Move validate_hosts to top-level imports
This commit is contained in:
Bas Nijholt
2026-01-08 00:05:30 +01:00
committed by GitHub
parent 7ce2067fcb
commit d65f4cf7f4
7 changed files with 376 additions and 44 deletions

View File

@@ -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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```

View File

@@ -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",

View File

@@ -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")

View File

@@ -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

View File

@@ -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 = '<span class="text-xs opacity-50">-</span>'
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'<td data-sort="{c.status.lower()}"><span class="{_status_class(c.status)}">{c.status}</span></td>'
f'<td data-sort="{uptime_sec}" class="text-xs text-right font-mono">{c.uptime or "-"}</td>'
f'<td data-sort="{cpu}" class="text-right font-mono"><div class="flex flex-col items-end gap-0.5"><div class="w-12 h-2 bg-base-300 rounded-full overflow-hidden"><div class="h-full {cpu_class}" style="width: {min(cpu, 100)}%"></div></div><span class="text-xs">{cpu:.0f}%</span></div></td>'
f'<td data-sort="{c.memory_usage}" class="text-right font-mono"><div class="flex flex-col items-end gap-0.5"><div class="w-12 h-2 bg-base-300 rounded-full overflow-hidden"><div class="h-full {mem_class}" style="width: {min(mem, 100)}%"></div></div><span class="text-xs">{_format_bytes(c.memory_usage)}</span></div></td>'
f'<td data-sort="{c.network_rx + c.network_tx}" class="text-xs text-right font-mono">↓{_format_bytes(c.network_rx)}{_format_bytes(c.network_tx)}</td>'
f'<td data-sort="{c.memory_usage}" class="text-right font-mono"><div class="flex flex-col items-end gap-0.5"><div class="w-12 h-2 bg-base-300 rounded-full overflow-hidden"><div class="h-full {mem_class}" style="width: {min(mem, 100)}%"></div></div><span class="text-xs">{format_bytes(c.memory_usage)}</span></div></td>'
f'<td data-sort="{c.network_rx + c.network_tx}" class="text-xs text-right font-mono">↓{format_bytes(c.network_rx)}{format_bytes(c.network_tx)}</td>'
"</tr>"
)

View File

@@ -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"

View File

@@ -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: