mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 06:03:25 +00:00
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:
12
README.md
12
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. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>"
|
||||
)
|
||||
|
||||
|
||||
168
tests/test_cli_monitoring.py
Normal file
168
tests/test_cli_monitoring.py
Normal 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"
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user