From fb20415a38fc33115993e1c240a2863ebdc7d82b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 18:22:48 -0400 Subject: [PATCH] feat(hosts): a host's health tiles span the card and the gaps match; Add host is a dialog on the list (#5290, #5291) #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 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 --- plugins/host_agent/routes.py | 2 +- .../host_agent/templates/_host_vitals.html | 2 +- .../host_agent/templates/settings_list.html | 4 +- steward/core/status.py | 17 +++++--- steward/hosts/routes.py | 17 ++++---- steward/static/steward.css | 17 ++++++++ steward/templates/hosts/detail.html | 6 +-- steward/templates/hosts/form.html | 29 +++++-------- steward/templates/hosts/list.html | 36 ++++++++++++++-- steward/templates/hosts/overview_widget.html | 2 +- tests/core/test_host_page.py | 43 ++++++++++++++++--- tests/core/test_status.py | 9 ++++ tests/plugins/host_agent/test_fleet_page.py | 4 +- 13 files changed, 136 insertions(+), 52 deletions(-) diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index 76ed6ce..03be356 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -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")) diff --git a/plugins/host_agent/templates/_host_vitals.html b/plugins/host_agent/templates/_host_vitals.html index 930fa36..0025a71 100644 --- a/plugins/host_agent/templates/_host_vitals.html +++ b/plugins/host_agent/templates/_host_vitals.html @@ -18,7 +18,7 @@ {%- if reg.agent_version %}{{ " · " if not stale }}agent v{{ reg.agent_version }}{% endif -%} -
+
{{ 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) }} diff --git a/plugins/host_agent/templates/settings_list.html b/plugins/host_agent/templates/settings_list.html index 459572d..27602f2 100644 --- a/plugins/host_agent/templates/settings_list.html +++ b/plugins/host_agent/templates/settings_list.html @@ -9,7 +9,7 @@

Bulk agent operations across your inventory. For a single host, use its host page: provisioning, a manual install command and - metrics live there. New hosts are added with Add host. + metrics live there. New hosts are added with Add host.

{% if new_token %} @@ -54,7 +54,7 @@ {% else %} - No agents registered yet. Add a host, then install its agent from the host's page. + No agents registered yet. Add a host, then install its agent from the host's page. {% endfor %} diff --git a/steward/core/status.py b/steward/core/status.py index 2586fcf..e204ed2 100644 --- a/steward/core/status.py +++ b/steward/core/status.py @@ -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'' + return f'' 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'' - f'' + f'' + f'' f'' ) diff --git a/steward/hosts/routes.py b/steward/hosts/routes.py index 2300d94..da1d8e0 100644 --- a/steward/hosts/routes.py +++ b/steward/hosts/routes.py @@ -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, diff --git a/steward/static/steward.css b/steward/static/steward.css index 4c92899..6df4788 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -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 ; 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%; } diff --git a/steward/templates/hosts/detail.html b/steward/templates/hosts/detail.html index 072438a..98bd7b1 100644 --- a/steward/templates/hosts/detail.html +++ b/steward/templates/hosts/detail.html @@ -51,10 +51,10 @@ hx-swap="innerHTML">
{# ── Monitors + Agent, side by side on wide screens, stacked when narrow ────── #} -
+
{# ── Monitors ─────────────────────────────────────────────────────────────── #} -
+

Monitors

{% if session.user_role in ['operator', 'admin'] %} @@ -108,7 +108,7 @@ {# ── Agent (host_agent management panel) ──────────────────────────────────── #}
-
Loading agent…
+
Loading agent…
{# Monitors + Agent grid #} diff --git a/steward/templates/hosts/form.html b/steward/templates/hosts/form.html index 8e6f0d1..e2fd3b1 100644 --- a/steward/templates/hosts/form.html +++ b/steward/templates/hosts/form.html @@ -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") }}
{% if error %}{% endif %} -
{{ csrf_field() }} + {{ csrf_field() }}
-
-

- {% 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.

- - Cancel + + Cancel
diff --git a/steward/templates/hosts/list.html b/steward/templates/hosts/list.html index 90cf06c..9e9d8a9 100644 --- a/steward/templates/hosts/list.html +++ b/steward/templates/hosts/list.html @@ -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 %}— {%- else %}{{ '%.0f'|format(v) }}%{% endif -%} {%- endmacro %} {% block title %}Hosts — Steward{% endblock %} {% block content %} +{% set can_add = session.user_role in ['operator', 'admin'] %}

Hosts

{% if session.user_role == 'admin' %} Agent fleet {% endif %} - Add host + {% if can_add %} + + {% endif %}
{% if hosts %} @@ -77,7 +81,33 @@
{% else %}
-

No hosts yet. Summon one to the table.

+

No hosts yet.{% if can_add %} Summon one to the table.{% endif %}

{% endif %} + +{% if can_add %} +{% set v = (add or {}).get('values') or {} %} + + + {% if add and add.error %}{% endif %} +
{{ csrf_field() }} +
+ + +
+
+ + +
+

Next, on the host's page: add monitors, install its agent, and link an Ansible target.

+
+ + +
+
+
+{% if add is not none %}{% endif %} +{% endif %} {% endblock %} diff --git a/steward/templates/hosts/overview_widget.html b/steward/templates/hosts/overview_widget.html index 5005f3e..1428827 100644 --- a/steward/templates/hosts/overview_widget.html +++ b/steward/templates/hosts/overview_widget.html @@ -5,7 +5,7 @@ {% from "_macros.html" import status_dot %} {% if not rows %}
- No hosts yet.{% if session.user_id %} Add one →{% endif %} + No hosts yet.{% if session.user_id %} Add one →{% endif %}
{% else %} {% set problems = rows | selectattr("state", "in", ["down", "crit", "stale", "warn"]) | list %} diff --git a/tests/core/test_host_page.py b/tests/core/test_host_page.py index ed33839..f503577 100644 --- a/tests/core/test_host_page.py +++ b/tests/core/test_host_page.py @@ -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 '
' in page assert page.index(">Ansible") < docker assert page.index('hx-get="/plugins/host_agent/panel/h1"') < docker # Monitors are added on their own form, filed under this host (#5277); @@ -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" 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'', closed, re.S).group(0) + assert 'action="/hosts/"' in dialog and ">Cancel" in dialog + assert re.search(r']*name="address"[^>]*>', dialog).group(0).count("required") == 0 + assert "showModal()\">Add host" in closed + assert "getElementById('add-host').showModal();" 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 "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']*name="address"[^>]*>', page).group(0).count("required") == 0 - assert 'Hosts' in page and ">Add host" in page + assert "getElementById('add-host').showModal();" 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 'nas' in page and ">Save" in page diff --git a/tests/core/test_status.py b/tests/core/test_status.py index 5a51a5d..3106d15 100644 --- a/tests/core/test_status.py +++ b/tests/core/test_status.py @@ -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 diff --git a/tests/plugins/host_agent/test_fleet_page.py b/tests/plugins/host_agent/test_fleet_page.py index 83438ac..c034394 100644 --- a/tests/plugins/host_agent/test_fleet_page.py +++ b/tests/plugins/host_agent/test_fleet_page.py @@ -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 'Silent' in page assert 'Never reported' in page assert 'Reporting' in page