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) @require_role(UserRole.admin)
async def add_host(): async def register_host(host_id: str):
form = await request.form """Issue an agent token for an existing host, for a manual install: the
name = (form.get("name") or "").strip() fleet page then shows the one-line install command, once. Hosts are added
address = (form.get("address") or "").strip() on Hosts → Add host; this is only the agent half (#5282)."""
if not name:
return _error(400, "missing_name")
async with current_app.db_sessionmaker() as session: async with current_app.db_sessionmaker() as session:
host = (await session.execute( host = await session.get(Host, host_id)
select(Host).where(Host.name == name))).scalar_one_or_none()
if host is None: if host is None:
host = Host(name=name, address=address) return _error(404, "not_found")
session.add(host)
await session.flush()
existing = (await session.execute( existing = (await session.execute(
select(HostAgentRegistration).where( select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host.id))).scalar_one_or_none() HostAgentRegistration.host_id == host.id))).scalar_one_or_none()
if existing is not None: if existing is not None:
await session.rollback()
return _error(400, "already_registered") return _error(400, "already_registered")
raw, hashed = _new_token_pair() raw, hashed = _new_token_pair()
reg = HostAgentRegistration(host_id=host.id, token_hash=hashed) session.add(HostAgentRegistration(host_id=host.id, token_hash=hashed))
session.add(reg)
await session.commit() await session.commit()
host_id = host.id
return redirect(url_for("host_agent.fleet") + f"?new_token={raw}&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 %} {% elif not ansible_available %}
<p style="color:var(--text-dim);font-size:0.85rem;"> <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 Ansible isn't available, so the agent can't be deployed from here. Install it manually instead (below).
<a href="/plugins/host_agent/fleet/">curl install command</a>.
</p> </p>
{% elif not managed_key_set %} {% elif not managed_key_set %}
@@ -141,6 +140,16 @@
</form> </form>
{% endif %} {% 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' %} {% if reg and session.user_role == 'admin' %}
<form method="post" action="/plugins/host_agent/fleet/{{ host.id }}/delete" style="margin:0.75rem 0 0;" <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() }} data-confirm="Clear this pending registration? Its token is discarded.">{{ csrf_field() }}
@@ -8,7 +8,8 @@
</div> </div>
<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 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> </p>
{% if new_token %} {% if new_token %}
@@ -47,34 +48,19 @@
</form> </form>
<form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete" <form method="post" action="/plugins/host_agent/fleet/{{ item.reg.host_id }}/delete"
style="display:inline;" 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() }} 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">Delete</button> <button type="submit" class="btn btn-danger btn-sm">Unregister</button>
</form> </form>
</td> </td>
</tr> </tr>
{% else %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</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 %} {% if ansible_available %}
{% set scope_select %} {% set scope_select %}
<label>Target</label> <label>Target</label>
+4 -12
View File
@@ -112,7 +112,8 @@ 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 str(exc), 400 return await render_template("hosts/form.html", host=None, values=form,
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,
@@ -150,7 +151,8 @@ async def update_host(host_id: str):
try: try:
await host_svc.check_name_free(db, name, exclude_id=host.id) await host_svc.check_name_free(db, name, exclude_id=host.id)
except ValueError as exc: 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.name = name
host.address = form["address"].strip() host.address = form["address"].strip()
# Agent and container metrics, and the rules and windows on them, # 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", ""), await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"host.deleted", entity_type="host", entity_id=host_name) "host.deleted", entity_type="host", entity_id=host_name)
return redirect(url_for("hosts.list_hosts")) 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. """Per-host status rollup from the latest result of each linked monitor.
{host_id: {"state": "up"|"down"|"pending", "latency_ms": float|None, {host_id: {"state": "up"|"down"|"pending", "latency_ms": float|None,
"count": int}}. State is down if ANY linked monitor's latest "count": int, "down_since": datetime|None}}. State is down if
result is down (worst-case), up if at least one is up, else pending. 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: if not host_ids:
return {} 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)) .where(Monitor.host_id.in_(host_ids), Monitor.enabled.is_(True))
)).all() )).all()
latest = await _last_n_results(db, [m.id for m in monitors], n=1) 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] = {} out: dict[str, dict] = {}
for res, host_id in rows: down: dict[str, list[str]] = {}
d = out.setdefault(host_id, {"state": "pending", "latency_ms": None, "count": 0}) 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 d["count"] += 1
if not res.is_up: if not res.is_up:
d["state"] = "down" d["state"] = "down"
down.setdefault(host_id, []).append(mid)
elif d["state"] != "down": elif d["state"] != "down":
d["state"] = "up" d["state"] = "up"
if res.is_up and res.response_ms is not None: if res.is_up and res.response_ms is not None:
d["latency_ms"] = (res.response_ms if d["latency_ms"] is None d["latency_ms"] = (res.response_ms if d["latency_ms"] is None
else min(d["latency_ms"], res.response_ms)) 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 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: def host_rank(status: dict | None, agent: dict | None) -> int:
"""Where a host sorts in the Hosts list: a down monitor first, then an """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 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-crit { color: var(--red); }
.tone-info { color: var(--blue); } .tone-info { color: var(--blue); }
.tone-dim, .tone-unknown { color: var(--text-dim); } .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; } .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 */ /* Empty state */
+36 -24
View File
@@ -1,30 +1,42 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs %} {% from "_macros.html" import crumbs, page_header %}
{% block title %}{% if host %}Edit Host{% else %}New Host{% endif %} — Steward{% endblock %} {# Add a host (host is none) or edit one; `values` holds what was typed when
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), (("Edit " ~ host.name) if host else "New host", "")]) }}{% endblock %} 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 %} {% block content %}
<div style="max-width:560px;margin:2rem auto;"> {% set v = values or {} %}
<h1 class="page-title">{% if host %}Edit Host{% else %}Add Host{% endif %}</h1> {{ page_header("Edit host" if host else "Add host") }}
<div class="card">
<form method="post" action="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}">{{ csrf_field() }} <div class="card" style="max-width:760px;">
<div class="form-group"> {% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<label>Display Name</label> <form method="post" action="{{ '/hosts/' ~ host.id if host else '/hosts/' }}" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
<input type="text" name="name" value="{{ host.name if host else '' }}" required autofocus> <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.6rem;align-items:end;">
</div> <div>
<div class="form-group"> <label class="field-label" for="h-name">Name <span class="tone-crit" aria-hidden="true">*</span></label>
<label>Address (hostname or IP)</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" name="address" value="{{ host.address if host else '' }}" required> placeholder="nas, web-1, …" autocomplete="off" required autofocus class="input">
</div> </div>
<div style="display:flex;gap:1rem;margin-top:1.5rem;"> <div>
<button type="submit" class="btn">{% if host %}Save{% else %}Add Host{% endif %}</button> <label class="field-label" for="h-address">Address</label>
<a href="{% if host %}/hosts/{{ host.id }}{% else %}/hosts/{% endif %}" class="btn btn-ghost">Cancel</a> <input type="text" id="h-address" name="address" value="{{ v.address if v.address is defined else (host.address if host else '') }}"
</div> placeholder="hostname or IP; empty resolves by name" autocomplete="off" class="input">
</form> </div>
</div> </div>
<p style="color:var(--text-muted);font-size:0.82rem;margin-top:1rem;text-align:center;"> <p class="field-hint" style="margin:0;">
{% if host %}Monitors, agent, Ansible target, and playbook runs live on the {% if host %}Renaming carries the host's agent and container history, and the alert rules on it, to the new name.
<a href="/hosts/{{ host.id }}">host page</a>.{% else %} {% else %}Next, on the host's page: add monitors, install its agent, and link an Ansible target.{% endif %}
After adding, attach ping / DNS / HTTP monitors from the host page.{% endif %}
</p> </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> </div>
{% endblock %} {% endblock %}
+41 -51
View File
@@ -1,18 +1,23 @@
{% extends "base.html" %} {% 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 {# Hosts, problems first (host_svc.host_rank): a down monitor, then an agent
gone quiet, then monitors not yet checked, then the healthy. #} gone quiet, then monitors not yet checked, then the healthy. Status says
{% macro pct(v) %}{% if v is none %}—{% else %}{{ '%.0f'|format(v) }}%{% endif %}{% endmacro %} 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 title %}Hosts — Steward{% endblock %}
{% block content %} {% block content %}
<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;">
<a class="btn btn-ghost" href="/hosts/uptime">SLA</a>
{% 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> <a class="btn" href="/hosts/new">Add host</a>
</div> </div>
</div> </div>
{% if hosts %} {% if hosts %}
@@ -22,64 +27,49 @@
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Address</th> <th>Address</th>
<th style="text-align:center;" title="Worst current status across this host's monitors">Status</th> <th title="The worst latest result across this host's monitors, and the agent if it has gone quiet">Status</th>
<th style="text-align:center;">Latency</th> <th class="num" title="Fastest latest response among this host's monitors">Latency</th>
<th style="text-align:center;" title="Number of monitors attached">Monitors</th> <th class="num" title="Monitors with a result">Monitors</th>
<th style="text-align:center;" title="Uptime last 24 hours">24h</th> <th class="num" title="Uptime over 30 days across this host's monitors; hover for 24h and 7d">30d uptime</th>
<th style="text-align:center;" title="Uptime last 7 days">7d</th> <th class="num" title="Agent: CPU in use, latest reading">CPU</th>
<th style="text-align:center;" title="Uptime last 30 days">30d</th> <th class="num" title="Agent: memory in use, latest reading">Memory</th>
<th style="text-align:center;" title="Agent CPU / memory (latest)">Agent</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for host in hosts %} {% for host in hosts %}
{% set hs = host_status.get(host.id) %} {% set hs = host_status.get(host.id) %}
{% set ut = uptime.get(host.id, {}) %} {% set ut = uptime.get(host.id, {}) %}
{% set am = agent.get(host.name) %}
{% set silent = am and not am.fresh %}
<tr> <tr>
<td><a href="/hosts/{{ host.id }}" style="font-weight:500;">{{ host.name }}</a></td> <td><a href="/hosts/{{ host.id }}" style="font-weight:500;">{{ host.name }}</a></td>
<td style="color:var(--text-muted);">{{ host.address }}</td> <td class="tone-muted">{{ host.address or "—" }}</td>
<td style="text-align:center;"> <td>
{% if not hs %} {% if hs %}
<span style="color:var(--text-dim);font-size:0.8rem;" title="No monitors">—</span> {{ status_badge(hs.state) }}
{% else %}{{ status_dot(hs.state) }}{% endif %} {%- 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>
<td style="text-align:center;font-size:0.9rem;"> <td class="num">
{% if hs and hs.latency_ms is not none %} {% 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> <span class="tone-{{ status_class(hs.latency_ms, 'latency') }}">{{ hs.latency_ms | round(1) }} ms</span>
{% else %} {% else %}<span class="tone-dim">—</span>{% endif %}
<span style="color:var(--text-dim);">—</span>
{% endif %}
</td> </td>
<td style="text-align:center;font-size:0.85rem;color:var(--text-muted);"> <td class="num tone-muted">{{ hs.count if hs else "—" }}</td>
{{ hs.count if hs else 0 }} <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 '—' }}">
</td> {% if ut.get('30d') is none %}<span class="tone-dim">—</span>
{% for window in ["24h", "7d", "30d"] %} {% else %}<span class="tone-{{ status_class(ut['30d'], 'uptime') }}">{{ "%.2f"|format(ut['30d']) }}%</span>{% endif %}
{% 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> </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> </tr>
{% endfor %} {% endfor %}
</tbody> </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).""" """The host page: what sits where (#5214)."""
import re
from types import SimpleNamespace from types import SimpleNamespace
from quart import render_template, session 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>"): for action in (">Update agent</button>", ">Rotate token</button>", ">Unregister agent</button>"):
assert page.index(action) > manage assert page.index(action) > manage
assert ">Remove</button>" not in page 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 not watches_host(rule("docker", "nas2/plex"), "nas", mine)
assert watches_host(rule("http", "nas web"), "nas", mine) assert watches_host(rule("http", "nas web"), "nas", mine)
assert not watches_host(rule("http", "other 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 len(last[b]) == 1 and last[quiet] == []
assert status[hid]["state"] == "down" and status[hid]["count"] == 2 assert status[hid]["state"] == "down" and status[hid]["count"] == 2
assert status[hid]["latency_ms"] == 12.0 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, 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)
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">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
assert page.index('href="/hosts/h2">nas</a>') < page.index('href="/hosts/h1">web</a>') 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