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):
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
<div style="margin-bottom:1rem;">
|
||||
<a href="/plugins/docker/" style="font-size:0.82rem;color:var(--text-muted);text-decoration:none;">← All containers</a>
|
||||
</div>
|
||||
<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;">
|
||||
Reclaimable = space held by images no container references. Cleanup actions
|
||||
(prune) arrive in a later release — these are read-only figures for now.
|
||||
</p>
|
||||
|
||||
{% if host_groups %}
|
||||
{% for g in host_groups %}
|
||||
<div style="margin-bottom:2rem;">
|
||||
<div style="font-weight:600;font-size:0.95rem;margin-bottom:0.75rem;">{{ g.host_name }}</div>
|
||||
|
||||
{# ── 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>
|
||||
<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>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Writable layers</div>
|
||||
<span class="stat-val">{{ g.summary.containers_size }}</span>
|
||||
<div style="font-size:0.74rem;color:var(--text-muted);">{{ g.summary.containers_count }} containers</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</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>
|
||||
</div>
|
||||
|
||||
{# ── Per-image table ──────────────────────────────────────────────────── #}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Image</th><th>Size</th><th>Shared</th><th>Containers</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for im in g.images %}
|
||||
<tr>
|
||||
<td style="font-size:0.84rem;font-family:ui-monospace,monospace;max-width:340px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||
{{ im.repo_tag }}
|
||||
{% if im.reclaimable %}<span title="not referenced by any container" 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>
|
||||
<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 %};">{{ im.containers }}</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>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -2,7 +2,15 @@
|
||||
{% block title %}Docker — Steward{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Docker</h1>
|
||||
<div style="display:flex;align-items:baseline;gap:1rem;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Docker</h1>
|
||||
{% if has_swarm %}
|
||||
<a href="/plugins/docker/swarm" style="font-size:0.85rem;color:var(--accent);text-decoration:none;">Swarm →</a>
|
||||
{% endif %}
|
||||
{% if has_disk %}
|
||||
<a href="/plugins/docker/disk" style="font-size:0.85rem;color:var(--accent);text-decoration:none;">Disk →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
<div style="margin-bottom:1rem;">
|
||||
<a href="/plugins/docker/" style="font-size:0.82rem;color:var(--text-muted);text-decoration:none;">← All containers</a>
|
||||
</div>
|
||||
<h1 class="page-title" style="margin-bottom:1.5rem;">Swarm</h1>
|
||||
|
||||
{% if host_groups %}
|
||||
{% for g in host_groups %}
|
||||
<div style="margin-bottom:2rem;">
|
||||
<div style="display:flex;align-items:baseline;gap:0.6rem;margin-bottom:0.75rem;">
|
||||
<span style="font-weight:600;font-size:0.95rem;">{{ g.host_name }}</span>
|
||||
<span style="font-size:0.78rem;color:var(--text-muted);">manager view</span>
|
||||
</div>
|
||||
|
||||
{# ── Services ─────────────────────────────────────────────────────────── #}
|
||||
<div class="card-flush" style="margin-bottom:1rem;">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th><th>Mode</th><th>Replicas</th><th>Image</th><th>Placement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in g.services %}
|
||||
<tr>
|
||||
<td style="font-weight:500;font-size:0.9rem;">{{ s.name }}</td>
|
||||
<td style="font-size:0.82rem;color:var(--text-muted);">{{ s.mode }}</td>
|
||||
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
|
||||
<span style="color:{% if s.healthy %}var(--green){% elif s.down %}var(--red){% elif s.degraded %}var(--orange){% else %}var(--text-muted){% endif %};">
|
||||
{{ s.running }}/{{ s.desired }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="font-size:0.8rem;color:var(--text-muted);max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ s.image or "—" }}</td>
|
||||
<td style="font-size:0.78rem;color:var(--text-muted);">
|
||||
{% for p in s.placement %}{{ p.node }} ({{ p.running }}){% if not loop.last %}, {% endif %}{% else %}—{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No services on this manager.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# ── Nodes ────────────────────────────────────────────────────────────── #}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Node</th><th>Role</th><th>Availability</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for n in g.nodes %}
|
||||
<tr>
|
||||
<td style="font-weight:500;font-size:0.9rem;">
|
||||
{{ n.hostname or n.node_id }}
|
||||
{% if n.leader %}<span title="cluster leader" style="font-size:0.68rem;color:var(--accent);border:1px solid var(--accent);border-radius:3px;padding:0 0.3rem;margin-left:0.3rem;">leader</span>{% endif %}
|
||||
</td>
|
||||
<td style="font-size:0.82rem;color:var(--text-muted);">{{ n.role }}</td>
|
||||
<td style="font-size:0.82rem;color:{% if n.availability == 'active' %}var(--text){% else %}var(--orange){% endif %};">{{ n.availability }}</td>
|
||||
<td style="font-size:0.82rem;">
|
||||
<span class="dot {% if n.status == 'ready' %}dot-up{% else %}dot-down{% endif %}"></span>
|
||||
<span style="color:{% if n.status == 'ready' %}var(--green){% else %}var(--red){% endif %};">{{ n.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" style="color:var(--text-muted);font-size:0.85rem;text-align:center;padding:1.5rem;">No nodes reported.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||
No Swarm topology reported. A Steward host agent running on a Swarm
|
||||
<strong>manager</strong> reports services, nodes, and task placement here —
|
||||
workers and non-swarm hosts contribute only their local containers.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user