From bfe3618e880b481efb8499a0169fcb6d5dad7ba2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 17:12:17 -0400 Subject: [PATCH] =?UTF-8?q?feat(hosts):=20the=20Hosts=20list=20labels=20it?= =?UTF-8?q?s=20figures=20and=20says=20how=20long;=20SLA=20tab=20gone;=20on?= =?UTF-8?q?e=20place=20to=20add=20a=20host=20(#5279=E2=80=93#5283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosts list: - CPU and Memory are their own labelled columns (#5279); a silent agent's last readings are greyed, and Status says "agent silent for 7m". - One uptime figure, 30 days, with 24h and 7d on hover. The SLA tab (/hosts/uptime) repeated it and is removed (#5280; one home per fact). - Status is a word with how long ("Down for 12m"); host_status now carries down_since. Edit and Delete live on the host's page only (#5281). - Figures are right-aligned with tabular digits (.table .num), not a monospace face. Agent fleet (#5282): its add-host form is gone, so hosts are added only on Hosts -> Add host. That form was also the only way to get a token for a manual curl install, so that moves to the host's Agent card as "Install manually" (POST /fleet//register), shown on the fleet page as before. "Delete" there reads "Unregister". Add host (#5283): laid out like the add-monitor form; a name clash comes back inline with what was typed instead of a bare text 400; the address is optional, as the host page already allowed. Co-Authored-By: Claude Opus 5.5 --- plugins/host_agent/routes.py | 27 ++--- plugins/host_agent/templates/panel.html | 13 ++- .../host_agent/templates/settings_list.html | 24 +---- steward/hosts/routes.py | 16 +-- steward/hosts/service.py | 31 +++--- steward/static/steward.css | 4 + steward/templates/hosts/form.html | 60 +++++++----- steward/templates/hosts/list.html | 92 ++++++++--------- steward/templates/hosts/uptime.html | 98 ------------------- tests/core/test_host_page.py | 49 ++++++++++ tests/core/test_problems_first.py | 9 -- tests/integration/test_monitors.py | 2 + tests/plugins/host_agent/test_fleet_page.py | 5 +- 13 files changed, 178 insertions(+), 252 deletions(-) delete mode 100644 steward/templates/hosts/uptime.html diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index 4448dcc..76ed6ce 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -796,35 +796,24 @@ async def fleet(): ) -@host_agent_bp.post("/fleet/add-host") +@host_agent_bp.post("/fleet//register") @require_role(UserRole.admin) -async def add_host(): - form = await request.form - name = (form.get("name") or "").strip() - address = (form.get("address") or "").strip() - if not name: - return _error(400, "missing_name") - +async def register_host(host_id: str): + """Issue an agent token for an existing host, for a manual install: the + fleet page then shows the one-line install command, once. Hosts are added + on Hosts → Add host; this is only the agent half (#5282).""" async with current_app.db_sessionmaker() as session: - host = (await session.execute( - select(Host).where(Host.name == name))).scalar_one_or_none() + host = await session.get(Host, host_id) if host is None: - host = Host(name=name, address=address) - session.add(host) - await session.flush() + return _error(404, "not_found") existing = (await session.execute( select(HostAgentRegistration).where( HostAgentRegistration.host_id == host.id))).scalar_one_or_none() if existing is not None: - await session.rollback() return _error(400, "already_registered") - raw, hashed = _new_token_pair() - reg = HostAgentRegistration(host_id=host.id, token_hash=hashed) - session.add(reg) + session.add(HostAgentRegistration(host_id=host.id, token_hash=hashed)) await session.commit() - host_id = host.id - return redirect(url_for("host_agent.fleet") + f"?new_token={raw}&host_id={host_id}") diff --git a/plugins/host_agent/templates/panel.html b/plugins/host_agent/templates/panel.html index 7b81d5f..150fc6c 100644 --- a/plugins/host_agent/templates/panel.html +++ b/plugins/host_agent/templates/panel.html @@ -91,8 +91,7 @@ {% elif not ansible_available %}

- Ansible isn't available, so the agent can't be deployed from here. Install it manually with the - curl install command. + Ansible isn't available, so the agent can't be deployed from here. Install it manually instead (below).

{% elif not managed_key_set %} @@ -141,6 +140,16 @@ {% endif %} + {% if not reg and session.user_role == 'admin' %} + {# A manual install: no Ansible, no SSH. Issues this host's token and shows + the one-line install command on the fleet page, once (#5282). #} +
{{ csrf_field() }} + Or by hand on the host: + +
+ {% endif %} + {% if reg and session.user_role == 'admin' %}
{{ csrf_field() }} diff --git a/plugins/host_agent/templates/settings_list.html b/plugins/host_agent/templates/settings_list.html index 0fcf308..459572d 100644 --- a/plugins/host_agent/templates/settings_list.html +++ b/plugins/host_agent/templates/settings_list.html @@ -8,7 +8,8 @@

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

{% if new_token %} @@ -47,34 +48,19 @@
{{ csrf_field() }} - + data-confirm="Unregister the agent for {{ item.name }}? Its token stops working and metrics stop until it is registered again. The agent itself stays installed on the host.">{{ csrf_field() }} +
{% else %} - No hosts registered. Add one below. + No agents registered yet. Add a host, then install its agent from the host's page. {% endfor %} -
-
{{ csrf_field() }} -
- - -
-
- - -
- -
-
- {% if ansible_available %} {% set scope_select %} diff --git a/steward/hosts/routes.py b/steward/hosts/routes.py index 22990a5..2300d94 100644 --- a/steward/hosts/routes.py +++ b/steward/hosts/routes.py @@ -112,7 +112,8 @@ async def create_host(): try: await host_svc.check_name_free(db, host.name) except ValueError as exc: - return str(exc), 400 + return await render_template("hosts/form.html", host=None, 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, @@ -150,7 +151,8 @@ async def update_host(host_id: str): try: await host_svc.check_name_free(db, name, exclude_id=host.id) except ValueError as exc: - return str(exc), 400 + return await render_template("hosts/form.html", host=host, values=form, + error=str(exc)), 400 host.name = name host.address = form["address"].strip() # Agent and container metrics, and the rules and windows on them, @@ -296,13 +298,3 @@ async def delete_host(host_id: str): await log_audit(current_app, session.get("user_id"), session.get("username", ""), "host.deleted", entity_type="host", entity_id=host_name) return redirect(url_for("hosts.list_hosts")) - - -@hosts_bp.get("/uptime") -@require_role(UserRole.viewer) -async def uptime_page(): - async with current_app.db_sessionmaker() as db: - result = await db.execute(select(Host).order_by(Host.name)) - uptime = await host_svc.compute_uptime(db) - hosts = host_svc.worst_uptime_first(result.scalars().all(), uptime) - return await render_template("hosts/uptime.html", hosts=hosts, uptime=uptime) diff --git a/steward/hosts/service.py b/steward/hosts/service.py index 46fb4bc..e28f650 100644 --- a/steward/hosts/service.py +++ b/steward/hosts/service.py @@ -88,8 +88,10 @@ async def host_status(db, host_ids: list[str]) -> dict[str, dict]: """Per-host status rollup from the latest result of each linked monitor. {host_id: {"state": "up"|"down"|"pending", "latency_ms": float|None, - "count": int}}. State is down if ANY linked monitor's latest - result is down (worst-case), up if at least one is up, else pending. + "count": int, "down_since": datetime|None}}. State is down if + ANY linked monitor's latest result is down (worst-case), up if at least + one is up, else pending. down_since is when the longest-down of its down + monitors went down. """ if not host_ids: return {} @@ -98,32 +100,29 @@ async def host_status(db, host_ids: list[str]) -> dict[str, dict]: .where(Monitor.host_id.in_(host_ids), Monitor.enabled.is_(True)) )).all() latest = await _last_n_results(db, [m.id for m in monitors], n=1) - rows = [(latest[mid][-1], host_id) for mid, host_id in monitors if latest.get(mid)] + rows = [(latest[mid][-1], host_id, mid) for mid, host_id in monitors if latest.get(mid)] out: dict[str, dict] = {} - for res, host_id in rows: - d = out.setdefault(host_id, {"state": "pending", "latency_ms": None, "count": 0}) + down: dict[str, list[str]] = {} + for res, host_id, mid in rows: + d = out.setdefault(host_id, {"state": "pending", "latency_ms": None, "count": 0, + "down_since": None}) d["count"] += 1 if not res.is_up: d["state"] = "down" + down.setdefault(host_id, []).append(mid) elif d["state"] != "down": d["state"] = "up" if res.is_up and res.response_ms is not None: d["latency_ms"] = (res.response_ms if d["latency_ms"] is None else min(d["latency_ms"], res.response_ms)) + if down: + from steward.monitors.service import down_since + for host_id, mids in down.items(): + starts = [t for t in [await down_since(db, mid) for mid in mids] if t] + out[host_id]["down_since"] = min(starts) if starts else None return out -def worst_uptime_first(hosts, uptime: dict) -> list: - """Hosts by their 24h uptime, worst first, then 7d and 30d to break ties; - hosts with no uptime yet (no monitors) last, by name.""" - def key(h): - u = uptime.get(h.id) or {} - windows = [u.get(w) for w in ("24h", "7d", "30d")] - known = [w for w in windows if w is not None] - return (not known, *[101.0 if w is None else w for w in windows], h.name.lower()) - return sorted(hosts, key=key) - - def host_rank(status: dict | None, agent: dict | None) -> int: """Where a host sorts in the Hosts list: a down monitor first, then an agent gone quiet, then monitors not yet checked, then the healthy; a host diff --git a/steward/static/steward.css b/steward/static/steward.css index 93dff41..4c92899 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -250,6 +250,10 @@ textarea { resize: vertical; } .tone-crit { color: var(--red); } .tone-info { color: var(--blue); } .tone-dim, .tone-unknown { color: var(--text-dim); } +.tone-muted { color: var(--text-muted); } +/* A figure column in a table: right-aligned, digits of equal width so the + numbers line up down the column without a monospace face (#5279). */ +.table th.num, .table td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } /* Empty state */ diff --git a/steward/templates/hosts/form.html b/steward/templates/hosts/form.html index f4c7788..8e6f0d1 100644 --- a/steward/templates/hosts/form.html +++ b/steward/templates/hosts/form.html @@ -1,30 +1,42 @@ {% extends "base.html" %} -{% from "_macros.html" import crumbs %} -{% block title %}{% if host %}Edit Host{% else %}New Host{% endif %} — Steward{% endblock %} -{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (("Edit " ~ host.name) if host else "New host", "")]) }}{% endblock %} +{% 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 %} {% block content %} -
-

{% if host %}Edit Host{% else %}Add Host{% endif %}

-
-
{{ csrf_field() }} -
- - -
-
- - -
-
- - Cancel -
-
+{% set v = values or {} %} +{{ page_header("Edit host" if host else "Add host") }} + +
+ {% if error %}{% endif %} +
{{ csrf_field() }} +
+
+ + +
+
+ + +
-

- {% if host %}Monitors, agent, Ansible target, and playbook runs live on the - host page.{% else %} - After adding, attach ping / DNS / HTTP monitors from the host page.{% endif %} +

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

+
+ + Cancel +
+
{% endblock %} diff --git a/steward/templates/hosts/list.html b/steward/templates/hosts/list.html index b7bb98f..90cf06c 100644 --- a/steward/templates/hosts/list.html +++ b/steward/templates/hosts/list.html @@ -1,18 +1,23 @@ {% extends "base.html" %} -{% from "_macros.html" import status_badge, status_dot %} +{% from "_macros.html" import status_badge %} {# Hosts, problems first (host_svc.host_rank): a down monitor, then an agent - gone quiet, then monitors not yet checked, then the healthy. #} -{% macro pct(v) %}{% if v is none %}—{% else %}{{ '%.0f'|format(v) }}%{% endif %}{% endmacro %} + 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). #} +{% macro pct(v, kind, stale) -%} +{%- if v is none %}— +{%- else %}{{ '%.0f'|format(v) }}%{% endif -%} +{%- endmacro %} {% block title %}Hosts — Steward{% endblock %} {% block content %}

Hosts

- SLA {% if session.user_role == 'admin' %} Agent fleet {% endif %} - Add Host + Add host
{% if hosts %} @@ -22,64 +27,49 @@ Name Address - Status - Latency - Monitors - 24h - 7d - 30d - Agent - + Status + Latency + Monitors + 30d uptime + CPU + Memory {% for host in hosts %} {% set hs = host_status.get(host.id) %} {% set ut = uptime.get(host.id, {}) %} + {% set am = agent.get(host.name) %} + {% set silent = am and not am.fresh %} {{ host.name }} - {{ host.address }} - - {% if not hs %} - — - {% else %}{{ status_dot(hs.state) }}{% endif %} + {{ host.address or "—" }} + + {% if hs %} + {{ status_badge(hs.state) }} + {%- if hs.state == 'down' and hs.down_since %} for {{ hs.down_since|ago|striptags|replace(" ago", "") }}{% endif %} + {% else %} + — + {% endif %} + {% if silent %} +
+ {{ status_badge('stale', label="agent silent", title="No agent report since " ~ (am.last_at.strftime('%Y-%m-%d %H:%M UTC') if am.last_at else 'it started')) }} + {%- if am.last_at %} for {{ am.last_at|ago|striptags|replace(" ago", "") }}{% endif %} +
+ {% endif %} - + {% if hs and hs.latency_ms is not none %} - {{ hs.latency_ms | round(1) }} ms - {% else %} - — - {% endif %} + {{ hs.latency_ms | round(1) }} ms + {% else %}—{% endif %} - - {{ hs.count if hs else 0 }} - - {% for window in ["24h", "7d", "30d"] %} - {% set pct = ut.get(window) %} - - {% if pct is none %} - — - {% else %} - {{ "%.1f"|format(pct) }}% - {% endif %} - - {% endfor %} - {% set am = agent.get(host.name) %} - - {% if am %} - {{ pct(am.cpu_pct) }} / {{ pct(am.mem_used_pct) }} - {% if not am.fresh %}
{{ status_badge('stale', title="No agent report since " ~ (am.last_at.strftime('%Y-%m-%d %H:%M UTC') if am.last_at else 'it started')) }} {{ am.last_at|ago }}
{% endif %} - {% else %} - — - {% endif %} - - - Edit -
{{ csrf_field() }} - -
+ {{ hs.count if hs else "—" }} + + {% if ut.get('30d') is none %}— + {% else %}{{ "%.2f"|format(ut['30d']) }}%{% endif %} + {% if am %}{{ pct(am.cpu_pct, 'cpu', silent) }}{% else %}—{% endif %} + {% if am %}{{ pct(am.mem_used_pct, 'mem', silent) }}{% else %}—{% endif %} {% endfor %} diff --git a/steward/templates/hosts/uptime.html b/steward/templates/hosts/uptime.html deleted file mode 100644 index ff0d0ac..0000000 --- a/steward/templates/hosts/uptime.html +++ /dev/null @@ -1,98 +0,0 @@ -{% extends "base.html" %} -{% from "_macros.html" import crumbs %} -{% block title %}Uptime / SLA — Steward{% endblock %} -{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("Uptime / SLA", "")]) }}{% endblock %} -{% block content %} -
-

Uptime / SLA

-
- -{# ── Summary pills ──────────────────────────────────────────────────────────── #} -{% set total = hosts | length %} -{% if total %} -{% set healthy = namespace(count=0) %} -{% for h in hosts %} - {% set pct = uptime.get(h.id, {}).get("30d") %} - {% if pct is not none and pct >= 99 %}{% set healthy.count = healthy.count + 1 %}{% endif %} -{% endfor %} - -
-
-
Hosts monitored
- {{ total }} -
-
-
≥ 99% (30d)
- - {{ healthy.count }} - -
-
-{% endif %} - -{# ── SLA table ──────────────────────────────────────────────────────────────── #} -{% if hosts %} -
- - - - - - - - - - - - - {% for host in hosts %} - {% set ut = uptime.get(host.id, {}) %} - - - - - {% for window in ["24h", "7d", "30d"] %} - {% set pct = ut.get(window) %} - - {% endfor %} - - {# 30-day visual bar #} - {% set pct30 = ut.get("30d") %} - - - {% endfor %} - -
HostAddress24 h7 d30 d30-day bar
{{ host.name }}{{ host.address }} - {% if pct is none %} - — - {% elif pct >= 99.9 %} - {{ "%.2f"|format(pct) }}% - {% elif pct >= 99 %} - {{ "%.2f"|format(pct) }}% - {% elif pct >= 95 %} - {{ "%.2f"|format(pct) }}% - {% else %} - {{ "%.2f"|format(pct) }}% - {% endif %} - - {% if pct30 is none %} - no data - {% else %} -
-
-
-
-
- {% endif %} -
-
-{% else %} -
-

No hosts under watch. Summon one to the table.

-
-{% endif %} - -

- Uptime aggregates every monitor attached to a host. Hosts with no monitors show —. -

-{% endblock %} diff --git a/tests/core/test_host_page.py b/tests/core/test_host_page.py index 40c79fd..ed33839 100644 --- a/tests/core/test_host_page.py +++ b/tests/core/test_host_page.py @@ -1,4 +1,5 @@ """The host page: what sits where (#5214).""" +import re from types import SimpleNamespace from quart import render_template, session @@ -52,3 +53,51 @@ async def test_the_agent_card_keeps_its_actions_under_manage(app): for action in (">Update agent", ">Rotate token", ">Unregister agent"): assert page.index(action) > manage assert ">Remove" not in page + + +async def test_the_hosts_list_says_what_is_wrong_and_labels_its_figures(app): + """#5279–#5281: labelled CPU and Memory, one uptime figure, status with how + long, no SLA tab and no per-row edit/delete.""" + from datetime import datetime, timedelta, timezone + now = datetime.now(timezone.utc) + down = SimpleNamespace(id="h1", name="nas", address="10.0.0.5") + quiet = SimpleNamespace(id="h2", name="pi", address="") + async with app.test_request_context("/hosts/"): + session["user_role"] = "admin" + page = await render_template( + "hosts/list.html", hosts=[down, quiet], + host_status={"h1": {"state": "down", "latency_ms": None, "count": 2, + "down_since": now - timedelta(minutes=12)}}, + uptime={"h1": {"24h": 91.5, "7d": 98.0, "30d": 99.42}}, + agent={"pi": {"fresh": False, "last_at": now - timedelta(minutes=7), + "cpu_pct": 3.0, "mem_used_pct": 41.0}}) + assert re.findall(r"]*>([^<]+)", page) == [ + "Name", "Address", "Status", "Latency", "Monitors", "30d uptime", "CPU", "Memory"] + assert "for 12m" in page and "agent silent" in page and "for 7m" in page + assert "99.42%" in page and 'title="24h 91.50% · 7d 98.00%"' in page + assert ">41%<" in page and "tone-dim" in page # a silent agent's last reading, greyed + assert "/hosts/uptime" not in page and ">Delete<" not in page and ">Edit<" not in page + + +async def test_a_host_without_an_agent_can_get_a_manual_install(app): + """#5282: the manual install moved here from the fleet's add-host form.""" + from tests._templates import render_plugin_template + host = SimpleNamespace(id="h1", name="box", address="10.0.0.5") + 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=None, reporting=False, stale=False, + target=None, ansible_available=False, managed_key_set=False) + 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"): + 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"}) + 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 diff --git a/tests/core/test_problems_first.py b/tests/core/test_problems_first.py index 0132b6a..a3c5389 100644 --- a/tests/core/test_problems_first.py +++ b/tests/core/test_problems_first.py @@ -44,12 +44,3 @@ def test_a_host_page_shows_only_the_alerts_on_that_host(): assert not watches_host(rule("docker", "nas2/plex"), "nas", mine) assert watches_host(rule("http", "nas web"), "nas", mine) assert not watches_host(rule("http", "other web"), "nas", mine) - - -def test_uptime_lists_the_worst_first_and_the_unmeasured_last(): - from steward.hosts.service import worst_uptime_first - hosts = [NS(id=i, name=n) for i, n in [("a", "alpha"), ("b", "bravo"), ("c", "charlie"), ("d", "delta")]] - uptime = {"a": {"24h": 100.0, "7d": 99.0, "30d": 99.5}, - "b": {"24h": 92.0, "7d": 98.0, "30d": 99.0}, - "c": {"24h": 100.0, "7d": 95.0, "30d": 97.0}} - assert [h.name for h in worst_uptime_first(hosts, uptime)] == ["bravo", "charlie", "alpha", "delta"] diff --git a/tests/integration/test_monitors.py b/tests/integration/test_monitors.py index d55afa3..4cd6a69 100644 --- a/tests/integration/test_monitors.py +++ b/tests/integration/test_monitors.py @@ -125,3 +125,5 @@ def test_last_results_are_the_newest_n_oldest_first_and_host_status_reads_the_la assert len(last[b]) == 1 and last[quiet] == [] assert status[hid]["state"] == "down" and status[hid]["count"] == 2 assert status[hid]["latency_ms"] == 12.0 + # Down since a's first failure after its last success (#5281). + assert abs((status[hid]["down_since"] - (now - timedelta(minutes=1))).total_seconds()) < 1 diff --git a/tests/plugins/host_agent/test_fleet_page.py b/tests/plugins/host_agent/test_fleet_page.py index 9c017e0..83438ac 100644 --- a/tests/plugins/host_agent/test_fleet_page.py +++ b/tests/plugins/host_agent/test_fleet_page.py @@ -31,9 +31,10 @@ 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) - assert page.index("Status") < page.index('action="/plugins/host_agent/fleet/add-host"') + # Hosts are added in one place, Hosts → Add host (#5282). + assert "fleet/add-host" not in page and 'href="/hosts/new"' in page assert 'Silent' in page assert 'Never reported' in page assert 'Reporting' in page assert page.index('href="/hosts/h2">nas') < page.index('href="/hosts/h1">web') - assert 'data-confirm="Delete the agent registration for nas?' in page + assert 'data-confirm="Unregister the agent for nas?' in page