feat(dashboard): unified Hosts widget + grouped widget picker
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/<id>) instead of the old /plugins/host_agent/<id>/ page. Scribe #903. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user