The agent sent Docker's working set (usage less inactive_file), which still counts recently used file cache. A file-heavy container with no limit read 191 GB while its processes held 2.2 GB, and the host's own figure, which already leaves cache out, disagreed with it. Agent 1.23.0 sends mem_used_bytes / mem_used_pct (anon + shmem, so Postgres's shared buffers count; v1: rss + shmem) and mem_cache_bytes apart. The new names keep the two meanings apart in history and in learned normals: docker_013 renames the columns, clears the old values and adds mem_cache_bytes. The Docker page, swarm by-node sums, Busiest Containers, container detail, normals and the MCP tools all read the new figure. File cache is shown only on the container's page and in get_container. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
420 lines
25 KiB
Python
420 lines
25 KiB
Python
"""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_used_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; each problem section carries its own count (#5214).
|
||
assert page.index('class="page-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 in the last 24h" not in page and "stat-strip" 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")
|
||
|
||
assert ('Needs attention <span class="tone-crit">1</span>'
|
||
'<span class="tone-muted"> · 1 restarting</span></h2>') in card
|
||
assert '">1 stale</span>' in page[:page.index("</p>")] # stale sits in the verdict
|
||
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 ('<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 "<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):
|
||
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):
|
||
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("</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
|
||
assert '<span class="badge badge-crit">Error</span><a href="/hosts/h2">web-1</a>' 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("</section>")]
|
||
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="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 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
|
||
# The count sits in the heading of the list it counts: one widget, not a tile beside it.
|
||
assert 'Failed in the last 24h <span class="tone-warn">2</span></h2>' in page
|
||
assert "stat-tile" not 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_folds_images_by_repository(app):
|
||
"""#5212, #5305: no unexplained jargon, no "<none>" rows, a capped list says
|
||
so; an image's versions sit under its repository."""
|
||
from quart import session
|
||
from plugins.docker.disk_view import group_images
|
||
gib = 1 << 30
|
||
summary = NS(images_total=312, images_active=40, images_size=40 * gib, images_reclaimable=12 * gib,
|
||
containers_count=9, containers_size=gib, volumes_count=4, volumes_size=3 * gib,
|
||
build_cache_size=2 * gib)
|
||
repos = group_images([
|
||
{"repo_tag": "nginx:1.27", "image_id": "sha256:" + "a" * 64, "size": 180 << 20, "shared_size": 70 << 20, "containers": 2},
|
||
{"repo_tag": "nginx:1.25", "image_id": "sha256:" + "c" * 64, "size": 170 << 20, "shared_size": 70 << 20, "containers": 0},
|
||
{"repo_tag": "<none>", "image_id": "sha256:" + "b" * 64, "size": 700 << 20, "shared_size": 0, "containers": 0}])
|
||
group = {"host_id": "h1", "host_name": "nas", "as_of": NOW - timedelta(minutes=2), "summary": summary,
|
||
"total": 46 * gib, "stopped": 1, "repos": repos, "images_listed": 3, "has_target": True,
|
||
"images_capped": True}
|
||
async with app.test_request_context("/plugins/docker/disk"):
|
||
session["user_role"] = "admin"
|
||
page = await render_plugin_template(app, "docker", "docker/disk.html", host_groups=[group],
|
||
ansible_available=True, current_range="7d")
|
||
page = re.sub(r">\s+<", "><", " ".join(page.split()))
|
||
assert 'hx-get="/plugins/docker/disk/hosts"' in page and 'id="time-range"' in page
|
||
assert 'id="host-h1"' in page and "as of <time" in page
|
||
assert '<span class="tone-muted">untagged</span>' in page and "<none>" not in page
|
||
assert "the 3 largest of 312 images" in page
|
||
assert '<span class="mono">nginx</span>' in page and ">1.27<" in page and ">1.25<" in page
|
||
assert "(1 unused)" in page and ">2 containers<" in page and ">nothing<" in page
|
||
assert "Container files" in page and "held by images no container uses" in page and "safe to clear" in page
|
||
assert page.count('action="/plugins/docker/disk/h1/prune"') == 3
|
||
assert "Writable layers" not in page and "dangling" not in page
|
||
|
||
|
||
async def test_the_disk_hosts_say_how_much_each_grew(app):
|
||
gib = 1 << 30
|
||
rows = [{"host_id": "h1", "host_name": "docker02", "total": 42 * gib, "reclaimable": 5 * gib,
|
||
"parts": {"disk_images_bytes": 30 * gib, "disk_volumes_bytes": 10 * gib,
|
||
"disk_containers_bytes": 2 * gib, "disk_build_cache_bytes": 0},
|
||
"growth": {"change": 6 * gib, "since": NOW - timedelta(days=7), "from": 36 * gib, "to": 42 * gib,
|
||
"pct": 16.7}, "spark": "<svg></svg>"},
|
||
{"host_id": "h2", "host_name": "util01", "total": gib, "reclaimable": 0,
|
||
"parts": {"disk_images_bytes": gib, "disk_volumes_bytes": 0, "disk_containers_bytes": 0,
|
||
"disk_build_cache_bytes": 0}, "growth": None, "spark": "<svg></svg>"}]
|
||
async with app.test_request_context("/plugins/docker/disk/hosts"):
|
||
page = await render_plugin_template(app, "docker", "docker/_disk_hosts.html", rows=rows, range_key="7d")
|
||
page = re.sub(r">\s+<", "><", " ".join(page.split()))
|
||
assert '<a href="#host-h1">docker02</a>' in page and "<strong>42.0 GiB</strong>" in page
|
||
assert 'class="tone-warn"' in page and ">+6.0 GiB<" in page and "Change (7d)" in page
|
||
assert "growth shows once there are two readings" in page
|
||
assert not hex_colours(page), hex_colours(page)
|
||
|
||
|
||
def _widget_svc(name, state="healthy", running=1, desired=1, hosts=("VDNT-Docker02",), **kw):
|
||
base = dict(name=name, state=state, running=running, desired=desired, crash_looping=False,
|
||
failures_1h=0, last_error=None, trouble_since=None,
|
||
replicas=[{"host": h, "ghost": False} for h in hosts])
|
||
base.update(kw)
|
||
return base
|
||
|
||
|
||
async def _services_widget(app, **ctx):
|
||
base = dict(bad_services=[], bad_containers=[], healthy=[], healthy_more=0, services_total=0,
|
||
services_healthy=0, running_count=0, failures=[], host_name={"h1": "VDNT-Docker02"},
|
||
total_count=1, widget_id="1")
|
||
base.update(ctx)
|
||
async with app.test_request_context("/plugins/docker/widget"):
|
||
return await render_plugin_template(app, "docker", "docker/widget.html", **base)
|
||
|
||
|
||
async def test_the_services_widget_lists_what_is_wrong_then_packs_the_healthy(app):
|
||
"""#5252: a verdict, the troubled service with why and since when, the
|
||
failures by name, then healthy services one line each in a packed grid."""
|
||
bad = _widget_svc("codex_db", state="down", running=0, crash_looping=True, failures_1h=7,
|
||
trouble_since=datetime.now(timezone.utc) - timedelta(minutes=9))
|
||
page = await _services_widget(
|
||
app, bad_services=[bad], services_total=3, services_healthy=2, running_count=4,
|
||
failures=[{"name": "codex_db", "host": "VDNT-Docker03", "count": 7, "at": NOW - timedelta(minutes=1),
|
||
"detail": "exit 1"}],
|
||
healthy=[{"kind": "service", "s": _widget_svc("curator_app")},
|
||
{"kind": "service", "s": _widget_svc("web", running=2, desired=2, hosts=("a", "b", "a"))},
|
||
{"kind": "container", "c": _c("portainer_agent")}],
|
||
healthy_more=12)
|
||
assert "2/3 services healthy" in page and "4 other containers running" in page and "1 failed in 24h" in page
|
||
assert page.index("Needs a look") < page.index("Failed in the last 24h") < page.index("Healthy")
|
||
assert "crash-looping: 7 failed in the last hour" in page and "9m ago" in page
|
||
assert "7 times, last" in page and "exit 1" in page
|
||
assert 'class="w-grid"' in page and " · a, b</span>" in page and ">2/2<" in page
|
||
assert "+12 more healthy" in page
|
||
assert not hex_colours(page), hex_colours(page)
|
||
|
||
|
||
async def test_a_troubled_container_says_since_when_on_the_widget(app):
|
||
"""#5810: a container in trouble carries its since, as the Docker page does."""
|
||
sick = _c("backup_cron", host_id="h1", status="running", health="unhealthy")
|
||
page = await _services_widget(
|
||
app, bad_containers=[sick],
|
||
bad_since={("h1", "backup_cron"): datetime.now(timezone.utc) - timedelta(minutes=40)})
|
||
page = " ".join(page.split())
|
||
assert "unhealthy since <time" in page and "40m ago" in page and "· VDNT-Docker02" in page
|
||
|
||
|
||
async def test_the_services_widget_says_when_all_is_well(app):
|
||
page = await _services_widget(app, services_total=2, services_healthy=2, running_count=1,
|
||
healthy=[{"kind": "service", "s": _widget_svc("a")}])
|
||
assert "2/2 services healthy" in page and "nothing failed in 24h" in page and "tone-ok" in page
|
||
assert "Needs a look" not in page and "Failed in the last 24h" not in page
|
||
|
||
|
||
def test_a_container_reads_by_its_service_or_ci_job():
|
||
"""#5253: no task id, no slot number, no forty-character CI container name."""
|
||
assert svc.display_name(_c("scribe_app.1.x7q", service_name="scribe_app")) == "scribe_app"
|
||
ci = "GITEA-ACTIONS-TASK-32792-WORKFLOW-Desktop-Tauri-JOB-verify-Web-d718b95dad0b02ad3cc1"
|
||
assert svc.display_name(_c(ci)) == "CI: Desktop-Tauri · verify-Web"
|
||
assert svc.display_name(_c("portainer_agent")) == "portainer_agent"
|
||
|
||
|
||
def _bars(values, floor):
|
||
return svc.scaled_bars(values, floor)
|
||
|
||
|
||
async def test_busiest_containers_show_the_number_beside_its_recent_bars(app):
|
||
"""#5253, #5214: every figure has its unit; the bars beside it are the last
|
||
hours, scaled to the row's own peak; the widget names the busiest."""
|
||
rows = [
|
||
{"c": _c("GITEA-ACTIONS-TASK-1-WORKFLOW-Android-JOB-build-0123456789abcdef0"),
|
||
"label": "CI: Android · build", "host_name": "VDNT-Docker03",
|
||
"usage": {"cores": 1.62, "cpus": 8, "cpu_share": 20.25, "mem_bytes": 820 * 2**20,
|
||
"mem_pct": 5.0, "mem_of": "the host's memory", "mem_limit": None},
|
||
"cpu_bars": _bars([None, 0.8, 3.2, 1.6], 1.0), "mem_bars": _bars([None] * 4, 2**30)},
|
||
{"c": _c("tdarr"), "label": "tdarr", "host_name": "VDNT-Docker03",
|
||
"usage": {"cores": 0.25, "cpus": 8, "cpu_share": 3.1, "mem_bytes": None,
|
||
"mem_pct": None, "mem_of": None, "mem_limit": None},
|
||
"cpu_bars": _bars([0.25] * 4, 1.0), "mem_bars": _bars([None] * 4, 2**30)},
|
||
]
|
||
async with app.test_request_context("/plugins/docker/widget/resources"):
|
||
page = await render_plugin_template(app, "docker", "docker/widget_resources.html",
|
||
rows=rows, minutes=5, hours=6, cpu_floor=1.0,
|
||
mem_floor=2**30, widget_id="1")
|
||
assert "Busiest: <strong>CI: Android · build</strong> on VDNT-Docker03" in page and "1.6 of 8 cores" in page
|
||
assert ">1.6 cores<" in page and ">0.25 cores<" in page and ">820.0 MiB<" in page and ">—<" in page
|
||
# Android peaks at 3.2 cores, so 1.6 is half height; tdarr's 0.25 sits under the 1-core floor.
|
||
assert 'class="gap"' in page and "height:50.0%" in page and "height:25.0%" in page
|
||
assert "Peak 3.2 cores in the last 6h; bars reach the top at 3.2 cores" in page
|
||
assert "Peak 0.25 cores in the last 6h; bars reach the top at 1.0 cores" in page
|
||
assert "No samples in the last 6h" in page
|
||
assert len(re.findall(r"<th[ >]", page)) == 4
|
||
assert ">CI: Android · build<" in page # the long CI name only as the hover title
|
||
assert not hex_colours(page), hex_colours(page)
|
||
|
||
|
||
def test_bars_reach_the_peak_but_never_below_the_floor():
|
||
"""#5214: a 0.05 → 0.10 wobble stays near the floor; a 1.5 → 3 spike fills."""
|
||
idle = svc.scaled_bars([0.05, 0.10, None], 1.0)
|
||
assert idle == {"bars": [5.0, 10.0, None], "peak": 0.10, "roof": 1.0}
|
||
busy = svc.scaled_bars([1.5, 3.0], 1.0)
|
||
assert busy["bars"] == [50.0, 100.0] and busy["roof"] == 3.0
|
||
assert svc.scaled_bars([None, None], 1.0) == {"bars": [None, None], "peak": None, "roof": 1.0}
|
||
|
||
|
||
def test_bar_slots_end_at_the_bucket_now_falls_in():
|
||
now = datetime(2026, 10, 7, 12, 7, 30, tzinfo=timezone.utc)
|
||
slots, width = svc.bar_slots(now, 6)
|
||
assert width == 900 and len(slots) == 24
|
||
assert slots[-1] == datetime(2026, 10, 7, 12, 0, tzinfo=timezone.utc)
|
||
assert slots[0] == datetime(2026, 10, 7, 6, 15, tzinfo=timezone.utc)
|
||
|
||
|
||
def test_builders_and_ci_jobs_read_by_what_they_are_on_a_hosts_page():
|
||
assert svc.display_name(_c("buildx_buildkit_steward-builder0")) == "BuildKit builder · steward-builder"
|
||
ci = "FORGEJO-ACTIONS-TASK-8597-WORKFLOW-CI-JOB-unit"
|
||
assert svc.task_label(_c(ci)) == "CI: CI · unit" # not a swarm task: display_name
|
||
assert svc.task_label(_c("codex_db.1.37ma", service_name="codex_db")) == "codex_db · 1"
|
||
|
||
|
||
def _panel_row(name, cpu, status="running", problems=()):
|
||
c = _c(name, cpu_pct=cpu, status=status)
|
||
return {"c": c, "label": name, "problems": list(problems), "since": None,
|
||
"usage": svc.usage(c, 4, 8 << 30), "uptime": "2h" if status == "running" else None}
|
||
|
||
|
||
def _panel_rows():
|
||
return ([_panel_row(f"busy{n:02d}", float(n)) for n in range(1, 11)]
|
||
+ [_panel_row("old", None, status="exited"),
|
||
_panel_row("dead", None, status="exited", problems=["exited_nonzero"])])
|
||
|
||
|
||
def test_a_hosts_docker_panel_shows_problems_then_the_busiest_and_folds_the_rest():
|
||
"""#5276: 33 containers made the card run off the page."""
|
||
from plugins.docker.routes import HOST_PANEL_BUSIEST, host_panel_rows
|
||
shown, folded = host_panel_rows(_panel_rows())
|
||
assert [r["label"] for r in shown] == ["dead"] + [f"busy{n:02d}" for n in range(10, 2, -1)]
|
||
assert len(shown) == 1 + HOST_PANEL_BUSIEST
|
||
assert [r["label"] for r in folded] == ["busy02", "busy01", "old"]
|
||
|
||
|
||
async def test_the_folded_containers_sit_behind_one_line(app):
|
||
from plugins.docker.routes import host_panel_rows
|
||
shown, folded = host_panel_rows(_panel_rows())
|
||
async with app.test_request_context("/plugins/docker/host/h1"):
|
||
page = await render_plugin_template(
|
||
app, "docker", "docker/host_panel.html", rows=shown, folded=folded,
|
||
says=svc.PROBLEM_SAYS, severity=svc.PROBLEM_SEVERITY,
|
||
running=10, stopped=2, problems=1, replaced=0, busiest=8)
|
||
fold = page.index("<details")
|
||
assert "+2 more running · 1 stopped" in page
|
||
assert page.index(">busy10<") < fold < page.index(">busy01<")
|
||
assert page.index(">dead<") < page.index(">busy10<")
|
||
assert len(re.findall(r"<th[ >]", page)) == 6 # one header row per table
|
||
assert not hex_colours(page), hex_colours(page)
|