From d4974ddf6c5f33a50aa7177181dc67491c2e469c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 17:08:46 -0400 Subject: [PATCH] =?UTF-8?q?feat(hosts):=20a=20host's=20page=20stays=20shor?= =?UTF-8?q?t=20=E2=80=94=20Docker=20folds=20past=20the=20busiest,=20monito?= =?UTF-8?q?rs=20add=20on=20their=20own=20form,=20agent=20actions=20under?= =?UTF-8?q?=20Manage=20(#5276,=20#5277,=20#5278)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker panel (#5276): problems first, then the 8 busiest running containers by CPU; the other running and the stopped fold behind one "+N more running · M stopped" line. Containers that are not swarm tasks read the way the Docker page reads them: CI jobs as "CI: workflow · job", buildx builders as "BuildKit builder · name". Monitors card (#5277): the edit/delete icon buttons and the inline add form are gone. Each row links to its monitor's page; "Add monitor" opens the add form filed under this host, starting from its address, with the host in its breadcrumbs and Cancel back to it. Agent card (#5278): Full metrics stays in view; Update, Rotate token, Unregister agent (was "Remove") and Re-provision sit under "Manage agent". Co-Authored-By: Claude Opus 5.5 --- plugins/docker/routes.py | 29 +++++- plugins/docker/service.py | 13 ++- .../docker/templates/docker/host_panel.html | 64 ++++++++----- plugins/host_agent/templates/panel.html | 91 ++++++++++--------- steward/hosts/routes.py | 2 - steward/monitors/routes.py | 14 ++- steward/templates/hosts/detail.html | 50 +--------- steward/templates/monitors/form.html | 6 +- tests/core/test_host_page.py | 37 +++++++- tests/plugins/docker/test_docker_page.py | 44 +++++++++ 10 files changed, 224 insertions(+), 126 deletions(-) diff --git a/plugins/docker/routes.py b/plugins/docker/routes.py index b74212e..4db1636 100644 --- a/plugins/docker/routes.py +++ b/plugins/docker/routes.py @@ -369,19 +369,38 @@ async def host_panel(host_id: str): "usage": docker_svc.usage(c, *capacity.get(host_id, (None, None))), "uptime": _uptime(c.started_at) if c.status == "running" else None, }) - rows.sort(key=lambda r: ( - docker_svc.PROBLEMS.index(r["problems"][0]) if r["problems"] else len(docker_svc.PROBLEMS), - r["c"].status != "running", r["label"].lower())) + shown, folded = host_panel_rows(rows) running = sum(1 for r in rows if r["c"].status == "running") return await render_template( "docker/host_panel.html", - rows=rows, says=docker_svc.PROBLEM_SAYS, severity=docker_svc.PROBLEM_SEVERITY, + rows=shown, folded=folded, says=docker_svc.PROBLEM_SAYS, severity=docker_svc.PROBLEM_SEVERITY, running=running, stopped=len(rows) - running, problems=sum(1 for r in rows if r["problems"]), - replaced=len(replaced), + replaced=len(replaced), busiest=HOST_PANEL_BUSIEST, ) +# Running containers shown before the rest fold away on a host's page (#5276). +HOST_PANEL_BUSIEST = 8 + + +def host_panel_rows(rows: list) -> tuple[list, list]: + """(shown, folded) for a host's Docker panel: every container with a + problem, worst first; then the busiest running ones by CPU. The other + running containers (busiest first) and the stopped ones (by name) fold + away, so a host with dozens of containers keeps a short card.""" + def cpu(r): + return getattr(r["c"], "cpu_pct", None) or 0.0 + problems = sorted((r for r in rows if r["problems"]), key=lambda r: ( + docker_svc.PROBLEMS.index(r["problems"][0]), r["label"].lower())) + running = sorted((r for r in rows if not r["problems"] and r["c"].status == "running"), + key=lambda r: (-cpu(r), r["label"].lower())) + stopped = sorted((r for r in rows if not r["problems"] and r["c"].status != "running"), + key=lambda r: r["label"].lower()) + return (problems + running[:HOST_PANEL_BUSIEST], + running[HOST_PANEL_BUSIEST:] + stopped) + + # Glyph + colour per lifecycle event, so the timeline reads at a glance. _EVENT_STYLE = { "start": ("▲", "var(--green)"), diff --git a/plugins/docker/service.py b/plugins/docker/service.py index 97d24a8..8d98931 100644 --- a/plugins/docker/service.py +++ b/plugins/docker/service.py @@ -115,11 +115,11 @@ def failures_24h(events, swarm_services, host_name: dict) -> list[dict]: def task_label(c, node_hostname: dict | None = None) -> str: """How a container reads to a person. A swarm task is its service and slot: "codex_db · 1" for a replica, "agent · node-3" for a global - service's task on a node. Anything else is its own name.""" + service's task on a node. Anything else as display_name reads it.""" from .swarm_view import _slot_key svc = getattr(c, "service_name", None) if not svc: - return c.name + return display_name(c) slot = _slot_key(c) if slot.isdigit(): return f"{svc} · {slot}" @@ -129,19 +129,24 @@ def task_label(c, node_hostname: dict | None = None) -> str: _CI_JOB = re.compile( r"^(?:GITEA|FORGEJO)-ACTIONS-TASK-\d+-WORKFLOW-(?P.+?)-JOB-(?P.+?)(?:-[0-9a-f]{16,})?$") +# docker buildx's container driver names its node "buildx_buildkit_". +_BUILDKIT = re.compile(r"^buildx_buildkit_(?P.+?)\d*$") def display_name(c) -> str: """A container's name as a list can show it in one short line (#5253): a swarm task by its service (the host says where it runs), a Gitea or - Forgejo Actions job container as "CI: · ", anything else - by its own name.""" + Forgejo Actions job container as "CI: · ", a buildx + builder as "BuildKit builder · ", anything else by its own name.""" svc = getattr(c, "service_name", None) if svc: return svc m = _CI_JOB.match(c.name or "") if m: return f"CI: {m['workflow']} · {m['job']}" + m = _BUILDKIT.match(c.name or "") + if m: + return f"BuildKit builder · {m['builder']}" return c.name diff --git a/plugins/docker/templates/docker/host_panel.html b/plugins/docker/templates/docker/host_panel.html index 882e744..f223a22 100644 --- a/plugins/docker/templates/docker/host_panel.html +++ b/plugins/docker/templates/docker/host_panel.html @@ -1,27 +1,21 @@ {# docker/host_panel.html — per-host Docker fragment embedded on the Hosts hub: - problems first with what is wrong and since when, then running, then - stopped. Tasks Swarm has replaced are left out (counted in the header). #} + problems first with what is wrong and since when, then the busiest running + containers; the rest of the running and the stopped fold away so the card + stays short (#5276). Tasks Swarm has replaced are left out (counted in the + header). #} {% from "_macros.html" import status_badge, status_dot %} {% from "docker/_usage.html" import cpu, mem %} -
-
-

Docker

- - {% if problems %}{{ problems }} with problems · {% endif %} - {{ running }} running{% if stopped %} · {{ stopped }} stopped{% endif %} - {% if replaced %} · {{ replaced }} replaced task{{ "s" if replaced != 1 }} hidden{% endif %} - All → - -
- - - - - - - - - +{% macro container_table(rows) %} +
ContainerCPUMemory
+ + + + + + + + + {% for r in rows %} {% set c = r.c %} @@ -53,6 +47,30 @@ {% endfor %} - -
ContainerCPUMemory
{% if c.status == 'running' %}{{ mem(r.usage) }}{% else %}—{% endif %}
+ + +{% endmacro %} +
+
+

Docker

+ + {% if problems %}{{ problems }} with problems · {% endif %} + {{ running }} running{% if stopped %} · {{ stopped }} stopped{% endif %} + {% if replaced %} · {{ replaced }} replaced task{{ "s" if replaced != 1 }} hidden{% endif %} + All → + +
+ {{ container_table(rows) }} + {% if folded %} +
+ + {%- set more_running = folded|selectattr("c.status", "equalto", "running")|list|length -%} + {%- set more_stopped = folded|length - more_running -%} + {%- if more_running %}+{{ more_running }} more running{% endif -%} + {%- if more_running and more_stopped %} · {% endif -%} + {%- if more_stopped %}{{ more_stopped }} stopped{% endif -%} + + {{ container_table(folded) }} +
+ {% endif %}
diff --git a/plugins/host_agent/templates/panel.html b/plugins/host_agent/templates/panel.html index 5e1d615..7b81d5f 100644 --- a/plugins/host_agent/templates/panel.html +++ b/plugins/host_agent/templates/panel.html @@ -15,52 +15,57 @@ {% if reporting %} {# Reporting: live vitals are shown by the vitals strip above; this panel is - lifecycle/management only. #} -
- Full metrics → - {% if session.user_role == 'admin' %} - {% if ansible_available and target %} -
{{ csrf_field() }} - - -
- {% endif %} -
{{ csrf_field() }} - -
-
{{ csrf_field() }} - -
- {% endif %} -
- {% if ansible_available and not target %} -

- Link an Ansible target (above) to enable one-click agent updates. -

- {% endif %} + the way to the full metrics, with the lifecycle actions folded under + "Manage agent" (#5278). #} + Full metrics → - {% if session.user_role == 'admin' and ansible_available and target and managed_key_set %} + {% if session.user_role == 'admin' %}
- Re-provision (reinstall the steward account + managed key, then the agent) -

- Use after regenerating the managed key, or if SSH auth as steward breaks. - Connects over a one-time bootstrap user + password (not stored). +

Manage agent +
+ {% if ansible_available and target %} +
{{ csrf_field() }} + + +
+ {% endif %} +
{{ csrf_field() }} + +
+
{{ csrf_field() }} + +
+
+ {% if ansible_available and not target %} +

+ Link an Ansible target (below) to update the agent from here.

-
{{ csrf_field() }} - -
- - -
-
- - -
- -
+ {% endif %} + + {% if ansible_available and target and managed_key_set %} +
+ Re-provision (reinstall the steward account + managed key, then the agent) +

+ Use after regenerating the managed key, or if SSH auth as steward breaks. + Connects over a one-time bootstrap user + password (not stored). +

+
{{ csrf_field() }} + +
+ + +
+
+ + +
+ +
+
+ {% endif %}
{% endif %} diff --git a/steward/hosts/routes.py b/steward/hosts/routes.py index 7ac08cb..22990a5 100644 --- a/steward/hosts/routes.py +++ b/steward/hosts/routes.py @@ -85,11 +85,9 @@ async def host_detail(host_id: str): select(AnsibleTarget).where(AnsibleTarget.host_id.is_(None)) .order_by(AnsibleTarget.name))).scalars().all() - from steward.models.monitors import MonitorType return await render_template( "hosts/detail.html", host=host, monitors=monitors, uptime=uptime, alerts=alerts, - monitor_types=list(MonitorType), linked_target=linked_target, linkable_targets=linkable_targets, ansible_sources=_ansible_source_names(), ) diff --git a/steward/monitors/routes.py b/steward/monitors/routes.py index ebea671..2b083a5 100644 --- a/steward/monitors/routes.py +++ b/steward/monitors/routes.py @@ -88,11 +88,21 @@ async def add_monitor(): async def _new_form(form, mtype: str, error: str | None = None) -> str: - """The add form, holding what was typed when `error` sends it back.""" + """The add form, holding what was typed when `error` sends it back. Opened + from a host's page (host_id), it files the monitor under that host and + starts from the host's address.""" values = {k: form.get(k, "") for k in ("name", "target", "host_id")} values["check_interval_seconds"] = form.get("check_interval_seconds", 0) + host = None + if values["host_id"]: + async with current_app.db_sessionmaker() as db: + host = await db.get(Host, values["host_id"]) + if host is None: + values["host_id"] = "" + elif not values["target"]: + values["target"] = host.address or host.name return await render_template( - "monitors/form.html", monitor=None, values=values, + "monitors/form.html", monitor=None, values=values, host=host, mtype=mtype if mtype in MONITOR_TYPE_VALUES else MonitorType.icmp.value, config=_form_opts(form, mtype) if error and mtype in MONITOR_TYPE_VALUES else {}, types=list(MonitorType), error=error, diff --git a/steward/templates/hosts/detail.html b/steward/templates/hosts/detail.html index 3d17ee6..072438a 100644 --- a/steward/templates/hosts/detail.html +++ b/steward/templates/hosts/detail.html @@ -1,6 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import crumbs, status_dot, status_badge, icon_button %} -{% from "monitors/_fields.html" import type_fields, toggle_script %} +{% from "_macros.html" import crumbs, status_dot, status_badge %} {% block title %}{{ host.name }} — Hosts — Steward{% endblock %} {% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "")]) }}{% endblock %} {% block content %} @@ -58,6 +57,9 @@

