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)
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:
@@ -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)"),
|
||||
|
||||
@@ -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<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:
|
||||
"""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: <workflow> · <job>", anything else
|
||||
by its own name."""
|
||||
Forgejo Actions job container as "CI: <workflow> · <job>", a buildx
|
||||
builder as "BuildKit builder · <name>", 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
|
||||
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
<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>
|
||||
<table class="table">
|
||||
<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>
|
||||
{% macro container_table(rows) %}
|
||||
<table class="table">
|
||||
<colgroup><col><col style="width:7rem"><col style="width:9rem"></colgroup>
|
||||
<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 %}
|
||||
{% set c = r.c %}
|
||||
<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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</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>
|
||||
|
||||
@@ -15,52 +15,57 @@
|
||||
|
||||
{% if reporting %}
|
||||
{# Reporting: live vitals are shown by the vitals strip above; this panel is
|
||||
lifecycle/management only. #}
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;">
|
||||
<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 %}
|
||||
the way to the full metrics, with the lifecycle actions folded under
|
||||
"Manage agent" (#5278). #}
|
||||
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
|
||||
|
||||
{% 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;">
|
||||
<summary style="cursor:pointer;font-size:0.8rem;color:var(--text-muted);">Re-provision (reinstall the steward account + managed key, then the agent)</summary>
|
||||
<p style="font-size:0.78rem;color:var(--text-dim);margin:0.5rem 0;">
|
||||
Use after regenerating the managed key, or if SSH auth as <code>steward</code> breaks.
|
||||
Connects over a one-time bootstrap user + password (not stored).
|
||||
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">Manage agent</summary>
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-top:0.6rem;">
|
||||
{% 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 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>
|
||||
<form method="post" action="/plugins/host_agent/provision"
|
||||
style="display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
|
||||
<input type="hidden" name="inventory_scope" value="{{ scope }}">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
{% if ansible_available and target and managed_key_set %}
|
||||
<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>
|
||||
<p style="font-size:0.78rem;color:var(--text-dim);margin:0.5rem 0;">
|
||||
Use after regenerating the managed key, or if SSH auth as <code>steward</code> breaks.
|
||||
Connects over a one-time bootstrap user + password (not stored).
|
||||
</p>
|
||||
<form method="post" action="/plugins/host_agent/provision"
|
||||
style="display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
|
||||
<input type="hidden" name="inventory_scope" value="{{ scope }}">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 @@
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<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>
|
||||
{% 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 %}
|
||||
<span style="font-size:0.8rem;color:var(--text-muted);">
|
||||
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>
|
||||
{% else %}{{ status_badge('up') }}{% endif %}
|
||||
</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>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<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>
|
||||
{% 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 #}
|
||||
|
||||
{# ── Agent (host_agent management panel) ──────────────────────────────────── #}
|
||||
@@ -249,6 +211,4 @@
|
||||
<div hx-get="/plugins/snmp/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"></div>
|
||||
{% endif %}
|
||||
|
||||
{{ toggle_script() }}
|
||||
<script>var _mt=document.getElementById('mtype'); if(_mt) mtoggle(_mt.value);</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
{% if monitor and monitor.host_id %}
|
||||
<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 %}
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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</h3>") < 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
|
||||
|
||||
@@ -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("<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)
|
||||
|
||||
Reference in New Issue
Block a user