- #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>
151 lines
7.2 KiB
Python
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)}
|