From 29be9e359015ed540ab1cfd323a86b457b3d082c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 21:33:25 -0400 Subject: [PATCH] feat(docker): the service and container pages say each fact once (#5310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- plugins/docker/routes.py | 144 ++++++++----- plugins/docker/service.py | 32 --- .../templates/docker/_container_history.html | 17 -- ...rvice_history.html => _usage_history.html} | 13 +- .../templates/docker/container_detail.html | 201 +++++++++--------- plugins/docker/templates/docker/service.html | 109 ++++------ steward/core/status.py | 14 +- steward/static/steward.css | 2 +- tests/core/test_status.py | 12 ++ tests/integration/test_docker_service_page.py | 7 +- tests/plugins/docker/test_overview.py | 165 +++++++++++--- tests/plugins/docker/test_service.py | 7 - 12 files changed, 408 insertions(+), 315 deletions(-) delete mode 100644 plugins/docker/templates/docker/_container_history.html rename plugins/docker/templates/docker/{_service_history.html => _usage_history.html} (51%) 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 %}