feat(docker): Services & Failures widget lists what is wrong, then packs the healthy (#5252)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 51s
CI / unit (push) Successful in 1m23s
CI / publish (push) Successful in 1m34s

A verdict line (services healthy · other containers running · failed in
24h), then "Needs a look": troubled services with why (crash-looping, N of
M running), since when and the last error, and troubled containers
(unhealthy, restarting, exited with an error, out of memory). Then "Failed
in the last 24h" by name, with how often, when and where. Then the healthy
rest one line each (name · hosts · replicas) in as many columns as the
widget is wide, collapsing after 40 to "+N more healthy".

Per-container CPU is gone from this widget: Busiest Containers owns it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:17:09 -04:00
co-authored by Claude Opus 5.5
parent 102c84987d
commit 213a28c23f
4 changed files with 147 additions and 95 deletions
+22 -24
View File
@@ -214,7 +214,6 @@ async def widget():
{c.host_id for c in all_containers}
| {n.host_id for n in nodes} | {s.host_id for s in services},
)
capacity = await docker_svc.host_capacity(db, {c.host_id for c in all_containers})
# Swarm tasks collapse into per-service rows (each replica tagged with its
# host); non-swarm containers stay grouped by host.
@@ -227,42 +226,41 @@ async def widget():
standalone = [c for c in all_containers if not c.service_name]
running = [c for c in standalone if c.status == "running"]
display = standalone if show_stopped else running
def trouble(c) -> int:
if (c.status or "") == "restarting" or c.health == "unhealthy":
return 0
if c.status != "running" and docker_svc.exit_is_failure(c.exit_code, c.oom_killed):
return 1
return 2 if c.status == "running" else 3
def troubled(c) -> bool:
return ((c.status or "") == "restarting" or c.health == "unhealthy"
or (c.status != "running" and docker_svc.exit_is_failure(c.exit_code, c.oom_killed)))
# Group for display so multi-host fleets read clearly; single-host stays flat.
groups: dict[str, dict] = {}
for c in sorted(display, key=lambda c: (trouble(c), c.name)):
g = groups.setdefault(c.host_id, {
"host_id": c.host_id,
"host_name": host_name.get(c.host_id, c.host_id),
"containers": [],
})
g["containers"].append({"c": c, "usage": docker_svc.usage(
c, *capacity.get(c.host_id, (None, None))),
"exit_failed": docker_svc.exit_is_failure(c.exit_code, c.oom_killed)})
host_groups = sorted(groups.values(), key=lambda g: g["host_name"].lower())
# The widget-shape order (UI & Design ruling): what is wrong first, then
# the healthy rest one line each, collapsing past HEALTHY_SHOWN.
bad_containers = sorted((c for c in standalone if troubled(c)), key=lambda c: c.name)
healthy = ([{"kind": "service", "s": s} for s in swarm_services if s["state"] == "healthy"]
+ [{"kind": "container", "c": c} for c in sorted(running, key=lambda c: c.name)
if not troubled(c)]
+ ([{"kind": "stopped", "c": c} for c in sorted(standalone, key=lambda c: c.name)
if c.status != "running" and not troubled(c)] if show_stopped else []))
return await render_template(
"docker/widget.html",
host_groups=host_groups,
multi_host=len(host_groups) > 1,
swarm_services=swarm_services,
bad_services=[s for s in swarm_services if s["state"] != "healthy"],
bad_containers=bad_containers,
healthy=healthy[:HEALTHY_SHOWN], healthy_more=max(0, len(healthy) - HEALTHY_SHOWN),
services_total=len(swarm_services),
services_healthy=sum(s["state"] == "healthy" for s in swarm_services),
running_count=len(running),
failures=failures,
host_name=host_name,
total_count=len(all_containers) + len(swarm_services),
show_stopped=show_stopped,
widget_id=widget_id,
)
# Healthy services and containers listed on the dashboard widget before the
# rest collapse to "+N healthy"; the Docker page lists them all.
HEALTHY_SHOWN = 40
FAILURES_SHOWN = 5
CPU_AVERAGE_MINUTES = 5