feat(host_agent): lazy-load full-metrics charts + live-poll current state
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user