feat(hosts): a host's health tiles span the card and the gaps match; Add host is a dialog on the list (#5290, #5291)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 54s
CI / unit (push) Successful in 1m18s
CI / publish (push) Successful in 1m23s

#5290 "Host page: the health tiles span the card's width; even spacing above Ansible":
the four tiles take equal quarters (halves on a phone) and their sparklines stretch
across them; the Monitors + Agent row drops the Agent card's own margin, so the gap
above Ansible is 1rem like every other.

#5291 "Add host opens as a dialog over the Hosts list": a native <dialog> on /hosts/,
opened by Add host or /hosts/?add=1. A name clash re-renders the list with it open,
the error and what was typed. GET /hosts/new is gone; hosts/form.html is Edit only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 18:22:48 -04:00
co-authored by Claude Opus 5.5
parent bfe3618e88
commit fb20415a38
13 changed files with 136 additions and 52 deletions
+1 -1
View File
@@ -690,7 +690,7 @@ async def host_vitals(host_id: str):
# At-a-glance disk = the root filesystem (what people actually care about),
# not the "worst" mount which is opaque. Worst stays on the full-metrics page.
disk_root = (latest.get(host.name + ":/", {}) or {}).get("disk_used_pct")
sparks = {k: sparkline_svg(v) for k, v in series_raw.items()}
sparks = {k: sparkline_svg(v, height=24, fill_width=True) for k, v in series_raw.items()}
# Normalize load by core count so it's comparable across hardware (load/core
# as %). Cores derived from the per-core CPU sub-resources already collected.
cores = sum(1 for k in latest if k.startswith(host.name + ":core"))
@@ -18,7 +18,7 @@
{%- if reg.agent_version %}{{ " · " if not stale }}agent v{{ reg.agent_version }}{% endif -%}
</span>
</div>
<div class="stat-strip" style="margin-bottom:0;">
<div class="stat-strip host-health-tiles" style="margin-bottom:0;">
{{ stat_tile("CPU", pct(cpu), state=status_class(cpu, 'cpu'), hint=sparks.cpu|safe) }}
{{ stat_tile("Memory", pct(mem), state=status_class(mem, 'mem'), hint=sparks.mem|safe) }}
{{ stat_tile("Disk /", pct(disk_root), state=status_class(disk_root, 'disk'), hint=sparks.disk|safe) }}
@@ -9,7 +9,7 @@
<p style="color:var(--text-muted);font-size:0.84rem;margin:-0.4rem 0 1.25rem;">
Bulk agent operations across your inventory. For a single host, use its
<a href="/hosts/">host page</a>: provisioning, a manual install command and
metrics live there. New hosts are added with <a href="/hosts/new">Add host</a>.
metrics live there. New hosts are added with <a href="/hosts/?add=1">Add host</a>.
</p>
{% if new_token %}
@@ -54,7 +54,7 @@
</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">No agents registered yet. <a href="/hosts/new">Add a host</a>, then install its agent from the host's page.</td></tr>
<tr><td colspan="7" class="empty">No agents registered yet. <a href="/hosts/?add=1">Add a host</a>, then install its agent from the host's page.</td></tr>
{% endfor %}
</tbody>
</table>
+11 -6
View File
@@ -77,11 +77,15 @@ async def collect_status(db) -> list[StatusEntry]:
def sparkline_svg(values: list[float], width: int = 80, height: int = 20,
stroke: str = "#6060c0") -> str:
"""Tiny inline SVG polyline for a response-time series."""
stroke: str = "#6060c0", fill_width: bool = False) -> str:
"""Tiny inline SVG polyline for a response-time series. With fill_width
the line stretches across its container (CSS sets the width) and keeps a
1.5px stroke."""
vals = [v for v in values if v is not None]
size = (f'width="100%" height="{height}" preserveAspectRatio="none"' if fill_width
else f'width="{width}" height="{height}"')
if len(vals) < 2:
return f'<svg width="{width}" height="{height}"></svg>'
return f'<svg {size}></svg>'
mn, mx = min(vals), max(vals)
if mx == mn:
mx = mn + 1.0
@@ -92,10 +96,11 @@ def sparkline_svg(values: list[float], width: int = 80, height: int = 20,
y = height - (v - mn) / (mx - mn) * (height - 2) - 1
pts.append(f"{x:.1f},{y:.1f}")
poly = " ".join(pts)
style = "display:block;" if fill_width else "vertical-align:middle;"
keep = ' vector-effect="non-scaling-stroke"' if fill_width else ""
return (
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
f'style="vertical-align:middle;">'
f'<polyline points="{poly}" fill="none" stroke="{stroke}" stroke-width="1.5"/>'
f'<svg {size} viewBox="0 0 {width} {height}" style="{style}">'
f'<polyline points="{poly}" fill="none" stroke="{stroke}" stroke-width="1.5"{keep}/>'
f'</svg>'
)
+9 -8
View File
@@ -41,6 +41,13 @@ async def overview_widget():
@hosts_bp.get("/")
@require_role(UserRole.viewer)
async def list_hosts():
# ?add=1 opens the Add host dialog on load (#5291).
return await _list_page(add={} if request.args.get("add") else None)
async def _list_page(add: dict | None = None):
"""The Hosts list. `add` opens the Add host dialog: {} for an empty form,
or {"values": form, "error": msg} when a submit comes back."""
async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all()
@@ -57,6 +64,7 @@ async def list_hosts():
host_status=host_status,
uptime=uptime,
agent=agent,
add=add,
)
@@ -93,12 +101,6 @@ async def host_detail(host_id: str):
)
@hosts_bp.get("/new")
@require_role(UserRole.operator)
async def new_host():
return await render_template("hosts/form.html", host=None)
@hosts_bp.post("/")
@require_role(UserRole.operator)
async def create_host():
@@ -112,8 +114,7 @@ async def create_host():
try:
await host_svc.check_name_free(db, host.name)
except ValueError as exc:
return await render_template("hosts/form.html", host=None, values=form,
error=str(exc)), 400
return await _list_page(add={"values": form, "error": str(exc)}), 400
db.add(host)
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"host.created", entity_type="host", entity_id=host.name,
+17
View File
@@ -299,6 +299,23 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
/* The host page's health header (_host_vitals.html). */
.host-health-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem 0.75rem; margin-bottom: 0.75rem; }
.host-health-summary { font-size: 0.92rem; }
/* Its four tiles share the card's width (two a row on a phone); each sparkline
stretches across its tile. */
.host-health-tiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.host-health-tiles .stat-tile { min-width: 0; }
.host-health-tiles .stat-tile-hint { margin-top: 0.25rem; }
@media (max-width: 640px) { .host-health-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* Monitors and Agent side by side: equal height, and the row keeps the same
1rem gap below it as every other card (the panel's own card margin would add
a second). */
.host-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 1rem; margin-bottom: 1rem; }
.host-pair > * { min-width: 0; }
.host-pair .card { margin-bottom: 0; height: 100%; }
/* A small form over the page (native <dialog>; Esc closes it). */
.modal { width: min(560px, calc(100vw - 2rem)); padding: 1.25rem; color: var(--text); background: var(--bg-card);
border: 1px solid var(--border); border-radius: var(--fs-radius-md); box-shadow: var(--fs-shadow-3); }
.modal::backdrop { background: var(--fs-overlay); }
.modal-title { font-family: var(--font-serif); font-size: 1.25rem; font-weight: 500; margin: 0 0 1rem; }
/* A filled bar for a percentage; the fill takes its grade from status_class. */
.meter { height: 8px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.meter-fill { height: 100%; }
+3 -3
View File
@@ -51,10 +51,10 @@
hx-swap="innerHTML"></div>
{# ── Monitors + Agent, side by side on wide screens, stacked when narrow ────── #}
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:1rem;align-items:start;margin-bottom:1rem;">
<div class="host-pair">
{# ── Monitors ─────────────────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:0;">
<div class="card">
<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'] %}
@@ -108,7 +108,7 @@
{# ── Agent (host_agent management panel) ──────────────────────────────────── #}
<div hx-get="/plugins/host_agent/panel/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML">
<div class="card" style="margin-bottom:0;"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></div>
<div class="card"><span style="color:var(--text-muted);font-size:0.85rem;">Loading agent…</span></div>
</div>
</div>{# Monitors + Agent grid #}
+11 -18
View File
@@ -1,41 +1,34 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{# Add a host (host is none) or edit one; `values` holds what was typed when
an error sends the form back. Laid out like the add-monitor form (#5283). #}
{% block title %}{{ "Edit " ~ host.name if host else "Add host" }} — Steward{% endblock %}
{% block breadcrumb %}
{%- if host -%}
{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Edit", "")]) }}
{%- else -%}
{{ crumbs([("Hosts", "/hosts/"), ("Add host", "")]) }}
{%- endif -%}
{% endblock %}
{# Edit a host; `values` holds what was typed when a clash sends the form back.
Adding one is a dialog on the Hosts list (#5291). #}
{% block title %}Edit {{ host.name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Edit", "")]) }}{% endblock %}
{% block content %}
{% set v = values or {} %}
{{ page_header("Edit host" if host else "Add host") }}
{{ page_header("Edit host") }}
<div class="card" style="max-width:760px;">
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<form method="post" action="{{ '/hosts/' ~ host.id if host else '/hosts/' }}" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
<form method="post" action="/hosts/{{ host.id }}" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.6rem;align-items:end;">
<div>
<label class="field-label" for="h-name">Name <span class="tone-crit" aria-hidden="true">*</span></label>
<input type="text" id="h-name" name="name" value="{{ v.name if v.name is defined else (host.name if host else '') }}"
<input type="text" id="h-name" name="name" value="{{ v.name if v.name is defined else host.name }}"
placeholder="nas, web-1, …" autocomplete="off" required autofocus class="input">
</div>
<div>
<label class="field-label" for="h-address">Address</label>
<input type="text" id="h-address" name="address" value="{{ v.address if v.address is defined else (host.address if host else '') }}"
<input type="text" id="h-address" name="address" value="{{ v.address if v.address is defined else host.address }}"
placeholder="hostname or IP; empty resolves by name" autocomplete="off" class="input">
</div>
</div>
<p class="field-hint" style="margin:0;">
{% if host %}Renaming carries the host's agent and container history, and the alert rules on it, to the new name.
{% else %}Next, on the host's page: add monitors, install its agent, and link an Ansible target.{% endif %}
Renaming carries the host's agent and container history, and the alert rules on it, to the new name.
</p>
<div style="display:flex;gap:0.5rem;">
<button type="submit" class="btn btn-sm">{{ "Save" if host else "Add host" }}</button>
<a href="{{ '/hosts/' ~ host.id if host else '/hosts/' }}" class="btn btn-sm btn-ghost">Cancel</a>
<button type="submit" class="btn btn-sm">Save</button>
<a href="/hosts/{{ host.id }}" class="btn btn-sm btn-ghost">Cancel</a>
</div>
</form>
</div>
+33 -3
View File
@@ -4,20 +4,24 @@
gone quiet, then monitors not yet checked, then the healthy. Status says
what is wrong and for how long; editing and deleting a host happen on its
own page (#5281). Uptime is one figure, 30 days, with the shorter windows
on hover: Monitors is where uptime is read in full (#5280). #}
on hover: Monitors is where uptime is read in full (#5280). Add host is a
dialog over this list (#5291); `add` opens it, carrying a clash's error. #}
{% macro pct(v, kind, stale) -%}
{%- if v is none %}<span class="tone-dim">—</span>
{%- else %}<span class="tone-{{ 'dim' if stale else status_class(v, kind) }}">{{ '%.0f'|format(v) }}%</span>{% endif -%}
{%- endmacro %}
{% block title %}Hosts — Steward{% endblock %}
{% block content %}
{% set can_add = session.user_role in ['operator', 'admin'] %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Hosts</h1>
<div style="display:flex;gap:0.5rem;">
{% if session.user_role == 'admin' %}
<a class="btn btn-ghost" href="/plugins/host_agent/fleet/">Agent fleet</a>
{% endif %}
<a class="btn" href="/hosts/new">Add host</a>
{% if can_add %}
<button type="button" class="btn" onclick="document.getElementById('add-host').showModal()">Add host</button>
{% endif %}
</div>
</div>
{% if hosts %}
@@ -77,7 +81,33 @@
</div>
{% else %}
<div class="card">
<p style="color:var(--text-muted);">No hosts yet. <a href="/hosts/new">Summon one to the table.</a></p>
<p style="color:var(--text-muted);">No hosts yet.{% if can_add %} <a href="/hosts/?add=1" onclick="document.getElementById('add-host').showModal();return false;">Summon one to the table.</a>{% endif %}</p>
</div>
{% endif %}
{% if can_add %}
{% set v = (add or {}).get('values') or {} %}
<dialog id="add-host" class="modal" aria-labelledby="add-host-title">
<h2 id="add-host-title" class="modal-title">Add host</h2>
{% if add and add.error %}<p class="field-error" role="alert">{{ add.error }}</p>{% endif %}
<form method="post" action="/hosts/" style="display:grid;gap:0.75rem;">{{ csrf_field() }}
<div>
<label class="field-label" for="h-name">Name <span class="tone-crit" aria-hidden="true">*</span></label>
<input type="text" id="h-name" name="name" value="{{ v.name or '' }}"
placeholder="nas, web-1, …" autocomplete="off" required autofocus class="input">
</div>
<div>
<label class="field-label" for="h-address">Address</label>
<input type="text" id="h-address" name="address" value="{{ v.address or '' }}"
placeholder="hostname or IP; empty resolves by name" autocomplete="off" class="input">
</div>
<p class="field-hint" style="margin:0;">Next, on the host's page: add monitors, install its agent, and link an Ansible target.</p>
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
<button type="button" class="btn btn-sm btn-ghost" onclick="this.closest('dialog').close()">Cancel</button>
<button type="submit" class="btn btn-sm">Add host</button>
</div>
</form>
</dialog>
{% if add is not none %}<script>document.getElementById('add-host').showModal();</script>{% endif %}
{% endif %}
{% endblock %}
+1 -1
View File
@@ -5,7 +5,7 @@
{% from "_macros.html" import status_dot %}
{% if not rows %}
<div class="w-verdict" style="color:var(--text-muted);">
No hosts yet.{% if session.user_id %} <a href="/hosts/new">Add one →</a>{% endif %}
No hosts yet.{% if session.user_id %} <a href="/hosts/?add=1">Add one →</a>{% endif %}
</div>
{% else %}
{% set problems = rows | selectattr("state", "in", ["down", "crit", "stale", "warn"]) | list %}
+36 -7
View File
@@ -16,6 +16,8 @@ async def test_ansible_comes_before_the_docker_list(app, monkeypatch):
"hosts/detail.html", host=host, monitors=[], uptime=None, alerts=[],
linked_target=None, linkable_targets=[], ansible_sources=[])
docker = page.index('hx-get="/plugins/docker/host/h1"')
# #5290: Monitors and Agent share a row whose cards drop their own margin.
assert '<div class="host-pair">' in page
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);
@@ -91,13 +93,40 @@ async def test_a_host_without_an_agent_can_get_a_manual_install(app):
assert 'action="/plugins/host_agent/fleet/h1/register"' in page and ">Install manually</button>" in page
async def test_add_host_sends_a_clash_back_with_what_was_typed(app):
"""#5283: an inline error and the typed values, not a bare text page."""
async with app.test_request_context("/hosts/new"):
async def test_add_host_is_a_dialog_on_the_hosts_list(app):
"""#5291: the form was two fields on a page of its own."""
async with app.test_request_context("/hosts/"):
session["user_role"] = "operator"
page = await render_template("hosts/form.html", host=None, error="A host named nas already exists.",
values={"name": "nas", "address": "10.0.0.9"})
closed = await render_template("hosts/list.html", hosts=[], host_status={}, uptime={}, agent={}, add=None)
dialog = re.search(r'<dialog id="add-host".*?</dialog>', closed, re.S).group(0)
assert 'action="/hosts/"' in dialog and ">Cancel</button>" in dialog
assert re.search(r'<input[^>]*name="address"[^>]*>', dialog).group(0).count("required") == 0
assert "showModal()\">Add host</button>" in closed
assert "getElementById('add-host').showModal();</script>" not in closed
assert "/hosts/new" not in closed
async with app.test_request_context("/hosts/"):
session["user_role"] = "viewer"
viewer = await render_template("hosts/list.html", hosts=[], host_status={}, uptime={}, agent={}, add={})
assert "<dialog" not in viewer and ">Add host<" not in viewer
async def test_add_host_sends_a_clash_back_with_what_was_typed(app):
"""#5283, #5291: the list again, the dialog open, an inline error and the typed values."""
async with app.test_request_context("/hosts/"):
session["user_role"] = "operator"
page = await render_template(
"hosts/list.html", hosts=[], host_status={}, uptime={}, agent={},
add={"values": {"name": "nas", "address": "10.0.0.9"}, "error": "A host named nas already exists."})
assert 'role="alert">A host named nas already exists.<' in page
assert 'value="nas"' in page and 'value="10.0.0.9"' in page
assert re.search(r'<input[^>]*name="address"[^>]*>', page).group(0).count("required") == 0
assert '<a href="/hosts/">Hosts</a>' in page and ">Add host</span>" in page
assert "getElementById('add-host').showModal();</script>" in page
async def test_edit_host_keeps_its_page(app):
host = SimpleNamespace(id="h1", name="nas", address="10.0.0.5")
async with app.test_request_context("/hosts/h1/edit"):
session["user_role"] = "operator"
page = await render_template("hosts/form.html", host=host)
assert 'action="/hosts/h1"' in page and 'value="nas"' in page and 'value="10.0.0.5"' in page
assert '<a href="/hosts/h1">nas</a>' in page and ">Save</button>" in page
+9
View File
@@ -69,3 +69,12 @@ def test_sparkline_svg_handles_edge_cases():
assert "polyline" in sparkline_svg([1, 2, 3])
assert "polyline" in sparkline_svg([4, 4, 4]) # flat series, no div-by-zero
assert "polyline" in sparkline_svg([1, None, 3]) # None values filtered
def test_a_sparkline_can_stretch_across_its_tile():
"""#5290: the host page's tiles are wide; the line fills them, stroke kept."""
wide = sparkline_svg([1, 2, 3], height=24, fill_width=True)
assert 'width="100%"' in wide and 'preserveAspectRatio="none"' in wide
assert 'vector-effect="non-scaling-stroke"' in wide
fixed = sparkline_svg([1, 2, 3])
assert 'width="80"' in fixed and "preserveAspectRatio" not in fixed
+2 -2
View File
@@ -31,8 +31,8 @@ async def test_silent_agents_come_first_and_the_table_leads_the_page(app):
new_token=None, install_url=None, ansible_available=False,
deploy_targets=[], deploy_groups=[], managed_key_set=False)
# Hosts are added in one place, Hosts → Add host (#5282).
assert "fleet/add-host" not in page and 'href="/hosts/new"' in page
# Hosts are added in one place, the Add host dialog on Hosts (#5282, #5291).
assert "fleet/add-host" not in page and 'href="/hosts/?add=1"' in page
assert '<span class="badge badge-warn">Silent</span>' in page
assert '<span class="badge badge-warn">Never reported</span>' in page
assert '<span class="badge badge-ok">Reporting</span>' in page