410 lines
25 KiB
Python
410 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_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="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 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_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)
|