feat(docker): swarm topology view + image/disk usage page (milestone 77 #942)
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:
+124
-1
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user