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))),
"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)"),
+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:
"""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
+41 -23
View File
@@ -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>
+48 -43
View File
@@ -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 %}
-2
View File
@@ -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(),
)
+12 -2
View File
@@ -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,
+5 -45
View File
@@ -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 %}
+5 -1
View File
@@ -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>
+36 -1
View File
@@ -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
+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 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)