449 lines
26 KiB
Python
449 lines
26 KiB
Python
"""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 '<nav class="breadcrumb"' in page and 'href="/plugins/docker/">Docker</a>' in page
|
|
assert page.index("Degraded") < page.index("Last failed task") < page.index('id="svc-history"') \
|
|
< page.index('id="tasks"') < page.index('id="logs"')
|
|
assert "1 of 2 running" in page and "in trouble since <time" in page
|
|
assert '<a class="chip" href="/plugins/docker/?stack=arr">arr</a>' in page
|
|
assert 'title="lscr.io/linuxserver/radarr:5@sha256:abc">lscr.io/linuxserver/radarr:5</span>' in page # digest on hover
|
|
assert f'href="?task={TASK}#logs">its logs →</a>' in page and "5m ago" in page
|
|
assert "stat-tile" not in page and 'class="kv"' not in page and "svc-replicas" not in page
|
|
tasks_html = page[page.index('id="tasks"'):page.index('id="logs"')]
|
|
assert '<span class="tone-warn">· 3 failed in 24h</span>' in tasks_html
|
|
assert f'href="/plugins/docker/container/id-docker02/arr_radarr.1.{LIVE}"' in tasks_html
|
|
assert '<a href="/hosts/id-docker02">docker02</a>' in tasks_html and "vdnt-docker02" not in tasks_html
|
|
assert "0.10 cores" in tasks_html and "100.0 MiB" in tasks_html
|
|
assert "1 running on a node with no Steward agent" in tasks_html
|
|
assert tasks_html.index(LIVE[:10]) < tasks_html.index("edge01") < tasks_html.index(TASK[:10])
|
|
assert '<span class="badge badge-crit">Failed</span>' in tasks_html
|
|
assert 'hx-get="/plugins/docker/service/arr_radarr/history"' in page and 'id="time-range"' in page
|
|
assert f'<option value="{TASK}" selected>2 · {TASK[:10]}</option>' in page
|
|
assert 'hx-get="/plugins/docker/service/arr_radarr/logs/lines"' in page
|
|
assert not hex_colours(page), hex_colours(page)
|
|
|
|
|
|
async def test_a_recovered_service_is_not_in_trouble(app):
|
|
"""#5310: a healthy service whose last task failed hours ago says when it
|
|
last failed, quietly, with its logs; no red card, no "in trouble"."""
|
|
now = datetime.now(timezone.utc)
|
|
s, tasks = _degraded_radarr(now, running=2, state="healthy", failures_24h=1)
|
|
s["last_error"] = dict(s["last_error"], age_s=5 * 3600, exit_code=137)
|
|
page = await _service_page(app, s, tasks, now)
|
|
assert "Healthy" in page and "2 of 2 running" in page
|
|
assert "in trouble" not in page and "Last failed task" not in page and "problem-card" not in page
|
|
assert "Last failed " in page and "(exit 137)" in page and f'href="?task={TASK}#logs">its logs</a>' in page
|
|
assert "reported <time" in page
|
|
|
|
|
|
async def test_a_service_nobody_reports_is_not_found(app):
|
|
async with app.test_request_context("/plugins/docker/service/gone"):
|
|
session["user_role"] = "viewer"
|
|
page = await render_plugin_template(app, "docker", "docker/service.html", name="gone", service=None)
|
|
assert "Service not found" in page and 'id="logs"' not in page
|
|
|
|
|
|
async def test_a_usage_history_shows_now_and_peak(app):
|
|
async with app.test_request_context("/plugins/docker/service/web/history"):
|
|
page = await render_plugin_template(
|
|
app, "docker", "docker/_usage_history.html", have_data=True, range_key="24h",
|
|
cpu_now=0.5, cpu_peak=2.0, mem_now=100 << 20, mem_peak=300 << 20,
|
|
cpu_floor=1.0, mem_floor=1 << 30, spark_cpu="<svg></svg>", spark_mem="<svg></svg>")
|
|
empty = await render_plugin_template(
|
|
app, "docker", "docker/_usage_history.html", have_data=False, range_key="1h")
|
|
assert "0.50 cores" in page and "peak 2.0 cores" in page and "peak 300.0 MiB" in page
|
|
assert "or to 1.0 core when the peak is lower" in page # the floor is said, not hidden
|
|
assert "Not enough samples in the last 1h" in empty
|
|
|
|
|
|
def _ctr(**kw):
|
|
base = dict(name="plex", host_id="h1", image="plexinc/pms:1.40", status="exited", health=None,
|
|
exit_code=137, oom_killed=True, restart_count=2, compose_project="media",
|
|
service_name=None, node_id=None, scraped_at=NOW - timedelta(seconds=30),
|
|
started_at=NOW - timedelta(hours=3))
|
|
base.update(kw)
|
|
return NS(**base)
|
|
|
|
|
|
async def _container_page(app, c, **over):
|
|
ctx = dict(container=c, host=NS(id="h1", name="nas"), host_id="h1", name=c.name, label=c.name,
|
|
node_name=None, exit_failed=True, ports=[], uptime=None,
|
|
usage={"mem_limit": None}, net_rx="1.0 KiB", net_tx="2.0 KiB", blk_read="3.0 MiB",
|
|
blk_write="4.0 MiB", current_range="24h",
|
|
died_at=datetime.now(timezone.utc) - timedelta(minutes=20), # |ago reads the clock
|
|
events=[{"event": "health_change", "detail": "healthy→unhealthy", "at": NOW,
|
|
"glyph": "◐", "tone": "warn"}])
|
|
ctx.update(over)
|
|
async with app.test_request_context(f"/plugins/docker/container/h1/{c.name}"):
|
|
session["user_role"] = "viewer"
|
|
page = await render_plugin_template(app, "docker", "docker/container_detail.html", **ctx)
|
|
return re.sub(r">\s+<", "><", " ".join(page.split()))
|
|
|
|
|
|
async def test_a_dead_container_says_why_first(app):
|
|
"""#5310: a stopped container that exited badly leads with how; what it
|
|
uses lives in Resource history alone; lifecycle is a table."""
|
|
page = await _container_page(app, _ctr())
|
|
assert 'href="/plugins/docker/">Docker</a>' in page
|
|
assert '<span class="badge badge-crit">Exited</span>' in page and 'href="/hosts/h1">nas</a>' in page
|
|
assert '<span class="chip">media</span>' in page and "plexinc/pms:1.40" in page
|
|
assert page.index("Last exit") < page.index('id="ctr-history"') < page.index('id="ctr-lifecycle"')
|
|
assert "killed for running out of memory" in page and "20m ago" in page
|
|
assert "<dt>Restarts</dt>" in page and "none set" in page and "1.0 KiB in · 2.0 KiB out" in page
|
|
assert 'hx-get="/plugins/docker/container/h1/plex/history"' in page
|
|
assert "<td><span class=\"tone-warn\" aria-hidden=\"true\">◐</span> health change</td>" in page
|
|
assert "stat-tile" not in page and "Net in since start" not in page
|
|
assert not hex_colours(page), hex_colours(page)
|
|
|
|
|
|
async def test_a_running_swarm_task_reads_under_its_service(app):
|
|
c = _ctr(name=f"arr_radarr.1.{LIVE}", status="running", exit_code=0, oom_killed=False,
|
|
service_name="arr_radarr", compose_project=None, node_id="n1")
|
|
page = await _container_page(app, c, label="arr_radarr · slot 1", exit_failed=False, uptime="3h",
|
|
node_name="docker02")
|
|
assert 'href="/plugins/docker/service/arr_radarr">arr_radarr</a>' in page # crumb and "task of"
|
|
assert "task of <a" in page and "· up 3h" in page and f"arr_radarr.1.{LIVE}" in page
|
|
assert "Last exit" not in page and "<dt>Restarts</dt>" not in page
|
|
assert "<dt>Swarm node</dt>" in page and "docker02" in page
|
|
|
|
|
|
async def test_a_service_log_line_names_its_task(app):
|
|
lines = [{"ts": NOW, "stream": "stderr", "line": "<b>boom</b>", "task": f"1 · {TASK[:10]}"}]
|
|
async with app.test_request_context("/plugins/docker/service/web/logs/lines"):
|
|
page = await render_plugin_template(app, "docker", "docker/_container_logs_lines.html",
|
|
lines=lines, name="web", filtered=False, service=True)
|
|
empty = await render_plugin_template(app, "docker", "docker/_container_logs_lines.html",
|
|
lines=[], name="web", filtered=False, service=True)
|
|
assert f"1 · {TASK[:10]}</span>" in page and "<b>boom</b>" in page
|
|
assert "one of its tasks" in empty
|
|
|
|
|
|
def _node(nid, hostname, role="worker", status="ready", availability="active", leader=False):
|
|
return NS(node_id=nid, hostname=hostname, role=role, status=status, availability=availability,
|
|
leader=leader)
|
|
|
|
|
|
def _swarm_svc(name, placement):
|
|
import json
|
|
return NS(service_name=name, placement_json=json.dumps(placement))
|
|
|
|
|
|
def _task(name, service, node, cpu, mem, status="running"):
|
|
return NS(name=name, service_name=service, node_id=node, status=status, cpu_pct=cpu,
|
|
mem_usage_bytes=mem)
|
|
|
|
|
|
def test_each_node_lists_what_runs_on_it_and_what_that_uses():
|
|
"""#5303: a node with an agent has figures; one without is Swarm's placement only."""
|
|
from plugins.docker.swarm_nodes import node_cards
|
|
nodes = [_node("n1", "docker02", role="manager", leader=True), _node("n2", "edge01"),
|
|
_node("n3", "docker03", availability="drain")]
|
|
services = [_swarm_svc("arr_radarr", [{"node_id": "n1", "running": 1}]),
|
|
_swarm_svc("web", [{"node_id": "n1", "running": 2}, {"node_id": "n2", "running": 1}]),
|
|
_swarm_svc("db", [{"node_id": "n1", "running": 1}])]
|
|
containers = [_task("arr_radarr.1.x", "arr_radarr", "n1", 150.0, 300),
|
|
_task("web.1.x", "web", "n1", 10.0, 100), _task("web.2.x", "web", "n1", 20.0, 100),
|
|
_task("web.0.old", "web", "n1", 99.0, 999, status="exited"),
|
|
_task("traefik", None, None, 5.0, 5)]
|
|
cards = node_cards(nodes, services, containers, node_host={"n1": "h1"}, host_names={"h1": "docker02"},
|
|
capacity={"h1": (8, 16 << 30)},
|
|
service_state={"arr_radarr": "healthy", "web": "healthy", "db": "down"})
|
|
assert [c["hostname"] for c in cards] == ["docker03", "docker02", "edge01"] # drained first, then managers
|
|
n1 = cards[1]
|
|
assert n1["host_id"] == "h1" and n1["leader"] and n1["agent"]
|
|
assert [t["name"] for t in n1["tasks"]] == ["db", "arr_radarr", "web"] # trouble, then busiest
|
|
assert n1["tasks"][0]["cores"] is None and not n1["tasks"][0]["measured"] # placed, not reported
|
|
web = n1["tasks"][2]
|
|
assert web["tasks"] == 2 and web["cores"] == 0.3 and web["mem_bytes"] == 200
|
|
assert n1["cores_used"] == 1.8 and n1["mem_used"] == 500 and n1["cores"] == 8
|
|
edge = cards[2]
|
|
assert not edge["agent"] and edge["cores_used"] is None
|
|
assert edge["tasks"] == [{"name": "web", "tasks": 1, "cpu_pct": None, "mem_bytes": None, "measured": False,
|
|
"state": "healthy", "cores": None}]
|
|
assert not cards[0]["ok"] and cards[0]["tasks"] == []
|
|
|
|
|
|
async def test_the_swarm_page_is_a_card_per_node(app):
|
|
from plugins.docker.swarm_nodes import node_cards
|
|
nodes = [_node("n1", "docker02", role="manager", leader=True), _node("n2", "edge01", status="down")]
|
|
cards = node_cards(nodes, [_swarm_svc("web", [{"node_id": "n2", "running": 1}])],
|
|
[_task("arr.1.x", "arr", "n1", 50.0, 1 << 30)], node_host={"n1": "h1"},
|
|
host_names={"h1": "docker02"}, capacity={"h1": (4, 8 << 30)},
|
|
service_state={"arr": "healthy", "web": "degraded"})
|
|
troubled = [{"name": "web", "running": 1, "desired": 2, "state": "degraded", "crash_looping": False,
|
|
"trouble_since": None, "last_error": {"state": "failed", "error": "no suitable node", "node": None}}]
|
|
async with app.test_request_context("/plugins/docker/swarm"):
|
|
session["user_role"] = "viewer"
|
|
page = await render_plugin_template(app, "docker", "docker/swarm.html", swarms=[{
|
|
"managers": ["docker02"], "services_total": 2, "services_healthy": 1, "troubled": troubled,
|
|
"nodes": cards}])
|
|
page = re.sub(r">\s+<", "><", " ".join(page.split()))
|
|
assert "2 need attention" in page and '<span class="tone-warn">1/2</span> services healthy' in page
|
|
problems = page[page.index('class="problem-list"'):]
|
|
assert "Node down" in problems and "edge01" in problems
|
|
assert '<a href="/plugins/docker/service/web">web</a>' in problems and "last task failed: no suitable node" in problems
|
|
assert page.index('id="node-n2"') < page.index('id="node-n1"') # the down node first
|
|
assert '<a href="/hosts/h1">docker02</a><span class="chip">leader</span>' in page
|
|
assert "Containers use <strong>0.50 cores</strong> of 4 · <strong>1.0 GiB</strong> of 8.0 GiB" in page
|
|
assert "No Steward agent" in page
|
|
assert '<a href="/plugins/docker/service/arr" title="arr">arr</a>' in page
|
|
|
|
|
|
def test_an_image_reference_splits_into_repository_and_tag():
|
|
from plugins.docker.disk_view import image_repo
|
|
assert image_repo("nginx:1.27") == ("nginx", "1.27")
|
|
assert image_repo("ghcr.io/x/app:2026.10.07") == ("ghcr.io/x/app", "2026.10.07")
|
|
assert image_repo("reg.local:5000/app") == ("reg.local:5000/app", None) # a port is not a tag
|
|
assert image_repo("reg.local:5000/app:3") == ("reg.local:5000/app", "3")
|
|
assert image_repo("app@sha256:" + "f" * 64) == ("app", "sha256:ffffffffffff")
|
|
assert image_repo("<none>") == (None, None) and image_repo("<none>:<none>") == (None, None)
|
|
assert image_repo(None) == (None, None)
|
|
|
|
|
|
def test_images_fold_under_their_repository_by_the_space_they_hold():
|
|
from plugins.docker.disk_view import group_images
|
|
repos = group_images([
|
|
{"repo_tag": "app:1", "image_id": "a", "size": 500, "shared_size": 100, "containers": 1},
|
|
{"repo_tag": "app:0", "image_id": "b", "size": 450, "shared_size": 100, "containers": 0},
|
|
{"repo_tag": "<none>", "image_id": "c", "size": 9000, "shared_size": 0, "containers": 0},
|
|
{"repo_tag": "db:16", "image_id": "d", "size": 300, "shared_size": 0, "containers": 1}])
|
|
assert [r["repo"] for r in repos] == ["app", "db", None] # untagged last
|
|
app_ = repos[0]
|
|
assert app_["own"] == 750 and app_["reclaimable"] == 350 and app_["unused"] == 1
|
|
assert [v["tag"] for v in app_["versions"]] == ["1", "0"] and app_["containers"] == 1
|
|
|
|
|
|
def test_disk_metrics_and_growth():
|
|
from plugins.docker.disk_view import disk_metrics, growth
|
|
got = disk_metrics({"images_size": 100, "containers_size": 10, "volumes_size": 5,
|
|
"build_cache_size": 1, "images_reclaimable": 40, "layers_size": 999})
|
|
assert got["disk_total_bytes"] == 116.0 and got["disk_reclaimable_bytes"] == 40.0
|
|
assert growth([(NOW, 1.0)]) is None
|
|
g = growth([(NOW - timedelta(days=7), 100.0), (NOW - timedelta(days=1), 90.0), (NOW, 125.0)])
|
|
assert g["change"] == 25.0 and g["pct"] == 25.0 and g["since"] == NOW - timedelta(days=7)
|
|
assert growth([(NOW, 0.0), (NOW, 5.0)])["pct"] is None
|
|
|
|
|
|
async def test_the_overview_has_one_disk_line_per_host(app):
|
|
gib = 1 << 30
|
|
disk = [{"host_id": "h1", "host_name": "docker02", "total": 42 * gib, "reclaimable": 5 * gib,
|
|
"growth": {"change": 2 * gib, "pct": 5.0, "since": NOW - timedelta(days=7)}},
|
|
{"host_id": "h2", "host_name": "util01", "total": gib, "reclaimable": 0, "growth": None}]
|
|
async with app.test_request_context("/plugins/docker/"):
|
|
session["user_role"] = "viewer"
|
|
page = await render_plugin_template(
|
|
app, "docker", "docker/index.html", poll_interval=10, has_swarm=False, has_disk=True, disk=disk,
|
|
f={"q": "", "stack": "", "host": "", "state": "", "sort": "status", "dir": "asc"},
|
|
choices={"stacks": [], "hosts": []}, states={}, no_stack="__none__")
|
|
page = re.sub(r">\s+<", "><", " ".join(page.split()))
|
|
line = page[page.index('class="docker-disk-line"'):]
|
|
line = line[:line.index("</p>")]
|
|
assert '<a href="/plugins/docker/disk#host-h1">docker02</a> 42.0 GiB' in line and "+2.0 GiB in 7d" in line
|
|
assert "5.0 GiB reclaimable" in line and '<a href="/plugins/docker/disk#host-h2">util01</a> 1.0 GiB' in line
|
|
assert page.index('id="docker-rows"') < page.index('class="docker-disk-line"')
|