feat(docker): readable names, CPU in cores and memory in bytes, failures listed with since-times (#5209)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m3s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped

#5209 "Docker views: readable names, CPU/memory in cores and bytes, failures
listed with since-time" (milestone 477 "UI problems first").

- A swarm task reads as its service and slot ("codex_db · 1", or the node
  for a global service), with the raw name in the title: Docker page, host
  panel, busiest widget, container detail.
- CPU reads in cores (Docker's 100% = one core), toned by its share of the
  host's CPUs from the agent's per-core series. Memory reads in bytes, against
  a limit only when one is really set (Docker reports host RAM otherwise).
- Exit 137/143 without an OOM kill is a stop, not a failure: no
  exited_nonzero, no crash-loop death, no "failed (24h)", dim exit code.
- "Failed (24h)" is a list (Docker page #failures, linked from the widget
  and the tile): plain containers by their deaths, swarm services by Swarm's
  failed tasks; a second manager's copy of a task death counts once.
- Failing swarm services lead Needs attention; services sort problems first
  with since-times on the page, the widget and the Swarm page; nodes that
  are down or drained sort first.
- Busiest widget orders by a 5-minute CPU average so the list holds still.
- Host panel: a table, problems first with what is wrong and since when;
  replaced swarm tasks hidden and counted.
- Container detail: node hostname, I/O labelled "since start", Restarts
  hidden for swarm tasks with a note on where task failures are.
- Widget names: "Docker — Services & failures", "Docker — Busiest containers".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:41:14 -04:00
co-authored by Claude Opus 5.5
parent 8941ed0372
commit 61b22056d5
16 changed files with 713 additions and 177 deletions
+44 -9
View File
@@ -43,22 +43,24 @@ def test_stale_is_older_than_the_agents_window():
async def test_a_crash_loop_comes_first_and_a_stale_row_says_so(app):
loop = _c("api", status="restarting", exit_code=1, restart_count=7)
old = _c("cache", scraped_at=NOW - timedelta(minutes=30), cpu_pct=95.0)
attention = [{"container": loop, "problems": ["restarting", "crash_looping"], "replaced": False,
attention = [{"container": loop, "label": "api", "problems": ["restarting", "crash_looping"], "replaced": False,
"since": datetime.now(timezone.utc) - timedelta(minutes=8), "stale": False}]
groups = [{"host": None, "host_id": "h1", "host_name": "nas", "running": 1, "stopped": 1, "grouped": False,
"subgroups": [{"label": None, "containers": [
{"container": loop, "ports": [], "uptime": None, "stale": False},
{"container": old, "ports": [], "uptime": "2h", "stale": True}]}]}]
{"container": loop, "ports": [], "uptime": None, "stale": False,
"usage": svc.usage(loop, 1, None), "exit_failed": True},
{"container": old, "ports": [], "uptime": "2h", "stale": True,
"usage": svc.usage(old, 1, None), "exit_failed": False}]}]}]
async with app.test_request_context("/plugins/docker/rows"):
page = await render_plugin_template(
app, "docker", "docker/rows.html", attention=attention, says=svc.PROBLEM_SAYS, severity=svc.PROBLEM_SEVERITY,
unhealthy=0, restarting=1, stale=1, host_groups=groups, swarm_services=[],
running=1, stopped=1, total=2)
running=1, stopped=1, docker_hosts=1, failures=[], failing_services=[])
page = re.sub(r">\s+<", "><", " ".join(page.split()))
card = page[page.index("Needs attention"):page.index("</section>")]
assert '<span class="badge badge-crit">Restarting</span>' in card
assert 'href="/plugins/docker/container/h1/api">api</a>' in card
assert 'href="/plugins/docker/container/h1/api" title="api">api</a>' in card
assert "is restarting and keeps dying · exit 1 · since <time" in card and ">8m ago</time>" in card
assert "cache" not in card
assert page.index("Needs attention") < page.index("<table")
@@ -66,7 +68,9 @@ async def test_a_crash_loop_comes_first_and_a_stale_row_says_so(app):
assert '<span class="stat-tile-label">Restarting</span><span class="stat-tile-value">1</span>' in page
assert '<span class="stat-tile-label">Stale</span><span class="stat-tile-value">1</span>' in page
assert 'class="badge badge-warn" title="Last report' in page # the stale row
assert '<span class="tone-crit">95.0%</span>' in page # CPU through Settings → Thresholds
# CPU in cores, toned by its share of the host's CPUs through Settings → Thresholds.
assert ('<span class="tone-crit" title="Docker counts CPU per core: 95% of one core, '
'95.0% of this host\'s 1 core">0.95 cores</span>') in page
assert "history" not in page.lower() and "<svg" not in page # no sparklines on the overview
assert not hex_colours(page), hex_colours(page)
@@ -75,7 +79,8 @@ async def test_a_healthy_board_has_no_attention_card(app):
async with app.test_request_context("/plugins/docker/rows"):
page = await render_plugin_template(
app, "docker", "docker/rows.html", attention=[], says=svc.PROBLEM_SAYS, severity=svc.PROBLEM_SEVERITY,
unhealthy=0, restarting=0, stale=0, host_groups=[], swarm_services=[], running=0, stopped=0, total=0)
unhealthy=0, restarting=0, stale=0, host_groups=[], swarm_services=[], running=0, stopped=0,
docker_hosts=0, failures=[], failing_services=[])
assert "Needs attention</h2>" not in page and "No containers reported yet" in page
@@ -89,7 +94,7 @@ async def test_a_host_whose_docker_stopped_answering_says_so(app):
page = await render_plugin_template(
app, "docker", "docker/rows.html", docker_down=down, attention=[], says=svc.PROBLEM_SAYS,
severity=svc.PROBLEM_SEVERITY, unhealthy=0, restarting=0, stale=0, host_groups=[],
swarm_services=[], running=0, stopped=0, total=0)
swarm_services=[], running=0, stopped=0, docker_hosts=0, failures=[], failing_services=[])
page = re.sub(r">\s+<", "><", " ".join(page.split()))
card = page[page.index("Docker not answering"):page.index("</section>")]
assert '<span class="badge badge-warn">No Docker</span><a href="/hosts/h1">nas</a>' in card
@@ -102,5 +107,35 @@ async def test_no_card_while_every_docker_answers(app):
page = await render_plugin_template(
app, "docker", "docker/rows.html", docker_down=[], attention=[], says=svc.PROBLEM_SAYS,
severity=svc.PROBLEM_SEVERITY, unhealthy=0, restarting=0, stale=0, host_groups=[],
swarm_services=[], running=0, stopped=0, total=0)
swarm_services=[], running=0, stopped=0, docker_hosts=0, failures=[], failing_services=[])
assert "Docker not answering" not in page
async def test_a_failing_service_leads_and_the_failures_are_listed(app):
now = datetime.now(timezone.utc)
sick = {"name": "codex_db", "mode": "replicated", "running": 0, "desired": 1, "state": "down",
"crash_looping": True, "failures_1h": 7, "failures_24h": 9, "tasks_known": True,
"trouble_since": now - timedelta(minutes=20), "replicas": [], "previous_tasks": 7,
"last_error": {"state": "failed", "error": "task: non-zero exit (1)", "exit_code": 1,
"node": "nas", "at": None}}
failures = [{"kind": "service", "name": "codex_db", "host_id": None, "host": "nas", "count": 9,
"at": now - timedelta(minutes=2), "detail": "task: non-zero exit (1)"},
{"kind": "container", "name": "backup", "host_id": "h1", "host": "nas", "count": 1,
"at": now - timedelta(hours=3), "detail": "exit 2"}]
async with app.test_request_context("/plugins/docker/rows"):
page = await render_plugin_template(
app, "docker", "docker/rows.html", docker_down=[], attention=[], says=svc.PROBLEM_SAYS,
severity=svc.PROBLEM_SEVERITY, unhealthy=0, restarting=0, stale=0, host_groups=[],
swarm_services=[sick], services_healthy=0, failing_services=[sick], failures=failures,
running=0, stopped=0, docker_hosts=1)
page = re.sub(r">\s+<", "><", " ".join(page.split()))
card = page[page.index("Needs attention"):page.index("</section>")]
assert '<span class="badge badge-crit">Crash-looping</span><a href="/plugins/docker/swarm">codex_db</a>' in card
assert "0 of 1 running · 7 tasks failed in the last hour · last task failed: task: non-zero exit (1)" in card
assert "since <time" in card
assert '<span class="stat-tile-label">Swarm services</span><span class="stat-tile-value">0/1</span>' in page
listed = page[page.index('id="failures"'):]
listed = listed[:listed.index("</section>")]
assert listed.index("codex_db") < listed.index("backup") # newest first
assert "9 failures · last: task: non-zero exit (1) · nas" in listed
assert 'href="/plugins/docker/container/h1/backup">backup</a>' in listed
+10 -1
View File
@@ -34,12 +34,21 @@ def test_container_problems_most_severe_first():
def test_exited_nonzero_needs_a_recent_death():
crashed = _c(status="exited", exit_code=137)
crashed = _c(status="exited", exit_code=1)
assert t.container_problems(crashed, 1, True) == ["exited_nonzero"]
# A container that died weeks ago and was left stopped is not news.
assert t.container_problems(crashed, 0, False) == []
def test_a_stop_is_not_an_exit_failure():
# 137 (SIGKILL) and 143 (SIGTERM) are how `docker stop` ends a container;
# without an OOM kill that is someone stopping it, not it failing (#5209).
for code in (137, 143):
assert t.container_problems(_c(status="exited", exit_code=code), 1, True) == []
oom = _c(status="exited", exit_code=137, oom_killed=True)
assert t.container_problems(oom, 1, True) == ["oom_killed"]
def test_cut_middle_keeps_both_ends_and_sizes_the_gap():
assert t.cut_middle("short") == "short"
line = "A" * 10 + "B" * 100 + "C" * 10
+81
View File
@@ -1,5 +1,11 @@
"""Unit tests for the docker plugin's shared query helpers (no DB)."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
from plugins.docker import service as svc
from plugins.docker import swarm_view as sv
NOW = datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc)
def test_escape_like_makes_wildcards_literal():
@@ -27,3 +33,78 @@ def test_prune_extra_vars_mapping():
}
# System prune stays conservative — no prune_all_images key.
assert svc.prune_extra_vars("system") == {"prune_target": "system"}
def test_a_stop_is_not_a_failure():
# docker stop ends a container with 143 (SIGTERM) or 137 (SIGKILL).
assert not svc.exit_is_failure(None) and not svc.exit_is_failure(0)
assert not svc.exit_is_failure(137) and not svc.exit_is_failure(143)
assert svc.exit_is_failure(1) and svc.exit_is_failure(2)
assert svc.exit_is_failure(137, oom_killed=True)
def _ev(name, event, detail="", mins=5, host="h1"):
return NS(container_name=name, host_id=host, event=event, detail=detail, at=NOW - timedelta(minutes=mins))
def test_event_is_failure_reads_the_exit_from_the_die_detail():
assert svc.event_is_failure(_ev("a", "die", "exit 1"))
assert not svc.event_is_failure(_ev("a", "die", "exit 143"))
assert not svc.event_is_failure(_ev("a", "die", "exit 0"))
assert svc.event_is_failure(_ev("a", "oom"))
assert not svc.event_is_failure(_ev("a", "start"))
def _service(name, *, known=True, failures=0, error=None):
return {"name": name, "tasks_known": known, "failures_24h": failures if known else None,
"last_error": error}
def test_failures_24h_counts_services_once_and_skips_stops():
events = [_ev("backup", "die", "exit 2", mins=30), _ev("backup", "die", "exit 2", mins=90),
_ev("web", "die", "exit 143", mins=10), # someone stopped it
_ev("app.1.abc", "die", "exit 1", mins=3), # Swarm counts its tasks
_ev("old.1.x", "die", "exit 1", mins=4), _ev("old.2.y", "die", "exit 1", mins=40),
_ev("old.1.x", "die", "exit 1", mins=4, host="h2")] # a second manager's copy
services = [_service("app", failures=3, error={"state": "failed", "error": "boom", "node": "nas",
"at": (NOW - timedelta(minutes=1)).isoformat()}),
_service("old", known=False)] # agent before 1.8.0
rows = svc.failures_24h(events, services, {"h1": "nas"})
got = {(r["kind"], r["name"]): r for r in rows}
assert set(got) == {("container", "backup"), ("service", "app"), ("service", "old")}
assert got[("container", "backup")]["count"] == 2 and got[("container", "backup")]["host"] == "nas"
assert got[("service", "app")]["count"] == 3 and got[("service", "app")]["detail"] == "boom"
assert got[("service", "old")]["count"] == 2 # its tasks' deaths
assert [r["name"] for r in rows] == ["app", "old", "backup"] # newest first
def test_task_label_names_the_service_and_slot():
assert svc.task_label(NS(name="nginx", service_name=None)) == "nginx"
assert svc.task_label(NS(name="codex_db.1.37ma", service_name="codex_db")) == "codex_db · 1"
agent = NS(name="agent.n0d3id.t4sk", service_name="agent", node_id="n0d3id")
assert svc.task_label(agent, {"n0d3id": "node-3"}) == "agent · node-3"
assert svc.task_label(agent) == "agent"
def test_usage_reads_cpu_in_cores_and_memory_against_a_real_limit():
gib = 1024 ** 3
c = NS(cpu_pct=150.0, mem_usage_bytes=gib, mem_limit_bytes=16 * gib, mem_pct=6.25)
u = svc.usage(c, 4, 16 * gib)
assert u["cores"] == 1.5 and u["cpu_share"] == 37.5
# A limit of the host's whole memory is Docker saying "no limit".
assert u["mem_limit"] is None and u["mem_of"] == "the host's memory"
limited = svc.usage(NS(cpu_pct=None, mem_usage_bytes=gib, mem_limit_bytes=2 * gib, mem_pct=50.0), 4, 16 * gib)
assert limited["cores"] is None and limited["mem_limit"] == 2 * gib and limited["mem_of"] == "its limit"
# Unknown host size: cores still read, no share to grade against.
assert svc.usage(c, None, None)["cpu_share"] is None
assert svc.usage(c, 4, 16 * gib, cpu_pct=50.0)["cores"] == 0.5 # an average in place
def test_services_in_trouble_come_first_worst_first():
def s(name, state, loop=False, f24=0):
return {"name": name, "state": state, "crash_looping": loop, "failures_24h": f24}
services = [s("alpha", "healthy"), s("beta", "healthy", f24=2), s("gamma", "degraded"),
s("delta", "down"), s("eps", "down", loop=True)]
assert [x["name"] for x in sv.services_problems_first(services)] == ["eps", "delta", "gamma", "beta", "alpha"]
# A recovered service's failed tasks are history: not in Needs attention.
assert [x["name"] for x in sv.failing_services(services)] == ["eps", "delta", "gamma"]