feat(hosts): the Hosts list labels its figures and says how long; SLA tab gone; one place to add a host (#5279–#5283)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 53s
CI / unit (push) Successful in 1m16s
CI / publish (push) Successful in 1m23s

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/<host_id>/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 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 17:12:17 -04:00
co-authored by Claude Opus 5.5
parent d4974ddf6c
commit bfe3618e88
13 changed files with 178 additions and 252 deletions
+8 -19
View File
@@ -796,35 +796,24 @@ async def fleet():
)
@host_agent_bp.post("/fleet/add-host")
@host_agent_bp.post("/fleet/<host_id>/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}")
+11 -2
View File
@@ -91,8 +91,7 @@
{% elif not ansible_available %}
<p style="color:var(--text-dim);font-size:0.85rem;">
Ansible isn't available, so the agent can't be deployed from here. Install it manually with the
<a href="/plugins/host_agent/fleet/">curl install command</a>.
Ansible isn't available, so the agent can't be deployed from here. Install it manually instead (below).
</p>
{% elif not managed_key_set %}
@@ -141,6 +140,16 @@
</form>
{% 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). #}
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/register"
style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin:0.75rem 0 0;">{{ csrf_field() }}
<span style="font-size:0.78rem;color:var(--text-muted);">Or by hand on the host:</span>
<button type="submit" class="btn btn-sm btn-ghost">Install manually</button>
</form>
{% endif %}
{% if reg and session.user_role == 'admin' %}
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0.75rem 0 0;"
data-confirm="Clear this pending registration? Its token is discarded.">{{ csrf_field() }}
@@ -8,7 +8,8 @@
</div>
<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 and metrics live there.
<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>.
</p>
{% if new_token %}
@@ -47,34 +48,19 @@
</form>
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete"
style="display:inline;"
data-confirm="Delete the agent registration for {{ item.name }}? The agent will be unable to report metrics until re-registered.">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
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() }}
<button type="submit" class="btn btn-danger btn-sm">Unregister</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">No hosts registered. Add one below.</td></tr>
<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>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card">
<form method="post" action="/plugins/host_agent/fleet/add-host"
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
<div class="form-group" style="margin-bottom:0;">
<label>Host name</label>
<input type="text" name="name" required>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Address (optional)</label>
<input type="text" name="address">
</div>
<button type="submit" class="btn">Add host</button>
</form>
</div>
{% if ansible_available %}
{% set scope_select %}
<label>Target</label>
+4 -12
View File
@@ -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)
+15 -16
View File
@@ -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
+4
View File
@@ -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 */
+36 -24
View File
@@ -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 %}
<div style="max-width:560px;margin:2rem auto;">
<h1 class="page-title">{% if host %}Edit Host{% else %}Add Host{% endif %}</h1>
<div class="card">
<form method="post" action="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}">{{ csrf_field() }}
<div class="form-group">
<label>Display Name</label>
<input type="text" name="name" value="{{ host.name if host else '' }}" required autofocus>
</div>
<div class="form-group">
<label>Address (hostname or IP)</label>
<input type="text" name="address" value="{{ host.address if host else '' }}" required>
</div>
<div style="display:flex;gap:1rem;margin-top:1.5rem;">
<button type="submit" class="btn">{% if host %}Save{% else %}Add Host{% endif %}</button>
<a href="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}" class="btn btn-ghost">Cancel</a>
</div>
</form>
{% set v = values or {} %}
{{ page_header("Edit host" if host else "Add 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() }}
<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 '') }}"
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 '') }}"
placeholder="hostname or IP; empty resolves by name" autocomplete="off" class="input">
</div>
</div>
<p style="color:var(--text-muted);font-size:0.82rem;margin-top:1rem;text-align:center;">
{% if host %}Monitors, agent, Ansible target, and playbook runs live on the
<a href="/hosts/{{ host.id }}">host page</a>.{% else %}
After adding, attach ping / DNS / HTTP monitors from the host page.{% endif %}
<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 %}
</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>
</div>
</form>
</div>
{% endblock %}
+41 -51
View File
@@ -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 %}<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 %}
<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;">
<a class="btn btn-ghost" href="/hosts/uptime">SLA</a>
{% 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>
<a class="btn" href="/hosts/new">Add host</a>
</div>
</div>
{% if hosts %}
@@ -22,64 +27,49 @@
<tr>
<th>Name</th>
<th>Address</th>
<th style="text-align:center;" title="Worst current status across this host's monitors">Status</th>
<th style="text-align:center;">Latency</th>
<th style="text-align:center;" title="Number of monitors attached">Monitors</th>
<th style="text-align:center;" title="Uptime last 24 hours">24h</th>
<th style="text-align:center;" title="Uptime last 7 days">7d</th>
<th style="text-align:center;" title="Uptime last 30 days">30d</th>
<th style="text-align:center;" title="Agent CPU / memory (latest)">Agent</th>
<th></th>
<th title="The worst latest result across this host's monitors, and the agent if it has gone quiet">Status</th>
<th class="num" title="Fastest latest response among this host's monitors">Latency</th>
<th class="num" title="Monitors with a result">Monitors</th>
<th class="num" title="Uptime over 30 days across this host's monitors; hover for 24h and 7d">30d uptime</th>
<th class="num" title="Agent: CPU in use, latest reading">CPU</th>
<th class="num" title="Agent: memory in use, latest reading">Memory</th>
</tr>
</thead>
<tbody>
{% 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 %}
<tr>
<td><a href="/hosts/{{ host.id }}" style="font-weight:500;">{{ host.name }}</a></td>
<td style="color:var(--text-muted);">{{ host.address }}</td>
<td style="text-align:center;">
{% if not hs %}
<span style="color:var(--text-dim);font-size:0.8rem;" title="No monitors">—</span>
{% else %}{{ status_dot(hs.state) }}{% endif %}
<td class="tone-muted">{{ host.address or "—" }}</td>
<td>
{% if hs %}
{{ status_badge(hs.state) }}
{%- if hs.state == 'down' and hs.down_since %} <span class="tone-dim">for {{ hs.down_since|ago|striptags|replace(" ago", "") }}</span>{% endif %}
{% else %}
<span class="tone-dim" title="No monitors with a result">—</span>
{% endif %}
{% if silent %}
<div style="margin-top:0.15rem;">
{{ 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 %} <span class="tone-dim">for {{ am.last_at|ago|striptags|replace(" ago", "") }}</span>{% endif %}
</div>
{% endif %}
</td>
<td style="text-align:center;font-size:0.9rem;">
<td class="num">
{% if hs and hs.latency_ms is not none %}
<span style="{{ threshold_style(hs.latency_ms, 'latency') or 'color:var(--green);' }}">{{ hs.latency_ms | round(1) }} ms</span>
{% else %}
<span style="color:var(--text-dim);">—</span>
{% endif %}
<span class="tone-{{ status_class(hs.latency_ms, 'latency') }}">{{ hs.latency_ms | round(1) }} ms</span>
{% else %}<span class="tone-dim">—</span>{% endif %}
</td>
<td style="text-align:center;font-size:0.85rem;color:var(--text-muted);">
{{ hs.count if hs else 0 }}
</td>
{% for window in ["24h", "7d", "30d"] %}
{% set pct = ut.get(window) %}
<td style="text-align:center;font-size:0.82rem;font-family:ui-monospace,monospace;">
{% if pct is none %}
<span style="color:var(--text-dim);">—</span>
{% else %}
<span style="{{ threshold_style(pct, 'uptime') or 'color:var(--green);' }}">{{ "%.1f"|format(pct) }}%</span>
{% endif %}
</td>
{% endfor %}
{% set am = agent.get(host.name) %}
<td style="text-align:center;font-size:0.8rem;font-family:ui-monospace,monospace;">
{% if am %}
<span style="color:var(--text-muted);" title="CPU / memory, latest">{{ pct(am.cpu_pct) }} / {{ pct(am.mem_used_pct) }}</span>
{% if not am.fresh %}<div>{{ 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')) }} <span class="tone-dim" style="font-family:inherit;">{{ am.last_at|ago }}</span></div>{% endif %}
{% else %}
<span style="color:var(--text-dim);" title="No agent on this host">—</span>
{% endif %}
</td>
<td class="td-actions">
<a href="/hosts/{{ host.id }}/edit" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a>
<form method="post" action="/hosts/{{ host.id }}/delete" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger"
data-confirm="Delete {{ host.name }}?">Delete</button>
</form>
<td class="num tone-muted">{{ hs.count if hs else "—" }}</td>
<td class="num" title="24h {{ ('%.2f%%'|format(ut['24h'])) if ut.get('24h') is not none else '—' }} · 7d {{ ('%.2f%%'|format(ut['7d'])) if ut.get('7d') is not none else '—' }}">
{% if ut.get('30d') is none %}<span class="tone-dim">—</span>
{% else %}<span class="tone-{{ status_class(ut['30d'], 'uptime') }}">{{ "%.2f"|format(ut['30d']) }}%</span>{% endif %}
</td>
<td class="num"{% if silent %} title="Last reading before the agent went quiet"{% endif %}>{% if am %}{{ pct(am.cpu_pct, 'cpu', silent) }}{% else %}<span class="tone-dim" title="No agent on this host">—</span>{% endif %}</td>
<td class="num"{% if silent %} title="Last reading before the agent went quiet"{% endif %}>{% if am %}{{ pct(am.mem_used_pct, 'mem', silent) }}{% else %}<span class="tone-dim" title="No agent on this host">—</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
-98
View File
@@ -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 %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Uptime / SLA</h1>
</div>
{# ── 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 %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.75rem;margin-bottom:1.5rem;">
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">Hosts monitored</div>
<span class="stat-val">{{ total }}</span>
</div>
<div class="card" style="margin-bottom:0;">
<div class="section-title" style="margin-bottom:0.3rem;">≥ 99% (30d)</div>
<span class="stat-val" style="color:{% if healthy.count == total %}var(--green){% else %}var(--orange){% endif %};">
{{ healthy.count }}
</span>
</div>
</div>
{% endif %}
{# ── SLA table ──────────────────────────────────────────────────────────────── #}
{% if hosts %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Host</th>
<th>Address</th>
<th style="text-align:center;min-width:80px;" title="Uptime last 24 hours">24 h</th>
<th style="text-align:center;min-width:80px;" title="Uptime last 7 days">7 d</th>
<th style="text-align:center;min-width:80px;" title="Uptime last 30 days">30 d</th>
<th style="min-width:160px;">30-day bar</th>
</tr>
</thead>
<tbody>
{% for host in hosts %}
{% set ut = uptime.get(host.id, {}) %}
<tr>
<td style="font-weight:500;"><a href="/hosts/{{ host.id }}" style="color:inherit;">{{ host.name }}</a></td>
<td style="color:var(--text-muted);font-size:0.85rem;">{{ host.address }}</td>
{% for window in ["24h", "7d", "30d"] %}
{% set pct = ut.get(window) %}
<td style="text-align:center;font-size:0.88rem;font-family:ui-monospace,monospace;">
{% if pct is none %}
<span style="color:var(--text-muted);" title="No data yet">—</span>
{% elif pct >= 99.9 %}
<span style="color:var(--green);">{{ "%.2f"|format(pct) }}%</span>
{% elif pct >= 99 %}
<span style="color:var(--yellow);">{{ "%.2f"|format(pct) }}%</span>
{% elif pct >= 95 %}
<span style="color:var(--orange);">{{ "%.2f"|format(pct) }}%</span>
{% else %}
<span style="color:var(--red);">{{ "%.2f"|format(pct) }}%</span>
{% endif %}
</td>
{% endfor %}
{# 30-day visual bar #}
{% set pct30 = ut.get("30d") %}
<td>
{% if pct30 is none %}
<span style="color:var(--text-muted);font-size:0.8rem;">no data</span>
{% else %}
<div style="display:flex;align-items:center;gap:0.5rem;">
<div style="flex:1;height:8px;background:var(--bg-elevated);border-radius:4px;overflow:hidden;border:1px solid var(--border);">
<div style="height:100%;width:{{ pct30 | round(1) }}%;background:{% if pct30 >= 99.9 %}var(--green){% elif pct30 >= 99 %}var(--yellow){% elif pct30 >= 95 %}var(--orange){% else %}var(--red){% endif %};border-radius:4px;transition:width 0.3s;"></div>
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card" style="text-align:center;padding:3rem;">
<p style="color:var(--text-muted);">No hosts under watch. <a href="/hosts/new">Summon one to the table.</a></p>
</div>
{% endif %}
<p style="color:var(--text-dim);font-size:0.78rem;margin-top:1rem;">
Uptime aggregates every monitor attached to a host. Hosts with no monitors show —.
</p>
{% endblock %}
+49
View File
@@ -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</button>", ">Rotate token</button>", ">Unregister agent</button>"):
assert page.index(action) > manage
assert ">Remove</button>" 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"<th[^>]*>([^<]+)</th>", 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</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"):
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'<input[^>]*name="address"[^>]*>', page).group(0).count("required") == 0
assert '<a href="/hosts/">Hosts</a>' in page and ">Add host</span>" in page
-9
View File
@@ -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"]
+2
View File
@@ -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
+3 -2
View File
@@ -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("<th>Status</th>") < 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 '<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
assert page.index('href="/hosts/h2">nas</a>') < page.index('href="/hosts/h1">web</a>')
assert 'data-confirm="Delete the agent registration for nas?' in page
assert 'data-confirm="Unregister the agent for nas?' in page