feat(docker): readable names, CPU in cores and memory in bytes, failures listed with since-times (#5209)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m3s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped

#5209 "Docker views: readable names, CPU/memory in cores and bytes, failures
listed with since-time" (milestone 477 "UI problems first").

- A swarm task reads as its service and slot ("codex_db · 1", or the node
  for a global service), with the raw name in the title: Docker page, host
  panel, busiest widget, container detail.
- CPU reads in cores (Docker's 100% = one core), toned by its share of the
  host's CPUs from the agent's per-core series. Memory reads in bytes, against
  a limit only when one is really set (Docker reports host RAM otherwise).
- Exit 137/143 without an OOM kill is a stop, not a failure: no
  exited_nonzero, no crash-loop death, no "failed (24h)", dim exit code.
- "Failed (24h)" is a list (Docker page #failures, linked from the widget
  and the tile): plain containers by their deaths, swarm services by Swarm's
  failed tasks; a second manager's copy of a task death counts once.
- Failing swarm services lead Needs attention; services sort problems first
  with since-times on the page, the widget and the Swarm page; nodes that
  are down or drained sort first.
- Busiest widget orders by a 5-minute CPU average so the list holds still.
- Host panel: a table, problems first with what is wrong and since when;
  replaced swarm tasks hidden and counted.
- Container detail: node hostname, I/O labelled "since start", Restarts
  hidden for swarm tasks with a note on where task failures are.
- Widget names: "Docker — Services & failures", "Docker — Busiest containers".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:41:14 -04:00
co-authored by Claude Opus 5.5
parent 8941ed0372
commit 61b22056d5
16 changed files with 713 additions and 177 deletions
+60 -27
View File
@@ -2,18 +2,36 @@
first (service.needs_attention, the ranking the MCP tools use), the counts,
then swarm services and each host's containers. #}
{% from "_macros.html" import stat_tile, status_badge, status_dot %}
{% from "docker/_usage.html" import cpu, mem, cores %}
{# ── Needs attention ──────────────────────────────────────────────────────── #}
{% if attention %}
{% if attention or failing_services %}
<section class="card problem-card" aria-labelledby="docker-problems">
<h2 id="docker-problems" class="section-title" style="margin-bottom:0.6rem;">Needs attention</h2>
<ul class="problem-list">
{# A swarm service in trouble: Swarm replaces its failed tasks, so the
service, not any one container, is what is failing. #}
{% for s in failing_services %}
{% set e = s.last_error %}
<li>
{% if s.crash_looping %}{{ status_badge('crit', 'Crash-looping') }}
{% elif s.state == 'down' %}{{ status_badge('crit', 'Down') }}
{% else %}{{ status_badge('warn', 'Degraded') }}{% endif %}
<a href="/plugins/docker/swarm">{{ s.name }}</a>
<span class="problem-detail">
{{- s.running }} of {{ s.desired }} running
{%- if s.crash_looping %} · {{ s.failures_1h }} tasks failed in the last hour{% endif %}
{%- if e %} · last task {{ e.state }}: {{ e.error or e.message or e.state }}{% if e.exit_code %} (exit {{ e.exit_code }}){% endif %}{% if e.node %} on {{ e.node }}{% endif %}{% endif %}
{%- if s.trouble_since %} · since {{ s.trouble_since|ago }}{% endif -%}
</span>
</li>
{% endfor %}
{% for r in attention %}
{% set c = r.container %}
{% set worst = r.problems[0] %}
<li>
{{ status_badge('crit' if severity[worst] == 'critical' else 'warn', worst|replace('_', ' ')|capitalize) }}
<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}">{{ c.name }}</a>
<a href="/plugins/docker/container/{{ c.host_id }}/{{ c.name }}" title="{{ c.name }}">{{ r.label }}</a>
<span class="problem-detail">
{%- for p in r.problems %}{{ says[p] }}{{ " and " if not loop.last }}{% endfor %}
{%- if c.exit_code and worst in ('exited_nonzero', 'crash_looping', 'restarting') %} · exit {{ c.exit_code }}{% endif %}
@@ -42,18 +60,45 @@
</section>
{% endif %}
{# ── Counts ───────────────────────────────────────────────────────────────── #}
{# ── Counts ──────────────────────────────────────────────────────────────
Swarm services are counted as services (a task's container comes and goes);
the container counts are the containers outside any swarm service. ────── #}
<div class="stat-strip">
{{ stat_tile("Needs attention", attention|length, state="crit" if attention else "inactive") }}
{{ stat_tile("Needs attention", attention|length + failing_services|length, state="crit" if attention or failing_services else "inactive") }}
{{ stat_tile("Unhealthy", unhealthy, state="crit" if unhealthy else "inactive") }}
{{ stat_tile("Restarting", restarting, state="crit" if restarting else "inactive", hint="restarting or crash-looping") }}
{{ stat_tile("Running", running, state="up" if running else "inactive") }}
{{ stat_tile("Stopped", stopped, state="inactive") }}
{{ stat_tile("Failed (24h)", failures|length, state="warn" if failures else "inactive", hint="containers and services; listed below", href="#failures" if failures else none) }}
{% if swarm_services %}{{ stat_tile("Swarm services", services_healthy ~ "/" ~ swarm_services|length, state="up" if services_healthy == swarm_services|length else "warn", hint="healthy", href="/plugins/docker/swarm") }}{% endif %}
{{ stat_tile("Containers running", running, state="up" if running else "inactive", hint="outside swarm services" if swarm_services else none) }}
{{ stat_tile("Containers stopped", stopped, state="inactive") }}
{% if stale %}{{ stat_tile("Stale", stale, state="stale", hint="no recent report") }}{% endif %}
{% if swarm_services %}{{ stat_tile("Services", swarm_services|length, href="/plugins/docker/swarm") }}{% endif %}
{{ stat_tile("Hosts", host_groups|length) }}
{{ stat_tile("Docker hosts", docker_hosts) }}
</div>
{# ── What failed in the last 24h: what the dashboard's "failed (24h)" counts.
A plain container's death, or a swarm service's failed tasks; a container
someone stopped (exit 137/143) is not a failure. ───────────────────────── #}
{% if failures %}
<section id="failures" class="card" aria-labelledby="docker-failures" style="margin-bottom:1.5rem;">
<h2 id="docker-failures" class="section-title" style="margin-bottom:0.6rem;">Failed in the last 24h</h2>
<ul class="problem-list">
{% for f in failures %}
<li>
{{ status_badge('warn', 'Service' if f.kind == 'service' else 'Container') }}
{% if f.kind == 'service' %}<a href="/plugins/docker/swarm">{{ f.name }}</a>
{% else %}<a href="/plugins/docker/container/{{ f.host_id }}/{{ f.name }}">{{ f.name }}</a>{% endif %}
<span class="problem-detail">
{{- f.count }} {{ "failure" if f.count == 1 else "failures" }}
{%- if f.detail %} · last: {{ f.detail }}{% endif %}
{%- if f.host %} · {{ f.host }}{% endif %}
{%- if f.at %} · {{ f.at|ago }}{% endif -%}
</span>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{# ── Swarm services: collapsed per-service, each current replica labelled with
its host (tasks Swarm replaced are left out). Ghost replicas (tasks on nodes
with no Steward agent) come from the managers' placement data so the picture
@@ -72,7 +117,7 @@
<span style="font-weight:500;font-size:0.9rem;">{{ s.name }}</span>
<span style="font-size:0.72rem;color:var(--text-dim);">{{ s.mode }}</span>
<span class="tone-{{ 'ok' if s.state == 'healthy' else ('warn' if s.state == 'degraded' else 'crit') }}" style="font-size:0.78rem;font-variant-numeric:tabular-nums;">
{{ s.running }}/{{ s.desired }} running
{{ s.running }}/{{ s.desired }} running{% if s.state != 'healthy' and s.trouble_since %} · since {{ s.trouble_since|ago }}{% endif %}
</span>
</div>
{% with style="padding-left:1.1rem;margin-bottom:0.35rem;" %}{% include "docker/_swarm_failures.html" %}{% endwith %}
@@ -88,7 +133,7 @@
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.8rem;background:var(--bg-elevated);border:1px solid var(--border);border-radius:5px;padding:0.3rem 0.55rem;">
<span class="dot {% if r.status == 'running' %}dot-up{% elif r.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
<a href="/plugins/docker/container/{{ r.host_id }}/{{ r.name }}" style="font-weight:500;color:inherit;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="{{ r.name }}">{{ r.host }}</a>
{% if r.cpu_pct is not none %}<span style="color:var(--text-muted);font-family:ui-monospace,monospace;margin-left:auto;">{{ "%.0f" | format(r.cpu_pct) }}%</span>{% endif %}
{% if r.cpu_pct is not none %}<span style="color:var(--text-muted);font-family:ui-monospace,monospace;margin-left:auto;" title="Docker counts CPU per core">{{ cores(r.cpu_pct / 100) }}</span>{% endif %}
</div>
{% endif %}
{% endfor %}
@@ -119,8 +164,8 @@
<th>Container</th>
<th>Image</th>
<th>Ports</th>
<th>CPU %</th>
<th>Mem %</th>
<th title="Docker counts CPU per core: 1 core = one core fully busy">CPU</th>
<th title="Used, and the limit when one is set">Memory</th>
</tr>
</thead>
<tbody>
@@ -144,7 +189,7 @@
<div style="font-size:0.73rem;color:var(--text-muted);">
{{ c.status }}{% if item.uptime %} · up {{ item.uptime }}{% endif %}
{% if c.status != 'running' and c.exit_code is not none and c.exit_code != 0 %}
· <span class="tone-crit">exit {{ c.exit_code }}{% if c.oom_killed %} (OOM){% endif %}</span>
· <span class="tone-{{ 'crit' if item.exit_failed else 'dim' }}" title="{{ 'last exit code' if item.exit_failed else 'stopped by a signal; not a failure' }}">exit {{ c.exit_code }}{% if c.oom_killed %} (OOM){% endif %}</span>
{% endif %}
{% if c.restart_count %} · <span class="tone-warn" title="Restarted {{ c.restart_count }} times">⟳{{ c.restart_count }}</span>{% endif %}
</div>
@@ -164,20 +209,8 @@
<div style="color:var(--text-muted);">{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}</div>
{% endfor %}
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
{% if c.cpu_pct is not none %}
<span class="tone-{{ status_class(c.cpu_pct, 'cpu') }}">{{ "%.1f" | format(c.cpu_pct) }}%</span>
{% else %}
<span style="color:var(--text-dim);">—</span>
{% endif %}
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">
{% if c.mem_pct is not none %}
<span class="tone-{{ status_class(c.mem_pct, 'mem') }}">{{ "%.1f" | format(c.mem_pct) }}%</span>
{% else %}
<span style="color:var(--text-dim);">—</span>
{% endif %}
</td>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;white-space:nowrap;">{{ cpu(item.usage) }}</td>
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;white-space:nowrap;">{{ mem(item.usage) }}</td>
</tr>
{% endfor %}
{% endfor %}