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:
@@ -67,5 +67,7 @@ def get_nav() -> list[dict]:
|
||||
def get_alert_metrics() -> dict[str, list[str]]:
|
||||
"""What ingest.py records per container (resource "<host>/<container>"):
|
||||
restart_count is cumulative (alert on crash-looping); is_healthy is 1.0 /
|
||||
0.0 from the container's HEALTHCHECK, only for containers that define one."""
|
||||
return {"docker": ["cpu_pct", "mem_pct", "restart_count", "is_healthy"]}
|
||||
0.0 from the container's HEALTHCHECK, only for containers that define one.
|
||||
Per host (resource "<host>"), what Docker keeps on its disk (#5304)."""
|
||||
from .disk_view import DISK_METRICS
|
||||
return {"docker": ["cpu_pct", "mem_pct", "restart_count", "is_healthy", *DISK_METRICS]}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# plugins/docker/disk_view.py
|
||||
"""What Docker keeps on each host's disk, over time and by image repository
|
||||
(#5304–#5306). Model-free, so it is unit-tested without a database.
|
||||
|
||||
The agent reports `docker system df` as current state; ingest.py records its
|
||||
totals as metrics too (DISK_METRICS, resource = the host's name), so each
|
||||
host's growth has a history and an alert rule can watch it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
# metric name → the df summary field it records; "disk_total_bytes" is their sum.
|
||||
DISK_PARTS = {
|
||||
"disk_images_bytes": "images_size",
|
||||
"disk_containers_bytes": "containers_size",
|
||||
"disk_volumes_bytes": "volumes_size",
|
||||
"disk_build_cache_bytes": "build_cache_size",
|
||||
}
|
||||
DISK_METRICS = [*DISK_PARTS, "disk_total_bytes", "disk_reclaimable_bytes"]
|
||||
|
||||
|
||||
def disk_metrics(disk: dict) -> dict[str, float]:
|
||||
"""The metric samples one df report gives: each part, their total, and
|
||||
what unused images hold."""
|
||||
out = {m: float(int(disk.get(f) or 0)) for m, f in DISK_PARTS.items()}
|
||||
out["disk_total_bytes"] = sum(out.values())
|
||||
out["disk_reclaimable_bytes"] = float(int(disk.get("images_reclaimable") or 0))
|
||||
return out
|
||||
|
||||
|
||||
def growth(points: list[tuple[datetime, float]]) -> dict | None:
|
||||
"""How much a total changed over the points given (oldest first): the
|
||||
change, the first point's time and value. None with fewer than two."""
|
||||
if len(points) < 2:
|
||||
return None
|
||||
(first_at, first), (_, last) = points[0], points[-1]
|
||||
return {"change": last - first, "since": first_at, "from": first, "to": last,
|
||||
"pct": (last - first) / first * 100 if first else None}
|
||||
|
||||
|
||||
def image_repo(repo_tag: str | None) -> tuple[str | None, str | None]:
|
||||
"""("ghcr.io/x/app", "1.2") for "ghcr.io/x/app:1.2"; a registry's port is
|
||||
part of the repository ("reg:5000/app" has no tag); a digest reference
|
||||
keeps its digest as the tag; (None, None) for an untagged image."""
|
||||
if not repo_tag or repo_tag == "<none>" or repo_tag.startswith("<none>"):
|
||||
return None, None
|
||||
if "@" in repo_tag:
|
||||
repo, _, digest = repo_tag.partition("@")
|
||||
return repo, digest[:19]
|
||||
head, sep, tag = repo_tag.rpartition(":")
|
||||
if not sep or "/" in tag:
|
||||
return repo_tag, None
|
||||
return head, tag
|
||||
|
||||
|
||||
def group_images(images: list[dict]) -> list[dict]:
|
||||
"""Images folded under their repository, the most space first.
|
||||
|
||||
Each image: {repo_tag, image_id, size, shared_size, containers}. A group's
|
||||
`own` counts each version's size less the layers it shares with other
|
||||
images, so a repository is not charged twice for one base layer;
|
||||
`reclaimable` is that, for the versions no container uses. Untagged images
|
||||
form one group of their own (repo None)."""
|
||||
groups: dict[str | None, dict] = {}
|
||||
for im in images:
|
||||
repo, tag = image_repo(im.get("repo_tag"))
|
||||
g = groups.setdefault(repo, {"repo": repo, "versions": [], "own": 0, "size": 0,
|
||||
"reclaimable": 0, "containers": 0})
|
||||
own = max(int(im.get("size") or 0) - int(im.get("shared_size") or 0), 0)
|
||||
used = int(im.get("containers") or 0)
|
||||
g["versions"].append({"tag": tag, "image_id": im.get("image_id"), "size": int(im.get("size") or 0),
|
||||
"shared": int(im.get("shared_size") or 0), "own": own, "containers": used})
|
||||
g["own"] += own
|
||||
g["size"] = max(g["size"], int(im.get("size") or 0))
|
||||
g["containers"] += used
|
||||
if not used:
|
||||
g["reclaimable"] += own
|
||||
for g in groups.values():
|
||||
g["versions"].sort(key=lambda v: (v["containers"] == 0, -v["size"]))
|
||||
g["unused"] = sum(1 for v in g["versions"] if not v["containers"])
|
||||
return sorted(groups.values(), key=lambda g: (g["repo"] is None, -g["own"], g["repo"] or ""))
|
||||
@@ -240,6 +240,14 @@ async def _persist_disk(session, host, disk: dict) -> None:
|
||||
summary.containers_count = int(disk.get("containers_count") or 0)
|
||||
summary.volumes_count = int(disk.get("volumes_count") or 0)
|
||||
summary.updated_at = now
|
||||
# The totals as metrics too (#5304): a history of each host's growth, and
|
||||
# something an alert rule can watch. Resource: the host's name.
|
||||
from steward.core.alerts import MetricBatch
|
||||
from .disk_view import disk_metrics
|
||||
batch = MetricBatch()
|
||||
for metric, value in disk_metrics(disk).items():
|
||||
batch.add(source_module="docker", resource_name=host.name, metric_name=metric, value=value)
|
||||
await batch.record(session)
|
||||
|
||||
images = disk.get("images") or []
|
||||
seen: set[str] = set()
|
||||
|
||||
+32
-20
@@ -110,11 +110,15 @@ async def index():
|
||||
has_disk = (await db.execute(
|
||||
select(DockerDiskUsage.host_id).limit(1))).first() is not None
|
||||
choices = await _filter_choices(db)
|
||||
# One line per host under the tables: what Docker keeps on its disk and
|
||||
# how much that grew in a week (#5306); the Disk page has the rest.
|
||||
disk = await docker_svc.disk_hosts(
|
||||
db, datetime.now(timezone.utc) - timedelta(days=7), points=14) if has_disk else []
|
||||
return await render_template(
|
||||
"docker/index.html",
|
||||
poll_interval=poll_interval,
|
||||
has_swarm=has_swarm,
|
||||
has_disk=has_disk,
|
||||
has_disk=has_disk, disk=disk,
|
||||
f=_filters(request.args), choices=choices, states=DOCKER_STATES, no_stack=NO_STACK,
|
||||
)
|
||||
|
||||
@@ -627,7 +631,9 @@ IMAGES_REPORTED = 50
|
||||
@docker_bp.get("/disk")
|
||||
@require_role(UserRole.viewer)
|
||||
async def disk():
|
||||
"""Image/disk usage page: reclaimable space, per-image sizes, stopped count.
|
||||
"""What Docker keeps on each host's disk (#5304, #5305): the hosts side by
|
||||
side with how much each grew over the selected range (the /disk/hosts
|
||||
fragment), then each host's parts, cleanup and images by repository.
|
||||
|
||||
Admins can reclaim space per host via the prune buttons, which fire the
|
||||
audited bundled prune playbook through Ansible (m78) — the collection agent
|
||||
@@ -635,6 +641,7 @@ async def disk():
|
||||
"""
|
||||
from steward.core.capabilities import has_capability
|
||||
from steward.models.ansible_inventory import AnsibleTarget
|
||||
from .disk_view import group_images
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
|
||||
@@ -664,43 +671,48 @@ async def disk():
|
||||
images_by_host: dict[str, list] = {}
|
||||
for im in images:
|
||||
images_by_host.setdefault(im.host_id, []).append({
|
||||
"repo_tag": None if im.repo_tag in ("<none>", "", None) else im.repo_tag,
|
||||
"image_id": im.image_id, "size": human_bytes(im.size),
|
||||
"shared": human_bytes(im.shared_size), "containers": im.containers,
|
||||
"reclaimable": im.containers == 0,
|
||||
})
|
||||
"repo_tag": im.repo_tag, "image_id": im.image_id, "size": im.size,
|
||||
"shared_size": im.shared_size, "containers": im.containers})
|
||||
|
||||
host_groups = [
|
||||
{
|
||||
"host_id": s.host_id,
|
||||
"host_name": hosts[s.host_id].name if s.host_id in hosts else s.host_id,
|
||||
"as_of": s.updated_at,
|
||||
"summary": {
|
||||
"images_total": s.images_total, "images_active": s.images_active,
|
||||
"images_size": human_bytes(s.images_size),
|
||||
"images_reclaimable": human_bytes(s.images_reclaimable),
|
||||
"layers_size": human_bytes(s.layers_size),
|
||||
"containers_count": s.containers_count,
|
||||
"containers_size": human_bytes(s.containers_size),
|
||||
"volumes_count": s.volumes_count,
|
||||
"volumes_size": human_bytes(s.volumes_size),
|
||||
"build_cache_size": human_bytes(s.build_cache_size),
|
||||
},
|
||||
"summary": s,
|
||||
"total": s.images_size + s.containers_size + s.volumes_size + s.build_cache_size,
|
||||
"stopped": stopped_by_host.get(s.host_id, 0),
|
||||
"images": images_by_host.get(s.host_id, []),
|
||||
"repos": group_images(images_by_host.get(s.host_id, [])),
|
||||
"images_listed": len(images_by_host.get(s.host_id, [])),
|
||||
# The agent reports the largest IMAGES_REPORTED images (agent._disk_images).
|
||||
"images_capped": len(images_by_host.get(s.host_id, [])) >= IMAGES_REPORTED,
|
||||
"has_target": s.host_id in target_by_host,
|
||||
}
|
||||
for s in summaries
|
||||
]
|
||||
host_groups.sort(key=lambda g: g["host_name"].lower())
|
||||
host_groups.sort(key=lambda g: (-g["total"], g["host_name"].lower()))
|
||||
return await render_template(
|
||||
"docker/disk.html", host_groups=host_groups,
|
||||
ansible_available=has_capability("ansible.run_playbook"),
|
||||
current_range=request.args.get("range", "7d"),
|
||||
)
|
||||
|
||||
|
||||
@docker_bp.get("/disk/hosts")
|
||||
@require_role(UserRole.viewer)
|
||||
async def disk_hosts_fragment():
|
||||
"""HTMX fragment: every Docker host's disk side by side, with how much its
|
||||
total grew over the selected range and a line of it (#5304)."""
|
||||
from steward.core.status import sparkline_svg
|
||||
since, range_key = parse_range(request.args.get("range") or "7d")
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
rows = await docker_svc.disk_hosts(db, since)
|
||||
for r in rows:
|
||||
r["spark"] = sparkline_svg([v for _, v in r["points"]], width=120, height=24,
|
||||
stroke="currentColor")
|
||||
return await render_template("docker/_disk_hosts.html", rows=rows, range_key=range_key)
|
||||
|
||||
|
||||
@docker_bp.post("/disk/<host_id>/prune")
|
||||
@require_role(UserRole.admin)
|
||||
async def disk_prune(host_id: str):
|
||||
|
||||
@@ -683,3 +683,25 @@ def stale_after_seconds(config) -> int:
|
||||
"""How old a container's last report may be before the page calls it
|
||||
stale: the host agent's own staleness window, since the agent reports it."""
|
||||
return int(((config.get("PLUGINS") or {}).get("host_agent") or {}).get("stale_after_seconds", 180))
|
||||
|
||||
|
||||
async def disk_hosts(db, since: datetime, *, points: int = 60) -> list[dict]:
|
||||
"""Each Docker host's disk as `docker system df` last said, most space
|
||||
first, with its recorded total since `since` (#5304): the points, oldest
|
||||
first, and the growth over them (disk_view.growth; None until there are
|
||||
two). Hosts are named by their Steward host."""
|
||||
from steward.core import metric_series
|
||||
from .disk_view import DISK_PARTS, growth
|
||||
from .models import DockerDiskUsage
|
||||
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
|
||||
hosts = await host_map(db, {s.host_id for s in summaries})
|
||||
out = []
|
||||
for s in summaries:
|
||||
name = hosts[s.host_id].name if s.host_id in hosts else s.host_id
|
||||
parts = {m: int(getattr(s, f) or 0) for m, f in DISK_PARTS.items()}
|
||||
series = await metric_series.points(db, "docker", name, "disk_total_bytes", since, target=points)
|
||||
out.append({"host_id": s.host_id, "host_name": name, "as_of": s.updated_at,
|
||||
"total": sum(parts.values()), "parts": parts,
|
||||
"reclaimable": int(s.images_reclaimable or 0), "points": series,
|
||||
"growth": growth(series)})
|
||||
return sorted(out, key=lambda h: (-h["total"], h["host_name"].lower()))
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{# docker/_disk_hosts.html — every Docker host's disk side by side (#5304):
|
||||
what it uses now, how much that grew over the selected range, and a line
|
||||
of it; the parts after. A host is linked to its own section below. #}
|
||||
{% if rows %}
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
<th>Host</th>
|
||||
<th class="num">Docker uses</th>
|
||||
<th class="num" title="Change in the total over the last {{ range_key }}, from Steward's own record of it">Change ({{ range_key }})</th>
|
||||
<th>Over {{ range_key }}</th>
|
||||
<th class="num">Images</th><th class="num">Volumes</th><th class="num" title="Written by containers on top of their images">Container files</th>
|
||||
<th class="num">Build cache</th><th class="num" title="Held by images no container uses">Reclaimable</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
{% set g = r.growth %}
|
||||
<tr>
|
||||
<td><a href="#host-{{ r.host_id }}">{{ r.host_name }}</a></td>
|
||||
<td class="num"><strong>{{ r.total|bytes }}</strong></td>
|
||||
<td class="num">
|
||||
{%- if g is none %}<span class="tone-dim" title="Steward records this from now on; growth shows once there are two readings">—</span>
|
||||
{%- else %}<span class="tone-{{ 'warn' if g.pct is not none and g.pct >= 10 else ('ok' if g.change < 0 else 'muted') }}"
|
||||
title="{{ g.from|bytes }} → {{ g.to|bytes }} since {{ g.since.strftime('%Y-%m-%d %H:%M UTC') }}">{{ "+" if g.change > 0 else ("−" if g.change < 0 else "±") }}{{ (g.change|abs)|bytes }}</span>{% endif -%}
|
||||
</td>
|
||||
<td class="tone-info">{{ r.spark|safe }}</td>
|
||||
<td class="num">{{ r.parts.disk_images_bytes|bytes }}</td>
|
||||
<td class="num">{{ r.parts.disk_volumes_bytes|bytes }}</td>
|
||||
<td class="num">{{ r.parts.disk_containers_bytes|bytes }}</td>
|
||||
<td class="num">{{ r.parts.disk_build_cache_bytes|bytes }}</td>
|
||||
<td class="num">{% if r.reclaimable %}<span class="tone-warn">{{ r.reclaimable|bytes }}</span>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="tone-muted" style="margin:0;padding:1rem;">No host has reported its Docker disk yet.</p>
|
||||
{% endif %}
|
||||
@@ -1,127 +1,124 @@
|
||||
{# docker/disk.html — image/disk usage: reclaimable space, per-image sizes #}
|
||||
{# docker/disk.html — what Docker keeps on each host's disk (#5304, #5305):
|
||||
the hosts side by side with how much each grew, then each host's parts,
|
||||
its cleanup actions, and its images folded under their repository. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% from "_macros.html" import crumbs, page_header, stat_tile, status_badge %}
|
||||
{% block title %}Disk — Docker — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Image & disk usage", "")]) }}{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), ("Disk", "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h1 class="page-title" style="margin-bottom:0.4rem;">Image & disk usage</h1>
|
||||
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
|
||||
What Docker is keeping on each host's disk, from <code>docker system df</code>.
|
||||
Admins can clean up per host below; each action runs an audited Ansible playbook on that host.
|
||||
</p>
|
||||
{% call page_header("Docker disk", "What Docker keeps on each host's disk, from docker system df, and how it has grown.") %}
|
||||
{% include "_time_range.html" %}
|
||||
{% endcall %}
|
||||
|
||||
{% if host_groups %}
|
||||
{% for g in host_groups %}
|
||||
<div style="margin-bottom:2rem;">
|
||||
<div style="font-weight:500;font-size:0.95rem;margin-bottom:0.75rem;">{{ g.host_name }}
|
||||
{% if g.as_of %}<span class="field-hint" style="font-weight:normal;">· as of {{ g.as_of|ago }}</span>{% endif %}</div>
|
||||
<section class="card-flush" aria-labelledby="disk-hosts">
|
||||
<h2 id="disk-hosts" class="table-caption">By host <span class="tone-muted">most space first</span></h2>
|
||||
<div hx-get="/plugins/docker/disk/hosts" hx-trigger="load, rangeChange from:body"
|
||||
hx-include="#time-range" hx-swap="innerHTML">
|
||||
<p class="field-hint" style="padding:1rem;margin:0;">Loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ── Summary stats ────────────────────────────────────────────────────── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1rem;">
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Reclaimable</div>
|
||||
<span class="stat-val" style="color:{% if g.summary.images_reclaimable != '0 B' %}var(--orange){% else %}var(--green){% endif %};">{{ g.summary.images_reclaimable }}</span>
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);">held by images no container uses</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Images</div>
|
||||
<span class="stat-val">{{ g.summary.images_size }}</span>
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);">{{ g.summary.images_active }}/{{ g.summary.images_total }} in use</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Stopped</div>
|
||||
<span class="stat-val" style="{% if g.stopped %}color:var(--text-muted){% endif %};">{{ g.stopped }}</span>
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);">containers not running</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Container files</div>
|
||||
<span class="stat-val">{{ g.summary.containers_size }}</span>
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);">written by {{ g.summary.containers_count }} containers on top of their images</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Volumes</div>
|
||||
<span class="stat-val">{{ g.summary.volumes_size }}</span>
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);">{{ g.summary.volumes_count }} volumes; never pruned here</div>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Build cache</div>
|
||||
<span class="stat-val">{{ g.summary.build_cache_size }}</span>
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);">left by <code>docker build</code>; safe to clear</div>
|
||||
</div>
|
||||
{% for g in host_groups %}
|
||||
{% set s = g.summary %}
|
||||
<section id="host-{{ g.host_id }}" class="disk-host" aria-labelledby="disk-host-{{ g.host_id }}">
|
||||
<h2 id="disk-host-{{ g.host_id }}" class="disk-host-head">
|
||||
<a href="/hosts/{{ g.host_id }}">{{ g.host_name }}</a>
|
||||
<span class="tone-muted">{{ g.total|bytes }}</span>
|
||||
{% if g.as_of %}<span class="field-hint">as of {{ g.as_of|ago }}</span>{% endif %}
|
||||
</h2>
|
||||
|
||||
<div class="stat-strip">
|
||||
{{ stat_tile("Reclaimable", s.images_reclaimable|bytes, state="warn" if s.images_reclaimable else "ok", hint="held by images no container uses") }}
|
||||
{{ stat_tile("Images", s.images_size|bytes, hint=s.images_active ~ " of " ~ s.images_total ~ " in use") }}
|
||||
{{ stat_tile("Container files", s.containers_size|bytes, hint="written by " ~ s.containers_count ~ " containers on top of their images") }}
|
||||
{{ stat_tile("Volumes", s.volumes_size|bytes, hint=s.volumes_count ~ " volumes; never pruned here") }}
|
||||
{{ stat_tile("Build cache", s.build_cache_size|bytes, hint="left by docker build; safe to clear") }}
|
||||
{{ stat_tile("Stopped", g.stopped, state="dim" if not g.stopped else none, hint="containers not running") }}
|
||||
</div>
|
||||
|
||||
{# ── Cleanup actions (admin only; audited Ansible prune run) ───────────── #}
|
||||
{% if session.user_role == 'admin' %}
|
||||
<div style="margin-bottom:1rem;">
|
||||
<div class="disk-actions">
|
||||
{% if ansible_available and g.has_target %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;">
|
||||
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||
data-confirm="Remove all STOPPED containers on {{ g.host_name }}? This cannot be undone.">{{ csrf_field() }}
|
||||
<input type="hidden" name="target" value="containers">
|
||||
<button type="submit" class="btn btn-sm">Prune stopped containers</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||
data-confirm="Remove ALL unused images on {{ g.host_name }} (docker image prune -a)? Any image not used by a container is deleted.">{{ csrf_field() }}
|
||||
<input type="hidden" name="target" value="images">
|
||||
<button type="submit" class="btn btn-sm">Prune unused images</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune" style="margin:0;"
|
||||
data-confirm="Run a full system prune on {{ g.host_name }}? Removes stopped containers, unused networks, untagged images nothing uses, and the build cache.">{{ csrf_field() }}
|
||||
<input type="hidden" name="target" value="system">
|
||||
<button type="submit" class="btn btn-sm btn-danger">System prune…</button>
|
||||
</form>
|
||||
</div>
|
||||
<div style="font-size:0.72rem;color:var(--text-muted);margin-top:0.4rem;">
|
||||
Reclaimed space appears on the next agent sample.
|
||||
</div>
|
||||
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune"
|
||||
data-confirm="Remove all STOPPED containers on {{ g.host_name }}? This cannot be undone.">{{ csrf_field() }}
|
||||
<input type="hidden" name="target" value="containers">
|
||||
<button type="submit" class="btn btn-sm">Prune stopped containers</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune"
|
||||
data-confirm="Remove ALL unused images on {{ g.host_name }} (docker image prune -a)? Any image not used by a container is deleted.">{{ csrf_field() }}
|
||||
<input type="hidden" name="target" value="images">
|
||||
<button type="submit" class="btn btn-sm">Prune unused images</button>
|
||||
</form>
|
||||
<form method="post" action="/plugins/docker/disk/{{ g.host_id }}/prune"
|
||||
data-confirm="Run a full system prune on {{ g.host_name }}? Removes stopped containers, unused networks, untagged images nothing uses, and the build cache.">{{ csrf_field() }}
|
||||
<input type="hidden" name="target" value="system">
|
||||
<button type="submit" class="btn btn-sm btn-danger">System prune…</button>
|
||||
</form>
|
||||
<span class="field-hint">Each runs an audited Ansible playbook on {{ g.host_name }}; reclaimed space shows on the next agent sample.</span>
|
||||
{% elif not ansible_available %}
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);">Cleanup needs the Ansible runner (currently unavailable).</div>
|
||||
<span class="field-hint">Cleanup needs the Ansible runner (currently unavailable).</span>
|
||||
{% else %}
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);">
|
||||
Link an <a href="/hosts/{{ g.host_id }}">Ansible target</a> to this host to enable prune actions.
|
||||
</div>
|
||||
<span class="field-hint">Link an <a href="/hosts/{{ g.host_id }}">Ansible target</a> to this host to enable prune actions.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Per-image table ──────────────────────────────────────────────────── #}
|
||||
<div class="field-hint" style="margin-bottom:0.4rem;">
|
||||
{% if g.images_capped %}The {{ g.images|length }} largest of {{ g.summary.images_total }} images, largest first.
|
||||
{% else %}Every image, largest first.{% endif %}
|
||||
</div>
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Image</th><th>Size</th>
|
||||
<th title="Layers this image has in common with other images: stored once, so removing this image frees its size less this">Shared with other images</th>
|
||||
<th>Used by</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for im in g.images %}
|
||||
{# ── Images by repository, versions folded under it (#5305) ─────────── #}
|
||||
<section class="card-flush" aria-label="Images on {{ g.host_name }}">
|
||||
<h3 class="table-caption">Images by repository
|
||||
<span class="tone-muted">{% if g.images_capped %}the {{ g.images_listed }} largest of {{ s.images_total }} images{% else %}{{ g.images_listed }} images{% endif %}</span></h3>
|
||||
{% if g.repos %}
|
||||
<table class="table image-repos">
|
||||
<thead><tr>
|
||||
<th>Repository</th>
|
||||
<th class="num">Versions</th>
|
||||
<th class="num" title="Each version's size less the layers it shares with other images, added up: what removing them all would free, roughly">Own size</th>
|
||||
<th class="num">Used by</th>
|
||||
<th class="num" title="Own size of the versions no container uses">Reclaimable</th>
|
||||
</tr></thead>
|
||||
{% for r in g.repos %}
|
||||
<tbody class="image-repo">
|
||||
<tr>
|
||||
<td style="font-size:0.84rem;font-family:ui-monospace,monospace;max-width:340px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{% if im.repo_tag %}{{ im.repo_tag }}{% else %}<span style="color:var(--text-muted);">untagged</span> {{ im.image_id }}{% endif %}
|
||||
{% if im.reclaimable %}<span title="No container uses this image" style="font-size:0.68rem;color:var(--orange);border:1px solid var(--orange);border-radius:3px;padding:0 0.3rem;margin-left:0.4rem;">reclaimable</span>{% endif %}
|
||||
<td class="cell-name">
|
||||
<details>
|
||||
<summary title="{{ r.repo or 'untagged images' }}">{% if r.repo %}<span class="mono">{{ r.repo }}</span>{% else %}<span class="tone-muted">untagged</span>{% endif %}</summary>
|
||||
<table class="table image-versions">
|
||||
<thead><tr><th>Tag</th><th>ID</th><th class="num">Size</th><th class="num" title="Layers in common with other images: stored once">Shared</th><th class="num">Used by</th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in r.versions %}
|
||||
<tr>
|
||||
<td class="mono">{{ v.tag or "—" }}</td>
|
||||
<td class="mono tone-muted">{{ (v.image_id or "")|replace("sha256:", "")|truncate(12, true, "") }}</td>
|
||||
<td class="num">{{ v.size|bytes }}</td>
|
||||
<td class="num tone-muted">{{ v.shared|bytes }}</td>
|
||||
<td class="num">{% if v.containers %}{{ v.containers }} container{{ "s" if v.containers != 1 }}{% else %}<span class="tone-warn">nothing</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</td>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.86rem;">{{ im.size }}</td>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.86rem;color:var(--text-muted);">{{ im.shared }}</td>
|
||||
<td style="font-size:0.86rem;color:{% if im.containers %}var(--text){% else %}var(--text-muted){% endif %};">{% if im.containers %}{{ im.containers }} container{{ "s" if im.containers != 1 }}{% else %}nothing{% endif %}</td>
|
||||
<td class="num">{{ r.versions|length }}{% if r.unused %} <span class="tone-dim">({{ r.unused }} unused)</span>{% endif %}</td>
|
||||
<td class="num">{{ r.own|bytes }}</td>
|
||||
<td class="num">{% if r.containers %}{{ r.containers }} container{{ "s" if r.containers != 1 }}{% else %}<span class="tone-warn">nothing</span>{% endif %}</td>
|
||||
<td class="num">{% if r.reclaimable %}<span class="tone-warn">{{ r.reclaimable|bytes }}</span>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No images reported.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="tone-muted" style="margin:0;padding:1rem;">No images reported.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||
No disk usage reported yet. The host agent reports image/disk usage from
|
||||
<code>docker system df</code> on hosts running Docker.
|
||||
</div>
|
||||
<div class="card empty-state">
|
||||
No disk usage reported yet. The host agent reports what Docker keeps on disk,
|
||||
from <code>docker system df</code>, on hosts running Docker.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -41,6 +41,19 @@
|
||||
hx-swap="innerHTML">
|
||||
<div class="card"><p class="field-hint">Loading…</p></div>
|
||||
</div>
|
||||
{% if disk %}
|
||||
{# What Docker keeps on each host's disk, and its growth over a week (#5306). #}
|
||||
<p class="docker-disk-line">
|
||||
<a href="/plugins/docker/disk" class="section-title">Disk</a>
|
||||
{% for d in disk %}
|
||||
<span class="docker-disk-host"><a href="/plugins/docker/disk#host-{{ d.host_id }}">{{ d.host_name }}</a>
|
||||
{{ d.total|bytes }}
|
||||
{%- if d.growth and d.growth.change %} <span class="tone-{{ 'warn' if d.growth.pct is not none and d.growth.pct >= 10 else 'muted' }}"
|
||||
title="Since {{ d.growth.since.strftime('%Y-%m-%d %H:%M UTC') }}">{{ "+" if d.growth.change > 0 else "−" }}{{ (d.growth.change|abs)|bytes }} in 7d</span>{% endif -%}
|
||||
{%- if d.reclaimable %} <span class="tone-dim">· {{ d.reclaimable|bytes }} reclaimable</span>{% endif %}</span>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<script>
|
||||
(function () {
|
||||
var rows = document.getElementById('docker-rows');
|
||||
|
||||
@@ -208,6 +208,12 @@ METRICS: dict[str, tuple[str, str]] = {
|
||||
"cpu_pct": ("CPU", "CPU in use; for a container, 100% is one core"),
|
||||
"mem_pct": ("Memory (% of limit)", "memory in use, against the container's limit or the host's RAM"),
|
||||
"restart_count": ("Restarts", "times the container has restarted, in total"),
|
||||
"disk_total_bytes": ("Docker disk use", "everything Docker keeps on the host (resource: the host)"),
|
||||
"disk_images_bytes": ("Docker images", "space Docker's images take on the host"),
|
||||
"disk_containers_bytes": ("Container files", "what containers wrote on top of their images"),
|
||||
"disk_volumes_bytes": ("Docker volumes", "space Docker's volumes take on the host"),
|
||||
"disk_build_cache_bytes": ("Docker build cache", "left by docker build; safe to clear"),
|
||||
"disk_reclaimable_bytes": ("Reclaimable by Docker", "held by images no container uses"),
|
||||
"mem_used_pct": ("Memory used", "memory in use"),
|
||||
"mem_available_bytes": ("Memory available", "memory free for new work"),
|
||||
"swap_used_bytes": ("Swap used", "memory paged out to disk"),
|
||||
|
||||
@@ -224,6 +224,19 @@ strong, b, th { font-weight: 500; }
|
||||
.node-tasks th, .node-tasks td { padding: 0.35rem 0.5rem; font-size: 0.82rem; }
|
||||
.node-tasks .cell-name { max-width: 12rem; }
|
||||
.node-tasks .cell-name > a { display: inline-block; max-width: 100%; vertical-align: bottom; }
|
||||
/* The Docker overview's one line per host of disk (#5306). */
|
||||
.docker-disk-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem 1.25rem; margin: 0 0 1rem; font-size: 0.85rem; }
|
||||
.docker-disk-host { white-space: nowrap; }
|
||||
/* The Disk page (#5304, #5305): a section per host; images by repository,
|
||||
each repository's versions folded under it. */
|
||||
.disk-host { margin: 2rem 0; }
|
||||
.disk-host-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.6rem; margin: 0 0 0.75rem; font-family: var(--fs-font-body); font-size: 1.05rem; font-weight: 500; }
|
||||
.disk-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.disk-actions form { margin: 0; }
|
||||
.image-repos .cell-name { max-width: 28rem; }
|
||||
.image-repos summary { cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-repos details[open] summary { margin-bottom: 0.5rem; }
|
||||
.image-versions th, .image-versions td { padding: 0.3rem 0.5rem; font-size: 0.8rem; }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
|
||||
@@ -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