diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index 8b7cf96..ed8ef20 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -11,10 +11,11 @@ import secrets from quart import Blueprint, current_app, jsonify, request, Response, render_template, redirect, url_for from steward.auth.middleware import require_role from steward.models.users import UserRole -from sqlalchemy import select, func +from sqlalchemy import select, func, or_ from datetime import timedelta from steward.core.settings import public_base_url +from steward.core.time_range import parse_range, RANGE_OPTIONS from steward.models.hosts import Host from steward.models.metrics import PluginMetric from .models import HostAgentRegistration @@ -299,35 +300,45 @@ async def agent_source(): return Response(body, mimetype="text/x-python") -@host_agent_bp.get("/widget") -async def widget_table(): - """Fleet-glance table: one row per monitored host, latest metrics.""" - async with current_app.db_sessionmaker() as session: - regs = (await session.execute(select(HostAgentRegistration))).scalars().all() - host_ids = [r.host_id for r in regs] - hosts = { - h.id: h for h in (await session.execute( - select(Host).where(Host.id.in_(host_ids)) - )).scalars().all() - } if host_ids else {} +def _stale_after_seconds() -> int: + return int( + current_app.config.get("PLUGINS", {}) + .get(SOURCE_MODULE, {}) + .get("stale_after_seconds", 180) + ) - subq = ( - select( - PluginMetric.resource_name, - PluginMetric.metric_name, - func.max(PluginMetric.recorded_at).label("max_ts"), - ) - .where(PluginMetric.source_module == SOURCE_MODULE) - .group_by(PluginMetric.resource_name, PluginMetric.metric_name) - ).subquery() - latest_rows = (await session.execute( - select(PluginMetric).join( - subq, - (PluginMetric.resource_name == subq.c.resource_name) & - (PluginMetric.metric_name == subq.c.metric_name) & - (PluginMetric.recorded_at == subq.c.max_ts), - ).where(PluginMetric.source_module == SOURCE_MODULE) + +async def _fleet_rows(session) -> list[dict]: + """One row per registered host with its latest host-level metrics + stale flag. + + Only bare host-level resources are used here (sub-resources carry a ':' and + are skipped) so the fleet glance stays one line per host. + """ + regs = (await session.execute(select(HostAgentRegistration))).scalars().all() + host_ids = [r.host_id for r in regs] + hosts = { + h.id: h for h in (await session.execute( + select(Host).where(Host.id.in_(host_ids)) )).scalars().all() + } if host_ids else {} + + subq = ( + select( + PluginMetric.resource_name, + PluginMetric.metric_name, + func.max(PluginMetric.recorded_at).label("max_ts"), + ) + .where(PluginMetric.source_module == SOURCE_MODULE) + .group_by(PluginMetric.resource_name, PluginMetric.metric_name) + ).subquery() + latest_rows = (await session.execute( + select(PluginMetric).join( + subq, + (PluginMetric.resource_name == subq.c.resource_name) & + (PluginMetric.metric_name == subq.c.metric_name) & + (PluginMetric.recorded_at == subq.c.max_ts), + ).where(PluginMetric.source_module == SOURCE_MODULE) + )).scalars().all() latest: dict[str, dict[str, float]] = {} for row in latest_rows: @@ -335,21 +346,46 @@ async def widget_table(): continue latest.setdefault(row.resource_name, {})[row.metric_name] = row.value + stale_after = _stale_after_seconds() + now = datetime.now(timezone.utc) rows = [] for reg in regs: host = hosts.get(reg.host_id) if host is None: continue m = latest.get(host.name, {}) + ls = reg.last_seen_at + stale = ls is None or (now - ls).total_seconds() > stale_after rows.append({ "host": host, "reg": reg, + "stale": stale, "cpu_pct": m.get("cpu_pct"), "mem_used_pct": m.get("mem_used_pct"), "disk_worst": m.get("disk_used_pct_worst"), "load_1m": m.get("load_1m"), + "temp_max": m.get("temp_c_max"), + "net_rx_bps": m.get("net_rx_bps"), + "net_tx_bps": m.get("net_tx_bps"), }) + rows.sort(key=lambda r: r["host"].name.lower()) + return rows + +@host_agent_bp.get("/") +@require_role(UserRole.viewer) +async def index(): + """Fleet overview page — cards per host linking to the detail view.""" + async with current_app.db_sessionmaker() as session: + rows = await _fleet_rows(session) + return await render_template("host_list.html", rows=rows) + + +@host_agent_bp.get("/widget") +async def widget_table(): + """Fleet-glance dashboard widget: one row per monitored host, latest metrics.""" + async with current_app.db_sessionmaker() as session: + rows = await _fleet_rows(session) return await render_template("widget_table.html", rows=rows) @@ -381,6 +417,116 @@ async def widget_history(): return await render_template("widget_history.html", host=host, series=series, hours=hours) +# Host-level metrics charted on the detail page (sub-resources are shown as +# current-value lists, not time series, to keep the page readable). +HISTORY_METRICS = ( + "cpu_pct", "mem_used_pct", "disk_used_pct_worst", "load_1m", + "net_rx_bps", "net_tx_bps", "disk_read_bps", "disk_write_bps", + "temp_c_max", "psi_mem_some_avg10", +) + + +async def _latest_metrics_for_host(session, host_name: str) -> dict[str, dict[str, float]]: + """{resource_name: {metric: value}} of the latest sample for a host + sub-resources.""" + subq = ( + select( + PluginMetric.resource_name, + PluginMetric.metric_name, + func.max(PluginMetric.recorded_at).label("max_ts"), + ) + .where(PluginMetric.source_module == SOURCE_MODULE) + .where(or_( + PluginMetric.resource_name == host_name, + PluginMetric.resource_name.like(host_name + ":%"), + )) + .group_by(PluginMetric.resource_name, PluginMetric.metric_name) + ).subquery() + rows = (await session.execute( + select(PluginMetric).join( + subq, + (PluginMetric.resource_name == subq.c.resource_name) & + (PluginMetric.metric_name == subq.c.metric_name) & + (PluginMetric.recorded_at == subq.c.max_ts), + ).where(PluginMetric.source_module == SOURCE_MODULE) + )).scalars().all() + out: dict[str, dict[str, float]] = {} + for r in rows: + out.setdefault(r.resource_name, {})[r.metric_name] = r.value + return out + + +async def _history_for_host(session, host_name: str, since) -> dict[str, list]: + """{metric: [[epoch_ms, value], …]} host-level series since `since`. + + Epoch-ms x values let the charts use a linear axis (no Chart.js date adapter). + """ + rows = (await session.execute( + select(PluginMetric).where( + PluginMetric.source_module == SOURCE_MODULE, + PluginMetric.resource_name == host_name, + PluginMetric.metric_name.in_(HISTORY_METRICS), + PluginMetric.recorded_at >= since, + ).order_by(PluginMetric.recorded_at) + )).scalars().all() + series: dict[str, list] = {m: [] for m in HISTORY_METRICS} + for p in rows: + series[p.metric_name].append([int(p.recorded_at.timestamp() * 1000), round(p.value, 2)]) + return series + + +@host_agent_bp.get("//") +@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) + + hostlvl = latest.get(host.name, {}) + cores: list = [] + nets: dict = {} + disks_io: dict = {} + temps: dict = {} + mounts: dict = {} + plen = len(host.name) + 1 + for res, metrics in latest.items(): + if res == host.name: + continue + suffix = res[plen:] + if suffix.startswith("core"): + try: + idx = int(suffix[4:]) + except ValueError: + idx = 0 + cores.append((idx, metrics.get("cpu_pct"))) + elif suffix.startswith("net:"): + nets[suffix[len("net:"):]] = metrics + elif suffix.startswith("diskio:"): + disks_io[suffix[len("diskio:"):]] = metrics + elif suffix.startswith("temp:"): + temps[suffix[len("temp:"):]] = metrics.get("temp_c") + else: + mounts[suffix] = metrics + cores.sort(key=lambda c: c[0]) + + 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_detail.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, + ) + + def _new_token_pair() -> tuple[str, str]: raw = secrets.token_urlsafe(32) return raw, _hash_token(raw) diff --git a/plugins/host_agent/templates/host_detail.html b/plugins/host_agent/templates/host_detail.html new file mode 100644 index 0000000..1129759 --- /dev/null +++ b/plugins/host_agent/templates/host_detail.html @@ -0,0 +1,251 @@ +{% extends "base.html" %} +{% block title %}{{ host.name }} — Host Agent — Steward{% endblock %} + +{% macro fmt_bps(v) %} +{%- if v is none -%}— +{%- elif v >= 1048576 -%}{{ "%.1f"|format(v / 1048576) }} MB/s +{%- elif v >= 1024 -%}{{ "%.1f"|format(v / 1024) }} KB/s +{%- else -%}{{ "%.0f"|format(v) }} B/s +{%- endif -%} +{% endmacro %} +{% macro fmt_bytes(v) %} +{%- if v is none -%}— +{%- elif v >= 1073741824 -%}{{ "%.1f"|format(v / 1073741824) }} GB +{%- elif v >= 1048576 -%}{{ "%.0f"|format(v / 1048576) }} MB +{%- else -%}{{ "%.0f"|format(v / 1024) }} KB +{%- endif -%} +{% endmacro %} +{% macro bar(pct) %} +{%- set p = pct if pct is not none else 0 -%} +
+
+
+{% endmacro %} + +{% block content %} +
+
+ ← Fleet +

{{ host.name }}

+ {% if stale %}stale{% else %}live{% endif %} +
+
+ {% for r in range_options %} + {{ r }} + {% endfor %} +
+
+ +{# ── Identity / metadata ─────────────────────────────────────────────────── #} +
+ Address {{ host.address or "—" }} + {% if reg %} + {% if reg.distro %}OS {{ reg.distro }}{% endif %} + {% if reg.kernel %}Kernel {{ reg.kernel }}{% endif %} + {% if reg.arch %}Arch {{ reg.arch }}{% endif %} + {% if reg.agent_version %}Agent v{{ reg.agent_version }}{% endif %} + {% set up = hostlvl.get('uptime_secs') %} + {% if up %}Uptime {{ (up // 86400)|int }}d {{ ((up % 86400) // 3600)|int }}h{% endif %} + Last seen {{ reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") ~ " UTC" if reg.last_seen_at else "never" }} + {% else %} + No agent registration found for this host. + {% endif %} +
+ +{% if not hostlvl %} +
No metrics reported yet. Once the agent checks in, CPU, memory, network, disk I/O, temperatures, and pressure will appear here.
+{% else %} + +{# ── Current headline gauges ─────────────────────────────────────────────── #} +
+
+
CPU
+ {% set c = hostlvl.get('cpu_pct') %} + {% if c is not none %}{{ "%.0f"|format(c) }}%{% else %}—{% endif %} +
{{ bar(c) }}
+
+
+
Memory
+ {% set mp = hostlvl.get('mem_used_pct') %} + {% if mp is not none %}{{ "%.0f"|format(mp) }}%{% else %}—{% endif %} +
{{ bar(mp) }}
+
+ avail {{ fmt_bytes(hostlvl.get('mem_available_bytes')) }} · cache {{ fmt_bytes(hostlvl.get('mem_cached_bytes')) }} · swap {{ fmt_bytes(hostlvl.get('swap_used_bytes')) }} +
+
+
+
Load
+ {% if hostlvl.get('load_1m') is not none %}{{ "%.2f"|format(hostlvl.get('load_1m')) }}{% else %}—{% endif %} +
+ 5m {% if hostlvl.get('load_5m') is not none %}{{ "%.2f"|format(hostlvl.get('load_5m')) }}{% else %}—{% endif %} · + 15m {% if hostlvl.get('load_15m') is not none %}{{ "%.2f"|format(hostlvl.get('load_15m')) }}{% else %}—{% endif %} +
+
+
+
Network
+
+
{{ fmt_bps(hostlvl.get('net_rx_bps')) }}
+
{{ fmt_bps(hostlvl.get('net_tx_bps')) }}
+
+
+
+
Disk I/O
+
+
rd {{ fmt_bps(hostlvl.get('disk_read_bps')) }}
+
wr {{ fmt_bps(hostlvl.get('disk_write_bps')) }}
+
+
+ {% if hostlvl.get('temp_c_max') is not none %} +
+
Temp (max)
+ {% set t = hostlvl.get('temp_c_max') %} + {{ "%.0f"|format(t) }}°C +
+ {% endif %} + {% if hostlvl.get('psi_mem_some_avg10') is not none %} +
+
Pressure (10s)
+
+
mem {{ "%.1f"|format(hostlvl.get('psi_mem_some_avg10')) }}%
+ {% if hostlvl.get('psi_cpu_some_avg10') is not none %}
cpu {{ "%.1f"|format(hostlvl.get('psi_cpu_some_avg10')) }}%
{% endif %} + {% if hostlvl.get('psi_io_some_avg10') is not none %}
io {{ "%.1f"|format(hostlvl.get('psi_io_some_avg10')) }}%
{% endif %} +
+
+ {% endif %} +
+ +{# ── Per-core CPU ─────────────────────────────────────────────────────────── #} +{% if cores %} +
+
Per-core CPU
+
+ {% for idx, pct in cores %} +
+
+ core {{ idx }}{% if pct is not none %}{{ "%.0f"|format(pct) }}%{% else %}—{% endif %} +
+ {{ bar(pct) }} +
+ {% endfor %} +
+
+{% endif %} + +{# ── Filesystems ──────────────────────────────────────────────────────────── #} +{% if mounts %} +
+
Filesystems
+ {% for mount, m in mounts.items() %} +
+
+ {{ mount }} + {{ fmt_bytes(m.get('disk_used_bytes')) }} / {{ fmt_bytes(m.get('disk_total_bytes')) }}{% if m.get('disk_used_pct') is not none %} · {{ "%.0f"|format(m.get('disk_used_pct')) }}%{% endif %} +
+ {{ bar(m.get('disk_used_pct')) }} +
+ {% endfor %} +
+{% endif %} + +{# ── Interfaces / disks / sensors current detail ──────────────────────────── #} +{% if nets or disks_io or temps %} +
+ {% if nets %} +
+
Interfaces
+ {% for iface, m in nets.items() %} +
+ {{ iface }} + ↓ {{ fmt_bps(m.get('net_rx_bps')) }} · ↑ {{ fmt_bps(m.get('net_tx_bps')) }} +
+ {% endfor %} +
+ {% endif %} + {% if disks_io %} +
+
Disks
+ {% for dev, m in disks_io.items() %} +
+ {{ dev }} + rd {{ fmt_bps(m.get('disk_read_bps')) }} · wr {{ fmt_bps(m.get('disk_write_bps')) }} +
+ {% endfor %} +
+ {% endif %} + {% if temps %} +
+
Temperatures
+ {% for label, c in temps.items() %} +
+ {{ label }} + {% if c is not none %}{{ "%.0f"|format(c) }}°C{% else %}—{% endif %} +
+ {% endfor %} +
+ {% endif %} +
+{% endif %} + +{# ── History charts ───────────────────────────────────────────────────────── #} +
+
+
Utilization % — last {{ range_key }}
+
+
+
+
Throughput (B/s) — last {{ range_key }}
+
+
+
+
Load & pressure — last {{ range_key }}
+
+
+
+ + +{% endif %} +{% endblock %} diff --git a/plugins/host_agent/templates/host_list.html b/plugins/host_agent/templates/host_list.html new file mode 100644 index 0000000..69aad8e --- /dev/null +++ b/plugins/host_agent/templates/host_list.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Host Agents — Steward{% endblock %} +{% block content %} +
+

Host Agents

+ {% if session.user_role == 'admin' %} + Manage agents → + {% endif %} +
+ +{% if not rows %} +
+ No hosts are reporting agent metrics yet. + {% if session.user_role == 'admin' %} + Add one from Host Agent settings and run the install command on the target. + {% else %} + Ask an admin to install the agent on a host. + {% endif %} +
+{% else %} +
+{% for r in rows %} + +
+ + {{ r.host.name }} + {% if r.stale %}stale{% endif %} +
+
+ CPU {% if r.cpu_pct is not none %}{{ "%.0f"|format(r.cpu_pct) }}%{% else %}—{% endif %} + Mem {% if r.mem_used_pct is not none %}{{ "%.0f"|format(r.mem_used_pct) }}%{% else %}—{% endif %} + Disk {% if r.disk_worst is not none %}{{ "%.0f"|format(r.disk_worst) }}%{% else %}—{% endif %} + Load {% if r.load_1m is not none %}{{ "%.2f"|format(r.load_1m) }}{% else %}—{% endif %} + {% if r.temp_max is not none %}Temp {{ "%.0f"|format(r.temp_max) }}°C{% endif %} +
+
+ last seen {{ r.reg.last_seen_at.strftime("%Y-%m-%d %H:%M:%S") ~ " UTC" if r.reg.last_seen_at else "never" }} +
+
+{% endfor %} +
+{% endif %} +{% endblock %} diff --git a/plugins/host_agent/templates/widget_table.html b/plugins/host_agent/templates/widget_table.html index a646fad..c391b0c 100644 --- a/plugins/host_agent/templates/widget_table.html +++ b/plugins/host_agent/templates/widget_table.html @@ -2,7 +2,7 @@ {% if rows %}
{% for r in rows %} - {% set stale = not r.reg.last_seen_at %} + {% set stale = r.stale %}