feat(docker): the service and container pages say each fact once (#5310)
Service page: - The header carries the verdict, mode, stack, image and when it was reported. 'In trouble since' shows only while the service is in trouble; once recovered, 'Last failed X ago · its logs'. - The failure card shows only while the service is down, degraded or crash-looping. - The tile strip and the kv card are gone. - One Tasks table: running tasks first with CPU, memory and since when, then Swarm's record. The node is named as its Steward host throughout, and 'N failed in 24h' sits in the caption. Container page: - Same shape: header verdict, a 'Last exit' card only while it is stopped after a failing exit, set-up and I/O in one kv card, and lifecycle as a table. - A swarm task's breadcrumb goes through its service. Both pages: - Resource history is the one fragment (_usage_history.html): now and peak in cores and bytes, drawn with a floor of 1 core / 1 GiB (sparkline_svg floor=), so idle noise draws flat. - The container page's old % sparklines, its private _sparkline and the unused bucket()/container_history() helpers are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+91
-53
@@ -46,27 +46,6 @@ def _uptime(started_at: datetime | None) -> str | None:
|
||||
return None if secs is None or secs < 0 else human_duration(secs)
|
||||
|
||||
|
||||
def _sparkline(values: list[float], width: int = 80, height: int = 20) -> str:
|
||||
if len(values) < 2:
|
||||
return f'<svg width="{width}" height="{height}"></svg>'
|
||||
mn, mx = min(values), max(values)
|
||||
if mx == mn:
|
||||
mx = mn + 1.0
|
||||
step = width / (len(values) - 1)
|
||||
pts = []
|
||||
for i, v in enumerate(values):
|
||||
x = i * step
|
||||
y = height - (v - mn) / (mx - mn) * (height - 2) - 1
|
||||
pts.append(f"{x:.1f},{y:.1f}")
|
||||
poly = " ".join(pts)
|
||||
return (
|
||||
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
|
||||
f'style="vertical-align:middle;">'
|
||||
f'<polyline points="{poly}" fill="none" stroke="#6060c0" stroke-width="1.5"/>'
|
||||
f'</svg>'
|
||||
)
|
||||
|
||||
|
||||
def _filters(args) -> dict:
|
||||
"""The Docker page's search, filters and sort from the query string
|
||||
(#5299): q, stack, host, state, sort, dir; anything unknown is dropped."""
|
||||
@@ -440,21 +419,23 @@ def host_panel_rows(rows: list) -> tuple[list, list]:
|
||||
running[HOST_PANEL_BUSIEST:] + stopped)
|
||||
|
||||
|
||||
# Glyph + colour per lifecycle event, so the timeline reads at a glance.
|
||||
# Glyph + tone per lifecycle event, so the timeline reads at a glance.
|
||||
_EVENT_STYLE = {
|
||||
"start": ("▲", "var(--green)"),
|
||||
"restart": ("↻", "var(--yellow)"),
|
||||
"stop": ("■", "var(--text-muted)"),
|
||||
"die": ("✕", "var(--red)"),
|
||||
"oom": ("☠", "var(--red)"),
|
||||
"health_change": ("◐", "var(--orange)"),
|
||||
"start": ("▲", "ok"),
|
||||
"restart": ("↻", "warn"),
|
||||
"stop": ("■", "muted"),
|
||||
"die": ("✕", "crit"),
|
||||
"oom": ("☠", "crit"),
|
||||
"health_change": ("◐", "warn"),
|
||||
}
|
||||
|
||||
|
||||
@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."""
|
||||
"""One container (#5310): a header that says whether it is well and what it
|
||||
is, why it stopped only while it has, what it moved since it started, its
|
||||
CPU and memory over time, and its lifecycle."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
c = await db.get(DockerContainer, (host_id, name))
|
||||
host = await db.get(Host, host_id)
|
||||
@@ -491,10 +472,11 @@ async def container_detail(host_id: str, name: str):
|
||||
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]}
|
||||
"glyph": _EVENT_STYLE.get(e.event, ("•", "muted"))[0],
|
||||
"tone": _EVENT_STYLE.get(e.event, ("•", "muted"))[1]}
|
||||
for e in events
|
||||
],
|
||||
died_at=next((e.at for e in events if e.event in ("die", "oom")), None),
|
||||
current_range=request.args.get("range", DEFAULT_RANGE),
|
||||
)
|
||||
|
||||
@@ -761,16 +743,35 @@ async def disk_prune(host_id: str):
|
||||
@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."""
|
||||
"""HTMX fragment: the container's CPU (cores) and memory over the
|
||||
selected range, with the latest and the peak (#5310)."""
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
cpu_hist, mem_hist = await docker_svc.container_history(db, host_id, name, since)
|
||||
series = await docker_svc.service_history(db, [(host_id, name)], since)
|
||||
return await _usage_history(series, range_key)
|
||||
|
||||
|
||||
# A history line is drawn against at least this much, so a container idling
|
||||
# near zero draws flat (UI & Design ruling 8, #5310).
|
||||
HISTORY_CPU_FLOOR = 1.0 # cores
|
||||
HISTORY_MEM_FLOOR = 1 << 30 # bytes
|
||||
|
||||
|
||||
async def _usage_history(series: list, range_key: str):
|
||||
"""The CPU and memory history fragment both detail pages load: the latest
|
||||
and the peak over a line that spans its half, scaled with a floor."""
|
||||
from steward.core.status import sparkline_svg
|
||||
cpu = [c / 100 for _, c, _ in series]
|
||||
mem = [m for _, _, m in series]
|
||||
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,
|
||||
"docker/_usage_history.html", have_data=len(series) >= 2, range_key=range_key,
|
||||
cpu_now=cpu[-1] if cpu else None, cpu_peak=max(cpu) if cpu else None,
|
||||
mem_now=mem[-1] if mem else None, mem_peak=max(mem) if mem else None,
|
||||
cpu_floor=HISTORY_CPU_FLOOR, mem_floor=HISTORY_MEM_FLOOR,
|
||||
spark_cpu=sparkline_svg(cpu, height=48, stroke="currentColor", fill_width=True,
|
||||
floor=HISTORY_CPU_FLOOR),
|
||||
spark_mem=sparkline_svg(mem, height=48, stroke="currentColor", fill_width=True,
|
||||
floor=HISTORY_MEM_FLOOR),
|
||||
)
|
||||
|
||||
|
||||
@@ -822,7 +823,7 @@ def _task_rows(tasks: list[dict], node_hostname: dict) -> list[dict]:
|
||||
for t in tasks:
|
||||
nid = t.get("node_id") or ""
|
||||
out.append({
|
||||
"task_id": t.get("task_id") or "", "slot": str(t.get("slot") or ""),
|
||||
"task_id": t.get("task_id") or "", "slot": str(t.get("slot") or ""), "node_id": nid,
|
||||
"node": node_hostname.get(nid) or (nid[:12] if nid else "—"),
|
||||
"state": t.get("state") or "unknown", "desired": t.get("desired_state") or "",
|
||||
"exit_code": t.get("exit_code"), "why": t.get("error") or t.get("message") or "",
|
||||
@@ -832,6 +833,50 @@ def _task_rows(tasks: list[dict], node_hostname: dict) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _service_tasks(name: str, tasks: list[dict], replicas: list[dict],
|
||||
node_host: dict, host_name: dict) -> list[dict]:
|
||||
"""One table for a service's tasks (#5310): the running ones first, each
|
||||
with what its container uses, then Swarm's record of the rest, newest
|
||||
first. A node reads as its Steward host's name wherever one is known, so a
|
||||
node is spelled one way down the table. A running replica Swarm's record
|
||||
does not list (an agent before 1.8.0) still gets a row, and so does a node
|
||||
running tasks with no Steward agent on it."""
|
||||
live, unmatched = {}, []
|
||||
for r in (r for r in replicas if not r["ghost"]):
|
||||
slot, tid = docker_svc.task_of(name, r["name"]) or ("", "")
|
||||
tid = r.get("task_id") or tid # the agent's own record of it, else its name's
|
||||
if tid:
|
||||
live[tid] = (slot, r)
|
||||
else:
|
||||
unmatched.append(r)
|
||||
|
||||
def place(node_id, host_id, fallback):
|
||||
hid = host_id or node_host.get(node_id)
|
||||
return hid, (host_name.get(hid) if hid else None) or fallback
|
||||
|
||||
rows = []
|
||||
for t in tasks:
|
||||
_, r = live.pop(t["task_id"], (None, None))
|
||||
hid, node = place(t["node_id"], r["host_id"] if r else None, t["node"])
|
||||
rows.append(dict(t, node=node, host_id=hid, container=r["name"] if r else None,
|
||||
usage=r["usage"] if r else None, health=r["health"] if r else None,
|
||||
at=(r["started_at"] or t["at"]) if r and t["current"] else t["at"]))
|
||||
for tid, (slot, r) in [*live.items(), *(("", ("", r)) for r in unmatched)]:
|
||||
rows.append({"task_id": tid, "slot": slot, "node_id": "", "node": r["host"],
|
||||
"host_id": r["host_id"], "state": r["status"], "desired": "",
|
||||
"exit_code": None, "why": "", "at": r["started_at"], "failed": False,
|
||||
"current": (r["status"] or "").lower() == "running", "container": r["name"],
|
||||
"usage": r["usage"], "health": r["health"]})
|
||||
listed = {t["node"] for t in tasks if t["current"]}
|
||||
for g in (r for r in replicas if r["ghost"] and r["host"] not in listed):
|
||||
rows.append({"task_id": "", "slot": "", "node_id": "", "node": g["host"], "host_id": None,
|
||||
"state": "running", "desired": "", "exit_code": None,
|
||||
"why": f"{g['count']} running on a node with no Steward agent: no figures",
|
||||
"at": None, "failed": False, "current": True, "container": None,
|
||||
"usage": None, "health": None})
|
||||
return sorted(rows, key=lambda r: not r["current"]) # stable: newest first within each
|
||||
|
||||
|
||||
@docker_bp.get("/service/<name>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def service_detail(name: str):
|
||||
@@ -857,9 +902,11 @@ async def service_detail(name: str):
|
||||
replicas = []
|
||||
for r in (s["replicas"] if s else []):
|
||||
c = None if r["ghost"] else by_key.get((r["host_id"], r["name"]))
|
||||
replicas.append(dict(r, usage=docker_svc.usage(c, *capacity.get(r["host_id"], (None, None)))
|
||||
if c is not None else None,
|
||||
uptime=_uptime(c.started_at) if c is not None and c.status == "running" else None))
|
||||
replicas.append(dict(r, task_id=getattr(c, "task_id", None) if c is not None else None,
|
||||
usage=docker_svc.usage(c, *capacity.get(r["host_id"], (None, None)))
|
||||
if c is not None else None))
|
||||
node_host = {c.node_id: c.host_id for c in ctx["containers"] if c.node_id}
|
||||
table = _service_tasks(name, tasks, replicas, node_host, ctx["host_name"])
|
||||
# Every task Steward holds logs or events for, newest known first.
|
||||
known = {t["task_id"]: _task_label(t["slot"], t["task_id"]) for t in tasks if t["task_id"]}
|
||||
for _, cname in task_containers:
|
||||
@@ -868,7 +915,7 @@ async def service_detail(name: str):
|
||||
focus = request.args.get("task", "")
|
||||
return await render_template(
|
||||
"docker/service.html", name=name, service=s,
|
||||
row=service_row(s, now) if s else None, svc=newest, tasks=tasks, replicas=replicas,
|
||||
row=service_row(s, now) if s else None, svc=newest, tasks=table,
|
||||
task_choices=list(known.items()), focus_task=focus if focus in known else "",
|
||||
stack=stack_of(name), current_range=request.args.get("range", DEFAULT_RANGE),
|
||||
)
|
||||
@@ -879,20 +926,11 @@ async def service_detail(name: str):
|
||||
async def service_history_fragment(name: str):
|
||||
"""HTMX fragment: the service's CPU (cores) and memory, summed across its
|
||||
replicas, over the selected range, with the latest and the peak."""
|
||||
from steward.core.status import sparkline_svg
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
series = await docker_svc.service_history(
|
||||
db, await docker_svc.service_containers(db, name), since)
|
||||
cpu = [c / 100 for _, c, _ in series]
|
||||
mem = [m for _, _, m in series]
|
||||
return await render_template(
|
||||
"docker/_service_history.html", have_data=len(series) >= 2, range_key=range_key,
|
||||
cpu_now=cpu[-1] if cpu else None, cpu_peak=max(cpu) if cpu else None,
|
||||
mem_now=mem[-1] if mem else None, mem_peak=max(mem) if mem else None,
|
||||
spark_cpu=sparkline_svg(cpu, height=48, stroke="currentColor", fill_width=True),
|
||||
spark_mem=sparkline_svg(mem, height=48, stroke="currentColor", fill_width=True),
|
||||
)
|
||||
return await _usage_history(series, range_key)
|
||||
|
||||
|
||||
@docker_bp.get("/service/<name>/logs/lines")
|
||||
|
||||
@@ -213,38 +213,6 @@ async def host_capacity(db, host_ids) -> dict[str, tuple[int | None, int | None]
|
||||
return out
|
||||
|
||||
|
||||
def bucket(values: list[float], target: int = 40) -> list[float]:
|
||||
"""Bucket-average a series down to ~target points (DB-agnostic, in Python).
|
||||
|
||||
Replaces the old SQL strftime() bucketing, which was SQLite-only and broke
|
||||
on Postgres. Agent cadence is dense, so a multi-hour window is hundreds of
|
||||
rows — averaging into a readable point count keeps the sparkline's shape.
|
||||
"""
|
||||
n = len(values)
|
||||
if n <= target:
|
||||
return values
|
||||
size = (n + target - 1) // target
|
||||
out: list[float] = []
|
||||
for i in range(0, n, size):
|
||||
chunk = values[i:i + size]
|
||||
out.append(sum(chunk) / len(chunk))
|
||||
return out
|
||||
|
||||
|
||||
async def container_history(db, host_id: str, name: str, since) -> tuple[list, list]:
|
||||
"""Return (cpu_series, mem_series) sparkline-ready for one host's container."""
|
||||
rows = (await db.execute(
|
||||
select(DockerMetric.cpu_pct, DockerMetric.mem_pct)
|
||||
.where(DockerMetric.host_id == host_id)
|
||||
.where(DockerMetric.container_name == name)
|
||||
.where(DockerMetric.scraped_at >= since)
|
||||
.order_by(DockerMetric.scraped_at)
|
||||
)).all()
|
||||
cpu = bucket([r.cpu_pct or 0.0 for r in rows])
|
||||
mem = bucket([r.mem_pct or 0.0 for r in rows])
|
||||
return cpu, mem
|
||||
|
||||
|
||||
async def host_map(db, host_ids: set[str]) -> dict[str, Host]:
|
||||
if not host_ids:
|
||||
return {}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{# docker/_container_history.html — CPU/mem sparklines for the selected range #}
|
||||
{% if have_data %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.5rem;">
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.4rem;">CPU %</div>
|
||||
{{ sparkline_cpu | safe }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.4rem;">Memory %</div>
|
||||
{{ sparkline_mem | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;">
|
||||
Not enough samples in this range yet — history fills in as the agent reports.
|
||||
</div>
|
||||
{% endif %}
|
||||
+7
-6
@@ -1,22 +1,23 @@
|
||||
{# docker/_service_history.html — a service's CPU and memory summed across its
|
||||
replicas over the selected range (#5302): the latest and the peak, over a
|
||||
line that spans its half. #}
|
||||
{# docker/_usage_history.html — CPU and memory over the selected range, for a
|
||||
service (summed across its replicas, #5302) or one container (#5310): the
|
||||
latest and the peak, over a line that spans its half. The line is drawn
|
||||
against at least cpu_floor / mem_floor, so idling near zero draws flat. #}
|
||||
{% from "docker/_usage.html" import cores %}
|
||||
{% if have_data %}
|
||||
<div class="history-pair">
|
||||
<div>
|
||||
<div class="section-title">CPU</div>
|
||||
<div style="font-variant-numeric:tabular-nums;">{{ cores(cpu_now) }} <span class="tone-dim">now · peak {{ cores(cpu_peak) }}</span></div>
|
||||
<div class="spark">{{ spark_cpu|safe }}</div>
|
||||
<div class="spark" title="Drawn from zero to its peak, or to {{ cores(cpu_floor)|striptags }} when the peak is lower">{{ spark_cpu|safe }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-title">Memory</div>
|
||||
<div style="font-variant-numeric:tabular-nums;">{{ mem_now|bytes }} <span class="tone-dim">now · peak {{ mem_peak|bytes }}</span></div>
|
||||
<div class="spark">{{ spark_mem|safe }}</div>
|
||||
<div class="spark" title="Drawn from zero to its peak, or to {{ mem_floor|bytes }} when the peak is lower">{{ spark_mem|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="tone-muted" style="margin:0;font-size:0.85rem;">
|
||||
Not enough samples in the last {{ range_key }} yet. History fills in as the agents report its replicas.
|
||||
Not enough samples in the last {{ range_key }} yet. History fills in as the agent reports.
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -1,98 +1,94 @@
|
||||
{# docker/container_detail.html — full detail page for one container. A swarm
|
||||
task is titled by its service and slot, with the raw container name under
|
||||
it; CPU reads in cores and memory in bytes (see _usage.html). #}
|
||||
{# docker/container_detail.html — one container (#5310), shaped like a
|
||||
service's page: a header that says whether it is well and what it is; why
|
||||
it stopped, only while it has; what it moved since it started; its CPU and
|
||||
memory over time (the one home for what it uses now); its lifecycle.
|
||||
A swarm task is titled by its service and slot, the raw name under it. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs %}
|
||||
{% from "docker/_usage.html" import cpu, mem %}
|
||||
{% from "_macros.html" import crumbs, status_badge %}
|
||||
{% block title %}{{ label or name }} — Docker — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), (label or name, "")]) }}{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
{%- if container is not none and container.service_name -%}
|
||||
{{ crumbs([("Docker", "/plugins/docker/"), (container.service_name, "/plugins/docker/service/" ~ container.service_name), (label, "")]) }}
|
||||
{%- else -%}
|
||||
{{ crumbs([("Docker", "/plugins/docker/"), (label or name, "")]) }}
|
||||
{%- endif -%}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if container is none %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<div style="font-weight:500;margin-bottom:0.4rem;">Container not found</div>
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||
No container named <code>{{ name }}</code> is currently reported for this host.
|
||||
It may have been removed, or the host agent hasn't reported recently.
|
||||
</div>
|
||||
<div class="card empty-state">
|
||||
<strong>Container not found</strong>
|
||||
<span>No container named <code>{{ name }}</code> is reported for this host now. It may have been
|
||||
removed, or the host agent has not reported recently. <a href="/plugins/docker/">Back to Docker</a></span>
|
||||
</div>
|
||||
{% else %}
|
||||
{% set c = container %}
|
||||
|
||||
{# ── Header ──────────────────────────────────────────────────────────────── #}
|
||||
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.35rem;flex-wrap:wrap;">
|
||||
<span class="dot {% if container.status == 'running' %}dot-up{% elif container.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||
<h1 class="page-title" style="margin-bottom:0;">{{ label }}</h1>
|
||||
{% if container.health == 'healthy' %}<span style="font-size:0.72rem;color:var(--green);border:1px solid var(--green);border-radius:3px;padding:0.05rem 0.4rem;">healthy</span>
|
||||
{% elif container.health == 'unhealthy' %}<span style="font-size:0.72rem;color:var(--red);border:1px solid var(--red);border-radius:3px;padding:0.05rem 0.4rem;">unhealthy</span>
|
||||
{% elif container.health == 'starting' %}<span style="font-size:0.72rem;color:var(--orange);border:1px solid var(--orange);border-radius:3px;padding:0.05rem 0.4rem;">starting</span>{% endif %}
|
||||
</div>
|
||||
{% if label != container.name %}
|
||||
<div style="font-size:0.75rem;color:var(--text-dim);font-family:ui-monospace,monospace;margin-bottom:0.25rem;overflow-wrap:anywhere;">{{ container.name }}</div>
|
||||
{% endif %}
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.5rem;">
|
||||
{{ container.status }}{% if uptime %} · up {{ uptime }}{% endif %}
|
||||
{% if host %} · on <a href="/hosts/{{ host.id }}" style="color:var(--text-muted);">{{ host.name }}</a>{% endif %}
|
||||
· <a href="/plugins/docker/container/{{ host_id }}/{{ name }}/logs">View logs</a>
|
||||
</div>
|
||||
|
||||
{# ── Facts grid ──────────────────────────────────────────────────────────── #}
|
||||
{% macro fact(label, value, colour="") %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">{{ label }}</div>
|
||||
<div style="font-size:0.95rem;{% if colour %}color:{{ colour }};{% endif %}font-family:ui-monospace,monospace;">{{ value }}</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
|
||||
{{ fact("CPU", cpu(usage) if container.status == 'running' else "—") }}
|
||||
{{ fact("Memory", mem(usage) if container.status == 'running' else "—") }}
|
||||
{#- Swarm never restarts a task's container: it replaces the task with a new
|
||||
one, so restart_count is always 0 there and the service's task failures
|
||||
(Swarm page) are the count that means something. #}
|
||||
{% if not container.service_name %}
|
||||
{{ fact("Restarts", container.restart_count, "var(--orange)" if container.restart_count else "") }}
|
||||
{% endif %}
|
||||
{{ fact("Last exit", (container.exit_code ~ (" (OOM)" if container.oom_killed else "")) if container.exit_code is not none else "—",
|
||||
"var(--red)" if exit_failed else "") }}
|
||||
{{ fact("Net in since start", net_rx) }}
|
||||
{{ fact("Net out since start", net_tx) }}
|
||||
{{ fact("Disk read since start", blk_read) }}
|
||||
{{ fact("Disk written since start", blk_write) }}
|
||||
</div>
|
||||
{% if container.service_name %}
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin:-0.75rem 0 1.5rem;">
|
||||
Swarm replaces a failed task with a new container rather than restarting it; the
|
||||
service's failed tasks are on the <a href="/plugins/docker/swarm">Swarm page</a>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Image / placement ───────────────────────────────────────────────────── #}
|
||||
<div class="card" style="margin-bottom:1.5rem;">
|
||||
<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.4rem 1.25rem;font-size:0.86rem;">
|
||||
<span style="color:var(--text-muted);">Image</span>
|
||||
<span style="font-family:ui-monospace,monospace;word-break:break-all;">{{ container.image or "—" }}</span>
|
||||
{% if container.compose_project %}
|
||||
<span style="color:var(--text-muted);">Compose project</span><span>{{ container.compose_project }}</span>
|
||||
{% endif %}
|
||||
{% if container.service_name %}
|
||||
<span style="color:var(--text-muted);">Swarm service</span><span>{{ container.service_name }}</span>
|
||||
{% endif %}
|
||||
{% if container.node_id %}
|
||||
<span style="color:var(--text-muted);">Node</span>
|
||||
{% if node_name %}<span title="node id {{ container.node_id }}">{{ node_name }}</span>
|
||||
{% else %}<span style="font-family:ui-monospace,monospace;">{{ container.node_id }}</span>{% endif %}
|
||||
{% endif %}
|
||||
{% if ports %}
|
||||
<span style="color:var(--text-muted);">Ports</span>
|
||||
<span style="font-family:ui-monospace,monospace;">{% for p in ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{% if not loop.last %}, {% endif %}{% endfor %}</span>
|
||||
{% endif %}
|
||||
{# ── Header: the verdict, then what it is ────────────────────────────────── #}
|
||||
<div class="page-header">
|
||||
<div style="min-width:0;">
|
||||
<h1 class="page-title" style="overflow-wrap:anywhere;">{{ label }}</h1>
|
||||
<p class="page-subtitle">
|
||||
{% if c.status == 'running' %}{{ status_badge('ok', 'Running') }}
|
||||
{% elif c.status == 'restarting' %}{{ status_badge('crit', 'Restarting') }}
|
||||
{% elif c.status == 'paused' %}{{ status_badge('warn', 'Paused') }}
|
||||
{% elif exit_failed %}{{ status_badge('crit', c.status|capitalize) }}
|
||||
{% else %}{{ status_badge('dim', c.status|capitalize) }}{% endif %}
|
||||
{%- if c.health == 'unhealthy' %} {{ status_badge('crit', 'Unhealthy') }}
|
||||
{%- elif c.health == 'starting' %} {{ status_badge('pending', 'Starting') }}
|
||||
{%- elif c.health == 'healthy' %} {{ status_badge('ok', 'Healthy') }}{% endif %}
|
||||
{%- if uptime %} · up {{ uptime }}{% endif %}
|
||||
{%- if host %} · on <a href="/hosts/{{ host.id }}">{{ host.name }}</a>{% endif %}
|
||||
{%- if c.service_name %} · task of <a href="/plugins/docker/service/{{ c.service_name }}">{{ c.service_name }}</a>
|
||||
{%- elif c.compose_project %} · compose <span class="chip">{{ c.compose_project }}</span>{% endif %}
|
||||
{%- if c.image %} · <span class="mono" style="overflow-wrap:anywhere;" title="{{ c.image }}">{{ c.image.split("@")[0] }}</span>{% endif %}
|
||||
</p>
|
||||
<p class="page-subtitle tone-dim" style="margin-top:0.2rem;">
|
||||
{%- if label != c.name %}<span class="mono" style="overflow-wrap:anywhere;">{{ c.name }}</span> · {% endif -%}
|
||||
{%- if c.scraped_at %}reported {{ c.scraped_at|ago }}{% endif -%}
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-actions"><a class="btn btn-sm btn-ghost" href="/plugins/docker/container/{{ host_id }}/{{ name }}/logs">Logs</a></div>
|
||||
</div>
|
||||
|
||||
{# ── Resource history (range-toggled) ────────────────────────────────────── #}
|
||||
<div class="card" style="margin-bottom:1.5rem;">
|
||||
{# ── Why it stopped, only while it has ───────────────────────────────────── #}
|
||||
{% if c.status != 'running' and exit_failed %}
|
||||
<section class="card problem-card" aria-labelledby="ctr-exit">
|
||||
<h2 id="ctr-exit" class="section-title" style="margin-bottom:0.4rem;">Last exit</h2>
|
||||
<p style="margin:0;">
|
||||
Exited <span class="tone-crit">{{ c.exit_code }}</span>{% if c.oom_killed %}: killed for running out of memory{% endif %}
|
||||
{%- if died_at %} · {{ died_at|ago }}{% endif %}
|
||||
· <a href="/plugins/docker/container/{{ host_id }}/{{ name }}/logs">its logs →</a>
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# ── What it is set up with, and what it moved since it started ─────────── #}
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
{% if not c.service_name %}
|
||||
{#- Swarm never restarts a task's container: it replaces the task, so a
|
||||
task's restart count is always 0; its service's Tasks table is the
|
||||
count that means something. #}
|
||||
<dt>Restarts</dt><dd>{% if c.restart_count %}<span class="tone-warn">{{ c.restart_count }}</span>{% else %}0{% endif %}
|
||||
{%- if c.exit_code is not none and c.status == 'running' and c.restart_count %} <span class="tone-dim">· last exit {{ c.exit_code }}</span>{% endif %}</dd>
|
||||
{% endif %}
|
||||
<dt>Memory limit</dt><dd>{% if usage.mem_limit %}{{ usage.mem_limit|bytes }}{% else %}<span class="tone-dim">none set</span>{% endif %}</dd>
|
||||
{% if ports %}
|
||||
<dt>Ports</dt><dd class="mono">{% for p in ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{% if not loop.last %}, {% endif %}{% endfor %}</dd>
|
||||
{% endif %}
|
||||
{% if c.node_id %}
|
||||
<dt>Swarm node</dt><dd title="node id {{ c.node_id }}">{{ node_name or c.node_id[:12] }}</dd>
|
||||
{% endif %}
|
||||
<dt>Network</dt><dd>{{ net_rx }} in · {{ net_tx }} out <span class="tone-dim">since it started</span></dd>
|
||||
<dt>Disk</dt><dd>{{ blk_read }} read · {{ blk_write }} written <span class="tone-dim">since it started</span></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{# ── Resource history: the one home for what it uses ────────────────────── #}
|
||||
<section class="card" aria-labelledby="ctr-history">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||
<h3 class="section-title" style="margin-bottom:0;">Resource history</h3>
|
||||
<h2 id="ctr-history" class="section-title" style="margin:0;">Resource history</h2>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
<div id="container-history"
|
||||
@@ -100,31 +96,30 @@
|
||||
hx-trigger="load, rangeChange from:body"
|
||||
hx-include="#time-range"
|
||||
hx-swap="innerHTML">
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;">Loading…</div>
|
||||
<p class="field-hint">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ── Lifecycle timeline ──────────────────────────────────────────────────── #}
|
||||
<div class="card">
|
||||
<h3 class="section-title" style="margin-bottom:0.75rem;">Lifecycle</h3>
|
||||
{# ── Lifecycle, newest first ─────────────────────────────────────────────── #}
|
||||
<section class="card-flush" aria-labelledby="ctr-lifecycle">
|
||||
<h2 id="ctr-lifecycle" class="table-caption">Lifecycle <span class="tone-muted">newest first</span></h2>
|
||||
{% if events %}
|
||||
<div style="display:grid;gap:0.5rem;">
|
||||
{% for e in events %}
|
||||
<div style="display:flex;align-items:baseline;gap:0.6rem;font-size:0.85rem;">
|
||||
<span style="color:{{ e.colour }};width:1rem;text-align:center;flex-shrink:0;">{{ e.glyph }}</span>
|
||||
<span style="font-weight:500;width:6.5rem;flex-shrink:0;">{{ e.event }}</span>
|
||||
<span style="color:var(--text-muted);flex:1;min-width:0;">{{ e.detail or "" }}</span>
|
||||
<span style="color:var(--text-dim);font-size:0.78rem;white-space:nowrap;" title="{{ e.at }}">{{ e.at.strftime("%Y-%m-%d %H:%M") }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>Event</th><th>Detail</th><th class="num">When</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in events %}
|
||||
<tr>
|
||||
<td><span class="tone-{{ e.tone }}" aria-hidden="true">{{ e.glyph }}</span> {{ e.event|replace("_", " ") }}</td>
|
||||
<td class="tone-muted" style="overflow-wrap:anywhere;">{{ e.detail or "" }}</td>
|
||||
<td class="num">{{ e.at|ago }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;">
|
||||
No lifecycle events recorded yet. Start/stop/health changes appear here as the
|
||||
agent reports them over time.
|
||||
</div>
|
||||
<p class="tone-muted" style="margin:0;padding:1rem;">No lifecycle events recorded yet. Starts, stops and health changes appear here as the agent reports them.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{# docker/service.html — one swarm service (#5302): its state and what it
|
||||
runs, where its replicas are and what they use, Swarm's record of its recent
|
||||
tasks, its CPU and memory over time, and the logs of every task it has run.
|
||||
{# docker/service.html — one swarm service (#5302, #5310): a header that says
|
||||
whether it is well and what it runs; its last failure only while it is in
|
||||
trouble; its CPU and memory over time; one table of its tasks, the running
|
||||
ones first with what they use; and the logs of every task it has run.
|
||||
A failure's "Logs →" lands on #logs with ?task= picked. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, status_badge, status_dot, stat_tile %}
|
||||
{% from "docker/_usage.html" import cpu, mem, cores %}
|
||||
{% from "_macros.html" import crumbs, status_badge %}
|
||||
{% from "docker/_usage.html" import cpu, mem %}
|
||||
{% block title %}{{ name }} — Docker — Steward{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Docker", "/plugins/docker/"), (name, "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
@@ -22,10 +23,14 @@
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{# ── Header: is it well, then what it is ─────────────────────────────────── #}
|
||||
{# ── Header: the verdict, then what it is (#5310). Each fact has one home:
|
||||
what it uses is in Resource history, its tasks and failures in Tasks. #}
|
||||
{% set troubled = row.crash_looping or row.state != 'healthy' %}
|
||||
{% set tone = 'ok' if row.state == 'healthy' else ('warn' if row.state == 'degraded' else 'crit') %}
|
||||
{% set image = (svc.image if svc and svc.image else row.image) %}
|
||||
{% set e = row.last_error %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div style="min-width:0;">
|
||||
<h1 class="page-title" style="overflow-wrap:anywhere;">{{ name }}</h1>
|
||||
<p class="page-subtitle">
|
||||
{% if row.crash_looping %}{{ status_badge('crit', 'Crash-looping') }}
|
||||
@@ -33,16 +38,25 @@
|
||||
{% elif row.state == 'degraded' %}{{ status_badge('warn', 'Degraded') }}
|
||||
{% else %}{{ status_badge('ok', 'Healthy') }}{% endif %}
|
||||
<span class="tone-{{ tone }}">{{ row.running }} of {{ row.desired }} running</span>
|
||||
{%- if troubled and row.trouble_since %} · <span class="tone-{{ tone }}">in trouble since {{ row.trouble_since|ago }}</span>{% endif %}
|
||||
· {{ row.mode }}
|
||||
{%- if stack %} · stack <a class="chip" href="/plugins/docker/?stack={{ stack }}">{{ stack }}</a>{% endif %}
|
||||
{%- if row.trouble_since %} · in trouble since {{ row.trouble_since|ago }}{% endif %}
|
||||
{%- if image %} · <span class="mono" style="overflow-wrap:anywhere;" title="{{ image }}">{{ image.split("@")[0] }}</span>{% endif %}
|
||||
</p>
|
||||
{% if (not troubled and e) or svc %}
|
||||
<p class="page-subtitle tone-dim" style="margin-top:0.2rem;">
|
||||
{%- if not troubled and e %}Last failed {% if e.age_s is not none %}{{ e.age_s|duration }} ago{% endif %}
|
||||
{%- if e.exit_code %} (exit {{ e.exit_code }}){% endif %}
|
||||
{%- if e.task_id %} · <a href="?task={{ e.task_id }}#logs">its logs</a>{% endif %} · {% endif -%}
|
||||
{%- if svc %}reported {{ svc.updated_at|ago }} by the manager's agent{% endif -%}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="page-actions"><a class="btn btn-sm btn-ghost" href="#logs">Logs</a></div>
|
||||
</div>
|
||||
|
||||
{% if row.last_error %}
|
||||
{% set e = row.last_error %}
|
||||
{# ── What is wrong, only while it is (#5310) ──────────────────────────────── #}
|
||||
{% if troubled and e %}
|
||||
<section class="card problem-card" aria-labelledby="svc-last-error">
|
||||
<h2 id="svc-last-error" class="section-title" style="margin-bottom:0.4rem;">Last failed task</h2>
|
||||
<p style="margin:0;">
|
||||
@@ -53,60 +67,10 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="stat-strip">
|
||||
{{ stat_tile("CPU", cores(row.cpu_cores) if row.cpu_cores is not none else "—", hint="across running replicas") }}
|
||||
{{ stat_tile("Memory", row.mem_bytes|bytes if row.mem_bytes is not none else "—", hint="across running replicas") }}
|
||||
{{ stat_tile("Up", (row.started_at|ago|striptags|replace(" ago", "")) if row.started_at else "—", hint="since its newest replica started") }}
|
||||
{{ stat_tile("Failed (24h)", row.failures_24h or 0, state="warn" if row.failures_24h else none, hint="tasks Swarm replaced", href="#tasks" if row.failures_24h else none) }}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
<dt>Image</dt><dd class="mono" style="overflow-wrap:anywhere;">{{ (svc.image if svc and svc.image else row.image) or "—" }}</dd>
|
||||
<dt>Mode</dt><dd>{{ row.mode }}{% if row.mode != 'global' %}, {{ row.desired }} replica{{ "s" if row.desired != 1 }}{% endif %}</dd>
|
||||
{% if svc %}<dt>Reported</dt><dd>{{ svc.updated_at|ago }} by the manager's agent</dd>{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{# ── Replicas: where it runs and what each uses ──────────────────────────── #}
|
||||
<section class="card-flush" aria-labelledby="svc-replicas">
|
||||
<h2 id="svc-replicas" class="table-caption">Replicas <span class="tone-muted">{{ replicas|length }}</span></h2>
|
||||
{% if replicas %}
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
<th>Node</th><th>Container</th><th>Status</th>
|
||||
<th class="num" title="Docker counts CPU per core">CPU</th><th class="num">Memory</th><th class="num">Up</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for r in replicas %}
|
||||
<tr>
|
||||
{% if r.ghost %}
|
||||
<td>{{ r.host }}</td>
|
||||
<td colspan="5" class="tone-dim">{{ r.count }} running on a node with no Steward agent: no figures for {{ "it" if r.count == 1 else "them" }}</td>
|
||||
{% else %}
|
||||
<td>{% if r.host_id %}<a href="/hosts/{{ r.host_id }}">{{ r.host }}</a>{% else %}{{ r.host }}{% endif %}</td>
|
||||
<td class="cell-name"><a href="/plugins/docker/container/{{ r.host_id }}/{{ r.name }}" title="{{ r.name }}">{{ r.name }}</a></td>
|
||||
<td>
|
||||
{{ status_dot('up' if r.status == 'running' else 'down') }} <span class="tone-muted">{{ r.status }}</span>
|
||||
{%- if r.health == 'unhealthy' %} {{ status_badge('crit', 'Unhealthy') }}{% elif r.health == 'starting' %} {{ status_badge('pending', 'Starting') }}{% endif %}
|
||||
</td>
|
||||
<td class="num">{% if r.usage %}{{ cpu(r.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
<td class="num">{% if r.usage %}{{ mem(r.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
<td class="num">{{ r.uptime or "—" }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="tone-muted" style="margin:0;padding:1rem;">No replica is running.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# ── Resource history, summed across replicas ────────────────────────────── #}
|
||||
<section class="card" aria-labelledby="svc-history">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:0.75rem;flex-wrap:wrap;">
|
||||
<h2 id="svc-history" class="section-title" style="margin:0;">Resource history</h2>
|
||||
<h2 id="svc-history" class="section-title" style="margin:0;">Resource history <span class="tone-dim">across running replicas</span></h2>
|
||||
{% include "_time_range.html" %}
|
||||
</div>
|
||||
<div hx-get="/plugins/docker/service/{{ name }}/history"
|
||||
@@ -117,27 +81,38 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ── Swarm's record of its recent tasks ──────────────────────────────────── #}
|
||||
{# ── Tasks: the running ones with what they use, then Swarm's record ─────── #}
|
||||
<section id="tasks" class="card-flush" aria-labelledby="svc-tasks">
|
||||
<h2 id="svc-tasks" class="table-caption">Tasks <span class="tone-muted">Swarm's most recent, newest first</span></h2>
|
||||
<h2 id="svc-tasks" class="table-caption">Tasks
|
||||
<span class="tone-muted">running first, then Swarm's most recent</span>
|
||||
{%- if row.failures_24h %} <span class="tone-warn">· {{ row.failures_24h }} failed in 24h</span>{% endif %}</h2>
|
||||
{% if tasks %}
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
<th title="The replica slot this task filled">Slot</th><th>Task</th><th>Node</th><th>State</th>
|
||||
<th class="num">Exit</th><th>Why</th><th class="num">When</th>
|
||||
<th class="num" title="Docker counts CPU per core">CPU</th><th class="num">Memory</th>
|
||||
<th class="num">Exit</th><th>Why</th><th class="num" title="When it started, for a running task; else when it reached its state">When</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for t in tasks %}
|
||||
<tr>
|
||||
<td>{{ t.slot or "—" }}</td>
|
||||
<td class="mono" title="{{ t.task_id }}">{% if t.task_id %}<a href="?task={{ t.task_id }}#logs">{{ t.task_id[:10] }}</a>{% else %}—{% endif %}</td>
|
||||
<td>{{ t.node }}</td>
|
||||
<td class="mono" title="{{ t.container or t.task_id }}">
|
||||
{%- if t.task_id %}<a href="?task={{ t.task_id }}#logs" title="Its logs">{{ t.task_id[:10] }}</a>
|
||||
{%- if t.container and t.host_id %} <a class="tone-dim" href="/plugins/docker/container/{{ t.host_id }}/{{ t.container }}" title="The container's own page">↗</a>{% endif %}
|
||||
{%- elif t.container and t.host_id %}<a href="/plugins/docker/container/{{ t.host_id }}/{{ t.container }}">{{ t.container|truncate(24, true, "…") }}</a>
|
||||
{%- else %}<span class="tone-dim">—</span>{% endif -%}
|
||||
</td>
|
||||
<td>{% if t.host_id %}<a href="/hosts/{{ t.host_id }}">{{ t.node }}</a>{% else %}{{ t.node }}{% endif %}</td>
|
||||
<td>
|
||||
{%- if t.failed %}{{ status_badge('crit', t.state|capitalize) }}
|
||||
{%- elif t.current %}{{ status_badge('ok', 'Running') }}
|
||||
{%- else %}<span class="tone-muted">{{ t.state }}</span>{% endif %}
|
||||
{%- if t.health == 'unhealthy' %} {{ status_badge('crit', 'Unhealthy') }}{% elif t.health == 'starting' %} {{ status_badge('pending', 'Starting') }}{% endif %}
|
||||
{%- if t.desired and t.desired != t.state and not t.current %} <span class="tone-dim" title="What Swarm wants of it">→ {{ t.desired }}</span>{% endif -%}
|
||||
</td>
|
||||
<td class="num">{% if t.usage %}{{ cpu(t.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
<td class="num">{% if t.usage %}{{ mem(t.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
<td class="num">{% if t.exit_code %}<span class="tone-{{ 'crit' if t.failed else 'dim' }}">{{ t.exit_code }}</span>{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
<td style="overflow-wrap:anywhere;">{{ t.why or "" }}</td>
|
||||
<td class="num">{% if t.at %}{{ t.at|ago }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
|
||||
@@ -146,7 +121,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="tone-muted" style="margin:0;padding:1rem;">No task record yet: the manager's agent reports them from version 1.8.0.</p>
|
||||
<p class="tone-muted" style="margin:0;padding:1rem;">No task is running, and Swarm's record of its tasks has not been reported yet (the manager's agent reports it from version 1.8.0).</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
|
||||
+11
-3
@@ -77,16 +77,24 @@ async def collect_status(db) -> list[StatusEntry]:
|
||||
|
||||
|
||||
def sparkline_svg(values: list[float], width: int = 80, height: int = 20,
|
||||
stroke: str = "#6060c0", fill_width: bool = False) -> str:
|
||||
stroke: str = "#6060c0", fill_width: bool = False,
|
||||
floor: float | None = None) -> str:
|
||||
"""Tiny inline SVG polyline for a response-time series. With fill_width
|
||||
the line stretches across its container (CSS sets the width) and keeps a
|
||||
1.5px stroke."""
|
||||
1.5px stroke.
|
||||
|
||||
With `floor` the line is drawn from zero up to its own peak, but never
|
||||
against less than `floor`, so a value idling near zero draws flat rather
|
||||
than its noise filling the height (UI & Design ruling 8)."""
|
||||
vals = [v for v in values if v is not None]
|
||||
size = (f'width="100%" height="{height}" preserveAspectRatio="none"' if fill_width
|
||||
else f'width="{width}" height="{height}"')
|
||||
if len(vals) < 2:
|
||||
return f'<svg {size}></svg>'
|
||||
mn, mx = min(vals), max(vals)
|
||||
if floor is not None:
|
||||
mn, mx = 0.0, max(max(vals), floor)
|
||||
else:
|
||||
mn, mx = min(vals), max(vals)
|
||||
if mx == mn:
|
||||
mx = mn + 1.0
|
||||
step = width / (len(vals) - 1)
|
||||
|
||||
@@ -213,7 +213,7 @@ strong, b, th { font-weight: 500; }
|
||||
.section-count { margin-bottom: 0.6rem; }
|
||||
.section-count > span:first-child { margin-left: 0.25rem; }
|
||||
/* A service's resource history: CPU and memory side by side, each a figure
|
||||
over a line that spans its half (docker/_service_history.html). */
|
||||
over a line that spans its half (docker/_usage_history.html). */
|
||||
.history-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1.25rem; }
|
||||
.history-pair .spark { color: var(--blue); margin-top: 0.4rem; }
|
||||
/* The Swarm page (#5303): one card per node, two or three a row on a wide
|
||||
|
||||
@@ -78,3 +78,15 @@ def test_a_sparkline_can_stretch_across_its_tile():
|
||||
assert 'vector-effect="non-scaling-stroke"' in wide
|
||||
fixed = sparkline_svg([1, 2, 3])
|
||||
assert 'width="80"' in fixed and "preserveAspectRatio" not in fixed
|
||||
|
||||
|
||||
def test_a_sparkline_with_a_floor_draws_idle_noise_flat():
|
||||
"""#5310: 0.01 → 0.09 cores against a 1-core floor stays near the bottom;
|
||||
without it the same wobble fills the height. A peak above the floor is
|
||||
the top, and the line starts from zero."""
|
||||
def ys(svg):
|
||||
pts = svg.split('points="')[1].split('"')[0].split()
|
||||
return [float(p.split(",")[1]) for p in pts]
|
||||
assert ys(sparkline_svg([0.01, 0.09])) == [19.0, 1.0]
|
||||
assert ys(sparkline_svg([0.01, 0.09], floor=1.0)) == [18.8, 17.4]
|
||||
assert ys(sparkline_svg([1.0, 2.0], floor=1.0)) == [10.0, 1.0]
|
||||
|
||||
@@ -116,8 +116,13 @@ def test_a_service_page_reads_every_task(app):
|
||||
|
||||
status, page = got["page"]
|
||||
assert status == 200
|
||||
assert "nginx:1.27" in page and f"node-{tag}" in page and "1 of 1 running" in page
|
||||
assert "nginx:1.27" in page and "1 of 1 running" in page
|
||||
assert "task: non-zero exit (1)" in page # Swarm's task record
|
||||
# One table (#5310): the node reads as the Steward host on it, on the running
|
||||
# task and the failed one alike; the running task links its container.
|
||||
assert page.count(f'<a href="/hosts/{hid}">dock-{tag}</a>') == 2 and f"node-{tag}" not in page
|
||||
assert f'href="/plugins/docker/container/{hid}/{live}"' in page
|
||||
assert "in trouble" not in page and "Last failed task" not in page # it is healthy again
|
||||
assert f'<option value="{live_task}">1 · {live_task[:10]}</option>' in page
|
||||
assert f'<option value="{dead_task}">1 · {dead_task[:10]}</option>' in page
|
||||
assert f'href="/plugins/docker/?stack=it{tag}"' in page
|
||||
|
||||
@@ -11,7 +11,8 @@ from plugins.docker import service as svc
|
||||
from plugins.docker.overview import (
|
||||
NO_STACK, container_row, filter_options, filter_rows, service_row, sort_rows, stack_of,
|
||||
)
|
||||
from plugins.docker.routes import _filters, _page_url, _sort_url, _task_rows
|
||||
from plugins.docker.routes import _filters, _page_url, _service_tasks, _sort_url, _task_rows
|
||||
from tests.test_style_tokens import hex_colours
|
||||
from tests._templates import render_plugin_template
|
||||
|
||||
NOW = datetime(2026, 10, 7, 12, 0, tzinfo=timezone.utc)
|
||||
@@ -144,37 +145,100 @@ def test_task_rows_say_where_each_ran_and_why_it_ended():
|
||||
assert rows[1]["node"] == ("n9" * 10)[:12] and rows[1]["current"] and not rows[1]["failed"]
|
||||
|
||||
|
||||
async def test_the_service_page_puts_state_first_and_offers_each_tasks_logs(app):
|
||||
now = datetime.now(timezone.utc)
|
||||
LIVE = "l7q2m9k4p1r8s5t3v6w0y2z4b" # the running task
|
||||
|
||||
|
||||
def _degraded_radarr(now, **over):
|
||||
"""arr_radarr at 1 of 2: a running task on docker02 with figures, one on a
|
||||
node with no agent, and a failed task Swarm replaced."""
|
||||
usage = {"cores": 0.1, "cpus": 4, "cpu_share": 2.5, "mem_bytes": 100 << 20,
|
||||
"mem_pct": None, "mem_of": None, "mem_limit": None}
|
||||
s = _service("arr_radarr", running=1, desired=2, state="degraded", failures_24h=3, replicas=[
|
||||
dict(_replica("docker02"), name=f"arr_radarr.1.{TASK}"), {"ghost": True, "host": "edge01", "count": 1}],
|
||||
dict(_replica("docker02"), name=f"arr_radarr.1.{LIVE}"), {"ghost": True, "host": "edge01", "count": 1}],
|
||||
last_error={"state": "failed", "error": "task: non-zero exit (1)", "exit_code": 1, "node": "docker02",
|
||||
"age_s": 300, "task_id": TASK, "slot": 2})
|
||||
replicas = [dict(s["replicas"][0], usage={"cores": 0.1, "cpus": 4, "cpu_share": 2.5, "mem_bytes": 100,
|
||||
"mem_pct": None, "mem_of": None, "mem_limit": None},
|
||||
uptime="2h"), s["replicas"][1]]
|
||||
tasks = _task_rows([{"task_id": TASK, "slot": 2, "node_id": "n1", "state": "failed",
|
||||
"desired_state": "shutdown", "error": "task: non-zero exit (1)", "exit_code": 1}],
|
||||
{"n1": "docker02"})
|
||||
svc_row = NS(image="lscr.io/linuxserver/radarr:5@sha256:abc", updated_at=now)
|
||||
"age_s": 300, "task_id": TASK, "slot": 2},
|
||||
trouble_since=now - timedelta(minutes=10))
|
||||
s.update(over)
|
||||
replicas = [dict(s["replicas"][0], usage=usage), s["replicas"][1]]
|
||||
tasks = _task_rows([
|
||||
{"task_id": LIVE, "slot": 1, "node_id": "n1", "state": "running", "desired_state": "running",
|
||||
"message": "started", "at": "2026-10-07T10:00:00Z"},
|
||||
{"task_id": TASK, "slot": 2, "node_id": "n1", "state": "failed", "desired_state": "shutdown",
|
||||
"error": "task: non-zero exit (1)", "exit_code": 1, "at": "2026-10-07T09:00:00Z"}],
|
||||
{"n1": "vdnt-docker02"})
|
||||
return s, _service_tasks("arr_radarr", tasks, replicas, {"n1": "id-docker02"}, {"id-docker02": "docker02"})
|
||||
|
||||
|
||||
def test_a_services_tasks_are_one_table_running_first():
|
||||
"""#5310: the running task carries its container's figures; the node reads
|
||||
as its Steward host wherever one is known, Swarm's hostname otherwise; a
|
||||
node with no agent still gets a row."""
|
||||
_, rows = _degraded_radarr(NOW)
|
||||
assert [r["task_id"] for r in rows] == [LIVE, "", TASK]
|
||||
live, ghost, failed = rows
|
||||
assert live["current"] and live["usage"]["cores"] == 0.1 and live["container"] == f"arr_radarr.1.{LIVE}"
|
||||
assert live["node"] == failed["node"] == "docker02" and failed["host_id"] == "id-docker02"
|
||||
assert live["at"] == NOW - timedelta(hours=2) # its container's start
|
||||
assert ghost["node"] == "edge01" and ghost["current"] and ghost["usage"] is None
|
||||
assert "no Steward agent" in ghost["why"]
|
||||
assert failed["failed"] and failed["usage"] is None and failed["container"] is None
|
||||
# A running replica Swarm's record does not list still gets its row.
|
||||
orphan = _service_tasks("arr_radarr", [], [dict(_replica("docker02"), name=f"arr_radarr.3.{LIVE}",
|
||||
usage=None)], {}, {})
|
||||
assert orphan[0]["slot"] == "3" and orphan[0]["task_id"] == LIVE and orphan[0]["current"]
|
||||
|
||||
|
||||
async def _service_page(app, s, tasks, now):
|
||||
async with app.test_request_context(f"/plugins/docker/service/arr_radarr?task={TASK}"):
|
||||
session["user_role"] = "viewer"
|
||||
page = await render_plugin_template(
|
||||
app, "docker", "docker/service.html", name="arr_radarr", service=s, row=service_row(s, now),
|
||||
svc=svc_row, tasks=tasks, replicas=replicas, stack="arr", current_range="24h",
|
||||
task_choices=[(TASK, f"2 · {TASK[:10]}"), ("y" * 25, "1 · yyyyyyyyyy")], focus_task=TASK)
|
||||
page = re.sub(r">\s+<", "><", " ".join(page.split()))
|
||||
svc=NS(image="lscr.io/linuxserver/radarr:5@sha256:abc", updated_at=now), tasks=tasks,
|
||||
stack="arr", current_range="24h", focus_task=TASK,
|
||||
task_choices=[(TASK, f"2 · {TASK[:10]}"), (LIVE, f"1 · {LIVE[:10]}")])
|
||||
return re.sub(r">\s+<", "><", " ".join(page.split()))
|
||||
|
||||
|
||||
async def test_the_service_page_says_each_fact_once(app):
|
||||
"""#5310: the verdict and what it runs in the header; the failure card only
|
||||
while in trouble; history; one Tasks table; logs. No tiles, no kv card,
|
||||
no separate Replicas table."""
|
||||
now = datetime.now(timezone.utc)
|
||||
s, tasks = _degraded_radarr(now)
|
||||
page = await _service_page(app, s, tasks, now)
|
||||
assert '<nav class="breadcrumb"' in page and 'href="/plugins/docker/">Docker</a>' in page
|
||||
assert page.index("Degraded") < page.index("Last failed task") < page.index('id="svc-replicas"')
|
||||
assert "1 of 2 running" in page and '<a class="chip" href="/plugins/docker/?stack=arr">arr</a>' in page
|
||||
assert page.index("Degraded") < page.index("Last failed task") < page.index('id="svc-history"') \
|
||||
< page.index('id="tasks"') < page.index('id="logs"')
|
||||
assert "1 of 2 running" in page and "in trouble since <time" in page
|
||||
assert '<a class="chip" href="/plugins/docker/?stack=arr">arr</a>' in page
|
||||
assert 'title="lscr.io/linuxserver/radarr:5@sha256:abc">lscr.io/linuxserver/radarr:5</span>' in page # digest on hover
|
||||
assert f'href="?task={TASK}#logs">its logs →</a>' in page and "5m ago" in page
|
||||
assert "radarr:5@sha256:abc" in page
|
||||
assert "1 running on a node with no Steward agent" in page and '<a href="/hosts/id-docker02">docker02</a>' in page
|
||||
assert page.index('id="svc-replicas"') < page.index('id="svc-history"') < page.index('id="tasks"') < page.index('id="logs"')
|
||||
assert "stat-tile" not in page and 'class="kv"' not in page and "svc-replicas" not in page
|
||||
tasks_html = page[page.index('id="tasks"'):page.index('id="logs"')]
|
||||
assert '<span class="tone-warn">· 3 failed in 24h</span>' in tasks_html
|
||||
assert f'href="/plugins/docker/container/id-docker02/arr_radarr.1.{LIVE}"' in tasks_html
|
||||
assert '<a href="/hosts/id-docker02">docker02</a>' in tasks_html and "vdnt-docker02" not in tasks_html
|
||||
assert "0.10 cores" in tasks_html and "100.0 MiB" in tasks_html
|
||||
assert "1 running on a node with no Steward agent" in tasks_html
|
||||
assert tasks_html.index(LIVE[:10]) < tasks_html.index("edge01") < tasks_html.index(TASK[:10])
|
||||
assert '<span class="badge badge-crit">Failed</span>' in tasks_html
|
||||
assert 'hx-get="/plugins/docker/service/arr_radarr/history"' in page and 'id="time-range"' in page
|
||||
assert '<span class="badge badge-crit">Failed</span>' in page
|
||||
assert f'<option value="{TASK}" selected>2 · {TASK[:10]}</option>' in page
|
||||
assert 'hx-get="/plugins/docker/service/arr_radarr/logs/lines"' in page
|
||||
assert not hex_colours(page), hex_colours(page)
|
||||
|
||||
|
||||
async def test_a_recovered_service_is_not_in_trouble(app):
|
||||
"""#5310: a healthy service whose last task failed hours ago says when it
|
||||
last failed, quietly, with its logs; no red card, no "in trouble"."""
|
||||
now = datetime.now(timezone.utc)
|
||||
s, tasks = _degraded_radarr(now, running=2, state="healthy", failures_24h=1)
|
||||
s["last_error"] = dict(s["last_error"], age_s=5 * 3600, exit_code=137)
|
||||
page = await _service_page(app, s, tasks, now)
|
||||
assert "Healthy" in page and "2 of 2 running" in page
|
||||
assert "in trouble" not in page and "Last failed task" not in page and "problem-card" not in page
|
||||
assert "Last failed " in page and "(exit 137)" in page and f'href="?task={TASK}#logs">its logs</a>' in page
|
||||
assert "reported <time" in page
|
||||
|
||||
|
||||
async def test_a_service_nobody_reports_is_not_found(app):
|
||||
@@ -184,18 +248,69 @@ async def test_a_service_nobody_reports_is_not_found(app):
|
||||
assert "Service not found" in page and 'id="logs"' not in page
|
||||
|
||||
|
||||
async def test_a_service_history_shows_now_and_peak(app):
|
||||
async def test_a_usage_history_shows_now_and_peak(app):
|
||||
async with app.test_request_context("/plugins/docker/service/web/history"):
|
||||
page = await render_plugin_template(
|
||||
app, "docker", "docker/_service_history.html", have_data=True, range_key="24h",
|
||||
app, "docker", "docker/_usage_history.html", have_data=True, range_key="24h",
|
||||
cpu_now=0.5, cpu_peak=2.0, mem_now=100 << 20, mem_peak=300 << 20,
|
||||
spark_cpu="<svg></svg>", spark_mem="<svg></svg>")
|
||||
cpu_floor=1.0, mem_floor=1 << 30, spark_cpu="<svg></svg>", spark_mem="<svg></svg>")
|
||||
empty = await render_plugin_template(
|
||||
app, "docker", "docker/_service_history.html", have_data=False, range_key="1h")
|
||||
app, "docker", "docker/_usage_history.html", have_data=False, range_key="1h")
|
||||
assert "0.50 cores" in page and "peak 2.0 cores" in page and "peak 300.0 MiB" in page
|
||||
assert "or to 1.0 core when the peak is lower" in page # the floor is said, not hidden
|
||||
assert "Not enough samples in the last 1h" in empty
|
||||
|
||||
|
||||
def _ctr(**kw):
|
||||
base = dict(name="plex", host_id="h1", image="plexinc/pms:1.40", status="exited", health=None,
|
||||
exit_code=137, oom_killed=True, restart_count=2, compose_project="media",
|
||||
service_name=None, node_id=None, scraped_at=NOW - timedelta(seconds=30),
|
||||
started_at=NOW - timedelta(hours=3))
|
||||
base.update(kw)
|
||||
return NS(**base)
|
||||
|
||||
|
||||
async def _container_page(app, c, **over):
|
||||
ctx = dict(container=c, host=NS(id="h1", name="nas"), host_id="h1", name=c.name, label=c.name,
|
||||
node_name=None, exit_failed=True, ports=[], uptime=None,
|
||||
usage={"mem_limit": None}, net_rx="1.0 KiB", net_tx="2.0 KiB", blk_read="3.0 MiB",
|
||||
blk_write="4.0 MiB", died_at=NOW - timedelta(minutes=20), current_range="24h",
|
||||
events=[{"event": "health_change", "detail": "healthy→unhealthy", "at": NOW,
|
||||
"glyph": "◐", "tone": "warn"}])
|
||||
ctx.update(over)
|
||||
async with app.test_request_context(f"/plugins/docker/container/h1/{c.name}"):
|
||||
session["user_role"] = "viewer"
|
||||
page = await render_plugin_template(app, "docker", "docker/container_detail.html", **ctx)
|
||||
return re.sub(r">\s+<", "><", " ".join(page.split()))
|
||||
|
||||
|
||||
async def test_a_dead_container_says_why_first(app):
|
||||
"""#5310: a stopped container that exited badly leads with how; what it
|
||||
uses lives in Resource history alone; lifecycle is a table."""
|
||||
page = await _container_page(app, _ctr())
|
||||
assert 'href="/plugins/docker/">Docker</a>' in page
|
||||
assert '<span class="badge badge-crit">Exited</span>' in page and 'href="/hosts/h1">nas</a>' in page
|
||||
assert '<span class="chip">media</span>' in page and "plexinc/pms:1.40" in page
|
||||
assert page.index("Last exit") < page.index('id="ctr-history"') < page.index('id="ctr-lifecycle"')
|
||||
assert "killed for running out of memory" in page and "20m ago" in page
|
||||
assert "<dt>Restarts</dt>" in page and "none set" in page and "1.0 KiB in · 2.0 KiB out" in page
|
||||
assert 'hx-get="/plugins/docker/container/h1/plex/history"' in page
|
||||
assert "<td><span class=\"tone-warn\" aria-hidden=\"true\">◐</span> health change</td>" in page
|
||||
assert "stat-tile" not in page and "Net in since start" not in page
|
||||
assert not hex_colours(page), hex_colours(page)
|
||||
|
||||
|
||||
async def test_a_running_swarm_task_reads_under_its_service(app):
|
||||
c = _ctr(name=f"arr_radarr.1.{LIVE}", status="running", exit_code=0, oom_killed=False,
|
||||
service_name="arr_radarr", compose_project=None, node_id="n1")
|
||||
page = await _container_page(app, c, label="arr_radarr · slot 1", exit_failed=False, uptime="3h",
|
||||
node_name="docker02")
|
||||
assert 'href="/plugins/docker/service/arr_radarr">arr_radarr</a>' in page # crumb and "task of"
|
||||
assert "task of <a" in page and "· up 3h" in page and f"arr_radarr.1.{LIVE}" in page
|
||||
assert "Last exit" not in page and "<dt>Restarts</dt>" not in page
|
||||
assert "<dt>Swarm node</dt>" in page and "docker02" in page
|
||||
|
||||
|
||||
async def test_a_service_log_line_names_its_task(app):
|
||||
lines = [{"ts": NOW, "stream": "stderr", "line": "<b>boom</b>", "task": f"1 · {TASK[:10]}"}]
|
||||
async with app.test_request_context("/plugins/docker/service/web/logs/lines"):
|
||||
|
||||
@@ -17,13 +17,6 @@ def test_escape_like_makes_wildcards_literal():
|
||||
assert svc.escape_like("plain text") == "plain text"
|
||||
|
||||
|
||||
def test_bucket_keeps_short_series_and_averages_long_ones():
|
||||
assert svc.bucket([1.0, 2.0, 3.0]) == [1.0, 2.0, 3.0]
|
||||
out = svc.bucket([float(i) for i in range(400)], target=40)
|
||||
assert len(out) == 40
|
||||
assert out[0] == sum(range(10)) / 10
|
||||
|
||||
|
||||
def test_prune_extra_vars_mapping():
|
||||
"""The prune buttons drive one playbook via prune_target; only 'images'
|
||||
widens to ALL unused images (docker image prune -a), system stays -f."""
|
||||
|
||||
Reference in New Issue
Block a user