From 609bd78af2f0a2021b9ed02108af7eaa430a3bc4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 17 Jun 2026 12:00:12 -0400 Subject: [PATCH] feat(dashboard): unified Hosts widget + grouped widget picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the dashboard in line with the unified host IA + improved displays. - New core "Hosts — Overview" widget (/hosts/overview/widget): one row per host combining monitor status (ping dot + latency, uptime 24h) with the agent glance (CPU / memory / disk root + stale flag), each row linking to the host hub. Reads agent data from the generic PluginMetric table via a core-safe _agent_overview_by_host helper (no host_agent import); freshness vs the plugin's stale window. The granular Ping/DNS/Uptime/Agent widgets stay. - Group the add-widget picker into Core monitors / Monitoring capabilities / Integrations (a `group` field on every WIDGET_REGISTRY entry + section headings in _edit_panels.html), matching the Settings → Plugins taxonomy. - Fix the agent fleet widget rows to link to the host hub (/hosts/) instead of the old /plugins/host_agent// page. Scribe #903. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../host_agent/templates/widget_table.html | 2 +- steward/core/widgets.py | 36 ++++++++ steward/hosts/routes.py | 85 +++++++++++++++++++ steward/templates/dashboard/_edit_panels.html | 9 +- steward/templates/hosts/overview_widget.html | 42 +++++++++ 5 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 steward/templates/hosts/overview_widget.html diff --git a/plugins/host_agent/templates/widget_table.html b/plugins/host_agent/templates/widget_table.html index 1250740..65bb418 100644 --- a/plugins/host_agent/templates/widget_table.html +++ b/plugins/host_agent/templates/widget_table.html @@ -5,7 +5,7 @@ {% set stale = r.stale %}
- {{ r.host.name }} diff --git a/steward/core/widgets.py b/steward/core/widgets.py index d637f1f..5f92858 100644 --- a/steward/core/widgets.py +++ b/steward/core/widgets.py @@ -32,6 +32,16 @@ WIDGET_REGISTRY: dict[str, dict] = { "poll": True, "params": [], }, + "hosts_overview": { + "key": "hosts_overview", + "label": "Hosts — Overview", + "description": "One row per host: monitor status + uptime and agent CPU/memory/disk, linking to the host hub", + "hx_url": "/hosts/overview/widget", + "detail_url": "/hosts/", + "plugin": None, + "poll": True, + "params": [], + }, "uptime_summary": { "key": "uptime_summary", "label": "Uptime / SLA", @@ -292,8 +302,34 @@ WIDGET_REGISTRY: dict[str, dict] = { } +# Group each widget for the picker (Core monitors / Monitoring capabilities / +# Integrations), mirroring the Settings → Plugins taxonomy. Capability plugins +# are host/monitoring facets; traefik/unifi are discrete vendor integrations. +_CORE_WIDGETS = {"ping", "dns", "status_overview", "uptime_summary", "hosts_overview"} +_INTEGRATION_PLUGINS = {"traefik", "unifi"} +GROUP_LABELS = { + "core": "Core monitors", + "capability": "Monitoring capabilities", + "integration": "Integrations", +} +GROUP_ORDER = ["core", "capability", "integration"] + + +def _group_for(w: dict) -> str: + if w["key"] in _CORE_WIDGETS: + return "core" + if w.get("plugin") in _INTEGRATION_PLUGINS: + return "integration" + return "capability" + + +for _w in WIDGET_REGISTRY.values(): + _w["group"] = _group_for(_w) + + def register_widget(definition: dict) -> None: """Register a widget at runtime (used by external plugins).""" + definition.setdefault("group", _group_for(definition)) WIDGET_REGISTRY[definition["key"]] = definition diff --git a/steward/hosts/routes.py b/steward/hosts/routes.py index e689220..804ffda 100644 --- a/steward/hosts/routes.py +++ b/steward/hosts/routes.py @@ -104,6 +104,91 @@ async def _agent_metrics_by_host(db, host_names: list[str]) -> dict[str, dict]: return out +async def _agent_overview_by_host(db, host_names: list[str]) -> dict[str, dict]: + """Per-host agent glance (cpu/mem/root-disk + freshness) from PluginMetric. + + Core-safe (no host_agent import). Freshness compares the latest host-level + sample against the plugin's stale window (config), defaulting to 180s. + """ + from steward.models.metrics import PluginMetric + if not host_names: + return {} + now = datetime.now(timezone.utc) + stale_after = int( + (current_app.config.get("PLUGINS", {}).get("host_agent", {}) or {}) + .get("stale_after_seconds", 180)) + resources = list(host_names) + [h + ":/" for h in host_names] + subq = ( + select( + PluginMetric.resource_name, PluginMetric.metric_name, + func.max(PluginMetric.recorded_at).label("m"), + ) + .where( + PluginMetric.source_module == "host_agent", + PluginMetric.resource_name.in_(resources), + PluginMetric.metric_name.in_(("cpu_pct", "mem_used_pct", "disk_used_pct")), + ) + .group_by(PluginMetric.resource_name, PluginMetric.metric_name) + ).subquery() + rows = (await db.execute( + select(PluginMetric).join( + subq, + (PluginMetric.resource_name == subq.c.resource_name) + & (PluginMetric.metric_name == subq.c.metric_name) + & (PluginMetric.recorded_at == subq.c.m), + ).where(PluginMetric.source_module == "host_agent") + )).scalars().all() + out: dict[str, dict] = {} + for r in rows: + if r.resource_name.endswith(":/") and r.metric_name == "disk_used_pct": + out.setdefault(r.resource_name[:-2], {})["disk_root"] = r.value + elif r.resource_name in host_names: + d = out.setdefault(r.resource_name, {}) + d[r.metric_name] = r.value + if d.get("_ts") is None or r.recorded_at > d["_ts"]: + d["_ts"] = r.recorded_at + for d in out.values(): + ts = d.pop("_ts", None) + d["fresh"] = bool(ts and (now - ts).total_seconds() <= stale_after) + return out + + +@hosts_bp.get("/overview/widget") +@require_role(UserRole.viewer) +async def overview_widget(): + """Dashboard widget: unified per-host monitor + agent glance, linking to the hub.""" + async with current_app.db_sessionmaker() as db: + hosts = (await db.execute(select(Host).order_by(Host.name))).scalars().all() + latest_ping_subq = ( + select(PingResult.host_id, func.max(PingResult.probed_at).label("max_at")) + .group_by(PingResult.host_id).subquery() + ) + latest_pings = {r.host_id: r for r in (await db.execute( + select(PingResult).join( + latest_ping_subq, + (PingResult.host_id == latest_ping_subq.c.host_id) + & (PingResult.probed_at == latest_ping_subq.c.max_at), + ))).scalars()} + latest_dns_subq = ( + select(DnsResult.host_id, func.max(DnsResult.resolved_at).label("max_at")) + .group_by(DnsResult.host_id).subquery() + ) + latest_dns = {r.host_id: r for r in (await db.execute( + select(DnsResult).join( + latest_dns_subq, + (DnsResult.host_id == latest_dns_subq.c.host_id) + & (DnsResult.resolved_at == latest_dns_subq.c.max_at), + ))).scalars()} + uptime = await _compute_uptime(db) + agent = await _agent_overview_by_host(db, [h.name for h in hosts]) + + return await render_template( + "hosts/overview_widget.html", + hosts=hosts, latest_pings=latest_pings, latest_dns=latest_dns, + uptime=uptime, agent=agent, + ) + + @hosts_bp.get("/") @require_role(UserRole.viewer) async def list_hosts(): diff --git a/steward/templates/dashboard/_edit_panels.html b/steward/templates/dashboard/_edit_panels.html index a3c09e3..c9d63c9 100644 --- a/steward/templates/dashboard/_edit_panels.html +++ b/steward/templates/dashboard/_edit_panels.html @@ -37,8 +37,13 @@
Available Widgets
{% if addable %} + {% set _groups = [("core", "Core monitors"), ("capability", "Monitoring capabilities"), ("integration", "Integrations")] %} + {% for _gkey, _glabel in _groups %} + {% set _gw = addable | selectattr("group", "equalto", _gkey) | list %} + {% if _gw %} +
{{ _glabel }}
- {% for w in addable %} + {% for w in _gw %}
No plugin widgets available. Enable plugins in Settings → Plugins. diff --git a/steward/templates/hosts/overview_widget.html b/steward/templates/hosts/overview_widget.html new file mode 100644 index 0000000..6cb2323 --- /dev/null +++ b/steward/templates/hosts/overview_widget.html @@ -0,0 +1,42 @@ +{# Unified Hosts widget — monitor status + agent glance per host, links to the hub. #} +{% if hosts %} +
+ {% for host in hosts %} + {% set ping = latest_pings.get(host.id) %} + {% set dns = latest_dns.get(host.id) %} + {% set ut = uptime.get(host.id, {}) %} + {% set a = agent.get(host.name, {}) %} + {% set down = host.ping_enabled and ping and ping.status.value != 'up' %} +
+ + + {{ host.name }} + + + {# Monitors #} + {% if host.ping_enabled and ping and ping.response_time_ms is not none %} + ping {{ "%.0f"|format(ping.response_time_ms) }}ms + {% endif %} + {% if ut.get('24h') is not none %} + 24h {{ "%.1f"|format(ut['24h']) }}% + {% endif %} + {# Agent glance #} + {% if a %} + {% if a.get('cpu_pct') is not none %}cpu {{ "%.0f"|format(a.cpu_pct) }}%{% endif %} + {% if a.get('mem_used_pct') is not none %}mem {{ "%.0f"|format(a.mem_used_pct) }}%{% endif %} + {% if a.get('disk_root') is not none %}disk / {{ "%.0f"|format(a.disk_root) }}%{% endif %} + {% if not a.fresh %}stale{% endif %} + {% else %} + no agent + {% endif %} + +
+ {% endfor %} +
+{% else %} +
+ No hosts yet. Add one. +
+{% endif %}