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)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m4s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped

- #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>
This commit is contained in:
2026-10-07 20:08:38 -04:00
co-authored by Claude Opus 5.5
parent 7dccb2f303
commit 2093f399e6
13 changed files with 1339 additions and 268 deletions
+111 -46
View File
@@ -6,6 +6,7 @@ from types import SimpleNamespace as NS
from plugins.docker import service as svc
from plugins.docker.overview import DOCKER_STATES, NO_STACK, container_row, service_row
from plugins.docker.routes import _is_stale
from tests._templates import render_plugin_template
from tests.test_style_tokens import hex_colours
@@ -40,28 +41,46 @@ def test_stale_is_older_than_the_agents_window():
assert svc.stale_after_seconds({}) == 180
_F = {"q": "", "stack": "", "host": "", "state": "", "sort": "status", "dir": "asc"}
async def _rows(app, **over):
"""docker/rows.html with what routes.rows() hands it (#5298–#5301),
nothing set unless given."""
ctx = dict(services=[], containers=[], services_total=0, containers_total=0, services_healthy=0,
failing_services=[], failures=[], docker_hosts=0, docker_down=[], attention=[],
says=svc.PROBLEM_SAYS, severity=svc.PROBLEM_SEVERITY, unhealthy=0, restarting=0,
stale=0, running=0, stopped=0, f=dict(_F), filtered=False,
sort_url=lambda key: f"/plugins/docker/?sort={key}",
page_url=lambda **kw: "/plugins/docker/?" + "&".join(f"{k}={v}" for k, v in kw.items()))
ctx.update(over)
async with app.test_request_context("/plugins/docker/rows"):
page = await render_plugin_template(app, "docker", "docker/rows.html", **ctx)
return re.sub(r">\s+<", "><", " ".join(page.split()))
def _crow(c, *, host="nas", stale=False, exit_failed=False, ports=()):
return container_row(c, label=svc.display_name(c), host=host, usage=svc.usage(c, 1, None),
ports=list(ports), stale=stale, exit_failed=exit_failed, now=NOW)
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, "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,
"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, docker_hosts=1, failures=[], failing_services=[])
page = re.sub(r">\s+<", "><", " ".join(page.split()))
page = await _rows(app, attention=attention, restarting=1, stale=1, running=1, stopped=1, docker_hosts=1,
containers=[_crow(loop, exit_failed=True), _crow(old, stale=True)], containers_total=2)
# #5298: the verdict first; only the tiles that aren't zero.
assert page.index('class="docker-verdict"') < page.index("Needs attention")
assert "1 needs attention" in page and "1 container running, 1 stopped" in page
assert "Unhealthy</span>" not in page and "Failed (24h)" not in page
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" 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 'href="/plugins/docker/container/h1/api/logs"' in card
assert "cache" not in card
assert page.index("Needs attention") < page.index("<table")
@@ -71,17 +90,14 @@ async def test_a_crash_loop_comes_first_and_a_stale_row_says_so(app):
# 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 "<svg" not in page # no sparklines on the overview
assert not hex_colours(page), hex_colours(page)
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,
docker_hosts=0, failures=[], failing_services=[])
page = await _rows(app)
assert "Needs attention</h2>" not in page and "No containers reported yet" in page
assert "Nothing needs attention" in page and "stat-strip" not in page
async def test_a_host_whose_docker_stopped_answering_says_so(app):
@@ -90,12 +106,7 @@ async def test_a_host_whose_docker_stopped_answering_says_so(app):
"detail": "no Docker socket at /var/run/docker.sock", "since": now - timedelta(minutes=5)},
{"host_id": "h2", "host_name": "web-1", "status": "error",
"detail": "permission denied on /var/run/docker.sock", "since": now - timedelta(minutes=1)}]
async with app.test_request_context("/plugins/docker/rows"):
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, docker_hosts=0, failures=[], failing_services=[])
page = re.sub(r">\s+<", "><", " ".join(page.split()))
page = await _rows(app, docker_down=down)
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
assert "no Docker socket at /var/run/docker.sock · since <time" in card and ">5m " in card
@@ -103,42 +114,96 @@ async def test_a_host_whose_docker_stopped_answering_says_so(app):
async def test_no_card_while_every_docker_answers(app):
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=[], running=0, stopped=0, docker_hosts=0, failures=[], failing_services=[])
assert "Docker not answering" not in page
assert "Docker not answering" not in await _rows(app)
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",
def _sick(now):
return {"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, "age_s": 120}}
"node": "nas", "at": None, "age_s": 120, "task_id": "t" * 25, "slot": 1}}
async def test_a_failing_service_leads_and_the_failures_are_listed_once_with_their_logs(app):
"""#5300: each failure links to the logs of what died; a service's to its
failed task's lines on the service page."""
now = datetime.now(timezone.utc)
sick = _sick(now)
failures = [{"kind": "service", "name": "codex_db", "host_id": None, "host": "nas", "count": 9,
"at": now - timedelta(minutes=2), "detail": "task: non-zero exit (1)"},
"at": now - timedelta(minutes=2), "detail": "task: non-zero exit (1)", "task_id": "t" * 25},
{"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()))
"at": now - timedelta(hours=3), "detail": "exit 2", "task_id": None}]
page = await _rows(app, services=[service_row(sick, now)], services_total=1, failing_services=[sick],
failures=failures, docker_hosts=1)
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 ('<span class="badge badge-crit">Crash-looping</span>'
'<a href="/plugins/docker/service/codex_db">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 f'href="/plugins/docker/service/codex_db?task={"t" * 25}#logs"' 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
assert '<span class="tone-warn">0/1</span> services healthy' 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
assert f'/plugins/docker/service/codex_db?task={"t" * 25}#logs"' in listed and "Logs of that task" in listed
assert 'href="/plugins/docker/container/h1/backup/logs"' in listed
assert '<span class="stat-tile-label">Failed (24h)</span><span class="stat-tile-value">2</span>' in page
async def test_services_and_containers_are_tables_that_sort_and_filter(app):
"""#5299, #5301: one row per service with its stack; every standalone
container in one table with its host; headers sort; a stack chip filters."""
now = datetime.now(timezone.utc)
web = {"name": "arr_radarr", "mode": "replicated", "running": 2, "desired": 2, "state": "healthy",
"crash_looping": False, "failures_24h": 0, "last_error": None, "trouble_since": None,
"image": "lscr.io/linuxserver/radarr:5", "replicas": [
{"ghost": False, "host": "docker02", "host_id": "h2", "name": "arr_radarr.1.x", "status": "running",
"cpu_pct": 25.0, "mem_bytes": 300 << 20, "started_at": now - timedelta(hours=3),
"image": "lscr.io/linuxserver/radarr:5", "health": None},
{"ghost": True, "host": "edge01", "count": 1}]}
proxy = _c("traefik", host_id="h1", compose_project="edge", image="traefik:v3.1",
ports_json=None, cpu_pct=3.0)
page = await _rows(app, services=[service_row(web, now)], services_total=3, services_healthy=3,
containers=[_crow(proxy, ports=[{"host_port": 443, "container_port": 443, "protocol": "tcp"}])],
containers_total=1, running=1, docker_hosts=2,
f=dict(_F, sort="cpu", dir="desc"))
services = page[page.index('id="docker-services"'):]
services = services[:services.index("</section>")]
assert "1 of 3" in services and 'href="/plugins/docker/swarm"' in services
assert 'href="/plugins/docker/service/arr_radarr" title="arr_radarr · lscr.io/linuxserver/radarr:5">arr_radarr</a>' in services
assert '<a class="chip" href="/plugins/docker/?stack=arr"' in services
assert "docker02, " in services and "edge01 (no agent)" in services
assert ">0.25 cores<" in services and ">300.0 MiB<" in services and ">3h" in services
assert 'aria-sort="descending"' in services and 'href="/plugins/docker/?sort=mem"' in services
containers = page[page.index('id="docker-containers"'):]
assert "Other containers" in containers and ">nas<" in containers
assert '<a class="chip" href="/plugins/docker/?stack=edge"' in containers
assert '<td class="cell-image" title="traefik:v3.1">traefik:v3.1</td>' in containers
assert '<div class="cell-sub mono">443:443/tcp</div>' in containers
assert not hex_colours(page), hex_colours(page)
async def test_a_filter_that_matches_nothing_says_so(app):
page = await _rows(app, services_total=4, containers_total=2, filtered=True, f=dict(_F, q="zzz"))
assert "Nothing matches these filters" in page and "No containers reported yet" not in page
async def test_the_docker_page_has_a_filter_bar_the_rows_read(app):
from quart import session
async with app.test_request_context("/plugins/docker/?stack=arr"):
session["user_role"] = "viewer"
page = await render_plugin_template(
app, "docker", "docker/index.html", poll_interval=10, has_swarm=True, has_disk=False,
f=dict(_F, stack="arr", sort="mem", dir="desc"), choices={"stacks": ["arr", "edge"], "hosts": ["a", "b"]},
states=DOCKER_STATES, no_stack=NO_STACK)
bar = re.search(r'<form id="docker-filters".*?</form>', page, re.S).group(0)
assert 'type="search" id="docker-q" name="q"' in bar
assert '<option value="arr" selected>arr</option>' in bar and f'<option value="{NO_STACK}"' in bar
assert 'aria-label="Host or node"' in bar and 'aria-label="Status"' in bar
assert '<input type="hidden" name="sort" value="mem">' in bar
assert 'hx-include="#docker-filters"' in page and "change from:#docker-filters" in page
async def test_the_disk_page_explains_its_terms_and_names_untagged_images(app):