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), # 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. # 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") 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 # 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. # 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")) 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 -%} {%- if reg.agent_version %}{{ " · " if not stale }}agent v{{ reg.agent_version }}{% endif -%}
</span> </span>
</div> </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("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("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) }} {{ 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;"> <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 Bulk agent operations across your inventory. For a single host, use its
<a href="/hosts/">host page</a>: provisioning, a manual install command and <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> </p>
{% if new_token %} {% if new_token %}
@@ -54,7 +54,7 @@
</td> </td>
</tr> </tr>
{% else %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </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, def sparkline_svg(values: list[float], width: int = 80, height: int = 20,
stroke: str = "#6060c0") -> str: stroke: str = "#6060c0", fill_width: bool = False) -> str:
"""Tiny inline SVG polyline for a response-time series.""" """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] 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: if len(vals) < 2:
return f'<svg width="{width}" height="{height}"></svg>' return f'<svg {size}></svg>'
mn, mx = min(vals), max(vals) mn, mx = min(vals), max(vals)
if mx == mn: if mx == mn:
mx = mn + 1.0 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 y = height - (v - mn) / (mx - mn) * (height - 2) - 1
pts.append(f"{x:.1f},{y:.1f}") pts.append(f"{x:.1f},{y:.1f}")
poly = " ".join(pts) poly = " ".join(pts)
style = "display:block;" if fill_width else "vertical-align:middle;"
keep = ' vector-effect="non-scaling-stroke"' if fill_width else ""
return ( return (
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" ' f'<svg {size} viewBox="0 0 {width} {height}" style="{style}">'
f'style="vertical-align:middle;">' f'<polyline points="{poly}" fill="none" stroke="{stroke}" stroke-width="1.5"{keep}/>'
f'<polyline points="{poly}" fill="none" stroke="{stroke}" stroke-width="1.5"/>'
f'</svg>' f'</svg>'
) )
+9 -8
View File
@@ -41,6 +41,13 @@ async def overview_widget():
@hosts_bp.get("/") @hosts_bp.get("/")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
async def list_hosts(): 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: async with current_app.db_sessionmaker() as db:
result = await db.execute(select(Host).order_by(Host.name)) result = await db.execute(select(Host).order_by(Host.name))
hosts = result.scalars().all() hosts = result.scalars().all()
@@ -57,6 +64,7 @@ async def list_hosts():
host_status=host_status, host_status=host_status,
uptime=uptime, uptime=uptime,
agent=agent, 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("/") @hosts_bp.post("/")
@require_role(UserRole.operator) @require_role(UserRole.operator)
async def create_host(): async def create_host():
@@ -112,8 +114,7 @@ async def create_host():
try: try:
await host_svc.check_name_free(db, host.name) await host_svc.check_name_free(db, host.name)
except ValueError as exc: except ValueError as exc:
return await render_template("hosts/form.html", host=None, values=form, return await _list_page(add={"values": form, "error": str(exc)}), 400
error=str(exc)), 400
db.add(host) db.add(host)
await log_audit(current_app, session.get("user_id"), session.get("username", ""), await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"host.created", entity_type="host", entity_id=host.name, "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). */ /* 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-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; } .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. */ /* 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 { height: 8px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.meter-fill { height: 100%; } .meter-fill { height: 100%; }
+3 -3
View File
@@ -51,10 +51,10 @@
hx-swap="innerHTML"></div> hx-swap="innerHTML"></div>
{# ── Monitors + Agent, side by side on wide screens, stacked when narrow ────── #} {# ── 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 ─────────────────────────────────────────────────────────────── #} {# ── 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;"> <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> <h3 class="section-title" style="margin-bottom:0;">Monitors</h3>
{% if session.user_role in ['operator', 'admin'] %} {% if session.user_role in ['operator', 'admin'] %}
@@ -108,7 +108,7 @@
{# ── Agent (host_agent management panel) ──────────────────────────────────── #} {# ── Agent (host_agent management panel) ──────────────────────────────────── #}
<div hx-get="/plugins/host_agent/panel/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML"> <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>
</div>{# Monitors + Agent grid #} </div>{# Monitors + Agent grid #}
+11 -18
View File
@@ -1,41 +1,34 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %} {% from "_macros.html" import crumbs, page_header %}
{# Add a host (host is none) or edit one; `values` holds what was typed when {# Edit a host; `values` holds what was typed when a clash sends the form back.
an error sends the form back. Laid out like the add-monitor form (#5283). #} Adding one is a dialog on the Hosts list (#5291). #}
{% block title %}{{ "Edit " ~ host.name if host else "Add host" }} — Steward{% endblock %} {% block title %}Edit {{ host.name }} — Steward{% endblock %}
{% block breadcrumb %} {% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Edit", "")]) }}{% endblock %}
{%- if host -%}
{{ crumbs([("Hosts", "/hosts/"), (host.name, "/hosts/" ~ host.id), ("Edit", "")]) }}
{%- else -%}
{{ crumbs([("Hosts", "/hosts/"), ("Add host", "")]) }}
{%- endif -%}
{% endblock %}
{% block content %} {% block content %}
{% set v = values or {} %} {% set v = values or {} %}
{{ page_header("Edit host" if host else "Add host") }} {{ page_header("Edit host") }}
<div class="card" style="max-width:760px;"> <div class="card" style="max-width:760px;">
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %} {% 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 style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.6rem;align-items:end;">
<div> <div>
<label class="field-label" for="h-name">Name <span class="tone-crit" aria-hidden="true">*</span></label> <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"> placeholder="nas, web-1, …" autocomplete="off" required autofocus class="input">
</div> </div>
<div> <div>
<label class="field-label" for="h-address">Address</label> <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"> placeholder="hostname or IP; empty resolves by name" autocomplete="off" class="input">
</div> </div>
</div> </div>
<p class="field-hint" style="margin:0;"> <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. 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 %}
</p> </p>
<div style="display:flex;gap:0.5rem;"> <div style="display:flex;gap:0.5rem;">
<button type="submit" class="btn btn-sm">{{ "Save" if host else "Add host" }}</button> <button type="submit" class="btn btn-sm">Save</button>
<a href="{{ '/hosts/' ~ host.id if host else '/hosts/' }}" class="btn btn-sm btn-ghost">Cancel</a> <a href="/hosts/{{ host.id }}" class="btn btn-sm btn-ghost">Cancel</a>
</div> </div>
</form> </form>
</div> </div>
+33 -3
View File
@@ -4,20 +4,24 @@
gone quiet, then monitors not yet checked, then the healthy. Status says 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 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 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) -%} {% macro pct(v, kind, stale) -%}
{%- if v is none %}<span class="tone-dim">—</span> {%- 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 -%} {%- else %}<span class="tone-{{ 'dim' if stale else status_class(v, kind) }}">{{ '%.0f'|format(v) }}%</span>{% endif -%}
{%- endmacro %} {%- endmacro %}
{% block title %}Hosts — Steward{% endblock %} {% block title %}Hosts — Steward{% endblock %}
{% block content %} {% 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;"> <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> <h1 class="page-title" style="margin-bottom:0;">Hosts</h1>
<div style="display:flex;gap:0.5rem;"> <div style="display:flex;gap:0.5rem;">
{% if session.user_role == 'admin' %} {% if session.user_role == 'admin' %}
<a class="btn btn-ghost" href="/plugins/host_agent/fleet/">Agent fleet</a> <a class="btn btn-ghost" href="/plugins/host_agent/fleet/">Agent fleet</a>
{% endif %} {% 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>
</div> </div>
{% if hosts %} {% if hosts %}
@@ -77,7 +81,33 @@
</div> </div>
{% else %} {% else %}
<div class="card"> <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> </div>
{% endif %} {% 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 %} {% endblock %}
+1 -1
View File
@@ -5,7 +5,7 @@
{% from "_macros.html" import status_dot %} {% from "_macros.html" import status_dot %}
{% if not rows %} {% if not rows %}
<div class="w-verdict" style="color:var(--text-muted);"> <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> </div>
{% else %} {% else %}
{% set problems = rows | selectattr("state", "in", ["down", "crit", "stale", "warn"]) | list %} {% 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=[], "hosts/detail.html", host=host, monitors=[], uptime=None, alerts=[],
linked_target=None, linkable_targets=[], ansible_sources=[]) linked_target=None, linkable_targets=[], ansible_sources=[])
docker = page.index('hx-get="/plugins/docker/host/h1"') 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(">Ansible</h3>") < docker
assert page.index('hx-get="/plugins/host_agent/panel/h1"') < docker assert page.index('hx-get="/plugins/host_agent/panel/h1"') < docker
# Monitors are added on their own form, filed under this host (#5277); # 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 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): async def test_add_host_is_a_dialog_on_the_hosts_list(app):
"""#5283: an inline error and the typed values, not a bare text page.""" """#5291: the form was two fields on a page of its own."""
async with app.test_request_context("/hosts/new"): async with app.test_request_context("/hosts/"):
session["user_role"] = "operator" session["user_role"] = "operator"
page = await render_template("hosts/form.html", host=None, error="A host named nas already exists.", closed = await render_template("hosts/list.html", hosts=[], host_status={}, uptime={}, agent={}, add=None)
values={"name": "nas", "address": "10.0.0.9"}) 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 'role="alert">A host named nas already exists.<' in page
assert 'value="nas"' in page and 'value="10.0.0.9"' 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 "getElementById('add-host').showModal();</script>" in page
assert '<a href="/hosts/">Hosts</a>' in page and ">Add host</span>" 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([1, 2, 3])
assert "polyline" in sparkline_svg([4, 4, 4]) # flat series, no div-by-zero 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 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, new_token=None, install_url=None, ansible_available=False,
deploy_targets=[], deploy_groups=[], managed_key_set=False) deploy_targets=[], deploy_groups=[], managed_key_set=False)
# Hosts are added in one place, Hosts → Add host (#5282). # Hosts are added in one place, the Add host dialog on Hosts (#5282, #5291).
assert "fleet/add-host" not in page and 'href="/hosts/new"' in page 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">Silent</span>' in page
assert '<span class="badge badge-warn">Never reported</span>' in page assert '<span class="badge badge-warn">Never reported</span>' in page
assert '<span class="badge badge-ok">Reporting</span>' in page assert '<span class="badge badge-ok">Reporting</span>' in page