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