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'