# plugins/docker/overview.py """The Docker page's two tables, as plain rows (#5299, #5301): one row per swarm service and one per standalone container, with the search, stack, host and status filters and the column sort the page offers. Model-free, so it is unit-tested without a database; routes.rows() feeds it. A stack is how the operator thinks of a group of services. Swarm names a stack's services "_", so a service's stack is the part of its name before the first "_"; a standalone container's is its compose project. Stacks filter the list rather than section it, as host groups do on Hosts. """ from __future__ import annotations from datetime import datetime from .swarm_view import service_trouble # Status filter values and their labels; NO_STACK picks rows with no stack. DOCKER_STATES = {"problem": "Needs attention", "healthy": "Healthy", "stopped": "Stopped"} NO_STACK = "__none__" # Each sort and whether it starts descending: the busiest first; "up" starts # with the shortest uptime, the thing that most recently restarted. DOCKER_SORTS = {"status": False, "name": False, "stack": False, "cpu": True, "mem": True, "up": False} def stack_of(service_name: str) -> str | None: """The stack a swarm service was deployed in: "arr" for "arr_radarr"; None for a name with no "_" (a service created on its own).""" stack, sep, rest = service_name.partition("_") return stack if sep and stack and rest else None def _seconds_up(started_at, now: datetime) -> float | None: return None if started_at is None else max((now - started_at).total_seconds(), 0.0) def service_row(s: dict, now: datetime) -> dict: """A built swarm service (swarm_view.build_swarm_services) as a table row. CPU and memory add up the running replicas; "up" is the newest replica's start, so a service that just restarted a task reads as just restarted.""" reals = [r for r in s["replicas"] if not r["ghost"]] running = [r for r in reals if (r["status"] or "").lower() == "running"] cpu = [r["cpu_pct"] for r in running if r.get("cpu_pct") is not None] mem = [r["mem_bytes"] for r in running if r.get("mem_bytes") is not None] starts = [r["started_at"] for r in running if r.get("started_at")] started = max(starts) if starts else None return { "kind": "service", "name": s["name"], "stack": stack_of(s["name"]), "mode": s["mode"], "image": s.get("image") or next((r["image"] for r in reals if r.get("image")), None), "running": s["running"], "desired": s["desired"], "state": s["state"], "trouble": service_trouble(s), "nodes": sorted({r["host"] for r in reals}, key=str.lower), "ghost_nodes": sorted({r["host"] for r in s["replicas"] if r["ghost"]}, key=str.lower), "cpu_cores": sum(cpu) / 100 if cpu else None, "mem_bytes": sum(mem) if mem else None, "started_at": started, "up_s": _seconds_up(started, now), "unhealthy": any(r.get("health") == "unhealthy" for r in reals), "failures_24h": s.get("failures_24h"), "crash_looping": s.get("crash_looping"), "last_error": s.get("last_error"), "trouble_since": s.get("trouble_since"), } def container_trouble(c, *, stale: bool, exit_failed: bool) -> int | None: """How bad a standalone container is, on service_trouble's scale: 1 unhealthy or restarting, 2 died with a failing exit, 3 its report is stale; None when nothing is wrong (a cleanly stopped one included).""" if c.health == "unhealthy" or c.status == "restarting": return 1 if c.status != "running" and exit_failed: return 2 if stale: return 3 return None def container_row(c, *, label: str, host: str, usage: dict, ports: list, stale: bool, exit_failed: bool, now: datetime) -> dict: """A standalone container as a table row; `usage` is service.usage().""" running = c.status == "running" return { "kind": "container", "name": label, "container_name": c.name, "host": host, "host_id": c.host_id, "stack": c.compose_project or None, "image": c.image, "status": c.status, "health": c.health, "running": running, "ports": ports, "usage": usage, "cpu_cores": usage.get("cores") if running else None, "mem_bytes": usage.get("mem_bytes") if running else None, "started_at": c.started_at if running else None, "up_s": _seconds_up(c.started_at, now) if running else None, "exit_code": c.exit_code, "oom_killed": c.oom_killed, "exit_failed": exit_failed, "restart_count": c.restart_count or 0, "stale": stale, "scraped_at": c.scraped_at, "trouble": container_trouble(c, stale=stale, exit_failed=exit_failed), } def _hosts(row: dict) -> list[str]: return row["nodes"] + row["ghost_nodes"] if row["kind"] == "service" else [row["host"]] def _stopped(row: dict) -> bool: return (row["running"] == 0) if row["kind"] == "service" else not row["running"] def filter_rows(rows: list[dict], *, q: str = "", stack: str = "", host: str = "", state: str = "") -> list[dict]: """Rows matching a search (name, container name, image or stack, any case), a stack (or NO_STACK), a host or node, and a Status filter; an empty value doesn't filter.""" q = (q or "").strip().lower() out = [] for r in rows: if q and not any(q in (v or "").lower() for v in (r["name"], r.get("container_name"), r["image"], r["stack"])): continue if (stack == NO_STACK and r["stack"]) or (stack and stack != NO_STACK and r["stack"] != stack): continue if host and host not in _hosts(r): continue if state == "problem" and r["trouble"] is None: continue if state == "healthy" and (r["trouble"] is not None or _stopped(r)): continue if state == "stopped" and not _stopped(r): continue out.append(r) return out def sort_rows(rows: list[dict], sort: str, desc: bool) -> list[dict]: """Rows in a column's order. Ties keep name order; a row with no value for the column goes last whichever way it runs. "status" is problems first, worst first, then running, then stopped.""" by_name = sorted(rows, key=lambda r: r["name"].lower()) if sort == "name": return list(reversed(by_name)) if desc else by_name if sort == "status": return sorted(by_name, key=lambda r: (4 if r["trouble"] is None else r["trouble"], _stopped(r)), reverse=desc) value = {"stack": lambda r: (r["stack"] or "").lower() or None, "cpu": lambda r: r["cpu_cores"], "mem": lambda r: r["mem_bytes"], "up": lambda r: r["up_s"]}[sort] have = [r for r in by_name if value(r) is not None] return sorted(have, key=value, reverse=desc) + [r for r in by_name if value(r) is None] def filter_options(services: list[dict], containers: list[dict]) -> dict: """The stack and host choices the filter bar offers, from every row.""" rows = services + containers return {"stacks": sorted({r["stack"] for r in rows if r["stack"]}, key=str.lower), "hosts": sorted({h for r in rows for h in _hosts(r)}, key=str.lower)}