From 3e4e35de960c1386a4b1e646287d0a2e2a4a10f6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 18 Jun 2026 21:54:54 -0400 Subject: [PATCH] feat(docker): container detail page + lifecycle timeline (milestone 77 #942) New /plugins/docker/container// detail page (v1 quality): status/health badge, uptime, CPU/mem, restart count, last exit code (+OOM), net + block I/O (humanised), image/compose/swarm-service/node/ports, a range-toggled CPU/mem history graph (HTMX fragment reusing the time-range selector), and a lifecycle timeline rendered from docker_events (glyph+colour per event kind). Not-found and empty-history/empty-timeline states included. Container names in the main list and the per-host hub panel now link to it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC --- plugins/docker/routes.py | 85 ++++++++++++- .../templates/docker/_container_history.html | 17 +++ .../templates/docker/container_detail.html | 112 ++++++++++++++++++ .../docker/templates/docker/host_panel.html | 2 +- plugins/docker/templates/docker/rows.html | 2 +- 5 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 plugins/docker/templates/docker/_container_history.html create mode 100644 plugins/docker/templates/docker/container_detail.html diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py index a6b38f2..7fe65c7 100644 --- a/plugins/docker/routes.py +++ b/plugins/docker/routes.py @@ -10,11 +10,25 @@ 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, DockerMetric +from .models import DockerContainer, DockerEvent, DockerMetric docker_bp = Blueprint("docker", __name__, template_folder="templates") +def _human_bytes(n: int | None) -> str: + """Compact binary size string (e.g. '1.4 GiB', '512 MiB', '0 B').""" + if n is None: + return "—" + size = float(n) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if abs(size) < 1024.0 or unit == "TiB": + if unit == "B": + return f"{int(size)} {unit}" + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} TiB" + + def _human_uptime(started_at: datetime | None) -> str | None: """Compact 'how long running' string (e.g. '3d 4h', '5h 12m', '8m').""" if started_at is None: @@ -258,3 +272,72 @@ async def host_panel(host_id: str): running=running, stopped=len(containers) - running, ) + + +# Glyph + colour per lifecycle event, so the timeline reads at a glance. +_EVENT_STYLE = { + "start": ("▲", "var(--green)"), + "stop": ("■", "var(--text-muted)"), + "die": ("✕", "var(--red)"), + "oom": ("☠", "var(--red)"), + "health_change": ("◐", "var(--orange)"), +} + + +@docker_bp.get("/container//") +@require_role(UserRole.viewer) +async def container_detail(host_id: str, name: str): + """Full detail page for one container: facts, resource history, lifecycle.""" + async with current_app.db_sessionmaker() as db: + c = await db.get(DockerContainer, (host_id, name)) + host = await db.get(Host, host_id) + events = [] + if c is not None: + events = list((await db.execute( + select(DockerEvent) + .where(DockerEvent.host_id == host_id) + .where(DockerEvent.container_name == name) + .order_by(DockerEvent.at.desc()) + .limit(50) + )).scalars()) + + if c is None: + return await render_template( + "docker/container_detail.html", + container=None, host_id=host_id, name=name, + ), 404 + + return await render_template( + "docker/container_detail.html", + container=c, + host=host, + host_id=host_id, + name=name, + ports=json.loads(c.ports_json) if c.ports_json else [], + uptime=_human_uptime(c.started_at) if c.status == "running" else None, + net_rx=_human_bytes(c.net_rx_bytes), net_tx=_human_bytes(c.net_tx_bytes), + blk_read=_human_bytes(c.blk_read_bytes), blk_write=_human_bytes(c.blk_write_bytes), + events=[ + {"event": e.event, "detail": e.detail, "at": e.at, + "glyph": _EVENT_STYLE.get(e.event, ("•", "var(--text-muted)"))[0], + "colour": _EVENT_STYLE.get(e.event, ("•", "var(--text-muted)"))[1]} + for e in events + ], + current_range=request.args.get("range", DEFAULT_RANGE), + ) + + +@docker_bp.get("/container///history") +@require_role(UserRole.viewer) +async def container_history(host_id: str, name: str): + """HTMX fragment: CPU + memory sparklines for the selected time range.""" + since, range_key = parse_range(request.args.get("range")) + async with current_app.db_sessionmaker() as db: + cpu_hist, mem_hist = await _container_history(db, host_id, name, since) + return await render_template( + "docker/_container_history.html", + sparkline_cpu=_sparkline(cpu_hist, width=320, height=48), + sparkline_mem=_sparkline(mem_hist, width=320, height=48), + have_data=len(cpu_hist) >= 2, + range_key=range_key, + ) diff --git a/plugins/docker/templates/docker/_container_history.html b/plugins/docker/templates/docker/_container_history.html new file mode 100644 index 0000000..0a9b780 --- /dev/null +++ b/plugins/docker/templates/docker/_container_history.html @@ -0,0 +1,17 @@ +{# docker/_container_history.html — CPU/mem sparklines for the selected range #} +{% if have_data %} +
+
+
CPU %
+ {{ sparkline_cpu | safe }} +
+
+
Memory %
+ {{ sparkline_mem | safe }} +
+
+{% else %} +
+ Not enough samples in this range yet — history fills in as the agent reports. +
+{% endif %} diff --git a/plugins/docker/templates/docker/container_detail.html b/plugins/docker/templates/docker/container_detail.html new file mode 100644 index 0000000..377c6a2 --- /dev/null +++ b/plugins/docker/templates/docker/container_detail.html @@ -0,0 +1,112 @@ +{# docker/container_detail.html — full detail page for one container #} +{% extends "base.html" %} +{% block title %}{{ name }} — Docker — Steward{% endblock %} +{% block content %} + + + +{% if container is none %} +
+
Container not found
+
+ No container named {{ name }} is currently reported for this host. + It may have been removed, or the host agent hasn't reported recently. +
+
+{% else %} + +{# ── Header ──────────────────────────────────────────────────────────────── #} +
+ +

{{ container.name }}

+ {% if container.health == 'healthy' %}healthy + {% elif container.health == 'unhealthy' %}unhealthy + {% elif container.health == 'starting' %}starting{% endif %} +
+
+ {{ container.status }}{% if uptime %} · up {{ uptime }}{% endif %} + {% if host %} · on {{ host.name }}{% endif %} +
+ +{# ── Facts grid ──────────────────────────────────────────────────────────── #} +{% macro fact(label, value, colour="") %} +
+
{{ label }}
+
{{ value }}
+
+{% endmacro %} + +
+ {{ fact("CPU", "%.1f%%" | format(container.cpu_pct) if container.cpu_pct is not none else "—") }} + {{ fact("Memory", "%.1f%%" | format(container.mem_pct) if container.mem_pct is not none else "—") }} + {{ fact("Restarts", container.restart_count, "var(--orange)" if container.restart_count else "") }} + {{ fact("Last exit", (container.exit_code ~ (" (OOM)" if container.oom_killed else "")) if container.exit_code is not none else "—", + "var(--red)" if (container.exit_code is not none and container.exit_code != 0) else "") }} + {{ fact("Net in", net_rx) }} + {{ fact("Net out", net_tx) }} + {{ fact("Block read", blk_read) }} + {{ fact("Block write", blk_write) }} +
+ +{# ── Image / placement ───────────────────────────────────────────────────── #} +
+
+ Image + {{ container.image or "—" }} + {% if container.compose_project %} + Compose project{{ container.compose_project }} + {% endif %} + {% if container.service_name %} + Swarm service{{ container.service_name }} + {% endif %} + {% if container.node_id %} + Node{{ container.node_id }} + {% endif %} + {% if ports %} + Ports + {% for p in ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{% if not loop.last %}, {% endif %}{% endfor %} + {% endif %} +
+
+ +{# ── Resource history (range-toggled) ────────────────────────────────────── #} +
+
+

Resource history

+ {% include "_time_range.html" %} +
+
+
Loading…
+
+
+ +{# ── Lifecycle timeline ──────────────────────────────────────────────────── #} +
+

Lifecycle

+ {% if events %} +
+ {% for e in events %} +
+ {{ e.glyph }} + {{ e.event }} + {{ e.detail or "" }} + {{ e.at.strftime("%Y-%m-%d %H:%M") }} +
+ {% endfor %} +
+ {% else %} +
+ No lifecycle events recorded yet. Start/stop/health changes appear here as the + agent reports them over time. +
+ {% endif %} +
+ +{% endif %} +{% endblock %} diff --git a/plugins/docker/templates/docker/host_panel.html b/plugins/docker/templates/docker/host_panel.html index 1666174..942aef4 100644 --- a/plugins/docker/templates/docker/host_panel.html +++ b/plugins/docker/templates/docker/host_panel.html @@ -11,7 +11,7 @@ {% for c in containers %}
- {{ c.name }} + {{ c.name }} {% if c.cpu_pct is not none %}{{ "%.1f" | format(c.cpu_pct) }}%{% endif %} {% if c.mem_pct is not none %} · {{ "%.1f" | format(c.mem_pct) }}%{% endif %} diff --git a/plugins/docker/templates/docker/rows.html b/plugins/docker/templates/docker/rows.html index 6dbfca8..66088c0 100644 --- a/plugins/docker/templates/docker/rows.html +++ b/plugins/docker/templates/docker/rows.html @@ -55,7 +55,7 @@
- {{ c.name }} + {{ c.name }} {% if c.health == 'healthy' %} {% elif c.health == 'unhealthy' %} {% elif c.health == 'starting' %}{% endif %}