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
+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 %}