feat(docker): needs attention first, stale rows say so, no sparklines on the overview (#5123)

The Docker page opens with a "Needs attention" card: every container
with something wrong, worst problem first — restarting, crash-looping,
unhealthy, OOM-killed, exited non-zero — each with what it does ("is
restarting and keeps dying · exit 1 · since 8m ago"), linked to its page.
It is ranked by the same code as the MCP tools: container_problems,
problem_since and the per-container ranking move from mcp_tools into
service.py (ranked_containers, needs_attention), and mcp_tools' _rows and
findings now read them from there.

Counts follow as stat tiles: needs attention, unhealthy, restarting (or
crash-looping), running, stopped, stale, services, hosts.

A container whose last report is older than the host agent's staleness
window (stale_after_seconds, 180 s by default) carries a Stale badge with
when it last reported, instead of a live-looking green dot.

The per-container CPU and memory history columns are gone from the
overview, and with them one history query per container per refresh; the
container's own page keeps its charts. With nothing left to range over,
the time-range control goes too, and the page now refreshes on the poll
interval. CPU and memory are graded by Settings → Thresholds rather than
50/80 and 70/90 written into the template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:03:37 -04:00
co-authored by Claude Opus 5.5
parent 2a3af60e99
commit 6cf04f57e1
6 changed files with 253 additions and 136 deletions
+19 -9
View File
@@ -63,7 +63,6 @@ def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
@require_role(UserRole.viewer)
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:
@@ -74,7 +73,6 @@ async def index():
return await render_template(
"docker/index.html",
poll_interval=poll_interval,
current_range=current_range,
has_swarm=has_swarm,
has_disk=has_disk,
)
@@ -83,11 +81,14 @@ async def index():
@docker_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
"""HTMX fragment: swarm services (collapsed, cluster-complete) on top, then
non-swarm containers grouped by host with resource sparklines."""
since, range_key = parse_range(request.args.get("range"))
"""HTMX fragment: what needs attention first, then swarm services
(collapsed, cluster-complete), then non-swarm containers grouped by host."""
now = datetime.now(timezone.utc)
stale_after = docker_svc.stale_after_seconds(current_app.config)
async with current_app.db_sessionmaker() as db:
ranked = await docker_svc.ranked_containers(
db, now, docker_svc.crash_loop_deaths(current_app.config))
containers = await docker_svc.list_containers(db)
services = list((await db.execute(select(DockerSwarmService))).scalars())
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
@@ -118,13 +119,11 @@ async def rows():
"host_name": host.name if host else c.host_id,
"containers": [], "running": 0, "stopped": 0,
}
cpu_hist, mem_hist = await docker_svc.container_history(db, c.host_id, c.name, since)
g["containers"].append({
"container": c,
"ports": json.loads(c.ports_json) if c.ports_json else [],
"uptime": _uptime(c.started_at) if c.status == "running" else None,
"sparkline_cpu": _sparkline(cpu_hist),
"sparkline_mem": _sparkline(mem_hist),
"stale": _is_stale(c, now, stale_after),
})
if c.status == "running":
g["running"] += 1
@@ -152,17 +151,28 @@ async def rows():
swarm_services = swarm_view["services"]
standalone_running = sum(g["running"] for g in host_groups)
ghost_total = sum(r["count"] for s in swarm_services for r in s["replicas"] if r["ghost"])
attention = [dict(r, stale=_is_stale(r["container"], now, stale_after))
for r in docker_svc.needs_attention(ranked)]
return await render_template(
"docker/rows.html",
host_groups=host_groups,
swarm_services=swarm_services,
attention=attention, says=docker_svc.PROBLEM_SAYS, severity=docker_svc.PROBLEM_SEVERITY,
unhealthy=sum("unhealthy" in r["problems"] for r in attention),
restarting=sum(bool({"restarting", "crash_looping"} & set(r["problems"])) for r in attention),
stale=sum(1 for g in host_groups for i in g["containers"] if i["stale"]),
running=standalone_running + sum(s["running"] for s in swarm_services),
stopped=len(standalone) - standalone_running,
total=len(containers) + ghost_total,
range_key=range_key,
)
def _is_stale(c, now: datetime, stale_after: int) -> bool:
"""A container whose last report is older than the agent's staleness
window: what the page shows may no longer be true."""
return c.scraped_at is None or (now - c.scraped_at).total_seconds() > stale_after
@docker_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():