Monitors

+ {% if session.user_role in ['operator', 'admin'] %} + Add monitor + {% endif %} {% if uptime %} uptime{% for w in ("24h", "7d", "30d") %}{{ " ·" if not loop.first }} {{ w }} @@ -94,54 +96,14 @@ {{ "%.0f"|format(latest.response_ms) }} ms {% else %}{{ status_badge('up') }}{% endif %}
- {% if session.user_role in ['operator', 'admin'] %} -
- {{ icon_button("✎", "Edit " ~ m.name, href="/monitors/" ~ m.id ~ "/edit") }} -
{{ csrf_field() }} - {{ icon_button("×", "Delete " ~ m.name, type="submit", confirm="Delete monitor " ~ m.name ~ "?") }} -
-
- {% endif %}
{% endfor %} {% else %}

- No monitors yet for this host. Add one below. + No monitors yet for this host.

{% endif %} - {% if session.user_role in ['operator', 'admin'] %} -
- + Add monitor for this host -
{{ csrf_field() }} - -
-
- - -
-
- - -
-
- - -
-
- - -
- {{ type_fields() }} -
-
-
-
- {% endif %}
{# Monitors card #} {# ── Agent (host_agent management panel) ──────────────────────────────────── #} @@ -249,6 +211,4 @@
{% endif %} -{{ toggle_script() }} - {% endblock %} diff --git a/steward/templates/monitors/form.html b/steward/templates/monitors/form.html index 97a7dc3..8c36b6c 100644 --- a/steward/templates/monitors/form.html +++ b/steward/templates/monitors/form.html @@ -6,6 +6,8 @@ {% block breadcrumb %} {%- if monitor -%} {{ crumbs([("Monitors", "/monitors/"), (monitor.name, "/monitors/" ~ monitor.id), ("Edit", "")]) }} +{%- elif host -%} +{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Add monitor", "")]) }} {%- else -%} {{ crumbs([("Monitors", "/monitors/"), ("Add monitor", "")]) }} {%- endif -%} @@ -47,10 +49,12 @@ {% if monitor and monitor.host_id %}

Linked to its host.

+ {% elif host %} +

Filed under {{ host.name }}; it shows on that host's page.

{% endif %}
- Cancel + Cancel
diff --git a/tests/core/test_host_page.py b/tests/core/test_host_page.py index ebde741..40c79fd 100644 --- a/tests/core/test_host_page.py +++ b/tests/core/test_host_page.py @@ -13,7 +13,42 @@ async def test_ansible_comes_before_the_docker_list(app, monkeypatch): session["user_role"] = "operator" page = await render_template( "hosts/detail.html", host=host, monitors=[], uptime=None, alerts=[], - monitor_types=[], linked_target=None, linkable_targets=[], ansible_sources=[]) + linked_target=None, linkable_targets=[], ansible_sources=[]) docker = page.index('hx-get="/plugins/docker/host/h1"') assert page.index(">Ansible") < docker assert page.index('hx-get="/plugins/host_agent/panel/h1"') < docker + # Monitors are added on their own form, filed under this host (#5277); + # each row's own page has the labelled Edit/Pause/Delete. + assert 'href="/monitors/new?host_id=h1"' in page + assert "row-actions" not in page and 'action="/monitors/add"' not in page + + +async def test_adding_a_monitor_from_a_host_page_says_whose_it_is(app): + host = SimpleNamespace(id="h1", name="box", address="10.0.0.5") + async with app.test_request_context("/monitors/new?host_id=h1"): + session["user_role"] = "operator" + page = await render_template( + "monitors/form.html", monitor=None, host=host, mtype="icmp", config={}, types=[], + values={"name": "", "target": "10.0.0.5", "host_id": "h1", "check_interval_seconds": 0}) + assert 'box' in page # crumb and hint name the host + assert 'name="host_id" value="h1"' in page and 'value="10.0.0.5"' in page + assert 'href="/hosts/h1" class="btn btn-sm btn-ghost">Cancel' in page + + +async def test_the_agent_card_keeps_its_actions_under_manage(app): + """#5278: "Remove" said nothing about what it removed.""" + from datetime import datetime, timezone + from tests._templates import render_plugin_template + host = SimpleNamespace(id="h1", name="box", address="10.0.0.5") + reg = SimpleNamespace(agent_version="1.8.0", last_seen_at=datetime.now(timezone.utc)) + target = SimpleNamespace(id="t1", name="box") + async with app.test_request_context("/plugins/host_agent/panel/h1"): + session["user_role"] = "admin" + page = await render_plugin_template( + app, "host_agent", "panel.html", host=host, reg=reg, reporting=True, stale=False, + target=target, ansible_available=True, managed_key_set=True) + manage = page.index(">Manage agent") + assert page.index("Full metrics →") < manage + for action in (">Update agent", ">Rotate token", ">Unregister agent"): + assert page.index(action) > manage + assert ">Remove" not in page diff --git a/tests/plugins/docker/test_docker_page.py b/tests/plugins/docker/test_docker_page.py index 22ccef5..d697d0f 100644 --- a/tests/plugins/docker/test_docker_page.py +++ b/tests/plugins/docker/test_docker_page.py @@ -267,3 +267,47 @@ def test_bar_slots_end_at_the_bucket_now_falls_in(): assert width == 900 and len(slots) == 24 assert slots[-1] == datetime(2026, 10, 7, 12, 0, tzinfo=timezone.utc) assert slots[0] == datetime(2026, 10, 7, 6, 15, tzinfo=timezone.utc) + + +def test_builders_and_ci_jobs_read_by_what_they_are_on_a_hosts_page(): + assert svc.display_name(_c("buildx_buildkit_steward-builder0")) == "BuildKit builder · steward-builder" + ci = "FORGEJO-ACTIONS-TASK-8597-WORKFLOW-CI-JOB-unit" + assert svc.task_label(_c(ci)) == "CI: CI · unit" # not a swarm task: display_name + assert svc.task_label(_c("codex_db.1.37ma", service_name="codex_db")) == "codex_db · 1" + + +def _panel_row(name, cpu, status="running", problems=()): + c = _c(name, cpu_pct=cpu, status=status) + return {"c": c, "label": name, "problems": list(problems), "since": None, + "usage": svc.usage(c, 4, 8 << 30), "uptime": "2h" if status == "running" else None} + + +def _panel_rows(): + return ([_panel_row(f"busy{n:02d}", float(n)) for n in range(1, 11)] + + [_panel_row("old", None, status="exited"), + _panel_row("dead", None, status="exited", problems=["exited_nonzero"])]) + + +def test_a_hosts_docker_panel_shows_problems_then_the_busiest_and_folds_the_rest(): + """#5276: 33 containers made the card run off the page.""" + from plugins.docker.routes import HOST_PANEL_BUSIEST, host_panel_rows + shown, folded = host_panel_rows(_panel_rows()) + assert [r["label"] for r in shown] == ["dead"] + [f"busy{n:02d}" for n in range(10, 2, -1)] + assert len(shown) == 1 + HOST_PANEL_BUSIEST + assert [r["label"] for r in folded] == ["busy02", "busy01", "old"] + + +async def test_the_folded_containers_sit_behind_one_line(app): + from plugins.docker.routes import host_panel_rows + shown, folded = host_panel_rows(_panel_rows()) + async with app.test_request_context("/plugins/docker/host/h1"): + page = await render_plugin_template( + app, "docker", "docker/host_panel.html", rows=shown, folded=folded, + says=svc.PROBLEM_SAYS, severity=svc.PROBLEM_SEVERITY, + running=10, stopped=2, problems=1, replaced=0, busiest=8) + fold = page.index("busy10<") < fold < page.index(">busy01<") + assert page.index(">dead<") < page.index(">busy10<") + assert len(re.findall(r"]", page)) == 6 # one header row per table + assert not hex_colours(page), hex_colours(page)