diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py index 8c2edfe..468aaf0 100644 --- a/plugins/docker/routes.py +++ b/plugins/docker/routes.py @@ -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'' - 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'' - f'' - f'' - ) - - 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//") @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///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/") @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//logs/lines") diff --git a/plugins/docker/service.py b/plugins/docker/service.py index aa63baf..1781f8b 100644 --- a/plugins/docker/service.py +++ b/plugins/docker/service.py @@ -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 {} diff --git a/plugins/docker/templates/docker/_container_history.html b/plugins/docker/templates/docker/_container_history.html deleted file mode 100644 index 0a9b780..0000000 --- a/plugins/docker/templates/docker/_container_history.html +++ /dev/null @@ -1,17 +0,0 @@ -{# docker/_container_history.html — CPU/mem sparklines for the selected range #} -{% if have_data %} -
-
-
CPU %
- {{ sparkline_cpu | safe }} -
-
-
Memory %
- {{ sparkline_mem | safe }} -
-
-{% else %} -
- Not enough samples in this range yet — history fills in as the agent reports. -
-{% endif %} diff --git a/plugins/docker/templates/docker/_service_history.html b/plugins/docker/templates/docker/_usage_history.html similarity index 51% rename from plugins/docker/templates/docker/_service_history.html rename to plugins/docker/templates/docker/_usage_history.html index c79b204..164e239 100644 --- a/plugins/docker/templates/docker/_service_history.html +++ b/plugins/docker/templates/docker/_usage_history.html @@ -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 %}
CPU
{{ cores(cpu_now) }} now · peak {{ cores(cpu_peak) }}
-
{{ spark_cpu|safe }}
+
{{ spark_cpu|safe }}
Memory
{{ mem_now|bytes }} now · peak {{ mem_peak|bytes }}
-
{{ spark_mem|safe }}
+
{{ spark_mem|safe }}
{% else %}

- 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.

{% endif %} diff --git a/plugins/docker/templates/docker/container_detail.html b/plugins/docker/templates/docker/container_detail.html index 5931077..44e9f35 100644 --- a/plugins/docker/templates/docker/container_detail.html +++ b/plugins/docker/templates/docker/container_detail.html @@ -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 %} -
-
Container not found
-
- No container named {{ name }} is currently reported for this host. - It may have been removed, or the host agent hasn't reported recently. -
+
+ Container not found + No container named {{ name }} is reported for this host now. It may have been + removed, or the host agent has not reported recently. Back to Docker
{% else %} +{% set c = container %} -{# ── Header ──────────────────────────────────────────────────────────────── #} -
- -

{{ label }}

- {% if container.health == 'healthy' %}healthy - {% elif container.health == 'unhealthy' %}unhealthy - {% elif container.health == 'starting' %}starting{% endif %} -
-{% if label != container.name %} -
{{ container.name }}
-{% endif %} -
- {{ container.status }}{% if uptime %} · up {{ uptime }}{% endif %} - {% if host %} · on {{ host.name }}{% endif %} - · View logs -
- -{# ── Facts grid ──────────────────────────────────────────────────────────── #} -{% macro fact(label, value, colour="") %} -
-
{{ label }}
-
{{ value }}
-
-{% endmacro %} - -
- {{ 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) }} -
-{% if container.service_name %} -
- Swarm replaces a failed task with a new container rather than restarting it; the - service's failed tasks are on the Swarm page. -
-{% endif %} - -{# ── Image / placement ───────────────────────────────────────────────────── #} -
-
- Image - {{ container.image or "—" }} - {% if container.compose_project %} - Compose project{{ container.compose_project }} - {% endif %} - {% if container.service_name %} - Swarm service{{ container.service_name }} - {% endif %} - {% if container.node_id %} - Node - {% if node_name %}{{ node_name }} - {% else %}{{ container.node_id }}{% endif %} - {% endif %} - {% if ports %} - Ports - {% for p in ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{% if not loop.last %}, {% endif %}{% endfor %} - {% endif %} +{# ── Header: the verdict, then what it is ────────────────────────────────── #} + -{# ── Resource history (range-toggled) ────────────────────────────────────── #} -
+{# ── Why it stopped, only while it has ───────────────────────────────────── #} +{% if c.status != 'running' and exit_failed %} +
+

Last exit

+

+ Exited {{ c.exit_code }}{% if c.oom_killed %}: killed for running out of memory{% endif %} + {%- if died_at %} · {{ died_at|ago }}{% endif %} + · its logs → +

+
+{% endif %} + +{# ── What it is set up with, and what it moved since it started ─────────── #} +
+
+ {% 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. #} +
Restarts
{% if c.restart_count %}{{ c.restart_count }}{% else %}0{% endif %} + {%- if c.exit_code is not none and c.status == 'running' and c.restart_count %} · last exit {{ c.exit_code }}{% endif %}
+ {% endif %} +
Memory limit
{% if usage.mem_limit %}{{ usage.mem_limit|bytes }}{% else %}none set{% endif %}
+ {% if ports %} +
Ports
{% for p in ports %}{{ p.host_port }}:{{ p.container_port }}/{{ p.protocol }}{% if not loop.last %}, {% endif %}{% endfor %}
+ {% endif %} + {% if c.node_id %} +
Swarm node
{{ node_name or c.node_id[:12] }}
+ {% endif %} +
Network
{{ net_rx }} in · {{ net_tx }} out since it started
+
Disk
{{ blk_read }} read · {{ blk_write }} written since it started
+
+
+ +{# ── Resource history: the one home for what it uses ────────────────────── #} +
-

Resource history

+

Resource history

{% include "_time_range.html" %}
-
Loading…
+

Loading…

-
+ -{# ── Lifecycle timeline ──────────────────────────────────────────────────── #} -
-

Lifecycle

+{# ── Lifecycle, newest first ─────────────────────────────────────────────── #} +
+

Lifecycle newest first

{% if events %} -
- {% for e in events %} -
- {{ e.glyph }} - {{ e.event }} - {{ e.detail or "" }} - {{ e.at.strftime("%Y-%m-%d %H:%M") }} -
- {% endfor %} -
+ + + + {% for e in events %} + + + + + + {% endfor %} + +
EventDetailWhen
{{ e.event|replace("_", " ") }}{{ e.detail or "" }}{{ e.at|ago }}
{% else %} -
- No lifecycle events recorded yet. Start/stop/health changes appear here as the - agent reports them over time. -
+

No lifecycle events recorded yet. Starts, stops and health changes appear here as the agent reports them.

{% endif %} -
+ {% endif %} {% endblock %} diff --git a/plugins/docker/templates/docker/service.html b/plugins/docker/templates/docker/service.html index cb83e39..87476f2 100644 --- a/plugins/docker/templates/docker/service.html +++ b/plugins/docker/templates/docker/service.html @@ -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 @@
{% 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 %}