feat(docker): the service and container pages say each fact once (#5310)
CI / lint (push) Successful in 4s
CI / integration (push) Successful in 1m33s
CI / unit (push) Failing after 1m59s
CI / publish (push) Skipped

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:
2026-10-07 21:33:25 -04:00
co-authored by Claude Opus 5.5
parent 6c486d2f9c
commit 29be9e3590
12 changed files with 408 additions and 315 deletions
+91 -53
View File
@@ -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")