feat(docker): Docker disk is recorded over time per host, the Disk page shows each host's growth and folds images under their repository, and the overview has one disk line per host (#5304–#5306)
- #5304 "Docker disk totals recorded over time per host, with a growth chart and alertable metrics" - #5305 "Disk page: images grouped by repository, versions folded under it" - #5306 "Docker overview: one disk line per host linking to the Disk page" Each `docker system df` report is now also recorded as metrics, with the host as the resource: disk_total_bytes, its parts, and disk_reclaimable_bytes. An alert rule can watch them. The Disk page starts with the hosts side by side, showing how much each grew over the selected range with a line of it, then gives each host its own section. An image's versions fold under its repository, which is charged only for the layers its versions don't share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -611,7 +611,8 @@ def test_disk_usage_persisted(app):
|
||||
async with s.begin():
|
||||
await s.execute(text("DELETE FROM docker_images"))
|
||||
await s.execute(text("DELETE FROM docker_disk_usage"))
|
||||
h = Host(id=str(uuid.uuid4()), name="dsk", address="10.6.6.6")
|
||||
hid = str(uuid.uuid4())
|
||||
h = Host(id=hid, name=f"dsk-{hid[:8]}", address="10.6.6.6")
|
||||
s.add(h)
|
||||
await s.flush()
|
||||
hid = h.id
|
||||
@@ -631,9 +632,17 @@ def test_disk_usage_persisted(app):
|
||||
reclaimable2 = (await s.execute(text(
|
||||
"SELECT images_reclaimable FROM docker_disk_usage WHERE host_id=:h"),
|
||||
{"h": hid})).scalar()
|
||||
return summary, img_count, [r[0] for r in after], reclaimable2
|
||||
# Each report is recorded as metrics too, resource = the host (#5304).
|
||||
metrics = (await s.execute(text(
|
||||
"SELECT metric_name, value FROM plugin_metrics WHERE source_module = 'docker' "
|
||||
"AND resource_name = :r"), {"r": f"dsk-{hid[:8]}"})).all()
|
||||
return summary, img_count, [r[0] for r in after], reclaimable2, metrics
|
||||
|
||||
summary, img_count, after_ids, reclaimable2 = asyncio.run(_go())
|
||||
summary, img_count, after_ids, reclaimable2, metrics = asyncio.run(_go())
|
||||
assert {v for m, v in metrics if m == "disk_total_bytes"} == {1155.0} # 1000 + 100 + 40 + 15
|
||||
assert sorted(v for m, v in metrics if m == "disk_reclaimable_bytes") == [0.0, 700.0]
|
||||
assert {m for m, _ in metrics} == {"disk_images_bytes", "disk_containers_bytes", "disk_volumes_bytes",
|
||||
"disk_build_cache_bytes", "disk_total_bytes", "disk_reclaimable_bytes"}
|
||||
assert summary[0] == 700 and summary[1] == 2 and summary[2] == 15
|
||||
assert img_count == 2
|
||||
assert after_ids == ["aaaaaaaaaaaa"] # the unreferenced image pruned on re-report
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Integration: the Disk page's hosts side by side with their growth (#5304)
|
||||
and the overview's disk line (#5306), from the totals ingest records as
|
||||
metrics. Requires STEWARD_DATABASE_URL."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
if not os.environ.get("STEWARD_DATABASE_URL"):
|
||||
pytest.skip("needs Postgres")
|
||||
from steward.app import create_app
|
||||
return create_app(testing=False)
|
||||
|
||||
|
||||
def _docker(sub):
|
||||
"""The app's loaded docker plugin module (see test_docker_recent_usage)."""
|
||||
from steward.core.plugin_manager import get_loaded_plugin_modules
|
||||
loaded = get_loaded_plugin_modules().get("docker")
|
||||
base = loaded.__name__ if loaded is not None else "plugins.docker"
|
||||
return importlib.import_module(f"{base}.{sub}")
|
||||
|
||||
|
||||
def test_the_disk_page_says_how_much_each_host_grew(app):
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.metrics import PluginMetric
|
||||
from steward.models.users import User, UserRole
|
||||
m = _docker("models")
|
||||
disk_hosts = _docker("service").disk_hosts
|
||||
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
hid, uid = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
name = f"disk-{tag}"
|
||||
now = datetime.now(timezone.utc)
|
||||
gib = 1 << 30
|
||||
|
||||
async def go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(User(id=uid, username=f"dp-{tag}", email=f"dp-{tag}@test",
|
||||
password_hash="x", role=UserRole.viewer, is_active=True))
|
||||
s.add(Host(id=hid, name=name, address="10.9.6.1"))
|
||||
async with s.begin():
|
||||
s.add(m.DockerDiskUsage(host_id=hid, images_size=30 * gib, containers_size=2 * gib,
|
||||
volumes_size=10 * gib, build_cache_size=0, images_reclaimable=5 * gib,
|
||||
images_total=12, images_active=8, updated_at=now))
|
||||
s.add(m.DockerImage(host_id=hid, image_id="sha256:" + "a" * 64, repo_tag=f"ghcr.io/x/app{tag}:1",
|
||||
size=gib, shared_size=0, containers=1, updated_at=now))
|
||||
s.add(m.DockerImage(host_id=hid, image_id="sha256:" + "b" * 64, repo_tag=f"ghcr.io/x/app{tag}:0",
|
||||
size=gib, shared_size=0, containers=0, updated_at=now))
|
||||
s.add_all([PluginMetric(source_module="docker", resource_name=name, metric_name="disk_total_bytes",
|
||||
value=float(v * gib), recorded_at=now - timedelta(days=d))
|
||||
for d, v in ((6, 40), (3, 41), (0, 42))])
|
||||
async with s.begin():
|
||||
rows = await disk_hosts(s, now - timedelta(days=7))
|
||||
|
||||
client = app.test_client()
|
||||
async with client.session_transaction() as sess:
|
||||
sess.update(user_id=uid, user_role="viewer", username=f"dp-{tag}", login_at=time.time())
|
||||
frag = await (await client.get("/plugins/docker/disk/hosts?range=7d")).get_data(as_text=True)
|
||||
page = await (await client.get("/plugins/docker/disk")).get_data(as_text=True)
|
||||
index = await (await client.get("/plugins/docker/")).get_data(as_text=True)
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.delete(await s.get(Host, hid))
|
||||
await s.delete(await s.get(User, uid))
|
||||
return rows, frag, page, index
|
||||
|
||||
rows, frag, page, index = asyncio.run(go())
|
||||
(row,) = [r for r in rows if r["host_id"] == hid]
|
||||
assert row["total"] == 42 * gib and row["reclaimable"] == 5 * gib
|
||||
assert row["growth"]["change"] == 2 * gib and row["growth"]["from"] == 40 * gib
|
||||
|
||||
assert f'<a href="#host-{hid}">{name}</a>' in frag and "+2.0 GiB" in frag and "<polyline" in frag
|
||||
assert f'id="host-{hid}"' in page and f"ghcr.io/x/app{tag}" in page and "(1 unused)" in page
|
||||
assert 'hx-get="/plugins/docker/disk/hosts"' in page
|
||||
assert f'href="/plugins/docker/disk#host-{hid}">{name}</a>' in index and "+2.0 GiB in 7d" in index
|
||||
@@ -206,27 +206,55 @@ async def test_the_docker_page_has_a_filter_bar_the_rows_read(app):
|
||||
assert 'hx-include="#docker-filters"' in page and "change from:#docker-filters" in page
|
||||
|
||||
|
||||
async def test_the_disk_page_explains_its_terms_and_names_untagged_images(app):
|
||||
"""#5212: no unexplained jargon, no "<none>" rows, and a capped list says so."""
|
||||
summary = {"images_total": 312, "images_active": 40, "images_size": "40 GiB",
|
||||
"images_reclaimable": "12 GiB", "layers_size": "30 GiB", "containers_count": 9,
|
||||
"containers_size": "1.2 GiB", "volumes_count": 4, "volumes_size": "3 GiB",
|
||||
"build_cache_size": "2 GiB"}
|
||||
images = [{"repo_tag": "nginx:1", "image_id": "aaaaaaaaaaaa", "size": "180 MiB", "shared": "70 MiB",
|
||||
"containers": 2, "reclaimable": False},
|
||||
{"repo_tag": None, "image_id": "bbbbbbbbbbbb", "size": "700 MiB", "shared": "0 B",
|
||||
"containers": 0, "reclaimable": True}]
|
||||
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,
|
||||
"stopped": 1, "images": images, "has_target": True, "images_capped": True}
|
||||
"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"):
|
||||
page = await render_plugin_template(app, "docker", "docker/disk.html",
|
||||
host_groups=[group], ansible_available=True)
|
||||
assert "untagged</span> bbbbbbbbbbbb" in page and "<none>" not in page
|
||||
assert "The 2 largest of 312 images" in page and "as of <time" in page
|
||||
assert "Writable layers" not in page and "Container files" in page
|
||||
assert "held by images no container uses" in page and "safe to clear" in page
|
||||
assert "Shared with other images" in page and "2 containers" in page and ">nothing<" in page
|
||||
assert "dangling" not in page
|
||||
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):
|
||||
|
||||
@@ -276,3 +276,57 @@ async def test_the_swarm_page_is_a_card_per_node(app):
|
||||
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"')
|
||||
|
||||
Reference in New Issue
Block a user