feat(hosts): a host's page stays short — Docker folds past the busiest, monitors add on their own form, agent actions under Manage (#5276, #5277, #5278)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 59s
CI / unit (push) Successful in 1m17s
CI / publish (push) Successful in 1m3s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 17:08:46 -04:00
co-authored by Claude Opus 5.5
parent 6788ce60f1
commit d4974ddf6c
10 changed files with 224 additions and 126 deletions
+24 -5
View File
@@ -369,19 +369,38 @@ async def host_panel(host_id: str):
"usage": docker_svc.usage(c, *capacity.get(host_id, (None, None))), "usage": docker_svc.usage(c, *capacity.get(host_id, (None, None))),
"uptime": _uptime(c.started_at) if c.status == "running" else None, "uptime": _uptime(c.started_at) if c.status == "running" else None,
}) })
rows.sort(key=lambda r: ( shown, folded = host_panel_rows(rows)
docker_svc.PROBLEMS.index(r["problems"][0]) if r["problems"] else len(docker_svc.PROBLEMS),
r["c"].status != "running", r["label"].lower()))
running = sum(1 for r in rows if r["c"].status == "running") running = sum(1 for r in rows if r["c"].status == "running")
return await render_template( return await render_template(
"docker/host_panel.html", "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, running=running, stopped=len(rows) - running,
problems=sum(1 for r in rows if r["problems"]), 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. # Glyph + colour per lifecycle event, so the timeline reads at a glance.
_EVENT_STYLE = { _EVENT_STYLE = {
"start": ("▲", "var(--green)"), "start": ("▲", "var(--green)"),
+9 -4
View File
@@ -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: def task_label(c, node_hostname: dict | None = None) -> str:
"""How a container reads to a person. A swarm task is its service and """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 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 from .swarm_view import _slot_key
svc = getattr(c, "service_name", None) svc = getattr(c, "service_name", None)
if not svc: if not svc:
return c.name return display_name(c)
slot = _slot_key(c) slot = _slot_key(c)
if slot.isdigit(): if slot.isdigit():
return f"{svc} · {slot}" return f"{svc} · {slot}"
@@ -129,19 +129,24 @@ def task_label(c, node_hostname: dict | None = None) -> str:
_CI_JOB = re.compile( _CI_JOB = re.compile(
r"^(?:GITEA|FORGEJO)-ACTIONS-TASK-\d+-WORKFLOW-(?P<workflow>.+?)-JOB-(?P<job>.+?)(?:-[0-9a-f]{16,})?$") r"^(?:GITEA|FORGEJO)-ACTIONS-TASK-\d+-WORKFLOW-(?P<workflow>.+?)-JOB-(?P<job>.+?)(?:-[0-9a-f]{16,})?$")
# docker buildx's container driver names its node "buildx_buildkit_<builder><n>".
_BUILDKIT = re.compile(r"^buildx_buildkit_(?P<builder>.+?)\d*$")
def display_name(c) -> str: def display_name(c) -> str:
"""A container's name as a list can show it in one short line (#5253): a """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 swarm task by its service (the host says where it runs), a Gitea or
Forgejo Actions job container as "CI: <workflow> · <job>", anything else Forgejo Actions job container as "CI: <workflow> · <job>", a buildx
by its own name.""" builder as "BuildKit builder · <name>", anything else by its own name."""
svc = getattr(c, "service_name", None) svc = getattr(c, "service_name", None)
if svc: if svc:
return svc return svc
m = _CI_JOB.match(c.name or "") m = _CI_JOB.match(c.name or "")
if m: if m:
return f"CI: {m['workflow']} · {m['job']}" return f"CI: {m['workflow']} · {m['job']}"
m = _BUILDKIT.match(c.name or "")
if m:
return f"BuildKit builder · {m['builder']}"
return c.name return c.name
+41 -23
View File
@@ -1,27 +1,21 @@
{# docker/host_panel.html — per-host Docker fragment embedded on the Hosts hub: {# 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 problems first with what is wrong and since when, then the busiest running
stopped. Tasks Swarm has replaced are left out (counted in the header). #} 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 "_macros.html" import status_badge, status_dot %}
{% from "docker/_usage.html" import cpu, mem %} {% from "docker/_usage.html" import cpu, mem %}
<div class="card"> {% macro container_table(rows) %}
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:0.6rem;margin-bottom:0.6rem;flex-wrap:wrap;"> <table class="table">
<h3 class="section-title" style="margin-bottom:0;">Docker</h3> <colgroup><col><col style="width:7rem"><col style="width:9rem"></colgroup>
<span style="font-size:0.78rem;color:var(--text-muted);"> <thead>
{% if problems %}<span class="tone-crit">{{ problems }} with problems</span> · {% endif %} <tr>
{{ running }} running{% if stopped %} · {{ stopped }} stopped{% endif %} <th>Container</th>
{% if replaced %}<span title="Old swarm tasks Swarm has replaced with new ones: history, not containers that need anything"> · {{ replaced }} replaced task{{ "s" if replaced != 1 }} hidden</span>{% endif %} <th title="Docker counts CPU per core: 1 core = one core fully busy">CPU</th>
<a href="/plugins/docker/" style="margin-left:0.6rem;color:var(--text-muted);">All →</a> <th title="Used, and the limit when one is set">Memory</th>
</span> </tr>
</div> </thead>
<table class="table"> <tbody>
<thead>
<tr>
<th>Container</th>
<th title="Docker counts CPU per core: 1 core = one core fully busy">CPU</th>
<th title="Used, and the limit when one is set">Memory</th>
</tr>
</thead>
<tbody>
{% for r in rows %} {% for r in rows %}
{% set c = r.c %} {% set c = r.c %}
<tr> <tr>
@@ -53,6 +47,30 @@
<td style="font-family:ui-monospace,monospace;font-size:0.85rem;white-space:nowrap;">{% if c.status == 'running' %}{{ mem(r.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td> <td style="font-family:ui-monospace,monospace;font-size:0.85rem;white-space:nowrap;">{% if c.status == 'running' %}{{ mem(r.usage) }}{% else %}<span class="tone-dim">—</span>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endmacro %}
<div class="card">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:0.6rem;margin-bottom:0.6rem;flex-wrap:wrap;">
<h3 class="section-title" style="margin-bottom:0;">Docker</h3>
<span style="font-size:0.78rem;color:var(--text-muted);">
{% if problems %}<span class="tone-crit">{{ problems }} with problems</span> · {% endif %}
{{ running }} running{% if stopped %} · {{ stopped }} stopped{% endif %}
{% if replaced %}<span title="Old swarm tasks Swarm has replaced with new ones: history, not containers that need anything"> · {{ replaced }} replaced task{{ "s" if replaced != 1 }} hidden</span>{% endif %}
<a href="/plugins/docker/" style="margin-left:0.6rem;color:var(--text-muted);">All →</a>
</span>
</div>
{{ container_table(rows) }}
{% if folded %}
<details style="margin-top:0.5rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">
{%- 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 -%}
</summary>
{{ container_table(folded) }}
</details>
{% endif %}
</div> </div>
+48 -43
View File
@@ -15,52 +15,57 @@
{% if reporting %} {% if reporting %}
{# Reporting: live vitals are shown by the vitals strip above; this panel is {# Reporting: live vitals are shown by the vitals strip above; this panel is
lifecycle/management only. #} the way to the full metrics, with the lifecycle actions folded under
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;"> "Manage agent" (#5278). #}
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a> <a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
{% if session.user_role == 'admin' %}
{% if ansible_available and target %}
<form method="post" action="/plugins/host_agent/update" style="margin:0;">{{ csrf_field() }}
<input type="hidden" name="inventory_scope" value="{{ scope }}">
<button type="submit" class="btn btn-sm" title="Refresh agent.py + restart (token preserved)">Update agent</button>
</form>
{% endif %}
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/rotate-token" style="margin:0;"
data-confirm="Rotate token? The agent stops reporting until reinstalled/updated with the new token.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost">Rotate token</button>
</form>
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0;"
data-confirm="Remove this agent registration? Metrics stop until re-registered.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
{% endif %}
</div>
{% if ansible_available and not target %}
<p style="color:var(--text-dim);font-size:0.8rem;margin:0.6rem 0 0;">
Link an Ansible target (above) to enable one-click agent updates.
</p>
{% endif %}
{% if session.user_role == 'admin' and ansible_available and target and managed_key_set %} {% if session.user_role == 'admin' %}
<details style="margin-top:0.7rem;"> <details style="margin-top:0.7rem;">
<summary style="cursor:pointer;font-size:0.8rem;color:var(--text-muted);">Re-provision (reinstall the steward account + managed key, then the agent)</summary> <summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">Manage agent</summary>
<p style="font-size:0.78rem;color:var(--text-dim);margin:0.5rem 0;"> <div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-top:0.6rem;">
Use after regenerating the managed key, or if SSH auth as <code>steward</code> breaks. {% if ansible_available and target %}
Connects over a one-time bootstrap user + password (not stored). <form method="post" action="/plugins/host_agent/update" style="margin:0;">{{ csrf_field() }}
<input type="hidden" name="inventory_scope" value="{{ scope }}">
<button type="submit" class="btn btn-sm" title="Refresh agent.py and restart it; the token is kept">Update agent</button>
</form>
{% endif %}
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/rotate-token" style="margin:0;"
data-confirm="Rotate token? The agent stops reporting until reinstalled/updated with the new token.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost">Rotate token</button>
</form>
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0;"
data-confirm="Unregister this host's agent? Its token stops working and metrics stop until it is registered again. The agent itself stays installed on the host.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger">Unregister agent</button>
</form>
</div>
{% if ansible_available and not target %}
<p style="color:var(--text-dim);font-size:0.8rem;margin:0.6rem 0 0;">
Link an Ansible target (below) to update the agent from here.
</p> </p>
<form method="post" action="/plugins/host_agent/provision" {% endif %}
style="display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
<input type="hidden" name="inventory_scope" value="{{ scope }}"> {% if ansible_available and target and managed_key_set %}
<div class="form-group" style="margin:0;"> <details style="margin-top:0.7rem;">
<label>Bootstrap user</label> <summary style="cursor:pointer;font-size:0.8rem;color:var(--text-muted);">Re-provision (reinstall the steward account + managed key, then the agent)</summary>
<input type="text" name="bootstrap_user" required placeholder="root" style="width:8rem;" autocomplete="off"> <p style="font-size:0.78rem;color:var(--text-dim);margin:0.5rem 0;">
</div> Use after regenerating the managed key, or if SSH auth as <code>steward</code> breaks.
<div class="form-group" style="margin:0;"> Connects over a one-time bootstrap user + password (not stored).
<label>Bootstrap password</label> </p>
<input type="password" name="bootstrap_password" required style="width:10rem;" autocomplete="new-password"> <form method="post" action="/plugins/host_agent/provision"
</div> style="display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm">Re-provision</button> <input type="hidden" name="inventory_scope" value="{{ scope }}">
</form> <div class="form-group" style="margin:0;">
<label>Bootstrap user</label>
<input type="text" name="bootstrap_user" required placeholder="root" style="width:8rem;" autocomplete="off">
</div>
<div class="form-group" style="margin:0;">
<label>Bootstrap password</label>
<input type="password" name="bootstrap_password" required style="width:10rem;" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-sm">Re-provision</button>
</form>
</details>
{% endif %}
</details> </details>
{% endif %} {% endif %}
-2
View File
@@ -85,11 +85,9 @@ async def host_detail(host_id: str):
select(AnsibleTarget).where(AnsibleTarget.host_id.is_(None)) select(AnsibleTarget).where(AnsibleTarget.host_id.is_(None))
.order_by(AnsibleTarget.name))).scalars().all() .order_by(AnsibleTarget.name))).scalars().all()
from steward.models.monitors import MonitorType
return await render_template( return await render_template(
"hosts/detail.html", "hosts/detail.html",
host=host, monitors=monitors, uptime=uptime, alerts=alerts, host=host, monitors=monitors, uptime=uptime, alerts=alerts,
monitor_types=list(MonitorType),
linked_target=linked_target, linkable_targets=linkable_targets, linked_target=linked_target, linkable_targets=linkable_targets,
ansible_sources=_ansible_source_names(), ansible_sources=_ansible_source_names(),
) )
+12 -2
View File
@@ -88,11 +88,21 @@ async def add_monitor():
async def _new_form(form, mtype: str, error: str | None = None) -> str: 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 = {k: form.get(k, "") for k in ("name", "target", "host_id")}
values["check_interval_seconds"] = form.get("check_interval_seconds", 0) 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( 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, 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 {}, config=_form_opts(form, mtype) if error and mtype in MONITOR_TYPE_VALUES else {},
types=list(MonitorType), error=error, types=list(MonitorType), error=error,
+5 -45
View File
@@ -1,6 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs, status_dot, status_badge, icon_button %} {% from "_macros.html" import crumbs, status_dot, status_badge %}
{% from "monitors/_fields.html" import type_fields, toggle_script %}
{% block title %}{{ host.name }} — Hosts — Steward{% endblock %} {% block title %}{{ host.name }} — Hosts — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "")]) }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "")]) }}{% endblock %}
{% block content %} {% block content %}
@@ -58,6 +57,9 @@
<div class="card" style="margin-bottom:0;"> <div class="card" style="margin-bottom:0;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.5rem;"> <div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.5rem;">
<h3 class="section-title" style="margin-bottom:0;">Monitors</h3> <h3 class="section-title" style="margin-bottom:0;">Monitors</h3>
{% if session.user_role in ['operator', 'admin'] %}
<a href="/monitors/new?host_id={{ host.id }}" class="btn btn-ghost btn-sm" style="margin-left:auto;margin-right:0.75rem;">Add monitor</a>
{% endif %}
{% if uptime %} {% if uptime %}
<span style="font-size:0.8rem;color:var(--text-muted);"> <span style="font-size:0.8rem;color:var(--text-muted);">
uptime{% for w in ("24h", "7d", "30d") %}{{ " ·" if not loop.first }} {{ w }} uptime{% for w in ("24h", "7d", "30d") %}{{ " ·" if not loop.first }} {{ w }}
@@ -94,54 +96,14 @@
<span class="tone-{{ status_class(latest.response_ms, 'latency') }}">{{ "%.0f"|format(latest.response_ms) }} ms</span> <span class="tone-{{ status_class(latest.response_ms, 'latency') }}">{{ "%.0f"|format(latest.response_ms) }} ms</span>
{% else %}{{ status_badge('up') }}{% endif %} {% else %}{{ status_badge('up') }}{% endif %}
</div> </div>
{% if session.user_role in ['operator', 'admin'] %}
<div class="row-actions">
{{ icon_button("✎", "Edit " ~ m.name, href="/monitors/" ~ m.id ~ "/edit") }}
<form method="post" action="/monitors/{{ m.id }}/delete">{{ csrf_field() }}
{{ icon_button("×", "Delete " ~ m.name, type="submit", confirm="Delete monitor " ~ m.name ~ "?") }}
</form>
</div>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<p style="color:var(--text-muted);font-size:0.88rem;margin:0.25rem 0 0.75rem;"> <p style="color:var(--text-muted);font-size:0.88rem;margin:0.25rem 0 0.75rem;">
No monitors yet for this host. Add one below. No monitors yet for this host.
</p> </p>
{% endif %} {% endif %}
{% if session.user_role in ['operator', 'admin'] %}
<details style="margin-top:0.75rem;">
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-muted);">+ Add monitor for this host</summary>
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;margin-top:0.75rem;">{{ csrf_field() }}
<input type="hidden" name="host_id" value="{{ host.id }}">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.6rem;align-items:end;">
<div>
<label class="field-label">Type</label>
<select name="type" id="mtype" onchange="mtoggle(this.value)" class="input">
{% for t in monitor_types %}
<option value="{{ t.value }}" {% if t.value == 'icmp' %}selected{% endif %}>{{ t.value | upper }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="field-label">Name</label>
<input type="text" name="name" placeholder="optional" autocomplete="off" class="input">
</div>
<div>
<label class="field-label">Target</label>
<input type="text" name="target" value="{{ host.address }}" autocomplete="off" required class="input">
</div>
<div>
<label class="field-label">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="0" min="0" class="input">
</div>
{{ type_fields() }}
</div>
<div><button type="submit" class="btn btn-sm">Add Monitor</button></div>
</form>
</details>
{% endif %}
</div>{# Monitors card #} </div>{# Monitors card #}
{# ── Agent (host_agent management panel) ──────────────────────────────────── #} {# ── Agent (host_agent management panel) ──────────────────────────────────── #}
@@ -249,6 +211,4 @@
<div hx-get="/plugins/snmp/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div> <div hx-get="/plugins/snmp/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
{% endif %} {% endif %}
{{ toggle_script() }}
<script>var _mt=document.getElementById('mtype'); if(_mt) mtoggle(_mt.value);</script>
{% endblock %} {% endblock %}
+5 -1
View File
@@ -6,6 +6,8 @@
{% block breadcrumb %} {% block breadcrumb %}
{%- if monitor -%} {%- if monitor -%}
{{ crumbs([("Monitors", "/monitors/"), (monitor.name, "/monitors/" ~ monitor.id), ("Edit", "")]) }} {{ crumbs([("Monitors", "/monitors/"), (monitor.name, "/monitors/" ~ monitor.id), ("Edit", "")]) }}
{%- elif host -%}
{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Add monitor", "")]) }}
{%- else -%} {%- else -%}
{{ crumbs([("Monitors", "/monitors/"), ("Add monitor", "")]) }} {{ crumbs([("Monitors", "/monitors/"), ("Add monitor", "")]) }}
{%- endif -%} {%- endif -%}
@@ -47,10 +49,12 @@
</div> </div>
{% if monitor and monitor.host_id %} {% if monitor and monitor.host_id %}
<p class="field-hint">Linked to <a href="/hosts/{{ monitor.host_id }}">its host</a>.</p> <p class="field-hint">Linked to <a href="/hosts/{{ monitor.host_id }}">its host</a>.</p>
{% elif host %}
<p class="field-hint">Filed under <a href="/hosts/{{ host.id }}">{{ host.name }}</a>; it shows on that host's page.</p>
{% endif %} {% endif %}
<div style="display:flex;gap:0.5rem;"> <div style="display:flex;gap:0.5rem;">
<button type="submit" class="btn btn-sm">{{ "Save" if monitor else "Add monitor" }}</button> <button type="submit" class="btn btn-sm">{{ "Save" if monitor else "Add monitor" }}</button>
<a href="{{ '/monitors/' ~ monitor.id if monitor else '/monitors/' }}" class="btn btn-sm btn-ghost">Cancel</a> <a href="{{ '/monitors/' ~ monitor.id if monitor else ('/hosts/' ~ host.id if host else '/monitors/') }}" class="btn btn-sm btn-ghost">Cancel</a>
</div> </div>
</form> </form>
</div> </div>
+36 -1
View File
@@ -13,7 +13,42 @@ async def test_ansible_comes_before_the_docker_list(app, monkeypatch):
session["user_role"] = "operator" session["user_role"] = "operator"
page = await render_template( page = await render_template(
"hosts/detail.html", host=host, monitors=[], uptime=None, alerts=[], "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"') docker = page.index('hx-get="/plugins/docker/host/h1"')
assert page.index(">Ansible</h3>") < docker assert page.index(">Ansible</h3>") < docker
assert page.index('hx-get="/plugins/host_agent/panel/h1"') < 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 '<a href="/hosts/h1">box</a>' 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</summary>")
assert page.index("Full metrics →") < manage
for action in (">Update agent</button>", ">Rotate token</button>", ">Unregister agent</button>"):
assert page.index(action) > manage
assert ">Remove</button>" not in page
+44
View File
@@ -267,3 +267,47 @@ def test_bar_slots_end_at_the_bucket_now_falls_in():
assert width == 900 and len(slots) == 24 assert width == 900 and len(slots) == 24
assert slots[-1] == datetime(2026, 10, 7, 12, 0, tzinfo=timezone.utc) assert slots[-1] == datetime(2026, 10, 7, 12, 0, tzinfo=timezone.utc)
assert slots[0] == datetime(2026, 10, 7, 6, 15, 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("<details")
assert "+2 more running · 1 stopped" in page
assert page.index(">busy10<") < fold < page.index(">busy01<")
assert page.index(">dead<") < page.index(">busy10<")
assert len(re.findall(r"<th[ >]", page)) == 6 # one header row per table
assert not hex_colours(page), hex_colours(page)