"""The Docker page's tables (#5299, #5301) and a service's page (#5302): the rows, their search, filters and sort, and the helpers that find a service's tasks, logs and summed history.""" import re from datetime import datetime, timedelta, timezone from types import SimpleNamespace as NS from quart import session from plugins.docker import service as svc from plugins.docker.overview import ( NO_STACK, container_row, filter_options, filter_rows, service_row, sort_rows, stack_of, ) from plugins.docker.routes import _filters, _page_url, _service_tasks, _sort_url, _task_rows from tests.test_style_tokens import hex_colours from tests._templates import render_plugin_template NOW = datetime(2026, 10, 7, 12, 0, tzinfo=timezone.utc) TASK = "x7q2m9k4p1r8s5t3v6w0y2z4a" # 25 characters, like Swarm's def _replica(host, cpu=10.0, mem=100, started=2, status="running", **kw): return dict(ghost=False, host=host, host_id=f"id-{host}", name=f"r.{host}", status=status, cpu_pct=cpu, mem_bytes=mem, started_at=NOW - timedelta(hours=started), image="img:1", health=kw.get("health")) def _service(name, state="healthy", running=1, desired=1, replicas=None, **kw): s = dict(name=name, mode="replicated", running=running, desired=desired, state=state, crash_looping=False, failures_24h=0, last_error=None, trouble_since=None, replicas=replicas if replicas is not None else [_replica("a")]) s.update(kw) return s def _container(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, compose_project=None, started_at=NOW - timedelta(hours=1), scraped_at=NOW) base.update(kw) c = NS(**base) usage = {"cores": 0.5, "mem_bytes": 2048} return container_row(c, label=name, host="nas", usage=usage, ports=[], stale=False, exit_failed=bool(kw.get("exit_code")), now=NOW) def test_a_service_reads_its_stack_from_its_name(): assert stack_of("arr_radarr") == "arr" and stack_of("steward_app_db") == "steward" assert stack_of("portainer") is None and stack_of("_x") is None and stack_of("x_") is None def test_a_service_row_adds_up_its_running_replicas(): s = _service("arr_radarr", running=2, desired=3, state="degraded", replicas=[ _replica("b", cpu=50.0, mem=300, started=5), _replica("a", cpu=25.0, mem=100, started=1), _replica("c", cpu=99.0, mem=999, status="exited"), {"ghost": True, "host": "edge", "count": 1}]) row = service_row(s, NOW) assert row["stack"] == "arr" and row["trouble"] == 2 assert row["cpu_cores"] == 0.75 and row["mem_bytes"] == 400 # the exited one is not counted assert row["started_at"] == NOW - timedelta(hours=1) and row["up_s"] == 3600 assert row["nodes"] == ["a", "b", "c"] and row["ghost_nodes"] == ["edge"] assert row["image"] == "img:1" empty = service_row(_service("solo", state="down", running=0, replicas=[]), NOW) assert empty["cpu_cores"] is None and empty["mem_bytes"] is None and empty["up_s"] is None def test_rows_filter_by_search_stack_host_and_status(): rows = [service_row(_service("arr_radarr"), NOW), service_row(_service("arr_sonarr", state="down", running=0, replicas=[]), NOW), service_row(_service("portainer", replicas=[_replica("b")]), NOW), _container("traefik", compose_project="edge"), _container("backup", status="exited", exit_code=0)] def names(**kw): return [r["name"] for r in filter_rows(rows, **kw)] assert names(q="SONARR") == ["arr_sonarr"] and names(q="nginx") == ["traefik", "backup"] assert names(stack="arr") == ["arr_radarr", "arr_sonarr"] and names(q="edge") == ["traefik"] assert names(stack=NO_STACK) == ["portainer", "backup"] assert names(host="b") == ["portainer"] and names(host="nas") == ["traefik", "backup"] assert names(state="problem") == ["arr_sonarr"] assert names(state="stopped") == ["arr_sonarr", "backup"] assert names(state="healthy") == ["arr_radarr", "portainer", "traefik"] assert filter_options(rows[:3], rows[3:]) == {"stacks": ["arr", "edge"], "hosts": ["a", "b", "nas"]} def test_rows_sort_by_column_with_missing_values_last(): busy = service_row(_service("busy", replicas=[_replica("a", cpu=300.0)]), NOW) idle = service_row(_service("idle", replicas=[_replica("a", cpu=5.0)]), NOW) down = service_row(_service("down", state="down", running=0, replicas=[]), NOW) rows = [idle, down, busy] assert [r["name"] for r in sort_rows(rows, "cpu", True)] == ["busy", "idle", "down"] assert [r["name"] for r in sort_rows(rows, "cpu", False)] == ["idle", "busy", "down"] assert [r["name"] for r in sort_rows(rows, "status", False)] == ["down", "busy", "idle"] assert [r["name"] for r in sort_rows(rows, "name", True)] == ["idle", "down", "busy"] def test_the_filters_live_in_the_url_without_their_defaults(): f = _filters({"q": " web ", "stack": "arr", "state": "bogus", "sort": "nope", "dir": "x"}) assert f == {"q": "web", "stack": "arr", "host": "", "state": "", "sort": "status", "dir": "asc"} assert _page_url(f) == "/plugins/docker/?q=web&stack=arr" assert _page_url(f, stack="") == "/plugins/docker/?q=web" plain = _filters({}) assert _sort_url(plain, "cpu") == "/plugins/docker/?sort=cpu" # busiest first by default assert _sort_url(plain, "name") == "/plugins/docker/?sort=name" cpu = _filters({"sort": "cpu"}) assert cpu["dir"] == "desc" and _sort_url(cpu, "cpu") == "/plugins/docker/?sort=cpu&dir=asc" def test_a_task_container_name_gives_its_slot_and_task(): assert svc.task_of("web", f"web.1.{TASK}") == ("1", TASK) assert svc.task_of("web", f"web.q8n2.{TASK}") == ("q8n2", TASK) # a global service's node slot assert svc.task_of("web", f"web_db.1.{TASK}") is None assert svc.task_of("arr", f"arr_radarr.1.{TASK}") is None assert svc.task_of("web", "web.1.short") is None def test_service_history_sums_replicas_after_averaging_each(): since = NOW - timedelta(hours=1) t = since + timedelta(minutes=10) samples = [("a", t, 100.0, 1000), ("a", t + timedelta(seconds=20), 300.0, 3000), ("b", t + timedelta(seconds=5), 50.0, 500), ("a", t + timedelta(minutes=5), 10.0, 100)] out = svc.sum_series(samples, since, NOW, points=60) assert out == [(t, 250.0, 2500), (t + timedelta(minutes=5), 10.0, 100)] def test_a_failure_carries_the_task_whose_logs_tell_why(): sick = {"name": "codex_db", "tasks_known": True, "failures_24h": 2, "last_error": {"state": "failed", "error": "boom", "task_id": TASK, "node": "nas", "at": None}} (row,) = svc.failures_24h([], [sick], {}) assert row["kind"] == "service" and row["task_id"] == TASK old = {"name": "legacy", "tasks_known": False, "failures_24h": None, "last_error": None} died = NS(container_name=f"legacy.1.{TASK}", host_id="h1", at=NOW, event="die", detail="exit 1") (row,) = svc.failures_24h([died], [old], {"h1": "nas"}) assert row["kind"] == "service" and row["task_id"] == TASK def test_task_rows_say_where_each_ran_and_why_it_ended(): rows = _task_rows([ {"task_id": TASK, "slot": 2, "node_id": "n1", "state": "failed", "desired_state": "shutdown", "error": "task: non-zero exit (1)", "exit_code": 1, "at": "2026-10-07T11:00:00Z"}, {"task_id": "y" * 25, "slot": 2, "node_id": "n9" * 10, "state": "running", "desired_state": "running"}], {"n1": "docker02"}) assert rows[0]["node"] == "docker02" and rows[0]["failed"] and rows[0]["why"] == "task: non-zero exit (1)" assert rows[1]["node"] == ("n9" * 10)[:12] and rows[1]["current"] and not rows[1]["failed"] LIVE = "l7q2m9k4p1r8s5t3v6w0y2z4b" # the running task def _degraded_radarr(now, **over): """arr_radarr at 1 of 2: a running task on docker02 with figures, one on a node with no agent, and a failed task Swarm replaced.""" usage = {"cores": 0.1, "cpus": 4, "cpu_share": 2.5, "mem_bytes": 100 << 20, "mem_pct": None, "mem_of": None, "mem_limit": None} s = _service("arr_radarr", running=1, desired=2, state="degraded", failures_24h=3, replicas=[ dict(_replica("docker02"), name=f"arr_radarr.1.{LIVE}"), {"ghost": True, "host": "edge01", "count": 1}], last_error={"state": "failed", "error": "task: non-zero exit (1)", "exit_code": 1, "node": "docker02", "age_s": 300, "task_id": TASK, "slot": 2}, trouble_since=now - timedelta(minutes=10)) s.update(over) replicas = [dict(s["replicas"][0], usage=usage), s["replicas"][1]] tasks = _task_rows([ {"task_id": LIVE, "slot": 1, "node_id": "n1", "state": "running", "desired_state": "running", "message": "started", "at": "2026-10-07T10:00:00Z"}, {"task_id": TASK, "slot": 2, "node_id": "n1", "state": "failed", "desired_state": "shutdown", "error": "task: non-zero exit (1)", "exit_code": 1, "at": "2026-10-07T09:00:00Z"}], {"n1": "vdnt-docker02"}) return s, _service_tasks("arr_radarr", tasks, replicas, {"n1": "id-docker02"}, {"id-docker02": "docker02"}) def test_a_services_tasks_are_one_table_running_first(): """#5310: the running task carries its container's figures; the node reads as its Steward host wherever one is known, Swarm's hostname otherwise; a node with no agent still gets a row.""" _, rows = _degraded_radarr(NOW) assert [r["task_id"] for r in rows] == [LIVE, "", TASK] live, ghost, failed = rows assert live["current"] and live["usage"]["cores"] == 0.1 and live["container"] == f"arr_radarr.1.{LIVE}" assert live["node"] == failed["node"] == "docker02" and failed["host_id"] == "id-docker02" assert live["at"] == NOW - timedelta(hours=2) # its container's start assert ghost["node"] == "edge01" and ghost["current"] and ghost["usage"] is None assert "no Steward agent" in ghost["why"] assert failed["failed"] and failed["usage"] is None and failed["container"] is None # A running replica Swarm's record does not list still gets its row. orphan = _service_tasks("arr_radarr", [], [dict(_replica("docker02"), name=f"arr_radarr.3.{LIVE}", usage=None)], {}, {}) assert orphan[0]["slot"] == "3" and orphan[0]["task_id"] == LIVE and orphan[0]["current"] async def _service_page(app, s, tasks, now): async with app.test_request_context(f"/plugins/docker/service/arr_radarr?task={TASK}"): session["user_role"] = "viewer" page = await render_plugin_template( app, "docker", "docker/service.html", name="arr_radarr", service=s, row=service_row(s, now), svc=NS(image="lscr.io/linuxserver/radarr:5@sha256:abc", updated_at=now), tasks=tasks, stack="arr", current_range="24h", focus_task=TASK, task_choices=[(TASK, f"2 · {TASK[:10]}"), (LIVE, f"1 · {LIVE[:10]}")]) return re.sub(r">\s+<", "><", " ".join(page.split())) async def test_the_service_page_says_each_fact_once(app): """#5310: the verdict and what it runs in the header; the failure card only while in trouble; history; one Tasks table; logs. No tiles, no kv card, no separate Replicas table.""" now = datetime.now(timezone.utc) s, tasks = _degraded_radarr(now) page = await _service_page(app, s, tasks, now) assert '