feat(docker): container detail page + lifecycle timeline (milestone 77 #942)
New /plugins/docker/container/<host_id>/<name> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
@@ -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/<host_id>/<name>")
|
||||
@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/<host_id>/<name>/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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user