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")
|
||||
|
||||
Reference in New Issue
Block a user