"""The Docker page leads with what needs attention, ranked the way the MCP tools rank it, and marks a container whose report is old as stale (#5123).""" import re from datetime import datetime, timedelta, timezone 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 NOW = datetime.now(timezone.utc) def _c(name, **kw): base = dict(name=name, host_id="h1", image="nginx:1", status="running", health=None, exit_code=None, oom_killed=False, restart_count=0, cpu_pct=5.0, mem_pct=10.0, compose_project=None, service_name=None, ports_json=None, started_at=NOW - timedelta(hours=2), scraped_at=NOW - timedelta(seconds=20)) base.update(kw) return NS(**base) def test_needs_attention_ranks_the_worst_problem_first(): ranked = [{"container": _c(n), "problems": p, "since": None, "replaced": False} for n, p in [ ("fine", []), ("oom", ["oom_killed"]), ("sick", ["unhealthy"]), ("loop", ["crash_looping"]), ("exit", ["exited_nonzero"]), ("bounce", ["restarting", "crash_looping"])]] assert [r["container"].name for r in svc.needs_attention(ranked)] == ["bounce", "loop", "sick", "oom", "exit"] def test_stale_is_older_than_the_agents_window(): assert not _is_stale(_c("a"), NOW, 180) assert _is_stale(_c("a", scraped_at=NOW - timedelta(minutes=10)), NOW, 180) assert _is_stale(_c("a", scraped_at=None), NOW, 180) # A fresh row on a host whose Docker stopped answering is stale too (#5152). assert _is_stale(_c("a"), NOW, 180, {"h1": object()}) assert not _is_stale(_c("a"), NOW, 180, {"h2": object()}) assert svc.stale_after_seconds({"PLUGINS": {"host_agent": {"stale_after_seconds": 60}}}) == 60 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}] 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" not in page and "Failed (24h)" not in page card = page[page.index("Needs attention"):page.index("")] assert 'Restarting' in card assert 'href="/plugins/docker/container/h1/api" title="api">api' in card assert "is restarting and keeps dying · exit 1 · since 8m ago" in card assert 'href="/plugins/docker/container/h1/api/logs"' in card assert "cache" not in card assert page.index("Needs attention") < page.index("Restarting1' in page assert 'Stale1' in page assert 'class="badge badge-warn" title="Last report' in page # the stale row # CPU in cores, toned by its share of the host's CPUs through Settings → Thresholds. assert ('0.95 cores') in page assert "" 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): now = datetime.now(timezone.utc) # not the module's NOW: |ago reads the clock down = [{"host_id": "h1", "host_name": "nas", "status": "unavailable", "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)}] page = await _rows(app, docker_down=down) card = page[page.index("Docker not answering"):page.index("")] assert 'No Dockernas' in card assert "no Docker socket at /var/run/docker.sock · since 5m " in card assert 'Errorweb-1' in card async def test_no_card_while_every_docker_answers(app): assert "Docker not answering" not in await _rows(app) 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, "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)", "task_id": "t" * 25}, {"kind": "container", "name": "backup", "host_id": "h1", "host": "nas", "count": 1, "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("")] assert ('Crash-looping' 'codex_db') 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 0/1 services healthy' in page listed = page[page.index('id="failures"'):] listed = listed[:listed.index("")] assert listed.index("codex_db") < listed.index("backup") # newest first assert "9 failures · last: task: non-zero exit (1) · nas" 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 'Failed (24h)2' 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("")] 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' 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 'traefik:v3.1' in containers assert '
443:443/tcp
' 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'
', page, re.S).group(0) assert 'type="search" id="docker-q" name="q"' in bar assert '' in bar and f'