feat(host_agent): lazy-load full-metrics charts + live-poll current state
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 51s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m10s

The full-metrics page rendered once server-side with the history query inline,
so the charts blocked first paint and nothing refreshed without a reload (it
reads the latest snapshot the server holds — the agent pushes ~every 30s).

Split it into a shell + two HTMX fragments:
- Shell (host_detail.html): header + shared time-range toggle + two containers;
  paints instantly.
- Current state (/<id>/metrics → _host_metrics.html): identity + gauges +
  per-core + filesystems + interfaces/disks + temps, from the DISTINCT ON latest
  query. hx-trigger "load, every 15s" → live numbers at ~the agent cadence.
- History charts (/<id>/charts → _host_charts.html): the 3 charts + Chart.js,
  from the date_bin history query. hx-trigger "load, every 60s, rangeChange" so
  they lazy-load (never block paint), refresh slowly, and follow the range
  selector. Leak-safe: previous Chart instances are destroyed before re-render.
- Range toggle switched from full-reload links to the shared _time_range.html
  (setTimeRange + rangeChange), so only the fragments refetch.
- routes: host_detail (shell) + host_detail_metrics + host_detail_charts, with a
  _split_host_metrics helper.
- tests/test_templates_parse.py now also parses plugin templates (these
  fragments aren't rendered in the unit lane).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-20 12:18:39 -04:00
parent a78af23793
commit 9e4f1983f8
5 changed files with 350 additions and 273 deletions
+63 -20
View File
@@ -545,30 +545,25 @@ def _downsample(series: list[list], target: int = 120) -> list[list]:
return out
@host_agent_bp.get("/<host_id>/")
@require_role(UserRole.viewer)
async def host_detail(host_id: str):
"""Netdata-style per-host detail: current gauges + history charts."""
since, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as session:
host = (await session.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return _error(404, "not_found")
reg = (await session.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
latest = await _latest_metrics_for_host(session, host.name)
series = await _history_for_host(session, host.name, since)
# Full-metrics refresh cadences. The agent pushes every ~30s by default, so
# polling the current snapshot at 15s keeps it ≤ one cadence stale; the history
# charts move slowly over a multi-hour/day window, so they refresh less often.
_DETAIL_POLL_SECONDS = 15
_CHART_POLL_SECONDS = 60
hostlvl = latest.get(host.name, {})
def _split_host_metrics(host_name: str, latest: dict) -> tuple:
"""Split a host's latest snapshot into
(hostlvl, cores, nets, disks_io, temps, mounts) for the current-state fragment."""
hostlvl = latest.get(host_name, {})
cores: list = []
nets: dict = {}
disks_io: dict = {}
temps: dict = {}
mounts: dict = {}
plen = len(host.name) + 1
plen = len(host_name) + 1
for res, metrics in latest.items():
if res == host.name:
if res == host_name:
continue
suffix = res[plen:]
if suffix.startswith("core"):
@@ -586,18 +581,66 @@ async def host_detail(host_id: str):
else:
mounts[suffix] = metrics
cores.sort(key=lambda c: c[0])
return hostlvl, cores, nets, disks_io, temps, mounts
ls = reg.last_seen_at if reg else None
stale = ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
@host_agent_bp.get("/<host_id>/")
@require_role(UserRole.viewer)
async def host_detail(host_id: str):
"""Full-metrics shell: header + range toggle only. The current-state and
history sections stream in via HTMX (host_detail_metrics / host_detail_charts)
so the heavy history query never blocks first paint and the data live-updates."""
_, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as session:
host = (await session.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return _error(404, "not_found")
return await render_template(
"host_detail.html",
host=host, current_range=range_key, range_options=RANGE_OPTIONS,
poll_seconds=_DETAIL_POLL_SECONDS, chart_poll_seconds=_CHART_POLL_SECONDS,
)
@host_agent_bp.get("/<host_id>/metrics")
@require_role(UserRole.viewer)
async def host_detail_metrics(host_id: str):
"""Current-state fragment (gauges/cores/filesystems/IO/temps) — polled live so
the page shows the latest snapshot the server holds without a full reload."""
async with current_app.db_sessionmaker() as session:
host = (await session.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return "", 404
reg = (await session.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
latest = await _latest_metrics_for_host(session, host.name)
hostlvl, cores, nets, disks_io, temps, mounts = _split_host_metrics(host.name, latest)
ls = reg.last_seen_at if reg else None
stale = ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
return await render_template(
"_host_metrics.html",
host=host, reg=reg, stale=stale, hostlvl=hostlvl,
cores=cores, nets=nets, disks_io=disks_io, temps=temps, mounts=mounts,
series=series, range_key=range_key, range_options=RANGE_OPTIONS,
)
@host_agent_bp.get("/<host_id>/charts")
@require_role(UserRole.viewer)
async def host_detail_charts(host_id: str):
"""History-charts fragment — lazy-loaded so the date_bin query never blocks
the page shell; refreshed on a slow cadence and on range change."""
since, range_key = parse_range(request.args.get("range"))
async with current_app.db_sessionmaker() as session:
host = (await session.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return "", 404
series = await _history_for_host(session, host.name, since)
return await render_template("_host_charts.html", series=series, range_key=range_key)
def _new_token_pair() -> tuple[str, str]:
raw = secrets.token_urlsafe(32)
return raw, _hash_token(raw)