Files
FabledSteward/plugins/docker/overview.py
T
bvandeusenandClaude Opus 5.5 2093f399e6
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m4s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped
feat(docker): the Docker page leads with a verdict, lists services and containers as filterable tables, and each service has its own page with logs across its tasks (#5298–#5302)
- #5298 "Docker overview: verdict line; only the problem tiles that aren't zero"
- #5299 "Docker overview: swarm services as a filterable, sortable table with a Stack column"
- #5300 "Docker overview: failures listed once, each with a link to the failed task's logs"
- #5301 "Docker overview: standalone containers in one table with a Host column"
- #5302 "Service page: replicas, image, task history, resource history and logs across all its tasks"

overview.py holds the rows, filters and sort as plain functions. The filter
bar sits outside the polled fragment and the URL keeps its state. A service's
page reads every task container Steward still holds anything for, the live
ones and removed ones known by their events, so a failed task's logs are
reachable after Docker has removed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:08:38 -04:00

151 lines
7.2 KiB
Python

# 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 "<stack>_<service>", 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)}