diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py index 7fe65c7..98d10a8 100644 --- a/plugins/docker/routes.py +++ b/plugins/docker/routes.py @@ -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///history") @require_role(UserRole.viewer) async def container_history(host_id: str, name: str): diff --git a/plugins/docker/templates/docker/disk.html b/plugins/docker/templates/docker/disk.html new file mode 100644 index 0000000..8d50149 --- /dev/null +++ b/plugins/docker/templates/docker/disk.html @@ -0,0 +1,84 @@ +{# docker/disk.html — image/disk usage: reclaimable space, per-image sizes #} +{% extends "base.html" %} +{% block title %}Disk — Docker — Steward{% endblock %} +{% block content %} + +
+ ← All containers +
+

Image & disk usage

+

+ Reclaimable = space held by images no container references. Cleanup actions + (prune) arrive in a later release — these are read-only figures for now. +

+ +{% if host_groups %} +{% for g in host_groups %} +
+
{{ g.host_name }}
+ + {# ── Summary stats ────────────────────────────────────────────────────── #} +
+
+
Reclaimable
+ {{ g.summary.images_reclaimable }} +
+
+
Images
+ {{ g.summary.images_size }} +
{{ g.summary.images_active }}/{{ g.summary.images_total }} in use
+
+
+
Stopped
+ {{ g.stopped }} +
+
+
Writable layers
+ {{ g.summary.containers_size }} +
{{ g.summary.containers_count }} containers
+
+
+
Volumes
+ {{ g.summary.volumes_size }} +
{{ g.summary.volumes_count }} volumes
+
+
+
Build cache
+ {{ g.summary.build_cache_size }} +
+
+ + {# ── Per-image table ──────────────────────────────────────────────────── #} +
+ + + + + + {% for im in g.images %} + + + + + + + {% else %} + + {% endfor %} + +
ImageSizeSharedContainers
+ {{ im.repo_tag }} + {% if im.reclaimable %}reclaimable{% endif %} + {{ im.size }}{{ im.shared }}{{ im.containers }}
No images reported.
+
+
+{% endfor %} +{% else %} +
+
+ No disk usage reported yet. The host agent reports image/disk usage from + docker system df on hosts running Docker. +
+
+{% endif %} +{% endblock %} diff --git a/plugins/docker/templates/docker/index.html b/plugins/docker/templates/docker/index.html index 9b33918..950dd06 100644 --- a/plugins/docker/templates/docker/index.html +++ b/plugins/docker/templates/docker/index.html @@ -2,7 +2,15 @@ {% block title %}Docker — Steward{% endblock %} {% block content %}
-

Docker

+
+

Docker

+ {% if has_swarm %} + Swarm → + {% endif %} + {% if has_disk %} + Disk → + {% endif %} +
{% include "_time_range.html" %}
diff --git a/plugins/docker/templates/docker/swarm.html b/plugins/docker/templates/docker/swarm.html new file mode 100644 index 0000000..b8314d1 --- /dev/null +++ b/plugins/docker/templates/docker/swarm.html @@ -0,0 +1,86 @@ +{# docker/swarm.html — Swarm topology: services, replica health, nodes, placement #} +{% extends "base.html" %} +{% block title %}Swarm — Docker — Steward{% endblock %} +{% block content %} + +
+ ← All containers +
+

Swarm

+ +{% if host_groups %} +{% for g in host_groups %} +
+
+ {{ g.host_name }} + manager view +
+ + {# ── Services ─────────────────────────────────────────────────────────── #} +
+ + + + + + + + {% for s in g.services %} + + + + + + + + {% else %} + + {% endfor %} + +
ServiceModeReplicasImagePlacement
{{ s.name }}{{ s.mode }} + + {{ s.running }}/{{ s.desired }} + + {{ s.image or "—" }} + {% for p in s.placement %}{{ p.node }} ({{ p.running }}){% if not loop.last %}, {% endif %}{% else %}—{% endfor %} +
No services on this manager.
+
+ + {# ── Nodes ────────────────────────────────────────────────────────────── #} +
+ + + + + + {% for n in g.nodes %} + + + + + + + {% else %} + + {% endfor %} + +
NodeRoleAvailabilityStatus
+ {{ n.hostname or n.node_id }} + {% if n.leader %}leader{% endif %} + {{ n.role }}{{ n.availability }} + + {{ n.status }} +
No nodes reported.
+
+
+{% endfor %} +{% else %} +
+
+ No Swarm topology reported. A Steward host agent running on a Swarm + manager reports services, nodes, and task placement here — + workers and non-swarm hosts contribute only their local containers. +
+
+{% endif %} +{% endblock %}