feat(docker): swarm topology view + image/disk usage page (milestone 77 #942)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 48s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 8s

Two new read-only sub-pages, linked from the Docker index header only when the
data exists (non-swarm / Docker-less installs aren't offered empty pages):

- /plugins/docker/swarm — services with replica health (running/desired,
  colour-coded green/amber/red), Swarm nodes (role/availability/status, leader
  badge), and task→node placement with node ids resolved to hostnames. Grouped
  by reporting manager host. Empty state explains manager-only collection.

- /plugins/docker/disk — per-host reclaimable space, image/layer/container/
  volume/build-cache sizes, stopped-container count, and a per-image table
  (size, shared, ref count, reclaimable badge). Notes prune actions are deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-18 21:58:19 -04:00
parent 3e4e35de96
commit 114262dbf9
4 changed files with 303 additions and 2 deletions
+124 -1
View File
@@ -10,7 +10,10 @@ from steward.auth.middleware import require_role
from steward.models.users import UserRole
from steward.models.hosts import Host
from steward.core.time_range import parse_range, DEFAULT_RANGE
from .models import DockerContainer, DockerEvent, DockerMetric
from .models import (
DockerContainer, DockerDiskUsage, DockerEvent, DockerImage, DockerMetric,
DockerSwarmNode, DockerSwarmService,
)
docker_bp = Blueprint("docker", __name__, template_folder="templates")
@@ -106,10 +109,19 @@ async def _container_history(db, host_id: str, name: str, since) -> tuple[list,
async def index():
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
current_range = request.args.get("range", DEFAULT_RANGE)
# Show the Swarm link only when a manager has actually reported topology, so
# non-swarm installs aren't offered an always-empty page.
async with current_app.db_sessionmaker() as db:
has_swarm = (await db.execute(
select(DockerSwarmService.host_id).limit(1))).first() is not None
has_disk = (await db.execute(
select(DockerDiskUsage.host_id).limit(1))).first() is not None
return await render_template(
"docker/index.html",
poll_interval=poll_interval,
current_range=current_range,
has_swarm=has_swarm,
has_disk=has_disk,
)
@@ -327,6 +339,117 @@ async def container_detail(host_id: str, name: str):
)
@docker_bp.get("/swarm")
@require_role(UserRole.viewer)
async def swarm():
"""Swarm topology page: services with replica health, nodes, placement.
Swarm tables are manager-scoped (each manager reports its own view), so we
group by reporting host — usually one group for a single-manager cluster.
"""
async with current_app.db_sessionmaker() as db:
services = list((await db.execute(
select(DockerSwarmService).order_by(DockerSwarmService.service_name)
)).scalars())
nodes = list((await db.execute(
select(DockerSwarmNode).order_by(
DockerSwarmNode.role, DockerSwarmNode.hostname)
)).scalars())
hosts = await _host_map(db, {s.host_id for s in services} | {n.host_id for n in nodes})
# node_id → hostname per reporting host, so placement reads as names not ids.
node_names: dict[tuple[str, str], str] = {
(n.host_id, n.node_id): (n.hostname or n.node_id) for n in nodes
}
groups: dict[str, dict] = {}
for s in services:
g = groups.setdefault(s.host_id, {"services": [], "nodes": []})
placement = []
for p in (json.loads(s.placement_json) if s.placement_json else []):
nid = p.get("node_id", "")
placement.append({
"node": node_names.get((s.host_id, nid), nid[:12] or "?"),
"running": p.get("running", 0),
})
g["services"].append({
"name": s.service_name, "mode": s.mode,
"running": s.running, "desired": s.desired, "image": s.image,
"healthy": s.running >= s.desired and s.desired > 0,
"degraded": 0 < s.running < s.desired,
"down": s.running == 0 and s.desired > 0,
"placement": placement,
})
for n in nodes:
g = groups.setdefault(n.host_id, {"services": [], "nodes": []})
g["nodes"].append(n)
host_groups = [
{"host_id": hid,
"host_name": hosts[hid].name if hid in hosts else hid,
"host": hosts.get(hid),
**g}
for hid, g in groups.items()
]
host_groups.sort(key=lambda g: g["host_name"].lower())
return await render_template("docker/swarm.html", host_groups=host_groups)
@docker_bp.get("/disk")
@require_role(UserRole.viewer)
async def disk():
"""Image/disk usage page: reclaimable space, per-image sizes, stopped count.
Read-only — prune actions are deferred to the cleanup-actions milestone, so
this surfaces the numbers and notes where reclaim lives.
"""
async with current_app.db_sessionmaker() as db:
summaries = list((await db.execute(select(DockerDiskUsage))).scalars())
images = list((await db.execute(
select(DockerImage).order_by(DockerImage.size.desc())
)).scalars())
# Stopped-container count per host, surfaced alongside reclaimable space.
stopped_rows = (await db.execute(
select(DockerContainer.host_id)
.where(DockerContainer.status != "running")
)).scalars()
stopped_by_host: dict[str, int] = {}
for hid in stopped_rows:
stopped_by_host[hid] = stopped_by_host.get(hid, 0) + 1
hosts = await _host_map(db, {s.host_id for s in summaries})
images_by_host: dict[str, list] = {}
for im in images:
images_by_host.setdefault(im.host_id, []).append({
"repo_tag": im.repo_tag, "size": _human_bytes(im.size),
"shared": _human_bytes(im.shared_size), "containers": im.containers,
"reclaimable": im.containers == 0,
})
host_groups = [
{
"host_id": s.host_id,
"host_name": hosts[s.host_id].name if s.host_id in hosts else s.host_id,
"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),
},
"stopped": stopped_by_host.get(s.host_id, 0),
"images": images_by_host.get(s.host_id, []),
}
for s in summaries
]
host_groups.sort(key=lambda g: g["host_name"].lower())
return await render_template("docker/disk.html", host_groups=host_groups)
@docker_bp.get("/container/<host_id>/<name>/history")
@require_role(UserRole.viewer)
async def container_history(host_id: str, name: str):